Add native local network integrations
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { AmcrestClient } from '../../ts/integrations/amcrest/index.js';
|
||||
|
||||
tap.test('fetches live snapshots and only reports command success after HTTP response', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: string[] = [];
|
||||
globalThis.fetch = (async (inputArg: RequestInfo | URL) => {
|
||||
const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url;
|
||||
requests.push(url);
|
||||
if (url.includes('/cgi-bin/magicBox.cgi?action=getDeviceType')) {
|
||||
return new Response('IP2M-841');
|
||||
}
|
||||
if (url.includes('/cgi-bin/magicBox.cgi?action=getVendor')) {
|
||||
return new Response('Amcrest');
|
||||
}
|
||||
if (url.includes('/cgi-bin/magicBox.cgi?action=getSerialNo')) {
|
||||
return new Response('AMC123');
|
||||
}
|
||||
if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=Encode')) {
|
||||
return new Response('table.Encode[0].MainFormat[0].VideoEnable=true\ntable.Encode[0].MainFormat[0].AudioEnable=true');
|
||||
}
|
||||
if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=RecordMode')) {
|
||||
return new Response('table.RecordMode[0].Mode=Manual');
|
||||
}
|
||||
if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=MotionDetect')) {
|
||||
return new Response('table.MotionDetect[0].Enable=true\ntable.MotionDetect[0].EventHandler.RecordEnable=false');
|
||||
}
|
||||
if (url.includes('/cgi-bin/configManager.cgi?action=getConfig&name=LeLensMask')) {
|
||||
return new Response('table.LeLensMask[0].Enable=false');
|
||||
}
|
||||
if (url.includes('/cgi-bin/eventManager.cgi?action=getEventIndexes&code=VideoMotion')) {
|
||||
return new Response('channels[0]=0');
|
||||
}
|
||||
if (url.includes('/cgi-bin/ptz.cgi?action=getPresets')) {
|
||||
return new Response('presets[0].Name=Home\npresets[1].Name=Driveway');
|
||||
}
|
||||
if (url.includes('/cgi-bin/configManager.cgi?action=setConfig&LeLensMask[0].Enable=true')) {
|
||||
return new Response('OK');
|
||||
}
|
||||
if (url.includes('/cgi-bin/snapshot.cgi?channel=0')) {
|
||||
return new Response(new Uint8Array([0xff, 0xd8, 0xff]), { headers: { 'content-type': 'image/jpeg' } });
|
||||
}
|
||||
return new Response('Not Found', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new AmcrestClient({ host: '192.168.1.30', username: 'user', password: 'pass' });
|
||||
const snapshot = await client.getSnapshot();
|
||||
expect(snapshot.connected).toBeTrue();
|
||||
expect(snapshot.deviceInfo.manufacturer).toEqual('Amcrest');
|
||||
expect(snapshot.cameras[0].rtspUrl).toEqual('rtsp://user:pass@192.168.1.30:554/cam/realmonitor?channel=1&subtype=0');
|
||||
expect(snapshot.binarySensors.find((sensorArg) => sensorArg.key === 'motion_detected')?.isOn).toBeTrue();
|
||||
expect(snapshot.switches.find((switchArg) => switchArg.key === 'privacy_mode')?.isOn).toEqual(false);
|
||||
expect(snapshot.sensors.find((sensorArg) => sensorArg.key === 'ptz_preset')?.value).toEqual(2);
|
||||
|
||||
const image = await client.execute({ type: 'snapshot_image', service: 'snapshot', channel: 0 });
|
||||
expect((image as { contentType: string }).contentType).toEqual('image/jpeg');
|
||||
|
||||
const result = await client.execute({ type: 'set_privacy_mode', service: 'turn_on', enabled: true, channel: 0 });
|
||||
expect((result as { ok: boolean }).ok).toBeTrue();
|
||||
expect(requests.some((requestArg) => requestArg.includes('LeLensMask[0].Enable=true'))).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('does not pretend live commands succeeded without a live endpoint or success body', async () => {
|
||||
const clientWithoutHost = new AmcrestClient({});
|
||||
let missingHostError = '';
|
||||
try {
|
||||
await clientWithoutHost.execute({ type: 'set_privacy_mode', service: 'turn_on', enabled: true });
|
||||
} catch (errorArg) {
|
||||
missingHostError = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
expect(missingHostError.includes('requires config.host or config.url')).toBeTrue();
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (inputArg: RequestInfo | URL) => {
|
||||
const url = typeof inputArg === 'string' ? inputArg : inputArg instanceof URL ? inputArg.toString() : inputArg.url;
|
||||
if (url.includes('/cgi-bin/magicBox.cgi?action=getDeviceType')) {
|
||||
return new Response('IP2M-841');
|
||||
}
|
||||
if (url.includes('/cgi-bin/configManager.cgi?action=setConfig&LeLensMask[0].Enable=true')) {
|
||||
return new Response('Error');
|
||||
}
|
||||
return new Response('', { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const client = new AmcrestClient({ host: '192.168.1.30' });
|
||||
await client.getSnapshot();
|
||||
let commandError = '';
|
||||
try {
|
||||
await client.execute({ type: 'set_privacy_mode', service: 'turn_on', enabled: true });
|
||||
} catch (errorArg) {
|
||||
commandError = errorArg instanceof Error ? errorArg.message : String(errorArg);
|
||||
}
|
||||
expect(commandError.includes('did not return a successful response')).toBeTrue();
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { AmcrestConfigFlow, createAmcrestDiscoveryDescriptor } from '../../ts/integrations/amcrest/index.js';
|
||||
|
||||
tap.test('matches manual Amcrest host entries and configures flow', async () => {
|
||||
const descriptor = createAmcrestDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[0];
|
||||
const match = await matcher.matches({ host: '192.168.1.30', name: 'Front Door' }, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.integrationDomain).toEqual('amcrest');
|
||||
expect(match.candidate?.host).toEqual('192.168.1.30');
|
||||
expect(match.candidate?.port).toEqual(80);
|
||||
|
||||
const validation = await descriptor.getValidators()[0].validate(match.candidate!, {});
|
||||
expect(validation.matched).toBeTrue();
|
||||
|
||||
const flow = new AmcrestConfigFlow();
|
||||
const step = await flow.start(match.candidate!, {});
|
||||
const done = await step.submit!({ username: 'admin', password: 'secret', streamSource: 'rtsp', resolution: 'low' });
|
||||
expect(done.kind).toEqual('done');
|
||||
expect(done.config?.host).toEqual('192.168.1.30');
|
||||
expect(done.config?.username).toEqual('admin');
|
||||
expect(done.config?.streamSource).toEqual('rtsp');
|
||||
expect(done.config?.resolution).toEqual('low');
|
||||
});
|
||||
|
||||
tap.test('matches local SSDP camera metadata', async () => {
|
||||
const descriptor = createAmcrestDiscoveryDescriptor();
|
||||
const matcher = descriptor.getMatchers()[2];
|
||||
const match = await matcher.matches({
|
||||
manufacturer: 'Amcrest',
|
||||
location: 'http://192.168.1.31:80/',
|
||||
upnp: {
|
||||
friendlyName: 'Garage Amcrest',
|
||||
modelName: 'IP8M-2496',
|
||||
serialNumber: 'AMC456',
|
||||
},
|
||||
}, {});
|
||||
|
||||
expect(match.matched).toBeTrue();
|
||||
expect(match.candidate?.host).toEqual('192.168.1.31');
|
||||
expect(match.candidate?.manufacturer).toEqual('Amcrest');
|
||||
expect(match.candidate?.model).toEqual('IP8M-2496');
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, tap } from '@git.zone/tstest/tapbundle';
|
||||
import { AmcrestMapper, type IAmcrestSnapshot } from '../../ts/integrations/amcrest/index.js';
|
||||
|
||||
const snapshot: IAmcrestSnapshot = {
|
||||
deviceInfo: {
|
||||
id: 'AMC123',
|
||||
name: 'Front Door Amcrest',
|
||||
manufacturer: 'Amcrest',
|
||||
model: 'IP2M-841',
|
||||
serialNumber: 'AMC123',
|
||||
host: '192.168.1.30',
|
||||
port: 80,
|
||||
protocol: 'http',
|
||||
online: true,
|
||||
},
|
||||
cameras: [{
|
||||
id: '0',
|
||||
name: 'Front Door Camera',
|
||||
channel: 0,
|
||||
resolution: 'high',
|
||||
subtype: 0,
|
||||
streamSource: 'rtsp',
|
||||
snapshotUrl: 'http://192.168.1.30:80/cgi-bin/snapshot.cgi?channel=0',
|
||||
mjpegUrl: 'http://192.168.1.30:80/cgi-bin/mjpg/video.cgi?channel=0&subtype=0',
|
||||
rtspUrl: 'rtsp://user:pass@192.168.1.30:554/cam/realmonitor?channel=1&subtype=0',
|
||||
isStreaming: true,
|
||||
isRecording: false,
|
||||
motionDetectionEnabled: true,
|
||||
audioEnabled: true,
|
||||
supportsPtz: true,
|
||||
available: true,
|
||||
}],
|
||||
sensors: [{ key: 'ptz_preset', name: 'PTZ Preset', value: 2, entityCategory: 'diagnostic', available: true }],
|
||||
binarySensors: [
|
||||
{ key: 'online', name: 'Online', isOn: true, deviceClass: 'connectivity', shouldPoll: true, available: true },
|
||||
{ key: 'motion_detected', name: 'Motion Detected', isOn: true, deviceClass: 'motion', eventCodes: ['VideoMotion'], available: true },
|
||||
],
|
||||
switches: [{ key: 'privacy_mode', name: 'Privacy Mode', isOn: false, command: 'privacy_mode', entityCategory: 'config', available: true }],
|
||||
events: [],
|
||||
currentSettings: { privacy_mode: false },
|
||||
connected: true,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
tap.test('maps Amcrest camera streams, binary sensors, sensors, and switches', async () => {
|
||||
const devices = AmcrestMapper.toDevices(snapshot);
|
||||
const entities = AmcrestMapper.toEntities(snapshot);
|
||||
|
||||
expect(devices.length).toEqual(1);
|
||||
expect(devices[0].features.some((featureArg) => featureArg.capability === 'camera')).toBeTrue();
|
||||
expect(entities.find((entityArg) => entityArg.id === 'camera.front_door_camera')?.attributes?.rtspUrl).toEqual('rtsp://user:pass@192.168.1.30:554/cam/realmonitor?channel=1&subtype=0');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.motion_detected')?.state).toEqual('on');
|
||||
expect(entities.find((entityArg) => entityArg.id === 'sensor.ptz_preset')?.state).toEqual(2);
|
||||
expect(entities.find((entityArg) => entityArg.id === 'switch.privacy_mode')?.state).toEqual('off');
|
||||
});
|
||||
|
||||
tap.test('models camera, switch, snapshot, and PTZ services as explicit commands', async () => {
|
||||
const streamCommand = AmcrestMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'stream_source',
|
||||
target: { entityId: 'camera.front_door_camera' },
|
||||
});
|
||||
const snapshotCommand = AmcrestMapper.commandForService(snapshot, {
|
||||
domain: 'camera',
|
||||
service: 'snapshot',
|
||||
target: { entityId: 'camera.front_door_camera' },
|
||||
});
|
||||
const privacyCommand = AmcrestMapper.commandForService(snapshot, {
|
||||
domain: 'switch',
|
||||
service: 'turn_on',
|
||||
target: { entityId: 'switch.privacy_mode' },
|
||||
});
|
||||
const ptzCommand = AmcrestMapper.commandForService(snapshot, {
|
||||
domain: 'amcrest',
|
||||
service: 'ptz_control',
|
||||
target: { entityId: 'camera.front_door_camera' },
|
||||
data: { movement: 'left', travel_time: '0.1' },
|
||||
});
|
||||
const presetCommand = AmcrestMapper.commandForService(snapshot, {
|
||||
domain: 'amcrest',
|
||||
service: 'goto_preset',
|
||||
target: { entityId: 'camera.front_door_camera' },
|
||||
data: { preset: 2 },
|
||||
});
|
||||
|
||||
expect(streamCommand?.type).toEqual('stream_source');
|
||||
expect(snapshotCommand?.type).toEqual('snapshot_image');
|
||||
expect(snapshotCommand?.httpCommands?.[0].path).toEqual('/cgi-bin/snapshot.cgi?channel=0');
|
||||
expect(privacyCommand?.type).toEqual('set_privacy_mode');
|
||||
expect(privacyCommand?.enabled).toBeTrue();
|
||||
expect(privacyCommand?.httpCommands?.[0].path.includes('LeLensMask[0].Enable=true')).toBeTrue();
|
||||
expect(ptzCommand?.type).toEqual('ptz_control');
|
||||
expect(ptzCommand?.movement).toEqual('left');
|
||||
expect(ptzCommand?.travelTime).toEqual(0.1);
|
||||
expect(presetCommand?.type).toEqual('goto_preset');
|
||||
expect(presetCommand?.httpCommands?.[0].path.includes('code=GotoPreset')).toBeTrue();
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
Reference in New Issue
Block a user