Add native local NAS and network service integrations
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HomeAssistantMikrotikIntegration, type IMikrotikCommand, type IMikrotikConfig } from '../../ts/integrations/mikrotik/index.js';
|
||||
|
||||
const config: IMikrotikConfig = {
|
||||
host: '192.168.88.1',
|
||||
snapshot: {
|
||||
connected: true,
|
||||
router: {
|
||||
name: 'API Router',
|
||||
host: '192.168.88.1',
|
||||
serialNumber: 'MK123456',
|
||||
actions: ['reboot'],
|
||||
},
|
||||
resources: {},
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
sensors: {},
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('does not fake RouterOS/API command success without injected executor', async () => {
|
||||
const runtime = await new HomeAssistantMikrotikIntegration().setup(config, {});
|
||||
const result = await runtime.callService!({ domain: 'mikrotik', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toInclude('not faked');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('executes explicit RouterOS/API commands through injected executor', async () => {
|
||||
let command: IMikrotikCommand | undefined;
|
||||
const runtime = await new HomeAssistantMikrotikIntegration().setup({
|
||||
...config,
|
||||
commandExecutor: async (commandArg) => {
|
||||
command = commandArg;
|
||||
return { success: true, data: { accepted: true } };
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'mikrotik', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(command?.path).toEqual('/system/reboot');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createMikrotikDiscoveryDescriptor, MikrotikConfigFlow } from '../../ts/integrations/mikrotik/index.js';
|
||||
|
||||
tap.test('matches and validates manual Mikrotik RouterOS/API entries', async () => {
|
||||
const descriptor = createMikrotikDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
host: '192.168.88.1',
|
||||
name: 'RouterOS Lab',
|
||||
model: 'hAP ax3',
|
||||
macAddress: 'AA-BB-CC-DD-EE-FF',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('mikrotik');
|
||||
expect(result.candidate?.port).toEqual(8728);
|
||||
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?.liveRouterOsApiImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('accepts snapshot-only manual setup and rejects unrelated entries', async () => {
|
||||
const descriptor = createMikrotikDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const snapshotResult = await matcher.matches({
|
||||
snapshot: {
|
||||
connected: true,
|
||||
router: { name: 'Snapshot Router', serialNumber: 'MK654321' },
|
||||
resources: {},
|
||||
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 Mikrotik config without claiming live API support', async () => {
|
||||
const flow = new MikrotikConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', host: '192.168.88.1', metadata: { verifySsl: true } }, {});
|
||||
const done = await step.submit!({ host: '192.168.88.1', verifySsl: true, username: 'admin' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.port).toEqual(8728);
|
||||
expect(done.config?.protocol).toEqual('routeros-api-ssl');
|
||||
expect(done.config?.metadata?.liveRouterOsApiImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MikrotikMapper, type IMikrotikSnapshot } from '../../ts/integrations/mikrotik/index.js';
|
||||
|
||||
const snapshot: IMikrotikSnapshot = {
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
router: {
|
||||
host: '192.168.88.1',
|
||||
name: 'Core Router',
|
||||
model: 'hAP ax3',
|
||||
serialNumber: 'MK123456',
|
||||
firmware: '7.15.3',
|
||||
actions: ['reboot'],
|
||||
},
|
||||
resources: {
|
||||
cpuLoad: 12,
|
||||
freeMemory: 512 * 1048576,
|
||||
totalMemory: 1024 * 1048576,
|
||||
uptime: '1h2m3s',
|
||||
version: '7.15.3',
|
||||
boardName: 'hAP ax3',
|
||||
},
|
||||
devices: [
|
||||
{
|
||||
mac: '11:22:33:44:55:66',
|
||||
name: 'Kitchen Phone',
|
||||
ipAddress: '192.168.88.20',
|
||||
interface: 'bridge',
|
||||
ssid: 'Home WiFi',
|
||||
connected: true,
|
||||
signalStrength: -55,
|
||||
signalToNoise: 42,
|
||||
actions: ['arp_ping'],
|
||||
},
|
||||
],
|
||||
interfaces: [
|
||||
{
|
||||
id: '*1',
|
||||
name: 'ether1',
|
||||
label: 'WAN',
|
||||
type: 'ether',
|
||||
running: true,
|
||||
disabled: false,
|
||||
rxBytes: 2_000_000_000,
|
||||
txBytes: 1_000_000_000,
|
||||
rxBitsPerSecond: 2_000_000,
|
||||
txBitsPerSecond: 1_000_000,
|
||||
},
|
||||
],
|
||||
sensors: {},
|
||||
};
|
||||
|
||||
tap.test('maps Mikrotik router resources, clients, interfaces, and traffic sensors', async () => {
|
||||
const normalized = MikrotikMapper.toSnapshot({ snapshot });
|
||||
const devices = MikrotikMapper.toDevices(normalized);
|
||||
const entities = MikrotikMapper.toEntities(normalized);
|
||||
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'mikrotik.router.mk123456')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'mikrotik.client.11_22_33_44_55_66')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_cpu_load')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_memory_free')?.state).toEqual(512);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_uptime')?.state).toEqual(3723);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_wan_download')?.state).toEqual(2);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.core_router_wan_download_speed')?.state).toEqual(2);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.attributes?.nativePlatform).toEqual('device_tracker');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.core_router_wan')?.state).toEqual('on');
|
||||
});
|
||||
|
||||
tap.test('models only represented Mikrotik RouterOS/API commands safely', async () => {
|
||||
const normalized = MikrotikMapper.toSnapshot({ snapshot });
|
||||
const rebootCommand = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'mikrotik',
|
||||
service: 'reboot',
|
||||
target: {},
|
||||
});
|
||||
const interfaceCommand = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.core_router_wan' },
|
||||
});
|
||||
const arpPingCommand = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'mikrotik',
|
||||
service: 'arp_ping',
|
||||
target: {},
|
||||
data: { mac: '11-22-33-44-55-66', count: 1 },
|
||||
});
|
||||
const unsupportedDisconnect = MikrotikMapper.commandForService(normalized, {
|
||||
domain: 'mikrotik',
|
||||
service: 'disconnect_client',
|
||||
target: {},
|
||||
data: { mac: '11:22:33:44:55:66' },
|
||||
});
|
||||
|
||||
expect(rebootCommand?.path).toEqual('/system/reboot');
|
||||
expect(interfaceCommand?.path).toEqual('/interface/set');
|
||||
expect(interfaceCommand?.params.disabled).toEqual('yes');
|
||||
expect(arpPingCommand?.path).toEqual('/ping');
|
||||
expect(arpPingCommand?.params.address).toEqual('192.168.88.20');
|
||||
expect(unsupportedDisconnect).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MotionEyeClient, type IMotionEyeCamera } from '../../ts/integrations/motioneye/index.js';
|
||||
|
||||
const rawCamera = {
|
||||
id: 1,
|
||||
name: 'Driveway',
|
||||
host: '10.0.0.5',
|
||||
streaming_port: 8081,
|
||||
streaming_auth_mode: 'basic',
|
||||
video_streaming: true,
|
||||
motion_detection: true,
|
||||
movies: true,
|
||||
still_images: true,
|
||||
actions: ['snapshot', 'record_start', 'record_stop'],
|
||||
root_directory: '/var/lib/motioneye',
|
||||
};
|
||||
|
||||
tap.test('fetches signed live snapshots and executes commands only after HTTP success', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; method: string; body?: string }> = [];
|
||||
let updatedMotionDetection: unknown;
|
||||
|
||||
globalThis.fetch = (async (inputArg: RequestInfo | URL, initArg?: RequestInit) => {
|
||||
const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url;
|
||||
const parsed = new URL(url);
|
||||
const method = initArg?.method || 'GET';
|
||||
const body = typeof initArg?.body === 'string' ? initArg.body : undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
expect(parsed.searchParams.get('_signature')).toBeTruthy();
|
||||
if (parsed.pathname === '/login') {
|
||||
expect(parsed.searchParams.get('_username')).toEqual('admin');
|
||||
return new Response('{}', { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/list') {
|
||||
return new Response(JSON.stringify({ cameras: [rawCamera] }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/manifest.json') {
|
||||
return new Response(JSON.stringify({ version: '0.43.1' }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/main/get') {
|
||||
return new Response(JSON.stringify({ enabled: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/picture/1/current/') {
|
||||
expect(parsed.searchParams.get('_username')).toEqual('viewer');
|
||||
return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { headers: { 'content-type': 'image/jpeg' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/1/get') {
|
||||
return new Response(JSON.stringify(rawCamera), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/config/1/set') {
|
||||
const payload = JSON.parse(body || '{}') as Record<string, unknown>;
|
||||
updatedMotionDetection = payload.motion_detection;
|
||||
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (parsed.pathname === '/action/1/record_start') {
|
||||
expect(method).toEqual('POST');
|
||||
expect(body).toEqual('{}');
|
||||
return new Response(JSON.stringify({ ok: true }), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response('not found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new MotionEyeClient({ url: 'http://127.0.0.1:8765', adminPassword: 'secret', surveillanceUsername: 'viewer', surveillancePassword: 'view' });
|
||||
const snapshot = await client.getSnapshot();
|
||||
expect(snapshot.connected).toBeTrue();
|
||||
expect(snapshot.cameras[0].mjpegUrl).toEqual('http://10.0.0.5:8081/');
|
||||
expect(snapshot.cameras[0].snapshotUrl?.includes('_username=viewer')).toBeTrue();
|
||||
expect(snapshot.switches.find((switchArg) => switchArg.key === 'motion_detection')?.isOn).toBeTrue();
|
||||
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'actions')?.value).toEqual(3);
|
||||
|
||||
const image = await client.execute({ type: 'snapshot_image', service: 'snapshot', cameraId: '1' });
|
||||
expect((image as { contentType: string }).contentType).toEqual('image/jpeg');
|
||||
|
||||
const switchResult = await client.execute({ type: 'set_switch', service: 'turn_off', cameraId: '1', key: 'motion_detection', enabled: false });
|
||||
expect((switchResult as { ok: boolean }).ok).toBeTrue();
|
||||
expect(updatedMotionDetection).toEqual(false);
|
||||
|
||||
const actionResult = await client.execute({ type: 'action', service: 'record_start', cameraId: '1', action: 'record_start' });
|
||||
expect((actionResult as { ok: boolean }).ok).toBeTrue();
|
||||
expect(requests.some((requestArg) => requestArg.url.includes('/action/1/record_start'))).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not fake command success without a live endpoint or successful HTTP response', async () => {
|
||||
const configuredCamera: IMotionEyeCamera = {
|
||||
id: '1',
|
||||
numericId: 1,
|
||||
name: 'Driveway',
|
||||
isStreaming: true,
|
||||
motionDetectionEnabled: true,
|
||||
actions: ['record_start'],
|
||||
};
|
||||
const clientWithoutEndpoint = new MotionEyeClient({ connected: true, cameras: [configuredCamera] });
|
||||
let missingEndpointError = '';
|
||||
try {
|
||||
await clientWithoutEndpoint.execute({ type: 'action', service: 'record_start', cameraId: '1', action: 'record_start' });
|
||||
} catch (errorArg) {
|
||||
missingEndpointError = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
expect(missingEndpointError.includes('requires config.url or config.host')).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 (new URL(url).pathname === '/action/1/record_start') {
|
||||
return new Response('failed', { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify({ cameras: [rawCamera] }), { headers: { 'content-type': 'application/json' } });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new MotionEyeClient({ url: 'http://127.0.0.1:8765', snapshot: { deviceInfo: { id: 'motioneye', online: true }, cameras: [configuredCamera], sensors: [], switches: [], rawCameras: [rawCamera], connected: true } });
|
||||
let httpError = '';
|
||||
try {
|
||||
await client.execute({ type: 'action', service: 'record_start', cameraId: '1', action: 'record_start' });
|
||||
} catch (errorArg) {
|
||||
httpError = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
expect(httpError.includes('failed with HTTP 500')).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,36 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MotionEyeConfigFlow, createMotionEyeDiscoveryDescriptor } from '../../ts/integrations/motioneye/index.js';
|
||||
|
||||
tap.test('matches manual motionEye URL entries and configures flow', async () => {
|
||||
const descriptor = createMotionEyeDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const match = await matcher.matches({ url: 'http://192.168.1.40:8765', name: 'Camera Hub' }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.integrationDomain).toEqual('motioneye');
|
||||
expect(match.candidate?.host).toEqual('192.168.1.40');
|
||||
expect(match.candidate?.port).toEqual(8765);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(match.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
|
||||
const flow = new MotionEyeConfigFlow();
|
||||
const step = await flow.start(match.candidate!, {});
|
||||
const done = await step.submit!({ adminUsername: 'admin', adminPassword: 'secret', surveillanceUsername: 'viewer', surveillancePassword: 'view' });
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.url).toEqual('http://192.168.1.40:8765');
|
||||
expect(done.config?.adminUsername).toEqual('admin');
|
||||
expect(done.config?.surveillanceUsername).toEqual('viewer');
|
||||
});
|
||||
|
||||
tap.test('matches local HTTP URL candidates on the default motionEye port', async () => {
|
||||
const descriptor = createMotionEyeDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[1];
|
||||
const match = await matcher.matches({ url: 'http://192.168.1.41:8765', metadata: { source: 'manual-url-list' } }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.source).toEqual('http');
|
||||
expect(match.candidate?.metadata?.url).toEqual('http://192.168.1.41:8765');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,93 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { MotionEyeMapper, type IMotionEyeSnapshot } from '../../ts/integrations/motioneye/index.js';
|
||||
|
||||
const snapshot: IMotionEyeSnapshot = {
|
||||
deviceInfo: {
|
||||
id: 'motioneye-host',
|
||||
name: 'motionEye Server',
|
||||
manufacturer: 'motionEye',
|
||||
host: '192.168.1.40',
|
||||
port: 8765,
|
||||
protocol: 'http',
|
||||
url: 'http://192.168.1.40:8765',
|
||||
online: true,
|
||||
},
|
||||
cameras: [{
|
||||
id: '1',
|
||||
numericId: 1,
|
||||
name: 'Driveway',
|
||||
streamingPort: 8081,
|
||||
streamingAuthMode: 'basic',
|
||||
mjpegUrl: 'http://192.168.1.41:8081/',
|
||||
snapshotUrl: 'http://192.168.1.40:8765/picture/1/current/?_username=user&_signature=abc',
|
||||
isStreaming: true,
|
||||
motionDetectionEnabled: true,
|
||||
moviesEnabled: true,
|
||||
stillImagesEnabled: true,
|
||||
actions: ['snapshot', 'record_start', 'record_stop'],
|
||||
rootDirectory: '/var/lib/motioneye',
|
||||
available: true,
|
||||
}],
|
||||
sensors: [{ key: 'actions', name: 'Driveway Actions', cameraId: '1', value: 3, attributes: { actions: ['snapshot', 'record_start', 'record_stop'] }, available: true }],
|
||||
switches: [
|
||||
{ key: 'motion_detection', name: 'Driveway Motion detection', cameraId: '1', isOn: true, entityCategory: 'config', available: true },
|
||||
{ key: 'movies', name: 'Driveway Movies', cameraId: '1', isOn: true, entityCategory: 'config', available: true },
|
||||
],
|
||||
rawCameras: [],
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
tap.test('maps motionEye cameras, motion switches, and action sensors', async () => {
|
||||
const devices = MotionEyeMapper.toDevices(snapshot);
|
||||
const entities = MotionEyeMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices.length).toEqual(1);
|
||||
expect(devices[0].features.some((featureArg) => featureArg.id === 'motion_detection')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'camera.driveway')?.attributes?.snapshotUrl).toEqual('http://192.168.1.40:8765/picture/1/current/?_username=user&_signature=abc');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.driveway_motion_detection')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.driveway_actions')?.state).toEqual(3);
|
||||
});
|
||||
|
||||
tap.test('models stream, snapshot, motion, recording, and text overlay services as commands', async () => {
|
||||
const streamCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'stream_source',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
});
|
||||
const snapshotCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'snapshot',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
});
|
||||
const motionCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.driveway_motion_detection' },
|
||||
});
|
||||
const recordCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'motioneye',
|
||||
service: 'record_start',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
});
|
||||
const overlayCommand = MotionEyeMapper.commandForService(snapshot, {
|
||||
domain: 'motioneye',
|
||||
service: 'set_text_overlay',
|
||||
target: { entityId: 'camera.driveway' },
|
||||
data: { left_text: 'timestamp', custom_right_text: 'Driveway' },
|
||||
});
|
||||
|
||||
expect(streamCommand?.type).toEqual('stream_source');
|
||||
expect(snapshotCommand?.type).toEqual('snapshot_image');
|
||||
expect(snapshotCommand?.httpCommands?.[0].path).toEqual('/picture/1/current/');
|
||||
expect(motionCommand?.type).toEqual('set_switch');
|
||||
expect(motionCommand?.key).toEqual('motion_detection');
|
||||
expect(motionCommand?.enabled).toEqual(false);
|
||||
expect(recordCommand?.type).toEqual('action');
|
||||
expect(recordCommand?.action).toEqual('record_start');
|
||||
expect(recordCommand?.httpCommands?.[0].path).toEqual('/action/1/record_start');
|
||||
expect(overlayCommand?.type).toEqual('set_text_overlay');
|
||||
expect(overlayCommand?.customRightText).toEqual('Driveway');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HomeAssistantOpnsenseIntegration, type IOpnsenseCommand, type IOpnsenseConfig } from '../../ts/integrations/opnsense/index.js';
|
||||
|
||||
const config: IOpnsenseConfig = {
|
||||
url: 'https://192.168.1.1',
|
||||
apiKey: 'key',
|
||||
apiSecret: 'secret',
|
||||
snapshot: {
|
||||
connected: true,
|
||||
router: {
|
||||
host: '192.168.1.1',
|
||||
name: 'Edge Firewall',
|
||||
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||
actions: ['reboot'],
|
||||
},
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
gateways: [],
|
||||
firewall: {},
|
||||
system: {},
|
||||
telemetry: {},
|
||||
services: [],
|
||||
vpn: {},
|
||||
sensors: {},
|
||||
switches: [],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('does not fake OPNsense live API command success without executor', async () => {
|
||||
const runtime = await new HomeAssistantOpnsenseIntegration().setup(config, {});
|
||||
const result = await runtime.callService!({ domain: 'opnsense', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toInclude('not faked');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('executes represented OPNsense commands through injected executor', async () => {
|
||||
let command: IOpnsenseCommand | undefined;
|
||||
const runtime = await new HomeAssistantOpnsenseIntegration().setup({
|
||||
...config,
|
||||
commandExecutor: async (commandArg) => {
|
||||
command = commandArg;
|
||||
return { success: true, data: { accepted: true } };
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'opnsense', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(command?.type).toEqual('router.action');
|
||||
expect(command?.path).toEqual('/api/core/system/reboot');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { OpnsenseConfigFlow, createOpnsenseDiscoveryDescriptor, type IOpnsenseSnapshot } from '../../ts/integrations/opnsense/index.js';
|
||||
|
||||
const snapshot: IOpnsenseSnapshot = {
|
||||
connected: true,
|
||||
router: { name: 'Snapshot Firewall', macAddress: 'AA:BB:CC:DD:EE:FF' },
|
||||
devices: [],
|
||||
interfaces: [],
|
||||
gateways: [],
|
||||
firewall: {},
|
||||
system: {},
|
||||
telemetry: {},
|
||||
services: [],
|
||||
vpn: {},
|
||||
sensors: {},
|
||||
switches: [],
|
||||
};
|
||||
|
||||
tap.test('matches and validates manual local HTTPS OPNsense candidates', async () => {
|
||||
const descriptor = createOpnsenseDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
url: 'https://firewall.local:8443',
|
||||
name: 'OPNsense Firewall',
|
||||
model: 'OPNsense',
|
||||
macAddress: 'AA-BB-CC-DD-EE-FF',
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('opnsense');
|
||||
expect(result.candidate?.port).toEqual(8443);
|
||||
expect(result.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(result.candidate?.metadata?.verifySsl).toBeFalse();
|
||||
|
||||
const validator = descriptor.getValidators()[0];
|
||||
const validation = await validator.validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.metadata?.liveHttpImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
tap.test('accepts snapshot-only setup and rejects non-HTTPS endpoints', async () => {
|
||||
const descriptor = createOpnsenseDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const snapshotResult = await matcher.matches({ snapshot }, {});
|
||||
const httpResult = await matcher.matches({ url: 'http://firewall.local', name: 'OPNsense' }, {});
|
||||
|
||||
expect(snapshotResult.matched).toBeTrue();
|
||||
expect(snapshotResult.confidence).toEqual('certain');
|
||||
expect(httpResult.matched).toBeFalse();
|
||||
expect(httpResult.reason).toInclude('HTTPS');
|
||||
});
|
||||
|
||||
tap.test('builds OPNsense config flow output without claiming live HTTP support', async () => {
|
||||
const flow = new OpnsenseConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', host: 'firewall.local', metadata: { trackerInterfaces: ['LAN'] } }, {});
|
||||
const done = await step.submit!({ url: 'firewall.local', apiKey: 'key', apiSecret: 'secret', trackerInterfaces: 'LAN,WAN' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.url).toEqual('https://firewall.local');
|
||||
expect(done.config?.ssl).toBeTrue();
|
||||
expect(done.config?.verifySsl).toBeFalse();
|
||||
expect(done.config?.trackerInterfaces).toEqual(['LAN', 'WAN']);
|
||||
expect(done.config?.metadata?.liveHttpImplemented).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,127 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { OpnsenseMapper, type IOpnsenseSnapshot } from '../../ts/integrations/opnsense/index.js';
|
||||
|
||||
const snapshot: IOpnsenseSnapshot = {
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
router: {
|
||||
host: '192.168.1.1',
|
||||
name: 'Edge Firewall',
|
||||
macAddress: 'AA:BB:CC:DD:EE:FF',
|
||||
firmware: '25.7',
|
||||
latestFirmware: '26.1',
|
||||
updateAvailable: true,
|
||||
actions: ['reboot', 'firmware_update'],
|
||||
},
|
||||
devices: [],
|
||||
interfaces: [
|
||||
{
|
||||
name: 'wan',
|
||||
label: 'WAN',
|
||||
status: 'up',
|
||||
inbytes: 2_000_000_000,
|
||||
outbytes: 1_000_000_000,
|
||||
inpkts: 100,
|
||||
outpkts: 50,
|
||||
actions: ['reload'],
|
||||
},
|
||||
],
|
||||
gateways: [
|
||||
{ name: 'WAN_DHCP', status: 'online', latency: '4.1 ms', loss: '0.0 %', interface: 'wan' },
|
||||
],
|
||||
firewall: {
|
||||
rules: [{ uuid: 'rule-1', description: 'Allow LAN', enabled: true, interface: 'lan', protocol: 'tcp' }],
|
||||
nat: { d_nat: [{ uuid: 'nat-1', description: 'HTTPS', disabled: '0', protocol: 'tcp' }] },
|
||||
aliases: [{ uuid: 'alias-1', name: 'BlockedHosts', enabled: false, type: 'host' }],
|
||||
state: { used: 42, total: 100, usedPercent: 42 },
|
||||
},
|
||||
system: {
|
||||
firmwareVersion: '25.7',
|
||||
productLatest: '26.1',
|
||||
updateAvailable: true,
|
||||
pendingNoticesPresent: true,
|
||||
pendingNotices: [{ id: 'notice1', notice: 'Reboot required' }],
|
||||
},
|
||||
telemetry: {
|
||||
cpu: { usage_total: 12 },
|
||||
memory: { used_percent: 34 },
|
||||
mbuf: { used_percent: 5 },
|
||||
},
|
||||
services: [
|
||||
{ name: 'unbound', displayName: 'Unbound DNS', running: 1 },
|
||||
],
|
||||
vpn: {
|
||||
openvpn: { servers: [{ uuid: 'ovpn1', name: 'Remote Access', enabled: true, connected_clients: 2 }] },
|
||||
wireguard: { clients: [{ uuid: 'wgclient1', name: 'Mullvad', enabled: false, connected_servers: 0 }] },
|
||||
},
|
||||
sensors: {},
|
||||
switches: [
|
||||
{ id: 'dnsbl', name: 'Unbound DNSBL', enabled: true, nativeType: 'unbound_blocklist', uuid: 'dnsbl-1' },
|
||||
],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
tap.test('maps OPNsense snapshot sections and HA ARP tracker filtering', async () => {
|
||||
const normalized = OpnsenseMapper.toSnapshot({
|
||||
snapshot,
|
||||
trackerInterfaces: ['LAN'],
|
||||
arpTable: [
|
||||
{ 'mac-address': '11:22:33:44:55:66', 'ip-address': '192.168.1.20', hostname: 'Kitchen Phone', intf_description: 'LAN', manufacturer: 'PhoneCo' },
|
||||
{ 'mac-address': '22:33:44:55:66:77', 'ip-address': '192.168.1.21', hostname: 'WAN Host', intf_description: 'WAN' },
|
||||
],
|
||||
});
|
||||
const devices = OpnsenseMapper.toDevices(normalized);
|
||||
const entities = OpnsenseMapper.toEntities(normalized);
|
||||
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'opnsense.router.aa_bb_cc_dd_ee_ff')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'opnsense.client.11_22_33_44_55_66')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'opnsense.client.22_33_44_55_66_77')).toBeFalse();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_cpu_usage')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_memory_usage')?.state).toEqual(34);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_firewall_state_table_used')?.state).toEqual(42);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.edge_firewall_wan_download')?.state).toEqual(2);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.edge_firewall_gateway_wan_dhcp')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.kitchen_phone_connected')?.attributes?.nativePlatform).toEqual('device_tracker');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.edge_firewall_firewall_allow_lan')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.unbound_dns_service')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.edge_firewall_openvpn_remote_access')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.edge_firewall_firmware')?.attributes?.latestVersion).toEqual('26.1');
|
||||
});
|
||||
|
||||
tap.test('models safe OPNsense commands only for represented resources', async () => {
|
||||
const normalized = OpnsenseMapper.toSnapshot({ snapshot });
|
||||
const serviceCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.unbound_dns_service' },
|
||||
});
|
||||
const firewallCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.edge_firewall_firewall_allow_lan' },
|
||||
});
|
||||
const natCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.edge_firewall_nat_https' },
|
||||
});
|
||||
const rebootCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'opnsense',
|
||||
service: 'reboot',
|
||||
target: {},
|
||||
});
|
||||
const missingServiceCommand = OpnsenseMapper.commandForService(normalized, {
|
||||
domain: 'opnsense',
|
||||
service: 'stop_service',
|
||||
target: {},
|
||||
data: { service: 'missing' },
|
||||
});
|
||||
|
||||
expect(serviceCommand && !('error' in serviceCommand) ? serviceCommand.path : '').toEqual('/api/core/service/stop/unbound');
|
||||
expect(firewallCommand && !('error' in firewallCommand) ? firewallCommand.path : '').toEqual('/api/firewall/filter/toggle_rule/rule-1/0');
|
||||
expect(natCommand && !('error' in natCommand) ? natCommand.path : '').toEqual('/api/firewall/d_nat/toggle_rule/nat-1/1');
|
||||
expect(rebootCommand && !('error' in rebootCommand) ? rebootCommand.type : '').toEqual('router.action');
|
||||
expect(missingServiceCommand).toBeUndefined();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,43 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleConfigFlow, PiHoleHttpMatcher, PiHoleManualMatcher, PiHoleCandidateValidator } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
tap.test('recognizes Pi-hole HTTP and manual discovery candidates', async () => {
|
||||
const httpMatch = await new PiHoleHttpMatcher().matches({
|
||||
url: 'http://192.168.1.2/admin/api.php?summaryRaw',
|
||||
headers: { server: 'lighttpd' },
|
||||
});
|
||||
const manualMatch = await new PiHoleManualMatcher().matches({
|
||||
host: 'pihole.local',
|
||||
name: 'Pi-hole',
|
||||
});
|
||||
const validation = await new PiHoleCandidateValidator().validate(httpMatch.candidate!);
|
||||
|
||||
expect(httpMatch.matched).toBeTrue();
|
||||
expect(httpMatch.candidate?.integrationDomain).toEqual('pi_hole');
|
||||
expect(httpMatch.candidate?.metadata?.apiVersion).toEqual(5);
|
||||
expect(httpMatch.candidate?.metadata?.location).toEqual('admin');
|
||||
expect(manualMatch.matched).toBeTrue();
|
||||
expect(manualMatch.candidate?.port).toEqual(80);
|
||||
expect(validation.matched).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('config flow parses Pi-hole URL and validates credentials shape', async () => {
|
||||
const step = await new PiHoleConfigFlow().start({ source: 'manual', name: 'Pi-hole' }, {});
|
||||
const missingKey = await step.submit!({ host: 'http://192.168.1.2:8080/admin/api.php' });
|
||||
const done = await step.submit!({
|
||||
host: 'http://192.168.1.2:8080/admin/api.php',
|
||||
apiKey: 'secret',
|
||||
apiVersion: '5',
|
||||
verifySsl: false,
|
||||
});
|
||||
|
||||
expect(missingKey.kind).toEqual('error');
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.2');
|
||||
expect(done.config?.port).toEqual(8080);
|
||||
expect(done.config?.location).toEqual('admin');
|
||||
expect(done.config?.apiVersion).toEqual(5);
|
||||
expect(done.config?.apiKey).toEqual('secret');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleMapper, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
const v5Snapshot: IPiHoleSnapshot = PiHoleMapper.toSnapshot({
|
||||
config: {
|
||||
host: '192.168.1.2',
|
||||
port: 80,
|
||||
name: 'Home DNS',
|
||||
apiVersion: 5,
|
||||
rawData: {
|
||||
v5Summary: {
|
||||
status: 'enabled',
|
||||
ads_blocked_today: 42,
|
||||
ads_percentage_today: 21.55,
|
||||
clients_ever_seen: 8,
|
||||
dns_queries_today: 200,
|
||||
domains_being_blocked: 150000,
|
||||
queries_cached: 50,
|
||||
queries_forwarded: 120,
|
||||
unique_clients: 5,
|
||||
unique_domains: 90,
|
||||
},
|
||||
v5Versions: {
|
||||
core_current: 'v5.18.3',
|
||||
core_latest: 'v5.18.4',
|
||||
core_update: true,
|
||||
web_current: 'v5.21',
|
||||
web_latest: 'v5.21',
|
||||
web_update: false,
|
||||
FTL_current: 'v5.25.2',
|
||||
FTL_latest: 'v5.25.2',
|
||||
FTL_update: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
|
||||
tap.test('maps Pi-hole v5 status, statistics, updates, and switch control', async () => {
|
||||
const devices = PiHoleMapper.toDevices(v5Snapshot);
|
||||
const entities = PiHoleMapper.toEntities(v5Snapshot);
|
||||
|
||||
expect(devices[0].id).toEqual('pi_hole.service.192_168_1_2_80');
|
||||
expect(devices[0].online).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.home_dns_status')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_blocked_today')?.state).toEqual(42);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_percentage_blocked_today')?.state).toEqual(21.6);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_dns_queries_today')?.state).toEqual(200);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.home_dns_core_update_available')?.attributes?.latestVersion).toEqual('v5.18.4');
|
||||
});
|
||||
|
||||
tap.test('models Pi-hole enable, disable, and refresh commands without secrets', async () => {
|
||||
const disableCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.home_dns' },
|
||||
data: { duration: '00:10:00' },
|
||||
});
|
||||
const enableCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'pi_hole',
|
||||
service: 'enable',
|
||||
target: { deviceId: 'pi_hole.service.192_168_1_2_80' },
|
||||
});
|
||||
const refreshCommand = PiHoleMapper.commandForService(v5Snapshot, {
|
||||
domain: 'pi_hole',
|
||||
service: 'refresh',
|
||||
target: {},
|
||||
});
|
||||
|
||||
expect(Boolean(disableCommand && !('error' in disableCommand))).toBeTrue();
|
||||
if (disableCommand && !('error' in disableCommand)) {
|
||||
expect(disableCommand.type).toEqual('disable');
|
||||
expect(disableCommand.path).toEqual('/admin/api.php');
|
||||
expect(disableCommand.query).toEqual({ disable: 600 });
|
||||
expect(JSON.stringify(disableCommand).includes('auth')).toBeFalse();
|
||||
}
|
||||
expect(Boolean(enableCommand && !('error' in enableCommand))).toBeTrue();
|
||||
if (enableCommand && !('error' in enableCommand)) {
|
||||
expect(enableCommand.query).toEqual({ enable: 'True' });
|
||||
}
|
||||
expect(refreshCommand && !('error' in refreshCommand) ? refreshCommand.type : undefined).toEqual('refresh');
|
||||
});
|
||||
|
||||
tap.test('maps Pi-hole v6 nested summary and version payloads', async () => {
|
||||
const snapshot = PiHoleMapper.toSnapshot({
|
||||
config: {
|
||||
host: 'pihole.local',
|
||||
name: 'Family DNS',
|
||||
apiVersion: 6,
|
||||
rawData: {
|
||||
v6Blocking: { blocking: 'disabled' },
|
||||
v6Summary: {
|
||||
queries: { blocked: 12, percent_blocked: 24.123, total: 50, cached: 8, forwarded: 30, unique_domains: 40 },
|
||||
clients: { total: 6, active: 3 },
|
||||
gravity: { domains_being_blocked: 180000 },
|
||||
},
|
||||
v6Versions: {
|
||||
version: {
|
||||
core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.1', hash: 'b' } },
|
||||
web: { local: { version: 'v6.0', hash: 'c' }, remote: { version: 'v6.0', hash: 'c' } },
|
||||
ftl: { local: { version: 'v6.0', hash: 'd' }, remote: { version: 'v6.0', hash: 'd' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
online: true,
|
||||
source: 'manual',
|
||||
});
|
||||
const entities = PiHoleMapper.toEntities(snapshot);
|
||||
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.family_dns')?.state).toEqual('off');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_blocked')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_percentage_blocked')?.state).toEqual(24.12);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_dns_queries')?.state).toEqual(50);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.family_dns_core_update_available')?.state).toEqual('on');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,112 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { PiHoleClient, PiHoleIntegration, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
|
||||
|
||||
const snapshot: IPiHoleSnapshot = {
|
||||
online: true,
|
||||
apiVersion: 6,
|
||||
status: 'enabled',
|
||||
statistics: {
|
||||
adsBlocked: 1,
|
||||
adsPercentage: 10,
|
||||
clientsSeen: 1,
|
||||
dnsQueries: 10,
|
||||
domainsBlocked: 100,
|
||||
queriesCached: 2,
|
||||
queriesForwarded: 7,
|
||||
uniqueClients: 1,
|
||||
uniqueDomains: 8,
|
||||
},
|
||||
versions: { core: {}, web: {}, ftl: {} },
|
||||
name: 'Pi-hole',
|
||||
};
|
||||
|
||||
tap.test('reads Pi-hole v6 local HTTP API and executes disable through HTTP', async () => {
|
||||
const requests: Array<{ method?: string; url?: string; sid?: string; csrf?: string; body?: string }> = [];
|
||||
const server = createServer((requestArg, responseArg) => {
|
||||
let body = '';
|
||||
requestArg.on('data', (chunkArg) => { body += String(chunkArg); });
|
||||
requestArg.on('end', () => {
|
||||
requests.push({
|
||||
method: requestArg.method,
|
||||
url: requestArg.url,
|
||||
sid: requestArg.headers['x-ftl-sid'] as string | undefined,
|
||||
csrf: requestArg.headers['x-ftl-csrf'] as string | undefined,
|
||||
body,
|
||||
});
|
||||
responseArg.setHeader('content-type', 'application/json');
|
||||
if (requestArg.method === 'POST' && requestArg.url === '/api/auth') {
|
||||
responseArg.end(JSON.stringify({ session: { valid: true, sid: 'sid-1', csrf: 'csrf-1', validity: 300 } }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'GET' && requestArg.url === '/api/stats/summary') {
|
||||
responseArg.end(JSON.stringify({ queries: { blocked: 5, percent_blocked: 25, total: 20, cached: 4, forwarded: 10, unique_domains: 12 }, clients: { total: 3, active: 2 }, gravity: { domains_being_blocked: 100000 } }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'GET' && requestArg.url === '/api/dns/blocking') {
|
||||
responseArg.end(JSON.stringify({ blocking: 'enabled' }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'GET' && requestArg.url === '/api/info/version') {
|
||||
responseArg.end(JSON.stringify({ version: { core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.0', hash: 'a' } }, web: {}, ftl: {} } }));
|
||||
return;
|
||||
}
|
||||
if (requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking') {
|
||||
responseArg.end(JSON.stringify({ blocking: false, timer: 15 }));
|
||||
return;
|
||||
}
|
||||
responseArg.statusCode = 404;
|
||||
responseArg.end(JSON.stringify({ error: 'not found' }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
const port = typeof address === 'object' && address ? address.port : 0;
|
||||
const runtime = await new PiHoleIntegration().setup({ host: '127.0.0.1', port, apiKey: 'secret', apiVersion: 6, timeoutMs: 1000 }, {});
|
||||
const entities = await runtime.entities();
|
||||
const result = await runtime.callService?.({
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: entities.find((entityArg) => entityArg.platform === 'switch')?.id },
|
||||
data: { duration: '00:00:15' },
|
||||
});
|
||||
|
||||
const disableRequest = requests.find((requestArg) => requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.pi_hole_dns_queries')?.state).toEqual(20);
|
||||
expect(result?.success).toBeTrue();
|
||||
expect(disableRequest?.sid).toEqual('sid-1');
|
||||
expect(disableRequest?.csrf).toEqual('csrf-1');
|
||||
expect(JSON.parse(disableRequest?.body || '{}')).toEqual({ blocking: false, timer: 15 });
|
||||
await runtime.destroy();
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not report snapshot-only Pi-hole commands as successful', async () => {
|
||||
const runtime = await new PiHoleIntegration().setup({ snapshot }, {});
|
||||
const switchEntity = (await runtime.entities()).find((entityArg) => entityArg.platform === 'switch');
|
||||
const result = await runtime.callService?.({
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: switchEntity?.id },
|
||||
});
|
||||
|
||||
expect(result?.success).toBeFalse();
|
||||
expect(result?.error).toEqual('Pi-hole live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('does not fake refresh success without HTTP endpoint or snapshot', async () => {
|
||||
const client = new PiHoleClient({});
|
||||
const snapshotResult = await client.getSnapshot();
|
||||
const refreshResult = await client.refresh();
|
||||
|
||||
expect(snapshotResult.online).toBeFalse();
|
||||
expect(refreshResult.success).toBeFalse();
|
||||
expect(refreshResult.error).toContain('endpoint');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SqueezeboxConfigFlow } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
tap.test('creates Squeezebox config from discovered host and credentials', async () => {
|
||||
const flow = new SqueezeboxConfigFlow();
|
||||
const step = await flow.start({
|
||||
source: 'mdns',
|
||||
integrationDomain: 'squeezebox',
|
||||
id: 'server-uuid-1',
|
||||
host: 'home-lms.local',
|
||||
port: 9000,
|
||||
name: 'Home LMS',
|
||||
}, {});
|
||||
|
||||
expect(step.kind).toEqual('form');
|
||||
const done = await step.submit!({ username: 'lms', password: 'secret', https: true, volumeStep: 10 });
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('home-lms.local');
|
||||
expect(done.config?.https).toBeTrue();
|
||||
expect(done.config?.serverId).toEqual('server-uuid-1');
|
||||
expect(done.config?.volumeStep).toEqual(10);
|
||||
});
|
||||
|
||||
tap.test('requires an LMS host for manual setup', async () => {
|
||||
const flow = new SqueezeboxConfigFlow();
|
||||
const step = await flow.start({ source: 'manual', integrationDomain: 'squeezebox' }, {});
|
||||
const result = await step.submit!({});
|
||||
expect(result.kind).toEqual('error');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,40 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { createSqueezeboxDiscoveryDescriptor } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
tap.test('matches LMS mDNS advertisements', async () => {
|
||||
const descriptor = createSqueezeboxDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({
|
||||
name: 'Home LMS._squeezebox-jsonrpc._tcp.local.',
|
||||
type: '_squeezebox-jsonrpc._tcp.local.',
|
||||
host: 'home-lms.local',
|
||||
port: 9000,
|
||||
txt: { uuid: 'server-uuid-1', name: 'Home LMS' },
|
||||
}, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.confidence).toEqual('certain');
|
||||
expect(result.normalizedDeviceId).toEqual('server-uuid-1');
|
||||
expect(result.candidate?.host).toEqual('home-lms.local');
|
||||
expect(result.candidate?.port).toEqual(9000);
|
||||
});
|
||||
|
||||
tap.test('matches DHCP player hints and manual LMS candidates', async () => {
|
||||
const descriptor = createSqueezeboxDiscoveryDescriptor();
|
||||
const dhcp = await descriptor.getMatchers()[1].matches({
|
||||
hostname: 'squeezebox-kitchen',
|
||||
macaddress: '00:04:20:AA:BB:02',
|
||||
ipAddress: '192.168.1.51',
|
||||
}, {});
|
||||
expect(dhcp.matched).toBeTrue();
|
||||
expect(dhcp.candidate?.metadata?.playerDiscovery).toBeTrue();
|
||||
|
||||
const manual = await descriptor.getMatchers()[2].matches({ host: '192.168.1.40', name: 'Manual LMS' }, {});
|
||||
expect(manual.matched).toBeTrue();
|
||||
expect(manual.candidate?.port).toEqual(9000);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(manual.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SqueezeboxMapper, type ISqueezeboxSnapshot } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
const snapshot: ISqueezeboxSnapshot = {
|
||||
server: {
|
||||
id: 'lms-1',
|
||||
uuid: 'server-uuid-1',
|
||||
name: 'Home LMS',
|
||||
host: '192.168.1.40',
|
||||
version: '8.5.0',
|
||||
playerCount: 2,
|
||||
stats: { totalSongs: 1200, totalAlbums: 100 },
|
||||
},
|
||||
players: [{
|
||||
playerId: '00:04:20:aa:bb:01',
|
||||
name: 'Living Room',
|
||||
model: 'Squeezebox Radio',
|
||||
firmware: '8.5.0',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'play',
|
||||
volume: 45,
|
||||
muting: false,
|
||||
repeat: 'playlist',
|
||||
shuffle: 'song',
|
||||
title: 'Example Track',
|
||||
artist: 'Example Artist',
|
||||
album: 'Example Album',
|
||||
url: 'http://radio.example/stream.mp3',
|
||||
duration: 180,
|
||||
time: 30,
|
||||
syncGroup: ['00:04:20:aa:bb:02'],
|
||||
}, {
|
||||
playerId: '00:04:20:aa:bb:02',
|
||||
name: 'Kitchen',
|
||||
model: 'Squeezebox Touch',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'pause',
|
||||
volume: 25,
|
||||
muting: true,
|
||||
syncGroup: ['00:04:20:aa:bb:01'],
|
||||
}],
|
||||
favorites: [{
|
||||
id: 'fav-1',
|
||||
name: 'Jazz Radio',
|
||||
url: 'http://radio.example/stream.mp3',
|
||||
type: 'audio',
|
||||
playable: true,
|
||||
}],
|
||||
syncGroups: [{
|
||||
id: 'sync-main',
|
||||
name: 'Downstairs',
|
||||
leaderPlayerId: '00:04:20:aa:bb:01',
|
||||
playerIds: ['00:04:20:aa:bb:01', '00:04:20:aa:bb:02'],
|
||||
}],
|
||||
online: true,
|
||||
source: 'snapshot',
|
||||
};
|
||||
|
||||
tap.test('maps Squeezebox server and players to devices', async () => {
|
||||
const devices = SqueezeboxMapper.toDevices(snapshot);
|
||||
const server = devices.find((deviceArg) => deviceArg.id === 'squeezebox.server.server_uuid_1');
|
||||
const player = devices.find((deviceArg) => deviceArg.id === 'squeezebox.player.00_04_20_aa_bb_01');
|
||||
expect(server?.state.some((stateArg) => stateArg.featureId === 'favorites' && stateArg.value === 1)).toBeTrue();
|
||||
expect(player?.manufacturer).toEqual('Logitech');
|
||||
expect(player?.state.some((stateArg) => stateArg.featureId === 'source' && stateArg.value === 'Jazz Radio')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('maps Squeezebox media entities favorites and sync groups', async () => {
|
||||
const entities = SqueezeboxMapper.toEntities(snapshot);
|
||||
const player = entities.find((entityArg) => entityArg.id === 'media_player.living_room');
|
||||
const favorites = entities.find((entityArg) => entityArg.id === 'sensor.home_lms_favorites');
|
||||
const syncGroups = entities.find((entityArg) => entityArg.id === 'sensor.home_lms_sync_groups');
|
||||
const media = entities.find((entityArg) => entityArg.id === 'sensor.living_room_squeezebox_media');
|
||||
|
||||
expect(player?.state).toEqual('playing');
|
||||
expect(player?.attributes?.volumeLevel).toEqual(0.45);
|
||||
expect(player?.attributes?.source).toEqual('Jazz Radio');
|
||||
expect(player?.attributes?.groupMembers).toEqual(['media_player.living_room', 'media_player.kitchen']);
|
||||
expect(player?.attributes?.repeat).toEqual('all');
|
||||
expect(favorites?.state).toEqual(1);
|
||||
expect(syncGroups?.state).toEqual(1);
|
||||
expect(media?.state).toEqual('Example Track');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,91 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SqueezeboxClient, SqueezeboxIntegration, type ISqueezeboxRawCommandRequest, type ISqueezeboxSnapshot } from '../../ts/integrations/squeezebox/index.js';
|
||||
|
||||
const snapshot: ISqueezeboxSnapshot = {
|
||||
server: { id: 'lms-1', uuid: 'server-uuid-1', name: 'Home LMS', host: '192.168.1.40' },
|
||||
players: [{
|
||||
playerId: '00:04:20:aa:bb:01',
|
||||
name: 'Living Room',
|
||||
model: 'Squeezebox Radio',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'pause',
|
||||
volume: 20,
|
||||
syncGroup: ['00:04:20:aa:bb:02'],
|
||||
}, {
|
||||
playerId: '00:04:20:aa:bb:02',
|
||||
name: 'Kitchen',
|
||||
model: 'Squeezebox Touch',
|
||||
connected: true,
|
||||
power: true,
|
||||
mode: 'pause',
|
||||
volume: 30,
|
||||
syncGroup: ['00:04:20:aa:bb:01'],
|
||||
}],
|
||||
favorites: [{ id: 'fav-1', name: 'Jazz Radio', url: 'http://radio.example/stream.mp3', playable: true }],
|
||||
syncGroups: [{ id: 'sync-main', playerIds: ['00:04:20:aa:bb:01', '00:04:20:aa:bb:02'], leaderPlayerId: '00:04:20:aa:bb:01' }],
|
||||
online: true,
|
||||
};
|
||||
|
||||
tap.test('models Squeezebox playback volume source and sync commands through an executor', async () => {
|
||||
const commands: ISqueezeboxRawCommandRequest[] = [];
|
||||
const runtime = await new SqueezeboxIntegration().setup({
|
||||
snapshot,
|
||||
commandExecutor: {
|
||||
execute: async (requestArg) => {
|
||||
commands.push(requestArg);
|
||||
return { id: requestArg.body?.id, result: {} };
|
||||
},
|
||||
},
|
||||
}, {});
|
||||
|
||||
const play = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
|
||||
const volume = await runtime.callService!({ domain: 'media_player', service: 'volume_set', target: { entityId: 'media_player.living_room' }, data: { volume_level: 0.35 } });
|
||||
const source = await runtime.callService!({ domain: 'media_player', service: 'select_source', target: { entityId: 'media_player.living_room' }, data: { source: 'Jazz Radio' } });
|
||||
const join = await runtime.callService!({ domain: 'media_player', service: 'join', target: { entityId: 'media_player.living_room' }, data: { group_members: ['media_player.kitchen'] } });
|
||||
const unjoin = await runtime.callService!({ domain: 'media_player', service: 'unjoin', target: { entityId: 'media_player.kitchen' } });
|
||||
|
||||
expect(play.success).toBeTrue();
|
||||
expect(volume.success).toBeTrue();
|
||||
expect(source.success).toBeTrue();
|
||||
expect(join.success).toBeTrue();
|
||||
expect(unjoin.success).toBeTrue();
|
||||
expect(commands.map((commandArg) => commandArg.command)).toEqual([
|
||||
['play'],
|
||||
['mixer', 'volume', '35'],
|
||||
['playlist', 'play', 'http://radio.example/stream.mp3'],
|
||||
['sync', '00:04:20:aa:bb:02'],
|
||||
['sync', '-'],
|
||||
]);
|
||||
expect(commands[0].playerId).toEqual('00:04:20:aa:bb:01');
|
||||
expect(commands[4].playerId).toEqual('00:04:20:aa:bb:02');
|
||||
});
|
||||
|
||||
tap.test('does not report live command success for static snapshots without transport', async () => {
|
||||
const runtime = await new SqueezeboxIntegration().setup({ snapshot }, {});
|
||||
const result = await runtime.callService!({ domain: 'media_player', service: 'media_play', target: { entityId: 'media_player.living_room' } });
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error?.includes('config.host or commandExecutor')).toBeTrue();
|
||||
});
|
||||
|
||||
tap.test('uses native LMS JSON-RPC over HTTP for live commands', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: ISqueezeboxRawCommandRequest['body']; authorization?: string }> = [];
|
||||
globalThis.fetch = (async (urlArg: URL | RequestInfo, initArg?: RequestInit) => {
|
||||
const headers = initArg?.headers as Record<string, string> | undefined;
|
||||
calls.push({ url: String(urlArg), body: JSON.parse(String(initArg?.body)), authorization: headers?.authorization });
|
||||
return new Response(JSON.stringify({ id: 1, method: 'slim.request', result: {} }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
await new SqueezeboxClient({ host: 'lms.local', username: 'user', password: 'pass' }).execute({ command: 'set_volume', playerId: '00:04:20:aa:bb:01', volumeLevel: 0.5 });
|
||||
expect(calls[0].url).toEqual('http://lms.local:9000/jsonrpc.js');
|
||||
expect(calls[0].body?.method).toEqual('slim.request');
|
||||
expect(calls[0].body?.params).toEqual(['00:04:20:aa:bb:01', ['mixer', 'volume', '50']]);
|
||||
expect(calls[0].authorization?.startsWith('Basic ')).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { HomeAssistantSynologyDsmIntegration, type ISynologyDsmCommand, type ISynologyDsmConfig } from '../../ts/integrations/synology_dsm/index.js';
|
||||
|
||||
const config: ISynologyDsmConfig = {
|
||||
host: '192.168.1.20',
|
||||
snapshot: {
|
||||
connected: true,
|
||||
system: {
|
||||
serial: 'SYN123',
|
||||
name: 'DiskStation',
|
||||
host: '192.168.1.20',
|
||||
model: 'DS920+',
|
||||
versionString: 'DSM 7.2.2-72806',
|
||||
},
|
||||
utilization: {},
|
||||
storage: { volumes: [], disks: [] },
|
||||
network: {},
|
||||
cameras: [],
|
||||
switches: [],
|
||||
actions: [],
|
||||
},
|
||||
};
|
||||
|
||||
tap.test('does not fake Synology DSM command success without injected executor', async () => {
|
||||
const runtime = await new HomeAssistantSynologyDsmIntegration().setup(config, {});
|
||||
const result = await runtime.callService!({ domain: 'synology_dsm', service: 'reboot', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toContain('not faked');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('executes explicit Synology DSM commands through injected executor', async () => {
|
||||
let command: ISynologyDsmCommand | undefined;
|
||||
const runtime = await new HomeAssistantSynologyDsmIntegration().setup({
|
||||
...config,
|
||||
commandExecutor: async (commandArg) => {
|
||||
command = commandArg;
|
||||
return { success: true, data: { accepted: true } };
|
||||
},
|
||||
}, {});
|
||||
const result = await runtime.callService!({ domain: 'synology_dsm', service: 'shutdown', target: {} });
|
||||
|
||||
expect(result.success).toBeTrue();
|
||||
expect(command?.type).toEqual('system.action');
|
||||
expect(command?.action).toEqual('shutdown');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
tap.test('reports offline refresh without snapshot, provider, or native client', async () => {
|
||||
const runtime = await new HomeAssistantSynologyDsmIntegration().setup({ host: '192.168.1.20' }, {});
|
||||
const result = await runtime.callService!({ domain: 'synology_dsm', service: 'refresh', target: {} });
|
||||
|
||||
expect(result.success).toBeFalse();
|
||||
expect(result.error || '').toContain('nativeClient');
|
||||
await runtime.destroy();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SynologyDsmConfigFlow, createSynologyDsmDiscoveryDescriptor, type ISynologyDsmSnapshot } from '../../ts/integrations/synology_dsm/index.js';
|
||||
|
||||
const snapshot: ISynologyDsmSnapshot = {
|
||||
connected: true,
|
||||
system: {
|
||||
serial: 'SYN123',
|
||||
name: 'DiskStation',
|
||||
host: '192.168.1.20',
|
||||
port: 5001,
|
||||
ssl: true,
|
||||
model: 'DS920+',
|
||||
},
|
||||
utilization: {},
|
||||
storage: { volumes: [], disks: [] },
|
||||
network: {},
|
||||
cameras: [],
|
||||
switches: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
tap.test('matches and validates manual Synology DSM entries', async () => {
|
||||
const descriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const result = await matcher.matches({ host: '192.168.1.20', name: 'NAS' }, {});
|
||||
|
||||
expect(result.matched).toBeTrue();
|
||||
expect(result.candidate?.integrationDomain).toEqual('synology_dsm');
|
||||
expect(result.candidate?.port).toEqual(5001);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(result.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
expect(validation.normalizedDeviceId).toEqual('192.168.1.20:5001');
|
||||
});
|
||||
|
||||
tap.test('matches Synology HTTP, SSDP, and mDNS local candidates', async () => {
|
||||
const descriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
const httpResult = await descriptor.getMatchers()[1].matches({ url: 'https://diskstation.local:5001/webapi/query.cgi' }, {});
|
||||
const ssdpResult = await descriptor.getMatchers()[2].matches({
|
||||
location: 'http://192.168.1.20:5000/description.xml',
|
||||
manufacturer: 'Synology',
|
||||
upnp: {
|
||||
friendlyName: 'DiskStation (DS920+)',
|
||||
modelName: 'DS920+',
|
||||
manufacturer: 'Synology',
|
||||
serialNumber: 'AABBCCDDEEFF',
|
||||
},
|
||||
}, {});
|
||||
const mdnsResult = await descriptor.getMatchers()[3].matches({
|
||||
type: '_http._tcp.local.',
|
||||
name: 'DiskStation._http._tcp.local.',
|
||||
host: 'diskstation.local',
|
||||
properties: {
|
||||
vendor: 'synology',
|
||||
mac_address: 'AA:BB:CC:DD:EE:FF',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(httpResult.matched).toBeTrue();
|
||||
expect(httpResult.candidate?.host).toEqual('diskstation.local');
|
||||
expect(ssdpResult.matched).toBeTrue();
|
||||
expect(ssdpResult.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
expect(mdnsResult.matched).toBeTrue();
|
||||
expect(mdnsResult.normalizedDeviceId).toEqual('aa:bb:cc:dd:ee:ff');
|
||||
});
|
||||
|
||||
tap.test('creates config flow output from discovery and snapshot JSON', async () => {
|
||||
const step = await new SynologyDsmConfigFlow().start({ source: 'manual', host: '192.168.1.20', id: 'SYN123', name: 'DiskStation' }, {});
|
||||
const done = await step.submit!({ username: 'admin', password: 'secret', snapshotJson: JSON.stringify(snapshot), snapshotQuality: '2' });
|
||||
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.20');
|
||||
expect(done.config?.port).toEqual(5001);
|
||||
expect(done.config?.snapshot?.system.serial).toEqual('SYN123');
|
||||
expect(done.config?.snapshotQuality).toEqual(2);
|
||||
});
|
||||
|
||||
tap.test('rejects candidates without Synology DSM hints or usable data', async () => {
|
||||
const descriptor = createSynologyDsmDiscoveryDescriptor();
|
||||
const httpResult = await descriptor.getMatchers()[1].matches({ url: 'http://example.local/status' }, {});
|
||||
expect(httpResult.matched).toBeFalse();
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate({ source: 'manual', name: 'Synology DSM' }, {});
|
||||
expect(validation.matched).toBeFalse();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { SynologyDsmMapper, type ISynologyDsmSnapshot } from '../../ts/integrations/synology_dsm/index.js';
|
||||
|
||||
const snapshot: ISynologyDsmSnapshot = {
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
system: {
|
||||
serial: 'SYN123',
|
||||
name: 'DiskStation',
|
||||
hostname: 'diskstation',
|
||||
host: '192.168.1.20',
|
||||
port: 5001,
|
||||
ssl: true,
|
||||
model: 'DS920+',
|
||||
versionString: 'DSM 7.2.2-72806',
|
||||
temperature: 42,
|
||||
uptimeSeconds: 3600,
|
||||
macs: ['AA:BB:CC:DD:EE:FF'],
|
||||
},
|
||||
utilization: {
|
||||
cpuUserLoad: 12,
|
||||
cpuSystemLoad: 5,
|
||||
cpuTotalLoad: 17,
|
||||
cpu5MinLoad: 23,
|
||||
memoryRealUsage: 64,
|
||||
memorySize: 8_589_934_592,
|
||||
networkUp: 1024,
|
||||
networkDown: 2048,
|
||||
},
|
||||
storage: {
|
||||
volumes: [
|
||||
{ id: 'volume_1', name: 'Volume 1', status: 'normal', sizeTotal: 4_000, sizeUsed: 2_000, percentageUsed: 50, diskTempAvg: 38, diskTempMax: 41, deviceType: 'shr' },
|
||||
],
|
||||
disks: [
|
||||
{ id: 'disk_1', name: 'Drive 1', vendor: 'Seagate', model: 'IronWolf', status: 'normal', smartStatus: 'normal', temperature: 36, exceedBadSectorThreshold: false, belowRemainLifeThreshold: false },
|
||||
],
|
||||
},
|
||||
network: {
|
||||
hostname: 'diskstation',
|
||||
macs: ['AA:BB:CC:DD:EE:FF'],
|
||||
uploadRate: 1024,
|
||||
downloadRate: 2048,
|
||||
},
|
||||
cameras: [
|
||||
{ id: '1', name: 'Front Door', model: 'BC500', enabled: true, recording: true, motionDetectionEnabled: true, rtsp: 'rtsp://nas/camera/1' },
|
||||
],
|
||||
update: {
|
||||
installedVersion: 'DSM 7.2.2-72806',
|
||||
latestVersion: 'DSM 7.3-73000',
|
||||
updateAvailable: true,
|
||||
releaseUrl: 'http://update.synology.com/autoupdate/whatsnew.php?model=DS920%2B&update_version=73000',
|
||||
},
|
||||
switches: [
|
||||
{ key: 'home_mode', name: 'Home mode', enabled: true, type: 'home_mode' },
|
||||
],
|
||||
security: {
|
||||
status: 'safe',
|
||||
statusByCheck: { malware: 'safe' },
|
||||
},
|
||||
actions: [],
|
||||
};
|
||||
|
||||
tap.test('maps Synology DSM system, storage, network, camera, switch, and update snapshot data', async () => {
|
||||
const normalized = SynologyDsmMapper.toSnapshot({ snapshot });
|
||||
const devices = SynologyDsmMapper.toDevices(normalized);
|
||||
const entities = SynologyDsmMapper.toEntities(normalized);
|
||||
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.nas.syn123')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.volume.syn123.volume_1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.disk.syn123.disk_1')).toBeTrue();
|
||||
expect(devices.some((deviceArg) => deviceArg.id === 'synology_dsm.camera.syn123.1')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'cpu_user_load')?.state).toEqual(12);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'cpu_5min_load')?.state).toEqual(0.23);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'volume_percentage_used')?.state).toEqual(50);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'disk_temp')?.state).toEqual(36);
|
||||
expect(entities.find((entityArg) => entityArg.attributes?.nativeKey === 'network_down')?.state).toEqual(2048);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.front_door_motion_detection')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.diskstation_surveillance_station_home_mode')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'update.diskstation_dsm_update')?.attributes?.latestVersion).toEqual('DSM 7.3-73000');
|
||||
});
|
||||
|
||||
tap.test('models represented Synology DSM commands without executing them', async () => {
|
||||
const normalized = SynologyDsmMapper.toSnapshot({ snapshot });
|
||||
const rebootCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'synology_dsm',
|
||||
service: 'reboot',
|
||||
target: {},
|
||||
});
|
||||
const homeModeCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
target: { entityId: 'switch.diskstation_surveillance_station_home_mode' },
|
||||
});
|
||||
const shutdownCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'button',
|
||||
service: 'press',
|
||||
target: { entityId: 'button.diskstation_shutdown' },
|
||||
});
|
||||
const cameraCommand = SynologyDsmMapper.commandForService(normalized, {
|
||||
domain: 'camera',
|
||||
service: 'disable_motion_detection',
|
||||
target: { deviceId: 'synology_dsm.camera.syn123.1' },
|
||||
});
|
||||
|
||||
expect(rebootCommand?.type).toEqual('system.action');
|
||||
expect(rebootCommand?.action).toEqual('reboot');
|
||||
expect(homeModeCommand?.type).toEqual('switch.set');
|
||||
expect(homeModeCommand?.payload?.enabled).toBeFalse();
|
||||
expect(shutdownCommand?.action).toEqual('shutdown');
|
||||
expect(cameraCommand?.type).toEqual('camera.action');
|
||||
expect(cameraCommand?.cameraId).toEqual('1');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user