mirror of
https://github.com/koush/scrypted.git
synced 2026-02-15 19:22:37 +00:00
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import sdk, { EventListener, EventListenerRegister, FFmpegInput, LockState, MediaStreamDestination, RequestMediaStreamOptions, ResponseMediaStreamOptions, ScryptedDevice, ScryptedDeviceBase, ScryptedInterface, ScryptedInterfaceDescriptors, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from "@scrypted/sdk";
|
|
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
|
const { systemManager, mediaManager, deviceManager } = sdk;
|
|
|
|
export interface AggregateDevice extends ScryptedDeviceBase {
|
|
computeInterfaces(): string[];
|
|
}
|
|
|
|
interface Aggregator<T> {
|
|
(values: T[]): T;
|
|
}
|
|
|
|
const aggregators = new Map<string, Aggregator<any>>();
|
|
|
|
const average: Aggregator<number> = values => values.reduce((prev, cur) => prev + (cur || 0), 0) / (values.length || 0);
|
|
const allFalse: Aggregator<boolean> = values => values.reduce((prev, cur) => prev || cur, false);
|
|
const allTrue: Aggregator<boolean> = values => values.reduce((prev, cur) => prev && cur, true);
|
|
|
|
aggregators.set(ScryptedInterface.Online, allTrue);
|
|
aggregators.set(ScryptedInterface.StartStop, allTrue);
|
|
aggregators.set(ScryptedInterface.Pause, allFalse);
|
|
aggregators.set(ScryptedInterface.OnOff, allFalse);
|
|
aggregators.set(ScryptedInterface.Brightness, average);
|
|
aggregators.set(ScryptedInterface.Battery, average);
|
|
aggregators.set(ScryptedInterface.MotionSensor, average);
|
|
aggregators.set(ScryptedInterface.HumiditySensor, average);
|
|
aggregators.set(ScryptedInterface.Thermometer, average);
|
|
aggregators.set(ScryptedInterface.BinarySensor, allFalse);
|
|
aggregators.set(ScryptedInterface.TamperSensor, allFalse);
|
|
aggregators.set(ScryptedInterface.PowerSensor, allFalse);
|
|
aggregators.set(ScryptedInterface.MotionSensor, allFalse);
|
|
aggregators.set(ScryptedInterface.AudioSensor, allFalse);
|
|
aggregators.set(ScryptedInterface.LuminanceSensor, average);
|
|
aggregators.set(ScryptedInterface.UltravioletSensor, average);
|
|
aggregators.set(ScryptedInterface.CO2Sensor, average);
|
|
aggregators.set(ScryptedInterface.PM25Sensor, average);
|
|
aggregators.set(ScryptedInterface.PM10Sensor, average);
|
|
aggregators.set(ScryptedInterface.NOXSensor, average);
|
|
aggregators.set(ScryptedInterface.FloodSensor, allFalse);
|
|
aggregators.set(ScryptedInterface.Lock,
|
|
values => values.reduce((prev, cur) => cur === LockState.Unlocked ? cur : prev, LockState.Locked));
|
|
|
|
|
|
function createVideoCamera(devices: VideoCamera[], console: Console): VideoCamera {
|
|
async function getVideoStreamWrapped(options: RequestMediaStreamOptions) {
|
|
const destination: MediaStreamDestination = devices.length > 4 ? 'low-resolution' : "medium-resolution";
|
|
const args = await Promise.allSettled(devices.map(async (device) => {
|
|
const mo = await device.getVideoStream({
|
|
destination,
|
|
});
|
|
const buffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
const ffmpegInput = JSON.parse(buffer.toString()) as FFmpegInput;
|
|
return ffmpegInput;
|
|
}));
|
|
|
|
const inputs = args.map(arg => (arg as PromiseFulfilledResult<FFmpegInput>).value).filter(input => !!input);
|
|
|
|
if (!inputs.length)
|
|
throw new Error('no inputs');
|
|
|
|
let dim = 1;
|
|
while (dim * dim < inputs.length) {
|
|
dim++;
|
|
}
|
|
|
|
const w = 1920 / dim;
|
|
const h = 1080 / dim;
|
|
|
|
const filter = [
|
|
'nullsrc=size=1920x1080 [base];'
|
|
];
|
|
|
|
const ffmpegInput: FFmpegInput = {
|
|
url: undefined,
|
|
container: 'rawvideo',
|
|
mediaStreamOptions: (await createVideoStreamOptions())?.[0],
|
|
inputArguments: [],
|
|
h264FilterArguments: [],
|
|
};
|
|
|
|
for (let i = 0; i < inputs.length; i++) {
|
|
ffmpegInput.inputArguments.push(...inputs[i].inputArguments);
|
|
filter.push(`[${i}:v] scale=-1:${h},pad=${w}:ih:(ow-iw)/2 [pos${i}];`)
|
|
}
|
|
for (let i = inputs.length; i < dim * dim; i++) {
|
|
ffmpegInput.inputArguments.push(
|
|
'-f', 'lavfi', '-i', `color=black:s=${w}x${h}`,
|
|
);
|
|
filter.push(`[${i}:v] scale=${w}x${h} [pos${i}];`)
|
|
}
|
|
|
|
let prev = 'base';
|
|
let curx = 0;
|
|
let cury = 0;
|
|
for (let i = 0; i < dim * dim - 1; i++) {
|
|
let cur = `tmp${i}`;
|
|
cury = Math.floor(i / dim) * h;
|
|
filter.push(`[${prev}][pos${i}] overlay=shortest=1:x=${curx % 1920}:y=${cury % 1080} [${cur}];`);
|
|
prev = cur;
|
|
curx += w;
|
|
}
|
|
|
|
|
|
let i = dim * dim - 1;
|
|
filter.push(`[${prev}][pos${i}] overlay=shortest=1:x=${curx % 1920}:y=${cury % 1080}`);
|
|
|
|
ffmpegInput.h264FilterArguments.push(
|
|
'-filter_complex',
|
|
filter.join(' '),
|
|
);
|
|
|
|
return ffmpegInput;
|
|
};
|
|
|
|
const createVideoStreamOptions: () => Promise<ResponseMediaStreamOptions[]> = async () => {
|
|
if (devices.length === 1)
|
|
return devices[0].getVideoStreamOptions();
|
|
return [{
|
|
id: 'default',
|
|
name: 'Default',
|
|
container: 'ffmpeg',
|
|
video: {},
|
|
audio: null,
|
|
}]
|
|
}
|
|
|
|
return {
|
|
async getVideoStreamOptions() {
|
|
return createVideoStreamOptions();
|
|
},
|
|
|
|
async getVideoStream(options) {
|
|
if (devices.length === 1)
|
|
return devices[0].getVideoStream(options);
|
|
|
|
const ffmpegInput = await getVideoStreamWrapped(options);
|
|
|
|
return mediaManager.createFFmpegMediaObject(ffmpegInput);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createAggregateDevice(nativeId: string): AggregateDevice {
|
|
class AggregateDeviceImpl extends ScryptedDeviceBase implements Settings {
|
|
listeners: EventListenerRegister[] = [];
|
|
storageSettings = new StorageSettings(this, {
|
|
deviceInterfaces: {
|
|
title: 'Selected Device Interfaces',
|
|
description: 'The components of other devices to combine into this device group.',
|
|
type: 'interface',
|
|
multiple: true,
|
|
deviceFilter: `id !== ${this.id} && interface !== ${ScryptedInterface.Settings}`,
|
|
}
|
|
})
|
|
|
|
constructor() {
|
|
super(nativeId);
|
|
|
|
try {
|
|
const data = this.storage.getItem('data');
|
|
if (data) {
|
|
const { deviceInterfaces } = JSON.parse(data);
|
|
this.storageSettings.values.deviceInterfaces = deviceInterfaces;
|
|
}
|
|
}
|
|
catch (e) {
|
|
}
|
|
this.storage.removeItem('data');
|
|
}
|
|
|
|
getSettings(): Promise<Setting[]> {
|
|
return this.storageSettings.getSettings();
|
|
}
|
|
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
return this.storageSettings.putSetting(key, value);
|
|
}
|
|
|
|
makeListener(iface: string, devices: ScryptedDevice[]) {
|
|
const aggregator = aggregators.get(iface);
|
|
if (!aggregator) {
|
|
const ds = deviceManager.getDeviceState(this.nativeId);
|
|
// if this device can't be aggregated for whatever reason, pass property through.
|
|
for (const device of devices) {
|
|
const register = device.listen({
|
|
event: iface,
|
|
watch: true,
|
|
}, (source, details, data) => {
|
|
if (details.property)
|
|
ds[details.property] = data;
|
|
});
|
|
this.listeners.push(register);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const property = ScryptedInterfaceDescriptors[iface]?.properties?.[0];
|
|
if (!property) {
|
|
this.console.warn('aggregating interface with no property?', iface);
|
|
return;
|
|
}
|
|
|
|
const runAggregator = () => {
|
|
const values = devices.map(device => device[property]);
|
|
(this as any)[property] = aggregator(values);
|
|
}
|
|
|
|
const listener: EventListener = () => runAggregator();
|
|
|
|
for (const device of devices) {
|
|
const register = device.listen({
|
|
event: iface,
|
|
watch: true,
|
|
}, listener);
|
|
this.listeners.push(register);
|
|
}
|
|
|
|
return runAggregator;
|
|
}
|
|
|
|
computeInterfaces(): string[] {
|
|
this.listeners.forEach(listener => listener.removeListener());
|
|
this.listeners = [];
|
|
|
|
try {
|
|
const interfaces = new Map<string, string[]>();
|
|
for (const deviceInterface of this.storageSettings.values.deviceInterfaces as string[]) {
|
|
const parts = deviceInterface.split('#');
|
|
const id = parts[0];
|
|
const iface = parts[1];
|
|
if (!interfaces.has(iface))
|
|
interfaces.set(iface, []);
|
|
interfaces.get(iface).push(id);
|
|
}
|
|
|
|
for (const [iface, ids] of interfaces.entries()) {
|
|
const devices = ids.map(id => systemManager.getDeviceById(id));
|
|
const runAggregator = this.makeListener(iface, devices);
|
|
runAggregator?.();
|
|
}
|
|
|
|
for (const [iface, ids] of interfaces.entries()) {
|
|
const devices = ids.map(id => systemManager.getDeviceById(id));
|
|
const descriptor = ScryptedInterfaceDescriptors[iface];
|
|
if (!descriptor) {
|
|
this.console.warn(`descriptor not found for ${iface}, skipping method generation`);
|
|
continue;
|
|
}
|
|
|
|
if (iface === ScryptedInterface.VideoCamera) {
|
|
const camera = createVideoCamera(devices as any, this.console);
|
|
for (const method of descriptor.methods) {
|
|
AggregateDeviceImpl.prototype[method] = (...args: any[]) => camera[method](...args);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
for (const method of descriptor.methods) {
|
|
AggregateDeviceImpl.prototype[method] = async function (...args: any[]) {
|
|
const ret: Promise<any>[] = [];
|
|
for (const device of devices) {
|
|
ret.push(device[method](...args));
|
|
}
|
|
|
|
const results = await Promise.all(ret);
|
|
return results[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...interfaces.keys()];
|
|
}
|
|
catch (e) {
|
|
// this.console.error('error loading aggregate device', e);
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
const ret = new AggregateDeviceImpl();
|
|
ret.computeInterfaces();
|
|
return new AggregateDeviceImpl();
|
|
} |