Files
scrypted/server/src/plugin/plugin-device.ts

419 lines
17 KiB
TypeScript

import { DeviceProvider, EventDetails, EventListenerOptions, EventListenerRegister, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedInterfaceDescriptors, ScryptedInterfaceProperty } from "@scrypted/sdk/types";
import { ScryptedRuntime } from "../runtime";
import { PluginDevice } from "../db-types";
import { MixinProvider } from "@scrypted/sdk/types";
import { handleFunctionInvocations, PrimitiveProxyHandler } from "../rpc";
import { getState } from "../state";
import { getDisplayType } from "../infer-defaults";
import { allInterfaceProperties, isValidInterfaceMethod, methodInterfaces } from "./descriptor";
import { PluginError } from "./plugin-error";
import { sleep } from "../sleep";
import path from 'path';
import fs from 'fs';
interface MixinTable {
mixinProviderId: string;
entry: Promise<MixinTableEntry>;
}
interface MixinTableEntry {
interfaces: Set<string>
allInterfaces: string[];
proxy: any;
error?: Error;
passthrough: boolean;
}
export const RefreshSymbol = Symbol('ScryptedDeviceRefresh');
export const QueryInterfaceSymbol = Symbol("ScryptedPluginDeviceQueryInterface");
export class PluginDeviceProxyHandler implements PrimitiveProxyHandler<any>, ScryptedDevice {
scrypted: ScryptedRuntime;
id: string;
mixinTable: MixinTable[];
releasing = new Set<any>();
constructor(scrypted: ScryptedRuntime, id: string) {
this.scrypted = scrypted;
this.id = id;
}
toPrimitive() {
return `PluginDevice-${this.id}`;
}
invalidateEntry(mixinEntry: MixinTable) {
if (!mixinEntry?.mixinProviderId)
return;
(async () => {
const mixinProvider = this.scrypted.getDevice(mixinEntry.mixinProviderId) as ScryptedDevice & MixinProvider;
const { proxy } = await mixinEntry.entry;
// allow mixins in the process of being released to manage final
// events, etc, before teardown.
this.releasing.add(proxy);
mixinProvider?.releaseMixin(this.id, proxy);
await sleep(1000);
this.releasing.delete(proxy);
})().catch(() => { });
}
async isMixin(id: string, mixinDevice: any) {
if (this.releasing.has(mixinDevice))
return true;
await this.scrypted.devices[id].handler.ensureProxy();
for (const mixin of this.scrypted.devices[id].handler.mixinTable) {
const { proxy } = await mixin.entry;
if (proxy === mixinDevice) {
return true;
}
}
return false;
}
// should this be async?
invalidate() {
const mixinTable = this.mixinTable;
this.mixinTable = undefined;
for (const mixinEntry of (mixinTable || [])) {
this.invalidateEntry(mixinEntry);
}
}
/**
* Rebuild the mixin table with any currently missing mixins.
*/
rebuildMixinTable() {
if (!this.mixinTable)
return this.invalidate();
let previousMixinIds = this.mixinTable?.map(entry => entry.mixinProviderId) || [];
previousMixinIds.pop();
previousMixinIds = previousMixinIds.reverse();
const pluginDevice = this.scrypted.findPluginDeviceById(this.id);
if (!pluginDevice)
return this.invalidate();
const mixins = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || [];
// iterate the new mixin table to find the last good mixin,
// and resume creation from there.
let lastValidMixinId: string;
for (const mixinId of mixins) {
if (!previousMixinIds.length) {
// reached of the previous mixin table, meaning
// mixins were added.
break;
}
const check = previousMixinIds.shift();
if (check !== mixinId)
break;
lastValidMixinId = mixinId;
}
if (!lastValidMixinId)
return this.invalidate();
// invalidate and remove everything up to lastValidMixinId
while (true) {
const entry = this.mixinTable[0];
if (entry.mixinProviderId === lastValidMixinId)
break;
this.mixinTable.shift();
this.invalidateEntry(entry);
console.log('invalidating mixin', this.id, entry.mixinProviderId);
}
this.ensureProxy(lastValidMixinId);
}
// this must not be async, because it potentially changes execution order.
ensureProxy(lastValidMixinId?: string): Promise<PluginDevice> {
const pluginDevice = this.scrypted.findPluginDeviceById(this.id);
if (!pluginDevice)
throw new PluginError(`device ${this.id} does not exist`);
if (!lastValidMixinId) {
if (this.mixinTable)
return Promise.resolve(pluginDevice);
this.mixinTable = [];
const entry = (async () => {
let proxy;
try {
if (!pluginDevice.nativeId) {
const plugin = this.scrypted.plugins[pluginDevice.pluginId];
proxy = await plugin.module;
}
else {
const providerId = getState(pluginDevice, ScryptedInterfaceProperty.providerId);
const provider = this.scrypted.getDevice(providerId) as ScryptedDevice & DeviceProvider;
proxy = await provider.getDevice(pluginDevice.nativeId);
}
if (!proxy)
console.warn('no device was returned by the plugin', this.id);
}
catch (e) {
console.warn('error occured retrieving device from plugin');
}
const interfaces: ScryptedInterface[] = getState(pluginDevice, ScryptedInterfaceProperty.providedInterfaces) || [];
return {
passthrough: false,
proxy,
interfaces: new Set<string>(interfaces),
allInterfaces: interfaces,
}
})();
this.mixinTable.unshift({
mixinProviderId: undefined,
entry,
});
}
else {
if (!this.mixinTable)
throw new PluginError('mixin table partial invalidation was called with empty mixin table');
const prevTable = this.mixinTable.find(table => table.mixinProviderId === lastValidMixinId);
if (!prevTable)
throw new PluginError('mixin table partial invalidation was called with invalid lastValidMixinId');
}
for (const mixinId of getState(pluginDevice, ScryptedInterfaceProperty.mixins) || []) {
if (lastValidMixinId) {
if (mixinId === lastValidMixinId)
lastValidMixinId = undefined;
continue;
}
const wrappedMixinTable = this.mixinTable.slice();
const entry = this.rebuildEntry(pluginDevice, mixinId, Promise.resolve(wrappedMixinTable));
this.mixinTable.unshift({
mixinProviderId: mixinId,
entry,
});
}
return this.mixinTable[0].entry.then(entry => {
this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.interfaces, entry.allInterfaces);
return pluginDevice;
});
}
async rebuildEntry(pluginDevice: PluginDevice, mixinId: string, wrappedMixinTablePromise: Promise<MixinTable[]>): Promise<MixinTableEntry> {
const wrappedMixinTable = await wrappedMixinTablePromise;
const previousEntry = wrappedMixinTable[0].entry;
const type = getDisplayType(pluginDevice);
let { allInterfaces } = await previousEntry;
try {
const mixinProvider = this.scrypted.getDevice(mixinId) as ScryptedDevice & MixinProvider;
const interfaces = mixinProvider?.interfaces?.includes(ScryptedInterface.MixinProvider) && await mixinProvider?.canMixin(type, allInterfaces) as any as ScryptedInterface[];
if (!interfaces) {
// this is not an error
// do not advertise interfaces so it is skipped during
// vtable lookup.
console.log(`mixin provider ${mixinId} can no longer mixin ${this.id}`);
const mixins: string[] = getState(pluginDevice, ScryptedInterfaceProperty.mixins) || [];
this.scrypted.stateManager.setPluginDeviceState(pluginDevice, ScryptedInterfaceProperty.mixins, mixins.filter(mid => mid !== mixinId))
this.scrypted.datastore.upsert(pluginDevice);
return {
passthrough: true,
allInterfaces,
interfaces: new Set<string>(),
proxy: undefined as any,
};
}
const previousInterfaces = allInterfaces;
allInterfaces = previousInterfaces.slice();
allInterfaces.push(...interfaces);
const combinedInterfaces = [...new Set(allInterfaces)];
const wrappedHandler = new PluginDeviceProxyHandler(this.scrypted, this.id);
wrappedHandler.mixinTable = wrappedMixinTable;
const wrappedProxy = new Proxy(wrappedHandler, wrappedHandler);
const implementer = await (mixinProvider as any)[QueryInterfaceSymbol](ScryptedInterface.MixinProvider);
const host = this.scrypted.getPluginHostForDeviceId(implementer);
// todo: remove this and pass the setter directly.
const deviceState = await host.remote.createDeviceState(this.id,
async (property, value) => this.scrypted.stateManager.setPluginDeviceState(pluginDevice, property, value));
const mixinProxy = await mixinProvider.getMixin(wrappedProxy, previousInterfaces as ScryptedInterface[], deviceState);
if (!mixinProxy)
throw new PluginError(`mixin provider ${mixinId} did not return mixin for ${this.id}`);
// mixin is a passthrough of no interfaces changed, and the proxy is the same
// a mixin can be a passthrough even if it implements an interface (like Settings),
// so long as the wrapped proxy also implements that interface.
// techically it is a passthrough if the proxies are the same instance, but
// better to be explicit here about interface differences.
const passthrough = wrappedProxy === mixinProxy && previousInterfaces.length === combinedInterfaces.length;
return {
passthrough,
interfaces: new Set<string>(interfaces),
allInterfaces: combinedInterfaces,
proxy: mixinProxy,
};
}
catch (e) {
// on any error, do not advertise interfaces
// on this mixin, so as to prevent total failure?
// this has been the behavior for a while,
// but maybe interfaces implemented by that mixin
// should rethrow the error caught here in applyMixin.
console.warn('mixin error', e);
return {
passthrough: false,
allInterfaces,
interfaces: new Set<string>(),
error: e,
proxy: undefined as any,
};
}
}
get(target: any, p: PropertyKey, receiver: any): any {
if (p === 'constructor')
return;
const handled = handleFunctionInvocations(this, target, p, receiver);
if (handled)
return handled;
const pluginDevice = this.scrypted.findPluginDeviceById(this.id);
// device may be deleted.
if (!pluginDevice)
return;
const prop = p.toString();
if (allInterfaceProperties.includes(prop))
return getState(pluginDevice, prop);
if (p === RefreshSymbol || p === QueryInterfaceSymbol)
return new Proxy(() => p, this);
if (!isValidInterfaceMethod(pluginDevice.state.interfaces.value, prop))
return;
if (ScryptedInterfaceDescriptors[ScryptedInterface.ScryptedDevice].methods.includes(prop))
return (this as any)[p].bind(this);
return new Proxy(() => prop, this);
}
listen(event: string | EventListenerOptions, callback: (eventSource: ScryptedDevice, eventDetails: EventDetails, eventData: any) => void): EventListenerRegister {
return this.scrypted.stateManager.listenDevice(this.id, event, (eventDetails, eventData) => callback(this.scrypted.getDevice(this.id), eventDetails, eventData));
}
async setName(name: string): Promise<void> {
const device = this.scrypted.findPluginDeviceById(this.id);
this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.name, name);
this.scrypted.stateManager.updateDescriptor(device);
}
async setRoom(room: string): Promise<void> {
const device = this.scrypted.findPluginDeviceById(this.id);
this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.room, room);
this.scrypted.stateManager.updateDescriptor(device);
}
async setType(type: ScryptedDeviceType): Promise<void> {
const device = this.scrypted.findPluginDeviceById(this.id);
this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.type, type);
this.scrypted.stateManager.updateDescriptor(device);
}
async probe(): Promise<boolean> {
try {
await this.ensureProxy();
return true;
}
catch (e) {
return false;
}
}
async applyMixin(method: string, argArray?: any): Promise<any> {
const iface = methodInterfaces[method];
if (!iface)
throw new PluginError(`unknown method ${method}`);
const found = await this.findMixin(iface);
if (found) {
const { mixin, entry } = found;
const { proxy } = entry;
if (!proxy)
throw new PluginError(`device is unavailable ${this.id} (mixin ${mixin.mixinProviderId})`);
return proxy[method](...argArray);
}
throw new PluginError(`${method} not implemented`)
}
async findMixin(iface: string) {
for (const mixin of this.mixinTable) {
const entry = await mixin.entry;
const { interfaces } = entry;
if (interfaces.has(iface)) {
return { mixin, entry };
}
}
}
async apply(target: any, thisArg: any, argArray?: any): Promise<any> {
const method = target();
this.ensureProxy();
const pluginDevice = this.scrypted.findPluginDeviceById(this.id);
if (method === RefreshSymbol)
return this.applyMixin('refresh', argArray);
if (method === QueryInterfaceSymbol) {
const iface = argArray[0];
const found = await this.findMixin(iface);
if (found?.entry.interfaces.has(iface)) {
return found.mixin.mixinProviderId || this.id;
}
throw new PluginError(`${iface} not implemented`)
}
if (method === 'getReadmeMarkdown') {
const pluginDevice = this.scrypted.findPluginDeviceById(this.id);
if (pluginDevice && !pluginDevice.nativeId) {
const plugin = this.scrypted.plugins[pluginDevice.pluginId];
if (!plugin.packageJson.scrypted.interfaces.includes(ScryptedInterface.Readme)) {
const readmePath = path.join(plugin.unzippedPath, 'README.md');
if (fs.existsSync(readmePath)) {
try {
return fs.readFileSync(readmePath).toString();
}
catch (e) {
return "# Error loading Readme:\n\n" + e;
}
}
}
}
}
if (!isValidInterfaceMethod(pluginDevice.state.interfaces.value, method))
throw new PluginError(`device ${this.id} does not support method ${method}`);
if (method === 'refresh') {
const refreshInterface = argArray[0];
const userInitiated = argArray[1];
return this.scrypted.stateManager.refresh(this.id, refreshInterface, userInitiated);
}
if (method === 'createDevice') {
const nativeId = await this.applyMixin(method, argArray);
const newDevice = this.scrypted.findPluginDevice(pluginDevice.pluginId, nativeId);
return newDevice._id;
}
return this.applyMixin(method, argArray);
}
}