Add native local network integrations
This commit is contained in:
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user