diff --git a/test/adguard/test.adguard.discovery.node.ts b/test/adguard/test.adguard.discovery.node.ts new file mode 100644 index 0000000..823ab55 --- /dev/null +++ b/test/adguard/test.adguard.discovery.node.ts @@ -0,0 +1,40 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AdguardIntegration, createAdguardDiscoveryDescriptor } from '../../ts/integrations/adguard/index.js'; + +tap.test('matches and validates manual AdGuard Home candidates', async () => { + const descriptor = createAdguardDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const match = await matcher.matches({ + host: '192.168.1.2', + port: 3000, + name: 'AdGuard Home', + }, {}); + expect(match.matched).toBeTrue(); + expect(match.candidate?.integrationDomain).toEqual('adguard'); + expect(match.candidate?.manufacturer).toEqual('AdGuard Team'); + + const validator = descriptor.getValidators()[0]; + const valid = await validator.validate({ + source: 'manual', + integrationDomain: 'adguard', + host: '192.168.1.2', + port: 3000, + }, {}); + expect(valid.matched).toBeTrue(); +}); + +tap.test('config flow returns local HTTP AdGuard Home config', async () => { + const integration = new AdguardIntegration(); + const step = await integration.configFlow.start({ source: 'manual', integrationDomain: 'adguard', host: 'http://192.168.1.2:3000/admin' }, {}); + const incomplete = await step.submit?.({ host: '192.168.1.2', username: 'admin' }); + expect(incomplete?.kind).toEqual('error'); + + const done = await step.submit?.({ host: 'http://192.168.1.2:3000/admin', username: 'admin', password: 'secret' }); + expect(done?.kind).toEqual('done'); + expect(done?.config?.host).toEqual('192.168.1.2'); + expect(done?.config?.port).toEqual(3000); + expect(done?.config?.ssl).toBeFalse(); + expect(done?.config?.basePath).toEqual('/admin'); +}); + +export default tap.start(); diff --git a/test/adguard/test.adguard.mapper.node.ts b/test/adguard/test.adguard.mapper.node.ts new file mode 100644 index 0000000..4533986 --- /dev/null +++ b/test/adguard/test.adguard.mapper.node.ts @@ -0,0 +1,100 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AdguardMapper, type IAdguardSnapshot } from '../../ts/integrations/adguard/index.js'; + +const snapshot: IAdguardSnapshot = { + online: true, + host: '192.168.1.2', + port: 3000, + name: 'Home DNS', + status: { + running: true, + version: 'v0.107.45', + protection_enabled: true, + language: 'en', + dns_port: 53, + }, + filtering: { + enabled: true, + filters: [ + { id: 1, name: 'AdGuard DNS filter', url: 'https://example.test/filter.txt', enabled: false, rules_count: 1200 }, + ], + }, + queryLog: { + enabled: false, + interval: 604800000, + anonymize_client_ip: false, + ignored: [], + ignored_enabled: false, + }, + safebrowsing: { enabled: true }, + safesearch: { enabled: false, google: true, youtube: true }, + parental: { enabled: true, sensitivity: 13 }, + stats: { + num_dns_queries: 200, + num_blocked_filtering: 50, + num_replaced_parental: 3, + num_replaced_safebrowsing: 4, + num_replaced_safesearch: 5, + avg_processing_time: 0.012345, + }, + update: { + disabled: false, + new_version: 'v0.107.46', + announcement: 'AdGuard Home v0.107.46 is available.', + announcement_url: 'https://github.com/AdguardTeam/AdGuardHome/releases/tag/v0.107.46', + }, +}; + +tap.test('maps AdGuard Home status, controls, update, and statistics sensors', async () => { + const devices = AdguardMapper.toDevices(snapshot); + const entities = AdguardMapper.toEntities(snapshot); + + expect(devices[0].id).toEqual('adguard.service.192_168_1_2_3000'); + expect(devices[0].online).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns_protection')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns_query_log')?.state).toEqual('off'); + expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns_safe_browsing')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_dns_queries')?.state).toEqual(200); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_dns_queries_blocked_ratio')?.state).toEqual(25); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_average_processing_speed')?.state).toEqual(12.35); + expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_rules_count')?.state).toEqual(1200); + expect(entities.find((entityArg) => entityArg.id === 'update.home_dns')?.attributes?.latestVersion).toEqual('v0.107.46'); +}); + +tap.test('models safe AdGuard service commands with validated payloads', async () => { + const turnOffCommand = AdguardMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_off', + target: { entityId: 'switch.home_dns_protection' }, + }); + const enableUrlCommand = AdguardMapper.commandForService(snapshot, { + domain: 'adguard', + service: 'enable_url', + target: {}, + data: { url: 'https://example.test/filter.txt' }, + }); + const unsafeAddCommand = AdguardMapper.commandForService(snapshot, { + domain: 'adguard', + service: 'add_url', + target: {}, + data: { name: 'Bad', url: 'relative.txt' }, + }); + + expect(Boolean(turnOffCommand && !('error' in turnOffCommand))).toBeTrue(); + if (turnOffCommand && !('error' in turnOffCommand)) { + expect(turnOffCommand.path).toEqual('/protection'); + expect(turnOffCommand.payload).toEqual({ enabled: false }); + } + expect(Boolean(enableUrlCommand && !('error' in enableUrlCommand))).toBeTrue(); + if (enableUrlCommand && !('error' in enableUrlCommand)) { + expect(enableUrlCommand.path).toEqual('/filtering/set_url'); + expect(enableUrlCommand.payload).toEqual({ + url: 'https://example.test/filter.txt', + whitelist: false, + data: { name: 'AdGuard DNS filter', url: 'https://example.test/filter.txt', enabled: true }, + }); + } + expect('error' in unsafeAddCommand!).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/adguard/test.adguard.runtime.node.ts b/test/adguard/test.adguard.runtime.node.ts new file mode 100644 index 0000000..6380b83 --- /dev/null +++ b/test/adguard/test.adguard.runtime.node.ts @@ -0,0 +1,86 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AdguardClient, AdguardIntegration, type IAdguardSnapshot } from '../../ts/integrations/adguard/index.js'; + +const snapshot: IAdguardSnapshot = { + online: true, + host: '127.0.0.1', + port: 3000, + status: { running: true, version: 'v0.107.45', protection_enabled: false }, + filtering: { enabled: true, filters: [{ name: 'Test', url: 'https://example.test/filter.txt', enabled: true, rules_count: 1 }] }, + queryLog: { enabled: true, interval: 604800000, anonymize_client_ip: false, ignored: [], ignored_enabled: false }, + safebrowsing: { enabled: false }, + safesearch: { enabled: false }, + parental: { enabled: false }, + stats: { num_dns_queries: 1, num_blocked_filtering: 0, avg_processing_time: 0.01 }, +}; + +tap.test('reads AdGuard Home snapshot from local HTTP API endpoints', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; method?: string; authorization?: string }> = []; + globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => { + calls.push({ url: String(urlArg), method: initArg?.method, authorization: (initArg?.headers as Record | undefined)?.authorization }); + const path = new URL(String(urlArg)).pathname; + const responses: Record = { + '/control/status': { running: true, version: 'v0.107.45', protection_enabled: true, http_port: 3000 }, + '/control/filtering/status': { enabled: true, filters: [{ name: 'Filter', url: 'https://example.test/filter.txt', rules_count: 10, enabled: true }] }, + '/control/querylog/config': { enabled: true, interval: 604800000, anonymize_client_ip: false, ignored: [], ignored_enabled: false }, + '/control/safebrowsing/status': { enabled: true }, + '/control/safesearch/status': { enabled: false }, + '/control/parental/status': { enabled: false }, + '/control/stats': { num_dns_queries: 40, num_blocked_filtering: 10, avg_processing_time: 0.02 }, + '/control/version.json': { disabled: true }, + }; + return new Response(JSON.stringify(responses[path] || {}), { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof globalThis.fetch; + + try { + const result = await new AdguardClient({ host: '127.0.0.1', port: 3000, ssl: false, username: 'admin', password: 'secret' }).getSnapshot(); + expect(result.online).toBeTrue(); + expect(result.status.version).toEqual('v0.107.45'); + expect(result.stats.num_blocked_filtering).toEqual(10); + expect(calls.some((callArg) => callArg.url === 'http://127.0.0.1:3000/control/status')).toBeTrue(); + expect(calls[0].authorization).toEqual(`Basic ${Buffer.from('admin:secret').toString('base64')}`); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('executes live AdGuard commands only through HTTP client or executor', async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; method?: string; body?: string }> = []; + globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => { + calls.push({ url: String(urlArg), method: initArg?.method, body: initArg?.body as string | undefined }); + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }) as typeof globalThis.fetch; + + try { + const runtime = await new AdguardIntegration().setup({ host: '127.0.0.1', port: 3000, snapshot }, {}); + const result = await runtime.callService?.({ + domain: 'switch', + service: 'turn_on', + target: { entityId: 'switch.adguard_home_protection' }, + }); + expect(result?.success).toBeTrue(); + expect(calls[0].url).toEqual('http://127.0.0.1:3000/control/protection'); + expect(calls[0].method).toEqual('POST'); + expect(JSON.parse(calls[0].body || '{}')).toEqual({ enabled: true }); + await runtime.destroy(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('does not report snapshot-only AdGuard commands as successful', async () => { + const runtime = await new AdguardIntegration().setup({ snapshot: { ...snapshot, host: undefined } }, {}); + const protectionEntity = (await runtime.entities()).find((entityArg) => entityArg.attributes?.adguardSwitchKey === 'protection'); + const result = await runtime.callService?.({ + domain: 'switch', + service: 'turn_on', + target: { entityId: protectionEntity?.id }, + }); + expect(result?.success).toBeFalse(); + expect(result?.error).toEqual('AdGuard Home live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/amcrest/test.amcrest.client.node.ts b/test/amcrest/test.amcrest.client.node.ts new file mode 100644 index 0000000..04d004f --- /dev/null +++ b/test/amcrest/test.amcrest.client.node.ts @@ -0,0 +1,104 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AmcrestClient } from '../../ts/integrations/amcrest/index.js'; + +tap.test('fetches live snapshots and only reports command success after HTTP response', async () => { + const originalFetch = globalThis.fetch; + const requests: string[] = []; + globalThis.fetch = (async (inputArg: RequestInfo | URL) => { + const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url; + requests.push(url); + if (url.includes('/cgi-bin/magicBox.cgi?action=getDeviceType')) { + return new Response('IP2M-841'); + } + if (url.includes('/cgi-bin/magicBox.cgi?action=getVendor')) { + return new Response('Amcrest'); + } + if (url.includes('/cgi-bin/magicBox.cgi?action=getSerialNo')) { + return new Response('AMC123'); + } + if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=Encode')) { + return new Response('table.Encode[0].MainFormat[0].VideoEnable=true\ntable.Encode[0].MainFormat[0].AudioEnable=true'); + } + if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=RecordMode')) { + return new Response('table.RecordMode[0].Mode=Manual'); + } + if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=MotionDetect')) { + return new Response('table.MotionDetect[0].Enable=true\ntable.MotionDetect[0].EventHandler.RecordEnable=false'); + } + if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=LeLensMask')) { + return new Response('table.LeLensMask[0].Enable=false'); + } + if (url.includes('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=VideoMotion')) { + return new Response('channels[0]=0'); + } + if (url.includes('/cgi-bin/ptz.cgi?action=getPresets')) { + return new Response('presets[0].Name=Home\npresets[1].Name=Driveway'); + } + if (url.includes('/cgi-bin/configManager.cgi?action=setConfig&LeLensMask[0].Enable=true')) { + return new Response('OK'); + } + if (url.includes('/cgi-bin/snapshot.cgi?channel=0')) { + return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { headers: { 'content-type': 'image/jpeg' } }); + } + return new Response('Not Found', { status: 404 }); + }) as typeof fetch; + + try { + const client = new AmcrestClient({ host: '192.168.1.30', username: 'user', password: 'pass' }); + const snapshot = await client.getSnapshot(); + expect(snapshot.connected).toBeTrue(); + expect(snapshot.deviceInfo.manufacturer).toEqual('Amcrest'); + expect(snapshot.cameras[0].rtspUrl).toEqual('rtsp://user:pass@192.168.1.30:554/cam/realmonitor?channel=1&subtype=0'); + expect(snapshot.binarySensors.find((sensorArg) => sensorArg.key === 'motion_detected')?.isOn).toBeTrue(); + expect(snapshot.switches.find((switchArg) => switchArg.key === 'privacy_mode')?.isOn).toEqual(false); + expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'ptz_preset')?.value).toEqual(2); + + const image = await client.execute({ type: 'snapshot_image', service: 'snapshot', channel: 0 }); + expect((image as { contentType: string }).contentType).toEqual('image/jpeg'); + + const result = await client.execute({ type: 'set_privacy_mode', service: 'turn_on', enabled: true, channel: 0 }); + expect((result as { ok: boolean }).ok).toBeTrue(); + expect(requests.some((requestArg) => requestArg.includes('LeLensMask[0].Enable=true'))).toBeTrue(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +tap.test('does not pretend live commands succeeded without a live endpoint or success body', async () => { + const clientWithoutHost = new AmcrestClient({}); + let missingHostError = ''; + try { + await clientWithoutHost.execute({ type: 'set_privacy_mode', service: 'turn_on', enabled: true }); + } catch (errorArg) { + missingHostError = errorArg instanceof Error ? errorArg.message : String(errorArg); + } + expect(missingHostError.includes('requires config.host or config.url')).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 (url.includes('/cgi-bin/magicBox.cgi?action=getDeviceType')) { + return new Response('IP2M-841'); + } + if (url.includes('/cgi-bin/configManager.cgi?action=setConfig&LeLensMask[0].Enable=true')) { + return new Response('Error'); + } + return new Response('', { status: 404 }); + }) as typeof fetch; + + try { + const client = new AmcrestClient({ host: '192.168.1.30' }); + await client.getSnapshot(); + let commandError = ''; + try { + await client.execute({ type: 'set_privacy_mode', service: 'turn_on', enabled: true }); + } catch (errorArg) { + commandError = errorArg instanceof Error ? errorArg.message : String(errorArg); + } + expect(commandError.includes('did not return a successful response')).toBeTrue(); + } finally { + globalThis.fetch = originalFetch; + } +}); + +export default tap.start(); diff --git a/test/amcrest/test.amcrest.discovery.node.ts b/test/amcrest/test.amcrest.discovery.node.ts new file mode 100644 index 0000000..623417c --- /dev/null +++ b/test/amcrest/test.amcrest.discovery.node.ts @@ -0,0 +1,46 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AmcrestConfigFlow, createAmcrestDiscoveryDescriptor } from '../../ts/integrations/amcrest/index.js'; + +tap.test('matches manual Amcrest host entries and configures flow', async () => { + const descriptor = createAmcrestDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const match = await matcher.matches({ host: '192.168.1.30', name: 'Front Door' }, {}); + + expect(match.matched).toBeTrue(); + expect(match.candidate?.integrationDomain).toEqual('amcrest'); + expect(match.candidate?.host).toEqual('192.168.1.30'); + expect(match.candidate?.port).toEqual(80); + + const validation = await descriptor.getValidators()[0].validate(match.candidate!, {}); + expect(validation.matched).toBeTrue(); + + const flow = new AmcrestConfigFlow(); + const step = await flow.start(match.candidate!, {}); + const done = await step.submit!({ username: 'admin', password: 'secret', streamSource: 'rtsp', resolution: 'low' }); + expect(done.kind).toEqual('done'); + expect(done.config?.host).toEqual('192.168.1.30'); + expect(done.config?.username).toEqual('admin'); + expect(done.config?.streamSource).toEqual('rtsp'); + expect(done.config?.resolution).toEqual('low'); +}); + +tap.test('matches local SSDP camera metadata', async () => { + const descriptor = createAmcrestDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[2]; + const match = await matcher.matches({ + manufacturer: 'Amcrest', + location: 'http://192.168.1.31:80/', + upnp: { + friendlyName: 'Garage Amcrest', + modelName: 'IP8M-2496', + serialNumber: 'AMC456', + }, + }, {}); + + expect(match.matched).toBeTrue(); + expect(match.candidate?.host).toEqual('192.168.1.31'); + expect(match.candidate?.manufacturer).toEqual('Amcrest'); + expect(match.candidate?.model).toEqual('IP8M-2496'); +}); + +export default tap.start(); diff --git a/test/amcrest/test.amcrest.mapper.node.ts b/test/amcrest/test.amcrest.mapper.node.ts new file mode 100644 index 0000000..3d54391 --- /dev/null +++ b/test/amcrest/test.amcrest.mapper.node.ts @@ -0,0 +1,99 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AmcrestMapper, type IAmcrestSnapshot } from '../../ts/integrations/amcrest/index.js'; + +const snapshot: IAmcrestSnapshot = { + deviceInfo: { + id: 'AMC123', + name: 'Front Door Amcrest', + manufacturer: 'Amcrest', + model: 'IP2M-841', + serialNumber: 'AMC123', + host: '192.168.1.30', + port: 80, + protocol: 'http', + online: true, + }, + cameras: [{ + id: '0', + name: 'Front Door Camera', + channel: 0, + resolution: 'high', + subtype: 0, + streamSource: 'rtsp', + snapshotUrl: 'http://192.168.1.30:80/cgi-bin/snapshot.cgi?channel=0', + mjpegUrl: 'http://192.168.1.30:80/cgi-bin/mjpg/video.cgi?channel=0&subtype=0', + rtspUrl: 'rtsp://user:pass@192.168.1.30:554/cam/realmonitor?channel=1&subtype=0', + isStreaming: true, + isRecording: false, + motionDetectionEnabled: true, + audioEnabled: true, + supportsPtz: true, + available: true, + }], + sensors: [{ key: 'ptz_preset', name: 'PTZ Preset', value: 2, entityCategory: 'diagnostic', available: true }], + binarySensors: [ + { key: 'online', name: 'Online', isOn: true, deviceClass: 'connectivity', shouldPoll: true, available: true }, + { key: 'motion_detected', name: 'Motion Detected', isOn: true, deviceClass: 'motion', eventCodes: ['VideoMotion'], available: true }, + ], + switches: [{ key: 'privacy_mode', name: 'Privacy Mode', isOn: false, command: 'privacy_mode', entityCategory: 'config', available: true }], + events: [], + currentSettings: { privacy_mode: false }, + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +tap.test('maps Amcrest camera streams, binary sensors, sensors, and switches', async () => { + const devices = AmcrestMapper.toDevices(snapshot); + const entities = AmcrestMapper.toEntities(snapshot); + + expect(devices.length).toEqual(1); + expect(devices[0].features.some((featureArg) => featureArg.capability === 'camera')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'camera.front_door_camera')?.attributes?.rtspUrl).toEqual('rtsp://user:pass@192.168.1.30:554/cam/realmonitor?channel=1&subtype=0'); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.motion_detected')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.ptz_preset')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'switch.privacy_mode')?.state).toEqual('off'); +}); + +tap.test('models camera, switch, snapshot, and PTZ services as explicit commands', async () => { + const streamCommand = AmcrestMapper.commandForService(snapshot, { + domain: 'camera', + service: 'stream_source', + target: { entityId: 'camera.front_door_camera' }, + }); + const snapshotCommand = AmcrestMapper.commandForService(snapshot, { + domain: 'camera', + service: 'snapshot', + target: { entityId: 'camera.front_door_camera' }, + }); + const privacyCommand = AmcrestMapper.commandForService(snapshot, { + domain: 'switch', + service: 'turn_on', + target: { entityId: 'switch.privacy_mode' }, + }); + const ptzCommand = AmcrestMapper.commandForService(snapshot, { + domain: 'amcrest', + service: 'ptz_control', + target: { entityId: 'camera.front_door_camera' }, + data: { movement: 'left', travel_time: '0.1' }, + }); + const presetCommand = AmcrestMapper.commandForService(snapshot, { + domain: 'amcrest', + service: 'goto_preset', + target: { entityId: 'camera.front_door_camera' }, + data: { preset: 2 }, + }); + + expect(streamCommand?.type).toEqual('stream_source'); + expect(snapshotCommand?.type).toEqual('snapshot_image'); + expect(snapshotCommand?.httpCommands?.[0].path).toEqual('/cgi-bin/snapshot.cgi?channel=0'); + expect(privacyCommand?.type).toEqual('set_privacy_mode'); + expect(privacyCommand?.enabled).toBeTrue(); + expect(privacyCommand?.httpCommands?.[0].path.includes('LeLensMask[0].Enable=true')).toBeTrue(); + expect(ptzCommand?.type).toEqual('ptz_control'); + expect(ptzCommand?.movement).toEqual('left'); + expect(ptzCommand?.travelTime).toEqual(0.1); + expect(presetCommand?.type).toEqual('goto_preset'); + expect(presetCommand?.httpCommands?.[0].path.includes('code=GotoPreset')).toBeTrue(); +}); + +export default tap.start(); diff --git a/test/androidtv_remote/test.androidtv_remote.discovery.node.ts b/test/androidtv_remote/test.androidtv_remote.discovery.node.ts new file mode 100644 index 0000000..963df0d --- /dev/null +++ b/test/androidtv_remote/test.androidtv_remote.discovery.node.ts @@ -0,0 +1,52 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidtvRemoteConfigFlow, createAndroidtvRemoteDiscoveryDescriptor } from '../../ts/integrations/androidtv_remote/index.js'; + +tap.test('matches Android TV Remote mDNS advertisements', async () => { + const descriptor = createAndroidtvRemoteDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + type: '_androidtvremote2._tcp.local.', + name: 'Living Room TV._androidtvremote2._tcp.local.', + host: '192.168.1.61', + port: 6466, + properties: { + bt: 'AA:BB:CC:DD:EE:FF', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('androidtv_remote'); + expect(result.candidate?.host).toEqual('192.168.1.61'); + expect(result.candidate?.port).toEqual(6466); + expect(result.candidate?.macAddress).toEqual('AA:BB:CC:DD:EE:FF'); + expect(result.normalizedDeviceId).toEqual('AA:BB:CC:DD:EE:FF'); +}); + +tap.test('matches manual Android TV Remote host entries', async () => { + const descriptor = createAndroidtvRemoteDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ + host: '192.168.1.62', + deviceName: 'Bedroom Google TV', + macAddress: '11:22:33:44:55:66', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.host).toEqual('192.168.1.62'); + expect(result.candidate?.port).toEqual(6466); + expect(result.candidate?.metadata?.pairPort).toEqual(6467); +}); + +tap.test('creates manual host config flow entries', async () => { + const flow = new AndroidtvRemoteConfigFlow(); + const step = await flow.start({ source: 'manual', host: '192.168.1.63', name: 'Office TV' }, {}); + const done = await step.submit?.({ host: '192.168.1.63', enableIme: false }); + + expect(done?.kind).toEqual('done'); + expect(done?.config?.host).toEqual('192.168.1.63'); + expect(done?.config?.apiPort).toEqual(6466); + expect(done?.config?.pairPort).toEqual(6467); + expect(done?.config?.enableIme).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/androidtv_remote/test.androidtv_remote.mapper.node.ts b/test/androidtv_remote/test.androidtv_remote.mapper.node.ts new file mode 100644 index 0000000..effac02 --- /dev/null +++ b/test/androidtv_remote/test.androidtv_remote.mapper.node.ts @@ -0,0 +1,59 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidtvRemoteMapper } from '../../ts/integrations/androidtv_remote/index.js'; + +const snapshot = { + deviceInfo: { + name: 'Living Room Google TV', + host: '192.168.1.61', + apiPort: 6466, + pairPort: 6467, + macAddress: 'AA:BB:CC:DD:EE:FF', + manufacturer: 'Google', + model: 'Chromecast', + }, + state: { + available: true, + isOn: true, + mediaState: 'playing', + currentApp: 'com.netflix.ninja', + volumeInfo: { + level: 7, + max: 10, + muted: false, + }, + }, + apps: [ + { id: 'com.netflix.ninja' }, + { id: 'com.plexapp.android', name: 'Plex', icon: 'https://example.invalid/plex.png' }, + ], +}; + +tap.test('maps Android TV Remote snapshots to media devices and entities', async () => { + const devices = AndroidtvRemoteMapper.toDevices(snapshot); + const entities = AndroidtvRemoteMapper.toEntities(snapshot); + + expect(devices[0].id).toEqual('androidtv_remote.device.aa_bb_cc_dd_ee_ff'); + expect(devices[0].metadata?.protocol).toEqual('androidtvremote2'); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'volume' && stateArg.value === 70)).toBeTrue(); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'activity' && stateArg.value === 'Netflix')).toBeTrue(); + expect(entities[0].platform).toEqual('media_player'); + expect(entities[0].state).toEqual('playing'); + expect(entities[0].attributes?.source).toEqual('Netflix'); + expect(entities[0].attributes?.volumeLevel).toEqual(0.7); + expect((entities[0].attributes?.activityList as string[]).includes('Plex')).toBeTrue(); +}); + +tap.test('maps Android TV Remote power-off snapshots to off state', async () => { + const entities = AndroidtvRemoteMapper.toEntities({ + ...snapshot, + state: { + available: true, + isOn: false, + mediaState: 'off', + }, + }); + + expect(entities[0].state).toEqual('off'); +}); + +export default tap.start(); diff --git a/test/androidtv_remote/test.androidtv_remote.runtime.node.ts b/test/androidtv_remote/test.androidtv_remote.runtime.node.ts new file mode 100644 index 0000000..2599085 --- /dev/null +++ b/test/androidtv_remote/test.androidtv_remote.runtime.node.ts @@ -0,0 +1,68 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AndroidtvRemoteIntegration, type IAndroidtvRemoteCommand, type IAndroidtvRemoteConfig } from '../../ts/integrations/androidtv_remote/index.js'; + +const baseConfig = (commandsArg: IAndroidtvRemoteCommand[] = []): IAndroidtvRemoteConfig => ({ + host: '192.168.1.61', + snapshot: { + deviceInfo: { + name: 'Living Room Google TV', + host: '192.168.1.61', + macAddress: 'AA:BB:CC:DD:EE:FF', + }, + state: { + available: true, + isOn: false, + currentApp: 'com.google.android.tvlauncher', + volumeInfo: { + level: 5, + max: 10, + muted: false, + }, + }, + apps: [ + { id: 'com.netflix.ninja' }, + { id: 'com.google.android.tvlauncher', name: 'Launcher' }, + ], + }, + executor: async (commandArg) => { + commandsArg.push(commandArg); + }, +}); + +tap.test('models media, app, and remote commands through injected executor', async () => { + const commands: IAndroidtvRemoteCommand[] = []; + const runtime = await new AndroidtvRemoteIntegration().setup(baseConfig(commands), {}); + + expect((await runtime.callService?.({ domain: 'media_player', service: 'turn_on', target: {} }))?.success).toBeTrue(); + expect(commands[0].action).toEqual('key_command'); + expect(commands[0].reason).toEqual('turn_on'); + expect(commands[0].keyCode).toEqual('POWER'); + expect(commands[0].direction).toEqual('SHORT'); + + expect((await runtime.callService?.({ domain: 'media_player', service: 'select_source', target: {}, data: { source: 'Netflix' } }))?.success).toBeTrue(); + expect(commands[1].action).toEqual('launch_app'); + expect(commands[1].reason).toEqual('select_activity'); + expect(commands[1].appId).toEqual('com.netflix.ninja'); + + expect((await runtime.callService?.({ domain: 'remote', service: 'send_command', target: {}, data: { command: ['left', 'center'], num_repeats: 2, hold_secs: 0.25 } }))?.success).toBeTrue(); + expect(commands[2].action).toEqual('remote_send_command'); + expect(commands[2].repeats).toEqual(2); + expect(commands[2].holdSecs).toEqual(0.25); + expect(commands[2].keys?.map((keyArg) => keyArg.keyCode)).toEqual(['DPAD_LEFT', 'DPAD_LEFT', 'DPAD_CENTER', 'DPAD_CENTER']); + expect(commands[2].keys?.map((keyArg) => keyArg.direction)).toEqual(['START_LONG', 'END_LONG', 'START_LONG', 'END_LONG']); + + expect((await runtime.callService?.({ domain: 'androidtv_remote', service: 'finish_pairing', target: {}, data: { pin: '123456' } }))?.success).toBeTrue(); + expect(commands[3].action).toEqual('finish_pairing'); + expect(commands[3].reason).toEqual('finish_pairing'); + expect(commands[3].pin).toEqual('123456'); +}); + +tap.test('returns explicit unsupported errors without an executor', async () => { + const runtime = await new AndroidtvRemoteIntegration().setup({ host: '192.168.1.61' }, {}); + const result = await runtime.callService?.({ domain: 'media_player', service: 'volume_up', target: {} }); + + expect(result?.success).toBeFalse(); + expect(result?.error).toContain('requires an injected executor'); +}); + +export default tap.start(); diff --git a/test/arcam_fmj/test.arcam_fmj.discovery.node.ts b/test/arcam_fmj/test.arcam_fmj.discovery.node.ts new file mode 100644 index 0000000..7a16bab --- /dev/null +++ b/test/arcam_fmj/test.arcam_fmj.discovery.node.ts @@ -0,0 +1,41 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createArcamFmjDiscoveryDescriptor } from '../../ts/integrations/arcam_fmj/index.js'; + +tap.test('matches Arcam FMJ SSDP media renderer records', async () => { + const descriptor = createArcamFmjDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + st: 'urn:schemas-upnp-org:device:MediaRenderer:1', + usn: 'uuid:2f402f80-da50-11e1-9b23-001788abcdef::urn:schemas-upnp-org:device:MediaRenderer:1', + location: 'http://192.168.1.60:8080/dd.xml', + upnp: { + manufacturer: 'ARCAM', + modelName: 'AVR20', + friendlyName: 'Living Room Arcam', + deviceType: 'urn:schemas-upnp-org:device:MediaRenderer:1', + UDN: 'uuid:2f402f80-da50-11e1-9b23-001788abcdef', + }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('arcam_fmj'); + expect(result.candidate?.host).toEqual('192.168.1.60'); + expect(result.candidate?.port).toEqual(50000); + expect(result.normalizedDeviceId).toEqual('001788abcdef'); +}); + +tap.test('matches and validates manual Arcam FMJ entries', async () => { + const descriptor = createArcamFmjDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[1]; + const result = await matcher.matches({ host: '192.168.1.61', name: 'Cinema Arcam', model: 'AVR850' }, {}); + expect(result.matched).toBeTrue(); + expect(result.candidate?.port).toEqual(50000); + + const validator = descriptor.getValidators()[0]; + const validation = await validator.validate(result.candidate!, {}); + expect(validation.matched).toBeTrue(); + expect(validation.confidence).toEqual('high'); + expect(validation.normalizedDeviceId).toEqual('192.168.1.61:50000'); +}); + +export default tap.start(); diff --git a/test/arcam_fmj/test.arcam_fmj.mapper.node.ts b/test/arcam_fmj/test.arcam_fmj.mapper.node.ts new file mode 100644 index 0000000..62b3621 --- /dev/null +++ b/test/arcam_fmj/test.arcam_fmj.mapper.node.ts @@ -0,0 +1,74 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ArcamFmjMapper, type IArcamFmjSnapshot } from '../../ts/integrations/arcam_fmj/index.js'; + +const snapshot: IArcamFmjSnapshot = { + deviceInfo: { + host: '192.168.1.60', + port: 50000, + name: 'Living Room Arcam', + manufacturer: 'Arcam', + model: 'AVR20', + revision: '1.2.3', + uniqueId: 'arcam-abc123', + apiModel: 'APIHDA_SERIES', + }, + zones: [{ + zone: 1, + name: 'Main Zone', + power: true, + volume: 50, + muted: false, + source: 'BD', + sourceList: ['BD', 'SAT', 'FM', 'DAB', 'NET'], + soundMode: 'DOLBY_SURROUND', + media: { + title: 'Blu-ray', + contentType: 'video', + }, + available: true, + }, { + zone: 2, + name: 'Patio', + power: false, + volumeLevel: 0.25, + muted: true, + source: 'FM', + media: { + title: 'FM - Radio One', + channel: 'Radio One', + contentType: 'music', + contentId: 'preset:4', + }, + available: true, + }], + online: true, + source: 'snapshot', + lastUpdated: '2026-01-01T00:00:00.000Z', +}; + +tap.test('maps Arcam FMJ receiver zones to canonical devices', async () => { + const devices = ArcamFmjMapper.toDevices(snapshot); + expect(devices.length).toEqual(2); + expect(devices[0].id).toEqual('arcam_fmj.receiver.arcam_abc123'); + expect(devices[0].manufacturer).toEqual('Arcam'); + expect(devices[0].state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'BD')).toBeTrue(); + expect(devices[1].metadata?.viaDeviceId).toEqual('arcam_fmj.receiver.arcam_abc123'); + expect(devices[1].state.some((stateArg) => stateArg.featureId === 'power' && stateArg.value === 'off')).toBeTrue(); +}); + +tap.test('maps Arcam FMJ zones to media player entities', async () => { + const entities = ArcamFmjMapper.toEntities(snapshot); + const main = entities.find((entityArg) => entityArg.id === 'media_player.living_room_arcam'); + const zone2 = entities.find((entityArg) => entityArg.id === 'media_player.living_room_arcam_zone_2'); + + expect(main?.platform).toEqual('media_player'); + expect(main?.state).toEqual('on'); + expect(main?.attributes?.volumeLevel).toEqual(50 / 99); + expect(main?.attributes?.source).toEqual('BD'); + expect(main?.attributes?.soundMode).toEqual('DOLBY_SURROUND'); + expect(zone2?.state).toEqual('off'); + expect(zone2?.attributes?.mediaChannel).toEqual('Radio One'); + expect(zone2?.attributes?.mediaContentId).toEqual('preset:4'); +}); + +export default tap.start(); diff --git a/test/arcam_fmj/test.arcam_fmj.runtime.node.ts b/test/arcam_fmj/test.arcam_fmj.runtime.node.ts new file mode 100644 index 0000000..2cab4db --- /dev/null +++ b/test/arcam_fmj/test.arcam_fmj.runtime.node.ts @@ -0,0 +1,85 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { ArcamFmjIntegration, type IArcamFmjModeledCommand, type IArcamFmjSnapshot } from '../../ts/integrations/arcam_fmj/index.js'; + +const snapshot: IArcamFmjSnapshot = { + deviceInfo: { + name: 'Living Room Arcam', + model: 'AVR450', + uniqueId: 'arcam-abc123', + apiModel: 'API450_SERIES', + }, + zones: [{ + zone: 1, + power: true, + source: 'BD', + available: true, + }, { + zone: 2, + power: true, + source: 'FM', + available: true, + }], + online: true, +}; + +tap.test('models Arcam FMJ volume services through an explicit executor', async () => { + const executed: IArcamFmjModeledCommand[] = []; + const integration = new ArcamFmjIntegration(); + const runtime = await integration.setup({ + snapshot, + commandExecutor: { + execute: async (commandArg) => { + executed.push(commandArg); + return { accepted: true }; + }, + }, + }, {}); + + const result = await runtime.callService!({ + domain: 'media_player', + service: 'volume_set', + target: { entityId: 'media_player.living_room_arcam_zone_2' }, + data: { volume_level: 0.25 }, + }); + + expect(result.success).toBeTrue(); + expect(executed[0].zone).toEqual(2); + expect(executed[0].commandCodeName).toEqual('VOLUME'); + expect(executed[0].data).toEqual([25]); + expect(executed[0].responseExpected).toBeTrue(); +}); + +tap.test('models Arcam FMJ source and power commands without pretending TCP success', async () => { + const executed: IArcamFmjModeledCommand[] = []; + const integration = new ArcamFmjIntegration(); + const runtimeWithExecutor = await integration.setup({ + snapshot, + commandExecutor: { + execute: async (commandArg) => { + executed.push(commandArg); + }, + }, + }, {}); + + const sourceResult = await runtimeWithExecutor.callService!({ + domain: 'media_player', + service: 'select_source', + target: { entityId: 'media_player.living_room_arcam' }, + data: { source: 'BD' }, + }); + expect(sourceResult.success).toBeTrue(); + expect(executed[0].commandCodeName).toEqual('SIMULATE_RC5_IR_COMMAND'); + expect(executed[0].data).toEqual([16, 4]); + expect(executed[0].usesRc5).toBeTrue(); + + const runtimeWithoutExecutor = await integration.setup({ snapshot }, {}); + const turnOnResult = await runtimeWithoutExecutor.callService!({ + domain: 'media_player', + service: 'turn_on', + target: { entityId: 'media_player.living_room_arcam' }, + }); + expect(turnOnResult.success).toBeFalse(); + expect(turnOnResult.error).toContain('config.host or commandExecutor'); +}); + +export default tap.start(); diff --git a/test/asuswrt/test.asuswrt.client.node.ts b/test/asuswrt/test.asuswrt.client.node.ts new file mode 100644 index 0000000..ec2a7f5 --- /dev/null +++ b/test/asuswrt/test.asuswrt.client.node.ts @@ -0,0 +1,47 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { HomeAssistantAsuswrtIntegration, type IAsuswrtCommand, type IAsuswrtConfig } from '../../ts/integrations/asuswrt/index.js'; + +const config: IAsuswrtConfig = { + host: '192.168.1.1', + protocol: 'ssh', + snapshot: { + connected: true, + router: { + name: 'SSH Router', + host: '192.168.1.1', + protocol: 'ssh', + labelMac: 'AA:BB:CC:DD:EE:FF', + actions: ['reboot'], + }, + devices: [], + interfaces: [], + sensors: {}, + }, +}; + +tap.test('does not fake SSH/Telnet command success without injected executor', async () => { + const runtime = await new HomeAssistantAsuswrtIntegration().setup(config, {}); + const result = await runtime.callService!({ domain: 'asuswrt', service: 'reboot', target: {} }); + + expect(result.success).toBeFalse(); + expect(result.error || '').toInclude('not faked'); + await runtime.destroy(); +}); + +tap.test('executes explicit commands through injected executor', async () => { + let command: IAsuswrtCommand | undefined; + const runtime = await new HomeAssistantAsuswrtIntegration().setup({ + ...config, + commandExecutor: async (commandArg) => { + command = commandArg; + return { success: true, data: { accepted: true } }; + }, + }, {}); + const result = await runtime.callService!({ domain: 'asuswrt', service: 'reboot', target: {} }); + + expect(result.success).toBeTrue(); + expect(command?.type).toEqual('router.reboot'); + await runtime.destroy(); +}); + +export default tap.start(); diff --git a/test/asuswrt/test.asuswrt.discovery.node.ts b/test/asuswrt/test.asuswrt.discovery.node.ts new file mode 100644 index 0000000..adeb1b3 --- /dev/null +++ b/test/asuswrt/test.asuswrt.discovery.node.ts @@ -0,0 +1,55 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AsuswrtConfigFlow, createAsuswrtDiscoveryDescriptor } from '../../ts/integrations/asuswrt/index.js'; + +tap.test('matches and validates manual ASUSWRT router entries', async () => { + const descriptor = createAsuswrtDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const result = await matcher.matches({ + host: '192.168.1.1', + name: 'Main Router', + model: 'RT-AX88U', + macAddress: 'AA-BB-CC-DD-EE-FF', + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.candidate?.integrationDomain).toEqual('asuswrt'); + expect(result.candidate?.port).toEqual(8443); + 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?.liveSshTelnetImplemented).toBeFalse(); +}); + +tap.test('accepts snapshot-only manual setup and rejects unrelated entries', async () => { + const descriptor = createAsuswrtDiscoveryDescriptor(); + const matcher = descriptor.getMatchers()[0]; + const snapshotResult = await matcher.matches({ + snapshot: { + connected: true, + router: { name: 'Snapshot Router', labelMac: 'AABBCCDDEEFF' }, + 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 ASUSWRT config without claiming SSH/Telnet live support', async () => { + const flow = new AsuswrtConfigFlow(); + const step = await flow.start({ source: 'manual', host: '192.168.1.1', metadata: { protocol: 'ssh' } }, {}); + const done = await step.submit!({ host: '192.168.1.1', protocol: 'ssh', username: 'admin', mode: 'ap' }); + + expect(done.kind).toEqual('done'); + expect(done.config?.protocol).toEqual('ssh'); + expect(done.config?.mode).toEqual('ap'); + expect(done.config?.metadata?.liveSshTelnetImplemented).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/asuswrt/test.asuswrt.mapper.node.ts b/test/asuswrt/test.asuswrt.mapper.node.ts new file mode 100644 index 0000000..ec0ab65 --- /dev/null +++ b/test/asuswrt/test.asuswrt.mapper.node.ts @@ -0,0 +1,92 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { AsuswrtMapper, type IAsuswrtSnapshot } from '../../ts/integrations/asuswrt/index.js'; + +const snapshot: IAsuswrtSnapshot = { + connected: true, + updatedAt: '2026-01-01T00:00:00.000Z', + router: { + host: '192.168.1.1', + protocol: 'https', + name: 'Main Router', + model: 'RT-AX88U', + firmware: '3.0.0.4', + labelMac: 'AA:BB:CC:DD:EE:FF', + actions: ['reboot'], + }, + devices: [ + { + mac: '11:22:33:44:55:66', + name: 'Kitchen Phone', + ipAddress: '192.168.1.40', + connected: true, + connectedTo: 'wl0.1', + actions: ['reconnect'], + }, + ], + interfaces: [ + { + name: 'eth0', + label: 'WAN', + connected: true, + rxBytes: 2_000_000_000, + txBytes: 1_000_000_000, + rxRate: 250_000, + txRate: 125_000, + }, + ], + sensors: { + sensor_connected_device: 1, + sensor_rx_bytes: 2_000_000_000, + sensor_tx_bytes: 1_000_000_000, + sensor_rx_rates: 250_000, + sensor_tx_rates: 125_000, + sensor_load_avg1: 0.12, + '2.4GHz': 42, + CPU: 61, + mem_usage_perc: 35, + mem_free: 262_144, + sensor_uptime: 3600, + }, + actions: [], +}; + +tap.test('maps ASUSWRT router, tracker, interface, and traffic sensors', async () => { + const devices = AsuswrtMapper.toDevices(snapshot); + const entities = AsuswrtMapper.toEntities(snapshot); + + expect(devices.some((deviceArg) => deviceArg.id === 'asuswrt.router.aa_bb_cc_dd_ee_ff')).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'asuswrt.client.11_22_33_44_55_66')).toBeTrue(); + expect(entities.find((entityArg) => entityArg.id === 'sensor.main_router_download')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'sensor.main_router_download_speed')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'sensor.main_router_wan_download')?.state).toEqual(2); + expect(entities.find((entityArg) => entityArg.id === 'sensor.main_router_wan_upload_speed')?.state).toEqual(1); + expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.id === 'sensor.main_router_memory_free')?.state).toEqual(256); +}); + +tap.test('maps only explicitly represented ASUSWRT actions to commands', async () => { + const rebootCommand = AsuswrtMapper.commandForService(snapshot, { + domain: 'asuswrt', + service: 'reboot', + target: {}, + }); + const reconnectCommand = AsuswrtMapper.commandForService(snapshot, { + domain: 'asuswrt', + service: 'reconnect_device', + target: {}, + data: { mac: '11-22-33-44-55-66' }, + }); + const unsupportedBlock = AsuswrtMapper.commandForService(snapshot, { + domain: 'asuswrt', + service: 'block_device', + target: {}, + data: { mac: '11:22:33:44:55:66' }, + }); + + expect(rebootCommand?.type).toEqual('router.reboot'); + expect(reconnectCommand?.type).toEqual('client.action'); + expect(reconnectCommand?.mac).toEqual('11:22:33:44:55:66'); + expect(unsupportedBlock).toBeUndefined(); +}); + +export default tap.start(); diff --git a/test/bluetooth_le_tracker/test.bluetooth_le_tracker.configflow.node.ts b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.configflow.node.ts new file mode 100644 index 0000000..18d21df --- /dev/null +++ b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.configflow.node.ts @@ -0,0 +1,26 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BluetoothLeTrackerConfigFlow } from '../../ts/integrations/bluetooth_le_tracker/index.js'; + +tap.test('builds static scanner config from a Bluetooth candidate', async () => { + const flow = new BluetoothLeTrackerConfigFlow(); + const step = await flow.start({ + source: 'bluetooth', + macAddress: 'AA:BB:CC:DD:EE:FF', + name: 'Backpack Tag', + metadata: { + advertisement: { address: 'AA:BB:CC:DD:EE:FF', rssi: -64 }, + }, + }, {}); + const done = await step.submit!({ + trackNewDevices: false, + trackBattery: true, + scanIntervalSeconds: 12, + }); + + expect(done.kind).toEqual('done'); + expect(done.config?.knownDevices?.[0].address).toEqual('aa:bb:cc:dd:ee:ff'); + expect(done.config?.knownDevices?.[0].trackBattery).toBeTrue(); + expect(done.config?.trackNewDevices).toBeFalse(); +}); + +export default tap.start(); diff --git a/test/bluetooth_le_tracker/test.bluetooth_le_tracker.discovery.node.ts b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.discovery.node.ts new file mode 100644 index 0000000..4bc48ab --- /dev/null +++ b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.discovery.node.ts @@ -0,0 +1,45 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { createBluetoothLeTrackerDiscoveryDescriptor } from '../../ts/integrations/bluetooth_le_tracker/index.js'; + +tap.test('matches Bluetooth LE advertisements and manual BLE entries', async () => { + const descriptor = createBluetoothLeTrackerDiscoveryDescriptor(); + const bluetoothMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'bluetooth-le-tracker-bluetooth-match'); + const manualMatcher = descriptor.getMatchers().find((matcherArg) => matcherArg.id === 'bluetooth-le-tracker-manual-match'); + + const bluetoothResult = await bluetoothMatcher!.matches({ + address: 'AA-BB-CC-DD-EE-FF', + name: 'Backpack Tag\x00', + rssi: -61, + connectable: false, + serviceUuids: ['0000180f-0000-1000-8000-00805f9b34fb'], + manufacturerData: { '76': [1, 2, 3] }, + }, {}); + const manualResult = await manualMatcher!.matches({ + mac: 'BLE_11:22:33:44:55:66', + name: 'Keys Beacon', + track: true, + trackBattery: true, + }, {}); + + expect(bluetoothResult.matched).toBeTrue(); + expect(bluetoothResult.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff'); + expect(bluetoothResult.candidate?.metadata?.haMac).toEqual('BLE_AA:BB:CC:DD:EE:FF'); + expect(manualResult.matched).toBeTrue(); + expect(manualResult.candidate?.macAddress).toEqual('11:22:33:44:55:66'); +}); + +tap.test('validates Bluetooth LE tracker candidates', async () => { + const validator = createBluetoothLeTrackerDiscoveryDescriptor().getValidators()[0]; + const result = await validator.validate({ + source: 'bluetooth', + id: 'aabbccddeeff', + name: 'BLE tracker tag', + metadata: { sourceType: 'bluetooth_le' }, + }, {}); + + expect(result.matched).toBeTrue(); + expect(result.confidence).toEqual('high'); + expect(result.candidate?.integrationDomain).toEqual('bluetooth_le_tracker'); +}); + +export default tap.start(); diff --git a/test/bluetooth_le_tracker/test.bluetooth_le_tracker.mapper.node.ts b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.mapper.node.ts new file mode 100644 index 0000000..2d03759 --- /dev/null +++ b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.mapper.node.ts @@ -0,0 +1,43 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BluetoothLeTrackerMapper } from '../../ts/integrations/bluetooth_le_tracker/index.js'; + +const lastSeen = Date.now(); +const repeatedAdvertisements = Array.from({ length: 5 }, (_, indexArg) => ({ + address: 'AA:BB:CC:DD:EE:FF', + name: indexArg === 4 ? 'Backpack Tag' : undefined, + rssi: -60 - indexArg, + battery: 87, + connectable: false, + time: lastSeen + indexArg, +})); + +tap.test('maps tracked BLE advertisements into devices and device-tracker-like entities', async () => { + const snapshot = BluetoothLeTrackerMapper.toSnapshot({ + trackNewDevices: true, + trackBattery: true, + advertisements: repeatedAdvertisements, + knownDevices: [{ mac: 'BLE_11:22:33:44:55:66', name: 'Ignored Beacon', track: false }], + }); + const devices = BluetoothLeTrackerMapper.toDevices(snapshot); + const entities = BluetoothLeTrackerMapper.toEntities(snapshot); + + expect(snapshot.devices.length).toEqual(1); + expect(snapshot.devices[0].address).toEqual('aa:bb:cc:dd:ee:ff'); + expect(snapshot.devices[0].advertisementCount).toEqual(5); + expect(devices[0].id).toEqual('bluetooth_le_tracker.device.aa_bb_cc_dd_ee_ff'); + expect(devices[0].metadata?.haMac).toEqual('BLE_AA:BB:CC:DD:EE:FF'); + expect(entities.find((entityArg) => entityArg.uniqueId === 'bluetooth_le_tracker_presence_aa_bb_cc_dd_ee_ff')?.state).toEqual('on'); + expect(entities.find((entityArg) => entityArg.uniqueId === 'bluetooth_le_tracker_state_aa_bb_cc_dd_ee_ff')?.state).toEqual('home'); + expect(entities.find((entityArg) => entityArg.uniqueId === 'bluetooth_le_tracker_battery_aa_bb_cc_dd_ee_ff')?.state).toEqual(87); +}); + +tap.test('keeps new BLE devices untracked until the HA sighting threshold is reached', async () => { + const snapshot = BluetoothLeTrackerMapper.toSnapshot({ + trackNewDevices: true, + advertisements: repeatedAdvertisements.slice(0, 4), + }); + + expect(snapshot.devices.length).toEqual(0); +}); + +export default tap.start(); diff --git a/test/bluetooth_le_tracker/test.bluetooth_le_tracker.runtime.node.ts b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.runtime.node.ts new file mode 100644 index 0000000..dd67675 --- /dev/null +++ b/test/bluetooth_le_tracker/test.bluetooth_le_tracker.runtime.node.ts @@ -0,0 +1,34 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import { BluetoothLeTrackerIntegration } from '../../ts/integrations/bluetooth_le_tracker/index.js'; + +tap.test('returns a clear unsupported response for live scans without injected data', async () => { + const runtime = await new BluetoothLeTrackerIntegration().setup({}, {}); + const result = await runtime.callService!({ + domain: 'bluetooth_le_tracker', + service: 'scan', + target: {}, + }); + + expect(result.success).toBeFalse(); + expect(String(result.error).includes('Live scanning is not implemented') || String(result.error).includes('live scanning is not implemented')).toBeTrue(); +}); + +tap.test('accepts injected scan data and refreshes runtime devices', async () => { + const runtime = await new BluetoothLeTrackerIntegration().setup({ minSeenNew: 1 }, {}); + const result = await runtime.callService!({ + domain: 'bluetooth_le_tracker', + service: 'scan', + target: {}, + data: { + advertisements: [{ address: 'AA:BB:CC:DD:EE:FF', name: 'Backpack Tag', rssi: -59, time: Date.now() }], + }, + }); + const devices = await runtime.devices(); + const entities = await runtime.entities(); + + expect(result.success).toBeTrue(); + expect(devices.some((deviceArg) => deviceArg.id === 'bluetooth_le_tracker.device.aa_bb_cc_dd_ee_ff')).toBeTrue(); + expect(entities.some((entityArg) => entityArg.uniqueId === 'bluetooth_le_tracker_presence_aa_bb_cc_dd_ee_ff' && entityArg.state === 'on')).toBeTrue(); +}); + +export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index e76f9ce..791fc78 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -3,12 +3,18 @@ export * from './protocols/index.js'; export * from './integrations/index.js'; import { HueIntegration } from './integrations/hue/index.js'; +import { AdguardIntegration } from './integrations/adguard/index.js'; import { AirgradientIntegration } from './integrations/airgradient/index.js'; +import { AmcrestIntegration } from './integrations/amcrest/index.js'; import { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js'; import { AndroidtvIntegration } from './integrations/androidtv/index.js'; +import { AndroidtvRemoteIntegration } from './integrations/androidtv_remote/index.js'; import { AxisIntegration } from './integrations/axis/index.js'; import { ApcupsdIntegration } from './integrations/apcupsd/index.js'; +import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js'; +import { AsuswrtIntegration } from './integrations/asuswrt/index.js'; import { BleboxIntegration } from './integrations/blebox/index.js'; +import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js'; import { BraviatvIntegration } from './integrations/braviatv/index.js'; import { BroadlinkIntegration } from './integrations/broadlink/index.js'; import { CastIntegration } from './integrations/cast/index.js'; @@ -53,12 +59,18 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated import { IntegrationRegistry } from './core/index.js'; export const integrations = [ + new AdguardIntegration(), new AirgradientIntegration(), + new AmcrestIntegration(), new AndroidIpWebcamIntegration(), new AndroidtvIntegration(), + new AndroidtvRemoteIntegration(), new ApcupsdIntegration(), + new ArcamFmjIntegration(), + new AsuswrtIntegration(), new AxisIntegration(), new BleboxIntegration(), + new BluetoothLeTrackerIntegration(), new BraviatvIntegration(), new BroadlinkIntegration(), new CastIntegration(), diff --git a/ts/integrations/adguard/.generated-by-smarthome-exchange b/ts/integrations/adguard/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/adguard/.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/adguard/adguard.classes.client.ts b/ts/integrations/adguard/adguard.classes.client.ts new file mode 100644 index 0000000..e415351 --- /dev/null +++ b/ts/integrations/adguard/adguard.classes.client.ts @@ -0,0 +1,216 @@ +import type { + IAdguardBooleanStatus, + IAdguardClientCommand, + IAdguardCommandResult, + IAdguardConfig, + IAdguardFilteringStatus, + IAdguardQueryLogConfig, + IAdguardSafeSearchConfig, + IAdguardServerStatus, + IAdguardSnapshot, + IAdguardStats, + IAdguardVersionInfo, + TAdguardJsonValue, + TAdguardSnapshotSource, +} from './adguard.types.js'; +import { adguardDefaultPort, adguardDefaultTimeoutMs } from './adguard.types.js'; + +export class AdguardClient { + constructor(private readonly config: IAdguardConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot), 'snapshot'); + } + + if (this.config.host) { + try { + return this.normalizeSnapshot(await this.fetchSnapshot(), 'http'); + } catch (errorArg) { + return this.normalizeSnapshot(this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg)), 'runtime'); + } + } + + return this.normalizeSnapshot(this.snapshotFromConfig(false), 'manual'); + } + + public async refresh(): Promise { + return this.getSnapshot(); + } + + public async ping(): Promise { + if (!this.config.host) { + return Boolean(this.config.snapshot); + } + const status = await this.requestJson('/status'); + return status.running !== false; + } + + public async sendCommand(commandArg: IAdguardClientCommand): Promise { + if (this.config.commandExecutor) { + return this.commandResult(await this.config.commandExecutor(commandArg), commandArg); + } + + if (!this.config.host) { + return { + success: false, + error: 'AdGuard Home live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.', + data: { command: commandArg }, + }; + } + + const data = await this.requestJson(commandArg.path, { + method: commandArg.method, + body: commandArg.payload, + }); + return { success: true, data: { command: commandArg, response: data } }; + } + + public async destroy(): Promise {} + + private async fetchSnapshot(): Promise { + const status = await this.requestJson('/status'); + const [filtering, queryLog, safebrowsing, safesearch, parental, stats, update] = await Promise.all([ + this.requestJson('/filtering/status').catch(() => ({})), + this.requestJson('/querylog/config').catch(() => this.requestJson('/querylog_info').catch(() => ({}))), + this.requestJson('/safebrowsing/status').catch(() => ({})), + this.requestJson('/safesearch/status').catch(() => ({})), + this.requestJson('/parental/status').catch(() => ({})), + this.requestJson('/stats').catch(() => ({})), + this.requestJson('/version.json', { method: 'POST', body: { recheck_now: false } }).catch(() => undefined), + ]); + + return { + online: status.running !== false, + status, + filtering, + queryLog, + safebrowsing, + safesearch, + parental, + stats, + update, + host: this.config.host, + port: this.config.port || status.http_port || adguardDefaultPort, + ssl: this.config.ssl ?? false, + basePath: this.config.basePath, + name: this.config.name, + uniqueId: this.config.uniqueId, + updatedAt: new Date().toISOString(), + source: 'http', + }; + } + + private async requestJson(pathArg: string, optionsArg: { method?: 'GET' | 'POST' | 'PUT'; body?: unknown } = {}): Promise { + const method = optionsArg.method || 'GET'; + const headers: Record = {}; + let body: string | undefined; + if (optionsArg.body !== undefined) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(optionsArg.body); + } + 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(`${this.controlUrl()}${pathArg}`, { + method, + headers, + body, + signal: AbortSignal.timeout(this.config.timeoutMs || adguardDefaultTimeoutMs), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`AdGuard Home request ${pathArg} failed with HTTP ${response.status}: ${text}`); + } + if (!text) { + return {} as TResponse; + } + return JSON.parse(text) as TResponse; + } + + private controlUrl(): string { + if (!this.config.host) { + throw new Error('AdGuard Home host is required for HTTP API access.'); + } + const protocol = this.config.ssl ? 'https' : 'http'; + const port = this.config.port || adguardDefaultPort; + const defaultPort = protocol === 'https' ? 443 : 80; + const portPart = port === defaultPort ? '' : `:${port}`; + const basePath = this.normalizeBasePath(this.config.basePath); + return `${protocol}://${this.config.host}${portPart}${basePath}/control`; + } + + private normalizeBasePath(valueArg: string | undefined): string { + if (!valueArg) { + return ''; + } + const trimmed = valueArg.trim().replace(/\/+$/g, ''); + if (!trimmed || trimmed === '/') { + return ''; + } + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; + } + + private snapshotFromConfig(onlineArg: boolean, errorArg?: string): IAdguardSnapshot { + return { + online: onlineArg, + status: { + running: onlineArg, + version: typeof this.config.metadata?.version === 'string' ? this.config.metadata.version : undefined, + }, + filtering: {}, + queryLog: {}, + safebrowsing: {}, + safesearch: {}, + parental: {}, + stats: {}, + host: this.config.host, + port: this.config.port || (this.config.host ? adguardDefaultPort : undefined), + ssl: this.config.ssl ?? false, + basePath: this.config.basePath, + name: this.config.name, + uniqueId: this.config.uniqueId, + updatedAt: new Date().toISOString(), + source: 'runtime', + error: errorArg, + }; + } + + private normalizeSnapshot(snapshotArg: IAdguardSnapshot, sourceArg: TAdguardSnapshotSource): IAdguardSnapshot { + return { + ...snapshotArg, + online: snapshotArg.online, + status: snapshotArg.status || {}, + filtering: snapshotArg.filtering || {}, + queryLog: snapshotArg.queryLog || {}, + safebrowsing: snapshotArg.safebrowsing || {}, + safesearch: snapshotArg.safesearch || {}, + parental: snapshotArg.parental || {}, + stats: snapshotArg.stats || {}, + host: snapshotArg.host || this.config.host, + port: snapshotArg.port || this.config.port || (snapshotArg.host || this.config.host ? adguardDefaultPort : undefined), + ssl: snapshotArg.ssl ?? this.config.ssl ?? false, + basePath: snapshotArg.basePath ?? this.config.basePath, + name: snapshotArg.name || this.config.name, + uniqueId: snapshotArg.uniqueId || this.config.uniqueId, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + source: snapshotArg.source || sourceArg, + }; + } + + private cloneSnapshot(snapshotArg: IAdguardSnapshot): IAdguardSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IAdguardSnapshot; + } + + private commandResult(resultArg: unknown, commandArg: IAdguardClientCommand): IAdguardCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IAdguardCommandResult { + return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; + } +} diff --git a/ts/integrations/adguard/adguard.classes.configflow.ts b/ts/integrations/adguard/adguard.classes.configflow.ts new file mode 100644 index 0000000..2fb9c3e --- /dev/null +++ b/ts/integrations/adguard/adguard.classes.configflow.ts @@ -0,0 +1,100 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IAdguardConfig, IAdguardSnapshot } from './adguard.types.js'; +import { adguardDefaultPort } from './adguard.types.js'; + +export class AdguardConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Set up AdGuard Home', + description: 'Connect to a local AdGuard Home HTTP API. This flow validates configuration shape and keeps live command success tied to a real HTTP client or command executor.', + fields: [ + { name: 'host', label: 'Host or URL', type: 'text', required: true }, + { name: 'port', label: 'Port', type: 'number' }, + { name: 'ssl', label: 'Use HTTPS', type: 'boolean' }, + { name: 'verifySsl', label: 'Verify TLS certificate', type: 'boolean' }, + { name: 'basePath', label: 'Base path', type: 'text' }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + ], + submit: async (valuesArg) => { + const parsed = parseHostInput(stringValue(valuesArg.host) || candidateArg.host || ''); + if (!parsed.host) { + return { kind: 'error', title: 'Invalid AdGuard Home host', error: 'AdGuard Home setup requires a hostname, IP address, or URL.' }; + } + + const port = numberValue(valuesArg.port) || parsed.port || candidateArg.port || adguardDefaultPort; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return { kind: 'error', title: 'Invalid AdGuard Home port', error: 'AdGuard Home port must be an integer between 1 and 65535.' }; + } + + const username = stringValue(valuesArg.username); + const password = stringValue(valuesArg.password); + if (Boolean(username) !== Boolean(password)) { + return { kind: 'error', title: 'Incomplete AdGuard Home credentials', error: 'AdGuard Home username and password must be provided together.' }; + } + + const ssl = booleanValue(valuesArg.ssl) ?? parsed.ssl ?? booleanValue(candidateArg.metadata?.ssl) ?? false; + const verifySsl = booleanValue(valuesArg.verifySsl) ?? booleanValue(candidateArg.metadata?.verifySsl) ?? true; + const basePath = stringValue(valuesArg.basePath) || parsed.basePath || stringValue(candidateArg.metadata?.basePath); + + return { + kind: 'done', + title: 'AdGuard Home configured', + config: { + host: parsed.host, + port, + ssl, + verifySsl, + basePath, + username, + password, + name: candidateArg.name || 'AdGuard Home', + uniqueId: candidateArg.id, + snapshot: isAdguardSnapshot(candidateArg.metadata?.snapshot) ? candidateArg.metadata.snapshot : undefined, + }, + }; + }, + }; + } +} + +const parseHostInput = (valueArg: string): { host?: string; port?: number; ssl?: boolean; basePath?: string } => { + const value = valueArg.trim(); + if (!value) { + return {}; + } + if (!value.includes('://')) { + return { host: value }; + } + try { + const parsed = new URL(value); + return { + host: parsed.hostname, + port: parsed.port ? Number(parsed.port) : undefined, + ssl: parsed.protocol === 'https:', + basePath: parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : undefined, + }; + } catch { + return {}; + } +}; + +const stringValue = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +}; + +const numberValue = (valueArg: unknown): number | undefined => { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) return valueArg; + if (typeof valueArg === 'string' && valueArg.trim() && Number.isFinite(Number(valueArg))) return Number(valueArg); + return undefined; +}; + +const booleanValue = (valueArg: unknown): boolean | undefined => { + return typeof valueArg === 'boolean' ? valueArg : undefined; +}; + +const isAdguardSnapshot = (valueArg: unknown): valueArg is IAdguardSnapshot => { + return typeof valueArg === 'object' && valueArg !== null && 'status' in valueArg && 'online' in valueArg; +}; diff --git a/ts/integrations/adguard/adguard.classes.integration.ts b/ts/integrations/adguard/adguard.classes.integration.ts index cdbb873..d3fe19f 100644 --- a/ts/integrations/adguard/adguard.classes.integration.ts +++ b/ts/integrations/adguard/adguard.classes.integration.ts @@ -1,26 +1,83 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { AdguardClient } from './adguard.classes.client.js'; +import { AdguardConfigFlow } from './adguard.classes.configflow.js'; +import { createAdguardDiscoveryDescriptor } from './adguard.discovery.js'; +import { AdguardMapper } from './adguard.mapper.js'; +import type { IAdguardConfig } from './adguard.types.js'; -export class HomeAssistantAdguardIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "adguard", - displayName: "AdGuard Home", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/adguard", - "upstreamDomain": "adguard", - "integrationType": "service", - "iotClass": "local_polling", - "requirements": [ - "adguardhome==0.8.1" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@frenck" - ] -}, - }); +export class AdguardIntegration extends BaseIntegration { + public readonly domain = 'adguard'; + public readonly displayName = 'AdGuard Home'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAdguardDiscoveryDescriptor(); + public readonly configFlow = new AdguardConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/adguard', + upstreamDomain: 'adguard', + integrationType: 'service', + iotClass: 'local_polling', + requirements: ['adguardhome==0.8.1'], + dependencies: [] as string[], + afterDependencies: [] as string[], + codeowners: ['@frenck'], + documentation: 'https://www.home-assistant.io/integrations/adguard', + protocolSource: 'AdGuard Home HTTP API under /control: status, filtering, querylog, safebrowsing, safesearch, parental, stats, version, and update endpoints.', + runtime: { + type: 'control-runtime', + polling: 'local HTTP AdGuard Home API', + services: ['snapshot', 'status', 'add_url', 'remove_url', 'enable_url', 'disable_url', 'refresh'], + controls: ['protection', 'filtering', 'querylog', 'safebrowsing', 'safesearch', 'parental'], + liveCommandSuccessRequiresClientOrExecutor: true, + }, + }; + + public async setup(configArg: IAdguardConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AdguardRuntime(new AdguardClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAdguardIntegration extends AdguardIntegration {} + +class AdguardRuntime implements IIntegrationRuntime { + public domain = 'adguard'; + + constructor(private readonly client: AdguardClient) {} + + public async devices(): Promise { + return AdguardMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AdguardMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'adguard' && (requestArg.service === 'snapshot' || requestArg.service === 'status')) { + return { success: true, data: await this.client.getSnapshot() }; + } + + const snapshot = await this.client.getSnapshot(); + const command = AdguardMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported AdGuard Home 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/adguard/adguard.discovery.ts b/ts/integrations/adguard/adguard.discovery.ts new file mode 100644 index 0000000..498d3e9 --- /dev/null +++ b/ts/integrations/adguard/adguard.discovery.ts @@ -0,0 +1,94 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IAdguardManualEntry, IAdguardSnapshot } from './adguard.types.js'; +import { adguardDefaultPort } from './adguard.types.js'; + +export class AdguardManualMatcher implements IDiscoveryMatcher { + public id = 'adguard-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual AdGuard Home setup entries by host, domain, manufacturer, model, or metadata.'; + + public async matches(inputArg: IAdguardManualEntry): Promise { + const matched = isAdguardHint(inputArg) || Boolean(inputArg.host); + if (!matched) { + return { + matched: false, + confidence: 'low', + reason: 'Manual entry does not contain AdGuard Home setup hints.', + }; + } + const id = inputArg.id || inputArg.host && `${inputArg.host}:${inputArg.port || adguardDefaultPort}`; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start AdGuard Home setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: 'adguard', + id, + host: inputArg.host, + port: inputArg.port || adguardDefaultPort, + name: inputArg.name || 'AdGuard Home', + manufacturer: 'AdGuard Team', + model: inputArg.model || 'AdGuard Home', + metadata: { + ...inputArg.metadata, + ssl: inputArg.ssl, + verifySsl: inputArg.verifySsl, + basePath: inputArg.basePath, + snapshot: inputArg.snapshot, + }, + }, + }; + } +} + +export class AdguardCandidateValidator implements IDiscoveryValidator { + public id = 'adguard-candidate-validator'; + public description = 'Validate AdGuard Home candidates before starting local HTTP setup.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const matched = isAdguardHint(candidateArg) || candidateArg.integrationDomain === 'adguard'; + const snapshot = candidateArg.metadata?.snapshot; + const hasSnapshot = isAdguardSnapshot(snapshot); + const hasUsableAddress = Boolean(candidateArg.host && isValidPort(candidateArg.port || adguardDefaultPort)); + return { + matched: matched && (hasUsableAddress || hasSnapshot), + confidence: matched && candidateArg.id ? 'certain' : matched && hasUsableAddress ? 'high' : matched ? 'medium' : 'low', + reason: matched + ? hasUsableAddress || hasSnapshot ? 'Candidate has AdGuard Home metadata and a usable HTTP address or snapshot.' : 'Candidate has AdGuard Home metadata but no usable HTTP address.' + : 'Candidate is not AdGuard Home.', + candidate: matched && (hasUsableAddress || hasSnapshot) ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.host && `${candidateArg.host}:${candidateArg.port || adguardDefaultPort}`, + }; + } +} + +export const createAdguardDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ + integrationDomain: 'adguard', + displayName: 'AdGuard Home', + }) + .addMatcher(new AdguardManualMatcher()) + .addValidator(new AdguardCandidateValidator()); +}; + +const isAdguardHint = (valueArg: { integrationDomain?: string; manufacturer?: string; model?: string; name?: string; metadata?: Record }): boolean => { + const manufacturer = valueArg.manufacturer?.toLowerCase() || ''; + const model = valueArg.model?.toLowerCase() || ''; + const name = valueArg.name?.toLowerCase() || ''; + return valueArg.integrationDomain === 'adguard' + || manufacturer.includes('adguard') + || model.includes('adguard') + || name.includes('adguard') + || Boolean(valueArg.metadata?.adguard); +}; + +const isValidPort = (valueArg: number): boolean => { + return Number.isInteger(valueArg) && valueArg > 0 && valueArg <= 65535; +}; + +const isAdguardSnapshot = (valueArg: unknown): valueArg is IAdguardSnapshot => { + return typeof valueArg === 'object' && valueArg !== null && 'status' in valueArg && 'online' in valueArg; +}; diff --git a/ts/integrations/adguard/adguard.mapper.ts b/ts/integrations/adguard/adguard.mapper.ts new file mode 100644 index 0000000..436e933 --- /dev/null +++ b/ts/integrations/adguard/adguard.mapper.ts @@ -0,0 +1,401 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest } from '../../core/types.js'; +import type { + IAdguardClientCommand, + IAdguardFilterSubscription, + IAdguardQueryLogConfig, + IAdguardSafeSearchConfig, + IAdguardSnapshot, + TAdguardSwitchKey, +} from './adguard.types.js'; + +const adguardDomain = 'adguard'; + +const switchDescriptions: Array<{ key: TAdguardSwitchKey; name: string }> = [ + { key: 'protection', name: 'Protection' }, + { key: 'parental', name: 'Parental control' }, + { key: 'safesearch', name: 'Safe search' }, + { key: 'safebrowsing', name: 'Safe browsing' }, + { key: 'filtering', name: 'Filtering' }, + { key: 'querylog', name: 'Query log' }, +]; + +const sensorDescriptions: Array<{ key: string; name: string; unit?: string; value: (snapshotArg: IAdguardSnapshot) => number | string | null }> = [ + { key: 'dns_queries', name: 'DNS queries', unit: 'queries', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_dns_queries) ?? sum(snapshotArg.stats.dns_queries) }, + { key: 'blocked_filtering', name: 'DNS queries blocked', unit: 'queries', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_blocked_filtering) ?? sum(snapshotArg.stats.blocked_filtering) }, + { key: 'blocked_percentage', name: 'DNS queries blocked ratio', unit: '%', value: (snapshotArg) => blockedPercentage(snapshotArg) }, + { key: 'blocked_parental', name: 'Parental control blocked', unit: 'requests', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_replaced_parental) ?? sum(snapshotArg.stats.replaced_parental) }, + { key: 'blocked_safebrowsing', name: 'Safe browsing blocked', unit: 'requests', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_replaced_safebrowsing) ?? sum(snapshotArg.stats.replaced_safebrowsing) }, + { key: 'enforced_safesearch', name: 'Safe searches enforced', unit: 'requests', value: (snapshotArg) => numberOrNull(snapshotArg.stats.num_replaced_safesearch) ?? sum(snapshotArg.stats.replaced_safesearch) }, + { key: 'average_speed', name: 'Average processing speed', unit: 'ms', value: (snapshotArg) => rounded(numberOrNull(snapshotArg.stats.avg_processing_time) === null ? null : numberOrNull(snapshotArg.stats.avg_processing_time)! * 1000) }, + { key: 'rules_count', name: 'Rules count', unit: 'rules', value: (snapshotArg) => rulesCount(snapshotArg.filtering.filters) }, +]; + +export class AdguardMapper { + public static toDevices(snapshotArg: IAdguardSnapshot): 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: 'running', capability: 'sensor', name: 'Running', readable: true, writable: false }, + { id: 'version', capability: 'sensor', name: 'Version', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.online ? 'online' : 'offline', updatedAt }, + { featureId: 'running', value: snapshotArg.status.running ?? snapshotArg.online, updatedAt }, + { featureId: 'version', value: snapshotArg.status.version || null, updatedAt }, + ]; + + for (const description of switchDescriptions) { + features.push({ id: description.key, capability: 'switch', name: description.name, readable: true, writable: true }); + state.push({ featureId: description.key, value: this.switchState(snapshotArg, description.key), updatedAt }); + } + + for (const sensor of sensorDescriptions) { + features.push({ id: sensor.key, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit }); + state.push({ featureId: sensor.key, value: sensor.value(snapshotArg), updatedAt }); + } + + return [{ + id: deviceId, + integrationDomain: adguardDomain, + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: 'AdGuard Team', + model: 'AdGuard Home', + online: snapshotArg.online, + features, + state, + metadata: this.cleanAttributes({ + host: snapshotArg.host, + port: snapshotArg.port, + basePath: snapshotArg.basePath, + version: snapshotArg.status.version, + language: snapshotArg.status.language, + dnsPort: snapshotArg.status.dns_port, + httpPort: snapshotArg.status.http_port, + source: snapshotArg.source, + error: snapshotArg.error, + }), + }]; + } + + public static toEntities(snapshotArg: IAdguardSnapshot): 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} Running`, deviceId, `${uniqueBase}_running`, snapshotArg.status.running ?? snapshotArg.online ? 'on' : 'off', snapshotArg.online, { + deviceClass: 'running', + host: snapshotArg.host, + port: snapshotArg.port, + }), + ]; + + if (snapshotArg.status.version) { + entities.push(this.entity('sensor', `${baseName} Version`, deviceId, `${uniqueBase}_version`, snapshotArg.status.version, snapshotArg.online, { + entityCategory: 'diagnostic', + })); + } + + for (const description of switchDescriptions) { + const state = this.switchState(snapshotArg, description.key); + entities.push({ + id: `switch.${baseSlug}_${this.slug(description.name)}`, + uniqueId: `adguard_${uniqueBase}_switch_${description.key}`, + integrationDomain: adguardDomain, + deviceId, + platform: 'switch', + name: `${baseName} ${description.name}`, + state: state ? 'on' : 'off', + attributes: this.cleanAttributes({ + adguardSwitchKey: description.key, + writable: true, + }), + available: snapshotArg.online, + }); + } + + for (const sensor of sensorDescriptions) { + const value = sensor.value(snapshotArg); + entities.push(this.entity('sensor', `${baseName} ${sensor.name}`, deviceId, `${uniqueBase}_sensor_${sensor.key}`, value, snapshotArg.online && value !== null, { + unit: sensor.unit, + stateClass: typeof value === 'number' ? 'measurement' : undefined, + entityRegistryEnabledDefault: sensor.key === 'rules_count' ? false : undefined, + })); + } + + if (snapshotArg.update && !snapshotArg.update.disabled) { + entities.push(this.entity('update', baseName, deviceId, `${uniqueBase}_update`, snapshotArg.update.new_version && snapshotArg.update.new_version !== snapshotArg.status.version ? 'on' : 'off', snapshotArg.online, { + installedVersion: snapshotArg.status.version, + latestVersion: snapshotArg.update.new_version, + releaseSummary: snapshotArg.update.announcement, + releaseUrl: snapshotArg.update.announcement_url, + canAutoupdate: snapshotArg.update.can_autoupdate, + })); + } + + return entities; + } + + public static commandForService(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): IAdguardClientCommand | { error: string } | undefined { + if (requestArg.domain === adguardDomain) { + return this.adguardServiceCommand(snapshotArg, requestArg); + } + + if (requestArg.domain === 'switch' && ['turn_on', 'turn_off'].includes(requestArg.service)) { + const target = this.targetSwitch(snapshotArg, requestArg); + if ('error' in target) { + return target; + } + return this.switchCommand(snapshotArg, requestArg.service, target.key, requestArg, target.entity); + } + + if (requestArg.domain === 'update' && requestArg.service === 'install') { + const entity = this.findTargetEntity(snapshotArg, requestArg); + if (!entity || entity.platform !== 'update') { + return { error: 'AdGuard update.install requires the AdGuard update entity target.' }; + } + return { + type: 'begin_update', + service: 'install', + method: 'POST', + path: '/update', + target: requestArg.target, + entityId: entity.id, + deviceId: entity.deviceId, + uniqueId: entity.uniqueId, + }; + } + + return undefined; + } + + public static deviceId(snapshotArg: IAdguardSnapshot): string { + return `adguard.service.${this.uniqueBase(snapshotArg)}`; + } + + public static switchState(snapshotArg: IAdguardSnapshot, keyArg: TAdguardSwitchKey): boolean { + if (keyArg === 'protection') return Boolean(snapshotArg.status.protection_enabled); + if (keyArg === 'filtering') return Boolean(snapshotArg.filtering.enabled); + if (keyArg === 'querylog') return Boolean(snapshotArg.queryLog.enabled); + if (keyArg === 'safebrowsing') return this.booleanStatus(snapshotArg.safebrowsing); + if (keyArg === 'safesearch') return Boolean(snapshotArg.safesearch.enabled); + return this.booleanStatus(snapshotArg.parental); + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'adguard'; + } + + private static adguardServiceCommand(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): IAdguardClientCommand | { error: string } | undefined { + if (requestArg.service === 'add_url') { + const name = stringValue(requestArg.data?.name); + const url = stringValue(requestArg.data?.url); + if (!name || !url || !isUrlOrAbsolutePath(url)) { + return { error: 'AdGuard add_url requires data.name and data.url as a URL or absolute path.' }; + } + return this.command('add_filter_url', 'add_url', 'POST', '/filtering/add_url', requestArg, { name, url, whitelist: false }, { name, url }); + } + + if (requestArg.service === 'remove_url') { + const url = stringValue(requestArg.data?.url); + if (!url || !isUrlOrAbsolutePath(url)) { + return { error: 'AdGuard remove_url requires data.url as a URL or absolute path.' }; + } + return this.command('remove_filter_url', 'remove_url', 'POST', '/filtering/remove_url', requestArg, { url, whitelist: false }, { url }); + } + + if (requestArg.service === 'enable_url' || requestArg.service === 'disable_url') { + const url = stringValue(requestArg.data?.url); + if (!url || !isUrlOrAbsolutePath(url)) { + return { error: 'AdGuard enable_url/disable_url requires data.url as a URL or absolute path.' }; + } + const filter = this.filterByUrl(snapshotArg, url); + if (!filter) { + return { error: `AdGuard filter URL is not present in the current filtering snapshot: ${url}` }; + } + const enabled = requestArg.service === 'enable_url'; + return this.command('set_filter_url_enabled', requestArg.service, 'POST', '/filtering/set_url', requestArg, { + url, + whitelist: false, + data: { + name: filter.name || url, + url, + enabled, + }, + }, { url, name: filter.name || url, enabled }); + } + + if (requestArg.service === 'refresh') { + const force = booleanValue(requestArg.data?.force) ?? false; + return this.command('refresh_filters', 'refresh', 'POST', '/filtering/refresh', requestArg, { whitelist: false, force }, { force }); + } + + return undefined; + } + + private static switchCommand(snapshotArg: IAdguardSnapshot, serviceArg: string, keyArg: TAdguardSwitchKey, requestArg: IServiceCallRequest, entityArg: IIntegrationEntity | undefined): IAdguardClientCommand { + const enabled = serviceArg === 'turn_on'; + if (keyArg === 'protection') { + return this.command('set_protection', serviceArg, 'POST', '/protection', requestArg, { enabled }, { enabled, switchKey: keyArg, entity: entityArg }); + } + if (keyArg === 'filtering') { + return this.command('set_filtering', serviceArg, 'POST', '/filtering/config', requestArg, { enabled }, { enabled, switchKey: keyArg, entity: entityArg }); + } + if (keyArg === 'querylog') { + return this.command('set_querylog', serviceArg, 'PUT', '/querylog/config/update', requestArg, this.queryLogPayload(snapshotArg.queryLog, enabled), { enabled, switchKey: keyArg, entity: entityArg }); + } + if (keyArg === 'safebrowsing') { + return this.command('set_safebrowsing', serviceArg, 'POST', `/safebrowsing/${enabled ? 'enable' : 'disable'}`, requestArg, undefined, { enabled, switchKey: keyArg, entity: entityArg }); + } + if (keyArg === 'safesearch') { + return this.command('set_safesearch', serviceArg, 'PUT', '/safesearch/settings', requestArg, this.safeSearchPayload(snapshotArg.safesearch, enabled), { enabled, switchKey: keyArg, entity: entityArg }); + } + return this.command('set_parental', serviceArg, 'POST', `/parental/${enabled ? 'enable' : 'disable'}`, requestArg, undefined, { enabled, switchKey: keyArg, entity: entityArg }); + } + + private static targetSwitch(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): { key: TAdguardSwitchKey; entity?: IIntegrationEntity } | { error: string } { + const entity = this.findTargetEntity(snapshotArg, requestArg); + const key = entity?.attributes?.adguardSwitchKey; + if (isSwitchKey(key)) { + return { key, entity }; + } + if (requestArg.target.deviceId && requestArg.target.deviceId === this.deviceId(snapshotArg)) { + return { error: 'AdGuard switch service calls require a switch entity target because the AdGuard device has multiple switches.' }; + } + return { error: 'AdGuard switch service calls require an AdGuard switch entity target.' }; + } + + private static command(typeArg: IAdguardClientCommand['type'], serviceArg: string, methodArg: IAdguardClientCommand['method'], pathArg: string, requestArg: IServiceCallRequest, payloadArg?: Record, optionsArg: { entity?: IIntegrationEntity; enabled?: boolean; switchKey?: TAdguardSwitchKey; url?: string; name?: string; force?: boolean } = {}): IAdguardClientCommand { + return this.cleanAttributes({ + type: typeArg, + service: serviceArg, + method: methodArg, + path: pathArg, + payload: payloadArg, + target: requestArg.target, + entityId: optionsArg.entity?.id || requestArg.target.entityId, + deviceId: optionsArg.entity?.deviceId || requestArg.target.deviceId, + uniqueId: optionsArg.entity?.uniqueId, + switchKey: optionsArg.switchKey, + enabled: optionsArg.enabled, + url: optionsArg.url, + name: optionsArg.name, + force: optionsArg.force, + }) as IAdguardClientCommand; + } + + private static findTargetEntity(snapshotArg: IAdguardSnapshot, requestArg: IServiceCallRequest): IIntegrationEntity | undefined { + if (!requestArg.target.entityId) { + return undefined; + } + return this.toEntities(snapshotArg).find((entityArg) => entityArg.id === requestArg.target.entityId); + } + + private static filterByUrl(snapshotArg: IAdguardSnapshot, urlArg: string): IAdguardFilterSubscription | undefined { + return (snapshotArg.filtering.filters || []).find((filterArg) => filterArg.url === urlArg); + } + + private static queryLogPayload(configArg: IAdguardQueryLogConfig, enabledArg: boolean): Record { + return { + enabled: enabledArg, + interval: configArg.interval ?? 7776000000, + anonymize_client_ip: configArg.anonymize_client_ip ?? false, + ignored: configArg.ignored || [], + ignored_enabled: configArg.ignored_enabled ?? false, + }; + } + + private static safeSearchPayload(configArg: IAdguardSafeSearchConfig, enabledArg: boolean): Record { + return { + ...configArg, + enabled: enabledArg, + }; + } + + 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: `adguard_${uniqueIdArg}`, + integrationDomain: adguardDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static deviceName(snapshotArg: IAdguardSnapshot): string { + return snapshotArg.name || 'AdGuard Home'; + } + + private static uniqueBase(snapshotArg: IAdguardSnapshot): string { + return this.slug(snapshotArg.uniqueId || snapshotArg.host && `${snapshotArg.host}_${snapshotArg.port || ''}` || this.deviceName(snapshotArg)); + } + + private static booleanStatus(valueArg: { enabled?: boolean; enable?: boolean }): boolean { + return Boolean(valueArg.enabled ?? valueArg.enable); + } + + private static cleanAttributes>(attributesArg: T): T { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)) as T; + } +} + +const numberOrNull = (valueArg: unknown): number | null => { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : null; +}; + +const sum = (valuesArg: number[] | undefined): number => { + return (valuesArg || []).reduce((totalArg, valueArg) => totalArg + (Number.isFinite(valueArg) ? valueArg : 0), 0); +}; + +const rounded = (valueArg: number | null): number | null => { + return valueArg === null ? null : Math.round(valueArg * 100) / 100; +}; + +const rulesCount = (filtersArg: IAdguardFilterSubscription[] | undefined): number => { + return (filtersArg || []).reduce((totalArg, filterArg) => totalArg + (typeof filterArg.rules_count === 'number' ? filterArg.rules_count : 0), 0); +}; + +const blockedPercentage = (snapshotArg: IAdguardSnapshot): number => { + const queries = numberOrNull(snapshotArg.stats.num_dns_queries) ?? sum(snapshotArg.stats.dns_queries); + if (!queries) { + return 0; + } + const blocked = numberOrNull(snapshotArg.stats.num_blocked_filtering) ?? sum(snapshotArg.stats.blocked_filtering); + return rounded(blocked / queries * 100) || 0; +}; + +const stringValue = (valueArg: unknown): string | undefined => { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; +}; + +const booleanValue = (valueArg: unknown): boolean | undefined => { + return typeof valueArg === 'boolean' ? valueArg : undefined; +}; + +const isUrlOrAbsolutePath = (valueArg: string): boolean => { + if (valueArg.startsWith('/')) { + return true; + } + try { + const parsed = new URL(valueArg); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +}; + +const isSwitchKey = (valueArg: unknown): valueArg is TAdguardSwitchKey => { + return valueArg === 'protection' + || valueArg === 'filtering' + || valueArg === 'querylog' + || valueArg === 'safebrowsing' + || valueArg === 'safesearch' + || valueArg === 'parental'; +}; diff --git a/ts/integrations/adguard/adguard.types.ts b/ts/integrations/adguard/adguard.types.ts index 2251b5d..a61b1a6 100644 --- a/ts/integrations/adguard/adguard.types.ts +++ b/ts/integrations/adguard/adguard.types.ts @@ -1,4 +1,198 @@ -export interface IHomeAssistantAdguardConfig { - // TODO: replace with the TypeScript-native config for adguard. +import type { IServiceCallResult } from '../../core/types.js'; + +export const adguardDefaultPort = 3000; +export const adguardDefaultTimeoutMs = 10000; + +export type TAdguardJsonValue = string | number | boolean | null | TAdguardJsonValue[] | { + [key: string]: TAdguardJsonValue | undefined; +}; + +export type TAdguardSnapshotSource = 'snapshot' | 'http' | 'manual' | 'runtime'; + +export type TAdguardHttpMethod = 'GET' | 'POST' | 'PUT'; + +export type TAdguardSwitchKey = 'protection' | 'filtering' | 'querylog' | 'safebrowsing' | 'safesearch' | 'parental'; + +export type TAdguardCommandType = + | 'set_protection' + | 'set_filtering' + | 'set_querylog' + | 'set_safebrowsing' + | 'set_safesearch' + | 'set_parental' + | 'add_filter_url' + | 'remove_filter_url' + | 'set_filter_url_enabled' + | 'refresh_filters' + | 'begin_update'; + +export interface IAdguardServerStatus { + dns_addresses?: string[]; + dns_port?: number; + http_port?: number; + protection_enabled?: boolean; + protection_disabled_until?: string | null; + protection_disabled_duration?: number; + dhcp_available?: boolean; + running?: boolean; + version?: string; + language?: string; + start_time?: number; + [key: string]: TAdguardJsonValue | undefined; +} + +export interface IAdguardFilterSubscription { + enabled?: boolean; + id?: number; + name?: string; + rules_count?: number; + url?: string; + last_updated?: string; + [key: string]: TAdguardJsonValue | undefined; +} + +export interface IAdguardFilteringStatus { + enabled?: boolean; + interval?: number; + filters?: IAdguardFilterSubscription[]; + whitelist_filters?: IAdguardFilterSubscription[]; + user_rules?: string[]; + [key: string]: TAdguardJsonValue | IAdguardFilterSubscription[] | string[] | undefined; +} + +export interface IAdguardQueryLogConfig { + enabled?: boolean; + interval?: number; + anonymize_client_ip?: boolean; + ignored?: string[]; + ignored_enabled?: boolean; + [key: string]: TAdguardJsonValue | string[] | undefined; +} + +export interface IAdguardBooleanStatus { + enabled?: boolean; + enable?: boolean; + sensitivity?: number; + [key: string]: TAdguardJsonValue | undefined; +} + +export interface IAdguardSafeSearchConfig { + enabled?: boolean; + bing?: boolean; + duckduckgo?: boolean; + ecosia?: boolean; + google?: boolean; + pixabay?: boolean; + yandex?: boolean; + youtube?: boolean; + [key: string]: TAdguardJsonValue | undefined; +} + +export interface IAdguardStats { + time_units?: 'hours' | 'days' | string; + num_dns_queries?: number; + num_blocked_filtering?: number; + num_replaced_safebrowsing?: number; + num_replaced_safesearch?: number; + num_replaced_parental?: number; + avg_processing_time?: number; + dns_queries?: number[]; + blocked_filtering?: number[]; + replaced_safebrowsing?: number[]; + replaced_parental?: number[]; + replaced_safesearch?: number[]; + [key: string]: TAdguardJsonValue | number[] | undefined; +} + +export interface IAdguardVersionInfo { + disabled?: boolean; + new_version?: string; + announcement?: string; + announcement_url?: string; + can_autoupdate?: boolean; + [key: string]: TAdguardJsonValue | undefined; +} + +export interface IAdguardSnapshot { + online: boolean; + status: IAdguardServerStatus; + filtering: IAdguardFilteringStatus; + queryLog: IAdguardQueryLogConfig; + safebrowsing: IAdguardBooleanStatus; + safesearch: IAdguardSafeSearchConfig; + parental: IAdguardBooleanStatus; + stats: IAdguardStats; + update?: IAdguardVersionInfo; + host?: string; + port?: number; + ssl?: boolean; + basePath?: string; + name?: string; + uniqueId?: string; + updatedAt?: string; + source?: TAdguardSnapshotSource; + error?: string; +} + +export interface IAdguardClientCommand { + type: TAdguardCommandType; + service: string; + method: TAdguardHttpMethod; + path: string; + payload?: Record; + target?: { + entityId?: string; + deviceId?: string; + }; + entityId?: string; + deviceId?: string; + uniqueId?: string; + switchKey?: TAdguardSwitchKey; + enabled?: boolean; + url?: string; + name?: string; + force?: boolean; +} + +export interface IAdguardCommandResult extends IServiceCallResult {} + +export type TAdguardCommandExecutor = ( + commandArg: IAdguardClientCommand +) => Promise | IAdguardCommandResult | unknown; + +export interface IAdguardConfig { + host?: string; + port?: number; + ssl?: boolean; + verifySsl?: boolean; + username?: string; + password?: string; + basePath?: string; + name?: string; + uniqueId?: string; + timeoutMs?: number; + snapshot?: IAdguardSnapshot; + commandExecutor?: TAdguardCommandExecutor; + metadata?: Record; [key: string]: unknown; } + +export interface IAdguardManualEntry { + host?: string; + port?: number; + ssl?: boolean; + verifySsl?: boolean; + basePath?: string; + username?: string; + password?: string; + id?: string; + name?: string; + model?: string; + manufacturer?: string; + snapshot?: IAdguardSnapshot; + metadata?: Record; + integrationDomain?: string; + [key: string]: unknown; +} + +export interface IHomeAssistantAdguardConfig extends IAdguardConfig {} diff --git a/ts/integrations/adguard/index.ts b/ts/integrations/adguard/index.ts index fc509d3..38e9be0 100644 --- a/ts/integrations/adguard/index.ts +++ b/ts/integrations/adguard/index.ts @@ -1,2 +1,6 @@ export * from './adguard.classes.integration.js'; +export * from './adguard.classes.client.js'; +export * from './adguard.classes.configflow.js'; +export * from './adguard.discovery.js'; +export * from './adguard.mapper.js'; export * from './adguard.types.js'; diff --git a/ts/integrations/amcrest/.generated-by-smarthome-exchange b/ts/integrations/amcrest/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/amcrest/.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/amcrest/amcrest.classes.client.ts b/ts/integrations/amcrest/amcrest.classes.client.ts new file mode 100644 index 0000000..1710d6a --- /dev/null +++ b/ts/integrations/amcrest/amcrest.classes.client.ts @@ -0,0 +1,950 @@ +import * as plugins from '../../plugins.js'; +import type { + IAmcrestBinarySensor, + IAmcrestCamera, + IAmcrestClientCommand, + IAmcrestCommandResponse, + IAmcrestConfig, + IAmcrestDeviceInfo, + IAmcrestEvent, + IAmcrestHttpCommand, + IAmcrestSensor, + IAmcrestSnapshot, + IAmcrestSnapshotImage, + IAmcrestSwitch, + TAmcrestAuthScheme, + TAmcrestColorBw, + TAmcrestProtocol, + TAmcrestPtzMovement, + TAmcrestResolution, + TAmcrestStreamSource, +} from './amcrest.types.js'; +import { + amcrestBinarySensorDescriptions, + amcrestColorModes, + amcrestDefaultPort, + amcrestDefaultRtspPort, + amcrestDefaultSnapshotTimeoutMs, + amcrestDefaultTimeoutMs, + amcrestResolutionSubtype, + amcrestSensorDescriptions, + amcrestSubtypeStream, + amcrestSwitchDescriptions, +} from './amcrest.types.js'; + +const ptzCodes: Record = { + zoom_out: 'ZoomWide', + zoom_in: 'ZoomTele', + right: 'Right', + left: 'Left', + up: 'Up', + down: 'Down', + right_down: 'RightDown', + right_up: 'RightUp', + left_down: 'LeftDown', + left_up: 'LeftUp', +}; + +const ptzMoveOneArg2 = new Set(['Right', 'Left', 'Up', 'Down']); +const ptzMoveBothArgs = new Set(['RightDown', 'RightUp', 'LeftDown', 'LeftUp']); + +export class AmcrestHttpError extends Error { + constructor(public readonly status: number, messageArg: string) { + super(messageArg); + this.name = 'AmcrestHttpError'; + } +} + +export class AmcrestClient { + private snapshot?: IAmcrestSnapshot; + + constructor(private readonly config: IAmcrestConfig) {} + + 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.requestText('/cgi-bin/magicBox.cgi?action=getDeviceType'); + } + + public async execute(commandArg: IAmcrestClientCommand): 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, + channel: camera.channel, + resolution: commandArg.resolution || camera.resolution, + streamSource: commandArg.streamSource || camera.streamSource, + streamSourceUrl: this.streamSourceUrl(camera, commandArg.streamSource), + snapshotUrl: camera.snapshotUrl, + mjpegUrl: camera.mjpegUrl, + rtspUrl: camera.rtspUrl, + verified: false, + }; + } + if (commandArg.type === 'snapshot_image') { + if (commandArg.filename) { + throw new Error('Amcrest snapshot file writes are not implemented; request data as base64 without data.filename.'); + } + const image = await this.getSnapshotImage(commandArg.channel); + return { + contentType: image.contentType, + dataBase64: Buffer.from(image.data).toString('base64'), + }; + } + if (commandArg.type === 'set_privacy_mode') { + const enabled = this.requireEnabled(commandArg); + const response = await this.setBooleanConfig('privacy_mode', enabled, commandArg.channel); + this.patchCachedSwitch('privacy_mode', enabled); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'set_video') { + const enabled = this.requireEnabled(commandArg); + if (!enabled && this.snapshot?.cameras.some((cameraArg) => cameraArg.isRecording)) { + await this.setRecordMode(false, commandArg.channel); + } + const response = await this.setBooleanConfig('video', enabled, commandArg.channel); + this.patchCachedCamera({ isStreaming: enabled }); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'set_recording') { + const enabled = this.requireEnabled(commandArg); + if (enabled && this.snapshot?.cameras.some((cameraArg) => cameraArg.isStreaming === false)) { + await this.setBooleanConfig('video', true, commandArg.channel); + } + const response = await this.setRecordMode(enabled, commandArg.channel); + this.patchCachedCamera({ isRecording: enabled }); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'set_audio') { + const enabled = this.requireEnabled(commandArg); + const response = await this.setBooleanConfig('audio', enabled, commandArg.channel); + this.patchCachedCamera({ audioEnabled: enabled }); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'set_motion_detection') { + const enabled = this.requireEnabled(commandArg); + const response = await this.setBooleanConfig('motion_detection', enabled, commandArg.channel); + this.patchCachedCamera({ motionDetectionEnabled: enabled }); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'set_motion_recording') { + const enabled = this.requireEnabled(commandArg); + const response = await this.setBooleanConfig('motion_recording', enabled, commandArg.channel); + this.patchCachedCamera({ motionRecordingEnabled: enabled }); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'set_color_bw') { + if (!commandArg.colorBw) { + throw new Error('Amcrest set_color_bw requires a supported color_bw value.'); + } + const response = await this.setColorBw(commandArg.colorBw, commandArg.channel); + this.patchCachedCamera({ colorBw: commandArg.colorBw }); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'goto_preset') { + if (typeof commandArg.preset !== 'number' || !Number.isFinite(commandArg.preset) || commandArg.preset < 1) { + throw new Error('Amcrest goto_preset requires a positive preset number.'); + } + const response = await this.gotoPreset(commandArg.preset, commandArg.channel); + return { ok: true, command: commandArg.type, responses: [response] }; + } + if (commandArg.type === 'ptz_control') { + if (!commandArg.movement) { + throw new Error('Amcrest ptz_control requires a movement value.'); + } + const responses = await this.ptzControl(commandArg.movement, commandArg.travelTime, commandArg.channel); + return { ok: true, command: commandArg.type, responses }; + } + if (commandArg.type === 'start_tour' || commandArg.type === 'stop_tour') { + const response = await this.tour(commandArg.type === 'start_tour', commandArg.channel); + return { ok: true, command: commandArg.type, responses: [response] }; + } + throw new Error(`Unsupported Amcrest command: ${commandArg.type}`); + } + + public async getSnapshotImage(channelArg?: number): Promise { + const response = await this.request(this.snapshotHttpCommand(channelArg).path, {}, this.config.snapshotTimeoutMs || amcrestDefaultSnapshotTimeoutMs); + return { + contentType: response.headers.get('content-type') || 'image/jpeg', + data: new Uint8Array(await response.arrayBuffer()), + }; + } + + public async destroy(): Promise {} + + private async fetchLiveSnapshot(): Promise { + const channel = this.channel(); + const subtype = this.subtype(); + const streamFormat = this.streamFormat(subtype); + const deviceTypeText = await this.requestText('/cgi-bin/magicBox.cgi?action=getDeviceType'); + const [vendorText, serialText, encodeText, recordText, motionText, privacyText, motionEventText, audioMutationEventText, audioIntensityEventText, crosslineEventText, presetText, storageText, colorText] = await Promise.all([ + this.requestText('/cgi-bin/magicBox.cgi?action=getVendor').catch(() => undefined), + this.requestText('/cgi-bin/magicBox.cgi?action=getSerialNo').catch(() => undefined), + this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=Encode').catch(() => undefined), + this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=RecordMode').catch(() => undefined), + this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=MotionDetect').catch(() => undefined), + this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=LeLensMask').catch(() => undefined), + this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=VideoMotion').catch(() => undefined), + this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=AudioMutation').catch(() => undefined), + this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=AudioIntensity').catch(() => undefined), + this.requestText('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=CrossLineDetection').catch(() => undefined), + this.requestText(`/cgi-bin/ptz.cgi?action=getPresets&channel=${channel}`).catch(() => undefined), + this.requestText('/cgi-bin/storage.cgi?action=getDeviceAllInfo').catch(() => undefined), + this.requestText('/cgi-bin/configManager.cgi?action=getConfig&name=VideoInMode').catch(() => undefined), + ]); + const encodeValues = parseKeyValues(encodeText); + const recordValues = parseKeyValues(recordText); + const motionValues = parseKeyValues(motionText); + const privacyValues = parseKeyValues(privacyText); + const colorValues = parseKeyValues(colorText); + const model = firstPlainValue(deviceTypeText) || this.config.model; + const serialNumber = firstPlainValue(serialText) || this.config.uniqueId; + const manufacturer = firstPlainValue(vendorText) || this.config.manufacturer || 'Amcrest'; + const connected = true; + const deviceInfo = this.deviceInfo(connected, { manufacturer, model, serialNumber }); + const recordMode = valueBySuffix(recordValues, [`RecordMode[${channel}].Mode`, 'RecordMode.Mode', 'Mode']); + const storage = this.storageFromText(storageText); + const camera = this.camera(deviceInfo, connected, { + isStreaming: booleanValue(valueBySuffix(encodeValues, [`Encode[${channel}].${streamFormat}[0].VideoEnable`, `${streamFormat}[0].VideoEnable`, 'VideoEnable'])) ?? true, + isRecording: recordMode === 'Manual' || recordMode === '1', + motionDetectionEnabled: booleanValue(valueBySuffix(motionValues, [`MotionDetect[${channel}].Enable`, 'MotionDetect.Enable', 'Enable'])), + audioEnabled: booleanValue(valueBySuffix(encodeValues, [`Encode[${channel}].${streamFormat}[0].AudioEnable`, `${streamFormat}[0].AudioEnable`, 'AudioEnable'])), + motionRecordingEnabled: booleanValue(valueBySuffix(motionValues, [`MotionDetect[${channel}].EventHandler.RecordEnable`, 'EventHandler.RecordEnable', 'RecordEnable'])), + colorBw: this.colorModeFromValue(valueBySuffix(colorValues, [`VideoInMode[${channel}].Config[0]`, 'VideoInMode.Config[0]', 'Config[0]'])), + }); + const currentSettings = { + ...this.config.currentSettings, + privacy_mode: booleanValue(valueBySuffix(privacyValues, [`LeLensMask[${channel}].Enable`, 'LeLensMask.Enable', 'Enable'])) ?? false, + }; + const eventStates = { + VideoMotion: this.eventTextIsOn(motionEventText), + AudioMutation: this.eventTextIsOn(audioMutationEventText), + AudioIntensity: this.eventTextIsOn(audioIntensityEventText), + CrossLineDetection: this.eventTextIsOn(crosslineEventText), + }; + const sensors = this.config.sensors || this.sensorsFromLive(this.presetCount(presetText), storage, connected); + const binarySensors = this.config.binarySensors || this.binarySensorsFromEvents(eventStates, connected); + const switches = this.config.switches || this.switchesFromSettings(currentSettings, connected); + + return this.normalizeSnapshot({ + deviceInfo, + cameras: this.config.cameras || [camera], + sensors, + binarySensors, + switches, + events: this.config.events || this.eventsFromEventStates(eventStates), + currentSettings, + connected, + updatedAt: new Date().toISOString(), + }); + } + + private snapshotFromConfig(connectedArg: boolean, lastErrorArg?: string): IAmcrestSnapshot { + const deviceInfo = this.deviceInfo(connectedArg); + const currentSettings = this.config.currentSettings || this.config.snapshot?.currentSettings || {}; + return this.normalizeSnapshot({ + deviceInfo, + cameras: this.config.cameras || this.config.snapshot?.cameras || [this.camera(deviceInfo, connectedArg)], + sensors: this.config.sensors || this.config.snapshot?.sensors || this.sensorsFromConfig(connectedArg), + binarySensors: this.config.binarySensors || this.config.snapshot?.binarySensors || this.binarySensorsFromEvents({}, connectedArg), + switches: this.config.switches || this.config.snapshot?.switches || this.switchesFromSettings(currentSettings, connectedArg), + events: this.config.events || this.config.snapshot?.events || [], + currentSettings, + connected: connectedArg, + updatedAt: new Date().toISOString(), + metadata: { + ...this.config.snapshot?.metadata, + lastLiveError: lastErrorArg, + }, + }); + } + + private normalizeSnapshot(snapshotArg: IAmcrestSnapshot): IAmcrestSnapshot { + const connected = Boolean(snapshotArg.connected && snapshotArg.deviceInfo.online !== false); + const deviceInfo = { + ...this.deviceInfo(connected), + ...snapshotArg.deviceInfo, + online: connected, + }; + return { + ...snapshotArg, + deviceInfo, + cameras: (snapshotArg.cameras || []).map((cameraArg) => this.normalizeCamera(cameraArg, deviceInfo, connected)), + sensors: snapshotArg.sensors || [], + binarySensors: snapshotArg.binarySensors || [], + switches: snapshotArg.switches || [], + events: snapshotArg.events || [], + currentSettings: snapshotArg.currentSettings || {}, + connected, + updatedAt: snapshotArg.updatedAt || new Date().toISOString(), + }; + } + + private normalizeCamera(cameraArg: IAmcrestCamera, deviceInfoArg: IAmcrestDeviceInfo, connectedArg: boolean): IAmcrestCamera { + const subtype = cameraArg.subtype ?? amcrestResolutionSubtype[cameraArg.resolution || this.resolution()]; + const baseCamera = this.camera(deviceInfoArg, connectedArg, cameraArg); + return { + ...baseCamera, + ...cameraArg, + subtype, + snapshotUrl: cameraArg.snapshotUrl || baseCamera.snapshotUrl, + mjpegUrl: cameraArg.mjpegUrl || baseCamera.mjpegUrl, + rtspUrl: cameraArg.rtspUrl || baseCamera.rtspUrl, + available: connectedArg && cameraArg.available !== false, + }; + } + + private camera(deviceInfoArg: IAmcrestDeviceInfo, connectedArg: boolean, overridesArg: Partial = {}): IAmcrestCamera { + const channel = overridesArg.channel ?? this.channel(); + const resolution = overridesArg.resolution ?? this.resolution(); + const subtype = overridesArg.subtype ?? amcrestResolutionSubtype[resolution]; + const name = overridesArg.name || `${deviceInfoArg.name || 'Amcrest'} Camera`; + return { + id: overridesArg.id || String(channel), + name, + channel, + resolution, + subtype, + streamSource: overridesArg.streamSource || this.config.streamSource || 'snapshot', + snapshotUrl: overridesArg.snapshotUrl || this.snapshotUrl(channel), + mjpegUrl: overridesArg.mjpegUrl || this.mjpegUrl(channel, subtype), + rtspUrl: overridesArg.rtspUrl || this.rtspUrl(channel, subtype), + supportsPtz: overridesArg.supportsPtz ?? this.config.supportsPtz ?? true, + isStreaming: overridesArg.isStreaming ?? true, + isRecording: overridesArg.isRecording ?? false, + motionDetectionEnabled: overridesArg.motionDetectionEnabled, + audioEnabled: overridesArg.audioEnabled, + motionRecordingEnabled: overridesArg.motionRecordingEnabled, + colorBw: overridesArg.colorBw, + attributes: overridesArg.attributes, + available: connectedArg && overridesArg.available !== false, + }; + } + + private deviceInfo(connectedArg: boolean, liveArg: Partial = {}): IAmcrestDeviceInfo { + const endpoint = this.endpoint(); + const serialNumber = liveArg.serialNumber || this.config.deviceInfo?.serialNumber || this.config.uniqueId; + return { + ...this.config.deviceInfo, + ...liveArg, + id: this.config.deviceInfo?.id || this.config.uniqueId || serialNumber || endpoint.host || 'manual-amcrest', + name: this.config.deviceInfo?.name || this.config.name || endpoint.host || 'Amcrest Camera', + manufacturer: liveArg.manufacturer || this.config.deviceInfo?.manufacturer || this.config.manufacturer || 'Amcrest', + model: liveArg.model || this.config.deviceInfo?.model || this.config.model, + serialNumber, + host: this.config.deviceInfo?.host || endpoint.host, + port: this.config.deviceInfo?.port || endpoint.port, + protocol: this.config.deviceInfo?.protocol || endpoint.protocol, + rtspPort: this.config.deviceInfo?.rtspPort || this.config.rtspPort || amcrestDefaultRtspPort, + url: this.config.deviceInfo?.url || this.baseUrl(), + online: connectedArg, + }; + } + + private sensorsFromLive(presetCountArg: number | undefined, storageArg: { usedPercent?: number; total?: string; used?: string } | undefined, connectedArg: boolean): IAmcrestSensor[] { + const enabled = new Set(this.config.enabledSensors || []); + const sensors: IAmcrestSensor[] = []; + if (enabled.has('ptz_preset') || presetCountArg !== undefined) { + sensors.push({ key: 'ptz_preset', name: 'PTZ Preset', value: presetCountArg ?? 'unknown', entityCategory: 'diagnostic', available: connectedArg }); + } + if (enabled.has('sdcard') || storageArg?.usedPercent !== undefined) { + sensors.push({ + key: 'sdcard', + name: 'SD Used', + value: storageArg?.usedPercent ?? 'unknown', + unit: '%', + entityCategory: 'diagnostic', + available: connectedArg, + attributes: { + Total: storageArg?.total, + Used: storageArg?.used, + }, + }); + } + return sensors; + } + + private sensorsFromConfig(connectedArg: boolean): IAmcrestSensor[] { + const enabled = new Set(this.config.enabledSensors || []); + return amcrestSensorDescriptions + .filter((descriptionArg) => enabled.has(descriptionArg.key)) + .map((descriptionArg) => ({ + key: descriptionArg.key, + name: descriptionArg.name, + value: 'unknown', + unit: descriptionArg.unit, + deviceClass: descriptionArg.deviceClass, + entityCategory: descriptionArg.entityCategory, + available: connectedArg, + })); + } + + private binarySensorsFromEvents(eventStatesArg: Record, connectedArg: boolean): IAmcrestBinarySensor[] { + const defaultKeys = ['online', 'motion_detected']; + const enabled = new Set(this.config.enabledBinarySensors || this.config.snapshot?.binarySensors?.map((sensorArg) => sensorArg.key) || defaultKeys); + return amcrestBinarySensorDescriptions + .filter((descriptionArg) => enabled.has(descriptionArg.key)) + .map((descriptionArg) => { + const isOnline = descriptionArg.key === 'online'; + const eventOn = descriptionArg.eventCodes?.some((codeArg) => eventStatesArg[codeArg] === true) ?? false; + return { + key: descriptionArg.key, + name: descriptionArg.name, + isOn: isOnline ? connectedArg : eventOn, + deviceClass: descriptionArg.deviceClass, + eventCodes: descriptionArg.eventCodes, + shouldPoll: descriptionArg.shouldPoll, + available: isOnline || connectedArg, + }; + }); + } + + private switchesFromSettings(settingsArg: Record, connectedArg: boolean): IAmcrestSwitch[] { + const enabled = new Set(this.config.enabledSwitches || this.config.snapshot?.switches?.map((switchArg) => switchArg.key) || ['privacy_mode']); + return amcrestSwitchDescriptions + .filter((descriptionArg) => enabled.has(descriptionArg.key)) + .map((descriptionArg) => ({ + key: descriptionArg.key, + name: descriptionArg.name, + isOn: booleanValue(settingsArg[descriptionArg.key]) ?? false, + command: descriptionArg.command, + entityCategory: descriptionArg.entityCategory, + available: connectedArg, + })); + } + + private eventsFromEventStates(eventStatesArg: Record): IAmcrestEvent[] { + return Object.entries(eventStatesArg) + .filter(([, valueArg]) => valueArg !== undefined) + .map(([codeArg, isOnArg]) => ({ + id: codeArg, + name: codeArg, + code: codeArg, + isOn: isOnArg, + state: isOnArg ? 'on' : 'off', + updatedAt: new Date().toISOString(), + })); + } + + private async setBooleanConfig(kindArg: 'privacy_mode' | 'video' | 'audio' | 'motion_detection' | 'motion_recording', enabledArg: boolean, channelArg?: number): Promise { + const channel = channelArg ?? this.channel(); + const subtype = this.subtype(); + const streamFormat = this.streamFormat(subtype); + const value = String(enabledArg).toLowerCase(); + const field = kindArg === 'privacy_mode' + ? `LeLensMask[${channel}].Enable` + : kindArg === 'video' + ? `Encode[${channel}].${streamFormat}[0].VideoEnable` + : kindArg === 'audio' + ? `Encode[${channel}].${streamFormat}[0].AudioEnable` + : kindArg === 'motion_detection' + ? `MotionDetect[${channel}].Enable` + : `MotionDetect[${channel}].EventHandler.RecordEnable`; + const command = this.httpCommand(kindArg, `/cgi-bin/configManager.cgi?action=setConfig&${field}=${value}`); + return this.requestOk(command); + } + + private async setRecordMode(enabledArg: boolean, channelArg?: number): Promise { + const channel = channelArg ?? this.channel(); + return this.requestOk(this.httpCommand('recording', `/cgi-bin/configManager.cgi?action=setConfig&RecordMode[${channel}].Mode=${enabledArg ? 1 : 0}`)); + } + + private async setColorBw(colorBwArg: TAmcrestColorBw, channelArg?: number): Promise { + const channel = channelArg ?? this.channel(); + const modeIndex = amcrestColorModes.indexOf(colorBwArg); + return this.requestOk(this.httpCommand('color_bw', `/cgi-bin/configManager.cgi?action=setConfig&VideoInMode[${channel}].Config[0]=${modeIndex}`)); + } + + private async gotoPreset(presetArg: number, channelArg?: number): Promise { + const channel = channelArg ?? this.channel(); + return this.requestOk(this.httpCommand('goto_preset', `/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${Math.round(presetArg)}&arg3=0`)); + } + + private async ptzControl(movementArg: TAmcrestPtzMovement, travelTimeArg = 0.2, channelArg?: number): Promise { + const channel = channelArg ?? this.channel(); + const code = ptzCodes[movementArg]; + let arg1 = 0; + let arg2 = 0; + if (ptzMoveOneArg2.has(code)) { + arg2 = 1; + } else if (ptzMoveBothArgs.has(code)) { + arg1 = 1; + arg2 = 1; + } + const query = `channel=${channel}&code=${code}&arg1=${arg1}&arg2=${arg2}&arg3=0`; + const start = await this.requestOk(this.httpCommand('ptz_start', `/cgi-bin/ptz.cgi?action=start&${query}`)); + await sleep(Math.max(0, Math.min(1, travelTimeArg)) * 1000); + const stop = await this.requestOk(this.httpCommand('ptz_stop', `/cgi-bin/ptz.cgi?action=stop&${query}`)); + return [start, stop]; + } + + private async tour(startArg: boolean, channelArg?: number): Promise { + const channel = channelArg ?? this.channel(); + return this.requestOk(this.httpCommand(startArg ? 'start_tour' : 'stop_tour', `/cgi-bin/ptz.cgi?action=${startArg ? 'start' : 'stop'}&channel=${channel}&code=StartTour&arg1=0&arg2=0&arg3=0`)); + } + + private async requestOk(commandArg: IAmcrestHttpCommand): Promise { + const response = await this.request(commandArg.path); + const responseText = await response.text(); + if (!this.commandSucceeded(responseText)) { + throw new Error(`Amcrest ${commandArg.label} command did not return a successful response: ${responseText.slice(0, 200)}`); + } + return { + ok: true, + label: commandArg.label, + method: commandArg.method, + path: commandArg.path, + status: response.status, + responseText, + }; + } + + private commandSucceeded(valueArg: string): boolean { + const value = valueArg.trim().toLowerCase(); + if (!value) { + return true; + } + if (value.includes('error') || value.includes('fail')) { + return false; + } + if (value === 'false' || /result\s*=\s*false/.test(value)) { + return false; + } + return true; + } + + private async requestText(pathArg: string): Promise { + return (await this.request(pathArg)).text(); + } + + private async request(pathArg: string, initArg: RequestInit = {}, timeoutMsArg = this.config.timeoutMs || amcrestDefaultTimeoutMs): Promise { + const baseUrl = this.baseUrl(); + if (!baseUrl) { + throw new Error('Amcrest live HTTP client requires config.host or config.url.'); + } + const url = `${baseUrl}${pathArg.startsWith('/') ? pathArg : `/${pathArg}`}`; + const headers = new Headers(initArg.headers); + if (this.authScheme() === 'basic') { + headers.set('authorization', this.basicAuthorization()); + } + const response = await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers }, timeoutMsArg); + if (response.status === 401 && this.authScheme() !== 'basic') { + const challenge = response.headers.get('www-authenticate') || ''; + const retryHeaders = new Headers(initArg.headers); + if (/digest/i.test(challenge)) { + const requestUrl = new URL(url); + retryHeaders.set('authorization', this.digestAuthorization(challenge, initArg.method || 'GET', `${requestUrl.pathname}${requestUrl.search}`)); + return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers: retryHeaders }, timeoutMsArg), pathArg); + } + if (/basic/i.test(challenge) || this.authScheme() === 'auto') { + retryHeaders.set('authorization', this.basicAuthorization()); + return this.checkedResponse(await this.fetchWithTimeout(url, { ...initArg, method: initArg.method || 'GET', headers: retryHeaders }, timeoutMsArg), pathArg); + } + } + return this.checkedResponse(response, pathArg); + } + + private async checkedResponse(responseArg: Response, pathArg: string): Promise { + if (!responseArg.ok) { + const text = await responseArg.text().catch(() => ''); + if (responseArg.status === 401) { + throw new AmcrestHttpError(responseArg.status, 'Amcrest authentication failed.'); + } + throw new AmcrestHttpError(responseArg.status, `Amcrest request ${pathArg} failed with HTTP ${responseArg.status}${text ? `: ${text}` : ''}`); + } + return responseArg; + } + + private async fetchWithTimeout(urlArg: string, initArg: RequestInit, timeoutMsArg: number): Promise { + const abortController = new AbortController(); + const timeout = setTimeout(() => abortController.abort(), timeoutMsArg); + try { + return await globalThis.fetch(urlArg, { ...initArg, signal: abortController.signal }); + } finally { + clearTimeout(timeout); + } + } + + private digestAuthorization(challengeArg: string, methodArg: string, uriArg: string): string { + const challenge = parseDigestChallenge(challengeArg); + if (!challenge.realm || !challenge.nonce) { + throw new Error('Amcrest digest authentication challenge is missing realm or nonce.'); + } + const algorithm = (challenge.algorithm || 'MD5').toUpperCase(); + if (algorithm !== 'MD5' && algorithm !== 'MD5-SESS') { + throw new Error(`Amcrest digest authentication algorithm is unsupported: ${algorithm}`); + } + const qop = splitCsv(challenge.qop).includes('auth') ? 'auth' : undefined; + const cnonce = plugins.crypto.randomBytes(8).toString('hex'); + const nc = '00000001'; + const username = this.config.username || ''; + const password = this.config.password || ''; + const ha1Raw = md5(`${username}:${challenge.realm}:${password}`); + const ha1 = algorithm === 'MD5-SESS' ? md5(`${ha1Raw}:${challenge.nonce}:${cnonce}`) : ha1Raw; + const ha2 = md5(`${methodArg.toUpperCase()}:${uriArg}`); + const response = qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`); + const parts: Record = { + username, + realm: challenge.realm, + nonce: challenge.nonce, + uri: uriArg, + response, + algorithm, + }; + if (challenge.opaque) { + parts.opaque = challenge.opaque; + } + if (qop) { + parts.qop = qop; + parts.nc = nc; + parts.cnonce = cnonce; + } + return `Digest ${Object.entries(parts).map(([keyArg, valueArg]) => keyArg === 'qop' || keyArg === 'nc' || keyArg === 'algorithm' ? `${keyArg}=${valueArg}` : `${keyArg}="${valueArg.replace(/"/g, '\\"')}"`).join(', ')}`; + } + + private basicAuthorization(): string { + return `Basic ${Buffer.from(`${this.config.username || ''}:${this.config.password || ''}`, 'utf8').toString('base64')}`; + } + + private httpCommand(labelArg: string, pathArg: string): IAmcrestHttpCommand { + return { label: labelArg, method: 'GET', path: pathArg, expect: 'ok' }; + } + + private snapshotHttpCommand(channelArg?: number): IAmcrestHttpCommand { + return { label: 'snapshot', method: 'GET', path: `/cgi-bin/snapshot.cgi?channel=${channelArg ?? this.channel()}`, expect: 'image' }; + } + + private snapshotUrl(channelArg: number): string | undefined { + const baseUrl = this.baseUrl(); + return baseUrl ? `${baseUrl}${this.snapshotHttpCommand(channelArg).path}` : undefined; + } + + private mjpegUrl(channelArg: number, subtypeArg: 0 | 1): string | undefined { + const baseUrl = this.baseUrl(); + return baseUrl ? `${baseUrl}/cgi-bin/mjpg/video.cgi?channel=${channelArg}&subtype=${subtypeArg}` : undefined; + } + + private rtspUrl(channelArg: number, subtypeArg: 0 | 1): string | undefined { + const endpoint = this.endpoint(); + if (!endpoint.host) { + return undefined; + } + const credentials = this.rtspCredentials(); + return `rtsp://${credentials}${endpoint.host}:${this.config.rtspPort || amcrestDefaultRtspPort}/cam/realmonitor?channel=${channelArg + 1}&subtype=${subtypeArg}`; + } + + private streamSourceUrl(cameraArg: IAmcrestCamera, streamSourceArg?: TAmcrestStreamSource): string | undefined { + const streamSource = streamSourceArg || cameraArg.streamSource; + if (streamSource === 'rtsp') { + return cameraArg.rtspUrl; + } + if (streamSource === 'mjpeg') { + return cameraArg.mjpegUrl; + } + return cameraArg.snapshotUrl; + } + + private baseUrl(): string | undefined { + if (this.config.url) { + const url = safeUrl(this.config.url); + if (url) { + return `${url.protocol}//${url.hostname}:${url.port || (url.protocol === 'https:' ? 443 : amcrestDefaultPort)}`; + } + } + const endpoint = this.endpoint(); + if (!endpoint.host) { + return undefined; + } + return `${endpoint.protocol}://${endpoint.host}:${endpoint.port || amcrestDefaultPort}`; + } + + private endpoint(): { protocol: TAmcrestProtocol; host?: string; port: number } { + const url = safeUrl(this.config.url || this.config.host); + if (url) { + return { + protocol: url.protocol === 'https:' ? 'https' : 'http', + host: url.hostname, + port: url.port ? Number(url.port) : url.protocol === 'https:' ? 443 : amcrestDefaultPort, + }; + } + return { + protocol: this.config.protocol || 'http', + host: this.config.host, + port: this.config.port || amcrestDefaultPort, + }; + } + + private rtspCredentials(): string { + if (!this.config.username || this.config.password === undefined) { + return ''; + } + return `${encodeURIComponent(this.config.username)}:${encodeURIComponent(this.config.password)}@`; + } + + private hasLiveTarget(): boolean { + return Boolean(this.baseUrl()); + } + + private authScheme(): TAmcrestAuthScheme { + return this.config.authScheme || 'auto'; + } + + private resolution(): TAmcrestResolution { + return this.config.resolution || 'high'; + } + + private subtype(): 0 | 1 { + return amcrestResolutionSubtype[this.resolution()]; + } + + private streamFormat(subtypeArg: 0 | 1): string { + return `${amcrestSubtypeStream[subtypeArg]}Format`; + } + + private channel(): number { + return Number.isInteger(this.config.channel) && this.config.channel! >= 0 ? this.config.channel! : 0; + } + + private requireEnabled(commandArg: IAmcrestClientCommand): boolean { + if (typeof commandArg.enabled !== 'boolean') { + throw new Error(`Amcrest ${commandArg.type} requires a boolean enabled value.`); + } + return commandArg.enabled; + } + + private findCamera(snapshotArg: IAmcrestSnapshot, cameraIdArg?: string): IAmcrestCamera { + const cameraId = cameraIdArg || ''; + const camera = snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.channel) === cameraId) || snapshotArg.cameras[0]; + if (!camera) { + throw new Error('Amcrest camera command requires a configured or discovered camera.'); + } + return camera; + } + + private patchCachedCamera(valuesArg: Partial): void { + if (!this.snapshot) { + return; + } + for (const camera of this.snapshot.cameras) { + Object.assign(camera, valuesArg); + } + } + + private patchCachedSwitch(keyArg: string, isOnArg: boolean): void { + if (!this.snapshot) { + return; + } + this.snapshot.currentSettings[keyArg] = isOnArg; + for (const switchArg of this.snapshot.switches) { + if (switchArg.key === keyArg) { + switchArg.isOn = isOnArg; + } + } + } + + private colorModeFromValue(valueArg: unknown): TAmcrestColorBw | undefined { + if (typeof valueArg === 'number') { + return amcrestColorModes[valueArg]; + } + if (typeof valueArg === 'string') { + const numeric = Number(valueArg); + if (Number.isInteger(numeric)) { + return amcrestColorModes[numeric]; + } + return amcrestColorModes.find((modeArg) => modeArg === valueArg.toLowerCase()); + } + return undefined; + } + + private eventTextIsOn(textArg: string | undefined): boolean | undefined { + if (textArg === undefined) { + return undefined; + } + const text = textArg.trim().toLowerCase(); + if (!text || text.includes('error') || text.includes('false')) { + return false; + } + return /channels\s*\[\s*\d+\s*\]\s*=/.test(text) || /\bindex(?:es)?\s*\[\s*\d+\s*\]\s*=/.test(text) || text.includes('true'); + } + + private presetCount(textArg: string | undefined): number | undefined { + if (textArg === undefined) { + return undefined; + } + const matches = new Set(); + for (const match of textArg.matchAll(/(?:presets?|Preset)\[(\d+)\]/g)) { + matches.add(match[1]); + } + return matches.size; + } + + private storageFromText(textArg: string | undefined): { usedPercent?: number; total?: string; used?: string } | undefined { + if (!textArg) { + return undefined; + } + const values = parseKeyValues(textArg); + let totalBytes: number | undefined; + let usedBytes: number | undefined; + let usedPercent: number | undefined; + for (const [key, value] of Object.entries(values)) { + const lowerKey = key.toLowerCase(); + const number = numberValue(value); + if (number === undefined) { + continue; + } + if (lowerKey.includes('percent')) { + usedPercent = number; + } else if (lowerKey.includes('total') && (lowerKey.includes('byte') || lowerKey.includes('space'))) { + totalBytes = number; + } else if (lowerKey.includes('used') && (lowerKey.includes('byte') || lowerKey.includes('space'))) { + usedBytes = number; + } + } + if (usedPercent === undefined && totalBytes && usedBytes !== undefined) { + usedPercent = Number(((usedBytes / totalBytes) * 100).toFixed(2)); + } + if (usedPercent === undefined && totalBytes === undefined && usedBytes === undefined) { + return undefined; + } + return { + usedPercent, + total: totalBytes === undefined ? undefined : formatBytes(totalBytes), + used: usedBytes === undefined ? undefined : formatBytes(usedBytes), + }; + } + + private cloneSnapshot(snapshotArg: IAmcrestSnapshot): IAmcrestSnapshot { + return JSON.parse(JSON.stringify(snapshotArg)) as IAmcrestSnapshot; + } +} + +const md5 = (valueArg: string): string => plugins.crypto.createHash('md5').update(valueArg).digest('hex'); + +const sleep = (msArg: number): Promise => new Promise((resolve) => setTimeout(resolve, msArg)); + +const parseDigestChallenge = (valueArg: string): Record => { + const result: Record = {}; + const challenge = valueArg.replace(/^\s*Digest\s+/i, ''); + const matcher = /([a-zA-Z0-9_-]+)=(?:"([^"]*)"|([^,\s]+))/g; + for (const match of challenge.matchAll(matcher)) { + result[match[1].toLowerCase()] = match[2] ?? match[3] ?? ''; + } + return result; +}; + +const splitCsv = (valueArg: string | undefined): string[] => { + return (valueArg || '').split(',').map((entryArg) => entryArg.trim()).filter(Boolean); +}; + +const safeUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; + +const parseKeyValues = (textArg: string | undefined): Record => { + const values: Record = {}; + for (const line of (textArg || '').split(/\r?\n/)) { + const [rawKey, ...rawValue] = line.split('='); + if (!rawKey || !rawValue.length) { + continue; + } + const key = rawKey.trim().replace(/^table\./, ''); + values[key] = rawValue.join('=').trim(); + } + return values; +}; + +const firstPlainValue = (textArg: string | undefined): string | undefined => { + const line = (textArg || '').split(/\r?\n/).map((entryArg) => entryArg.trim()).find(Boolean); + if (!line) { + return undefined; + } + const [, ...rest] = line.split('='); + return rest.length ? rest.join('=').trim() || undefined : line; +}; + +const valueBySuffix = (valuesArg: Record, suffixesArg: string[]): string | undefined => { + for (const suffix of suffixesArg) { + const exact = valuesArg[suffix]; + if (exact !== undefined) { + return exact; + } + const entry = Object.entries(valuesArg).find(([key]) => key.endsWith(suffix)); + if (entry) { + return entry[1]; + } + } + 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', 'manual'].includes(valueArg.toLowerCase())) { + return true; + } + if (['false', 'no', 'off', '0', 'automatic'].includes(valueArg.toLowerCase())) { + return false; + } + } + return 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 formatBytes = (valueArg: number): string => { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = valueArg; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value.toFixed(unitIndex === 0 ? 0 : 2)} ${units[unitIndex]}`; +}; diff --git a/ts/integrations/amcrest/amcrest.classes.configflow.ts b/ts/integrations/amcrest/amcrest.classes.configflow.ts new file mode 100644 index 0000000..079be86 --- /dev/null +++ b/ts/integrations/amcrest/amcrest.classes.configflow.ts @@ -0,0 +1,121 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IAmcrestConfig, TAmcrestAuthScheme, TAmcrestProtocol, TAmcrestResolution, TAmcrestStreamSource } from './amcrest.types.js'; +import { amcrestDefaultPort, amcrestDefaultTimeoutMs } from './amcrest.types.js'; + +export class AmcrestConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Amcrest camera', + description: 'Configure the local Amcrest HTTP CGI endpoint. Use a base URL 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: 'username', label: 'Username', type: 'text', required: true }, + { name: 'password', label: 'Password', type: 'password', required: true }, + { name: 'authScheme', label: 'Authentication', type: 'select', options: [{ label: 'Auto', value: 'auto' }, { label: 'Basic', value: 'basic' }, { label: 'Digest', value: 'digest' }] }, + { name: 'streamSource', label: 'Stream source', type: 'select', options: [{ label: 'Snapshot', value: 'snapshot' }, { label: 'MJPEG', value: 'mjpeg' }, { label: 'RTSP', value: 'rtsp' }] }, + { name: 'resolution', label: 'Resolution', type: 'select', options: [{ label: 'High', value: 'high' }, { label: 'Low', value: 'low' }] }, + { name: 'channel', label: 'Channel', type: 'number' }, + { 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) { + return { kind: 'error', error: 'Amcrest requires a base URL or host.' }; + } + const username = this.stringValue(valuesArg.username) || this.stringMetadata(candidateArg, 'username'); + const password = this.stringValue(valuesArg.password) || this.stringMetadata(candidateArg, 'password'); + if (!username || password === undefined) { + return { kind: 'error', error: 'Amcrest requires username and password.' }; + } + const channel = this.numberValue(valuesArg.channel); + return { + kind: 'done', + title: 'Amcrest camera configured', + config: { + protocol: endpoint.protocol, + host: endpoint.host, + port: endpoint.port, + url: endpoint.url, + username, + password, + authScheme: this.authSchemeValue(valuesArg.authScheme) || 'auto', + streamSource: this.streamSourceValue(valuesArg.streamSource) || 'snapshot', + resolution: this.resolutionValue(valuesArg.resolution) || 'high', + channel: channel === undefined ? 0 : Math.max(0, Math.floor(channel)), + name: this.stringValue(valuesArg.name) || candidateArg.name || endpoint.host, + uniqueId: candidateArg.id || candidateArg.macAddress || candidateArg.serialNumber || endpoint.host, + manufacturer: candidateArg.manufacturer || 'Amcrest', + model: candidateArg.model, + timeoutMs: amcrestDefaultTimeoutMs, + controlLight: true, + }, + }; + }, + }; + } + + private endpoint(urlArg: string | undefined, hostArg: string | undefined, portArg: number | undefined, protocolArg: TAmcrestProtocol | undefined): { protocol: TAmcrestProtocol; 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 : amcrestDefaultPort; + return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` }; + } + const protocol = protocolArg || 'http'; + const port = portArg || amcrestDefaultPort; + 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): TAmcrestProtocol | undefined { + const protocol = candidateArg.metadata?.protocol; + return protocol === 'http' || protocol === 'https' ? protocol : undefined; + } + + private authSchemeValue(valueArg: unknown): TAmcrestAuthScheme | undefined { + return valueArg === 'auto' || valueArg === 'basic' || valueArg === 'digest' ? valueArg : undefined; + } + + private streamSourceValue(valueArg: unknown): TAmcrestStreamSource | undefined { + return valueArg === 'snapshot' || valueArg === 'mjpeg' || valueArg === 'rtsp' ? valueArg : undefined; + } + + private resolutionValue(valueArg: unknown): TAmcrestResolution | undefined { + return valueArg === 'high' || valueArg === 'low' ? valueArg : 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/amcrest/amcrest.classes.integration.ts b/ts/integrations/amcrest/amcrest.classes.integration.ts index 581a28a..e99e879 100644 --- a/ts/integrations/amcrest/amcrest.classes.integration.ts +++ b/ts/integrations/amcrest/amcrest.classes.integration.ts @@ -1,28 +1,83 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import type * as shxInterfaces from '@smarthome.exchange/interfaces'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { AmcrestClient } from './amcrest.classes.client.js'; +import { AmcrestConfigFlow } from './amcrest.classes.configflow.js'; +import { createAmcrestDiscoveryDescriptor } from './amcrest.discovery.js'; +import { AmcrestMapper } from './amcrest.mapper.js'; +import type { IAmcrestConfig } from './amcrest.types.js'; -export class HomeAssistantAmcrestIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "amcrest", - displayName: "Amcrest", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/amcrest", - "upstreamDomain": "amcrest", - "iotClass": "local_polling", - "qualityScale": "legacy", - "requirements": [ - "amcrest==1.9.9" - ], - "dependencies": [ - "ffmpeg" - ], - "afterDependencies": [], - "codeowners": [ - "@flacjacket" - ] -}, - }); +export class AmcrestIntegration extends BaseIntegration { + public readonly domain = 'amcrest'; + public readonly displayName = 'Amcrest'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAmcrestDiscoveryDescriptor(); + public readonly configFlow = new AmcrestConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/amcrest', + upstreamDomain: 'amcrest', + integrationType: 'device', + iotClass: 'local_polling', + qualityScale: 'legacy', + requirements: ['amcrest==1.9.9'], + dependencies: ['ffmpeg'], + afterDependencies: [], + codeowners: ['@flacjacket'], + documentation: 'https://www.home-assistant.io/integrations/amcrest', + nativePort: { + manualLocalDiscovery: true, + snapshotMapping: true, + liveHttpCgiCommands: true, + liveEvents: false, + rtspProxying: false, + ffmpegProxying: false, + }, + }; + + public async setup(configArg: IAmcrestConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AmcrestRuntime(new AmcrestClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAmcrestIntegration extends AmcrestIntegration {} + +class AmcrestRuntime implements IIntegrationRuntime { + public domain = 'amcrest'; + + constructor(private readonly client: AmcrestClient) {} + + public async devices(): Promise { + return AmcrestMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AmcrestMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + void handlerArg; + throw new Error('Amcrest live event streaming is not implemented in this TypeScript port; use polled binary sensors or refresh snapshots.'); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + const snapshot = await this.client.getSnapshot(); + const command = AmcrestMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported Amcrest 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/amcrest/amcrest.discovery.ts b/ts/integrations/amcrest/amcrest.discovery.ts new file mode 100644 index 0000000..77d37d7 --- /dev/null +++ b/ts/integrations/amcrest/amcrest.discovery.ts @@ -0,0 +1,259 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryContext, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IAmcrestManualEntry, IAmcrestMdnsRecord, IAmcrestSsdpRecord, TAmcrestProtocol } from './amcrest.types.js'; +import { amcrestDefaultPort } from './amcrest.types.js'; + +export class AmcrestManualMatcher implements IDiscoveryMatcher { + public id = 'amcrest-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Amcrest local camera host or base URL entries.'; + + public async matches(inputArg: IAmcrestManualEntry, contextArg: IDiscoveryContext): Promise { + void contextArg; + const endpoint = endpointFromInput(inputArg); + const hint = hasAmcrestHint(inputArg.name, inputArg.manufacturer, inputArg.model) || Boolean(inputArg.metadata?.amcrest || inputArg.metadata?.dahua); + if (!endpoint.host && !hint) { + return { matched: false, confidence: 'low', reason: 'Manual Amcrest entry requires host, url, or Amcrest metadata.' }; + } + const normalizedDeviceId = normalizeMac(inputArg.macAddress || inputArg.id || inputArg.serialNumber) || inputArg.id || inputArg.serialNumber || endpoint.host || endpoint.url; + return { + matched: true, + confidence: endpoint.host ? 'high' : 'medium', + reason: endpoint.host ? 'Manual entry contains a local Amcrest camera endpoint.' : 'Manual entry contains Amcrest metadata.', + normalizedDeviceId, + candidate: { + source: 'manual', + integrationDomain: 'amcrest', + id: normalizedDeviceId, + host: endpoint.host, + port: endpoint.port, + name: inputArg.name || endpoint.host, + manufacturer: inputArg.manufacturer || 'Amcrest', + model: inputArg.model, + serialNumber: inputArg.serialNumber, + macAddress: normalizeMac(inputArg.macAddress) || undefined, + metadata: { + ...inputArg.metadata, + protocol: endpoint.protocol, + url: endpoint.url, + username: inputArg.username, + password: inputArg.password, + discoveryProtocol: 'manual', + }, + }, + metadata: { + protocol: endpoint.protocol, + url: endpoint.url, + }, + }; + } +} + +export class AmcrestMdnsMatcher implements IDiscoveryMatcher { + public id = 'amcrest-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize local Amcrest-style mDNS records by host, name, and TXT metadata.'; + + public async matches(recordArg: IAmcrestMdnsRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const properties = { ...recordArg.txt, ...recordArg.properties }; + const manufacturer = valueForKey(properties, 'manufacturer') || valueForKey(properties, 'vendor'); + const model = valueForKey(properties, 'model') || valueForKey(properties, 'product'); + const serial = valueForKey(properties, 'serial') || valueForKey(properties, 'serialNumber'); + const mac = normalizeMac(valueForKey(properties, 'mac') || valueForKey(properties, 'macAddress') || serial); + const name = cleanMdnsName(recordArg.name || recordArg.hostname); + const matched = hasAmcrestHint(name, manufacturer, model) || Boolean(valueForKey(properties, 'amcrest')); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record does not contain Amcrest camera hints.' }; + } + return { + matched: true, + confidence: 'high', + reason: 'mDNS record contains Amcrest camera metadata.', + normalizedDeviceId: mac || serial || recordArg.host, + candidate: { + source: 'mdns', + integrationDomain: 'amcrest', + id: mac || serial || recordArg.host, + host: recordArg.host || recordArg.addresses?.[0], + port: recordArg.port || amcrestDefaultPort, + name: name || undefined, + manufacturer: manufacturer || 'Amcrest', + model, + serialNumber: serial, + macAddress: mac || undefined, + metadata: { + mdnsName: recordArg.name, + mdnsType: recordArg.type, + txt: properties, + protocol: 'http' satisfies TAmcrestProtocol, + }, + }, + }; + } +} + +export class AmcrestSsdpMatcher implements IDiscoveryMatcher { + public id = 'amcrest-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize local Amcrest cameras from SSDP manufacturer and UPnP metadata.'; + + public async matches(recordArg: IAmcrestSsdpRecord, contextArg: IDiscoveryContext): Promise { + void contextArg; + const upnp = { ...recordArg.headers, ...recordArg.upnp }; + const manufacturer = recordArg.manufacturer || valueForKey(upnp, 'manufacturer') || ''; + const model = valueForKey(upnp, 'modelName') || valueForKey(upnp, 'modelNumber'); + const friendlyName = valueForKey(upnp, 'friendlyName') || valueForKey(upnp, 'upnp:friendlyName'); + const location = recordArg.location || valueForKey(upnp, 'location') || valueForKey(upnp, 'presentationURL') || valueForKey(upnp, 'presentation_url'); + const url = safeUrl(location); + const serial = valueForKey(upnp, 'serialNumber') || valueForKey(upnp, 'serial') || recordArg.usn; + const mac = normalizeMac(serial); + const matched = hasAmcrestHint(friendlyName, manufacturer, model) || hasAmcrestHint(recordArg.server); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not published by an Amcrest-like camera.' }; + } + return { + matched: true, + confidence: 'high', + reason: 'SSDP record contains Amcrest camera metadata.', + normalizedDeviceId: mac || serial, + candidate: { + source: 'ssdp', + integrationDomain: 'amcrest', + id: mac || serial, + host: url?.hostname, + port: url?.port ? Number(url.port) : amcrestDefaultPort, + name: friendlyName, + manufacturer: manufacturer || 'Amcrest', + model, + serialNumber: serial, + macAddress: mac || undefined, + metadata: { + protocol: url?.protocol === 'https:' ? 'https' : 'http', + location, + ssdp: upnp, + }, + }, + }; + } +} + +export class AmcrestCandidateValidator implements IDiscoveryValidator { + public id = 'amcrest-candidate-validator'; + public description = 'Validate that a candidate can be configured as a local Amcrest camera.'; + + public async validate(candidateArg: IDiscoveryCandidate, contextArg: IDiscoveryContext): Promise { + void contextArg; + if (candidateArg.integrationDomain && candidateArg.integrationDomain !== 'amcrest') { + return { matched: false, confidence: 'low', reason: `Candidate belongs to ${candidateArg.integrationDomain}, not Amcrest.` }; + } + const endpoint = endpointFromCandidate(candidateArg); + const mac = normalizeMac(candidateArg.macAddress || candidateArg.id || candidateArg.serialNumber); + const hasHint = candidateArg.integrationDomain === 'amcrest' + || candidateArg.source === 'manual' + || hasAmcrestHint(candidateArg.name, candidateArg.manufacturer, candidateArg.model) + || Boolean(candidateArg.metadata?.amcrest || candidateArg.metadata?.dahua); + if (!hasHint || !endpoint.host) { + return { matched: false, confidence: 'low', reason: 'Amcrest candidates require a host plus manual or Amcrest camera metadata.' }; + } + if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65535) { + return { matched: false, confidence: 'low', reason: 'Amcrest candidate has an invalid port.' }; + } + return { + matched: true, + confidence: candidateArg.source === 'manual' ? 'high' : 'medium', + reason: 'Candidate has enough local Amcrest metadata to start configuration.', + normalizedDeviceId: candidateArg.id || mac || endpoint.host, + candidate: { + ...candidateArg, + integrationDomain: 'amcrest', + id: candidateArg.id || mac || endpoint.host, + host: endpoint.host, + port: endpoint.port, + manufacturer: candidateArg.manufacturer || 'Amcrest', + macAddress: candidateArg.macAddress || mac || undefined, + metadata: { + ...candidateArg.metadata, + protocol: endpoint.protocol, + url: endpoint.url, + }, + }, + metadata: { + manualSupported: candidateArg.source === 'manual', + protocol: endpoint.protocol, + url: endpoint.url, + }, + }; + } +} + +export const createAmcrestDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'amcrest', displayName: 'Amcrest' }) + .addMatcher(new AmcrestManualMatcher()) + .addMatcher(new AmcrestMdnsMatcher()) + .addMatcher(new AmcrestSsdpMatcher()) + .addValidator(new AmcrestCandidateValidator()); +}; + +const endpointFromInput = (inputArg: IAmcrestManualEntry): { protocol: TAmcrestProtocol; host?: string; port: number; url?: string } => { + const url = safeUrl(inputArg.url || inputArg.host); + if (url) { + const protocol = url.protocol === 'https:' ? 'https' : 'http'; + const port = url.port ? Number(url.port) : protocol === 'https' ? 443 : amcrestDefaultPort; + return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` }; + } + const protocol = inputArg.protocol || 'http'; + const port = inputArg.port || amcrestDefaultPort; + return { protocol, host: inputArg.host, port, url: inputArg.host ? `${protocol}://${inputArg.host}:${port}` : undefined }; +}; + +const endpointFromCandidate = (candidateArg: IDiscoveryCandidate): { protocol: TAmcrestProtocol; 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 : amcrestDefaultPort; + return { protocol, host: url.hostname, port, url: `${protocol}://${url.hostname}:${port}` }; + } + const port = candidateArg.port || amcrestDefaultPort; + return { protocol: metadataProtocol, host: candidateArg.host, port, url: candidateArg.host ? `${metadataProtocol}://${candidateArg.host}:${port}` : metadataUrl }; +}; + +const hasAmcrestHint = (...valuesArgs: Array): boolean => { + const haystack = valuesArgs.filter(Boolean).join(' ').toLowerCase(); + return haystack.includes('amcrest') || haystack.includes('dahua'); +}; + +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 cleanMdnsName = (valueArg: string | undefined): string => { + return valueArg?.replace(/\._[^.]+\._tcp\.local\.?$/i, '').replace(/\.local\.?$/i, '').trim() || ''; +}; + +const normalizeMac = (valueArg: string | undefined): string => { + const cleaned = (valueArg || '').replace(/[^a-fA-F0-9]/g, '').toLowerCase(); + return cleaned.length === 12 ? cleaned : ''; +}; + +const safeUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; diff --git a/ts/integrations/amcrest/amcrest.mapper.ts b/ts/integrations/amcrest/amcrest.mapper.ts new file mode 100644 index 0000000..9c77391 --- /dev/null +++ b/ts/integrations/amcrest/amcrest.mapper.ts @@ -0,0 +1,445 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IAmcrestCamera, + IAmcrestClientCommand, + IAmcrestEvent, + IAmcrestSnapshot, + IAmcrestSwitch, + TAmcrestColorBw, + TAmcrestPtzMovement, + TAmcrestResolution, + TAmcrestStreamSource, +} from './amcrest.types.js'; +import { amcrestColorModes, amcrestPtzMovements, amcrestResolutionSubtype, amcrestSubtypeStream } from './amcrest.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']; + +export class AmcrestMapper { + public static toDevices(snapshotArg: IAmcrestSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'connectivity', capability: 'sensor', name: 'Connectivity', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt }, + ]; + + for (const camera of snapshotArg.cameras) { + features.push({ id: `camera_${this.slug(camera.id)}`, capability: 'camera', name: camera.name || `Camera ${camera.channel}`, readable: true, writable: Boolean(camera.supportsPtz) }); + state.push({ + featureId: `camera_${this.slug(camera.id)}`, + value: { + snapshotUrl: camera.snapshotUrl || null, + mjpegUrl: camera.mjpegUrl || null, + rtspUrl: camera.rtspUrl || null, + streamSource: camera.streamSource, + isStreaming: camera.isStreaming ?? null, + }, + updatedAt, + }); + } + for (const sensor of snapshotArg.binarySensors) { + features.push({ id: `binary_${this.slug(sensor.key)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false }); + state.push({ featureId: `binary_${this.slug(sensor.key)}`, value: sensor.isOn, updatedAt }); + } + for (const sensor of snapshotArg.sensors) { + features.push({ id: `sensor_${this.slug(sensor.key)}`, capability: 'sensor', name: sensor.name, readable: true, writable: false, unit: sensor.unit }); + state.push({ featureId: `sensor_${this.slug(sensor.key)}`, value: this.deviceStateValue(sensor.value), updatedAt }); + } + for (const switchArg of snapshotArg.switches) { + 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), + integrationDomain: 'amcrest', + name: this.deviceName(snapshotArg), + protocol: 'http', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Amcrest', + model: snapshotArg.deviceInfo.model, + online: snapshotArg.connected, + features, + state, + metadata: { + serialNumber: snapshotArg.deviceInfo.serialNumber, + macAddress: snapshotArg.deviceInfo.macAddress, + firmwareVersion: snapshotArg.deviceInfo.firmwareVersion, + host: snapshotArg.deviceInfo.host, + port: snapshotArg.deviceInfo.port, + protocol: snapshotArg.deviceInfo.protocol, + rtspPort: snapshotArg.deviceInfo.rtspPort, + cameraStreams: snapshotArg.cameras.map((cameraArg) => ({ + id: cameraArg.id, + channel: cameraArg.channel, + resolution: cameraArg.resolution, + subtype: cameraArg.subtype, + streamSource: cameraArg.streamSource, + snapshotUrl: cameraArg.snapshotUrl, + mjpegUrl: cameraArg.mjpegUrl, + rtspUrl: cameraArg.rtspUrl, + })), + }, + }]; + } + + public static toEntities(snapshotArg: IAmcrestSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const deviceId = this.deviceId(snapshotArg); + + for (const camera of snapshotArg.cameras) { + entities.push(this.entity('camera' as TEntityPlatform, camera.name || `${this.deviceName(snapshotArg)} Camera`, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_camera_${this.slug(camera.id)}`, camera.available === false || !snapshotArg.connected ? 'unavailable' : camera.isStreaming === false ? 'off' : 'idle', usedIds, { + cameraId: camera.id, + channel: camera.channel, + resolution: camera.resolution, + subtype: camera.subtype, + streamSource: camera.streamSource, + streamSourceUrl: this.streamSourceUrl(camera), + snapshotUrl: camera.snapshotUrl, + stillImageUrl: camera.snapshotUrl, + mjpegUrl: camera.mjpegUrl, + rtspUrl: camera.rtspUrl, + supportedFeatures: camera.supportsPtz ? ['stream', 'snapshot', 'ptz'] : ['stream', 'snapshot'], + isStreaming: camera.isStreaming, + isRecording: camera.isRecording, + motionDetectionEnabled: camera.motionDetectionEnabled, + audio: stateFromBoolean(camera.audioEnabled), + motionRecording: stateFromBoolean(camera.motionRecordingEnabled), + color_bw: camera.colorBw, + serviceMappings: { + snapshot: 'camera.snapshot', + streamSource: 'camera.stream_source', + ptzControl: 'amcrest.ptz_control', + gotoPreset: 'amcrest.goto_preset', + }, + ...camera.attributes, + }, snapshotArg.connected && camera.available !== false)); + } + + for (const sensor of snapshotArg.binarySensors) { + entities.push(this.entity('binary_sensor', sensor.name, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.key)}`, sensor.isOn ? 'on' : 'off', usedIds, { + key: sensor.key, + deviceClass: sensor.deviceClass, + eventCodes: sensor.eventCodes, + shouldPoll: sensor.shouldPoll, + ...sensor.attributes, + }, sensor.key === 'online' || (snapshotArg.connected && sensor.available !== false))); + } + for (const sensor of snapshotArg.sensors) { + entities.push(this.entity('sensor', sensor.name, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_${this.slug(sensor.key)}`, sensor.value ?? 'unknown', usedIds, { + key: sensor.key, + unit: sensor.unit, + deviceClass: sensor.deviceClass, + entityCategory: sensor.entityCategory, + ...sensor.attributes, + }, snapshotArg.connected && sensor.available !== false)); + } + for (const switchArg of snapshotArg.switches) { + entities.push(this.entity('switch', switchArg.name, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_${this.slug(switchArg.key)}`, switchArg.isOn ? 'on' : 'off', usedIds, { + key: switchArg.key, + command: switchArg.command, + entityCategory: switchArg.entityCategory, + ...switchArg.attributes, + }, snapshotArg.connected && switchArg.available !== false)); + } + for (const event of snapshotArg.events) { + entities.push(this.entity('event' as TEntityPlatform, event.name || event.id, deviceId, `amcrest_${this.uniqueBase(snapshotArg)}_event_${this.slug(event.id)}`, event.state || (event.isOn ? 'on' : 'off'), usedIds, { + eventId: event.id, + code: event.code, + updatedAt: event.updatedAt, + payload: event.payload, + }, true)); + } + + return entities; + } + + public static commandForService(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestClientCommand | 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, + channel: camera?.channel, + resolution: this.resolutionValue(requestArg.data?.resolution) || camera?.resolution, + streamSource: this.streamSourceValue(requestArg.data?.stream_source ?? requestArg.data?.streamSource) || camera?.streamSource, + }; + } + 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, + channel: camera?.channel, + filename: this.stringValue(requestArg.data?.filename), + httpCommands: camera ? [{ label: 'snapshot', method: 'GET', path: `/cgi-bin/snapshot.cgi?channel=${camera.channel}`, expect: 'image' }] : undefined, + }; + } + if (requestArg.domain === 'camera') { + return this.cameraCommand(snapshotArg, 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 { + type: 'set_privacy_mode', + service: requestArg.service, + target: requestArg.target, + data: requestArg.data, + enabled, + httpCommands: this.booleanHttpCommands('privacy_mode', snapshotArg, enabled), + }; + } + if (requestArg.domain === 'amcrest') { + return this.amcrestCommand(snapshotArg, requestArg); + } + return undefined; + } + + public static deviceId(snapshotArg: IAmcrestSnapshot): string { + return `amcrest.device.${this.uniqueBase(snapshotArg)}`; + } + + private static cameraCommand(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestClientCommand | undefined { + const camera = this.findCamera(snapshotArg, requestArg); + if (requestArg.service === 'turn_on' || requestArg.service === 'turn_off') { + const enabled = requestArg.service === 'turn_on'; + return { type: 'set_video', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('video', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'enable_motion_detection' || requestArg.service === 'disable_motion_detection') { + const enabled = requestArg.service === 'enable_motion_detection'; + return { type: 'set_motion_detection', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('motion_detection', snapshotArg, enabled, camera) }; + } + return undefined; + } + + private static amcrestCommand(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestClientCommand | 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 === 'enable_recording' || requestArg.service === 'disable_recording') { + const enabled = requestArg.service === 'enable_recording'; + return { type: 'set_recording', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled }; + } + if (requestArg.service === 'enable_audio' || requestArg.service === 'disable_audio') { + const enabled = requestArg.service === 'enable_audio'; + return { type: 'set_audio', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('audio', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'enable_motion_recording' || requestArg.service === 'disable_motion_recording') { + const enabled = requestArg.service === 'enable_motion_recording'; + return { type: 'set_motion_recording', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('motion_recording', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'set_privacy_mode') { + const enabled = this.booleanFromData(requestArg.data); + return enabled === undefined ? undefined : { type: 'set_privacy_mode', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('privacy_mode', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'set_video') { + const enabled = this.booleanFromData(requestArg.data); + return enabled === undefined ? undefined : { type: 'set_video', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('video', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'set_audio') { + const enabled = this.booleanFromData(requestArg.data); + return enabled === undefined ? undefined : { type: 'set_audio', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('audio', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'set_motion_detection') { + const enabled = this.booleanFromData(requestArg.data); + return enabled === undefined ? undefined : { type: 'set_motion_detection', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, enabled, httpCommands: this.booleanHttpCommands('motion_detection', snapshotArg, enabled, camera) }; + } + if (requestArg.service === 'goto_preset') { + const preset = this.numberValue(requestArg.data?.preset); + return preset === undefined ? undefined : { type: 'goto_preset', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, preset, httpCommands: [{ label: 'goto_preset', method: 'GET', path: `/cgi-bin/ptz.cgi?action=start&channel=${camera?.channel ?? 0}&code=GotoPreset&arg1=0&arg2=${Math.round(preset)}&arg3=0`, expect: 'ok' }] }; + } + if (requestArg.service === 'ptz_control') { + const movement = this.ptzMovement(requestArg.data?.movement); + if (!movement) { + return undefined; + } + return { type: 'ptz_control', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, movement, travelTime: this.numberValue(requestArg.data?.travel_time ?? requestArg.data?.travelTime) ?? 0.2 }; + } + if (requestArg.service === 'set_color_bw') { + const colorBw = this.colorBw(requestArg.data?.color_bw ?? requestArg.data?.colorBw); + return colorBw ? { type: 'set_color_bw', service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel, colorBw } : undefined; + } + if (requestArg.service === 'start_tour' || requestArg.service === 'stop_tour') { + return { type: requestArg.service, service: requestArg.service, target: requestArg.target, data: requestArg.data, cameraId: camera?.id, channel: camera?.channel } as IAmcrestClientCommand; + } + return undefined; + } + + private static booleanHttpCommands(kindArg: 'privacy_mode' | 'video' | 'audio' | 'motion_detection' | 'motion_recording', snapshotArg: IAmcrestSnapshot, enabledArg: boolean, cameraArg?: IAmcrestCamera) { + const camera = cameraArg || snapshotArg.cameras[0]; + const channel = camera?.channel ?? 0; + const subtype = camera?.subtype ?? amcrestResolutionSubtype.high; + const format = `${amcrestSubtypeStream[subtype]}Format`; + const value = String(enabledArg).toLowerCase(); + const field = kindArg === 'privacy_mode' + ? `LeLensMask[${channel}].Enable` + : kindArg === 'video' + ? `Encode[${channel}].${format}[0].VideoEnable` + : kindArg === 'audio' + ? `Encode[${channel}].${format}[0].AudioEnable` + : kindArg === 'motion_detection' + ? `MotionDetect[${channel}].Enable` + : `MotionDetect[${channel}].EventHandler.RecordEnable`; + return [{ label: kindArg, method: 'GET' as const, path: `/cgi-bin/configManager.cgi?action=setConfig&${field}=${value}`, expect: 'ok' as const }]; + } + + 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: 'amcrest', + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static findSwitch(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestSwitch | undefined { + const target = requestArg.target.entityId || requestArg.target.deviceId || this.stringValue(requestArg.data?.entity_id ?? requestArg.data?.entityId ?? requestArg.data?.key); + 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) || target; + return snapshotArg.switches.find((switchArg) => switchArg.key === key || switchArg.name === target || this.deviceId(snapshotArg) === target); + } + + private static findCamera(snapshotArg: IAmcrestSnapshot, requestArg: IServiceCallRequest): IAmcrestCamera | undefined { + const target = requestArg.target.entityId || this.stringValue(requestArg.data?.cameraId ?? requestArg.data?.camera_id ?? requestArg.data?.camera ?? requestArg.data?.channel); + 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?.channel === target); + const cameraId = this.stringValue(entity?.attributes?.cameraId) || target; + return snapshotArg.cameras.find((cameraArg) => cameraArg.id === cameraId || String(cameraArg.channel) === cameraId) || snapshotArg.cameras[0]; + } + + private static streamSourceUrl(cameraArg: IAmcrestCamera): string | undefined { + if (cameraArg.streamSource === 'rtsp') { + return cameraArg.rtspUrl; + } + if (cameraArg.streamSource === 'mjpeg') { + return cameraArg.mjpegUrl; + } + return cameraArg.snapshotUrl; + } + + private static deviceName(snapshotArg: IAmcrestSnapshot): string { + return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.host || 'Amcrest Camera'; + } + + private static uniqueBase(snapshotArg: IAmcrestSnapshot): string { + return this.slug(snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg)); + } + + private static cleanAttributes(attributesArg: Record): Record { + return Object.fromEntries(Object.entries(attributesArg).filter(([, valueArg]) => valueArg !== undefined)); + } + + private static deviceStateValue(valueArg: unknown): plugins.shxInterfaces.data.TDeviceStateValue { + if (valueArg === undefined) { + return null; + } + if (valueArg === null || typeof valueArg === 'string' || typeof valueArg === 'number' || typeof valueArg === 'boolean') { + return valueArg; + } + if (valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg)) { + return valueArg as Record; + } + return String(valueArg); + } + + 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 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 static stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : typeof valueArg === 'number' ? String(valueArg) : undefined; + } + + private static resolutionValue(valueArg: unknown): TAmcrestResolution | undefined { + const value = this.stringValue(valueArg); + return value === 'high' || value === 'low' ? value : undefined; + } + + private static streamSourceValue(valueArg: unknown): TAmcrestStreamSource | undefined { + const value = this.stringValue(valueArg); + return value === 'snapshot' || value === 'mjpeg' || value === 'rtsp' ? value : undefined; + } + + private static colorBw(valueArg: unknown): TAmcrestColorBw | undefined { + const value = this.stringValue(valueArg)?.toLowerCase(); + return value && amcrestColorModes.includes(value as TAmcrestColorBw) ? value as TAmcrestColorBw : undefined; + } + + private static ptzMovement(valueArg: unknown): TAmcrestPtzMovement | undefined { + const value = this.stringValue(valueArg)?.toLowerCase(); + return value && amcrestPtzMovements.includes(value as TAmcrestPtzMovement) ? value as TAmcrestPtzMovement : undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'amcrest'; + } +} + +const stateFromBoolean = (valueArg: boolean | undefined): string | undefined => { + return valueArg === undefined ? undefined : valueArg ? 'on' : 'off'; +}; diff --git a/ts/integrations/amcrest/amcrest.types.ts b/ts/integrations/amcrest/amcrest.types.ts index cc2f0dd..1fc1feb 100644 --- a/ts/integrations/amcrest/amcrest.types.ts +++ b/ts/integrations/amcrest/amcrest.types.ts @@ -1,4 +1,303 @@ -export interface IHomeAssistantAmcrestConfig { - // TODO: replace with the TypeScript-native config for amcrest. - [key: string]: unknown; +export const amcrestDefaultPort = 80; +export const amcrestDefaultRtspPort = 554; +export const amcrestDefaultTimeoutMs = 10000; +export const amcrestDefaultSnapshotTimeoutMs = 20000; + +export const amcrestResolutionSubtype = { + high: 0, + low: 1, +} as const; + +export const amcrestSubtypeStream = { + 0: 'Main', + 1: 'Extra', +} as const; + +export const amcrestColorModes = ['color', 'auto', 'bw'] as const; +export const amcrestStreamSources = ['snapshot', 'mjpeg', 'rtsp'] as const; +export const amcrestPtzMovements = [ + 'zoom_out', + 'zoom_in', + 'right', + 'left', + 'up', + 'down', + 'right_down', + 'right_up', + 'left_down', + 'left_up', +] as const; + +export type TAmcrestProtocol = 'http' | 'https'; +export type TAmcrestAuthScheme = 'auto' | 'basic' | 'digest'; +export type TAmcrestResolution = keyof typeof amcrestResolutionSubtype; +export type TAmcrestStreamSource = typeof amcrestStreamSources[number]; +export type TAmcrestColorBw = typeof amcrestColorModes[number]; +export type TAmcrestPtzMovement = typeof amcrestPtzMovements[number]; +export type TAmcrestSwitchCommand = 'privacy_mode'; +export type TAmcrestHttpMethod = 'GET'; +export type TAmcrestCommandType = + | 'refresh' + | 'stream_source' + | 'snapshot_image' + | 'set_privacy_mode' + | 'set_video' + | 'set_recording' + | 'set_audio' + | 'set_motion_detection' + | 'set_motion_recording' + | 'set_color_bw' + | 'goto_preset' + | 'ptz_control' + | 'start_tour' + | 'stop_tour'; + +export interface IAmcrestConfig { + protocol?: TAmcrestProtocol; + host?: string; + port?: number; + url?: string; + rtspPort?: number; + username?: string; + password?: string; + authScheme?: TAmcrestAuthScheme; + timeoutMs?: number; + snapshotTimeoutMs?: number; + name?: string; + uniqueId?: string; + manufacturer?: string; + model?: string; + channel?: number; + resolution?: TAmcrestResolution; + streamSource?: TAmcrestStreamSource; + controlLight?: boolean; + ffmpegArguments?: string; + supportsPtz?: boolean; + connected?: boolean; + deviceInfo?: IAmcrestDeviceInfo; + cameras?: IAmcrestCamera[]; + sensors?: IAmcrestSensor[]; + binarySensors?: IAmcrestBinarySensor[]; + switches?: IAmcrestSwitch[]; + events?: IAmcrestEvent[]; + enabledSensors?: string[]; + enabledBinarySensors?: string[]; + enabledSwitches?: string[]; + currentSettings?: Record; + snapshot?: IAmcrestSnapshot; } + +export interface IHomeAssistantAmcrestConfig extends IAmcrestConfig {} + +export interface IAmcrestDeviceInfo { + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + firmwareVersion?: string; + host?: string; + port?: number; + protocol?: TAmcrestProtocol; + rtspPort?: number; + url?: string; + online?: boolean; +} + +export interface IAmcrestCamera { + id: string; + name?: string; + channel: number; + resolution: TAmcrestResolution; + subtype: 0 | 1; + streamSource: TAmcrestStreamSource; + snapshotUrl?: string; + mjpegUrl?: string; + rtspUrl?: string; + available?: boolean; + isStreaming?: boolean; + isRecording?: boolean; + motionDetectionEnabled?: boolean; + audioEnabled?: boolean; + motionRecordingEnabled?: boolean; + colorBw?: TAmcrestColorBw; + supportsPtz?: boolean; + attributes?: Record; +} + +export interface IAmcrestSensor { + key: string; + name: string; + value: TValue; + unit?: string; + deviceClass?: string; + entityCategory?: string; + available?: boolean; + attributes?: Record; +} + +export interface IAmcrestBinarySensor { + key: string; + name: string; + isOn: boolean; + deviceClass?: string; + eventCodes?: string[]; + shouldPoll?: boolean; + available?: boolean; + attributes?: Record; +} + +export interface IAmcrestSwitch { + key: string; + name: string; + isOn: boolean; + command: TAmcrestSwitchCommand; + entityCategory?: string; + available?: boolean; + attributes?: Record; +} + +export interface IAmcrestEvent { + id: string; + name?: string; + code?: string; + state?: string; + isOn?: boolean; + updatedAt?: string; + payload?: Record; +} + +export interface IAmcrestSnapshot { + deviceInfo: IAmcrestDeviceInfo; + cameras: IAmcrestCamera[]; + sensors: IAmcrestSensor[]; + binarySensors: IAmcrestBinarySensor[]; + switches: IAmcrestSwitch[]; + events: IAmcrestEvent[]; + currentSettings: Record; + connected: boolean; + updatedAt?: string; + metadata?: Record; +} + +export interface IAmcrestHttpCommand { + label: string; + method: TAmcrestHttpMethod; + path: string; + expect?: 'ok' | 'image' | 'text'; +} + +export interface IAmcrestCommandResponse { + ok: boolean; + label: string; + method: TAmcrestHttpMethod; + path: string; + status: number; + responseText?: string; +} + +export interface IAmcrestClientCommand { + type: TAmcrestCommandType; + service: string; + target?: { + entityId?: string; + deviceId?: string; + }; + data?: Record; + cameraId?: string; + channel?: number; + resolution?: TAmcrestResolution; + streamSource?: TAmcrestStreamSource; + filename?: string; + enabled?: boolean; + preset?: number; + colorBw?: TAmcrestColorBw; + movement?: TAmcrestPtzMovement; + travelTime?: number; + httpCommands?: IAmcrestHttpCommand[]; +} + +export interface IAmcrestSnapshotImage { + contentType: string; + data: Uint8Array; +} + +export interface IAmcrestSensorDescription { + key: string; + name: string; + unit?: string; + deviceClass?: string; + entityCategory?: string; +} + +export interface IAmcrestBinarySensorDescription { + key: string; + name: string; + deviceClass?: string; + eventCodes?: string[]; + shouldPoll?: boolean; +} + +export interface IAmcrestSwitchDescription { + key: string; + name: string; + command: TAmcrestSwitchCommand; + entityCategory?: string; +} + +export interface IAmcrestManualEntry { + host?: string; + port?: number; + url?: string; + protocol?: TAmcrestProtocol; + username?: string; + password?: string; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + macAddress?: string; + metadata?: Record; +} + +export interface IAmcrestMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + addresses?: string[]; + hostname?: string; + txt?: Record; + properties?: Record; +} + +export interface IAmcrestSsdpRecord { + manufacturer?: string; + server?: string; + st?: string; + usn?: string; + location?: string; + upnp?: Record; + headers?: Record; +} + +export const amcrestSensorDescriptions: IAmcrestSensorDescription[] = [ + { key: 'ptz_preset', name: 'PTZ Preset', entityCategory: 'diagnostic' }, + { key: 'sdcard', name: 'SD Used', unit: '%', entityCategory: 'diagnostic' }, +]; + +export const amcrestBinarySensorDescriptions: IAmcrestBinarySensorDescription[] = [ + { key: 'audio_detected', name: 'Audio Detected', deviceClass: 'sound', eventCodes: ['AudioMutation', 'AudioIntensity'] }, + { key: 'audio_detected_polled', name: 'Audio Detected', deviceClass: 'sound', eventCodes: ['AudioMutation', 'AudioIntensity'], shouldPoll: true }, + { key: 'crossline_detected', name: 'CrossLine Detected', deviceClass: 'motion', eventCodes: ['CrossLineDetection'] }, + { key: 'crossline_detected_polled', name: 'CrossLine Detected', deviceClass: 'motion', eventCodes: ['CrossLineDetection'], shouldPoll: true }, + { key: 'motion_detected', name: 'Motion Detected', deviceClass: 'motion', eventCodes: ['VideoMotion'] }, + { key: 'motion_detected_polled', name: 'Motion Detected', deviceClass: 'motion', eventCodes: ['VideoMotion'], shouldPoll: true }, + { key: 'online', name: 'Online', deviceClass: 'connectivity', shouldPoll: true }, +]; + +export const amcrestSwitchDescriptions: IAmcrestSwitchDescription[] = [ + { key: 'privacy_mode', name: 'Privacy Mode', command: 'privacy_mode', entityCategory: 'config' }, +]; diff --git a/ts/integrations/amcrest/index.ts b/ts/integrations/amcrest/index.ts index 89f118a..1c4243e 100644 --- a/ts/integrations/amcrest/index.ts +++ b/ts/integrations/amcrest/index.ts @@ -1,2 +1,6 @@ +export * from './amcrest.classes.client.js'; +export * from './amcrest.classes.configflow.js'; export * from './amcrest.classes.integration.js'; +export * from './amcrest.discovery.js'; +export * from './amcrest.mapper.js'; export * from './amcrest.types.js'; diff --git a/ts/integrations/androidtv_remote/.generated-by-smarthome-exchange b/ts/integrations/androidtv_remote/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/androidtv_remote/.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/androidtv_remote/androidtv_remote.classes.client.ts b/ts/integrations/androidtv_remote/androidtv_remote.classes.client.ts new file mode 100644 index 0000000..439af6f --- /dev/null +++ b/ts/integrations/androidtv_remote/androidtv_remote.classes.client.ts @@ -0,0 +1,331 @@ +import { + androidtvRemoteApiPort, + androidtvRemoteKeyAliases, + androidtvRemoteKnownApps, + androidtvRemotePairPort, +} from './androidtv_remote.constants.js'; +import type { + IAndroidtvRemoteApp, + IAndroidtvRemoteCommand, + IAndroidtvRemoteCommandContext, + IAndroidtvRemoteConfig, + IAndroidtvRemoteConfiguredApp, + IAndroidtvRemoteDeviceInfo, + IAndroidtvRemoteDeviceState, + IAndroidtvRemoteKeyPress, + IAndroidtvRemoteSnapshot, + IAndroidtvRemoteVolumeInfo, + TAndroidtvRemoteCommandDirection, + TAndroidtvRemoteCommandExecutor, + TAndroidtvRemoteCommandReason, + TAndroidtvRemoteKeyCode, +} from './androidtv_remote.types.js'; + +export class AndroidtvRemoteUnsupportedProtocolError extends Error { + constructor(commandArg: IAndroidtvRemoteCommand) { + super(`Android TV Remote protocol action "${commandArg.action}" requires an injected executor. This TypeScript port does not implement pairing or live androidtvremote2 transport.`); + this.name = 'AndroidtvRemoteUnsupportedProtocolError'; + } +} + +export class AndroidtvRemoteClient { + private readonly snapshot?: IAndroidtvRemoteSnapshot; + + constructor(private readonly config: IAndroidtvRemoteConfig) { + this.snapshot = config.snapshot ? this.cloneSnapshot(config.snapshot) : undefined; + } + + public async getSnapshot(): Promise { + return this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig()); + } + + public async connect(): Promise { + await this.execute({ action: 'connect', reason: 'connect' }); + } + + public async startPairing(): Promise { + await this.execute({ action: 'start_pairing', reason: 'start_pairing' }); + } + + public async finishPairing(pinArg: string): Promise { + await this.execute({ action: 'finish_pairing', reason: 'finish_pairing', pin: pinArg }); + } + + public async turnOn(): Promise { + await this.sendKeyCommand('POWER', 'SHORT', 'turn_on'); + } + + public async turnOff(): Promise { + await this.sendKeyCommand('POWER', 'SHORT', 'turn_off'); + } + + public async volumeUp(): Promise { + await this.sendKeyCommand('VOLUME_UP', 'SHORT', 'volume_up'); + } + + public async volumeDown(): Promise { + await this.sendKeyCommand('VOLUME_DOWN', 'SHORT', 'volume_down'); + } + + public async muteVolume(mutedArg: boolean): Promise { + await this.sendKeyCommand('VOLUME_MUTE', 'SHORT', 'volume_mute', { muted: mutedArg }); + } + + public async setVolumeLevel(volumeLevelArg: number): Promise { + const volumeLevel = Math.max(0, Math.min(1, volumeLevelArg)); + await this.execute({ action: 'volume_set', reason: 'volume_set', volumeLevel }); + } + + public async mediaPlay(): Promise { + await this.sendKeyCommand('MEDIA_PLAY', 'SHORT', 'media_play'); + } + + public async mediaPause(): Promise { + await this.sendKeyCommand('MEDIA_PAUSE', 'SHORT', 'media_pause'); + } + + public async mediaPlayPause(): Promise { + await this.sendKeyCommand('MEDIA_PLAY_PAUSE', 'SHORT', 'media_play_pause'); + } + + public async mediaStop(): Promise { + await this.sendKeyCommand('MEDIA_STOP', 'SHORT', 'media_stop'); + } + + public async mediaPreviousTrack(): Promise { + await this.sendKeyCommand('MEDIA_PREVIOUS', 'SHORT', 'media_previous_track'); + } + + public async mediaNextTrack(): Promise { + await this.sendKeyCommand('MEDIA_NEXT', 'SHORT', 'media_next_track'); + } + + public async playChannel(channelArg: string): Promise { + if (!/^\d+$/.test(channelArg)) { + throw new Error(`Android TV Remote channel media_id must be numeric: ${channelArg}`); + } + await this.sendCommand(channelArg.split(''), { reason: 'play_channel' }); + } + + public async launchApp(appLinkOrAppIdArg: string, reasonArg: TAndroidtvRemoteCommandReason = 'launch_app'): Promise { + const app = await this.appForActivity(appLinkOrAppIdArg); + const appId = app?.id || (this.hasUrlScheme(appLinkOrAppIdArg) ? undefined : appLinkOrAppIdArg); + const appLink = app?.link || (this.hasUrlScheme(appLinkOrAppIdArg) ? appLinkOrAppIdArg : `market://launch?id=${appLinkOrAppIdArg}`); + await this.execute({ + action: 'launch_app', + reason: reasonArg, + appId, + appLink, + appName: app?.name || (appId ? androidtvRemoteKnownApps[appId] : undefined), + }); + } + + public async selectActivity(activityArg: string): Promise { + const app = await this.appForActivity(activityArg); + await this.launchApp(app?.id || activityArg, 'select_activity'); + } + + public async sendText(textArg: string): Promise { + await this.execute({ action: 'send_text', reason: 'send_text', text: textArg }); + } + + public async sendKeyCommand( + keyCodeArg: TAndroidtvRemoteKeyCode | string, + directionArg: TAndroidtvRemoteCommandDirection = 'SHORT', + reasonArg: TAndroidtvRemoteCommandReason = 'remote_send_command', + extraArg: Partial = {} + ): Promise { + await this.execute({ + action: 'key_command', + reason: reasonArg, + keyCode: this.normalizeKeyCode(keyCodeArg), + direction: directionArg, + ...extraArg, + }); + } + + public async sendCommand( + commandsArg: Array, + optionsArg: { + repeats?: number; + delaySecs?: number; + holdSecs?: number; + reason?: TAndroidtvRemoteCommandReason; + } = {} + ): Promise { + const keys: IAndroidtvRemoteKeyPress[] = commandsArg.flatMap((keyArg): IAndroidtvRemoteKeyPress[] => { + const keyCode = this.normalizeKeyCode(keyArg); + if (optionsArg.holdSecs) { + return [ + { keyCode, direction: 'START_LONG' }, + { keyCode, direction: 'END_LONG' }, + ]; + } + return [{ keyCode, direction: 'SHORT' }]; + }); + await this.execute({ + action: 'remote_send_command', + reason: optionsArg.reason || 'remote_send_command', + keys, + repeats: this.repeats(optionsArg.repeats), + delaySecs: optionsArg.delaySecs, + holdSecs: optionsArg.holdSecs, + }); + } + + public async destroy(): Promise {} + + private async execute(commandArg: IAndroidtvRemoteCommand): Promise { + const executor = this.config.executor; + if (!executor) { + throw new AndroidtvRemoteUnsupportedProtocolError(commandArg); + } + const context: IAndroidtvRemoteCommandContext = { + config: this.config, + snapshot: await this.getSnapshot(), + }; + if (typeof executor === 'function') { + await executor(commandArg, context); + return; + } + await executor.execute(commandArg, context); + } + + private snapshotFromManualConfig(): IAndroidtvRemoteSnapshot { + const deviceInfo: IAndroidtvRemoteDeviceInfo = { + ...this.config.deviceInfo, + host: this.config.deviceInfo?.host || this.config.host, + apiPort: this.config.deviceInfo?.apiPort || this.config.apiPort || androidtvRemoteApiPort, + pairPort: this.config.deviceInfo?.pairPort || this.config.pairPort || androidtvRemotePairPort, + name: this.config.deviceInfo?.name || this.config.deviceName || this.config.host || 'Android TV Remote', + macAddress: this.config.deviceInfo?.macAddress || this.config.macAddress, + manufacturer: this.config.deviceInfo?.manufacturer || this.config.manufacturer, + model: this.config.deviceInfo?.model || this.config.model, + }; + const state: IAndroidtvRemoteDeviceState = { + mediaState: 'unknown', + available: false, + ...this.config.state, + volumeInfo: this.config.state?.volumeInfo || this.config.volumeInfo, + }; + return { + deviceInfo, + state, + apps: this.normalizeApps(this.config.apps), + }; + } + + private normalizeSnapshot(snapshotArg: IAndroidtvRemoteSnapshot): IAndroidtvRemoteSnapshot { + const deviceInfo: IAndroidtvRemoteDeviceInfo = { + ...snapshotArg.deviceInfo, + host: snapshotArg.deviceInfo.host || this.config.host, + apiPort: snapshotArg.deviceInfo.apiPort || this.config.apiPort || androidtvRemoteApiPort, + pairPort: snapshotArg.deviceInfo.pairPort || this.config.pairPort || androidtvRemotePairPort, + macAddress: snapshotArg.deviceInfo.macAddress || this.config.macAddress, + }; + if (!deviceInfo.name) { + deviceInfo.name = this.config.deviceName || deviceInfo.host || 'Android TV Remote'; + } + const apps = this.normalizeApps(snapshotArg.apps.length ? snapshotArg.apps : this.config.apps); + const volumeInfo = this.normalizeVolumeInfo(snapshotArg.state.volumeInfo || this.config.volumeInfo); + const state: IAndroidtvRemoteDeviceState = { + ...snapshotArg.state, + volumeInfo, + }; + if (state.available === undefined) { + state.available = state.isOn !== undefined || Boolean(state.currentApp || volumeInfo); + } + if (!state.currentAppName && state.currentApp) { + state.currentAppName = this.appName(apps, state.currentApp); + } + if (!state.currentActivity) { + state.currentActivity = state.currentAppName || state.currentApp; + } + if (state.isVolumeMuted === undefined && volumeInfo?.muted !== undefined) { + state.isVolumeMuted = volumeInfo.muted; + } + if (state.volumeLevel === undefined) { + state.volumeLevel = this.volumeLevel(volumeInfo); + } + return { + deviceInfo, + state, + apps, + updatedAt: snapshotArg.updatedAt, + }; + } + + private normalizeApps(appsArg: IAndroidtvRemoteConfig['apps']): IAndroidtvRemoteApp[] { + if (!appsArg) { + return []; + } + if (Array.isArray(appsArg)) { + return appsArg.map((appArg) => ({ + ...appArg, + name: appArg.name || androidtvRemoteKnownApps[appArg.id], + })); + } + return Object.entries(appsArg).map(([id, appArg]) => this.normalizeConfiguredApp(id, appArg)); + } + + private normalizeConfiguredApp(idArg: string, appArg: IAndroidtvRemoteConfiguredApp): IAndroidtvRemoteApp { + return { + id: idArg, + name: appArg.name || appArg.appName || appArg.app_name || androidtvRemoteKnownApps[idArg], + icon: appArg.icon || appArg.appIcon || appArg.app_icon, + link: appArg.link, + }; + } + + private normalizeVolumeInfo(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): IAndroidtvRemoteVolumeInfo | undefined { + return volumeInfoArg ? { ...volumeInfoArg } : undefined; + } + + private volumeLevel(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): number | undefined { + if (!volumeInfoArg) { + return undefined; + } + if (typeof volumeInfoArg.max === 'number' && volumeInfoArg.max > 0 && typeof volumeInfoArg.level === 'number') { + return volumeInfoArg.level / volumeInfoArg.max; + } + return undefined; + } + + private async appForActivity(activityArg: string): Promise { + const snapshot = await this.getSnapshot(); + return snapshot.apps.find((appArg) => activityArg === appArg.id || activityArg === appArg.name || activityArg === appArg.link); + } + + private appName(appsArg: IAndroidtvRemoteApp[], appIdArg: string): string | undefined { + return appsArg.find((appArg) => appArg.id === appIdArg)?.name || androidtvRemoteKnownApps[appIdArg]; + } + + private normalizeKeyCode(keyCodeArg: TAndroidtvRemoteKeyCode | string): TAndroidtvRemoteKeyCode | string { + const raw = String(keyCodeArg).trim(); + if (!raw) { + return raw; + } + const withoutPrefix = raw.toUpperCase().replace(/^KEYCODE_/, '').replace(/[\s-]+/g, '_'); + return androidtvRemoteKeyAliases[withoutPrefix] || withoutPrefix; + } + + private repeats(repeatsArg?: number): number { + return typeof repeatsArg === 'number' && Number.isFinite(repeatsArg) ? Math.max(1, Math.floor(repeatsArg)) : 1; + } + + private hasUrlScheme(valueArg: string): boolean { + return /^[a-z][a-z0-9+.-]*:/i.test(valueArg); + } + + private cloneSnapshot(snapshotArg: IAndroidtvRemoteSnapshot): IAndroidtvRemoteSnapshot { + return { + deviceInfo: { ...snapshotArg.deviceInfo }, + state: { + ...snapshotArg.state, + volumeInfo: snapshotArg.state.volumeInfo ? { ...snapshotArg.state.volumeInfo } : undefined, + }, + apps: snapshotArg.apps.map((appArg) => ({ ...appArg })), + updatedAt: snapshotArg.updatedAt, + }; + } +} diff --git a/ts/integrations/androidtv_remote/androidtv_remote.classes.configflow.ts b/ts/integrations/androidtv_remote/androidtv_remote.classes.configflow.ts new file mode 100644 index 0000000..e310daf --- /dev/null +++ b/ts/integrations/androidtv_remote/androidtv_remote.classes.configflow.ts @@ -0,0 +1,64 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { androidtvRemoteApiPort, androidtvRemotePairPort } from './androidtv_remote.constants.js'; +import type { IAndroidtvRemoteConfig } from './androidtv_remote.types.js'; + +export class AndroidtvRemoteConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Android TV Remote', + description: 'Configure an Android TV Remote protocol v2 host. Pairing and live protocol transport require an injected executor.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'apiPort', label: 'API port', type: 'number' }, + { name: 'pairPort', label: 'Pairing port', type: 'number' }, + { name: 'deviceName', label: 'Device name', type: 'text' }, + { name: 'macAddress', label: 'MAC address', type: 'text' }, + { name: 'enableIme', label: 'Enable IME updates', type: 'boolean' }, + ], + submit: async (valuesArg) => { + const host = this.stringValue(valuesArg.host) || candidateArg.host; + if (!host) { + return { kind: 'error', title: 'Android TV Remote configuration failed', error: 'Host is required.' }; + } + const apiPort = this.numberValue(valuesArg.apiPort) || this.numberValue(candidateArg.metadata?.apiPort) || candidateArg.port || androidtvRemoteApiPort; + const pairPort = this.numberValue(valuesArg.pairPort) || this.numberValue(candidateArg.metadata?.pairPort) || androidtvRemotePairPort; + const deviceName = this.stringValue(valuesArg.deviceName) || candidateArg.name; + const macAddress = this.stringValue(valuesArg.macAddress) || candidateArg.macAddress || this.stringValue(candidateArg.metadata?.macAddress); + return { + kind: 'done', + title: 'Android TV Remote configured', + config: { + host, + apiPort, + pairPort, + deviceName, + macAddress, + manufacturer: candidateArg.manufacturer, + model: candidateArg.model, + enableIme: typeof valuesArg.enableIme === 'boolean' ? valuesArg.enableIme : true, + deviceInfo: { + id: candidateArg.id || macAddress, + name: deviceName, + host, + apiPort, + pairPort, + macAddress, + manufacturer: candidateArg.manufacturer, + model: candidateArg.model, + }, + }, + }; + }, + }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined; + } +} diff --git a/ts/integrations/androidtv_remote/androidtv_remote.classes.integration.ts b/ts/integrations/androidtv_remote/androidtv_remote.classes.integration.ts index 79f566e..1971743 100644 --- a/ts/integrations/androidtv_remote/androidtv_remote.classes.integration.ts +++ b/ts/integrations/androidtv_remote/androidtv_remote.classes.integration.ts @@ -1,28 +1,236 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import * as plugins from '../../plugins.js'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { AndroidtvRemoteClient } from './androidtv_remote.classes.client.js'; +import { AndroidtvRemoteConfigFlow } from './androidtv_remote.classes.configflow.js'; +import { createAndroidtvRemoteDiscoveryDescriptor } from './androidtv_remote.discovery.js'; +import { AndroidtvRemoteMapper } from './androidtv_remote.mapper.js'; +import type { IAndroidtvRemoteConfig } from './androidtv_remote.types.js'; -export class HomeAssistantAndroidtvRemoteIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "androidtv_remote", - displayName: "Android TV Remote", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/androidtv_remote", - "upstreamDomain": "androidtv_remote", - "integrationType": "device", - "iotClass": "local_push", - "qualityScale": "platinum", - "requirements": [ - "androidtvremote2==0.3.1" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@tronikos", - "@Drafteed" - ] -}, +export class AndroidtvRemoteIntegration extends BaseIntegration { + public readonly domain = 'androidtv_remote'; + public readonly displayName = 'Android TV Remote'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAndroidtvRemoteDiscoveryDescriptor(); + public readonly configFlow = new AndroidtvRemoteConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/androidtv_remote', + upstreamDomain: 'androidtv_remote', + integrationType: 'device', + iotClass: 'local_push', + qualityScale: 'platinum', + requirements: ['androidtvremote2==0.3.1'], + dependencies: [], + afterDependencies: [], + codeowners: ['@tronikos', '@Drafteed'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/androidtv_remote', + }; + + public async setup(configArg: IAndroidtvRemoteConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AndroidtvRemoteRuntime(new AndroidtvRemoteClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAndroidtvRemoteIntegration extends AndroidtvRemoteIntegration {} + +class AndroidtvRemoteRuntime implements IIntegrationRuntime { + public domain = 'androidtv_remote'; + + constructor(private readonly client: AndroidtvRemoteClient) {} + + public async devices(): Promise { + return AndroidtvRemoteMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AndroidtvRemoteMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + try { + if (requestArg.domain === 'remote') { + return await this.callRemoteService(requestArg); + } + if (requestArg.domain === 'androidtv_remote') { + return await this.callAndroidtvRemoteService(requestArg); + } + if (requestArg.domain !== 'media_player') { + return { success: false, error: `Unsupported Android TV Remote service domain: ${requestArg.domain}` }; + } + return await this.callMediaPlayerService(requestArg); + } catch (errorArg) { + return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async callRemoteService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'turn_on') { + await this.client.turnOn(); + const activity = this.stringValue(requestArg.data?.activity); + if (activity) { + await this.client.selectActivity(activity); + } + return { success: true }; + } + if (requestArg.service === 'turn_off') { + await this.client.turnOff(); + return { success: true }; + } + if (requestArg.service !== 'send_command') { + return { success: false, error: `Unsupported Android TV Remote remote service: ${requestArg.service}` }; + } + const command = requestArg.data?.command; + const commands = typeof command === 'string' ? [command] : Array.isArray(command) ? command.filter((itemArg): itemArg is string => typeof itemArg === 'string' && Boolean(itemArg)) : []; + if (!commands.length) { + return { success: false, error: 'Android TV Remote remote.send_command requires data.command.' }; + } + await this.client.sendCommand(commands, { + repeats: this.numberValue(requestArg.data?.num_repeats ?? requestArg.data?.numRepeats), + delaySecs: this.numberValue(requestArg.data?.delay_secs ?? requestArg.data?.delaySecs), + holdSecs: this.numberValue(requestArg.data?.hold_secs ?? requestArg.data?.holdSecs), + reason: 'remote_send_command', }); + return { success: true }; + } + + private async callAndroidtvRemoteService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'connect') { + await this.client.connect(); + return { success: true }; + } + if (requestArg.service === 'start_pairing') { + await this.client.startPairing(); + return { success: true }; + } + if (requestArg.service === 'finish_pairing') { + const pin = this.stringValue(requestArg.data?.pin); + if (!pin) { + return { success: false, error: 'Android TV Remote finish_pairing requires data.pin.' }; + } + await this.client.finishPairing(pin); + return { success: true }; + } + if (requestArg.service === 'launch_app') { + const app = this.stringValue(requestArg.data?.app_id ?? requestArg.data?.appId ?? requestArg.data?.app_link ?? requestArg.data?.appLink); + if (!app) { + return { success: false, error: 'Android TV Remote launch_app requires data.app_id or data.app_link.' }; + } + await this.client.launchApp(app); + return { success: true }; + } + if (requestArg.service === 'send_text') { + const text = this.stringValue(requestArg.data?.text); + if (!text) { + return { success: false, error: 'Android TV Remote send_text requires data.text.' }; + } + await this.client.sendText(text); + return { success: true }; + } + return { success: false, error: `Unsupported Android TV Remote service: ${requestArg.service}` }; + } + + private async callMediaPlayerService(requestArg: IServiceCallRequest): Promise { + if (requestArg.service === 'turn_on') { + await this.client.turnOn(); + return { success: true }; + } + if (requestArg.service === 'turn_off') { + await this.client.turnOff(); + return { success: true }; + } + if (requestArg.service === 'play' || requestArg.service === 'media_play') { + await this.client.mediaPlay(); + return { success: true }; + } + if (requestArg.service === 'pause' || requestArg.service === 'media_pause') { + await this.client.mediaPause(); + return { success: true }; + } + if (requestArg.service === 'play_pause' || requestArg.service === 'media_play_pause') { + await this.client.mediaPlayPause(); + return { success: true }; + } + if (requestArg.service === 'stop' || requestArg.service === 'media_stop') { + await this.client.mediaStop(); + return { success: true }; + } + if (requestArg.service === 'next_track' || requestArg.service === 'media_next_track') { + await this.client.mediaNextTrack(); + return { success: true }; + } + if (requestArg.service === 'previous_track' || requestArg.service === 'media_previous_track') { + await this.client.mediaPreviousTrack(); + return { success: true }; + } + if (requestArg.service === 'volume_up') { + await this.client.volumeUp(); + return { success: true }; + } + if (requestArg.service === 'volume_down') { + await this.client.volumeDown(); + return { success: true }; + } + if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') { + const muted = requestArg.data?.is_volume_muted ?? requestArg.data?.muted; + if (typeof muted !== 'boolean') { + return { success: false, error: 'Android TV Remote volume_mute requires data.is_volume_muted.' }; + } + await this.client.muteVolume(muted); + return { success: true }; + } + if (requestArg.service === 'volume_set') { + const level = requestArg.data?.volume_level; + if (typeof level !== 'number') { + return { success: false, error: 'Android TV Remote volume_set requires data.volume_level.' }; + } + await this.client.setVolumeLevel(level); + return { success: true }; + } + if (requestArg.service === 'select_source') { + const source = this.stringValue(requestArg.data?.source); + if (!source) { + return { success: false, error: 'Android TV Remote select_source requires data.source.' }; + } + await this.client.selectActivity(source); + return { success: true }; + } + if (requestArg.service === 'play_media') { + return await this.callPlayMediaService(requestArg); + } + return { success: false, error: `Unsupported Android TV Remote media_player service: ${requestArg.service}` }; + } + + private async callPlayMediaService(requestArg: IServiceCallRequest): Promise { + const mediaId = this.stringValue(requestArg.data?.media_content_id ?? requestArg.data?.media_id ?? requestArg.data?.uri); + const mediaType = this.stringValue(requestArg.data?.media_content_type ?? requestArg.data?.media_type); + if (!mediaId) { + return { success: false, error: 'Android TV Remote play_media requires data.media_content_id.' }; + } + if (mediaType === 'channel') { + await this.client.playChannel(mediaId); + return { success: true }; + } + if (mediaType === 'app' || mediaType === 'url') { + await this.client.launchApp(mediaId); + return { success: true }; + } + return { success: false, error: `Unsupported Android TV Remote media type: ${mediaType || 'unknown'}` }; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; } } diff --git a/ts/integrations/androidtv_remote/androidtv_remote.constants.ts b/ts/integrations/androidtv_remote/androidtv_remote.constants.ts new file mode 100644 index 0000000..47ceb79 --- /dev/null +++ b/ts/integrations/androidtv_remote/androidtv_remote.constants.ts @@ -0,0 +1,56 @@ +import type { TAndroidtvRemoteKeyCode } from './androidtv_remote.types.js'; + +export const androidtvRemoteApiPort = 6466; +export const androidtvRemotePairPort = 6467; +export const androidtvRemoteMdnsService = '_androidtvremote2._tcp.local.'; + +export const androidtvRemoteKnownApps: Record = { + 'com.android.tv.settings': 'Settings', + 'com.disney.disneyplus': 'Disney+', + 'com.google.android.apps.tv.launcherx': 'Google TV Launcher', + 'com.google.android.tvlauncher': 'Android TV Launcher', + 'com.google.android.youtube.tv': 'YouTube', + 'com.google.android.youtube.tvkids': 'YouTube Kids', + 'com.google.android.youtube.tvmusic': 'YouTube Music', + 'com.hbo.hbonow': 'HBO Max', + 'com.hulu.plus': 'Hulu', + 'com.netflix.ninja': 'Netflix', + 'com.plexapp.android': 'Plex', + 'com.spotify.tv.android': 'Spotify', + 'org.jellyfin.androidtv': 'Jellyfin', + 'org.videolan.vlc': 'VLC', + 'org.xbmc.kodi': 'Kodi', + 'tv.twitch.android.app': 'Twitch', +}; + +export const androidtvRemoteKeyAliases: Record = { + BLUE: 'PROG_BLUE', + CENTER: 'DPAD_CENTER', + CH_DOWN: 'CHANNEL_DOWN', + CH_UP: 'CHANNEL_UP', + CHANNELDOWN: 'CHANNEL_DOWN', + CHANNELUP: 'CHANNEL_UP', + DOWN: 'DPAD_DOWN', + FAST_FORWARD: 'MEDIA_FAST_FORWARD', + FORWARD: 'MEDIA_FAST_FORWARD', + GREEN: 'PROG_GREEN', + INFO: 'INFO', + LEFT: 'DPAD_LEFT', + NEXT: 'MEDIA_NEXT', + PAUSE: 'MEDIA_PAUSE', + PLAY: 'MEDIA_PLAY', + PLAY_PAUSE: 'MEDIA_PLAY_PAUSE', + PREVIOUS: 'MEDIA_PREVIOUS', + RED: 'PROG_RED', + REWIND: 'MEDIA_REWIND', + RIGHT: 'DPAD_RIGHT', + SELECT: 'DPAD_CENTER', + STOP: 'MEDIA_STOP', + UP: 'DPAD_UP', + VOL_DOWN: 'VOLUME_DOWN', + VOL_UP: 'VOLUME_UP', + VOLUMEDOWN: 'VOLUME_DOWN', + VOLUMEMUTE: 'VOLUME_MUTE', + VOLUMEUP: 'VOLUME_UP', + YELLOW: 'PROG_YELLOW', +}; diff --git a/ts/integrations/androidtv_remote/androidtv_remote.discovery.ts b/ts/integrations/androidtv_remote/androidtv_remote.discovery.ts new file mode 100644 index 0000000..d54495a --- /dev/null +++ b/ts/integrations/androidtv_remote/androidtv_remote.discovery.ts @@ -0,0 +1,127 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { androidtvRemoteApiPort, androidtvRemoteMdnsService, androidtvRemotePairPort } from './androidtv_remote.constants.js'; +import type { IAndroidtvRemoteManualEntry, IAndroidtvRemoteMdnsRecord } from './androidtv_remote.types.js'; + +export class AndroidtvRemoteMdnsMatcher implements IDiscoveryMatcher { + public id = 'androidtv-remote-mdns-match'; + public source = 'mdns' as const; + public description = 'Recognize Android TV Remote protocol v2 mDNS advertisements.'; + + public async matches(recordArg: IAndroidtvRemoteMdnsRecord): Promise { + const type = this.stringValue(recordArg.type || recordArg.metadata?.type).toLowerCase(); + const name = this.stringValue(recordArg.name || recordArg.metadata?.name); + const matched = type.includes('androidtvremote2') || name.toLowerCase().includes(androidtvRemoteMdnsService); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'mDNS record is not an Android TV Remote advertisement.' }; + } + const properties = { ...(recordArg.txt || {}), ...(recordArg.properties || {}) }; + const macAddress = this.stringValue(properties.bt || properties.mac || properties.macAddress || recordArg.metadata?.macAddress); + const id = this.stringValue(properties.id || macAddress); + const displayName = this.displayName(name); + const apiPort = this.numberValue(recordArg.port) || androidtvRemoteApiPort; + return { + matched: true, + confidence: recordArg.host && macAddress ? 'certain' : recordArg.host ? 'high' : 'medium', + reason: 'mDNS record matches Android TV Remote protocol v2.', + normalizedDeviceId: id || recordArg.host, + candidate: { + source: 'mdns', + integrationDomain: 'androidtv_remote', + id, + host: recordArg.host, + port: apiPort, + name: displayName, + macAddress, + metadata: { + type: recordArg.type, + txt: properties, + apiPort, + pairPort: androidtvRemotePairPort, + }, + }, + }; + } + + private displayName(nameArg: string): string | undefined { + const name = nameArg.replace(androidtvRemoteMdnsService, '').replace(/\.$/, '').trim(); + return name || undefined; + } + + private stringValue(valueArg: unknown): string { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : ''; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined; + } +} + +export class AndroidtvRemoteManualMatcher implements IDiscoveryMatcher { + public id = 'androidtv-remote-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Android TV Remote host entries.'; + + public async matches(inputArg: IAndroidtvRemoteManualEntry): Promise { + const matched = Boolean(inputArg.host || inputArg.metadata?.androidtvRemote || inputArg.metadata?.androidtv_remote); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not include an Android TV Remote host.' }; + } + const apiPort = this.numberValue(inputArg.apiPort || inputArg.port || inputArg.metadata?.apiPort) || androidtvRemoteApiPort; + const pairPort = this.numberValue(inputArg.pairPort || inputArg.metadata?.pairPort) || androidtvRemotePairPort; + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Android TV Remote setup.', + normalizedDeviceId: inputArg.id || inputArg.macAddress || inputArg.host, + candidate: { + source: 'manual', + integrationDomain: 'androidtv_remote', + id: inputArg.id || inputArg.macAddress, + host: inputArg.host, + port: apiPort, + name: inputArg.deviceName || inputArg.name, + manufacturer: inputArg.manufacturer, + model: inputArg.model, + macAddress: inputArg.macAddress, + metadata: { + ...inputArg.metadata, + apiPort, + pairPort, + enableIme: inputArg.enableIme, + }, + }, + }; + } + + private numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) && valueArg > 0 ? valueArg : undefined; + } +} + +export class AndroidtvRemoteCandidateValidator implements IDiscoveryValidator { + public id = 'androidtv-remote-candidate-validator'; + public description = 'Validate Android TV Remote candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const matched = candidateArg.integrationDomain === 'androidtv_remote' || Boolean(candidateArg.metadata?.androidtvRemote || candidateArg.metadata?.androidtv_remote); + return { + matched, + confidence: matched && candidateArg.host && (candidateArg.id || candidateArg.macAddress) ? 'certain' : matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Android TV Remote metadata.' : 'Candidate is not Android TV Remote.', + candidate: matched ? candidateArg : undefined, + normalizedDeviceId: candidateArg.id || candidateArg.macAddress || candidateArg.host, + metadata: matched ? { + apiPort: candidateArg.port || candidateArg.metadata?.apiPort || androidtvRemoteApiPort, + pairPort: candidateArg.metadata?.pairPort || androidtvRemotePairPort, + } : undefined, + }; + } +} + +export const createAndroidtvRemoteDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: 'androidtv_remote', displayName: 'Android TV Remote' }) + .addMatcher(new AndroidtvRemoteMdnsMatcher()) + .addMatcher(new AndroidtvRemoteManualMatcher()) + .addValidator(new AndroidtvRemoteCandidateValidator()); +}; diff --git a/ts/integrations/androidtv_remote/androidtv_remote.mapper.ts b/ts/integrations/androidtv_remote/androidtv_remote.mapper.ts new file mode 100644 index 0000000..a81e84e --- /dev/null +++ b/ts/integrations/androidtv_remote/androidtv_remote.mapper.ts @@ -0,0 +1,165 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import { androidtvRemoteKnownApps } from './androidtv_remote.constants.js'; +import type { IAndroidtvRemoteApp, IAndroidtvRemoteSnapshot, IAndroidtvRemoteVolumeInfo } from './androidtv_remote.types.js'; + +export class AndroidtvRemoteMapper { + public static toDevices(snapshotArg: IAndroidtvRemoteSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.updatedAt || new Date().toISOString(); + return [{ + id: this.deviceId(snapshotArg), + integrationDomain: 'androidtv_remote', + name: this.deviceName(snapshotArg), + protocol: 'unknown', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Android', + model: snapshotArg.deviceInfo.model || 'Android TV Remote', + online: this.available(snapshotArg), + features: [ + { id: 'power', capability: 'media', name: 'Power', readable: true, writable: true }, + { id: 'media_state', capability: 'media', name: 'Media state', readable: true, writable: false }, + { id: 'activity', capability: 'media', name: 'Activity', readable: true, writable: true }, + { id: 'volume', capability: 'media', name: 'Volume', readable: true, writable: true }, + { id: 'mute', capability: 'media', name: 'Mute', readable: true, writable: true }, + { id: 'remote_key', capability: 'media', name: 'Remote key', readable: false, writable: true }, + ], + state: [ + { featureId: 'power', value: this.powerState(snapshotArg), updatedAt }, + { featureId: 'media_state', value: this.mediaState(snapshotArg), updatedAt }, + { featureId: 'activity', value: this.activity(snapshotArg) || null, updatedAt }, + { featureId: 'volume', value: this.volumePercent(snapshotArg), updatedAt }, + { featureId: 'mute', value: this.muted(snapshotArg), updatedAt }, + ], + metadata: { + host: snapshotArg.deviceInfo.host, + protocol: 'androidtvremote2', + apiPort: snapshotArg.deviceInfo.apiPort, + pairPort: snapshotArg.deviceInfo.pairPort, + macAddress: snapshotArg.deviceInfo.macAddress, + softwareVersion: snapshotArg.deviceInfo.softwareVersion, + appVersion: snapshotArg.deviceInfo.appVersion, + currentApp: snapshotArg.state.currentApp, + voiceEnabled: snapshotArg.state.voiceEnabled, + apps: snapshotArg.apps.map((appArg) => ({ id: appArg.id, name: this.appName(appArg), icon: appArg.icon, link: appArg.link })), + }, + }]; + } + + public static toEntities(snapshotArg: IAndroidtvRemoteSnapshot): IIntegrationEntity[] { + return [{ + id: `media_player.${this.slug(this.deviceName(snapshotArg))}`, + uniqueId: `androidtv_remote_${this.slug(this.stableDeviceKey(snapshotArg))}`, + integrationDomain: 'androidtv_remote', + deviceId: this.deviceId(snapshotArg), + platform: 'media_player', + name: this.deviceName(snapshotArg), + state: this.mediaState(snapshotArg), + attributes: { + appId: snapshotArg.state.currentApp, + appName: snapshotArg.state.currentAppName || (snapshotArg.state.currentApp ? this.appNameById(snapshotArg, snapshotArg.state.currentApp) : undefined), + currentActivity: this.activity(snapshotArg), + activityList: this.activityList(snapshotArg), + source: this.activity(snapshotArg), + sourceList: this.activityList(snapshotArg), + volumeLevel: this.normalizedVolumeLevel(snapshotArg), + isVolumeMuted: this.muted(snapshotArg), + assumedState: true, + voiceEnabled: snapshotArg.state.voiceEnabled, + rawState: snapshotArg.state.rawState, + }, + available: this.available(snapshotArg), + }]; + } + + public static mediaState(snapshotArg: IAndroidtvRemoteSnapshot): string { + if (!this.available(snapshotArg)) { + return 'unavailable'; + } + const rawState = String(snapshotArg.state.mediaState || snapshotArg.state.rawState || '').toLowerCase(); + if (snapshotArg.state.isOn === false || snapshotArg.state.powerState === 'off' || rawState === 'off') { + return 'off'; + } + if (['playing', 'paused', 'stopped', 'idle', 'buffering', 'on'].includes(rawState)) { + return rawState; + } + if (snapshotArg.state.isOn === true || snapshotArg.state.currentApp) { + return 'on'; + } + return 'unknown'; + } + + public static powerState(snapshotArg: IAndroidtvRemoteSnapshot): string { + if (snapshotArg.state.isOn === true || snapshotArg.state.powerState === 'on') { + return 'on'; + } + if (snapshotArg.state.isOn === false || snapshotArg.state.powerState === 'off' || String(snapshotArg.state.mediaState || '').toLowerCase() === 'off') { + return 'off'; + } + return this.available(snapshotArg) && snapshotArg.state.currentApp ? 'on' : 'unknown'; + } + + private static available(snapshotArg: IAndroidtvRemoteSnapshot): boolean { + return snapshotArg.state.available !== false; + } + + private static activity(snapshotArg: IAndroidtvRemoteSnapshot): string | undefined { + return snapshotArg.state.currentActivity || snapshotArg.state.currentAppName || (snapshotArg.state.currentApp ? this.appNameById(snapshotArg, snapshotArg.state.currentApp) || snapshotArg.state.currentApp : undefined); + } + + private static activityList(snapshotArg: IAndroidtvRemoteSnapshot): string[] { + const activities = new Set(); + for (const appArg of snapshotArg.apps) { + const name = this.appName(appArg); + if (name) { + activities.add(name); + } + } + return [...activities]; + } + + private static appNameById(snapshotArg: IAndroidtvRemoteSnapshot, appIdArg: string): string | undefined { + return snapshotArg.apps.find((appArg) => appArg.id === appIdArg)?.name || androidtvRemoteKnownApps[appIdArg]; + } + + private static appName(appArg: IAndroidtvRemoteApp): string { + return appArg.name || androidtvRemoteKnownApps[appArg.id] || appArg.id; + } + + private static normalizedVolumeLevel(snapshotArg: IAndroidtvRemoteSnapshot): number | undefined { + if (typeof snapshotArg.state.volumeLevel === 'number') { + return Math.max(0, Math.min(1, snapshotArg.state.volumeLevel)); + } + return this.volumeLevelFromInfo(snapshotArg.state.volumeInfo); + } + + private static volumePercent(snapshotArg: IAndroidtvRemoteSnapshot): number | null { + const level = this.normalizedVolumeLevel(snapshotArg); + return typeof level === 'number' ? Math.round(level * 100) : null; + } + + private static volumeLevelFromInfo(volumeInfoArg?: IAndroidtvRemoteVolumeInfo): number | undefined { + if (!volumeInfoArg || typeof volumeInfoArg.level !== 'number' || typeof volumeInfoArg.max !== 'number' || volumeInfoArg.max <= 0) { + return undefined; + } + return Math.max(0, Math.min(1, volumeInfoArg.level / volumeInfoArg.max)); + } + + private static muted(snapshotArg: IAndroidtvRemoteSnapshot): boolean | null { + return snapshotArg.state.isVolumeMuted ?? snapshotArg.state.volumeInfo?.muted ?? null; + } + + private static deviceId(snapshotArg: IAndroidtvRemoteSnapshot): string { + return `androidtv_remote.device.${this.slug(this.stableDeviceKey(snapshotArg))}`; + } + + private static stableDeviceKey(snapshotArg: IAndroidtvRemoteSnapshot): string { + return snapshotArg.deviceInfo.id || snapshotArg.deviceInfo.macAddress || snapshotArg.deviceInfo.host || this.deviceName(snapshotArg); + } + + private static deviceName(snapshotArg: IAndroidtvRemoteSnapshot): string { + return snapshotArg.deviceInfo.name || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || 'Android TV Remote'; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'androidtv_remote'; + } +} diff --git a/ts/integrations/androidtv_remote/androidtv_remote.types.ts b/ts/integrations/androidtv_remote/androidtv_remote.types.ts index 23334e6..7387158 100644 --- a/ts/integrations/androidtv_remote/androidtv_remote.types.ts +++ b/ts/integrations/androidtv_remote/androidtv_remote.types.ts @@ -1,4 +1,254 @@ -export interface IHomeAssistantAndroidtvRemoteConfig { - // TODO: replace with the TypeScript-native config for androidtv_remote. - [key: string]: unknown; +export type TAndroidtvRemoteMediaState = + | 'off' + | 'on' + | 'idle' + | 'playing' + | 'paused' + | 'stopped' + | 'buffering' + | 'unknown'; + +export type TAndroidtvRemotePowerState = 'on' | 'off' | 'unknown'; + +export type TAndroidtvRemoteCommandDirection = 'SHORT' | 'START_LONG' | 'END_LONG'; + +export type TAndroidtvRemoteKeyCode = + | '0' + | '1' + | '2' + | '3' + | '4' + | '5' + | '6' + | '7' + | '8' + | '9' + | 'ASSIST' + | 'BACK' + | 'BUTTON_A' + | 'BUTTON_B' + | 'BUTTON_MODE' + | 'BUTTON_X' + | 'BUTTON_Y' + | 'CAPTIONS' + | 'CHANNEL_DOWN' + | 'CHANNEL_UP' + | 'DEL' + | 'DPAD_CENTER' + | 'DPAD_DOWN' + | 'DPAD_LEFT' + | 'DPAD_RIGHT' + | 'DPAD_UP' + | 'DVR' + | 'ENTER' + | 'EXPLORER' + | 'F1' + | 'F2' + | 'F3' + | 'F4' + | 'F5' + | 'F6' + | 'F7' + | 'F8' + | 'F9' + | 'F10' + | 'F11' + | 'F12' + | 'GUIDE' + | 'HOME' + | 'INFO' + | 'MEDIA_AUDIO_TRACK' + | 'MEDIA_FAST_FORWARD' + | 'MEDIA_NEXT' + | 'MEDIA_PAUSE' + | 'MEDIA_PLAY' + | 'MEDIA_PLAY_PAUSE' + | 'MEDIA_PREVIOUS' + | 'MEDIA_RECORD' + | 'MEDIA_REWIND' + | 'MEDIA_STOP' + | 'MENU' + | 'MUTE' + | 'POWER' + | 'PROG_BLUE' + | 'PROG_GREEN' + | 'PROG_RED' + | 'PROG_YELLOW' + | 'SEARCH' + | 'SETTINGS' + | 'TV' + | 'TV_TELETEXT' + | 'VOLUME_DOWN' + | 'VOLUME_MUTE' + | 'VOLUME_UP'; + +export type TAndroidtvRemoteCommandReason = + | 'connect' + | 'finish_pairing' + | 'launch_app' + | 'media_next_track' + | 'media_pause' + | 'media_play' + | 'media_play_pause' + | 'media_previous_track' + | 'media_stop' + | 'play_channel' + | 'remote_send_command' + | 'select_activity' + | 'send_text' + | 'start_pairing' + | 'turn_off' + | 'turn_on' + | 'volume_down' + | 'volume_mute' + | 'volume_set' + | 'volume_up'; + +export type TAndroidtvRemoteCommandAction = + | 'connect' + | 'finish_pairing' + | 'key_command' + | 'launch_app' + | 'remote_send_command' + | 'send_text' + | 'start_pairing' + | 'volume_set'; + +export interface IAndroidtvRemoteVolumeInfo { + level?: number; + max?: number; + muted?: boolean; + playerModel?: string; } + +export interface IAndroidtvRemoteDeviceInfo { + id?: string; + name?: string; + host?: string; + apiPort?: number; + pairPort?: number; + macAddress?: string; + manufacturer?: string; + model?: string; + softwareVersion?: string; + appVersion?: string; +} + +export interface IAndroidtvRemoteDeviceState { + available?: boolean; + isOn?: boolean | null; + powerState?: TAndroidtvRemotePowerState; + mediaState?: TAndroidtvRemoteMediaState | string; + rawState?: string; + currentApp?: string; + currentAppName?: string; + currentActivity?: string; + volumeInfo?: IAndroidtvRemoteVolumeInfo; + volumeLevel?: number; + isVolumeMuted?: boolean; + voiceEnabled?: boolean; +} + +export interface IAndroidtvRemoteApp { + id: string; + name?: string; + icon?: string; + link?: string; +} + +export interface IAndroidtvRemoteConfiguredApp { + appName?: string; + appIcon?: string; + app_name?: string; + app_icon?: string; + name?: string; + icon?: string; + link?: string; +} + +export interface IAndroidtvRemoteSnapshot { + deviceInfo: IAndroidtvRemoteDeviceInfo; + state: IAndroidtvRemoteDeviceState; + apps: IAndroidtvRemoteApp[]; + updatedAt?: string; +} + +export interface IAndroidtvRemoteKeyPress { + keyCode: TAndroidtvRemoteKeyCode | string; + direction?: TAndroidtvRemoteCommandDirection; +} + +export interface IAndroidtvRemoteCommand { + action: TAndroidtvRemoteCommandAction; + reason?: TAndroidtvRemoteCommandReason; + keyCode?: TAndroidtvRemoteKeyCode | string; + direction?: TAndroidtvRemoteCommandDirection; + keys?: IAndroidtvRemoteKeyPress[]; + appId?: string; + appLink?: string; + appName?: string; + text?: string; + pin?: string; + volumeLevel?: number; + muted?: boolean; + repeats?: number; + delaySecs?: number; + holdSecs?: number; +} + +export interface IAndroidtvRemoteCommandContext { + config: IAndroidtvRemoteConfig; + snapshot: IAndroidtvRemoteSnapshot; +} + +export type TAndroidtvRemoteCommandExecutor = + | ((commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext) => Promise | void) + | { + execute(commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext): Promise | void; + }; + +export interface IAndroidtvRemoteConfig { + host?: string; + apiPort?: number; + pairPort?: number; + deviceName?: string; + macAddress?: string; + manufacturer?: string; + model?: string; + enableIme?: boolean; + deviceInfo?: IAndroidtvRemoteDeviceInfo; + state?: IAndroidtvRemoteDeviceState; + volumeInfo?: IAndroidtvRemoteVolumeInfo; + apps?: IAndroidtvRemoteApp[] | Record; + snapshot?: IAndroidtvRemoteSnapshot; + executor?: TAndroidtvRemoteCommandExecutor; +} + +export interface IAndroidtvRemoteMdnsRecord { + type?: string; + name?: string; + host?: string; + port?: number; + txt?: Record; + properties?: Record; + metadata?: Record; +} + +export interface IAndroidtvRemoteManualEntry { + host?: string; + apiPort?: number; + pairPort?: number; + port?: number; + id?: string; + name?: string; + deviceName?: string; + macAddress?: string; + manufacturer?: string; + model?: string; + enableIme?: boolean; + metadata?: Record; +} + +export type TAndroidtvRemoteDiscoveryRecord = IAndroidtvRemoteMdnsRecord | IAndroidtvRemoteManualEntry; + +export type IHomeAssistantAndroidtvRemoteConfig = IAndroidtvRemoteConfig; diff --git a/ts/integrations/androidtv_remote/index.ts b/ts/integrations/androidtv_remote/index.ts index 2a2544d..5160e54 100644 --- a/ts/integrations/androidtv_remote/index.ts +++ b/ts/integrations/androidtv_remote/index.ts @@ -1,2 +1,7 @@ +export * from './androidtv_remote.classes.client.js'; +export * from './androidtv_remote.classes.configflow.js'; export * from './androidtv_remote.classes.integration.js'; +export * from './androidtv_remote.constants.js'; +export * from './androidtv_remote.discovery.js'; +export * from './androidtv_remote.mapper.js'; export * from './androidtv_remote.types.js'; diff --git a/ts/integrations/arcam_fmj/.generated-by-smarthome-exchange b/ts/integrations/arcam_fmj/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/arcam_fmj/.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/arcam_fmj/arcam_fmj.classes.client.ts b/ts/integrations/arcam_fmj/arcam_fmj.classes.client.ts new file mode 100644 index 0000000..d062692 --- /dev/null +++ b/ts/integrations/arcam_fmj/arcam_fmj.classes.client.ts @@ -0,0 +1,748 @@ +import * as plugins from '../../plugins.js'; +import type { + IArcamFmjCommandRequest, + IArcamFmjCommandResult, + IArcamFmjConfig, + IArcamFmjDeviceInfo, + IArcamFmjIncomingAudioInfo, + IArcamFmjIncomingVideoParameters, + IArcamFmjModeledCommand, + IArcamFmjResponsePacket, + IArcamFmjSnapshot, + IArcamFmjZoneState, + TArcamFmjApiModel, + TArcamFmjSnapshotSource, + TArcamFmjSource, +} from './arcam_fmj.types.js'; +import { arcamFmjDefaultPort } from './arcam_fmj.types.js'; + +const defaultTimeoutMs = 3000; +const protocolStart = 0x21; +const protocolEnd = 0x0d; +const queryByte = 0xf0; + +const commandCodes = { + POWER: 0x00, + SIMULATE_RC5_IR_COMMAND: 0x08, + VOLUME: 0x0d, + MUTE: 0x0e, + DECODE_MODE_STATUS_2CH: 0x10, + DECODE_MODE_STATUS_MCH: 0x11, + RDS_INFORMATION: 0x12, + MENU: 0x14, + TUNER_PRESET: 0x15, + DAB_STATION: 0x18, + DLS_PDT_INFO: 0x1a, + PRESET_DETAIL: 0x1b, + CURRENT_SOURCE: 0x1d, + INCOMING_VIDEO_PARAMETERS: 0x42, + INCOMING_AUDIO_FORMAT: 0x43, + INCOMING_AUDIO_SAMPLE_RATE: 0x44, +} as const; + +const commandNames = Object.fromEntries( + Object.entries(commandCodes).map(([key, value]) => [value, key]) +) as Record; + +const directPowerWriteSupported = new Set(['APISA_SERIES', 'APIPA_SERIES', 'APIST_SERIES']); +const directMuteWriteSupported = directPowerWriteSupported; +const directSourceWriteSupported = new Set(['APISA_SERIES']); +const volumeStepSupported = new Set(['APIST_SERIES']); + +const modelSeries: Array<{ apiModel: TArcamFmjApiModel; models: string[] }> = [ + { apiModel: 'API450_SERIES', models: ['AVR380', 'AVR450', 'AVR750'] }, + { apiModel: 'API860_SERIES', models: ['AV860', 'AVR850', 'AVR550', 'AVR390', 'SR250', 'RV-6', 'RV-9', 'MC-10'] }, + { apiModel: 'APIHDA_SERIES', models: ['AVR5', 'AVR10', 'AVR20', 'AVR30', 'AV40', 'AVR11', 'AVR21', 'AVR31', 'AV41', 'SDP-55', 'SDP-58'] }, + { apiModel: 'APISA_SERIES', models: ['SA10', 'SA20', 'SA30', 'SA750'] }, + { apiModel: 'APIPA_SERIES', models: ['PA720', 'PA240', 'PA410'] }, + { apiModel: 'APIST_SERIES', models: ['ST60'] }, +]; + +const statusSourceMaps: Record>>> = { + API450_SERIES: { + 1: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 }, + 2: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 }, + }, + API860_SERIES: { + 1: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 }, + 2: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, VCR: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, PHONO: 0x12, ARC_ERC: 0x13 }, + }, + APIHDA_SERIES: { + 1: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, UHD: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, BT: 0x12 }, + 2: { FOLLOW_ZONE_1: 0x00, CD: 0x01, BD: 0x02, AV: 0x03, SAT: 0x04, PVR: 0x05, UHD: 0x06, AUX: 0x08, DISPLAY: 0x09, FM: 0x0b, DAB: 0x0c, NET: 0x0e, USB: 0x0f, STB: 0x10, GAME: 0x11, BT: 0x12 }, + }, + APISA_SERIES: { + 1: { PHONO: 0x01, AUX: 0x02, PVR: 0x03, AV: 0x04, STB: 0x05, CD: 0x06, BD: 0x07, SAT: 0x08, GAME: 0x09, NET: 0x0b, USB: 0x0b, ARC_ERC: 0x0d }, + 2: { PHONO: 0x01, AUX: 0x02, PVR: 0x03, AV: 0x04, STB: 0x05, CD: 0x06, BD: 0x07, SAT: 0x08, GAME: 0x09, NET: 0x0b, USB: 0x0b, ARC_ERC: 0x0d }, + }, + APIPA_SERIES: {}, + APIST_SERIES: { + 1: { DIG1: 0x01, DIG2: 0x02, DIG3: 0x03, DIG4: 0x04, NET_USB: 0x05 }, + }, +}; + +const rc5SourceMaps: Record>>> = { + API450_SERIES: { + 1: { STB: [16, 1], AV: [16, 2], DAB: [16, 72], FM: [16, 54], BD: [16, 4], GAME: [16, 5], VCR: [16, 6], CD: [16, 7], AUX: [16, 8], DISPLAY: [16, 9], SAT: [16, 0], PVR: [16, 34], USB: [16, 18], NET: [16, 11] }, + 2: { STB: [23, 8], AV: [23, 9], DAB: [23, 16], FM: [23, 14], BD: [23, 7], GAME: [23, 11], CD: [23, 6], AUX: [23, 13], PVR: [23, 15], USB: [23, 18], NET: [23, 19], FOLLOW_ZONE_1: [16, 20] }, + }, + API860_SERIES: { + 1: { STB: [16, 100], AV: [16, 94], DAB: [16, 72], FM: [16, 28], BD: [16, 98], GAME: [16, 97], VCR: [16, 119], CD: [16, 118], AUX: [16, 99], DISPLAY: [16, 58], SAT: [16, 27], PVR: [16, 96], USB: [16, 93], NET: [16, 92] }, + 2: { STB: [23, 8], AV: [23, 9], DAB: [23, 16], FM: [23, 14], BD: [23, 7], GAME: [23, 11], CD: [23, 6], AUX: [23, 13], PVR: [23, 15], USB: [23, 18], NET: [23, 19], SAT: [23, 20], VCR: [23, 21], FOLLOW_ZONE_1: [16, 20] }, + }, + APIHDA_SERIES: { + 1: { STB: [16, 100], AV: [16, 94], DAB: [16, 72], FM: [16, 28], BD: [16, 98], GAME: [16, 97], UHD: [16, 125], CD: [16, 118], AUX: [16, 99], DISPLAY: [16, 58], SAT: [16, 27], PVR: [16, 96], NET: [16, 92], BT: [16, 122] }, + 2: { STB: [23, 8], AV: [23, 9], DAB: [23, 16], FM: [23, 14], BD: [23, 7], GAME: [23, 11], CD: [23, 6], AUX: [23, 13], PVR: [23, 15], USB: [23, 18], NET: [23, 19], SAT: [23, 20], UHD: [23, 23], BT: [23, 22], FOLLOW_ZONE_1: [16, 20] }, + }, + APISA_SERIES: { + 1: { PHONO: [16, 117], CD: [16, 118], BD: [16, 98], SAT: [16, 27], PVR: [16, 96], AV: [16, 94], AUX: [16, 99], STB: [16, 100], NET: [16, 92], USB: [16, 93], GAME: [16, 97], ARC_ERC: [16, 125] }, + 2: { PHONO: [16, 117], CD: [16, 118], BD: [16, 98], SAT: [16, 27], PVR: [16, 96], AV: [16, 94], AUX: [16, 99], STB: [16, 100], NET: [16, 92], USB: [16, 93], GAME: [16, 97], ARC_ERC: [16, 125] }, + }, + APIPA_SERIES: {}, + APIST_SERIES: { + 1: { DIG1: [21, 94], DIG2: [21, 98], DIG3: [21, 27], DIG4: [21, 97], USB: [21, 93], NET: [21, 92] }, + }, +}; + +const rc5PowerMaps: Record>>> = { + API450_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [23, 123], false: [23, 124] } }, + API860_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [23, 123], false: [23, 124] } }, + APIHDA_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [23, 123], false: [23, 124] } }, + APISA_SERIES: { 1: { true: [16, 123], false: [16, 124] }, 2: { true: [16, 123], false: [16, 124] } }, + APIPA_SERIES: {}, + APIST_SERIES: {}, +}; + +const rc5MuteMaps: Record>>> = { + API450_SERIES: { 1: { true: [16, 119], false: [16, 120] }, 2: { true: [23, 4], false: [23, 5] } }, + API860_SERIES: { 1: { true: [16, 26], false: [16, 120] }, 2: { true: [23, 4], false: [23, 5] } }, + APIHDA_SERIES: { 1: { true: [16, 26], false: [16, 120] }, 2: { true: [23, 4], false: [23, 5] } }, + APISA_SERIES: { 1: { true: [16, 26], false: [16, 120] }, 2: { true: [16, 26], false: [16, 120] } }, + APIPA_SERIES: {}, + APIST_SERIES: {}, +}; + +const rc5VolumeMaps: Record>>> = { + API450_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [23, 1], false: [23, 2] } }, + API860_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [23, 1], false: [23, 2] } }, + APIHDA_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [23, 1], false: [23, 2] } }, + APISA_SERIES: { 1: { true: [16, 16], false: [16, 17] }, 2: { true: [16, 16], false: [16, 17] } }, + APIPA_SERIES: {}, + APIST_SERIES: { 1: { true: [21, 86], false: [21, 85] } }, +}; + +const incomingAudioFormats: Record = { + 0x00: 'PCM', + 0x01: 'ANALOGUE_DIRECT', + 0x02: 'DOLBY_DIGITAL', + 0x03: 'DOLBY_DIGITAL_EX', + 0x04: 'DOLBY_DIGITAL_SURROUND', + 0x05: 'DOLBY_DIGITAL_PLUS', + 0x06: 'DOLBY_DIGITAL_TRUE_HD', + 0x07: 'DTS', + 0x08: 'DTS_96_24', + 0x0d: 'DTS_HD_MASTER_AUDIO', + 0x0e: 'DTS_HD_HIGH_RES_AUDIO', + 0x14: 'UNSUPPORTED', + 0x15: 'UNDETECTED', + 0x16: 'DOLBY_ATMOS', + 0x17: 'DTS_X', + 0x18: 'IMAX_ENHANCED', + 0x19: 'AURO_3D', +}; + +const incomingAudioConfigs: Record = { + 0x00: 'DUAL_MONO', + 0x01: 'MONO', + 0x02: 'STEREO_ONLY', + 0x08: 'STEREO_CENTER', + 0x0e: 'STEREO_DOWNMIX', + 0x20: 'UNKNOWN', + 0x21: 'UNDETECTED', +}; + +const incomingAudioSampleRates: Record = { + 0x00: 32000, + 0x01: 44100, + 0x02: 48000, + 0x03: 88200, + 0x04: 96000, + 0x05: 176400, + 0x06: 192000, +}; + +const videoAspectRatios: Record = { + 0x00: 'UNDEFINED', + 0x01: 'ASPECT_4_3', + 0x02: 'ASPECT_16_9', +}; + +const videoColorspaces: Record = { + 0x00: 'NORMAL', + 0x01: 'HDR10', + 0x02: 'DOLBY_VISION', + 0x03: 'HLG', + 0x04: 'HDR10_PLUS', +}; + +export class ArcamFmjClient { + constructor(private readonly config: IArcamFmjConfig) {} + + public async getSnapshot(): Promise { + if (this.config.snapshot) { + return this.normalizeSnapshot(this.cloneValue(this.config.snapshot), 'snapshot'); + } + + if (!this.config.host) { + return this.normalizeSnapshot({ + deviceInfo: this.deviceInfoFromConfig(), + zones: this.config.zones?.length ? this.config.zones : [this.manualZone(1, false)], + online: false, + source: 'manual', + lastUpdated: new Date().toISOString(), + }, 'manual'); + } + + const amx = await this.requestAmx().catch(() => undefined); + const deviceInfo = this.deviceInfoFromConfig(amx); + const zoneNumbers = this.config.zones?.length ? this.config.zones.map((zoneArg) => zoneArg.zone) : [1, 2]; + const zones = await Promise.all(zoneNumbers.map((zoneArg) => this.getZoneState(zoneArg, deviceInfo).catch(() => this.manualZone(zoneArg, false)))); + + return this.normalizeSnapshot({ + deviceInfo, + zones, + online: zones.some((zoneArg) => zoneArg.available !== false), + source: 'tcp', + lastUpdated: new Date().toISOString(), + }, 'tcp'); + } + + public async execute(requestArg: IArcamFmjCommandRequest): Promise { + const modeledCommand = this.modelCommand(requestArg); + if (this.config.commandExecutor) { + return { + transport: 'executor', + modeledCommand, + executorResult: await this.config.commandExecutor.execute(modeledCommand), + }; + } + + if (!this.config.host) { + throw new Error('Arcam FMJ commands require config.host or commandExecutor.'); + } + + const response = modeledCommand.responseExpected ? await this.requestPacket(modeledCommand) : undefined; + if (response && response.answerCode !== 0x00) { + throw new Error(`Arcam FMJ command ${modeledCommand.commandCodeName} failed with answer code 0x${response.answerCode.toString(16)}.`); + } + if (!modeledCommand.responseExpected) { + await this.sendPacket(modeledCommand); + } + return { transport: 'tcp', modeledCommand, response }; + } + + public modelCommand(requestArg: IArcamFmjCommandRequest): IArcamFmjModeledCommand { + const zone = this.normalizeZone(requestArg.zone); + const apiModel = this.apiModel(); + + if (requestArg.command === 'raw_command') { + if (typeof requestArg.commandCode !== 'number') { + throw new Error('Arcam FMJ raw_command requires commandCode.'); + } + return this.modeled(requestArg, zone, apiModel, requestArg.commandCode, requestArg.data || [], Boolean(requestArg.sendOnly), false); + } + + if (requestArg.command === 'turn_on' || requestArg.command === 'turn_off') { + const power = requestArg.command === 'turn_on'; + if (directPowerWriteSupported.has(apiModel)) { + return this.modeled(requestArg, zone, apiModel, commandCodes.POWER, [power ? 0x01 : 0x00], false, false); + } + return this.modeled(requestArg, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5(rc5PowerMaps, apiModel, zone, power), !power, true); + } + + if (requestArg.command === 'volume_up' || requestArg.command === 'volume_down') { + const up = requestArg.command === 'volume_up'; + if (volumeStepSupported.has(apiModel)) { + return this.modeled(requestArg, zone, apiModel, commandCodes.VOLUME, [up ? 0xf1 : 0xf2], false, false); + } + return this.modeled(requestArg, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5(rc5VolumeMaps, apiModel, zone, up), false, true); + } + + if (requestArg.command === 'set_volume') { + const rawVolume = typeof requestArg.volume === 'number' ? requestArg.volume : Math.round((requestArg.volumeLevel ?? 0) * 99); + return this.modeled(requestArg, zone, apiModel, commandCodes.VOLUME, [clamp(Math.round(rawVolume), 0, 99)], false, false); + } + + if (requestArg.command === 'mute') { + const muted = Boolean(requestArg.muted); + if (directMuteWriteSupported.has(apiModel)) { + return this.modeled(requestArg, zone, apiModel, commandCodes.MUTE, [muted ? 0x00 : 0x01], false, false); + } + return this.modeled(requestArg, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5(rc5MuteMaps, apiModel, zone, muted), false, true); + } + + if (requestArg.command === 'select_source') { + if (!requestArg.source) { + throw new Error('Arcam FMJ select_source requires source.'); + } + const source = normalizeSource(requestArg.source); + if (directSourceWriteSupported.has(apiModel)) { + return this.modeled(requestArg, zone, apiModel, commandCodes.CURRENT_SOURCE, [this.lookupStatusSource(apiModel, zone, source)], false, false); + } + return this.modeled({ ...requestArg, source }, zone, apiModel, commandCodes.SIMULATE_RC5_IR_COMMAND, this.lookupRc5Source(apiModel, zone, source), false, true); + } + + throw new Error(`Unsupported Arcam FMJ command: ${requestArg.command}`); + } + + public async destroy(): Promise {} + + private async getZoneState(zoneArg: number, deviceInfoArg: IArcamFmjDeviceInfo): Promise { + const apiModel = deviceInfoArg.apiModel || this.apiModel(); + const [power, volume, mute, source, menu, decode2ch, decodeMch, incomingVideo, incomingAudio, audioSampleRate, dabStation, dlsPdt, rds, tunerPreset] = await Promise.all([ + this.optionalStatus(zoneArg, commandCodes.POWER), + this.optionalStatus(zoneArg, commandCodes.VOLUME), + this.optionalStatus(zoneArg, commandCodes.MUTE), + this.optionalStatus(zoneArg, commandCodes.CURRENT_SOURCE), + this.optionalStatus(zoneArg, commandCodes.MENU), + this.optionalStatus(zoneArg, commandCodes.DECODE_MODE_STATUS_2CH), + this.optionalStatus(zoneArg, commandCodes.DECODE_MODE_STATUS_MCH), + this.optionalStatus(zoneArg, commandCodes.INCOMING_VIDEO_PARAMETERS), + this.optionalStatus(zoneArg, commandCodes.INCOMING_AUDIO_FORMAT), + this.optionalStatus(zoneArg, commandCodes.INCOMING_AUDIO_SAMPLE_RATE), + this.optionalStatus(zoneArg, commandCodes.DAB_STATION), + this.optionalStatus(zoneArg, commandCodes.DLS_PDT_INFO), + this.optionalStatus(zoneArg, commandCodes.RDS_INFORMATION), + this.optionalStatus(zoneArg, commandCodes.TUNER_PRESET), + ]); + const sourceName = source?.length ? this.sourceFromStatusByte(apiModel, zoneArg, source[0]) : undefined; + const zone: IArcamFmjZoneState = { + zone: zoneArg, + name: this.zoneName(zoneArg), + power: power?.[0] === 0x01, + state: power?.[0] === 0x01 ? 'on' : 'off', + volume: volume?.[0], + muted: mute?.length ? mute[0] === 0x00 : undefined, + source: sourceName, + sourceList: this.sourceList(apiModel, zoneArg), + soundMode: decodeMch?.length ? `CODE_${decodeMch[0]}` : decode2ch?.length ? `CODE_${decode2ch[0]}` : undefined, + incomingVideo: incomingVideo ? parseIncomingVideo(incomingVideo) : undefined, + incomingAudio: parseIncomingAudio(incomingAudio, audioSampleRate), + dabStation: textValue(dabStation), + dlsPdt: textValue(dlsPdt), + rdsInformation: textValue(rds), + tunerPreset: tunerPreset && tunerPreset[0] !== 0xff ? tunerPreset[0] : undefined, + available: Boolean(power || volume || mute || source || menu), + }; + zone.volumeLevel = typeof zone.volume === 'number' ? zone.volume / 99 : undefined; + zone.media = this.mediaInfo(zone); + return zone; + } + + private async optionalStatus(zoneArg: number, commandCodeArg: number): Promise { + try { + const response = await this.requestPacket(this.modeled({ command: 'raw_command' }, zoneArg, this.apiModel(), commandCodeArg, [queryByte], false, false)); + return response.answerCode === 0x00 ? response.data : undefined; + } catch { + return undefined; + } + } + + private async requestPacket(commandArg: IArcamFmjModeledCommand): Promise { + const response = await this.exchange(this.commandPacketBytes(commandArg), (packetArg) => { + return packetArg.type === 'response' && packetArg.packet.zone === commandArg.zone && packetArg.packet.commandCode === commandArg.commandCode; + }); + if (response.type !== 'response') { + throw new Error('Arcam FMJ command returned non-command response.'); + } + return response.packet; + } + + private async sendPacket(commandArg: IArcamFmjModeledCommand): Promise { + await this.exchange(this.commandPacketBytes(commandArg), () => false, false); + } + + private async requestAmx(): Promise> { + const response = await this.exchange(Buffer.from('AMX\r', 'ascii'), (packetArg) => packetArg.type === 'amx'); + if (response.type !== 'amx') { + throw new Error('Arcam FMJ AMX request returned command response.'); + } + return response.values; + } + + private async exchange( + requestArg: Buffer, + matchesArg: (packetArg: TParsedPacket) => boolean, + expectResponseArg = true + ): Promise { + if (!this.config.host) { + throw new Error('Arcam FMJ TCP exchange requires config.host.'); + } + const host = this.config.host; + const port = this.config.port || arcamFmjDefaultPort; + const timeoutMs = this.config.requestTimeoutMs || defaultTimeoutMs; + + return new Promise((resolve, reject) => { + let buffer: Buffer = Buffer.alloc(0); + let settled = false; + const socket = plugins.net.createConnection({ host, port }); + const timeout = setTimeout(() => finish(new Error(`Arcam FMJ TCP exchange timed out after ${timeoutMs}ms.`)), timeoutMs); + + const finish = (errorArg?: Error, packetArg?: TParsedPacket) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + socket.removeAllListeners(); + socket.destroy(); + if (errorArg) { + reject(errorArg); + return; + } + resolve(packetArg as TParsedPacket); + }; + + socket.on('connect', () => { + socket.write(requestArg, (errorArg) => { + if (errorArg) { + finish(errorArg); + return; + } + if (!expectResponseArg) { + finish(undefined, { type: 'sent' }); + } + }); + }); + socket.on('data', (chunkArg) => { + const chunk = typeof chunkArg === 'string' ? Buffer.from(chunkArg) : chunkArg; + buffer = Buffer.concat([buffer, chunk]); + while (true) { + const parsed = parsePacket(buffer); + if (!parsed) { + return; + } + buffer = parsed.remaining; + if (matchesArg(parsed.packet)) { + finish(undefined, parsed.packet); + return; + } + } + }); + socket.on('error', (errorArg) => finish(errorArg)); + socket.on('close', () => finish(new Error('Arcam FMJ TCP connection closed before exchange completed.'))); + }); + } + + private commandPacketBytes(commandArg: IArcamFmjModeledCommand): Buffer { + return Buffer.from([protocolStart, commandArg.zone, commandArg.commandCode, commandArg.data.length, ...commandArg.data, protocolEnd]); + } + + private modeled( + requestArg: IArcamFmjCommandRequest, + zoneArg: number, + apiModelArg: TArcamFmjApiModel, + commandCodeArg: number, + dataArg: number[], + sendOnlyArg: boolean, + usesRc5Arg: boolean + ): IArcamFmjModeledCommand { + const data = dataArg.map((valueArg) => clamp(Math.round(valueArg), 0, 255)); + return { + command: requestArg.command, + zone: zoneArg, + apiModel: apiModelArg, + commandCode: commandCodeArg, + commandCodeName: commandNames[commandCodeArg] || `CODE_${commandCodeArg}`, + data, + dataHex: hex(data), + sendOnly: sendOnlyArg, + responseExpected: !sendOnlyArg, + usesRc5: usesRc5Arg, + source: requestArg.source, + volumeLevel: requestArg.volumeLevel, + muted: requestArg.muted, + }; + } + + private lookupRc5( + mapsArg: Record>>>, + apiModelArg: TArcamFmjApiModel, + zoneArg: number, + valueArg: boolean + ): number[] { + const command = mapsArg[apiModelArg]?.[zoneArg]?.[valueArg ? 'true' : 'false']; + if (!command) { + throw new Error(`Arcam FMJ ${apiModelArg} zone ${zoneArg} does not support this RC5 command.`); + } + return command; + } + + private lookupRc5Source(apiModelArg: TArcamFmjApiModel, zoneArg: number, sourceArg: string): number[] { + const command = rc5SourceMaps[apiModelArg]?.[zoneArg]?.[sourceArg]; + if (!command) { + throw new Error(`Arcam FMJ ${apiModelArg} zone ${zoneArg} does not support source ${sourceArg}.`); + } + return command; + } + + private lookupStatusSource(apiModelArg: TArcamFmjApiModel, zoneArg: number, sourceArg: string): number { + const value = statusSourceMaps[apiModelArg]?.[zoneArg]?.[sourceArg]; + if (typeof value !== 'number') { + throw new Error(`Arcam FMJ ${apiModelArg} zone ${zoneArg} does not support source ${sourceArg}.`); + } + return value; + } + + private sourceFromStatusByte(apiModelArg: TArcamFmjApiModel, zoneArg: number, valueArg: number): TArcamFmjSource | undefined { + const map = statusSourceMaps[apiModelArg]?.[zoneArg]; + if (!map) { + return undefined; + } + return Object.entries(map).find((entryArg) => entryArg[1] === valueArg)?.[0] as TArcamFmjSource | undefined; + } + + private sourceList(apiModelArg: TArcamFmjApiModel, zoneArg: number): TArcamFmjSource[] | undefined { + const sourceMap = rc5SourceMaps[apiModelArg]?.[zoneArg] || statusSourceMaps[apiModelArg]?.[zoneArg]; + return sourceMap ? Object.keys(sourceMap) as TArcamFmjSource[] : undefined; + } + + private mediaInfo(zoneArg: IArcamFmjZoneState) { + if (zoneArg.source === 'DAB') { + return { + title: zoneArg.dabStation ? `DAB - ${zoneArg.dabStation}` : 'DAB', + artist: zoneArg.dlsPdt, + channel: zoneArg.dabStation, + contentType: 'music', + contentId: zoneArg.tunerPreset ? `preset:${zoneArg.tunerPreset}` : undefined, + }; + } + if (zoneArg.source === 'FM') { + return { + title: zoneArg.rdsInformation ? `FM - ${zoneArg.rdsInformation}` : 'FM', + channel: zoneArg.rdsInformation, + contentType: 'music', + contentId: zoneArg.tunerPreset ? `preset:${zoneArg.tunerPreset}` : undefined, + }; + } + return zoneArg.source ? { title: zoneArg.source } : undefined; + } + + private normalizeSnapshot(snapshotArg: IArcamFmjSnapshot, sourceArg: TArcamFmjSnapshotSource): IArcamFmjSnapshot { + const deviceInfo = { + ...this.deviceInfoFromConfig(), + ...snapshotArg.deviceInfo, + }; + const apiModel = deviceInfo.apiModel || this.apiModel(deviceInfo.model); + deviceInfo.apiModel = apiModel; + const online = snapshotArg.online ?? snapshotArg.zones.some((zoneArg) => zoneArg.available !== false); + const zones = snapshotArg.zones.map((zoneArg) => { + const zone: IArcamFmjZoneState = { + ...zoneArg, + name: zoneArg.name || this.zoneName(zoneArg.zone), + available: zoneArg.available ?? online, + }; + zone.sourceList = zone.sourceList || this.sourceList(apiModel, zone.zone); + zone.volumeLevel = typeof zone.volumeLevel === 'number' + ? clamp(zone.volumeLevel, 0, 1) + : typeof zone.volume === 'number' + ? clamp(zone.volume / 99, 0, 1) + : undefined; + zone.state = zone.state || (zone.power === false ? 'off' : zone.power === true ? 'on' : 'unknown'); + zone.media = zone.media || this.mediaInfo(zone); + return zone; + }); + return { + ...snapshotArg, + deviceInfo, + zones, + online, + source: snapshotArg.source || sourceArg, + lastUpdated: snapshotArg.lastUpdated || new Date().toISOString(), + }; + } + + private deviceInfoFromConfig(amxArg?: Record): IArcamFmjDeviceInfo { + const model = amxArg?.['Device-Model'] || this.config.model; + return { + host: this.config.host, + port: this.config.port || arcamFmjDefaultPort, + name: this.config.name || (this.config.host ? `Arcam FMJ (${this.config.host})` : 'Arcam FMJ'), + manufacturer: amxArg?.['Device-Make'] || this.config.manufacturer || 'Arcam', + model: model || 'Arcam FMJ AVR', + revision: amxArg?.['Device-Revision'] || this.config.revision, + serialNumber: this.config.serialNumber, + uniqueId: this.config.uniqueId || this.config.serialNumber || this.config.host, + apiModel: this.apiModel(model), + amx: amxArg, + }; + } + + private manualZone(zoneArg: number, availableArg: boolean): IArcamFmjZoneState { + const configured = this.config.zones?.find((itemArg) => itemArg.zone === zoneArg); + return { + zone: zoneArg, + name: this.zoneName(zoneArg), + power: false, + state: 'off', + available: availableArg, + sourceList: this.sourceList(this.apiModel(), zoneArg), + ...configured, + }; + } + + private apiModel(modelArg = this.config.model): TArcamFmjApiModel { + if (this.config.apiModel) { + return this.config.apiModel; + } + const model = modelArg?.toUpperCase(); + const match = model ? modelSeries.find((seriesArg) => seriesArg.models.includes(model)) : undefined; + return match?.apiModel || 'API450_SERIES'; + } + + private normalizeZone(zoneArg: number | undefined): number { + return zoneArg && Number.isFinite(zoneArg) && zoneArg > 0 ? Math.round(zoneArg) : 1; + } + + private zoneName(zoneArg: number): string { + const configured = this.config.zones?.find((itemArg) => itemArg.zone === zoneArg)?.name; + if (configured) { + return configured; + } + return zoneArg === 1 ? 'Main Zone' : `Zone ${zoneArg}`; + } + + private cloneValue(valueArg: TValue): TValue { + return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue; + } +} + +type TParsedPacket = + | { type: 'response'; packet: IArcamFmjResponsePacket } + | { type: 'amx'; values: Record } + | { type: 'sent' }; + +const parsePacket = (bufferArg: Buffer): { packet: TParsedPacket; remaining: Buffer } | undefined => { + let buffer = bufferArg; + while (buffer[0] === 0x00) { + buffer = buffer.subarray(1); + } + if (!buffer.length) { + return undefined; + } + + if (buffer[0] === protocolStart) { + if (buffer.length < 6) { + return undefined; + } + const dataLength = buffer[4]; + const packetLength = 6 + dataLength; + if (buffer.length < packetLength) { + return undefined; + } + if (buffer[packetLength - 1] !== protocolEnd) { + return { packet: { type: 'sent' }, remaining: buffer.subarray(1) }; + } + const data = [...buffer.subarray(5, 5 + dataLength)]; + return { + packet: { + type: 'response', + packet: { + zone: buffer[1], + commandCode: buffer[2], + commandCodeName: commandNames[buffer[2]] || `CODE_${buffer[2]}`, + answerCode: buffer[3], + data, + dataHex: hex(data), + }, + }, + remaining: buffer.subarray(packetLength), + }; + } + + if (buffer[0] === 0x01) { + if (buffer.length < 5) { + return undefined; + } + if (buffer.subarray(1, 5).toString('ascii') !== '^AMX') { + return { packet: { type: 'sent' }, remaining: buffer.subarray(1) }; + } + const end = buffer.indexOf(protocolEnd, 5); + if (end < 0) { + return undefined; + } + const amx = Buffer.concat([Buffer.from('AMX', 'ascii'), buffer.subarray(5, end + 1)]); + return { packet: { type: 'amx', values: parseAmxValues(amx) }, remaining: buffer.subarray(end + 1) }; + } + + if (buffer.subarray(0, 3).toString('ascii') === 'AMX') { + const end = buffer.indexOf(protocolEnd, 3); + if (end < 0) { + return undefined; + } + return { packet: { type: 'amx', values: parseAmxValues(buffer.subarray(0, end + 1)) }, remaining: buffer.subarray(end + 1) }; + } + + return { packet: { type: 'sent' }, remaining: buffer.subarray(1) }; +}; + +const parseAmxValues = (bufferArg: Buffer): Record => { + const text = bufferArg.toString('ascii'); + if (!text.startsWith('AMXB')) { + return {}; + } + const values: Record = {}; + for (const match of text.slice(4).matchAll(/<(.+?)=(.+?)>/g)) { + values[match[1]] = match[2]; + } + return values; +}; + +const parseIncomingVideo = (dataArg: number[]): IArcamFmjIncomingVideoParameters | undefined => { + if (dataArg.length < 7) { + return undefined; + } + return { + horizontalResolution: dataArg[0] * 256 + dataArg[1], + verticalResolution: dataArg[2] * 256 + dataArg[3], + refreshRate: dataArg[4], + interlaced: dataArg[5] === 0x01, + aspectRatio: videoAspectRatios[dataArg[6]] || `CODE_${dataArg[6]}`, + colorspace: dataArg.length >= 8 ? videoColorspaces[dataArg[7]] || `CODE_${dataArg[7]}` : undefined, + }; +}; + +const parseIncomingAudio = (formatArg: number[] | undefined, sampleRateArg: number[] | undefined): IArcamFmjIncomingAudioInfo | undefined => { + if (!formatArg?.length && !sampleRateArg?.length) { + return undefined; + } + return { + format: formatArg?.length ? incomingAudioFormats[formatArg[0]] || `CODE_${formatArg[0]}` : undefined, + config: formatArg && formatArg.length > 1 ? incomingAudioConfigs[formatArg[1]] || `CODE_${formatArg[1]}` : undefined, + sampleRate: sampleRateArg?.length ? incomingAudioSampleRates[sampleRateArg[0]] || 0 : undefined, + }; +}; + +const normalizeSource = (sourceArg: string): string => { + const source = sourceArg.trim().toUpperCase().replace(/[\s/-]+/g, '_').replace(/^ARC_EARC$/, 'ARC_ERC'); + const aliases: Record = { + ARC: 'ARC_ERC', + EARC: 'ARC_ERC', + ARC_ERC: 'ARC_ERC', + BLUETOOTH: 'BT', + NETUSB: 'NET_USB', + NET_USB: 'NET_USB', + }; + return aliases[source] || source; +}; + +const textValue = (dataArg: number[] | undefined): string | undefined => { + if (!dataArg?.length) { + return undefined; + } + return Buffer.from(dataArg).toString('utf8').trim() || undefined; +}; + +const clamp = (valueArg: number, minArg: number, maxArg: number): number => Math.max(minArg, Math.min(maxArg, valueArg)); + +const hex = (dataArg: number[]): string => dataArg.map((valueArg) => valueArg.toString(16).padStart(2, '0')).join(''); diff --git a/ts/integrations/arcam_fmj/arcam_fmj.classes.configflow.ts b/ts/integrations/arcam_fmj/arcam_fmj.classes.configflow.ts new file mode 100644 index 0000000..ef3447e --- /dev/null +++ b/ts/integrations/arcam_fmj/arcam_fmj.classes.configflow.ts @@ -0,0 +1,57 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import type { IArcamFmjConfig } from './arcam_fmj.types.js'; +import { arcamFmjDefaultPort } from './arcam_fmj.types.js'; + +export class ArcamFmjConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + return { + kind: 'form', + title: 'Connect Arcam FMJ Receiver', + description: 'Configure the local Arcam FMJ TCP control endpoint.', + fields: [ + { name: 'host', label: 'Host', type: 'text', required: true }, + { name: 'port', label: 'TCP port', type: 'number' }, + { name: 'name', label: 'Name', type: 'text' }, + { name: 'model', label: 'Model', type: 'text' }, + ], + submit: async (valuesArg) => { + const host = stringValue(valuesArg.host) || candidateArg.host; + if (!host) { + return { kind: 'error', title: 'Arcam FMJ setup failed', error: 'Arcam FMJ host is required.' }; + } + const port = numberValue(valuesArg.port) || candidateArg.port || arcamFmjDefaultPort; + const name = stringValue(valuesArg.name) || candidateArg.name; + const model = stringValue(valuesArg.model) || candidateArg.model; + return { + kind: 'done', + title: 'Arcam FMJ configured', + config: { + host, + port, + name, + model, + manufacturer: candidateArg.manufacturer || 'Arcam', + serialNumber: candidateArg.serialNumber, + uniqueId: candidateArg.id || candidateArg.serialNumber || `${host}:${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) && 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; +}; diff --git a/ts/integrations/arcam_fmj/arcam_fmj.classes.integration.ts b/ts/integrations/arcam_fmj/arcam_fmj.classes.integration.ts index da8f8dd..bbdca1e 100644 --- a/ts/integrations/arcam_fmj/arcam_fmj.classes.integration.ts +++ b/ts/integrations/arcam_fmj/arcam_fmj.classes.integration.ts @@ -1,26 +1,163 @@ -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 { ArcamFmjClient } from './arcam_fmj.classes.client.js'; +import { ArcamFmjConfigFlow } from './arcam_fmj.classes.configflow.js'; +import { createArcamFmjDiscoveryDescriptor } from './arcam_fmj.discovery.js'; +import { ArcamFmjMapper } from './arcam_fmj.mapper.js'; +import type { IArcamFmjConfig, IArcamFmjCommandRequest } from './arcam_fmj.types.js'; -export class HomeAssistantArcamFmjIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "arcam_fmj", - displayName: "Arcam FMJ Receivers", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/arcam_fmj", - "upstreamDomain": "arcam_fmj", - "integrationType": "device", - "iotClass": "local_polling", - "requirements": [ - "arcam-fmj==1.8.3" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@elupus" - ] -}, - }); +export class ArcamFmjIntegration extends BaseIntegration { + public readonly domain = 'arcam_fmj'; + public readonly displayName = 'Arcam FMJ Receivers'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createArcamFmjDiscoveryDescriptor(); + public readonly configFlow = new ArcamFmjConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/arcam_fmj', + upstreamDomain: 'arcam_fmj', + integrationType: 'device', + iotClass: 'local_polling', + requirements: ['arcam-fmj==1.8.3'], + dependencies: [], + afterDependencies: [], + codeowners: ['@elupus'], + configFlow: true, + documentation: 'https://www.home-assistant.io/integrations/arcam_fmj', + }; + + public async setup(configArg: IArcamFmjConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new ArcamFmjRuntime(new ArcamFmjClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantArcamFmjIntegration extends ArcamFmjIntegration {} + +class ArcamFmjRuntime implements IIntegrationRuntime { + public domain = 'arcam_fmj'; + + constructor(private readonly client: ArcamFmjClient) {} + + public async devices(): Promise { + return ArcamFmjMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return ArcamFmjMapper.toEntities(await this.client.getSnapshot()); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain !== 'media_player') { + return { success: false, error: `Unsupported Arcam FMJ service domain: ${requestArg.domain}` }; + } + + try { + const command = await this.commandFromService(requestArg); + if (!command) { + return { success: false, error: `Unsupported Arcam FMJ media_player service: ${requestArg.service}` }; + } + return { success: true, data: await this.client.execute(command) }; + } catch (errorArg) { + return { success: false, error: errorArg instanceof Error ? errorArg.message : String(errorArg) }; + } + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private async commandFromService(requestArg: IServiceCallRequest): Promise { + const zone = await this.zoneFromRequest(requestArg); + if (requestArg.service === 'turn_on') { + return { command: 'turn_on', zone }; + } + if (requestArg.service === 'turn_off') { + return { command: 'turn_off', zone }; + } + if (requestArg.service === 'volume_up') { + return { command: 'volume_up', zone }; + } + if (requestArg.service === 'volume_down') { + return { command: 'volume_down', zone }; + } + if (requestArg.service === 'volume_set' || requestArg.service === 'set_volume') { + const volumeLevel = this.numberData(requestArg, 'volume_level') ?? this.numberData(requestArg, 'volumeLevel'); + const volume = this.numberData(requestArg, 'volume'); + if (typeof volumeLevel !== 'number' && typeof volume !== 'number') { + throw new Error('Arcam FMJ volume_set requires data.volume_level or data.volume.'); + } + return { command: 'set_volume', zone, volumeLevel, volume }; + } + if (requestArg.service === 'volume_mute' || requestArg.service === 'mute') { + const muted = this.boolData(requestArg, 'is_volume_muted') ?? this.boolData(requestArg, 'mute') ?? this.boolData(requestArg, 'muted'); + if (typeof muted !== 'boolean') { + throw new Error('Arcam FMJ volume_mute requires data.is_volume_muted.'); + } + return { command: 'mute', zone, muted }; + } + if (requestArg.service === 'select_source') { + const source = this.stringData(requestArg, 'source'); + if (!source) { + throw new Error('Arcam FMJ select_source requires data.source.'); + } + return { command: 'select_source', zone, source }; + } + return undefined; + } + + private async zoneFromRequest(requestArg: IServiceCallRequest): Promise { + const explicitZone = this.numberData(requestArg, 'zone') ?? numberFromString(this.stringData(requestArg, 'zone')); + if (explicitZone) { + return explicitZone; + } + + const entityId = requestArg.target.entityId; + if (entityId) { + const snapshot = await this.client.getSnapshot().catch(() => undefined); + const entity = snapshot ? ArcamFmjMapper.toEntities(snapshot).find((entityArg) => entityArg.id === entityId) : undefined; + const zone = entity?.attributes?.zone; + if (typeof zone === 'number') { + return zone; + } + const match = /zone[_-]?(\d+)/i.exec(entityId); + if (match) { + return Number(match[1]); + } + } + return 1; + } + + 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]; + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; + } + + private boolData(requestArg: IServiceCallRequest, keyArg: string): boolean | undefined { + const value = requestArg.data?.[keyArg]; + return typeof value === 'boolean' ? value : undefined; } } + +const numberFromString = (valueArg: string | undefined): number | undefined => { + if (!valueArg) { + return undefined; + } + const match = valueArg.match(/\d+/); + return match ? Number(match[0]) : undefined; +}; diff --git a/ts/integrations/arcam_fmj/arcam_fmj.discovery.ts b/ts/integrations/arcam_fmj/arcam_fmj.discovery.ts new file mode 100644 index 0000000..fd3081c --- /dev/null +++ b/ts/integrations/arcam_fmj/arcam_fmj.discovery.ts @@ -0,0 +1,157 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import type { IArcamFmjManualEntry, IArcamFmjSsdpRecord } from './arcam_fmj.types.js'; +import { arcamFmjDefaultPort } from './arcam_fmj.types.js'; + +const domain = 'arcam_fmj'; + +export class ArcamFmjSsdpMatcher implements IDiscoveryMatcher { + public id = 'arcam-fmj-ssdp-match'; + public source = 'ssdp' as const; + public description = 'Recognize Arcam FMJ SSDP media renderer advertisements.'; + + public async matches(recordArg: IArcamFmjSsdpRecord): Promise { + const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType'); + const usn = header(recordArg, 'usn'); + const location = header(recordArg, 'location'); + const manufacturer = upnp(recordArg, 'manufacturer'); + const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model'); + const friendlyName = upnp(recordArg, 'friendlyName'); + const udn = upnp(recordArg, 'UDN') || upnp(recordArg, 'udn') || usn; + const deviceType = upnp(recordArg, 'deviceType') || st; + const haystack = `${manufacturer || ''} ${model || ''} ${friendlyName || ''} ${deviceType || ''} ${usn || ''}`.toLowerCase(); + const matched = haystack.includes('arcam') || (haystack.includes('mediarenderer') && manufacturer?.toLowerCase() === 'arcam'); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'SSDP record is not an Arcam FMJ receiver.' }; + } + + const url = parseUrl(location); + const id = uniqueIdFromUdn(udn) || stripUuid(usn); + return { + matched: true, + confidence: id ? 'certain' : 'high', + reason: 'SSDP record matches Arcam FMJ metadata.', + normalizedDeviceId: id, + candidate: { + source: 'ssdp', + integrationDomain: domain, + id, + host: url?.hostname, + port: arcamFmjDefaultPort, + name: friendlyName, + manufacturer: manufacturer || 'Arcam', + model, + metadata: { st, usn, location, udn, deviceType }, + }, + }; + } +} + +export class ArcamFmjManualMatcher implements IDiscoveryMatcher { + public id = 'arcam-fmj-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual Arcam FMJ receiver setup entries.'; + + public async matches(inputArg: IArcamFmjManualEntry): Promise { + const haystack = `${inputArg.name || ''} ${inputArg.manufacturer || ''} ${inputArg.model || ''}`.toLowerCase(); + const matched = Boolean(inputArg.host || haystack.includes('arcam') || haystack.includes('fmj') || inputArg.metadata?.arcam_fmj); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain Arcam FMJ setup hints.' }; + } + const port = inputArg.port || arcamFmjDefaultPort; + const id = inputArg.id || inputArg.serialNumber || (inputArg.host ? `${inputArg.host}:${port}` : undefined); + return { + matched: true, + confidence: inputArg.host ? 'high' : 'medium', + reason: 'Manual entry can start Arcam FMJ TCP setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: domain, + id, + host: inputArg.host, + port, + name: inputArg.name, + manufacturer: inputArg.manufacturer || 'Arcam', + model: inputArg.model, + serialNumber: inputArg.serialNumber, + metadata: inputArg.metadata, + }, + }; + } +} + +export class ArcamFmjCandidateValidator implements IDiscoveryValidator { + public id = 'arcam-fmj-candidate-validator'; + public description = 'Validate Arcam FMJ candidates have Arcam receiver metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const haystack = `${candidateArg.name || ''} ${candidateArg.manufacturer || ''} ${candidateArg.model || ''}`.toLowerCase(); + const matched = candidateArg.integrationDomain === domain + || haystack.includes('arcam') + || haystack.includes('fmj') + || Boolean(metadata.arcam_fmj); + + return { + matched, + confidence: matched && candidateArg.host ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has Arcam FMJ metadata.' : 'Candidate is not an Arcam FMJ receiver.', + normalizedDeviceId: candidateArg.id || candidateArg.serialNumber || (candidateArg.host ? `${candidateArg.host}:${candidateArg.port || arcamFmjDefaultPort}` : undefined), + candidate: matched ? { ...candidateArg, port: candidateArg.port || arcamFmjDefaultPort } : undefined, + }; + } +} + +export const createArcamFmjDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: domain, displayName: 'Arcam FMJ Receivers' }) + .addMatcher(new ArcamFmjSsdpMatcher()) + .addMatcher(new ArcamFmjManualMatcher()) + .addValidator(new ArcamFmjCandidateValidator()); +}; + +const header = (recordArg: IArcamFmjSsdpRecord, keyArg: string): string | undefined => { + return recordArg[keyArg as keyof IArcamFmjSsdpRecord] as string | undefined || valueForKey(recordArg.headers, keyArg); +}; + +const upnp = (recordArg: IArcamFmjSsdpRecord, keyArg: string): string | undefined => { + return valueForKey(recordArg.upnp, keyArg) || valueForKey(recordArg.headers, keyArg); +}; + +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 parseUrl = (valueArg: string | undefined): URL | undefined => { + if (!valueArg) { + return undefined; + } + try { + return new URL(valueArg); + } catch { + return undefined; + } +}; + +const uniqueIdFromUdn = (valueArg: string | undefined): string | undefined => { + if (!valueArg) { + return undefined; + } + const stripped = stripUuid(valueArg); + const parts = stripped?.split('-') || []; + return parts[4] || stripped; +}; + +const stripUuid = (valueArg: string | undefined): string | undefined => { + return valueArg?.replace(/^uuid:/i, '').split('::')[0]; +}; diff --git a/ts/integrations/arcam_fmj/arcam_fmj.mapper.ts b/ts/integrations/arcam_fmj/arcam_fmj.mapper.ts new file mode 100644 index 0000000..b120813 --- /dev/null +++ b/ts/integrations/arcam_fmj/arcam_fmj.mapper.ts @@ -0,0 +1,143 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity } from '../../core/types.js'; +import type { IArcamFmjDeviceInfo, IArcamFmjSnapshot, IArcamFmjZoneState } from './arcam_fmj.types.js'; + +export class ArcamFmjMapper { + public static toDevices(snapshotArg: IArcamFmjSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + const updatedAt = snapshotArg.lastUpdated || new Date().toISOString(); + return snapshotArg.zones.map((zoneArg) => { + const volumeLevel = this.volumeLevel(zoneArg); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'power', capability: 'media', name: 'Power', readable: true, writable: true }, + { id: 'source', capability: 'media', name: 'Source', 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 }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'power', value: this.powerState(zoneArg), updatedAt }, + { featureId: 'source', value: zoneArg.source || null, updatedAt }, + { featureId: 'volume', value: typeof volumeLevel === 'number' ? Math.round(volumeLevel * 100) : null, updatedAt }, + { featureId: 'muted', value: typeof zoneArg.muted === 'boolean' ? zoneArg.muted : null, updatedAt }, + ]; + + if (zoneArg.soundMode || zoneArg.soundModeList?.length) { + features.push({ id: 'sound_mode', capability: 'media', name: 'Sound mode', readable: true, writable: true }); + state.push({ featureId: 'sound_mode', value: zoneArg.soundMode || null, updatedAt }); + } + + return { + id: this.deviceId(snapshotArg, zoneArg), + integrationDomain: 'arcam_fmj', + name: this.deviceName(snapshotArg.deviceInfo, zoneArg), + protocol: 'unknown', + manufacturer: snapshotArg.deviceInfo.manufacturer || 'Arcam', + model: snapshotArg.deviceInfo.model || 'Arcam FMJ AVR', + online: zoneArg.available !== false, + features, + state, + metadata: { + host: snapshotArg.deviceInfo.host, + port: snapshotArg.deviceInfo.port, + zone: zoneArg.zone, + revision: snapshotArg.deviceInfo.revision, + serialNumber: snapshotArg.deviceInfo.serialNumber, + uniqueId: snapshotArg.deviceInfo.uniqueId, + apiModel: snapshotArg.deviceInfo.apiModel, + viaDeviceId: zoneArg.zone === 1 ? undefined : this.mainDeviceId(snapshotArg), + }, + }; + }); + } + + public static toEntities(snapshotArg: IArcamFmjSnapshot): IIntegrationEntity[] { + return snapshotArg.zones.map((zoneArg) => ({ + id: `media_player.${this.entityBase(snapshotArg, zoneArg)}`, + uniqueId: `arcam_fmj_${this.uniqueBase(snapshotArg)}_${zoneArg.zone}`, + integrationDomain: 'arcam_fmj', + deviceId: this.deviceId(snapshotArg, zoneArg), + platform: 'media_player', + name: this.deviceName(snapshotArg.deviceInfo, zoneArg), + state: this.mediaState(zoneArg), + attributes: { + deviceClass: 'receiver', + zone: zoneArg.zone, + power: zoneArg.power, + volume: zoneArg.volume, + volumeLevel: this.volumeLevel(zoneArg), + isVolumeMuted: zoneArg.muted, + source: zoneArg.source, + sourceList: zoneArg.sourceList, + soundMode: zoneArg.soundMode, + soundModeList: zoneArg.soundModeList, + mediaContentType: zoneArg.media?.contentType, + mediaContentId: zoneArg.media?.contentId, + mediaTitle: zoneArg.media?.title || zoneArg.source, + mediaArtist: zoneArg.media?.artist, + mediaChannel: zoneArg.media?.channel, + tunerPreset: zoneArg.tunerPreset, + presets: zoneArg.presets, + incomingVideo: zoneArg.incomingVideo, + incomingAudio: zoneArg.incomingAudio, + }, + available: zoneArg.available !== false, + })); + } + + public static deviceId(snapshotArg: IArcamFmjSnapshot, zoneArg: Pick): string { + const suffix = zoneArg.zone === 1 ? '' : `.zone_${zoneArg.zone}`; + return `arcam_fmj.receiver.${this.uniqueBase(snapshotArg)}${suffix}`; + } + + public static slug(valueArg: string | undefined): string { + return (valueArg || 'arcam_fmj').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'arcam_fmj'; + } + + private static mediaState(zoneArg: IArcamFmjZoneState): string { + if (zoneArg.power === false || zoneArg.state?.toLowerCase() === 'off') { + return 'off'; + } + if (zoneArg.power === true || zoneArg.state?.toLowerCase() === 'on') { + return 'on'; + } + return zoneArg.state || 'unknown'; + } + + private static powerState(zoneArg: IArcamFmjZoneState): string { + return zoneArg.power === false || zoneArg.state?.toLowerCase() === 'off' ? 'off' : 'on'; + } + + private static volumeLevel(zoneArg: IArcamFmjZoneState): number | undefined { + if (typeof zoneArg.volumeLevel === 'number') { + return Math.max(0, Math.min(1, zoneArg.volumeLevel)); + } + if (typeof zoneArg.volume === 'number') { + return Math.max(0, Math.min(1, zoneArg.volume / 99)); + } + return undefined; + } + + private static mainDeviceId(snapshotArg: IArcamFmjSnapshot): string { + return this.deviceId(snapshotArg, { zone: 1 }); + } + + private static deviceName(infoArg: IArcamFmjDeviceInfo, zoneArg: IArcamFmjZoneState): string { + const receiverName = this.receiverName(infoArg); + if (zoneArg.zone === 1) { + return receiverName; + } + return `${receiverName} ${zoneArg.name || `Zone ${zoneArg.zone}`}`; + } + + private static receiverName(infoArg: IArcamFmjDeviceInfo): string { + return infoArg.name || infoArg.model || 'Arcam FMJ'; + } + + private static entityBase(snapshotArg: IArcamFmjSnapshot, zoneArg: IArcamFmjZoneState): string { + const suffix = zoneArg.zone === 1 ? '' : `_zone_${zoneArg.zone}`; + return `${this.slug(this.receiverName(snapshotArg.deviceInfo))}${suffix}`; + } + + private static uniqueBase(snapshotArg: IArcamFmjSnapshot): string { + return this.slug(snapshotArg.deviceInfo.uniqueId || snapshotArg.deviceInfo.serialNumber || snapshotArg.deviceInfo.model || snapshotArg.deviceInfo.host || this.receiverName(snapshotArg.deviceInfo)); + } +} diff --git a/ts/integrations/arcam_fmj/arcam_fmj.types.ts b/ts/integrations/arcam_fmj/arcam_fmj.types.ts index a09a9ba..1056c67 100644 --- a/ts/integrations/arcam_fmj/arcam_fmj.types.ts +++ b/ts/integrations/arcam_fmj/arcam_fmj.types.ts @@ -1,4 +1,207 @@ -export interface IHomeAssistantArcamFmjConfig { - // TODO: replace with the TypeScript-native config for arcam_fmj. - [key: string]: unknown; +export const arcamFmjDefaultPort = 50000; + +export type TArcamFmjApiModel = + | 'API450_SERIES' + | 'API860_SERIES' + | 'APIHDA_SERIES' + | 'APISA_SERIES' + | 'APIPA_SERIES' + | 'APIST_SERIES'; + +export type TArcamFmjSnapshotSource = 'manual' | 'snapshot' | 'tcp'; + +export type TArcamFmjCommand = + | 'turn_on' + | 'turn_off' + | 'volume_up' + | 'volume_down' + | 'set_volume' + | 'mute' + | 'select_source' + | 'raw_command'; + +export type TArcamFmjSource = + | 'FOLLOW_ZONE_1' + | 'CD' + | 'BD' + | 'AV' + | 'SAT' + | 'PVR' + | 'VCR' + | 'AUX' + | 'DISPLAY' + | 'FM' + | 'DAB' + | 'NET' + | 'USB' + | 'STB' + | 'GAME' + | 'PHONO' + | 'ARC_ERC' + | 'UHD' + | 'BT' + | 'DIG1' + | 'DIG2' + | 'DIG3' + | 'DIG4' + | 'NET_USB' + | (string & {}); + +export interface IArcamFmjConfig { + host?: string; + port?: number; + name?: string; + manufacturer?: string; + model?: string; + revision?: string; + serialNumber?: string; + uniqueId?: string; + apiModel?: TArcamFmjApiModel; + zones?: IArcamFmjZoneState[]; + requestTimeoutMs?: number; + sourceMap?: Record; + commandExecutor?: IArcamFmjCommandExecutor; + snapshot?: IArcamFmjSnapshot; +} + +export interface IHomeAssistantArcamFmjConfig extends IArcamFmjConfig {} + +export interface IArcamFmjCommandExecutor { + execute(commandArg: IArcamFmjModeledCommand): Promise; +} + +export interface IArcamFmjDeviceInfo { + host?: string; + port?: number; + name?: string; + manufacturer?: string; + model?: string; + revision?: string; + serialNumber?: string; + uniqueId?: string; + apiModel?: TArcamFmjApiModel; + amx?: Record; +} + +export interface IArcamFmjIncomingVideoParameters { + horizontalResolution?: number; + verticalResolution?: number; + refreshRate?: number; + interlaced?: boolean; + aspectRatio?: string; + colorspace?: string; +} + +export interface IArcamFmjIncomingAudioInfo { + format?: string; + config?: string; + sampleRate?: number; +} + +export interface IArcamFmjPresetDetail { + index: number; + type?: string | number; + name: string; +} + +export interface IArcamFmjMediaInfo { + title?: string; + artist?: string; + channel?: string; + contentType?: string; + contentId?: string; +} + +export interface IArcamFmjZoneState { + zone: number; + name?: string; + power?: boolean; + state?: 'on' | 'off' | string; + volume?: number; + volumeLevel?: number; + muted?: boolean; + source?: TArcamFmjSource; + sourceList?: TArcamFmjSource[]; + soundMode?: string; + soundModeList?: string[]; + incomingVideo?: IArcamFmjIncomingVideoParameters; + incomingAudio?: IArcamFmjIncomingAudioInfo; + dabStation?: string; + dlsPdt?: string; + rdsInformation?: string; + tunerPreset?: number; + presets?: IArcamFmjPresetDetail[]; + media?: IArcamFmjMediaInfo; + available?: boolean; +} + +export interface IArcamFmjSnapshot { + deviceInfo: IArcamFmjDeviceInfo; + zones: IArcamFmjZoneState[]; + online?: boolean; + source?: TArcamFmjSnapshotSource; + lastUpdated?: string; +} + +export interface IArcamFmjCommandRequest { + command: TArcamFmjCommand; + zone?: number; + source?: string; + volumeLevel?: number; + volume?: number; + muted?: boolean; + commandCode?: number; + data?: number[]; + sendOnly?: boolean; +} + +export interface IArcamFmjModeledCommand { + command: TArcamFmjCommand; + zone: number; + apiModel: TArcamFmjApiModel; + commandCode: number; + commandCodeName: string; + data: number[]; + dataHex: string; + sendOnly: boolean; + responseExpected: boolean; + usesRc5: boolean; + source?: string; + volumeLevel?: number; + muted?: boolean; +} + +export interface IArcamFmjResponsePacket { + zone: number; + commandCode: number; + commandCodeName: string; + answerCode: number; + data: number[]; + dataHex: string; +} + +export interface IArcamFmjCommandResult { + transport: 'tcp' | 'executor'; + modeledCommand: IArcamFmjModeledCommand; + response?: IArcamFmjResponsePacket; + executorResult?: unknown; +} + +export interface IArcamFmjSsdpRecord { + st?: string; + usn?: string; + location?: string; + headers?: Record; + upnp?: Record; +} + +export interface IArcamFmjManualEntry { + host?: string; + port?: number; + id?: string; + name?: string; + manufacturer?: string; + model?: string; + serialNumber?: string; + metadata?: Record; } diff --git a/ts/integrations/arcam_fmj/index.ts b/ts/integrations/arcam_fmj/index.ts index 26817fe..8eea18e 100644 --- a/ts/integrations/arcam_fmj/index.ts +++ b/ts/integrations/arcam_fmj/index.ts @@ -1,2 +1,6 @@ +export * from './arcam_fmj.classes.client.js'; +export * from './arcam_fmj.classes.configflow.js'; export * from './arcam_fmj.classes.integration.js'; +export * from './arcam_fmj.discovery.js'; +export * from './arcam_fmj.mapper.js'; export * from './arcam_fmj.types.js'; diff --git a/ts/integrations/asuswrt/.generated-by-smarthome-exchange b/ts/integrations/asuswrt/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/asuswrt/.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/asuswrt/asuswrt.classes.client.ts b/ts/integrations/asuswrt/asuswrt.classes.client.ts new file mode 100644 index 0000000..66ea84c --- /dev/null +++ b/ts/integrations/asuswrt/asuswrt.classes.client.ts @@ -0,0 +1,115 @@ +import type { IAsuswrtCommand, IAsuswrtCommandResult, IAsuswrtConfig, IAsuswrtEvent, IAsuswrtSnapshot } from './asuswrt.types.js'; +import { AsuswrtMapper } from './asuswrt.mapper.js'; + +type TAsuswrtEventHandler = (eventArg: IAsuswrtEvent) => void; + +export class AsuswrtClient { + private currentSnapshot?: IAsuswrtSnapshot; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IAsuswrtConfig) {} + + 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 = AsuswrtMapper.toSnapshot(this.config); + } + return this.cloneSnapshot(this.currentSnapshot); + } + + public onEvent(handlerArg: TAsuswrtEventHandler): () => 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 = AsuswrtMapper.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: IAsuswrtCommand): 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: IAsuswrtCommandResult = { + 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: IAsuswrtCommandResult = { 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: IAsuswrtSnapshot, sourceArg: IAsuswrtSnapshot['source']): IAsuswrtSnapshot { + const normalized = AsuswrtMapper.toSnapshot({ ...this.config, snapshot: this.cloneSnapshot(snapshotArg) }, snapshotArg.connected); + return { ...normalized, source: snapshotArg.source || sourceArg }; + } + + private commandResult(resultArg: unknown, commandArg: IAsuswrtCommand): IAsuswrtCommandResult { + if (this.isCommandResult(resultArg)) { + return resultArg; + } + return { success: true, data: resultArg ?? { command: commandArg } }; + } + + private isCommandResult(valueArg: unknown): valueArg is IAsuswrtCommandResult { + return Boolean(valueArg && typeof valueArg === 'object' && 'success' in valueArg); + } + + private unsupportedCommandMessage(commandArg: IAsuswrtCommand): string { + const protocol = this.config.protocol || commandArg.protocol || 'https'; + if (protocol === 'ssh' || protocol === 'telnet') { + return 'ASUSWRT SSH/Telnet commands are not faked by this native TypeScript port. Provide commandExecutor or nativeClient.executeCommand for live reboot or connected-device actions.'; + } + return 'ASUSWRT live commands require an injected commandExecutor or nativeClient.executeCommand; snapshot/manual mode only maps commands safely.'; + } + + private emit(eventArg: IAsuswrtEvent): 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/asuswrt/asuswrt.classes.configflow.ts b/ts/integrations/asuswrt/asuswrt.classes.configflow.ts new file mode 100644 index 0000000..540e375 --- /dev/null +++ b/ts/integrations/asuswrt/asuswrt.classes.configflow.ts @@ -0,0 +1,123 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { AsuswrtMapper } from './asuswrt.mapper.js'; +import type { IAsuswrtConfig, IAsuswrtSnapshot, TAsuswrtMode, TAsuswrtProtocol } from './asuswrt.types.js'; +import { asuswrtDefaultConsiderHomeSeconds, asuswrtDefaultDnsmasqPath, asuswrtDefaultInterface } from './asuswrt.types.js'; + +export class AsuswrtConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const metadata = candidateArg.metadata || {}; + const protocol = this.protocolValue(metadata.protocol) || 'https'; + return { + kind: 'form', + title: 'Connect ASUSWRT router', + description: 'Provide the local ASUSWRT router endpoint. Snapshot/manual data is supported directly; SSH/Telnet live success is not assumed without an injected executor or native client.', + fields: [ + { name: 'host', label: candidateArg.host ? `Host (${candidateArg.host})` : 'Host', type: 'text', required: true }, + { name: 'port', label: `Port (${candidateArg.port || AsuswrtMapper.defaultPort(protocol)})`, type: 'number' }, + { name: 'protocol', label: 'Protocol', type: 'select', options: [ + { label: 'HTTPS', value: 'https' }, + { label: 'HTTP', value: 'http' }, + { label: 'SSH', value: 'ssh' }, + { label: 'Telnet', value: 'telnet' }, + ] }, + { name: 'username', label: 'Username', type: 'text' }, + { name: 'password', label: 'Password', type: 'password' }, + { name: 'sshKey', label: 'SSH key path', type: 'text' }, + { name: 'mode', label: 'Router mode', type: 'select', options: [ + { label: 'Router', value: 'router' }, + { label: 'Access Point', value: 'ap' }, + ] }, + { name: 'interface', label: `Interface (${asuswrtDefaultInterface})`, type: 'text' }, + { name: 'trackUnknown', label: 'Track unknown devices', type: 'boolean' }, + { 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 ASUSWRT snapshot', error: snapshot.message }; + } + + const protocol = this.protocolValue(valuesArg.protocol) || this.protocolValue(candidateArg.metadata?.protocol) || snapshot?.router.protocol || 'https'; + const host = this.stringValue(valuesArg.host) || candidateArg.host || snapshot?.router.host; + if (!host && !snapshot) { + return { kind: 'error', title: 'ASUSWRT setup failed', error: 'ASUSWRT setup requires a host or snapshot JSON.' }; + } + + const port = this.numberValue(valuesArg.port) || candidateArg.port || snapshot?.router.port || (host ? AsuswrtMapper.defaultPort(protocol) : undefined); + const config: IAsuswrtConfig = { + host, + port, + protocol, + username: this.stringValue(valuesArg.username), + password: this.stringValue(valuesArg.password), + sshKey: this.stringValue(valuesArg.sshKey), + mode: this.modeValue(valuesArg.mode) || this.modeValue(candidateArg.metadata?.mode) || snapshot?.router.mode || 'router', + interface: this.stringValue(valuesArg.interface) || asuswrtDefaultInterface, + dnsmasq: asuswrtDefaultDnsmasqPath, + requireIp: true, + trackUnknown: valuesArg.trackUnknown === true, + considerHomeSeconds: asuswrtDefaultConsiderHomeSeconds, + uniqueId: candidateArg.id || snapshot?.router.labelMac || snapshot?.router.macAddress, + name: candidateArg.name || snapshot?.router.name, + snapshot, + metadata: { + discoverySource: candidateArg.source, + discoveryMetadata: candidateArg.metadata, + liveSshTelnetImplemented: false, + }, + }; + + return { + kind: 'done', + title: 'ASUSWRT configured', + config, + }; + } + + private snapshotFromInput(valueArg: unknown): IAsuswrtSnapshot | undefined | Error { + if (valueArg && typeof valueArg === 'object') { + return valueArg as IAsuswrtSnapshot; + } + const text = this.stringValue(valueArg); + if (!text) { + return undefined; + } + try { + const parsed = JSON.parse(text) as IAsuswrtSnapshot; + 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 protocolValue(valueArg: unknown): TAsuswrtProtocol | undefined { + return valueArg === 'http' || valueArg === 'https' || valueArg === 'ssh' || valueArg === 'telnet' ? valueArg : undefined; + } + + private modeValue(valueArg: unknown): TAsuswrtMode | undefined { + return valueArg === 'router' || valueArg === 'ap' ? valueArg : undefined; + } +} diff --git a/ts/integrations/asuswrt/asuswrt.classes.integration.ts b/ts/integrations/asuswrt/asuswrt.classes.integration.ts index a005645..e2c6622 100644 --- a/ts/integrations/asuswrt/asuswrt.classes.integration.ts +++ b/ts/integrations/asuswrt/asuswrt.classes.integration.ts @@ -1,29 +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 { AsuswrtClient } from './asuswrt.classes.client.js'; +import { AsuswrtConfigFlow } from './asuswrt.classes.configflow.js'; +import { createAsuswrtDiscoveryDescriptor } from './asuswrt.discovery.js'; +import { AsuswrtMapper } from './asuswrt.mapper.js'; +import type { IAsuswrtConfig } from './asuswrt.types.js'; +import { asuswrtDomain } from './asuswrt.types.js'; -export class HomeAssistantAsuswrtIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "asuswrt", - displayName: "ASUSWRT", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/asuswrt", - "upstreamDomain": "asuswrt", - "integrationType": "hub", - "iotClass": "local_polling", - "requirements": [ - "aioasuswrt==1.5.4", - "asusrouter==1.21.3" - ], - "dependencies": [], - "afterDependencies": [], - "codeowners": [ - "@kennedyshead", - "@ollo69", - "@Vaskivskyi" - ] -}, - }); +export class AsuswrtIntegration extends BaseIntegration { + public readonly domain = asuswrtDomain; + public readonly displayName = 'ASUSWRT'; + public readonly status = 'control-runtime' as const; + public readonly discoveryDescriptor = createAsuswrtDiscoveryDescriptor(); + public readonly configFlow = new AsuswrtConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/asuswrt', + upstreamDomain: asuswrtDomain, + integrationType: 'hub', + iotClass: 'local_polling', + requirements: ['aioasuswrt==1.5.4', 'asusrouter==1.21.3'], + dependencies: [] as string[], + afterDependencies: [] as string[], + codeowners: ['@kennedyshead', '@ollo69', '@Vaskivskyi'], + documentation: 'https://www.home-assistant.io/integrations/asuswrt', + configFlow: true, + runtime: { + mode: 'native TypeScript snapshot/manual router mapping', + platforms: ['sensor', 'binary_sensor', 'button'], + services: ['refresh', 'snapshot', 'reboot', 'reconnect_device', 'disconnect_device', 'block_device', 'unblock_device'], + }, + localApi: { + implemented: [ + 'manual ASUSWRT router setup candidates and config flow', + 'snapshot mapping for router sensors, device tracker presence, interfaces, traffic counters/rates, CPU, memory, temperatures, uptime, and load average', + 'safe command modeling for explicitly declared router reboot and connected-device actions', + ], + explicitUnsupported: [ + 'homeassistant_compat shims', + 'fake SSH/Telnet connection or command success without commandExecutor/nativeClient injection', + 'full asusrouter/aioasuswrt live protocol implementation in dependency-free TypeScript', + ], + }, + }; + + public async setup(configArg: IAsuswrtConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new AsuswrtRuntime(new AsuswrtClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantAsuswrtIntegration extends AsuswrtIntegration {} + +class AsuswrtRuntime implements IIntegrationRuntime { + public domain = asuswrtDomain; + + constructor(private readonly client: AsuswrtClient) {} + + public async devices(): Promise { + return AsuswrtMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return AsuswrtMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg(AsuswrtMapper.toIntegrationEvent(eventArg))); + await this.client.getSnapshot(); + return async () => unsubscribe(); + } + + public async callService(requestArg: IServiceCallRequest): Promise { + if (requestArg.domain === asuswrtDomain && (requestArg.service === 'snapshot' || requestArg.service === 'status')) { + return { success: true, data: await this.client.getSnapshot() }; + } + if (requestArg.domain === asuswrtDomain && requestArg.service === 'refresh') { + return this.client.refresh(); + } + const snapshot = await this.client.getSnapshot(); + const command = AsuswrtMapper.commandForService(snapshot, requestArg); + if (!command) { + return { success: false, error: `Unsupported ASUSWRT service mapping: ${requestArg.domain}.${requestArg.service}` }; + } + return this.client.sendCommand(command); + } + + public async destroy(): Promise { + await this.client.destroy(); } } diff --git a/ts/integrations/asuswrt/asuswrt.discovery.ts b/ts/integrations/asuswrt/asuswrt.discovery.ts new file mode 100644 index 0000000..05977c0 --- /dev/null +++ b/ts/integrations/asuswrt/asuswrt.discovery.ts @@ -0,0 +1,124 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { AsuswrtMapper } from './asuswrt.mapper.js'; +import type { IAsuswrtManualDiscoveryRecord, IAsuswrtSnapshot, TAsuswrtProtocol } from './asuswrt.types.js'; +import { asuswrtDomain } from './asuswrt.types.js'; + +const asusTextHints = ['asuswrt', 'asus router', 'asus wireless', 'rt-', 'gt-', 'zenwifi', 'aimesh', 'rog rapture']; + +export class AsuswrtManualMatcher implements IDiscoveryMatcher { + public id = 'asuswrt-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual ASUSWRT router setup entries, including snapshot-only records.'; + + public async matches(inputArg: IAsuswrtManualDiscoveryRecord): Promise { + const metadata = inputArg.metadata || {}; + const snapshot = inputArg.snapshot || metadata.snapshot as IAsuswrtSnapshot | undefined; + const host = inputArg.host || snapshot?.router.host; + const protocol = this.protocol(inputArg.protocol || metadata.protocol || snapshot?.router.protocol) || 'https'; + const mac = AsuswrtMapper.normalizeMac(inputArg.macAddress || snapshot?.router.labelMac || 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 === asuswrtDomain + || metadata.asuswrt === true + || hasSnapshot + || asusTextHints.some((hintArg) => text.includes(hintArg)) + || Boolean(host && !text); + + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain ASUSWRT setup hints.' }; + } + + const port = inputArg.port || snapshot?.router.port || AsuswrtMapper.defaultPort(protocol); + const id = inputArg.id || mac || snapshot?.router.id || (host ? `${host}:${port}` : undefined); + return { + matched: true, + confidence: hasSnapshot || mac ? 'certain' : host ? 'high' : 'medium', + reason: hasSnapshot ? 'Manual entry includes an ASUSWRT snapshot.' : 'Manual entry can start ASUSWRT router setup.', + normalizedDeviceId: id, + candidate: { + source: 'manual', + integrationDomain: asuswrtDomain, + id, + host, + port, + name: inputArg.name || snapshot?.router.name || host || 'ASUSWRT', + manufacturer: inputArg.manufacturer || snapshot?.router.manufacturer || 'Asus', + model: inputArg.model || snapshot?.router.model || 'Asus Router', + macAddress: mac, + metadata: { + ...metadata, + asuswrt: true, + protocol, + mode: inputArg.mode || snapshot?.router.mode || 'router', + hasSnapshot, + liveSshTelnetImplemented: false, + }, + }, + metadata: { hasSnapshot, protocol, liveSshTelnetImplemented: false }, + }; + } + + private protocol(valueArg: unknown): TAsuswrtProtocol | undefined { + return valueArg === 'http' || valueArg === 'https' || valueArg === 'ssh' || valueArg === 'telnet' ? valueArg : undefined; + } +} + +export class AsuswrtCandidateValidator implements IDiscoveryValidator { + public id = 'asuswrt-candidate-validator'; + public description = 'Validate ASUSWRT manual candidates have a host or snapshot and router metadata.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const snapshot = metadata.snapshot as IAsuswrtSnapshot | undefined; + const mac = AsuswrtMapper.normalizeMac(candidateArg.macAddress || snapshot?.router.labelMac || snapshot?.router.macAddress); + 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 === asuswrtDomain + || metadata.asuswrt === true + || Boolean(snapshot) + || asusTextHints.some((hintArg) => text.includes(hintArg)) + || candidateArg.source === 'manual' && Boolean(candidateArg.host); + const hasUsableSource = Boolean(candidateArg.host || snapshot); + + if (!matched || !hasUsableSource) { + return { + matched: false, + confidence: matched ? 'medium' : 'low', + reason: matched ? 'ASUSWRT candidate lacks host or snapshot information.' : 'Candidate is not ASUSWRT.', + }; + } + + const protocol = this.protocol(metadata.protocol) || snapshot?.router.protocol || 'https'; + const port = candidateArg.port || snapshot?.router.port || AsuswrtMapper.defaultPort(protocol); + const normalizedDeviceId = candidateArg.id || mac || (candidateArg.host ? `${candidateArg.host}:${port}` : snapshot?.router.id); + return { + matched: true, + confidence: mac || snapshot ? 'certain' : candidateArg.host ? 'high' : 'medium', + reason: 'Candidate has ASUSWRT metadata and a usable manual source.', + normalizedDeviceId, + candidate: { + ...candidateArg, + id: candidateArg.id || normalizedDeviceId, + port, + macAddress: mac || candidateArg.macAddress, + }, + metadata: { protocol, liveSshTelnetImplemented: false }, + }; + } + + private protocol(valueArg: unknown): TAsuswrtProtocol | undefined { + return valueArg === 'http' || valueArg === 'https' || valueArg === 'ssh' || valueArg === 'telnet' ? valueArg : undefined; + } +} + +export const createAsuswrtDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: asuswrtDomain, displayName: 'ASUSWRT' }) + .addMatcher(new AsuswrtManualMatcher()) + .addValidator(new AsuswrtCandidateValidator()); +}; diff --git a/ts/integrations/asuswrt/asuswrt.mapper.ts b/ts/integrations/asuswrt/asuswrt.mapper.ts new file mode 100644 index 0000000..e9729a3 --- /dev/null +++ b/ts/integrations/asuswrt/asuswrt.mapper.ts @@ -0,0 +1,652 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, IIntegrationEvent, IServiceCallRequest, TEntityPlatform } from '../../core/types.js'; +import type { + IAsuswrtActionDescriptor, + IAsuswrtClientDevice, + IAsuswrtCommand, + IAsuswrtConfig, + IAsuswrtEvent, + IAsuswrtInterfaceStats, + IAsuswrtManualEntry, + IAsuswrtRouterInfo, + IAsuswrtSensorMap, + IAsuswrtSnapshot, + TAsuswrtClientAction, + TAsuswrtProtocol, + TAsuswrtRouterAction, +} from './asuswrt.types.js'; +import { asuswrtDefaultHttpPort, asuswrtDefaultHttpsPort, asuswrtDefaultSshPort, asuswrtDefaultTelnetPort, asuswrtDomain } from './asuswrt.types.js'; + +type TSensorDescriptor = { + key: string; + name: string; + unit?: string; + deviceClass?: string; + stateClass?: string; + entityCategory?: string; + factor?: number; +}; + +const manufacturer = 'Asus'; +const routerSensorDescriptors: TSensorDescriptor[] = [ + { key: 'sensor_connected_device', name: 'Devices Connected', unit: 'devices', stateClass: 'measurement' }, + { key: 'sensor_rx_rates', name: 'Download Speed', unit: 'Mbit/s', deviceClass: 'data_rate', stateClass: 'measurement', factor: 125000 }, + { key: 'sensor_tx_rates', name: 'Upload Speed', unit: 'Mbit/s', deviceClass: 'data_rate', stateClass: 'measurement', factor: 125000 }, + { key: 'sensor_rx_bytes', name: 'Download', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing', factor: 1000000000 }, + { key: 'sensor_tx_bytes', name: 'Upload', unit: 'GB', deviceClass: 'data_size', stateClass: 'total_increasing', factor: 1000000000 }, + { key: 'sensor_load_avg1', name: 'Average Load (1 min)', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'sensor_load_avg5', name: 'Average Load (5 min)', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'sensor_load_avg15', name: 'Average Load (15 min)', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: '2.4GHz', name: '2.4GHz Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: '5.0GHz', name: '5GHz Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'CPU', name: 'CPU Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: '5.0GHz_2', name: '5GHz Temperature (Radio 2)', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: '6.0GHz', name: '6GHz Temperature', unit: 'C', deviceClass: 'temperature', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'mem_usage_perc', name: 'Memory Usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + { key: 'mem_free', name: 'Memory Free', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', factor: 1024 }, + { key: 'mem_used', name: 'Memory Used', unit: 'MB', deviceClass: 'data_size', stateClass: 'measurement', entityCategory: 'diagnostic', factor: 1024 }, + { key: 'sensor_last_boot', name: 'Last Boot', deviceClass: 'timestamp' }, + { key: 'sensor_uptime', name: 'Uptime', unit: 's', deviceClass: 'duration', stateClass: 'total', entityCategory: 'diagnostic' }, + { key: 'cpu_total_usage', name: 'CPU Usage', unit: '%', stateClass: 'measurement', entityCategory: 'diagnostic' }, + ...Array.from({ length: 8 }, (_valueArg, indexArg) => ({ + key: `cpu${indexArg + 1}_usage`, + name: `CPU Core ${indexArg + 1} Usage`, + unit: '%', + stateClass: 'measurement', + entityCategory: 'diagnostic', + })), +]; + +export class AsuswrtMapper { + public static toSnapshot(configArg: IAsuswrtConfig, connectedArg?: boolean, eventsArg: IAsuswrtEvent[] = []): IAsuswrtSnapshot { + const source = configArg.snapshot; + const manualSnapshots = (configArg.manualEntries || []) + .map((entryArg) => entryArg.snapshot) + .filter((snapshotArg): snapshotArg is IAsuswrtSnapshot => Boolean(snapshotArg)); + const manualData = this.mergeManualEntries(configArg.manualEntries || []); + const router = this.routerInfo(configArg, source, manualSnapshots, manualData.router); + const devices = this.uniqueClients([ + ...(source?.devices || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.devices || []), + ...(configArg.devices || []), + ...(configArg.clients || []), + ...manualData.devices, + ]); + const interfaces = this.uniqueInterfaces([ + ...(source?.interfaces || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.interfaces || []), + ...(configArg.interfaces || []), + ...manualData.interfaces, + ]); + const sensors = this.sensorMap([ + source?.sensors, + ...manualSnapshots.map((snapshotArg) => snapshotArg.sensors), + configArg.sensors, + manualData.sensors, + ], devices.length, interfaces); + const actions = this.uniqueActions([ + ...(source?.actions || []), + ...manualSnapshots.flatMap((snapshotArg) => snapshotArg.actions || []), + ...(configArg.actions || []), + ...manualData.actions, + ...this.actionsFromRouter(router), + ...this.actionsFromClients(devices), + ]); + const hasManualData = Boolean(source || manualSnapshots.length || configArg.router || configArg.devices?.length || configArg.clients?.length || configArg.interfaces?.length || configArg.sensors || 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, + sensors, + actions, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + error: source?.error, + metadata: { + ...source?.metadata, + ...configArg.metadata, + liveSshTelnetImplemented: false, + commandExecutorConfigured: Boolean(configArg.commandExecutor || configArg.nativeClient?.executeCommand), + }, + }; + } + + public static toDevices(snapshotArg: IAsuswrtSnapshot): 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: IAsuswrtSnapshot): IIntegrationEntity[] { + const entities: IIntegrationEntity[] = []; + const usedIds = new Map(); + const routerDeviceId = this.routerDeviceId(snapshotArg); + entities.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} Connected`, routerDeviceId, `${this.uniqueBase(snapshotArg)}_connected`, snapshotArg.connected ? 'on' : 'off', usedIds, { + deviceClass: 'connectivity', + host: snapshotArg.router.host, + port: snapshotArg.router.port, + protocol: snapshotArg.router.protocol, + }, true)); + + for (const descriptor of routerSensorDescriptors) { + const value = snapshotArg.sensors[descriptor.key]; + if (value === undefined) { + continue; + } + entities.push(this.entity('sensor', `${this.routerName(snapshotArg)} ${descriptor.name}`, routerDeviceId, `${this.uniqueBase(snapshotArg)}_${this.slug(descriptor.key)}`, this.sensorValue(value, descriptor), 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: IAsuswrtSnapshot, requestArg: IServiceCallRequest): IAsuswrtCommand | undefined { + const actions = this.snapshotActions(snapshotArg); + const targetEntity = this.findTargetEntity(snapshotArg, requestArg); + const serviceAction = this.actionFromService(requestArg.service); + + if (requestArg.domain === asuswrtDomain && 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 === 'button' && requestArg.service === 'press' && targetEntity?.attributes?.nativeAction) { + const targetAction = String(targetEntity.attributes.nativeAction); + const action = actions.find((actionArg) => actionArg.entityId === targetEntity.id || actionArg.action === targetAction && (actionArg.target === 'router' || actionArg.mac === targetEntity.attributes?.mac)); + return action ? this.command(snapshotArg, requestArg, action, targetEntity) : undefined; + } + + if (requestArg.domain !== asuswrtDomain || !serviceAction || serviceAction === 'reboot') { + return undefined; + } + + 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; + } + + public static toIntegrationEvent(eventArg: IAsuswrtEvent): IIntegrationEvent { + return { + type: eventArg.type === 'command_failed' || eventArg.type === 'error' ? 'error' : 'state_changed', + integrationDomain: asuswrtDomain, + 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(protocolArg?: TAsuswrtProtocol): number { + if (protocolArg === 'http') return asuswrtDefaultHttpPort; + if (protocolArg === 'ssh') return asuswrtDefaultSshPort; + if (protocolArg === 'telnet') return asuswrtDefaultTelnetPort; + return asuswrtDefaultHttpsPort; + } + + public static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'asuswrt'; + } + + private static routerInfo(configArg: IAsuswrtConfig, sourceArg: IAsuswrtSnapshot | undefined, manualSnapshotsArg: IAsuswrtSnapshot[], manualRouterArg?: IAsuswrtRouterInfo): IAsuswrtRouterInfo { + const manualRouter = manualRouterArg || manualSnapshotsArg.find((snapshotArg) => snapshotArg.router)?.router; + const router = { + ...sourceArg?.router, + ...manualRouter, + ...configArg.router, + }; + const protocol = configArg.protocol || router.protocol || sourceArg?.router.protocol || 'https'; + const host = configArg.host || router.host || sourceArg?.router.host; + const port = configArg.port || router.port || (host ? this.defaultPort(protocol) : undefined); + const mac = this.normalizeMac(configArg.uniqueId || router.labelMac || router.macAddress || sourceArg?.router.labelMac || sourceArg?.router.macAddress); + return { + ...router, + id: router.id || mac || (host ? `${host}:${port || this.defaultPort(protocol)}` : undefined) || configArg.name || 'asuswrt', + host, + port, + name: configArg.name || router.name || host || 'ASUSWRT', + protocol, + mode: configArg.mode || router.mode || 'router', + labelMac: mac || router.labelMac, + macAddress: mac || router.macAddress, + manufacturer: router.manufacturer || manufacturer, + configurationUrl: router.configurationUrl || (host ? `${protocol === 'https' ? 'https' : 'http'}://${host}${port && !this.isDefaultWebPort(protocol, port) ? `:${port}` : ''}` : undefined), + }; + } + + private static mergeManualEntries(entriesArg: IAsuswrtManualEntry[]): { router?: IAsuswrtRouterInfo; devices: IAsuswrtClientDevice[]; interfaces: IAsuswrtInterfaceStats[]; sensors?: IAsuswrtSensorMap; actions: IAsuswrtActionDescriptor[]; hasData: boolean } { + const devices: IAsuswrtClientDevice[] = []; + const interfaces: IAsuswrtInterfaceStats[] = []; + const actions: IAsuswrtActionDescriptor[] = []; + const sensors: IAsuswrtSensorMap = {}; + let router: IAsuswrtRouterInfo | 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)) { + router = { + id: entry.id || entry.macAddress || entry.host, + host: entry.host, + port: entry.port, + protocol: entry.protocol, + mode: entry.mode, + name: entry.name, + model: entry.model, + macAddress: entry.macAddress, + manufacturer: entry.manufacturer, + }; + 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, devices, interfaces, sensors: Object.keys(sensors).length ? sensors : undefined, actions, hasData }; + } + + private static sensorMap(sourcesArg: Array, deviceCountArg: number, interfacesArg: IAsuswrtInterfaceStats[]): IAsuswrtSensorMap { + const sensors: IAsuswrtSensorMap = {}; + for (const source of sourcesArg) { + Object.assign(sensors, source || {}); + } + if (sensors.sensor_connected_device === undefined && deviceCountArg > 0) { + sensors.sensor_connected_device = deviceCountArg; + } + if (interfacesArg.length) { + sensors.sensor_rx_bytes ??= this.sumInterfaces(interfacesArg, 'rxBytes', 'downloadBytes'); + sensors.sensor_tx_bytes ??= this.sumInterfaces(interfacesArg, 'txBytes', 'uploadBytes'); + sensors.sensor_rx_rates ??= this.sumInterfaces(interfacesArg, 'rxRate', 'downloadRate'); + sensors.sensor_tx_rates ??= this.sumInterfaces(interfacesArg, 'txRate', 'uploadRate'); + } + return this.cleanAttributes(sensors) as IAsuswrtSensorMap; + } + + private static sumInterfaces(interfacesArg: IAsuswrtInterfaceStats[], primaryKeyArg: keyof IAsuswrtInterfaceStats, fallbackKeyArg: keyof IAsuswrtInterfaceStats): number | undefined { + let total = 0; + let found = false; + for (const iface of interfacesArg) { + const value = this.numberValue(iface[primaryKeyArg]) ?? this.numberValue(iface[fallbackKeyArg]); + if (value !== undefined) { + total += value; + found = true; + } + } + return found ? total : undefined; + } + + private static routerDevice(snapshotArg: IAsuswrtSnapshot, 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_devices', capability: 'sensor', name: 'Connected Devices', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'connectivity', value: snapshotArg.connected ? 'online' : 'offline', updatedAt: updatedAtArg }, + { featureId: 'connected_devices', value: sensors.sensor_connected_device ?? snapshotArg.devices.filter((deviceArg) => deviceArg.connected !== false).length, updatedAt: updatedAtArg }, + ]; + this.addFeatureState(features, state, 'download_speed', 'Download Speed', this.sensorValue(sensors.sensor_rx_rates, routerSensorDescriptors[1]), updatedAtArg, 'Mbit/s'); + this.addFeatureState(features, state, 'upload_speed', 'Upload Speed', this.sensorValue(sensors.sensor_tx_rates, routerSensorDescriptors[2]), updatedAtArg, 'Mbit/s'); + this.addFeatureState(features, state, 'cpu_usage', 'CPU Usage', sensors.cpu_total_usage, updatedAtArg, '%'); + this.addFeatureState(features, state, 'memory_usage', 'Memory Usage', sensors.mem_usage_perc, updatedAtArg, '%'); + + return { + id: this.routerDeviceId(snapshotArg), + integrationDomain: asuswrtDomain, + name: this.routerName(snapshotArg), + protocol: this.deviceProtocol(snapshotArg.router.protocol), + manufacturer: snapshotArg.router.manufacturer || manufacturer, + model: snapshotArg.router.model || 'Asus Router', + online: snapshotArg.connected, + features, + state, + metadata: this.cleanAttributes({ + host: snapshotArg.router.host, + port: snapshotArg.router.port, + protocol: snapshotArg.router.protocol, + mode: snapshotArg.router.mode, + macAddress: snapshotArg.router.macAddress || snapshotArg.router.labelMac, + modelId: snapshotArg.router.modelId, + serialNumber: snapshotArg.router.serialNumber, + firmware: snapshotArg.router.firmware, + configurationUrl: snapshotArg.router.configurationUrl, + source: snapshotArg.source, + liveSshTelnetImplemented: false, + }), + }; + } + + private static clientDevice(clientArg: IAsuswrtClientDevice, snapshotArg: IAsuswrtSnapshot, updatedAtArg: string): plugins.shxInterfaces.data.IDeviceDefinition { + const name = this.clientName(clientArg); + return { + id: this.clientDeviceId(clientArg), + integrationDomain: asuswrtDomain, + 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 || null, updatedAt: updatedAtArg }, + ], + metadata: this.cleanAttributes({ + mac: this.clientMac(clientArg), + ipAddress: clientArg.ipAddress || clientArg.ip, + hostname: clientArg.hostname || clientArg.name, + connectedTo: clientArg.connectedTo || clientArg.node || clientArg.interface, + lastActivity: this.dateString(clientArg.lastActivity), + }), + }; + } + + private static pushInterfaceEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IAsuswrtSnapshot, ifaceArg: IAsuswrtInterfaceStats, usedIdsArg: Map): void { + const deviceId = this.routerDeviceId(snapshotArg); + const ifaceKey = this.slug(ifaceArg.id || ifaceArg.name); + const ifaceName = ifaceArg.label || ifaceArg.name; + const values: Array<[string, string, unknown, string | undefined, Record]> = [ + ['rx_bytes', 'Download', this.bytesToGigabytes(ifaceArg.rxBytes ?? ifaceArg.downloadBytes), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['tx_bytes', 'Upload', this.bytesToGigabytes(ifaceArg.txBytes ?? ifaceArg.uploadBytes), 'GB', { deviceClass: 'data_size', stateClass: 'total_increasing' }], + ['rx_rate', 'Download Speed', this.bytesPerSecondToMegabits(ifaceArg.rxRate ?? ifaceArg.downloadRate), 'Mbit/s', { deviceClass: 'data_rate', stateClass: 'measurement' }], + ['tx_rate', 'Upload Speed', this.bytesPerSecondToMegabits(ifaceArg.txRate ?? ifaceArg.uploadRate), 'Mbit/s', { deviceClass: 'data_rate', stateClass: 'measurement' }], + ]; + 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 && ifaceArg.connected !== false)); + } + if (ifaceArg.connected !== undefined) { + entitiesArg.push(this.entity('binary_sensor', `${this.routerName(snapshotArg)} ${ifaceName} Link`, deviceId, `${this.uniqueBase(snapshotArg)}_interface_${ifaceKey}_link`, ifaceArg.connected ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + interface: ifaceArg.name, + }, snapshotArg.connected)); + } + } + + private static pushClientEntities(entitiesArg: IIntegrationEntity[], snapshotArg: IAsuswrtSnapshot, clientArg: IAsuswrtClientDevice, usedIdsArg: Map): void { + const mac = this.clientMac(clientArg); + const deviceId = this.clientDeviceId(clientArg); + entitiesArg.push(this.entity('binary_sensor', `${this.clientName(clientArg)} Connected`, deviceId, `${this.slug(mac || this.clientName(clientArg))}_connected`, clientArg.connected !== false ? 'on' : 'off', usedIdsArg, { + deviceClass: 'connectivity', + mac, + ipAddress: clientArg.ipAddress || clientArg.ip, + hostname: clientArg.hostname || clientArg.name, + connectedTo: clientArg.connectedTo || clientArg.node || clientArg.interface, + lastActivity: this.dateString(clientArg.lastActivity), + }, snapshotArg.connected)); + } + + private static actionButton(snapshotArg: IAsuswrtSnapshot, actionArg: IAsuswrtActionDescriptor, 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, { + nativeAction: 'reboot', + actionTarget: 'router', + 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, { + nativeAction: actionArg.action, + actionTarget: 'client', + mac: this.clientMac(client), + writable: true, + }, snapshotArg.connected && client.connected !== false, actionArg.entityId); + } + + private static command(snapshotArg: IAsuswrtSnapshot, requestArg: IServiceCallRequest, actionArg: IAsuswrtActionDescriptor, entityArg?: IIntegrationEntity): IAsuswrtCommand { + return { + type: actionArg.target === 'router' ? 'router.reboot' : 'client.action', + service: requestArg.service, + action: actionArg.action, + target: requestArg.target, + protocol: snapshotArg.router.protocol, + routerId: this.routerDeviceId(snapshotArg), + mac: actionArg.mac ? this.normalizeMac(actionArg.mac) : undefined, + entityId: entityArg?.id || actionArg.entityId || requestArg.target.entityId, + deviceId: entityArg?.deviceId || actionArg.deviceId || requestArg.target.deviceId, + payload: { ...(requestArg.data || {}), actionMetadata: actionArg.metadata }, + }; + } + + private static findTargetEntity(snapshotArg: IAsuswrtSnapshot, 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 actionFromService(serviceArg: string): TAsuswrtRouterAction | TAsuswrtClientAction | undefined { + if (serviceArg === 'reboot') return 'reboot'; + if (serviceArg === 'reconnect_device' || serviceArg === 'reconnect_client') return 'reconnect'; + if (serviceArg === 'disconnect_device' || serviceArg === 'disconnect_client') return 'disconnect'; + if (serviceArg === 'block_device' || serviceArg === 'block_client') return 'block'; + if (serviceArg === 'unblock_device' || serviceArg === 'unblock_client') return 'unblock'; + return undefined; + } + + private static actionsFromRouter(routerArg: IAsuswrtRouterInfo): IAsuswrtActionDescriptor[] { + return (routerArg.actions || []).map((actionArg) => ({ target: 'router', action: actionArg })); + } + + private static actionsFromClients(devicesArg: IAsuswrtClientDevice[]): IAsuswrtActionDescriptor[] { + const actions: IAsuswrtActionDescriptor[] = []; + 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 snapshotActions(snapshotArg: IAsuswrtSnapshot): IAsuswrtActionDescriptor[] { + return this.uniqueActions([ + ...(snapshotArg.actions || []), + ...this.actionsFromRouter(snapshotArg.router), + ...this.actionsFromClients(snapshotArg.devices), + ]); + } + + private static uniqueClients(devicesArg: IAsuswrtClientDevice[]): IAsuswrtClientDevice[] { + const seen = new Map(); + for (const device of devicesArg) { + const key = this.clientMac(device) || device.id || device.ipAddress || device.ip || device.name; + if (!key) { + continue; + } + seen.set(key, { ...seen.get(key), ...device, mac: this.clientMac(device) || device.mac }); + } + return [...seen.values()]; + } + + private static uniqueInterfaces(interfacesArg: IAsuswrtInterfaceStats[]): IAsuswrtInterfaceStats[] { + 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 uniqueActions(actionsArg: IAsuswrtActionDescriptor[]): IAsuswrtActionDescriptor[] { + const seen = new Map(); + for (const action of actionsArg) { + const mac = this.normalizeMac(action.mac); + const key = [action.target, action.action, mac || action.entityId || action.deviceId || 'router'].join(':'); + seen.set(key, { ...action, mac }); + } + return [...seen.values()]; + } + + private static routerDeviceId(snapshotArg: IAsuswrtSnapshot): string { + return `asuswrt.router.${this.uniqueBase(snapshotArg)}`; + } + + private static clientDeviceId(clientArg: IAsuswrtClientDevice): string { + return `asuswrt.client.${this.slug(this.clientMac(clientArg) || clientArg.id || clientArg.ipAddress || clientArg.ip || this.clientName(clientArg))}`; + } + + private static routerName(snapshotArg: IAsuswrtSnapshot): string { + return snapshotArg.router.name || snapshotArg.router.host || 'ASUSWRT'; + } + + private static clientName(clientArg: IAsuswrtClientDevice): string { + return clientArg.name || clientArg.hostname || clientArg.macAddress || clientArg.mac || clientArg.ipAddress || clientArg.ip || 'Unknown device'; + } + + private static clientMac(clientArg: IAsuswrtClientDevice): string | undefined { + return this.normalizeMac(clientArg.macAddress || clientArg.mac); + } + + private static uniqueBase(snapshotArg: IAsuswrtSnapshot): string { + return this.slug(snapshotArg.router.labelMac || 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: `${asuswrtDomain}_${this.slug(uniqueIdArg)}`, + integrationDomain: asuswrtDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: this.cleanAttributes(attributesArg), + available: availableArg, + }; + } + + private static sensorValue(valueArg: unknown, descriptorArg: TSensorDescriptor | undefined): unknown { + if (valueArg === undefined || valueArg === null) { + return valueArg; + } + if (descriptorArg?.factor && typeof valueArg === 'number') { + return valueArg / descriptorArg.factor; + } + return 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 bytesToGigabytes(valueArg: number | undefined): number | undefined { + return valueArg === undefined ? undefined : valueArg / 1000000000; + } + + private static bytesPerSecondToMegabits(valueArg: number | undefined): number | undefined { + return valueArg === undefined ? undefined : valueArg / 125000; + } + + 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 deviceProtocol(protocolArg?: TAsuswrtProtocol): plugins.shxInterfaces.data.TDeviceProtocol { + if (protocolArg === 'http' || protocolArg === 'https') { + return 'http'; + } + return 'unknown'; + } + + 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 numberValue(valueArg: unknown): number | undefined { + return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; + } + + private static dateString(valueArg: IAsuswrtClientDevice['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()); + } + + private static isDefaultWebPort(protocolArg: TAsuswrtProtocol, portArg: number): boolean { + return (protocolArg === 'http' && portArg === asuswrtDefaultHttpPort) || (protocolArg === 'https' && portArg === 443); + } +} diff --git a/ts/integrations/asuswrt/asuswrt.types.ts b/ts/integrations/asuswrt/asuswrt.types.ts index 5db65dd..c9d7f5b 100644 --- a/ts/integrations/asuswrt/asuswrt.types.ts +++ b/ts/integrations/asuswrt/asuswrt.types.ts @@ -1,4 +1,231 @@ -export interface IHomeAssistantAsuswrtConfig { - // TODO: replace with the TypeScript-native config for asuswrt. +import type { IServiceCallResult } from '../../core/types.js'; + +export const asuswrtDomain = 'asuswrt'; +export const asuswrtDefaultHttpPort = 80; +export const asuswrtDefaultHttpsPort = 8443; +export const asuswrtDefaultSshPort = 22; +export const asuswrtDefaultTelnetPort = 23; +export const asuswrtDefaultInterface = 'eth0'; +export const asuswrtDefaultDnsmasqPath = '/var/lib/misc'; +export const asuswrtDefaultConsiderHomeSeconds = 180; + +export type TAsuswrtProtocol = 'http' | 'https' | 'ssh' | 'telnet'; +export type TAsuswrtMode = 'router' | 'ap'; +export type TAsuswrtSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime'; +export type TAsuswrtCommandType = 'router.reboot' | 'client.action'; +export type TAsuswrtRouterAction = 'reboot'; +export type TAsuswrtClientAction = 'reconnect' | 'disconnect' | 'block' | 'unblock'; +export type TAsuswrtAction = TAsuswrtRouterAction | TAsuswrtClientAction; + +export interface IAsuswrtConfig { + host?: string; + port?: number; + protocol?: TAsuswrtProtocol; + username?: string; + password?: string; + sshKey?: string; + mode?: TAsuswrtMode; + interface?: string; + dnsmasq?: string; + requireIp?: boolean; + trackUnknown?: boolean; + considerHomeSeconds?: number; + connected?: boolean; + uniqueId?: string; + name?: string; + snapshot?: IAsuswrtSnapshot; + router?: IAsuswrtRouterInfo; + devices?: IAsuswrtClientDevice[]; + clients?: IAsuswrtClientDevice[]; + interfaces?: IAsuswrtInterfaceStats[]; + sensors?: IAsuswrtSensorMap; + actions?: IAsuswrtActionDescriptor[]; + manualEntries?: IAsuswrtManualEntry[]; + events?: IAsuswrtEvent[]; + snapshotProvider?: TAsuswrtSnapshotProvider; + commandExecutor?: TAsuswrtCommandExecutor; + nativeClient?: IAsuswrtNativeClient; + metadata?: Record; [key: string]: unknown; } + +export interface IHomeAssistantAsuswrtConfig extends IAsuswrtConfig {} + +export interface IAsuswrtRouterInfo { + id?: string; + host?: string; + port?: number; + name?: string; + model?: string; + modelId?: string; + serialNumber?: string; + firmware?: string; + macAddress?: string; + labelMac?: string; + configurationUrl?: string; + mode?: TAsuswrtMode; + protocol?: TAsuswrtProtocol; + manufacturer?: string; + actions?: TAsuswrtRouterAction[]; + metadata?: Record; +} + +export interface IAsuswrtClientDevice { + id?: string; + mac?: string; + macAddress?: string; + name?: string; + hostname?: string; + ip?: string; + ipAddress?: string; + connected?: boolean; + connectedTo?: string; + node?: string; + interface?: string; + lastActivity?: string | number | Date; + manufacturer?: string; + model?: string; + actions?: TAsuswrtClientAction[]; + metadata?: Record; + [key: string]: unknown; +} + +export interface IAsuswrtInterfaceStats { + id?: string; + name: string; + label?: string; + connected?: boolean; + macAddress?: string; + ipAddress?: string; + rxBytes?: number; + txBytes?: number; + rxRate?: number; + txRate?: number; + downloadBytes?: number; + uploadBytes?: number; + downloadRate?: number; + uploadRate?: number; + metadata?: Record; +} + +export interface IAsuswrtSensorMap { + sensor_connected_device?: number; + sensor_rx_bytes?: number; + sensor_tx_bytes?: number; + sensor_rx_rates?: number; + sensor_tx_rates?: number; + sensor_load_avg1?: number; + sensor_load_avg5?: number; + sensor_load_avg15?: number; + '2.4GHz'?: number; + '5.0GHz'?: number; + CPU?: number; + '5.0GHz_2'?: number; + '6.0GHz'?: number; + mem_usage_perc?: number; + mem_free?: number; + mem_used?: number; + sensor_last_boot?: string | number; + sensor_uptime?: number; + cpu_total_usage?: number; + cpu1_usage?: number; + cpu2_usage?: number; + cpu3_usage?: number; + cpu4_usage?: number; + cpu5_usage?: number; + cpu6_usage?: number; + cpu7_usage?: number; + cpu8_usage?: number; + [key: string]: string | number | boolean | null | undefined; +} + +export interface IAsuswrtActionDescriptor { + target: 'router' | 'client'; + action: TAsuswrtAction; + service?: string; + mac?: string; + entityId?: string; + deviceId?: string; + label?: string; + metadata?: Record; +} + +export interface IAsuswrtSnapshot { + connected: boolean; + source?: TAsuswrtSnapshotSource; + updatedAt?: string; + router: IAsuswrtRouterInfo; + devices: IAsuswrtClientDevice[]; + interfaces: IAsuswrtInterfaceStats[]; + sensors: IAsuswrtSensorMap; + actions?: IAsuswrtActionDescriptor[]; + events?: IAsuswrtEvent[]; + error?: string; + metadata?: Record; +} + +export interface IAsuswrtManualEntry { + id?: string; + host?: string; + port?: number; + protocol?: TAsuswrtProtocol; + mode?: TAsuswrtMode; + name?: string; + manufacturer?: string; + model?: string; + macAddress?: string; + router?: IAsuswrtRouterInfo; + devices?: IAsuswrtClientDevice[]; + clients?: IAsuswrtClientDevice[]; + interfaces?: IAsuswrtInterfaceStats[]; + sensors?: IAsuswrtSensorMap; + actions?: IAsuswrtActionDescriptor[]; + snapshot?: IAsuswrtSnapshot; + metadata?: Record; + [key: string]: unknown; +} + +export interface IAsuswrtManualDiscoveryRecord extends IAsuswrtManualEntry { + integrationDomain?: string; +} + +export interface IAsuswrtCommand { + type: TAsuswrtCommandType; + service: string; + action: TAsuswrtAction; + target: { + entityId?: string; + deviceId?: string; + }; + protocol?: TAsuswrtProtocol; + routerId?: string; + mac?: string; + entityId?: string; + deviceId?: string; + payload?: Record; +} + +export interface IAsuswrtCommandResult extends IServiceCallResult {} + +export interface IAsuswrtEvent { + type: string; + timestamp?: number; + deviceId?: string; + entityId?: string; + command?: IAsuswrtCommand; + snapshot?: IAsuswrtSnapshot; + error?: string; + data?: unknown; + [key: string]: unknown; +} + +export interface IAsuswrtNativeClient { + getSnapshot(): Promise | IAsuswrtSnapshot; + executeCommand?(commandArg: IAsuswrtCommand): Promise | IAsuswrtCommandResult | unknown; + destroy?(): Promise | void; +} + +export type TAsuswrtSnapshotProvider = () => Promise | IAsuswrtSnapshot | undefined; +export type TAsuswrtCommandExecutor = ( + commandArg: IAsuswrtCommand +) => Promise | IAsuswrtCommandResult | unknown; diff --git a/ts/integrations/asuswrt/index.ts b/ts/integrations/asuswrt/index.ts index 16b9262..fcb7933 100644 --- a/ts/integrations/asuswrt/index.ts +++ b/ts/integrations/asuswrt/index.ts @@ -1,2 +1,6 @@ +export * from './asuswrt.classes.client.js'; +export * from './asuswrt.classes.configflow.js'; export * from './asuswrt.classes.integration.js'; +export * from './asuswrt.discovery.js'; +export * from './asuswrt.mapper.js'; export * from './asuswrt.types.js'; diff --git a/ts/integrations/bluetooth_le_tracker/.generated-by-smarthome-exchange b/ts/integrations/bluetooth_le_tracker/.generated-by-smarthome-exchange deleted file mode 100644 index d35eb12..0000000 --- a/ts/integrations/bluetooth_le_tracker/.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/bluetooth_le_tracker/bluetooth_le_tracker.classes.client.ts b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.client.ts new file mode 100644 index 0000000..ad5796c --- /dev/null +++ b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.client.ts @@ -0,0 +1,130 @@ +import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js'; +import type { + IBluetoothLeAdvertisement, + IBluetoothLeTrackedDevice, + IBluetoothLeTrackerCommand, + IBluetoothLeTrackerCommandResult, + IBluetoothLeTrackerConfig, + IBluetoothLeTrackerEvent, + IBluetoothLeTrackerSnapshot, +} from './bluetooth_le_tracker.types.js'; + +type TBluetoothLeTrackerEventHandler = (eventArg: IBluetoothLeTrackerEvent) => void; + +export class BluetoothLeTrackerClient { + private injectedSnapshot?: IBluetoothLeTrackerSnapshot; + private readonly injectedDevices: IBluetoothLeTrackedDevice[] = []; + private readonly injectedAdvertisements: IBluetoothLeAdvertisement[] = []; + private readonly events: IBluetoothLeTrackerEvent[] = []; + private readonly eventHandlers = new Set(); + + constructor(private readonly config: IBluetoothLeTrackerConfig) {} + + public async getSnapshot(): Promise { + return BluetoothLeTrackerMapper.toSnapshot(this.runtimeConfig(), undefined, this.events); + } + + public onEvent(handlerArg: TBluetoothLeTrackerEventHandler): () => void { + this.eventHandlers.add(handlerArg); + return () => this.eventHandlers.delete(handlerArg); + } + + public async sendCommand(commandArg: IBluetoothLeTrackerCommand): Promise { + if (commandArg.type !== 'scan') { + return { success: false, error: `Unsupported Bluetooth LE Tracker command: ${commandArg.type}` }; + } + const payload = await this.injectedPayload(commandArg); + if (!payload) { + const error = 'Bluetooth LE live scanning is not implemented in this dependency-free TypeScript port. Provide injected advertisements, devices, a snapshot, or scanProvider data to refresh tracker state.'; + this.emit({ type: 'unsupported_scan', data: { service: commandArg.service }, timestamp: Date.now() }); + return { success: false, error }; + } + if (payload.snapshot) { + this.injectedSnapshot = payload.snapshot; + } + if (payload.devices?.length) { + this.injectedDevices.push(...payload.devices); + } + if (payload.advertisements?.length) { + this.injectedAdvertisements.push(...payload.advertisements); + } + const snapshot = await this.getSnapshot(); + this.emit({ type: payload.snapshot ? 'snapshot_updated' : 'scan_applied', data: { service: commandArg.service, snapshot }, timestamp: Date.now() }); + return { success: true, data: { snapshot } }; + } + + public async destroy(): Promise { + this.eventHandlers.clear(); + } + + private runtimeConfig(): IBluetoothLeTrackerConfig { + return { + ...this.config, + snapshot: this.injectedSnapshot || this.config.snapshot, + devices: [...(this.config.devices || []), ...this.injectedDevices], + advertisements: [...(this.config.advertisements || []), ...this.injectedAdvertisements], + }; + } + + private async injectedPayload(commandArg: IBluetoothLeTrackerCommand): Promise<{ + snapshot?: IBluetoothLeTrackerSnapshot; + devices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; + } | undefined> { + if (commandArg.snapshot || commandArg.devices?.length || commandArg.advertisements?.length) { + return { + snapshot: commandArg.snapshot, + devices: commandArg.devices, + advertisements: commandArg.advertisements, + }; + } + if (this.config.scanProvider) { + const result = await this.config.scanProvider(); + return this.providerResult(result); + } + if (this.hasInjectedConfigData()) { + return { + snapshot: this.config.snapshot, + devices: [...(this.config.devices || []), ...(this.config.knownDevices || []), ...(this.config.trackedDevices || [])], + advertisements: this.config.advertisements, + }; + } + return undefined; + } + + private providerResult(valueArg: unknown): { + snapshot?: IBluetoothLeTrackerSnapshot; + devices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; + } | undefined { + if (Array.isArray(valueArg)) { + return { advertisements: valueArg as IBluetoothLeAdvertisement[] }; + } + if (!this.isRecord(valueArg)) { + return undefined; + } + if (Array.isArray(valueArg.devices) || Array.isArray(valueArg.advertisements) || this.isRecord(valueArg.snapshot)) { + return { + snapshot: this.isRecord(valueArg.snapshot) ? valueArg.snapshot as unknown as IBluetoothLeTrackerSnapshot : undefined, + devices: Array.isArray(valueArg.devices) ? valueArg.devices as IBluetoothLeTrackedDevice[] : undefined, + advertisements: Array.isArray(valueArg.advertisements) ? valueArg.advertisements as IBluetoothLeAdvertisement[] : undefined, + }; + } + return { snapshot: valueArg as unknown as IBluetoothLeTrackerSnapshot }; + } + + private hasInjectedConfigData(): boolean { + return Boolean(this.config.snapshot || this.config.devices?.length || this.config.knownDevices?.length || this.config.trackedDevices?.length || this.config.advertisements?.length || this.config.manualEntries?.length); + } + + private emit(eventArg: IBluetoothLeTrackerEvent): void { + this.events.push(eventArg); + for (const handler of this.eventHandlers) { + handler(eventArg); + } + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.configflow.ts b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.configflow.ts new file mode 100644 index 0000000..eb37ffe --- /dev/null +++ b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.configflow.ts @@ -0,0 +1,167 @@ +import type { IConfigFlow, IConfigFlowContext, IConfigFlowStep, IDiscoveryCandidate } from '../../core/types.js'; +import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js'; +import type { IBluetoothLeAdvertisement, IBluetoothLeTrackedDevice, IBluetoothLeTrackerConfig, IBluetoothLeTrackerSnapshot } from './bluetooth_le_tracker.types.js'; +import { + bluetoothLeTrackerDefaultConsiderHomeSeconds, + bluetoothLeTrackerDefaultScanIntervalSeconds, + bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds, +} from './bluetooth_le_tracker.types.js'; + +export class BluetoothLeTrackerConfigFlow implements IConfigFlow { + public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise> { + void contextArg; + const defaults = this.defaultsFromCandidate(candidateArg); + return { + kind: 'form', + title: 'Configure Bluetooth LE Tracker', + description: 'Configure known BLE devices or provide advertisement/snapshot data. Live Bluetooth scanning is not performed by this dependency-free TypeScript port.', + fields: [ + { name: 'address', label: 'BLE address', type: 'text' }, + { name: 'name', label: 'Device name', type: 'text' }, + { name: 'trackNewDevices', label: 'Track new devices after repeated advertisements', type: 'boolean' }, + { name: 'trackBattery', label: 'Track battery from injected data', type: 'boolean' }, + { name: 'trackBatteryIntervalSeconds', label: 'Battery refresh interval seconds', type: 'number' }, + { name: 'scanIntervalSeconds', label: 'Scanner refresh interval seconds', type: 'number' }, + { name: 'considerHomeSeconds', label: 'Consider home seconds', type: 'number' }, + { name: 'knownDevicesJson', label: 'Known devices JSON', type: 'text' }, + { name: 'advertisementsJson', label: 'Advertisements JSON', type: 'text' }, + { name: 'snapshotJson', label: 'Snapshot JSON', type: 'text' }, + ], + submit: async (valuesArg) => { + const knownDevices = this.arrayValue(valuesArg.knownDevicesJson, defaults.knownDevices); + if (knownDevices === false) { + return { kind: 'error', title: 'Invalid known devices', error: 'Known devices JSON must be an array.' }; + } + const advertisements = this.arrayValue(valuesArg.advertisementsJson, defaults.advertisements); + if (advertisements === false) { + return { kind: 'error', title: 'Invalid advertisements', error: 'Advertisements JSON must be an array.' }; + } + const snapshot = this.snapshotValue(valuesArg.snapshotJson, defaults.snapshot); + if (snapshot === false) { + return { kind: 'error', title: 'Invalid BLE tracker snapshot', error: 'Snapshot JSON must be a JSON object.' }; + } + const address = BluetoothLeTrackerMapper.normalizeAddress(valuesArg.address) || defaults.address; + const name = this.stringValue(valuesArg.name) || defaults.name; + const trackedDevices = [...(knownDevices || [])]; + if (address && !trackedDevices.some((deviceArg) => BluetoothLeTrackerMapper.normalizeAddress(deviceArg.address || deviceArg.mac || deviceArg.macAddress || deviceArg.haMac) === address)) { + trackedDevices.push({ address, name, track: true, trackBattery: this.booleanValue(valuesArg.trackBattery) ?? defaults.trackBattery }); + } + return { + kind: 'done', + title: 'Bluetooth LE Tracker configured', + config: { + scanIntervalSeconds: this.numberValue(valuesArg.scanIntervalSeconds) ?? defaults.scanIntervalSeconds ?? bluetoothLeTrackerDefaultScanIntervalSeconds, + trackNewDevices: this.booleanValue(valuesArg.trackNewDevices) ?? defaults.trackNewDevices ?? true, + trackBattery: this.booleanValue(valuesArg.trackBattery) ?? defaults.trackBattery ?? false, + trackBatteryIntervalSeconds: this.numberValue(valuesArg.trackBatteryIntervalSeconds) ?? defaults.trackBatteryIntervalSeconds ?? bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds, + considerHomeSeconds: this.numberValue(valuesArg.considerHomeSeconds) ?? defaults.considerHomeSeconds ?? bluetoothLeTrackerDefaultConsiderHomeSeconds, + knownDevices: trackedDevices, + advertisements: advertisements || undefined, + snapshot: snapshot || undefined, + }, + }; + }, + }; + } + + private defaultsFromCandidate(candidateArg: IDiscoveryCandidate): { + address?: string; + name?: string; + trackNewDevices?: boolean; + trackBattery?: boolean; + trackBatteryIntervalSeconds?: number; + scanIntervalSeconds?: number; + considerHomeSeconds?: number; + knownDevices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; + snapshot?: IBluetoothLeTrackerSnapshot; + } { + const metadata = candidateArg.metadata || {}; + return { + address: BluetoothLeTrackerMapper.normalizeAddress(candidateArg.macAddress || metadata.address || metadata.haMac), + name: candidateArg.name, + trackNewDevices: this.booleanValue(metadata.trackNewDevices), + trackBattery: this.booleanValue(metadata.trackBattery), + trackBatteryIntervalSeconds: this.numberValue(metadata.trackBatteryIntervalSeconds), + scanIntervalSeconds: this.numberValue(metadata.scanIntervalSeconds), + considerHomeSeconds: this.numberValue(metadata.considerHomeSeconds), + knownDevices: this.arrayCandidateValue(metadata.knownDevices) || this.arrayCandidateValue(metadata.trackedDevices) || this.arrayCandidateValue(metadata.devices), + advertisements: this.arrayCandidateValue(metadata.advertisements) || this.arrayCandidateValue(metadata.advertisement ? [metadata.advertisement] : undefined), + snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined, + }; + } + + private arrayValue(valueArg: unknown, fallbackArg?: TValue[]): TValue[] | undefined | false { + if (valueArg === undefined || valueArg === null || valueArg === '') { + return fallbackArg; + } + if (Array.isArray(valueArg)) { + return valueArg as TValue[]; + } + if (typeof valueArg !== 'string') { + return false; + } + try { + const parsed = JSON.parse(valueArg) as unknown; + return Array.isArray(parsed) ? parsed as TValue[] : false; + } catch { + return false; + } + } + + private snapshotValue(valueArg: unknown, fallbackArg?: IBluetoothLeTrackerSnapshot): IBluetoothLeTrackerSnapshot | undefined | false { + if (valueArg === undefined || valueArg === null || valueArg === '') { + return fallbackArg; + } + if (this.isSnapshot(valueArg)) { + return valueArg; + } + if (typeof valueArg !== 'string') { + return false; + } + try { + const parsed = JSON.parse(valueArg) as unknown; + return this.isRecord(parsed) ? parsed as unknown as IBluetoothLeTrackerSnapshot : false; + } catch { + return false; + } + } + + private arrayCandidateValue(valueArg: unknown): TValue[] | undefined { + return Array.isArray(valueArg) ? valueArg as TValue[] : undefined; + } + + private stringValue(valueArg: unknown): string | undefined { + return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : 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 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 isSnapshot(valueArg: unknown): valueArg is IBluetoothLeTrackerSnapshot { + return this.isRecord(valueArg); + } + + private isRecord(valueArg: unknown): valueArg is Record { + return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); + } +} diff --git a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.integration.ts b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.integration.ts index 30a6598..2d801c9 100644 --- a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.integration.ts +++ b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.classes.integration.ts @@ -1,24 +1,107 @@ -import { DescriptorOnlyIntegration } from '../../core/classes.descriptoronlyintegration.js'; +import * as plugins from '../../plugins.js'; +import { BaseIntegration } from '../../core/classes.baseintegration.js'; +import type { IIntegrationEntity, IIntegrationEvent, IIntegrationRuntime, IIntegrationSetupContext, IServiceCallRequest, IServiceCallResult } from '../../core/types.js'; +import { BluetoothLeTrackerClient } from './bluetooth_le_tracker.classes.client.js'; +import { BluetoothLeTrackerConfigFlow } from './bluetooth_le_tracker.classes.configflow.js'; +import { createBluetoothLeTrackerDiscoveryDescriptor } from './bluetooth_le_tracker.discovery.js'; +import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js'; +import type { IBluetoothLeAdvertisement, IBluetoothLeTrackedDevice, IBluetoothLeTrackerCommand, IBluetoothLeTrackerConfig, IBluetoothLeTrackerSnapshot } from './bluetooth_le_tracker.types.js'; +import { bluetoothLeTrackerDomain } from './bluetooth_le_tracker.types.js'; -export class HomeAssistantBluetoothLeTrackerIntegration extends DescriptorOnlyIntegration { - constructor() { - super({ - domain: "bluetooth_le_tracker", - displayName: "Bluetooth LE Tracker", - status: 'descriptor-only', - metadata: { - "source": "home-assistant/core", - "upstreamPath": "homeassistant/components/bluetooth_le_tracker", - "upstreamDomain": "bluetooth_le_tracker", - "iotClass": "local_push", - "qualityScale": "legacy", - "requirements": [], - "dependencies": [ - "bluetooth_adapters" - ], - "afterDependencies": [], - "codeowners": [] -}, - }); +export class BluetoothLeTrackerIntegration extends BaseIntegration { + public readonly domain = bluetoothLeTrackerDomain; + public readonly displayName = 'Bluetooth LE Tracker'; + public readonly status = 'read-only-runtime' as const; + public readonly discoveryDescriptor = createBluetoothLeTrackerDiscoveryDescriptor(); + public readonly configFlow = new BluetoothLeTrackerConfigFlow(); + public readonly metadata = { + source: 'home-assistant/core', + upstreamPath: 'homeassistant/components/bluetooth_le_tracker', + upstreamDomain: bluetoothLeTrackerDomain, + iotClass: 'local_push', + qualityScale: 'legacy', + requirements: [] as string[], + dependencies: ['bluetooth_adapters'], + afterDependencies: [] as string[], + codeowners: [] as string[], + documentation: 'https://www.home-assistant.io/integrations/bluetooth_le_tracker', + nativeBehavior: 'Maps injected BLE advertisement/snapshot data into device-tracker-like binary_sensor/sensor entities. Live Bluetooth adapter scanning is intentionally not implemented.', + }; + + public async setup(configArg: IBluetoothLeTrackerConfig, contextArg: IIntegrationSetupContext): Promise { + void contextArg; + return new BluetoothLeTrackerRuntime(new BluetoothLeTrackerClient(configArg)); + } + + public async destroy(): Promise {} +} + +export class HomeAssistantBluetoothLeTrackerIntegration extends BluetoothLeTrackerIntegration {} + +class BluetoothLeTrackerRuntime implements IIntegrationRuntime { + public domain = bluetoothLeTrackerDomain; + + constructor(private readonly client: BluetoothLeTrackerClient) {} + + public async devices(): Promise { + return BluetoothLeTrackerMapper.toDevices(await this.client.getSnapshot()); + } + + public async entities(): Promise { + return BluetoothLeTrackerMapper.toEntities(await this.client.getSnapshot()); + } + + public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise> { + const unsubscribe = this.client.onEvent((eventArg) => handlerArg({ + type: eventArg.type === 'unsupported_scan' ? 'error' : 'state_changed', + integrationDomain: bluetoothLeTrackerDomain, + 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 { + const command = this.commandFromService(requestArg); + if (!command) { + return { success: false, error: `Unsupported Bluetooth LE Tracker service: ${requestArg.domain}.${requestArg.service}` }; + } + const result = await this.client.sendCommand(command); + return { success: result.success, error: result.error, data: result.data }; + } + + public async destroy(): Promise { + await this.client.destroy(); + } + + private commandFromService(requestArg: IServiceCallRequest): IBluetoothLeTrackerCommand | undefined { + if (requestArg.domain !== bluetoothLeTrackerDomain || !['scan', 'scan_once', 'refresh', 'refresh_devices'].includes(requestArg.service)) { + return undefined; + } + return { + type: 'scan', + service: requestArg.service, + target: requestArg.target, + snapshot: this.snapshotValue(requestArg.data?.snapshot), + devices: this.arrayValue(requestArg.data?.devices || requestArg.data?.trackedDevices || requestArg.data?.knownDevices), + advertisements: this.arrayValue(requestArg.data?.advertisements || requestArg.data?.advertisement), + }; + } + + private arrayValue(valueArg: unknown): TValue[] | undefined { + if (Array.isArray(valueArg)) { + return valueArg as TValue[]; + } + if (valueArg && typeof valueArg === 'object') { + return [valueArg as TValue]; + } + return undefined; + } + + private snapshotValue(valueArg: unknown): IBluetoothLeTrackerSnapshot | undefined { + return valueArg && typeof valueArg === 'object' && !Array.isArray(valueArg) ? valueArg as IBluetoothLeTrackerSnapshot : undefined; } } diff --git a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.discovery.ts b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.discovery.ts new file mode 100644 index 0000000..6e1d40a --- /dev/null +++ b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.discovery.ts @@ -0,0 +1,143 @@ +import { DiscoveryDescriptor } from '../../core/classes.discoverydescriptor.js'; +import type { IDiscoveryCandidate, IDiscoveryMatch, IDiscoveryMatcher, IDiscoveryValidator } from '../../core/types.js'; +import { BluetoothLeTrackerMapper } from './bluetooth_le_tracker.mapper.js'; +import type { IBluetoothLeDiscoveryRecord, IBluetoothLeManualEntry } from './bluetooth_le_tracker.types.js'; +import { bluetoothLeTrackerDomain, bluetoothLeTrackerSourceType } from './bluetooth_le_tracker.types.js'; + +const bleTextHints = ['bluetooth', 'ble', 'beacon', 'ibeacon', 'tag', 'tracker']; + +export class BluetoothLeAdvertisementMatcher implements IDiscoveryMatcher { + public id = 'bluetooth-le-tracker-bluetooth-match'; + public source = 'bluetooth' as const; + public description = 'Recognize Bluetooth LE advertisements that can feed the BLE tracker.'; + + public async matches(recordArg: IBluetoothLeDiscoveryRecord): Promise { + const address = BluetoothLeTrackerMapper.normalizeAddress(recordArg.address || recordArg.mac || recordArg.macAddress || recordArg.haMac || recordArg.id); + if (!address) { + return { matched: false, confidence: 'low', reason: 'Bluetooth record does not include a trackable BLE MAC address.' }; + } + const name = BluetoothLeTrackerMapper.cleanName(recordArg.name || recordArg.localName || recordArg.hostName); + const hasBlePayload = Boolean(recordArg.serviceUuids?.length || recordArg.serviceUUIDs?.length || recordArg.serviceData || recordArg.manufacturerData || typeof recordArg.rssi === 'number' || recordArg.connectable !== undefined); + return { + matched: true, + confidence: recordArg.sourceType === bluetoothLeTrackerSourceType ? 'certain' : hasBlePayload ? 'high' : 'medium', + reason: hasBlePayload ? 'Bluetooth record contains BLE advertisement metadata.' : 'Bluetooth record contains a BLE address.', + normalizedDeviceId: address, + candidate: { + source: 'bluetooth', + integrationDomain: bluetoothLeTrackerDomain, + id: address, + name: name || BluetoothLeTrackerMapper.haMac(address), + manufacturer: 'Bluetooth', + model: 'Bluetooth LE device', + macAddress: address, + metadata: { + address, + haMac: BluetoothLeTrackerMapper.haMac(address), + sourceType: bluetoothLeTrackerSourceType, + rssi: recordArg.rssi, + connectable: recordArg.connectable, + serviceUuids: recordArg.serviceUuids || recordArg.serviceUUIDs, + serviceData: recordArg.serviceData, + manufacturerData: recordArg.manufacturerData, + advertisement: recordArg, + }, + }, + }; + } +} + +export class BluetoothLeManualMatcher implements IDiscoveryMatcher { + public id = 'bluetooth-le-tracker-manual-match'; + public source = 'manual' as const; + public description = 'Recognize manual BLE tracker entries and known_devices-style BLE addresses.'; + + public async matches(inputArg: IBluetoothLeManualEntry): Promise { + const address = BluetoothLeTrackerMapper.normalizeAddress(inputArg.address || inputArg.mac || inputArg.macAddress || inputArg.haMac || inputArg.id) + || this.firstDeviceAddress(inputArg); + const metadata = inputArg.metadata || {}; + const matched = Boolean(address || metadata.bluetooth_le_tracker || metadata.bleTracker || inputArg.sourceType === bluetoothLeTrackerSourceType || inputArg.snapshot || inputArg.devices?.length || inputArg.knownDevices?.length || inputArg.trackedDevices?.length); + if (!matched) { + return { matched: false, confidence: 'low', reason: 'Manual entry does not contain BLE tracker setup hints.' }; + } + return { + matched: true, + confidence: address ? 'high' : 'medium', + reason: address ? 'Manual entry contains a trackable BLE address.' : 'Manual entry contains BLE tracker snapshot or device data.', + normalizedDeviceId: inputArg.id || address, + candidate: { + source: 'manual', + integrationDomain: bluetoothLeTrackerDomain, + id: inputArg.id || address, + name: inputArg.name || (address ? BluetoothLeTrackerMapper.haMac(address) : 'Bluetooth LE Tracker'), + manufacturer: inputArg.manufacturer || 'Bluetooth', + model: inputArg.model || 'Bluetooth LE tracker', + macAddress: address, + metadata: { + ...metadata, + address, + haMac: BluetoothLeTrackerMapper.haMac(address), + sourceType: bluetoothLeTrackerSourceType, + track: inputArg.track, + trackBattery: inputArg.trackBattery ?? inputArg.track_battery, + knownDevices: inputArg.knownDevices, + trackedDevices: inputArg.trackedDevices, + devices: inputArg.devices, + advertisements: inputArg.advertisements, + snapshot: inputArg.snapshot, + }, + }, + }; + } + + private firstDeviceAddress(inputArg: IBluetoothLeManualEntry): string | undefined { + for (const device of [...(inputArg.knownDevices || []), ...(inputArg.trackedDevices || []), ...(inputArg.devices || []), ...(inputArg.snapshot?.devices || [])]) { + const address = BluetoothLeTrackerMapper.normalizeAddress(device.address || device.mac || device.macAddress || device.haMac); + if (address) { + return address; + } + } + return undefined; + } +} + +export class BluetoothLeTrackerCandidateValidator implements IDiscoveryValidator { + public id = 'bluetooth-le-tracker-candidate-validator'; + public description = 'Validate BLE tracker discovery candidates.'; + + public async validate(candidateArg: IDiscoveryCandidate): Promise { + const metadata = candidateArg.metadata || {}; + const address = BluetoothLeTrackerMapper.normalizeAddress(candidateArg.macAddress || candidateArg.id || metadata.address || metadata.haMac); + const text = [candidateArg.name, candidateArg.manufacturer, candidateArg.model].filter((valueArg): valueArg is string => typeof valueArg === 'string').join(' ').toLowerCase(); + const hasBleHint = candidateArg.source === 'bluetooth' + || metadata.sourceType === bluetoothLeTrackerSourceType + || metadata.bluetooth_le_tracker === true + || metadata.bleTracker === true + || bleTextHints.some((hintArg) => text.includes(hintArg)); + const matched = candidateArg.integrationDomain === bluetoothLeTrackerDomain || Boolean(address && hasBleHint); + return { + matched, + confidence: matched && candidateArg.integrationDomain === bluetoothLeTrackerDomain && address ? 'certain' : matched && address ? 'high' : matched ? 'medium' : 'low', + reason: matched ? 'Candidate has BLE tracker metadata.' : 'Candidate is not a Bluetooth LE tracker device.', + normalizedDeviceId: address || candidateArg.id, + candidate: matched ? { + ...candidateArg, + integrationDomain: bluetoothLeTrackerDomain, + macAddress: address || candidateArg.macAddress, + metadata: { + ...metadata, + address, + haMac: BluetoothLeTrackerMapper.haMac(address), + sourceType: bluetoothLeTrackerSourceType, + }, + } : undefined, + }; + } +} + +export const createBluetoothLeTrackerDiscoveryDescriptor = (): DiscoveryDescriptor => { + return new DiscoveryDescriptor({ integrationDomain: bluetoothLeTrackerDomain, displayName: 'Bluetooth LE Tracker' }) + .addMatcher(new BluetoothLeAdvertisementMatcher()) + .addMatcher(new BluetoothLeManualMatcher()) + .addValidator(new BluetoothLeTrackerCandidateValidator()); +}; diff --git a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.mapper.ts b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.mapper.ts new file mode 100644 index 0000000..3b70231 --- /dev/null +++ b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.mapper.ts @@ -0,0 +1,468 @@ +import * as plugins from '../../plugins.js'; +import type { IIntegrationEntity, TEntityPlatform } from '../../core/types.js'; +import type { + IBluetoothLeAdvertisement, + IBluetoothLeManualEntry, + IBluetoothLeTrackedDevice, + IBluetoothLeTrackerConfig, + IBluetoothLeTrackerEvent, + IBluetoothLeTrackerScannerState, + IBluetoothLeTrackerSnapshot, + TBluetoothLePresenceState, +} from './bluetooth_le_tracker.types.js'; +import { + bluetoothLeTrackerBlePrefix, + bluetoothLeTrackerDefaultConsiderHomeSeconds, + bluetoothLeTrackerDefaultMinSeenNew, + bluetoothLeTrackerDefaultScanIntervalSeconds, + bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds, + bluetoothLeTrackerDomain, + bluetoothLeTrackerSourceType, +} from './bluetooth_le_tracker.types.js'; + +export class BluetoothLeTrackerMapper { + public static toSnapshot(configArg: IBluetoothLeTrackerConfig, connectedArg?: boolean, eventsArg: IBluetoothLeTrackerEvent[] = []): IBluetoothLeTrackerSnapshot { + const source = configArg.snapshot; + const scanner = this.scannerState(configArg, source?.scanner); + const advertisements = this.advertisementsFromConfig(configArg) + .map((advertisementArg) => this.normalizeAdvertisement(advertisementArg)) + .filter((advertisementArg): advertisementArg is IBluetoothLeAdvertisement => Boolean(advertisementArg)); + const trackedDevices = new Map(); + const blockedDevices = new Set(); + + const addDevice = (deviceArg: IBluetoothLeTrackedDevice | undefined): void => { + const device = this.normalizeDevice(deviceArg); + const address = device?.address; + if (!device || !address) { + return; + } + if (device.track === false || device.tracked === false) { + blockedDevices.add(address); + trackedDevices.delete(address); + return; + } + const existing = trackedDevices.get(address); + trackedDevices.set(address, this.mergeDevice(existing, device, scanner)); + }; + + for (const device of this.devicesFromConfig(configArg)) { + addDevice(device); + } + + const advertisementCounts = new Map(); + for (const advertisement of advertisements) { + const address = advertisement.address; + if (!address) { + continue; + } + advertisementCounts.set(address, (advertisementCounts.get(address) || 0) + 1); + if (blockedDevices.has(address)) { + continue; + } + if (trackedDevices.has(address)) { + addDevice(this.deviceFromAdvertisement(advertisement, advertisementCounts.get(address))); + } + } + + if (scanner.trackNewDevices) { + for (const advertisement of advertisements) { + const address = advertisement.address; + if (!address || blockedDevices.has(address) || trackedDevices.has(address)) { + continue; + } + const count = advertisementCounts.get(address) || 0; + if (count >= scanner.minSeenNew) { + addDevice(this.deviceFromAdvertisement(advertisement, count)); + } + } + } + + const devices = [...trackedDevices.values()].map((deviceArg) => this.withPresence(deviceArg, scanner)); + return { + connected: connectedArg ?? source?.connected ?? this.hasStaticData(configArg), + scanner, + devices, + advertisements, + events: [...(source?.events || []), ...(configArg.events || []), ...eventsArg], + }; + } + + public static toDevices(snapshotArg: IBluetoothLeTrackerSnapshot): plugins.shxInterfaces.data.IDeviceDefinition[] { + return snapshotArg.devices + .map((deviceArg) => this.normalizeDevice(deviceArg)) + .filter((deviceArg): deviceArg is IBluetoothLeTrackedDevice => Boolean(deviceArg?.address && deviceArg.tracked !== false && deviceArg.track !== false)) + .map((deviceArg) => { + const updatedAt = this.lastSeenIso(deviceArg) || new Date().toISOString(); + const presence = this.presenceState(deviceArg, snapshotArg.scanner); + const features: plugins.shxInterfaces.data.IDeviceFeature[] = [ + { id: 'presence', capability: 'sensor', name: 'Presence', readable: true, writable: false }, + { id: 'tracker_state', capability: 'sensor', name: 'Tracker state', readable: true, writable: false }, + ]; + const state: plugins.shxInterfaces.data.IDeviceState[] = [ + { featureId: 'presence', value: presence, updatedAt }, + { featureId: 'tracker_state', value: presence, updatedAt }, + ]; + if (typeof deviceArg.rssi === 'number') { + features.push({ id: 'rssi', capability: 'sensor', name: 'RSSI', readable: true, writable: false, unit: 'dBm' }); + state.push({ featureId: 'rssi', value: deviceArg.rssi, updatedAt }); + } + if (typeof deviceArg.battery === 'number' || deviceArg.trackBattery || deviceArg.track_battery) { + features.push({ id: 'battery', capability: 'sensor', name: 'Battery', readable: true, writable: false, unit: '%' }); + state.push({ featureId: 'battery', value: typeof deviceArg.battery === 'number' ? deviceArg.battery : null, updatedAt }); + } + const lastSeen = this.lastSeenIso(deviceArg); + if (lastSeen) { + features.push({ id: 'last_seen', capability: 'sensor', name: 'Last seen', readable: true, writable: false }); + state.push({ featureId: 'last_seen', value: lastSeen, updatedAt }); + } + if (typeof deviceArg.advertisementCount === 'number') { + features.push({ id: 'advertisement_count', capability: 'sensor', name: 'Advertisement count', readable: true, writable: false }); + state.push({ featureId: 'advertisement_count', value: deviceArg.advertisementCount, updatedAt }); + } + return { + id: this.deviceId(deviceArg), + integrationDomain: bluetoothLeTrackerDomain, + name: this.deviceName(deviceArg), + protocol: 'unknown', + manufacturer: deviceArg.manufacturer || 'Bluetooth', + model: deviceArg.model || 'Bluetooth LE tracker', + online: presence === 'home', + features, + state, + metadata: { + address: deviceArg.address, + haMac: this.haMac(deviceArg.address), + sourceType: bluetoothLeTrackerSourceType, + connectable: deviceArg.connectable, + serviceUuids: deviceArg.serviceUuids || deviceArg.serviceUUIDs, + serviceData: deviceArg.serviceData, + manufacturerData: deviceArg.manufacturerData, + trackBattery: deviceArg.trackBattery ?? deviceArg.track_battery, + }, + }; + }); + } + + public static toEntities(snapshotArg: IBluetoothLeTrackerSnapshot): IIntegrationEntity[] { + const usedIds = new Map(); + const entities: IIntegrationEntity[] = []; + for (const device of snapshotArg.devices) { + const normalizedDevice = this.normalizeDevice(device); + if (!normalizedDevice?.address || normalizedDevice.track === false || normalizedDevice.tracked === false) { + continue; + } + const deviceId = this.deviceId(normalizedDevice); + const addressSlug = this.slug(normalizedDevice.address); + const presence = this.presenceState(normalizedDevice, snapshotArg.scanner); + const name = this.deviceName(normalizedDevice); + entities.push(this.entity('binary_sensor', `${name} Presence`, deviceId, `bluetooth_le_tracker_presence_${addressSlug}`, presence === 'home' ? 'on' : 'off', usedIds, { + deviceClass: 'presence', + sourceType: bluetoothLeTrackerSourceType, + address: normalizedDevice.address, + haMac: this.haMac(normalizedDevice.address), + }, true)); + entities.push(this.entity('sensor', `${name} Tracker State`, deviceId, `bluetooth_le_tracker_state_${addressSlug}`, presence, usedIds, { + sourceType: bluetoothLeTrackerSourceType, + address: normalizedDevice.address, + haMac: this.haMac(normalizedDevice.address), + }, true)); + if (typeof normalizedDevice.rssi === 'number') { + entities.push(this.entity('sensor', `${name} RSSI`, deviceId, `bluetooth_le_tracker_rssi_${addressSlug}`, normalizedDevice.rssi, usedIds, { unit: 'dBm' }, presence === 'home')); + } + if (typeof normalizedDevice.battery === 'number' || normalizedDevice.trackBattery || normalizedDevice.track_battery) { + entities.push(this.entity('sensor', `${name} Battery`, deviceId, `bluetooth_le_tracker_battery_${addressSlug}`, typeof normalizedDevice.battery === 'number' ? normalizedDevice.battery : null, usedIds, { + deviceClass: 'battery', + unit: '%', + }, true)); + } + const lastSeen = this.lastSeenIso(normalizedDevice); + if (lastSeen) { + entities.push(this.entity('sensor', `${name} Last Seen`, deviceId, `bluetooth_le_tracker_last_seen_${addressSlug}`, lastSeen, usedIds, { deviceClass: 'timestamp' }, true)); + } + if (typeof normalizedDevice.advertisementCount === 'number') { + entities.push(this.entity('sensor', `${name} Advertisement Count`, deviceId, `bluetooth_le_tracker_advertisement_count_${addressSlug}`, normalizedDevice.advertisementCount, usedIds, undefined, true)); + } + } + return entities; + } + + public static normalizeAddress(valueArg: unknown): string | undefined { + if (typeof valueArg !== 'string' || !valueArg.trim()) { + return undefined; + } + const withoutPrefix = valueArg.trim().replace(new RegExp(`^${bluetoothLeTrackerBlePrefix}`, 'i'), ''); + const compact = withoutPrefix.toLowerCase().replace(/[^a-f0-9]/g, ''); + if (compact.length === 12) { + return compact.match(/.{1,2}/g)?.join(':'); + } + if (/^[a-f0-9]{2}([:-][a-f0-9]{2}){5}$/i.test(withoutPrefix)) { + return withoutPrefix.toLowerCase().replace(/-/g, ':'); + } + return undefined; + } + + public static haMac(addressArg: unknown): string | undefined { + const address = this.normalizeAddress(addressArg); + return address ? `${bluetoothLeTrackerBlePrefix}${address.toUpperCase()}` : undefined; + } + + public static cleanName(valueArg: unknown): string | undefined { + if (typeof valueArg !== 'string') { + return undefined; + } + const clean = valueArg.replace(/\x00/g, '').trim(); + return clean || undefined; + } + + private static scannerState(configArg: IBluetoothLeTrackerConfig, sourceArg?: Partial): IBluetoothLeTrackerScannerState { + return { + scanIntervalSeconds: this.numberValue(configArg.scanIntervalSeconds, configArg.interval_seconds, sourceArg?.scanIntervalSeconds) ?? bluetoothLeTrackerDefaultScanIntervalSeconds, + trackNewDevices: this.booleanValue(configArg.trackNewDevices, configArg.track_new_devices, sourceArg?.trackNewDevices) ?? true, + trackBattery: this.booleanValue(configArg.trackBattery, configArg.track_battery, sourceArg?.trackBattery) ?? false, + trackBatteryIntervalSeconds: this.numberValue(configArg.trackBatteryIntervalSeconds, configArg.track_battery_interval, sourceArg?.trackBatteryIntervalSeconds) ?? bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds, + considerHomeSeconds: this.numberValue(configArg.considerHomeSeconds, configArg.consider_home, sourceArg?.considerHomeSeconds) ?? bluetoothLeTrackerDefaultConsiderHomeSeconds, + minSeenNew: this.numberValue(configArg.minSeenNew, sourceArg?.minSeenNew) ?? bluetoothLeTrackerDefaultMinSeenNew, + }; + } + + private static devicesFromConfig(configArg: IBluetoothLeTrackerConfig): IBluetoothLeTrackedDevice[] { + const entries = configArg.manualEntries || []; + return [ + ...(configArg.snapshot?.devices || []), + ...(configArg.knownDevices || []), + ...(configArg.trackedDevices || []), + ...(configArg.devices || []), + ...entries.flatMap((entryArg) => this.devicesFromManualEntry(entryArg)), + ]; + } + + private static advertisementsFromConfig(configArg: IBluetoothLeTrackerConfig): IBluetoothLeAdvertisement[] { + const entries = configArg.manualEntries || []; + return [ + ...(configArg.snapshot?.advertisements || []), + ...(configArg.advertisements || []), + ...entries.flatMap((entryArg) => [ + ...(entryArg.snapshot?.advertisements || []), + ...(entryArg.advertisements || []), + ]), + ]; + } + + private static devicesFromManualEntry(entryArg: IBluetoothLeManualEntry): IBluetoothLeTrackedDevice[] { + const nested = [ + ...(entryArg.snapshot?.devices || []), + ...(entryArg.knownDevices || []), + ...(entryArg.trackedDevices || []), + ...(entryArg.devices || []), + ]; + const ownAddress = this.normalizeAddress(entryArg.address || entryArg.mac || entryArg.macAddress || entryArg.haMac); + return ownAddress ? [entryArg, ...nested] : nested; + } + + private static normalizeAdvertisement(advertisementArg: IBluetoothLeAdvertisement | undefined): IBluetoothLeAdvertisement | undefined { + const address = this.normalizeAddress(advertisementArg?.address || advertisementArg?.mac || advertisementArg?.macAddress || advertisementArg?.haMac); + if (!advertisementArg || !address) { + return undefined; + } + const lastSeen = advertisementArg.lastSeen ?? advertisementArg.last_seen ?? advertisementArg.time ?? Date.now(); + return { + ...advertisementArg, + address, + macAddress: address, + haMac: this.haMac(address), + name: this.cleanName(advertisementArg.name || advertisementArg.localName || advertisementArg.hostName), + serviceUuids: advertisementArg.serviceUuids || advertisementArg.serviceUUIDs, + lastSeen, + sourceType: bluetoothLeTrackerSourceType, + metadata: { + ...advertisementArg.metadata, + source: advertisementArg.source, + }, + }; + } + + private static normalizeDevice(deviceArg: IBluetoothLeTrackedDevice | undefined): IBluetoothLeTrackedDevice | undefined { + const address = this.normalizeAddress(deviceArg?.address || deviceArg?.mac || deviceArg?.macAddress || deviceArg?.haMac); + if (!deviceArg || !address) { + return undefined; + } + return { + ...deviceArg, + address, + macAddress: address, + haMac: this.haMac(address), + name: this.cleanName(deviceArg.name || deviceArg.hostName || deviceArg.hostname), + serviceUuids: deviceArg.serviceUuids || deviceArg.serviceUUIDs, + sourceType: bluetoothLeTrackerSourceType, + }; + } + + private static deviceFromAdvertisement(advertisementArg: IBluetoothLeAdvertisement, countArg?: number): IBluetoothLeTrackedDevice { + return { + address: advertisementArg.address, + name: advertisementArg.name || advertisementArg.localName || advertisementArg.hostName, + track: true, + tracked: true, + battery: advertisementArg.battery, + rssi: advertisementArg.rssi, + txPower: advertisementArg.txPower, + connectable: advertisementArg.connectable, + serviceUuids: advertisementArg.serviceUuids || advertisementArg.serviceUUIDs, + serviceData: advertisementArg.serviceData, + manufacturerData: advertisementArg.manufacturerData, + lastSeen: advertisementArg.lastSeen ?? advertisementArg.last_seen ?? advertisementArg.time, + advertisementCount: countArg, + raw: advertisementArg.raw, + metadata: { + ...advertisementArg.metadata, + discoveredFromAdvertisement: true, + }, + }; + } + + private static mergeDevice(existingArg: IBluetoothLeTrackedDevice | undefined, incomingArg: IBluetoothLeTrackedDevice, scannerArg: IBluetoothLeTrackerScannerState): IBluetoothLeTrackedDevice { + if (!existingArg) { + return { + ...incomingArg, + track: incomingArg.track ?? true, + tracked: incomingArg.tracked ?? incomingArg.track ?? true, + trackBattery: incomingArg.trackBattery ?? incomingArg.track_battery ?? scannerArg.trackBattery, + }; + } + const latest = this.newerDevice(existingArg, incomingArg); + return { + ...existingArg, + ...incomingArg, + name: incomingArg.name || existingArg.name, + manufacturer: incomingArg.manufacturer || existingArg.manufacturer, + model: incomingArg.model || existingArg.model, + battery: incomingArg.battery ?? existingArg.battery, + rssi: incomingArg.rssi ?? existingArg.rssi, + txPower: incomingArg.txPower ?? existingArg.txPower, + connectable: incomingArg.connectable ?? existingArg.connectable, + serviceUuids: incomingArg.serviceUuids || existingArg.serviceUuids, + serviceData: incomingArg.serviceData || existingArg.serviceData, + manufacturerData: incomingArg.manufacturerData || existingArg.manufacturerData, + lastSeen: latest.lastSeen ?? latest.last_seen, + last_seen: latest.last_seen, + firstSeen: existingArg.firstSeen ?? existingArg.first_seen ?? incomingArg.firstSeen ?? incomingArg.first_seen, + advertisementCount: Math.max(existingArg.advertisementCount || 0, incomingArg.advertisementCount || 0) || undefined, + track: incomingArg.track ?? existingArg.track ?? true, + tracked: incomingArg.tracked ?? existingArg.tracked ?? true, + trackBattery: incomingArg.trackBattery ?? incomingArg.track_battery ?? existingArg.trackBattery ?? existingArg.track_battery ?? scannerArg.trackBattery, + metadata: { + ...existingArg.metadata, + ...incomingArg.metadata, + }, + }; + } + + private static newerDevice(firstArg: IBluetoothLeTrackedDevice, secondArg: IBluetoothLeTrackedDevice): IBluetoothLeTrackedDevice { + const firstTime = this.timestampMillis(firstArg.lastSeen ?? firstArg.last_seen); + const secondTime = this.timestampMillis(secondArg.lastSeen ?? secondArg.last_seen); + return (secondTime || 0) >= (firstTime || 0) ? secondArg : firstArg; + } + + private static withPresence(deviceArg: IBluetoothLeTrackedDevice, scannerArg: IBluetoothLeTrackerScannerState): IBluetoothLeTrackedDevice { + return { + ...deviceArg, + state: this.presenceState(deviceArg, scannerArg), + }; + } + + private static presenceState(deviceArg: IBluetoothLeTrackedDevice, scannerArg: IBluetoothLeTrackerScannerState): TBluetoothLePresenceState { + const explicit = typeof deviceArg.state === 'string' ? deviceArg.state.toLowerCase() : undefined; + if (explicit === 'home' || explicit === 'on' || explicit === 'true') { + return 'home'; + } + if (explicit === 'not_home' || explicit === 'off' || explicit === 'false') { + return 'not_home'; + } + const lastSeen = this.timestampMillis(deviceArg.lastSeen ?? deviceArg.last_seen); + if (!lastSeen) { + return typeof deviceArg.rssi === 'number' || typeof deviceArg.battery === 'number' ? 'home' : 'not_home'; + } + return Date.now() - lastSeen <= scannerArg.considerHomeSeconds * 1000 ? 'home' : 'not_home'; + } + + private static lastSeenIso(deviceArg: IBluetoothLeTrackedDevice): string | undefined { + const millis = this.timestampMillis(deviceArg.lastSeen ?? deviceArg.last_seen); + return millis ? new Date(millis).toISOString() : undefined; + } + + private static timestampMillis(valueArg: unknown): number | undefined { + if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { + return valueArg > 1_000_000_000_000 ? valueArg : valueArg > 1_000_000_000 ? valueArg * 1000 : valueArg; + } + if (typeof valueArg === 'string' && valueArg.trim()) { + const parsed = Number(valueArg); + if (Number.isFinite(parsed)) { + return this.timestampMillis(parsed); + } + const millis = Date.parse(valueArg); + return Number.isNaN(millis) ? undefined : millis; + } + return undefined; + } + + private static entity(platformArg: TEntityPlatform, nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, usedIdsArg: Map, attributesArg?: Record, availableArg = true): IIntegrationEntity { + const baseId = `${platformArg}.${this.slug(nameArg)}`; + const current = usedIdsArg.get(baseId) || 0; + usedIdsArg.set(baseId, current + 1); + return { + id: current ? `${baseId}_${current + 1}` : baseId, + uniqueId: uniqueIdArg, + integrationDomain: bluetoothLeTrackerDomain, + deviceId: deviceIdArg, + platform: platformArg, + name: nameArg, + state: stateArg, + attributes: attributesArg, + available: availableArg, + }; + } + + private static deviceId(deviceArg: IBluetoothLeTrackedDevice): string { + return `${bluetoothLeTrackerDomain}.device.${this.slug(deviceArg.address || deviceArg.macAddress || deviceArg.mac || 'unknown')}`; + } + + private static deviceName(deviceArg: IBluetoothLeTrackedDevice): string { + return this.cleanName(deviceArg.name || deviceArg.hostName || deviceArg.hostname) || this.haMac(deviceArg.address) || deviceArg.address || 'Bluetooth LE device'; + } + + private static hasStaticData(configArg: IBluetoothLeTrackerConfig): boolean { + return Boolean(configArg.snapshot || configArg.advertisements?.length || configArg.devices?.length || configArg.knownDevices?.length || configArg.trackedDevices?.length || configArg.manualEntries?.length); + } + + private static booleanValue(...valuesArg: unknown[]): boolean | undefined { + for (const value of valuesArg) { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + if (['true', '1', 'yes', 'on'].includes(value.toLowerCase())) return true; + if (['false', '0', 'no', 'off'].includes(value.toLowerCase())) return false; + } + } + return undefined; + } + + private static numberValue(...valuesArg: unknown[]): number | undefined { + for (const value of valuesArg) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + } + return undefined; + } + + private static slug(valueArg: string): string { + return valueArg.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'bluetooth_le_tracker'; + } +} diff --git a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.types.ts b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.types.ts index 23fc122..162ffd5 100644 --- a/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.types.ts +++ b/ts/integrations/bluetooth_le_tracker/bluetooth_le_tracker.types.ts @@ -1,4 +1,160 @@ -export interface IHomeAssistantBluetoothLeTrackerConfig { - // TODO: replace with the TypeScript-native config for bluetooth_le_tracker. +export const bluetoothLeTrackerDomain = 'bluetooth_le_tracker'; +export const bluetoothLeTrackerBlePrefix = 'BLE_'; +export const bluetoothLeTrackerSourceType = 'bluetooth_le'; +export const bluetoothLeTrackerDefaultScanIntervalSeconds = 12; +export const bluetoothLeTrackerDefaultConsiderHomeSeconds = 180; +export const bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds = 86400; +export const bluetoothLeTrackerDefaultMinSeenNew = 5; + +export type TBluetoothLePresenceState = 'home' | 'not_home'; +export type TBluetoothLeTrackerCommandType = 'scan'; + +export type TBluetoothLeScanProvider = () => Promise< + | IBluetoothLeAdvertisement[] + | IBluetoothLeTrackerSnapshot + | { + snapshot?: IBluetoothLeTrackerSnapshot; + devices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; + } +>; + +export interface IBluetoothLeTrackerConfig { + scanIntervalSeconds?: number; + interval_seconds?: number; + trackNewDevices?: boolean; + track_new_devices?: boolean; + trackBattery?: boolean; + track_battery?: boolean; + trackBatteryIntervalSeconds?: number; + track_battery_interval?: number; + considerHomeSeconds?: number; + consider_home?: number; + minSeenNew?: number; + snapshot?: IBluetoothLeTrackerSnapshot; + knownDevices?: IBluetoothLeTrackedDevice[]; + trackedDevices?: IBluetoothLeTrackedDevice[]; + devices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; + manualEntries?: IBluetoothLeManualEntry[]; + events?: IBluetoothLeTrackerEvent[]; + scanProvider?: TBluetoothLeScanProvider; +} + +export interface IHomeAssistantBluetoothLeTrackerConfig extends IBluetoothLeTrackerConfig {} + +export interface IBluetoothLeTrackerScannerState { + scanIntervalSeconds: number; + trackNewDevices: boolean; + trackBattery: boolean; + trackBatteryIntervalSeconds: number; + considerHomeSeconds: number; + minSeenNew: number; +} + +export interface IBluetoothLeAdvertisement { + address?: string; + mac?: string; + macAddress?: string; + haMac?: string; + name?: string; + localName?: string; + hostName?: string; + rssi?: number; + txPower?: number; + connectable?: boolean; + serviceUuids?: string[]; + serviceUUIDs?: string[]; + serviceData?: Record; + manufacturerData?: Record; + battery?: number; + time?: number; + lastSeen?: number | string; + last_seen?: number | string; + source?: string; + sourceType?: string; + metadata?: Record; + raw?: Record; [key: string]: unknown; } + +export interface IBluetoothLeTrackedDevice { + address?: string; + mac?: string; + macAddress?: string; + haMac?: string; + name?: string; + hostName?: string; + hostname?: string; + manufacturer?: string; + model?: string; + track?: boolean; + tracked?: boolean; + trackBattery?: boolean; + track_battery?: boolean; + battery?: number; + rssi?: number; + txPower?: number; + connectable?: boolean; + serviceUuids?: string[]; + serviceUUIDs?: string[]; + serviceData?: Record; + manufacturerData?: Record; + lastSeen?: number | string; + last_seen?: number | string; + firstSeen?: number | string; + first_seen?: number | string; + advertisementCount?: number; + state?: TBluetoothLePresenceState | 'on' | 'off' | string; + sourceType?: string; + metadata?: Record; + raw?: Record; + [key: string]: unknown; +} + +export interface IBluetoothLeManualEntry extends IBluetoothLeTrackedDevice { + id?: string; + knownDevices?: IBluetoothLeTrackedDevice[]; + trackedDevices?: IBluetoothLeTrackedDevice[]; + devices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; + snapshot?: IBluetoothLeTrackerSnapshot; +} + +export interface IBluetoothLeDiscoveryRecord extends IBluetoothLeAdvertisement { + id?: string; +} + +export interface IBluetoothLeTrackerSnapshot { + connected: boolean; + scanner: IBluetoothLeTrackerScannerState; + devices: IBluetoothLeTrackedDevice[]; + advertisements: IBluetoothLeAdvertisement[]; + events: IBluetoothLeTrackerEvent[]; +} + +export interface IBluetoothLeTrackerCommand { + type: TBluetoothLeTrackerCommandType; + service: string; + target: { + entityId?: string; + deviceId?: string; + }; + snapshot?: IBluetoothLeTrackerSnapshot; + devices?: IBluetoothLeTrackedDevice[]; + advertisements?: IBluetoothLeAdvertisement[]; +} + +export interface IBluetoothLeTrackerCommandResult { + success: boolean; + error?: string; + data?: unknown; +} + +export interface IBluetoothLeTrackerEvent { + type?: 'scan_applied' | 'snapshot_updated' | 'unsupported_scan' | string; + deviceId?: string; + entityId?: string; + data?: unknown; + timestamp?: number; +} diff --git a/ts/integrations/bluetooth_le_tracker/index.ts b/ts/integrations/bluetooth_le_tracker/index.ts index 0b11de9..3fa369d 100644 --- a/ts/integrations/bluetooth_le_tracker/index.ts +++ b/ts/integrations/bluetooth_le_tracker/index.ts @@ -1,2 +1,6 @@ export * from './bluetooth_le_tracker.classes.integration.js'; +export * from './bluetooth_le_tracker.classes.client.js'; +export * from './bluetooth_le_tracker.classes.configflow.js'; +export * from './bluetooth_le_tracker.discovery.js'; +export * from './bluetooth_le_tracker.mapper.js'; export * from './bluetooth_le_tracker.types.js'; diff --git a/ts/integrations/generated/index.ts b/ts/integrations/generated/index.ts index 5307db3..e04fa88 100644 --- a/ts/integrations/generated/index.ts +++ b/ts/integrations/generated/index.ts @@ -11,7 +11,6 @@ import { HomeAssistantAcomaxIntegration } from '../acomax/index.js'; import { HomeAssistantActiontecIntegration } from '../actiontec/index.js'; import { HomeAssistantActronAirIntegration } from '../actron_air/index.js'; import { HomeAssistantAdaxIntegration } from '../adax/index.js'; -import { HomeAssistantAdguardIntegration } from '../adguard/index.js'; import { HomeAssistantAdsIntegration } from '../ads/index.js'; import { HomeAssistantAdvantageAirIntegration } from '../advantage_air/index.js'; import { HomeAssistantAemetIntegration } from '../aemet/index.js'; @@ -47,12 +46,10 @@ import { HomeAssistantAmazonPollyIntegration } from '../amazon_polly/index.js'; import { HomeAssistantAmberelectricIntegration } from '../amberelectric/index.js'; import { HomeAssistantAmbientNetworkIntegration } from '../ambient_network/index.js'; import { HomeAssistantAmbientStationIntegration } from '../ambient_station/index.js'; -import { HomeAssistantAmcrestIntegration } from '../amcrest/index.js'; import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/index.js'; import { HomeAssistantAmpioIntegration } from '../ampio/index.js'; import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js'; import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js'; -import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js'; import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js'; import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js'; import { HomeAssistantAnovaIntegration } from '../anova/index.js'; @@ -74,7 +71,6 @@ import { HomeAssistantAquacellIntegration } from '../aquacell/index.js'; import { HomeAssistantAqualogicIntegration } from '../aqualogic/index.js'; import { HomeAssistantAquostvIntegration } from '../aquostv/index.js'; import { HomeAssistantAranetIntegration } from '../aranet/index.js'; -import { HomeAssistantArcamFmjIntegration } from '../arcam_fmj/index.js'; import { HomeAssistantArestIntegration } from '../arest/index.js'; import { HomeAssistantArrisTg2492lgIntegration } from '../arris_tg2492lg/index.js'; import { HomeAssistantArtsoundIntegration } from '../artsound/index.js'; @@ -84,7 +80,6 @@ import { HomeAssistantArwnIntegration } from '../arwn/index.js'; import { HomeAssistantAsekoPoolLiveIntegration } from '../aseko_pool_live/index.js'; import { HomeAssistantAssistPipelineIntegration } from '../assist_pipeline/index.js'; import { HomeAssistantAssistSatelliteIntegration } from '../assist_satellite/index.js'; -import { HomeAssistantAsuswrtIntegration } from '../asuswrt/index.js'; import { HomeAssistantAtagIntegration } from '../atag/index.js'; import { HomeAssistantAtenPeIntegration } from '../aten_pe/index.js'; import { HomeAssistantAtlanticcityelectricIntegration } from '../atlanticcityelectric/index.js'; @@ -136,7 +131,6 @@ import { HomeAssistantBlueprintIntegration } from '../blueprint/index.js'; import { HomeAssistantBluesoundIntegration } from '../bluesound/index.js'; import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js'; import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/index.js'; -import { HomeAssistantBluetoothLeTrackerIntegration } from '../bluetooth_le_tracker/index.js'; import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js'; import { HomeAssistantBondIntegration } from '../bond/index.js'; import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js'; @@ -1425,7 +1419,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAcomaxIntegration() generatedHomeAssistantPortIntegrations.push(new HomeAssistantActiontecIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantActronAirIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdaxIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdguardIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdvantageAirIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAemetIntegration()); @@ -1461,12 +1454,10 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmazonPollyIntegrat generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmberelectricIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientNetworkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientStationIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmcrestIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnovaIntegration()); @@ -1488,7 +1479,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquacellIntegration generatedHomeAssistantPortIntegrations.push(new HomeAssistantAqualogicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquostvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAranetIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantArcamFmjIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantArestIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantArrisTg2492lgIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantArtsoundIntegration()); @@ -1498,7 +1488,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantArwnIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsekoPoolLiveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistPipelineIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistSatelliteIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsuswrtIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtagIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtenPeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtlanticcityelectricIntegration()); @@ -1550,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegratio generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration()); -generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothLeTrackerIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration()); @@ -2828,14 +2816,20 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); -export const generatedHomeAssistantPortCount = 1412; +export const generatedHomeAssistantPortCount = 1406; export const handwrittenHomeAssistantPortDomains = [ + "adguard", "airgradient", + "amcrest", "android_ip_webcam", "androidtv", + "androidtv_remote", "apcupsd", + "arcam_fmj", + "asuswrt", "axis", "blebox", + "bluetooth_le_tracker", "braviatv", "broadlink", "cast",