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