From ae901a3308f8bb2d9581afd6ef0d29fd56668bd9 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 5 May 2026 19:37:20 +0000 Subject: [PATCH] Add native local NAS and network service integrations --- test/mikrotik/test.mikrotik.client.node.ts | 46 + test/mikrotik/test.mikrotik.discovery.node.ts | 56 + test/mikrotik/test.mikrotik.mapper.node.ts | 102 ++ test/motioneye/test.motioneye.client.node.ts | 129 ++ .../test.motioneye.discovery.node.ts | 36 + test/motioneye/test.motioneye.mapper.node.ts | 93 ++ test/opnsense/test.opnsense.client.node.ts | 55 + test/opnsense/test.opnsense.discovery.node.ts | 66 + test/opnsense/test.opnsense.mapper.node.ts | 127 ++ test/pi_hole/test.pi_hole.discovery.node.ts | 43 + test/pi_hole/test.pi_hole.mapper.node.ts | 120 ++ test/pi_hole/test.pi_hole.runtime.node.ts | 112 ++ .../test.squeezebox.configflow.node.ts | 31 + .../test.squeezebox.discovery.node.ts | 40 + .../squeezebox/test.squeezebox.mapper.node.ts | 87 + .../test.squeezebox.runtime.node.ts | 91 ++ .../test.synology_dsm.client.node.ts | 59 + .../test.synology_dsm.discovery.node.ts | 87 + .../test.synology_dsm.mapper.node.ts | 114 ++ ts/index.ts | 12 + ts/integrations/generated/index.ts | 20 +- .../mikrotik/.generated-by-smarthome-exchange | 1 - ts/integrations/mikrotik/index.ts | 4 + .../mikrotik/mikrotik.classes.client.ts | 110 ++ .../mikrotik/mikrotik.classes.configflow.ts | 117 ++ .../mikrotik/mikrotik.classes.integration.ts | 115 +- .../mikrotik/mikrotik.discovery.ts | 162 ++ ts/integrations/mikrotik/mikrotik.mapper.ts | 1099 +++++++++++++ ts/integrations/mikrotik/mikrotik.types.ts | 346 +++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/motioneye/index.ts | 4 + .../motioneye/motioneye.classes.client.ts | 676 ++++++++ .../motioneye/motioneye.classes.configflow.ts | 100 ++ .../motioneye.classes.integration.ts | 103 +- .../motioneye/motioneye.discovery.ts | 192 +++ ts/integrations/motioneye/motioneye.mapper.ts | 362 +++++ ts/integrations/motioneye/motioneye.types.ts | 244 ++- .../opnsense/.generated-by-smarthome-exchange | 1 - ts/integrations/opnsense/index.ts | 4 + .../opnsense/opnsense.classes.client.ts | 107 ++ .../opnsense/opnsense.classes.configflow.ts | 152 ++ .../opnsense/opnsense.classes.integration.ts | 121 +- .../opnsense/opnsense.discovery.ts | 151 ++ ts/integrations/opnsense/opnsense.mapper.ts | 1426 +++++++++++++++++ ts/integrations/opnsense/opnsense.types.ts | 495 +++++- .../pi_hole/.generated-by-smarthome-exchange | 1 - ts/integrations/pi_hole/index.ts | 4 + .../pi_hole/pi_hole.classes.client.ts | 388 +++++ .../pi_hole/pi_hole.classes.configflow.ts | 150 ++ .../pi_hole/pi_hole.classes.integration.ts | 121 +- ts/integrations/pi_hole/pi_hole.discovery.ts | 193 +++ ts/integrations/pi_hole/pi_hole.mapper.ts | 512 ++++++ ts/integrations/pi_hole/pi_hole.types.ts | 225 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/squeezebox/index.ts | 4 + .../squeezebox/squeezebox.classes.client.ts | 839 ++++++++++ .../squeezebox.classes.configflow.ts | 67 + .../squeezebox.classes.integration.ts | 270 +++- .../squeezebox/squeezebox.discovery.ts | 203 +++ .../squeezebox/squeezebox.mapper.ts | 380 +++++ .../squeezebox/squeezebox.types.ts | 264 ++- .../.generated-by-smarthome-exchange | 1 - ts/integrations/synology_dsm/index.ts | 4 + .../synology_dsm.classes.client.ts | 139 ++ .../synology_dsm.classes.configflow.ts | 141 ++ .../synology_dsm.classes.integration.ts | 132 +- .../synology_dsm/synology_dsm.discovery.ts | 371 +++++ .../synology_dsm/synology_dsm.mapper.ts | 1080 +++++++++++++ .../synology_dsm/synology_dsm.types.ts | 319 +++- 69 files changed, 13245 insertions(+), 183 deletions(-) create mode 100644 test/mikrotik/test.mikrotik.client.node.ts create mode 100644 test/mikrotik/test.mikrotik.discovery.node.ts create mode 100644 test/mikrotik/test.mikrotik.mapper.node.ts create mode 100644 test/motioneye/test.motioneye.client.node.ts create mode 100644 test/motioneye/test.motioneye.discovery.node.ts create mode 100644 test/motioneye/test.motioneye.mapper.node.ts create mode 100644 test/opnsense/test.opnsense.client.node.ts create mode 100644 test/opnsense/test.opnsense.discovery.node.ts create mode 100644 test/opnsense/test.opnsense.mapper.node.ts create mode 100644 test/pi_hole/test.pi_hole.discovery.node.ts create mode 100644 test/pi_hole/test.pi_hole.mapper.node.ts create mode 100644 test/pi_hole/test.pi_hole.runtime.node.ts create mode 100644 test/squeezebox/test.squeezebox.configflow.node.ts create mode 100644 test/squeezebox/test.squeezebox.discovery.node.ts create mode 100644 test/squeezebox/test.squeezebox.mapper.node.ts create mode 100644 test/squeezebox/test.squeezebox.runtime.node.ts create mode 100644 test/synology_dsm/test.synology_dsm.client.node.ts create mode 100644 test/synology_dsm/test.synology_dsm.discovery.node.ts create mode 100644 test/synology_dsm/test.synology_dsm.mapper.node.ts delete mode 100644 ts/integrations/mikrotik/.generated-by-smarthome-exchange create mode 100644 ts/integrations/mikrotik/mikrotik.classes.client.ts create mode 100644 ts/integrations/mikrotik/mikrotik.classes.configflow.ts create mode 100644 ts/integrations/mikrotik/mikrotik.discovery.ts create mode 100644 ts/integrations/mikrotik/mikrotik.mapper.ts delete mode 100644 ts/integrations/motioneye/.generated-by-smarthome-exchange create mode 100644 ts/integrations/motioneye/motioneye.classes.client.ts create mode 100644 ts/integrations/motioneye/motioneye.classes.configflow.ts create mode 100644 ts/integrations/motioneye/motioneye.discovery.ts create mode 100644 ts/integrations/motioneye/motioneye.mapper.ts delete mode 100644 ts/integrations/opnsense/.generated-by-smarthome-exchange create mode 100644 ts/integrations/opnsense/opnsense.classes.client.ts create mode 100644 ts/integrations/opnsense/opnsense.classes.configflow.ts create mode 100644 ts/integrations/opnsense/opnsense.discovery.ts create mode 100644 ts/integrations/opnsense/opnsense.mapper.ts delete mode 100644 ts/integrations/pi_hole/.generated-by-smarthome-exchange create mode 100644 ts/integrations/pi_hole/pi_hole.classes.client.ts create mode 100644 ts/integrations/pi_hole/pi_hole.classes.configflow.ts create mode 100644 ts/integrations/pi_hole/pi_hole.discovery.ts create mode 100644 ts/integrations/pi_hole/pi_hole.mapper.ts delete mode 100644 ts/integrations/squeezebox/.generated-by-smarthome-exchange create mode 100644 ts/integrations/squeezebox/squeezebox.classes.client.ts create mode 100644 ts/integrations/squeezebox/squeezebox.classes.configflow.ts create mode 100644 ts/integrations/squeezebox/squeezebox.discovery.ts create mode 100644 ts/integrations/squeezebox/squeezebox.mapper.ts delete mode 100644 ts/integrations/synology_dsm/.generated-by-smarthome-exchange create mode 100644 ts/integrations/synology_dsm/synology_dsm.classes.client.ts create mode 100644 ts/integrations/synology_dsm/synology_dsm.classes.configflow.ts create mode 100644 ts/integrations/synology_dsm/synology_dsm.discovery.ts create mode 100644 ts/integrations/synology_dsm/synology_dsm.mapper.ts diff --git a/test/mikrotik/test.mikrotik.client.node.ts b/test/mikrotik/test.mikrotik.client.node.ts new file mode 100644 index 0000000..3e6aff2 --- /dev/null +++ b/test/mikrotik/test.mikrotik.client.node.ts @@ -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(); diff --git a/test/mikrotik/test.mikrotik.discovery.node.ts b/test/mikrotik/test.mikrotik.discovery.node.ts new file mode 100644 index 0000000..6b0556e --- /dev/null +++ b/test/mikrotik/test.mikrotik.discovery.node.ts @@ -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(); diff --git a/test/mikrotik/test.mikrotik.mapper.node.ts b/test/mikrotik/test.mikrotik.mapper.node.ts new file mode 100644 index 0000000..5e29639 --- /dev/null +++ b/test/mikrotik/test.mikrotik.mapper.node.ts @@ -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(); diff --git a/test/motioneye/test.motioneye.client.node.ts b/test/motioneye/test.motioneye.client.node.ts new file mode 100644 index 0000000..9166805 --- /dev/null +++ b/test/motioneye/test.motioneye.client.node.ts @@ -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; + 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(); diff --git a/test/motioneye/test.motioneye.discovery.node.ts b/test/motioneye/test.motioneye.discovery.node.ts new file mode 100644 index 0000000..3d3ba11 --- /dev/null +++ b/test/motioneye/test.motioneye.discovery.node.ts @@ -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(); diff --git a/test/motioneye/test.motioneye.mapper.node.ts b/test/motioneye/test.motioneye.mapper.node.ts new file mode 100644 index 0000000..43fc650 --- /dev/null +++ b/test/motioneye/test.motioneye.mapper.node.ts @@ -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(); diff --git a/test/opnsense/test.opnsense.client.node.ts b/test/opnsense/test.opnsense.client.node.ts new file mode 100644 index 0000000..2eb054f --- /dev/null +++ b/test/opnsense/test.opnsense.client.node.ts @@ -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(); diff --git a/test/opnsense/test.opnsense.discovery.node.ts b/test/opnsense/test.opnsense.discovery.node.ts new file mode 100644 index 0000000..a4d8df3 --- /dev/null +++ b/test/opnsense/test.opnsense.discovery.node.ts @@ -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(); diff --git a/test/opnsense/test.opnsense.mapper.node.ts b/test/opnsense/test.opnsense.mapper.node.ts new file mode 100644 index 0000000..0797ebd --- /dev/null +++ b/test/opnsense/test.opnsense.mapper.node.ts @@ -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(); diff --git a/test/pi_hole/test.pi_hole.discovery.node.ts b/test/pi_hole/test.pi_hole.discovery.node.ts new file mode 100644 index 0000000..c628f57 --- /dev/null +++ b/test/pi_hole/test.pi_hole.discovery.node.ts @@ -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(); diff --git a/test/pi_hole/test.pi_hole.mapper.node.ts b/test/pi_hole/test.pi_hole.mapper.node.ts new file mode 100644 index 0000000..12f61be --- /dev/null +++ b/test/pi_hole/test.pi_hole.mapper.node.ts @@ -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(); diff --git a/test/pi_hole/test.pi_hole.runtime.node.ts b/test/pi_hole/test.pi_hole.runtime.node.ts new file mode 100644 index 0000000..30641c7 --- /dev/null +++ b/test/pi_hole/test.pi_hole.runtime.node.ts @@ -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((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((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(); diff --git a/test/squeezebox/test.squeezebox.configflow.node.ts b/test/squeezebox/test.squeezebox.configflow.node.ts new file mode 100644 index 0000000..058b293 --- /dev/null +++ b/test/squeezebox/test.squeezebox.configflow.node.ts @@ -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(); diff --git a/test/squeezebox/test.squeezebox.discovery.node.ts b/test/squeezebox/test.squeezebox.discovery.node.ts new file mode 100644 index 0000000..190039b --- /dev/null +++ b/test/squeezebox/test.squeezebox.discovery.node.ts @@ -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(); diff --git a/test/squeezebox/test.squeezebox.mapper.node.ts b/test/squeezebox/test.squeezebox.mapper.node.ts new file mode 100644 index 0000000..95e3de1 --- /dev/null +++ b/test/squeezebox/test.squeezebox.mapper.node.ts @@ -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(); diff --git a/test/squeezebox/test.squeezebox.runtime.node.ts b/test/squeezebox/test.squeezebox.runtime.node.ts new file mode 100644 index 0000000..12c8be1 --- /dev/null +++ b/test/squeezebox/test.squeezebox.runtime.node.ts @@ -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 | 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(); diff --git a/test/synology_dsm/test.synology_dsm.client.node.ts b/test/synology_dsm/test.synology_dsm.client.node.ts new file mode 100644 index 0000000..567f69c --- /dev/null +++ b/test/synology_dsm/test.synology_dsm.client.node.ts @@ -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(); diff --git a/test/synology_dsm/test.synology_dsm.discovery.node.ts b/test/synology_dsm/test.synology_dsm.discovery.node.ts new file mode 100644 index 0000000..1a8058d --- /dev/null +++ b/test/synology_dsm/test.synology_dsm.discovery.node.ts @@ -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(); diff --git a/test/synology_dsm/test.synology_dsm.mapper.node.ts b/test/synology_dsm/test.synology_dsm.mapper.node.ts new file mode 100644 index 0000000..9ccae72 --- /dev/null +++ b/test/synology_dsm/test.synology_dsm.mapper.node.ts @@ -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(); diff --git a/ts/index.ts b/ts/index.ts index 7bfb0f8..da4ba50 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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(), diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 35406b7..216fabe 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -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", diff --git a/ts/integrations/mikrotik/.generated-by-smarthome-exchange b/ts/integrations/mikrotik/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/mikrotik/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/mikrotik/index.ts b/ts/integrations/mikrotik/index.ts index eb58ea6..fb2b881 100644 --- a/ts/integrations/mikrotik/index.ts +++ b/ts/integrations/mikrotik/index.ts @@ -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'; diff --git a/ts/integrations/mikrotik/mikrotik.classes.client.ts b/ts/integrations/mikrotik/mikrotik.classes.client.ts new file mode 100644 index 0000000..25cff4f --- /dev/null +++ b/ts/integrations/mikrotik/mikrotik.classes.client.ts @@ -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(); + + constructor(private readonly config: IMikrotikConfig) {} + + public async getSnapshot(): Promise { + 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 { + 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 { + 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 { + 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(snapshotArg: T): T { + return JSON.parse(JSON.stringify(snapshotArg)) as T; + } +} diff --git a/ts/integrations/mikrotik/mikrotik.classes.configflow.ts b/ts/integrations/mikrotik/mikrotik.classes.configflow.ts new file mode 100644 index 0000000..9049716 --- /dev/null +++ b/ts/integrations/mikrotik/mikrotik.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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): Promise> { + 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; + } +} diff --git a/ts/integrations/mikrotik/mikrotik.classes.integration.ts b/ts/integrations/mikrotik/mikrotik.classes.integration.ts index d21a6e9..ebe7fbb 100644 --- a/ts/integrations/mikrotik/mikrotik.classes.integration.ts +++ b/ts/integrations/mikrotik/mikrotik.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new MikrotikRuntime(new MikrotikClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantMikrotikIntegration extends MikrotikIntegration {} + +class MikrotikRuntime implements IIntegrationRuntime { + public domain = mikrotikDomain; + + constructor(private readonly client: MikrotikClient) {} + + public async devices(): Promise { + return MikrotikMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return MikrotikMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(MikrotikMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/mikrotik/mikrotik.discovery.ts b/ts/integrations/mikrotik/mikrotik.discovery.ts new file mode 100644 index 0000000..e235976 --- /dev/null +++ b/ts/integrations/mikrotik/mikrotik.discovery.ts @@ -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 { + 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 { + 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 { + 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()); +}; diff --git a/ts/integrations/mikrotik/mikrotik.mapper.ts b/ts/integrations/mikrotik/mikrotik.mapper.ts new file mode 100644 index 0000000..d9ee39f --- /dev/null +++ b/ts/integrations/mikrotik/mikrotik.mapper.ts @@ -0,0 +1,1099 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IMikrotikActionDescriptor, + IMikrotikClientDevice, + IMikrotikCommand, + IMikrotikConfig, + IMikrotikEvent, + IMikrotikInterfaceStats, + IMikrotikManualEntry, + IMikrotikResourceInfo, + IMikrotikRouterInfo, + IMikrotikSensorMap, + IMikrotikSnapshot, + IMikrotikTrafficStats, + TMikrotikAction, + TMikrotikApiPath, + TMikrotikClientAction, + TMikrotikInterfaceAction, + TMikrotikProtocol, + TMikrotikRouterAction, +} from './mikrotik.types.js'; +import { mikrotikDefaultApiPort, mikrotikDomain, mikrotikManufacturer } from './mikrotik.types.js'; + +type TMikrotikSensorDescriptor = { + key: keyof IMikrotikSensorMap & string; + name: string; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + transform?: (valueArg: unknown) => unknown; +}; + +const routerSensorDescriptors: TMikrotikSensorDescriptor[] = [ + { key: 'connected_clients', name: 'Clients Connected', unit: 'clients', stateClass: 'measurement' }, + { key: 'cpu_load', name: 'CPU Load', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'cpu_count', name: 'CPU Count', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'cpu_frequency', name: 'CPU Frequency', unit: 'MHz', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'memory_free', name: 'Memory Free', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', transform: (valueArg) => MikrotikMapper.bytesToMegabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'memory_total', name: 'Memory Total', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', transform: (valueArg) => MikrotikMapper.bytesToMegabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'memory_used', name: 'Memory Used', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', transform: (valueArg) => MikrotikMapper.bytesToMegabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'memory_usage_percent', name: 'Memory Usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'hdd_free', name: 'HDD Free', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', transform: (valueArg) => MikrotikMapper.bytesToMegabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'hdd_total', name: 'HDD Total', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', transform: (valueArg) => MikrotikMapper.bytesToMegabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'bad_blocks', name: 'Bad Blocks', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'uptime', name: 'Uptime', unit: 's', deviceClass: 'duration', stateClass: 'total', entityCategory: 'diagnostic', transform: (valueArg) => MikrotikMapper.durationSeconds(valueArg) ?? valueArg }, + { key: 'routeros_version', name: 'RouterOS Version', entityCategory: 'diagnostic' }, + { key: 'firmware', name: 'Firmware', entityCategory: 'diagnostic' }, + { key: 'rx_bytes', name: 'Download', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing', transform: (valueArg) => MikrotikMapper.bytesToGigabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'tx_bytes', name: 'Upload', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing', transform: (valueArg) => MikrotikMapper.bytesToGigabytes(MikrotikMapper.numberValue(valueArg)) }, + { key: 'rx_rate', name: 'Download Speed', unit: 'Mbit/s', deviceClass: 'data_rate', stateClass: 'measurement' }, + { key: 'tx_rate', name: 'Upload Speed', unit: 'Mbit/s', deviceClass: 'data_rate', stateClass: 'measurement' }, + { key: 'temperature', name: 'Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'voltage', name: 'Voltage', unit: 'V', deviceClass: 'voltage', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'current', name: 'Current', unit: 'A', deviceClass: 'current', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'fan_speed', name: 'Fan Speed', unit: 'rpm', stateClass: 'measurement', entityCategory: 'diagnostic' }, +]; + +export class MikrotikMapper { + public static toSnapshot(configArg: IMikrotikConfig, connectedArg?: boolean, eventsArg: IMikrotikEvent[] = []): IMikrotikSnapshot { + const source = configArg.snapshot; + const manualSnapshots = (configArg.manualEntries || []) + .map((entryArg) => entryArg.snapshot) + .filter((snapshotArg): snapshotArg is IMikrotikSnapshot => Boolean(snapshotArg)); + const manualData = this.mergeManualEntries(configArg.manualEntries || []); + const resources = this.resourceInfo([ + source?.resources, + ...manualSnapshots.map((snapshotArg) => snapshotArg.resources), + configArg.resources, + manualData.resources, + ]); + const router = this.routerInfo(configArg, source, manualSnapshots, manualData.router, resources); + const interfaces = this.uniqueInterfaces([ + ...(source?.interfaces || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.interfaces || []), + ...(configArg.interfaces || []), + ...manualData.interfaces, + ]); + const devices = this.uniqueClients([ + ...(source?.devices || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.devices || []), + ...(configArg.devices || []), + ...(configArg.clients || []), + ...manualData.devices, + ]); + const traffic = this.trafficInfo([ + source?.traffic, + ...manualSnapshots.map((snapshotArg) => snapshotArg.traffic), + configArg.traffic, + manualData.traffic, + ], interfaces); + const sensors = this.sensorMap([ + source?.sensors, + ...manualSnapshots.map((snapshotArg) => snapshotArg.sensors), + configArg.sensors, + manualData.sensors, + ], resources, devices, interfaces, traffic, router); + const actions = this.uniqueActions([ + ...(source?.actions || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.actions || []), + ...(configArg.actions || []), + ...manualData.actions, + ...this.actionsFromRouter(router), + ...this.actionsFromClients(devices), + ...this.actionsFromInterfaces(interfaces), + ]); + const hasManualData = Boolean( + source + || manualSnapshots.length + || configArg.router + || configArg.resources + || configArg.traffic + || configArg.devices?.length + || configArg.clients?.length + || configArg.interfaces?.length + || configArg.sensors + || configArg.actions?.length + || manualData.hasData + ); + + return { + connected: connectedArg ?? configArg.connected ?? source?.connected ?? hasManualData, + source: source?.source || (hasManualData ? 'manual' : 'runtime'), + updatedAt: source?.updatedAt || new Date().toISOString(), + router, + resources, + devices, + interfaces, + sensors, + traffic, + actions, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + error: source?.error, + metadata: { + ...source?.metadata, + ...configArg.metadata, + upstreamPlatforms: ['device_tracker'], + liveRouterOsApiImplemented: false, + commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand), + }, + }; + } + + public static toDevices(snapshotArg: IMikrotikSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.routerDevice(snapshotArg, updatedAt)]; + for (const client of snapshotArg.devices) { + devices.push(this.clientDevice(client, snapshotArg, updatedAt)); + } + return devices; + } + + public static toEntities(snapshotArg: IMikrotikSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const routerDeviceId = this.routerDeviceId(snapshotArg); + const routerName = this.routerName(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + + entities.push(this.entity('binary_sensor', `${routerName} Connected`, routerDeviceId, `${uniqueBase}_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: snapshotArg.router.host, + port: snapshotArg.router.port, + protocol: snapshotArg.router.protocol, + verifySsl: snapshotArg.router.verifySsl, + }, true)); + + for (const descriptor of routerSensorDescriptors) { + const rawValue = snapshotArg.sensors[descriptor.key]; + if (rawValue === undefined) { + continue; + } + const value = this.sensorValue(rawValue, descriptor); + if (value === undefined) { + continue; + } + entities.push(this.entity('sensor', `${routerName} ${descriptor.name}`, routerDeviceId, `${uniqueBase}_${this.slug(descriptor.key)}`, value, usedIds, { + nativeKey: descriptor.key, + unit: descriptor.unit, + deviceClass: descriptor.deviceClass, + stateClass: descriptor.stateClass, + entityCategory: descriptor.entityCategory, + }, snapshotArg.connected)); + } + + for (const iface of snapshotArg.interfaces) { + this.pushInterfaceEntities(entities, snapshotArg, iface, usedIds); + } + + for (const client of snapshotArg.devices) { + this.pushClientEntities(entities, snapshotArg, client, usedIds); + } + + for (const action of this.snapshotActions(snapshotArg)) { + const button = this.actionButton(snapshotArg, action, usedIds); + if (button) { + entities.push(button); + } + } + + return entities; + } + + public static commandForService(snapshotArg: IMikrotikSnapshot, requestArg: IServiceCallRequest): IMikrotikCommand | undefined { + const actions = this.snapshotActions(snapshotArg); + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + + if (requestArg.domain === mikrotikDomain && requestArg.service === 'reboot') { + const action = actions.find((actionArg) => actionArg.target === 'router' && actionArg.action === 'reboot'); + return action ? this.command(snapshotArg, requestArg, action) : undefined; + } + + if (requestArg.domain === mikrotikDomain && (requestArg.service === 'arp_ping' || requestArg.service === 'disconnect_client')) { + const serviceAction = requestArg.service === 'arp_ping' ? 'arp_ping' : 'disconnect'; + const mac = this.normalizeMac(this.stringValue(requestArg.data?.mac) || this.stringValue(requestArg.data?.macAddress) || this.stringValue(targetEntity?.attributes?.mac)); + const action = actions.find((actionArg) => actionArg.target === 'client' && actionArg.action === serviceAction && (!actionArg.mac || this.normalizeMac(actionArg.mac) === mac)); + return action && mac ? this.command(snapshotArg, requestArg, { ...action, mac }, targetEntity) : undefined; + } + + if (requestArg.domain === mikrotikDomain && (requestArg.service === 'enable_interface' || requestArg.service === 'disable_interface')) { + const enabled = requestArg.service === 'enable_interface'; + const interfaceName = this.stringValue(requestArg.data?.interface) || this.stringValue(requestArg.data?.name) || this.stringValue(targetEntity?.attributes?.interfaceName); + const interfaceId = this.stringValue(requestArg.data?.id) || this.stringValue(targetEntity?.attributes?.interfaceId); + const action = actions.find((actionArg) => actionArg.target === 'interface' && actionArg.action === 'set_enabled' && this.interfaceActionMatches(actionArg, interfaceId, interfaceName)); + return action ? this.command(snapshotArg, requestArg, action, targetEntity, { enabled }) : undefined; + } + + if (requestArg.domain === 'button' && requestArg.service === 'press' && targetEntity?.attributes?.nativeAction) { + const nativeAction = this.stringValue(targetEntity.attributes.nativeAction) as TMikrotikAction | undefined; + const action = actions.find((actionArg) => nativeAction && actionArg.action === nativeAction && this.actionMatchesEntity(actionArg, targetEntity)); + return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined; + } + + if ((requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) || requestArg.service === 'set_state') { + if (!targetEntity) { + return undefined; + } + const enabled = requestArg.service === 'turn_on' || requestArg.data?.enabled === true || requestArg.data?.state === true; + const action = this.switchActionForEntity(snapshotArg, targetEntity); + return action ? this.command(snapshotArg, requestArg, action, targetEntity, { enabled }) : undefined; + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IMikrotikEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: mikrotikDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } + + public static defaultPort(_verifySslArg = false): number { + return mikrotikDefaultApiPort; + } + + public static protocol(verifySslArg = false): TMikrotikProtocol { + return verifySslArg ? 'routeros-api-ssl' : 'routeros-api'; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'mikrotik'; + } + + public static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg.replace(/[^0-9.-]/g, '')); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + public static bytesToMegabytes(valueArg: number | undefined): number | undefined { + return valueArg === undefined ? undefined : valueArg / 1048576; + } + + public static bytesToGigabytes(valueArg: number | undefined): number | undefined { + return valueArg === undefined ? undefined : valueArg / 1000000000; + } + + public static durationSeconds(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (valueArg instanceof Date) { + return undefined; + } + if (typeof valueArg !== 'string' || !valueArg.trim()) { + return undefined; + } + const value = valueArg.trim(); + if (/^\d+$/.test(value)) { + return Number(value); + } + const timeMatch = value.match(/^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i); + if (timeMatch && timeMatch.slice(1).some(Boolean)) { + const [, weeks, days, hours, minutes, seconds] = timeMatch; + return (Number(weeks || 0) * 604800) + (Number(days || 0) * 86400) + (Number(hours || 0) * 3600) + (Number(minutes || 0) * 60) + Number(seconds || 0); + } + const colonMatch = value.match(/^(?:(\d+)d)?(?:(\d+):)?(\d{1,2}):(\d{2})(?::(\d{2}))?$/i); + if (colonMatch) { + const [, days, hours, minutes, secondsOrUndefined, trailingSeconds] = colonMatch; + const hasTrailingSeconds = trailingSeconds !== undefined; + return (Number(days || 0) * 86400) + + (Number(hours || 0) * 3600) + + (Number(minutes || 0) * (hasTrailingSeconds ? 60 : 1)) + + Number(hasTrailingSeconds ? trailingSeconds : secondsOrUndefined || 0); + } + return undefined; + } + + private static routerInfo(configArg: IMikrotikConfig, sourceArg: IMikrotikSnapshot | undefined, manualSnapshotsArg: IMikrotikSnapshot[], manualRouterArg: IMikrotikRouterInfo | undefined, resourcesArg: IMikrotikResourceInfo): IMikrotikRouterInfo { + const manualRouter = manualRouterArg || manualSnapshotsArg.find((snapshotArg) => snapshotArg.router)?.router; + const router = { + ...sourceArg?.router, + ...manualRouter, + ...configArg.router, + }; + const verifySsl = configArg.verifySsl ?? router.verifySsl ?? sourceArg?.router.verifySsl ?? false; + const protocol = configArg.protocol || router.protocol || this.protocol(verifySsl); + const host = configArg.host || router.host || sourceArg?.router.host; + const port = configArg.port || router.port || (host ? this.defaultPort(verifySsl) : undefined); + const mac = this.normalizeMac(configArg.uniqueId || router.macAddress || sourceArg?.router.macAddress); + const serialNumber = router.serialNumber || configArg.uniqueId || sourceArg?.router.serialNumber; + const name = configArg.name || router.name || router.identity || host || 'Mikrotik'; + return { + ...router, + id: router.id || serialNumber || mac || (host ? `${host}:${port || this.defaultPort(verifySsl)}` : undefined) || name, + host, + port, + name, + identity: router.identity || name, + model: this.stringValue(configArg.model) || router.model || router.boardName || this.stringValue(resourcesArg.boardName) || this.stringValue(resourcesArg['board-name']) || 'RouterOS device', + boardName: router.boardName || this.stringValue(resourcesArg.boardName) || this.stringValue(resourcesArg['board-name']), + routerOsVersion: router.routerOsVersion || this.stringValue(resourcesArg.routerOsVersion) || this.stringValue(resourcesArg.version), + architectureName: router.architectureName || this.stringValue(resourcesArg.architectureName) || this.stringValue(resourcesArg['architecture-name']), + macAddress: mac || router.macAddress, + serialNumber, + firmware: router.firmware || router.currentFirmware || this.stringValue(resourcesArg.version), + manufacturer: router.manufacturer || mikrotikManufacturer, + verifySsl, + protocol, + configurationUrl: router.configurationUrl || (host ? `http://${host}` : undefined), + }; + } + + private static mergeManualEntries(entriesArg: IMikrotikManualEntry[]): { + router?: IMikrotikRouterInfo; + resources?: IMikrotikResourceInfo; + traffic?: IMikrotikTrafficStats; + devices: IMikrotikClientDevice[]; + interfaces: IMikrotikInterfaceStats[]; + sensors?: IMikrotikSensorMap; + actions: IMikrotikActionDescriptor[]; + hasData: boolean; + } { + const devices: IMikrotikClientDevice[] = []; + const interfaces: IMikrotikInterfaceStats[] = []; + const actions: IMikrotikActionDescriptor[] = []; + const sensors: IMikrotikSensorMap = {}; + let router: IMikrotikRouterInfo | undefined; + let resources: IMikrotikResourceInfo | undefined; + let traffic: IMikrotikTrafficStats | undefined; + let hasData = false; + for (const entry of entriesArg) { + if (entry.router) { + router = { ...router, ...entry.router }; + hasData = true; + } else if (!router && (entry.host || entry.name || entry.model || entry.macAddress || entry.serialNumber)) { + router = { + id: entry.id || entry.serialNumber || entry.macAddress || entry.host, + host: entry.host, + port: entry.port, + name: entry.name, + model: entry.model, + serialNumber: entry.serialNumber, + macAddress: entry.macAddress, + manufacturer: entry.manufacturer, + verifySsl: entry.verifySsl, + protocol: this.protocol(entry.verifySsl || false), + }; + hasData = true; + } + if (entry.resources) { + resources = { ...resources, ...entry.resources }; + hasData = true; + } + if (entry.traffic) { + traffic = { ...traffic, ...entry.traffic }; + hasData = true; + } + devices.push(...(entry.devices || []), ...(entry.clients || [])); + interfaces.push(...(entry.interfaces || [])); + Object.assign(sensors, entry.sensors || {}); + actions.push(...(entry.actions || [])); + hasData = hasData || Boolean(entry.devices?.length || entry.clients?.length || entry.interfaces?.length || entry.sensors || entry.actions?.length); + } + return { router, resources, traffic, devices, interfaces, sensors: Object.keys(sensors).length ? sensors : undefined, actions, hasData }; + } + + private static resourceInfo(sourcesArg: Array): IMikrotikResourceInfo { + const resources: IMikrotikResourceInfo = {}; + for (const source of sourcesArg) { + Object.assign(resources, source || {}); + } + resources.freeMemory ??= this.numberValue(resources['free-memory']); + resources.totalMemory ??= this.numberValue(resources['total-memory']); + resources.cpuCount ??= this.numberValue(resources['cpu-count']); + resources.cpuFrequency ??= this.numberValue(resources['cpu-frequency']); + resources.cpuLoad ??= this.numberValue(resources['cpu-load']); + resources.freeHddSpace ??= this.numberValue(resources['free-hdd-space']); + resources.totalHddSpace ??= this.numberValue(resources['total-hdd-space']); + resources.badBlocks ??= this.numberValue(resources['bad-blocks']); + resources.architectureName ||= this.stringValue(resources['architecture-name']); + resources.boardName ||= this.stringValue(resources['board-name']); + resources.routerOsVersion ||= this.stringValue(resources.version); + if (resources.usedMemory === undefined && resources.totalMemory !== undefined && resources.freeMemory !== undefined) { + resources.usedMemory = resources.totalMemory - resources.freeMemory; + } + if (resources.memoryUsagePercent === undefined && resources.totalMemory && resources.usedMemory !== undefined) { + resources.memoryUsagePercent = resources.usedMemory / resources.totalMemory * 100; + } + return this.cleanAttributes(resources as Record) as IMikrotikResourceInfo; + } + + private static trafficInfo(sourcesArg: Array, interfacesArg: IMikrotikInterfaceStats[]): IMikrotikTrafficStats | undefined { + const traffic: IMikrotikTrafficStats = {}; + for (const source of sourcesArg) { + Object.assign(traffic, source || {}); + } + traffic.rxBytes ??= this.numberValue(traffic.downloadBytes) ?? this.sumInterfaces(interfacesArg, 'rxBytes', 'downloadBytes', 'rx-byte'); + traffic.txBytes ??= this.numberValue(traffic.uploadBytes) ?? this.sumInterfaces(interfacesArg, 'txBytes', 'uploadBytes', 'tx-byte'); + traffic.rxRateMbps ??= this.numberValue(traffic.downloadRateMbps) ?? this.rateMbpsFromTraffic(traffic, 'rx') ?? this.sumInterfaceRatesMbps(interfacesArg, 'rx'); + traffic.txRateMbps ??= this.numberValue(traffic.uploadRateMbps) ?? this.rateMbpsFromTraffic(traffic, 'tx') ?? this.sumInterfaceRatesMbps(interfacesArg, 'tx'); + const cleaned = this.cleanAttributes(traffic as Record) as IMikrotikTrafficStats; + return Object.keys(cleaned).length ? cleaned : undefined; + } + + private static sensorMap(sourcesArg: Array, resourcesArg: IMikrotikResourceInfo, devicesArg: IMikrotikClientDevice[], interfacesArg: IMikrotikInterfaceStats[], trafficArg: IMikrotikTrafficStats | undefined, routerArg: IMikrotikRouterInfo): IMikrotikSensorMap { + const sensors: IMikrotikSensorMap = {}; + for (const source of sourcesArg) { + Object.assign(sensors, source || {}); + } + sensors.connected_clients ??= devicesArg.filter((deviceArg) => this.clientConnected(deviceArg)).length; + sensors.cpu_load ??= this.numberValue(resourcesArg.cpuLoad); + sensors.cpu_count ??= this.numberValue(resourcesArg.cpuCount); + sensors.cpu_frequency ??= this.numberValue(resourcesArg.cpuFrequency); + sensors.memory_free ??= this.numberValue(resourcesArg.freeMemory); + sensors.memory_total ??= this.numberValue(resourcesArg.totalMemory); + sensors.memory_used ??= this.numberValue(resourcesArg.usedMemory); + sensors.memory_usage_percent ??= this.numberValue(resourcesArg.memoryUsagePercent); + sensors.hdd_free ??= this.numberValue(resourcesArg.freeHddSpace); + sensors.hdd_total ??= this.numberValue(resourcesArg.totalHddSpace); + sensors.bad_blocks ??= this.numberValue(resourcesArg.badBlocks); + sensors.uptime ??= resourcesArg.uptime; + sensors.routeros_version ??= this.stringValue(resourcesArg.routerOsVersion) || this.stringValue(resourcesArg.version) || routerArg.routerOsVersion; + sensors.firmware ??= routerArg.currentFirmware || routerArg.firmware; + sensors.temperature ??= this.numberValue(resourcesArg.temperature); + sensors.voltage ??= this.numberValue(resourcesArg.voltage); + sensors.current ??= this.numberValue(resourcesArg.current); + sensors.fan_speed ??= this.numberValue(resourcesArg.fanSpeed ?? resourcesArg['fan-speed']); + sensors.rx_bytes ??= this.numberValue(trafficArg?.rxBytes) ?? this.sumInterfaces(interfacesArg, 'rxBytes', 'downloadBytes', 'rx-byte'); + sensors.tx_bytes ??= this.numberValue(trafficArg?.txBytes) ?? this.sumInterfaces(interfacesArg, 'txBytes', 'uploadBytes', 'tx-byte'); + sensors.rx_rate ??= this.numberValue(trafficArg?.rxRateMbps) ?? this.sumInterfaceRatesMbps(interfacesArg, 'rx'); + sensors.tx_rate ??= this.numberValue(trafficArg?.txRateMbps) ?? this.sumInterfaceRatesMbps(interfacesArg, 'tx'); + return this.cleanAttributes(sensors) as IMikrotikSensorMap; + } + + private static routerDevice(snapshotArg: IMikrotikSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const sensors = snapshotArg.sensors; + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'connected_clients', capability: 'sensor', name: 'Connected Clients', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + { featureId: 'connected_clients', value: sensors.connected_clients ?? snapshotArg.devices.filter((deviceArg) => this.clientConnected(deviceArg)).length, updatedAt: updatedAtArg }, + ]; + this.addFeatureState(features, state, 'cpu_load', 'CPU Load', sensors.cpu_load, updatedAtArg, '%'); + this.addFeatureState(features, state, 'memory_usage', 'Memory Usage', sensors.memory_usage_percent, updatedAtArg, '%'); + this.addFeatureState(features, state, 'download_speed', 'Download Speed', sensors.rx_rate, updatedAtArg, 'Mbit/s'); + this.addFeatureState(features, state, 'upload_speed', 'Upload Speed', sensors.tx_rate, updatedAtArg, 'Mbit/s'); + + return { + id: this.routerDeviceId(snapshotArg), + integrationDomain: mikrotikDomain, + name: this.routerName(snapshotArg), + protocol: 'unknown', + manufacturer: snapshotArg.router.manufacturer || mikrotikManufacturer, + model: snapshotArg.router.model || snapshotArg.resources.boardName || 'RouterOS device', + online: snapshotArg.connected, + features, + state, + metadata: this.cleanAttributes({ + host: snapshotArg.router.host, + port: snapshotArg.router.port, + protocol: snapshotArg.router.protocol, + verifySsl: snapshotArg.router.verifySsl, + macAddress: snapshotArg.router.macAddress, + serialNumber: snapshotArg.router.serialNumber, + firmware: snapshotArg.router.firmware || snapshotArg.router.currentFirmware, + routerOsVersion: snapshotArg.router.routerOsVersion || snapshotArg.resources.version, + architectureName: snapshotArg.router.architectureName || snapshotArg.resources.architectureName, + configurationUrl: snapshotArg.router.configurationUrl, + supportsCapsman: snapshotArg.router.supportsCapsman, + supportsWireless: snapshotArg.router.supportsWireless, + supportsWifiwave2: snapshotArg.router.supportsWifiwave2, + supportsWifi: snapshotArg.router.supportsWifi, + source: snapshotArg.source, + liveRouterOsApiImplemented: false, + }), + }; + } + + private static clientDevice(clientArg: IMikrotikClientDevice, snapshotArg: IMikrotikSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + return { + id: this.clientDeviceId(clientArg), + integrationDomain: mikrotikDomain, + name: this.clientName(clientArg), + protocol: 'unknown', + manufacturer: clientArg.manufacturer || 'Unknown', + model: clientArg.model || 'Network client', + online: this.clientConnected(clientArg) && snapshotArg.connected, + features: [ + { id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false }, + { id: 'ip_address', capability: 'sensor', name: 'IP Address', readable: true, writable: false }, + ], + state: [ + { featureId: 'presence', value: this.clientConnected(clientArg), updatedAt: updatedAtArg }, + { featureId: 'ip_address', value: this.clientIp(clientArg) || null, updatedAt: updatedAtArg }, + ], + metadata: this.cleanAttributes({ + mac: this.clientMac(clientArg), + ipAddress: this.clientIp(clientArg), + hostname: this.clientName(clientArg), + connectedTo: clientArg.connectedTo || clientArg.interface, + ssid: clientArg.ssid, + source: clientArg.source, + comment: clientArg.comment, + lastSeen: this.dateString(clientArg.lastSeen || clientArg.lastActivity || clientArg['last-seen']), + }), + }; + } + + private static pushInterfaceEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IMikrotikSnapshot, ifaceArg: IMikrotikInterfaceStats, usedIdsArg: Map): void { + const deviceId = this.routerDeviceId(snapshotArg); + const ifaceId = this.interfaceId(ifaceArg); + const ifaceKey = this.slug(String(ifaceId)); + const ifaceName = this.interfaceName(ifaceArg); + const values: Array<[string, string, unknown, string | undefined, Record]> = [ + ['rx_bytes', 'Download', this.bytesToGigabytes(this.interfaceBytes(ifaceArg, 'rx')), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['tx_bytes', 'Upload', this.bytesToGigabytes(this.interfaceBytes(ifaceArg, 'tx')), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['rx_rate', 'Download Speed', this.interfaceRateMbps(ifaceArg, 'rx'), 'Mbit/s', { deviceClass: 'data_rate', stateClass: 'measurement' }], + ['tx_rate', 'Upload Speed', this.interfaceRateMbps(ifaceArg, 'tx'), 'Mbit/s', { deviceClass: 'data_rate', stateClass: 'measurement' }], + ['rx_packets', 'RX Packets', this.numberValue(ifaceArg.rxPackets ?? ifaceArg['rx-packet']), 'packets', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['tx_packets', 'TX Packets', this.numberValue(ifaceArg.txPackets ?? ifaceArg['tx-packet']), 'packets', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['rx_drops', 'RX Drops', this.numberValue(ifaceArg.rxDrops ?? ifaceArg['rx-drop']), 'packets', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['tx_drops', 'TX Drops', this.numberValue(ifaceArg.txDrops ?? ifaceArg['tx-drop']), 'packets', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['rx_errors', 'RX Errors', this.numberValue(ifaceArg.rxErrors ?? ifaceArg['rx-error']), 'errors', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['tx_errors', 'TX Errors', this.numberValue(ifaceArg.txErrors ?? ifaceArg['tx-error']), 'errors', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ]; + for (const [key, name, value, unit, attrs] of values) { + if (value === undefined) { + continue; + } + entitiesArg.push(this.entity('sensor', `${this.routerName(snapshotArg)} ${ifaceName} ${name}`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_${key}`, value, usedIdsArg, { + ...attrs, + unit, + interfaceId: ifaceId, + interfaceName: ifaceArg.name, + interfaceType: ifaceArg.type, + ssid: ifaceArg.ssid, + }, snapshotArg.connected && this.interfaceEnabled(ifaceArg) !== false)); + } + if (this.interfaceConnected(ifaceArg) !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} ${ifaceName} Link`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_link`, this.interfaceConnected(ifaceArg) ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + interfaceId: ifaceId, + interfaceName: ifaceArg.name, + interfaceType: ifaceArg.type, + }, snapshotArg.connected)); + } + if (this.interfaceHasSetEnabled(ifaceArg)) { + entitiesArg.push(this.entity('switch', `${this.routerName(snapshotArg)} ${ifaceName}`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_enabled`, this.interfaceEnabled(ifaceArg) === false ? 'off' : 'on', usedIdsArg, { + nativeType: 'interface', + nativeAction: 'set_enabled', + interfaceId: ifaceId, + interfaceName: ifaceArg.name, + interfaceType: ifaceArg.type, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + } + + private static pushClientEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IMikrotikSnapshot, clientArg: IMikrotikClientDevice, usedIdsArg: Map): void { + const mac = this.clientMac(clientArg); + const deviceId = this.clientDeviceId(clientArg); + const clientName = this.clientName(clientArg); + entitiesArg.push(this.entity('binary_sensor', `${clientName} Connected`, deviceId, `${this.slug(mac || clientName)}_connected`, this.clientConnected(clientArg) ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + nativePlatform: 'device_tracker', + mac, + ipAddress: this.clientIp(clientArg), + hostname: clientName, + connectedTo: clientArg.connectedTo || clientArg.interface, + interface: clientArg.interface, + ssid: clientArg.ssid, + comment: clientArg.comment, + signalStrength: this.numberValue(clientArg.signalStrength ?? clientArg['signal-strength']), + signalToNoise: this.numberValue(clientArg.signalToNoise ?? clientArg['signal-to-noise']), + rxRate: clientArg.rxRate ?? clientArg['rx-rate'], + txRate: clientArg.txRate ?? clientArg['tx-rate'], + uptime: clientArg.uptime, + lastSeen: this.dateString(clientArg.lastSeen || clientArg.lastActivity || clientArg['last-seen']), + }, snapshotArg.connected)); + + const clientSensors: Array<[string, string, unknown, string | undefined, Record]> = [ + ['signal_strength', 'Signal Strength', this.numberValue(clientArg.signalStrength ?? clientArg['signal-strength']), 'dBm', { deviceClass: 'signal_strength', stateClass: 'measurement', entityCategory: 'diagnostic' }], + ['signal_to_noise', 'Signal To Noise', this.numberValue(clientArg.signalToNoise ?? clientArg['signal-to-noise']), 'dB', { stateClass: 'measurement', entityCategory: 'diagnostic' }], + ['rx_rate', 'RX Rate', this.numberValue(clientArg.rxRate ?? clientArg['rx-rate']), undefined, { entityCategory: 'diagnostic' }], + ['tx_rate', 'TX Rate', this.numberValue(clientArg.txRate ?? clientArg['tx-rate']), undefined, { entityCategory: 'diagnostic' }], + ['uptime', 'Uptime', this.durationSeconds(clientArg.uptime), 's', { deviceClass: 'duration', stateClass: 'total', entityCategory: 'diagnostic' }], + ]; + for (const [key, name, value, unit, attrs] of clientSensors) { + if (value === undefined) { + continue; + } + entitiesArg.push(this.entity('sensor', `${clientName} ${name}`, deviceId, `${this.slug(mac || clientName)}_${key}`, value, usedIdsArg, { + ...attrs, + unit, + mac, + interface: clientArg.interface, + }, snapshotArg.connected && this.clientConnected(clientArg))); + } + } + + private static actionButton(snapshotArg: IMikrotikSnapshot, actionArg: IMikrotikActionDescriptor, usedIdsArg: Map): IIntegrationEntity | undefined { + if (actionArg.target === 'router' && actionArg.action === 'reboot') { + return this.entity('button', `${this.routerName(snapshotArg)} Reboot`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_reboot`, 'available', usedIdsArg, { + nativeType: 'router_action', + nativeAction: 'reboot', + actionTarget: 'router', + entityCategory: 'config', + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + if (actionArg.target !== 'client' || !actionArg.mac) { + return undefined; + } + const client = snapshotArg.devices.find((clientArg) => this.clientMac(clientArg) === this.normalizeMac(actionArg.mac)); + if (!client) { + return undefined; + } + return this.entity('button', `${this.clientName(client)} ${this.title(actionArg.action)}`, this.clientDeviceId(client), `${this.slug(actionArg.mac)}_${this.slug(actionArg.action)}`, 'available', usedIdsArg, { + nativeType: 'client_action', + nativeAction: actionArg.action, + actionTarget: 'client', + mac: this.clientMac(client), + ipAddress: this.clientIp(client), + interface: client.interface, + entityCategory: 'diagnostic', + writable: true, + }, snapshotArg.connected && this.clientConnected(client), actionArg.entityId); + } + + private static command(snapshotArg: IMikrotikSnapshot, requestArg: IServiceCallRequest, actionArg: IMikrotikActionDescriptor, entityArg?: IIntegrationEntity, payloadArg: Record = {}): IMikrotikCommand | undefined { + const path = this.commandPath(actionArg); + if (!path) { + return undefined; + } + const payload = this.cleanAttributes({ ...(requestArg.data || {}), ...payloadArg, actionMetadata: actionArg.metadata }); + const params = this.commandParams(snapshotArg, actionArg, entityArg, payload); + if (!params) { + return undefined; + } + return { + type: actionArg.target === 'router' ? 'router.action' : actionArg.target === 'client' ? 'client.action' : 'interface.set', + service: requestArg.service, + action: actionArg.action, + path, + params, + target: requestArg.target, + routerId: this.routerDeviceId(snapshotArg), + mac: actionArg.mac ? this.normalizeMac(actionArg.mac) : this.normalizeMac(this.stringValue(entityArg?.attributes?.mac)), + interfaceId: actionArg.id || this.stringValue(entityArg?.attributes?.interfaceId), + interfaceName: actionArg.interfaceName || this.stringValue(entityArg?.attributes?.interfaceName), + entityId: entityArg?.id || actionArg.entityId || requestArg.target.entityId, + deviceId: entityArg?.deviceId || actionArg.deviceId || requestArg.target.deviceId, + payload, + }; + } + + private static commandPath(actionArg: IMikrotikActionDescriptor): TMikrotikApiPath | undefined { + if (actionArg.command) { + return actionArg.command; + } + if (actionArg.target === 'router' && actionArg.action === 'reboot') { + return '/system/reboot'; + } + if (actionArg.target === 'interface' && actionArg.action === 'set_enabled') { + return '/interface/set'; + } + if (actionArg.target === 'client' && actionArg.action === 'arp_ping') { + return '/ping'; + } + return undefined; + } + + private static commandParams(snapshotArg: IMikrotikSnapshot, actionArg: IMikrotikActionDescriptor, entityArg: IIntegrationEntity | undefined, payloadArg: Record): Record | undefined { + const base = { ...(actionArg.params || {}) }; + if (actionArg.target === 'router' && actionArg.action === 'reboot') { + return base; + } + if (actionArg.target === 'interface' && actionArg.action === 'set_enabled') { + const id = actionArg.id || this.stringValue(entityArg?.attributes?.interfaceId) || this.stringValue(payloadArg.id); + const interfaceName = actionArg.interfaceName || this.stringValue(entityArg?.attributes?.interfaceName) || this.stringValue(payloadArg.interface) || this.stringValue(payloadArg.name); + const enabled = payloadArg.enabled === true || payloadArg.state === true; + const numbers = id || interfaceName; + if (!numbers) { + return undefined; + } + return this.cleanAttributes({ ...base, numbers, disabled: enabled ? 'no' : 'yes' }); + } + if (actionArg.target === 'client' && actionArg.action === 'arp_ping') { + const client = this.findClient(snapshotArg, actionArg.mac || this.stringValue(entityArg?.attributes?.mac)); + const address = this.stringValue(payloadArg.address) || this.stringValue(payloadArg.ipAddress) || (client ? this.clientIp(client) : undefined); + const iface = this.stringValue(payloadArg.interface) || this.stringValue(entityArg?.attributes?.interface) || client?.interface; + if (!address || !iface) { + return undefined; + } + return this.cleanAttributes({ ...base, address, interface: iface, 'arp-ping': 'yes', interval: payloadArg.interval || '100ms', count: payloadArg.count || 3 }); + } + if (actionArg.target === 'client' && actionArg.action === 'disconnect' && actionArg.command) { + return this.cleanAttributes({ ...base, mac: actionArg.mac || this.stringValue(entityArg?.attributes?.mac) }); + } + return undefined; + } + + private static findTargetEntity(snapshotArg: IMikrotikSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const targetEntityId = requestArg.target.entityId; + if (!targetEntityId) { + return undefined; + } + return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === targetEntityId || entityArg.uniqueId === targetEntityId); + } + + private static switchActionForEntity(snapshotArg: IMikrotikSnapshot, entityArg: IIntegrationEntity): IMikrotikActionDescriptor | undefined { + if (entityArg.attributes?.nativeType !== 'interface') { + return undefined; + } + const interfaceId = this.stringValue(entityArg.attributes.interfaceId); + const interfaceName = this.stringValue(entityArg.attributes.interfaceName); + return this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'interface' && actionArg.action === 'set_enabled' && this.interfaceActionMatches(actionArg, interfaceId, interfaceName)); + } + + private static actionMatchesEntity(actionArg: IMikrotikActionDescriptor, entityArg: IIntegrationEntity): boolean { + if (actionArg.target === 'router') { + return entityArg.attributes?.actionTarget === 'router'; + } + if (actionArg.target === 'client') { + return this.normalizeMac(actionArg.mac) === this.normalizeMac(this.stringValue(entityArg.attributes?.mac)); + } + if (actionArg.target === 'interface') { + return this.interfaceActionMatches(actionArg, this.stringValue(entityArg.attributes?.interfaceId), this.stringValue(entityArg.attributes?.interfaceName)); + } + return false; + } + + private static interfaceActionMatches(actionArg: IMikrotikActionDescriptor, interfaceIdArg?: string, interfaceNameArg?: string): boolean { + return String(actionArg.id || '') === String(interfaceIdArg || '') + || Boolean(actionArg.interfaceName && interfaceNameArg && actionArg.interfaceName === interfaceNameArg) + || Boolean(actionArg.id && interfaceNameArg && String(actionArg.id) === interfaceNameArg); + } + + private static actionsFromRouter(routerArg: IMikrotikRouterInfo): IMikrotikActionDescriptor[] { + return (routerArg.actions || []).map((actionArg) => ({ target: 'router', action: actionArg })); + } + + private static actionsFromClients(devicesArg: IMikrotikClientDevice[]): IMikrotikActionDescriptor[] { + const actions: IMikrotikActionDescriptor[] = []; + for (const device of devicesArg) { + const mac = this.clientMac(device); + if (!mac) { + continue; + } + for (const action of device.actions || []) { + actions.push({ target: 'client', action, mac }); + } + } + return actions; + } + + private static actionsFromInterfaces(interfacesArg: IMikrotikInterfaceStats[]): IMikrotikActionDescriptor[] { + const actions: IMikrotikActionDescriptor[] = []; + for (const iface of interfacesArg) { + if (!this.interfaceHasSetEnabled(iface)) { + continue; + } + actions.push({ target: 'interface', action: 'set_enabled', id: this.interfaceId(iface), interfaceName: iface.name }); + } + return actions; + } + + private static snapshotActions(snapshotArg: IMikrotikSnapshot): IMikrotikActionDescriptor[] { + return this.uniqueActions([ + ...(snapshotArg.actions || []), + ...this.actionsFromRouter(snapshotArg.router), + ...this.actionsFromClients(snapshotArg.devices), + ...this.actionsFromInterfaces(snapshotArg.interfaces), + ]); + } + + private static uniqueClients(devicesArg: IMikrotikClientDevice[]): IMikrotikClientDevice[] { + const seen = new Map(); + for (const device of devicesArg) { + const key = this.clientMac(device) || device.id || this.clientIp(device) || this.clientName(device); + if (!key) { + continue; + } + seen.set(key, { + ...seen.get(key), + ...device, + mac: this.clientMac(device) || device.mac, + ipAddress: this.clientIp(device) || device.ipAddress, + name: this.clientName(device), + }); + } + return [...seen.values()]; + } + + private static uniqueInterfaces(interfacesArg: IMikrotikInterfaceStats[]): IMikrotikInterfaceStats[] { + const seen = new Map(); + for (const iface of interfacesArg) { + if (!iface.name) { + continue; + } + const key = String(this.interfaceId(iface)); + const normalized = this.normalizeInterface(iface); + seen.set(key, { ...seen.get(key), ...normalized }); + } + return [...seen.values()]; + } + + private static uniqueActions(actionsArg: IMikrotikActionDescriptor[]): IMikrotikActionDescriptor[] { + const seen = new Map(); + for (const action of actionsArg) { + const mac = this.normalizeMac(action.mac); + const key = [action.target, action.action, mac || action.id || action.interfaceName || action.entityId || action.deviceId || 'router'].join(':'); + seen.set(key, { ...action, mac }); + } + return [...seen.values()]; + } + + private static normalizeInterface(ifaceArg: IMikrotikInterfaceStats): IMikrotikInterfaceStats { + const disabled = this.booleanValue(ifaceArg.disabled ?? ifaceArg['disabled']); + const running = this.booleanValue(ifaceArg.running ?? ifaceArg['running']); + return { + ...ifaceArg, + id: ifaceArg.id || ifaceArg['.id'], + label: ifaceArg.label || ifaceArg.comment || ifaceArg.name, + macAddress: this.normalizeMac(ifaceArg.macAddress || this.stringValue(ifaceArg['mac-address'])), + actualMtu: this.numberValue(ifaceArg.actualMtu ?? ifaceArg['actual-mtu']), + rxBytes: this.numberValue(ifaceArg.rxBytes ?? ifaceArg['rx-byte'] ?? ifaceArg.downloadBytes), + txBytes: this.numberValue(ifaceArg.txBytes ?? ifaceArg['tx-byte'] ?? ifaceArg.uploadBytes), + rxPackets: this.numberValue(ifaceArg.rxPackets ?? ifaceArg['rx-packet']), + txPackets: this.numberValue(ifaceArg.txPackets ?? ifaceArg['tx-packet']), + rxDrops: this.numberValue(ifaceArg.rxDrops ?? ifaceArg['rx-drop']), + txDrops: this.numberValue(ifaceArg.txDrops ?? ifaceArg['tx-drop']), + rxErrors: this.numberValue(ifaceArg.rxErrors ?? ifaceArg['rx-error']), + txErrors: this.numberValue(ifaceArg.txErrors ?? ifaceArg['tx-error']), + rxBitsPerSecond: this.numberValue(ifaceArg.rxBitsPerSecond ?? ifaceArg['rx-bits-per-second']), + txBitsPerSecond: this.numberValue(ifaceArg.txBitsPerSecond ?? ifaceArg['tx-bits-per-second']), + disabled, + enabled: ifaceArg.enabled ?? (disabled !== undefined ? !disabled : undefined), + running, + connected: ifaceArg.connected ?? running, + lastLinkUpTime: ifaceArg.lastLinkUpTime || ifaceArg['last-link-up-time'], + }; + } + + private static routerDeviceId(snapshotArg: IMikrotikSnapshot): string { + return `${mikrotikDomain}.router.${this.uniqueBase(snapshotArg)}`; + } + + private static clientDeviceId(clientArg: IMikrotikClientDevice): string { + return `${mikrotikDomain}.client.${this.slug(this.clientMac(clientArg) || clientArg.id || this.clientIp(clientArg) || this.clientName(clientArg))}`; + } + + private static routerName(snapshotArg: IMikrotikSnapshot): string { + return snapshotArg.router.name || snapshotArg.router.identity || snapshotArg.router.host || 'Mikrotik'; + } + + private static uniqueBase(snapshotArg: IMikrotikSnapshot): string { + return this.slug(snapshotArg.router.serialNumber || snapshotArg.router.macAddress || snapshotArg.router.id || snapshotArg.router.host || this.routerName(snapshotArg)); + } + + private static clientName(clientArg: IMikrotikClientDevice): string { + return clientArg.name || clientArg.hostname || clientArg.hostName || this.stringValue(clientArg['host-name']) || clientArg.comment || clientArg.macAddress || clientArg.mac || this.stringValue(clientArg['mac-address']) || this.clientIp(clientArg) || 'Unknown device'; + } + + private static clientMac(clientArg: IMikrotikClientDevice): string | undefined { + return this.normalizeMac(clientArg.macAddress || clientArg.mac || this.stringValue(clientArg['mac-address'])); + } + + private static clientIp(clientArg: IMikrotikClientDevice): string | undefined { + return this.stringValue(clientArg.ipAddress || clientArg.ip || clientArg.activeAddress || clientArg.address || clientArg['active-address']); + } + + private static clientConnected(clientArg: IMikrotikClientDevice): boolean { + if (clientArg.connected !== undefined) { + return clientArg.connected; + } + return Boolean(clientArg.lastSeen || clientArg.lastActivity || clientArg['last-seen'] || clientArg.activeAddress || clientArg['active-address'] || clientArg.interface); + } + + private static findClient(snapshotArg: IMikrotikSnapshot, macArg?: string): IMikrotikClientDevice | undefined { + const mac = this.normalizeMac(macArg); + if (!mac) { + return undefined; + } + return snapshotArg.devices.find((clientArg) => this.clientMac(clientArg) === mac); + } + + private static interfaceId(ifaceArg: IMikrotikInterfaceStats): string | number { + return ifaceArg.id || ifaceArg['.id'] || ifaceArg.name; + } + + private static interfaceName(ifaceArg: IMikrotikInterfaceStats): string { + return ifaceArg.label || ifaceArg.comment || ifaceArg.name; + } + + private static interfaceEnabled(ifaceArg: IMikrotikInterfaceStats): boolean | undefined { + if (ifaceArg.enabled !== undefined) { + return ifaceArg.enabled; + } + const disabled = this.booleanValue(ifaceArg.disabled ?? ifaceArg['disabled']); + return disabled === undefined ? undefined : !disabled; + } + + private static interfaceConnected(ifaceArg: IMikrotikInterfaceStats): boolean | undefined { + return ifaceArg.connected ?? this.booleanValue(ifaceArg.running ?? ifaceArg['running']); + } + + private static interfaceHasSetEnabled(ifaceArg: IMikrotikInterfaceStats): boolean { + return ifaceArg.actions?.includes('set_enabled') || this.interfaceEnabled(ifaceArg) !== undefined; + } + + private static interfaceBytes(ifaceArg: IMikrotikInterfaceStats, directionArg: 'rx' | 'tx'): number | undefined { + return directionArg === 'rx' + ? this.numberValue(ifaceArg.rxBytes ?? ifaceArg.downloadBytes ?? ifaceArg['rx-byte']) + : this.numberValue(ifaceArg.txBytes ?? ifaceArg.uploadBytes ?? ifaceArg['tx-byte']); + } + + private static interfaceRateMbps(ifaceArg: IMikrotikInterfaceStats, directionArg: 'rx' | 'tx'): number | undefined { + const mbps = directionArg === 'rx' + ? this.numberValue(ifaceArg.rxBitsPerSecond ?? ifaceArg['rx-bits-per-second']) + : this.numberValue(ifaceArg.txBitsPerSecond ?? ifaceArg['tx-bits-per-second']); + if (mbps !== undefined) { + return mbps / 1000000; + } + const bytesPerSecond = directionArg === 'rx' ? this.numberValue(ifaceArg.rxRate) : this.numberValue(ifaceArg.txRate); + return bytesPerSecond === undefined ? undefined : bytesPerSecond / 125000; + } + + private static sumInterfaces(interfacesArg: IMikrotikInterfaceStats[], primaryKeyArg: keyof IMikrotikInterfaceStats, fallbackKeyArg: keyof IMikrotikInterfaceStats, rawKeyArg: string): number | undefined { + let total = 0; + let found = false; + for (const iface of interfacesArg) { + const value = this.numberValue(iface[primaryKeyArg]) ?? this.numberValue(iface[fallbackKeyArg]) ?? this.numberValue(iface[rawKeyArg]); + if (value !== undefined) { + total += value; + found = true; + } + } + return found ? total : undefined; + } + + private static sumInterfaceRatesMbps(interfacesArg: IMikrotikInterfaceStats[], directionArg: 'rx' | 'tx'): number | undefined { + let total = 0; + let found = false; + for (const iface of interfacesArg) { + const value = this.interfaceRateMbps(iface, directionArg); + if (value !== undefined) { + total += value; + found = true; + } + } + return found ? total : undefined; + } + + private static rateMbpsFromTraffic(trafficArg: IMikrotikTrafficStats, directionArg: 'rx' | 'tx'): number | undefined { + const explicit = directionArg === 'rx' ? this.numberValue(trafficArg.rxRateMbps) : this.numberValue(trafficArg.txRateMbps); + if (explicit !== undefined) { + return explicit; + } + const bits = directionArg === 'rx' ? this.numberValue(trafficArg.rxBitsPerSecond) : this.numberValue(trafficArg.txBitsPerSecond); + if (bits !== undefined) { + return bits / 1000000; + } + const bytes = directionArg === 'rx' ? this.numberValue(trafficArg.rxRate) : this.numberValue(trafficArg.txRate); + return bytes === undefined ? undefined : bytes / 125000; + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record = {}, availableArg = true, explicitIdArg?: string): IIntegrationEntity { + const baseId = explicitIdArg || `${platformArg}.${this.slug(nameArg)}`; + const used = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, used + 1); + return { + id: used ? `${baseId}_${used + 1}` : baseId, + uniqueId: `${mikrotikDomain}_${this.slug(uniqueIdArg)}`, + integrationDomain: mikrotikDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static sensorValue(valueArg: unknown, descriptorArg: TMikrotikSensorDescriptor): unknown { + if (valueArg === undefined || valueArg === null) { + return valueArg; + } + return descriptorArg.transform ? descriptorArg.transform(valueArg) : valueArg; + } + + private static addFeatureState(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, updatedAtArg: string, unitArg?: string): void { + if (valueArg === undefined) { + return; + } + featuresArg.push({ id: idArg, capability: 'sensor', name: nameArg, readable: true, writable: false, unit: unitArg }); + stateArg.push({ featureId: idArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) { + return valueArg; + } + if (valueArg && typeof valueArg === 'object') { + return valueArg as Record; + } + return null; + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'string') { + if (['true', '1', 'yes', 'on'].includes(valueArg.toLowerCase())) { + return true; + } + if (['false', '0', 'no', 'off'].includes(valueArg.toLowerCase())) { + return false; + } + } + return undefined; + } + + private static dateString(valueArg: IMikrotikClientDevice['lastSeen']): string | undefined { + if (valueArg instanceof Date) { + return valueArg.toISOString(); + } + if (typeof valueArg === 'number') { + return new Date(valueArg < 100000000000 ? valueArg * 1000 : valueArg).toISOString(); + } + return typeof valueArg === 'string' ? valueArg : undefined; + } + + private static title(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } +} diff --git a/ts/integrations/mikrotik/mikrotik.types.ts b/ts/integrations/mikrotik/mikrotik.types.ts index 79153ed..1cfbfb2 100644 --- a/ts/integrations/mikrotik/mikrotik.types.ts +++ b/ts/integrations/mikrotik/mikrotik.types.ts @@ -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; [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; + [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; + [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; + '.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; + '.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; + mac?: string; + id?: string | number; + interfaceName?: string; + entityId?: string; + deviceId?: string; + label?: string; + metadata?: Record; +} + +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; +} + +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; + [key: string]: unknown; +} + +export interface IMikrotikManualDiscoveryRecord extends IMikrotikManualEntry { + integrationDomain?: string; +} + +export interface IMikrotikCommand { + type: TMikrotikCommandType; + service: string; + action: TMikrotikAction; + path: TMikrotikApiPath; + params: Record; + target: { + entityId?: string; + deviceId?: string; + }; + routerId?: string; + mac?: string; + interfaceId?: string | number; + interfaceName?: string; + entityId?: string; + deviceId?: string; + payload?: Record; +} + +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; + executeCommand?(commandArg: IMikrotikCommand): Promise | IMikrotikCommandResult | unknown; + destroy?(): Promise | void; +} + +export type TMikrotikSnapshotProvider = () => Promise | IMikrotikSnapshot | undefined; +export type TMikrotikCommandExecutor = ( + commandArg: IMikrotikCommand +) => Promise | IMikrotikCommandResult | unknown; diff --git a/ts/integrations/motioneye/.generated-by-smarthome-exchange b/ts/integrations/motioneye/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/motioneye/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/motioneye/index.ts b/ts/integrations/motioneye/index.ts index b1f319f..c0e0893 100644 --- a/ts/integrations/motioneye/index.ts +++ b/ts/integrations/motioneye/index.ts @@ -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'; diff --git a/ts/integrations/motioneye/motioneye.classes.client.ts b/ts/integrations/motioneye/motioneye.classes.client.ts new file mode 100644 index 0000000..b2210a4 --- /dev/null +++ b/ts/integrations/motioneye/motioneye.classes.client.ts @@ -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 { + 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 { + await this.requestJson('/login'); + } + + public async execute(commandArg: IMotionEyeClientCommand): Promise { + 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 { + 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 {} + + private async fetchLiveSnapshot(): Promise { + 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 => ({ + 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 { + 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 { + const camera = await this.getLatestRawCamera(cameraIdArg); + camera[keyArg] = valueArg; + return this.setRawCamera(camera, keyArg); + } + + private async setTextOverlay(commandArg: IMotionEyeClientCommand): Promise { + 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 { + 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 { + 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, true); + } + + private async getMediaList(cameraIdArg: string | undefined, kindArg: TMotionEyeMediaKind, prefixArg?: string): Promise { + 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 | undefined, adminArg: boolean): Promise { + 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, dataArg?: Record, 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; data?: string; method?: 'GET' | 'POST'; admin?: boolean } = {}): Promise { + 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 { + 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 | 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 = { + 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 | undefined => { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : 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')}`); +}; diff --git a/ts/integrations/motioneye/motioneye.classes.configflow.ts b/ts/integrations/motioneye/motioneye.classes.configflow.ts new file mode 100644 index 0000000..2e74718 --- /dev/null +++ b/ts/integrations/motioneye/motioneye.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +}; diff --git a/ts/integrations/motioneye/motioneye.classes.integration.ts b/ts/integrations/motioneye/motioneye.classes.integration.ts index 863f4cc..718526d 100644 --- a/ts/integrations/motioneye/motioneye.classes.integration.ts +++ b/ts/integrations/motioneye/motioneye.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new MotionEyeRuntime(new MotionEyeClient(configArg)); + } + + public async destroy(): Promise {} +} + +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 { + return MotionEyeMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return MotionEyeMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/motioneye/motioneye.discovery.ts b/ts/integrations/motioneye/motioneye.discovery.ts new file mode 100644 index 0000000..6a52d71 --- /dev/null +++ b/ts/integrations/motioneye/motioneye.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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): 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; + } +}; diff --git a/ts/integrations/motioneye/motioneye.mapper.ts b/ts/integrations/motioneye/motioneye.mapper.ts new file mode 100644 index 0000000..46003e1 --- /dev/null +++ b/ts/integrations/motioneye/motioneye.mapper.ts @@ -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(); + + 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, attributesArg: Record, 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): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static booleanFromData(dataArg: Record | 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'; + } +} diff --git a/ts/integrations/motioneye/motioneye.types.ts b/ts/integrations/motioneye/motioneye.types.ts index 8079768..cc2a63d 100644 --- a/ts/integrations/motioneye/motioneye.types.ts +++ b/ts/integrations/motioneye/motioneye.types.ts @@ -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; + serverConfig?: Record; + 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; +} + +export interface IMotionEyeSensor { + key: string; + name: string; + cameraId?: string; + value: TValue; + unit?: string; + deviceClass?: string; + entityCategory?: string; + available?: boolean; + attributes?: Record; +} + +export interface IMotionEyeSwitch { + key: string; + name: string; + cameraId: string; + isOn: boolean; + entityCategory?: string; + available?: boolean; + attributes?: Record; +} + +export interface IMotionEyeSnapshot { + deviceInfo: IMotionEyeDeviceInfo; + cameras: IMotionEyeCamera[]; + sensors: IMotionEyeSensor[]; + switches: IMotionEyeSwitch[]; + rawCameras: IMotionEyeRawCamera[]; + connected: boolean; + updatedAt?: string; + manifest?: Record; + serverConfig?: Record; + metadata?: Record; +} + +export interface IMotionEyeHttpCommand { + label: string; + method: TMotionEyeHttpMethod; + path: string; + admin: boolean; + data?: Record; +} + +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; + 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; +} + +export interface IMotionEyeUrlRecord { + url?: string; + host?: string; + port?: number; + name?: string; + manufacturer?: string; + model?: string; + metadata?: Record; +} + +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' }, +]; diff --git a/ts/integrations/opnsense/.generated-by-smarthome-exchange b/ts/integrations/opnsense/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/opnsense/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/opnsense/index.ts b/ts/integrations/opnsense/index.ts index 57c3437..62d2a9c 100644 --- a/ts/integrations/opnsense/index.ts +++ b/ts/integrations/opnsense/index.ts @@ -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'; diff --git a/ts/integrations/opnsense/opnsense.classes.client.ts b/ts/integrations/opnsense/opnsense.classes.client.ts new file mode 100644 index 0000000..ac26a32 --- /dev/null +++ b/ts/integrations/opnsense/opnsense.classes.client.ts @@ -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(); + + constructor(private readonly config: IOpnsenseConfig) {} + + public async getSnapshot(): Promise { + 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 { + 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 { + 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 { + 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(snapshotArg: T): T { + return snapshotArg ? JSON.parse(JSON.stringify(snapshotArg)) as T : snapshotArg; + } +} diff --git a/ts/integrations/opnsense/opnsense.classes.configflow.ts b/ts/integrations/opnsense/opnsense.classes.configflow.ts new file mode 100644 index 0000000..7ad7398 --- /dev/null +++ b/ts/integrations/opnsense/opnsense.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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): Promise> { + 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); +}; diff --git a/ts/integrations/opnsense/opnsense.classes.integration.ts b/ts/integrations/opnsense/opnsense.classes.integration.ts index cc28161..0da3272 100644 --- a/ts/integrations/opnsense/opnsense.classes.integration.ts +++ b/ts/integrations/opnsense/opnsense.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new OpnsenseRuntime(new OpnsenseClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantOpnsenseIntegration extends OpnsenseIntegration {} + +class OpnsenseRuntime implements IIntegrationRuntime { + public domain = opnsenseDomain; + + constructor(private readonly client: OpnsenseClient) {} + + public async devices(): Promise { + return OpnsenseMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return OpnsenseMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(OpnsenseMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/opnsense/opnsense.discovery.ts b/ts/integrations/opnsense/opnsense.discovery.ts new file mode 100644 index 0000000..e073bde --- /dev/null +++ b/ts/integrations/opnsense/opnsense.discovery.ts @@ -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 { + 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 { + 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 { + 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}`}`; +}; diff --git a/ts/integrations/opnsense/opnsense.mapper.ts b/ts/integrations/opnsense/opnsense.mapper.ts new file mode 100644 index 0000000..f45d343 --- /dev/null +++ b/ts/integrations/opnsense/opnsense.mapper.ts @@ -0,0 +1,1426 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IOpnsenseActionDescriptor, + IOpnsenseArpEntry, + IOpnsenseClientDevice, + IOpnsenseCommand, + IOpnsenseConfig, + IOpnsenseEvent, + IOpnsenseFirewallAlias, + IOpnsenseFirewallRule, + IOpnsenseFirewallSnapshot, + IOpnsenseGatewayInfo, + IOpnsenseInterfaceInfo, + IOpnsenseManualEntry, + IOpnsenseRouterInfo, + IOpnsenseSensorMap, + IOpnsenseServiceInfo, + IOpnsenseSnapshot, + IOpnsenseSwitchInfo, + IOpnsenseSystemInfo, + IOpnsenseTelemetryInfo, + IOpnsenseVpnInstance, + IOpnsenseVpnSnapshot, +} from './opnsense.types.js'; +import { opnsenseDefaultPort, opnsenseDefaultVerifySsl, opnsenseDomain } from './opnsense.types.js'; + +type TSensorDescriptor = { + key: string; + name: string; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; +}; + +type TMergedManualData = { + router?: IOpnsenseRouterInfo; + devices: IOpnsenseClientDevice[]; + arpTable: IOpnsenseArpEntry[]; + interfaces: IOpnsenseInterfaceInfo[]; + gateways: IOpnsenseGatewayInfo[]; + firewall?: IOpnsenseFirewallSnapshot; + system?: IOpnsenseSystemInfo; + telemetry?: IOpnsenseTelemetryInfo; + services: IOpnsenseServiceInfo[]; + vpn?: IOpnsenseVpnSnapshot; + sensors?: IOpnsenseSensorMap; + switches: IOpnsenseSwitchInfo[]; + actions: IOpnsenseActionDescriptor[]; + hasData: boolean; +}; + +type TOpnsenseNatRules = IOpnsenseFirewallRule[] | Record | undefined; + +const manufacturer = 'OPNsense'; + +const standardSensorDescriptors: TSensorDescriptor[] = [ + { key: 'connected_clients', name: 'Connected clients', unit: 'clients', stateClass: 'measurement' }, + { key: 'cpu_usage', name: 'CPU usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'memory_usage', name: 'Memory usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'pf_state_used', name: 'Firewall state table used', unit: 'states', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'pf_state_used_percent', name: 'Firewall state table used', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'mbuf_used_percent', name: 'Mbuf used', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'uptime', name: 'Uptime', unit: 's', deviceClass: 'duration', stateClass: 'total', entityCategory: 'diagnostic' }, +]; + +export class OpnsenseMapper { + public static toSnapshot(configArg: IOpnsenseConfig, connectedArg?: boolean, eventsArg: IOpnsenseEvent[] = []): IOpnsenseSnapshot { + const source = configArg.snapshot; + const manualSnapshots = (configArg.manualEntries || []) + .map((entryArg) => entryArg.snapshot) + .filter((snapshotArg): snapshotArg is IOpnsenseSnapshot => Boolean(snapshotArg)); + const manualData = this.mergeManualEntries(configArg.manualEntries || []); + const trackerInterfaces = this.trackerInterfaces(configArg, source); + const router = this.routerInfo(configArg, source, manualSnapshots, manualData.router); + const interfaces = this.uniqueInterfaces([ + ...(source?.interfaces || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.interfaces || []), + ...(configArg.interfaces || []), + ...manualData.interfaces, + ]); + const devices = this.uniqueClients([ + ...(source?.devices || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.devices || []), + ...(configArg.devices || []), + ...(configArg.clients || []), + ...manualData.devices, + ...this.devicesFromArp([...(configArg.arpTable || []), ...manualData.arpTable], trackerInterfaces), + ]); + const gateways = this.uniqueGateways([ + ...(source?.gateways || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.gateways || []), + ...(configArg.gateways || []), + ...manualData.gateways, + ]); + const firewall = this.firewallSnapshot(source?.firewall, ...manualSnapshots.map((snapshotArg) => snapshotArg.firewall), configArg.firewall, manualData.firewall); + const system = this.objectMerge(source?.system, ...manualSnapshots.map((snapshotArg) => snapshotArg.system), configArg.system, manualData.system); + const telemetry = this.objectMerge(source?.telemetry, ...manualSnapshots.map((snapshotArg) => snapshotArg.telemetry), configArg.telemetry, manualData.telemetry); + const services = this.uniqueServices([ + ...(source?.services || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.services || []), + ...(configArg.services || []), + ...manualData.services, + ]); + const vpn = this.vpnSnapshot(source?.vpn, ...manualSnapshots.map((snapshotArg) => snapshotArg.vpn), configArg.vpn || configArg.vpns, manualData.vpn); + const switches = this.uniqueSwitches([ + ...(source?.switches || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.switches || []), + ...(configArg.switches || []), + ...manualData.switches, + ]); + const sensors = this.sensorMap([ + source?.sensors, + ...manualSnapshots.map((snapshotArg) => snapshotArg.sensors), + configArg.sensors, + manualData.sensors, + ], devices, interfaces, gateways, services, system, telemetry, firewall, vpn); + const actions = this.uniqueActions([ + ...(source?.actions || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.actions || []), + ...(configArg.actions || []), + ...manualData.actions, + ...this.actionsFromRouter(router), + ...this.actionsFromInterfaces(interfaces), + ...this.actionsFromServices(services), + ...this.actionsFromFirewall(firewall), + ...this.actionsFromVpn(vpn), + ...this.actionsFromSwitches(switches), + ...this.actionsFromSystem(system), + ]); + const hasManualData = Boolean( + source + || manualSnapshots.length + || configArg.router + || configArg.devices?.length + || configArg.clients?.length + || configArg.arpTable?.length + || configArg.interfaces?.length + || configArg.gateways?.length + || configArg.firewall + || configArg.system + || configArg.telemetry + || configArg.services?.length + || configArg.vpn + || configArg.vpns + || configArg.sensors + || configArg.switches?.length + || manualData.hasData + ); + + return { + connected: connectedArg ?? configArg.connected ?? source?.connected ?? hasManualData, + source: source?.source || (hasManualData ? 'manual' : 'runtime'), + updatedAt: source?.updatedAt || new Date().toISOString(), + router, + devices, + interfaces, + gateways, + firewall, + system, + telemetry, + services, + vpn, + sensors, + switches, + actions, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + error: source?.error, + metadata: { + ...source?.metadata, + ...configArg.metadata, + trackerInterfaces, + homeAssistantLegacyTrackerMapping: true, + liveHttpImplemented: false, + commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand), + }, + }; + } + + public static toDevices(snapshotArg: IOpnsenseSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.routerDevice(snapshotArg, updatedAt)]; + for (const client of snapshotArg.devices) { + devices.push(this.clientDevice(client, snapshotArg, updatedAt)); + } + return devices; + } + + public static toEntities(snapshotArg: IOpnsenseSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const routerDeviceId = this.routerDeviceId(snapshotArg); + const routerName = this.routerName(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + const seenSensorKeys = new Set(); + + entities.push(this.entity('binary_sensor', `${routerName} Connected`, routerDeviceId, `${uniqueBase}_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: snapshotArg.router.host, + port: snapshotArg.router.port, + verifySsl: snapshotArg.router.verifySsl, + }, true)); + + const firmware = snapshotArg.router.firmware || snapshotArg.router.productVersion || snapshotArg.system.firmwareVersion || snapshotArg.system.productVersion; + if (firmware) { + entities.push(this.entity('sensor', `${routerName} Firmware`, routerDeviceId, `${uniqueBase}_firmware`, firmware, usedIds, { + entityCategory: 'diagnostic', + latestVersion: snapshotArg.router.latestFirmware || snapshotArg.system.productLatest, + updateAvailable: snapshotArg.router.updateAvailable ?? snapshotArg.system.updateAvailable, + }, snapshotArg.connected)); + } + + if (this.pendingNotices(snapshotArg.system) !== undefined) { + entities.push(this.entity('binary_sensor', `${routerName} Pending notices`, routerDeviceId, `${uniqueBase}_pending_notices`, this.pendingNotices(snapshotArg.system) ? 'on' : 'off', usedIds, { + entityCategory: 'diagnostic', + notices: snapshotArg.system.pendingNotices || snapshotArg.system.pending_notices, + }, snapshotArg.connected)); + } + + for (const descriptor of standardSensorDescriptors) { + const value = snapshotArg.sensors[descriptor.key]; + if (value === undefined) { + continue; + } + seenSensorKeys.add(descriptor.key); + entities.push(this.entity('sensor', `${routerName} ${descriptor.name}`, routerDeviceId, `${uniqueBase}_${this.slug(descriptor.key)}`, this.sensorValue(value), usedIds, { + nativeKey: descriptor.key, + unit: descriptor.unit, + deviceClass: descriptor.deviceClass, + stateClass: descriptor.stateClass, + entityCategory: descriptor.entityCategory, + }, snapshotArg.connected)); + } + + for (const [key, value] of Object.entries(snapshotArg.sensors)) { + if (seenSensorKeys.has(key) || value === undefined || typeof value === 'object') { + continue; + } + entities.push(this.entity('sensor', `${routerName} ${this.title(key)}`, routerDeviceId, `${uniqueBase}_${this.slug(key)}`, this.sensorValue(value), usedIds, { + nativeKey: key, + entityCategory: 'diagnostic', + }, snapshotArg.connected)); + } + + for (const iface of snapshotArg.interfaces) { + this.pushInterfaceEntities(entities, snapshotArg, iface, usedIds); + } + for (const gateway of snapshotArg.gateways) { + this.pushGatewayEntities(entities, snapshotArg, gateway, usedIds); + } + for (const client of snapshotArg.devices) { + this.pushClientEntities(entities, snapshotArg, client, usedIds); + } + this.pushFirewallEntities(entities, snapshotArg, usedIds); + this.pushServiceEntities(entities, snapshotArg, usedIds); + this.pushVpnEntities(entities, snapshotArg, usedIds); + this.pushGenericSwitchEntities(entities, snapshotArg, usedIds); + + if (this.hasFirmwareUpdate(snapshotArg)) { + entities.push(this.entity('update', `${routerName} Firmware`, routerDeviceId, `${uniqueBase}_firmware_update`, this.updateAvailable(snapshotArg) ? 'on' : 'off', usedIds, { + nativeType: 'firmware_update', + nativeAction: 'firmware_update', + installedVersion: firmware, + latestVersion: snapshotArg.router.latestFirmware || snapshotArg.system.productLatest, + writable: this.snapshotActions(snapshotArg).some((actionArg) => actionArg.target === 'router' && (actionArg.action === 'firmware_update' || actionArg.action === 'firmware_upgrade')), + }, snapshotArg.connected)); + } + + for (const action of this.snapshotActions(snapshotArg)) { + const button = this.actionButton(snapshotArg, action, usedIds); + if (button) { + entities.push(button); + } + } + + return entities; + } + + public static commandForService(snapshotArg: IOpnsenseSnapshot, requestArg: IServiceCallRequest): IOpnsenseCommand | { error: string } | undefined { + if (requestArg.domain === opnsenseDomain) { + return this.opnsenseServiceCommand(snapshotArg, requestArg); + } + + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + if (!targetEntity) { + return undefined; + } + + if (requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) { + return this.switchCommand(snapshotArg, targetEntity, requestArg, requestArg.service === 'turn_on'); + } + + if (requestArg.domain === 'button' && requestArg.service === 'press') { + return this.buttonCommand(snapshotArg, targetEntity, requestArg); + } + + if (requestArg.domain === 'update' && requestArg.service === 'install' && targetEntity.attributes?.nativeType === 'firmware_update') { + return this.firmwareCommand(snapshotArg, requestArg, targetEntity); + } + + return undefined; + } + + public static toIntegrationEvent(eventArg: IOpnsenseEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: opnsenseDomain, + deviceId: eventArg.deviceId, + entityId: eventArg.entityId, + data: eventArg, + timestamp: eventArg.timestamp || Date.now(), + }; + } + + public static defaultPort(): number { + return opnsenseDefaultPort; + } + + public static normalizeMac(valueArg?: string): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.replace(/[^0-9a-f]/gi, '').toLowerCase(); + return compact.length === 12 ? compact.match(/.{1,2}/g)?.join(':') : valueArg.toLowerCase(); + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'opnsense'; + } + + private static routerInfo(configArg: IOpnsenseConfig, sourceArg: IOpnsenseSnapshot | undefined, manualSnapshotsArg: IOpnsenseSnapshot[], manualRouterArg?: IOpnsenseRouterInfo): IOpnsenseRouterInfo { + const parsed = this.parseEndpoint(configArg.url || configArg.router?.url || sourceArg?.router.url); + const manualRouter = manualRouterArg || manualSnapshotsArg.find((snapshotArg) => snapshotArg.router)?.router; + const router = { + ...sourceArg?.router, + ...manualRouter, + ...configArg.router, + }; + const host = configArg.host || parsed.host || router.host || sourceArg?.router.host; + const port = configArg.port || parsed.port || router.port || (host ? opnsenseDefaultPort : undefined); + const ssl = true; + const verifySsl = configArg.verifySsl ?? configArg.verify_ssl ?? router.verifySsl ?? opnsenseDefaultVerifySsl; + const configurationUrl = router.configurationUrl || (host ? this.endpointUrl(host, port) : router.url || configArg.url); + const mac = this.normalizeMac(configArg.uniqueId || router.macAddress || sourceArg?.router.macAddress); + return { + ...router, + id: router.id || configArg.uniqueId || mac || (host ? `${host}:${port || opnsenseDefaultPort}` : undefined) || configArg.name || 'opnsense', + host, + port, + ssl, + verifySsl, + url: configurationUrl, + name: configArg.name || router.name || host || 'OPNsense', + model: router.model || 'OPNsense Firewall', + firmware: router.firmware || router.productVersion, + macAddress: mac || router.macAddress, + manufacturer: router.manufacturer || manufacturer, + configurationUrl, + }; + } + + private static mergeManualEntries(entriesArg: IOpnsenseManualEntry[]): TMergedManualData { + const devices: IOpnsenseClientDevice[] = []; + const arpTable: IOpnsenseArpEntry[] = []; + const interfaces: IOpnsenseInterfaceInfo[] = []; + const gateways: IOpnsenseGatewayInfo[] = []; + const services: IOpnsenseServiceInfo[] = []; + const switches: IOpnsenseSwitchInfo[] = []; + const actions: IOpnsenseActionDescriptor[] = []; + const sensors: IOpnsenseSensorMap = {}; + let router: IOpnsenseRouterInfo | undefined; + let firewall: IOpnsenseFirewallSnapshot | undefined; + let system: IOpnsenseSystemInfo | undefined; + let telemetry: IOpnsenseTelemetryInfo | undefined; + let vpn: IOpnsenseVpnSnapshot | undefined; + let hasData = false; + + for (const entry of entriesArg) { + if (entry.router) { + router = { ...router, ...entry.router }; + hasData = true; + } else if (!router && (entry.host || entry.url || entry.name || entry.model || entry.macAddress)) { + router = { + id: entry.id || entry.macAddress || entry.host || entry.url, + url: entry.url, + host: entry.host, + port: entry.port, + ssl: true, + verifySsl: entry.verifySsl, + name: entry.name, + model: entry.model, + macAddress: entry.macAddress, + manufacturer: entry.manufacturer, + }; + hasData = true; + } + firewall = this.firewallSnapshot(firewall, entry.firewall); + system = this.objectMerge(system, entry.system); + telemetry = this.objectMerge(telemetry, entry.telemetry); + vpn = this.vpnSnapshot(vpn, entry.vpn || entry.vpns); + devices.push(...(entry.devices || []), ...(entry.clients || [])); + arpTable.push(...(entry.arpTable || [])); + interfaces.push(...(entry.interfaces || [])); + gateways.push(...(entry.gateways || [])); + services.push(...(entry.services || [])); + switches.push(...(entry.switches || [])); + Object.assign(sensors, entry.sensors || {}); + actions.push(...(entry.actions || [])); + hasData = hasData || Boolean(entry.devices?.length || entry.clients?.length || entry.arpTable?.length || entry.interfaces?.length || entry.gateways?.length || entry.firewall || entry.system || entry.telemetry || entry.services?.length || entry.vpn || entry.vpns || entry.sensors || entry.switches?.length || entry.actions?.length); + } + + return { router, devices, arpTable, interfaces, gateways, firewall, system, telemetry, services, vpn, sensors: Object.keys(sensors).length ? sensors : undefined, switches, actions, hasData }; + } + + private static devicesFromArp(entriesArg: IOpnsenseArpEntry[], trackerInterfacesArg: string[]): IOpnsenseClientDevice[] { + const devices: IOpnsenseClientDevice[] = []; + for (const entry of entriesArg) { + const mac = this.normalizeMac(entry.mac || entry.macAddress || entry['mac-address']); + if (!mac) { + continue; + } + const interfaceDescription = this.stringValue(entry.intf_description || entry.interfaceDescription); + if (trackerInterfacesArg.length && (!interfaceDescription || !trackerInterfacesArg.includes(interfaceDescription))) { + continue; + } + const hostname = this.stringValue(entry.hostname); + devices.push({ + mac, + ipAddress: this.stringValue(entry.ipAddress || entry.ip || entry['ip-address'] || entry.address), + hostname: hostname && hostname !== '?' ? hostname : undefined, + name: hostname && hostname !== '?' ? hostname : undefined, + connected: true, + interface: this.stringValue(entry.interface), + interfaceDescription, + intf_description: interfaceDescription, + manufacturer: this.stringValue(entry.manufacturer), + expires: entry.expires, + type: this.stringValue(entry.type), + }); + } + return devices; + } + + private static sensorMap(sourcesArg: Array, devicesArg: IOpnsenseClientDevice[], interfacesArg: IOpnsenseInterfaceInfo[], gatewaysArg: IOpnsenseGatewayInfo[], servicesArg: IOpnsenseServiceInfo[], systemArg: IOpnsenseSystemInfo, telemetryArg: IOpnsenseTelemetryInfo, firewallArg: IOpnsenseFirewallSnapshot, vpnArg: IOpnsenseVpnSnapshot): IOpnsenseSensorMap { + const sensors: IOpnsenseSensorMap = {}; + for (const source of sourcesArg) { + Object.assign(sensors, source || {}); + } + sensors.connected_clients ??= devicesArg.filter((deviceArg) => deviceArg.connected !== false).length; + sensors.uptime ??= this.numberValue(systemArg.uptime) ?? this.numberValue(telemetryArg.system?.uptime); + sensors.pending_notices ??= this.pendingNotices(systemArg); + sensors.cpu_usage ??= this.numberValue(telemetryArg.cpu?.usage_total) ?? this.numberValue(telemetryArg.cpu?.usageTotal) ?? this.numberValue(telemetryArg.cpu?.total); + sensors.memory_usage ??= this.numberValue(telemetryArg.memory?.used_percent) ?? this.numberValue(telemetryArg.memory?.usedPercent); + sensors.pf_state_used ??= this.numberValue(firewallArg.state?.used) ?? this.numberValue(telemetryArg.pfstate?.used); + sensors.pf_state_used_percent ??= this.numberValue(firewallArg.state?.usedPercent) ?? this.numberValue(firewallArg.state?.used_percent) ?? this.numberValue(telemetryArg.pfstate?.used_percent) ?? this.numberValue(telemetryArg.pfstate?.usedPercent); + sensors.mbuf_used_percent ??= this.numberValue(telemetryArg.mbuf?.used_percent) ?? this.numberValue(telemetryArg.mbuf?.usedPercent); + sensors.interface_count ??= interfacesArg.length; + sensors.gateway_count ??= gatewaysArg.length; + sensors.running_services ??= servicesArg.filter((serviceArg) => this.serviceRunning(serviceArg)).length; + sensors.openvpn_servers ??= this.vpnInstances(vpnArg, 'openvpn', 'servers').length; + sensors.wireguard_servers ??= this.vpnInstances(vpnArg, 'wireguard', 'servers').length; + return this.cleanAttributes(sensors) as IOpnsenseSensorMap; + } + + private static routerDevice(snapshotArg: IOpnsenseSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + { id: 'connected_clients', capability: 'sensor', name: 'Connected clients', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + { featureId: 'connected_clients', value: snapshotArg.sensors.connected_clients ?? snapshotArg.devices.length, updatedAt: updatedAtArg }, + ]; + this.addFeatureState(features, state, 'cpu_usage', 'CPU usage', snapshotArg.sensors.cpu_usage, updatedAtArg, '%'); + this.addFeatureState(features, state, 'memory_usage', 'Memory usage', snapshotArg.sensors.memory_usage, updatedAtArg, '%'); + this.addFeatureState(features, state, 'pf_state_used_percent', 'Firewall state table used', snapshotArg.sensors.pf_state_used_percent, updatedAtArg, '%'); + + return { + id: this.routerDeviceId(snapshotArg), + integrationDomain: opnsenseDomain, + name: this.routerName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.router.manufacturer || manufacturer, + model: snapshotArg.router.model || 'OPNsense Firewall', + online: snapshotArg.connected, + features, + state, + metadata: this.cleanAttributes({ + host: snapshotArg.router.host, + port: snapshotArg.router.port, + ssl: snapshotArg.router.ssl, + verifySsl: snapshotArg.router.verifySsl, + macAddress: snapshotArg.router.macAddress, + firmware: snapshotArg.router.firmware || snapshotArg.system.firmwareVersion || snapshotArg.system.productVersion, + latestFirmware: snapshotArg.router.latestFirmware || snapshotArg.system.productLatest, + configurationUrl: snapshotArg.router.configurationUrl, + source: snapshotArg.source, + liveHttpImplemented: false, + }), + }; + } + + private static clientDevice(clientArg: IOpnsenseClientDevice, snapshotArg: IOpnsenseSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const name = this.clientName(clientArg); + return { + id: this.clientDeviceId(clientArg), + integrationDomain: opnsenseDomain, + name, + protocol: 'unknown', + manufacturer: clientArg.manufacturer || 'Unknown', + model: clientArg.model || 'Network client', + online: clientArg.connected !== false && snapshotArg.connected, + features: [ + { id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false }, + { id: 'ip_address', capability: 'sensor', name: 'IP address', readable: true, writable: false }, + ], + state: [ + { featureId: 'presence', value: clientArg.connected !== false, updatedAt: updatedAtArg }, + { featureId: 'ip_address', value: clientArg.ipAddress || clientArg.ip || clientArg.address || null, updatedAt: updatedAtArg }, + ], + metadata: this.cleanAttributes({ + mac: this.clientMac(clientArg), + ipAddress: clientArg.ipAddress || clientArg.ip || clientArg.address, + hostname: clientArg.hostname || clientArg.name, + interface: clientArg.interface, + interfaceDescription: clientArg.interfaceDescription || clientArg.intf_description, + leaseType: clientArg.leaseType || clientArg.type, + expires: this.dateString(clientArg.expires), + lastActivity: this.dateString(clientArg.lastActivity), + }), + }; + } + + private static pushInterfaceEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, ifaceArg: IOpnsenseInterfaceInfo, usedIdsArg: Map): void { + const deviceId = this.routerDeviceId(snapshotArg); + const ifaceKey = this.slug(ifaceArg.id || ifaceArg.name); + const ifaceName = ifaceArg.label || ifaceArg.description || ifaceArg.name; + const values: Array<[string, string, unknown, string | undefined, Record]> = [ + ['rx_bytes', 'Download', this.bytesToGigabytes(this.numberValue(ifaceArg.rxBytes) ?? this.numberValue(ifaceArg.inbytes)), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['tx_bytes', 'Upload', this.bytesToGigabytes(this.numberValue(ifaceArg.txBytes) ?? this.numberValue(ifaceArg.outbytes)), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['rx_packets', 'Packets received', this.numberValue(ifaceArg.rxPackets) ?? this.numberValue(ifaceArg.inpkts), 'packets', { stateClass: 'total_increasing' }], + ['tx_packets', 'Packets transmitted', this.numberValue(ifaceArg.txPackets) ?? this.numberValue(ifaceArg.outpkts), 'packets', { stateClass: 'total_increasing' }], + ['input_errors', 'Input errors', this.numberValue(ifaceArg.inputErrors) ?? this.numberValue(ifaceArg.inerrs), 'errors', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['output_errors', 'Output errors', this.numberValue(ifaceArg.outputErrors) ?? this.numberValue(ifaceArg.outerrs), 'errors', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ['collisions', 'Collisions', this.numberValue(ifaceArg.collisions), 'collisions', { stateClass: 'total_increasing', entityCategory: 'diagnostic' }], + ]; + entitiesArg.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} ${ifaceName} Link`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_link`, this.interfaceUp(ifaceArg) ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + interface: ifaceArg.name, + status: ifaceArg.status, + enabled: this.booleanLike(ifaceArg.enabled), + macAddress: ifaceArg.macAddress || ifaceArg.mac, + ipv4: ifaceArg.ipv4, + ipv6: ifaceArg.ipv6, + media: ifaceArg.media, + device: ifaceArg.device, + gateways: ifaceArg.gateways, + }, snapshotArg.connected)); + for (const [key, name, value, unit, attrs] of values) { + if (value === undefined) { + continue; + } + entitiesArg.push(this.entity('sensor', `${this.routerName(snapshotArg)} ${ifaceName} ${name}`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_${key}`, value, usedIdsArg, { + ...attrs, + unit, + interface: ifaceArg.name, + }, snapshotArg.connected && this.interfaceUp(ifaceArg))); + } + } + + private static pushGatewayEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, gatewayArg: IOpnsenseGatewayInfo, usedIdsArg: Map): void { + const gatewayKey = this.slug(gatewayArg.id || gatewayArg.name); + const name = `${this.routerName(snapshotArg)} Gateway ${gatewayArg.name}`; + const up = this.gatewayUp(gatewayArg); + entitiesArg.push(this.entity('binary_sensor', name, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_gateway_${gatewayKey}`, up ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + nativeType: 'gateway', + gateway: gatewayArg.name, + status: gatewayArg.status, + address: gatewayArg.address, + interface: gatewayArg.interface, + monitor: gatewayArg.monitor, + }, snapshotArg.connected)); + const latency = this.numberValue(gatewayArg.latency) ?? this.numberValue(gatewayArg.rtt) ?? this.numberValue(gatewayArg.delay); + if (latency !== undefined) { + entitiesArg.push(this.entity('sensor', `${name} Latency`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_gateway_${gatewayKey}_latency`, latency, usedIdsArg, { + unit: 'ms', + stateClass: 'measurement', + gateway: gatewayArg.name, + }, snapshotArg.connected && up)); + } + const loss = this.numberValue(gatewayArg.loss); + if (loss !== undefined) { + entitiesArg.push(this.entity('sensor', `${name} Loss`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_gateway_${gatewayKey}_loss`, loss, usedIdsArg, { + unit: '%', + stateClass: 'measurement', + gateway: gatewayArg.name, + }, snapshotArg.connected && up)); + } + } + + private static pushClientEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, clientArg: IOpnsenseClientDevice, usedIdsArg: Map): void { + const mac = this.clientMac(clientArg); + entitiesArg.push(this.entity('binary_sensor', `${this.clientName(clientArg)} Connected`, this.clientDeviceId(clientArg), `${this.slug(mac || this.clientName(clientArg))}_connected`, clientArg.connected !== false ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + nativePlatform: 'device_tracker', + mac, + ipAddress: clientArg.ipAddress || clientArg.ip || clientArg.address, + hostname: clientArg.hostname || clientArg.name, + interface: clientArg.interface, + interfaceDescription: clientArg.interfaceDescription || clientArg.intf_description, + manufacturer: clientArg.manufacturer, + }, snapshotArg.connected)); + } + + private static pushFirewallEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, usedIdsArg: Map): void { + for (const rule of this.firewallRules(snapshotArg.firewall.rules)) { + const uuid = this.ruleId(rule); + if (!uuid) { + continue; + } + const label = rule.description || rule.descr || uuid; + entitiesArg.push(this.entity('switch', `${this.routerName(snapshotArg)} Firewall ${label}`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_firewall_rule_${this.slug(uuid)}`, this.ruleEnabled(rule) ? 'on' : 'off', usedIdsArg, { + nativeType: 'firewall_rule', + nativeAction: 'toggle', + uuid, + interface: rule.interface, + direction: rule.direction, + protocol: rule.protocol, + ruleAction: rule.action, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + for (const [natType, rules] of Object.entries(snapshotArg.firewall.nat || {})) { + for (const rule of this.firewallRules(rules)) { + const uuid = this.ruleId(rule); + if (!uuid) { + continue; + } + const label = rule.description || rule.descr || uuid; + entitiesArg.push(this.entity('switch', `${this.routerName(snapshotArg)} NAT ${label}`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_nat_${this.slug(natType)}_${this.slug(uuid)}`, this.ruleEnabled(rule), usedIdsArg, { + nativeType: 'nat_rule', + nativeAction: 'toggle', + natRuleType: natType, + uuid, + interface: rule.interface, + protocol: rule.protocol, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + } + for (const alias of this.firewallAliases(snapshotArg.firewall.aliases)) { + const uuid = alias.uuid || alias.id || alias.name; + entitiesArg.push(this.entity('switch', `${this.routerName(snapshotArg)} Alias ${alias.name}`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_alias_${this.slug(uuid)}`, this.ruleEnabled(alias), usedIdsArg, { + nativeType: 'firewall_alias', + nativeAction: 'toggle', + uuid, + alias: alias.name, + aliasType: alias.type, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + } + + private static pushServiceEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, usedIdsArg: Map): void { + for (const service of snapshotArg.services) { + const serviceName = this.serviceName(service); + const display = this.serviceDisplayName(service); + entitiesArg.push(this.entity('switch', `${display} Service`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_service_${this.slug(serviceName)}`, this.serviceRunning(service) ? 'on' : 'off', usedIdsArg, { + nativeType: 'service', + nativeAction: 'start_stop', + serviceName, + description: service.description, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + entitiesArg.push(this.entity('button', `${display} Restart`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_service_${this.slug(serviceName)}_restart`, 'available', usedIdsArg, { + nativeType: 'service', + nativeAction: 'restart', + serviceName, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + } + + private static pushVpnEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, usedIdsArg: Map): void { + for (const vpnType of ['openvpn', 'wireguard'] as const) { + for (const role of ['servers', 'clients'] as const) { + for (const instance of this.vpnInstances(snapshotArg.vpn, vpnType, role)) { + const uuid = this.vpnId(instance); + if (!uuid) { + continue; + } + const name = `${this.routerName(snapshotArg)} ${this.title(vpnType)} ${this.vpnName(instance)}`; + entitiesArg.push(this.entity('switch', name, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_${vpnType}_${role}_${this.slug(uuid)}`, this.booleanLike(instance.enabled) ? 'on' : 'off', usedIdsArg, { + nativeType: 'vpn_instance', + nativeAction: 'toggle', + vpnType, + vpnRole: role.slice(0, -1), + uuid, + status: instance.status, + interface: instance.interface, + endpoint: instance.endpoint, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + const connectedCount = this.numberValue(instance.connectedClients ?? instance.connected_clients ?? instance.connectedServers ?? instance.connected_servers); + if (connectedCount !== undefined) { + entitiesArg.push(this.entity('sensor', `${name} Connected peers`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_${vpnType}_${role}_${this.slug(uuid)}_connected_peers`, connectedCount, usedIdsArg, { + unit: 'peers', + stateClass: 'measurement', + vpnType, + vpnRole: role.slice(0, -1), + }, snapshotArg.connected)); + } + } + } + } + } + + private static pushGenericSwitchEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IOpnsenseSnapshot, usedIdsArg: Map): void { + for (const switchInfo of snapshotArg.switches) { + const id = switchInfo.id || switchInfo.uuid || switchInfo.name; + entitiesArg.push(this.entity('switch', `${this.routerName(snapshotArg)} ${switchInfo.name}`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_switch_${this.slug(id)}`, this.booleanLike(switchInfo.enabled) ? 'on' : 'off', usedIdsArg, { + nativeType: switchInfo.nativeType || 'generic_switch', + nativeAction: switchInfo.action || 'toggle', + uuid: switchInfo.uuid, + service: switchInfo.service, + path: switchInfo.path, + method: switchInfo.method, + payload: switchInfo.payload, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected && switchInfo.available !== false)); + } + } + + private static actionButton(snapshotArg: IOpnsenseSnapshot, actionArg: IOpnsenseActionDescriptor, usedIdsArg: Map): IIntegrationEntity | undefined { + if (actionArg.target === 'router' && (actionArg.action === 'reboot' || actionArg.action === 'halt')) { + return this.entity('button', `${this.routerName(snapshotArg)} ${this.title(actionArg.action)}`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_${this.slug(actionArg.action)}`, 'available', usedIdsArg, { + nativeType: 'router_action', + nativeAction: actionArg.action, + actionTarget: 'router', + entityCategory: 'config', + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + if (actionArg.target === 'interface' && actionArg.action === 'reload' && actionArg.interface) { + return this.entity('button', `${this.routerName(snapshotArg)} ${actionArg.interface} Reload`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_interface_${this.slug(actionArg.interface)}_reload`, 'available', usedIdsArg, { + nativeType: 'interface', + nativeAction: 'reload', + interface: actionArg.interface, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + if (actionArg.target === 'speedtest' && actionArg.action === 'run') { + return this.entity('button', `${this.routerName(snapshotArg)} Speedtest`, this.routerDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_speedtest`, 'available', usedIdsArg, { + nativeType: 'speedtest', + nativeAction: 'run', + entityCategory: 'diagnostic', + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + return undefined; + } + + private static opnsenseServiceCommand(snapshotArg: IOpnsenseSnapshot, requestArg: IServiceCallRequest): IOpnsenseCommand | { error: string } | undefined { + if (requestArg.service === 'reboot' || requestArg.service === 'halt') { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'router' && actionArg.action === requestArg.service); + return action ? this.command(snapshotArg, requestArg, action, undefined, { path: `/api/core/system/${requestArg.service}`, type: 'router.action' }) : undefined; + } + if (requestArg.service === 'firmware_update' || requestArg.service === 'upgrade_firmware') { + return this.firmwareCommand(snapshotArg, requestArg); + } + if (requestArg.service === 'start_service' || requestArg.service === 'stop_service' || requestArg.service === 'restart_service') { + const serviceName = this.stringValue(requestArg.data?.service) || this.stringValue(requestArg.data?.name); + if (!serviceName) { + return { error: 'OPNsense service control requires data.service.' }; + } + const actionName = requestArg.service.replace('_service', ''); + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'service' && actionArg.action === actionName && actionArg.service === serviceName); + return action ? this.command(snapshotArg, requestArg, action, undefined, { type: 'service.action', path: `/api/core/service/${actionName}/${encodeURIComponent(serviceName)}` }) : undefined; + } + if (requestArg.service === 'reload_interface') { + const ifName = this.stringValue(requestArg.data?.interface) || this.stringValue(requestArg.data?.interfaceName); + if (!ifName) { + return { error: 'OPNsense reload_interface requires data.interface.' }; + } + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'interface' && actionArg.action === 'reload' && actionArg.interface === ifName); + return action ? this.command(snapshotArg, requestArg, action, undefined, { type: 'interface.reload', path: `/api/interfaces/overview/reload_interface/${encodeURIComponent(ifName)}` }) : undefined; + } + if (requestArg.service === 'close_notice') { + const id = this.stringValue(requestArg.data?.id) || 'all'; + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'notice' && (actionArg.id === id || id === 'all')); + return action ? this.command(snapshotArg, requestArg, action, undefined, { type: 'notice.close', path: '/api/core/system/dismiss_status', payload: { subject: id } }) : undefined; + } + if (requestArg.service === 'send_wol') { + const mac = this.normalizeMac(this.stringValue(requestArg.data?.mac) || this.stringValue(requestArg.data?.macAddress)); + const ifName = this.stringValue(requestArg.data?.interface); + if (!mac || !ifName) { + return { error: 'OPNsense send_wol requires data.interface and data.mac.' }; + } + return this.command(snapshotArg, requestArg, { target: 'wol', action: 'send', interface: ifName, mac }, undefined, { type: 'wol.send', path: '/api/wol/wol/set', payload: { wake: { interface: ifName, mac } } }); + } + if (requestArg.service === 'run_speedtest') { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'speedtest' && actionArg.action === 'run'); + return action ? this.command(snapshotArg, requestArg, action, undefined, { type: 'speedtest.run', path: '/api/speedtest/service/run', method: 'GET' }) : undefined; + } + return undefined; + } + + private static switchCommand(snapshotArg: IOpnsenseSnapshot, entityArg: IIntegrationEntity, requestArg: IServiceCallRequest, enabledArg: boolean): IOpnsenseCommand | { error: string } | undefined { + const nativeType = this.stringValue(entityArg.attributes?.nativeType); + const uuid = this.stringValue(entityArg.attributes?.uuid); + if (nativeType === 'service') { + const serviceName = this.stringValue(entityArg.attributes?.serviceName); + if (!serviceName) { + return { error: 'OPNsense service switch is missing serviceName metadata.' }; + } + const actionName = enabledArg ? 'start' : 'stop'; + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'service' && actionArg.action === actionName && actionArg.service === serviceName); + return action ? this.command(snapshotArg, requestArg, action, entityArg, { type: 'service.action', path: `/api/core/service/${actionName}/${encodeURIComponent(serviceName)}` }) : undefined; + } + if (nativeType === 'firewall_rule' && uuid) { + return this.command(snapshotArg, requestArg, { target: 'firewall_rule', action: 'toggle', uuid }, entityArg, { + type: 'firewall.toggle', + path: `/api/firewall/filter/toggle_rule/${encodeURIComponent(uuid)}/${enabledArg ? '1' : '0'}`, + payload: { enabled: enabledArg, applyPath: '/api/firewall/filter/apply' }, + }); + } + if (nativeType === 'nat_rule' && uuid) { + const natRuleType = this.stringValue(entityArg.attributes?.natRuleType) || 'd_nat'; + const toggleFlag = natRuleType === 'd_nat' ? enabledArg ? '0' : '1' : enabledArg ? '1' : '0'; + return this.command(snapshotArg, requestArg, { target: 'nat_rule', action: 'toggle', uuid, natRuleType }, entityArg, { + type: 'nat.toggle', + path: `/api/firewall/${encodeURIComponent(natRuleType)}/toggle_rule/${encodeURIComponent(uuid)}/${toggleFlag}`, + payload: { enabled: enabledArg, applyPath: `/api/firewall/${natRuleType}/apply` }, + }); + } + if (nativeType === 'firewall_alias' && uuid) { + return this.command(snapshotArg, requestArg, { target: 'firewall_alias', action: 'toggle', uuid }, entityArg, { + type: 'alias.toggle', + path: `/api/firewall/alias/toggle_item/${encodeURIComponent(uuid)}/${enabledArg ? '1' : '0'}`, + payload: { enabled: enabledArg, savePath: '/api/firewall/alias/set', reconfigurePath: '/api/firewall/alias/reconfigure' }, + }); + } + if (nativeType === 'vpn_instance' && uuid) { + const vpnType = this.stringValue(entityArg.attributes?.vpnType) || 'openvpn'; + const vpnRole = this.stringValue(entityArg.attributes?.vpnRole) || 'server'; + return this.vpnToggleCommand(snapshotArg, requestArg, entityArg, uuid, vpnType, vpnRole, enabledArg); + } + if (nativeType === 'unbound_blocklist') { + return this.command(snapshotArg, requestArg, { target: 'unbound', action: enabledArg ? 'enable' : 'disable', uuid }, entityArg, { + type: 'unbound.toggle', + path: uuid ? `/api/unbound/settings/toggle_dnsbl/${encodeURIComponent(uuid)}/${enabledArg ? '1' : '0'}` : undefined, + payload: { enabled: enabledArg }, + }); + } + if (nativeType === 'generic_switch') { + const path = this.stringValue(entityArg.attributes?.path); + if (!path) { + return undefined; + } + return this.command(snapshotArg, requestArg, { target: 'switch', action: enabledArg ? 'enable' : 'disable', path, method: this.methodValue(entityArg.attributes?.method), payload: this.recordValue(entityArg.attributes?.payload) }, entityArg, { + type: 'switch.action', + path, + method: this.methodValue(entityArg.attributes?.method), + payload: { ...this.recordValue(entityArg.attributes?.payload), enabled: enabledArg }, + }); + } + return undefined; + } + + private static buttonCommand(snapshotArg: IOpnsenseSnapshot, entityArg: IIntegrationEntity, requestArg: IServiceCallRequest): IOpnsenseCommand | { error: string } | undefined { + const nativeType = this.stringValue(entityArg.attributes?.nativeType); + const nativeAction = this.stringValue(entityArg.attributes?.nativeAction); + if (nativeType === 'router_action' && (nativeAction === 'reboot' || nativeAction === 'halt')) { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'router' && actionArg.action === nativeAction); + return action ? this.command(snapshotArg, requestArg, action, entityArg, { type: 'router.action', path: `/api/core/system/${nativeAction}` }) : undefined; + } + if (nativeType === 'service' && nativeAction === 'restart') { + const serviceName = this.stringValue(entityArg.attributes?.serviceName); + if (!serviceName) { + return { error: 'OPNsense service restart button is missing serviceName metadata.' }; + } + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'service' && actionArg.action === 'restart' && actionArg.service === serviceName); + return action ? this.command(snapshotArg, requestArg, action, entityArg, { type: 'service.action', path: `/api/core/service/restart/${encodeURIComponent(serviceName)}` }) : undefined; + } + if (nativeType === 'interface' && nativeAction === 'reload') { + const ifName = this.stringValue(entityArg.attributes?.interface); + if (!ifName) { + return { error: 'OPNsense interface reload button is missing interface metadata.' }; + } + return this.command(snapshotArg, requestArg, { target: 'interface', action: 'reload', interface: ifName }, entityArg, { type: 'interface.reload', path: `/api/interfaces/overview/reload_interface/${encodeURIComponent(ifName)}` }); + } + if (nativeType === 'speedtest' && nativeAction === 'run') { + return this.command(snapshotArg, requestArg, { target: 'speedtest', action: 'run' }, entityArg, { type: 'speedtest.run', path: '/api/speedtest/service/run', method: 'GET' }); + } + return undefined; + } + + private static firmwareCommand(snapshotArg: IOpnsenseSnapshot, requestArg: IServiceCallRequest, entityArg?: IIntegrationEntity): IOpnsenseCommand | undefined { + const actions = this.snapshotActions(snapshotArg); + const action = actions.find((actionArg) => actionArg.target === 'router' && (actionArg.action === 'firmware_update' || actionArg.action === 'firmware_upgrade')); + if (!action) { + return undefined; + } + const upgradeType = this.stringValue(requestArg.data?.type) === 'upgrade' || action.action === 'firmware_upgrade' ? 'upgrade' : 'update'; + return this.command(snapshotArg, requestArg, action, entityArg, { type: 'firmware.action', path: `/api/core/firmware/${upgradeType}` }); + } + + private static vpnToggleCommand(snapshotArg: IOpnsenseSnapshot, requestArg: IServiceCallRequest, entityArg: IIntegrationEntity, uuidArg: string, vpnTypeArg: string, vpnRoleArg: string, enabledArg: boolean): IOpnsenseCommand | undefined { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'vpn' && actionArg.uuid === uuidArg && actionArg.vpnType === vpnTypeArg); + if (!action) { + return undefined; + } + let path: string | undefined; + let reconfigurePath: string | undefined; + if (vpnTypeArg === 'openvpn') { + path = `/api/openvpn/instances/toggle/${encodeURIComponent(uuidArg)}`; + reconfigurePath = '/api/openvpn/service/reconfigure'; + } else if (vpnTypeArg === 'wireguard') { + const rolePath = vpnRoleArg === 'client' ? 'client/toggle_client' : 'server/toggle_server'; + path = `/api/wireguard/${rolePath}/${encodeURIComponent(uuidArg)}`; + reconfigurePath = '/api/wireguard/service/reconfigure'; + } + return this.command(snapshotArg, requestArg, action, entityArg, { type: 'vpn.toggle', path, payload: { enabled: enabledArg, reconfigurePath } }); + } + + private static command(snapshotArg: IOpnsenseSnapshot, requestArg: IServiceCallRequest, actionArg: IOpnsenseActionDescriptor, entityArg?: IIntegrationEntity, optionsArg: Partial = {}): IOpnsenseCommand { + return { + type: optionsArg.type || this.commandType(actionArg), + service: requestArg.service, + action: actionArg.action, + target: requestArg.target, + method: optionsArg.method || actionArg.method || 'POST', + path: optionsArg.path || actionArg.path, + payload: this.cleanAttributes({ ...(actionArg.payload || {}), ...(requestArg.data || {}), ...(optionsArg.payload || {}), actionMetadata: actionArg.metadata }), + routerId: this.routerDeviceId(snapshotArg), + entityId: entityArg?.id || actionArg.entityId || requestArg.target.entityId, + deviceId: entityArg?.deviceId || actionArg.deviceId || requestArg.target.deviceId, + uuid: actionArg.uuid || this.stringValue(actionArg.id), + serviceName: actionArg.service, + interface: actionArg.interface, + mac: actionArg.mac ? this.normalizeMac(actionArg.mac) : undefined, + vpnType: actionArg.vpnType, + vpnRole: actionArg.vpnRole, + natRuleType: actionArg.natRuleType, + }; + } + + private static commandType(actionArg: IOpnsenseActionDescriptor): IOpnsenseCommand['type'] { + if (actionArg.target === 'service') return 'service.action'; + if (actionArg.target === 'firewall_rule') return 'firewall.toggle'; + if (actionArg.target === 'nat_rule') return 'nat.toggle'; + if (actionArg.target === 'firewall_alias') return 'alias.toggle'; + if (actionArg.target === 'vpn') return 'vpn.toggle'; + if (actionArg.target === 'interface') return 'interface.reload'; + if (actionArg.target === 'notice') return 'notice.close'; + if (actionArg.target === 'wol') return 'wol.send'; + if (actionArg.target === 'firmware') return 'firmware.action'; + if (actionArg.target === 'unbound') return 'unbound.toggle'; + if (actionArg.target === 'speedtest') return 'speedtest.run'; + if (actionArg.target === 'switch') return 'switch.action'; + return 'router.action'; + } + + private static findTargetEntity(snapshotArg: IOpnsenseSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + if (!requestArg.target.entityId) { + return undefined; + } + return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + + private static actionsFromRouter(routerArg: IOpnsenseRouterInfo): IOpnsenseActionDescriptor[] { + return (routerArg.actions || []).map((actionArg) => ({ target: 'router', action: actionArg })); + } + + private static actionsFromInterfaces(interfacesArg: IOpnsenseInterfaceInfo[]): IOpnsenseActionDescriptor[] { + return interfacesArg.flatMap((interfaceArg) => (interfaceArg.actions || []).map((actionArg) => ({ target: 'interface' as const, action: actionArg, interface: interfaceArg.name }))); + } + + private static actionsFromServices(servicesArg: IOpnsenseServiceInfo[]): IOpnsenseActionDescriptor[] { + const actions: IOpnsenseActionDescriptor[] = []; + for (const service of servicesArg) { + const serviceName = this.serviceName(service); + if (!serviceName) { + continue; + } + const serviceActions = service.actions?.length ? service.actions : ['start', 'stop', 'restart'] as const; + for (const action of serviceActions) { + actions.push({ target: 'service', action, service: serviceName }); + } + } + return actions; + } + + private static actionsFromFirewall(firewallArg: IOpnsenseFirewallSnapshot): IOpnsenseActionDescriptor[] { + const actions: IOpnsenseActionDescriptor[] = []; + for (const rule of this.firewallRules(firewallArg.rules)) { + const uuid = this.ruleId(rule); + if (uuid) actions.push({ target: 'firewall_rule', action: 'toggle', uuid }); + } + for (const [natRuleType, rules] of Object.entries(firewallArg.nat || {})) { + for (const rule of this.firewallRules(rules)) { + const uuid = this.ruleId(rule); + if (uuid) actions.push({ target: 'nat_rule', action: 'toggle', uuid, natRuleType }); + } + } + for (const alias of this.firewallAliases(firewallArg.aliases)) { + const uuid = alias.uuid || alias.id || alias.name; + actions.push({ target: 'firewall_alias', action: 'toggle', uuid }); + } + return actions; + } + + private static actionsFromVpn(vpnArg: IOpnsenseVpnSnapshot): IOpnsenseActionDescriptor[] { + const actions: IOpnsenseActionDescriptor[] = []; + for (const vpnType of ['openvpn', 'wireguard'] as const) { + for (const role of ['servers', 'clients'] as const) { + for (const instance of this.vpnInstances(vpnArg, vpnType, role)) { + const uuid = this.vpnId(instance); + if (uuid) actions.push({ target: 'vpn', action: 'toggle', uuid, vpnType, vpnRole: role.slice(0, -1) }); + } + } + } + return actions; + } + + private static actionsFromSwitches(switchesArg: IOpnsenseSwitchInfo[]): IOpnsenseActionDescriptor[] { + return switchesArg + .filter((switchArg) => Boolean(switchArg.path || switchArg.action || switchArg.uuid)) + .map((switchArg) => ({ target: switchArg.nativeType === 'unbound_blocklist' ? 'unbound' : 'switch', action: switchArg.action || 'toggle', uuid: switchArg.uuid, service: switchArg.service, path: switchArg.path, method: switchArg.method, payload: switchArg.payload })); + } + + private static actionsFromSystem(systemArg: IOpnsenseSystemInfo): IOpnsenseActionDescriptor[] { + const actions: IOpnsenseActionDescriptor[] = []; + for (const notice of systemArg.pendingNotices || systemArg.pending_notices || []) { + if (notice.id) actions.push({ target: 'notice', action: 'close', id: notice.id }); + } + if (systemArg.speedtest || systemArg.speedtest_available === true) { + actions.push({ target: 'speedtest', action: 'run' }); + } + return actions; + } + + private static snapshotActions(snapshotArg: IOpnsenseSnapshot): IOpnsenseActionDescriptor[] { + return this.uniqueActions([ + ...(snapshotArg.actions || []), + ...this.actionsFromRouter(snapshotArg.router), + ...this.actionsFromInterfaces(snapshotArg.interfaces), + ...this.actionsFromServices(snapshotArg.services), + ...this.actionsFromFirewall(snapshotArg.firewall), + ...this.actionsFromVpn(snapshotArg.vpn), + ...this.actionsFromSwitches(snapshotArg.switches), + ...this.actionsFromSystem(snapshotArg.system), + ]); + } + + private static uniqueClients(devicesArg: IOpnsenseClientDevice[]): IOpnsenseClientDevice[] { + const seen = new Map(); + for (const device of devicesArg) { + const mac = this.clientMac(device); + const key = mac || device.id || device.ipAddress || device.ip || device.address || device.name || device.hostname; + if (!key) { + continue; + } + seen.set(key, { ...seen.get(key), ...device, mac: mac || device.mac }); + } + return [...seen.values()]; + } + + private static uniqueInterfaces(interfacesArg: IOpnsenseInterfaceInfo[]): IOpnsenseInterfaceInfo[] { + const seen = new Map(); + for (const iface of interfacesArg) { + const key = iface.id || iface.name; + if (!key) continue; + seen.set(key, { ...seen.get(key), ...iface }); + } + return [...seen.values()]; + } + + private static uniqueGateways(gatewaysArg: IOpnsenseGatewayInfo[]): IOpnsenseGatewayInfo[] { + const seen = new Map(); + for (const gateway of gatewaysArg) { + const key = gateway.id || gateway.name; + if (!key) continue; + seen.set(key, { ...seen.get(key), ...gateway }); + } + return [...seen.values()]; + } + + private static uniqueServices(servicesArg: IOpnsenseServiceInfo[]): IOpnsenseServiceInfo[] { + const seen = new Map(); + for (const service of servicesArg) { + const key = this.serviceName(service); + if (!key) continue; + seen.set(key, { ...seen.get(key), ...service }); + } + return [...seen.values()]; + } + + private static uniqueSwitches(switchesArg: IOpnsenseSwitchInfo[]): IOpnsenseSwitchInfo[] { + const seen = new Map(); + for (const switchInfo of switchesArg) { + const key = switchInfo.id || switchInfo.uuid || switchInfo.name; + if (!key) continue; + seen.set(key, { ...seen.get(key), ...switchInfo }); + } + return [...seen.values()]; + } + + private static uniqueActions(actionsArg: IOpnsenseActionDescriptor[]): IOpnsenseActionDescriptor[] { + const seen = new Map(); + for (const action of actionsArg) { + const key = [action.target, action.action, action.uuid || action.id || action.service || action.interface || action.vpnType || action.natRuleType || action.entityId || 'router'].join(':'); + seen.set(key, { ...seen.get(key), ...action }); + } + return [...seen.values()]; + } + + private static firewallSnapshot(...sourcesArg: Array): IOpnsenseFirewallSnapshot { + const firewall: IOpnsenseFirewallSnapshot = { rules: [], nat: {}, aliases: [] }; + for (const source of sourcesArg) { + if (!source) continue; + firewall.rules = this.firewallRules(firewall.rules).concat(this.firewallRules(source.rules)); + firewall.aliases = this.firewallAliases(firewall.aliases).concat(this.firewallAliases(source.aliases)); + const nat = { ...(firewall.nat || {}) }; + firewall.nat = nat; + for (const [natType, rules] of Object.entries(source.nat || {})) { + nat[natType] = this.firewallRules(nat[natType]).concat(this.firewallRules(rules)); + } + firewall.state = { ...firewall.state, ...source.state }; + firewall.metadata = { ...firewall.metadata, ...source.metadata }; + } + firewall.rules = this.uniqueRules(this.firewallRules(firewall.rules)); + firewall.aliases = this.uniqueAliases(this.firewallAliases(firewall.aliases)); + const nat = firewall.nat || {}; + firewall.nat = nat; + for (const [natType, rules] of Object.entries(nat)) { + nat[natType] = this.uniqueRules(this.firewallRules(rules)); + } + return firewall; + } + + private static vpnSnapshot(...sourcesArg: Array): IOpnsenseVpnSnapshot { + const vpn: IOpnsenseVpnSnapshot = { openvpn: { servers: [], clients: [] }, wireguard: { servers: [], clients: [] } }; + for (const source of sourcesArg) { + if (!source) continue; + for (const vpnType of ['openvpn', 'wireguard'] as const) { + const current = vpn[vpnType] || {}; + const incoming = source[vpnType] || {}; + current.servers = this.uniqueVpnInstances([...this.vpnRecords(current.servers), ...this.vpnRecords(incoming.servers)]); + current.clients = this.uniqueVpnInstances([...this.vpnRecords(current.clients), ...this.vpnRecords(incoming.clients)]); + vpn[vpnType] = current; + } + vpn.metadata = { ...vpn.metadata, ...source.metadata }; + } + return vpn; + } + + private static objectMerge>(...sourcesArg: Array): T { + const output: Record = {}; + for (const source of sourcesArg) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && output[key] && typeof output[key] === 'object' && !Array.isArray(output[key])) { + output[key] = { ...(output[key] as Record), ...(value as Record) }; + } else { + output[key] = value; + } + } + } + return output as T; + } + + private static firewallRules(valueArg: IOpnsenseFirewallSnapshot['rules'] | TOpnsenseNatRules): IOpnsenseFirewallRule[] { + if (!valueArg) return []; + return Array.isArray(valueArg) ? valueArg : Object.values(valueArg).filter((ruleArg): ruleArg is IOpnsenseFirewallRule => Boolean(ruleArg)); + } + + private static firewallAliases(valueArg: IOpnsenseFirewallSnapshot['aliases']): IOpnsenseFirewallAlias[] { + if (!valueArg) return []; + return Array.isArray(valueArg) ? valueArg : Object.values(valueArg).filter((aliasArg): aliasArg is IOpnsenseFirewallAlias => Boolean(aliasArg)); + } + + private static uniqueRules(rulesArg: IOpnsenseFirewallRule[]): IOpnsenseFirewallRule[] { + const seen = new Map(); + for (const rule of rulesArg) { + const key = this.ruleId(rule); + if (!key) continue; + seen.set(key, { ...seen.get(key), ...rule }); + } + return [...seen.values()]; + } + + private static uniqueAliases(aliasesArg: IOpnsenseFirewallAlias[]): IOpnsenseFirewallAlias[] { + const seen = new Map(); + for (const alias of aliasesArg) { + const key = alias.uuid || alias.id || alias.name; + if (!key) continue; + seen.set(key, { ...seen.get(key), ...alias }); + } + return [...seen.values()]; + } + + private static vpnInstances(vpnArg: IOpnsenseVpnSnapshot, typeArg: 'openvpn' | 'wireguard', roleArg: 'servers' | 'clients'): IOpnsenseVpnInstance[] { + return this.vpnRecords(vpnArg[typeArg]?.[roleArg]); + } + + private static vpnRecords(valueArg: IOpnsenseVpnInstance[] | Record | undefined): IOpnsenseVpnInstance[] { + if (!valueArg) return []; + return Array.isArray(valueArg) ? valueArg : Object.entries(valueArg).map(([key, value]) => ({ uuid: value.uuid || value.id || key, ...value })); + } + + private static uniqueVpnInstances(instancesArg: IOpnsenseVpnInstance[]): IOpnsenseVpnInstance[] { + const seen = new Map(); + for (const instance of instancesArg) { + const key = this.vpnId(instance) || instance.name; + if (!key) continue; + seen.set(key, { ...seen.get(key), ...instance }); + } + return [...seen.values()]; + } + + private static ruleId(ruleArg: IOpnsenseFirewallRule): string | undefined { + return this.stringValue(ruleArg.uuid || ruleArg.id); + } + + private static ruleEnabled(ruleArg: IOpnsenseFirewallRule | IOpnsenseFirewallAlias): boolean { + const enabled = this.booleanLike(ruleArg.enabled); + if (enabled !== undefined) return enabled; + const disabled = this.booleanLike(ruleArg.disabled); + if (disabled !== undefined) return !disabled; + return true; + } + + private static serviceName(serviceArg: IOpnsenseServiceInfo): string { + return serviceArg.name || serviceArg.id || serviceArg.displayName || 'service'; + } + + private static serviceDisplayName(serviceArg: IOpnsenseServiceInfo): string { + return serviceArg.displayName || serviceArg.description || serviceArg.name || serviceArg.id || 'Service'; + } + + private static serviceRunning(serviceArg: IOpnsenseServiceInfo): boolean { + return this.booleanLike(serviceArg.status) ?? this.booleanLike(serviceArg.running) ?? this.booleanLike(serviceArg.enabled) ?? false; + } + + private static vpnId(instanceArg: IOpnsenseVpnInstance): string | undefined { + return this.stringValue(instanceArg.uuid || instanceArg.id); + } + + private static vpnName(instanceArg: IOpnsenseVpnInstance): string { + return instanceArg.name || instanceArg.description || this.vpnId(instanceArg) || 'VPN'; + } + + private static interfaceUp(ifaceArg: IOpnsenseInterfaceInfo): boolean { + if (ifaceArg.connected !== undefined) return ifaceArg.connected; + const enabled = this.booleanLike(ifaceArg.enabled); + const status = this.stringValue(ifaceArg.status)?.toLowerCase(); + if (status) return enabled !== false && ['up', 'associated', 'online'].includes(status); + return enabled !== false; + } + + private static gatewayUp(gatewayArg: IOpnsenseGatewayInfo): boolean { + const disabled = this.booleanLike(gatewayArg.disabled); + const status = this.stringValue(gatewayArg.status)?.toLowerCase(); + if (disabled === true) return false; + return !status || ['online', 'up', 'none'].includes(status); + } + + private static trackerInterfaces(configArg: IOpnsenseConfig, sourceArg?: IOpnsenseSnapshot): string[] { + const fromConfig = configArg.trackerInterfaces || configArg.tracker_interfaces; + if (Array.isArray(fromConfig)) return fromConfig.filter((valueArg): valueArg is string => typeof valueArg === 'string' && valueArg.trim().length > 0); + const fromMetadata = sourceArg?.metadata?.trackerInterfaces; + return Array.isArray(fromMetadata) ? fromMetadata.filter((valueArg): valueArg is string => typeof valueArg === 'string' && valueArg.trim().length > 0) : []; + } + + private static pendingNotices(systemArg: IOpnsenseSystemInfo): boolean | undefined { + return this.booleanLike(systemArg.pendingNoticesPresent) ?? this.booleanLike(systemArg.pending_notices_present); + } + + private static hasFirmwareUpdate(snapshotArg: IOpnsenseSnapshot): boolean { + return Boolean(snapshotArg.router.latestFirmware || snapshotArg.system.productLatest || snapshotArg.router.updateAvailable !== undefined || snapshotArg.system.updateAvailable !== undefined); + } + + private static updateAvailable(snapshotArg: IOpnsenseSnapshot): boolean { + return Boolean(snapshotArg.router.updateAvailable ?? snapshotArg.system.updateAvailable); + } + + private static routerDeviceId(snapshotArg: IOpnsenseSnapshot): string { + return `${opnsenseDomain}.router.${this.uniqueBase(snapshotArg)}`; + } + + private static clientDeviceId(clientArg: IOpnsenseClientDevice): string { + return `${opnsenseDomain}.client.${this.slug(this.clientMac(clientArg) || clientArg.id || clientArg.ipAddress || clientArg.ip || clientArg.address || this.clientName(clientArg))}`; + } + + private static routerName(snapshotArg: IOpnsenseSnapshot): string { + return snapshotArg.router.name || snapshotArg.system.name || snapshotArg.system.hostname || snapshotArg.router.host || 'OPNsense'; + } + + private static clientName(clientArg: IOpnsenseClientDevice): string { + return clientArg.name || clientArg.hostname || clientArg.macAddress || clientArg.mac || clientArg.ipAddress || clientArg.ip || clientArg.address || 'Unknown device'; + } + + private static clientMac(clientArg: IOpnsenseClientDevice): string | undefined { + return this.normalizeMac(clientArg.macAddress || clientArg.mac); + } + + private static uniqueBase(snapshotArg: IOpnsenseSnapshot): string { + return this.slug(snapshotArg.router.macAddress || snapshotArg.router.serialNumber || snapshotArg.router.id || snapshotArg.router.host || this.routerName(snapshotArg)); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record = {}, availableArg = true, explicitIdArg?: string): IIntegrationEntity { + const baseId = explicitIdArg || `${platformArg}.${this.slug(nameArg)}`; + const used = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, used + 1); + return { + id: used ? `${baseId}_${used + 1}` : baseId, + uniqueId: `${opnsenseDomain}_${this.slug(uniqueIdArg)}`, + integrationDomain: opnsenseDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: this.entityState(stateArg), + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static addFeatureState(featuresArg: plugins.shxInterfaces.data.IDeviceFeature[], stateArg: plugins.shxInterfaces.data.IDeviceState[], idArg: string, nameArg: string, valueArg: unknown, updatedAtArg: string, unitArg?: string): void { + if (valueArg === undefined) return; + featuresArg.push({ id: idArg, capability: 'sensor', name: nameArg, readable: true, writable: false, unit: unitArg }); + stateArg.push({ featureId: idArg, value: this.deviceStateValue(valueArg), updatedAt: updatedAtArg }); + } + + private static entityState(valueArg: unknown): unknown { + if (typeof valueArg === 'boolean') return valueArg ? 'on' : 'off'; + return valueArg; + } + + private static sensorValue(valueArg: unknown): unknown { + if (valueArg instanceof Date) return valueArg.toISOString(); + return valueArg; + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean' || valueArg === null) return valueArg; + if (valueArg && typeof valueArg === 'object') return valueArg as Record; + return null; + } + + private static bytesToGigabytes(valueArg: number | undefined): number | undefined { + return valueArg === undefined ? undefined : valueArg / 1000000000; + } + + private static parseEndpoint(valueArg: string | undefined): { host?: string; port?: number } { + if (!valueArg) return {}; + try { + const parsed = new URL(valueArg.includes('://') ? valueArg : `https://${valueArg}`); + return { host: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined }; + } catch { + return {}; + } + } + + private static endpointUrl(hostArg: string, portArg: number | undefined): string { + const port = portArg || opnsenseDefaultPort; + return `https://${hostArg}${port === opnsenseDefaultPort ? '' : `:${port}`}`; + } + + private static cleanAttributes>(attributesArg: T): T { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return valueArg; + if (typeof valueArg === 'string' && valueArg.trim()) { + const cleaned = valueArg.replace(/[^0-9.+-]/g, ''); + const parsed = Number(cleaned); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private static booleanLike(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') return valueArg; + if (typeof valueArg === 'number') return valueArg === 1 ? true : valueArg === 0 ? false : undefined; + if (typeof valueArg === 'string') { + const normalized = valueArg.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on', 'enabled', 'running', 'up', 'ok', 'online'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off', 'disabled', 'stopped', 'down', 'failed', 'offline'].includes(normalized)) return false; + } + return undefined; + } + + private static methodValue(valueArg: unknown): 'GET' | 'POST' | undefined { + return valueArg === 'GET' || valueArg === 'POST' ? valueArg : undefined; + } + + private static recordValue(valueArg: unknown): Record | undefined { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; + } + + private static dateString(valueArg: IOpnsenseClientDevice['lastActivity']): string | undefined { + if (valueArg instanceof Date) return valueArg.toISOString(); + if (typeof valueArg === 'number') return new Date(valueArg).toISOString(); + return typeof valueArg === 'string' ? valueArg : undefined; + } + + private static title(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (matchArg) => matchArg.toUpperCase()); + } +} diff --git a/ts/integrations/opnsense/opnsense.types.ts b/ts/integrations/opnsense/opnsense.types.ts index 14bb6a4..f4f5690 100644 --- a/ts/integrations/opnsense/opnsense.types.ts +++ b/ts/integrations/opnsense/opnsense.types.ts @@ -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; [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; +} + +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; + [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; + [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; + [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; + [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; + [key: string]: unknown; +} + +export interface IOpnsenseFirewallSnapshot { + rules?: IOpnsenseFirewallRule[] | Record; + nat?: Record | undefined>; + aliases?: IOpnsenseFirewallAlias[] | Record; + state?: { + used?: number; + total?: number; + usedPercent?: number; + used_percent?: number; + [key: string]: unknown; + }; + metadata?: Record; + [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; + certificates?: Record; + speedtest?: Record; + metadata?: Record; + [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; + memory?: Record; + mbuf?: Record; + pfstate?: Record; + system?: Record; + filesystems?: Array>; + temps?: Record>; + vnstat?: Record; + speedtest?: Record; + metadata?: Record; + [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; + [key: string]: unknown; +} + +export interface IOpnsenseVpnSnapshot { + openvpn?: { + servers?: IOpnsenseVpnInstance[] | Record; + clients?: IOpnsenseVpnInstance[] | Record; + [key: string]: unknown; + }; + wireguard?: { + servers?: IOpnsenseVpnInstance[] | Record; + clients?: IOpnsenseVpnInstance[] | Record; + [key: string]: unknown; + }; + metadata?: Record; + [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>; + servers?: Array>; + metadata?: Record; + [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; + metadata?: Record; + [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; + label?: string; + metadata?: Record; +} + +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; +} + +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; + 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; + 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; + executeCommand?(commandArg: IOpnsenseCommand): Promise | IOpnsenseCommandResult | unknown; + destroy?(): Promise | void; +} + +export type TOpnsenseSnapshotProvider = () => Promise | IOpnsenseSnapshot | undefined; +export type TOpnsenseCommandExecutor = ( + commandArg: IOpnsenseCommand +) => Promise | IOpnsenseCommandResult | unknown; diff --git a/ts/integrations/pi_hole/.generated-by-smarthome-exchange b/ts/integrations/pi_hole/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/pi_hole/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/pi_hole/index.ts b/ts/integrations/pi_hole/index.ts index 96cbf77..04ee28e 100644 --- a/ts/integrations/pi_hole/index.ts +++ b/ts/integrations/pi_hole/index.ts @@ -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'; diff --git a/ts/integrations/pi_hole/pi_hole.classes.client.ts b/ts/integrations/pi_hole/pi_hole.classes.client.ts new file mode 100644 index 0000000..1f6f44a --- /dev/null +++ b/ts/integrations/pi_hole/pi_hole.classes.client.ts @@ -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 { + 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 }> { + 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 { + if (this.hasManualData()) { + return true; + } + if (!this.config.host) { + return false; + } + return (await this.refresh()).success; + } + + public async sendCommand(commandArg: IPiHoleClientCommand): Promise { + 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 { + if (this.sessionId) { + await this.logoutV6().catch(() => undefined); + } + } + + public async fetchSnapshot(): Promise { + 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 { + const [summary, versions] = await Promise.all([ + this.requestV5('summaryRaw'), + this.requestV5('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 { + await this.ensureV6Auth(); + const [summary, blocking, versions] = await Promise.all([ + this.requestV6('/api/stats/summary'), + this.requestV6('/api/dns/blocking'), + this.requestV6('/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 { + 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 { + 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(query, false); + this.currentSnapshot = await this.fetchV5Snapshot().catch(() => this.currentSnapshot); + return response; + } + + private async setV6Blocking(enabledArg: boolean, durationSecondsArg?: number): Promise { + await this.ensureV6Auth(); + const response = await this.requestV6('/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(queryArg: string, appendAuthArg = true): Promise { + 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(String(url), { method: 'GET' }); + } + + private async requestV6(pathArg: string, initArg: RequestInit = {}, retryArg = true): Promise { + const headers: Record = { + ...(initArg.headers as Record | undefined), + }; + if (this.sessionId) { + headers['X-FTL-SID'] = this.sessionId; + if (this.csrfToken) { + headers['X-FTL-CSRF'] = this.csrfToken; + } + } + + try { + return await this.requestJson(`${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(pathArg, initArg, false); + } + throw errorArg; + } + } + + private async ensureV6Auth(): Promise { + 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 { + 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 { + if (!this.sessionId) { + return; + } + await this.requestJson(`${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(urlArg: string, initArg: RequestInit): Promise { + 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; + } +} diff --git a/ts/integrations/pi_hole/pi_hole.classes.configflow.ts b/ts/integrations/pi_hole/pi_hole.classes.configflow.ts new file mode 100644 index 0000000..3370531 --- /dev/null +++ b/ts/integrations/pi_hole/pi_hole.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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): Promise> { + 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 | undefined { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record : 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]; +}; diff --git a/ts/integrations/pi_hole/pi_hole.classes.integration.ts b/ts/integrations/pi_hole/pi_hole.classes.integration.ts index 0103b9b..33f2b2f 100644 --- a/ts/integrations/pi_hole/pi_hole.classes.integration.ts +++ b/ts/integrations/pi_hole/pi_hole.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new PiHoleRuntime(new PiHoleClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantPiHoleIntegration extends PiHoleIntegration {} + +class PiHoleRuntime implements IIntegrationRuntime { + public domain = piHoleDomain; + + constructor(private readonly client: PiHoleClient) {} + + public async devices(): Promise { + return PiHoleMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return PiHoleMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/pi_hole/pi_hole.discovery.ts b/ts/integrations/pi_hole/pi_hole.discovery.ts new file mode 100644 index 0000000..74d45c5 --- /dev/null +++ b/ts/integrations/pi_hole/pi_hole.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 }): 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): Record => { + const normalized: Record = {}; + 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; +}; diff --git a/ts/integrations/pi_hole/pi_hole.mapper.ts b/ts/integrations/pi_hole/pi_hole.mapper.ts new file mode 100644 index 0000000..336994e --- /dev/null +++ b/ts/integrations/pi_hole/pi_hole.mapper.ts @@ -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 { + 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 { + 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 = {}): 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 { + 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 { + 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 | undefined { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg) ? valueArg as Record : 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>(attributesArg: T): T { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T; + } +} diff --git a/ts/integrations/pi_hole/pi_hole.types.ts b/ts/integrations/pi_hole/pi_hole.types.ts index 6b16ca1..92e2d6c 100644 --- a/ts/integrations/pi_hole/pi_hole.types.ts +++ b/ts/integrations/pi_hole/pi_hole.types.ts @@ -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; + clients?: Record; + gravity?: Record; + [key: string]: TPiHoleJsonValue | Record | 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; + payload?: Record; + 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; + +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; + versions?: Partial; + online?: boolean; + commandExecutor?: TPiHoleCommandExecutor; + metadata?: Record; + [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; + versions?: Partial; + metadata?: Record; + [key: string]: unknown; +} + +export interface IPiHoleHttpCandidateRecord { + url?: string; + location?: string; + host?: string; + port?: number; + ssl?: boolean; + name?: string; + manufacturer?: string; + model?: string; + headers?: Record; + metadata?: Record; +} + +export interface IHomeAssistantPiHoleConfig extends IPiHoleConfig {} diff --git a/ts/integrations/squeezebox/.generated-by-smarthome-exchange b/ts/integrations/squeezebox/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/squeezebox/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/squeezebox/index.ts b/ts/integrations/squeezebox/index.ts index cde38a9..e91a340 100644 --- a/ts/integrations/squeezebox/index.ts +++ b/ts/integrations/squeezebox/index.ts @@ -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'; diff --git a/ts/integrations/squeezebox/squeezebox.classes.client.ts b/ts/integrations/squeezebox/squeezebox.classes.client.ts new file mode 100644 index 0000000..e212171 --- /dev/null +++ b/ts/integrations/squeezebox/squeezebox.classes.client.ts @@ -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 { + 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 { + 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 { + 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> { + return this.playerQuery(playerIdArg, commandArg); + } + + public async snapshot(): Promise { + this.restorePoint = await this.getSnapshot(); + return this.cloneSnapshot(this.restorePoint); + } + + public async restore(snapshotArg = this.restorePoint): Promise { + 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 {} + + private async fetchSnapshot(): Promise { + 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): Promise { + 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 { + 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> { + const response = await this.playerQuery(playerIdArg, ['status', '-', '1', 'tags:adKlJytxN']); + return this.playerFromData({ ...response, playerid: playerIdArg }); + } + + private async fetchFavorites(): Promise { + 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 { + 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 { + 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 { + 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> { + 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> { + 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 = { + 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 { + 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((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): 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): 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 { + 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, 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, 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(); + 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): 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): 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, commandArg: string[]): Record { + 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 { + 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 = {}; + 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 { + 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 | 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[] => { + if (!Array.isArray(valueArg)) { + return []; + } + return valueArg.filter((itemArg): itemArg is Record => Boolean(itemArg && typeof itemArg === 'object' && !Array.isArray(itemArg))); +}; + +const recordValue = (valueArg: unknown): Record => { + if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) { + return valueArg as Record; + } + return valueArg === undefined ? {} : { value: valueArg }; +}; + +const addRecordValue = (recordArg: Record, 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'; diff --git a/ts/integrations/squeezebox/squeezebox.classes.configflow.ts b/ts/integrations/squeezebox/squeezebox.classes.configflow.ts new file mode 100644 index 0000000..2f26054 --- /dev/null +++ b/ts/integrations/squeezebox/squeezebox.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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; + } +} diff --git a/ts/integrations/squeezebox/squeezebox.classes.integration.ts b/ts/integrations/squeezebox/squeezebox.classes.integration.ts index d783556..79f3367 100644 --- a/ts/integrations/squeezebox/squeezebox.classes.integration.ts +++ b/ts/integrations/squeezebox/squeezebox.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new SqueezeboxRuntime(new SqueezeboxClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantSqueezeboxIntegration extends SqueezeboxIntegration {} + +class SqueezeboxRuntime implements IIntegrationRuntime { + public domain = 'squeezebox'; + + constructor(private readonly client: SqueezeboxClient) {} + + public async devices(): Promise { + return SqueezeboxMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return SqueezeboxMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + 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 { + await this.client.destroy(); + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } + + 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; } } diff --git a/ts/integrations/squeezebox/squeezebox.discovery.ts b/ts/integrations/squeezebox/squeezebox.discovery.ts new file mode 100644 index 0000000..0cf2c62 --- /dev/null +++ b/ts/integrations/squeezebox/squeezebox.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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, ''); diff --git a/ts/integrations/squeezebox/squeezebox.mapper.ts b/ts/integrations/squeezebox/squeezebox.mapper.ts new file mode 100644 index 0000000..5e8e022 --- /dev/null +++ b/ts/integrations/squeezebox/squeezebox.mapper.ts @@ -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(); + 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)); diff --git a/ts/integrations/squeezebox/squeezebox.types.ts b/ts/integrations/squeezebox/squeezebox.types.ts index 01fc782..dd1aed8 100644 --- a/ts/integrations/squeezebox/squeezebox.types.ts +++ b/ts/integrations/squeezebox/squeezebox.types.ts @@ -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 | unknown>; +} + +export interface ISqueezeboxRawCommandRequest { + transport: Exclude; + 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> { + 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; +} + +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; +} + +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; +} + +export interface ISqueezeboxTrack { + id?: string | number; + url?: string; + title?: string; + artist?: string; + album?: string; + duration?: number; + remoteTitle?: string; + imageUrl?: string; + raw?: Record; +} + +export interface ISqueezeboxAlarm { + id: string; + enabled?: boolean; + time?: string; + repeat?: boolean; + scheduledToday?: boolean; + daysOfWeek?: number[]; + volume?: number; + url?: string; + raw?: Record; +} + +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; +} + +export interface ISqueezeboxFavorite { + id: string; + name: string; + type?: string; + url?: string; + itemId?: string; + imageUrl?: string; + playable?: boolean; + raw?: Record; +} + +export interface ISqueezeboxSyncGroup { + id: string; + name?: string; + playerIds: string[]; + leaderPlayerId?: string; + raw?: Record; +} + +export interface ISqueezeboxSnapshot { + server: ISqueezeboxServerInfo; + players: ISqueezeboxPlayer[]; + favorites?: ISqueezeboxFavorite[]; + syncGroups?: ISqueezeboxSyncGroup[]; + online: boolean; + updatedAt?: string; + source?: TSqueezeboxSnapshotSource; + error?: string; + raw?: Record; +} + +export interface ISqueezeboxMdnsRecord { + name?: string; + type?: string; + serviceType?: string; + host?: string; + hostname?: string; + port?: number; + addresses?: string[]; + txt?: Record; + properties?: Record; +} + +export interface ISqueezeboxDhcpRecord { + hostname?: string; + macaddress?: string; + macAddress?: string; + ipAddress?: string; + host?: string; + name?: string; + metadata?: Record; +} + +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; } diff --git a/ts/integrations/synology_dsm/.generated-by-smarthome-exchange b/ts/integrations/synology_dsm/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/synology_dsm/.generated-by-smarthome-exchange +++ /dev/null @@ -1 +0,0 @@ -This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support. diff --git a/ts/integrations/synology_dsm/index.ts b/ts/integrations/synology_dsm/index.ts index 481294c..868cf02 100644 --- a/ts/integrations/synology_dsm/index.ts +++ b/ts/integrations/synology_dsm/index.ts @@ -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'; diff --git a/ts/integrations/synology_dsm/synology_dsm.classes.client.ts b/ts/integrations/synology_dsm/synology_dsm.classes.client.ts new file mode 100644 index 0000000..c8f6bd2 --- /dev/null +++ b/ts/integrations/synology_dsm/synology_dsm.classes.client.ts @@ -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(); + + constructor(private readonly config: ISynologyDsmConfig) {} + + public async getSnapshot(): Promise { + 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 { + 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 { + 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 { + 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(snapshotArg: T): T { + return JSON.parse(JSON.stringify(snapshotArg)) as T; + } +} diff --git a/ts/integrations/synology_dsm/synology_dsm.classes.configflow.ts b/ts/integrations/synology_dsm/synology_dsm.classes.configflow.ts new file mode 100644 index 0000000..9aba627 --- /dev/null +++ b/ts/integrations/synology_dsm/synology_dsm.classes.configflow.ts @@ -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 { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + 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): Promise> { + 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; + } +} diff --git a/ts/integrations/synology_dsm/synology_dsm.classes.integration.ts b/ts/integrations/synology_dsm/synology_dsm.classes.integration.ts index a59920c..c504cae 100644 --- a/ts/integrations/synology_dsm/synology_dsm.classes.integration.ts +++ b/ts/integrations/synology_dsm/synology_dsm.classes.integration.ts @@ -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 { + 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 { + void contextArg; + return new SynologyDsmRuntime(new SynologyDsmClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantSynologyDsmIntegration extends SynologyDsmIntegration {} + +class SynologyDsmRuntime implements IIntegrationRuntime { + public domain = synologyDsmDomain; + + constructor(private readonly client: SynologyDsmClient) {} + + public async devices(): Promise { + return SynologyDsmMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return SynologyDsmMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + 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 { + 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 { + await this.client.destroy(); } } diff --git a/ts/integrations/synology_dsm/synology_dsm.discovery.ts b/ts/integrations/synology_dsm/synology_dsm.discovery.ts new file mode 100644 index 0000000..429f01d --- /dev/null +++ b/ts/integrations/synology_dsm/synology_dsm.discovery.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Record => { + const normalized: Record = {}; + 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(); diff --git a/ts/integrations/synology_dsm/synology_dsm.mapper.ts b/ts/integrations/synology_dsm/synology_dsm.mapper.ts new file mode 100644 index 0000000..ccda76e --- /dev/null +++ b/ts/integrations/synology_dsm/synology_dsm.mapper.ts @@ -0,0 +1,1080 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + ISynologyDsmActionDescriptor, + ISynologyDsmCameraInfo, + ISynologyDsmCommand, + ISynologyDsmConfig, + ISynologyDsmDiskInfo, + ISynologyDsmEvent, + ISynologyDsmNetworkInfo, + ISynologyDsmSnapshot, + ISynologyDsmStorageInfo, + ISynologyDsmSwitchInfo, + ISynologyDsmSystemInfo, + ISynologyDsmUpdateInfo, + ISynologyDsmUtilizationInfo, + ISynologyDsmVolumeInfo, + TSynologyDsmCommandAction, +} from './synology_dsm.types.js'; +import { synologyDsmDefaultPort, synologyDsmDefaultSsl, synologyDsmDefaultSslPort, synologyDsmDomain } from './synology_dsm.types.js'; + +type TSynologyDsmSensorDescriptor> = { + sourceKey: keyof TSource & string; + nativeKey: string; + name: string; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + enabledByDefault?: boolean; + transform?: (valueArg: unknown) => unknown; +}; + +const manufacturer = 'Synology'; + +const utilizationSensorDescriptors: TSynologyDsmSensorDescriptor[] = [ + { sourceKey: 'cpuOtherLoad', nativeKey: 'cpu_other_load', name: 'CPU other load', unit: '%', stateClass: 'measurement', enabledByDefault: false }, + { sourceKey: 'cpuUserLoad', nativeKey: 'cpu_user_load', name: 'CPU user load', unit: '%', stateClass: 'measurement' }, + { sourceKey: 'cpuSystemLoad', nativeKey: 'cpu_system_load', name: 'CPU system load', unit: '%', stateClass: 'measurement', enabledByDefault: false }, + { sourceKey: 'cpuTotalLoad', nativeKey: 'cpu_total_load', name: 'CPU total load', unit: '%', stateClass: 'measurement' }, + { sourceKey: 'cpu1MinLoad', nativeKey: 'cpu_1min_load', name: 'CPU 1 minute load', unit: 'load', enabledByDefault: false, transform: (valueArg) => SynologyDsmMapper.loadValue(valueArg) }, + { sourceKey: 'cpu5MinLoad', nativeKey: 'cpu_5min_load', name: 'CPU 5 minute load', unit: 'load', transform: (valueArg) => SynologyDsmMapper.loadValue(valueArg) }, + { sourceKey: 'cpu15MinLoad', nativeKey: 'cpu_15min_load', name: 'CPU 15 minute load', unit: 'load', transform: (valueArg) => SynologyDsmMapper.loadValue(valueArg) }, + { sourceKey: 'memoryRealUsage', nativeKey: 'memory_real_usage', name: 'Memory real usage', unit: '%', stateClass: 'measurement' }, + { sourceKey: 'memorySize', nativeKey: 'memory_size', name: 'Memory size', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement', enabledByDefault: false }, + { sourceKey: 'memoryCached', nativeKey: 'memory_cached', name: 'Memory cached', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement', enabledByDefault: false }, + { sourceKey: 'memoryAvailableSwap', nativeKey: 'memory_available_swap', name: 'Memory available swap', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement' }, + { sourceKey: 'memoryAvailableReal', nativeKey: 'memory_available_real', name: 'Memory available real', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement' }, + { sourceKey: 'memoryTotalSwap', nativeKey: 'memory_total_swap', name: 'Memory total swap', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement' }, + { sourceKey: 'memoryTotalReal', nativeKey: 'memory_total_real', name: 'Memory total real', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement' }, + { sourceKey: 'networkUp', nativeKey: 'network_up', name: 'Network up', unit: 'B/s', deviceClass: 'data_rate', stateClass: 'measurement' }, + { sourceKey: 'networkDown', nativeKey: 'network_down', name: 'Network down', unit: 'B/s', deviceClass: 'data_rate', stateClass: 'measurement' }, +]; + +const volumeSensorDescriptors: TSynologyDsmSensorDescriptor[] = [ + { sourceKey: 'status', nativeKey: 'volume_status', name: 'Status' }, + { sourceKey: 'sizeTotal', nativeKey: 'volume_size_total', name: 'Size total', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement', enabledByDefault: false }, + { sourceKey: 'sizeUsed', nativeKey: 'volume_size_used', name: 'Size used', unit: 'B', deviceClass: 'data_size', stateClass: 'measurement' }, + { sourceKey: 'percentageUsed', nativeKey: 'volume_percentage_used', name: 'Percentage used', unit: '%', stateClass: 'measurement' }, + { sourceKey: 'diskTempAvg', nativeKey: 'volume_disk_temp_avg', name: 'Disk temperature average', unit: 'C', deviceClass: 'temperature', entityCategory: 'diagnostic' }, + { sourceKey: 'diskTempMax', nativeKey: 'volume_disk_temp_max', name: 'Disk temperature maximum', unit: 'C', deviceClass: 'temperature', entityCategory: 'diagnostic', enabledByDefault: false }, +]; + +const diskSensorDescriptors: TSynologyDsmSensorDescriptor[] = [ + { sourceKey: 'smartStatus', nativeKey: 'disk_smart_status', name: 'S.M.A.R.T. status', entityCategory: 'diagnostic', enabledByDefault: false }, + { sourceKey: 'status', nativeKey: 'disk_status', name: 'Status', entityCategory: 'diagnostic' }, + { sourceKey: 'temperature', nativeKey: 'disk_temp', name: 'Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, +]; + +export class SynologyDsmMapper { + public static toSnapshot(configArg: ISynologyDsmConfig, connectedArg?: boolean, eventsArg: ISynologyDsmEvent[] = []): ISynologyDsmSnapshot { + const source = configArg.snapshot; + const sourceRecord = this.recordValue(source); + const sourceStorage = this.recordValue(source?.storage); + const configStorage = this.recordValue(configArg.storage); + const sourceSurveillance = this.recordValue(sourceRecord?.surveillance); + const configSurveillance = this.recordValue(configArg.surveillance); + const system = this.systemInfo(configArg, source); + const network = this.networkInfo(configArg, source, system); + const utilization = this.utilizationInfo(configArg, source, network); + const volumes = this.uniqueVolumes([ + ...this.arrayValues(sourceStorage?.volumes), + ...this.arrayValues(sourceRecord?.volumes), + ...this.arrayValues(configStorage?.volumes), + ...this.arrayValues(configArg.volumes), + ]); + const disks = this.uniqueDisks([ + ...this.arrayValues(sourceStorage?.disks), + ...this.arrayValues(sourceRecord?.disks), + ...this.arrayValues(configStorage?.disks), + ...this.arrayValues(configArg.disks), + ]); + const storage: ISynologyDsmStorageInfo = { + ...(source?.storage || {}), + ...(configArg.storage || {}), + volumes, + disks, + }; + const cameras = this.uniqueCameras([ + ...this.arrayValues(source?.cameras), + ...this.arrayValues(sourceSurveillance?.cameras), + ...this.arrayValues(configArg.cameras), + ...this.arrayValues(configSurveillance?.cameras), + ]); + const switches = this.uniqueSwitches([ + ...this.switchValues(source?.switches), + ...this.switchValues(sourceRecord?.switches), + ...this.switchValues(configArg.switches), + ...this.homeModeSwitchValues(sourceSurveillance, configSurveillance), + ]); + const update = this.updateInfo([source?.update, sourceRecord?.update, configArg.update], system); + const security = this.securityInfo(source?.security, configArg.security); + const actions = this.uniqueActions([ + ...(source?.actions || []), + ...(configArg.actions || []), + ...this.actionsFromSystem(system), + ...this.actionsFromSwitches(switches), + ...this.actionsFromCameras(cameras), + ]); + const hasManualData = this.hasManualData(configArg, source, volumes, disks, cameras, switches, update, security); + + return { + connected: connectedArg ?? configArg.online ?? configArg.connected ?? source?.connected ?? hasManualData, + source: source?.source || (source ? 'snapshot' : hasManualData ? 'manual' : 'runtime'), + updatedAt: source?.updatedAt || new Date().toISOString(), + system, + utilization, + storage, + network, + cameras, + update, + switches, + security, + actions, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + error: source?.error, + metadata: this.cleanAttributes({ + ...source?.metadata, + ...configArg.metadata, + liveHttpImplemented: false, + commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand), + }), + }; + } + + public static toDevices(snapshotArg: ISynologyDsmSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const devices: plugins.shxInterfaces.data.IDeviceDefinition[] = [this.nasDevice(snapshotArg, updatedAt)]; + for (const volume of snapshotArg.storage.volumes) { + devices.push(this.volumeDevice(snapshotArg, volume, updatedAt)); + } + for (const disk of snapshotArg.storage.disks) { + devices.push(this.diskDevice(snapshotArg, disk, updatedAt)); + } + for (const camera of snapshotArg.cameras) { + devices.push(this.cameraDevice(snapshotArg, camera, updatedAt)); + } + return devices; + } + + public static toEntities(snapshotArg: ISynologyDsmSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const nasDeviceId = this.nasDeviceId(snapshotArg); + const nasName = this.nasName(snapshotArg); + const uniqueBase = this.uniqueBase(snapshotArg); + + entities.push(this.entity('binary_sensor', `${nasName} Integration connected`, nasDeviceId, `${uniqueBase}_integration_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: snapshotArg.system.host, + port: snapshotArg.system.port, + ssl: snapshotArg.system.ssl, + }, true)); + + if (snapshotArg.system.temperature !== undefined) { + entities.push(this.entity('sensor', `${nasName} Temperature`, nasDeviceId, `${uniqueBase}_temperature`, snapshotArg.system.temperature, usedIds, { + nativeKey: 'temperature', + unit: 'C', + deviceClass: 'temperature', + stateClass: 'measurement', + entityCategory: 'diagnostic', + }, snapshotArg.connected)); + } + + const uptime = this.uptimeValue(snapshotArg.system.uptimeSeconds); + if (uptime !== undefined) { + entities.push(this.entity('sensor', `${nasName} Uptime`, nasDeviceId, `${uniqueBase}_uptime`, uptime, usedIds, { + nativeKey: 'uptime', + deviceClass: 'timestamp', + entityCategory: 'diagnostic', + enabledByDefault: false, + }, snapshotArg.connected)); + } + + for (const descriptor of utilizationSensorDescriptors) { + const rawValue = snapshotArg.utilization[descriptor.sourceKey]; + const value = descriptor.transform ? descriptor.transform(rawValue) : rawValue; + if (value === undefined || value === null) { + continue; + } + entities.push(this.entity('sensor', `${nasName} ${descriptor.name}`, nasDeviceId, `${uniqueBase}_${descriptor.nativeKey}`, value, usedIds, { + nativeKey: descriptor.nativeKey, + unit: descriptor.unit, + unitOfMeasurement: descriptor.unit, + deviceClass: descriptor.deviceClass, + stateClass: descriptor.stateClass, + entityCategory: descriptor.entityCategory, + enabledByDefault: descriptor.enabledByDefault, + }, snapshotArg.connected)); + } + + if (snapshotArg.security?.status) { + entities.push(this.entity('binary_sensor', `${nasName} Security status`, nasDeviceId, `${uniqueBase}_security_status`, snapshotArg.security.status === 'safe' ? 'off' : 'on', usedIds, { + nativeKey: 'status', + deviceClass: 'safety', + entityCategory: 'diagnostic', + status: snapshotArg.security.status, + statusByCheck: snapshotArg.security.statusByCheck, + }, snapshotArg.connected)); + } + + for (const volume of snapshotArg.storage.volumes) { + this.pushVolumeEntities(entities, snapshotArg, volume, usedIds); + } + for (const disk of snapshotArg.storage.disks) { + this.pushDiskEntities(entities, snapshotArg, disk, usedIds); + } + for (const camera of snapshotArg.cameras) { + this.pushCameraEntities(entities, snapshotArg, camera, usedIds); + } + for (const switchState of snapshotArg.switches) { + this.pushSwitchEntity(entities, snapshotArg, switchState, usedIds); + } + if (snapshotArg.update) { + entities.push(this.updateEntity(snapshotArg, usedIds)); + } + for (const action of this.snapshotActions(snapshotArg).filter((actionArg) => actionArg.target === 'system')) { + entities.push(this.actionButton(snapshotArg, action, usedIds)); + } + + return entities; + } + + public static commandForService(snapshotArg: ISynologyDsmSnapshot, requestArg: IServiceCallRequest): ISynologyDsmCommand | undefined { + if (requestArg.domain === synologyDsmDomain && (requestArg.service === 'reboot' || requestArg.service === 'shutdown')) { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'system' && actionArg.action === requestArg.service); + return action ? this.command(snapshotArg, requestArg, action) : undefined; + } + + if (requestArg.domain === synologyDsmDomain && requestArg.service === 'set_home_mode' && typeof requestArg.data?.enabled === 'boolean') { + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'switch' && actionArg.action === 'set_home_mode'); + return action ? this.command(snapshotArg, requestArg, action, undefined, { enabled: requestArg.data.enabled }) : undefined; + } + + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + if (requestArg.domain === 'button' && requestArg.service === 'press') { + const nativeAction = this.stringValue(targetEntity?.attributes?.nativeAction) as TSynologyDsmCommandAction | undefined; + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'system' && actionArg.action === nativeAction); + return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined; + } + + if (requestArg.domain === 'switch' && (requestArg.service === 'turn_on' || requestArg.service === 'turn_off')) { + const enabled = requestArg.service === 'turn_on'; + const switchKey = this.stringValue(targetEntity?.attributes?.switchKey); + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'switch' && actionArg.action === 'set_home_mode' && (!switchKey || actionArg.switchKey === switchKey)); + return action ? this.command(snapshotArg, requestArg, action, targetEntity, { enabled }) : undefined; + } + + if (requestArg.domain === 'camera' && (requestArg.service === 'enable_motion_detection' || requestArg.service === 'disable_motion_detection')) { + const cameraId = this.stringValue(requestArg.data?.cameraId) || this.stringValue(targetEntity?.attributes?.cameraId) || this.cameraIdFromTarget(snapshotArg, requestArg); + const expectedAction = requestArg.service === 'enable_motion_detection' ? 'enable_camera_motion_detection' : 'disable_camera_motion_detection'; + const action = this.snapshotActions(snapshotArg).find((actionArg) => actionArg.target === 'camera' && actionArg.action === expectedAction && (!cameraId || actionArg.cameraId === cameraId)); + return action ? this.command(snapshotArg, requestArg, action, targetEntity, { cameraId }) : undefined; + } + + return undefined; + } + + public static loadValue(valueArg: unknown): unknown { + if (typeof valueArg === 'number' && Number.isInteger(valueArg)) { + return Math.round((valueArg / 100) * 100) / 100; + } + return valueArg; + } + + public static defaultPort(sslArg: boolean | undefined): number { + return sslArg === false ? synologyDsmDefaultPort : synologyDsmDefaultSslPort; + } + + public static normalizeMac(valueArg: string | undefined): string | undefined { + if (!valueArg) { + return undefined; + } + const compact = valueArg.toLowerCase().replace(/[^a-f0-9]/g, ''); + if (compact.length !== 12) { + return valueArg.toLowerCase(); + } + return compact.match(/.{1,2}/g)?.join(':'); + } + + private static nasDevice(snapshotArg: ISynologyDsmSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + return { + id: this.nasDeviceId(snapshotArg), + integrationDomain: synologyDsmDomain, + name: this.nasName(snapshotArg), + protocol: snapshotArg.system.host ? 'http' : 'unknown', + manufacturer, + model: snapshotArg.system.model || 'Synology NAS', + online: snapshotArg.connected, + features: [ + { id: 'connection', capability: 'sensor', name: 'Connection', readable: true, writable: false }, + { id: 'temperature', capability: 'sensor', name: 'Temperature', readable: true, writable: false, unit: 'C' }, + { id: 'version', capability: 'sensor', name: 'DSM version', readable: true, writable: false }, + ...snapshotArg.switches.map((switchArg) => ({ id: switchArg.key, capability: 'switch' as const, name: switchArg.name || switchArg.key, readable: true, writable: true })), + ], + state: [ + { featureId: 'connection', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + { featureId: 'temperature', value: snapshotArg.system.temperature ?? null, updatedAt: updatedAtArg }, + { featureId: 'version', value: snapshotArg.system.versionString || snapshotArg.system.version || null, updatedAt: updatedAtArg }, + ...snapshotArg.switches.map((switchArg) => ({ featureId: switchArg.key, value: switchArg.enabled, updatedAt: updatedAtArg })), + ], + metadata: this.cleanAttributes({ + serial: snapshotArg.system.serial, + host: snapshotArg.system.host, + port: snapshotArg.system.port, + ssl: snapshotArg.system.ssl, + macs: snapshotArg.system.macs, + source: snapshotArg.source, + error: snapshotArg.error, + }), + }; + } + + private static volumeDevice(snapshotArg: ISynologyDsmSnapshot, volumeArg: ISynologyDsmVolumeInfo, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + return { + id: this.volumeDeviceId(snapshotArg, volumeArg), + integrationDomain: synologyDsmDomain, + name: `${this.nasName(snapshotArg)} (${this.volumeName(volumeArg)})`, + protocol: 'unknown', + manufacturer, + model: volumeArg.deviceType || volumeArg.raidType || snapshotArg.system.model || 'DSM volume', + online: snapshotArg.connected, + features: [ + { id: 'status', capability: 'sensor', name: 'Status', readable: true, writable: false }, + { id: 'used', capability: 'sensor', name: 'Used', readable: true, writable: false, unit: 'B' }, + { id: 'used_percent', capability: 'sensor', name: 'Used percentage', readable: true, writable: false, unit: '%' }, + ], + state: [ + { featureId: 'status', value: volumeArg.status || null, updatedAt: updatedAtArg }, + { featureId: 'used', value: volumeArg.sizeUsed ?? null, updatedAt: updatedAtArg }, + { featureId: 'used_percent', value: volumeArg.percentageUsed ?? null, updatedAt: updatedAtArg }, + ], + metadata: this.cleanAttributes({ volumeId: volumeArg.id, viaDevice: this.nasDeviceId(snapshotArg) }), + }; + } + + private static diskDevice(snapshotArg: ISynologyDsmSnapshot, diskArg: ISynologyDsmDiskInfo, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + return { + id: this.diskDeviceId(snapshotArg, diskArg), + integrationDomain: synologyDsmDomain, + name: `${this.nasName(snapshotArg)} (${this.diskName(diskArg)})`, + protocol: 'unknown', + manufacturer: diskArg.vendor || manufacturer, + model: diskArg.model || diskArg.diskType || 'DSM disk', + online: snapshotArg.connected, + features: [ + { id: 'status', capability: 'sensor', name: 'Status', readable: true, writable: false }, + { id: 'temperature', capability: 'sensor', name: 'Temperature', readable: true, writable: false, unit: 'C' }, + ], + state: [ + { featureId: 'status', value: diskArg.status || null, updatedAt: updatedAtArg }, + { featureId: 'temperature', value: diskArg.temperature ?? null, updatedAt: updatedAtArg }, + ], + metadata: this.cleanAttributes({ diskId: diskArg.id, firmware: diskArg.firmware, viaDevice: this.nasDeviceId(snapshotArg) }), + }; + } + + private static cameraDevice(snapshotArg: ISynologyDsmSnapshot, cameraArg: ISynologyDsmCameraInfo, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + return { + id: this.cameraDeviceId(snapshotArg, cameraArg), + integrationDomain: synologyDsmDomain, + name: cameraArg.name || `Camera ${cameraArg.id}`, + protocol: 'unknown', + manufacturer, + model: cameraArg.model || 'Surveillance Station camera', + online: snapshotArg.connected && cameraArg.enabled !== false, + features: [ + { id: 'enabled', capability: 'sensor', name: 'Enabled', readable: true, writable: false }, + { id: 'recording', capability: 'sensor', name: 'Recording', readable: true, writable: false }, + { id: 'motion_detection', capability: 'sensor', name: 'Motion detection', readable: true, writable: true }, + ], + state: [ + { featureId: 'enabled', value: cameraArg.enabled ?? null, updatedAt: updatedAtArg }, + { featureId: 'recording', value: cameraArg.recording ?? null, updatedAt: updatedAtArg }, + { featureId: 'motion_detection', value: cameraArg.motionDetectionEnabled ?? null, updatedAt: updatedAtArg }, + ], + metadata: this.cleanAttributes({ cameraId: cameraArg.id, rtsp: cameraArg.rtsp, nativePlatform: 'camera', viaDevice: this.nasDeviceId(snapshotArg) }), + }; + } + + private static pushVolumeEntities(entitiesArg: IIntegrationEntity[], snapshotArg: ISynologyDsmSnapshot, volumeArg: ISynologyDsmVolumeInfo, usedIdsArg: Map): void { + for (const descriptor of volumeSensorDescriptors) { + const value = volumeArg[descriptor.sourceKey]; + if (value === undefined || value === null) { + continue; + } + entitiesArg.push(this.entity('sensor', `${this.volumeName(volumeArg)} ${descriptor.name}`, this.volumeDeviceId(snapshotArg, volumeArg), `${this.uniqueBase(snapshotArg)}_${descriptor.nativeKey}_${this.slug(volumeArg.id)}`, value, usedIdsArg, { + nativeKey: descriptor.nativeKey, + volumeId: volumeArg.id, + unit: descriptor.unit, + unitOfMeasurement: descriptor.unit, + deviceClass: descriptor.deviceClass, + stateClass: descriptor.stateClass, + entityCategory: descriptor.entityCategory, + enabledByDefault: descriptor.enabledByDefault, + }, snapshotArg.connected)); + } + } + + private static pushDiskEntities(entitiesArg: IIntegrationEntity[], snapshotArg: ISynologyDsmSnapshot, diskArg: ISynologyDsmDiskInfo, usedIdsArg: Map): void { + for (const descriptor of diskSensorDescriptors) { + const value = diskArg[descriptor.sourceKey]; + if (value === undefined || value === null) { + continue; + } + entitiesArg.push(this.entity('sensor', `${this.diskName(diskArg)} ${descriptor.name}`, this.diskDeviceId(snapshotArg, diskArg), `${this.uniqueBase(snapshotArg)}_${descriptor.nativeKey}_${this.slug(diskArg.id)}`, value, usedIdsArg, { + nativeKey: descriptor.nativeKey, + diskId: diskArg.id, + unit: descriptor.unit, + unitOfMeasurement: descriptor.unit, + deviceClass: descriptor.deviceClass, + stateClass: descriptor.stateClass, + entityCategory: descriptor.entityCategory, + enabledByDefault: descriptor.enabledByDefault, + }, snapshotArg.connected)); + } + if (diskArg.exceedBadSectorThreshold !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${this.diskName(diskArg)} Bad sector threshold`, this.diskDeviceId(snapshotArg, diskArg), `${this.uniqueBase(snapshotArg)}_disk_exceed_bad_sector_thr_${this.slug(diskArg.id)}`, diskArg.exceedBadSectorThreshold ? 'on' : 'off', usedIdsArg, { + nativeKey: 'disk_exceed_bad_sector_thr', + diskId: diskArg.id, + deviceClass: 'safety', + entityCategory: 'diagnostic', + }, snapshotArg.connected)); + } + if (diskArg.belowRemainLifeThreshold !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${this.diskName(diskArg)} Remaining life threshold`, this.diskDeviceId(snapshotArg, diskArg), `${this.uniqueBase(snapshotArg)}_disk_below_remain_life_thr_${this.slug(diskArg.id)}`, diskArg.belowRemainLifeThreshold ? 'on' : 'off', usedIdsArg, { + nativeKey: 'disk_below_remain_life_thr', + diskId: diskArg.id, + deviceClass: 'safety', + entityCategory: 'diagnostic', + }, snapshotArg.connected)); + } + } + + private static pushCameraEntities(entitiesArg: IIntegrationEntity[], snapshotArg: ISynologyDsmSnapshot, cameraArg: ISynologyDsmCameraInfo, usedIdsArg: Map): void { + const deviceId = this.cameraDeviceId(snapshotArg, cameraArg); + const name = cameraArg.name || `Camera ${cameraArg.id}`; + if (cameraArg.enabled !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${name} Enabled`, deviceId, `${this.uniqueBase(snapshotArg)}_camera_enabled_${this.slug(cameraArg.id)}`, cameraArg.enabled ? 'on' : 'off', usedIdsArg, { + nativePlatform: 'camera', + nativeKey: 'is_enabled', + cameraId: cameraArg.id, + }, snapshotArg.connected)); + } + if (cameraArg.recording !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${name} Recording`, deviceId, `${this.uniqueBase(snapshotArg)}_camera_recording_${this.slug(cameraArg.id)}`, cameraArg.recording ? 'on' : 'off', usedIdsArg, { + nativePlatform: 'camera', + nativeKey: 'is_recording', + cameraId: cameraArg.id, + }, snapshotArg.connected && cameraArg.enabled !== false)); + } + if (cameraArg.motionDetectionEnabled !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${name} Motion detection`, deviceId, `${this.uniqueBase(snapshotArg)}_camera_motion_detection_${this.slug(cameraArg.id)}`, cameraArg.motionDetectionEnabled ? 'on' : 'off', usedIdsArg, { + nativePlatform: 'camera', + nativeKey: 'is_motion_detection_enabled', + nativeAction: cameraArg.motionDetectionEnabled ? 'disable_camera_motion_detection' : 'enable_camera_motion_detection', + cameraId: cameraArg.id, + writable: true, + }, snapshotArg.connected && cameraArg.enabled !== false)); + } + if (cameraArg.rtsp) { + entitiesArg.push(this.entity('sensor', `${name} Stream source`, deviceId, `${this.uniqueBase(snapshotArg)}_camera_stream_source_${this.slug(cameraArg.id)}`, cameraArg.rtsp, usedIdsArg, { + nativePlatform: 'camera', + nativeKey: 'stream_source', + cameraId: cameraArg.id, + rtsp: cameraArg.rtsp, + }, snapshotArg.connected && cameraArg.enabled !== false)); + } + } + + private static pushSwitchEntity(entitiesArg: IIntegrationEntity[], snapshotArg: ISynologyDsmSnapshot, switchArg: ISynologyDsmSwitchInfo, usedIdsArg: Map): void { + if (switchArg.type !== 'home_mode' && switchArg.key !== 'home_mode') { + return; + } + entitiesArg.push(this.entity('switch', `${this.nasName(snapshotArg)} Surveillance Station Home mode`, this.nasDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_surveillance_station_home_mode`, switchArg.enabled ? 'on' : 'off', usedIdsArg, { + nativeKey: 'home_mode', + nativeType: 'home_mode', + nativeAction: 'set_home_mode', + switchKey: switchArg.key, + entityCategory: 'config', + writable: true, + }, snapshotArg.connected)); + } + + private static updateEntity(snapshotArg: ISynologyDsmSnapshot, usedIdsArg: Map): IIntegrationEntity { + const update = snapshotArg.update || {}; + const installedVersion = update.installedVersion || snapshotArg.system.versionString || snapshotArg.system.version; + const latestVersion = update.updateAvailable ? update.latestVersion || installedVersion : installedVersion; + return this.entity('update', `${this.nasName(snapshotArg)} DSM update`, this.nasDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_dsm_update`, update.updateAvailable ? 'on' : 'off', usedIdsArg, { + nativeKey: 'update', + installedVersion, + latestVersion, + releaseUrl: update.releaseUrl, + entityCategory: 'diagnostic', + writable: false, + }, snapshotArg.connected); + } + + private static actionButton(snapshotArg: ISynologyDsmSnapshot, actionArg: ISynologyDsmActionDescriptor, usedIdsArg: Map): IIntegrationEntity { + return this.entity('button', `${this.nasName(snapshotArg)} ${this.title(actionArg.action)}`, this.nasDeviceId(snapshotArg), `${this.uniqueBase(snapshotArg)}_${this.slug(actionArg.action)}`, 'available', usedIdsArg, { + nativeType: 'system_action', + nativeAction: actionArg.action, + actionTarget: 'system', + entityCategory: 'config', + writable: true, + }, snapshotArg.connected, actionArg.entityId); + } + + private static command(snapshotArg: ISynologyDsmSnapshot, requestArg: IServiceCallRequest, actionArg: ISynologyDsmActionDescriptor, entityArg?: IIntegrationEntity, payloadArg: Record = {}): ISynologyDsmCommand { + return { + type: actionArg.target === 'system' ? 'system.action' : actionArg.target === 'camera' ? 'camera.action' : 'switch.set', + service: requestArg.service, + action: actionArg.action, + target: requestArg.target, + serial: snapshotArg.system.serial, + deviceId: entityArg?.deviceId || actionArg.deviceId || requestArg.target.deviceId, + entityId: entityArg?.id || actionArg.entityId || requestArg.target.entityId, + cameraId: actionArg.cameraId || this.stringValue(entityArg?.attributes?.cameraId) || this.stringValue(payloadArg.cameraId), + switchKey: actionArg.switchKey || this.stringValue(entityArg?.attributes?.switchKey), + payload: this.cleanAttributes(payloadArg), + snapshotSource: snapshotArg.source, + }; + } + + private static findTargetEntity(snapshotArg: ISynologyDsmSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + const entities = this.toEntities(snapshotArg); + if (requestArg.target.entityId) { + return entities.find((entityArg) => entityArg.id === requestArg.target.entityId || entityArg.uniqueId === requestArg.target.entityId); + } + if (requestArg.target.deviceId) { + return entities.find((entityArg) => entityArg.deviceId === requestArg.target.deviceId); + } + return undefined; + } + + private static cameraIdFromTarget(snapshotArg: ISynologyDsmSnapshot, requestArg: IServiceCallRequest): string | undefined { + if (!requestArg.target.deviceId) { + return undefined; + } + return snapshotArg.cameras.find((cameraArg) => this.cameraDeviceId(snapshotArg, cameraArg) === requestArg.target.deviceId)?.id; + } + + private static snapshotActions(snapshotArg: ISynologyDsmSnapshot): ISynologyDsmActionDescriptor[] { + return snapshotArg.actions || []; + } + + private static actionsFromSystem(systemArg: ISynologyDsmSystemInfo): ISynologyDsmActionDescriptor[] { + if (!systemArg.serial && !systemArg.host && !systemArg.name) { + return []; + } + return [ + { target: 'system', action: 'reboot' }, + { target: 'system', action: 'shutdown' }, + ]; + } + + private static actionsFromSwitches(switchesArg: ISynologyDsmSwitchInfo[]): ISynologyDsmActionDescriptor[] { + return switchesArg + .filter((switchArg) => switchArg.type === 'home_mode' || switchArg.key === 'home_mode') + .map((switchArg) => ({ target: 'switch' as const, action: 'set_home_mode' as const, switchKey: switchArg.key })); + } + + private static actionsFromCameras(camerasArg: ISynologyDsmCameraInfo[]): ISynologyDsmActionDescriptor[] { + return camerasArg.flatMap((cameraArg) => [ + { target: 'camera' as const, action: 'enable_camera_motion_detection' as const, cameraId: cameraArg.id }, + { target: 'camera' as const, action: 'disable_camera_motion_detection' as const, cameraId: cameraArg.id }, + ]); + } + + private static systemInfo(configArg: ISynologyDsmConfig, sourceArg?: ISynologyDsmSnapshot): ISynologyDsmSystemInfo { + const records = [sourceArg?.system, configArg.system, configArg.information].map((recordArg) => this.recordValue(recordArg)).filter((recordArg): recordArg is Record => Boolean(recordArg)); + const ssl = configArg.ssl ?? this.firstBoolean(records, 'ssl', 'use_ssl') ?? synologyDsmDefaultSsl; + const port = configArg.port || this.firstNumber(records, 'port') || this.defaultPort(ssl); + const macs = this.uniqueStrings([ + ...records.flatMap((recordArg) => this.stringArray(recordArg.macs)), + ...records.flatMap((recordArg) => this.stringArray(recordArg.mac)), + ...this.stringArray(configArg.macs), + ...this.stringArray(configArg.macAddress), + ].map((valueArg) => this.normalizeMac(valueArg) || valueArg)); + const version = this.firstString(records, 'versionString', 'version_string', 'version', 'firmware'); + return this.cleanAttributes({ + id: sourceArg?.system.id || configArg.uniqueId || configArg.serial || macs[0] || (configArg.host ? `${configArg.host}:${port}` : undefined), + serial: this.firstString(records, 'serial', 'serialNumber', 'serial_number') || configArg.serial, + name: configArg.name || this.firstString(records, 'name', 'friendlyName', 'friendly_name', 'hostname') || configArg.host, + hostname: this.firstString(records, 'hostname', 'hostName', 'host_name') || configArg.network?.hostname || configArg.host, + host: configArg.host || this.firstString(records, 'host'), + port, + ssl, + verifySsl: configArg.verifySsl ?? this.firstBoolean(records, 'verifySsl', 'verify_ssl'), + manufacturer, + model: this.firstString(records, 'model', 'modelName', 'model_name'), + version, + versionString: version, + temperature: this.firstNumber(records, 'temperature', 'system_temp'), + uptimeSeconds: this.firstNumber(records, 'uptimeSeconds', 'uptime_seconds', 'uptime'), + macs, + }) as ISynologyDsmSystemInfo; + } + + private static networkInfo(configArg: ISynologyDsmConfig, sourceArg: ISynologyDsmSnapshot | undefined, systemArg: ISynologyDsmSystemInfo): ISynologyDsmNetworkInfo { + const records = [sourceArg?.network, configArg.network].map((recordArg) => this.recordValue(recordArg)).filter((recordArg): recordArg is Record => Boolean(recordArg)); + const macs = this.uniqueStrings([ + ...(systemArg.macs || []), + ...records.flatMap((recordArg) => this.stringArray(recordArg.macs)), + ...records.flatMap((recordArg) => this.stringArray(recordArg.mac)), + ].map((valueArg) => this.normalizeMac(valueArg) || valueArg)); + return this.cleanAttributes({ + ...(sourceArg?.network || {}), + ...(configArg.network || {}), + hostname: this.firstString(records, 'hostname', 'hostName', 'host_name') || systemArg.hostname, + macs, + uploadRate: this.firstNumber(records, 'uploadRate', 'networkUp', 'network_up', 'up'), + downloadRate: this.firstNumber(records, 'downloadRate', 'networkDown', 'network_down', 'down'), + interfaces: this.arrayValues(records.flatMap((recordArg) => this.arrayValues(recordArg.interfaces))).map((interfaceArg) => this.normalizeNetworkInterface(interfaceArg)).filter((interfaceArg): interfaceArg is NonNullable> => Boolean(interfaceArg)), + }) as ISynologyDsmNetworkInfo; + } + + private static utilizationInfo(configArg: ISynologyDsmConfig, sourceArg: ISynologyDsmSnapshot | undefined, networkArg: ISynologyDsmNetworkInfo): ISynologyDsmUtilizationInfo { + const records = [sourceArg?.utilization, configArg.utilization].map((recordArg) => this.recordValue(recordArg)).filter((recordArg): recordArg is Record => Boolean(recordArg)); + return this.cleanAttributes({ + ...(sourceArg?.utilization || {}), + ...(configArg.utilization || {}), + cpuOtherLoad: this.firstNumber(records, 'cpuOtherLoad', 'cpu_other_load'), + cpuUserLoad: this.firstNumber(records, 'cpuUserLoad', 'cpu_user_load'), + cpuSystemLoad: this.firstNumber(records, 'cpuSystemLoad', 'cpu_system_load'), + cpuTotalLoad: this.firstNumber(records, 'cpuTotalLoad', 'cpu_total_load'), + cpu1MinLoad: this.firstNumber(records, 'cpu1MinLoad', 'cpu_1min_load'), + cpu5MinLoad: this.firstNumber(records, 'cpu5MinLoad', 'cpu_5min_load'), + cpu15MinLoad: this.firstNumber(records, 'cpu15MinLoad', 'cpu_15min_load'), + memoryRealUsage: this.firstNumber(records, 'memoryRealUsage', 'memory_real_usage'), + memorySize: this.firstNumber(records, 'memorySize', 'memory_size'), + memoryCached: this.firstNumber(records, 'memoryCached', 'memory_cached'), + memoryAvailableSwap: this.firstNumber(records, 'memoryAvailableSwap', 'memory_available_swap'), + memoryAvailableReal: this.firstNumber(records, 'memoryAvailableReal', 'memory_available_real'), + memoryTotalSwap: this.firstNumber(records, 'memoryTotalSwap', 'memory_total_swap'), + memoryTotalReal: this.firstNumber(records, 'memoryTotalReal', 'memory_total_real'), + networkUp: this.firstNumber(records, 'networkUp', 'network_up') ?? networkArg.uploadRate, + networkDown: this.firstNumber(records, 'networkDown', 'network_down') ?? networkArg.downloadRate, + }) as ISynologyDsmUtilizationInfo; + } + + private static normalizeVolume(valueArg: unknown): ISynologyDsmVolumeInfo | undefined { + if (typeof valueArg === 'string' && valueArg.trim()) { + return { id: valueArg.trim() }; + } + const record = this.recordValue(valueArg); + if (!record) { + return undefined; + } + const id = this.stringValue(record.id) || this.stringValue(record.volumeId) || this.stringValue(record.volume_id) || this.stringValue(record.name); + if (!id) { + return undefined; + } + return this.cleanAttributes({ + ...record, + id, + name: this.stringValue(record.name) || id.replace(/_/g, ' '), + status: this.stringValue(record.status) || this.stringValue(record.volume_status), + sizeTotal: this.numberValue(record.sizeTotal) ?? this.numberValue(record.size_total) ?? this.numberValue(record.volume_size_total) ?? this.numberValue(record.total), + sizeUsed: this.numberValue(record.sizeUsed) ?? this.numberValue(record.size_used) ?? this.numberValue(record.volume_size_used) ?? this.numberValue(record.used), + percentageUsed: this.numberValue(record.percentageUsed) ?? this.numberValue(record.percentage_used) ?? this.numberValue(record.volume_percentage_used) ?? this.numberValue(record.percent), + diskTempAvg: this.numberValue(record.diskTempAvg) ?? this.numberValue(record.disk_temp_avg) ?? this.numberValue(record.volume_disk_temp_avg), + diskTempMax: this.numberValue(record.diskTempMax) ?? this.numberValue(record.disk_temp_max) ?? this.numberValue(record.volume_disk_temp_max), + deviceType: this.stringValue(record.deviceType) || this.stringValue(record.device_type), + raidType: this.stringValue(record.raidType) || this.stringValue(record.raid_type), + }) as ISynologyDsmVolumeInfo; + } + + private static normalizeDisk(valueArg: unknown): ISynologyDsmDiskInfo | undefined { + if (typeof valueArg === 'string' && valueArg.trim()) { + return { id: valueArg.trim(), name: valueArg.trim() }; + } + const record = this.recordValue(valueArg); + if (!record) { + return undefined; + } + const id = this.stringValue(record.id) || this.stringValue(record.diskId) || this.stringValue(record.disk_id) || this.stringValue(record.name); + if (!id) { + return undefined; + } + return this.cleanAttributes({ + ...record, + id, + name: this.stringValue(record.name) || id, + vendor: this.stringValue(record.vendor) || this.stringValue(record.manufacturer), + model: this.stringValue(record.model)?.trim(), + firmware: this.stringValue(record.firmware) || this.stringValue(record.firm), + diskType: this.stringValue(record.diskType) || this.stringValue(record.disk_type), + status: this.stringValue(record.status) || this.stringValue(record.disk_status), + smartStatus: this.stringValue(record.smartStatus) || this.stringValue(record.smart_status) || this.stringValue(record.disk_smart_status), + temperature: this.numberValue(record.temperature) ?? this.numberValue(record.temp) ?? this.numberValue(record.disk_temp), + exceedBadSectorThreshold: this.booleanValue(record.exceedBadSectorThreshold) ?? this.booleanValue(record.exceed_bad_sector_thr) ?? this.booleanValue(record.disk_exceed_bad_sector_thr), + belowRemainLifeThreshold: this.booleanValue(record.belowRemainLifeThreshold) ?? this.booleanValue(record.below_remain_life_thr) ?? this.booleanValue(record.disk_below_remain_life_thr), + }) as ISynologyDsmDiskInfo; + } + + private static normalizeCamera(valueArg: unknown): ISynologyDsmCameraInfo | undefined { + const record = this.recordValue(valueArg); + if (!record) { + return undefined; + } + const id = this.stringValue(record.id) || this.stringValue(record.cameraId) || this.stringValue(record.camera_id); + if (!id) { + return undefined; + } + const liveView = this.recordValue(record.liveView) || this.recordValue(record.live_view); + return this.cleanAttributes({ + ...record, + id, + name: this.stringValue(record.name) || `Camera ${id}`, + model: this.stringValue(record.model), + enabled: this.booleanValue(record.enabled) ?? this.booleanValue(record.is_enabled), + recording: this.booleanValue(record.recording) ?? this.booleanValue(record.is_recording), + motionDetectionEnabled: this.booleanValue(record.motionDetectionEnabled) ?? this.booleanValue(record.motion_detection_enabled) ?? this.booleanValue(record.is_motion_detection_enabled), + rtsp: this.stringValue(record.rtsp) || this.stringValue(liveView?.rtsp), + snapshotUrl: this.stringValue(record.snapshotUrl) || this.stringValue(record.snapshot_url), + }) as ISynologyDsmCameraInfo; + } + + private static normalizeSwitch(valueArg: unknown): ISynologyDsmSwitchInfo | undefined { + const record = this.recordValue(valueArg); + if (!record) { + return undefined; + } + const key = this.stringValue(record.key) || this.stringValue(record.id) || this.stringValue(record.name); + const enabled = this.booleanValue(record.enabled) ?? this.booleanValue(record.is_on) ?? this.booleanValue(record.value) ?? this.booleanValue(record.state); + if (!key || enabled === undefined) { + return undefined; + } + return this.cleanAttributes({ + ...record, + key, + name: this.stringValue(record.name) || this.title(key), + enabled, + type: this.stringValue(record.type) || (key === 'home_mode' || key === 'homeMode' ? 'home_mode' : key), + }) as ISynologyDsmSwitchInfo; + } + + private static normalizeNetworkInterface(valueArg: unknown) { + const record = this.recordValue(valueArg); + if (!record) { + return undefined; + } + const name = this.stringValue(record.name) || this.stringValue(record.id); + if (!name) { + return undefined; + } + return this.cleanAttributes({ + ...record, + id: this.stringValue(record.id) || name, + name, + mac: this.normalizeMac(this.stringValue(record.mac) || this.stringValue(record.macAddress) || this.stringValue(record.mac_address)), + ipAddress: this.stringValue(record.ipAddress) || this.stringValue(record.ip_address) || this.stringValue(record.ip), + ipv6Address: this.stringValue(record.ipv6Address) || this.stringValue(record.ipv6_address) || this.stringValue(record.ipv6), + connected: this.booleanValue(record.connected) ?? this.booleanValue(record.is_up), + speedMbps: this.numberValue(record.speedMbps) ?? this.numberValue(record.speed_mbps) ?? this.numberValue(record.speed), + }); + } + + private static updateInfo(recordsArg: unknown[], systemArg: ISynologyDsmSystemInfo): ISynologyDsmUpdateInfo | undefined { + const records = recordsArg.map((recordArg) => this.recordValue(recordArg)).filter((recordArg): recordArg is Record => Boolean(recordArg)); + if (!records.length) { + return undefined; + } + const updateAvailable = this.firstBoolean(records, 'updateAvailable', 'update_available'); + const installedVersion = this.firstString(records, 'installedVersion', 'installed_version', 'currentVersion', 'current_version') || systemArg.versionString || systemArg.version; + const latestVersion = this.firstString(records, 'latestVersion', 'latest_version', 'availableVersion', 'available_version') || (updateAvailable ? undefined : installedVersion); + const details = records.map((recordArg) => this.recordValue(recordArg.availableVersionDetails) || this.recordValue(recordArg.available_version_details)).find(Boolean); + return this.cleanAttributes({ + ...records.reduce((accumulatorArg, recordArg) => ({ ...accumulatorArg, ...recordArg }), {}), + installedVersion, + latestVersion, + updateAvailable, + releaseUrl: this.firstString(records, 'releaseUrl', 'release_url') || this.releaseUrlFromDetails(details, systemArg.model), + availableVersionDetails: details, + }) as ISynologyDsmUpdateInfo; + } + + private static securityInfo(...valuesArg: unknown[]) { + const records = valuesArg.map((recordArg) => this.recordValue(recordArg)).filter((recordArg): recordArg is Record => Boolean(recordArg)); + if (!records.length) { + return undefined; + } + const merged = records.reduce((accumulatorArg, recordArg) => ({ ...accumulatorArg, ...recordArg }), {} as Record); + return this.cleanAttributes({ + ...merged, + status: this.firstString(records, 'status'), + statusByCheck: this.stringRecordValue(merged.statusByCheck) || this.stringRecordValue(merged.status_by_check), + }); + } + + private static uniqueVolumes(valuesArg: unknown[]): ISynologyDsmVolumeInfo[] { + return this.uniqueBy(valuesArg.map((valueArg) => this.normalizeVolume(valueArg)).filter((valueArg): valueArg is ISynologyDsmVolumeInfo => Boolean(valueArg)), (valueArg) => valueArg.id); + } + + private static uniqueDisks(valuesArg: unknown[]): ISynologyDsmDiskInfo[] { + return this.uniqueBy(valuesArg.map((valueArg) => this.normalizeDisk(valueArg)).filter((valueArg): valueArg is ISynologyDsmDiskInfo => Boolean(valueArg)), (valueArg) => valueArg.id); + } + + private static uniqueCameras(valuesArg: unknown[]): ISynologyDsmCameraInfo[] { + return this.uniqueBy(valuesArg.map((valueArg) => this.normalizeCamera(valueArg)).filter((valueArg): valueArg is ISynologyDsmCameraInfo => Boolean(valueArg)), (valueArg) => valueArg.id); + } + + private static uniqueSwitches(valuesArg: unknown[]): ISynologyDsmSwitchInfo[] { + return this.uniqueBy(valuesArg.map((valueArg) => this.normalizeSwitch(valueArg)).filter((valueArg): valueArg is ISynologyDsmSwitchInfo => Boolean(valueArg)), (valueArg) => valueArg.key); + } + + private static uniqueActions(valuesArg: ISynologyDsmActionDescriptor[]): ISynologyDsmActionDescriptor[] { + return this.uniqueBy(valuesArg, (valueArg) => [valueArg.target, valueArg.action, valueArg.cameraId || '', valueArg.switchKey || ''].join(':')); + } + + private static uniqueBy(valuesArg: TValue[], keyFnArg: (valueArg: TValue) => string): TValue[] { + const values = new Map(); + for (const value of valuesArg) { + values.set(keyFnArg(value), value); + } + return [...values.values()]; + } + + private static switchValues(valueArg: unknown): unknown[] { + if (Array.isArray(valueArg)) { + return valueArg; + } + const record = this.recordValue(valueArg); + if (!record) { + return []; + } + return Object.entries(record).map(([key, value]) => this.recordValue(value) ? { key, ...this.recordValue(value) } : { key, value }); + } + + private static homeModeSwitchValues(...recordsArg: Array | undefined>): unknown[] { + return recordsArg.flatMap((recordArg) => { + if (!recordArg) { + return []; + } + const value = this.booleanValue(recordArg.homeMode) ?? this.booleanValue(recordArg.home_mode); + return value === undefined ? [] : [{ key: 'home_mode', name: 'Home mode', type: 'home_mode', enabled: value }]; + }); + } + + private static hasManualData(configArg: ISynologyDsmConfig, sourceArg: ISynologyDsmSnapshot | undefined, volumesArg: ISynologyDsmVolumeInfo[], disksArg: ISynologyDsmDiskInfo[], camerasArg: ISynologyDsmCameraInfo[], switchesArg: ISynologyDsmSwitchInfo[], updateArg?: ISynologyDsmUpdateInfo, securityArg?: unknown): boolean { + return Boolean( + sourceArg + || configArg.system + || configArg.information + || configArg.utilization + || configArg.storage + || configArg.network + || volumesArg.length + || disksArg.length + || camerasArg.length + || switchesArg.length + || updateArg + || securityArg + ); + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg: Record = {}, availableArg = true, explicitIdArg?: string): IIntegrationEntity { + return { + id: explicitIdArg || this.entityId(platformArg, nameArg, usedIdsArg), + uniqueId: `${synologyDsmDomain}_${uniqueIdArg}`, + integrationDomain: synologyDsmDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static entityId(platformArg: TEntityPlatform, nameArg: string, usedIdsArg: Map): string { + const base = `${platformArg}.${this.slug(nameArg)}`; + const count = usedIdsArg.get(base) || 0; + usedIdsArg.set(base, count + 1); + return count ? `${base}_${count + 1}` : base; + } + + private static nasDeviceId(snapshotArg: ISynologyDsmSnapshot): string { + return `synology_dsm.nas.${this.slug(this.uniqueBase(snapshotArg))}`; + } + + private static volumeDeviceId(snapshotArg: ISynologyDsmSnapshot, volumeArg: ISynologyDsmVolumeInfo): string { + return `synology_dsm.volume.${this.slug(this.uniqueBase(snapshotArg))}.${this.slug(volumeArg.id)}`; + } + + private static diskDeviceId(snapshotArg: ISynologyDsmSnapshot, diskArg: ISynologyDsmDiskInfo): string { + return `synology_dsm.disk.${this.slug(this.uniqueBase(snapshotArg))}.${this.slug(diskArg.id)}`; + } + + private static cameraDeviceId(snapshotArg: ISynologyDsmSnapshot, cameraArg: ISynologyDsmCameraInfo): string { + return `synology_dsm.camera.${this.slug(this.uniqueBase(snapshotArg))}.${this.slug(cameraArg.id)}`; + } + + private static uniqueBase(snapshotArg: ISynologyDsmSnapshot): string { + return snapshotArg.system.serial || snapshotArg.system.id || snapshotArg.system.macs?.[0] || snapshotArg.system.host || snapshotArg.system.name || 'synology_dsm'; + } + + private static nasName(snapshotArg: ISynologyDsmSnapshot): string { + return snapshotArg.system.name || snapshotArg.network.hostname || snapshotArg.system.hostname || snapshotArg.system.model || snapshotArg.system.host || 'Synology DSM'; + } + + private static volumeName(volumeArg: ISynologyDsmVolumeInfo): string { + return volumeArg.name || volumeArg.id.replace(/_/g, ' ').replace(/^./, (valueArg) => valueArg.toUpperCase()); + } + + private static diskName(diskArg: ISynologyDsmDiskInfo): string { + return diskArg.name || diskArg.id; + } + + private static uptimeValue(valueArg: unknown): string | undefined { + if (typeof valueArg === 'string' && valueArg) { + return valueArg; + } + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return new Date(Date.now() - valueArg * 1000).toISOString(); + } + return undefined; + } + + private static releaseUrlFromDetails(detailsArg: Record | undefined, modelArg: string | undefined): string | undefined { + if (!detailsArg || !modelArg) { + return undefined; + } + const buildNumber = this.stringValue(detailsArg.buildnumber) || this.stringValue(detailsArg.buildNumber) || this.stringValue(detailsArg.build_number); + const nano = this.numberValue(detailsArg.nano) || 0; + if (!buildNumber) { + return undefined; + } + const updateVersion = nano > 0 ? `${buildNumber}-${nano}` : buildNumber; + const query = new URLSearchParams({ model: modelArg, update_version: updateVersion }); + return `http://update.synology.com/autoupdate/whatsnew.php?${query.toString()}`; + } + + private static arrayValues(valueArg: unknown): unknown[] { + if (Array.isArray(valueArg)) { + return valueArg; + } + if (valueArg && typeof valueArg === 'object') { + return Object.values(valueArg); + } + return []; + } + + private static recordValue(valueArg: unknown): Record | undefined { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as Record : undefined; + } + + private static stringRecordValue(valueArg: unknown): Record | undefined { + const record = this.recordValue(valueArg); + if (!record) { + return undefined; + } + const stringRecord: Record = {}; + for (const [key, value] of Object.entries(record)) { + stringRecord[key] = typeof value === 'string' ? value : String(value); + } + return stringRecord; + } + + private static firstString(recordsArg: Record[], ...keysArg: string[]): string | undefined { + for (const record of recordsArg) { + for (const key of keysArg) { + const value = this.stringValue(record[key]); + if (value) { + return value; + } + } + } + return undefined; + } + + private static firstNumber(recordsArg: Record[], ...keysArg: string[]): number | undefined { + for (const record of recordsArg) { + for (const key of keysArg) { + const value = this.numberValue(record[key]); + if (value !== undefined) { + return value; + } + } + } + return undefined; + } + + private static firstBoolean(recordsArg: Record[], ...keysArg: string[]): boolean | undefined { + for (const record of recordsArg) { + for (const key of keysArg) { + const value = this.booleanValue(record[key]); + if (value !== undefined) { + return value; + } + } + } + return undefined; + } + + private static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private static numberValue(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private static booleanValue(valueArg: unknown): boolean | undefined { + if (typeof valueArg === 'boolean') { + return valueArg; + } + if (typeof valueArg === 'number') { + return valueArg === 1 ? true : valueArg === 0 ? false : undefined; + } + if (typeof valueArg === 'string') { + if (['true', '1', 'yes', 'on', 'enabled'].includes(valueArg.toLowerCase())) { + return true; + } + if (['false', '0', 'no', 'off', 'disabled'].includes(valueArg.toLowerCase())) { + return false; + } + } + return undefined; + } + + private static stringArray(valueArg: unknown): string[] { + if (Array.isArray(valueArg)) { + return valueArg.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg.trim())); + } + const value = this.stringValue(valueArg); + return value ? [value] : []; + } + + private static uniqueStrings(valuesArg: string[]): string[] { + return [...new Set(valuesArg.filter(Boolean))]; + } + + private static cleanAttributes>(recordArg: TValue): TValue { + const cleaned: Record = {}; + for (const [key, value] of Object.entries(recordArg)) { + if (value !== undefined) { + cleaned[key] = value; + } + } + return cleaned as TValue; + } + + private static slug(valueArg: string): string { + const slug = valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, ''); + return slug || 'synology_dsm'; + } + + private static title(valueArg: string): string { + return valueArg.replace(/_/g, ' ').replace(/\b\w/g, (characterArg) => characterArg.toUpperCase()); + } +} diff --git a/ts/integrations/synology_dsm/synology_dsm.types.ts b/ts/integrations/synology_dsm/synology_dsm.types.ts index 21e40eb..b513400 100644 --- a/ts/integrations/synology_dsm/synology_dsm.types.ts +++ b/ts/integrations/synology_dsm/synology_dsm.types.ts @@ -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; + nativeClient?: ISynologyDsmNativeClient; + commandExecutor?: (commandArg: ISynologyDsmCommand) => Promise; + online?: boolean; + connected?: boolean; + system?: Partial & Record; + information?: Record; + utilization?: ISynologyDsmUtilizationInfo & Record; + storage?: Partial & Record; + volumes?: Array | string>; + disks?: Array | string>; + network?: ISynologyDsmNetworkInfo & Record; + cameras?: Array>; + surveillance?: Record; + update?: ISynologyDsmUpdateInfo & Record; + switches?: Array> | Record; + security?: ISynologyDsmSecurityInfo & Record; + actions?: ISynologyDsmActionDescriptor[]; + events?: ISynologyDsmEvent[]; + metadata?: Record; +} + +export interface IHomeAssistantSynologyDsmConfig extends ISynologyDsmConfig {} + +export interface ISynologyDsmNativeClient { + getSnapshot(): Promise; + executeCommand?(commandArg: ISynologyDsmCommand): Promise; + destroy?(): Promise; +} + +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; +} + +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; + [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; + [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; + 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; +} + +export interface ISynologyDsmHttpDiscoveryRecord { + url?: string; + location?: string; + host?: string; + port?: number; + ssl?: boolean; + name?: string; + manufacturer?: string; + model?: string; + headers?: Record; + metadata?: Record; +} + +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; + metadata?: Record; +} + +export interface ISynologyDsmMdnsDiscoveryRecord { + type?: string; + serviceType?: string; + fullname?: string; + name?: string; + host?: string; + port?: number; + properties?: Record; + txt?: Record; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + metadata?: Record; +}