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();