Add native local NAS and network service integrations
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HomeAssistantMikrotikIntegration, type IMikrotikCommand, type IMikrotikConfig } from '../../ts/integrations/mikrotik/index.js';
|
||||
|
||||
const config: IMikrotikConfig = {
|
||||
host: '192.168.88.1',
|
||||
snapshot: {
|
||||
connected: true,
|
||||
router: {
|
||||
name: 'API Router',
|
||||
host: '192.168.88.1',
|
||||
serialNumber: 'MK123456',
|
||||
actions: ['reboot'],
|
||||
},
|
||||
resources: {},
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
sensors: {},
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('does not fake RouterOS/API command success without injected executor', async () => {
|
||||
const runtime = await new HomeAssistantMikrotikIntegration().setup(config, {});
|
||||
const result = await runtime.callService!({ domain: 'mikrotik', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toInclude('not faked');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('executes explicit RouterOS/API commands through injected executor', async () => {
|
||||
let command: IMikrotikCommand | undefined;
|
||||
const runtime = await new HomeAssistantMikrotikIntegration().setup({
|
||||
...config,
|
||||
commandExecutor: async (commandArg) => {
|
||||
command = commandArg;
|
||||
return { success: true, data: { accepted: true } };
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'mikrotik', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(command?.path).toEqual('/system/reboot');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createMikrotikDiscoveryDescriptor, MikrotikConfigFlow } from '../../ts/integrations/mikrotik/index.js';
|
||||
|
||||
tap.test('matches and validates manual Mikrotik RouterOS/API entries', async () => {
|
||||
const descriptor = createMikrotikDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
host: '192.168.88.1',
|
||||
name: 'RouterOS Lab',
|
||||
model: 'hAP ax3',
|
||||
macAddress: 'AA-BB-CC-DD-EE-FF',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('mikrotik');
|
||||
expect(result.candidate?.port).toEqual(8728);
|
||||
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.metadata?.liveRouterOsApiImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('accepts snapshot-only manual setup and rejects unrelated entries', async () => {
|
||||
const descriptor = createMikrotikDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const snapshotResult = await matcher.matches({
|
||||
snapshot: {
|
||||
connected: true,
|
||||
router: { name: 'Snapshot Router', serialNumber: 'MK654321' },
|
||||
resources: {},
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
sensors: {},
|
||||
},
|
||||
}, {});
|
||||
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('builds manual Mikrotik config without claiming live API support', async () => {
|
||||
const flow = new MikrotikConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', host: '192.168.88.1', metadata: { verifySsl: true } }, {});
|
||||
const done = await step.submit!({ host: '192.168.88.1', verifySsl: true, username: 'admin' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.port).toEqual(8728);
|
||||
expect(done.config?.protocol).toEqual('routeros-api-ssl');
|
||||
expect(done.config?.metadata?.liveRouterOsApiImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MikrotikMapper, type IMikrotikSnapshot } from '../../ts/integrations/mikrotik/index.js';
|
||||
|
||||
const snapshot: IMikrotikSnapshot = {
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
router: {
|
||||
host: '192.168.88.1',
|
||||
name: 'Core Router',
|
||||
model: 'hAP ax3',
|
||||
serialNumber: 'MK123456',
|
||||
firmware: '7.15.3',
|
||||
actions: ['reboot'],
|
||||
},
|
||||
resources: {
|
||||
cpuLoad: 12,
|
||||
freeMemory: 512 * 1048576,
|
||||
totalMemory: 1024 * 1048576,
|
||||
uptime: '1h2m3s',
|
||||
version: '7.15.3',
|
||||
boardName: 'hAP ax3',
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
mac: '11:22:33:44:55:66',
|
||||
name: 'Kitchen Phone',
|
||||
ipAddress: '192.168.88.20',
|
||||
interface: 'bridge',
|
||||
ssid: 'Home WiFi',
|
||||
connected: true,
|
||||
signalStrength: -55,
|
||||
signalToNoise: 42,
|
||||
actions: ['arp_ping'],
|
||||
},
|
||||
],
|
||||
interfaces: [
|
||||
{
|
||||
id: '*1',
|
||||
name: 'ether1',
|
||||
label: 'WAN',
|
||||
type: 'ether',
|
||||
running: true,
|
||||
disabled: false,
|
||||
rxBytes: 2_000_000_000,
|
||||
txBytes: 1_000_000_000,
|
||||
rxBitsPerSecond: 2_000_000,
|
||||
txBitsPerSecond: 1_000_000,
|
||||
},
|
||||
],
|
||||
sensors: {},
|
||||
};
|
||||
|
||||
tap.test('maps Mikrotik router resources, clients, interfaces, and traffic sensors', async () => {
|
||||
const normalized = MikrotikMapper.toSnapshot({ snapshot });
|
||||
const devices = MikrotikMapper.toDevices(normalized);
|
||||
const entities = MikrotikMapper.toEntities(normalized);
|
||||
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'mikrotik.router.mk123456')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'mikrotik.client.11_22_33_44_55_66')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_cpu_load')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_memory_free')?.state).toEqual(512);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_uptime')?.state).toEqual(3723);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_wan_download')?.state).toEqual(2);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_wan_download_speed')?.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.core_router_wan')?.state).toEqual('on');
|
||||
});
|
||||
|
||||
tap.test('models only represented Mikrotik RouterOS/API commands safely', async () => {
|
||||
const normalized = MikrotikMapper.toSnapshot({ snapshot });
|
||||
const rebootCommand = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'mikrotik',
|
||||
service: 'reboot',
|
||||
target: {},
|
||||
});
|
||||
const interfaceCommand = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.core_router_wan' },
|
||||
});
|
||||
const arpPingCommand = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'mikrotik',
|
||||
service: 'arp_ping',
|
||||
target: {},
|
||||
data: { mac: '11-22-33-44-55-66', count: 1 },
|
||||
});
|
||||
const unsupportedDisconnect = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'mikrotik',
|
||||
service: 'disconnect_client',
|
||||
target: {},
|
||||
data: { mac: '11:22:33:44:55:66' },
|
||||
});
|
||||
|
||||
expect(rebootCommand?.path).toEqual('/system/reboot');
|
||||
expect(interfaceCommand?.path).toEqual('/interface/set');
|
||||
expect(interfaceCommand?.params.disabled).toEqual('yes');
|
||||
expect(arpPingCommand?.path).toEqual('/ping');
|
||||
expect(arpPingCommand?.params.address).toEqual('192.168.88.20');
|
||||
expect(unsupportedDisconnect).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MotionEyeClient, type IMotionEyeCamera } from '../../ts/integrations/motioneye/index.js';
|
||||
|
||||
const rawCamera = {
|
||||
id: 1,
|
||||
name: 'Driveway',
|
||||
host: '10.0.0.5',
|
||||
streaming_port: 8081,
|
||||
streaming_auth_mode: 'basic',
|
||||
video_streaming: true,
|
||||
motion_detection: true,
|
||||
movies: true,
|
||||
still_images: true,
|
||||
actions: ['snapshot', 'record_start', 'record_stop'],
|
||||
root_directory: '/var/lib/motioneye',
|
||||
};
|
||||
|
||||
tap.test('fetches signed live snapshots and executes commands only after HTTP success', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; method: string; body?: string }> = [];
|
||||
let updatedMotionDetection: unknown;
|
||||
|
||||
globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => {
|
||||
const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url;
|
||||
const parsed = new URL(url);
|
||||
const method = initArg?.method || 'GET';
|
||||
const body = typeof initArg?.body === 'string' ? initArg.body : undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
expect(parsed.searchParams.get('_signature')).toBeTruthy();
|
||||
if (parsed.pathname === '/login') {
|
||||
expect(parsed.searchParams.get('_username')).toEqual('admin');
|
||||
return new Response('{}', { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/list') {
|
||||
return new Response(JSON.stringify({ cameras: [rawCamera] }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/manifest.json') {
|
||||
return new Response(JSON.stringify({ version: '0.43.1' }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/main/get') {
|
||||
return new Response(JSON.stringify({ enabled: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/picture/1/current/') {
|
||||
expect(parsed.searchParams.get('_username')).toEqual('viewer');
|
||||
return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { headers: { 'content-type': 'image/jpeg' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/1/get') {
|
||||
return new Response(JSON.stringify(rawCamera), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/1/set') {
|
||||
const payload = JSON.parse(body || '{}') as Record<string, unknown>;
|
||||
updatedMotionDetection = payload.motion_detection;
|
||||
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/action/1/record_start') {
|
||||
expect(method).toEqual('POST');
|
||||
expect(body).toEqual('{}');
|
||||
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new MotionEyeClient({ url: 'http://127.0.0.1:8765', adminPassword: 'secret', surveillanceUsername: 'viewer', surveillancePassword: 'view' });
|
||||
const snapshot = await client.getSnapshot();
|
||||
expect(snapshot.connected).toBeTrue();
|
||||
expect(snapshot.cameras[0].mjpegUrl).toEqual('http://10.0.0.5:8081/');
|
||||
expect(snapshot.cameras[0].snapshotUrl?.includes('_username=viewer')).toBeTrue();
|
||||
expect(snapshot.switches.find((switchArg) => switchArg.key === 'motion_detection')?.isOn).toBeTrue();
|
||||
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'actions')?.value).toEqual(3);
|
||||
|
||||
const image = await client.execute({ type: 'snapshot_image', service: 'snapshot', cameraId: '1' });
|
||||
expect((image as { contentType: string }).contentType).toEqual('image/jpeg');
|
||||
|
||||
const switchResult = await client.execute({ type: 'set_switch', service: 'turn_off', cameraId: '1', key: 'motion_detection', enabled: false });
|
||||
expect((switchResult as { ok: boolean }).ok).toBeTrue();
|
||||
expect(updatedMotionDetection).toEqual(false);
|
||||
|
||||
const actionResult = await client.execute({ type: 'action', service: 'record_start', cameraId: '1', action: 'record_start' });
|
||||
expect((actionResult as { ok: boolean }).ok).toBeTrue();
|
||||
expect(requests.some((requestArg) => requestArg.url.includes('/action/1/record_start'))).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not fake command success without a live endpoint or successful HTTP response', async () => {
|
||||
const configuredCamera: IMotionEyeCamera = {
|
||||
id: '1',
|
||||
numericId: 1,
|
||||
name: 'Driveway',
|
||||
isStreaming: true,
|
||||
motionDetectionEnabled: true,
|
||||
actions: ['record_start'],
|
||||
};
|
||||
const clientWithoutEndpoint = new MotionEyeClient({ connected: true, cameras: [configuredCamera] });
|
||||
let missingEndpointError = '';
|
||||
try {
|
||||
await clientWithoutEndpoint.execute({ type: 'action', service: 'record_start', cameraId: '1', action: 'record_start' });
|
||||
} catch (errorArg) {
|
||||
missingEndpointError = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
expect(missingEndpointError.includes('requires config.url or config.host')).toBeTrue();
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (inputArg: RequestInfo | URL) => {
|
||||
const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url;
|
||||
if (new URL(url).pathname === '/action/1/record_start') {
|
||||
return new Response('failed', { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify({ cameras: [rawCamera] }), { headers: { 'content-type': 'application/json' } });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new MotionEyeClient({ url: 'http://127.0.0.1:8765', snapshot: { deviceInfo: { id: 'motioneye', online: true }, cameras: [configuredCamera], sensors: [], switches: [], rawCameras: [rawCamera], connected: true } });
|
||||
let httpError = '';
|
||||
try {
|
||||
await client.execute({ type: 'action', service: 'record_start', cameraId: '1', action: 'record_start' });
|
||||
} catch (errorArg) {
|
||||
httpError = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
expect(httpError.includes('failed with HTTP 500')).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MotionEyeConfigFlow, createMotionEyeDiscoveryDescriptor } from '../../ts/integrations/motioneye/index.js';
|
||||
|
||||
tap.test('matches manual motionEye URL entries and configures flow', async () => {
|
||||
const descriptor = createMotionEyeDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const match = await matcher.matches({ url: 'http://192.168.1.40:8765', name: 'Camera Hub' }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.integrationDomain).toEqual('motioneye');
|
||||
expect(match.candidate?.host).toEqual('192.168.1.40');
|
||||
expect(match.candidate?.port).toEqual(8765);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(match.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
|
||||
const flow = new MotionEyeConfigFlow();
|
||||
const step = await flow.start(match.candidate!, {});
|
||||
const done = await step.submit!({ adminUsername: 'admin', adminPassword: 'secret', surveillanceUsername: 'viewer', surveillancePassword: 'view' });
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.url).toEqual('http://192.168.1.40:8765');
|
||||
expect(done.config?.adminUsername).toEqual('admin');
|
||||
expect(done.config?.surveillanceUsername).toEqual('viewer');
|
||||
});
|
||||
|
||||
tap.test('matches local HTTP URL candidates on the default motionEye port', async () => {
|
||||
const descriptor = createMotionEyeDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const match = await matcher.matches({ url: 'http://192.168.1.41:8765', metadata: { source: 'manual-url-list' } }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.source).toEqual('http');
|
||||
expect(match.candidate?.metadata?.url).toEqual('http://192.168.1.41:8765');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MotionEyeMapper, type IMotionEyeSnapshot } from '../../ts/integrations/motioneye/index.js';
|
||||
|
||||
const snapshot: IMotionEyeSnapshot = {
|
||||
deviceInfo: {
|
||||
id: 'motioneye-host',
|
||||
name: 'motionEye Server',
|
||||
manufacturer: 'motionEye',
|
||||
host: '192.168.1.40',
|
||||
port: 8765,
|
||||
protocol: 'http',
|
||||
url: 'http://192.168.1.40:8765',
|
||||
online: true,
|
||||
},
|
||||
cameras: [{
|
||||
id: '1',
|
||||
numericId: 1,
|
||||
name: 'Driveway',
|
||||
streamingPort: 8081,
|
||||
streamingAuthMode: 'basic',
|
||||
mjpegUrl: 'http://192.168.1.41:8081/',
|
||||
snapshotUrl: 'http://192.168.1.40:8765/picture/1/current/?_username=user&_signature=abc',
|
||||
isStreaming: true,
|
||||
motionDetectionEnabled: true,
|
||||
moviesEnabled: true,
|
||||
stillImagesEnabled: true,
|
||||
actions: ['snapshot', 'record_start', 'record_stop'],
|
||||
rootDirectory: '/var/lib/motioneye',
|
||||
available: true,
|
||||
}],
|
||||
sensors: [{ key: 'actions', name: 'Driveway Actions', cameraId: '1', value: 3, attributes: { actions: ['snapshot', 'record_start', 'record_stop'] }, available: true }],
|
||||
switches: [
|
||||
{ key: 'motion_detection', name: 'Driveway Motion detection', cameraId: '1', isOn: true, entityCategory: 'config', available: true },
|
||||
{ key: 'movies', name: 'Driveway Movies', cameraId: '1', isOn: true, entityCategory: 'config', available: true },
|
||||
],
|
||||
rawCameras: [],
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
tap.test('maps motionEye cameras, motion switches, and action sensors', async () => {
|
||||
const devices = MotionEyeMapper.toDevices(snapshot);
|
||||
const entities = MotionEyeMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices.length).toEqual(1);
|
||||
expect(devices[0].features.some((featureArg) => featureArg.id === 'motion_detection')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'camera.driveway')?.attributes?.snapshotUrl).toEqual('http://192.168.1.40:8765/picture/1/current/?_username=user&_signature=abc');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.driveway_motion_detection')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.driveway_actions')?.state).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('models stream, snapshot, motion, recording, and text overlay services as commands', async () => {
|
||||
const streamCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'stream_source',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
});
|
||||
const snapshotCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'snapshot',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
});
|
||||
const motionCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.driveway_motion_detection' },
|
||||
});
|
||||
const recordCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'motioneye',
|
||||
service: 'record_start',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
});
|
||||
const overlayCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'motioneye',
|
||||
service: 'set_text_overlay',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
data: { left_text: 'timestamp', custom_right_text: 'Driveway' },
|
||||
});
|
||||
|
||||
expect(streamCommand?.type).toEqual('stream_source');
|
||||
expect(snapshotCommand?.type).toEqual('snapshot_image');
|
||||
expect(snapshotCommand?.httpCommands?.[0].path).toEqual('/picture/1/current/');
|
||||
expect(motionCommand?.type).toEqual('set_switch');
|
||||
expect(motionCommand?.key).toEqual('motion_detection');
|
||||
expect(motionCommand?.enabled).toEqual(false);
|
||||
expect(recordCommand?.type).toEqual('action');
|
||||
expect(recordCommand?.action).toEqual('record_start');
|
||||
expect(recordCommand?.httpCommands?.[0].path).toEqual('/action/1/record_start');
|
||||
expect(overlayCommand?.type).toEqual('set_text_overlay');
|
||||
expect(overlayCommand?.customRightText).toEqual('Driveway');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HomeAssistantOpnsenseIntegration, type IOpnsenseCommand, type IOpnsenseConfig } from '../../ts/integrations/opnsense/index.js';
|
||||
|
||||
const config: IOpnsenseConfig = {
|
||||
url: 'https://192.168.1.1',
|
||||
apiKey: 'key',
|
||||
apiSecret: 'secret',
|
||||
snapshot: {
|
||||
connected: true,
|
||||
router: {
|
||||
host: '192.168.1.1',
|
||||
name: 'Edge Firewall',
|
||||
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||
actions: ['reboot'],
|
||||
},
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
gateways: [],
|
||||
firewall: {},
|
||||
system: {},
|
||||
telemetry: {},
|
||||
services: [],
|
||||
vpn: {},
|
||||
sensors: {},
|
||||
switches: [],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('does not fake OPNsense live API command success without executor', async () => {
|
||||
const runtime = await new HomeAssistantOpnsenseIntegration().setup(config, {});
|
||||
const result = await runtime.callService!({ domain: 'opnsense', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toInclude('not faked');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('executes represented OPNsense commands through injected executor', async () => {
|
||||
let command: IOpnsenseCommand | undefined;
|
||||
const runtime = await new HomeAssistantOpnsenseIntegration().setup({
|
||||
...config,
|
||||
commandExecutor: async (commandArg) => {
|
||||
command = commandArg;
|
||||
return { success: true, data: { accepted: true } };
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'opnsense', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(command?.type).toEqual('router.action');
|
||||
expect(command?.path).toEqual('/api/core/system/reboot');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { OpnsenseConfigFlow, createOpnsenseDiscoveryDescriptor, type IOpnsenseSnapshot } from '../../ts/integrations/opnsense/index.js';
|
||||
|
||||
const snapshot: IOpnsenseSnapshot = {
|
||||
connected: true,
|
||||
router: { name: 'Snapshot Firewall', macAddress: 'AA:BB:CC:DD:EE:FF' },
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
gateways: [],
|
||||
firewall: {},
|
||||
system: {},
|
||||
telemetry: {},
|
||||
services: [],
|
||||
vpn: {},
|
||||
sensors: {},
|
||||
switches: [],
|
||||
};
|
||||
|
||||
tap.test('matches and validates manual local HTTPS OPNsense candidates', async () => {
|
||||
const descriptor = createOpnsenseDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
url: 'https://firewall.local:8443',
|
||||
name: 'OPNsense Firewall',
|
||||
model: 'OPNsense',
|
||||
macAddress: 'AA-BB-CC-DD-EE-FF',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('opnsense');
|
||||
expect(result.candidate?.port).toEqual(8443);
|
||||
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(result.candidate?.metadata?.verifySsl).toBeFalse();
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.metadata?.liveHttpImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('accepts snapshot-only setup and rejects non-HTTPS endpoints', async () => {
|
||||
const descriptor = createOpnsenseDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const snapshotResult = await matcher.matches({ snapshot }, {});
|
||||
const httpResult = await matcher.matches({ url: 'http://firewall.local', name: 'OPNsense' }, {});
|
||||
|
||||
expect(snapshotResult.matched).toBeTrue();
|
||||
expect(snapshotResult.confidence).toEqual('certain');
|
||||
expect(httpResult.matched).toBeFalse();
|
||||
expect(httpResult.reason).toInclude('HTTPS');
|
||||
});
|
||||
|
||||
tap.test('builds OPNsense config flow output without claiming live HTTP support', async () => {
|
||||
const flow = new OpnsenseConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', host: 'firewall.local', metadata: { trackerInterfaces: ['LAN'] } }, {});
|
||||
const done = await step.submit!({ url: 'firewall.local', apiKey: 'key', apiSecret: 'secret', trackerInterfaces: 'LAN,WAN' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.url).toEqual('https://firewall.local');
|
||||
expect(done.config?.ssl).toBeTrue();
|
||||
expect(done.config?.verifySsl).toBeFalse();
|
||||
expect(done.config?.trackerInterfaces).toEqual(['LAN', 'WAN']);
|
||||
expect(done.config?.metadata?.liveHttpImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { OpnsenseMapper, type IOpnsenseSnapshot } from '../../ts/integrations/opnsense/index.js';
|
||||
|
||||
const snapshot: IOpnsenseSnapshot = {
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
router: {
|
||||
host: '192.168.1.1',
|
||||
name: 'Edge Firewall',
|
||||
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||
firmware: '25.7',
|
||||
latestFirmware: '26.1',
|
||||
updateAvailable: true,
|
||||
actions: ['reboot', 'firmware_update'],
|
||||
},
|
||||
devices: [],
|
||||
interfaces: [
|
||||
{
|
||||
name: 'wan',
|
||||
label: 'WAN',
|
||||
status: 'up',
|
||||
inbytes: 2_000_000_000,
|
||||
outbytes: 1_000_000_000,
|
||||
inpkts: 100,
|
||||
outpkts: 50,
|
||||
actions: ['reload'],
|
||||
},
|
||||
],
|
||||
gateways: [
|
||||
{ name: 'WAN_DHCP', status: 'online', latency: '4.1 ms', loss: '0.0 %', interface: 'wan' },
|
||||
],
|
||||
firewall: {
|
||||
rules: [{ uuid: 'rule-1', description: 'Allow LAN', enabled: true, interface: 'lan', protocol: 'tcp' }],
|
||||
nat: { d_nat: [{ uuid: 'nat-1', description: 'HTTPS', disabled: '0', protocol: 'tcp' }] },
|
||||
aliases: [{ uuid: 'alias-1', name: 'BlockedHosts', enabled: false, type: 'host' }],
|
||||
state: { used: 42, total: 100, usedPercent: 42 },
|
||||
},
|
||||
system: {
|
||||
firmwareVersion: '25.7',
|
||||
productLatest: '26.1',
|
||||
updateAvailable: true,
|
||||
pendingNoticesPresent: true,
|
||||
pendingNotices: [{ id: 'notice1', notice: 'Reboot required' }],
|
||||
},
|
||||
telemetry: {
|
||||
cpu: { usage_total: 12 },
|
||||
memory: { used_percent: 34 },
|
||||
mbuf: { used_percent: 5 },
|
||||
},
|
||||
services: [
|
||||
{ name: 'unbound', displayName: 'Unbound DNS', running: 1 },
|
||||
],
|
||||
vpn: {
|
||||
openvpn: { servers: [{ uuid: 'ovpn1', name: 'Remote Access', enabled: true, connected_clients: 2 }] },
|
||||
wireguard: { clients: [{ uuid: 'wgclient1', name: 'Mullvad', enabled: false, connected_servers: 0 }] },
|
||||
},
|
||||
sensors: {},
|
||||
switches: [
|
||||
{ id: 'dnsbl', name: 'Unbound DNSBL', enabled: true, nativeType: 'unbound_blocklist', uuid: 'dnsbl-1' },
|
||||
],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
tap.test('maps OPNsense snapshot sections and HA ARP tracker filtering', async () => {
|
||||
const normalized = OpnsenseMapper.toSnapshot({
|
||||
snapshot,
|
||||
trackerInterfaces: ['LAN'],
|
||||
arpTable: [
|
||||
{ 'mac-address': '11:22:33:44:55:66', 'ip-address': '192.168.1.20', hostname: 'Kitchen Phone', intf_description: 'LAN', manufacturer: 'PhoneCo' },
|
||||
{ 'mac-address': '22:33:44:55:66:77', 'ip-address': '192.168.1.21', hostname: 'WAN Host', intf_description: 'WAN' },
|
||||
],
|
||||
});
|
||||
const devices = OpnsenseMapper.toDevices(normalized);
|
||||
const entities = OpnsenseMapper.toEntities(normalized);
|
||||
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'opnsense.router.aa_bb_cc_dd_ee_ff')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'opnsense.client.11_22_33_44_55_66')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'opnsense.client.22_33_44_55_66_77')).toBeFalse();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_cpu_usage')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_memory_usage')?.state).toEqual(34);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_firewall_state_table_used')?.state).toEqual(42);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_wan_download')?.state).toEqual(2);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.edge_firewall_gateway_wan_dhcp')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.attributes?.nativePlatform).toEqual('device_tracker');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.edge_firewall_firewall_allow_lan')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.unbound_dns_service')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.edge_firewall_openvpn_remote_access')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.edge_firewall_firmware')?.attributes?.latestVersion).toEqual('26.1');
|
||||
});
|
||||
|
||||
tap.test('models safe OPNsense commands only for represented resources', async () => {
|
||||
const normalized = OpnsenseMapper.toSnapshot({ snapshot });
|
||||
const serviceCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.unbound_dns_service' },
|
||||
});
|
||||
const firewallCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.edge_firewall_firewall_allow_lan' },
|
||||
});
|
||||
const natCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.edge_firewall_nat_https' },
|
||||
});
|
||||
const rebootCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'opnsense',
|
||||
service: 'reboot',
|
||||
target: {},
|
||||
});
|
||||
const missingServiceCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'opnsense',
|
||||
service: 'stop_service',
|
||||
target: {},
|
||||
data: { service: 'missing' },
|
||||
});
|
||||
|
||||
expect(serviceCommand && !('error' in serviceCommand) ? serviceCommand.path : '').toEqual('/api/core/service/stop/unbound');
|
||||
expect(firewallCommand && !('error' in firewallCommand) ? firewallCommand.path : '').toEqual('/api/firewall/filter/toggle_rule/rule-1/0');
|
||||
expect(natCommand && !('error' in natCommand) ? natCommand.path : '').toEqual('/api/firewall/d_nat/toggle_rule/nat-1/1');
|
||||
expect(rebootCommand && !('error' in rebootCommand) ? rebootCommand.type : '').toEqual('router.action');
|
||||
expect(missingServiceCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleConfigFlow, PiHoleHttpMatcher, PiHoleManualMatcher, PiHoleCandidateValidator } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
tap.test('recognizes Pi-hole HTTP and manual discovery candidates', async () => {
|
||||
const httpMatch = await new PiHoleHttpMatcher().matches({
|
||||
url: 'http://192.168.1.2/admin/api.php?summaryRaw',
|
||||
headers: { server: 'lighttpd' },
|
||||
});
|
||||
const manualMatch = await new PiHoleManualMatcher().matches({
|
||||
host: 'pihole.local',
|
||||
name: 'Pi-hole',
|
||||
});
|
||||
const validation = await new PiHoleCandidateValidator().validate(httpMatch.candidate!);
|
||||
|
||||
expect(httpMatch.matched).toBeTrue();
|
||||
expect(httpMatch.candidate?.integrationDomain).toEqual('pi_hole');
|
||||
expect(httpMatch.candidate?.metadata?.apiVersion).toEqual(5);
|
||||
expect(httpMatch.candidate?.metadata?.location).toEqual('admin');
|
||||
expect(manualMatch.matched).toBeTrue();
|
||||
expect(manualMatch.candidate?.port).toEqual(80);
|
||||
expect(validation.matched).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('config flow parses Pi-hole URL and validates credentials shape', async () => {
|
||||
const step = await new PiHoleConfigFlow().start({ source: 'manual', name: 'Pi-hole' }, {});
|
||||
const missingKey = await step.submit!({ host: 'http://192.168.1.2:8080/admin/api.php' });
|
||||
const done = await step.submit!({
|
||||
host: 'http://192.168.1.2:8080/admin/api.php',
|
||||
apiKey: 'secret',
|
||||
apiVersion: '5',
|
||||
verifySsl: false,
|
||||
});
|
||||
|
||||
expect(missingKey.kind).toEqual('error');
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.2');
|
||||
expect(done.config?.port).toEqual(8080);
|
||||
expect(done.config?.location).toEqual('admin');
|
||||
expect(done.config?.apiVersion).toEqual(5);
|
||||
expect(done.config?.apiKey).toEqual('secret');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleMapper, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
const v5Snapshot: IPiHoleSnapshot = PiHoleMapper.toSnapshot({
|
||||
config: {
|
||||
host: '192.168.1.2',
|
||||
port: 80,
|
||||
name: 'Home DNS',
|
||||
apiVersion: 5,
|
||||
rawData: {
|
||||
v5Summary: {
|
||||
status: 'enabled',
|
||||
ads_blocked_today: 42,
|
||||
ads_percentage_today: 21.55,
|
||||
clients_ever_seen: 8,
|
||||
dns_queries_today: 200,
|
||||
domains_being_blocked: 150000,
|
||||
queries_cached: 50,
|
||||
queries_forwarded: 120,
|
||||
unique_clients: 5,
|
||||
unique_domains: 90,
|
||||
},
|
||||
v5Versions: {
|
||||
core_current: 'v5.18.3',
|
||||
core_latest: 'v5.18.4',
|
||||
core_update: true,
|
||||
web_current: 'v5.21',
|
||||
web_latest: 'v5.21',
|
||||
web_update: false,
|
||||
FTL_current: 'v5.25.2',
|
||||
FTL_latest: 'v5.25.2',
|
||||
FTL_update: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
tap.test('maps Pi-hole v5 status, statistics, updates, and switch control', async () => {
|
||||
const devices = PiHoleMapper.toDevices(v5Snapshot);
|
||||
const entities = PiHoleMapper.toEntities(v5Snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('pi_hole.service.192_168_1_2_80');
|
||||
expect(devices[0].online).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.home_dns_status')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_blocked_today')?.state).toEqual(42);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_percentage_blocked_today')?.state).toEqual(21.6);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_dns_queries_today')?.state).toEqual(200);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.home_dns_core_update_available')?.attributes?.latestVersion).toEqual('v5.18.4');
|
||||
});
|
||||
|
||||
tap.test('models Pi-hole enable, disable, and refresh commands without secrets', async () => {
|
||||
const disableCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.home_dns' },
|
||||
data: { duration: '00:10:00' },
|
||||
});
|
||||
const enableCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'pi_hole',
|
||||
service: 'enable',
|
||||
target: { deviceId: 'pi_hole.service.192_168_1_2_80' },
|
||||
});
|
||||
const refreshCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'pi_hole',
|
||||
service: 'refresh',
|
||||
target: {},
|
||||
});
|
||||
|
||||
expect(Boolean(disableCommand && !('error' in disableCommand))).toBeTrue();
|
||||
if (disableCommand && !('error' in disableCommand)) {
|
||||
expect(disableCommand.type).toEqual('disable');
|
||||
expect(disableCommand.path).toEqual('/admin/api.php');
|
||||
expect(disableCommand.query).toEqual({ disable: 600 });
|
||||
expect(JSON.stringify(disableCommand).includes('auth')).toBeFalse();
|
||||
}
|
||||
expect(Boolean(enableCommand && !('error' in enableCommand))).toBeTrue();
|
||||
if (enableCommand && !('error' in enableCommand)) {
|
||||
expect(enableCommand.query).toEqual({ enable: 'True' });
|
||||
}
|
||||
expect(refreshCommand && !('error' in refreshCommand) ? refreshCommand.type : undefined).toEqual('refresh');
|
||||
});
|
||||
|
||||
tap.test('maps Pi-hole v6 nested summary and version payloads', async () => {
|
||||
const snapshot = PiHoleMapper.toSnapshot({
|
||||
config: {
|
||||
host: 'pihole.local',
|
||||
name: 'Family DNS',
|
||||
apiVersion: 6,
|
||||
rawData: {
|
||||
v6Blocking: { blocking: 'disabled' },
|
||||
v6Summary: {
|
||||
queries: { blocked: 12, percent_blocked: 24.123, total: 50, cached: 8, forwarded: 30, unique_domains: 40 },
|
||||
clients: { total: 6, active: 3 },
|
||||
gravity: { domains_being_blocked: 180000 },
|
||||
},
|
||||
v6Versions: {
|
||||
version: {
|
||||
core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.1', hash: 'b' } },
|
||||
web: { local: { version: 'v6.0', hash: 'c' }, remote: { version: 'v6.0', hash: 'c' } },
|
||||
ftl: { local: { version: 'v6.0', hash: 'd' }, remote: { version: 'v6.0', hash: 'd' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
const entities = PiHoleMapper.toEntities(snapshot);
|
||||
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.family_dns')?.state).toEqual('off');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_blocked')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_percentage_blocked')?.state).toEqual(24.12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_dns_queries')?.state).toEqual(50);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.family_dns_core_update_available')?.state).toEqual('on');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleClient, PiHoleIntegration, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
const snapshot: IPiHoleSnapshot = {
|
||||
online: true,
|
||||
apiVersion: 6,
|
||||
status: 'enabled',
|
||||
statistics: {
|
||||
adsBlocked: 1,
|
||||
adsPercentage: 10,
|
||||
clientsSeen: 1,
|
||||
dnsQueries: 10,
|
||||
domainsBlocked: 100,
|
||||
queriesCached: 2,
|
||||
queriesForwarded: 7,
|
||||
uniqueClients: 1,
|
||||
uniqueDomains: 8,
|
||||
},
|
||||
versions: { core: {}, web: {}, ftl: {} },
|
||||
name: 'Pi-hole',
|
||||
};
|
||||
|
||||
tap.test('reads Pi-hole v6 local HTTP API and executes disable through HTTP', async () => {
|
||||
const requests: Array<{ method?: string; url?: string; sid?: string; csrf?: string; body?: string }> = [];
|
||||
const server = createServer((requestArg, responseArg) => {
|
||||
let body = '';
|
||||
requestArg.on('data', (chunkArg) => { body += String(chunkArg); });
|
||||
requestArg.on('end', () => {
|
||||
requests.push({
|
||||
method: requestArg.method,
|
||||
url: requestArg.url,
|
||||
sid: requestArg.headers['x-ftl-sid'] as string | undefined,
|
||||
csrf: requestArg.headers['x-ftl-csrf'] as string | undefined,
|
||||
body,
|
||||
});
|
||||
responseArg.setHeader('content-type', 'application/json');
|
||||
if (requestArg.method === 'POST' && requestArg.url === '/api/auth') {
|
||||
responseArg.end(JSON.stringify({ session: { valid: true, sid: 'sid-1', csrf: 'csrf-1', validity: 300 } }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'GET' && requestArg.url === '/api/stats/summary') {
|
||||
responseArg.end(JSON.stringify({ queries: { blocked: 5, percent_blocked: 25, total: 20, cached: 4, forwarded: 10, unique_domains: 12 }, clients: { total: 3, active: 2 }, gravity: { domains_being_blocked: 100000 } }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'GET' && requestArg.url === '/api/dns/blocking') {
|
||||
responseArg.end(JSON.stringify({ blocking: 'enabled' }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'GET' && requestArg.url === '/api/info/version') {
|
||||
responseArg.end(JSON.stringify({ version: { core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.0', hash: 'a' } }, web: {}, ftl: {} } }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking') {
|
||||
responseArg.end(JSON.stringify({ blocking: false, timer: 15 }));
|
||||
return;
|
||||
}
|
||||
responseArg.statusCode = 404;
|
||||
responseArg.end(JSON.stringify({ error: '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 runtime = await new PiHoleIntegration().setup({ host: '127.0.0.1', port, apiKey: 'secret', apiVersion: 6, timeoutMs: 1000 }, {});
|
||||
const entities = await runtime.entities();
|
||||
const result = await runtime.callService?.({
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: entities.find((entityArg) => entityArg.platform === 'switch')?.id },
|
||||
data: { duration: '00:00:15' },
|
||||
});
|
||||
|
||||
const disableRequest = requests.find((requestArg) => requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.pi_hole_dns_queries')?.state).toEqual(20);
|
||||
expect(result?.success).toBeTrue();
|
||||
expect(disableRequest?.sid).toEqual('sid-1');
|
||||
expect(disableRequest?.csrf).toEqual('csrf-1');
|
||||
expect(JSON.parse(disableRequest?.body || '{}')).toEqual({ blocking: false, timer: 15 });
|
||||
await runtime.destroy();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not report snapshot-only Pi-hole commands as successful', async () => {
|
||||
const runtime = await new PiHoleIntegration().setup({ snapshot }, {});
|
||||
const switchEntity = (await runtime.entities()).find((entityArg) => entityArg.platform === 'switch');
|
||||
const result = await runtime.callService?.({
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: switchEntity?.id },
|
||||
});
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toEqual('Pi-hole live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('does not fake refresh success without HTTP endpoint or snapshot', async () => {
|
||||
const client = new PiHoleClient({});
|
||||
const snapshotResult = await client.getSnapshot();
|
||||
const refreshResult = await client.refresh();
|
||||
|
||||
expect(snapshotResult.online).toBeFalse();
|
||||
expect(refreshResult.success).toBeFalse();
|
||||
expect(refreshResult.error).toContain('endpoint');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SqueezeboxConfigFlow } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
tap.test('creates Squeezebox config from discovered host and credentials', async () => {
|
||||
const flow = new SqueezeboxConfigFlow();
|
||||
const step = await flow.start({
|
||||
source: 'mdns',
|
||||
integrationDomain: 'squeezebox',
|
||||
id: 'server-uuid-1',
|
||||
host: 'home-lms.local',
|
||||
port: 9000,
|
||||
name: 'Home LMS',
|
||||
}, {});
|
||||
|
||||
expect(step.kind).toEqual('form');
|
||||
const done = await step.submit!({ username: 'lms', password: 'secret', https: true, volumeStep: 10 });
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('home-lms.local');
|
||||
expect(done.config?.https).toBeTrue();
|
||||
expect(done.config?.serverId).toEqual('server-uuid-1');
|
||||
expect(done.config?.volumeStep).toEqual(10);
|
||||
});
|
||||
|
||||
tap.test('requires an LMS host for manual setup', async () => {
|
||||
const flow = new SqueezeboxConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', integrationDomain: 'squeezebox' }, {});
|
||||
const result = await step.submit!({});
|
||||
expect(result.kind).toEqual('error');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createSqueezeboxDiscoveryDescriptor } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
tap.test('matches LMS mDNS advertisements', async () => {
|
||||
const descriptor = createSqueezeboxDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
name: 'Home LMS._squeezebox-jsonrpc._tcp.local.',
|
||||
type: '_squeezebox-jsonrpc._tcp.local.',
|
||||
host: 'home-lms.local',
|
||||
port: 9000,
|
||||
txt: { uuid: 'server-uuid-1', name: 'Home LMS' },
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('certain');
|
||||
expect(result.normalizedDeviceId).toEqual('server-uuid-1');
|
||||
expect(result.candidate?.host).toEqual('home-lms.local');
|
||||
expect(result.candidate?.port).toEqual(9000);
|
||||
});
|
||||
|
||||
tap.test('matches DHCP player hints and manual LMS candidates', async () => {
|
||||
const descriptor = createSqueezeboxDiscoveryDescriptor();
|
||||
const dhcp = await descriptor.getMatchers()[1].matches({
|
||||
hostname: 'squeezebox-kitchen',
|
||||
macaddress: '00:04:20:AA:BB:02',
|
||||
ipAddress: '192.168.1.51',
|
||||
}, {});
|
||||
expect(dhcp.matched).toBeTrue();
|
||||
expect(dhcp.candidate?.metadata?.playerDiscovery).toBeTrue();
|
||||
|
||||
const manual = await descriptor.getMatchers()[2].matches({ host: '192.168.1.40', name: 'Manual LMS' }, {});
|
||||
expect(manual.matched).toBeTrue();
|
||||
expect(manual.candidate?.port).toEqual(9000);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SqueezeboxMapper, type ISqueezeboxSnapshot } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
const snapshot: ISqueezeboxSnapshot = {
|
||||
server: {
|
||||
id: 'lms-1',
|
||||
uuid: 'server-uuid-1',
|
||||
name: 'Home LMS',
|
||||
host: '192.168.1.40',
|
||||
version: '8.5.0',
|
||||
playerCount: 2,
|
||||
stats: { totalSongs: 1200, totalAlbums: 100 },
|
||||
},
|
||||
players: [{
|
||||
playerId: '00:04:20:aa:bb:01',
|
||||
name: 'Living Room',
|
||||
model: 'Squeezebox Radio',
|
||||
firmware: '8.5.0',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'play',
|
||||
volume: 45,
|
||||
muting: false,
|
||||
repeat: 'playlist',
|
||||
shuffle: 'song',
|
||||
title: 'Example Track',
|
||||
artist: 'Example Artist',
|
||||
album: 'Example Album',
|
||||
url: 'http://radio.example/stream.mp3',
|
||||
duration: 180,
|
||||
time: 30,
|
||||
syncGroup: ['00:04:20:aa:bb:02'],
|
||||
}, {
|
||||
playerId: '00:04:20:aa:bb:02',
|
||||
name: 'Kitchen',
|
||||
model: 'Squeezebox Touch',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'pause',
|
||||
volume: 25,
|
||||
muting: true,
|
||||
syncGroup: ['00:04:20:aa:bb:01'],
|
||||
}],
|
||||
favorites: [{
|
||||
id: 'fav-1',
|
||||
name: 'Jazz Radio',
|
||||
url: 'http://radio.example/stream.mp3',
|
||||
type: 'audio',
|
||||
playable: true,
|
||||
}],
|
||||
syncGroups: [{
|
||||
id: 'sync-main',
|
||||
name: 'Downstairs',
|
||||
leaderPlayerId: '00:04:20:aa:bb:01',
|
||||
playerIds: ['00:04:20:aa:bb:01', '00:04:20:aa:bb:02'],
|
||||
}],
|
||||
online: true,
|
||||
source: 'snapshot',
|
||||
};
|
||||
|
||||
tap.test('maps Squeezebox server and players to devices', async () => {
|
||||
const devices = SqueezeboxMapper.toDevices(snapshot);
|
||||
const server = devices.find((deviceArg) => deviceArg.id === 'squeezebox.server.server_uuid_1');
|
||||
const player = devices.find((deviceArg) => deviceArg.id === 'squeezebox.player.00_04_20_aa_bb_01');
|
||||
expect(server?.state.some((stateArg) => stateArg.featureId === 'favorites' && stateArg.value === 1)).toBeTrue();
|
||||
expect(player?.manufacturer).toEqual('Logitech');
|
||||
expect(player?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Jazz Radio')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Squeezebox media entities favorites and sync groups', async () => {
|
||||
const entities = SqueezeboxMapper.toEntities(snapshot);
|
||||
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
|
||||
const favorites = entities.find((entityArg) => entityArg.id === 'sensor.home_lms_favorites');
|
||||
const syncGroups = entities.find((entityArg) => entityArg.id === 'sensor.home_lms_sync_groups');
|
||||
const media = entities.find((entityArg) => entityArg.id === 'sensor.living_room_squeezebox_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?.repeat).toEqual('all');
|
||||
expect(favorites?.state).toEqual(1);
|
||||
expect(syncGroups?.state).toEqual(1);
|
||||
expect(media?.state).toEqual('Example Track');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SqueezeboxClient, SqueezeboxIntegration, type ISqueezeboxRawCommandRequest, type ISqueezeboxSnapshot } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
const snapshot: ISqueezeboxSnapshot = {
|
||||
server: { id: 'lms-1', uuid: 'server-uuid-1', name: 'Home LMS', host: '192.168.1.40' },
|
||||
players: [{
|
||||
playerId: '00:04:20:aa:bb:01',
|
||||
name: 'Living Room',
|
||||
model: 'Squeezebox Radio',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'pause',
|
||||
volume: 20,
|
||||
syncGroup: ['00:04:20:aa:bb:02'],
|
||||
}, {
|
||||
playerId: '00:04:20:aa:bb:02',
|
||||
name: 'Kitchen',
|
||||
model: 'Squeezebox Touch',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'pause',
|
||||
volume: 30,
|
||||
syncGroup: ['00:04:20:aa:bb:01'],
|
||||
}],
|
||||
favorites: [{ id: 'fav-1', name: 'Jazz Radio', url: 'http://radio.example/stream.mp3', playable: true }],
|
||||
syncGroups: [{ id: 'sync-main', playerIds: ['00:04:20:aa:bb:01', '00:04:20:aa:bb:02'], leaderPlayerId: '00:04:20:aa:bb:01' }],
|
||||
online: true,
|
||||
};
|
||||
|
||||
tap.test('models Squeezebox playback volume source and sync commands through an executor', async () => {
|
||||
const commands: ISqueezeboxRawCommandRequest[] = [];
|
||||
const runtime = await new SqueezeboxIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (requestArg) => {
|
||||
commands.push(requestArg);
|
||||
return { id: requestArg.body?.id, result: {} };
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
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 unjoin = await runtime.callService!({ domain: 'media_player', service: 'unjoin', target: { entityId: 'media_player.kitchen' } });
|
||||
|
||||
expect(play.success).toBeTrue();
|
||||
expect(volume.success).toBeTrue();
|
||||
expect(source.success).toBeTrue();
|
||||
expect(join.success).toBeTrue();
|
||||
expect(unjoin.success).toBeTrue();
|
||||
expect(commands.map((commandArg) => commandArg.command)).toEqual([
|
||||
['play'],
|
||||
['mixer', 'volume', '35'],
|
||||
['playlist', 'play', 'http://radio.example/stream.mp3'],
|
||||
['sync', '00:04:20:aa:bb:02'],
|
||||
['sync', '-'],
|
||||
]);
|
||||
expect(commands[0].playerId).toEqual('00:04:20:aa:bb:01');
|
||||
expect(commands[4].playerId).toEqual('00:04:20:aa:bb:02');
|
||||
});
|
||||
|
||||
tap.test('does not report live command success for static snapshots without transport', async () => {
|
||||
const runtime = await new SqueezeboxIntegration().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();
|
||||
});
|
||||
|
||||
tap.test('uses native LMS JSON-RPC over HTTP for live commands', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: ISqueezeboxRawCommandRequest['body']; authorization?: string }> = [];
|
||||
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
|
||||
const headers = initArg?.headers as Record<string, string> | undefined;
|
||||
calls.push({ url: String(urlArg), body: JSON.parse(String(initArg?.body)), authorization: headers?.authorization });
|
||||
return new Response(JSON.stringify({ id: 1, method: 'slim.request', result: {} }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await new SqueezeboxClient({ host: 'lms.local', username: 'user', password: 'pass' }).execute({ command: 'set_volume', playerId: '00:04:20:aa:bb:01', volumeLevel: 0.5 });
|
||||
expect(calls[0].url).toEqual('http://lms.local:9000/jsonrpc.js');
|
||||
expect(calls[0].body?.method).toEqual('slim.request');
|
||||
expect(calls[0].body?.params).toEqual(['00:04:20:aa:bb:01', ['mixer', 'volume', '50']]);
|
||||
expect(calls[0].authorization?.startsWith('Basic ')).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HomeAssistantSynologyDsmIntegration, type ISynologyDsmCommand, type ISynologyDsmConfig } from '../../ts/integrations/synology_dsm/index.js';
|
||||
|
||||
const config: ISynologyDsmConfig = {
|
||||
host: '192.168.1.20',
|
||||
snapshot: {
|
||||
connected: true,
|
||||
system: {
|
||||
serial: 'SYN123',
|
||||
name: 'DiskStation',
|
||||
host: '192.168.1.20',
|
||||
model: 'DS920+',
|
||||
versionString: 'DSM 7.2.2-72806',
|
||||
},
|
||||
utilization: {},
|
||||
storage: { volumes: [], disks: [] },
|
||||
network: {},
|
||||
cameras: [],
|
||||
switches: [],
|
||||
actions: [],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('does not fake Synology DSM command success without injected executor', async () => {
|
||||
const runtime = await new HomeAssistantSynologyDsmIntegration().setup(config, {});
|
||||
const result = await runtime.callService!({ domain: 'synology_dsm', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toContain('not faked');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('executes explicit Synology DSM commands through injected executor', async () => {
|
||||
let command: ISynologyDsmCommand | undefined;
|
||||
const runtime = await new HomeAssistantSynologyDsmIntegration().setup({
|
||||
...config,
|
||||
commandExecutor: async (commandArg) => {
|
||||
command = commandArg;
|
||||
return { success: true, data: { accepted: true } };
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'synology_dsm', service: 'shutdown', target: {} });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(command?.type).toEqual('system.action');
|
||||
expect(command?.action).toEqual('shutdown');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('reports offline refresh without snapshot, provider, or native client', async () => {
|
||||
const runtime = await new HomeAssistantSynologyDsmIntegration().setup({ host: '192.168.1.20' }, {});
|
||||
const result = await runtime.callService!({ domain: 'synology_dsm', service: 'refresh', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toContain('nativeClient');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SynologyDsmConfigFlow, createSynologyDsmDiscoveryDescriptor, type ISynologyDsmSnapshot } from '../../ts/integrations/synology_dsm/index.js';
|
||||
|
||||
const snapshot: ISynologyDsmSnapshot = {
|
||||
connected: true,
|
||||
system: {
|
||||
serial: 'SYN123',
|
||||
name: 'DiskStation',
|
||||
host: '192.168.1.20',
|
||||
port: 5001,
|
||||
ssl: true,
|
||||
model: 'DS920+',
|
||||
},
|
||||
utilization: {},
|
||||
storage: { volumes: [], disks: [] },
|
||||
network: {},
|
||||
cameras: [],
|
||||
switches: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
tap.test('matches and validates manual Synology DSM entries', async () => {
|
||||
const descriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({ host: '192.168.1.20', name: 'NAS' }, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('synology_dsm');
|
||||
expect(result.candidate?.port).toEqual(5001);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.normalizedDeviceId).toEqual('192.168.1.20:5001');
|
||||
});
|
||||
|
||||
tap.test('matches Synology HTTP, SSDP, and mDNS local candidates', async () => {
|
||||
const descriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
const httpResult = await descriptor.getMatchers()[1].matches({ url: 'https://diskstation.local:5001/webapi/query.cgi' }, {});
|
||||
const ssdpResult = await descriptor.getMatchers()[2].matches({
|
||||
location: 'http://192.168.1.20:5000/description.xml',
|
||||
manufacturer: 'Synology',
|
||||
upnp: {
|
||||
friendlyName: 'DiskStation (DS920+)',
|
||||
modelName: 'DS920+',
|
||||
manufacturer: 'Synology',
|
||||
serialNumber: 'AABBCCDDEEFF',
|
||||
},
|
||||
}, {});
|
||||
const mdnsResult = await descriptor.getMatchers()[3].matches({
|
||||
type: '_http._tcp.local.',
|
||||
name: 'DiskStation._http._tcp.local.',
|
||||
host: 'diskstation.local',
|
||||
properties: {
|
||||
vendor: 'synology',
|
||||
mac_address: 'AA:BB:CC:DD:EE:FF',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(httpResult.matched).toBeTrue();
|
||||
expect(httpResult.candidate?.host).toEqual('diskstation.local');
|
||||
expect(ssdpResult.matched).toBeTrue();
|
||||
expect(ssdpResult.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(mdnsResult.matched).toBeTrue();
|
||||
expect(mdnsResult.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
});
|
||||
|
||||
tap.test('creates config flow output from discovery and snapshot JSON', async () => {
|
||||
const step = await new SynologyDsmConfigFlow().start({ source: 'manual', host: '192.168.1.20', id: 'SYN123', name: 'DiskStation' }, {});
|
||||
const done = await step.submit!({ username: 'admin', password: 'secret', snapshotJson: JSON.stringify(snapshot), snapshotQuality: '2' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.20');
|
||||
expect(done.config?.port).toEqual(5001);
|
||||
expect(done.config?.snapshot?.system.serial).toEqual('SYN123');
|
||||
expect(done.config?.snapshotQuality).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('rejects candidates without Synology DSM hints or usable data', async () => {
|
||||
const descriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
const httpResult = await descriptor.getMatchers()[1].matches({ url: 'http://example.local/status' }, {});
|
||||
expect(httpResult.matched).toBeFalse();
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate({ source: 'manual', name: 'Synology DSM' }, {});
|
||||
expect(validation.matched).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SynologyDsmMapper, type ISynologyDsmSnapshot } from '../../ts/integrations/synology_dsm/index.js';
|
||||
|
||||
const snapshot: ISynologyDsmSnapshot = {
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
system: {
|
||||
serial: 'SYN123',
|
||||
name: 'DiskStation',
|
||||
hostname: 'diskstation',
|
||||
host: '192.168.1.20',
|
||||
port: 5001,
|
||||
ssl: true,
|
||||
model: 'DS920+',
|
||||
versionString: 'DSM 7.2.2-72806',
|
||||
temperature: 42,
|
||||
uptimeSeconds: 3600,
|
||||
macs: ['AA:BB:CC:DD:EE:FF'],
|
||||
},
|
||||
utilization: {
|
||||
cpuUserLoad: 12,
|
||||
cpuSystemLoad: 5,
|
||||
cpuTotalLoad: 17,
|
||||
cpu5MinLoad: 23,
|
||||
memoryRealUsage: 64,
|
||||
memorySize: 8_589_934_592,
|
||||
networkUp: 1024,
|
||||
networkDown: 2048,
|
||||
},
|
||||
storage: {
|
||||
volumes: [
|
||||
{ id: 'volume_1', name: 'Volume 1', status: 'normal', sizeTotal: 4_000, sizeUsed: 2_000, percentageUsed: 50, diskTempAvg: 38, diskTempMax: 41, deviceType: 'shr' },
|
||||
],
|
||||
disks: [
|
||||
{ id: 'disk_1', name: 'Drive 1', vendor: 'Seagate', model: 'IronWolf', status: 'normal', smartStatus: 'normal', temperature: 36, exceedBadSectorThreshold: false, belowRemainLifeThreshold: false },
|
||||
],
|
||||
},
|
||||
network: {
|
||||
hostname: 'diskstation',
|
||||
macs: ['AA:BB:CC:DD:EE:FF'],
|
||||
uploadRate: 1024,
|
||||
downloadRate: 2048,
|
||||
},
|
||||
cameras: [
|
||||
{ id: '1', name: 'Front Door', model: 'BC500', enabled: true, recording: true, motionDetectionEnabled: true, rtsp: 'rtsp://nas/camera/1' },
|
||||
],
|
||||
update: {
|
||||
installedVersion: 'DSM 7.2.2-72806',
|
||||
latestVersion: 'DSM 7.3-73000',
|
||||
updateAvailable: true,
|
||||
releaseUrl: 'http://update.synology.com/autoupdate/whatsnew.php?model=DS920%2B&update_version=73000',
|
||||
},
|
||||
switches: [
|
||||
{ key: 'home_mode', name: 'Home mode', enabled: true, type: 'home_mode' },
|
||||
],
|
||||
security: {
|
||||
status: 'safe',
|
||||
statusByCheck: { malware: 'safe' },
|
||||
},
|
||||
actions: [],
|
||||
};
|
||||
|
||||
tap.test('maps Synology DSM system, storage, network, camera, switch, and update snapshot data', async () => {
|
||||
const normalized = SynologyDsmMapper.toSnapshot({ snapshot });
|
||||
const devices = SynologyDsmMapper.toDevices(normalized);
|
||||
const entities = SynologyDsmMapper.toEntities(normalized);
|
||||
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.nas.syn123')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.volume.syn123.volume_1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.disk.syn123.disk_1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.camera.syn123.1')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'cpu_user_load')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'cpu_5min_load')?.state).toEqual(0.23);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'volume_percentage_used')?.state).toEqual(50);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'disk_temp')?.state).toEqual(36);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'network_down')?.state).toEqual(2048);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.front_door_motion_detection')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.diskstation_surveillance_station_home_mode')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.diskstation_dsm_update')?.attributes?.latestVersion).toEqual('DSM 7.3-73000');
|
||||
});
|
||||
|
||||
tap.test('models represented Synology DSM commands without executing them', async () => {
|
||||
const normalized = SynologyDsmMapper.toSnapshot({ snapshot });
|
||||
const rebootCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'synology_dsm',
|
||||
service: 'reboot',
|
||||
target: {},
|
||||
});
|
||||
const homeModeCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.diskstation_surveillance_station_home_mode' },
|
||||
});
|
||||
const shutdownCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'button',
|
||||
service: 'press',
|
||||
target: { entityId: 'button.diskstation_shutdown' },
|
||||
});
|
||||
const cameraCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'camera',
|
||||
service: 'disable_motion_detection',
|
||||
target: { deviceId: 'synology_dsm.camera.syn123.1' },
|
||||
});
|
||||
|
||||
expect(rebootCommand?.type).toEqual('system.action');
|
||||
expect(rebootCommand?.action).toEqual('reboot');
|
||||
expect(homeModeCommand?.type).toEqual('switch.set');
|
||||
expect(homeModeCommand?.payload?.enabled).toBeFalse();
|
||||
expect(shutdownCommand?.action).toEqual('shutdown');
|
||||
expect(cameraCommand?.type).toEqual('camera.action');
|
||||
expect(cameraCommand?.cameraId).toEqual('1');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
+12
@@ -35,12 +35,16 @@ import { JellyfinIntegration } from './integrations/jellyfin/index.js';
|
||||
import { KnxIntegration } from './integrations/knx/index.js';
|
||||
import { KodiIntegration } from './integrations/kodi/index.js';
|
||||
import { MatterIntegration } from './integrations/matter/index.js';
|
||||
import { MikrotikIntegration } from './integrations/mikrotik/index.js';
|
||||
import { ModbusIntegration } from './integrations/modbus/index.js';
|
||||
import { MotionEyeIntegration } from './integrations/motioneye/index.js';
|
||||
import { MqttIntegration } from './integrations/mqtt/index.js';
|
||||
import { MpdIntegration } from './integrations/mpd/index.js';
|
||||
import { NanoleafIntegration } from './integrations/nanoleaf/index.js';
|
||||
import { OpenthermGwIntegration } from './integrations/opentherm_gw/index.js';
|
||||
import { OpnsenseIntegration } from './integrations/opnsense/index.js';
|
||||
import { OnvifIntegration } from './integrations/onvif/index.js';
|
||||
import { PiHoleIntegration } from './integrations/pi_hole/index.js';
|
||||
import { PlexIntegration } from './integrations/plex/index.js';
|
||||
import { RainbirdIntegration } from './integrations/rainbird/index.js';
|
||||
import { RflinkIntegration } from './integrations/rflink/index.js';
|
||||
@@ -49,6 +53,8 @@ import { SamsungtvIntegration } from './integrations/samsungtv/index.js';
|
||||
import { ShellyIntegration } from './integrations/shelly/index.js';
|
||||
import { SnapcastIntegration } from './integrations/snapcast/index.js';
|
||||
import { SonosIntegration } from './integrations/sonos/index.js';
|
||||
import { SqueezeboxIntegration } from './integrations/squeezebox/index.js';
|
||||
import { SynologyDsmIntegration } from './integrations/synology_dsm/index.js';
|
||||
import { TplinkIntegration } from './integrations/tplink/index.js';
|
||||
import { TradfriIntegration } from './integrations/tradfri/index.js';
|
||||
import { UnifiIntegration } from './integrations/unifi/index.js';
|
||||
@@ -98,12 +104,16 @@ export const integrations = [
|
||||
new KnxIntegration(),
|
||||
new KodiIntegration(),
|
||||
new MatterIntegration(),
|
||||
new MikrotikIntegration(),
|
||||
new ModbusIntegration(),
|
||||
new MotionEyeIntegration(),
|
||||
new MqttIntegration(),
|
||||
new MpdIntegration(),
|
||||
new NanoleafIntegration(),
|
||||
new OpenthermGwIntegration(),
|
||||
new OpnsenseIntegration(),
|
||||
new OnvifIntegration(),
|
||||
new PiHoleIntegration(),
|
||||
new PlexIntegration(),
|
||||
new RainbirdIntegration(),
|
||||
new RflinkIntegration(),
|
||||
@@ -112,6 +122,8 @@ export const integrations = [
|
||||
new ShellyIntegration(),
|
||||
new SnapcastIntegration(),
|
||||
new SonosIntegration(),
|
||||
new SqueezeboxIntegration(),
|
||||
new SynologyDsmIntegration(),
|
||||
new TplinkIntegration(),
|
||||
new TradfriIntegration(),
|
||||
new UnifiIntegration(),
|
||||
|
||||
@@ -726,7 +726,6 @@ import { HomeAssistantMicrosoftFaceDetectIntegration } from '../microsoft_face_d
|
||||
import { HomeAssistantMicrosoftFaceIdentifyIntegration } from '../microsoft_face_identify/index.js';
|
||||
import { HomeAssistantMieleIntegration } from '../miele/index.js';
|
||||
import { HomeAssistantMijndomeinEnergieIntegration } from '../mijndomein_energie/index.js';
|
||||
import { HomeAssistantMikrotikIntegration } from '../mikrotik/index.js';
|
||||
import { HomeAssistantMillIntegration } from '../mill/index.js';
|
||||
import { HomeAssistantMinMaxIntegration } from '../min_max/index.js';
|
||||
import { HomeAssistantMinecraftServerIntegration } from '../minecraft_server/index.js';
|
||||
@@ -750,7 +749,6 @@ import { HomeAssistantMopekaIntegration } from '../mopeka/index.js';
|
||||
import { HomeAssistantMotionIntegration } from '../motion/index.js';
|
||||
import { HomeAssistantMotionBlindsIntegration } from '../motion_blinds/index.js';
|
||||
import { HomeAssistantMotionblindsBleIntegration } from '../motionblinds_ble/index.js';
|
||||
import { HomeAssistantMotioneyeIntegration } from '../motioneye/index.js';
|
||||
import { HomeAssistantMotionmountIntegration } from '../motionmount/index.js';
|
||||
import { HomeAssistantMqttEventstreamIntegration } from '../mqtt_eventstream/index.js';
|
||||
import { HomeAssistantMqttJsonIntegration } from '../mqtt_json/index.js';
|
||||
@@ -862,7 +860,6 @@ import { HomeAssistantOpensensemapIntegration } from '../opensensemap/index.js';
|
||||
import { HomeAssistantOpenskyIntegration } from '../opensky/index.js';
|
||||
import { HomeAssistantOpenuvIntegration } from '../openuv/index.js';
|
||||
import { HomeAssistantOpenweathermapIntegration } from '../openweathermap/index.js';
|
||||
import { HomeAssistantOpnsenseIntegration } from '../opnsense/index.js';
|
||||
import { HomeAssistantOpowerIntegration } from '../opower/index.js';
|
||||
import { HomeAssistantOppleIntegration } from '../opple/index.js';
|
||||
import { HomeAssistantOralbIntegration } from '../oralb/index.js';
|
||||
@@ -897,7 +894,6 @@ import { HomeAssistantPersonIntegration } from '../person/index.js';
|
||||
import { HomeAssistantPgeIntegration } from '../pge/index.js';
|
||||
import { HomeAssistantPglabIntegration } from '../pglab/index.js';
|
||||
import { HomeAssistantPhilipsJsIntegration } from '../philips_js/index.js';
|
||||
import { HomeAssistantPiHoleIntegration } from '../pi_hole/index.js';
|
||||
import { HomeAssistantPicnicIntegration } from '../picnic/index.js';
|
||||
import { HomeAssistantPicottsIntegration } from '../picotts/index.js';
|
||||
import { HomeAssistantPilightIntegration } from '../pilight/index.js';
|
||||
@@ -1127,7 +1123,6 @@ import { HomeAssistantSpiderIntegration } from '../spider/index.js';
|
||||
import { HomeAssistantSplunkIntegration } from '../splunk/index.js';
|
||||
import { HomeAssistantSpotifyIntegration } from '../spotify/index.js';
|
||||
import { HomeAssistantSqlIntegration } from '../sql/index.js';
|
||||
import { HomeAssistantSqueezeboxIntegration } from '../squeezebox/index.js';
|
||||
import { HomeAssistantSrpEnergyIntegration } from '../srp_energy/index.js';
|
||||
import { HomeAssistantSsdpIntegration } from '../ssdp/index.js';
|
||||
import { HomeAssistantStarlineIntegration } from '../starline/index.js';
|
||||
@@ -1166,7 +1161,6 @@ import { HomeAssistantSymfoniskIntegration } from '../symfonisk/index.js';
|
||||
import { HomeAssistantSyncthingIntegration } from '../syncthing/index.js';
|
||||
import { HomeAssistantSyncthruIntegration } from '../syncthru/index.js';
|
||||
import { HomeAssistantSynologyChatIntegration } from '../synology_chat/index.js';
|
||||
import { HomeAssistantSynologyDsmIntegration } from '../synology_dsm/index.js';
|
||||
import { HomeAssistantSynologySrmIntegration } from '../synology_srm/index.js';
|
||||
import { HomeAssistantSyslogIntegration } from '../syslog/index.js';
|
||||
import { HomeAssistantSystemBridgeIntegration } from '../system_bridge/index.js';
|
||||
@@ -2128,7 +2122,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMicrosoftFaceDetect
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMicrosoftFaceIdentifyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMieleIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMijndomeinEnergieIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMikrotikIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMillIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMinMaxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMinecraftServerIntegration());
|
||||
@@ -2152,7 +2145,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantMopekaIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionBlindsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionblindsBleIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotioneyeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMotionmountIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttEventstreamIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantMqttJsonIntegration());
|
||||
@@ -2264,7 +2256,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpensensemapIntegra
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenskyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenuvIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpenweathermapIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpnsenseIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOpowerIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOppleIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantOralbIntegration());
|
||||
@@ -2299,7 +2290,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantPersonIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPgeIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPglabIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPhilipsJsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPiHoleIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPicnicIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPicottsIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantPilightIntegration());
|
||||
@@ -2529,7 +2519,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpiderIntegration()
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSplunkIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSpotifyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSqlIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSqueezeboxIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSrpEnergyIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSsdpIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantStarlineIntegration());
|
||||
@@ -2568,7 +2557,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantSymfoniskIntegratio
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSyncthingIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSyncthruIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSynologyChatIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSynologyDsmIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSynologySrmIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSyslogIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantSystemBridgeIntegration());
|
||||
@@ -2804,7 +2792,7 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
|
||||
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
|
||||
|
||||
export const generatedHomeAssistantPortCount = 1400;
|
||||
export const generatedHomeAssistantPortCount = 1394;
|
||||
export const handwrittenHomeAssistantPortDomains = [
|
||||
"adguard",
|
||||
"airgradient",
|
||||
@@ -2839,12 +2827,16 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"knx",
|
||||
"kodi",
|
||||
"matter",
|
||||
"mikrotik",
|
||||
"modbus",
|
||||
"motioneye",
|
||||
"mpd",
|
||||
"mqtt",
|
||||
"nanoleaf",
|
||||
"onvif",
|
||||
"opentherm_gw",
|
||||
"opnsense",
|
||||
"pi_hole",
|
||||
"plex",
|
||||
"rainbird",
|
||||
"rflink",
|
||||
@@ -2853,6 +2845,8 @@ export const handwrittenHomeAssistantPortDomains = [
|
||||
"shelly",
|
||||
"snapcast",
|
||||
"sonos",
|
||||
"squeezebox",
|
||||
"synology_dsm",
|
||||
"tplink",
|
||||
"tradfri",
|
||||
"unifi",
|
||||
|
||||
@@ -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 './mikrotik.classes.client.js';
|
||||
export * from './mikrotik.classes.configflow.js';
|
||||
export * from './mikrotik.classes.integration.js';
|
||||
export * from './mikrotik.discovery.js';
|
||||
export * from './mikrotik.mapper.js';
|
||||
export * from './mikrotik.types.js';
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { MikrotikMapper } from './mikrotik.mapper.js';
|
||||
import type { IMikrotikCommand, IMikrotikCommandResult, IMikrotikConfig, IMikrotikEvent, IMikrotikSnapshot } from './mikrotik.types.js';
|
||||
|
||||
type TMikrotikEventHandler = (eventArg: IMikrotikEvent) => void;
|
||||
|
||||
export class MikrotikClient {
|
||||
private currentSnapshot?: IMikrotikSnapshot;
|
||||
private readonly eventHandlers = new Set<TMikrotikEventHandler>();
|
||||
|
||||
constructor(private readonly config: IMikrotikConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IMikrotikSnapshot> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot = this.currentSnapshot ?? MikrotikMapper.toSnapshot(this.config);
|
||||
this.currentSnapshot = snapshot;
|
||||
return this.cloneSnapshot(snapshot);
|
||||
}
|
||||
|
||||
public onEvent(handlerArg: TMikrotikEventHandler): () => void {
|
||||
this.eventHandlers.add(handlerArg);
|
||||
return () => this.eventHandlers.delete(handlerArg);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IMikrotikCommandResult> {
|
||||
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 = MikrotikMapper.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: IMikrotikCommand): Promise<IMikrotikCommandResult> {
|
||||
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: IMikrotikCommandResult = {
|
||||
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: IMikrotikCommandResult = { 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: IMikrotikSnapshot, sourceArg: IMikrotikSnapshot['source']): IMikrotikSnapshot {
|
||||
const normalized = MikrotikMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
|
||||
return { ...normalized, source: snapshotArg.source || sourceArg };
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: IMikrotikCommand): IMikrotikCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is IMikrotikCommandResult {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
|
||||
}
|
||||
|
||||
private unsupportedCommandMessage(commandArg: IMikrotikCommand): string {
|
||||
return `Mikrotik RouterOS/API command ${commandArg.path} is not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live command execution.`;
|
||||
}
|
||||
|
||||
private emit(eventArg: IMikrotikEvent): void {
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(eventArg);
|
||||
}
|
||||
}
|
||||
|
||||
private cloneSnapshot<T extends IMikrotikSnapshot>(snapshotArg: T): T {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { MikrotikMapper } from './mikrotik.mapper.js';
|
||||
import type { IMikrotikConfig, IMikrotikSnapshot } from './mikrotik.types.js';
|
||||
import { mikrotikDefaultApiPort, mikrotikDefaultDetectionTime } from './mikrotik.types.js';
|
||||
|
||||
export class MikrotikConfigFlow implements IConfigFlow<IMikrotikConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMikrotikConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Set up Mikrotik Router',
|
||||
description: 'Provide the local RouterOS API endpoint. Snapshot/manual data is supported directly; live RouterOS/API success is not assumed without an injected native client or command executor.',
|
||||
fields: [
|
||||
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
{ name: 'password', label: 'Password', type: 'password' },
|
||||
{ name: 'port', label: `API port (${candidateArg.port || mikrotikDefaultApiPort})`, type: 'number' },
|
||||
{ name: 'verifySsl', label: 'Use SSL/TLS for RouterOS API', type: 'boolean' },
|
||||
{ name: 'forceDhcp', label: 'Force scanning using DHCP', type: 'boolean' },
|
||||
{ name: 'arpPing', label: 'Enable ARP ping', type: 'boolean' },
|
||||
{ name: 'detectionTime', label: `Consider home interval (${mikrotikDefaultDetectionTime}s)`, 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<IMikrotikConfig>> {
|
||||
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot);
|
||||
if (snapshot instanceof Error) {
|
||||
return { kind: 'error', title: 'Invalid Mikrotik snapshot', error: snapshot.message };
|
||||
}
|
||||
|
||||
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(candidateArg.metadata?.verifySsl) ?? snapshot?.router.verifySsl ?? false;
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.router.host;
|
||||
if (!host && !snapshot) {
|
||||
return { kind: 'error', title: 'Mikrotik setup failed', error: 'Mikrotik setup requires a host or snapshot JSON.' };
|
||||
}
|
||||
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.router.port || (host ? MikrotikMapper.defaultPort(verifySsl) : undefined);
|
||||
const config: IMikrotikConfig = {
|
||||
host,
|
||||
port,
|
||||
username: this.stringValue(valuesArg.username),
|
||||
password: this.stringValue(valuesArg.password),
|
||||
verifySsl,
|
||||
protocol: MikrotikMapper.protocol(verifySsl),
|
||||
forceDhcp: this.booleanValue(valuesArg.forceDhcp) ?? false,
|
||||
arpPing: this.booleanValue(valuesArg.arpPing) ?? false,
|
||||
detectionTime: this.numberValue(valuesArg.detectionTime) || mikrotikDefaultDetectionTime,
|
||||
uniqueId: candidateArg.id || snapshot?.router.serialNumber || snapshot?.router.macAddress,
|
||||
name: candidateArg.name || snapshot?.router.name || snapshot?.router.identity || host,
|
||||
snapshot,
|
||||
metadata: {
|
||||
discoverySource: candidateArg.source,
|
||||
discoveryMetadata: candidateArg.metadata,
|
||||
upstreamPlatforms: ['device_tracker'],
|
||||
liveRouterOsApiImplemented: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Mikrotik configured',
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromInput(valueArg: unknown): IMikrotikSnapshot | undefined | Error {
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
return valueArg as IMikrotikSnapshot;
|
||||
}
|
||||
const text = this.stringValue(valueArg);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as IMikrotikSnapshot;
|
||||
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,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, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js';
|
||||
import { MikrotikClient } from './mikrotik.classes.client.js';
|
||||
import { MikrotikConfigFlow } from './mikrotik.classes.configflow.js';
|
||||
import { createMikrotikDiscoveryDescriptor } from './mikrotik.discovery.js';
|
||||
import { MikrotikMapper } from './mikrotik.mapper.js';
|
||||
import type { IMikrotikConfig } from './mikrotik.types.js';
|
||||
import { mikrotikDomain } from './mikrotik.types.js';
|
||||
|
||||
export class HomeAssistantMikrotikIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "mikrotik",
|
||||
displayName: "Mikrotik",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/mikrotik",
|
||||
"upstreamDomain": "mikrotik",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"librouteros==3.2.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@engrbm87"
|
||||
]
|
||||
export class MikrotikIntegration extends BaseIntegration<IMikrotikConfig> {
|
||||
public readonly domain = mikrotikDomain;
|
||||
public readonly displayName = 'Mikrotik';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createMikrotikDiscoveryDescriptor();
|
||||
public readonly configFlow = new MikrotikConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/mikrotik',
|
||||
upstreamDomain: mikrotikDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['librouteros==3.2.0'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@engrbm87'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/mikrotik',
|
||||
configFlow: true,
|
||||
runtime: {
|
||||
mode: 'native TypeScript snapshot/manual RouterOS/API mapping',
|
||||
platforms: ['device_tracker', 'binary_sensor', 'sensor', 'switch', 'button'],
|
||||
services: ['refresh', 'snapshot', 'status', 'reboot', 'arp_ping', 'disconnect_client', 'enable_interface', 'disable_interface'],
|
||||
},
|
||||
});
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual Mikrotik RouterOS/API setup candidates and config flow',
|
||||
'snapshot mapping for router resources, device-tracker equivalents, interfaces, clients, traffic counters/rates, and represented interface/client/router controls',
|
||||
'safe RouterOS/API command modeling for explicitly represented reboot, ARP ping, client disconnect, and interface enablement actions',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'homeassistant_compat shims',
|
||||
'fake RouterOS/API connection or command success without commandExecutor/nativeClient injection',
|
||||
'full librouteros live protocol implementation in dependency-free TypeScript',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IMikrotikConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new MikrotikRuntime(new MikrotikClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantMikrotikIntegration extends MikrotikIntegration {}
|
||||
|
||||
class MikrotikRuntime implements IIntegrationRuntime {
|
||||
public domain = mikrotikDomain;
|
||||
|
||||
constructor(private readonly client: MikrotikClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return MikrotikMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return MikrotikMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(MikrotikMapper.toIntegrationEvent(eventArg)));
|
||||
await this.client.getSnapshot();
|
||||
return async () => unsubscribe();
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain === mikrotikDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === mikrotikDomain && requestArg.service === 'refresh') {
|
||||
return this.client.refresh();
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = MikrotikMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Mikrotik service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { MikrotikMapper } from './mikrotik.mapper.js';
|
||||
import type { IMikrotikManualDiscoveryRecord, IMikrotikSnapshot } from './mikrotik.types.js';
|
||||
import { mikrotikDefaultApiPort, mikrotikDomain, mikrotikManufacturer } from './mikrotik.types.js';
|
||||
|
||||
const mikrotikTextHints = ['mikrotik', 'routeros', 'routerboard', 'router os', 'crs', 'ccr', 'hex', 'hap', 'cap ac', 'cap ax'];
|
||||
|
||||
export class MikrotikManualMatcher implements IDiscoveryMatcher<IMikrotikManualDiscoveryRecord> {
|
||||
public id = 'mikrotik-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Mikrotik RouterOS/API setup entries, including snapshot-only records.';
|
||||
|
||||
public async matches(inputArg: IMikrotikManualDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const snapshot = inputArg.snapshot || metadata.snapshot as IMikrotikSnapshot | undefined;
|
||||
const host = inputArg.host || snapshot?.router.host;
|
||||
const verifySsl = this.booleanValue(inputArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? snapshot?.router.verifySsl ?? false;
|
||||
const mac = MikrotikMapper.normalizeMac(inputArg.macAddress || snapshot?.router.macAddress);
|
||||
const text = this.text(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.protocol, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.boardName, snapshot?.router.name);
|
||||
const hasSnapshot = Boolean(snapshot);
|
||||
const matched = inputArg.integrationDomain === mikrotikDomain
|
||||
|| metadata.mikrotik === true
|
||||
|| metadata.routeros === true
|
||||
|| metadata.routerOs === true
|
||||
|| hasSnapshot
|
||||
|| mikrotikTextHints.some((hintArg) => text.includes(hintArg))
|
||||
|| Boolean(host && !text);
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Mikrotik RouterOS/API setup hints.' };
|
||||
}
|
||||
|
||||
const port = inputArg.port || snapshot?.router.port || mikrotikDefaultApiPort;
|
||||
const id = inputArg.id || inputArg.serialNumber || snapshot?.router.serialNumber || mac || snapshot?.router.id || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: hasSnapshot || mac || inputArg.serialNumber ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: hasSnapshot ? 'Manual entry includes a Mikrotik snapshot.' : 'Manual entry can start Mikrotik RouterOS/API setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: mikrotikDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.router.name || snapshot?.router.identity || host || 'Mikrotik',
|
||||
manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || mikrotikManufacturer,
|
||||
model: inputArg.model || snapshot?.router.model || snapshot?.router.boardName || 'RouterOS device',
|
||||
serialNumber: inputArg.serialNumber || snapshot?.router.serialNumber,
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...metadata,
|
||||
mikrotik: true,
|
||||
routeros: true,
|
||||
protocol: MikrotikMapper.protocol(verifySsl),
|
||||
verifySsl,
|
||||
hasSnapshot,
|
||||
upstreamPlatforms: ['device_tracker'],
|
||||
liveRouterOsApiImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { hasSnapshot, verifySsl, protocol: MikrotikMapper.protocol(verifySsl), liveRouterOsApiImplemented: false },
|
||||
};
|
||||
}
|
||||
|
||||
private text(...valuesArg: unknown[]): string {
|
||||
return valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export class MikrotikCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'mikrotik-candidate-validator';
|
||||
public description = 'Validate Mikrotik candidates have a host or snapshot and RouterOS identity metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = metadata.snapshot as IMikrotikSnapshot | undefined;
|
||||
const mac = MikrotikMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.macAddress);
|
||||
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.protocol, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.boardName]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === mikrotikDomain
|
||||
|| metadata.mikrotik === true
|
||||
|| metadata.routeros === true
|
||||
|| metadata.routerOs === true
|
||||
|| Boolean(snapshot)
|
||||
|| mikrotikTextHints.some((hintArg) => text.includes(hintArg));
|
||||
const hasUsableSource = Boolean(candidateArg.host || snapshot);
|
||||
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Mikrotik candidate lacks host or snapshot information.' : 'Candidate is not Mikrotik RouterOS/API.',
|
||||
};
|
||||
}
|
||||
|
||||
const verifySsl = this.booleanValue(metadata.verifySsl) ?? snapshot?.router.verifySsl ?? false;
|
||||
const port = candidateArg.port || snapshot?.router.port || mikrotikDefaultApiPort;
|
||||
const normalizedDeviceId = candidateArg.id || snapshot?.router.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.router.id);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: mac || snapshot ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has Mikrotik metadata and a usable local RouterOS/API source.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
id: candidateArg.id || normalizedDeviceId,
|
||||
port,
|
||||
macAddress: mac || candidateArg.macAddress,
|
||||
metadata: {
|
||||
...metadata,
|
||||
mikrotik: true,
|
||||
routeros: true,
|
||||
protocol: MikrotikMapper.protocol(verifySsl),
|
||||
verifySsl,
|
||||
upstreamPlatforms: ['device_tracker'],
|
||||
liveRouterOsApiImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { verifySsl, protocol: MikrotikMapper.protocol(verifySsl), liveRouterOsApiImplemented: false },
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export const createMikrotikDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: mikrotikDomain, displayName: 'Mikrotik' })
|
||||
.addMatcher(new MikrotikManualMatcher())
|
||||
.addValidator(new MikrotikCandidateValidator());
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,346 @@
|
||||
export interface IHomeAssistantMikrotikConfig {
|
||||
// TODO: replace with the TypeScript-native config for mikrotik.
|
||||
import type { IServiceCallResult } from '../../core/types.js';
|
||||
|
||||
export const mikrotikDomain = 'mikrotik';
|
||||
export const mikrotikDefaultApiPort = 8728;
|
||||
export const mikrotikDefaultDetectionTime = 300;
|
||||
export const mikrotikManufacturer = 'Mikrotik';
|
||||
|
||||
export type TMikrotikProtocol = 'routeros-api' | 'routeros-api-ssl';
|
||||
export type TMikrotikSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
|
||||
export type TMikrotikActionTarget = 'router' | 'client' | 'interface';
|
||||
export type TMikrotikRouterAction = 'reboot';
|
||||
export type TMikrotikClientAction = 'arp_ping' | 'disconnect';
|
||||
export type TMikrotikInterfaceAction = 'set_enabled';
|
||||
export type TMikrotikAction = TMikrotikRouterAction | TMikrotikClientAction | TMikrotikInterfaceAction;
|
||||
export type TMikrotikCommandType = 'router.action' | 'client.action' | 'interface.set';
|
||||
export type TMikrotikApiPath =
|
||||
| '/system/reboot'
|
||||
| '/ping'
|
||||
| '/interface/set'
|
||||
| '/interface/wireless/registration-table/remove'
|
||||
| '/caps-man/registration-table/remove'
|
||||
| '/interface/wifiwave2/registration-table/remove'
|
||||
| '/interface/wifi/registration-table/remove';
|
||||
|
||||
export interface IMikrotikConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
verifySsl?: boolean;
|
||||
protocol?: TMikrotikProtocol;
|
||||
arpPing?: boolean;
|
||||
forceDhcp?: boolean;
|
||||
detectionTime?: number;
|
||||
connected?: boolean;
|
||||
uniqueId?: string;
|
||||
name?: string;
|
||||
snapshot?: IMikrotikSnapshot;
|
||||
router?: IMikrotikRouterInfo;
|
||||
resources?: IMikrotikResourceInfo;
|
||||
traffic?: IMikrotikTrafficStats;
|
||||
devices?: IMikrotikClientDevice[];
|
||||
clients?: IMikrotikClientDevice[];
|
||||
interfaces?: IMikrotikInterfaceStats[];
|
||||
sensors?: IMikrotikSensorMap;
|
||||
actions?: IMikrotikActionDescriptor[];
|
||||
manualEntries?: IMikrotikManualEntry[];
|
||||
events?: IMikrotikEvent[];
|
||||
snapshotProvider?: TMikrotikSnapshotProvider;
|
||||
commandExecutor?: TMikrotikCommandExecutor;
|
||||
nativeClient?: IMikrotikNativeClient;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantMikrotikConfig extends IMikrotikConfig {}
|
||||
|
||||
export interface IMikrotikRouterInfo {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
identity?: string;
|
||||
model?: string;
|
||||
boardName?: string;
|
||||
serialNumber?: string;
|
||||
firmware?: string;
|
||||
currentFirmware?: string;
|
||||
factoryFirmware?: string;
|
||||
upgradeFirmware?: string;
|
||||
routerOsVersion?: string;
|
||||
architectureName?: string;
|
||||
macAddress?: string;
|
||||
configurationUrl?: string;
|
||||
manufacturer?: string;
|
||||
verifySsl?: boolean;
|
||||
protocol?: TMikrotikProtocol;
|
||||
supportsCapsman?: boolean;
|
||||
supportsWireless?: boolean;
|
||||
supportsWifiwave2?: boolean;
|
||||
supportsWifi?: boolean;
|
||||
actions?: TMikrotikRouterAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikResourceInfo {
|
||||
uptime?: string | number | Date;
|
||||
version?: string;
|
||||
routerOsVersion?: string;
|
||||
buildTime?: string;
|
||||
factorySoftware?: string;
|
||||
freeMemory?: number;
|
||||
totalMemory?: number;
|
||||
usedMemory?: number;
|
||||
memoryUsagePercent?: number;
|
||||
cpu?: string;
|
||||
cpuCount?: number;
|
||||
cpuFrequency?: number;
|
||||
cpuLoad?: number;
|
||||
freeHddSpace?: number;
|
||||
totalHddSpace?: number;
|
||||
badBlocks?: number;
|
||||
architectureName?: string;
|
||||
boardName?: string;
|
||||
platform?: string;
|
||||
temperature?: number;
|
||||
voltage?: number;
|
||||
current?: number;
|
||||
fanSpeed?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikTrafficStats {
|
||||
rxBytes?: number;
|
||||
txBytes?: number;
|
||||
rxRateMbps?: number;
|
||||
txRateMbps?: number;
|
||||
rxBitsPerSecond?: number;
|
||||
txBitsPerSecond?: number;
|
||||
rxRate?: number;
|
||||
txRate?: number;
|
||||
downloadBytes?: number;
|
||||
uploadBytes?: number;
|
||||
downloadRateMbps?: number;
|
||||
uploadRateMbps?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikClientDevice {
|
||||
id?: string;
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
hostName?: string;
|
||||
ip?: string;
|
||||
ipAddress?: string;
|
||||
address?: string;
|
||||
activeAddress?: string;
|
||||
connected?: boolean;
|
||||
connectedTo?: string;
|
||||
interface?: string;
|
||||
ssid?: string;
|
||||
comment?: string;
|
||||
signalStrength?: number | string;
|
||||
signalToNoise?: number | string;
|
||||
rxRate?: number | string;
|
||||
txRate?: number | string;
|
||||
uptime?: string | number | Date;
|
||||
lastSeen?: string | number | Date;
|
||||
lastActivity?: string | number | Date;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
source?: 'dhcp' | 'arp' | 'wireless' | 'capsman' | 'wifiwave2' | 'wifi' | 'manual' | string;
|
||||
actions?: TMikrotikClientAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
'.id'?: string;
|
||||
'mac-address'?: string;
|
||||
'host-name'?: string;
|
||||
'active-address'?: string;
|
||||
'last-seen'?: string | number | Date;
|
||||
'signal-strength'?: number | string;
|
||||
'signal-to-noise'?: number | string;
|
||||
'rx-rate'?: number | string;
|
||||
'tx-rate'?: number | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikInterfaceStats {
|
||||
id?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
connected?: boolean;
|
||||
running?: boolean;
|
||||
enabled?: boolean;
|
||||
disabled?: boolean;
|
||||
macAddress?: string;
|
||||
ipAddress?: string;
|
||||
ssid?: string;
|
||||
comment?: string;
|
||||
mtu?: number;
|
||||
actualMtu?: number;
|
||||
rxBytes?: number;
|
||||
txBytes?: number;
|
||||
rxRate?: number;
|
||||
txRate?: number;
|
||||
rxBitsPerSecond?: number;
|
||||
txBitsPerSecond?: number;
|
||||
downloadBytes?: number;
|
||||
uploadBytes?: number;
|
||||
rxPackets?: number;
|
||||
txPackets?: number;
|
||||
rxDrops?: number;
|
||||
txDrops?: number;
|
||||
rxErrors?: number;
|
||||
txErrors?: number;
|
||||
lastLinkUpTime?: string | number | Date;
|
||||
actions?: TMikrotikInterfaceAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
'.id'?: string;
|
||||
'mac-address'?: string;
|
||||
'actual-mtu'?: number;
|
||||
'rx-byte'?: number;
|
||||
'tx-byte'?: number;
|
||||
'rx-packet'?: number;
|
||||
'tx-packet'?: number;
|
||||
'rx-drop'?: number;
|
||||
'tx-drop'?: number;
|
||||
'rx-error'?: number;
|
||||
'tx-error'?: number;
|
||||
'rx-bits-per-second'?: number;
|
||||
'tx-bits-per-second'?: number;
|
||||
'last-link-up-time'?: string | number | Date;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikSensorMap {
|
||||
connected_clients?: number;
|
||||
cpu_load?: number;
|
||||
cpu_count?: number;
|
||||
cpu_frequency?: number;
|
||||
memory_free?: number;
|
||||
memory_total?: number;
|
||||
memory_used?: number;
|
||||
memory_usage_percent?: number;
|
||||
hdd_free?: number;
|
||||
hdd_total?: number;
|
||||
bad_blocks?: number;
|
||||
uptime?: string | number | Date;
|
||||
routeros_version?: string;
|
||||
firmware?: string;
|
||||
rx_bytes?: number;
|
||||
tx_bytes?: number;
|
||||
rx_rate?: number;
|
||||
tx_rate?: number;
|
||||
temperature?: number;
|
||||
voltage?: number;
|
||||
current?: number;
|
||||
fan_speed?: number;
|
||||
[key: string]: string | number | boolean | Date | null | undefined;
|
||||
}
|
||||
|
||||
export interface IMikrotikActionDescriptor {
|
||||
target: TMikrotikActionTarget;
|
||||
action: TMikrotikAction;
|
||||
command?: TMikrotikApiPath;
|
||||
params?: Record<string, unknown>;
|
||||
mac?: string;
|
||||
id?: string | number;
|
||||
interfaceName?: string;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
label?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMikrotikSnapshot {
|
||||
connected: boolean;
|
||||
source?: TMikrotikSnapshotSource;
|
||||
updatedAt?: string;
|
||||
router: IMikrotikRouterInfo;
|
||||
resources: IMikrotikResourceInfo;
|
||||
devices: IMikrotikClientDevice[];
|
||||
interfaces: IMikrotikInterfaceStats[];
|
||||
sensors: IMikrotikSensorMap;
|
||||
traffic?: IMikrotikTrafficStats;
|
||||
actions?: IMikrotikActionDescriptor[];
|
||||
events?: IMikrotikEvent[];
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMikrotikManualEntry {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
verifySsl?: boolean;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
router?: IMikrotikRouterInfo;
|
||||
resources?: IMikrotikResourceInfo;
|
||||
traffic?: IMikrotikTrafficStats;
|
||||
devices?: IMikrotikClientDevice[];
|
||||
clients?: IMikrotikClientDevice[];
|
||||
interfaces?: IMikrotikInterfaceStats[];
|
||||
sensors?: IMikrotikSensorMap;
|
||||
actions?: IMikrotikActionDescriptor[];
|
||||
snapshot?: IMikrotikSnapshot;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikManualDiscoveryRecord extends IMikrotikManualEntry {
|
||||
integrationDomain?: string;
|
||||
}
|
||||
|
||||
export interface IMikrotikCommand {
|
||||
type: TMikrotikCommandType;
|
||||
service: string;
|
||||
action: TMikrotikAction;
|
||||
path: TMikrotikApiPath;
|
||||
params: Record<string, unknown>;
|
||||
target: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
routerId?: string;
|
||||
mac?: string;
|
||||
interfaceId?: string | number;
|
||||
interfaceName?: string;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMikrotikCommandResult extends IServiceCallResult {}
|
||||
|
||||
export interface IMikrotikEvent {
|
||||
type: string;
|
||||
timestamp?: number;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
command?: IMikrotikCommand;
|
||||
snapshot?: IMikrotikSnapshot;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMikrotikNativeClient {
|
||||
getSnapshot(): Promise<IMikrotikSnapshot> | IMikrotikSnapshot;
|
||||
executeCommand?(commandArg: IMikrotikCommand): Promise<IMikrotikCommandResult | unknown> | IMikrotikCommandResult | unknown;
|
||||
destroy?(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export type TMikrotikSnapshotProvider = () => Promise<IMikrotikSnapshot | undefined> | IMikrotikSnapshot | undefined;
|
||||
export type TMikrotikCommandExecutor = (
|
||||
commandArg: IMikrotikCommand
|
||||
) => Promise<IMikrotikCommandResult | unknown> | IMikrotikCommandResult | unknown;
|
||||
|
||||
@@ -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 './motioneye.classes.client.js';
|
||||
export * from './motioneye.classes.configflow.js';
|
||||
export * from './motioneye.classes.integration.js';
|
||||
export * from './motioneye.discovery.js';
|
||||
export * from './motioneye.mapper.js';
|
||||
export * from './motioneye.types.js';
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
IMotionEyeCamera,
|
||||
IMotionEyeClientCommand,
|
||||
IMotionEyeCommandResponse,
|
||||
IMotionEyeConfig,
|
||||
IMotionEyeDeviceInfo,
|
||||
IMotionEyeRawCamera,
|
||||
IMotionEyeSensor,
|
||||
IMotionEyeSnapshot,
|
||||
IMotionEyeSnapshotImage,
|
||||
IMotionEyeSwitch,
|
||||
TMotionEyeMediaKind,
|
||||
TMotionEyeProtocol,
|
||||
} from './motioneye.types.js';
|
||||
import {
|
||||
motionEyeDefaultAdminUsername,
|
||||
motionEyeDefaultPort,
|
||||
motionEyeDefaultSurveillanceUsername,
|
||||
motionEyeDefaultTimeoutMs,
|
||||
motionEyeSwitchDescriptions,
|
||||
} from './motioneye.types.js';
|
||||
|
||||
const signatureRegex = /[^a-zA-Z0-9/?_.=&{}\[\]":, -]/g;
|
||||
|
||||
export class MotionEyeHttpError extends Error {
|
||||
constructor(public readonly status: number, messageArg: string) {
|
||||
super(messageArg);
|
||||
this.name = 'MotionEyeHttpError';
|
||||
}
|
||||
}
|
||||
|
||||
export class MotionEyeClient {
|
||||
private snapshot?: IMotionEyeSnapshot;
|
||||
|
||||
constructor(private readonly config: IMotionEyeConfig) {}
|
||||
|
||||
public async getSnapshot(forceRefreshArg = false): Promise<IMotionEyeSnapshot> {
|
||||
if (!forceRefreshArg && this.snapshot) {
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
if (!forceRefreshArg && this.config.snapshot) {
|
||||
this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
if (this.hasLiveTarget()) {
|
||||
try {
|
||||
this.snapshot = await this.fetchLiveSnapshot();
|
||||
return this.snapshot;
|
||||
} catch (errorArg) {
|
||||
this.snapshot = this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg));
|
||||
return this.snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
this.snapshot = this.snapshotFromConfig(this.config.connected ?? false);
|
||||
return this.snapshot;
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<void> {
|
||||
await this.requestJson('/login');
|
||||
}
|
||||
|
||||
public async execute(commandArg: IMotionEyeClientCommand): Promise<unknown> {
|
||||
if (commandArg.type === 'refresh') {
|
||||
return this.getSnapshot(true);
|
||||
}
|
||||
if (commandArg.type === 'stream_source') {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const camera = this.findCamera(snapshot, commandArg.cameraId);
|
||||
return {
|
||||
cameraId: camera.id,
|
||||
numericId: camera.numericId,
|
||||
streamSource: camera.mjpegUrl,
|
||||
mjpegUrl: camera.mjpegUrl,
|
||||
stillImageUrl: camera.snapshotUrl,
|
||||
snapshotUrl: camera.snapshotUrl,
|
||||
streamingAuthMode: camera.streamingAuthMode,
|
||||
verified: false,
|
||||
};
|
||||
}
|
||||
if (commandArg.type === 'snapshot_image') {
|
||||
if (commandArg.filename) {
|
||||
throw new Error('motionEye snapshot file writes are not implemented; request data as base64 without data.filename.');
|
||||
}
|
||||
const image = await this.getSnapshotImage(commandArg.cameraId);
|
||||
return {
|
||||
contentType: image.contentType,
|
||||
dataBase64: Buffer.from(image.data).toString('base64'),
|
||||
};
|
||||
}
|
||||
if (commandArg.type === 'action') {
|
||||
if (!commandArg.action) {
|
||||
throw new Error('motionEye action command requires a non-empty action.');
|
||||
}
|
||||
const response = await this.action(commandArg.cameraId, commandArg.action);
|
||||
return { ok: true, command: commandArg.type, action: commandArg.action, response };
|
||||
}
|
||||
if (commandArg.type === 'set_switch') {
|
||||
if (!commandArg.key || typeof commandArg.enabled !== 'boolean') {
|
||||
throw new Error('motionEye set_switch requires key and boolean enabled values.');
|
||||
}
|
||||
const response = await this.setCameraValue(commandArg.cameraId, commandArg.key, commandArg.enabled);
|
||||
this.patchCachedSwitch(commandArg.cameraId, commandArg.key, commandArg.enabled);
|
||||
return { ok: true, command: commandArg.type, key: commandArg.key, enabled: commandArg.enabled, response };
|
||||
}
|
||||
if (commandArg.type === 'set_text_overlay') {
|
||||
const response = await this.setTextOverlay(commandArg);
|
||||
return { ok: true, command: commandArg.type, response };
|
||||
}
|
||||
if (commandArg.type === 'media_list') {
|
||||
const kind = commandArg.mediaKind || 'images';
|
||||
return this.getMediaList(commandArg.cameraId, kind, commandArg.prefix);
|
||||
}
|
||||
throw new Error(`Unsupported motionEye command: ${commandArg.type}`);
|
||||
}
|
||||
|
||||
public async getSnapshotImage(cameraIdArg?: string): Promise<IMotionEyeSnapshotImage> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const camera = this.findCamera(snapshot, cameraIdArg);
|
||||
if (camera.numericId === undefined) {
|
||||
throw new Error('motionEye snapshot image requires a numeric camera id.');
|
||||
}
|
||||
const response = await this.requestResponse(`/picture/${camera.numericId}/current/`, { admin: false });
|
||||
return {
|
||||
contentType: response.headers.get('content-type') || 'image/jpeg',
|
||||
data: new Uint8Array(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraStreamUrl(cameraArg: IMotionEyeRawCamera): string | undefined {
|
||||
if (!this.isCameraStreaming(cameraArg)) {
|
||||
return undefined;
|
||||
}
|
||||
const endpoint = this.endpoint();
|
||||
const host = stringValue(cameraArg.host) || endpoint.host;
|
||||
const port = numberValue(cameraArg.streaming_port);
|
||||
if (!host || port === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return `http://${host}:${port}/`;
|
||||
}
|
||||
|
||||
public getCameraSnapshotUrl(cameraArg: IMotionEyeRawCamera): string | undefined {
|
||||
const cameraId = numberValue(cameraArg.id);
|
||||
if (!this.isCameraStreaming(cameraArg) || cameraId === undefined || !this.hasLiveTarget()) {
|
||||
return undefined;
|
||||
}
|
||||
return this.buildSignedUrl(`/picture/${cameraId}/current/`, undefined, undefined, 'GET', false);
|
||||
}
|
||||
|
||||
public getMovieUrl(cameraIdArg: number, pathArg: string, previewArg = false): string | undefined {
|
||||
if (!this.hasLiveTarget()) {
|
||||
return undefined;
|
||||
}
|
||||
return this.buildSignedUrl(`/movie/${cameraIdArg}/${previewArg ? 'preview' : 'playback'}/${this.stripLeadingSlash(pathArg)}`, undefined, undefined, 'GET', false);
|
||||
}
|
||||
|
||||
public getImageUrl(cameraIdArg: number, pathArg: string, previewArg = false): string | undefined {
|
||||
if (!this.hasLiveTarget()) {
|
||||
return undefined;
|
||||
}
|
||||
return this.buildSignedUrl(`/picture/${cameraIdArg}/${previewArg ? 'preview' : 'download'}/${this.stripLeadingSlash(pathArg)}`, undefined, undefined, 'GET', false);
|
||||
}
|
||||
|
||||
public isCameraStreaming(cameraArg: IMotionEyeRawCamera | undefined): boolean {
|
||||
return Boolean(cameraArg && numberValue(cameraArg.streaming_port) !== undefined && cameraArg.video_streaming === true);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchLiveSnapshot(): Promise<IMotionEyeSnapshot> {
|
||||
await this.requestJson('/login');
|
||||
const camerasResponse = await this.requestJson('/config/list');
|
||||
const [manifest, serverConfig] = await Promise.all([
|
||||
this.requestJson('/manifest.json').then((responseArg) => responseArg.data).catch(() => undefined),
|
||||
this.requestJson('/config/main/get').then((responseArg) => responseArg.data).catch(() => undefined),
|
||||
]);
|
||||
const rawCameras = this.rawCamerasFromResponse(camerasResponse.data);
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo: this.deviceInfo(true),
|
||||
cameras: this.camerasFromRaw(rawCameras, true),
|
||||
sensors: [],
|
||||
switches: [],
|
||||
rawCameras,
|
||||
connected: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
manifest: record(manifest),
|
||||
serverConfig: record(serverConfig),
|
||||
});
|
||||
}
|
||||
|
||||
private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IMotionEyeSnapshot {
|
||||
const rawCameras = this.config.rawCameras || this.config.snapshot?.rawCameras || this.rawCamerasFromConfiguredCameras(this.config.cameras || this.config.snapshot?.cameras || []);
|
||||
const cameras = this.config.cameras || this.config.snapshot?.cameras || this.camerasFromRaw(rawCameras, connectedArg);
|
||||
return this.normalizeSnapshot({
|
||||
deviceInfo: this.deviceInfo(connectedArg),
|
||||
cameras,
|
||||
sensors: this.config.sensors || this.config.snapshot?.sensors || [],
|
||||
switches: this.config.switches || this.config.snapshot?.switches || [],
|
||||
rawCameras,
|
||||
connected: connectedArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
manifest: this.config.manifest || this.config.snapshot?.manifest,
|
||||
serverConfig: this.config.serverConfig || this.config.snapshot?.serverConfig,
|
||||
metadata: {
|
||||
...this.config.snapshot?.metadata,
|
||||
lastLiveError: lastErrorArg,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: IMotionEyeSnapshot): IMotionEyeSnapshot {
|
||||
const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false);
|
||||
const deviceInfo = {
|
||||
...this.deviceInfo(connected),
|
||||
...snapshotArg.deviceInfo,
|
||||
online: connected,
|
||||
};
|
||||
const cameras = (snapshotArg.cameras || []).map((cameraArg) => this.normalizeCamera(cameraArg, connected));
|
||||
const sensors = snapshotArg.sensors.length ? snapshotArg.sensors : this.sensorsFromCameras(cameras, connected);
|
||||
const switches = snapshotArg.switches.length ? snapshotArg.switches : this.switchesFromCameras(cameras, connected);
|
||||
return {
|
||||
...snapshotArg,
|
||||
deviceInfo,
|
||||
cameras,
|
||||
sensors,
|
||||
switches,
|
||||
rawCameras: snapshotArg.rawCameras || [],
|
||||
connected,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCamera(cameraArg: IMotionEyeCamera, connectedArg: boolean): IMotionEyeCamera {
|
||||
const raw = cameraArg.raw;
|
||||
const numericId = cameraArg.numericId ?? numberValue(raw?.id) ?? numberValue(cameraArg.id);
|
||||
const rawStreamUrl = raw ? this.getCameraStreamUrl(raw) : undefined;
|
||||
const rawSnapshotUrl = raw ? this.getCameraSnapshotUrl(raw) : undefined;
|
||||
return {
|
||||
...cameraArg,
|
||||
id: String(cameraArg.id || numericId),
|
||||
numericId,
|
||||
name: cameraArg.name || `Camera ${cameraArg.id || numericId}`,
|
||||
streamingPort: cameraArg.streamingPort ?? numberValue(raw?.streaming_port),
|
||||
streamingAuthMode: cameraArg.streamingAuthMode ?? raw?.streaming_auth_mode,
|
||||
mjpegUrl: cameraArg.mjpegUrl || this.renderStreamUrlTemplate(raw) || rawStreamUrl,
|
||||
snapshotUrl: cameraArg.snapshotUrl || rawSnapshotUrl,
|
||||
isStreaming: cameraArg.isStreaming ?? Boolean(raw && this.isCameraStreaming(raw)),
|
||||
motionDetectionEnabled: cameraArg.motionDetectionEnabled ?? Boolean(raw?.motion_detection),
|
||||
actions: cameraArg.actions || this.stringList(raw?.actions),
|
||||
available: connectedArg && cameraArg.available !== false && cameraArg.isStreaming !== false,
|
||||
};
|
||||
}
|
||||
|
||||
private camerasFromRaw(rawCamerasArg: IMotionEyeRawCamera[], connectedArg: boolean): IMotionEyeCamera[] {
|
||||
return rawCamerasArg
|
||||
.filter((cameraArg) => cameraArg.id !== undefined && cameraArg.name !== undefined)
|
||||
.map((cameraArg) => {
|
||||
const numericId = numberValue(cameraArg.id);
|
||||
const id = String(cameraArg.id);
|
||||
return this.normalizeCamera({
|
||||
id,
|
||||
numericId,
|
||||
name: stringValue(cameraArg.name) || `Camera ${id}`,
|
||||
host: stringValue(cameraArg.host),
|
||||
streamingPort: numberValue(cameraArg.streaming_port),
|
||||
streamingAuthMode: cameraArg.streaming_auth_mode,
|
||||
mjpegUrl: this.renderStreamUrlTemplate(cameraArg) || this.getCameraStreamUrl(cameraArg),
|
||||
snapshotUrl: this.getCameraSnapshotUrl(cameraArg),
|
||||
isStreaming: this.isCameraStreaming(cameraArg),
|
||||
motionDetectionEnabled: Boolean(cameraArg.motion_detection),
|
||||
textOverlayEnabled: booleanValue(cameraArg.text_overlay),
|
||||
stillImagesEnabled: booleanValue(cameraArg.still_images),
|
||||
moviesEnabled: booleanValue(cameraArg.movies),
|
||||
uploadEnabled: booleanValue(cameraArg.upload_enabled),
|
||||
actions: this.stringList(cameraArg.actions),
|
||||
rootDirectory: stringValue(cameraArg.root_directory),
|
||||
raw: cameraArg,
|
||||
available: connectedArg,
|
||||
}, connectedArg);
|
||||
});
|
||||
}
|
||||
|
||||
private rawCamerasFromResponse(valueArg: unknown): IMotionEyeRawCamera[] {
|
||||
const cameras = record(valueArg)?.cameras;
|
||||
return Array.isArray(cameras) ? cameras.filter(record).map((cameraArg) => cameraArg as IMotionEyeRawCamera) : [];
|
||||
}
|
||||
|
||||
private rawCamerasFromConfiguredCameras(camerasArg: IMotionEyeCamera[]): IMotionEyeRawCamera[] {
|
||||
return camerasArg.map((cameraArg) => ({
|
||||
id: cameraArg.numericId ?? cameraArg.id,
|
||||
name: cameraArg.name,
|
||||
host: cameraArg.host,
|
||||
streaming_port: cameraArg.streamingPort,
|
||||
streaming_auth_mode: cameraArg.streamingAuthMode,
|
||||
video_streaming: cameraArg.isStreaming,
|
||||
motion_detection: cameraArg.motionDetectionEnabled,
|
||||
text_overlay: cameraArg.textOverlayEnabled,
|
||||
still_images: cameraArg.stillImagesEnabled,
|
||||
movies: cameraArg.moviesEnabled,
|
||||
upload_enabled: cameraArg.uploadEnabled,
|
||||
actions: cameraArg.actions,
|
||||
root_directory: cameraArg.rootDirectory,
|
||||
...cameraArg.raw,
|
||||
}));
|
||||
}
|
||||
|
||||
private sensorsFromCameras(camerasArg: IMotionEyeCamera[], connectedArg: boolean): IMotionEyeSensor[] {
|
||||
return camerasArg.map((cameraArg) => ({
|
||||
key: 'actions',
|
||||
name: `${cameraArg.name} Actions`,
|
||||
cameraId: cameraArg.id,
|
||||
value: cameraArg.actions.length,
|
||||
entityCategory: 'diagnostic',
|
||||
available: connectedArg,
|
||||
attributes: cameraArg.actions.length ? { actions: cameraArg.actions } : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private switchesFromCameras(camerasArg: IMotionEyeCamera[], connectedArg: boolean): IMotionEyeSwitch[] {
|
||||
const valuesByKey = (cameraArg: IMotionEyeCamera): Record<string, boolean | undefined> => ({
|
||||
motion_detection: cameraArg.motionDetectionEnabled,
|
||||
text_overlay: cameraArg.textOverlayEnabled,
|
||||
video_streaming: cameraArg.isStreaming,
|
||||
still_images: cameraArg.stillImagesEnabled,
|
||||
movies: cameraArg.moviesEnabled,
|
||||
upload_enabled: cameraArg.uploadEnabled,
|
||||
});
|
||||
return camerasArg.flatMap((cameraArg) => {
|
||||
const values = valuesByKey(cameraArg);
|
||||
return motionEyeSwitchDescriptions.map((descriptionArg) => ({
|
||||
key: descriptionArg.key,
|
||||
name: `${cameraArg.name} ${descriptionArg.name}`,
|
||||
cameraId: cameraArg.id,
|
||||
isOn: Boolean(values[descriptionArg.key]),
|
||||
entityCategory: descriptionArg.entityCategory,
|
||||
available: connectedArg,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private async action(cameraIdArg: string | undefined, actionArg: string): Promise<IMotionEyeCommandResponse> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const camera = this.findCamera(snapshot, cameraIdArg);
|
||||
if (camera.numericId === undefined) {
|
||||
throw new Error('motionEye action requires a numeric camera id.');
|
||||
}
|
||||
return this.requestCommand(`action:${actionArg}`, 'POST', `/action/${camera.numericId}/${encodeURIComponent(actionArg)}`, {}, true);
|
||||
}
|
||||
|
||||
private async setCameraValue(cameraIdArg: string | undefined, keyArg: string, valueArg: unknown): Promise<IMotionEyeCommandResponse> {
|
||||
const camera = await this.getLatestRawCamera(cameraIdArg);
|
||||
camera[keyArg] = valueArg;
|
||||
return this.setRawCamera(camera, keyArg);
|
||||
}
|
||||
|
||||
private async setTextOverlay(commandArg: IMotionEyeClientCommand): Promise<IMotionEyeCommandResponse> {
|
||||
const camera = await this.getLatestRawCamera(commandArg.cameraId);
|
||||
if (commandArg.leftText !== undefined) {
|
||||
camera.left_text = commandArg.leftText;
|
||||
}
|
||||
if (commandArg.rightText !== undefined) {
|
||||
camera.right_text = commandArg.rightText;
|
||||
}
|
||||
if (commandArg.customLeftText !== undefined) {
|
||||
camera.custom_left_text = unicodeEscape(commandArg.customLeftText);
|
||||
}
|
||||
if (commandArg.customRightText !== undefined) {
|
||||
camera.custom_right_text = unicodeEscape(commandArg.customRightText);
|
||||
}
|
||||
return this.setRawCamera(camera, 'set_text_overlay');
|
||||
}
|
||||
|
||||
private async getLatestRawCamera(cameraIdArg: string | undefined): Promise<IMotionEyeRawCamera> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const camera = this.findCamera(snapshot, cameraIdArg);
|
||||
if (camera.numericId === undefined) {
|
||||
throw new Error('motionEye camera configuration updates require a numeric camera id.');
|
||||
}
|
||||
const response = await this.requestJson(`/config/${camera.numericId}/get`);
|
||||
const raw = record(response.data) as IMotionEyeRawCamera | undefined;
|
||||
if (!raw) {
|
||||
throw new Error(`motionEye camera ${camera.numericId} config response was empty.`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private async setRawCamera(cameraArg: IMotionEyeRawCamera, labelArg: string): Promise<IMotionEyeCommandResponse> {
|
||||
const cameraId = numberValue(cameraArg.id);
|
||||
if (cameraId === undefined) {
|
||||
throw new Error('motionEye set camera requires a numeric camera id.');
|
||||
}
|
||||
return this.requestCommand(labelArg, 'POST', `/config/${cameraId}/set`, cameraArg as Record<string, unknown>, true);
|
||||
}
|
||||
|
||||
private async getMediaList(cameraIdArg: string | undefined, kindArg: TMotionEyeMediaKind, prefixArg?: string): Promise<unknown> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const camera = this.findCamera(snapshot, cameraIdArg);
|
||||
if (camera.numericId === undefined) {
|
||||
throw new Error('motionEye media list requires a numeric camera id.');
|
||||
}
|
||||
const response = await this.requestJson(`/${kindArg === 'movies' ? 'movie' : 'picture'}/${camera.numericId}/list`, prefixArg ? { prefix: prefixArg } : undefined);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private async requestCommand(labelArg: string, methodArg: 'GET' | 'POST', pathArg: string, dataArg: Record<string, unknown> | undefined, adminArg: boolean): Promise<IMotionEyeCommandResponse> {
|
||||
const response = await this.requestJson(pathArg, undefined, dataArg, methodArg, adminArg);
|
||||
return {
|
||||
ok: true,
|
||||
label: labelArg,
|
||||
method: methodArg,
|
||||
path: pathArg,
|
||||
status: response.status,
|
||||
response: response.data,
|
||||
};
|
||||
}
|
||||
|
||||
private async requestJson(pathArg: string, paramsArg?: Record<string, unknown>, dataArg?: Record<string, unknown>, methodArg: 'GET' | 'POST' = 'GET', adminArg = true): Promise<{ status: number; data: unknown }> {
|
||||
const serializedData = dataArg === undefined ? undefined : JSON.stringify(dataArg);
|
||||
const response = await this.requestResponse(pathArg, { params: paramsArg, data: serializedData, method: methodArg, admin: adminArg });
|
||||
const text = await response.text();
|
||||
return {
|
||||
status: response.status,
|
||||
data: text.trim() ? JSON.parse(text) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async requestResponse(pathArg: string, optionsArg: { params?: Record<string, unknown>; data?: string; method?: 'GET' | 'POST'; admin?: boolean } = {}): Promise<Response> {
|
||||
const method = optionsArg.method || 'GET';
|
||||
const url = this.buildSignedUrl(pathArg, optionsArg.params, optionsArg.data, method, optionsArg.admin !== false);
|
||||
const headers = new Headers();
|
||||
if (optionsArg.data !== undefined) {
|
||||
headers.set('content-type', 'application/json');
|
||||
}
|
||||
const response = await this.fetchWithTimeout(url, { method, headers, body: method === 'GET' ? undefined : optionsArg.data });
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (response.status === 403) {
|
||||
throw new MotionEyeHttpError(response.status, 'motionEye authentication failed.');
|
||||
}
|
||||
throw new MotionEyeHttpError(response.status, `motionEye request ${pathArg} failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(urlArg: string, initArg: RequestInit): Promise<Response> {
|
||||
const abortController = new AbortController();
|
||||
const timeout = setTimeout(() => abortController.abort(), this.config.timeoutMs || motionEyeDefaultTimeoutMs);
|
||||
try {
|
||||
return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal });
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSignedUrl(pathArg: string, paramsArg: Record<string, unknown> | undefined, dataArg: string | undefined, methodArg: 'GET' | 'POST', adminArg: boolean): string {
|
||||
const baseUrl = this.baseUrl();
|
||||
if (!baseUrl) {
|
||||
throw new Error('motionEye live HTTP client requires config.url or config.host.');
|
||||
}
|
||||
const url = safeUrl(pathArg) || new URL(pathArg.startsWith('/') ? pathArg : `/${pathArg}`, `${baseUrl}/`);
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(paramsArg || {})) {
|
||||
if (value !== undefined && value !== null) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
params.set('_username', adminArg ? this.adminUsername() : this.surveillanceUsername());
|
||||
url.search = params.toString();
|
||||
const key = sha1(adminArg ? this.adminPassword() : this.surveillancePassword());
|
||||
url.searchParams.set('_signature', computeMotionEyeSignature(methodArg, url.toString(), dataArg, key));
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
private renderStreamUrlTemplate(cameraArg: IMotionEyeRawCamera | undefined): string | undefined {
|
||||
const template = this.config.streamUrlTemplate?.trim();
|
||||
if (!template || !cameraArg) {
|
||||
return undefined;
|
||||
}
|
||||
return template.replace(/{{\s*([a-zA-Z0-9_]+)\s*}}/g, (_matchArg, keyArg: string) => String(cameraArg[keyArg] ?? ''));
|
||||
}
|
||||
|
||||
private stripLeadingSlash(pathArg: string): string {
|
||||
const path = pathArg.trim();
|
||||
if (!path) {
|
||||
throw new Error('motionEye media path must not be empty.');
|
||||
}
|
||||
return path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
private patchCachedSwitch(cameraIdArg: string | undefined, keyArg: string, valueArg: boolean): void {
|
||||
if (!this.snapshot) {
|
||||
return;
|
||||
}
|
||||
const camera = this.findCamera(this.snapshot, cameraIdArg);
|
||||
const raw = this.snapshot.rawCameras.find((rawArg) => String(rawArg.id) === camera.id || String(rawArg.id) === String(camera.numericId));
|
||||
if (raw) {
|
||||
raw[keyArg] = valueArg;
|
||||
}
|
||||
const propertyByKey: Record<string, keyof IMotionEyeCamera> = {
|
||||
motion_detection: 'motionDetectionEnabled',
|
||||
text_overlay: 'textOverlayEnabled',
|
||||
video_streaming: 'isStreaming',
|
||||
still_images: 'stillImagesEnabled',
|
||||
movies: 'moviesEnabled',
|
||||
upload_enabled: 'uploadEnabled',
|
||||
};
|
||||
const property = propertyByKey[keyArg];
|
||||
if (property) {
|
||||
(camera[property] as boolean | undefined) = valueArg;
|
||||
}
|
||||
for (const switchArg of this.snapshot.switches) {
|
||||
if (switchArg.cameraId === camera.id && switchArg.key === keyArg) {
|
||||
switchArg.isOn = valueArg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findCamera(snapshotArg: IMotionEyeSnapshot, cameraIdArg?: string): IMotionEyeCamera {
|
||||
const cameraId = cameraIdArg || '';
|
||||
const camera = cameraId
|
||||
? snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.numericId) === cameraId || cameraArg.name === cameraId)
|
||||
: snapshotArg.cameras[0];
|
||||
if (!camera) {
|
||||
throw new Error('motionEye camera command requires a configured or discovered camera.');
|
||||
}
|
||||
return camera;
|
||||
}
|
||||
|
||||
private deviceInfo(connectedArg: boolean): IMotionEyeDeviceInfo {
|
||||
const endpoint = this.endpoint();
|
||||
return {
|
||||
...this.config.deviceInfo,
|
||||
id: this.config.deviceInfo?.id || this.config.uniqueId || endpoint.host || this.config.url || 'manual-motioneye',
|
||||
name: this.config.deviceInfo?.name || this.config.name || endpoint.host || this.config.url || 'motionEye',
|
||||
manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'motionEye',
|
||||
model: this.config.deviceInfo?.model || this.config.model,
|
||||
host: this.config.deviceInfo?.host || endpoint.host,
|
||||
port: this.config.deviceInfo?.port || endpoint.port,
|
||||
protocol: this.config.deviceInfo?.protocol || endpoint.protocol,
|
||||
url: this.config.deviceInfo?.url || this.baseUrl(),
|
||||
online: connectedArg,
|
||||
};
|
||||
}
|
||||
|
||||
private baseUrl(): string | undefined {
|
||||
if (this.config.url) {
|
||||
const url = safeUrl(this.config.url);
|
||||
if (url) {
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
}
|
||||
const endpoint = this.endpoint();
|
||||
if (!endpoint.host) {
|
||||
return undefined;
|
||||
}
|
||||
return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || motionEyeDefaultPort}`;
|
||||
}
|
||||
|
||||
private endpoint(): { protocol: TMotionEyeProtocol; host?: string; port: number } {
|
||||
const url = safeUrl(this.config.url || this.config.host);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
return {
|
||||
protocol,
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort,
|
||||
};
|
||||
}
|
||||
return {
|
||||
protocol: this.config.protocol || 'http',
|
||||
host: this.config.host,
|
||||
port: this.config.port || motionEyeDefaultPort,
|
||||
};
|
||||
}
|
||||
|
||||
private adminUsername(): string {
|
||||
return this.config.adminUsername || motionEyeDefaultAdminUsername;
|
||||
}
|
||||
|
||||
private adminPassword(): string {
|
||||
return this.config.adminPassword || '';
|
||||
}
|
||||
|
||||
private surveillanceUsername(): string {
|
||||
return this.config.surveillanceUsername || motionEyeDefaultSurveillanceUsername;
|
||||
}
|
||||
|
||||
private surveillancePassword(): string {
|
||||
return this.config.surveillancePassword || '';
|
||||
}
|
||||
|
||||
private hasLiveTarget(): boolean {
|
||||
return Boolean(this.baseUrl());
|
||||
}
|
||||
|
||||
private stringList(valueArg: unknown): string[] {
|
||||
return Array.isArray(valueArg) ? valueArg.map((entryArg) => stringValue(entryArg)).filter((entryArg): entryArg is string => Boolean(entryArg)) : [];
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IMotionEyeSnapshot): IMotionEyeSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IMotionEyeSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
export const computeMotionEyeSignature = (methodArg: string, pathArg: string, bodyArg: string | undefined, keyArg: string): string => {
|
||||
const url = new URL(pathArg);
|
||||
const query = [...url.searchParams.entries()].filter(([nameArg]) => nameArg !== '_signature').sort(([leftArg], [rightArg]) => leftArg.localeCompare(rightArg));
|
||||
const queryString = query.map(([nameArg, valueArg]) => `${nameArg}=${encodeURIComponent(valueArg)}`).join('&');
|
||||
const unsignedPath = `${url.pathname}${queryString ? `?${queryString}` : ''}`.replace(signatureRegex, '-');
|
||||
const key = keyArg.replace(signatureRegex, '-');
|
||||
const body = bodyArg && bodyArg.startsWith('---') ? '' : (bodyArg || '').replace(signatureRegex, '-');
|
||||
return sha1(`${methodArg}:${unsignedPath}:${body}:${key}`).toLowerCase();
|
||||
};
|
||||
|
||||
const sha1 = (valueArg: string): string => plugins.crypto.createHash('sha1').update(valueArg, 'utf8').digest('hex');
|
||||
|
||||
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const record = (valueArg: unknown): Record<string, unknown> | undefined => {
|
||||
return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||
};
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' || typeof valueArg === 'boolean' ? String(valueArg) : undefined;
|
||||
};
|
||||
|
||||
const 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;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg !== 0;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
if (['true', 'yes', 'on', '1'].includes(valueArg.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (['false', 'no', 'off', '0'].includes(valueArg.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const unicodeEscape = (valueArg: string): string => {
|
||||
return valueArg
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\r/g, '\\r')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/[^\x20-\x7e]/g, (charArg) => `\\u${charArg.charCodeAt(0).toString(16).padStart(4, '0')}`);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IMotionEyeConfig, TMotionEyeProtocol } from './motioneye.types.js';
|
||||
import { motionEyeDefaultPort, motionEyeDefaultTimeoutMs } from './motioneye.types.js';
|
||||
|
||||
export class MotionEyeConfigFlow implements IConfigFlow<IMotionEyeConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IMotionEyeConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect motionEye',
|
||||
description: 'Configure the local motionEye HTTP endpoint. Use a base URL such as http://192.168.1.20:8765 or host plus port.',
|
||||
fields: [
|
||||
{ name: 'url', label: 'Base URL', type: 'text' },
|
||||
{ name: 'host', label: 'Host', type: 'text' },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'adminUsername', label: 'Admin username', type: 'text' },
|
||||
{ name: 'adminPassword', label: 'Admin password', type: 'password' },
|
||||
{ name: 'surveillanceUsername', label: 'Surveillance username', type: 'text' },
|
||||
{ name: 'surveillancePassword', label: 'Surveillance password', type: 'password' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const urlValue = this.stringValue(valuesArg.url) || this.stringMetadata(candidateArg, 'url');
|
||||
const endpoint = this.endpoint(urlValue, this.stringValue(valuesArg.host) || candidateArg.host, this.numberValue(valuesArg.port) || candidateArg.port, this.protocolMetadata(candidateArg));
|
||||
if (!endpoint.host || !endpoint.url) {
|
||||
return { kind: 'error', error: 'motionEye requires a base URL or host.' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'motionEye configured',
|
||||
config: {
|
||||
protocol: endpoint.protocol,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
url: endpoint.url,
|
||||
adminUsername: this.stringValue(valuesArg.adminUsername) || this.stringMetadata(candidateArg, 'adminUsername'),
|
||||
adminPassword: this.stringValue(valuesArg.adminPassword) || this.stringMetadata(candidateArg, 'adminPassword'),
|
||||
surveillanceUsername: this.stringValue(valuesArg.surveillanceUsername) || this.stringMetadata(candidateArg, 'surveillanceUsername'),
|
||||
surveillancePassword: this.stringValue(valuesArg.surveillancePassword) || this.stringMetadata(candidateArg, 'surveillancePassword'),
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || endpoint.host,
|
||||
uniqueId: candidateArg.id || endpoint.host,
|
||||
manufacturer: candidateArg.manufacturer || 'motionEye',
|
||||
model: candidateArg.model,
|
||||
timeoutMs: motionEyeDefaultTimeoutMs,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TMotionEyeProtocol | undefined): { protocol: TMotionEyeProtocol; host?: string; port: number; url?: string } {
|
||||
const url = safeUrl(urlArg || hostArg);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort;
|
||||
const pathname = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
|
||||
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}${pathname}` };
|
||||
}
|
||||
const protocol = protocolArg || 'http';
|
||||
const port = portArg || motionEyeDefaultPort;
|
||||
return { protocol, host: hostArg, port, url: hostArg ? `${protocol}://${hostArg}:${port}` : undefined };
|
||||
}
|
||||
|
||||
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 stringMetadata(candidateArg: IDiscoveryCandidate, keyArg: string): string | undefined {
|
||||
const value = candidateArg.metadata?.[keyArg];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
private protocolMetadata(candidateArg: IDiscoveryCandidate): TMotionEyeProtocol | undefined {
|
||||
const protocol = candidateArg.metadata?.protocol;
|
||||
return protocol === 'http' || protocol === 'https' ? protocol : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,31 +1,78 @@
|
||||
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 { MotionEyeClient } from './motioneye.classes.client.js';
|
||||
import { MotionEyeConfigFlow } from './motioneye.classes.configflow.js';
|
||||
import { createMotionEyeDiscoveryDescriptor } from './motioneye.discovery.js';
|
||||
import { MotionEyeMapper } from './motioneye.mapper.js';
|
||||
import type { IMotionEyeConfig } from './motioneye.types.js';
|
||||
|
||||
export class HomeAssistantMotioneyeIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "motioneye",
|
||||
displayName: "motionEye",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/motioneye",
|
||||
"upstreamDomain": "motioneye",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"motioneye-client==0.3.14"
|
||||
],
|
||||
"dependencies": [
|
||||
"http",
|
||||
"webhook"
|
||||
],
|
||||
"afterDependencies": [
|
||||
"media_source"
|
||||
],
|
||||
"codeowners": [
|
||||
"@dermotduffy"
|
||||
]
|
||||
export class MotionEyeIntegration extends BaseIntegration<IMotionEyeConfig> {
|
||||
public readonly domain = 'motioneye';
|
||||
public readonly displayName = 'motionEye';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createMotionEyeDiscoveryDescriptor();
|
||||
public readonly configFlow = new MotionEyeConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/motioneye',
|
||||
upstreamDomain: 'motioneye',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['motioneye-client==0.3.14'],
|
||||
dependencies: ['http', 'webhook'],
|
||||
afterDependencies: ['media_source'],
|
||||
codeowners: ['@dermotduffy'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/motioneye',
|
||||
nativePort: {
|
||||
snapshotMapping: true,
|
||||
manualUrlDiscovery: true,
|
||||
liveHttpCommands: true,
|
||||
liveEvents: false,
|
||||
homeAssistantCompat: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
public async setup(configArg: IMotionEyeConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new MotionEyeRuntime(new MotionEyeClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantMotioneyeIntegration extends MotionEyeIntegration {}
|
||||
export class HomeAssistantMotionEyeIntegration extends MotionEyeIntegration {}
|
||||
|
||||
class MotionEyeRuntime implements IIntegrationRuntime {
|
||||
public domain = 'motioneye';
|
||||
|
||||
constructor(private readonly client: MotionEyeClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return MotionEyeMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return MotionEyeMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = MotionEyeMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported motionEye service: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
const data = await this.client.execute(command);
|
||||
return { success: true, data };
|
||||
} 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,192 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IMotionEyeManualEntry, IMotionEyeUrlRecord, TMotionEyeProtocol } from './motioneye.types.js';
|
||||
import { motionEyeDefaultPort } from './motioneye.types.js';
|
||||
|
||||
export class MotionEyeManualMatcher implements IDiscoveryMatcher<IMotionEyeManualEntry> {
|
||||
public id = 'motioneye-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual motionEye base URL or host entries.';
|
||||
|
||||
public async matches(inputArg: IMotionEyeManualEntry, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const endpoint = endpointFromInput(inputArg);
|
||||
const hint = hasMotionEyeHint(inputArg.name, inputArg.manufacturer, inputArg.model) || Boolean(inputArg.metadata?.motioneye);
|
||||
if (!endpoint.host && !hint) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual motionEye entry requires a URL, host, or motionEye metadata.' };
|
||||
}
|
||||
const normalizedDeviceId = inputArg.id || endpoint.host || endpoint.url;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: endpoint.host ? 'high' : 'medium',
|
||||
reason: endpoint.host ? 'Manual entry contains a local motionEye endpoint.' : 'Manual entry contains motionEye metadata.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: 'motioneye',
|
||||
id: normalizedDeviceId,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
name: inputArg.name || endpoint.host,
|
||||
manufacturer: inputArg.manufacturer || 'motionEye',
|
||||
model: inputArg.model,
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
adminUsername: inputArg.adminUsername,
|
||||
adminPassword: inputArg.adminPassword,
|
||||
surveillanceUsername: inputArg.surveillanceUsername,
|
||||
surveillancePassword: inputArg.surveillancePassword,
|
||||
discoveryProtocol: 'manual',
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MotionEyeUrlMatcher implements IDiscoveryMatcher<IMotionEyeUrlRecord> {
|
||||
public id = 'motioneye-url-match';
|
||||
public source = 'http' as const;
|
||||
public description = 'Recognize local HTTP URL candidates that point at motionEye.';
|
||||
|
||||
public async matches(recordArg: IMotionEyeUrlRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const endpoint = endpointFromInput(recordArg);
|
||||
const urlText = recordArg.url || String(recordArg.metadata?.url || '');
|
||||
const hint = hasMotionEyeHint(recordArg.name, recordArg.manufacturer, recordArg.model, urlText) || Boolean(recordArg.metadata?.motioneye);
|
||||
const defaultPortHint = endpoint.port === motionEyeDefaultPort;
|
||||
if (!endpoint.host || (!hint && !defaultPortHint)) {
|
||||
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a motionEye endpoint.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: hint ? 'high' : 'medium',
|
||||
reason: hint ? 'HTTP candidate contains motionEye metadata.' : 'HTTP candidate uses the default motionEye port.',
|
||||
normalizedDeviceId: endpoint.host,
|
||||
candidate: {
|
||||
source: 'http',
|
||||
integrationDomain: 'motioneye',
|
||||
id: endpoint.host,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
name: recordArg.name || endpoint.host,
|
||||
manufacturer: recordArg.manufacturer || 'motionEye',
|
||||
model: recordArg.model,
|
||||
metadata: {
|
||||
...recordArg.metadata,
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
discoveryProtocol: 'http',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MotionEyeCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'motioneye-candidate-validator';
|
||||
public description = 'Validate that a discovery candidate can be configured as a local motionEye server.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'motioneye') {
|
||||
return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not motionEye.` };
|
||||
}
|
||||
const endpoint = endpointFromCandidate(candidateArg);
|
||||
if (!endpoint.host) {
|
||||
return { matched: false, confidence: 'low', reason: 'motionEye candidates require a local URL or host.' };
|
||||
}
|
||||
if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) {
|
||||
return { matched: false, confidence: 'low', reason: 'motionEye candidate has an invalid port.' };
|
||||
}
|
||||
const hasHint = candidateArg.integrationDomain === 'motioneye'
|
||||
|| candidateArg.source === 'manual'
|
||||
|| candidateArg.source === 'http'
|
||||
|| endpoint.port === motionEyeDefaultPort
|
||||
|| hasMotionEyeHint(candidateArg.name, candidateArg.manufacturer, candidateArg.model)
|
||||
|| Boolean(candidateArg.metadata?.motioneye);
|
||||
if (!hasHint) {
|
||||
return { matched: false, confidence: 'low', reason: 'Candidate does not contain motionEye metadata.' };
|
||||
}
|
||||
return {
|
||||
matched: true,
|
||||
confidence: candidateArg.integrationDomain === 'motioneye' || candidateArg.source === 'manual' ? 'high' : 'medium',
|
||||
reason: 'Candidate has enough local motionEye metadata to start configuration.',
|
||||
normalizedDeviceId: candidateArg.id || endpoint.host,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: 'motioneye',
|
||||
id: candidateArg.id || endpoint.host,
|
||||
host: endpoint.host,
|
||||
port: endpoint.port,
|
||||
manufacturer: candidateArg.manufacturer || 'motionEye',
|
||||
metadata: {
|
||||
...candidateArg.metadata,
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
manualSupported: candidateArg.source === 'manual',
|
||||
protocol: endpoint.protocol,
|
||||
url: endpoint.url,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createMotionEyeDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: 'motioneye', displayName: 'motionEye' })
|
||||
.addMatcher(new MotionEyeManualMatcher())
|
||||
.addMatcher(new MotionEyeUrlMatcher())
|
||||
.addValidator(new MotionEyeCandidateValidator());
|
||||
};
|
||||
|
||||
const endpointFromInput = (inputArg: IMotionEyeManualEntry | IMotionEyeUrlRecord): { protocol: TMotionEyeProtocol; host?: string; port: number; url?: string } => {
|
||||
const metadataUrl = typeof inputArg.metadata?.url === 'string' ? inputArg.metadata.url : undefined;
|
||||
const url = safeUrl(inputArg.url || metadataUrl || inputArg.host);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort;
|
||||
const pathname = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
|
||||
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}${pathname}` };
|
||||
}
|
||||
const protocol = ('protocol' in inputArg && inputArg.protocol) || 'http';
|
||||
const port = inputArg.port || motionEyeDefaultPort;
|
||||
return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined };
|
||||
};
|
||||
|
||||
const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TMotionEyeProtocol; host?: string; port: number; url?: string } => {
|
||||
const metadataUrl = typeof candidateArg.metadata?.url === 'string' ? candidateArg.metadata.url : undefined;
|
||||
const metadataProtocol = candidateArg.metadata?.protocol === 'https' ? 'https' : 'http';
|
||||
const url = safeUrl(metadataUrl || candidateArg.host);
|
||||
if (url) {
|
||||
const protocol = url.protocol === 'https:' ? 'https' : 'http';
|
||||
const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : motionEyeDefaultPort;
|
||||
const pathname = url.pathname && url.pathname !== '/' ? url.pathname.replace(/\/$/, '') : '';
|
||||
return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}${pathname}` };
|
||||
}
|
||||
const port = candidateArg.port || motionEyeDefaultPort;
|
||||
return { protocol: metadataProtocol, host: candidateArg.host, port, url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${port}` : metadataUrl };
|
||||
};
|
||||
|
||||
const hasMotionEyeHint = (...valuesArgs: Array<string | undefined>): boolean => {
|
||||
return valuesArgs.filter(Boolean).join(' ').toLowerCase().includes('motioneye')
|
||||
|| valuesArgs.filter(Boolean).join(' ').toLowerCase().includes('motion eye');
|
||||
};
|
||||
|
||||
const safeUrl = (valueArg: string | undefined): URL | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return new URL(valueArg);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js';
|
||||
import type {
|
||||
IMotionEyeCamera,
|
||||
IMotionEyeClientCommand,
|
||||
IMotionEyeSnapshot,
|
||||
IMotionEyeSwitch,
|
||||
} from './motioneye.types.js';
|
||||
import { motionEyeKnownActions, motionEyeSwitchDescriptions } from './motioneye.types.js';
|
||||
|
||||
const cameraStreamServices = new Set(['stream', 'stream_source', 'get_stream']);
|
||||
const cameraSnapshotServices = new Set(['snapshot', 'camera_image', 'camera_snapshot']);
|
||||
const serviceBooleanKeys = ['enabled', 'enable', 'on', 'state', 'value'];
|
||||
const recordStartServices = new Set(['record_start', 'start_recording', 'enable_recording']);
|
||||
const recordStopServices = new Set(['record_stop', 'stop_recording', 'disable_recording']);
|
||||
|
||||
export class MotionEyeMapper {
|
||||
public static toDevices(snapshotArg: IMotionEyeSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
return snapshotArg.cameras.map((cameraArg) => {
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
{ id: 'camera', capability: 'camera', name: cameraArg.name, readable: true, writable: true },
|
||||
{ id: 'motion_detection', capability: 'switch', name: 'Motion detection', readable: true, writable: true },
|
||||
{ id: 'recording', capability: 'switch', name: 'Recording', readable: false, writable: true },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'connectivity', value: snapshotArg.connected && cameraArg.available !== false ? 'online' : 'offline', updatedAt },
|
||||
{ featureId: 'camera', value: { mjpegUrl: cameraArg.mjpegUrl || null, snapshotUrl: cameraArg.snapshotUrl || null, isStreaming: cameraArg.isStreaming }, updatedAt },
|
||||
{ featureId: 'motion_detection', value: cameraArg.motionDetectionEnabled, updatedAt },
|
||||
{ featureId: 'recording', value: 'action', updatedAt },
|
||||
];
|
||||
|
||||
for (const switchArg of snapshotArg.switches.filter((switchArg) => switchArg.cameraId === cameraArg.id)) {
|
||||
if (!features.some((featureArg) => featureArg.id === `switch_${this.slug(switchArg.key)}`)) {
|
||||
features.push({ id: `switch_${this.slug(switchArg.key)}`, capability: 'switch', name: switchArg.name, readable: true, writable: true });
|
||||
}
|
||||
state.push({ featureId: `switch_${this.slug(switchArg.key)}`, value: switchArg.isOn, updatedAt });
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.deviceId(snapshotArg, cameraArg),
|
||||
integrationDomain: 'motioneye',
|
||||
name: cameraArg.name,
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.deviceInfo.manufacturer || 'motionEye',
|
||||
model: snapshotArg.deviceInfo.model || 'motionEye camera',
|
||||
online: snapshotArg.connected && cameraArg.available !== false,
|
||||
features,
|
||||
state,
|
||||
metadata: {
|
||||
motionEyeUrl: snapshotArg.deviceInfo.url,
|
||||
host: snapshotArg.deviceInfo.host,
|
||||
port: snapshotArg.deviceInfo.port,
|
||||
protocol: snapshotArg.deviceInfo.protocol,
|
||||
cameraId: cameraArg.id,
|
||||
numericId: cameraArg.numericId,
|
||||
streamingPort: cameraArg.streamingPort,
|
||||
streamingAuthMode: cameraArg.streamingAuthMode,
|
||||
rootDirectory: cameraArg.rootDirectory,
|
||||
actions: cameraArg.actions,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IMotionEyeSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const usedIds = new Map<string, number>();
|
||||
|
||||
for (const camera of snapshotArg.cameras) {
|
||||
const deviceId = this.deviceId(snapshotArg, camera);
|
||||
entities.push(this.entity('camera' as TEntityPlatform, camera.name, deviceId, `motioneye_${this.uniqueBase(snapshotArg)}_camera_${this.slug(camera.id)}`, snapshotArg.connected && camera.available !== false ? 'idle' : 'unavailable', usedIds, {
|
||||
cameraId: camera.id,
|
||||
numericId: camera.numericId,
|
||||
mjpegUrl: camera.mjpegUrl,
|
||||
streamSource: camera.mjpegUrl,
|
||||
snapshotUrl: camera.snapshotUrl,
|
||||
stillImageUrl: camera.snapshotUrl,
|
||||
streamingPort: camera.streamingPort,
|
||||
streamingAuthMode: camera.streamingAuthMode,
|
||||
motionDetectionEnabled: camera.motionDetectionEnabled,
|
||||
actions: camera.actions,
|
||||
rootDirectory: camera.rootDirectory,
|
||||
supportedFeatures: this.supportedCameraFeatures(camera),
|
||||
serviceMappings: {
|
||||
snapshot: 'camera.snapshot',
|
||||
streamSource: 'camera.stream_source',
|
||||
motionDetection: 'camera.enable_motion_detection',
|
||||
action: 'motioneye.action',
|
||||
recordStart: 'motioneye.record_start',
|
||||
recordStop: 'motioneye.record_stop',
|
||||
},
|
||||
...camera.attributes,
|
||||
}, snapshotArg.connected && camera.available !== false));
|
||||
}
|
||||
|
||||
for (const switchArg of snapshotArg.switches) {
|
||||
const camera = this.cameraById(snapshotArg, switchArg.cameraId);
|
||||
const deviceId = camera ? this.deviceId(snapshotArg, camera) : `motioneye.device.${this.uniqueBase(snapshotArg)}`;
|
||||
entities.push(this.entity('switch', switchArg.name, deviceId, `motioneye_${this.uniqueBase(snapshotArg)}_${this.slug(switchArg.cameraId)}_${this.slug(switchArg.key)}`, switchArg.isOn ? 'on' : 'off', usedIds, {
|
||||
key: switchArg.key,
|
||||
cameraId: switchArg.cameraId,
|
||||
entityCategory: switchArg.entityCategory,
|
||||
...switchArg.attributes,
|
||||
}, snapshotArg.connected && switchArg.available !== false));
|
||||
}
|
||||
|
||||
for (const sensor of snapshotArg.sensors) {
|
||||
const camera = sensor.cameraId ? this.cameraById(snapshotArg, sensor.cameraId) : undefined;
|
||||
const deviceId = camera ? this.deviceId(snapshotArg, camera) : `motioneye.device.${this.uniqueBase(snapshotArg)}`;
|
||||
entities.push(this.entity('sensor', sensor.name, deviceId, `motioneye_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.cameraId || 'hub')}_${this.slug(sensor.key)}`, sensor.value ?? 'unknown', usedIds, {
|
||||
key: sensor.key,
|
||||
cameraId: sensor.cameraId,
|
||||
unit: sensor.unit,
|
||||
deviceClass: sensor.deviceClass,
|
||||
entityCategory: sensor.entityCategory,
|
||||
...sensor.attributes,
|
||||
}, snapshotArg.connected && sensor.available !== false));
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
|
||||
if (requestArg.domain === 'camera' && cameraStreamServices.has(requestArg.service)) {
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
return { type: 'stream_source', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id };
|
||||
}
|
||||
if (requestArg.domain === 'camera' && cameraSnapshotServices.has(requestArg.service)) {
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
return {
|
||||
type: 'snapshot_image',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
cameraId: camera?.id,
|
||||
filename: this.stringValue(requestArg.data?.filename),
|
||||
httpCommands: camera?.numericId === undefined ? undefined : [{ label: 'snapshot', method: 'GET', path: `/picture/${camera.numericId}/current/`, admin: false }],
|
||||
};
|
||||
}
|
||||
if (requestArg.domain === 'camera' && (requestArg.service === 'enable_motion_detection' || requestArg.service === 'disable_motion_detection')) {
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
const enabled = requestArg.service === 'enable_motion_detection';
|
||||
return this.switchCommand(camera, 'motion_detection', enabled, requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'switch' && ['turn_on', 'turn_off', 'toggle'].includes(requestArg.service)) {
|
||||
const switchEntity = this.findSwitch(snapshotArg, requestArg);
|
||||
if (!switchEntity) {
|
||||
return undefined;
|
||||
}
|
||||
const enabled = requestArg.service === 'turn_on' ? true : requestArg.service === 'turn_off' ? false : !switchEntity.isOn;
|
||||
return this.switchCommand(this.cameraById(snapshotArg, switchEntity.cameraId), switchEntity.key, enabled, requestArg);
|
||||
}
|
||||
if (requestArg.domain === 'motioneye') {
|
||||
return this.motionEyeCommand(snapshotArg, requestArg);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IMotionEyeSnapshot, cameraArg: IMotionEyeCamera): string {
|
||||
return `motioneye.camera.${this.uniqueBase(snapshotArg)}.${this.slug(cameraArg.id)}`;
|
||||
}
|
||||
|
||||
private static motionEyeCommand(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
|
||||
if (requestArg.service === 'refresh') {
|
||||
return { type: 'refresh', service: requestArg.service, target: requestArg.target, data: requestArg.data };
|
||||
}
|
||||
if (cameraStreamServices.has(requestArg.service) || cameraSnapshotServices.has(requestArg.service)) {
|
||||
return this.commandForService(snapshotArg, { ...requestArg, domain: 'camera' });
|
||||
}
|
||||
const camera = this.findCamera(snapshotArg, requestArg);
|
||||
if (requestArg.service === 'action') {
|
||||
const action = this.stringValue(requestArg.data?.action);
|
||||
return action ? this.actionCommand(camera, action, requestArg) : undefined;
|
||||
}
|
||||
if (requestArg.service === 'snapshot') {
|
||||
return this.actionCommand(camera, 'snapshot', requestArg);
|
||||
}
|
||||
if (recordStartServices.has(requestArg.service)) {
|
||||
return this.actionCommand(camera, 'record_start', requestArg);
|
||||
}
|
||||
if (recordStopServices.has(requestArg.service)) {
|
||||
return this.actionCommand(camera, 'record_stop', requestArg);
|
||||
}
|
||||
if (requestArg.service === 'set_text_overlay') {
|
||||
const leftText = this.stringValue(requestArg.data?.left_text ?? requestArg.data?.leftText);
|
||||
const rightText = this.stringValue(requestArg.data?.right_text ?? requestArg.data?.rightText);
|
||||
const customLeftText = this.stringValue(requestArg.data?.custom_left_text ?? requestArg.data?.customLeftText);
|
||||
const customRightText = this.stringValue(requestArg.data?.custom_right_text ?? requestArg.data?.customRightText);
|
||||
if ([leftText, rightText, customLeftText, customRightText].every((valueArg) => valueArg === undefined)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: 'set_text_overlay',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
cameraId: camera?.id,
|
||||
leftText,
|
||||
rightText,
|
||||
customLeftText,
|
||||
customRightText,
|
||||
httpCommands: camera?.numericId === undefined ? undefined : [{ label: 'set_text_overlay', method: 'POST', path: `/config/${camera.numericId}/set`, admin: true }],
|
||||
};
|
||||
}
|
||||
if (requestArg.service === 'set_switch' || requestArg.service === 'set_camera_setting') {
|
||||
const key = this.switchKey(requestArg.data?.key ?? requestArg.data?.setting);
|
||||
const enabled = this.booleanFromData(requestArg.data);
|
||||
return key && enabled !== undefined ? this.switchCommand(camera, key, enabled, requestArg) : undefined;
|
||||
}
|
||||
if (requestArg.service === 'media_list') {
|
||||
const kind = requestArg.data?.kind === 'movies' || requestArg.data?.mediaKind === 'movies' ? 'movies' : 'images';
|
||||
return { type: 'media_list', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, mediaKind: kind, prefix: this.stringValue(requestArg.data?.prefix) };
|
||||
}
|
||||
|
||||
const switchKey = requestArg.service.startsWith('set_') ? this.switchKey(requestArg.service.slice(4)) : this.switchKey(requestArg.service);
|
||||
if (switchKey) {
|
||||
const enabled = this.booleanFromData(requestArg.data) ?? true;
|
||||
return this.switchCommand(camera, switchKey, enabled, requestArg);
|
||||
}
|
||||
const action = motionEyeKnownActions.includes(requestArg.service as typeof motionEyeKnownActions[number]) ? requestArg.service : undefined;
|
||||
return action ? this.actionCommand(camera, action, requestArg) : undefined;
|
||||
}
|
||||
|
||||
private static actionCommand(cameraArg: IMotionEyeCamera | undefined, actionArg: string, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
|
||||
if (!actionArg.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: 'action',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
cameraId: cameraArg?.id,
|
||||
action: actionArg,
|
||||
httpCommands: cameraArg?.numericId === undefined ? undefined : [{ label: `action:${actionArg}`, method: 'POST', path: `/action/${cameraArg.numericId}/${encodeURIComponent(actionArg)}`, admin: true, data: {} }],
|
||||
};
|
||||
}
|
||||
|
||||
private static switchCommand(cameraArg: IMotionEyeCamera | undefined, keyArg: string, enabledArg: boolean, requestArg: IServiceCallRequest): IMotionEyeClientCommand | undefined {
|
||||
if (!cameraArg) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
type: 'set_switch',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
data: requestArg.data,
|
||||
cameraId: cameraArg.id,
|
||||
key: keyArg,
|
||||
enabled: enabledArg,
|
||||
httpCommands: cameraArg.numericId === undefined ? undefined : [{ label: keyArg, method: 'POST', path: `/config/${cameraArg.numericId}/set`, admin: true }],
|
||||
};
|
||||
}
|
||||
|
||||
private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
|
||||
const baseId = `${String(platformArg)}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
|
||||
const seen = usedIdsArg.get(baseId) || 0;
|
||||
usedIdsArg.set(baseId, seen + 1);
|
||||
return {
|
||||
id: seen ? `${baseId}_${seen + 1}` : baseId,
|
||||
uniqueId: uniqueIdArg,
|
||||
integrationDomain: 'motioneye',
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static findCamera(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeCamera | undefined {
|
||||
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.cameraId ?? requestArg.data?.camera_id ?? requestArg.data?.camera);
|
||||
if (!target) {
|
||||
return snapshotArg.cameras[0];
|
||||
}
|
||||
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === ('camera' as TEntityPlatform));
|
||||
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.cameraId === target || entityArg.attributes?.numericId === target);
|
||||
const cameraId = this.stringValue(entity?.attributes?.cameraId) || target;
|
||||
return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.numericId) === cameraId || this.deviceId(snapshotArg, cameraArg) === target) || snapshotArg.cameras[0];
|
||||
}
|
||||
|
||||
private static findSwitch(snapshotArg: IMotionEyeSnapshot, requestArg: IServiceCallRequest): IMotionEyeSwitch | undefined {
|
||||
const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key ?? requestArg.data?.setting);
|
||||
if (!target) {
|
||||
return snapshotArg.switches[0];
|
||||
}
|
||||
const entities = this.toEntities(snapshotArg).filter((entityArg) => entityArg.platform === 'switch');
|
||||
const entity = entities.find((entityArg) => entityArg.id === target || entityArg.uniqueId === target || entityArg.attributes?.key === target);
|
||||
const key = this.stringValue(entity?.attributes?.key) || this.switchKey(target);
|
||||
const cameraId = this.stringValue(entity?.attributes?.cameraId);
|
||||
return snapshotArg.switches.find((switchArg) => (key ? switchArg.key === key : false) && (!cameraId || switchArg.cameraId === cameraId))
|
||||
|| snapshotArg.switches.find((switchArg) => switchArg.key === target || switchArg.name === target);
|
||||
}
|
||||
|
||||
private static cameraById(snapshotArg: IMotionEyeSnapshot, cameraIdArg: string): IMotionEyeCamera | undefined {
|
||||
return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraIdArg || String(cameraArg.numericId) === cameraIdArg);
|
||||
}
|
||||
|
||||
private static supportedCameraFeatures(cameraArg: IMotionEyeCamera): string[] {
|
||||
const features = ['stream', 'snapshot', 'motion_detection'];
|
||||
if (cameraArg.actions.includes('record_start') || cameraArg.actions.includes('record_stop')) {
|
||||
features.push('recording');
|
||||
}
|
||||
if (cameraArg.actions.length) {
|
||||
features.push('actions');
|
||||
}
|
||||
return features;
|
||||
}
|
||||
|
||||
private static switchKey(valueArg: unknown): string | undefined {
|
||||
const value = this.stringValue(valueArg);
|
||||
return value && motionEyeSwitchDescriptions.some((descriptionArg) => descriptionArg.key === value) ? value : undefined;
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IMotionEyeSnapshot): string {
|
||||
return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'motionEye';
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IMotionEyeSnapshot): string {
|
||||
return this.slug(snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || snapshotArg.deviceInfo.url || this.deviceName(snapshotArg));
|
||||
}
|
||||
|
||||
private static cleanAttributes(attributesArg: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined));
|
||||
}
|
||||
|
||||
private static booleanFromData(dataArg: Record<string, unknown> | undefined): boolean | undefined {
|
||||
for (const key of serviceBooleanKeys) {
|
||||
const value = this.booleanValue(dataArg?.[key]);
|
||||
if (value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static booleanValue(valueArg: unknown): boolean | undefined {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' || typeof valueArg === 'boolean' ? String(valueArg) : undefined;
|
||||
}
|
||||
|
||||
private static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'motioneye';
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,244 @@
|
||||
export interface IHomeAssistantMotioneyeConfig {
|
||||
// TODO: replace with the TypeScript-native config for motioneye.
|
||||
export const motionEyeDefaultPort = 8765;
|
||||
export const motionEyeDefaultTimeoutMs = 10000;
|
||||
export const motionEyeDefaultAdminUsername = 'admin';
|
||||
export const motionEyeDefaultSurveillanceUsername = 'user';
|
||||
|
||||
export const motionEyeKnownActions = [
|
||||
'snapshot',
|
||||
'record_start',
|
||||
'record_stop',
|
||||
'lock',
|
||||
'unlock',
|
||||
'light_on',
|
||||
'light_off',
|
||||
'alarm_on',
|
||||
'alarm_off',
|
||||
'up',
|
||||
'right',
|
||||
'down',
|
||||
'left',
|
||||
'zoom_in',
|
||||
'zoom_out',
|
||||
'preset1',
|
||||
'preset2',
|
||||
'preset3',
|
||||
'preset4',
|
||||
'preset5',
|
||||
'preset6',
|
||||
'preset7',
|
||||
'preset8',
|
||||
'preset9',
|
||||
] as const;
|
||||
|
||||
export type TMotionEyeProtocol = 'http' | 'https';
|
||||
export type TMotionEyeAuthMode = 'basic' | 'digest' | string;
|
||||
export type TMotionEyeHttpMethod = 'GET' | 'POST';
|
||||
export type TMotionEyeCommandType =
|
||||
| 'refresh'
|
||||
| 'stream_source'
|
||||
| 'snapshot_image'
|
||||
| 'action'
|
||||
| 'set_switch'
|
||||
| 'set_text_overlay'
|
||||
| 'media_list';
|
||||
export type TMotionEyeMediaKind = 'images' | 'movies';
|
||||
|
||||
export interface IMotionEyeConfig {
|
||||
protocol?: TMotionEyeProtocol;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
adminUsername?: string;
|
||||
adminPassword?: string;
|
||||
surveillanceUsername?: string;
|
||||
surveillancePassword?: string;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
streamUrlTemplate?: string;
|
||||
connected?: boolean;
|
||||
deviceInfo?: IMotionEyeDeviceInfo;
|
||||
cameras?: IMotionEyeCamera[];
|
||||
rawCameras?: IMotionEyeRawCamera[];
|
||||
sensors?: IMotionEyeSensor[];
|
||||
switches?: IMotionEyeSwitch[];
|
||||
manifest?: Record<string, unknown>;
|
||||
serverConfig?: Record<string, unknown>;
|
||||
snapshot?: IMotionEyeSnapshot;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantMotioneyeConfig extends IMotionEyeConfig {}
|
||||
export interface IHomeAssistantMotionEyeConfig extends IMotionEyeConfig {}
|
||||
|
||||
export interface IMotionEyeDeviceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
protocol?: TMotionEyeProtocol;
|
||||
url?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
export interface IMotionEyeRawCamera {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
streaming_port?: number | string;
|
||||
streaming_auth_mode?: TMotionEyeAuthMode;
|
||||
video_streaming?: boolean;
|
||||
motion_detection?: boolean;
|
||||
text_overlay?: boolean;
|
||||
still_images?: boolean;
|
||||
movies?: boolean;
|
||||
upload_enabled?: boolean;
|
||||
actions?: string[];
|
||||
root_directory?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IMotionEyeCamera {
|
||||
id: string;
|
||||
numericId?: number;
|
||||
name: string;
|
||||
host?: string;
|
||||
streamingPort?: number;
|
||||
streamingAuthMode?: TMotionEyeAuthMode;
|
||||
mjpegUrl?: string;
|
||||
snapshotUrl?: string;
|
||||
isStreaming: boolean;
|
||||
motionDetectionEnabled: boolean;
|
||||
textOverlayEnabled?: boolean;
|
||||
stillImagesEnabled?: boolean;
|
||||
moviesEnabled?: boolean;
|
||||
uploadEnabled?: boolean;
|
||||
actions: string[];
|
||||
rootDirectory?: string;
|
||||
available?: boolean;
|
||||
raw?: IMotionEyeRawCamera;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeSensor<TValue = unknown> {
|
||||
key: string;
|
||||
name: string;
|
||||
cameraId?: string;
|
||||
value: TValue;
|
||||
unit?: string;
|
||||
deviceClass?: string;
|
||||
entityCategory?: string;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeSwitch {
|
||||
key: string;
|
||||
name: string;
|
||||
cameraId: string;
|
||||
isOn: boolean;
|
||||
entityCategory?: string;
|
||||
available?: boolean;
|
||||
attributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeSnapshot {
|
||||
deviceInfo: IMotionEyeDeviceInfo;
|
||||
cameras: IMotionEyeCamera[];
|
||||
sensors: IMotionEyeSensor[];
|
||||
switches: IMotionEyeSwitch[];
|
||||
rawCameras: IMotionEyeRawCamera[];
|
||||
connected: boolean;
|
||||
updatedAt?: string;
|
||||
manifest?: Record<string, unknown>;
|
||||
serverConfig?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeHttpCommand {
|
||||
label: string;
|
||||
method: TMotionEyeHttpMethod;
|
||||
path: string;
|
||||
admin: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeCommandResponse {
|
||||
ok: boolean;
|
||||
label: string;
|
||||
method: TMotionEyeHttpMethod;
|
||||
path: string;
|
||||
status: number;
|
||||
response?: unknown;
|
||||
}
|
||||
|
||||
export interface IMotionEyeClientCommand {
|
||||
type: TMotionEyeCommandType;
|
||||
service: string;
|
||||
target?: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
data?: Record<string, unknown>;
|
||||
cameraId?: string;
|
||||
action?: string;
|
||||
key?: string;
|
||||
enabled?: boolean;
|
||||
leftText?: string;
|
||||
rightText?: string;
|
||||
customLeftText?: string;
|
||||
customRightText?: string;
|
||||
mediaKind?: TMotionEyeMediaKind;
|
||||
prefix?: string;
|
||||
filename?: string;
|
||||
httpCommands?: IMotionEyeHttpCommand[];
|
||||
}
|
||||
|
||||
export interface IMotionEyeSnapshotImage {
|
||||
contentType: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface IMotionEyeManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
protocol?: TMotionEyeProtocol;
|
||||
adminUsername?: string;
|
||||
adminPassword?: string;
|
||||
surveillanceUsername?: string;
|
||||
surveillancePassword?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeUrlRecord {
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IMotionEyeSwitchDescription {
|
||||
key: string;
|
||||
name: string;
|
||||
entityCategory?: string;
|
||||
}
|
||||
|
||||
export const motionEyeSwitchDescriptions: IMotionEyeSwitchDescription[] = [
|
||||
{ key: 'motion_detection', name: 'Motion detection', entityCategory: 'config' },
|
||||
{ key: 'text_overlay', name: 'Text overlay', entityCategory: 'config' },
|
||||
{ key: 'video_streaming', name: 'Video streaming', entityCategory: 'config' },
|
||||
{ key: 'still_images', name: 'Still images', entityCategory: 'config' },
|
||||
{ key: 'movies', name: 'Movies', entityCategory: 'config' },
|
||||
{ key: 'upload_enabled', name: 'Upload enabled', entityCategory: 'config' },
|
||||
];
|
||||
|
||||
@@ -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 './opnsense.classes.client.js';
|
||||
export * from './opnsense.classes.configflow.js';
|
||||
export * from './opnsense.classes.integration.js';
|
||||
export * from './opnsense.discovery.js';
|
||||
export * from './opnsense.mapper.js';
|
||||
export * from './opnsense.types.js';
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { IOpnsenseCommand, IOpnsenseCommandResult, IOpnsenseConfig, IOpnsenseEvent, IOpnsenseSnapshot } from './opnsense.types.js';
|
||||
import { OpnsenseMapper } from './opnsense.mapper.js';
|
||||
|
||||
type TOpnsenseEventHandler = (eventArg: IOpnsenseEvent) => void;
|
||||
|
||||
export class OpnsenseClient {
|
||||
private currentSnapshot?: IOpnsenseSnapshot;
|
||||
private readonly eventHandlers = new Set<TOpnsenseEventHandler>();
|
||||
|
||||
constructor(private readonly config: IOpnsenseConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IOpnsenseSnapshot> {
|
||||
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 = OpnsenseMapper.toSnapshot(this.config);
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public onEvent(handlerArg: TOpnsenseEventHandler): () => void {
|
||||
this.eventHandlers.add(handlerArg);
|
||||
return () => this.eventHandlers.delete(handlerArg);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<IOpnsenseCommandResult> {
|
||||
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 = OpnsenseMapper.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: IOpnsenseCommand): Promise<IOpnsenseCommandResult> {
|
||||
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: IOpnsenseCommandResult = {
|
||||
success: false,
|
||||
error: 'OPNsense live API commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for reboot, service, firewall, VPN, interface, firmware, or switch actions.',
|
||||
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: IOpnsenseCommandResult = { 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: IOpnsenseSnapshot, sourceArg: IOpnsenseSnapshot['source']): IOpnsenseSnapshot {
|
||||
const normalized = OpnsenseMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
|
||||
return { ...normalized, source: snapshotArg.source || sourceArg };
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: IOpnsenseCommand): IOpnsenseCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is IOpnsenseCommandResult {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
|
||||
}
|
||||
|
||||
private emit(eventArg: IOpnsenseEvent): void {
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(eventArg);
|
||||
}
|
||||
}
|
||||
|
||||
private cloneSnapshot<T extends IOpnsenseSnapshot | undefined>(snapshotArg: T): T {
|
||||
return snapshotArg ? JSON.parse(JSON.stringify(snapshotArg)) as T : snapshotArg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IOpnsenseConfig, IOpnsenseSnapshot } from './opnsense.types.js';
|
||||
import { opnsenseDefaultPort, opnsenseDefaultVerifySsl } from './opnsense.types.js';
|
||||
|
||||
export class OpnsenseConfigFlow implements IConfigFlow<IOpnsenseConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IOpnsenseConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect OPNsense',
|
||||
description: 'Provide a local HTTPS OPNsense API endpoint. Snapshot/manual data is supported natively; live API success is only reported through an injected native client or command executor.',
|
||||
fields: [
|
||||
{ name: 'url', label: candidateArg.host ? `URL or host (${candidateArg.host})` : 'URL or host', type: 'text', required: true },
|
||||
{ name: 'port', label: `HTTPS port (${candidateArg.port || opnsenseDefaultPort})`, type: 'number' },
|
||||
{ name: 'apiKey', label: 'API key', type: 'text' },
|
||||
{ name: 'apiSecret', label: 'API secret', type: 'password' },
|
||||
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
|
||||
{ name: 'trackerInterfaces', label: 'Tracker interface descriptions, comma-separated', 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<IOpnsenseConfig>> {
|
||||
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || candidateArg.metadata?.snapshot);
|
||||
if (snapshot instanceof Error) {
|
||||
return { kind: 'error', title: 'Invalid OPNsense snapshot', error: snapshot.message };
|
||||
}
|
||||
|
||||
const endpoint = parseHttpsEndpoint(stringValue(valuesArg.url) || candidateArg.metadata?.url as string | undefined || candidateArg.host || snapshot?.router.url || snapshot?.router.host || '');
|
||||
if (endpoint.error) {
|
||||
return { kind: 'error', title: 'Invalid OPNsense endpoint', error: endpoint.error };
|
||||
}
|
||||
if (!endpoint.host && !snapshot) {
|
||||
return { kind: 'error', title: 'OPNsense setup failed', error: 'OPNsense setup requires a local HTTPS host/URL or snapshot JSON.' };
|
||||
}
|
||||
|
||||
const port = numberValue(valuesArg.port) || candidateArg.port || endpoint.port || snapshot?.router.port || (endpoint.host ? opnsenseDefaultPort : undefined);
|
||||
if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
||||
return { kind: 'error', title: 'Invalid OPNsense port', error: 'OPNsense port must be an integer between 1 and 65535.' };
|
||||
}
|
||||
|
||||
const apiKey = stringValue(valuesArg.apiKey) || stringValue(candidateArg.metadata?.apiKey);
|
||||
const apiSecret = stringValue(valuesArg.apiSecret) || stringValue(candidateArg.metadata?.apiSecret);
|
||||
if (Boolean(apiKey) !== Boolean(apiSecret)) {
|
||||
return { kind: 'error', title: 'Incomplete OPNsense API credentials', error: 'OPNsense API key and API secret must be provided together.' };
|
||||
}
|
||||
|
||||
const trackerInterfaces = listValue(valuesArg.trackerInterfaces) || listValue(candidateArg.metadata?.trackerInterfaces) || [];
|
||||
const verifySsl = booleanValue(valuesArg.verifySsl) ?? booleanValue(candidateArg.metadata?.verifySsl) ?? snapshot?.router.verifySsl ?? opnsenseDefaultVerifySsl;
|
||||
const url = endpoint.host ? endpointUrl(endpoint.host, port) : snapshot?.router.url;
|
||||
const config: IOpnsenseConfig = {
|
||||
url,
|
||||
host: endpoint.host || snapshot?.router.host,
|
||||
port,
|
||||
ssl: true,
|
||||
verifySsl,
|
||||
apiKey,
|
||||
apiSecret,
|
||||
trackerInterfaces,
|
||||
uniqueId: candidateArg.id || snapshot?.router.macAddress || snapshot?.router.id,
|
||||
name: candidateArg.name || snapshot?.router.name || endpoint.host || 'OPNsense',
|
||||
snapshot,
|
||||
metadata: {
|
||||
discoverySource: candidateArg.source,
|
||||
discoveryMetadata: candidateArg.metadata,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'OPNsense configured',
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromInput(valueArg: unknown): IOpnsenseSnapshot | undefined | Error {
|
||||
if (isOpnsenseSnapshot(valueArg)) {
|
||||
return valueArg;
|
||||
}
|
||||
const text = stringValue(valueArg);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as IOpnsenseSnapshot;
|
||||
if (!isOpnsenseSnapshot(parsed)) {
|
||||
return new Error('Snapshot JSON must include router, interfaces, services, vpn, firewall, system, telemetry, sensors, and connected fields.');
|
||||
}
|
||||
return parsed;
|
||||
} catch (errorArg) {
|
||||
return errorArg instanceof Error ? errorArg : new Error(String(errorArg));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parseHttpsEndpoint = (valueArg: string): { host?: string; port?: number; error?: string } => {
|
||||
const value = valueArg.trim();
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value.includes('://') ? value : `https://${value}`);
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return { error: 'OPNsense setup only supports local HTTPS candidates.' };
|
||||
}
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return { error: 'OPNsense endpoint must be a hostname, IP address, or HTTPS URL.' };
|
||||
}
|
||||
};
|
||||
|
||||
const endpointUrl = (hostArg: string, portArg: number | undefined): string => {
|
||||
const port = portArg || opnsenseDefaultPort;
|
||||
return `https://${hostArg}${port === opnsenseDefaultPort ? '' : `:${port}`}`;
|
||||
};
|
||||
|
||||
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 Math.round(valueArg);
|
||||
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) return Math.round(Number(valueArg));
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||
};
|
||||
|
||||
const listValue = (valueArg: unknown): string[] | undefined => {
|
||||
if (Array.isArray(valueArg)) {
|
||||
const values = valueArg.filter((entryArg): entryArg is string => typeof entryArg === 'string' && entryArg.trim().length > 0).map((entryArg) => entryArg.trim());
|
||||
return values.length ? values : undefined;
|
||||
}
|
||||
const text = stringValue(valueArg);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
const values = text.split(',').map((entryArg) => entryArg.trim()).filter(Boolean);
|
||||
return values.length ? values : undefined;
|
||||
};
|
||||
|
||||
const isOpnsenseSnapshot = (valueArg: unknown): valueArg is IOpnsenseSnapshot => {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'router' in valueArg && 'connected' in valueArg);
|
||||
};
|
||||
@@ -1,28 +1,99 @@
|
||||
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 { OpnsenseClient } from './opnsense.classes.client.js';
|
||||
import { OpnsenseConfigFlow } from './opnsense.classes.configflow.js';
|
||||
import { createOpnsenseDiscoveryDescriptor } from './opnsense.discovery.js';
|
||||
import { OpnsenseMapper } from './opnsense.mapper.js';
|
||||
import type { IOpnsenseConfig } from './opnsense.types.js';
|
||||
import { opnsenseDomain } from './opnsense.types.js';
|
||||
|
||||
export class HomeAssistantOpnsenseIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "opnsense",
|
||||
displayName: "OPNsense",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/opnsense",
|
||||
"upstreamDomain": "opnsense",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "legacy",
|
||||
"requirements": [
|
||||
"aiopnsense==1.0.8"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@HarlemSquirrel",
|
||||
"@Snuffy2"
|
||||
]
|
||||
export class OpnsenseIntegration extends BaseIntegration<IOpnsenseConfig> {
|
||||
public readonly domain = opnsenseDomain;
|
||||
public readonly displayName = 'OPNsense';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createOpnsenseDiscoveryDescriptor();
|
||||
public readonly configFlow = new OpnsenseConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/opnsense',
|
||||
upstreamDomain: opnsenseDomain,
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'legacy',
|
||||
requirements: ['aiopnsense==1.0.8'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@HarlemSquirrel', '@Snuffy2'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/opnsense',
|
||||
runtime: {
|
||||
mode: 'native TypeScript snapshot/manual OPNsense mapping',
|
||||
platforms: ['binary_sensor', 'button', 'device_tracker', 'sensor', 'switch', 'update'],
|
||||
services: ['refresh', 'snapshot', 'status', 'reboot', 'halt', 'start_service', 'stop_service', 'restart_service', 'reload_interface', 'firmware_update', 'upgrade_firmware', 'close_notice', 'send_wol', 'run_speedtest'],
|
||||
},
|
||||
});
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual and local HTTPS OPNsense setup candidates plus config flow matching Home Assistant api_key/api_secret/verify_ssl/tracker_interfaces inputs',
|
||||
'Home Assistant legacy device_tracker-style ARP table mapping filtered by interface description',
|
||||
'snapshot mapping for system, firewall/NAT/aliases, interfaces, gateways, VPN, services, telemetry sensors, generic sensors, and switches where represented',
|
||||
'safe command modeling for represented router, service, firewall, NAT, alias, VPN, interface, firmware, notice, Wake-on-LAN, speedtest, and switch actions',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'Home Assistant compatibility shims',
|
||||
'fake OPNsense HTTPS API connection, validation, or command success without commandExecutor/nativeClient injection',
|
||||
'full aiopnsense live HTTP implementation in dependency-free TypeScript',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IOpnsenseConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new OpnsenseRuntime(new OpnsenseClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantOpnsenseIntegration extends OpnsenseIntegration {}
|
||||
|
||||
class OpnsenseRuntime implements IIntegrationRuntime {
|
||||
public domain = opnsenseDomain;
|
||||
|
||||
constructor(private readonly client: OpnsenseClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return OpnsenseMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return OpnsenseMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OpnsenseMapper.toIntegrationEvent(eventArg)));
|
||||
await this.client.getSnapshot();
|
||||
return async () => unsubscribe();
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain === opnsenseDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === opnsenseDomain && requestArg.service === 'refresh') {
|
||||
return this.client.refresh();
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = OpnsenseMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported OPNsense service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
if ('error' in command) {
|
||||
return { success: false, error: command.error };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { OpnsenseMapper } from './opnsense.mapper.js';
|
||||
import type { IOpnsenseManualDiscoveryRecord, IOpnsenseSnapshot } from './opnsense.types.js';
|
||||
import { opnsenseDefaultPort, opnsenseDefaultVerifySsl, opnsenseDomain } from './opnsense.types.js';
|
||||
|
||||
const opnsenseTextHints = ['opnsense', 'opn sense', 'deciso'];
|
||||
|
||||
export class OpnsenseManualMatcher implements IDiscoveryMatcher<IOpnsenseManualDiscoveryRecord> {
|
||||
public id = 'opnsense-manual-https-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual OPNsense HTTPS setup entries, including snapshot-only records.';
|
||||
|
||||
public async matches(inputArg: IOpnsenseManualDiscoveryRecord): Promise<IDiscoveryMatch> {
|
||||
const metadata = inputArg.metadata || {};
|
||||
const snapshot = inputArg.snapshot || metadata.snapshot as IOpnsenseSnapshot | undefined;
|
||||
const endpoint = parseEndpoint(inputArg.url || inputArg.host || snapshot?.router.url || snapshot?.router.host);
|
||||
const mac = OpnsenseMapper.normalizeMac(inputArg.macAddress || snapshot?.router.macAddress);
|
||||
const text = [inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.router.manufacturer, snapshot?.router.model, snapshot?.router.name]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const hasSnapshot = Boolean(snapshot);
|
||||
const matched = inputArg.integrationDomain === opnsenseDomain
|
||||
|| metadata.opnsense === true
|
||||
|| hasSnapshot
|
||||
|| opnsenseTextHints.some((hintArg) => text.includes(hintArg))
|
||||
|| Boolean(endpoint.host && !text);
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain OPNsense setup hints.' };
|
||||
}
|
||||
|
||||
if (endpoint.error) {
|
||||
return { matched: false, confidence: 'medium', reason: endpoint.error };
|
||||
}
|
||||
|
||||
const port = inputArg.port || endpoint.port || snapshot?.router.port || opnsenseDefaultPort;
|
||||
const id = inputArg.id || mac || snapshot?.router.id || (endpoint.host ? `${endpoint.host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: hasSnapshot || mac ? 'certain' : endpoint.host ? 'high' : 'medium',
|
||||
reason: hasSnapshot ? 'Manual entry includes an OPNsense snapshot.' : 'Manual entry can start OPNsense HTTPS setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: opnsenseDomain,
|
||||
id,
|
||||
host: endpoint.host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.router.name || endpoint.host || 'OPNsense',
|
||||
manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || 'OPNsense',
|
||||
model: inputArg.model || snapshot?.router.model || 'OPNsense Firewall',
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...metadata,
|
||||
opnsense: true,
|
||||
protocol: 'https',
|
||||
ssl: true,
|
||||
verifySsl: inputArg.verifySsl ?? opnsenseDefaultVerifySsl,
|
||||
url: endpoint.host ? endpointUrl(endpoint.host, port) : inputArg.url,
|
||||
hasSnapshot,
|
||||
liveHttpImplemented: false,
|
||||
trackerInterfaces: inputArg.trackerInterfaces,
|
||||
snapshot,
|
||||
},
|
||||
},
|
||||
metadata: { hasSnapshot, protocol: 'https', liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class OpnsenseHttpsCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'opnsense-https-candidate-validator';
|
||||
public description = 'Validate OPNsense candidates have HTTPS metadata and a host or snapshot before config flow.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = metadata.snapshot as IOpnsenseSnapshot | undefined;
|
||||
const endpoint = parseEndpoint(metadata.url as string | undefined || candidateArg.host || snapshot?.router.url || snapshot?.router.host);
|
||||
const text = [candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name]
|
||||
.filter((valueArg): valueArg is string => typeof valueArg === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
const matched = candidateArg.integrationDomain === opnsenseDomain
|
||||
|| metadata.opnsense === true
|
||||
|| Boolean(snapshot)
|
||||
|| opnsenseTextHints.some((hintArg) => text.includes(hintArg));
|
||||
const hasUsableSource = Boolean(endpoint.host || snapshot);
|
||||
|
||||
if (endpoint.error) {
|
||||
return { matched: false, confidence: matched ? 'medium' : 'low', reason: endpoint.error };
|
||||
}
|
||||
if (!matched || !hasUsableSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'OPNsense candidate lacks a usable HTTPS host or snapshot.' : 'Candidate is not OPNsense.',
|
||||
};
|
||||
}
|
||||
|
||||
const mac = OpnsenseMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.macAddress);
|
||||
const port = candidateArg.port || endpoint.port || snapshot?.router.port || opnsenseDefaultPort;
|
||||
const normalizedDeviceId = candidateArg.id || mac || (endpoint.host ? `${endpoint.host}:${port}` : snapshot?.router.id);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: mac || snapshot ? 'certain' : endpoint.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has OPNsense metadata and a usable local HTTPS source.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
id: candidateArg.id || normalizedDeviceId,
|
||||
host: endpoint.host || candidateArg.host,
|
||||
port,
|
||||
macAddress: mac || candidateArg.macAddress,
|
||||
metadata: {
|
||||
...metadata,
|
||||
protocol: 'https',
|
||||
ssl: true,
|
||||
verifySsl: metadata.verifySsl ?? opnsenseDefaultVerifySsl,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { protocol: 'https', liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createOpnsenseDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: opnsenseDomain, displayName: 'OPNsense' })
|
||||
.addMatcher(new OpnsenseManualMatcher())
|
||||
.addValidator(new OpnsenseHttpsCandidateValidator());
|
||||
};
|
||||
|
||||
const parseEndpoint = (valueArg: string | undefined): { host?: string; port?: number; error?: string } => {
|
||||
if (!valueArg) return {};
|
||||
try {
|
||||
const parsed = new URL(valueArg.includes('://') ? valueArg : `https://${valueArg}`);
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return { error: 'OPNsense discovery only accepts local HTTPS candidates.' };
|
||||
}
|
||||
return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined };
|
||||
} catch {
|
||||
return { error: 'OPNsense endpoint must be a hostname, IP address, or HTTPS URL.' };
|
||||
}
|
||||
};
|
||||
|
||||
const endpointUrl = (hostArg: string, portArg: number | undefined): string => {
|
||||
const port = portArg || opnsenseDefaultPort;
|
||||
return `https://${hostArg}${port === opnsenseDefaultPort ? '' : `:${port}`}`;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,495 @@
|
||||
export interface IHomeAssistantOpnsenseConfig {
|
||||
// TODO: replace with the TypeScript-native config for opnsense.
|
||||
import type { IServiceCallResult } from '../../core/types.js';
|
||||
|
||||
export const opnsenseDomain = 'opnsense';
|
||||
export const opnsenseDefaultPort = 443;
|
||||
export const opnsenseDefaultVerifySsl = false;
|
||||
export const opnsenseDefaultTimeoutMs = 10000;
|
||||
|
||||
export type TOpnsenseSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
|
||||
export type TOpnsenseHttpMethod = 'GET' | 'POST';
|
||||
export type TOpnsenseRouterAction = 'reboot' | 'halt' | 'firmware_update' | 'firmware_upgrade';
|
||||
export type TOpnsenseServiceAction = 'start' | 'stop' | 'restart';
|
||||
export type TOpnsenseToggleAction = 'toggle' | 'enable' | 'disable';
|
||||
export type TOpnsenseInterfaceAction = 'reload';
|
||||
export type TOpnsenseCommandType =
|
||||
| 'router.action'
|
||||
| 'service.action'
|
||||
| 'firewall.toggle'
|
||||
| 'nat.toggle'
|
||||
| 'alias.toggle'
|
||||
| 'vpn.toggle'
|
||||
| 'interface.reload'
|
||||
| 'notice.close'
|
||||
| 'wol.send'
|
||||
| 'firmware.action'
|
||||
| 'unbound.toggle'
|
||||
| 'speedtest.run'
|
||||
| 'switch.action';
|
||||
|
||||
export type TOpnsenseJsonValue = string | number | boolean | null | TOpnsenseJsonValue[] | {
|
||||
[key: string]: TOpnsenseJsonValue | undefined;
|
||||
};
|
||||
|
||||
export interface IOpnsenseConfig {
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
verify_ssl?: boolean;
|
||||
apiKey?: string;
|
||||
api_key?: string;
|
||||
apiSecret?: string;
|
||||
api_secret?: string;
|
||||
timeoutMs?: number;
|
||||
trackerInterfaces?: string[];
|
||||
tracker_interfaces?: string[];
|
||||
connected?: boolean;
|
||||
uniqueId?: string;
|
||||
name?: string;
|
||||
snapshot?: IOpnsenseSnapshot;
|
||||
router?: IOpnsenseRouterInfo;
|
||||
devices?: IOpnsenseClientDevice[];
|
||||
clients?: IOpnsenseClientDevice[];
|
||||
arpTable?: IOpnsenseArpEntry[];
|
||||
interfaces?: IOpnsenseInterfaceInfo[];
|
||||
gateways?: IOpnsenseGatewayInfo[];
|
||||
firewall?: IOpnsenseFirewallSnapshot;
|
||||
system?: IOpnsenseSystemInfo;
|
||||
telemetry?: IOpnsenseTelemetryInfo;
|
||||
services?: IOpnsenseServiceInfo[];
|
||||
vpn?: IOpnsenseVpnSnapshot;
|
||||
vpns?: IOpnsenseVpnSnapshot;
|
||||
sensors?: IOpnsenseSensorMap;
|
||||
switches?: IOpnsenseSwitchInfo[];
|
||||
actions?: IOpnsenseActionDescriptor[];
|
||||
manualEntries?: IOpnsenseManualEntry[];
|
||||
events?: IOpnsenseEvent[];
|
||||
snapshotProvider?: TOpnsenseSnapshotProvider;
|
||||
commandExecutor?: TOpnsenseCommandExecutor;
|
||||
nativeClient?: IOpnsenseNativeClient;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantOpnsenseConfig extends IOpnsenseConfig {}
|
||||
|
||||
export interface IOpnsenseRouterInfo {
|
||||
id?: string;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
name?: string;
|
||||
model?: string;
|
||||
firmware?: string;
|
||||
productVersion?: string;
|
||||
latestFirmware?: string;
|
||||
updateAvailable?: boolean;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
configurationUrl?: string;
|
||||
manufacturer?: string;
|
||||
actions?: TOpnsenseRouterAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IOpnsenseClientDevice {
|
||||
id?: string;
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
name?: string;
|
||||
hostname?: string | null;
|
||||
ip?: string;
|
||||
ipAddress?: string;
|
||||
address?: string;
|
||||
connected?: boolean;
|
||||
interface?: string;
|
||||
interfaceDescription?: string;
|
||||
intf_description?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
lastActivity?: string | number | Date;
|
||||
expires?: string | number | Date;
|
||||
leaseType?: string;
|
||||
type?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseArpEntry {
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
'mac-address'?: string;
|
||||
ip?: string;
|
||||
ipAddress?: string;
|
||||
'ip-address'?: string;
|
||||
address?: string;
|
||||
hostname?: string;
|
||||
interface?: string;
|
||||
intf_description?: string;
|
||||
interfaceDescription?: string;
|
||||
manufacturer?: string;
|
||||
expires?: string | number;
|
||||
type?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseInterfaceInfo {
|
||||
id?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
enabled?: boolean | string | number;
|
||||
connected?: boolean;
|
||||
mac?: string;
|
||||
macAddress?: string;
|
||||
ipv4?: string | null;
|
||||
ipv6?: string | null;
|
||||
media?: string | null;
|
||||
device?: string | null;
|
||||
gateways?: string[];
|
||||
routes?: unknown[];
|
||||
vlanTag?: string | number | null;
|
||||
rxBytes?: number;
|
||||
txBytes?: number;
|
||||
inbytes?: number;
|
||||
outbytes?: number;
|
||||
rxPackets?: number;
|
||||
txPackets?: number;
|
||||
inpkts?: number;
|
||||
outpkts?: number;
|
||||
inputErrors?: number;
|
||||
outputErrors?: number;
|
||||
inerrs?: number;
|
||||
outerrs?: number;
|
||||
collisions?: number;
|
||||
actions?: TOpnsenseInterfaceAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseGatewayInfo {
|
||||
id?: string;
|
||||
name: string;
|
||||
status?: string;
|
||||
address?: string;
|
||||
interface?: string;
|
||||
monitor?: string;
|
||||
delay?: number | string;
|
||||
rtt?: number | string;
|
||||
latency?: number | string;
|
||||
loss?: number | string;
|
||||
stddev?: number | string;
|
||||
disabled?: boolean | string | number;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseFirewallRule {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
description?: string;
|
||||
descr?: string;
|
||||
enabled?: boolean | string | number;
|
||||
disabled?: boolean | string | number;
|
||||
interface?: string;
|
||||
direction?: string;
|
||||
protocol?: string;
|
||||
action?: string;
|
||||
source?: unknown;
|
||||
destination?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseFirewallAlias {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
enabled?: boolean | string | number;
|
||||
disabled?: boolean | string | number;
|
||||
type?: string;
|
||||
content?: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseFirewallSnapshot {
|
||||
rules?: IOpnsenseFirewallRule[] | Record<string, IOpnsenseFirewallRule>;
|
||||
nat?: Record<string, IOpnsenseFirewallRule[] | Record<string, IOpnsenseFirewallRule> | undefined>;
|
||||
aliases?: IOpnsenseFirewallAlias[] | Record<string, IOpnsenseFirewallAlias>;
|
||||
state?: {
|
||||
used?: number;
|
||||
total?: number;
|
||||
usedPercent?: number;
|
||||
used_percent?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseSystemInfo {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
firmwareVersion?: string;
|
||||
productVersion?: string;
|
||||
productLatest?: string;
|
||||
updateAvailable?: boolean;
|
||||
uptime?: number;
|
||||
boottime?: string | number | Date;
|
||||
loadAverage?: {
|
||||
oneMinute?: number;
|
||||
fiveMinute?: number;
|
||||
fifteenMinute?: number;
|
||||
one_minute?: number;
|
||||
five_minute?: number;
|
||||
fifteen_minute?: number;
|
||||
};
|
||||
pendingNoticesPresent?: boolean;
|
||||
pending_notices_present?: boolean;
|
||||
pendingNotices?: IOpnsenseNotice[];
|
||||
pending_notices?: IOpnsenseNotice[];
|
||||
carp?: Record<string, unknown>;
|
||||
certificates?: Record<string, unknown>;
|
||||
speedtest?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseNotice {
|
||||
id?: string;
|
||||
notice?: string;
|
||||
createdAt?: string | number | Date;
|
||||
created_at?: string | number | Date;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseTelemetryInfo {
|
||||
cpu?: Record<string, unknown>;
|
||||
memory?: Record<string, unknown>;
|
||||
mbuf?: Record<string, unknown>;
|
||||
pfstate?: Record<string, unknown>;
|
||||
system?: Record<string, unknown>;
|
||||
filesystems?: Array<Record<string, unknown>>;
|
||||
temps?: Record<string, Record<string, unknown>>;
|
||||
vnstat?: Record<string, unknown>;
|
||||
speedtest?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseServiceInfo {
|
||||
id?: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
running?: boolean | string | number;
|
||||
status?: boolean | string | number;
|
||||
enabled?: boolean | string | number;
|
||||
locked?: boolean | string | number;
|
||||
actions?: TOpnsenseServiceAction[];
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseVpnSnapshot {
|
||||
openvpn?: {
|
||||
servers?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
|
||||
clients?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
wireguard?: {
|
||||
servers?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
|
||||
clients?: IOpnsenseVpnInstance[] | Record<string, IOpnsenseVpnInstance>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseVpnInstance {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
type?: 'openvpn' | 'wireguard' | string;
|
||||
role?: 'server' | 'client' | string;
|
||||
enabled?: boolean | string | number;
|
||||
status?: string;
|
||||
connected?: boolean;
|
||||
interface?: string;
|
||||
endpoint?: string;
|
||||
connectedClients?: number;
|
||||
connected_clients?: number;
|
||||
connectedServers?: number;
|
||||
connected_servers?: number;
|
||||
totalBytesRecv?: number;
|
||||
total_bytes_recv?: number;
|
||||
totalBytesSent?: number;
|
||||
total_bytes_sent?: number;
|
||||
latestHandshake?: string | number | Date;
|
||||
latest_handshake?: string | number | Date;
|
||||
clients?: Array<Record<string, unknown>>;
|
||||
servers?: Array<Record<string, unknown>>;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseSensorMap {
|
||||
connected_clients?: number;
|
||||
pending_notices?: boolean;
|
||||
cpu_usage?: number;
|
||||
memory_usage?: number;
|
||||
pf_state_used?: number;
|
||||
pf_state_used_percent?: number;
|
||||
mbuf_used_percent?: number;
|
||||
uptime?: number;
|
||||
[key: string]: string | number | boolean | Date | null | undefined;
|
||||
}
|
||||
|
||||
export interface IOpnsenseSwitchInfo {
|
||||
id?: string;
|
||||
name: string;
|
||||
enabled?: boolean | string | number;
|
||||
available?: boolean;
|
||||
nativeType?: string;
|
||||
action?: string;
|
||||
uuid?: string;
|
||||
service?: string;
|
||||
path?: string;
|
||||
method?: TOpnsenseHttpMethod;
|
||||
payload?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseActionDescriptor {
|
||||
target: 'router' | 'interface' | 'service' | 'firewall_rule' | 'nat_rule' | 'firewall_alias' | 'vpn' | 'switch' | 'firmware' | 'notice' | 'wol' | 'unbound' | 'speedtest';
|
||||
action: string;
|
||||
id?: string | number;
|
||||
uuid?: string;
|
||||
service?: string;
|
||||
interface?: string;
|
||||
mac?: string;
|
||||
vpnType?: 'openvpn' | 'wireguard' | string;
|
||||
vpnRole?: 'server' | 'client' | string;
|
||||
natRuleType?: string;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
path?: string;
|
||||
method?: TOpnsenseHttpMethod;
|
||||
payload?: Record<string, unknown>;
|
||||
label?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IOpnsenseSnapshot {
|
||||
connected: boolean;
|
||||
source?: TOpnsenseSnapshotSource;
|
||||
updatedAt?: string;
|
||||
router: IOpnsenseRouterInfo;
|
||||
devices: IOpnsenseClientDevice[];
|
||||
interfaces: IOpnsenseInterfaceInfo[];
|
||||
gateways: IOpnsenseGatewayInfo[];
|
||||
firewall: IOpnsenseFirewallSnapshot;
|
||||
system: IOpnsenseSystemInfo;
|
||||
telemetry: IOpnsenseTelemetryInfo;
|
||||
services: IOpnsenseServiceInfo[];
|
||||
vpn: IOpnsenseVpnSnapshot;
|
||||
sensors: IOpnsenseSensorMap;
|
||||
switches: IOpnsenseSwitchInfo[];
|
||||
actions?: IOpnsenseActionDescriptor[];
|
||||
events?: IOpnsenseEvent[];
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IOpnsenseManualEntry {
|
||||
id?: string;
|
||||
url?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
apiKey?: string;
|
||||
apiSecret?: string;
|
||||
trackerInterfaces?: string[];
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
macAddress?: string;
|
||||
snapshot?: IOpnsenseSnapshot;
|
||||
router?: IOpnsenseRouterInfo;
|
||||
devices?: IOpnsenseClientDevice[];
|
||||
clients?: IOpnsenseClientDevice[];
|
||||
arpTable?: IOpnsenseArpEntry[];
|
||||
interfaces?: IOpnsenseInterfaceInfo[];
|
||||
gateways?: IOpnsenseGatewayInfo[];
|
||||
firewall?: IOpnsenseFirewallSnapshot;
|
||||
system?: IOpnsenseSystemInfo;
|
||||
telemetry?: IOpnsenseTelemetryInfo;
|
||||
services?: IOpnsenseServiceInfo[];
|
||||
vpn?: IOpnsenseVpnSnapshot;
|
||||
vpns?: IOpnsenseVpnSnapshot;
|
||||
sensors?: IOpnsenseSensorMap;
|
||||
switches?: IOpnsenseSwitchInfo[];
|
||||
actions?: IOpnsenseActionDescriptor[];
|
||||
metadata?: Record<string, unknown>;
|
||||
integrationDomain?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseManualDiscoveryRecord extends IOpnsenseManualEntry {}
|
||||
|
||||
export interface IOpnsenseCommand {
|
||||
type: TOpnsenseCommandType;
|
||||
service: string;
|
||||
action: string;
|
||||
target: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
method?: TOpnsenseHttpMethod;
|
||||
path?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
routerId?: string;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
uuid?: string;
|
||||
serviceName?: string;
|
||||
interface?: string;
|
||||
mac?: string;
|
||||
vpnType?: string;
|
||||
vpnRole?: string;
|
||||
natRuleType?: string;
|
||||
}
|
||||
|
||||
export interface IOpnsenseCommandResult extends IServiceCallResult {}
|
||||
|
||||
export interface IOpnsenseEvent {
|
||||
type: string;
|
||||
timestamp?: number;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
command?: IOpnsenseCommand;
|
||||
snapshot?: IOpnsenseSnapshot;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IOpnsenseNativeClient {
|
||||
getSnapshot(): Promise<IOpnsenseSnapshot> | IOpnsenseSnapshot;
|
||||
executeCommand?(commandArg: IOpnsenseCommand): Promise<IOpnsenseCommandResult | unknown> | IOpnsenseCommandResult | unknown;
|
||||
destroy?(): Promise<void> | void;
|
||||
}
|
||||
|
||||
export type TOpnsenseSnapshotProvider = () => Promise<IOpnsenseSnapshot | undefined> | IOpnsenseSnapshot | undefined;
|
||||
export type TOpnsenseCommandExecutor = (
|
||||
commandArg: IOpnsenseCommand
|
||||
) => Promise<IOpnsenseCommandResult | unknown> | IOpnsenseCommandResult | unknown;
|
||||
|
||||
@@ -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 './pi_hole.classes.client.js';
|
||||
export * from './pi_hole.classes.configflow.js';
|
||||
export * from './pi_hole.classes.integration.js';
|
||||
export * from './pi_hole.discovery.js';
|
||||
export * from './pi_hole.mapper.js';
|
||||
export * from './pi_hole.types.js';
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { PiHoleMapper } from './pi_hole.mapper.js';
|
||||
import type {
|
||||
IPiHoleClientCommand,
|
||||
IPiHoleCommandResult,
|
||||
IPiHoleConfig,
|
||||
IPiHoleRawData,
|
||||
IPiHoleSnapshot,
|
||||
IPiHoleV5Summary,
|
||||
IPiHoleV5Versions,
|
||||
IPiHoleV6BlockingStatus,
|
||||
IPiHoleV6InfoVersionResponse,
|
||||
IPiHoleV6Summary,
|
||||
TPiHoleApiVersion,
|
||||
} from './pi_hole.types.js';
|
||||
import { piHoleDefaultLocation, piHoleDefaultPort, piHoleDefaultTimeoutMs } from './pi_hole.types.js';
|
||||
|
||||
export class PiHoleApiError extends Error {}
|
||||
export class PiHoleConnectionError extends PiHoleApiError {}
|
||||
export class PiHoleAuthorizationError extends PiHoleApiError {}
|
||||
|
||||
export class PiHoleClient {
|
||||
private currentSnapshot?: IPiHoleSnapshot;
|
||||
private sessionId?: string;
|
||||
private csrfToken?: string;
|
||||
private sessionValidUntil?: number;
|
||||
|
||||
constructor(private readonly config: IPiHoleConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<IPiHoleSnapshot> {
|
||||
if (this.hasManualData()) {
|
||||
this.currentSnapshot = PiHoleMapper.toSnapshot({
|
||||
config: this.config,
|
||||
source: this.config.snapshot ? 'snapshot' : 'manual',
|
||||
online: this.config.snapshot?.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 Pi-hole HTTP endpoint or snapshot/manual data is configured.');
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<{ success: boolean; snapshot: IPiHoleSnapshot; error?: string; data?: Record<string, unknown> }> {
|
||||
if (this.hasManualData()) {
|
||||
const snapshot = await this.getSnapshot();
|
||||
return { success: true, snapshot, data: { source: snapshot.source, apiVersion: snapshot.apiVersion } };
|
||||
}
|
||||
|
||||
if (!this.config.host) {
|
||||
const snapshot = await this.getSnapshot();
|
||||
return {
|
||||
success: false,
|
||||
snapshot,
|
||||
error: 'Pi-hole 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 sendCommand(commandArg: IPiHoleClientCommand): Promise<IPiHoleCommandResult> {
|
||||
if (this.config.commandExecutor) {
|
||||
return this.commandResult(await this.config.commandExecutor(commandArg), commandArg);
|
||||
}
|
||||
|
||||
if (commandArg.type === 'refresh') {
|
||||
const result = await this.refresh();
|
||||
return { success: result.success, error: result.error, data: result.snapshot };
|
||||
}
|
||||
|
||||
if (!this.config.host) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Pi-hole live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.',
|
||||
data: { command: commandArg },
|
||||
};
|
||||
}
|
||||
|
||||
const apiVersion = commandArg.apiVersion || this.currentSnapshot?.apiVersion || this.config.apiVersion || await this.detectApiVersion();
|
||||
if (commandArg.type === 'enable' || commandArg.type === 'disable') {
|
||||
const response = apiVersion === 6
|
||||
? await this.setV6Blocking(commandArg.type === 'enable', commandArg.durationSeconds)
|
||||
: await this.setV5Blocking(commandArg.type === 'enable', commandArg.durationSeconds);
|
||||
return { success: true, data: { command: { ...commandArg, apiVersion }, response } };
|
||||
}
|
||||
|
||||
return { success: false, error: `Unsupported Pi-hole command: ${commandArg.type}`, data: { command: commandArg } };
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
if (this.sessionId) {
|
||||
await this.logoutV6().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
public async fetchSnapshot(): Promise<IPiHoleSnapshot> {
|
||||
if (this.config.apiVersion === 5) {
|
||||
return this.fetchV5Snapshot();
|
||||
}
|
||||
if (this.config.apiVersion === 6) {
|
||||
return this.fetchV6Snapshot();
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.fetchV6Snapshot();
|
||||
} catch (errorArg) {
|
||||
if (errorArg instanceof PiHoleAuthorizationError) {
|
||||
throw errorArg;
|
||||
}
|
||||
return this.fetchV5Snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchV5Snapshot(): Promise<IPiHoleSnapshot> {
|
||||
const [summary, versions] = await Promise.all([
|
||||
this.requestV5<IPiHoleV5Summary | unknown[]>('summaryRaw'),
|
||||
this.requestV5<IPiHoleV5Versions>('versions'),
|
||||
]);
|
||||
|
||||
if (!summary || Array.isArray(summary) || typeof summary !== 'object') {
|
||||
throw new PiHoleAuthorizationError('Pi-hole v5 returned an unauthenticated or invalid summary response.');
|
||||
}
|
||||
if ('error' in summary) {
|
||||
throw new PiHoleApiError(`Pi-hole v5 summary returned an error: ${JSON.stringify(summary.error)}`);
|
||||
}
|
||||
|
||||
return PiHoleMapper.toSnapshot({
|
||||
config: this.config,
|
||||
rawData: { v5Summary: summary as IPiHoleV5Summary, v5Versions: versions },
|
||||
online: true,
|
||||
source: 'http',
|
||||
apiVersion: 5,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchV6Snapshot(): Promise<IPiHoleSnapshot> {
|
||||
await this.ensureV6Auth();
|
||||
const [summary, blocking, versions] = await Promise.all([
|
||||
this.requestV6<IPiHoleV6Summary>('/api/stats/summary'),
|
||||
this.requestV6<IPiHoleV6BlockingStatus>('/api/dns/blocking'),
|
||||
this.requestV6<IPiHoleV6InfoVersionResponse>('/api/info/version'),
|
||||
]);
|
||||
|
||||
return PiHoleMapper.toSnapshot({
|
||||
config: this.config,
|
||||
rawData: { v6Summary: summary, v6Blocking: blocking, v6Versions: versions },
|
||||
online: true,
|
||||
source: 'http',
|
||||
apiVersion: 6,
|
||||
});
|
||||
}
|
||||
|
||||
private async detectApiVersion(): Promise<TPiHoleApiVersion> {
|
||||
if (this.config.apiVersion) {
|
||||
return this.config.apiVersion;
|
||||
}
|
||||
if (this.currentSnapshot?.apiVersion) {
|
||||
return this.currentSnapshot.apiVersion;
|
||||
}
|
||||
const snapshot = await this.fetchSnapshot();
|
||||
this.currentSnapshot = snapshot;
|
||||
return snapshot.apiVersion || 6;
|
||||
}
|
||||
|
||||
private async setV5Blocking(enabledArg: boolean, durationSecondsArg?: number): Promise<unknown> {
|
||||
const apiKey = this.apiKey();
|
||||
if (!apiKey) {
|
||||
throw new PiHoleAuthorizationError('Pi-hole v5 enable/disable requires apiKey.');
|
||||
}
|
||||
const query = enabledArg ? 'enable=True' : `disable=${durationSecondsArg ?? true}`;
|
||||
const response = await this.requestV5<unknown>(query, false);
|
||||
this.currentSnapshot = await this.fetchV5Snapshot().catch(() => this.currentSnapshot);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async setV6Blocking(enabledArg: boolean, durationSecondsArg?: number): Promise<unknown> {
|
||||
await this.ensureV6Auth();
|
||||
const response = await this.requestV6<unknown>('/api/dns/blocking', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blocking: enabledArg, timer: enabledArg ? null : durationSecondsArg ?? null }),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
this.currentSnapshot = await this.fetchV6Snapshot().catch(() => this.currentSnapshot);
|
||||
return response;
|
||||
}
|
||||
|
||||
private async requestV5<TResponse>(queryArg: string, appendAuthArg = true): Promise<TResponse> {
|
||||
const auth = this.apiKey();
|
||||
const query = appendAuthArg || auth ? `${queryArg}&auth=${encodeURIComponent(auth || '')}` : queryArg;
|
||||
const url = new URL(this.v5ApiUrl());
|
||||
url.search = query;
|
||||
return this.requestJson<TResponse>(String(url), { method: 'GET' });
|
||||
}
|
||||
|
||||
private async requestV6<TResponse>(pathArg: string, initArg: RequestInit = {}, retryArg = true): Promise<TResponse> {
|
||||
const headers: Record<string, string> = {
|
||||
...(initArg.headers as Record<string, string> | undefined),
|
||||
};
|
||||
if (this.sessionId) {
|
||||
headers['X-FTL-SID'] = this.sessionId;
|
||||
if (this.csrfToken) {
|
||||
headers['X-FTL-CSRF'] = this.csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.requestJson<TResponse>(`${this.baseUrl()}${pathArg}`, { ...initArg, headers });
|
||||
} catch (errorArg) {
|
||||
if (retryArg && errorArg instanceof PiHoleAuthorizationError && this.sessionId) {
|
||||
this.sessionId = undefined;
|
||||
this.csrfToken = undefined;
|
||||
this.sessionValidUntil = undefined;
|
||||
await this.ensureV6Auth();
|
||||
return this.requestV6<TResponse>(pathArg, initArg, false);
|
||||
}
|
||||
throw errorArg;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureV6Auth(): Promise<void> {
|
||||
const password = this.apiKey();
|
||||
if (!password) {
|
||||
return;
|
||||
}
|
||||
if (this.sessionId && this.sessionValidUntil && Date.now() < this.sessionValidUntil) {
|
||||
return;
|
||||
}
|
||||
await this.authenticateV6(password);
|
||||
}
|
||||
|
||||
private async authenticateV6(passwordArg: string): Promise<void> {
|
||||
const response = await this.requestJson<{ session?: { valid?: boolean; sid?: string; csrf?: string; validity?: number } }>(`${this.baseUrl()}/api/auth`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password: passwordArg }),
|
||||
});
|
||||
const session = response.session;
|
||||
if (!session?.valid || !session.sid) {
|
||||
throw new PiHoleAuthorizationError('Pi-hole v6 authentication did not return a valid session.');
|
||||
}
|
||||
this.sessionId = session.sid;
|
||||
this.csrfToken = session.csrf;
|
||||
this.sessionValidUntil = Date.now() + Math.max((session.validity || 300) - 5, 1) * 1000;
|
||||
}
|
||||
|
||||
private async logoutV6(): Promise<void> {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
await this.requestJson<unknown>(`${this.baseUrl()}/api/auth`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-FTL-SID': this.sessionId },
|
||||
}).catch(() => undefined);
|
||||
this.sessionId = undefined;
|
||||
this.csrfToken = undefined;
|
||||
this.sessionValidUntil = undefined;
|
||||
}
|
||||
|
||||
private async requestJson<TResponse>(urlArg: string, initArg: RequestInit): Promise<TResponse> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await globalThis.fetch(urlArg, {
|
||||
...initArg,
|
||||
signal: AbortSignal.timeout(this.config.timeoutMs || piHoleDefaultTimeoutMs),
|
||||
});
|
||||
} catch (errorArg) {
|
||||
throw new PiHoleConnectionError(`Connection to ${urlArg} failed: ${this.errorMessage(errorArg)}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new PiHoleAuthorizationError('Pi-hole authentication failed.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new PiHoleApiError(`Pi-hole request ${urlArg} failed with HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
if (!text) {
|
||||
return {} as TResponse;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as TResponse;
|
||||
} catch (errorArg) {
|
||||
throw new PiHoleConnectionError(`Unable to parse Pi-hole response from ${urlArg}: ${this.errorMessage(errorArg)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private offlineSnapshot(errorArg: string): IPiHoleSnapshot {
|
||||
return PiHoleMapper.toSnapshot({
|
||||
config: this.config,
|
||||
online: false,
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
});
|
||||
}
|
||||
|
||||
private hasManualData(): boolean {
|
||||
return Boolean(
|
||||
this.config.snapshot
|
||||
|| this.config.rawData
|
||||
|| this.config.v5Summary
|
||||
|| this.config.v5Versions
|
||||
|| this.config.v6Summary
|
||||
|| this.config.v6Blocking
|
||||
|| this.config.v6Versions
|
||||
|| this.config.status !== undefined
|
||||
|| this.config.statistics
|
||||
|| this.config.versions
|
||||
);
|
||||
}
|
||||
|
||||
private v5ApiUrl(): string {
|
||||
return `${this.baseUrl()}/${this.location()}/api.php`;
|
||||
}
|
||||
|
||||
private baseUrl(): string {
|
||||
if (!this.config.host) {
|
||||
throw new PiHoleConnectionError('Pi-hole host is required for HTTP API access.');
|
||||
}
|
||||
const protocol = this.config.ssl ? 'https' : 'http';
|
||||
const port = this.config.port || (this.config.ssl ? 443 : piHoleDefaultPort);
|
||||
const defaultPort = protocol === 'https' ? 443 : 80;
|
||||
return `${protocol}://${this.hostForUrl(this.config.host)}${port === defaultPort ? '' : `:${port}`}`;
|
||||
}
|
||||
|
||||
private hostForUrl(hostArg: string): string {
|
||||
return hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
}
|
||||
|
||||
private location(): string {
|
||||
const location = this.config.location || piHoleDefaultLocation;
|
||||
return location.trim().replace(/^\/+|\/+$/g, '') || piHoleDefaultLocation;
|
||||
}
|
||||
|
||||
private apiKey(): string | undefined {
|
||||
return this.stringValue(this.config.apiKey) || this.stringValue(this.config.password);
|
||||
}
|
||||
|
||||
private stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: IPiHoleClientCommand): IPiHoleCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is IPiHoleCommandResult {
|
||||
return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg;
|
||||
}
|
||||
|
||||
private errorMessage(errorArg: unknown): string {
|
||||
return errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: IPiHoleSnapshot): IPiHoleSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as IPiHoleSnapshot;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { IPiHoleConfig, IPiHoleSnapshot, TPiHoleApiVersion } from './pi_hole.types.js';
|
||||
import { piHoleDefaultLocation, piHoleDefaultName, piHoleDefaultPort, piHoleDefaultTimeoutMs } from './pi_hole.types.js';
|
||||
|
||||
export class PiHoleConfigFlow implements IConfigFlow<IPiHoleConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IPiHoleConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Pi-hole',
|
||||
description: 'Configure a local Pi-hole HTTP API endpoint. Runtime writes are only reported successful after a real HTTP call or an explicit command executor.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'Host or URL', type: 'text', required: true },
|
||||
{ name: 'port', label: 'Port', type: 'number' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'location', label: 'Admin location', type: 'text' },
|
||||
{ name: 'apiKey', label: 'App password or API key', type: 'password', required: true },
|
||||
{ name: 'ssl', label: 'Use HTTPS', type: 'boolean' },
|
||||
{ name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' },
|
||||
{ name: 'apiVersion', label: 'API version', type: 'select', options: [
|
||||
{ label: 'Auto (v6 then v5)', value: 'auto' },
|
||||
{ label: 'v6', value: '6' },
|
||||
{ label: 'v5', value: '5' },
|
||||
] },
|
||||
],
|
||||
submit: async (valuesArg) => this.submit(candidateArg, valuesArg),
|
||||
};
|
||||
}
|
||||
|
||||
private async submit(candidateArg: IDiscoveryCandidate, valuesArg: Record<string, unknown>): Promise<IConfigFlowStep<IPiHoleConfig>> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const parsed = parseHostInput(this.stringValue(valuesArg.host) || candidateArg.host || this.stringValue(metadata.url) || '');
|
||||
const host = parsed.host || candidateArg.host;
|
||||
const snapshot = this.snapshotValue(metadata.snapshot);
|
||||
const rawData = this.recordValue(metadata.rawData);
|
||||
const port = this.numberValue(valuesArg.port) || parsed.port || candidateArg.port || piHoleDefaultPort;
|
||||
const ssl = this.booleanValue(valuesArg.ssl) ?? parsed.ssl ?? this.booleanValue(metadata.ssl) ?? false;
|
||||
const verifySsl = this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? true;
|
||||
const location = this.stringValue(valuesArg.location) || parsed.location || this.stringValue(metadata.location) || piHoleDefaultLocation;
|
||||
const apiKey = this.stringValue(valuesArg.apiKey) || this.stringValue(metadata.apiKey) || this.stringValue(metadata.password);
|
||||
const apiVersion = this.apiVersionValue(valuesArg.apiVersion) || this.apiVersionValue(metadata.apiVersion);
|
||||
|
||||
if (!host && !snapshot && !rawData) {
|
||||
return { kind: 'error', title: 'Pi-hole setup failed', error: 'Pi-hole setup requires a host, URL, or snapshot/manual data.' };
|
||||
}
|
||||
if (!this.validPort(port)) {
|
||||
return { kind: 'error', title: 'Pi-hole setup failed', error: 'Pi-hole port must be an integer between 1 and 65535.' };
|
||||
}
|
||||
if (host && !apiKey) {
|
||||
return { kind: 'error', title: 'Pi-hole setup failed', error: 'Pi-hole App password or API key is required for HTTP API access.' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Pi-hole configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
ssl,
|
||||
verifySsl,
|
||||
location,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || this.stringValue(metadata.name) || piHoleDefaultName,
|
||||
uniqueId: candidateArg.id || (host ? `${host}:${port}` : undefined),
|
||||
timeoutMs: piHoleDefaultTimeoutMs,
|
||||
snapshot,
|
||||
rawData,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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): TPiHoleApiVersion | undefined {
|
||||
const value = typeof valueArg === 'number' ? String(valueArg) : this.stringValue(valueArg);
|
||||
if (value === '5' || value === '6') {
|
||||
return Number(value) as TPiHoleApiVersion;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private validPort(valueArg: number): boolean {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
}
|
||||
|
||||
private snapshotValue(valueArg: unknown): IPiHoleSnapshot | undefined {
|
||||
const record = this.recordValue(valueArg);
|
||||
return record && 'statistics' in record && 'status' in record ? record as unknown as IPiHoleSnapshot : undefined;
|
||||
}
|
||||
|
||||
private recordValue(valueArg: unknown): Record<string, unknown> | undefined {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const parseHostInput = (valueArg: string): { host?: string; port?: number; ssl?: boolean; location?: string } => {
|
||||
const value = valueArg.trim();
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
if (!value.includes('://')) {
|
||||
const hostPort = value.match(/^([^/:]+):(\d+)$/);
|
||||
return hostPort ? { host: hostPort[1], port: Number(hostPort[2]) } : { host: value };
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
const location = locationFromPath(parsed.pathname);
|
||||
return {
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
ssl: parsed.protocol === 'https:',
|
||||
location,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const locationFromPath = (pathArg: string): string | undefined => {
|
||||
const parts = pathArg.split('/').filter(Boolean);
|
||||
if (!parts.length || parts[0] === 'api') {
|
||||
return undefined;
|
||||
}
|
||||
return parts[0];
|
||||
};
|
||||
@@ -1,26 +1,101 @@
|
||||
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 { PiHoleClient } from './pi_hole.classes.client.js';
|
||||
import { PiHoleConfigFlow } from './pi_hole.classes.configflow.js';
|
||||
import { createPiHoleDiscoveryDescriptor } from './pi_hole.discovery.js';
|
||||
import { PiHoleMapper } from './pi_hole.mapper.js';
|
||||
import type { IPiHoleConfig } from './pi_hole.types.js';
|
||||
import { piHoleDomain } from './pi_hole.types.js';
|
||||
|
||||
export class HomeAssistantPiHoleIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "pi_hole",
|
||||
displayName: "Pi-hole",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/pi_hole",
|
||||
"upstreamDomain": "pi_hole",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"hole==0.9.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@shenxn"
|
||||
]
|
||||
export class PiHoleIntegration extends BaseIntegration<IPiHoleConfig> {
|
||||
public readonly domain = piHoleDomain;
|
||||
public readonly displayName = 'Pi-hole';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createPiHoleDiscoveryDescriptor();
|
||||
public readonly configFlow = new PiHoleConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/pi_hole',
|
||||
upstreamDomain: piHoleDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['hole==0.9.0'],
|
||||
dependencies: [] as string[],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@shenxn'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/pi_hole',
|
||||
protocolSource: 'Pi-hole HTTP APIs: v5 /admin/api.php summaryRaw/versions/enable/disable and v6 /api/auth, /api/stats/summary, /api/dns/blocking, /api/info/version.',
|
||||
runtime: {
|
||||
type: 'control-runtime',
|
||||
polling: 'local Pi-hole HTTP API',
|
||||
services: ['snapshot', 'status', 'refresh', 'enable', 'disable'],
|
||||
controls: ['dns_blocking'],
|
||||
liveCommandSuccessRequiresClientOrExecutor: true,
|
||||
},
|
||||
});
|
||||
localApi: {
|
||||
implemented: [
|
||||
'Pi-hole API v6 authenticated summary, blocking status, version, enable, and disable endpoints',
|
||||
'Pi-hole API v5 summaryRaw, versions, enable, and disable endpoints',
|
||||
'manual raw API data and normalized snapshot inputs',
|
||||
'status, statistics, update, and DNS-blocking switch entity mappings',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'Home Assistant Python hole compatibility wrapper',
|
||||
'fake live enable/disable success without a configured HTTP endpoint or command executor',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: IPiHoleConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new PiHoleRuntime(new PiHoleClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantPiHoleIntegration extends PiHoleIntegration {}
|
||||
|
||||
class PiHoleRuntime implements IIntegrationRuntime {
|
||||
public domain = piHoleDomain;
|
||||
|
||||
constructor(private readonly client: PiHoleClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return PiHoleMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return PiHoleMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
try {
|
||||
if (requestArg.domain === piHoleDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === piHoleDomain && requestArg.service === 'refresh') {
|
||||
const result = await this.client.refresh();
|
||||
return { success: result.success, error: result.error, data: result.snapshot };
|
||||
}
|
||||
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = PiHoleMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Pi-hole service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
if ('error' in command) {
|
||||
return { success: false, error: command.error };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
} 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,193 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { IPiHoleHttpCandidateRecord, IPiHoleManualEntry, IPiHoleSnapshot } from './pi_hole.types.js';
|
||||
import { piHoleDefaultLocation, piHoleDefaultName, piHoleDefaultPort, piHoleDomain } from './pi_hole.types.js';
|
||||
|
||||
export class PiHoleManualMatcher implements IDiscoveryMatcher<IPiHoleManualEntry> {
|
||||
public id = 'pi-hole-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Pi-hole local HTTP and snapshot setup entries.';
|
||||
|
||||
public async matches(inputArg: IPiHoleManualEntry): Promise<IDiscoveryMatch> {
|
||||
const parsedUrl = parseUrl(inputArg.url);
|
||||
const metadata = inputArg.metadata || {};
|
||||
const hasManualData = Boolean(inputArg.snapshot || inputArg.rawData || metadata.snapshot || metadata.rawData || inputArg.statistics || inputArg.status || inputArg.versions);
|
||||
const matched = isPiHoleHint(inputArg) || Boolean(inputArg.host || parsedUrl || hasManualData);
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Pi-hole setup hints.' };
|
||||
}
|
||||
|
||||
const host = inputArg.host || parsedUrl?.host;
|
||||
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? booleanMetadata(metadata.ssl) ?? false;
|
||||
const port = inputArg.port || parsedUrl?.port || piHoleDefaultPort;
|
||||
const id = inputArg.id || snapshotId(inputArg.snapshot || metadata.snapshot) || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host || hasManualData ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Pi-hole setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: piHoleDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || piHoleDefaultName,
|
||||
manufacturer: 'Pi-hole',
|
||||
model: inputArg.model || 'Pi-hole',
|
||||
metadata: {
|
||||
...metadata,
|
||||
piHole: true,
|
||||
ssl,
|
||||
verifySsl: inputArg.verifySsl ?? metadata.verifySsl,
|
||||
location: inputArg.location || parsedUrl?.location || metadata.location || piHoleDefaultLocation,
|
||||
apiKey: inputArg.apiKey ?? metadata.apiKey,
|
||||
password: inputArg.password ?? metadata.password,
|
||||
apiVersion: inputArg.apiVersion || metadata.apiVersion || parsedUrl?.apiVersion,
|
||||
url: inputArg.url,
|
||||
snapshot: inputArg.snapshot || metadata.snapshot,
|
||||
rawData: inputArg.rawData || metadata.rawData,
|
||||
hasManualData,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PiHoleHttpMatcher implements IDiscoveryMatcher<IPiHoleHttpCandidateRecord> {
|
||||
public id = 'pi-hole-http-match';
|
||||
public source = 'http' as const;
|
||||
public description = 'Recognize local HTTP candidates that point at a Pi-hole API.';
|
||||
|
||||
public async matches(recordArg: IPiHoleHttpCandidateRecord): 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?.location === piHoleDefaultLocation || metadata.piHole || metadata.pi_hole || metadata.pihole || text.includes('pi-hole') || text.includes('pihole'));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Pi-hole API.' };
|
||||
}
|
||||
const host = recordArg.host || parsedUrl?.host;
|
||||
const ssl = recordArg.ssl ?? parsedUrl?.ssl ?? false;
|
||||
const port = recordArg.port || parsedUrl?.port || piHoleDefaultPort;
|
||||
const id = host ? `${host}:${port}` : undefined;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: parsedUrl?.apiVersion && host ? 'high' : host ? 'medium' : 'low',
|
||||
reason: 'HTTP candidate has Pi-hole API hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'http',
|
||||
integrationDomain: piHoleDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: recordArg.name || piHoleDefaultName,
|
||||
manufacturer: recordArg.manufacturer || 'Pi-hole',
|
||||
model: recordArg.model || 'Pi-hole',
|
||||
metadata: {
|
||||
...metadata,
|
||||
piHole: true,
|
||||
ssl,
|
||||
url,
|
||||
location: parsedUrl?.location || metadata.location || piHoleDefaultLocation,
|
||||
apiVersion: parsedUrl?.apiVersion || metadata.apiVersion,
|
||||
headers,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class PiHoleCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'pi-hole-candidate-validator';
|
||||
public description = 'Validate Pi-hole candidates have a usable HTTP endpoint or snapshot/manual data.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const matched = candidateArg.integrationDomain === piHoleDomain || isPiHoleHint(candidateArg);
|
||||
const hasManualData = Boolean(metadata.snapshot || metadata.rawData || metadata.statistics || metadata.status || metadata.versions);
|
||||
const port = candidateArg.port || piHoleDefaultPort;
|
||||
const hasUsableAddress = Boolean(candidateArg.host && isValidPort(port));
|
||||
if (!matched || (!hasUsableAddress && !hasManualData)) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Pi-hole candidate lacks a usable host or snapshot/manual data.' : 'Candidate is not Pi-hole.',
|
||||
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 Pi-hole metadata and a usable HTTP endpoint or snapshot/manual data.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
integrationDomain: piHoleDomain,
|
||||
port,
|
||||
manufacturer: candidateArg.manufacturer || 'Pi-hole',
|
||||
model: candidateArg.model || 'Pi-hole',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createPiHoleDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: piHoleDomain, displayName: 'Pi-hole' })
|
||||
.addMatcher(new PiHoleManualMatcher())
|
||||
.addMatcher(new PiHoleHttpMatcher())
|
||||
.addValidator(new PiHoleCandidateValidator());
|
||||
};
|
||||
|
||||
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; location?: string; apiVersion?: 5 | 6 } | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
const path = url.pathname.toLowerCase();
|
||||
const apiVersion = path.includes('/api.php') ? 5 : path.startsWith('/api/') || path === '/api' ? 6 : undefined;
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
ssl: url.protocol === 'https:',
|
||||
location: parts[0] && parts[0] !== 'api' ? parts[0] : undefined,
|
||||
apiVersion,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const isPiHoleHint = (valueArg: { integrationDomain?: string; manufacturer?: string; model?: string; name?: string; metadata?: Record<string, unknown> }): boolean => {
|
||||
const text = [valueArg.integrationDomain, valueArg.manufacturer, valueArg.model, valueArg.name].filter(Boolean).join(' ').toLowerCase();
|
||||
return valueArg.integrationDomain === piHoleDomain
|
||||
|| text.includes('pi-hole')
|
||||
|| text.includes('pihole')
|
||||
|| Boolean(valueArg.metadata?.piHole || valueArg.metadata?.pi_hole || valueArg.metadata?.pihole);
|
||||
};
|
||||
|
||||
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 isValidPort = (valueArg: number): boolean => Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
|
||||
const booleanMetadata = (valueArg: unknown): boolean | undefined => typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||
|
||||
const isPiHoleSnapshot = (valueArg: unknown): valueArg is IPiHoleSnapshot => Boolean(valueArg && typeof valueArg === 'object' && 'statistics' in valueArg && 'status' in valueArg);
|
||||
|
||||
const snapshotId = (valueArg: unknown): string | undefined => {
|
||||
const snapshot = isPiHoleSnapshot(valueArg) ? valueArg : undefined;
|
||||
return snapshot?.uniqueId || snapshot?.host;
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js';
|
||||
import type {
|
||||
IPiHoleClientCommand,
|
||||
IPiHoleConfig,
|
||||
IPiHoleRawData,
|
||||
IPiHoleSnapshot,
|
||||
IPiHoleStatistics,
|
||||
IPiHoleVersions,
|
||||
TPiHoleApiVersion,
|
||||
TPiHoleBlockingStatus,
|
||||
TPiHoleSnapshotSource,
|
||||
} from './pi_hole.types.js';
|
||||
import { piHoleDefaultLocation, piHoleDefaultName, piHoleDefaultPort, piHoleDomain } from './pi_hole.types.js';
|
||||
|
||||
interface IPiHoleSnapshotOptions {
|
||||
config: IPiHoleConfig;
|
||||
rawData?: IPiHoleRawData;
|
||||
online?: boolean;
|
||||
source?: TPiHoleSnapshotSource;
|
||||
apiVersion?: TPiHoleApiVersion;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface IPiHoleStatDescription {
|
||||
key: keyof IPiHoleStatistics;
|
||||
entityKey: string;
|
||||
name: string;
|
||||
unit?: string;
|
||||
precision?: number;
|
||||
}
|
||||
|
||||
const releaseBaseUrls = {
|
||||
core: 'https://github.com/pi-hole/pi-hole/releases/tag',
|
||||
web: 'https://github.com/pi-hole/AdminLTE/releases/tag',
|
||||
ftl: 'https://github.com/pi-hole/FTL/releases/tag',
|
||||
};
|
||||
|
||||
const v5StatisticDescriptions: IPiHoleStatDescription[] = [
|
||||
{ key: 'adsBlocked', entityKey: 'ads_blocked_today', name: 'Ads blocked today', unit: 'ads', precision: 0 },
|
||||
{ key: 'adsPercentage', entityKey: 'ads_percentage_today', name: 'Ads percentage blocked today', unit: '%', precision: 1 },
|
||||
{ key: 'clientsSeen', entityKey: 'clients_ever_seen', name: 'Seen clients', unit: 'clients', precision: 0 },
|
||||
{ key: 'dnsQueries', entityKey: 'dns_queries_today', name: 'DNS queries today', unit: 'queries', precision: 0 },
|
||||
{ key: 'domainsBlocked', entityKey: 'domains_being_blocked', name: 'Domains blocked', unit: 'domains', precision: 0 },
|
||||
{ key: 'queriesCached', entityKey: 'queries_cached', name: 'DNS queries cached', unit: 'queries', precision: 0 },
|
||||
{ key: 'queriesForwarded', entityKey: 'queries_forwarded', name: 'DNS queries forwarded', unit: 'queries', precision: 0 },
|
||||
{ key: 'uniqueClients', entityKey: 'unique_clients', name: 'DNS unique clients', unit: 'clients', precision: 0 },
|
||||
{ key: 'uniqueDomains', entityKey: 'unique_domains', name: 'DNS unique domains', unit: 'domains', precision: 0 },
|
||||
];
|
||||
|
||||
const v6StatisticDescriptions: IPiHoleStatDescription[] = [
|
||||
{ key: 'adsBlocked', entityKey: 'ads_blocked', name: 'Ads blocked', unit: 'ads', precision: 0 },
|
||||
{ key: 'adsPercentage', entityKey: 'percent_ads_blocked', name: 'Ads percentage blocked', unit: '%', precision: 2 },
|
||||
{ key: 'clientsSeen', entityKey: 'clients_ever_seen', name: 'Seen clients', unit: 'clients', precision: 0 },
|
||||
{ key: 'dnsQueries', entityKey: 'dns_queries', name: 'DNS queries', unit: 'queries', precision: 0 },
|
||||
{ key: 'domainsBlocked', entityKey: 'domains_being_blocked', name: 'Domains blocked', unit: 'domains', precision: 0 },
|
||||
{ key: 'queriesCached', entityKey: 'queries_cached', name: 'DNS queries cached', unit: 'queries', precision: 0 },
|
||||
{ key: 'queriesForwarded', entityKey: 'queries_forwarded', name: 'DNS queries forwarded', unit: 'queries', precision: 0 },
|
||||
{ key: 'uniqueClients', entityKey: 'unique_clients', name: 'DNS unique clients', unit: 'clients', precision: 0 },
|
||||
{ key: 'uniqueDomains', entityKey: 'unique_domains', name: 'DNS unique domains', unit: 'domains', precision: 0 },
|
||||
];
|
||||
|
||||
const updateDescriptions: Array<{ key: keyof IPiHoleVersions; name: string; title: string }> = [
|
||||
{ key: 'core', name: 'Core update available', title: 'Pi-hole Core' },
|
||||
{ key: 'web', name: 'Web update available', title: 'Pi-hole Web interface' },
|
||||
{ key: 'ftl', name: 'FTL update available', title: 'Pi-hole FTL DNS' },
|
||||
];
|
||||
|
||||
export class PiHoleMapper {
|
||||
public static toSnapshot(optionsArg: IPiHoleSnapshotOptions): IPiHoleSnapshot {
|
||||
if (optionsArg.config.snapshot && !optionsArg.rawData) {
|
||||
return this.normalizeSnapshot(optionsArg.config.snapshot, optionsArg.config, optionsArg.source || 'snapshot');
|
||||
}
|
||||
|
||||
const rawData = this.rawData(optionsArg.config, optionsArg.rawData);
|
||||
const apiVersion = optionsArg.apiVersion || optionsArg.config.apiVersion || this.versionFromRaw(rawData);
|
||||
const statistics = this.statisticsFromRaw(rawData, optionsArg.config.statistics);
|
||||
const versions = this.versionsFromRaw(rawData, optionsArg.config.versions);
|
||||
const status = this.statusFromRaw(rawData, optionsArg.config.status);
|
||||
const online = optionsArg.online ?? optionsArg.config.online ?? Boolean(optionsArg.rawData || optionsArg.config.rawData || optionsArg.config.v5Summary || optionsArg.config.v6Summary || optionsArg.config.status || optionsArg.config.statistics || optionsArg.config.versions);
|
||||
|
||||
return {
|
||||
online,
|
||||
apiVersion,
|
||||
status,
|
||||
statistics,
|
||||
versions,
|
||||
raw: rawData,
|
||||
host: optionsArg.config.host,
|
||||
port: optionsArg.config.port || (optionsArg.config.host ? this.defaultPort(optionsArg.config.ssl) : undefined),
|
||||
ssl: optionsArg.config.ssl ?? false,
|
||||
verifySsl: optionsArg.config.verifySsl ?? true,
|
||||
location: optionsArg.config.location || piHoleDefaultLocation,
|
||||
name: optionsArg.config.name || piHoleDefaultName,
|
||||
uniqueId: optionsArg.config.uniqueId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: optionsArg.source || (Object.keys(rawData).length ? 'manual' : 'runtime'),
|
||||
error: optionsArg.error,
|
||||
};
|
||||
}
|
||||
|
||||
public static toDevices(snapshotArg: IPiHoleSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const features: plugins.shxInterfaces.data.IDeviceFeature[] = [
|
||||
{ id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false },
|
||||
{ id: 'blocking', capability: 'switch', name: 'DNS blocking', readable: true, writable: true },
|
||||
];
|
||||
const state: plugins.shxInterfaces.data.IDeviceState[] = [
|
||||
{ featureId: 'connectivity', value: snapshotArg.online ? 'online' : 'offline', updatedAt },
|
||||
{ featureId: 'blocking', value: snapshotArg.status === 'enabled', updatedAt },
|
||||
];
|
||||
|
||||
for (const description of this.statisticDescriptions(snapshotArg)) {
|
||||
const value = this.statisticValue(snapshotArg.statistics, description.key, description.precision);
|
||||
features.push({ id: description.entityKey, capability: 'sensor', name: description.name, readable: true, writable: false, unit: description.unit });
|
||||
state.push({ featureId: description.entityKey, value, updatedAt });
|
||||
}
|
||||
|
||||
for (const description of updateDescriptions) {
|
||||
const update = snapshotArg.versions[description.key];
|
||||
features.push({ id: `${description.key}_update_available`, capability: 'sensor', name: description.name, readable: true, writable: false });
|
||||
state.push({ featureId: `${description.key}_update_available`, value: Boolean(update.updateAvailable), updatedAt });
|
||||
}
|
||||
|
||||
return [{
|
||||
id: deviceId,
|
||||
integrationDomain: piHoleDomain,
|
||||
name: this.deviceName(snapshotArg),
|
||||
protocol: snapshotArg.host ? 'http' : 'unknown',
|
||||
manufacturer: 'Pi-hole',
|
||||
model: snapshotArg.apiVersion ? `Pi-hole API v${snapshotArg.apiVersion}` : 'Pi-hole',
|
||||
online: snapshotArg.online,
|
||||
features,
|
||||
state,
|
||||
metadata: this.cleanAttributes({
|
||||
host: snapshotArg.host,
|
||||
port: snapshotArg.port,
|
||||
ssl: snapshotArg.ssl,
|
||||
location: snapshotArg.location,
|
||||
apiVersion: snapshotArg.apiVersion,
|
||||
source: snapshotArg.source,
|
||||
error: snapshotArg.error,
|
||||
}),
|
||||
}];
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: IPiHoleSnapshot): IIntegrationEntity[] {
|
||||
const deviceId = this.deviceId(snapshotArg);
|
||||
const baseName = this.deviceName(snapshotArg);
|
||||
const baseSlug = this.slug(baseName);
|
||||
const uniqueBase = this.uniqueBase(snapshotArg);
|
||||
const entities: IIntegrationEntity[] = [
|
||||
this.entity('binary_sensor', `${baseName} Status`, deviceId, `${uniqueBase}_status`, this.statusState(snapshotArg.status), snapshotArg.online, {
|
||||
deviceClass: 'running',
|
||||
piHoleStatus: snapshotArg.status,
|
||||
apiVersion: snapshotArg.apiVersion,
|
||||
}),
|
||||
{
|
||||
id: `switch.${baseSlug}`,
|
||||
uniqueId: `${piHoleDomain}_${uniqueBase}_switch`,
|
||||
integrationDomain: piHoleDomain,
|
||||
deviceId,
|
||||
platform: 'switch',
|
||||
name: baseName,
|
||||
state: this.statusState(snapshotArg.status),
|
||||
attributes: this.cleanAttributes({
|
||||
piHoleSwitch: 'blocking',
|
||||
writable: true,
|
||||
apiVersion: snapshotArg.apiVersion,
|
||||
}),
|
||||
available: snapshotArg.online && snapshotArg.status !== 'unknown',
|
||||
},
|
||||
];
|
||||
|
||||
for (const description of this.statisticDescriptions(snapshotArg)) {
|
||||
const value = this.statisticValue(snapshotArg.statistics, description.key, description.precision);
|
||||
entities.push(this.entity('sensor', `${baseName} ${description.name}`, deviceId, `${uniqueBase}_sensor_${description.entityKey}`, value, snapshotArg.online && value !== null, {
|
||||
unit: description.unit,
|
||||
stateClass: typeof value === 'number' ? 'measurement' : undefined,
|
||||
apiVersion: snapshotArg.apiVersion,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const description of updateDescriptions) {
|
||||
const update = snapshotArg.versions[description.key];
|
||||
const latestVersion = update.updateAvailable ? update.latest : update.current || update.latest;
|
||||
entities.push(this.entity('update', `${baseName} ${description.name}`, deviceId, `${uniqueBase}_update_${description.key}`, update.updateAvailable ? 'on' : 'off', snapshotArg.online && Boolean(update.current || update.latest), {
|
||||
title: description.title,
|
||||
entityCategory: 'diagnostic',
|
||||
installedVersion: update.current,
|
||||
latestVersion,
|
||||
releaseUrl: latestVersion ? `${releaseBaseUrls[description.key]}/${latestVersion}` : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static commandForService(snapshotArg: IPiHoleSnapshot, requestArg: IServiceCallRequest): IPiHoleClientCommand | { error: string } | undefined {
|
||||
if (requestArg.domain === piHoleDomain) {
|
||||
if (requestArg.service === 'refresh') {
|
||||
return this.command(snapshotArg, 'refresh', requestArg, undefined);
|
||||
}
|
||||
if (requestArg.service === 'enable' || requestArg.service === 'disable') {
|
||||
return this.command(snapshotArg, requestArg.service, requestArg, undefined);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) {
|
||||
const target = this.targetSwitch(snapshotArg, requestArg);
|
||||
if ('error' in target) {
|
||||
return target;
|
||||
}
|
||||
return this.command(snapshotArg, requestArg.service === 'turn_on' ? 'enable' : 'disable', requestArg, target.entity);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public static deviceId(snapshotArg: IPiHoleSnapshot): string {
|
||||
return `${piHoleDomain}.service.${this.uniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string): string {
|
||||
return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'pi_hole';
|
||||
}
|
||||
|
||||
private static normalizeSnapshot(snapshotArg: IPiHoleSnapshot, configArg: IPiHoleConfig, sourceArg: TPiHoleSnapshotSource): IPiHoleSnapshot {
|
||||
const raw = this.rawData(configArg, snapshotArg.raw);
|
||||
return {
|
||||
...snapshotArg,
|
||||
online: snapshotArg.online,
|
||||
apiVersion: snapshotArg.apiVersion || configArg.apiVersion || this.versionFromRaw(raw),
|
||||
status: snapshotArg.status || this.statusFromRaw(raw, configArg.status),
|
||||
statistics: this.completeStatistics(snapshotArg.statistics || this.statisticsFromRaw(raw, configArg.statistics)),
|
||||
versions: this.completeVersions(snapshotArg.versions || this.versionsFromRaw(raw, configArg.versions)),
|
||||
raw,
|
||||
host: snapshotArg.host || configArg.host,
|
||||
port: snapshotArg.port || configArg.port || (snapshotArg.host || configArg.host ? this.defaultPort(snapshotArg.ssl ?? configArg.ssl) : undefined),
|
||||
ssl: snapshotArg.ssl ?? configArg.ssl ?? false,
|
||||
verifySsl: snapshotArg.verifySsl ?? configArg.verifySsl ?? true,
|
||||
location: snapshotArg.location || configArg.location || piHoleDefaultLocation,
|
||||
name: snapshotArg.name || configArg.name || piHoleDefaultName,
|
||||
uniqueId: snapshotArg.uniqueId || configArg.uniqueId,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: snapshotArg.source || sourceArg,
|
||||
error: snapshotArg.error,
|
||||
};
|
||||
}
|
||||
|
||||
private static rawData(configArg: IPiHoleConfig, rawDataArg?: IPiHoleRawData): IPiHoleRawData {
|
||||
return this.cleanAttributes({
|
||||
...(configArg.rawData || {}),
|
||||
...(rawDataArg || {}),
|
||||
v5Summary: rawDataArg?.v5Summary || configArg.v5Summary || configArg.rawData?.v5Summary,
|
||||
v5Versions: rawDataArg?.v5Versions || configArg.v5Versions || configArg.rawData?.v5Versions,
|
||||
v6Summary: rawDataArg?.v6Summary || configArg.v6Summary || configArg.rawData?.v6Summary,
|
||||
v6Blocking: rawDataArg?.v6Blocking || configArg.v6Blocking || configArg.rawData?.v6Blocking,
|
||||
v6Versions: rawDataArg?.v6Versions || configArg.v6Versions || configArg.rawData?.v6Versions,
|
||||
}) as IPiHoleRawData;
|
||||
}
|
||||
|
||||
private static statisticsFromRaw(rawDataArg: IPiHoleRawData, overrideArg?: Partial<IPiHoleStatistics>): IPiHoleStatistics {
|
||||
const queries = this.recordValue(rawDataArg.v6Summary?.queries);
|
||||
const clients = this.recordValue(rawDataArg.v6Summary?.clients);
|
||||
const gravity = this.recordValue(rawDataArg.v6Summary?.gravity);
|
||||
const statistics = this.completeStatistics({
|
||||
adsBlocked: this.numberValue(rawDataArg.v5Summary?.ads_blocked_today) ?? this.numberValue(queries?.blocked),
|
||||
adsPercentage: this.numberValue(rawDataArg.v5Summary?.ads_percentage_today) ?? this.numberValue(queries?.percent_blocked),
|
||||
clientsSeen: this.numberValue(rawDataArg.v5Summary?.clients_ever_seen) ?? this.numberValue(clients?.total),
|
||||
dnsQueries: this.numberValue(rawDataArg.v5Summary?.dns_queries_today) ?? this.numberValue(queries?.total),
|
||||
domainsBlocked: this.numberValue(rawDataArg.v5Summary?.domains_being_blocked) ?? this.numberValue(gravity?.domains_being_blocked),
|
||||
queriesCached: this.numberValue(rawDataArg.v5Summary?.queries_cached) ?? this.numberValue(queries?.cached),
|
||||
queriesForwarded: this.numberValue(rawDataArg.v5Summary?.queries_forwarded) ?? this.numberValue(queries?.forwarded),
|
||||
uniqueClients: this.numberValue(rawDataArg.v5Summary?.unique_clients) ?? this.numberValue(clients?.active),
|
||||
uniqueDomains: this.numberValue(rawDataArg.v5Summary?.unique_domains) ?? this.numberValue(queries?.unique_domains),
|
||||
});
|
||||
return this.completeStatistics({ ...statistics, ...(overrideArg || {}) });
|
||||
}
|
||||
|
||||
private static versionsFromRaw(rawDataArg: IPiHoleRawData, overrideArg?: Partial<IPiHoleVersions>): IPiHoleVersions {
|
||||
const versionRoot = rawDataArg.v6Versions?.version;
|
||||
const versions = this.completeVersions({
|
||||
core: {
|
||||
current: this.stringValue(rawDataArg.v5Versions?.core_current) || this.stringValue(versionRoot?.core?.local?.version),
|
||||
latest: this.stringValue(rawDataArg.v5Versions?.core_latest) || this.stringValue(versionRoot?.core?.remote?.version),
|
||||
updateAvailable: this.booleanValue(rawDataArg.v5Versions?.core_update) ?? this.updateAvailable(versionRoot?.core),
|
||||
},
|
||||
web: {
|
||||
current: this.stringValue(rawDataArg.v5Versions?.web_current) || this.stringValue(versionRoot?.web?.local?.version),
|
||||
latest: this.stringValue(rawDataArg.v5Versions?.web_latest) || this.stringValue(versionRoot?.web?.remote?.version),
|
||||
updateAvailable: this.booleanValue(rawDataArg.v5Versions?.web_update) ?? this.updateAvailable(versionRoot?.web),
|
||||
},
|
||||
ftl: {
|
||||
current: this.stringValue(rawDataArg.v5Versions?.FTL_current) || this.stringValue(versionRoot?.ftl?.local?.version),
|
||||
latest: this.stringValue(rawDataArg.v5Versions?.FTL_latest) || this.stringValue(versionRoot?.ftl?.remote?.version),
|
||||
updateAvailable: this.booleanValue(rawDataArg.v5Versions?.FTL_update) ?? this.updateAvailable(versionRoot?.ftl),
|
||||
},
|
||||
});
|
||||
|
||||
return this.completeVersions({
|
||||
core: { ...versions.core, ...(overrideArg?.core || {}) },
|
||||
web: { ...versions.web, ...(overrideArg?.web || {}) },
|
||||
ftl: { ...versions.ftl, ...(overrideArg?.ftl || {}) },
|
||||
});
|
||||
}
|
||||
|
||||
private static statusFromRaw(rawDataArg: IPiHoleRawData, overrideArg?: string | boolean): TPiHoleBlockingStatus {
|
||||
return this.normalizeStatus(overrideArg ?? rawDataArg.v5Summary?.status ?? rawDataArg.v6Blocking?.blocking);
|
||||
}
|
||||
|
||||
private static versionFromRaw(rawDataArg: IPiHoleRawData): TPiHoleApiVersion | undefined {
|
||||
if (rawDataArg.v6Summary || rawDataArg.v6Blocking || rawDataArg.v6Versions) return 6;
|
||||
if (rawDataArg.v5Summary || rawDataArg.v5Versions) return 5;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static command(snapshotArg: IPiHoleSnapshot, serviceArg: 'enable' | 'disable' | 'refresh', requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined): IPiHoleClientCommand | { error: string } {
|
||||
if (serviceArg === 'refresh') {
|
||||
return {
|
||||
type: 'refresh',
|
||||
service: requestArg.service,
|
||||
target: requestArg.target,
|
||||
entityId: entityArg?.id || requestArg.target.entityId,
|
||||
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
|
||||
uniqueId: entityArg?.uniqueId,
|
||||
apiVersion: snapshotArg.apiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
const durationSeconds = serviceArg === 'disable' ? this.durationSeconds(requestArg.data?.duration) : undefined;
|
||||
if (durationSeconds === null) {
|
||||
return { error: 'Pi-hole disable requires data.duration as seconds or HH:MM:SS when provided.' };
|
||||
}
|
||||
|
||||
const apiVersion = snapshotArg.apiVersion || 6;
|
||||
const enabled = serviceArg === 'enable';
|
||||
return this.cleanAttributes({
|
||||
type: serviceArg,
|
||||
service: requestArg.service,
|
||||
method: apiVersion === 6 ? 'POST' : 'GET',
|
||||
path: apiVersion === 6 ? '/api/dns/blocking' : `/${snapshotArg.location || piHoleDefaultLocation}/api.php`,
|
||||
query: apiVersion === 5 ? enabled ? { enable: 'True' } : { disable: durationSeconds ?? true } : undefined,
|
||||
payload: apiVersion === 6 ? { blocking: enabled, timer: enabled ? null : durationSeconds ?? null } : undefined,
|
||||
target: requestArg.target,
|
||||
entityId: entityArg?.id || requestArg.target.entityId,
|
||||
deviceId: entityArg?.deviceId || requestArg.target.deviceId,
|
||||
uniqueId: entityArg?.uniqueId,
|
||||
apiVersion,
|
||||
enabled,
|
||||
durationSeconds,
|
||||
requiresAuth: true,
|
||||
}) as IPiHoleClientCommand;
|
||||
}
|
||||
|
||||
private static targetSwitch(snapshotArg: IPiHoleSnapshot, requestArg: IServiceCallRequest): { entity?: IIntegrationEntity } | { error: string } {
|
||||
const entity = this.findTargetEntity(snapshotArg, requestArg);
|
||||
if (entity?.attributes?.piHoleSwitch === 'blocking') {
|
||||
return { entity };
|
||||
}
|
||||
if (requestArg.target.deviceId && requestArg.target.deviceId === this.deviceId(snapshotArg)) {
|
||||
return { entity };
|
||||
}
|
||||
return { error: 'Pi-hole switch service calls require the Pi-hole switch entity or device target.' };
|
||||
}
|
||||
|
||||
private static findTargetEntity(snapshotArg: IPiHoleSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined {
|
||||
if (!requestArg.target.entityId) {
|
||||
return undefined;
|
||||
}
|
||||
return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId);
|
||||
}
|
||||
|
||||
private static statisticDescriptions(snapshotArg: IPiHoleSnapshot): IPiHoleStatDescription[] {
|
||||
return snapshotArg.apiVersion === 6 ? v6StatisticDescriptions : v5StatisticDescriptions;
|
||||
}
|
||||
|
||||
private static statisticValue(statisticsArg: IPiHoleStatistics, keyArg: keyof IPiHoleStatistics, precisionArg = 2): number | null {
|
||||
const value = statisticsArg[keyArg];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const factor = 10 ** precisionArg;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
private static entity(platformArg: IIntegrationEntity['platform'], nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, availableArg: boolean, attributesArg: Record<string, unknown> = {}): IIntegrationEntity {
|
||||
return {
|
||||
id: `${platformArg}.${this.slug(nameArg)}`,
|
||||
uniqueId: `${piHoleDomain}_${uniqueIdArg}`,
|
||||
integrationDomain: piHoleDomain,
|
||||
deviceId: deviceIdArg,
|
||||
platform: platformArg,
|
||||
name: nameArg,
|
||||
state: stateArg,
|
||||
attributes: this.cleanAttributes(attributesArg),
|
||||
available: availableArg,
|
||||
};
|
||||
}
|
||||
|
||||
private static statusState(statusArg: TPiHoleBlockingStatus): 'on' | 'off' | 'unknown' {
|
||||
if (statusArg === 'enabled') return 'on';
|
||||
if (statusArg === 'disabled') return 'off';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private static normalizeStatus(valueArg: unknown): TPiHoleBlockingStatus {
|
||||
if (valueArg === true) return 'enabled';
|
||||
if (valueArg === false) return 'disabled';
|
||||
const value = typeof valueArg === 'string' ? valueArg.toLowerCase() : '';
|
||||
if (value === 'enabled' || value === 'enable' || value === 'true') return 'enabled';
|
||||
if (value === 'disabled' || value === 'disable' || value === 'false') return 'disabled';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
private static completeStatistics(valueArg: Partial<IPiHoleStatistics>): IPiHoleStatistics {
|
||||
return {
|
||||
adsBlocked: this.numberOrNull(valueArg.adsBlocked),
|
||||
adsPercentage: this.numberOrNull(valueArg.adsPercentage),
|
||||
clientsSeen: this.numberOrNull(valueArg.clientsSeen),
|
||||
dnsQueries: this.numberOrNull(valueArg.dnsQueries),
|
||||
domainsBlocked: this.numberOrNull(valueArg.domainsBlocked),
|
||||
queriesCached: this.numberOrNull(valueArg.queriesCached),
|
||||
queriesForwarded: this.numberOrNull(valueArg.queriesForwarded),
|
||||
uniqueClients: this.numberOrNull(valueArg.uniqueClients),
|
||||
uniqueDomains: this.numberOrNull(valueArg.uniqueDomains),
|
||||
};
|
||||
}
|
||||
|
||||
private static completeVersions(valueArg: Partial<IPiHoleVersions>): IPiHoleVersions {
|
||||
return {
|
||||
core: { ...(valueArg.core || {}) },
|
||||
web: { ...(valueArg.web || {}) },
|
||||
ftl: { ...(valueArg.ftl || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
private static updateAvailable(componentArg: { local?: { hash?: string; version?: string }; remote?: { hash?: string; version?: string } } | undefined): boolean | undefined {
|
||||
if (!componentArg) return undefined;
|
||||
const localHash = this.stringValue(componentArg.local?.hash);
|
||||
const remoteHash = this.stringValue(componentArg.remote?.hash);
|
||||
if (localHash && remoteHash) return localHash !== remoteHash;
|
||||
const localVersion = this.stringValue(componentArg.local?.version);
|
||||
const remoteVersion = this.stringValue(componentArg.remote?.version);
|
||||
return localVersion && remoteVersion ? localVersion !== remoteVersion : undefined;
|
||||
}
|
||||
|
||||
private static durationSeconds(valueArg: unknown): number | undefined | null {
|
||||
if (valueArg === undefined || valueArg === null || valueArg === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg >= 0) {
|
||||
return Math.round(valueArg);
|
||||
}
|
||||
if (typeof valueArg !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = valueArg.trim();
|
||||
if (/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
||||
return Math.round(Number(trimmed));
|
||||
}
|
||||
const parts = trimmed.split(':').map((partArg) => Number(partArg));
|
||||
if (parts.length < 2 || parts.length > 3 || parts.some((partArg) => !Number.isInteger(partArg) || partArg < 0)) {
|
||||
return null;
|
||||
}
|
||||
const [hours, minutes, seconds] = parts.length === 3 ? parts : [0, parts[0], parts[1]];
|
||||
if (minutes > 59 || seconds > 59) {
|
||||
return null;
|
||||
}
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
private static deviceName(snapshotArg: IPiHoleSnapshot): string {
|
||||
return snapshotArg.name || piHoleDefaultName;
|
||||
}
|
||||
|
||||
private static uniqueBase(snapshotArg: IPiHoleSnapshot): string {
|
||||
return this.slug(snapshotArg.uniqueId || snapshotArg.host && `${snapshotArg.host}_${snapshotArg.port || ''}` || this.deviceName(snapshotArg));
|
||||
}
|
||||
|
||||
private static defaultPort(sslArg: boolean | undefined): number {
|
||||
return sslArg ? 443 : piHoleDefaultPort;
|
||||
}
|
||||
|
||||
private static recordValue(valueArg: unknown): Record<string, unknown> | undefined {
|
||||
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
private static numberValue(valueArg: unknown): number | undefined {
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return valueArg;
|
||||
if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) return Number(valueArg);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static numberOrNull(valueArg: unknown): number | null {
|
||||
return this.numberValue(valueArg) ?? null;
|
||||
}
|
||||
|
||||
private static stringValue(valueArg: unknown): string | undefined {
|
||||
return typeof valueArg === 'string' && valueArg ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private static booleanValue(valueArg: unknown): boolean | undefined {
|
||||
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||
}
|
||||
|
||||
private static cleanAttributes<T extends Record<string, unknown>>(attributesArg: T): T {
|
||||
return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,225 @@
|
||||
export interface IHomeAssistantPiHoleConfig {
|
||||
// TODO: replace with the TypeScript-native config for pi_hole.
|
||||
import type { IServiceCallResult } from '../../core/types.js';
|
||||
|
||||
export const piHoleDomain = 'pi_hole';
|
||||
export const piHoleDefaultName = 'Pi-hole';
|
||||
export const piHoleDefaultLocation = 'admin';
|
||||
export const piHoleDefaultPort = 80;
|
||||
export const piHoleDefaultTimeoutMs = 5000;
|
||||
|
||||
export type TPiHoleApiVersion = 5 | 6;
|
||||
export type TPiHoleSnapshotSource = 'snapshot' | 'http' | 'manual' | 'runtime';
|
||||
export type TPiHoleBlockingStatus = 'enabled' | 'disabled' | 'unknown';
|
||||
export type TPiHoleHttpMethod = 'GET' | 'POST';
|
||||
export type TPiHoleCommandType = 'enable' | 'disable' | 'refresh';
|
||||
export type TPiHoleJsonValue = string | number | boolean | null | TPiHoleJsonValue[] | {
|
||||
[key: string]: TPiHoleJsonValue | undefined;
|
||||
};
|
||||
|
||||
export interface IPiHoleStatistics {
|
||||
adsBlocked: number | null;
|
||||
adsPercentage: number | null;
|
||||
clientsSeen: number | null;
|
||||
dnsQueries: number | null;
|
||||
domainsBlocked: number | null;
|
||||
queriesCached: number | null;
|
||||
queriesForwarded: number | null;
|
||||
uniqueClients: number | null;
|
||||
uniqueDomains: number | null;
|
||||
}
|
||||
|
||||
export interface IPiHoleComponentVersion {
|
||||
current?: string;
|
||||
latest?: string;
|
||||
updateAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface IPiHoleVersions {
|
||||
core: IPiHoleComponentVersion;
|
||||
web: IPiHoleComponentVersion;
|
||||
ftl: IPiHoleComponentVersion;
|
||||
}
|
||||
|
||||
export interface IPiHoleV5Summary {
|
||||
status?: string;
|
||||
ads_blocked_today?: number;
|
||||
ads_percentage_today?: number;
|
||||
clients_ever_seen?: number;
|
||||
dns_queries_today?: number;
|
||||
domains_being_blocked?: number;
|
||||
queries_cached?: number;
|
||||
queries_forwarded?: number;
|
||||
unique_clients?: number;
|
||||
unique_domains?: number;
|
||||
error?: TPiHoleJsonValue;
|
||||
[key: string]: TPiHoleJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleV5Versions {
|
||||
FTL_current?: string;
|
||||
FTL_latest?: string;
|
||||
FTL_update?: boolean;
|
||||
core_current?: string;
|
||||
core_latest?: string;
|
||||
core_update?: boolean;
|
||||
web_current?: string;
|
||||
web_latest?: string;
|
||||
web_update?: boolean;
|
||||
[key: string]: TPiHoleJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleV6Summary {
|
||||
queries?: Record<string, TPiHoleJsonValue | undefined>;
|
||||
clients?: Record<string, TPiHoleJsonValue | undefined>;
|
||||
gravity?: Record<string, TPiHoleJsonValue | undefined>;
|
||||
[key: string]: TPiHoleJsonValue | Record<string, TPiHoleJsonValue | undefined> | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleV6BlockingStatus {
|
||||
blocking?: string | boolean;
|
||||
timer?: number | null;
|
||||
[key: string]: TPiHoleJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleV6VersionSide {
|
||||
version?: string;
|
||||
hash?: string;
|
||||
branch?: string;
|
||||
[key: string]: TPiHoleJsonValue | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleV6ComponentVersion {
|
||||
local?: IPiHoleV6VersionSide;
|
||||
remote?: IPiHoleV6VersionSide;
|
||||
[key: string]: TPiHoleJsonValue | IPiHoleV6VersionSide | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleV6InfoVersionResponse {
|
||||
version?: {
|
||||
core?: IPiHoleV6ComponentVersion;
|
||||
web?: IPiHoleV6ComponentVersion;
|
||||
ftl?: IPiHoleV6ComponentVersion;
|
||||
[key: string]: TPiHoleJsonValue | IPiHoleV6ComponentVersion | undefined;
|
||||
};
|
||||
[key: string]: TPiHoleJsonValue | IPiHoleV6InfoVersionResponse['version'] | undefined;
|
||||
}
|
||||
|
||||
export interface IPiHoleRawData {
|
||||
v5Summary?: IPiHoleV5Summary;
|
||||
v5Versions?: IPiHoleV5Versions;
|
||||
v6Summary?: IPiHoleV6Summary;
|
||||
v6Blocking?: IPiHoleV6BlockingStatus;
|
||||
v6Versions?: IPiHoleV6InfoVersionResponse;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IPiHoleSnapshot {
|
||||
online: boolean;
|
||||
apiVersion?: TPiHoleApiVersion;
|
||||
status: TPiHoleBlockingStatus;
|
||||
statistics: IPiHoleStatistics;
|
||||
versions: IPiHoleVersions;
|
||||
raw?: IPiHoleRawData;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
location?: string;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
updatedAt?: string;
|
||||
source?: TPiHoleSnapshotSource;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface IPiHoleClientCommand {
|
||||
type: TPiHoleCommandType;
|
||||
service: string;
|
||||
method?: TPiHoleHttpMethod;
|
||||
path?: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
payload?: Record<string, unknown>;
|
||||
target?: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
uniqueId?: string;
|
||||
apiVersion?: TPiHoleApiVersion;
|
||||
enabled?: boolean;
|
||||
durationSeconds?: number;
|
||||
requiresAuth?: boolean;
|
||||
}
|
||||
|
||||
export interface IPiHoleCommandResult extends IServiceCallResult {}
|
||||
|
||||
export type TPiHoleCommandExecutor = (
|
||||
commandArg: IPiHoleClientCommand
|
||||
) => Promise<IPiHoleCommandResult | unknown> | IPiHoleCommandResult | unknown;
|
||||
|
||||
export interface IPiHoleConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
location?: string;
|
||||
apiKey?: string;
|
||||
password?: string;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
apiVersion?: TPiHoleApiVersion;
|
||||
timeoutMs?: number;
|
||||
snapshot?: IPiHoleSnapshot;
|
||||
rawData?: IPiHoleRawData;
|
||||
v5Summary?: IPiHoleV5Summary;
|
||||
v5Versions?: IPiHoleV5Versions;
|
||||
v6Summary?: IPiHoleV6Summary;
|
||||
v6Blocking?: IPiHoleV6BlockingStatus;
|
||||
v6Versions?: IPiHoleV6InfoVersionResponse;
|
||||
status?: string | boolean;
|
||||
statistics?: Partial<IPiHoleStatistics>;
|
||||
versions?: Partial<IPiHoleVersions>;
|
||||
online?: boolean;
|
||||
commandExecutor?: TPiHoleCommandExecutor;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IPiHoleManualEntry {
|
||||
id?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
location?: string;
|
||||
apiKey?: string;
|
||||
password?: string;
|
||||
apiVersion?: TPiHoleApiVersion;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
integrationDomain?: string;
|
||||
snapshot?: IPiHoleSnapshot;
|
||||
rawData?: IPiHoleRawData;
|
||||
status?: string | boolean;
|
||||
statistics?: Partial<IPiHoleStatistics>;
|
||||
versions?: Partial<IPiHoleVersions>;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IPiHoleHttpCandidateRecord {
|
||||
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>;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantPiHoleConfig extends IPiHoleConfig {}
|
||||
|
||||
@@ -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 './squeezebox.classes.integration.js';
|
||||
export * from './squeezebox.classes.client.js';
|
||||
export * from './squeezebox.classes.configflow.js';
|
||||
export * from './squeezebox.discovery.js';
|
||||
export * from './squeezebox.mapper.js';
|
||||
export * from './squeezebox.types.js';
|
||||
|
||||
@@ -0,0 +1,839 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type {
|
||||
ISqueezeboxAlarm,
|
||||
ISqueezeboxCliResponse,
|
||||
ISqueezeboxCommandRequest,
|
||||
ISqueezeboxConfig,
|
||||
ISqueezeboxFavorite,
|
||||
ISqueezeboxJsonRpcRequest,
|
||||
ISqueezeboxJsonRpcResponse,
|
||||
ISqueezeboxPlayer,
|
||||
ISqueezeboxServerInfo,
|
||||
ISqueezeboxSnapshot,
|
||||
ISqueezeboxSyncGroup,
|
||||
ISqueezeboxTrack,
|
||||
TSqueezeboxSnapshotSource,
|
||||
} from './squeezebox.types.js';
|
||||
import { squeezeboxDefaultBrowseLimit, squeezeboxDefaultCliPort, squeezeboxDefaultHttpPort, squeezeboxDefaultTimeoutMs, squeezeboxDefaultVolumeStep } from './squeezebox.types.js';
|
||||
|
||||
export class SqueezeboxCommandError extends Error {
|
||||
constructor(public readonly command: string[], messageArg: string) {
|
||||
super(`Squeezebox command ${command.join(' ')} failed: ${messageArg}`);
|
||||
this.name = 'SqueezeboxCommandError';
|
||||
}
|
||||
}
|
||||
|
||||
export class SqueezeboxClient {
|
||||
private nextId = 1;
|
||||
private currentSnapshot?: ISqueezeboxSnapshot;
|
||||
private restorePoint?: ISqueezeboxSnapshot;
|
||||
|
||||
constructor(private readonly config: ISqueezeboxConfig) {
|
||||
this.currentSnapshot = config.snapshot ? this.normalizeSnapshot(this.cloneSnapshot(config.snapshot), 'snapshot') : undefined;
|
||||
}
|
||||
|
||||
public async getSnapshot(): Promise<ISqueezeboxSnapshot> {
|
||||
if (this.currentSnapshot) {
|
||||
return this.normalizeSnapshot(this.cloneSnapshot(this.currentSnapshot), this.currentSnapshot.source || 'snapshot');
|
||||
}
|
||||
if (!this.config.host && !this.config.commandExecutor) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, 'Squeezebox refresh requires config.host, config.snapshot, or commandExecutor.'), 'runtime');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.fetchSnapshot();
|
||||
} catch (errorArg) {
|
||||
return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime');
|
||||
}
|
||||
}
|
||||
|
||||
public async validateConnection(): Promise<ISqueezeboxSnapshot> {
|
||||
const snapshot = await this.fetchSnapshot();
|
||||
if (!snapshot.server.uuid && !snapshot.server.id) {
|
||||
throw new Error('Lyrion Music Server did not provide a unique identifier.');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public async execute(requestArg: ISqueezeboxCommandRequest): Promise<unknown> {
|
||||
if (requestArg.command === 'play') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['play']);
|
||||
}
|
||||
if (requestArg.command === 'pause') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['pause', '1']);
|
||||
}
|
||||
if (requestArg.command === 'play_pause') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['pause']);
|
||||
}
|
||||
if (requestArg.command === 'stop') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['stop']);
|
||||
}
|
||||
if (requestArg.command === 'next_track') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['playlist', 'index', '+1']);
|
||||
}
|
||||
if (requestArg.command === 'previous_track') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['playlist', 'index', '-1']);
|
||||
}
|
||||
if (requestArg.command === 'seek') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['time', String(this.requiredNumber(requestArg.position, 'Squeezebox seek requires position.'))]);
|
||||
}
|
||||
if (requestArg.command === 'set_power') {
|
||||
if (typeof requestArg.powered !== 'boolean') {
|
||||
throw new Error('Squeezebox set_power requires powered.');
|
||||
}
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['power', requestArg.powered ? '1' : '0']);
|
||||
}
|
||||
if (requestArg.command === 'set_volume') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', String(this.volumePercent(requestArg))]);
|
||||
}
|
||||
if (requestArg.command === 'volume_up') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', `+${this.volumeStep(requestArg.step)}`]);
|
||||
}
|
||||
if (requestArg.command === 'volume_down') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'volume', `-${this.volumeStep(requestArg.step)}`]);
|
||||
}
|
||||
if (requestArg.command === 'mute') {
|
||||
if (typeof requestArg.muted !== 'boolean') {
|
||||
throw new Error('Squeezebox mute requires muted.');
|
||||
}
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['mixer', 'muting', requestArg.muted ? '1' : '0']);
|
||||
}
|
||||
if (requestArg.command === 'select_source') {
|
||||
return this.selectSource(this.requiredPlayerId(requestArg), this.requiredString(requestArg.source, 'Squeezebox select_source requires source.'));
|
||||
}
|
||||
if (requestArg.command === 'play_media') {
|
||||
return this.playMedia(this.requiredPlayerId(requestArg), this.requiredString(requestArg.mediaId, 'Squeezebox play_media requires mediaId.'), requestArg.enqueue);
|
||||
}
|
||||
if (requestArg.command === 'sync') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['sync', this.requiredString(requestArg.targetPlayerId, 'Squeezebox sync requires targetPlayerId.')]);
|
||||
}
|
||||
if (requestArg.command === 'unsync') {
|
||||
return this.playerQuery(this.requiredPlayerId(requestArg), ['sync', '-']);
|
||||
}
|
||||
if (requestArg.command === 'raw_query') {
|
||||
const params = requestArg.parameters || [];
|
||||
if (!params.length || params.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
|
||||
throw new Error('Squeezebox raw_query requires string, number, or boolean parameters.');
|
||||
}
|
||||
return this.playerQuery(requestArg.playerId, params.map((itemArg) => String(itemArg)));
|
||||
}
|
||||
throw new Error(`Unsupported Squeezebox command: ${requestArg.command}`);
|
||||
}
|
||||
|
||||
public async query(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
|
||||
return this.playerQuery(playerIdArg, commandArg);
|
||||
}
|
||||
|
||||
public async snapshot(): Promise<ISqueezeboxSnapshot> {
|
||||
this.restorePoint = await this.getSnapshot();
|
||||
return this.cloneSnapshot(this.restorePoint);
|
||||
}
|
||||
|
||||
public async restore(snapshotArg = this.restorePoint): Promise<void> {
|
||||
if (!snapshotArg) {
|
||||
throw new Error('Squeezebox restore requires a prior snapshot.');
|
||||
}
|
||||
for (const player of snapshotArg.players) {
|
||||
if (typeof player.volume === 'number') {
|
||||
await this.execute({ command: 'set_volume', playerId: player.playerId, volume: player.volume });
|
||||
}
|
||||
if (typeof player.muting === 'boolean') {
|
||||
await this.execute({ command: 'mute', playerId: player.playerId, muted: player.muting });
|
||||
}
|
||||
if (typeof player.power === 'boolean') {
|
||||
await this.execute({ command: 'set_power', playerId: player.playerId, powered: player.power });
|
||||
}
|
||||
if (player.mode === 'play') {
|
||||
await this.execute({ command: 'play', playerId: player.playerId });
|
||||
} else if (player.mode === 'pause') {
|
||||
await this.execute({ command: 'pause', playerId: player.playerId });
|
||||
} else if (player.mode === 'stop') {
|
||||
await this.execute({ command: 'stop', playerId: player.playerId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
|
||||
private async fetchSnapshot(): Promise<ISqueezeboxSnapshot> {
|
||||
const serverStatus = await this.playerQuery(undefined, ['serverstatus', '-', '-', 'prefs:libraryname']);
|
||||
const players = await this.playersFromStatus(serverStatus);
|
||||
const [favorites, syncGroups] = await Promise.all([
|
||||
this.fetchFavorites().catch(() => []),
|
||||
this.fetchSyncGroups(players).catch(() => this.syncGroupsFromPlayers(players)),
|
||||
]);
|
||||
const source = this.config.commandExecutor ? 'executor' : this.config.transport === 'cli' ? 'cli' : 'jsonrpc';
|
||||
return this.normalizeSnapshot({
|
||||
server: this.serverFromStatus(serverStatus),
|
||||
players,
|
||||
favorites,
|
||||
syncGroups,
|
||||
online: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source,
|
||||
raw: { serverStatus },
|
||||
}, source);
|
||||
}
|
||||
|
||||
private async playersFromStatus(serverStatusArg: Record<string, unknown>): Promise<ISqueezeboxPlayer[]> {
|
||||
const loop = arrayRecords(valueForKeys(serverStatusArg, ['players_loop', 'player_loop', 'players']));
|
||||
const basePlayers = loop.length ? loop.map((itemArg) => this.playerFromData(itemArg)) : await this.fetchPlayers();
|
||||
const refreshed = await Promise.all(basePlayers.map(async (playerArg) => ({
|
||||
...playerArg,
|
||||
...(await this.fetchPlayerStatus(playerArg.playerId).catch(() => undefined)),
|
||||
})));
|
||||
return refreshed.map((playerArg) => this.normalizePlayer(playerArg));
|
||||
}
|
||||
|
||||
private async fetchPlayers(): Promise<ISqueezeboxPlayer[]> {
|
||||
const response = await this.playerQuery(undefined, ['players', '0', String(this.config.browseLimit || squeezeboxDefaultBrowseLimit)]);
|
||||
const loop = arrayRecords(valueForKeys(response, ['players_loop', 'player_loop', 'players']));
|
||||
return loop.map((itemArg) => this.playerFromData(itemArg));
|
||||
}
|
||||
|
||||
private async fetchPlayerStatus(playerIdArg: string): Promise<Partial<ISqueezeboxPlayer>> {
|
||||
const response = await this.playerQuery(playerIdArg, ['status', '-', '1', 'tags:adKlJytxN']);
|
||||
return this.playerFromData({ ...response, playerid: playerIdArg });
|
||||
}
|
||||
|
||||
private async fetchFavorites(): Promise<ISqueezeboxFavorite[]> {
|
||||
const response = await this.playerQuery(undefined, ['favorites', 'items', '0', String(this.config.browseLimit || squeezeboxDefaultBrowseLimit)]);
|
||||
const loop = arrayRecords(valueForKeys(response, ['loop_loop', 'favorites_loop', 'items_loop', 'items', 'favorites']));
|
||||
return loop.map((itemArg, indexArg) => this.favoriteFromData(itemArg, indexArg));
|
||||
}
|
||||
|
||||
private async fetchSyncGroups(playersArg: ISqueezeboxPlayer[]): Promise<ISqueezeboxSyncGroup[]> {
|
||||
const response = await this.playerQuery(undefined, ['syncgroups', '?']);
|
||||
const loop = arrayRecords(valueForKeys(response, ['syncgroups_loop', 'sync_groups', 'groups']));
|
||||
const groups = loop.map((itemArg, indexArg) => this.syncGroupFromData(itemArg, indexArg));
|
||||
return groups.length ? groups : this.syncGroupsFromPlayers(playersArg);
|
||||
}
|
||||
|
||||
private async selectSource(playerIdArg: string, sourceArg: string): Promise<unknown> {
|
||||
const snapshot = await this.getSnapshot();
|
||||
const favorite = (snapshot.favorites || []).find((favoriteArg) => favoriteArg.name === sourceArg || favoriteArg.id === sourceArg || favoriteArg.itemId === sourceArg || favoriteArg.url === sourceArg);
|
||||
if (favorite?.url) {
|
||||
return this.playerQuery(playerIdArg, ['playlist', 'play', favorite.url]);
|
||||
}
|
||||
if (favorite?.itemId) {
|
||||
return this.playerQuery(playerIdArg, ['favorites', 'playlist', 'play', `item_id:${favorite.itemId}`]);
|
||||
}
|
||||
if (sourceLike(sourceArg) || isUrl(sourceArg)) {
|
||||
return this.playerQuery(playerIdArg, ['playlist', 'play', sourceArg]);
|
||||
}
|
||||
throw new Error(`Unknown Squeezebox source: ${sourceArg}`);
|
||||
}
|
||||
|
||||
private async playMedia(playerIdArg: string, mediaIdArg: string, enqueueArg: 'play' | 'add' | 'next' = 'play'): Promise<unknown> {
|
||||
const command = enqueueArg === 'add' ? 'add' : enqueueArg === 'next' ? 'insert' : 'play';
|
||||
return this.playerQuery(playerIdArg, ['playlist', command, mediaIdArg]);
|
||||
}
|
||||
|
||||
private async playerQuery(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
|
||||
if (!commandArg.length || commandArg.some((itemArg) => !itemArg)) {
|
||||
throw new Error('Squeezebox command parameters must be non-empty strings.');
|
||||
}
|
||||
const transport = this.config.transport || 'jsonrpc';
|
||||
if (transport === 'snapshot' || this.currentSnapshot && !this.config.host && !this.config.commandExecutor) {
|
||||
throw new Error('Squeezebox command transport requires config.host or commandExecutor. Static snapshots are read-only.');
|
||||
}
|
||||
if (transport === 'cli') {
|
||||
return this.cliResponseToRecord(await this.requestCli(playerIdArg, commandArg));
|
||||
}
|
||||
return this.requestJsonRpc(playerIdArg, commandArg);
|
||||
}
|
||||
|
||||
private async requestJsonRpc(playerIdArg: string | undefined, commandArg: string[]): Promise<Record<string, unknown>> {
|
||||
const host = this.config.host;
|
||||
const port = this.config.port || squeezeboxDefaultHttpPort;
|
||||
const endpoint = host ? `${this.config.https ? 'https' : 'http'}://${formatHost(host)}:${port}/jsonrpc.js` : undefined;
|
||||
const body: ISqueezeboxJsonRpcRequest = {
|
||||
id: this.nextId++,
|
||||
method: 'slim.request',
|
||||
params: [playerIdArg || '', commandArg],
|
||||
};
|
||||
if (this.config.commandExecutor) {
|
||||
return this.executorResultToRecord(await this.config.commandExecutor.execute({
|
||||
transport: 'jsonrpc',
|
||||
host,
|
||||
port,
|
||||
endpoint,
|
||||
playerId: playerIdArg,
|
||||
command: commandArg,
|
||||
body,
|
||||
}), commandArg);
|
||||
}
|
||||
if (!host || !endpoint) {
|
||||
throw new Error('Squeezebox HTTP JSON-RPC requires config.host or commandExecutor.');
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), this.config.timeoutMs || squeezeboxDefaultTimeoutMs);
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
if (this.config.username || this.config.password) {
|
||||
headers.authorization = `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`).toString('base64')}`;
|
||||
}
|
||||
const response = await globalThis.fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Squeezebox JSON-RPC failed with HTTP ${response.status}${text ? `: ${text}` : ''}`);
|
||||
}
|
||||
if (!text.trim()) {
|
||||
throw new Error('Squeezebox JSON-RPC returned an empty response.');
|
||||
}
|
||||
return this.jsonRpcResponseToRecord(JSON.parse(text) as ISqueezeboxJsonRpcResponse, commandArg);
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
private async requestCli(playerIdArg: string | undefined, commandArg: string[]): Promise<ISqueezeboxCliResponse> {
|
||||
const host = this.config.host;
|
||||
const port = this.config.cliPort || this.config.port || squeezeboxDefaultCliPort;
|
||||
const cliLine = this.cliLine(playerIdArg, commandArg);
|
||||
if (this.config.commandExecutor) {
|
||||
return this.executorResultToCliResponse(await this.config.commandExecutor.execute({
|
||||
transport: 'cli',
|
||||
host,
|
||||
port,
|
||||
playerId: playerIdArg,
|
||||
command: commandArg,
|
||||
cliLine,
|
||||
}), playerIdArg, commandArg);
|
||||
}
|
||||
if (!host) {
|
||||
throw new Error('Squeezebox CLI command requires config.host or commandExecutor.');
|
||||
}
|
||||
const timeoutMs = this.config.timeoutMs || squeezeboxDefaultTimeoutMs;
|
||||
return new Promise<ISqueezeboxCliResponse>((resolve, reject) => {
|
||||
let buffer = '';
|
||||
let settled = false;
|
||||
let authenticated = !this.config.username && !this.config.password;
|
||||
let commandSent = false;
|
||||
const socket = plugins.net.createConnection({ host, port });
|
||||
|
||||
const finish = (errorArg?: Error, responseArg?: ISqueezeboxCliResponse) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
if (errorArg) {
|
||||
reject(errorArg);
|
||||
return;
|
||||
}
|
||||
resolve(responseArg as ISqueezeboxCliResponse);
|
||||
};
|
||||
|
||||
const writeLine = (lineArg: string) => socket.write(`${lineArg}\n`);
|
||||
const handleLine = (lineArg: string) => {
|
||||
if (!lineArg.trim()) {
|
||||
return;
|
||||
}
|
||||
if (!authenticated) {
|
||||
authenticated = true;
|
||||
commandSent = true;
|
||||
writeLine(cliLine);
|
||||
return;
|
||||
}
|
||||
if (!commandSent) {
|
||||
return;
|
||||
}
|
||||
finish(undefined, this.parseCliLine(lineArg, playerIdArg, commandArg));
|
||||
};
|
||||
|
||||
socket.setEncoding('utf8');
|
||||
socket.setTimeout(timeoutMs, () => finish(new Error(`Squeezebox CLI command timed out after ${timeoutMs}ms.`)));
|
||||
socket.on('connect', () => {
|
||||
if (this.config.username || this.config.password) {
|
||||
writeLine(this.cliLine(undefined, ['login', this.config.username || '', this.config.password || '']));
|
||||
} else {
|
||||
commandSent = true;
|
||||
writeLine(cliLine);
|
||||
}
|
||||
});
|
||||
socket.on('error', (errorArg) => finish(errorArg));
|
||||
socket.on('close', () => finish(new Error('Squeezebox CLI connection closed before a response was received.')));
|
||||
socket.on('data', (chunkArg) => {
|
||||
buffer += chunkArg;
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() || '';
|
||||
lines.forEach(handleLine);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private serverFromStatus(statusArg: Record<string, unknown>): ISqueezeboxServerInfo {
|
||||
const host = this.config.host;
|
||||
const name = this.config.name || stringValue(valueForKeys(statusArg, ['libraryname', 'prefs:libraryname', 'name', 'server_name'])) || host || 'Lyrion Music Server';
|
||||
const uuid = stringValue(valueForKeys(statusArg, ['uuid', 'server_uuid']));
|
||||
return {
|
||||
id: this.config.serverId || uuid || (host ? `${host}:${this.config.port || squeezeboxDefaultHttpPort}` : name),
|
||||
uuid,
|
||||
mac: stringValue(valueForKeys(statusArg, ['mac', 'server_mac'])),
|
||||
name,
|
||||
libraryName: stringValue(valueForKeys(statusArg, ['libraryname', 'prefs:libraryname'])),
|
||||
host,
|
||||
port: this.config.port || (host ? squeezeboxDefaultHttpPort : undefined),
|
||||
version: stringValue(valueForKeys(statusArg, ['version', 'server_version'])),
|
||||
manufacturer: 'Lyrion',
|
||||
model: 'Lyrion Music Server',
|
||||
playerCount: numberValue(valueForKeys(statusArg, ['player count', 'player_count', 'players', 'playercount'])),
|
||||
otherPlayerCount: numberValue(valueForKeys(statusArg, ['other player count', 'other_player_count'])),
|
||||
rescan: booleanValue(valueForKeys(statusArg, ['rescan'])),
|
||||
needsRestart: booleanValue(valueForKeys(statusArg, ['needsrestart', 'needs_restart'])),
|
||||
stats: {
|
||||
totalAlbums: numberValue(valueForKeys(statusArg, ['info total albums', 'info_total_albums', 'albums'])),
|
||||
totalArtists: numberValue(valueForKeys(statusArg, ['info total artists', 'info_total_artists', 'artists'])),
|
||||
totalDuration: numberValue(valueForKeys(statusArg, ['info total duration', 'info_total_duration', 'duration'])),
|
||||
totalGenres: numberValue(valueForKeys(statusArg, ['info total genres', 'info_total_genres', 'genres'])),
|
||||
totalSongs: numberValue(valueForKeys(statusArg, ['info total songs', 'info_total_songs', 'songs'])),
|
||||
lastScan: stringValue(valueForKeys(statusArg, ['lastscan', 'last_scan'])) || numberValue(valueForKeys(statusArg, ['lastscan', 'last_scan'])),
|
||||
},
|
||||
raw: statusArg,
|
||||
};
|
||||
}
|
||||
|
||||
private playerFromData(dataArg: Record<string, unknown>): ISqueezeboxPlayer {
|
||||
const playerId = stringValue(valueForKeys(dataArg, ['playerid', 'player_id', 'id', 'playerId'])) || 'unknown';
|
||||
const playlist = this.playlistFromData(dataArg);
|
||||
return this.normalizePlayer({
|
||||
playerId,
|
||||
uuid: stringValue(valueForKeys(dataArg, ['uuid', 'player_uuid'])),
|
||||
name: stringValue(valueForKeys(dataArg, ['name', 'player_name'])) || playerId,
|
||||
model: stringValue(valueForKeys(dataArg, ['model', 'modelname', 'player_model'])) || 'Squeezebox Player',
|
||||
modelType: stringValue(valueForKeys(dataArg, ['model_type', 'modelType', 'displaytype'])),
|
||||
manufacturer: stringValue(valueForKeys(dataArg, ['manufacturer'])) || 'Logitech',
|
||||
creator: stringValue(valueForKeys(dataArg, ['creator'])),
|
||||
firmware: stringValue(valueForKeys(dataArg, ['firmware', 'firmware_version', 'player_version'])),
|
||||
ipAddress: cleanIp(stringValue(valueForKeys(dataArg, ['ip', 'ipAddress', 'player_ip']))),
|
||||
connected: booleanValue(valueForKeys(dataArg, ['connected', 'player_connected'])),
|
||||
power: booleanValue(valueForKeys(dataArg, ['power'])),
|
||||
mode: stringValue(valueForKeys(dataArg, ['mode', 'playmode'])) as ISqueezeboxPlayer['mode'],
|
||||
volume: numberValue(valueForKeys(dataArg, ['volume', 'mixer volume'])),
|
||||
muting: booleanValue(valueForKeys(dataArg, ['muting', 'mixer muting', 'muted'])),
|
||||
repeat: this.repeatFromValue(valueForKeys(dataArg, ['repeat', 'playlist repeat'])) as ISqueezeboxPlayer['repeat'],
|
||||
shuffle: this.shuffleFromValue(valueForKeys(dataArg, ['shuffle', 'playlist shuffle'])) as ISqueezeboxPlayer['shuffle'],
|
||||
time: numberValue(valueForKeys(dataArg, ['time', 'elapsed'])),
|
||||
duration: numberValue(valueForKeys(dataArg, ['duration'])),
|
||||
title: stringValue(valueForKeys(dataArg, ['title', 'track'])),
|
||||
remoteTitle: stringValue(valueForKeys(dataArg, ['remote_title', 'remoteTitle'])),
|
||||
artist: stringValue(valueForKeys(dataArg, ['artist'])),
|
||||
album: stringValue(valueForKeys(dataArg, ['album'])),
|
||||
url: stringValue(valueForKeys(dataArg, ['url', 'current_url'])),
|
||||
imageUrl: stringValue(valueForKeys(dataArg, ['image_url', 'artwork_url', 'coverart'])),
|
||||
playlist,
|
||||
currentIndex: numberValue(valueForKeys(dataArg, ['playlist_cur_index', 'current_index', 'currentIndex'])),
|
||||
alarms: this.alarmsFromData(dataArg),
|
||||
alarmsEnabled: booleanValue(valueForKeys(dataArg, ['alarms_enabled', 'alarmsEnabled'])),
|
||||
alarmNext: stringValue(valueForKeys(dataArg, ['alarm_next', 'alarmNext'])),
|
||||
syncGroup: stringArray(valueForKeys(dataArg, ['sync_group', 'syncGroup', 'syncgroup'])).filter((idArg) => idArg !== playerId),
|
||||
source: stringValue(valueForKeys(dataArg, ['source'])),
|
||||
available: booleanValue(valueForKeys(dataArg, ['available'])) ?? booleanValue(valueForKeys(dataArg, ['connected', 'player_connected'])),
|
||||
raw: dataArg,
|
||||
});
|
||||
}
|
||||
|
||||
private normalizePlayer(playerArg: ISqueezeboxPlayer | Partial<ISqueezeboxPlayer>): ISqueezeboxPlayer {
|
||||
const playerId = playerArg.playerId || 'unknown';
|
||||
const connected = playerArg.connected ?? playerArg.available ?? true;
|
||||
return {
|
||||
...playerArg,
|
||||
playerId,
|
||||
name: playerArg.name || playerId,
|
||||
model: playerArg.model || 'Squeezebox Player',
|
||||
connected,
|
||||
available: playerArg.available ?? connected,
|
||||
power: playerArg.power ?? true,
|
||||
mode: playerArg.mode || 'unknown',
|
||||
syncGroup: playerArg.syncGroup || [],
|
||||
} as ISqueezeboxPlayer;
|
||||
}
|
||||
|
||||
private favoriteFromData(dataArg: Record<string, unknown>, indexArg: number): ISqueezeboxFavorite {
|
||||
const itemId = stringValue(valueForKeys(dataArg, ['item_id', 'itemId', 'id']));
|
||||
const url = stringValue(valueForKeys(dataArg, ['url', 'playlist_url', 'play_url']));
|
||||
const name = stringValue(valueForKeys(dataArg, ['name', 'title', 'text'])) || itemId || url || `Favorite ${indexArg + 1}`;
|
||||
return {
|
||||
id: itemId || url || String(indexArg + 1),
|
||||
name,
|
||||
type: stringValue(valueForKeys(dataArg, ['type', 'isaudio'])),
|
||||
url,
|
||||
itemId,
|
||||
imageUrl: stringValue(valueForKeys(dataArg, ['image', 'image_url', 'icon'])),
|
||||
playable: booleanValue(valueForKeys(dataArg, ['playable', 'isaudio'])) ?? true,
|
||||
raw: dataArg,
|
||||
};
|
||||
}
|
||||
|
||||
private syncGroupFromData(dataArg: Record<string, unknown>, indexArg: number): ISqueezeboxSyncGroup {
|
||||
const playerIds = stringArray(valueForKeys(dataArg, ['players', 'playerids', 'members', 'sync_members', 'playerIds']));
|
||||
const leader = stringValue(valueForKeys(dataArg, ['leader', 'master', 'leaderPlayerId'])) || playerIds[0];
|
||||
return {
|
||||
id: stringValue(valueForKeys(dataArg, ['id', 'sync_group_id'])) || `sync_${indexArg + 1}`,
|
||||
name: stringValue(valueForKeys(dataArg, ['name'])),
|
||||
playerIds,
|
||||
leaderPlayerId: leader,
|
||||
raw: dataArg,
|
||||
};
|
||||
}
|
||||
|
||||
private syncGroupsFromPlayers(playersArg: ISqueezeboxPlayer[]): ISqueezeboxSyncGroup[] {
|
||||
const groups = new Map<string, ISqueezeboxSyncGroup>();
|
||||
for (const player of playersArg) {
|
||||
const playerIds = [player.playerId, ...(player.syncGroup || [])].filter(Boolean).sort();
|
||||
if (playerIds.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const key = playerIds.join(',');
|
||||
groups.set(key, { id: `sync_${slug(key)}`, playerIds, leaderPlayerId: playerIds[0] });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private playlistFromData(dataArg: Record<string, unknown>): ISqueezeboxTrack[] | undefined {
|
||||
const loop = arrayRecords(valueForKeys(dataArg, ['playlist_loop', 'playlist']));
|
||||
if (!loop.length) {
|
||||
return undefined;
|
||||
}
|
||||
return loop.map((trackArg) => ({
|
||||
id: valueForKeys(trackArg, ['id', 'track_id']) as string | number | undefined,
|
||||
url: stringValue(valueForKeys(trackArg, ['url'])),
|
||||
title: stringValue(valueForKeys(trackArg, ['title', 'track'])),
|
||||
artist: stringValue(valueForKeys(trackArg, ['artist'])),
|
||||
album: stringValue(valueForKeys(trackArg, ['album'])),
|
||||
duration: numberValue(valueForKeys(trackArg, ['duration'])),
|
||||
remoteTitle: stringValue(valueForKeys(trackArg, ['remote_title'])),
|
||||
imageUrl: stringValue(valueForKeys(trackArg, ['image_url', 'coverart'])),
|
||||
raw: trackArg,
|
||||
}));
|
||||
}
|
||||
|
||||
private alarmsFromData(dataArg: Record<string, unknown>): ISqueezeboxAlarm[] | undefined {
|
||||
const loop = arrayRecords(valueForKeys(dataArg, ['alarms', 'alarms_loop']));
|
||||
if (!loop.length) {
|
||||
return undefined;
|
||||
}
|
||||
return loop.map((alarmArg, indexArg) => ({
|
||||
id: stringValue(valueForKeys(alarmArg, ['id', 'alarm_id'])) || String(indexArg + 1),
|
||||
enabled: booleanValue(valueForKeys(alarmArg, ['enabled'])),
|
||||
time: stringValue(valueForKeys(alarmArg, ['time'])),
|
||||
repeat: booleanValue(valueForKeys(alarmArg, ['repeat'])),
|
||||
scheduledToday: booleanValue(valueForKeys(alarmArg, ['scheduled_today', 'scheduledToday'])),
|
||||
daysOfWeek: numberArray(valueForKeys(alarmArg, ['dow', 'daysOfWeek'])),
|
||||
volume: numberValue(valueForKeys(alarmArg, ['volume'])),
|
||||
url: stringValue(valueForKeys(alarmArg, ['url'])),
|
||||
raw: alarmArg,
|
||||
}));
|
||||
}
|
||||
|
||||
private normalizeSnapshot(snapshotArg: ISqueezeboxSnapshot, sourceArg: TSqueezeboxSnapshotSource): ISqueezeboxSnapshot {
|
||||
const server = {
|
||||
...snapshotArg.server,
|
||||
id: snapshotArg.server.id || snapshotArg.server.uuid || this.config.serverId || this.config.host || snapshotArg.server.name || 'squeezebox',
|
||||
name: snapshotArg.server.name || snapshotArg.server.libraryName || this.config.name || this.config.host || 'Lyrion Music Server',
|
||||
host: snapshotArg.server.host || this.config.host,
|
||||
port: snapshotArg.server.port || (this.config.host ? this.config.port || squeezeboxDefaultHttpPort : this.config.port),
|
||||
manufacturer: snapshotArg.server.manufacturer || 'Lyrion',
|
||||
model: snapshotArg.server.model || 'Lyrion Music Server',
|
||||
playerCount: snapshotArg.server.playerCount ?? snapshotArg.players.length,
|
||||
};
|
||||
const players = (snapshotArg.players || []).map((playerArg) => this.normalizePlayer(playerArg));
|
||||
return {
|
||||
...snapshotArg,
|
||||
server,
|
||||
players,
|
||||
favorites: snapshotArg.favorites || [],
|
||||
syncGroups: snapshotArg.syncGroups || this.syncGroupsFromPlayers(players),
|
||||
online: snapshotArg.online,
|
||||
updatedAt: snapshotArg.updatedAt || new Date().toISOString(),
|
||||
source: snapshotArg.source || sourceArg,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromConfig(onlineArg: boolean, errorArg?: string): ISqueezeboxSnapshot {
|
||||
const host = this.config.host;
|
||||
return {
|
||||
server: {
|
||||
id: this.config.serverId || (host ? `${host}:${this.config.port || squeezeboxDefaultHttpPort}` : undefined) || this.config.name || 'squeezebox',
|
||||
name: this.config.name || host || 'Lyrion Music Server',
|
||||
host,
|
||||
port: this.config.port || (host ? squeezeboxDefaultHttpPort : undefined),
|
||||
manufacturer: 'Lyrion',
|
||||
model: 'Lyrion Music Server',
|
||||
},
|
||||
players: [],
|
||||
favorites: [],
|
||||
syncGroups: [],
|
||||
online: onlineArg,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: 'runtime',
|
||||
error: errorArg,
|
||||
};
|
||||
}
|
||||
|
||||
private jsonRpcResponseToRecord(responseArg: ISqueezeboxJsonRpcResponse | Record<string, unknown>, commandArg: string[]): Record<string, unknown> {
|
||||
if ('error' in responseArg && responseArg.error) {
|
||||
const error = responseArg.error as { message?: string } | string;
|
||||
throw new SqueezeboxCommandError(commandArg, typeof error === 'string' ? error : error.message || JSON.stringify(error));
|
||||
}
|
||||
if ('result' in responseArg) {
|
||||
return recordValue(responseArg.result);
|
||||
}
|
||||
return recordValue(responseArg);
|
||||
}
|
||||
|
||||
private executorResultToRecord(resultArg: unknown, commandArg: string[]): Record<string, unknown> {
|
||||
if (this.isCliResponse(resultArg)) {
|
||||
return resultArg.result;
|
||||
}
|
||||
if (this.isJsonRpcResponse(resultArg)) {
|
||||
return this.jsonRpcResponseToRecord(resultArg, commandArg);
|
||||
}
|
||||
return recordValue(resultArg);
|
||||
}
|
||||
|
||||
private executorResultToCliResponse(resultArg: unknown, playerIdArg: string | undefined, commandArg: string[]): ISqueezeboxCliResponse {
|
||||
if (this.isCliResponse(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
if (typeof resultArg === 'string') {
|
||||
return this.parseCliLine(resultArg, playerIdArg, commandArg);
|
||||
}
|
||||
return {
|
||||
playerId: playerIdArg,
|
||||
command: commandArg,
|
||||
rawLine: '',
|
||||
tokens: [],
|
||||
result: this.executorResultToRecord(resultArg, commandArg),
|
||||
};
|
||||
}
|
||||
|
||||
private parseCliLine(lineArg: string, playerIdArg: string | undefined, commandArg: string[]): ISqueezeboxCliResponse {
|
||||
const tokens = lineArg.trim().split(/\s+/).map((tokenArg) => decodeURIComponentSafe(tokenArg));
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const token of tokens) {
|
||||
const separator = token.indexOf(':');
|
||||
if (separator > 0) {
|
||||
addRecordValue(result, token.slice(0, separator), token.slice(separator + 1));
|
||||
}
|
||||
}
|
||||
if (!Object.keys(result).length && tokens.length > commandArg.length) {
|
||||
result.value = tokens[tokens.length - 1];
|
||||
}
|
||||
return { playerId: playerIdArg, command: commandArg, rawLine: lineArg, tokens, result };
|
||||
}
|
||||
|
||||
private cliResponseToRecord(responseArg: ISqueezeboxCliResponse): Record<string, unknown> {
|
||||
return responseArg.result;
|
||||
}
|
||||
|
||||
private cliLine(playerIdArg: string | undefined, commandArg: string[]): string {
|
||||
return [playerIdArg, ...commandArg].filter((itemArg): itemArg is string => Boolean(itemArg)).map((itemArg) => encodeCliValue(itemArg)).join(' ');
|
||||
}
|
||||
|
||||
private isJsonRpcResponse(valueArg: unknown): valueArg is ISqueezeboxJsonRpcResponse {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && ('result' in valueArg || 'error' in valueArg || (valueArg as { method?: unknown }).method === 'slim.request'));
|
||||
}
|
||||
|
||||
private isCliResponse(valueArg: unknown): valueArg is ISqueezeboxCliResponse {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && Array.isArray((valueArg as ISqueezeboxCliResponse).command) && (valueArg as ISqueezeboxCliResponse).result);
|
||||
}
|
||||
|
||||
private requiredPlayerId(requestArg: ISqueezeboxCommandRequest): string {
|
||||
return this.requiredString(requestArg.playerId, 'Squeezebox command requires playerId.');
|
||||
}
|
||||
|
||||
private requiredString(valueArg: unknown, errorArg: string): string {
|
||||
if (typeof valueArg !== 'string' || !valueArg) {
|
||||
throw new Error(errorArg);
|
||||
}
|
||||
return valueArg;
|
||||
}
|
||||
|
||||
private requiredNumber(valueArg: unknown, errorArg: string): number {
|
||||
if (typeof valueArg !== 'number' || !Number.isFinite(valueArg)) {
|
||||
throw new Error(errorArg);
|
||||
}
|
||||
return valueArg;
|
||||
}
|
||||
|
||||
private volumePercent(requestArg: ISqueezeboxCommandRequest): number {
|
||||
const value = requestArg.volumeLevel ?? requestArg.volume;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
throw new Error('Squeezebox set_volume requires volumeLevel or volume.');
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(value <= 1 ? value * 100 : value)));
|
||||
}
|
||||
|
||||
private volumeStep(valueArg: number | undefined): number {
|
||||
const step = typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : this.config.volumeStep || squeezeboxDefaultVolumeStep;
|
||||
return Math.max(1, Math.min(100, Math.round(step <= 1 ? step * 100 : step)));
|
||||
}
|
||||
|
||||
private repeatFromValue(valueArg: unknown): string | undefined {
|
||||
if (valueArg === 2 || valueArg === '2' || valueArg === 'playlist') {
|
||||
return 'playlist';
|
||||
}
|
||||
if (valueArg === 1 || valueArg === '1' || valueArg === 'song') {
|
||||
return 'song';
|
||||
}
|
||||
if (valueArg !== undefined) {
|
||||
return 'none';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private shuffleFromValue(valueArg: unknown): string | undefined {
|
||||
if (valueArg === 2 || valueArg === '2' || valueArg === 'album') {
|
||||
return 'album';
|
||||
}
|
||||
if (valueArg === 1 || valueArg === '1' || valueArg === 'song') {
|
||||
return 'song';
|
||||
}
|
||||
if (valueArg !== undefined) {
|
||||
return 'none';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private cloneSnapshot(snapshotArg: ISqueezeboxSnapshot): ISqueezeboxSnapshot {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as ISqueezeboxSnapshot;
|
||||
}
|
||||
}
|
||||
|
||||
const valueForKeys = (recordArg: Record<string, unknown> | undefined, keysArg: string[]): unknown => {
|
||||
if (!recordArg) {
|
||||
return undefined;
|
||||
}
|
||||
const lowerEntries = Object.entries(recordArg).map(([key, value]) => [key.toLowerCase(), value] as const);
|
||||
for (const key of keysArg) {
|
||||
const direct = recordArg[key];
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
const match = lowerEntries.find(([entryKey]) => entryKey === key.toLowerCase());
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const arrayRecords = (valueArg: unknown): Record<string, unknown>[] => {
|
||||
if (!Array.isArray(valueArg)) {
|
||||
return [];
|
||||
}
|
||||
return valueArg.filter((itemArg): itemArg is Record<string, unknown> => Boolean(itemArg && typeof itemArg === 'object' && !Array.isArray(itemArg)));
|
||||
};
|
||||
|
||||
const recordValue = (valueArg: unknown): Record<string, unknown> => {
|
||||
if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) {
|
||||
return valueArg as Record<string, unknown>;
|
||||
}
|
||||
return valueArg === undefined ? {} : { value: valueArg };
|
||||
};
|
||||
|
||||
const addRecordValue = (recordArg: Record<string, unknown>, keyArg: string, valueArg: unknown): void => {
|
||||
const existing = recordArg[keyArg];
|
||||
if (existing === undefined) {
|
||||
recordArg[keyArg] = valueArg;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(valueArg);
|
||||
} else {
|
||||
recordArg[keyArg] = [existing, valueArg];
|
||||
}
|
||||
};
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => {
|
||||
if (typeof valueArg === 'string' && valueArg) {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number' && Number.isFinite(valueArg)) {
|
||||
return String(valueArg);
|
||||
}
|
||||
return 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;
|
||||
};
|
||||
|
||||
const booleanValue = (valueArg: unknown): boolean | undefined => {
|
||||
if (typeof valueArg === 'boolean') {
|
||||
return valueArg;
|
||||
}
|
||||
if (typeof valueArg === 'number') {
|
||||
return valueArg !== 0;
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
const lower = valueArg.toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(lower)) {
|
||||
return true;
|
||||
}
|
||||
if (['0', 'false', 'no', 'off'].includes(lower)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const stringArray = (valueArg: unknown): string[] => {
|
||||
if (Array.isArray(valueArg)) {
|
||||
return valueArg.map((itemArg) => stringValue(itemArg)).filter((itemArg): itemArg is string => Boolean(itemArg));
|
||||
}
|
||||
if (typeof valueArg === 'string') {
|
||||
return valueArg.split(',').map((itemArg) => itemArg.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const numberArray = (valueArg: unknown): number[] | undefined => {
|
||||
const values = Array.isArray(valueArg) ? valueArg : typeof valueArg === 'string' ? valueArg.split(',') : [];
|
||||
const numbers = values.map((itemArg) => numberValue(itemArg)).filter((itemArg): itemArg is number => typeof itemArg === 'number');
|
||||
return numbers.length ? numbers : undefined;
|
||||
};
|
||||
|
||||
const cleanIp = (valueArg: string | undefined): string | undefined => valueArg?.split(':')[0] || undefined;
|
||||
|
||||
const sourceLike = (valueArg: string): boolean => /^(source|wavin|spotify|loop):/i.test(valueArg);
|
||||
|
||||
const isUrl = (valueArg: string): boolean => {
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
return Boolean(url.protocol && url.host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const encodeCliValue = (valueArg: string): string => encodeURIComponent(valueArg).replace(/%3A/gi, ':');
|
||||
|
||||
const decodeURIComponentSafe = (valueArg: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(valueArg);
|
||||
} catch {
|
||||
return valueArg;
|
||||
}
|
||||
};
|
||||
|
||||
const formatHost = (hostArg: string): string => hostArg.includes(':') && !hostArg.startsWith('[') ? `[${hostArg}]` : hostArg;
|
||||
|
||||
const slug = (valueArg: string): string => valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'squeezebox';
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import type { ISqueezeboxConfig } from './squeezebox.types.js';
|
||||
import { squeezeboxDefaultBrowseLimit, squeezeboxDefaultHttpPort, squeezeboxDefaultTimeoutMs, squeezeboxDefaultVolumeStep } from './squeezebox.types.js';
|
||||
|
||||
export class SqueezeboxConfigFlow implements IConfigFlow<ISqueezeboxConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISqueezeboxConfig>> {
|
||||
void contextArg;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Lyrion Music Server',
|
||||
description: 'Configure a local Logitech/Lyrion Media Server HTTP JSON-RPC endpoint.',
|
||||
fields: [
|
||||
{ name: 'host', label: 'LMS host', type: 'text', required: true },
|
||||
{ name: 'port', label: 'HTTP port', type: 'number' },
|
||||
{ name: 'https', label: 'Use HTTPS', type: 'boolean' },
|
||||
{ name: 'username', label: 'Username', type: 'text' },
|
||||
{ name: 'password', label: 'Password', type: 'password' },
|
||||
{ name: 'name', label: 'Name', type: 'text' },
|
||||
{ name: 'browseLimit', label: 'Browse limit', type: 'number' },
|
||||
{ name: 'volumeStep', label: 'Volume step percent', type: 'number' },
|
||||
],
|
||||
submit: async (valuesArg) => {
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host || '';
|
||||
if (!host) {
|
||||
return { kind: 'error', title: 'Squeezebox setup failed', error: 'Lyrion Music Server host is required.' };
|
||||
}
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || squeezeboxDefaultHttpPort;
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Squeezebox configured',
|
||||
config: {
|
||||
host,
|
||||
port,
|
||||
https: this.booleanValue(valuesArg.https),
|
||||
username: this.stringValue(valuesArg.username),
|
||||
password: this.stringValue(valuesArg.password),
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name,
|
||||
serverId: candidateArg.id || `${host}:${port}`,
|
||||
timeoutMs: squeezeboxDefaultTimeoutMs,
|
||||
browseLimit: this.numberValue(valuesArg.browseLimit) || squeezeboxDefaultBrowseLimit,
|
||||
volumeStep: this.numberValue(valuesArg.volumeStep) || squeezeboxDefaultVolumeStep,
|
||||
transport: 'jsonrpc',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
return typeof valueArg === 'boolean' ? valueArg : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,247 @@
|
||||
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 { SqueezeboxClient } from './squeezebox.classes.client.js';
|
||||
import { SqueezeboxConfigFlow } from './squeezebox.classes.configflow.js';
|
||||
import { createSqueezeboxDiscoveryDescriptor } from './squeezebox.discovery.js';
|
||||
import { SqueezeboxMapper } from './squeezebox.mapper.js';
|
||||
import type { ISqueezeboxConfig, ISqueezeboxSnapshot } from './squeezebox.types.js';
|
||||
|
||||
export class HomeAssistantSqueezeboxIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "squeezebox",
|
||||
displayName: "Squeezebox (Lyrion Music Server)",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/squeezebox",
|
||||
"upstreamDomain": "squeezebox",
|
||||
"integrationType": "hub",
|
||||
"iotClass": "local_polling",
|
||||
"qualityScale": "silver",
|
||||
"requirements": [
|
||||
"pysqueezebox==0.14.0"
|
||||
],
|
||||
"dependencies": [],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@rajlaud",
|
||||
"@pssc",
|
||||
"@peteS-UK"
|
||||
]
|
||||
},
|
||||
});
|
||||
export class SqueezeboxIntegration extends BaseIntegration<ISqueezeboxConfig> {
|
||||
public readonly domain = 'squeezebox';
|
||||
public readonly displayName = 'Squeezebox (Lyrion Music Server)';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createSqueezeboxDiscoveryDescriptor();
|
||||
public readonly configFlow = new SqueezeboxConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/squeezebox',
|
||||
upstreamDomain: 'squeezebox',
|
||||
integrationType: 'hub',
|
||||
iotClass: 'local_polling',
|
||||
qualityScale: 'silver',
|
||||
requirements: ['pysqueezebox==0.14.0'],
|
||||
dependencies: [],
|
||||
afterDependencies: [],
|
||||
codeowners: ['@rajlaud', '@pssc', '@peteS-UK'],
|
||||
configFlow: true,
|
||||
documentation: 'https://www.home-assistant.io/integrations/squeezebox',
|
||||
};
|
||||
|
||||
public async setup(configArg: ISqueezeboxConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new SqueezeboxRuntime(new SqueezeboxClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantSqueezeboxIntegration extends SqueezeboxIntegration {}
|
||||
|
||||
class SqueezeboxRuntime implements IIntegrationRuntime {
|
||||
public domain = 'squeezebox';
|
||||
|
||||
constructor(private readonly client: SqueezeboxClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return SqueezeboxMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return SqueezeboxMapper.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 === 'squeezebox') {
|
||||
return await this.callSqueezeboxService(requestArg);
|
||||
}
|
||||
return { success: false, error: `Unsupported Squeezebox 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 === 'media_play' || requestArg.service === 'play') {
|
||||
return { success: true, data: await this.client.execute({ command: 'play', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'media_pause' || requestArg.service === 'pause') {
|
||||
return { success: true, data: await this.client.execute({ command: 'pause', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'media_play_pause' || requestArg.service === 'play_pause') {
|
||||
return { success: true, data: await this.client.execute({ command: 'play_pause', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'media_stop' || requestArg.service === 'stop') {
|
||||
return { success: true, data: await this.client.execute({ command: 'stop', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'media_next_track' || requestArg.service === 'next_track' || requestArg.service === 'next') {
|
||||
return { success: true, data: await this.client.execute({ command: 'next_track', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'media_previous_track' || requestArg.service === 'previous_track' || requestArg.service === 'previous') {
|
||||
return { success: true, data: await this.client.execute({ command: 'previous_track', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'media_seek' || requestArg.service === 'seek') {
|
||||
return { success: true, data: await this.client.execute({ command: 'seek', playerId: await this.playerIdFromRequest(requestArg), position: this.numberData(requestArg, 'seek_position') ?? this.numberData(requestArg, 'position') }) };
|
||||
}
|
||||
if (requestArg.service === 'turn_on') {
|
||||
return { success: true, data: await this.client.execute({ command: 'set_power', playerId: await this.playerIdFromRequest(requestArg), powered: true }) };
|
||||
}
|
||||
if (requestArg.service === 'turn_off') {
|
||||
return { success: true, data: await this.client.execute({ command: 'set_power', playerId: await this.playerIdFromRequest(requestArg), powered: false }) };
|
||||
}
|
||||
if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') {
|
||||
return { success: true, data: await this.client.execute({ command: 'set_volume', playerId: await this.playerIdFromRequest(requestArg), volumeLevel: this.numberData(requestArg, 'volume_level'), volume: this.numberData(requestArg, 'volume') }) };
|
||||
}
|
||||
if (requestArg.service === 'volume_up') {
|
||||
return { success: true, data: await this.client.execute({ command: 'volume_up', playerId: await this.playerIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
|
||||
}
|
||||
if (requestArg.service === 'volume_down') {
|
||||
return { success: true, data: await this.client.execute({ command: 'volume_down', playerId: await this.playerIdFromRequest(requestArg), step: this.numberData(requestArg, 'step') }) };
|
||||
}
|
||||
if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') {
|
||||
return { success: true, data: await this.client.execute({ command: 'mute', playerId: await this.playerIdFromRequest(requestArg), muted: this.booleanData(requestArg, 'is_volume_muted') ?? this.booleanData(requestArg, 'muted') ?? this.booleanData(requestArg, 'mute') }) };
|
||||
}
|
||||
if (requestArg.service === 'select_source' || requestArg.service === 'source') {
|
||||
return { success: true, data: await this.client.execute({ command: 'select_source', playerId: await this.playerIdFromRequest(requestArg), source: this.stringData(requestArg, 'source') }) };
|
||||
}
|
||||
if (requestArg.service === 'play_media') {
|
||||
return { success: true, data: await this.client.execute({ command: 'play_media', playerId: await this.playerIdFromRequest(requestArg), mediaId: this.stringData(requestArg, 'media_content_id') || this.stringData(requestArg, 'mediaId') || this.stringData(requestArg, 'uri'), mediaType: this.stringData(requestArg, 'media_content_type'), enqueue: this.enqueueData(requestArg) }) };
|
||||
}
|
||||
if (requestArg.service === 'join' || requestArg.service === 'join_players') {
|
||||
return { success: true, data: await this.joinPlayers(requestArg) };
|
||||
}
|
||||
if (requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
||||
return { success: true, data: await this.client.execute({ command: 'unsync', playerId: await this.playerIdFromRequest(requestArg) }) };
|
||||
}
|
||||
return { success: false, error: `Unsupported Squeezebox media_player service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async callSqueezeboxService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.service === 'snapshot') {
|
||||
return { success: true, data: await this.client.snapshot() };
|
||||
}
|
||||
if (requestArg.service === 'restore') {
|
||||
await this.client.restore(requestArg.data?.snapshot as ISqueezeboxSnapshot | undefined);
|
||||
return { success: true };
|
||||
}
|
||||
if (requestArg.service === 'call_method' || requestArg.service === 'call_query' || requestArg.service === 'query' || requestArg.service === 'command') {
|
||||
const command = this.stringData(requestArg, 'command');
|
||||
if (!command) {
|
||||
throw new Error('Squeezebox raw command service requires data.command.');
|
||||
}
|
||||
const parameters = this.parameterArray(requestArg.data?.parameters ?? requestArg.data?.args);
|
||||
const playerId = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || await this.optionalPlayerIdFromRequest(requestArg);
|
||||
const data = await this.client.query(playerId, [command, ...parameters.map((itemArg) => String(itemArg))]);
|
||||
return { success: true, data };
|
||||
}
|
||||
if (requestArg.service === 'join' || requestArg.service === 'join_players' || requestArg.service === 'unjoin' || requestArg.service === 'unjoin_player') {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
if (requestArg.service === 'play' || requestArg.service === 'pause' || requestArg.service === 'stop' || requestArg.service === 'media_play' || requestArg.service === 'media_pause' || requestArg.service === 'media_stop' || requestArg.service === 'volume_set' || requestArg.service === 'set_volume' || requestArg.service === 'volume_mute' || requestArg.service === 'mute' || requestArg.service === 'select_source' || requestArg.service === 'source') {
|
||||
return this.callMediaPlayerService({ ...requestArg, domain: 'media_player' });
|
||||
}
|
||||
return { success: false, error: `Unsupported Squeezebox service: ${requestArg.service}` };
|
||||
}
|
||||
|
||||
private async joinPlayers(requestArg: IServiceCallRequest): Promise<unknown[]> {
|
||||
const leaderId = await this.playerIdFromRequest(requestArg);
|
||||
const memberIds = await this.joinMemberIdsFromRequest(requestArg);
|
||||
const results: unknown[] = [];
|
||||
for (const playerId of memberIds.filter((playerIdArg) => playerIdArg !== leaderId)) {
|
||||
results.push(await this.client.execute({ command: 'sync', playerId: leaderId, targetPlayerId: playerId }));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async playerIdFromRequest(requestArg: IServiceCallRequest): Promise<string> {
|
||||
const playerId = await this.optionalPlayerIdFromRequest(requestArg);
|
||||
if (playerId) {
|
||||
return playerId;
|
||||
}
|
||||
throw new Error('Squeezebox service call requires data.player_id or a target Squeezebox media_player entity.');
|
||||
}
|
||||
|
||||
private async optionalPlayerIdFromRequest(requestArg: IServiceCallRequest): Promise<string | undefined> {
|
||||
const direct = this.stringData(requestArg, 'player_id') || this.stringData(requestArg, 'playerId') || this.stringData(requestArg, 'player');
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
if (requestArg.target.entityId) {
|
||||
const entityPlayerId = SqueezeboxMapper.entityPlayerId(snapshot, requestArg.target.entityId);
|
||||
if (entityPlayerId) {
|
||||
return entityPlayerId;
|
||||
}
|
||||
}
|
||||
if (requestArg.target.deviceId) {
|
||||
const player = snapshot.players.find((playerArg) => SqueezeboxMapper.playerDeviceId(playerArg) === requestArg.target.deviceId);
|
||||
if (player) {
|
||||
return player.playerId;
|
||||
}
|
||||
}
|
||||
return snapshot.players.length === 1 ? snapshot.players[0].playerId : undefined;
|
||||
}
|
||||
|
||||
private async joinMemberIdsFromRequest(requestArg: IServiceCallRequest): Promise<string[]> {
|
||||
const direct = this.stringArrayData(requestArg, 'player_ids') || this.stringArrayData(requestArg, 'playerIds');
|
||||
if (direct?.length) {
|
||||
return direct;
|
||||
}
|
||||
const members = this.stringArrayData(requestArg, 'group_members') || this.stringArrayData(requestArg, 'groupMembers') || this.stringArrayData(requestArg, 'sync_members');
|
||||
if (!members?.length) {
|
||||
throw new Error('Squeezebox join service requires data.group_members or data.player_ids.');
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
return members.map((memberArg) => SqueezeboxMapper.entityPlayerId(snapshot, memberArg) || memberArg);
|
||||
}
|
||||
|
||||
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 booleanData(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 parameterArray(valueArg: unknown): Array<string | number | boolean> {
|
||||
if (valueArg === undefined) {
|
||||
return [];
|
||||
}
|
||||
const values = Array.isArray(valueArg) ? valueArg : [valueArg];
|
||||
if (values.some((itemArg) => typeof itemArg !== 'string' && typeof itemArg !== 'number' && typeof itemArg !== 'boolean')) {
|
||||
throw new Error('Squeezebox raw command parameters must be strings, numbers, or booleans.');
|
||||
}
|
||||
return values as Array<string | number | boolean>;
|
||||
}
|
||||
|
||||
private enqueueData(requestArg: IServiceCallRequest): 'play' | 'add' | 'next' | undefined {
|
||||
const enqueue = this.stringData(requestArg, 'enqueue') || this.stringData(requestArg, 'media_enqueue');
|
||||
if (enqueue === 'add' || enqueue === 'next' || enqueue === 'play') {
|
||||
return enqueue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import type { ISqueezeboxDhcpRecord, ISqueezeboxManualEntry, ISqueezeboxMdnsRecord } from './squeezebox.types.js';
|
||||
import { squeezeboxDefaultHttpPort } from './squeezebox.types.js';
|
||||
|
||||
const squeezeboxDomain = 'squeezebox';
|
||||
const lmsNames = ['squeezebox', 'lyrion', 'logitech media server', 'lms', 'slimserver'];
|
||||
const lmsMdnsTypes = new Set([
|
||||
'_squeezebox._tcp',
|
||||
'_squeezebox-jsonrpc._tcp',
|
||||
'_squeezebox-server._tcp',
|
||||
'_lms._tcp',
|
||||
'_slimserver._tcp',
|
||||
]);
|
||||
|
||||
export class SqueezeboxMdnsMatcher implements IDiscoveryMatcher<ISqueezeboxMdnsRecord> {
|
||||
public id = 'squeezebox-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize local Lyrion/Logitech Media Server mDNS advertisements.';
|
||||
|
||||
public async matches(recordArg: ISqueezeboxMdnsRecord): Promise<IDiscoveryMatch> {
|
||||
const type = normalizeMdnsType(recordArg.type || recordArg.serviceType || '');
|
||||
const properties = { ...recordArg.txt, ...recordArg.properties };
|
||||
const name = cleanName(recordArg.name || recordArg.hostname || valueForKey(properties, 'name')) || 'Lyrion Music Server';
|
||||
const haystack = `${name} ${type} ${valueForKey(properties, 'model') || ''} ${valueForKey(properties, 'server') || ''}`.toLowerCase();
|
||||
const serviceMatch = lmsMdnsTypes.has(type);
|
||||
const nameMatch = lmsNames.some((needleArg) => haystack.includes(needleArg));
|
||||
|
||||
if (!serviceMatch && !nameMatch) {
|
||||
return { matched: false, confidence: 'low', reason: 'mDNS record is not an LMS/Squeezebox service.' };
|
||||
}
|
||||
|
||||
const host = recordArg.host || recordArg.addresses?.[0];
|
||||
const port = recordArg.port || numberString(valueForKey(properties, 'port')) || squeezeboxDefaultHttpPort;
|
||||
const id = valueForKey(properties, 'uuid') || valueForKey(properties, 'id') || valueForKey(properties, 'mac') || (host ? `${host}:${port}` : name);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: serviceMatch && host ? 'certain' : serviceMatch ? 'high' : 'medium',
|
||||
reason: serviceMatch ? `mDNS service ${type} is an LMS service.` : 'mDNS metadata contains LMS/Squeezebox hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: squeezeboxDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name,
|
||||
manufacturer: 'Lyrion',
|
||||
model: 'Lyrion Music Server',
|
||||
metadata: {
|
||||
mdnsType: type,
|
||||
txt: properties,
|
||||
uuid: valueForKey(properties, 'uuid'),
|
||||
},
|
||||
},
|
||||
metadata: { mdnsType: type },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SqueezeboxDhcpMatcher implements IDiscoveryMatcher<ISqueezeboxDhcpRecord> {
|
||||
public id = 'squeezebox-dhcp-match';
|
||||
public source = 'dhcp' as const;
|
||||
public description = 'Recognize Squeezebox player DHCP hints that can start LMS setup.';
|
||||
|
||||
public async matches(recordArg: ISqueezeboxDhcpRecord): Promise<IDiscoveryMatch> {
|
||||
const hostname = recordArg.hostname || recordArg.name || '';
|
||||
const mac = recordArg.macaddress || recordArg.macAddress || '';
|
||||
const matched = hostname.toLowerCase().startsWith('squeezebox') || normalizeMac(mac).startsWith('000420');
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'DHCP record is not a known Squeezebox player hint.' };
|
||||
}
|
||||
|
||||
const host = recordArg.host || recordArg.ipAddress;
|
||||
const id = normalizeMac(mac) || host || hostname;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: normalizeMac(mac).startsWith('000420') ? 'high' : 'medium',
|
||||
reason: 'DHCP record matches the Home Assistant Squeezebox player discovery hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'dhcp',
|
||||
integrationDomain: squeezeboxDomain,
|
||||
id,
|
||||
host,
|
||||
port: squeezeboxDefaultHttpPort,
|
||||
name: hostname || 'Squeezebox player',
|
||||
manufacturer: 'Logitech',
|
||||
model: 'Squeezebox Player',
|
||||
macAddress: mac || undefined,
|
||||
metadata: {
|
||||
...recordArg.metadata,
|
||||
playerDiscovery: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SqueezeboxManualMatcher implements IDiscoveryMatcher<ISqueezeboxManualEntry> {
|
||||
public id = 'squeezebox-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Lyrion Music Server setup entries.';
|
||||
|
||||
public async matches(inputArg: ISqueezeboxManualEntry): Promise<IDiscoveryMatch> {
|
||||
const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase();
|
||||
const matched = Boolean(inputArg.host || inputArg.metadata?.squeezebox || inputArg.metadata?.lms || lmsNames.some((needleArg) => haystack.includes(needleArg)));
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain LMS/Squeezebox setup hints.' };
|
||||
}
|
||||
|
||||
const port = inputArg.port || squeezeboxDefaultHttpPort;
|
||||
const id = inputArg.id || (inputArg.host ? `${inputArg.host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: inputArg.host ? 'high' : 'medium',
|
||||
reason: 'Manual entry can start Lyrion Music Server setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: squeezeboxDomain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port,
|
||||
name: inputArg.name,
|
||||
manufacturer: inputArg.manufacturer || 'Lyrion',
|
||||
model: inputArg.model || 'Lyrion Music Server',
|
||||
metadata: {
|
||||
...inputArg.metadata,
|
||||
cliPort: inputArg.cliPort,
|
||||
https: inputArg.https,
|
||||
username: inputArg.username ? true : undefined,
|
||||
password: inputArg.password ? true : undefined,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SqueezeboxCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'squeezebox-candidate-validator';
|
||||
public description = 'Validate LMS/Squeezebox discovery candidates have local setup metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate): Promise<IDiscoveryMatch> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase();
|
||||
const mdnsType = typeof metadata.mdnsType === 'string' ? normalizeMdnsType(metadata.mdnsType) : '';
|
||||
const matched = candidateArg.integrationDomain === squeezeboxDomain
|
||||
|| Boolean(metadata.squeezebox || metadata.lms || metadata.playerDiscovery)
|
||||
|| lmsMdnsTypes.has(mdnsType)
|
||||
|| lmsNames.some((needleArg) => haystack.includes(needleArg));
|
||||
|
||||
return {
|
||||
matched,
|
||||
confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Candidate has LMS/Squeezebox metadata.' : 'Candidate is not LMS/Squeezebox.',
|
||||
normalizedDeviceId: candidateArg.id || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || squeezeboxDefaultHttpPort}` : undefined),
|
||||
candidate: matched ? { ...candidateArg, port: candidateArg.port || squeezeboxDefaultHttpPort } : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createSqueezeboxDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: squeezeboxDomain, displayName: 'Squeezebox (Lyrion Music Server)' })
|
||||
.addMatcher(new SqueezeboxMdnsMatcher())
|
||||
.addMatcher(new SqueezeboxDhcpMatcher())
|
||||
.addMatcher(new SqueezeboxManualMatcher())
|
||||
.addValidator(new SqueezeboxCandidateValidator());
|
||||
};
|
||||
|
||||
const normalizeMdnsType = (valueArg: string): string => valueArg.toLowerCase().replace(/\.local\.?$/, '').replace(/\.$/, '');
|
||||
|
||||
const cleanName = (valueArg: string | undefined): string | undefined => {
|
||||
return valueArg
|
||||
?.replace(/\._squeezebox(?:-jsonrpc|-server)?\._tcp\.local\.?$/i, '')
|
||||
.replace(/\._lms\._tcp\.local\.?$/i, '')
|
||||
.replace(/\._slimserver\._tcp\.local\.?$/i, '')
|
||||
.replace(/\.local\.?$/i, '')
|
||||
.trim() || undefined;
|
||||
};
|
||||
|
||||
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 numberString = (valueArg: string | undefined): number | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(valueArg);
|
||||
return Number.isFinite(value) && value > 0 ? Math.round(value) : undefined;
|
||||
};
|
||||
|
||||
const normalizeMac = (valueArg: string | undefined): string => (valueArg || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
@@ -0,0 +1,380 @@
|
||||
import * as plugins from '../../plugins.js';
|
||||
import type { IIntegrationEntity } from '../../core/types.js';
|
||||
import type { ISqueezeboxFavorite, ISqueezeboxPlayer, ISqueezeboxServerInfo, ISqueezeboxSnapshot, ISqueezeboxSyncGroup, ISqueezeboxTrack } from './squeezebox.types.js';
|
||||
|
||||
export class SqueezeboxMapper {
|
||||
public static toDevices(snapshotArg: ISqueezeboxSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] {
|
||||
const updatedAt = snapshotArg.updatedAt || new Date().toISOString();
|
||||
const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [{
|
||||
id: this.serverDeviceId(snapshotArg),
|
||||
integrationDomain: 'squeezebox',
|
||||
name: this.serverName(snapshotArg.server),
|
||||
protocol: 'http',
|
||||
manufacturer: snapshotArg.server.manufacturer || 'Lyrion',
|
||||
model: snapshotArg.server.model || 'Lyrion Music Server',
|
||||
online: snapshotArg.online,
|
||||
features: [
|
||||
{ id: 'players', capability: 'sensor', name: 'Players', readable: true, writable: false },
|
||||
{ id: 'favorites', capability: 'media', name: 'Favorites', readable: true, writable: false },
|
||||
{ id: 'sync_groups', capability: 'media', name: 'Sync groups', readable: true, writable: true },
|
||||
{ id: 'library_songs', capability: 'sensor', name: 'Library songs', readable: true, writable: false },
|
||||
{ id: 'rescan', capability: 'sensor', name: 'Library rescan', readable: true, writable: false },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'players', value: snapshotArg.server.playerCount ?? snapshotArg.players.length, updatedAt },
|
||||
{ featureId: 'favorites', value: snapshotArg.favorites?.length || 0, updatedAt },
|
||||
{ featureId: 'sync_groups', value: this.syncGroups(snapshotArg).length, updatedAt },
|
||||
{ featureId: 'library_songs', value: snapshotArg.server.stats?.totalSongs ?? null, updatedAt },
|
||||
{ featureId: 'rescan', value: snapshotArg.server.rescan ?? null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
uuid: snapshotArg.server.uuid,
|
||||
mac: snapshotArg.server.mac,
|
||||
version: snapshotArg.server.version,
|
||||
host: snapshotArg.server.host,
|
||||
port: snapshotArg.server.port,
|
||||
source: snapshotArg.source,
|
||||
},
|
||||
}];
|
||||
|
||||
for (const player of snapshotArg.players) {
|
||||
devices.push({
|
||||
id: this.playerDeviceId(player),
|
||||
integrationDomain: 'squeezebox',
|
||||
name: player.name,
|
||||
protocol: 'http',
|
||||
manufacturer: player.manufacturer || player.creator || 'Logitech',
|
||||
model: player.model || 'Squeezebox Player',
|
||||
online: this.playerAvailable(player),
|
||||
features: [
|
||||
{ id: 'playback', capability: 'media', name: 'Playback', readable: true, writable: true },
|
||||
{ id: 'power', capability: 'switch', name: 'Power', 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: 'sync_group', capability: 'media', name: 'Sync group', readable: true, writable: true },
|
||||
{ id: 'current_title', capability: 'media', name: 'Current title', readable: true, writable: false },
|
||||
],
|
||||
state: [
|
||||
{ featureId: 'playback', value: this.mediaState(player), updatedAt },
|
||||
{ featureId: 'power', value: player.power ?? null, updatedAt },
|
||||
{ featureId: 'volume', value: this.volumePercent(player) ?? null, updatedAt },
|
||||
{ featureId: 'muted', value: player.muting ?? null, updatedAt },
|
||||
{ featureId: 'source', value: this.currentSource(snapshotArg, player) || null, updatedAt },
|
||||
{ featureId: 'sync_group', value: this.groupForPlayer(snapshotArg, player)?.id || null, updatedAt },
|
||||
{ featureId: 'current_title', value: this.mediaTitle(player) || null, updatedAt },
|
||||
],
|
||||
metadata: {
|
||||
playerId: player.playerId,
|
||||
uuid: player.uuid,
|
||||
firmware: player.firmware,
|
||||
ipAddress: player.ipAddress,
|
||||
connected: player.connected,
|
||||
modelType: player.modelType,
|
||||
viaDeviceId: this.serverDeviceId(snapshotArg),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return devices;
|
||||
}
|
||||
|
||||
public static toEntities(snapshotArg: ISqueezeboxSnapshot): IIntegrationEntity[] {
|
||||
const entities: IIntegrationEntity[] = [];
|
||||
const sourceList = this.sourceList(snapshotArg);
|
||||
for (const player of snapshotArg.players) {
|
||||
const base = this.playerEntityBase(player);
|
||||
const group = this.groupForPlayer(snapshotArg, player);
|
||||
const available = this.playerAvailable(player);
|
||||
entities.push({
|
||||
id: this.playerEntityId(player),
|
||||
uniqueId: `squeezebox_${this.slug(player.playerId)}`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.playerDeviceId(player),
|
||||
platform: 'media_player',
|
||||
name: player.name,
|
||||
state: this.mediaState(player),
|
||||
attributes: {
|
||||
deviceClass: 'speaker',
|
||||
playerId: player.playerId,
|
||||
uuid: player.uuid,
|
||||
model: player.model,
|
||||
modelType: player.modelType,
|
||||
firmware: player.firmware,
|
||||
ipAddress: player.ipAddress,
|
||||
connected: player.connected,
|
||||
power: player.power,
|
||||
volumeLevel: this.volumeLevel(player),
|
||||
volume: this.volumePercent(player),
|
||||
isVolumeMuted: player.muting,
|
||||
source: this.currentSource(snapshotArg, player),
|
||||
sourceList,
|
||||
repeat: this.repeatMode(player),
|
||||
shuffle: player.shuffle === 'song',
|
||||
shuffleMode: player.shuffle,
|
||||
mediaContentId: player.url,
|
||||
mediaContentType: player.playlist && player.playlist.length > 1 ? 'playlist' : 'music',
|
||||
mediaDuration: player.duration,
|
||||
mediaPosition: player.time,
|
||||
mediaImageUrl: player.imageUrl,
|
||||
mediaTitle: player.title,
|
||||
mediaChannel: player.remoteTitle,
|
||||
mediaArtist: player.artist,
|
||||
mediaAlbumName: player.album,
|
||||
currentIndex: player.currentIndex,
|
||||
playlist: player.playlist,
|
||||
syncGroupId: group?.id,
|
||||
groupMembers: this.groupMembers(snapshotArg, player),
|
||||
alarmsEnabled: player.alarmsEnabled,
|
||||
alarmNext: player.alarmNext,
|
||||
},
|
||||
available,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${base}_squeezebox_media`,
|
||||
uniqueId: `squeezebox_${this.slug(player.playerId)}_media`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.playerDeviceId(player),
|
||||
platform: 'sensor',
|
||||
name: `${player.name} Squeezebox Media`,
|
||||
state: this.mediaTitle(player) || 'None',
|
||||
attributes: {
|
||||
playerId: player.playerId,
|
||||
url: player.url,
|
||||
title: player.title,
|
||||
remoteTitle: player.remoteTitle,
|
||||
artist: player.artist,
|
||||
album: player.album,
|
||||
playlist: player.playlist,
|
||||
},
|
||||
available,
|
||||
});
|
||||
|
||||
if (player.alarms?.length) {
|
||||
entities.push({
|
||||
id: `sensor.${base}_squeezebox_alarms`,
|
||||
uniqueId: `squeezebox_${this.slug(player.playerId)}_alarms`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.playerDeviceId(player),
|
||||
platform: 'sensor',
|
||||
name: `${player.name} Squeezebox Alarms`,
|
||||
state: player.alarms.length,
|
||||
attributes: { playerId: player.playerId, alarms: player.alarms },
|
||||
available,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${this.slug(this.serverName(snapshotArg.server))}_favorites`,
|
||||
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_favorites`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.serverDeviceId(snapshotArg),
|
||||
platform: 'sensor',
|
||||
name: `${this.serverName(snapshotArg.server)} Favorites`,
|
||||
state: snapshotArg.favorites?.length || 0,
|
||||
attributes: {
|
||||
favorites: snapshotArg.favorites || [],
|
||||
sourceList,
|
||||
},
|
||||
available: snapshotArg.online,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${this.slug(this.serverName(snapshotArg.server))}_sync_groups`,
|
||||
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_sync_groups`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.serverDeviceId(snapshotArg),
|
||||
platform: 'sensor',
|
||||
name: `${this.serverName(snapshotArg.server)} Sync Groups`,
|
||||
state: this.syncGroups(snapshotArg).length,
|
||||
attributes: {
|
||||
syncGroups: this.syncGroups(snapshotArg).map((groupArg) => ({
|
||||
...groupArg,
|
||||
members: groupArg.playerIds.map((playerIdArg) => {
|
||||
const player = snapshotArg.players.find((itemArg) => itemArg.playerId === playerIdArg);
|
||||
return player ? this.playerEntityId(player) : undefined;
|
||||
}).filter((valueArg): valueArg is string => Boolean(valueArg)),
|
||||
})),
|
||||
},
|
||||
available: snapshotArg.online,
|
||||
});
|
||||
|
||||
entities.push({
|
||||
id: `sensor.${this.slug(this.serverName(snapshotArg.server))}_server_status`,
|
||||
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_server_status`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.serverDeviceId(snapshotArg),
|
||||
platform: 'sensor',
|
||||
name: `${this.serverName(snapshotArg.server)} Server Status`,
|
||||
state: snapshotArg.online ? 'online' : 'offline',
|
||||
attributes: {
|
||||
version: snapshotArg.server.version,
|
||||
playerCount: snapshotArg.server.playerCount ?? snapshotArg.players.length,
|
||||
otherPlayerCount: snapshotArg.server.otherPlayerCount,
|
||||
stats: snapshotArg.server.stats,
|
||||
rescan: snapshotArg.server.rescan,
|
||||
needsRestart: snapshotArg.server.needsRestart,
|
||||
error: snapshotArg.error,
|
||||
},
|
||||
available: true,
|
||||
});
|
||||
|
||||
if (snapshotArg.server.rescan !== undefined) {
|
||||
entities.push({
|
||||
id: `binary_sensor.${this.slug(this.serverName(snapshotArg.server))}_library_rescan`,
|
||||
uniqueId: `squeezebox_${this.serverUniqueBase(snapshotArg)}_library_rescan`,
|
||||
integrationDomain: 'squeezebox',
|
||||
deviceId: this.serverDeviceId(snapshotArg),
|
||||
platform: 'binary_sensor',
|
||||
name: `${this.serverName(snapshotArg.server)} Library Rescan`,
|
||||
state: snapshotArg.server.rescan ? 'on' : 'off',
|
||||
attributes: {},
|
||||
available: snapshotArg.online,
|
||||
});
|
||||
}
|
||||
|
||||
return entities;
|
||||
}
|
||||
|
||||
public static entityPlayerId(snapshotArg: ISqueezeboxSnapshot, entityIdArg: string): string | undefined {
|
||||
const entity = this.toEntities(snapshotArg).find((entityArg) => entityArg.id === entityIdArg);
|
||||
const playerId = entity?.attributes?.playerId;
|
||||
return typeof playerId === 'string' ? playerId : undefined;
|
||||
}
|
||||
|
||||
public static playerEntityId(playerArg: ISqueezeboxPlayer): string {
|
||||
return `media_player.${this.playerEntityBase(playerArg)}`;
|
||||
}
|
||||
|
||||
public static playerDeviceId(playerArg: ISqueezeboxPlayer): string {
|
||||
return `squeezebox.player.${this.slug(playerArg.playerId)}`;
|
||||
}
|
||||
|
||||
public static serverDeviceId(snapshotArg: ISqueezeboxSnapshot): string {
|
||||
return `squeezebox.server.${this.serverUniqueBase(snapshotArg)}`;
|
||||
}
|
||||
|
||||
public static slug(valueArg: string | undefined): string {
|
||||
return (valueArg || 'squeezebox').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'squeezebox';
|
||||
}
|
||||
|
||||
private static playerAvailable(playerArg: ISqueezeboxPlayer): boolean {
|
||||
return playerArg.available !== false && playerArg.connected !== false;
|
||||
}
|
||||
|
||||
private static mediaState(playerArg: ISqueezeboxPlayer): string {
|
||||
if (!this.playerAvailable(playerArg) || playerArg.power === false) {
|
||||
return 'off';
|
||||
}
|
||||
if (playerArg.mode === 'play') {
|
||||
return 'playing';
|
||||
}
|
||||
if (playerArg.mode === 'pause') {
|
||||
return 'paused';
|
||||
}
|
||||
if (playerArg.mode === 'stop') {
|
||||
return 'idle';
|
||||
}
|
||||
return playerArg.mode || 'unknown';
|
||||
}
|
||||
|
||||
private static repeatMode(playerArg: ISqueezeboxPlayer): 'off' | 'one' | 'all' | undefined {
|
||||
if (!playerArg.repeat) {
|
||||
return undefined;
|
||||
}
|
||||
if (playerArg.repeat === 'song') {
|
||||
return 'one';
|
||||
}
|
||||
if (playerArg.repeat === 'playlist') {
|
||||
return 'all';
|
||||
}
|
||||
return 'off';
|
||||
}
|
||||
|
||||
private static mediaTitle(playerArg: ISqueezeboxPlayer): string | undefined {
|
||||
return playerArg.title || playerArg.remoteTitle || playerArg.playlist?.[playerArg.currentIndex || 0]?.title;
|
||||
}
|
||||
|
||||
private static volumePercent(playerArg: ISqueezeboxPlayer): number | undefined {
|
||||
return typeof playerArg.volume === 'number' ? Math.max(0, Math.min(100, Math.round(playerArg.volume))) : undefined;
|
||||
}
|
||||
|
||||
private static volumeLevel(playerArg: ISqueezeboxPlayer): number | undefined {
|
||||
const volume = this.volumePercent(playerArg);
|
||||
return typeof volume === 'number' ? volume / 100 : undefined;
|
||||
}
|
||||
|
||||
private static currentSource(snapshotArg: ISqueezeboxSnapshot, playerArg: ISqueezeboxPlayer): string | undefined {
|
||||
if (playerArg.source) {
|
||||
return playerArg.source;
|
||||
}
|
||||
if (playerArg.url) {
|
||||
const favorite = (snapshotArg.favorites || []).find((favoriteArg) => favoriteArg.url === playerArg.url || favoriteArg.itemId === playerArg.url);
|
||||
if (favorite) {
|
||||
return favorite.name;
|
||||
}
|
||||
if (sourceLike(playerArg.url)) {
|
||||
return playerArg.url;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private static sourceList(snapshotArg: ISqueezeboxSnapshot): string[] {
|
||||
const values = [
|
||||
...(snapshotArg.favorites || []).map((favoriteArg) => favoriteArg.name),
|
||||
...snapshotArg.players.map((playerArg) => sourceLike(playerArg.url) ? playerArg.url : undefined),
|
||||
].filter((valueArg): valueArg is string => Boolean(valueArg));
|
||||
return [...new Set(values)];
|
||||
}
|
||||
|
||||
private static groupForPlayer(snapshotArg: ISqueezeboxSnapshot, playerArg: ISqueezeboxPlayer): ISqueezeboxSyncGroup | undefined {
|
||||
return this.syncGroups(snapshotArg).find((groupArg) => groupArg.playerIds.includes(playerArg.playerId));
|
||||
}
|
||||
|
||||
private static groupMembers(snapshotArg: ISqueezeboxSnapshot, playerArg: ISqueezeboxPlayer): string[] | undefined {
|
||||
const group = this.groupForPlayer(snapshotArg, playerArg);
|
||||
if (!group) {
|
||||
return playerArg.syncGroup?.length ? playerArg.syncGroup.map((playerIdArg) => this.playerById(snapshotArg, playerIdArg)).filter((valueArg): valueArg is ISqueezeboxPlayer => Boolean(valueArg)).map((itemArg) => this.playerEntityId(itemArg)) : undefined;
|
||||
}
|
||||
return group.playerIds.map((playerIdArg) => this.playerById(snapshotArg, playerIdArg)).filter((valueArg): valueArg is ISqueezeboxPlayer => Boolean(valueArg)).map((itemArg) => this.playerEntityId(itemArg));
|
||||
}
|
||||
|
||||
private static playerById(snapshotArg: ISqueezeboxSnapshot, playerIdArg: string): ISqueezeboxPlayer | undefined {
|
||||
return snapshotArg.players.find((playerArg) => playerArg.playerId === playerIdArg);
|
||||
}
|
||||
|
||||
private static syncGroups(snapshotArg: ISqueezeboxSnapshot): ISqueezeboxSyncGroup[] {
|
||||
if (snapshotArg.syncGroups?.length) {
|
||||
return snapshotArg.syncGroups;
|
||||
}
|
||||
const groups = new Map<string, ISqueezeboxSyncGroup>();
|
||||
for (const player of snapshotArg.players) {
|
||||
const ids = [player.playerId, ...(player.syncGroup || [])].filter(Boolean).sort();
|
||||
if (ids.length < 2) {
|
||||
continue;
|
||||
}
|
||||
const key = ids.join(',');
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, { id: `sync_${this.slug(key)}`, playerIds: ids, leaderPlayerId: ids[0] });
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private static playerEntityBase(playerArg: ISqueezeboxPlayer): string {
|
||||
return this.slug(playerArg.name || playerArg.playerId);
|
||||
}
|
||||
|
||||
private static serverUniqueBase(snapshotArg: ISqueezeboxSnapshot): string {
|
||||
return this.slug(snapshotArg.server.uuid || snapshotArg.server.id || snapshotArg.server.host || this.serverName(snapshotArg.server));
|
||||
}
|
||||
|
||||
private static serverName(serverArg: ISqueezeboxServerInfo): string {
|
||||
return serverArg.name || serverArg.libraryName || serverArg.host || 'Lyrion Music Server';
|
||||
}
|
||||
}
|
||||
|
||||
const sourceLike = (valueArg: string | undefined): valueArg is string => Boolean(valueArg && /^(source|wavin|spotify|loop):/i.test(valueArg));
|
||||
@@ -1,4 +1,262 @@
|
||||
export interface IHomeAssistantSqueezeboxConfig {
|
||||
// TODO: replace with the TypeScript-native config for squeezebox.
|
||||
[key: string]: unknown;
|
||||
export const squeezeboxDefaultHttpPort = 9000;
|
||||
export const squeezeboxDefaultCliPort = 9090;
|
||||
export const squeezeboxDefaultTimeoutMs = 5000;
|
||||
export const squeezeboxDefaultBrowseLimit = 1000;
|
||||
export const squeezeboxDefaultVolumeStep = 5;
|
||||
|
||||
export type TSqueezeboxTransport = 'jsonrpc' | 'cli' | 'snapshot';
|
||||
export type TSqueezeboxSnapshotSource = 'snapshot' | 'jsonrpc' | 'cli' | 'executor' | 'runtime' | 'manual';
|
||||
export type TSqueezeboxPlaybackMode = 'play' | 'pause' | 'stop' | 'unknown' | (string & {});
|
||||
export type TSqueezeboxRepeatMode = 'none' | 'song' | 'playlist' | (string & {});
|
||||
export type TSqueezeboxShuffleMode = 'none' | 'song' | 'album' | (string & {});
|
||||
export type TSqueezeboxMediaCommand =
|
||||
| 'play'
|
||||
| 'pause'
|
||||
| 'play_pause'
|
||||
| 'stop'
|
||||
| 'next_track'
|
||||
| 'previous_track'
|
||||
| 'seek'
|
||||
| 'set_power'
|
||||
| 'set_volume'
|
||||
| 'volume_up'
|
||||
| 'volume_down'
|
||||
| 'mute'
|
||||
| 'select_source'
|
||||
| 'play_media'
|
||||
| 'sync'
|
||||
| 'unsync'
|
||||
| 'raw_query';
|
||||
|
||||
export interface ISqueezeboxConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
cliPort?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
https?: boolean;
|
||||
name?: string;
|
||||
serverId?: string;
|
||||
timeoutMs?: number;
|
||||
browseLimit?: number;
|
||||
volumeStep?: number;
|
||||
transport?: TSqueezeboxTransport;
|
||||
snapshot?: ISqueezeboxSnapshot;
|
||||
commandExecutor?: ISqueezeboxCommandExecutor;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantSqueezeboxConfig extends ISqueezeboxConfig {}
|
||||
|
||||
export interface ISqueezeboxCommandExecutor {
|
||||
execute(requestArg: ISqueezeboxRawCommandRequest): Promise<ISqueezeboxJsonRpcResponse | ISqueezeboxCliResponse | Record<string, unknown> | unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxRawCommandRequest {
|
||||
transport: Exclude<TSqueezeboxTransport, 'snapshot'>;
|
||||
host?: string;
|
||||
port: number;
|
||||
endpoint?: string;
|
||||
playerId?: string;
|
||||
command: string[];
|
||||
body?: ISqueezeboxJsonRpcRequest;
|
||||
cliLine?: string;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxJsonRpcRequest {
|
||||
id: number | string;
|
||||
method: 'slim.request';
|
||||
params: [string | 0, string[]];
|
||||
}
|
||||
|
||||
export interface ISqueezeboxJsonRpcError {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxJsonRpcResponse<TResult = Record<string, unknown>> {
|
||||
id?: number | string;
|
||||
method?: string;
|
||||
params?: unknown[];
|
||||
result?: TResult;
|
||||
error?: ISqueezeboxJsonRpcError | string;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxCliResponse {
|
||||
playerId?: string;
|
||||
command: string[];
|
||||
rawLine: string;
|
||||
tokens: string[];
|
||||
result: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxCommandRequest {
|
||||
command: TSqueezeboxMediaCommand;
|
||||
playerId?: string;
|
||||
targetPlayerId?: string;
|
||||
playerIds?: string[];
|
||||
volumeLevel?: number;
|
||||
volume?: number;
|
||||
step?: number;
|
||||
muted?: boolean;
|
||||
powered?: boolean;
|
||||
source?: string;
|
||||
mediaId?: string;
|
||||
mediaType?: string;
|
||||
enqueue?: 'play' | 'add' | 'next';
|
||||
position?: number;
|
||||
parameters?: Array<string | number | boolean>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxServerInfo {
|
||||
id?: string;
|
||||
uuid?: string;
|
||||
mac?: string;
|
||||
name?: string;
|
||||
libraryName?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
version?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
playerCount?: number;
|
||||
otherPlayerCount?: number;
|
||||
rescan?: boolean;
|
||||
needsRestart?: boolean;
|
||||
stats?: {
|
||||
totalAlbums?: number;
|
||||
totalArtists?: number;
|
||||
totalDuration?: number;
|
||||
totalGenres?: number;
|
||||
totalSongs?: number;
|
||||
lastScan?: string | number;
|
||||
};
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxTrack {
|
||||
id?: string | number;
|
||||
url?: string;
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
duration?: number;
|
||||
remoteTitle?: string;
|
||||
imageUrl?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxAlarm {
|
||||
id: string;
|
||||
enabled?: boolean;
|
||||
time?: string;
|
||||
repeat?: boolean;
|
||||
scheduledToday?: boolean;
|
||||
daysOfWeek?: number[];
|
||||
volume?: number;
|
||||
url?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxPlayer {
|
||||
playerId: string;
|
||||
uuid?: string;
|
||||
name: string;
|
||||
model?: string;
|
||||
modelType?: string;
|
||||
manufacturer?: string;
|
||||
creator?: string;
|
||||
firmware?: string;
|
||||
ipAddress?: string;
|
||||
connected?: boolean;
|
||||
power?: boolean;
|
||||
mode?: TSqueezeboxPlaybackMode;
|
||||
volume?: number;
|
||||
muting?: boolean;
|
||||
repeat?: TSqueezeboxRepeatMode;
|
||||
shuffle?: TSqueezeboxShuffleMode;
|
||||
time?: number;
|
||||
duration?: number;
|
||||
title?: string;
|
||||
remoteTitle?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
url?: string;
|
||||
imageUrl?: string;
|
||||
playlist?: ISqueezeboxTrack[];
|
||||
currentIndex?: number;
|
||||
alarms?: ISqueezeboxAlarm[];
|
||||
alarmsEnabled?: boolean;
|
||||
alarmNext?: string;
|
||||
syncGroup?: string[];
|
||||
source?: string;
|
||||
available?: boolean;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxFavorite {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
url?: string;
|
||||
itemId?: string;
|
||||
imageUrl?: string;
|
||||
playable?: boolean;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxSyncGroup {
|
||||
id: string;
|
||||
name?: string;
|
||||
playerIds: string[];
|
||||
leaderPlayerId?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxSnapshot {
|
||||
server: ISqueezeboxServerInfo;
|
||||
players: ISqueezeboxPlayer[];
|
||||
favorites?: ISqueezeboxFavorite[];
|
||||
syncGroups?: ISqueezeboxSyncGroup[];
|
||||
online: boolean;
|
||||
updatedAt?: string;
|
||||
source?: TSqueezeboxSnapshotSource;
|
||||
error?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxMdnsRecord {
|
||||
name?: string;
|
||||
type?: string;
|
||||
serviceType?: string;
|
||||
host?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
addresses?: string[];
|
||||
txt?: Record<string, string | undefined>;
|
||||
properties?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxDhcpRecord {
|
||||
hostname?: string;
|
||||
macaddress?: string;
|
||||
macAddress?: string;
|
||||
ipAddress?: string;
|
||||
host?: string;
|
||||
name?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISqueezeboxManualEntry {
|
||||
host?: string;
|
||||
port?: number;
|
||||
cliPort?: number;
|
||||
id?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
https?: boolean;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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 './synology_dsm.classes.integration.js';
|
||||
export * from './synology_dsm.classes.client.js';
|
||||
export * from './synology_dsm.classes.configflow.js';
|
||||
export * from './synology_dsm.discovery.js';
|
||||
export * from './synology_dsm.mapper.js';
|
||||
export * from './synology_dsm.types.js';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { ISynologyDsmCommand, ISynologyDsmCommandResult, ISynologyDsmConfig, ISynologyDsmEvent, ISynologyDsmSnapshot } from './synology_dsm.types.js';
|
||||
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
|
||||
|
||||
type TSynologyDsmEventHandler = (eventArg: ISynologyDsmEvent) => void;
|
||||
|
||||
export class SynologyDsmClient {
|
||||
private currentSnapshot?: ISynologyDsmSnapshot;
|
||||
private readonly eventHandlers = new Set<TSynologyDsmEventHandler>();
|
||||
|
||||
constructor(private readonly config: ISynologyDsmConfig) {}
|
||||
|
||||
public async getSnapshot(): Promise<ISynologyDsmSnapshot> {
|
||||
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 = SynologyDsmMapper.toSnapshot(this.config, this.config.connected ?? this.config.online ?? this.hasManualData());
|
||||
if (!this.hasManualData()) {
|
||||
this.currentSnapshot = {
|
||||
...this.currentSnapshot,
|
||||
connected: false,
|
||||
error: this.config.host
|
||||
? 'Synology DSM live HTTP API access is not implemented by this dependency-free TypeScript port. Provide nativeClient, snapshotProvider, or snapshot/manual data.'
|
||||
: 'Synology DSM setup requires a NAS host plus nativeClient/snapshotProvider, or snapshot/manual data.',
|
||||
};
|
||||
}
|
||||
}
|
||||
return this.cloneSnapshot(this.currentSnapshot);
|
||||
}
|
||||
|
||||
public onEvent(handlerArg: TSynologyDsmEventHandler): () => void {
|
||||
this.eventHandlers.add(handlerArg);
|
||||
return () => this.eventHandlers.delete(handlerArg);
|
||||
}
|
||||
|
||||
public async refresh(): Promise<ISynologyDsmCommandResult> {
|
||||
try {
|
||||
this.currentSnapshot = undefined;
|
||||
const snapshot = await this.getSnapshot();
|
||||
const success = snapshot.connected || this.hasManualData() || Boolean(this.config.nativeClient || this.config.snapshotProvider);
|
||||
this.emit({ type: success ? 'snapshot_refreshed' : 'refresh_failed', snapshot, error: success ? undefined : snapshot.error, timestamp: Date.now() });
|
||||
return success ? { success: true, data: snapshot } : { success: false, error: snapshot.error, data: snapshot };
|
||||
} catch (errorArg) {
|
||||
const error = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
const snapshot = SynologyDsmMapper.toSnapshot({ ...this.config, snapshot: this.currentSnapshot, online: false }, false);
|
||||
this.currentSnapshot = { ...snapshot, error };
|
||||
this.emit({ type: 'refresh_failed', snapshot: this.currentSnapshot, error, timestamp: Date.now() });
|
||||
return { success: false, error, data: this.cloneSnapshot(this.currentSnapshot) };
|
||||
}
|
||||
}
|
||||
|
||||
public async sendCommand(commandArg: ISynologyDsmCommand): Promise<ISynologyDsmCommandResult> {
|
||||
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: ISynologyDsmCommandResult = {
|
||||
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: ISynologyDsmCommandResult = { 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: ISynologyDsmSnapshot, sourceArg: ISynologyDsmSnapshot['source']): ISynologyDsmSnapshot {
|
||||
const normalized = SynologyDsmMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected);
|
||||
return { ...normalized, source: snapshotArg.source || sourceArg };
|
||||
}
|
||||
|
||||
private hasManualData(): boolean {
|
||||
return Boolean(
|
||||
this.config.snapshot
|
||||
|| this.config.system
|
||||
|| this.config.information
|
||||
|| this.config.utilization
|
||||
|| this.config.storage
|
||||
|| this.config.network
|
||||
|| this.config.volumes?.length
|
||||
|| this.config.disks?.length
|
||||
|| this.config.cameras?.length
|
||||
|| this.config.switches
|
||||
|| this.config.update
|
||||
|| this.config.security
|
||||
);
|
||||
}
|
||||
|
||||
private commandResult(resultArg: unknown, commandArg: ISynologyDsmCommand): ISynologyDsmCommandResult {
|
||||
if (this.isCommandResult(resultArg)) {
|
||||
return resultArg;
|
||||
}
|
||||
return { success: true, data: resultArg ?? { command: commandArg } };
|
||||
}
|
||||
|
||||
private isCommandResult(valueArg: unknown): valueArg is ISynologyDsmCommandResult {
|
||||
return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg);
|
||||
}
|
||||
|
||||
private unsupportedCommandMessage(commandArg: ISynologyDsmCommand): string {
|
||||
const action = commandArg.action.replace(/_/g, ' ');
|
||||
return `Synology DSM live ${action} command execution is not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live DSM system, Surveillance Station switch, or camera actions.`;
|
||||
}
|
||||
|
||||
private emit(eventArg: ISynologyDsmEvent): void {
|
||||
for (const handler of this.eventHandlers) {
|
||||
handler(eventArg);
|
||||
}
|
||||
}
|
||||
|
||||
private cloneSnapshot<T extends ISynologyDsmSnapshot>(snapshotArg: T): T {
|
||||
return JSON.parse(JSON.stringify(snapshotArg)) as T;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js';
|
||||
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
|
||||
import type { ISynologyDsmConfig, ISynologyDsmSnapshot } from './synology_dsm.types.js';
|
||||
import { synologyDsmDefaultSnapshotQuality, synologyDsmDefaultSsl, synologyDsmDefaultTimeoutMs, synologyDsmDefaultVerifySsl } from './synology_dsm.types.js';
|
||||
|
||||
export class SynologyDsmConfigFlow implements IConfigFlow<ISynologyDsmConfig> {
|
||||
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<ISynologyDsmConfig>> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const ssl = this.booleanValue(metadata.ssl) ?? synologyDsmDefaultSsl;
|
||||
return {
|
||||
kind: 'form',
|
||||
title: 'Connect Synology DSM',
|
||||
description: 'Provide the local DSM endpoint. Snapshot/manual data and injected native clients are supported directly; live DSM API success is not assumed without a native client or snapshot provider.',
|
||||
fields: [
|
||||
{ name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true },
|
||||
{ name: 'port', label: `Port (${candidateArg.port || SynologyDsmMapper.defaultPort(ssl)})`, 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: 'serial', label: 'Serial', type: 'text' },
|
||||
{ name: 'snapshotQuality', label: 'Surveillance Station snapshot quality', type: 'select', options: [
|
||||
{ label: 'Low', value: '0' },
|
||||
{ label: 'Balanced', value: '1' },
|
||||
{ label: 'High', value: '2' },
|
||||
] },
|
||||
{ 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<ISynologyDsmConfig>> {
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = this.snapshotFromInput(valuesArg.snapshotJson || metadata.snapshot);
|
||||
if (snapshot instanceof Error) {
|
||||
return { kind: 'error', title: 'Invalid Synology DSM snapshot', error: snapshot.message };
|
||||
}
|
||||
|
||||
const ssl = this.booleanValue(valuesArg.ssl) ?? this.booleanValue(metadata.ssl) ?? snapshot?.system.ssl ?? synologyDsmDefaultSsl;
|
||||
const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.system.host;
|
||||
if (!host && !snapshot) {
|
||||
return { kind: 'error', title: 'Synology DSM setup failed', error: 'Synology DSM setup requires a host or snapshot JSON.' };
|
||||
}
|
||||
|
||||
const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.system.port || SynologyDsmMapper.defaultPort(ssl);
|
||||
if (!this.validPort(port)) {
|
||||
return { kind: 'error', title: 'Synology DSM setup failed', error: 'Synology DSM port must be between 1 and 65535.' };
|
||||
}
|
||||
const username = this.stringValue(valuesArg.username) || this.stringValue(metadata.username);
|
||||
const password = this.stringValue(valuesArg.password) || this.stringValue(metadata.password);
|
||||
if (password && !username) {
|
||||
return { kind: 'error', title: 'Synology DSM setup failed', error: 'Username is required when a password is provided.' };
|
||||
}
|
||||
|
||||
const config: ISynologyDsmConfig = {
|
||||
host,
|
||||
port,
|
||||
ssl,
|
||||
verifySsl: this.booleanValue(valuesArg.verifySsl) ?? this.booleanValue(metadata.verifySsl) ?? snapshot?.system.verifySsl ?? synologyDsmDefaultVerifySsl,
|
||||
username,
|
||||
password,
|
||||
timeoutMs: synologyDsmDefaultTimeoutMs,
|
||||
name: this.stringValue(valuesArg.name) || candidateArg.name || snapshot?.system.name || snapshot?.system.hostname || host,
|
||||
serial: this.stringValue(valuesArg.serial) || candidateArg.serialNumber || snapshot?.system.serial,
|
||||
uniqueId: candidateArg.id || snapshot?.system.serial || candidateArg.serialNumber || candidateArg.macAddress || (host ? `${host}:${port}` : undefined),
|
||||
macAddress: candidateArg.macAddress,
|
||||
macs: snapshot?.system.macs,
|
||||
snapshotQuality: this.numberValue(valuesArg.snapshotQuality) ?? synologyDsmDefaultSnapshotQuality,
|
||||
snapshot,
|
||||
metadata: {
|
||||
discoverySource: candidateArg.source,
|
||||
discoveryMetadata: metadata,
|
||||
upstreamSupportsSsdp: true,
|
||||
upstreamSupportsZeroconf: true,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
kind: 'done',
|
||||
title: 'Synology DSM configured',
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
private snapshotFromInput(valueArg: unknown): ISynologyDsmSnapshot | undefined | Error {
|
||||
if (valueArg && typeof valueArg === 'object') {
|
||||
return valueArg as ISynologyDsmSnapshot;
|
||||
}
|
||||
const text = this.stringValue(valueArg);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text) as ISynologyDsmSnapshot;
|
||||
if (!parsed || typeof parsed !== 'object' || !parsed.system) {
|
||||
return new Error('Snapshot JSON must include a system 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)) {
|
||||
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 (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private validPort(valueArg: number): boolean {
|
||||
return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,109 @@
|
||||
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 { SynologyDsmClient } from './synology_dsm.classes.client.js';
|
||||
import { SynologyDsmConfigFlow } from './synology_dsm.classes.configflow.js';
|
||||
import { createSynologyDsmDiscoveryDescriptor } from './synology_dsm.discovery.js';
|
||||
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
|
||||
import type { ISynologyDsmConfig } from './synology_dsm.types.js';
|
||||
import { synologyDsmDomain } from './synology_dsm.types.js';
|
||||
|
||||
export class HomeAssistantSynologyDsmIntegration extends DescriptorOnlyIntegration {
|
||||
constructor() {
|
||||
super({
|
||||
domain: "synology_dsm",
|
||||
displayName: "Synology DSM",
|
||||
status: 'descriptor-only',
|
||||
metadata: {
|
||||
"source": "home-assistant/core",
|
||||
"upstreamPath": "homeassistant/components/synology_dsm",
|
||||
"upstreamDomain": "synology_dsm",
|
||||
"integrationType": "device",
|
||||
"iotClass": "local_polling",
|
||||
"requirements": [
|
||||
"py-synologydsm-api==2.7.3"
|
||||
],
|
||||
"dependencies": [
|
||||
"http"
|
||||
],
|
||||
"afterDependencies": [],
|
||||
"codeowners": [
|
||||
"@Quentame",
|
||||
"@mib1185"
|
||||
]
|
||||
export class SynologyDsmIntegration extends BaseIntegration<ISynologyDsmConfig> {
|
||||
public readonly domain = synologyDsmDomain;
|
||||
public readonly displayName = 'Synology DSM';
|
||||
public readonly status = 'control-runtime' as const;
|
||||
public readonly discoveryDescriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
public readonly configFlow = new SynologyDsmConfigFlow();
|
||||
public readonly metadata = {
|
||||
source: 'home-assistant/core',
|
||||
upstreamPath: 'homeassistant/components/synology_dsm',
|
||||
upstreamDomain: synologyDsmDomain,
|
||||
integrationType: 'device',
|
||||
iotClass: 'local_polling',
|
||||
requirements: ['py-synologydsm-api==2.7.3'],
|
||||
dependencies: ['http'],
|
||||
afterDependencies: [] as string[],
|
||||
codeowners: ['@Quentame', '@mib1185'],
|
||||
documentation: 'https://www.home-assistant.io/integrations/synology_dsm',
|
||||
configFlow: true,
|
||||
runtime: {
|
||||
mode: 'native TypeScript snapshot/provider Synology DSM mapping',
|
||||
platforms: ['binary_sensor', 'button', 'camera-metadata', 'sensor', 'switch', 'update'],
|
||||
services: ['snapshot', 'status', 'refresh', 'reboot', 'shutdown', 'set_home_mode', 'camera.enable_motion_detection', 'camera.disable_motion_detection'],
|
||||
},
|
||||
});
|
||||
discovery: {
|
||||
manual: true,
|
||||
ssdp: 'Synology Basic:1 SSDP advertisements from the Home Assistant manifest are recognized.',
|
||||
zeroconf: 'Synology _http._tcp.local zeroconf advertisements with vendor metadata are recognized.',
|
||||
http: 'Manual/local DSM HTTP endpoint candidates are recognized; no active LAN scan is performed.',
|
||||
},
|
||||
localApi: {
|
||||
implemented: [
|
||||
'manual/local NAS candidates and config flow',
|
||||
'snapshot/native-client mapping for DSM system, utilization, network, storage, volume, disk, security, Surveillance Station camera, home mode, and DSM update data',
|
||||
'safe command modeling for reboot, shutdown, Surveillance Station home mode, and camera motion detection actions represented by the snapshot',
|
||||
],
|
||||
explicitUnsupported: [
|
||||
'homeassistant_compat shims',
|
||||
'fake Synology DSM HTTP/API login, polling, or command success without nativeClient, snapshotProvider, or commandExecutor injection',
|
||||
'full py-synologydsm-api live protocol parity in dependency-free TypeScript',
|
||||
'native camera image entity streaming because the current integration entity platform model has no camera entity type',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
public async setup(configArg: ISynologyDsmConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
|
||||
void contextArg;
|
||||
return new SynologyDsmRuntime(new SynologyDsmClient(configArg));
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
export class HomeAssistantSynologyDsmIntegration extends SynologyDsmIntegration {}
|
||||
|
||||
class SynologyDsmRuntime implements IIntegrationRuntime {
|
||||
public domain = synologyDsmDomain;
|
||||
|
||||
constructor(private readonly client: SynologyDsmClient) {}
|
||||
|
||||
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
|
||||
return SynologyDsmMapper.toDevices(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async entities(): Promise<IIntegrationEntity[]> {
|
||||
return SynologyDsmMapper.toEntities(await this.client.getSnapshot());
|
||||
}
|
||||
|
||||
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
|
||||
const unsubscribe = this.client.onEvent((eventArg) => handlerArg({
|
||||
type: eventArg.type === 'command_failed' || eventArg.type === 'refresh_failed' ? 'error' : 'state_changed',
|
||||
integrationDomain: synologyDsmDomain,
|
||||
deviceId: eventArg.deviceId,
|
||||
entityId: eventArg.entityId,
|
||||
data: eventArg,
|
||||
timestamp: eventArg.timestamp || Date.now(),
|
||||
}));
|
||||
await this.client.getSnapshot();
|
||||
return async () => unsubscribe();
|
||||
}
|
||||
|
||||
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
|
||||
if (requestArg.domain === synologyDsmDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) {
|
||||
return { success: true, data: await this.client.getSnapshot() };
|
||||
}
|
||||
if (requestArg.domain === synologyDsmDomain && requestArg.service === 'refresh') {
|
||||
return this.client.refresh();
|
||||
}
|
||||
const snapshot = await this.client.getSnapshot();
|
||||
const command = SynologyDsmMapper.commandForService(snapshot, requestArg);
|
||||
if (!command) {
|
||||
return { success: false, error: `Unsupported Synology DSM service mapping: ${requestArg.domain}.${requestArg.service}` };
|
||||
}
|
||||
return this.client.sendCommand(command);
|
||||
}
|
||||
|
||||
public async destroy(): Promise<void> {
|
||||
await this.client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js';
|
||||
import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js';
|
||||
import { SynologyDsmMapper } from './synology_dsm.mapper.js';
|
||||
import type { ISynologyDsmHttpDiscoveryRecord, ISynologyDsmManualDiscoveryRecord, ISynologyDsmMdnsDiscoveryRecord, ISynologyDsmSnapshot, ISynologyDsmSsdpDiscoveryRecord } from './synology_dsm.types.js';
|
||||
import { synologyDsmDefaultSsl, synologyDsmDomain } from './synology_dsm.types.js';
|
||||
|
||||
const synologyTextHints = ['synology', 'diskstation', 'rackstation', 'dsm', 'surveillance station'];
|
||||
const synologyMdnsType = '_http._tcp.local.';
|
||||
const synologySsdpDeviceType = 'urn:schemas-upnp-org:device:Basic:1';
|
||||
|
||||
export class SynologyDsmManualMatcher implements IDiscoveryMatcher<ISynologyDsmManualDiscoveryRecord> {
|
||||
public id = 'synology-dsm-manual-match';
|
||||
public source = 'manual' as const;
|
||||
public description = 'Recognize manual Synology DSM local NAS setup entries and snapshot-only records.';
|
||||
|
||||
public async matches(inputArg: ISynologyDsmManualDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = inputArg.metadata || {};
|
||||
const parsedUrl = parseUrl(inputArg.url);
|
||||
const snapshot = inputArg.snapshot || metadata.snapshot as ISynologyDsmSnapshot | undefined;
|
||||
const host = inputArg.host || parsedUrl?.host || snapshot?.system.host;
|
||||
const text = textValue(inputArg.integrationDomain, inputArg.manufacturer, inputArg.model, inputArg.name, metadata.manufacturer, metadata.model, metadata.name, snapshot?.system.name, snapshot?.system.model);
|
||||
const matched = inputArg.integrationDomain === synologyDsmDomain
|
||||
|| metadata.synologyDsm === true
|
||||
|| Boolean(snapshot)
|
||||
|| Boolean(host)
|
||||
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Synology DSM setup hints.' };
|
||||
}
|
||||
|
||||
const ssl = booleanValue(inputArg.ssl) ?? parsedUrl?.ssl ?? booleanValue(metadata.ssl) ?? snapshot?.system.ssl ?? synologyDsmDefaultSsl;
|
||||
const port = inputArg.port || parsedUrl?.port || snapshot?.system.port || SynologyDsmMapper.defaultPort(ssl);
|
||||
const serial = inputArg.serialNumber || snapshot?.system.serial;
|
||||
const mac = SynologyDsmMapper.normalizeMac(inputArg.macAddress || snapshot?.system.macs?.[0]);
|
||||
const id = inputArg.id || serial || mac || snapshot?.system.id || (host ? `${host}:${port}` : undefined);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: snapshot || serial || mac ? 'certain' : host ? 'high' : 'medium',
|
||||
reason: snapshot ? 'Manual entry includes a Synology DSM snapshot.' : 'Manual entry can start Synology DSM setup.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'manual',
|
||||
integrationDomain: synologyDsmDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || snapshot?.system.name || snapshot?.system.hostname || host || 'Synology DSM',
|
||||
manufacturer: inputArg.manufacturer || 'Synology',
|
||||
model: inputArg.model || snapshot?.system.model || 'DSM NAS',
|
||||
serialNumber: serial,
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...metadata,
|
||||
synologyDsm: true,
|
||||
ssl,
|
||||
verifySsl: inputArg.verifySsl ?? metadata.verifySsl,
|
||||
username: inputArg.username ?? metadata.username,
|
||||
password: inputArg.password ?? metadata.password,
|
||||
snapshot,
|
||||
hasSnapshot: Boolean(snapshot),
|
||||
upstreamSupportsSsdp: true,
|
||||
upstreamSupportsZeroconf: true,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { ssl, hasSnapshot: Boolean(snapshot), upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SynologyDsmHttpMatcher implements IDiscoveryMatcher<ISynologyDsmHttpDiscoveryRecord> {
|
||||
public id = 'synology-dsm-http-match';
|
||||
public source = 'http' as const;
|
||||
public description = 'Recognize local HTTP candidates that look like DSM web/API endpoints.';
|
||||
|
||||
public async matches(inputArg: ISynologyDsmHttpDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = inputArg.metadata || {};
|
||||
const url = inputArg.url || inputArg.location;
|
||||
const parsedUrl = parseUrl(url);
|
||||
const headers = normalizeKeys(inputArg.headers || {});
|
||||
const host = inputArg.host || parsedUrl?.host;
|
||||
const port = inputArg.port || parsedUrl?.port;
|
||||
const text = textValue(url, inputArg.name, inputArg.manufacturer, inputArg.model, headers.server, headers['x-powered-by'], metadata.name, metadata.manufacturer, metadata.model);
|
||||
const matched = inputArg.ssl !== undefined
|
||||
|| metadata.synologyDsm === true
|
||||
|| port === 5000
|
||||
|| port === 5001
|
||||
|| Boolean(parsedUrl?.path?.includes('/webapi/'))
|
||||
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
|
||||
|
||||
if (!matched) {
|
||||
return { matched: false, confidence: 'low', reason: 'HTTP candidate does not look like a Synology DSM endpoint.' };
|
||||
}
|
||||
|
||||
const ssl = inputArg.ssl ?? parsedUrl?.ssl ?? booleanValue(metadata.ssl) ?? (port === 5000 ? false : synologyDsmDefaultSsl);
|
||||
const resolvedPort = port || SynologyDsmMapper.defaultPort(ssl);
|
||||
const id = host ? `${host}:${resolvedPort}` : undefined;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: host && (parsedUrl?.path?.includes('/webapi/') || port === 5000 || port === 5001) ? 'high' : host ? 'medium' : 'low',
|
||||
reason: 'HTTP candidate has Synology DSM endpoint hints.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'http',
|
||||
integrationDomain: synologyDsmDomain,
|
||||
id,
|
||||
host,
|
||||
port: resolvedPort,
|
||||
name: inputArg.name || 'Synology DSM',
|
||||
manufacturer: inputArg.manufacturer || 'Synology',
|
||||
model: inputArg.model || 'DSM NAS',
|
||||
metadata: {
|
||||
...metadata,
|
||||
synologyDsm: true,
|
||||
ssl,
|
||||
url,
|
||||
headers,
|
||||
upstreamSupportsSsdp: true,
|
||||
upstreamSupportsZeroconf: true,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SynologyDsmSsdpMatcher implements IDiscoveryMatcher<ISynologyDsmSsdpDiscoveryRecord> {
|
||||
public id = 'synology-dsm-ssdp-match';
|
||||
public source = 'ssdp' as const;
|
||||
public description = 'Recognize Home Assistant supported Synology SSDP advertisements.';
|
||||
|
||||
public async matches(inputArg: ISynologyDsmSsdpDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = inputArg.metadata || {};
|
||||
const upnp = inputArg.upnp || {};
|
||||
const location = stringValue(inputArg.ssdpLocation || inputArg.ssdp_location || inputArg.location || metadata.ssdpLocation || metadata.location);
|
||||
const host = inputArg.host || hostFromUrl(location);
|
||||
const st = stringValue(inputArg.st || metadata.st || metadata.ssdpSt);
|
||||
const friendlyName = firstString(upnp.friendlyName, upnp.FriendlyName, upnp.friendly_name, upnp['upnp:ATTR_UPNP_FRIENDLY_NAME'], metadata.friendlyName, inputArg.name);
|
||||
const modelName = firstString(upnp.modelName, upnp.ModelName, upnp.model_name, metadata.modelName, inputArg.model);
|
||||
const manufacturerName = firstString(upnp.manufacturer, upnp.Manufacturer, metadata.manufacturer, inputArg.manufacturer);
|
||||
const serial = firstString(upnp.serialNumber, upnp.SerialNumber, upnp.serial, upnp['upnp:ATTR_UPNP_SERIAL'], metadata.serialNumber, inputArg.serialNumber);
|
||||
const text = textValue(inputArg.manufacturer, inputArg.model, inputArg.name, friendlyName, modelName, manufacturerName, st);
|
||||
const matched = inputArg.manufacturer === 'Synology'
|
||||
|| manufacturerName?.toLowerCase() === 'synology'
|
||||
|| metadata.synologyDsm === true
|
||||
|| st === synologySsdpDeviceType
|
||||
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
|
||||
|
||||
if (!matched || !host) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Synology SSDP advertisement lacks a usable host.' : 'SSDP advertisement is not Synology DSM.',
|
||||
};
|
||||
}
|
||||
|
||||
const ssl = booleanValue(metadata.ssl) ?? synologyDsmDefaultSsl;
|
||||
const port = inputArg.port || portFromUrl(location) || SynologyDsmMapper.defaultPort(ssl);
|
||||
const mac = SynologyDsmMapper.normalizeMac(serial);
|
||||
const id = inputArg.serialNumber || mac || inputArg.usn || `${host}:${port}`;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: manufacturerName?.toLowerCase() === 'synology' || serial ? 'certain' : 'high',
|
||||
reason: 'SSDP advertisement matches Synology DSM support from Home Assistant.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'ssdp',
|
||||
integrationDomain: synologyDsmDomain,
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
name: inputArg.name || friendlyName?.split('(', 1)[0]?.trim() || modelName || 'Synology DSM',
|
||||
manufacturer: inputArg.manufacturer || manufacturerName || 'Synology',
|
||||
model: inputArg.model || modelName || 'DSM NAS',
|
||||
serialNumber: inputArg.serialNumber || serial,
|
||||
macAddress: mac,
|
||||
metadata: {
|
||||
...metadata,
|
||||
synologyDsm: true,
|
||||
ssl,
|
||||
ssdpSt: st,
|
||||
ssdpLocation: location,
|
||||
upnp,
|
||||
upstreamSupportsSsdp: true,
|
||||
upstreamSupportsZeroconf: true,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SynologyDsmMdnsMatcher implements IDiscoveryMatcher<ISynologyDsmMdnsDiscoveryRecord> {
|
||||
public id = 'synology-dsm-mdns-match';
|
||||
public source = 'mdns' as const;
|
||||
public description = 'Recognize Home Assistant supported Synology zeroconf/mDNS HTTP advertisements.';
|
||||
|
||||
public async matches(inputArg: ISynologyDsmMdnsDiscoveryRecord, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = inputArg.metadata || {};
|
||||
const properties = { ...(inputArg.properties || {}), ...(inputArg.txt || {}) };
|
||||
const type = stringValue(inputArg.type || inputArg.serviceType || metadata.serviceType);
|
||||
const vendor = stringValue(properties.vendor || metadata.vendor);
|
||||
const macs = stringValue(properties.mac_address || properties.macAddress || metadata.macAddress)?.split('|').map((valueArg) => SynologyDsmMapper.normalizeMac(valueArg)).filter((valueArg): valueArg is string => Boolean(valueArg)) || [];
|
||||
const text = textValue(inputArg.name, inputArg.fullname, inputArg.manufacturer, inputArg.model, vendor, metadata.name, metadata.manufacturer, metadata.model);
|
||||
const matched = metadata.synologyDsm === true
|
||||
|| vendor?.toLowerCase().startsWith('synology')
|
||||
|| type === synologyMdnsType && synologyTextHints.some((hintArg) => text.includes(hintArg));
|
||||
|
||||
if (!matched || !inputArg.host) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Synology mDNS advertisement lacks a usable host.' : 'mDNS advertisement is not Synology DSM.',
|
||||
};
|
||||
}
|
||||
|
||||
const ssl = booleanValue(metadata.ssl) ?? synologyDsmDefaultSsl;
|
||||
const port = inputArg.port || SynologyDsmMapper.defaultPort(ssl);
|
||||
const name = inputArg.name?.replace(/\._http\._tcp\.local\.?$/i, '') || inputArg.fullname?.replace(/\._http\._tcp\.local\.?$/i, '') || 'Synology DSM';
|
||||
const id = inputArg.serialNumber || macs[0] || `${inputArg.host}:${port}`;
|
||||
return {
|
||||
matched: true,
|
||||
confidence: macs.length ? 'certain' : 'high',
|
||||
reason: 'mDNS/zeroconf advertisement contains Synology vendor metadata.',
|
||||
normalizedDeviceId: id,
|
||||
candidate: {
|
||||
source: 'mdns',
|
||||
integrationDomain: synologyDsmDomain,
|
||||
id,
|
||||
host: inputArg.host,
|
||||
port,
|
||||
name,
|
||||
manufacturer: inputArg.manufacturer || 'Synology',
|
||||
model: inputArg.model || 'DSM NAS',
|
||||
serialNumber: inputArg.serialNumber,
|
||||
macAddress: inputArg.macAddress || macs[0],
|
||||
metadata: {
|
||||
...metadata,
|
||||
synologyDsm: true,
|
||||
ssl,
|
||||
vendor,
|
||||
macs,
|
||||
properties,
|
||||
upstreamSupportsSsdp: true,
|
||||
upstreamSupportsZeroconf: true,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class SynologyDsmCandidateValidator implements IDiscoveryValidator {
|
||||
public id = 'synology-dsm-candidate-validator';
|
||||
public description = 'Validate Synology DSM candidates have a local host or snapshot plus Synology identity metadata.';
|
||||
|
||||
public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise<IDiscoveryMatch> {
|
||||
void contextArg;
|
||||
const metadata = candidateArg.metadata || {};
|
||||
const snapshot = metadata.snapshot as ISynologyDsmSnapshot | undefined;
|
||||
const text = textValue(candidateArg.integrationDomain, candidateArg.manufacturer, candidateArg.model, candidateArg.name, metadata.manufacturer, metadata.model, metadata.name, metadata.vendor, metadata.ssdpSt);
|
||||
const matched = candidateArg.integrationDomain === synologyDsmDomain
|
||||
|| metadata.synologyDsm === true
|
||||
|| Boolean(snapshot)
|
||||
|| candidateArg.port === 5000
|
||||
|| candidateArg.port === 5001
|
||||
|| synologyTextHints.some((hintArg) => text.includes(hintArg));
|
||||
const hasSource = Boolean(candidateArg.host || snapshot);
|
||||
|
||||
if (!matched || !hasSource) {
|
||||
return {
|
||||
matched: false,
|
||||
confidence: matched ? 'medium' : 'low',
|
||||
reason: matched ? 'Synology DSM candidate lacks host or snapshot information.' : 'Candidate is not Synology DSM.',
|
||||
};
|
||||
}
|
||||
|
||||
const ssl = booleanValue(metadata.ssl) ?? snapshot?.system.ssl ?? synologyDsmDefaultSsl;
|
||||
const port = candidateArg.port || snapshot?.system.port || SynologyDsmMapper.defaultPort(ssl);
|
||||
const mac = SynologyDsmMapper.normalizeMac(candidateArg.macAddress || snapshot?.system.macs?.[0]);
|
||||
const normalizedDeviceId = candidateArg.id || snapshot?.system.serial || candidateArg.serialNumber || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.system.id);
|
||||
return {
|
||||
matched: true,
|
||||
confidence: snapshot || candidateArg.serialNumber || mac ? 'certain' : candidateArg.host ? 'high' : 'medium',
|
||||
reason: 'Candidate has Synology DSM metadata and a usable local source.',
|
||||
normalizedDeviceId,
|
||||
candidate: {
|
||||
...candidateArg,
|
||||
id: candidateArg.id || normalizedDeviceId,
|
||||
integrationDomain: synologyDsmDomain,
|
||||
port,
|
||||
macAddress: mac || candidateArg.macAddress,
|
||||
metadata: {
|
||||
...metadata,
|
||||
synologyDsm: true,
|
||||
ssl,
|
||||
upstreamSupportsSsdp: true,
|
||||
upstreamSupportsZeroconf: true,
|
||||
liveHttpImplemented: false,
|
||||
},
|
||||
},
|
||||
metadata: { ssl, upstreamSupportsSsdp: true, upstreamSupportsZeroconf: true, liveHttpImplemented: false },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const createSynologyDsmDiscoveryDescriptor = (): DiscoveryDescriptor => {
|
||||
return new DiscoveryDescriptor({ integrationDomain: synologyDsmDomain, displayName: 'Synology DSM' })
|
||||
.addMatcher(new SynologyDsmManualMatcher())
|
||||
.addMatcher(new SynologyDsmHttpMatcher())
|
||||
.addMatcher(new SynologyDsmSsdpMatcher())
|
||||
.addMatcher(new SynologyDsmMdnsMatcher())
|
||||
.addValidator(new SynologyDsmCandidateValidator());
|
||||
};
|
||||
|
||||
const parseUrl = (valueArg: string | undefined): { host: string; port?: number; ssl: boolean; path: string } | undefined => {
|
||||
if (!valueArg) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const url = new URL(valueArg);
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: url.port ? Number(url.port) : undefined,
|
||||
ssl: url.protocol === 'https:',
|
||||
path: url.pathname,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const hostFromUrl = (valueArg: string | undefined): string | undefined => parseUrl(valueArg)?.host;
|
||||
const portFromUrl = (valueArg: string | undefined): number | undefined => parseUrl(valueArg)?.port;
|
||||
|
||||
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 firstString = (...valuesArg: unknown[]): string | undefined => valuesArg.find((valueArg): valueArg is string => typeof valueArg === 'string' && Boolean(valueArg.trim()))?.trim();
|
||||
|
||||
const stringValue = (valueArg: unknown): string | undefined => typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined;
|
||||
|
||||
const 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;
|
||||
};
|
||||
|
||||
const textValue = (...valuesArg: unknown[]): string => valuesArg.filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,319 @@
|
||||
export interface IHomeAssistantSynologyDsmConfig {
|
||||
// TODO: replace with the TypeScript-native config for synology_dsm.
|
||||
export const synologyDsmDomain = 'synology_dsm';
|
||||
export const synologyDsmDefaultPort = 5000;
|
||||
export const synologyDsmDefaultSslPort = 5001;
|
||||
export const synologyDsmDefaultSsl = true;
|
||||
export const synologyDsmDefaultVerifySsl = false;
|
||||
export const synologyDsmDefaultTimeoutMs = 60000;
|
||||
export const synologyDsmDefaultSnapshotQuality = 1;
|
||||
|
||||
export type TSynologyDsmSnapshotSource = 'manual' | 'snapshot' | 'provider' | 'runtime';
|
||||
export type TSynologyDsmCommandAction =
|
||||
| 'reboot'
|
||||
| 'shutdown'
|
||||
| 'set_home_mode'
|
||||
| 'enable_camera_motion_detection'
|
||||
| 'disable_camera_motion_detection';
|
||||
|
||||
export type TSynologyDsmCommandType = 'system.action' | 'switch.set' | 'camera.action';
|
||||
|
||||
export type TSynologyDsmActionTarget = 'system' | 'switch' | 'camera';
|
||||
|
||||
export interface ISynologyDsmConfig {
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
timeoutMs?: number;
|
||||
name?: string;
|
||||
uniqueId?: string;
|
||||
serial?: string;
|
||||
macs?: string[];
|
||||
macAddress?: string;
|
||||
snapshotQuality?: number;
|
||||
snapshot?: ISynologyDsmSnapshot;
|
||||
snapshotProvider?: () => Promise<ISynologyDsmSnapshot | undefined>;
|
||||
nativeClient?: ISynologyDsmNativeClient;
|
||||
commandExecutor?: (commandArg: ISynologyDsmCommand) => Promise<ISynologyDsmCommandResult | unknown>;
|
||||
online?: boolean;
|
||||
connected?: boolean;
|
||||
system?: Partial<ISynologyDsmSystemInfo> & Record<string, unknown>;
|
||||
information?: Record<string, unknown>;
|
||||
utilization?: ISynologyDsmUtilizationInfo & Record<string, unknown>;
|
||||
storage?: Partial<ISynologyDsmStorageInfo> & Record<string, unknown>;
|
||||
volumes?: Array<ISynologyDsmVolumeInfo | Record<string, unknown> | string>;
|
||||
disks?: Array<ISynologyDsmDiskInfo | Record<string, unknown> | string>;
|
||||
network?: ISynologyDsmNetworkInfo & Record<string, unknown>;
|
||||
cameras?: Array<ISynologyDsmCameraInfo | Record<string, unknown>>;
|
||||
surveillance?: Record<string, unknown>;
|
||||
update?: ISynologyDsmUpdateInfo & Record<string, unknown>;
|
||||
switches?: Array<ISynologyDsmSwitchInfo | Record<string, unknown>> | Record<string, unknown>;
|
||||
security?: ISynologyDsmSecurityInfo & Record<string, unknown>;
|
||||
actions?: ISynologyDsmActionDescriptor[];
|
||||
events?: ISynologyDsmEvent[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IHomeAssistantSynologyDsmConfig extends ISynologyDsmConfig {}
|
||||
|
||||
export interface ISynologyDsmNativeClient {
|
||||
getSnapshot(): Promise<ISynologyDsmSnapshot>;
|
||||
executeCommand?(commandArg: ISynologyDsmCommand): Promise<ISynologyDsmCommandResult | unknown>;
|
||||
destroy?(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmSnapshot {
|
||||
connected: boolean;
|
||||
source?: TSynologyDsmSnapshotSource;
|
||||
updatedAt?: string;
|
||||
system: ISynologyDsmSystemInfo;
|
||||
utilization: ISynologyDsmUtilizationInfo;
|
||||
storage: ISynologyDsmStorageInfo;
|
||||
network: ISynologyDsmNetworkInfo;
|
||||
cameras: ISynologyDsmCameraInfo[];
|
||||
update?: ISynologyDsmUpdateInfo;
|
||||
switches: ISynologyDsmSwitchInfo[];
|
||||
security?: ISynologyDsmSecurityInfo;
|
||||
actions: ISynologyDsmActionDescriptor[];
|
||||
events?: ISynologyDsmEvent[];
|
||||
error?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmSystemInfo {
|
||||
id?: string;
|
||||
serial?: string;
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
version?: string;
|
||||
versionString?: string;
|
||||
temperature?: number;
|
||||
uptimeSeconds?: number;
|
||||
macs?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmUtilizationInfo {
|
||||
cpuOtherLoad?: number;
|
||||
cpuUserLoad?: number;
|
||||
cpuSystemLoad?: number;
|
||||
cpuTotalLoad?: number;
|
||||
cpu1MinLoad?: number;
|
||||
cpu5MinLoad?: number;
|
||||
cpu15MinLoad?: number;
|
||||
memoryRealUsage?: number;
|
||||
memorySize?: number;
|
||||
memoryCached?: number;
|
||||
memoryAvailableSwap?: number;
|
||||
memoryAvailableReal?: number;
|
||||
memoryTotalSwap?: number;
|
||||
memoryTotalReal?: number;
|
||||
networkUp?: number;
|
||||
networkDown?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmStorageInfo {
|
||||
volumes: ISynologyDsmVolumeInfo[];
|
||||
disks: ISynologyDsmDiskInfo[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmVolumeInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
sizeTotal?: number;
|
||||
sizeUsed?: number;
|
||||
percentageUsed?: number;
|
||||
diskTempAvg?: number;
|
||||
diskTempMax?: number;
|
||||
deviceType?: string;
|
||||
raidType?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmDiskInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
vendor?: string;
|
||||
model?: string;
|
||||
firmware?: string;
|
||||
diskType?: string;
|
||||
status?: string;
|
||||
smartStatus?: string;
|
||||
temperature?: number;
|
||||
exceedBadSectorThreshold?: boolean;
|
||||
belowRemainLifeThreshold?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmNetworkInfo {
|
||||
hostname?: string;
|
||||
macs?: string[];
|
||||
uploadRate?: number;
|
||||
downloadRate?: number;
|
||||
interfaces?: ISynologyDsmNetworkInterfaceInfo[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmNetworkInterfaceInfo {
|
||||
id?: string;
|
||||
name?: string;
|
||||
mac?: string;
|
||||
ipAddress?: string;
|
||||
ipv6Address?: string;
|
||||
connected?: boolean;
|
||||
speedMbps?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmCameraInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
model?: string;
|
||||
enabled?: boolean;
|
||||
recording?: boolean;
|
||||
motionDetectionEnabled?: boolean;
|
||||
rtsp?: string;
|
||||
snapshotUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmUpdateInfo {
|
||||
installedVersion?: string;
|
||||
latestVersion?: string;
|
||||
updateAvailable?: boolean;
|
||||
releaseUrl?: string;
|
||||
availableVersionDetails?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmSwitchInfo {
|
||||
key: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
type?: 'home_mode' | string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmSecurityInfo {
|
||||
status?: string;
|
||||
statusByCheck?: Record<string, string>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmActionDescriptor {
|
||||
target: TSynologyDsmActionTarget;
|
||||
action: TSynologyDsmCommandAction;
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
cameraId?: string;
|
||||
switchKey?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmCommand {
|
||||
type: TSynologyDsmCommandType;
|
||||
service: string;
|
||||
action: TSynologyDsmCommandAction;
|
||||
target: {
|
||||
entityId?: string;
|
||||
deviceId?: string;
|
||||
};
|
||||
serial?: string;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
cameraId?: string;
|
||||
switchKey?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
snapshotSource?: TSynologyDsmSnapshotSource;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmCommandResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmEvent {
|
||||
type: string;
|
||||
snapshot?: ISynologyDsmSnapshot;
|
||||
command?: ISynologyDsmCommand;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
deviceId?: string;
|
||||
entityId?: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmManualDiscoveryRecord {
|
||||
integrationDomain?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
url?: string;
|
||||
ssl?: boolean;
|
||||
verifySsl?: boolean;
|
||||
username?: string;
|
||||
password?: string;
|
||||
id?: string;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
snapshot?: ISynologyDsmSnapshot;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmHttpDiscoveryRecord {
|
||||
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>;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmSsdpDiscoveryRecord {
|
||||
st?: string;
|
||||
usn?: string;
|
||||
ssdpLocation?: string;
|
||||
ssdp_location?: string;
|
||||
location?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
upnp?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ISynologyDsmMdnsDiscoveryRecord {
|
||||
type?: string;
|
||||
serviceType?: string;
|
||||
fullname?: string;
|
||||
name?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
properties?: Record<string, unknown>;
|
||||
txt?: Record<string, unknown>;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serialNumber?: string;
|
||||
macAddress?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user