mirror of
https://github.com/koush/scrypted.git
synced 2026-02-12 18:12:04 +00:00
402 lines
15 KiB
TypeScript
402 lines
15 KiB
TypeScript
import { connectScryptedClient, ScryptedClientStatic } from '@scrypted/client/src/index';
|
|
import sdk, { BufferConverter, Battery, Device, DeviceCreator, DeviceCreatorSettings, DeviceManifest, DeviceProvider, FFmpegInput, ScryptedDevice, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, MediaObjectOptions, MediaManager } from '@scrypted/sdk';
|
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
import https from 'https';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { MediaObjectRemote } from '../../../server/src/plugin/plugin-api';
|
|
import { RpcPeer } from '../../../server/src/rpc';
|
|
|
|
const { deviceManager } = sdk;
|
|
|
|
export class MediaObject implements MediaObjectRemote {
|
|
__proxy_props: any;
|
|
|
|
constructor(public mimeType: string, public data: any, options: MediaObjectOptions) {
|
|
this.__proxy_props = {
|
|
mimeType,
|
|
}
|
|
if (options) {
|
|
for (const [key, value] of Object.entries(options)) {
|
|
if (RpcPeer.isTransportSafe(key))
|
|
this.__proxy_props[key] = value;
|
|
(this as any)[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
async getData(): Promise<Buffer | string> {
|
|
return Promise.resolve(this.data);
|
|
}
|
|
}
|
|
|
|
interface RemoteMediaObject extends MediaObjectRemote {
|
|
realMimeType: string;
|
|
}
|
|
|
|
class ScryptedRemoteInstance extends ScryptedDeviceBase implements DeviceProvider, Settings, BufferConverter {
|
|
client: ScryptedClientStatic = null;
|
|
|
|
devices = new Map<string, ScryptedDevice>();
|
|
|
|
settingsStorage = new StorageSettings(this, {
|
|
baseUrl: {
|
|
title: 'Base URL',
|
|
placeholder: 'https://localhost:10443',
|
|
onPut: async () => await this.clearTryDiscoverDevices(),
|
|
},
|
|
username: {
|
|
title: 'Username',
|
|
onPut: async () => await this.clearTryDiscoverDevices(),
|
|
},
|
|
password: {
|
|
title: 'Password',
|
|
type: 'password',
|
|
onPut: async () => await this.clearTryDiscoverDevices(),
|
|
},
|
|
});
|
|
|
|
fromMimeType: string = ""
|
|
toMimeType: string = ""
|
|
|
|
constructor(nativeId: string) {
|
|
super(nativeId);
|
|
this.clearTryDiscoverDevices();
|
|
|
|
|
|
this.fromMimeType = 'x-scrypted-remote/x-media-object-' + this.id;
|
|
this.toMimeType = '*';
|
|
sdk.mediaManager.addConverter(this);
|
|
}
|
|
|
|
|
|
/**
|
|
* Checks the given remote device to see if it can be correctly imported by this plugin.
|
|
* Returns the (potentially modified) device that is allowed, or null if the device cannot
|
|
* be imported.
|
|
*
|
|
* @param device
|
|
* The local device representation. Will be modified in-place and returned.
|
|
*/
|
|
filtered(device: Device): Device {
|
|
// only permit the following device types through
|
|
const allowedTypes = [
|
|
ScryptedDeviceType.Camera,
|
|
ScryptedDeviceType.Doorbell,
|
|
ScryptedDeviceType.DeviceProvider,
|
|
ScryptedDeviceType.API,
|
|
]
|
|
if (!allowedTypes.includes(device.type)) {
|
|
return null;
|
|
}
|
|
|
|
// only permit the following functional interfaces through
|
|
const allowedInterfaces = [
|
|
ScryptedInterface.VideoRecorder,
|
|
ScryptedInterface.VideoClips,
|
|
ScryptedInterface.EventRecorder,
|
|
ScryptedInterface.VideoCamera,
|
|
ScryptedInterface.Camera,
|
|
ScryptedInterface.RTCSignalingChannel,
|
|
ScryptedInterface.Battery,
|
|
ScryptedInterface.MotionSensor,
|
|
ScryptedInterface.AudioSensor,
|
|
ScryptedInterface.DeviceProvider,
|
|
ScryptedInterface.ObjectDetection,
|
|
];
|
|
const intersection = allowedInterfaces.filter(i => device.interfaces.includes(i));
|
|
if (intersection.length == 0) {
|
|
return null;
|
|
}
|
|
|
|
// explicitly drop plugins if all they do is provide devices
|
|
if (device.interfaces.includes(ScryptedInterface.ScryptedPlugin) && intersection.length == 1 && intersection[0] == ScryptedInterface.DeviceProvider) {
|
|
return null;
|
|
}
|
|
|
|
// some extra interfaces that are nice to expose, but not needed
|
|
const nonessentialInterfaces = [
|
|
ScryptedInterface.Readme,
|
|
];
|
|
const nonessentialIntersection = nonessentialInterfaces.filter(i => device.interfaces.includes(i));
|
|
|
|
device.interfaces = intersection.concat(nonessentialIntersection);
|
|
return device;
|
|
}
|
|
|
|
/**
|
|
* Configures relevant proxies for the local device representation and the remote device.
|
|
* Listeners are added for interface property updates, and select remote function calls are
|
|
* intercepted to tweak arguments for better remote integration.
|
|
*
|
|
* @param device
|
|
* The local device representation.
|
|
*
|
|
* @param remoteDevice
|
|
* The RPC reference to the remote device.
|
|
*/
|
|
setupProxies(device: Device, remoteDevice: ScryptedDevice) {
|
|
// set up event listeners for all the relevant interfaces
|
|
device.interfaces.map(iface => remoteDevice.listen(iface, (source, details, data) => {
|
|
if (!details.property) {
|
|
deviceManager.onDeviceEvent(device.nativeId, details.eventInterface, data);
|
|
} else {
|
|
deviceManager.getDeviceState(device.nativeId)[details.property] = data;
|
|
}
|
|
}));
|
|
|
|
// for certain interfaces with fixed state, transfer the initial values over
|
|
if (device.interfaces.includes(ScryptedInterface.Battery)) {
|
|
deviceManager.getDeviceState(device.nativeId).batteryLevel = (<Battery>remoteDevice).batteryLevel;
|
|
}
|
|
|
|
// for device providers, we intercept calls to load device representations
|
|
// stored within this plugin instance, instead of directly from the remote
|
|
if (device.interfaces.includes(ScryptedInterface.DeviceProvider)) {
|
|
(<DeviceProvider><any>remoteDevice).getDevice = async (nativeId: string): Promise<Device> => {
|
|
return <Device>this.devices.get(nativeId);
|
|
}
|
|
(<DeviceProvider><any>remoteDevice).releaseDevice = async (id: string, nativeId: string): Promise<any> => {
|
|
// don't delete the device from the remote
|
|
this.releaseDevice(id, nativeId);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resets the connection to the remote Scrypted server and attempts to reconnect
|
|
* and rediscover remoted devices.
|
|
*/
|
|
async clearTryDiscoverDevices(): Promise<void> {
|
|
await this.tryLogin();
|
|
// bjia56:
|
|
// there's some race condition with multi-tier device discovery that I haven't
|
|
// sorted out, but it appears to work fine if we run discovery twice
|
|
await this.discoverDevices(0);
|
|
await this.discoverDevices(0);
|
|
}
|
|
|
|
async tryLogin(): Promise<void> {
|
|
this.client = null;
|
|
|
|
if (!this.settingsStorage.values.baseUrl || !this.settingsStorage.values.username || !this.settingsStorage.values.password) {
|
|
this.console.log("Initializing remote Scrypted login requires the base URL, username, and password");
|
|
return;
|
|
}
|
|
|
|
const httpsAgent = new https.Agent({
|
|
rejectUnauthorized: false,
|
|
});
|
|
this.client = await connectScryptedClient({
|
|
baseUrl: this.settingsStorage.values.baseUrl,
|
|
pluginId: '@scrypted/core',
|
|
username: this.settingsStorage.values.username,
|
|
password: this.settingsStorage.values.password,
|
|
axiosConfig: {
|
|
httpsAgent,
|
|
},
|
|
});
|
|
|
|
this.client.onClose = () => {
|
|
this.console.log('client killed, reconnecting in 60s');
|
|
setTimeout(async () => await this.clearTryDiscoverDevices(), 60000);
|
|
}
|
|
|
|
/* bjia56: since the MediaObject conversion isn't completely implemented, disable this for now
|
|
const { rpcPeer } = this.client;
|
|
const map = new WeakMap<RemoteMediaObject, MediaObject>();
|
|
rpcPeer.nameDeserializerMap.set('MediaObject', {
|
|
serialize(value, serializationContext) {
|
|
throw new Error();
|
|
},
|
|
deserialize: (mo: RemoteMediaObject, serializationContext) => {
|
|
let rmo = map.get(mo);
|
|
if (rmo)
|
|
return rmo;
|
|
rmo = new MediaObject(this.fromMimeType, mo, {});
|
|
map.set(mo, rmo);
|
|
// mo.realMimeType = mo.mimeType;
|
|
// mo.mimeType = this.fromMimeType;
|
|
// mo.getData = async() => mo as any;
|
|
// mo.mediaManager = this.client.mediaManager;
|
|
return rmo;
|
|
},
|
|
});
|
|
*/
|
|
|
|
this.console.log(`Connected to remote Scrypted server. Remote server version: ${this.client.serverVersion}`)
|
|
}
|
|
|
|
async convert(data: RemoteMediaObject, fromMimeType: string, toMimeType: string, options?: MediaObjectOptions): Promise<any> {
|
|
if (toMimeType.startsWith('x-scrypted-remote/x-media-object'))
|
|
return data;
|
|
let ret = await this.client.mediaManager.convertMediaObject(data, toMimeType);
|
|
if (toMimeType === ScryptedMimeTypes.FFmpegInput) {
|
|
const ffmpegInput = JSON.parse(ret.toString()) as FFmpegInput;
|
|
if (ffmpegInput.urls?.[0]) {
|
|
ffmpegInput.url = ffmpegInput.urls[0];
|
|
delete ffmpegInput.urls;
|
|
ret = Buffer.from(JSON.stringify(ffmpegInput));
|
|
}
|
|
}
|
|
else if (toMimeType === ScryptedMimeTypes.LocalUrl) {
|
|
ret = Buffer.from(new URL(ret.toString(),this.settingsStorage.values.baseUrl).toString());
|
|
}
|
|
return sdk.mediaManager.createMediaObject(ret, toMimeType);
|
|
return ret;
|
|
}
|
|
|
|
getSettings(): Promise<Setting[]> {
|
|
return this.settingsStorage.getSettings();
|
|
}
|
|
|
|
putSetting(key: string, value: SettingValue): Promise<void> {
|
|
return this.settingsStorage.putSetting(key, value);
|
|
}
|
|
|
|
async discoverDevices(duration: number): Promise<void> {
|
|
if (!this.client) {
|
|
return
|
|
}
|
|
|
|
// construct initial (flat) list of devices from the remote server
|
|
const state = this.client.systemManager.getSystemState();
|
|
const devices = <Device[]>[];
|
|
for (const id in state) {
|
|
const remoteDevice = this.client.systemManager.getDeviceById(id);
|
|
const remoteProviderDevice = this.client.systemManager.getDeviceById(remoteDevice.providerId);
|
|
const remoteProviderNativeId = remoteProviderDevice?.id == remoteDevice.id ? undefined : remoteProviderDevice?.id;
|
|
|
|
const nativeId = `${this.nativeId}:${remoteDevice.id}`;
|
|
const device = this.filtered(<Device>{
|
|
name: remoteDevice.name,
|
|
type: remoteDevice.type,
|
|
interfaces: remoteDevice.interfaces,
|
|
info: remoteDevice.info,
|
|
nativeId: nativeId,
|
|
providerNativeId: remoteProviderNativeId ? `${this.nativeId}:${remoteProviderNativeId}` : this.nativeId,
|
|
});
|
|
if (!device) {
|
|
this.console.log(`Device ${remoteDevice.name} is not supported, ignoring`)
|
|
continue;
|
|
}
|
|
|
|
this.console.log(`Found ${remoteDevice.name}\n${JSON.stringify(device, null, 2)}`);
|
|
this.devices.set(device.nativeId, remoteDevice);
|
|
devices.push(device)
|
|
}
|
|
|
|
// it may be that a parent device was filtered out, so reparent these child devices to
|
|
// the top level
|
|
devices.map(device => {
|
|
if (!this.devices.has(device.providerNativeId)) {
|
|
device.providerNativeId = this.nativeId;
|
|
}
|
|
});
|
|
|
|
// group devices by parent provider id
|
|
const providerDeviceMap = new Map<string, Device[]>();
|
|
devices.map(device => {
|
|
// group devices by parent provider id
|
|
if (!providerDeviceMap.has(device.providerNativeId)) {
|
|
providerDeviceMap.set(device.providerNativeId, [device]);
|
|
} else {
|
|
providerDeviceMap.get(device.providerNativeId).push(device);
|
|
}
|
|
})
|
|
|
|
// first register the top level devices, then register the remaining
|
|
// devices by provider id
|
|
// top level devices are discovered one by one to avoid clobbering
|
|
providerDeviceMap.get(this.nativeId).map(async device => {
|
|
await deviceManager.onDeviceDiscovered(device);
|
|
});
|
|
for (let [providerNativeId, devices] of providerDeviceMap) {
|
|
await deviceManager.onDevicesChanged(<DeviceManifest>{
|
|
devices,
|
|
providerNativeId,
|
|
});
|
|
}
|
|
|
|
// setup relevant proxies and monkeypatches for all devices
|
|
devices.map(device => this.setupProxies(device, this.devices.get(device.nativeId)));
|
|
this.console.log(`Discovered ${devices.length} devices`);
|
|
}
|
|
|
|
async getDevice(nativeId: string): Promise<Device> {
|
|
if (!this.devices.has(nativeId)) {
|
|
throw new Error(`${nativeId} does not exist`);
|
|
}
|
|
return <Device>this.devices.get(nativeId);
|
|
}
|
|
|
|
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
|
this.devices.delete(nativeId)
|
|
}
|
|
}
|
|
|
|
class ScryptedRemotePlugin extends ScryptedDeviceBase implements DeviceCreator, DeviceProvider {
|
|
remotes = new Map<string, ScryptedRemoteInstance>();
|
|
|
|
async getDevice(nativeId: string): Promise<Device> {
|
|
if (!this.remotes.has(nativeId)) {
|
|
this.remotes.set(nativeId, new ScryptedRemoteInstance(nativeId));
|
|
}
|
|
return this.remotes.get(nativeId) as Device;
|
|
}
|
|
|
|
async releaseDevice(id: string, nativeId: string): Promise<void> {
|
|
return;
|
|
}
|
|
|
|
async getCreateDeviceSettings(): Promise<Setting[]> {
|
|
return [
|
|
{
|
|
key: 'name',
|
|
title: 'Name',
|
|
},
|
|
{
|
|
key: 'baseUrl',
|
|
title: 'Base URL',
|
|
placeholder: 'https://localhost:10443',
|
|
},
|
|
{
|
|
key: 'username',
|
|
title: 'Username',
|
|
},
|
|
{
|
|
key: 'password',
|
|
title: 'Password',
|
|
type: 'password',
|
|
},
|
|
];
|
|
}
|
|
|
|
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
|
|
const name = settings.name?.toString();
|
|
const url = settings.baseUrl?.toString();
|
|
const username = settings.username?.toString();
|
|
const password = settings.password?.toString();
|
|
|
|
const nativeId = uuidv4();
|
|
await deviceManager.onDeviceDiscovered(<Device>{
|
|
nativeId,
|
|
name,
|
|
interfaces: [
|
|
ScryptedInterface.BufferConverter,
|
|
ScryptedInterface.Settings,
|
|
ScryptedInterface.DeviceProvider
|
|
],
|
|
type: ScryptedDeviceType.DeviceProvider,
|
|
});
|
|
|
|
const remote = await this.getDevice(nativeId) as ScryptedRemoteInstance;
|
|
remote.storage.setItem("baseUrl", url);
|
|
remote.storage.setItem("username", username);
|
|
remote.storage.setItem("password", password);
|
|
await remote.clearTryDiscoverDevices();
|
|
return nativeId;
|
|
}
|
|
}
|
|
|
|
export default new ScryptedRemotePlugin();
|