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';
import { HueIntegration } from './integrations/hue/index.js';
import { AdguardIntegration } from './integrations/adguard/index.js';
import { AirgradientIntegration } from './integrations/airgradient/index.js';
import { AmcrestIntegration } from './integrations/amcrest/index.js';
import { AndroidIpWebcamIntegration } from './integrations/android_ip_webcam/index.js';
import { AndroidtvIntegration } from './integrations/androidtv/index.js';
import { AndroidtvRemoteIntegration } from './integrations/androidtv_remote/index.js';
import { AxisIntegration } from './integrations/axis/index.js';
import { ApcupsdIntegration } from './integrations/apcupsd/index.js';
import { ArcamFmjIntegration } from './integrations/arcam_fmj/index.js';
import { AsuswrtIntegration } from './integrations/asuswrt/index.js';
import { BleboxIntegration } from './integrations/blebox/index.js';
import { BluetoothLeTrackerIntegration } from './integrations/bluetooth_le_tracker/index.js';
import { BraviatvIntegration } from './integrations/braviatv/index.js';
import { BroadlinkIntegration } from './integrations/broadlink/index.js';
import { CastIntegration } from './integrations/cast/index.js';
@@ -53,12 +59,18 @@ import { generatedHomeAssistantPortIntegrations } from './integrations/generated
import { IntegrationRegistry } from './core/index.js';
export const integrations = [
new AdguardIntegration(),
new AirgradientIntegration(),
new AmcrestIntegration(),
new AndroidIpWebcamIntegration(),
new AndroidtvIntegration(),
new AndroidtvRemoteIntegration(),
new ApcupsdIntegration(),
new ArcamFmjIntegration(),
new AsuswrtIntegration(),
new AxisIntegration(),
new BleboxIntegration(),
new BluetoothLeTrackerIntegration(),
new BraviatvIntegration(),
new BroadlinkIntegration(),
new CastIntegration(),
@@ -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 {
constructor() {
super({
domain: "adguard",
displayName: "AdGuard Home",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/adguard",
"upstreamDomain": "adguard",
"integrationType": "service",
"iotClass": "local_polling",
"requirements": [
"adguardhome==0.8.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@frenck"
]
export class AdguardIntegration extends BaseIntegration<IAdguardConfig> {
public readonly domain = 'adguard';
public readonly displayName = 'AdGuard Home';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAdguardDiscoveryDescriptor();
public readonly configFlow = new AdguardConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/adguard',
upstreamDomain: 'adguard',
integrationType: 'service',
iotClass: 'local_polling',
requirements: ['adguardhome==0.8.1'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@frenck'],
documentation: 'https://www.home-assistant.io/integrations/adguard',
protocolSource: 'AdGuard Home HTTP API under /control: status, filtering, querylog, safebrowsing, safesearch, parental, stats, version, and update endpoints.',
runtime: {
type: 'control-runtime',
polling: 'local HTTP AdGuard Home API',
services: ['snapshot', 'status', 'add_url', 'remove_url', 'enable_url', 'disable_url', 'refresh'],
controls: ['protection', 'filtering', 'querylog', 'safebrowsing', 'safesearch', 'parental'],
liveCommandSuccessRequiresClientOrExecutor: true,
},
});
};
public async setup(configArg: IAdguardConfig, contextArg: IIntegrationSetupContext): Promise<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 {
// TODO: replace with the TypeScript-native config for adguard.
import type { IServiceCallResult } from '../../core/types.js';
export const adguardDefaultPort = 3000;
export const adguardDefaultTimeoutMs = 10000;
export type TAdguardJsonValue = string | number | boolean | null | TAdguardJsonValue[] | {
[key: string]: TAdguardJsonValue | undefined;
};
export type TAdguardSnapshotSource = 'snapshot' | 'http' | 'manual' | 'runtime';
export type TAdguardHttpMethod = 'GET' | 'POST' | 'PUT';
export type TAdguardSwitchKey = 'protection' | 'filtering' | 'querylog' | 'safebrowsing' | 'safesearch' | 'parental';
export type TAdguardCommandType =
| 'set_protection'
| 'set_filtering'
| 'set_querylog'
| 'set_safebrowsing'
| 'set_safesearch'
| 'set_parental'
| 'add_filter_url'
| 'remove_filter_url'
| 'set_filter_url_enabled'
| 'refresh_filters'
| 'begin_update';
export interface IAdguardServerStatus {
dns_addresses?: string[];
dns_port?: number;
http_port?: number;
protection_enabled?: boolean;
protection_disabled_until?: string | null;
protection_disabled_duration?: number;
dhcp_available?: boolean;
running?: boolean;
version?: string;
language?: string;
start_time?: number;
[key: string]: TAdguardJsonValue | undefined;
}
export interface IAdguardFilterSubscription {
enabled?: boolean;
id?: number;
name?: string;
rules_count?: number;
url?: string;
last_updated?: string;
[key: string]: TAdguardJsonValue | undefined;
}
export interface IAdguardFilteringStatus {
enabled?: boolean;
interval?: number;
filters?: IAdguardFilterSubscription[];
whitelist_filters?: IAdguardFilterSubscription[];
user_rules?: string[];
[key: string]: TAdguardJsonValue | IAdguardFilterSubscription[] | string[] | undefined;
}
export interface IAdguardQueryLogConfig {
enabled?: boolean;
interval?: number;
anonymize_client_ip?: boolean;
ignored?: string[];
ignored_enabled?: boolean;
[key: string]: TAdguardJsonValue | string[] | undefined;
}
export interface IAdguardBooleanStatus {
enabled?: boolean;
enable?: boolean;
sensitivity?: number;
[key: string]: TAdguardJsonValue | undefined;
}
export interface IAdguardSafeSearchConfig {
enabled?: boolean;
bing?: boolean;
duckduckgo?: boolean;
ecosia?: boolean;
google?: boolean;
pixabay?: boolean;
yandex?: boolean;
youtube?: boolean;
[key: string]: TAdguardJsonValue | undefined;
}
export interface IAdguardStats {
time_units?: 'hours' | 'days' | string;
num_dns_queries?: number;
num_blocked_filtering?: number;
num_replaced_safebrowsing?: number;
num_replaced_safesearch?: number;
num_replaced_parental?: number;
avg_processing_time?: number;
dns_queries?: number[];
blocked_filtering?: number[];
replaced_safebrowsing?: number[];
replaced_parental?: number[];
replaced_safesearch?: number[];
[key: string]: TAdguardJsonValue | number[] | undefined;
}
export interface IAdguardVersionInfo {
disabled?: boolean;
new_version?: string;
announcement?: string;
announcement_url?: string;
can_autoupdate?: boolean;
[key: string]: TAdguardJsonValue | undefined;
}
export interface IAdguardSnapshot {
online: boolean;
status: IAdguardServerStatus;
filtering: IAdguardFilteringStatus;
queryLog: IAdguardQueryLogConfig;
safebrowsing: IAdguardBooleanStatus;
safesearch: IAdguardSafeSearchConfig;
parental: IAdguardBooleanStatus;
stats: IAdguardStats;
update?: IAdguardVersionInfo;
host?: string;
port?: number;
ssl?: boolean;
basePath?: string;
name?: string;
uniqueId?: string;
updatedAt?: string;
source?: TAdguardSnapshotSource;
error?: string;
}
export interface IAdguardClientCommand {
type: TAdguardCommandType;
service: string;
method: TAdguardHttpMethod;
path: string;
payload?: Record<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;
}
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.client.js';
export * from './adguard.classes.configflow.js';
export * from './adguard.discovery.js';
export * from './adguard.mapper.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 {
constructor() {
super({
domain: "amcrest",
displayName: "Amcrest",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/amcrest",
"upstreamDomain": "amcrest",
"iotClass": "local_polling",
"qualityScale": "legacy",
"requirements": [
"amcrest==1.9.9"
],
"dependencies": [
"ffmpeg"
],
"afterDependencies": [],
"codeowners": [
"@flacjacket"
]
export class AmcrestIntegration extends BaseIntegration<IAmcrestConfig> {
public readonly domain = 'amcrest';
public readonly displayName = 'Amcrest';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAmcrestDiscoveryDescriptor();
public readonly configFlow = new AmcrestConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/amcrest',
upstreamDomain: 'amcrest',
integrationType: 'device',
iotClass: 'local_polling',
qualityScale: 'legacy',
requirements: ['amcrest==1.9.9'],
dependencies: ['ffmpeg'],
afterDependencies: [],
codeowners: ['@flacjacket'],
documentation: 'https://www.home-assistant.io/integrations/amcrest',
nativePort: {
manualLocalDiscovery: true,
snapshotMapping: true,
liveHttpCgiCommands: true,
liveEvents: false,
rtspProxying: false,
ffmpegProxying: false,
},
});
};
public async setup(configArg: IAmcrestConfig, contextArg: IIntegrationSetupContext): Promise<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 {
// TODO: replace with the TypeScript-native config for amcrest.
[key: string]: unknown;
export const amcrestDefaultPort = 80;
export const amcrestDefaultRtspPort = 554;
export const amcrestDefaultTimeoutMs = 10000;
export const amcrestDefaultSnapshotTimeoutMs = 20000;
export const amcrestResolutionSubtype = {
high: 0,
low: 1,
} as const;
export const amcrestSubtypeStream = {
0: 'Main',
1: 'Extra',
} as const;
export const amcrestColorModes = ['color', 'auto', 'bw'] as const;
export const amcrestStreamSources = ['snapshot', 'mjpeg', 'rtsp'] as const;
export const amcrestPtzMovements = [
'zoom_out',
'zoom_in',
'right',
'left',
'up',
'down',
'right_down',
'right_up',
'left_down',
'left_up',
] as const;
export type TAmcrestProtocol = 'http' | 'https';
export type TAmcrestAuthScheme = 'auto' | 'basic' | 'digest';
export type TAmcrestResolution = keyof typeof amcrestResolutionSubtype;
export type TAmcrestStreamSource = typeof amcrestStreamSources[number];
export type TAmcrestColorBw = typeof amcrestColorModes[number];
export type TAmcrestPtzMovement = typeof amcrestPtzMovements[number];
export type TAmcrestSwitchCommand = 'privacy_mode';
export type TAmcrestHttpMethod = 'GET';
export type TAmcrestCommandType =
| 'refresh'
| 'stream_source'
| 'snapshot_image'
| 'set_privacy_mode'
| 'set_video'
| 'set_recording'
| 'set_audio'
| 'set_motion_detection'
| 'set_motion_recording'
| 'set_color_bw'
| 'goto_preset'
| 'ptz_control'
| 'start_tour'
| 'stop_tour';
export interface IAmcrestConfig {
protocol?: TAmcrestProtocol;
host?: string;
port?: number;
url?: string;
rtspPort?: number;
username?: string;
password?: string;
authScheme?: TAmcrestAuthScheme;
timeoutMs?: number;
snapshotTimeoutMs?: number;
name?: string;
uniqueId?: string;
manufacturer?: string;
model?: string;
channel?: number;
resolution?: TAmcrestResolution;
streamSource?: TAmcrestStreamSource;
controlLight?: boolean;
ffmpegArguments?: string;
supportsPtz?: boolean;
connected?: boolean;
deviceInfo?: IAmcrestDeviceInfo;
cameras?: IAmcrestCamera[];
sensors?: IAmcrestSensor[];
binarySensors?: IAmcrestBinarySensor[];
switches?: IAmcrestSwitch[];
events?: IAmcrestEvent[];
enabledSensors?: string[];
enabledBinarySensors?: string[];
enabledSwitches?: string[];
currentSettings?: Record<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.discovery.js';
export * from './amcrest.mapper.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 {
constructor() {
super({
domain: "androidtv_remote",
displayName: "Android TV Remote",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/androidtv_remote",
"upstreamDomain": "androidtv_remote",
"integrationType": "device",
"iotClass": "local_push",
"qualityScale": "platinum",
"requirements": [
"androidtvremote2==0.3.1"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@tronikos",
"@Drafteed"
]
},
export class AndroidtvRemoteIntegration extends BaseIntegration<IAndroidtvRemoteConfig> {
public readonly domain = 'androidtv_remote';
public readonly displayName = 'Android TV Remote';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAndroidtvRemoteDiscoveryDescriptor();
public readonly configFlow = new AndroidtvRemoteConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/androidtv_remote',
upstreamDomain: 'androidtv_remote',
integrationType: 'device',
iotClass: 'local_push',
qualityScale: 'platinum',
requirements: ['androidtvremote2==0.3.1'],
dependencies: [],
afterDependencies: [],
codeowners: ['@tronikos', '@Drafteed'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/androidtv_remote',
};
public async setup(configArg: IAndroidtvRemoteConfig, contextArg: IIntegrationSetupContext): Promise<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 {
// TODO: replace with the TypeScript-native config for androidtv_remote.
[key: string]: unknown;
export type TAndroidtvRemoteMediaState =
| 'off'
| 'on'
| 'idle'
| 'playing'
| 'paused'
| 'stopped'
| 'buffering'
| 'unknown';
export type TAndroidtvRemotePowerState = 'on' | 'off' | 'unknown';
export type TAndroidtvRemoteCommandDirection = 'SHORT' | 'START_LONG' | 'END_LONG';
export type TAndroidtvRemoteKeyCode =
| '0'
| '1'
| '2'
| '3'
| '4'
| '5'
| '6'
| '7'
| '8'
| '9'
| 'ASSIST'
| 'BACK'
| 'BUTTON_A'
| 'BUTTON_B'
| 'BUTTON_MODE'
| 'BUTTON_X'
| 'BUTTON_Y'
| 'CAPTIONS'
| 'CHANNEL_DOWN'
| 'CHANNEL_UP'
| 'DEL'
| 'DPAD_CENTER'
| 'DPAD_DOWN'
| 'DPAD_LEFT'
| 'DPAD_RIGHT'
| 'DPAD_UP'
| 'DVR'
| 'ENTER'
| 'EXPLORER'
| 'F1'
| 'F2'
| 'F3'
| 'F4'
| 'F5'
| 'F6'
| 'F7'
| 'F8'
| 'F9'
| 'F10'
| 'F11'
| 'F12'
| 'GUIDE'
| 'HOME'
| 'INFO'
| 'MEDIA_AUDIO_TRACK'
| 'MEDIA_FAST_FORWARD'
| 'MEDIA_NEXT'
| 'MEDIA_PAUSE'
| 'MEDIA_PLAY'
| 'MEDIA_PLAY_PAUSE'
| 'MEDIA_PREVIOUS'
| 'MEDIA_RECORD'
| 'MEDIA_REWIND'
| 'MEDIA_STOP'
| 'MENU'
| 'MUTE'
| 'POWER'
| 'PROG_BLUE'
| 'PROG_GREEN'
| 'PROG_RED'
| 'PROG_YELLOW'
| 'SEARCH'
| 'SETTINGS'
| 'TV'
| 'TV_TELETEXT'
| 'VOLUME_DOWN'
| 'VOLUME_MUTE'
| 'VOLUME_UP';
export type TAndroidtvRemoteCommandReason =
| 'connect'
| 'finish_pairing'
| 'launch_app'
| 'media_next_track'
| 'media_pause'
| 'media_play'
| 'media_play_pause'
| 'media_previous_track'
| 'media_stop'
| 'play_channel'
| 'remote_send_command'
| 'select_activity'
| 'send_text'
| 'start_pairing'
| 'turn_off'
| 'turn_on'
| 'volume_down'
| 'volume_mute'
| 'volume_set'
| 'volume_up';
export type TAndroidtvRemoteCommandAction =
| 'connect'
| 'finish_pairing'
| 'key_command'
| 'launch_app'
| 'remote_send_command'
| 'send_text'
| 'start_pairing'
| 'volume_set';
export interface IAndroidtvRemoteVolumeInfo {
level?: number;
max?: number;
muted?: boolean;
playerModel?: string;
}
export interface IAndroidtvRemoteDeviceInfo {
id?: string;
name?: string;
host?: string;
apiPort?: number;
pairPort?: number;
macAddress?: string;
manufacturer?: string;
model?: string;
softwareVersion?: string;
appVersion?: string;
}
export interface IAndroidtvRemoteDeviceState {
available?: boolean;
isOn?: boolean | null;
powerState?: TAndroidtvRemotePowerState;
mediaState?: TAndroidtvRemoteMediaState | string;
rawState?: string;
currentApp?: string;
currentAppName?: string;
currentActivity?: string;
volumeInfo?: IAndroidtvRemoteVolumeInfo;
volumeLevel?: number;
isVolumeMuted?: boolean;
voiceEnabled?: boolean;
}
export interface IAndroidtvRemoteApp {
id: string;
name?: string;
icon?: string;
link?: string;
}
export interface IAndroidtvRemoteConfiguredApp {
appName?: string;
appIcon?: string;
app_name?: string;
app_icon?: string;
name?: string;
icon?: string;
link?: string;
}
export interface IAndroidtvRemoteSnapshot {
deviceInfo: IAndroidtvRemoteDeviceInfo;
state: IAndroidtvRemoteDeviceState;
apps: IAndroidtvRemoteApp[];
updatedAt?: string;
}
export interface IAndroidtvRemoteKeyPress {
keyCode: TAndroidtvRemoteKeyCode | string;
direction?: TAndroidtvRemoteCommandDirection;
}
export interface IAndroidtvRemoteCommand {
action: TAndroidtvRemoteCommandAction;
reason?: TAndroidtvRemoteCommandReason;
keyCode?: TAndroidtvRemoteKeyCode | string;
direction?: TAndroidtvRemoteCommandDirection;
keys?: IAndroidtvRemoteKeyPress[];
appId?: string;
appLink?: string;
appName?: string;
text?: string;
pin?: string;
volumeLevel?: number;
muted?: boolean;
repeats?: number;
delaySecs?: number;
holdSecs?: number;
}
export interface IAndroidtvRemoteCommandContext {
config: IAndroidtvRemoteConfig;
snapshot: IAndroidtvRemoteSnapshot;
}
export type TAndroidtvRemoteCommandExecutor =
| ((commandArg: IAndroidtvRemoteCommand, contextArg: IAndroidtvRemoteCommandContext) => Promise<void> | 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.constants.js';
export * from './androidtv_remote.discovery.js';
export * from './androidtv_remote.mapper.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 {
constructor() {
super({
domain: "arcam_fmj",
displayName: "Arcam FMJ Receivers",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/arcam_fmj",
"upstreamDomain": "arcam_fmj",
"integrationType": "device",
"iotClass": "local_polling",
"requirements": [
"arcam-fmj==1.8.3"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@elupus"
]
},
});
export class ArcamFmjIntegration extends BaseIntegration<IArcamFmjConfig> {
public readonly domain = 'arcam_fmj';
public readonly displayName = 'Arcam FMJ Receivers';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createArcamFmjDiscoveryDescriptor();
public readonly configFlow = new ArcamFmjConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/arcam_fmj',
upstreamDomain: 'arcam_fmj',
integrationType: 'device',
iotClass: 'local_polling',
requirements: ['arcam-fmj==1.8.3'],
dependencies: [],
afterDependencies: [],
codeowners: ['@elupus'],
configFlow: true,
documentation: 'https://www.home-assistant.io/integrations/arcam_fmj',
};
public async setup(configArg: IArcamFmjConfig, contextArg: IIntegrationSetupContext): Promise<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 {
// TODO: replace with the TypeScript-native config for arcam_fmj.
[key: string]: unknown;
export const arcamFmjDefaultPort = 50000;
export type TArcamFmjApiModel =
| 'API450_SERIES'
| 'API860_SERIES'
| 'APIHDA_SERIES'
| 'APISA_SERIES'
| 'APIPA_SERIES'
| 'APIST_SERIES';
export type TArcamFmjSnapshotSource = 'manual' | 'snapshot' | 'tcp';
export type TArcamFmjCommand =
| 'turn_on'
| 'turn_off'
| 'volume_up'
| 'volume_down'
| 'set_volume'
| 'mute'
| 'select_source'
| 'raw_command';
export type TArcamFmjSource =
| 'FOLLOW_ZONE_1'
| 'CD'
| 'BD'
| 'AV'
| 'SAT'
| 'PVR'
| 'VCR'
| 'AUX'
| 'DISPLAY'
| 'FM'
| 'DAB'
| 'NET'
| 'USB'
| 'STB'
| 'GAME'
| 'PHONO'
| 'ARC_ERC'
| 'UHD'
| 'BT'
| 'DIG1'
| 'DIG2'
| 'DIG3'
| 'DIG4'
| 'NET_USB'
| (string & {});
export interface IArcamFmjConfig {
host?: string;
port?: number;
name?: string;
manufacturer?: string;
model?: string;
revision?: string;
serialNumber?: string;
uniqueId?: string;
apiModel?: TArcamFmjApiModel;
zones?: IArcamFmjZoneState[];
requestTimeoutMs?: number;
sourceMap?: Record<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.discovery.js';
export * from './arcam_fmj.mapper.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 {
constructor() {
super({
domain: "asuswrt",
displayName: "ASUSWRT",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/asuswrt",
"upstreamDomain": "asuswrt",
"integrationType": "hub",
"iotClass": "local_polling",
"requirements": [
"aioasuswrt==1.5.4",
"asusrouter==1.21.3"
],
"dependencies": [],
"afterDependencies": [],
"codeowners": [
"@kennedyshead",
"@ollo69",
"@Vaskivskyi"
]
export class AsuswrtIntegration extends BaseIntegration<IAsuswrtConfig> {
public readonly domain = asuswrtDomain;
public readonly displayName = 'ASUSWRT';
public readonly status = 'control-runtime' as const;
public readonly discoveryDescriptor = createAsuswrtDiscoveryDescriptor();
public readonly configFlow = new AsuswrtConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/asuswrt',
upstreamDomain: asuswrtDomain,
integrationType: 'hub',
iotClass: 'local_polling',
requirements: ['aioasuswrt==1.5.4', 'asusrouter==1.21.3'],
dependencies: [] as string[],
afterDependencies: [] as string[],
codeowners: ['@kennedyshead', '@ollo69', '@Vaskivskyi'],
documentation: 'https://www.home-assistant.io/integrations/asuswrt',
configFlow: true,
runtime: {
mode: 'native TypeScript snapshot/manual router mapping',
platforms: ['sensor', 'binary_sensor', 'button'],
services: ['refresh', 'snapshot', 'reboot', 'reconnect_device', 'disconnect_device', 'block_device', 'unblock_device'],
},
});
localApi: {
implemented: [
'manual ASUSWRT router setup candidates and config flow',
'snapshot mapping for router sensors, device tracker presence, interfaces, traffic counters/rates, CPU, memory, temperatures, uptime, and load average',
'safe command modeling for explicitly declared router reboot and connected-device actions',
],
explicitUnsupported: [
'homeassistant_compat shims',
'fake SSH/Telnet connection or command success without commandExecutor/nativeClient injection',
'full asusrouter/aioasuswrt live protocol implementation in dependency-free TypeScript',
],
},
};
public async setup(configArg: IAsuswrtConfig, contextArg: IIntegrationSetupContext): Promise<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 {
// TODO: replace with the TypeScript-native config for asuswrt.
import type { IServiceCallResult } from '../../core/types.js';
export const asuswrtDomain = 'asuswrt';
export const asuswrtDefaultHttpPort = 80;
export const asuswrtDefaultHttpsPort = 8443;
export const asuswrtDefaultSshPort = 22;
export const asuswrtDefaultTelnetPort = 23;
export const asuswrtDefaultInterface = 'eth0';
export const asuswrtDefaultDnsmasqPath = '/var/lib/misc';
export const asuswrtDefaultConsiderHomeSeconds = 180;
export type TAsuswrtProtocol = 'http' | 'https' | 'ssh' | 'telnet';
export type TAsuswrtMode = 'router' | 'ap';
export type TAsuswrtSnapshotSource = 'snapshot' | 'manual' | 'provider' | 'runtime';
export type TAsuswrtCommandType = 'router.reboot' | 'client.action';
export type TAsuswrtRouterAction = 'reboot';
export type TAsuswrtClientAction = 'reconnect' | 'disconnect' | 'block' | 'unblock';
export type TAsuswrtAction = TAsuswrtRouterAction | TAsuswrtClientAction;
export interface IAsuswrtConfig {
host?: string;
port?: number;
protocol?: TAsuswrtProtocol;
username?: string;
password?: string;
sshKey?: string;
mode?: TAsuswrtMode;
interface?: string;
dnsmasq?: string;
requireIp?: boolean;
trackUnknown?: boolean;
considerHomeSeconds?: number;
connected?: boolean;
uniqueId?: string;
name?: string;
snapshot?: IAsuswrtSnapshot;
router?: IAsuswrtRouterInfo;
devices?: IAsuswrtClientDevice[];
clients?: IAsuswrtClientDevice[];
interfaces?: IAsuswrtInterfaceStats[];
sensors?: IAsuswrtSensorMap;
actions?: IAsuswrtActionDescriptor[];
manualEntries?: IAsuswrtManualEntry[];
events?: IAsuswrtEvent[];
snapshotProvider?: TAsuswrtSnapshotProvider;
commandExecutor?: TAsuswrtCommandExecutor;
nativeClient?: IAsuswrtNativeClient;
metadata?: Record<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.discovery.js';
export * from './asuswrt.mapper.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 {
constructor() {
super({
domain: "bluetooth_le_tracker",
displayName: "Bluetooth LE Tracker",
status: 'descriptor-only',
metadata: {
"source": "home-assistant/core",
"upstreamPath": "homeassistant/components/bluetooth_le_tracker",
"upstreamDomain": "bluetooth_le_tracker",
"iotClass": "local_push",
"qualityScale": "legacy",
"requirements": [],
"dependencies": [
"bluetooth_adapters"
],
"afterDependencies": [],
"codeowners": []
},
});
export class BluetoothLeTrackerIntegration extends BaseIntegration<IBluetoothLeTrackerConfig> {
public readonly domain = bluetoothLeTrackerDomain;
public readonly displayName = 'Bluetooth LE Tracker';
public readonly status = 'read-only-runtime' as const;
public readonly discoveryDescriptor = createBluetoothLeTrackerDiscoveryDescriptor();
public readonly configFlow = new BluetoothLeTrackerConfigFlow();
public readonly metadata = {
source: 'home-assistant/core',
upstreamPath: 'homeassistant/components/bluetooth_le_tracker',
upstreamDomain: bluetoothLeTrackerDomain,
iotClass: 'local_push',
qualityScale: 'legacy',
requirements: [] as string[],
dependencies: ['bluetooth_adapters'],
afterDependencies: [] as string[],
codeowners: [] as string[],
documentation: 'https://www.home-assistant.io/integrations/bluetooth_le_tracker',
nativeBehavior: 'Maps injected BLE advertisement/snapshot data into device-tracker-like binary_sensor/sensor entities. Live Bluetooth adapter scanning is intentionally not implemented.',
};
public async setup(configArg: IBluetoothLeTrackerConfig, contextArg: IIntegrationSetupContext): Promise<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 {
// TODO: replace with the TypeScript-native config for bluetooth_le_tracker.
export const bluetoothLeTrackerDomain = 'bluetooth_le_tracker';
export const bluetoothLeTrackerBlePrefix = 'BLE_';
export const bluetoothLeTrackerSourceType = 'bluetooth_le';
export const bluetoothLeTrackerDefaultScanIntervalSeconds = 12;
export const bluetoothLeTrackerDefaultConsiderHomeSeconds = 180;
export const bluetoothLeTrackerDefaultTrackBatteryIntervalSeconds = 86400;
export const bluetoothLeTrackerDefaultMinSeenNew = 5;
export type TBluetoothLePresenceState = 'home' | 'not_home';
export type TBluetoothLeTrackerCommandType = 'scan';
export type TBluetoothLeScanProvider = () => Promise<
| IBluetoothLeAdvertisement[]
| IBluetoothLeTrackerSnapshot
| {
snapshot?: IBluetoothLeTrackerSnapshot;
devices?: IBluetoothLeTrackedDevice[];
advertisements?: IBluetoothLeAdvertisement[];
}
>;
export interface IBluetoothLeTrackerConfig {
scanIntervalSeconds?: number;
interval_seconds?: number;
trackNewDevices?: boolean;
track_new_devices?: boolean;
trackBattery?: boolean;
track_battery?: boolean;
trackBatteryIntervalSeconds?: number;
track_battery_interval?: number;
considerHomeSeconds?: number;
consider_home?: number;
minSeenNew?: number;
snapshot?: IBluetoothLeTrackerSnapshot;
knownDevices?: IBluetoothLeTrackedDevice[];
trackedDevices?: IBluetoothLeTrackedDevice[];
devices?: IBluetoothLeTrackedDevice[];
advertisements?: IBluetoothLeAdvertisement[];
manualEntries?: IBluetoothLeManualEntry[];
events?: IBluetoothLeTrackerEvent[];
scanProvider?: TBluetoothLeScanProvider;
}
export interface IHomeAssistantBluetoothLeTrackerConfig extends IBluetoothLeTrackerConfig {}
export interface IBluetoothLeTrackerScannerState {
scanIntervalSeconds: number;
trackNewDevices: boolean;
trackBattery: boolean;
trackBatteryIntervalSeconds: number;
considerHomeSeconds: number;
minSeenNew: number;
}
export interface IBluetoothLeAdvertisement {
address?: string;
mac?: string;
macAddress?: string;
haMac?: string;
name?: string;
localName?: string;
hostName?: string;
rssi?: number;
txPower?: number;
connectable?: boolean;
serviceUuids?: string[];
serviceUUIDs?: string[];
serviceData?: Record<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;
}
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.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';
+7 -13
View File
@@ -11,7 +11,6 @@ import { HomeAssistantAcomaxIntegration } from '../acomax/index.js';
import { HomeAssistantActiontecIntegration } from '../actiontec/index.js';
import { HomeAssistantActronAirIntegration } from '../actron_air/index.js';
import { HomeAssistantAdaxIntegration } from '../adax/index.js';
import { HomeAssistantAdguardIntegration } from '../adguard/index.js';
import { HomeAssistantAdsIntegration } from '../ads/index.js';
import { HomeAssistantAdvantageAirIntegration } from '../advantage_air/index.js';
import { HomeAssistantAemetIntegration } from '../aemet/index.js';
@@ -47,12 +46,10 @@ import { HomeAssistantAmazonPollyIntegration } from '../amazon_polly/index.js';
import { HomeAssistantAmberelectricIntegration } from '../amberelectric/index.js';
import { HomeAssistantAmbientNetworkIntegration } from '../ambient_network/index.js';
import { HomeAssistantAmbientStationIntegration } from '../ambient_station/index.js';
import { HomeAssistantAmcrestIntegration } from '../amcrest/index.js';
import { HomeAssistantAmpMotorizationIntegration } from '../amp_motorization/index.js';
import { HomeAssistantAmpioIntegration } from '../ampio/index.js';
import { HomeAssistantAnalyticsIntegration } from '../analytics/index.js';
import { HomeAssistantAnalyticsInsightsIntegration } from '../analytics_insights/index.js';
import { HomeAssistantAndroidtvRemoteIntegration } from '../androidtv_remote/index.js';
import { HomeAssistantAnelPwrctrlIntegration } from '../anel_pwrctrl/index.js';
import { HomeAssistantAnglianWaterIntegration } from '../anglian_water/index.js';
import { HomeAssistantAnovaIntegration } from '../anova/index.js';
@@ -74,7 +71,6 @@ import { HomeAssistantAquacellIntegration } from '../aquacell/index.js';
import { HomeAssistantAqualogicIntegration } from '../aqualogic/index.js';
import { HomeAssistantAquostvIntegration } from '../aquostv/index.js';
import { HomeAssistantAranetIntegration } from '../aranet/index.js';
import { HomeAssistantArcamFmjIntegration } from '../arcam_fmj/index.js';
import { HomeAssistantArestIntegration } from '../arest/index.js';
import { HomeAssistantArrisTg2492lgIntegration } from '../arris_tg2492lg/index.js';
import { HomeAssistantArtsoundIntegration } from '../artsound/index.js';
@@ -84,7 +80,6 @@ import { HomeAssistantArwnIntegration } from '../arwn/index.js';
import { HomeAssistantAsekoPoolLiveIntegration } from '../aseko_pool_live/index.js';
import { HomeAssistantAssistPipelineIntegration } from '../assist_pipeline/index.js';
import { HomeAssistantAssistSatelliteIntegration } from '../assist_satellite/index.js';
import { HomeAssistantAsuswrtIntegration } from '../asuswrt/index.js';
import { HomeAssistantAtagIntegration } from '../atag/index.js';
import { HomeAssistantAtenPeIntegration } from '../aten_pe/index.js';
import { HomeAssistantAtlanticcityelectricIntegration } from '../atlanticcityelectric/index.js';
@@ -136,7 +131,6 @@ import { HomeAssistantBlueprintIntegration } from '../blueprint/index.js';
import { HomeAssistantBluesoundIntegration } from '../bluesound/index.js';
import { HomeAssistantBluetoothIntegration } from '../bluetooth/index.js';
import { HomeAssistantBluetoothAdaptersIntegration } from '../bluetooth_adapters/index.js';
import { HomeAssistantBluetoothLeTrackerIntegration } from '../bluetooth_le_tracker/index.js';
import { HomeAssistantBmwConnectedDriveIntegration } from '../bmw_connected_drive/index.js';
import { HomeAssistantBondIntegration } from '../bond/index.js';
import { HomeAssistantBoschAlarmIntegration } from '../bosch_alarm/index.js';
@@ -1425,7 +1419,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAcomaxIntegration()
generatedHomeAssistantPortIntegrations.push(new HomeAssistantActiontecIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantActronAirIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdaxIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdguardIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAdvantageAirIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAemetIntegration());
@@ -1461,12 +1454,10 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmazonPollyIntegrat
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmberelectricIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientNetworkIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmbientStationIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmcrestIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpMotorizationIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAmpioIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnalyticsInsightsIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAndroidtvRemoteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnelPwrctrlIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnglianWaterIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAnovaIntegration());
@@ -1488,7 +1479,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquacellIntegration
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAqualogicIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAquostvIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAranetIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArcamFmjIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArestIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArrisTg2492lgIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantArtsoundIntegration());
@@ -1498,7 +1488,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantArwnIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsekoPoolLiveIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistPipelineIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAssistSatelliteIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAsuswrtIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtagIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtenPeIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantAtlanticcityelectricIntegration());
@@ -1550,7 +1539,6 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantBlueprintIntegratio
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluesoundIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothAdaptersIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBluetoothLeTrackerIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBmwConnectedDriveIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBondIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantBoschAlarmIntegration());
@@ -2828,14 +2816,20 @@ generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZoneminderIntegration());
generatedHomeAssistantPortIntegrations.push(new HomeAssistantZwaveMeIntegration());
export const generatedHomeAssistantPortCount = 1412;
export const generatedHomeAssistantPortCount = 1406;
export const handwrittenHomeAssistantPortDomains = [
"adguard",
"airgradient",
"amcrest",
"android_ip_webcam",
"androidtv",
"androidtv_remote",
"apcupsd",
"arcam_fmj",
"asuswrt",
"axis",
"blebox",
"bluetooth_le_tracker",
"braviatv",
"broadlink",
"cast",