Add native local network integrations

This commit is contained in:
2026-05-05 18:45:46 +00:00
parent 282283d344
commit cfab8c593e
70 changed files with 9688 additions and 176 deletions
@@ -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();
+100
View File
@@ -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();
+86
View File
@@ -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<string, string> | undefined)?.authorization });
const path = new URL(String(urlArg)).pathname;
const responses: Record<string, unknown> = {
'/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();
+104
View File
@@ -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();
@@ -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();
+99
View File
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
+47
View File
@@ -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();
@@ -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();
+92
View File
@@ -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();
@@ -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();
@@ -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();
@@ -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();
@@ -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();
+12
View File
@@ -3,12 +3,18 @@ export * from './protocols/index.js';
export * from './integrations/index.js'; export * from './integrations/index.js';
import { HueIntegration } from './integrations/hue/index.js'; import { HueIntegration } from './integrations/hue/index.js';
import { AdguardIntegration } from './integrations/adguard/index.js';
import { AirgradientIntegration } from './integrations/airgradient/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 { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js';
import { AndroidtvIntegration } from './integrations/androidtv/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 { AxisIntegration } from './integrations/axis/index.js';
import { ApcupsdIntegration } from './integrations/apcupsd/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 { BleboxIntegration } from './integrations/blebox/index.js';
import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js';
import { BraviatvIntegration } from './integrations/braviatv/index.js'; import { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js'; import { BroadlinkIntegration } from './integrations/broadlink/index.js';
import { CastIntegration } from './integrations/cast/index.js'; import { CastIntegration } from './integrations/cast/index.js';
@@ -53,12 +59,18 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
import { IntegrationRegistry } from './core/index.js'; import { IntegrationRegistry } from './core/index.js';
export const integrations = [ export const integrations = [
new AdguardIntegration(),
new AirgradientIntegration(), new AirgradientIntegration(),
new AmcrestIntegration(),
new AndroidIpWebcamIntegration(), new AndroidIpWebcamIntegration(),
new AndroidtvIntegration(), new AndroidtvIntegration(),
new AndroidtvRemoteIntegration(),
new ApcupsdIntegration(), new ApcupsdIntegration(),
new ArcamFmjIntegration(),
new AsuswrtIntegration(),
new AxisIntegration(), new AxisIntegration(),
new BleboxIntegration(), new BleboxIntegration(),
new BluetoothLeTrackerIntegration(),
new BraviatvIntegration(), new BraviatvIntegration(),
new BroadlinkIntegration(), new BroadlinkIntegration(),
new CastIntegration(), new CastIntegration(),
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,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<IAdguardSnapshot> {
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<IAdguardSnapshot> {
return this.getSnapshot();
}
public async ping(): Promise<boolean> {
if (!this.config.host) {
return Boolean(this.config.snapshot);
}
const status = await this.requestJson<IAdguardServerStatus>('/status');
return status.running !== false;
}
public async sendCommand(commandArg: IAdguardClientCommand): Promise<IAdguardCommandResult> {
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<TAdguardJsonValue>(commandArg.path, {
method: commandArg.method,
body: commandArg.payload,
});
return { success: true, data: { command: commandArg, response: data } };
}
public async destroy(): Promise<void> {}
private async fetchSnapshot(): Promise<IAdguardSnapshot> {
const status = await this.requestJson<IAdguardServerStatus>('/status');
const [filtering, queryLog, safebrowsing, safesearch, parental, stats, update] = await Promise.all([
this.requestJson<IAdguardFilteringStatus>('/filtering/status').catch(() => ({})),
this.requestJson<IAdguardQueryLogConfig>('/querylog/config').catch(() => this.requestJson<IAdguardQueryLogConfig>('/querylog_info').catch(() => ({}))),
this.requestJson<IAdguardBooleanStatus>('/safebrowsing/status').catch(() => ({})),
this.requestJson<IAdguardSafeSearchConfig>('/safesearch/status').catch(() => ({})),
this.requestJson<IAdguardBooleanStatus>('/parental/status').catch(() => ({})),
this.requestJson<IAdguardStats>('/stats').catch(() => ({})),
this.requestJson<IAdguardVersionInfo>('/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<TResponse>(pathArg: string, optionsArg: { method?: 'GET' | 'POST' | 'PUT'; body?: unknown } = {}): Promise<TResponse> {
const method = optionsArg.method || 'GET';
const headers: Record<string, string> = {};
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;
}
}
@@ -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<IAdguardConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAdguardConfig>> {
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;
};
@@ -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 { export class AdguardIntegration extends BaseIntegration<IAdguardConfig> {
constructor() { public readonly domain = 'adguard';
super({ public readonly displayName = 'AdGuard Home';
domain: "adguard", public readonly status = 'control-runtime' as const;
displayName: "AdGuard Home", public readonly discoveryDescriptor = createAdguardDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AdguardConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/adguard", upstreamPath: 'homeassistant/components/adguard',
"upstreamDomain": "adguard", upstreamDomain: 'adguard',
"integrationType": "service", integrationType: 'service',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['adguardhome==0.8.1'],
"adguardhome==0.8.1" dependencies: [] as string[],
], afterDependencies: [] as string[],
"dependencies": [], codeowners: ['@frenck'],
"afterDependencies": [], documentation: 'https://www.home-assistant.io/integrations/adguard',
"codeowners": [ protocolSource: 'AdGuard Home HTTP API under /control: status, filtering, querylog, safebrowsing, safesearch, parental, stats, version, and update endpoints.',
"@frenck" 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<IIntegrationRuntime> {
void contextArg;
return new AdguardRuntime(new AdguardClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAdguardIntegration extends AdguardIntegration {}
class AdguardRuntime implements IIntegrationRuntime {
public domain = 'adguard';
constructor(private readonly client: AdguardClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AdguardMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AdguardMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<IAdguardManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string, unknown> }): 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;
};
+401
View File
@@ -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<string, unknown>, 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<string, unknown> {
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<string, unknown> {
return {
...configArg,
enabled: enabledArg,
};
}
private static entity(platformArg: IIntegrationEntity['platform'], nameArg: string, deviceIdArg: string, uniqueIdArg: string, stateArg: unknown, availableArg: boolean, attributesArg: Record<string, unknown> = {}): IIntegrationEntity {
return {
id: `${platformArg}.${this.slug(nameArg)}`,
uniqueId: `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<T extends Record<string, unknown>>(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';
};
+196 -2
View File
@@ -1,4 +1,198 @@
export interface IHomeAssistantAdguardConfig { import type { IServiceCallResult } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for adguard.
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<string, unknown>;
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> | 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<string, unknown>;
[key: string]: unknown; [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<string, unknown>;
integrationDomain?: string;
[key: string]: unknown;
}
export interface IHomeAssistantAdguardConfig extends IAdguardConfig {}
+4
View File
@@ -1,2 +1,6 @@
export * from './adguard.classes.integration.js'; 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'; export * from './adguard.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,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<TAmcrestPtzMovement, string> = {
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<IAmcrestSnapshot> {
if (!forceRefreshArg && this.snapshot) {
return this.snapshot;
}
if (!forceRefreshArg && this.config.snapshot) {
this.snapshot = this.normalizeSnapshot(this.cloneSnapshot(this.config.snapshot));
return this.snapshot;
}
if (this.hasLiveTarget()) {
try {
this.snapshot = await this.fetchLiveSnapshot();
return this.snapshot;
} catch (errorArg) {
this.snapshot = this.snapshotFromConfig(false, errorArg instanceof Error ? errorArg.message : String(errorArg));
return this.snapshot;
}
}
this.snapshot = this.snapshotFromConfig(this.config.connected ?? false);
return this.snapshot;
}
public async validateConnection(): Promise<void> {
await this.requestText('/cgi-bin/magicBox.cgi?action=getDeviceType');
}
public async execute(commandArg: IAmcrestClientCommand): Promise<unknown> {
if (commandArg.type === 'refresh') {
return this.getSnapshot(true);
}
if (commandArg.type === 'stream_source') {
const snapshot = await this.getSnapshot();
const camera = this.findCamera(snapshot, commandArg.cameraId);
return {
cameraId: camera.id,
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<IAmcrestSnapshotImage> {
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<void> {}
private async fetchLiveSnapshot(): Promise<IAmcrestSnapshot> {
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> = {}): 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> = {}): 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<string, boolean | undefined>, 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<string, unknown>, 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<string, boolean | undefined>): 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<IAmcrestCommandResponse> {
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<IAmcrestCommandResponse> {
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<IAmcrestCommandResponse> {
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<IAmcrestCommandResponse> {
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<IAmcrestCommandResponse[]> {
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<IAmcrestCommandResponse> {
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<IAmcrestCommandResponse> {
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<string> {
return (await this.request(pathArg)).text();
}
private async request(pathArg: string, initArg: RequestInit = {}, timeoutMsArg = this.config.timeoutMs || amcrestDefaultTimeoutMs): Promise<Response> {
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<Response> {
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<Response> {
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<string, string> = {
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<IAmcrestCamera>): 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<string>();
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<void> => new Promise((resolve) => setTimeout(resolve, msArg));
const parseDigestChallenge = (valueArg: string): Record<string, string> => {
const result: Record<string, string> = {};
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<string, string> => {
const values: Record<string, string> = {};
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<string, string>, 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]}`;
};
@@ -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<IAmcrestConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAmcrestConfig>> {
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;
}
};
@@ -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 { export class AmcrestIntegration extends BaseIntegration<IAmcrestConfig> {
constructor() { public readonly domain = 'amcrest';
super({ public readonly displayName = 'Amcrest';
domain: "amcrest", public readonly status = 'control-runtime' as const;
displayName: "Amcrest", public readonly discoveryDescriptor = createAmcrestDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AmcrestConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/amcrest", upstreamPath: 'homeassistant/components/amcrest',
"upstreamDomain": "amcrest", upstreamDomain: 'amcrest',
"iotClass": "local_polling", integrationType: 'device',
"qualityScale": "legacy", iotClass: 'local_polling',
"requirements": [ qualityScale: 'legacy',
"amcrest==1.9.9" requirements: ['amcrest==1.9.9'],
], dependencies: ['ffmpeg'],
"dependencies": [ afterDependencies: [],
"ffmpeg" codeowners: ['@flacjacket'],
], documentation: 'https://www.home-assistant.io/integrations/amcrest',
"afterDependencies": [], nativePort: {
"codeowners": [ manualLocalDiscovery: true,
"@flacjacket" snapshotMapping: true,
] liveHttpCgiCommands: true,
liveEvents: false,
rtspProxying: false,
ffmpegProxying: false,
}, },
}); };
public async setup(configArg: IAmcrestConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
void contextArg;
return new AmcrestRuntime(new AmcrestClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAmcrestIntegration extends AmcrestIntegration {}
class AmcrestRuntime implements IIntegrationRuntime {
public domain = 'amcrest';
constructor(private readonly client: AmcrestClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AmcrestMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AmcrestMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
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<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<IAmcrestManualEntry> {
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<IDiscoveryMatch> {
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<IAmcrestMdnsRecord> {
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<IDiscoveryMatch> {
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<IAmcrestSsdpRecord> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string | undefined>): boolean => {
const haystack = valuesArgs.filter(Boolean).join(' ').toLowerCase();
return haystack.includes('amcrest') || haystack.includes('dahua');
};
const valueForKey = (recordArg: Record<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const 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;
}
};
+445
View File
@@ -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<string, number>();
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<string, number>, attributesArg: Record<string, unknown>, availableArg: boolean): IIntegrationEntity {
const baseId = `${String(platformArg)}.${this.slug(nameArg) || this.slug(uniqueIdArg)}`;
const seen = usedIdsArg.get(baseId) || 0;
usedIdsArg.set(baseId, seen + 1);
return {
id: seen ? `${baseId}_${seen + 1}` : baseId,
uniqueId: uniqueIdArg,
integrationDomain: '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<string, unknown>): Record<string, unknown> {
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<string, unknown>;
}
return String(valueArg);
}
private static booleanFromData(dataArg: Record<string, unknown> | undefined): boolean | undefined {
for (const key of serviceBooleanKeys) {
const value = this.booleanValue(dataArg?.[key]);
if (value !== undefined) {
return value;
}
}
return undefined;
}
private static booleanValue(valueArg: unknown): boolean | undefined {
if (typeof valueArg === 'boolean') {
return valueArg;
}
if (typeof valueArg === 'string') {
if (['on', 'true', 'yes', '1'].includes(valueArg.toLowerCase())) {
return true;
}
if (['off', 'false', 'no', '0'].includes(valueArg.toLowerCase())) {
return false;
}
}
return undefined;
}
private static 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';
};
+302 -3
View File
@@ -1,4 +1,303 @@
export interface IHomeAssistantAmcrestConfig { export const amcrestDefaultPort = 80;
// TODO: replace with the TypeScript-native config for amcrest. export const amcrestDefaultRtspPort = 554;
[key: string]: unknown; 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<string, unknown>;
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<string, unknown>;
}
export interface IAmcrestSensor<TValue = unknown> {
key: string;
name: string;
value: TValue;
unit?: string;
deviceClass?: string;
entityCategory?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAmcrestBinarySensor {
key: string;
name: string;
isOn: boolean;
deviceClass?: string;
eventCodes?: string[];
shouldPoll?: boolean;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAmcrestSwitch {
key: string;
name: string;
isOn: boolean;
command: TAmcrestSwitchCommand;
entityCategory?: string;
available?: boolean;
attributes?: Record<string, unknown>;
}
export interface IAmcrestEvent {
id: string;
name?: string;
code?: string;
state?: string;
isOn?: boolean;
updatedAt?: string;
payload?: Record<string, unknown>;
}
export interface IAmcrestSnapshot {
deviceInfo: IAmcrestDeviceInfo;
cameras: IAmcrestCamera[];
sensors: IAmcrestSensor[];
binarySensors: IAmcrestBinarySensor[];
switches: IAmcrestSwitch[];
events: IAmcrestEvent[];
currentSettings: Record<string, unknown>;
connected: boolean;
updatedAt?: string;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
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<string, unknown>;
}
export interface IAmcrestMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
addresses?: string[];
hostname?: string;
txt?: Record<string, string | undefined>;
properties?: Record<string, string | undefined>;
}
export interface IAmcrestSsdpRecord {
manufacturer?: string;
server?: string;
st?: string;
usn?: string;
location?: string;
upnp?: Record<string, string | undefined>;
headers?: Record<string, string | undefined>;
}
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' },
];
+4
View File
@@ -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.classes.integration.js';
export * from './amcrest.discovery.js';
export * from './amcrest.mapper.js';
export * from './amcrest.types.js'; export * from './amcrest.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,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<IAndroidtvRemoteSnapshot> {
return this.normalizeSnapshot(this.snapshot || this.snapshotFromManualConfig());
}
public async connect(): Promise<void> {
await this.execute({ action: 'connect', reason: 'connect' });
}
public async startPairing(): Promise<void> {
await this.execute({ action: 'start_pairing', reason: 'start_pairing' });
}
public async finishPairing(pinArg: string): Promise<void> {
await this.execute({ action: 'finish_pairing', reason: 'finish_pairing', pin: pinArg });
}
public async turnOn(): Promise<void> {
await this.sendKeyCommand('POWER', 'SHORT', 'turn_on');
}
public async turnOff(): Promise<void> {
await this.sendKeyCommand('POWER', 'SHORT', 'turn_off');
}
public async volumeUp(): Promise<void> {
await this.sendKeyCommand('VOLUME_UP', 'SHORT', 'volume_up');
}
public async volumeDown(): Promise<void> {
await this.sendKeyCommand('VOLUME_DOWN', 'SHORT', 'volume_down');
}
public async muteVolume(mutedArg: boolean): Promise<void> {
await this.sendKeyCommand('VOLUME_MUTE', 'SHORT', 'volume_mute', { muted: mutedArg });
}
public async setVolumeLevel(volumeLevelArg: number): Promise<void> {
const volumeLevel = Math.max(0, Math.min(1, volumeLevelArg));
await this.execute({ action: 'volume_set', reason: 'volume_set', volumeLevel });
}
public async mediaPlay(): Promise<void> {
await this.sendKeyCommand('MEDIA_PLAY', 'SHORT', 'media_play');
}
public async mediaPause(): Promise<void> {
await this.sendKeyCommand('MEDIA_PAUSE', 'SHORT', 'media_pause');
}
public async mediaPlayPause(): Promise<void> {
await this.sendKeyCommand('MEDIA_PLAY_PAUSE', 'SHORT', 'media_play_pause');
}
public async mediaStop(): Promise<void> {
await this.sendKeyCommand('MEDIA_STOP', 'SHORT', 'media_stop');
}
public async mediaPreviousTrack(): Promise<void> {
await this.sendKeyCommand('MEDIA_PREVIOUS', 'SHORT', 'media_previous_track');
}
public async mediaNextTrack(): Promise<void> {
await this.sendKeyCommand('MEDIA_NEXT', 'SHORT', 'media_next_track');
}
public async playChannel(channelArg: string): Promise<void> {
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<void> {
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<void> {
const app = await this.appForActivity(activityArg);
await this.launchApp(app?.id || activityArg, 'select_activity');
}
public async sendText(textArg: string): Promise<void> {
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<IAndroidtvRemoteCommand> = {}
): Promise<void> {
await this.execute({
action: 'key_command',
reason: reasonArg,
keyCode: this.normalizeKeyCode(keyCodeArg),
direction: directionArg,
...extraArg,
});
}
public async sendCommand(
commandsArg: Array<TAndroidtvRemoteKeyCode | string>,
optionsArg: {
repeats?: number;
delaySecs?: number;
holdSecs?: number;
reason?: TAndroidtvRemoteCommandReason;
} = {}
): Promise<void> {
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<void> {}
private async execute(commandArg: IAndroidtvRemoteCommand): Promise<void> {
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<IAndroidtvRemoteApp | undefined> {
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,
};
}
}
@@ -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<IAndroidtvRemoteConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAndroidtvRemoteConfig>> {
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;
}
}
@@ -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 { export class AndroidtvRemoteIntegration extends BaseIntegration<IAndroidtvRemoteConfig> {
constructor() { public readonly domain = 'androidtv_remote';
super({ public readonly displayName = 'Android TV Remote';
domain: "androidtv_remote", public readonly status = 'control-runtime' as const;
displayName: "Android TV Remote", public readonly discoveryDescriptor = createAndroidtvRemoteDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AndroidtvRemoteConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/androidtv_remote", upstreamPath: 'homeassistant/components/androidtv_remote',
"upstreamDomain": "androidtv_remote", upstreamDomain: 'androidtv_remote',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "platinum", qualityScale: 'platinum',
"requirements": [ requirements: ['androidtvremote2==0.3.1'],
"androidtvremote2==0.3.1" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@tronikos', '@Drafteed'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/androidtv_remote',
"@tronikos", };
"@Drafteed"
] public async setup(configArg: IAndroidtvRemoteConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
}, void contextArg;
return new AndroidtvRemoteRuntime(new AndroidtvRemoteClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAndroidtvRemoteIntegration extends AndroidtvRemoteIntegration {}
class AndroidtvRemoteRuntime implements IIntegrationRuntime {
public domain = 'androidtv_remote';
constructor(private readonly client: AndroidtvRemoteClient) {}
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
return AndroidtvRemoteMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AndroidtvRemoteMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
}
private async callRemoteService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
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<IServiceCallResult> {
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;
} }
} }
@@ -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<string, string> = {
'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<string, TAndroidtvRemoteKeyCode> = {
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',
};
@@ -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<IAndroidtvRemoteMdnsRecord> {
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<IDiscoveryMatch> {
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<IAndroidtvRemoteManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
@@ -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<string>();
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';
}
}
@@ -1,4 +1,254 @@
export interface IHomeAssistantAndroidtvRemoteConfig { export type TAndroidtvRemoteMediaState =
// TODO: replace with the TypeScript-native config for androidtv_remote. | 'off'
[key: string]: unknown; | '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> | void)
| {
execute(commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext): Promise<void> | 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<string, IAndroidtvRemoteConfiguredApp>;
snapshot?: IAndroidtvRemoteSnapshot;
executor?: TAndroidtvRemoteCommandExecutor;
}
export interface IAndroidtvRemoteMdnsRecord {
type?: string;
name?: string;
host?: string;
port?: number;
txt?: Record<string, unknown>;
properties?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
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<string, unknown>;
}
export type TAndroidtvRemoteDiscoveryRecord = IAndroidtvRemoteMdnsRecord | IAndroidtvRemoteManualEntry;
export type IHomeAssistantAndroidtvRemoteConfig = IAndroidtvRemoteConfig;
@@ -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.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'; export * from './androidtv_remote.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,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<number, string>;
const directPowerWriteSupported = new Set<TArcamFmjApiModel>(['APISA_SERIES', 'APIPA_SERIES', 'APIST_SERIES']);
const directMuteWriteSupported = directPowerWriteSupported;
const directSourceWriteSupported = new Set<TArcamFmjApiModel>(['APISA_SERIES']);
const volumeStepSupported = new Set<TArcamFmjApiModel>(['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<TArcamFmjApiModel, Partial<Record<number, Record<string, number>>>> = {
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<TArcamFmjApiModel, Partial<Record<number, Record<string, number[]>>>> = {
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<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>> = {
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<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>> = {
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<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>> = {
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<number, string> = {
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<number, string> = {
0x00: 'DUAL_MONO',
0x01: 'MONO',
0x02: 'STEREO_ONLY',
0x08: 'STEREO_CENTER',
0x0e: 'STEREO_DOWNMIX',
0x20: 'UNKNOWN',
0x21: 'UNDETECTED',
};
const incomingAudioSampleRates: Record<number, number> = {
0x00: 32000,
0x01: 44100,
0x02: 48000,
0x03: 88200,
0x04: 96000,
0x05: 176400,
0x06: 192000,
};
const videoAspectRatios: Record<number, string> = {
0x00: 'UNDEFINED',
0x01: 'ASPECT_4_3',
0x02: 'ASPECT_16_9',
};
const videoColorspaces: Record<number, string> = {
0x00: 'NORMAL',
0x01: 'HDR10',
0x02: 'DOLBY_VISION',
0x03: 'HLG',
0x04: 'HDR10_PLUS',
};
export class ArcamFmjClient {
constructor(private readonly config: IArcamFmjConfig) {}
public async getSnapshot(): Promise<IArcamFmjSnapshot> {
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<IArcamFmjCommandResult> {
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<void> {}
private async getZoneState(zoneArg: number, deviceInfoArg: IArcamFmjDeviceInfo): Promise<IArcamFmjZoneState> {
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<number[] | undefined> {
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<IArcamFmjResponsePacket> {
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<void> {
await this.exchange(this.commandPacketBytes(commandArg), () => false, false);
}
private async requestAmx(): Promise<Record<string, string>> {
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<TParsedPacket> {
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<TParsedPacket>((resolve, reject) => {
let buffer: Buffer<ArrayBufferLike> = 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<TArcamFmjApiModel, Partial<Record<number, Record<'true' | 'false', number[]>>>>,
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<string, string>): 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<TValue>(valueArg: TValue): TValue {
return valueArg === undefined ? valueArg : JSON.parse(JSON.stringify(valueArg)) as TValue;
}
}
type TParsedPacket =
| { type: 'response'; packet: IArcamFmjResponsePacket }
| { type: 'amx'; values: Record<string, string> }
| { 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<string, string> => {
const text = bufferArg.toString('ascii');
if (!text.startsWith('AMXB')) {
return {};
}
const values: Record<string, string> = {};
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<string, string> = {
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('');
@@ -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<IArcamFmjConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IArcamFmjConfig>> {
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;
};
@@ -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 { export class ArcamFmjIntegration extends BaseIntegration<IArcamFmjConfig> {
constructor() { public readonly domain = 'arcam_fmj';
super({ public readonly displayName = 'Arcam FMJ Receivers';
domain: "arcam_fmj", public readonly status = 'control-runtime' as const;
displayName: "Arcam FMJ Receivers", public readonly discoveryDescriptor = createArcamFmjDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new ArcamFmjConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/arcam_fmj", upstreamPath: 'homeassistant/components/arcam_fmj',
"upstreamDomain": "arcam_fmj", upstreamDomain: 'arcam_fmj',
"integrationType": "device", integrationType: 'device',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['arcam-fmj==1.8.3'],
"arcam-fmj==1.8.3" dependencies: [],
], afterDependencies: [],
"dependencies": [], codeowners: ['@elupus'],
"afterDependencies": [], configFlow: true,
"codeowners": [ documentation: 'https://www.home-assistant.io/integrations/arcam_fmj',
"@elupus" };
]
}, public async setup(configArg: IArcamFmjConfig, contextArg: IIntegrationSetupContext): Promise<IIntegrationRuntime> {
}); void contextArg;
return new ArcamFmjRuntime(new ArcamFmjClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantArcamFmjIntegration extends ArcamFmjIntegration {}
class ArcamFmjRuntime implements IIntegrationRuntime {
public domain = 'arcam_fmj';
constructor(private readonly client: ArcamFmjClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return ArcamFmjMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return ArcamFmjMapper.toEntities(await this.client.getSnapshot());
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
}
private async commandFromService(requestArg: IServiceCallRequest): Promise<IArcamFmjCommandRequest | undefined> {
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<number> {
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;
};
@@ -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<IArcamFmjSsdpRecord> {
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<IDiscoveryMatch> {
const st = header(recordArg, 'st') || upnp(recordArg, 'deviceType');
const usn = header(recordArg, 'usn');
const location = header(recordArg, 'location');
const manufacturer = upnp(recordArg, 'manufacturer');
const model = upnp(recordArg, 'modelName') || upnp(recordArg, 'model');
const 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<IArcamFmjManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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<string, string | undefined> | undefined, keyArg: string): string | undefined => {
if (!recordArg) {
return undefined;
}
const lowerKey = keyArg.toLowerCase();
for (const [key, value] of Object.entries(recordArg)) {
if (key.toLowerCase() === lowerKey) {
return value;
}
}
return undefined;
};
const parseUrl = (valueArg: string | undefined): URL | undefined => {
if (!valueArg) {
return undefined;
}
try {
return new URL(valueArg);
} catch {
return undefined;
}
};
const 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];
};
@@ -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<IArcamFmjZoneState, 'zone'>): 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));
}
}
+206 -3
View File
@@ -1,4 +1,207 @@
export interface IHomeAssistantArcamFmjConfig { export const arcamFmjDefaultPort = 50000;
// TODO: replace with the TypeScript-native config for arcam_fmj.
[key: string]: unknown; 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<string, string>;
commandExecutor?: IArcamFmjCommandExecutor;
snapshot?: IArcamFmjSnapshot;
}
export interface IHomeAssistantArcamFmjConfig extends IArcamFmjConfig {}
export interface IArcamFmjCommandExecutor {
execute(commandArg: IArcamFmjModeledCommand): Promise<unknown>;
}
export interface IArcamFmjDeviceInfo {
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
revision?: string;
serialNumber?: string;
uniqueId?: string;
apiModel?: TArcamFmjApiModel;
amx?: Record<string, string>;
}
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<string, string | undefined>;
upnp?: Record<string, string | undefined>;
}
export interface IArcamFmjManualEntry {
host?: string;
port?: number;
id?: string;
name?: string;
manufacturer?: string;
model?: string;
serialNumber?: string;
metadata?: Record<string, unknown>;
} }
+4
View File
@@ -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.classes.integration.js';
export * from './arcam_fmj.discovery.js';
export * from './arcam_fmj.mapper.js';
export * from './arcam_fmj.types.js'; export * from './arcam_fmj.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,115 @@
import 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<TAsuswrtEventHandler>();
constructor(private readonly config: IAsuswrtConfig) {}
public async getSnapshot(): Promise<IAsuswrtSnapshot> {
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<IAsuswrtCommandResult> {
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<IAsuswrtCommandResult> {
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<void> {
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<T extends IAsuswrtSnapshot>(snapshotArg: T): T {
return JSON.parse(JSON.stringify(snapshotArg)) as T;
}
}
@@ -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<IAsuswrtConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IAsuswrtConfig>> {
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<string, unknown>): Promise<IConfigFlowStep<IAsuswrtConfig>> {
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;
}
}
@@ -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 { export class AsuswrtIntegration extends BaseIntegration<IAsuswrtConfig> {
constructor() { public readonly domain = asuswrtDomain;
super({ public readonly displayName = 'ASUSWRT';
domain: "asuswrt", public readonly status = 'control-runtime' as const;
displayName: "ASUSWRT", public readonly discoveryDescriptor = createAsuswrtDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new AsuswrtConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/asuswrt", upstreamPath: 'homeassistant/components/asuswrt',
"upstreamDomain": "asuswrt", upstreamDomain: asuswrtDomain,
"integrationType": "hub", integrationType: 'hub',
"iotClass": "local_polling", iotClass: 'local_polling',
"requirements": [ requirements: ['aioasuswrt==1.5.4', 'asusrouter==1.21.3'],
"aioasuswrt==1.5.4", dependencies: [] as string[],
"asusrouter==1.21.3" afterDependencies: [] as string[],
], codeowners: ['@kennedyshead', '@ollo69', '@Vaskivskyi'],
"dependencies": [], documentation: 'https://www.home-assistant.io/integrations/asuswrt',
"afterDependencies": [], configFlow: true,
"codeowners": [ runtime: {
"@kennedyshead", mode: 'native TypeScript snapshot/manual router mapping',
"@ollo69", platforms: ['sensor', 'binary_sensor', 'button'],
"@Vaskivskyi" 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<IIntegrationRuntime> {
void contextArg;
return new AsuswrtRuntime(new AsuswrtClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantAsuswrtIntegration extends AsuswrtIntegration {}
class AsuswrtRuntime implements IIntegrationRuntime {
public domain = asuswrtDomain;
constructor(private readonly client: AsuswrtClient) {}
public async devices(): Promise<shxInterfaces.data.IDeviceDefinition[]> {
return AsuswrtMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return AsuswrtMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg(AsuswrtMapper.toIntegrationEvent(eventArg)));
await this.client.getSnapshot();
return async () => unsubscribe();
}
public async callService(requestArg: IServiceCallRequest): Promise<IServiceCallResult> {
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<void> {
await this.client.destroy();
} }
} }
@@ -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<IAsuswrtManualDiscoveryRecord> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
+652
View File
@@ -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<string, number>();
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<IAsuswrtSensorMap | undefined>, 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<string, number>): 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<string, unknown>]> = [
['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<string, number>): 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<string, number>): 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<string, IAsuswrtClientDevice>();
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<string, IAsuswrtInterfaceStats>();
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<string, IAsuswrtActionDescriptor>();
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<string, number>, attributesArg: Record<string, unknown> = {}, 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<string, unknown>;
}
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<string, unknown>): Record<string, unknown> {
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);
}
}
+229 -2
View File
@@ -1,4 +1,231 @@
export interface IHomeAssistantAsuswrtConfig { import type { IServiceCallResult } from '../../core/types.js';
// TODO: replace with the TypeScript-native config for asuswrt.
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<string, unknown>;
[key: string]: unknown; [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<string, unknown>;
}
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<string, unknown>;
[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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
}
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<string, unknown>;
[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<string, unknown>;
}
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> | IAsuswrtSnapshot;
executeCommand?(commandArg: IAsuswrtCommand): Promise<IAsuswrtCommandResult | unknown> | IAsuswrtCommandResult | unknown;
destroy?(): Promise<void> | void;
}
export type TAsuswrtSnapshotProvider = () => Promise<IAsuswrtSnapshot | undefined> | IAsuswrtSnapshot | undefined;
export type TAsuswrtCommandExecutor = (
commandArg: IAsuswrtCommand
) => Promise<IAsuswrtCommandResult | unknown> | IAsuswrtCommandResult | unknown;
+4
View File
@@ -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.classes.integration.js';
export * from './asuswrt.discovery.js';
export * from './asuswrt.mapper.js';
export * from './asuswrt.types.js'; export * from './asuswrt.types.js';
@@ -1 +0,0 @@
This folder is generated from Home Assistant component metadata. Replace it with a handwritten TypeScript port when implementing runtime support.
@@ -0,0 +1,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<TBluetoothLeTrackerEventHandler>();
constructor(private readonly config: IBluetoothLeTrackerConfig) {}
public async getSnapshot(): Promise<IBluetoothLeTrackerSnapshot> {
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<IBluetoothLeTrackerCommandResult> {
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<void> {
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<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -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<IBluetoothLeTrackerConfig> {
public async start(candidateArg: IDiscoveryCandidate, contextArg: IConfigFlowContext): Promise<IConfigFlowStep<IBluetoothLeTrackerConfig>> {
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<IBluetoothLeTrackedDevice>(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<IBluetoothLeAdvertisement>(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<IBluetoothLeTrackedDevice>(metadata.knownDevices) || this.arrayCandidateValue<IBluetoothLeTrackedDevice>(metadata.trackedDevices) || this.arrayCandidateValue<IBluetoothLeTrackedDevice>(metadata.devices),
advertisements: this.arrayCandidateValue<IBluetoothLeAdvertisement>(metadata.advertisements) || this.arrayCandidateValue<IBluetoothLeAdvertisement>(metadata.advertisement ? [metadata.advertisement] : undefined),
snapshot: this.isSnapshot(metadata.snapshot) ? metadata.snapshot : undefined,
};
}
private arrayValue<TValue>(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<TValue>(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<string, unknown> {
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
}
}
@@ -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 { export class BluetoothLeTrackerIntegration extends BaseIntegration<IBluetoothLeTrackerConfig> {
constructor() { public readonly domain = bluetoothLeTrackerDomain;
super({ public readonly displayName = 'Bluetooth LE Tracker';
domain: "bluetooth_le_tracker", public readonly status = 'read-only-runtime' as const;
displayName: "Bluetooth LE Tracker", public readonly discoveryDescriptor = createBluetoothLeTrackerDiscoveryDescriptor();
status: 'descriptor-only', public readonly configFlow = new BluetoothLeTrackerConfigFlow();
metadata: { public readonly metadata = {
"source": "home-assistant/core", source: 'home-assistant/core',
"upstreamPath": "homeassistant/components/bluetooth_le_tracker", upstreamPath: 'homeassistant/components/bluetooth_le_tracker',
"upstreamDomain": "bluetooth_le_tracker", upstreamDomain: bluetoothLeTrackerDomain,
"iotClass": "local_push", iotClass: 'local_push',
"qualityScale": "legacy", qualityScale: 'legacy',
"requirements": [], requirements: [] as string[],
"dependencies": [ dependencies: ['bluetooth_adapters'],
"bluetooth_adapters" afterDependencies: [] as string[],
], codeowners: [] as string[],
"afterDependencies": [], documentation: 'https://www.home-assistant.io/integrations/bluetooth_le_tracker',
"codeowners": [] 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<IIntegrationRuntime> {
void contextArg;
return new BluetoothLeTrackerRuntime(new BluetoothLeTrackerClient(configArg));
}
public async destroy(): Promise<void> {}
}
export class HomeAssistantBluetoothLeTrackerIntegration extends BluetoothLeTrackerIntegration {}
class BluetoothLeTrackerRuntime implements IIntegrationRuntime {
public domain = bluetoothLeTrackerDomain;
constructor(private readonly client: BluetoothLeTrackerClient) {}
public async devices(): Promise<plugins.shxInterfaces.data.IDeviceDefinition[]> {
return BluetoothLeTrackerMapper.toDevices(await this.client.getSnapshot());
}
public async entities(): Promise<IIntegrationEntity[]> {
return BluetoothLeTrackerMapper.toEntities(await this.client.getSnapshot());
}
public async subscribe(handlerArg: (eventArg: IIntegrationEvent) => void): Promise<() => Promise<void>> {
const unsubscribe = this.client.onEvent((eventArg) => handlerArg({
type: eventArg.type === '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<IServiceCallResult> {
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<void> {
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<IBluetoothLeTrackedDevice>(requestArg.data?.devices || requestArg.data?.trackedDevices || requestArg.data?.knownDevices),
advertisements: this.arrayValue<IBluetoothLeAdvertisement>(requestArg.data?.advertisements || requestArg.data?.advertisement),
};
}
private arrayValue<TValue>(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;
} }
} }
@@ -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<IBluetoothLeDiscoveryRecord> {
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<IDiscoveryMatch> {
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<IBluetoothLeManualEntry> {
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<IDiscoveryMatch> {
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<IDiscoveryMatch> {
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());
};
@@ -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<string, IBluetoothLeTrackedDevice>();
const blockedDevices = new Set<string>();
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<string, number>();
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<string, number>();
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>): 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<string, number>, attributesArg?: Record<string, unknown>, 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';
}
}
@@ -1,4 +1,160 @@
export interface IHomeAssistantBluetoothLeTrackerConfig { export const bluetoothLeTrackerDomain = 'bluetooth_le_tracker';
// TODO: replace with the TypeScript-native config for 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<string, unknown>;
manufacturerData?: Record<string, unknown>;
battery?: number;
time?: number;
lastSeen?: number | string;
last_seen?: number | string;
source?: string;
sourceType?: string;
metadata?: Record<string, unknown>;
raw?: Record<string, unknown>;
[key: string]: unknown; [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<string, unknown>;
manufacturerData?: Record<string, unknown>;
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<string, unknown>;
raw?: Record<string, unknown>;
[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;
}
@@ -1,2 +1,6 @@
export * from './bluetooth_le_tracker.classes.integration.js'; 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'; export * from './bluetooth_le_tracker.types.js';
+7 -13
View File
@@ -11,7 +11,6 @@ import { HomeAssistantAcomaxIntegration } from '../acomax/index.js';
import { HomeAssistantActiontecIntegration } from '../actiontec/index.js'; import { HomeAssistantActiontecIntegration } from '../actiontec/index.js';
import { HomeAssistantActronAirIntegration } from '../actron_air/index.js'; import { HomeAssistantActronAirIntegration } from '../actron_air/index.js';
import { HomeAssistantAdaxIntegration } from '../adax/index.js'; import { HomeAssistantAdaxIntegration } from '../adax/index.js';
import { HomeAssistantAdguardIntegration } from '../adguard/index.js';
import { HomeAssistantAdsIntegration } from '../ads/index.js'; import { HomeAssistantAdsIntegration } from '../ads/index.js';
import { HomeAssistantAdvantageAirIntegration } from '../advantage_air/index.js'; import { HomeAssistantAdvantageAirIntegration } from '../advantage_air/index.js';
import { HomeAssistantAemetIntegration } from '../aemet/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 { HomeAssistantAmberelectricIntegration } from '../amberelectric/index.js';
import { HomeAssistantAmbientNetworkIntegration } from '../ambient_network/index.js'; import { HomeAssistantAmbientNetworkIntegration } from '../ambient_network/index.js';
import { HomeAssistantAmbientStationIntegration } from '../ambient_station/index.js'; import { HomeAssistantAmbientStationIntegration } from '../ambient_station/index.js';
import { HomeAssistantAmcrestIntegration } from '../amcrest/index.js';
import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/index.js'; import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/index.js';
import { HomeAssistantAmpioIntegration } from '../ampio/index.js'; import { HomeAssistantAmpioIntegration } from '../ampio/index.js';
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js'; import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js'; import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js';
import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js'; import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js'; import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js';
import { HomeAssistantAnovaIntegration } from '../anova/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 { HomeAssistantAqualogicIntegration } from '../aqualogic/index.js';
import { HomeAssistantAquostvIntegration } from '../aquostv/index.js'; import { HomeAssistantAquostvIntegration } from '../aquostv/index.js';
import { HomeAssistantAranetIntegration } from '../aranet/index.js'; import { HomeAssistantAranetIntegration } from '../aranet/index.js';
import { HomeAssistantArcamFmjIntegration } from '../arcam_fmj/index.js';
import { HomeAssistantArestIntegration } from '../arest/index.js'; import { HomeAssistantArestIntegration } from '../arest/index.js';
import { HomeAssistantArrisTg2492lgIntegration } from '../arris_tg2492lg/index.js'; import { HomeAssistantArrisTg2492lgIntegration } from '../arris_tg2492lg/index.js';
import { HomeAssistantArtsoundIntegration } from '../artsound/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 { HomeAssistantAsekoPoolLiveIntegration } from '../aseko_pool_live/index.js';
import { HomeAssistantAssistPipelineIntegration } from '../assist_pipeline/index.js'; import { HomeAssistantAssistPipelineIntegration } from '../assist_pipeline/index.js';
import { HomeAssistantAssistSatelliteIntegration } from '../assist_satellite/index.js'; import { HomeAssistantAssistSatelliteIntegration } from '../assist_satellite/index.js';
import { HomeAssistantAsuswrtIntegration } from '../asuswrt/index.js';
import { HomeAssistantAtagIntegration } from '../atag/index.js'; import { HomeAssistantAtagIntegration } from '../atag/index.js';
import { HomeAssistantAtenPeIntegration } from '../aten_pe/index.js'; import { HomeAssistantAtenPeIntegration } from '../aten_pe/index.js';
import { HomeAssistantAtlanticcityelectricIntegration } from '../atlanticcityelectric/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 { HomeAssistantBluesoundIntegration } from '../bluesound/index.js';
import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js'; import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js';
import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/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 { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
import { HomeAssistantBondIntegration } from '../bond/index.js'; import { HomeAssistantBondIntegration } from '../bond/index.js';
import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js'; import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js';
@@ -1425,7 +1419,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAcomaxIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantActiontecIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantActiontecIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantActronAirIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantActronAirIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdaxIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdaxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdguardIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdvantageAirIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdvantageAirIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAemetIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAemetIntegration());
@@ -1461,12 +1454,10 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmazonPollyIntegrat
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmberelectricIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmberelectricIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientNetworkIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientNetworkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientStationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientStationIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmcrestIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnovaIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnovaIntegration());
@@ -1488,7 +1479,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquacellIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAqualogicIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAqualogicIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquostvIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquostvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAranetIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAranetIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArcamFmjIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArestIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantArestIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArrisTg2492lgIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantArrisTg2492lgIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArtsoundIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantArtsoundIntegration());
@@ -1498,7 +1488,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantArwnIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsekoPoolLiveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsekoPoolLiveIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistPipelineIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistPipelineIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistSatelliteIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistSatelliteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsuswrtIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtagIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtagIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtenPeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtenPeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtlanticcityelectricIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtlanticcityelectricIntegration());
@@ -1550,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothLeTrackerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration());
@@ -2828,14 +2816,20 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration()); generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1412; export const generatedHomeAssistantPortCount = 1406;
export const handwrittenHomeAssistantPortDomains = [ export const handwrittenHomeAssistantPortDomains = [
"adguard",
"airgradient", "airgradient",
"amcrest",
"android_ip_webcam", "android_ip_webcam",
"androidtv", "androidtv",
"androidtv_remote",
"apcupsd", "apcupsd",
"arcam_fmj",
"asuswrt",
"axis", "axis",
"blebox", "blebox",
"bluetooth_le_tracker",
"braviatv", "braviatv",
"broadlink", "broadlink",
"cast", "cast",