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; } interface MixinTableEntry { interfaces: Set allInterfaces: string[]; proxy: any; error?: Error; passthrough: boolean; } export const RefreshSymbol = Symbol('ScryptedDeviceRefresh'); export const QueryInterfaceSymbol = Symbol("ScryptedPluginDeviceQueryInterface"); export class PluginDeviceProxyHandler implements PrimitiveProxyHandler, ScryptedDevice { scrypted: ScryptedRuntime; id: string; mixinTable: MixinTable[]; releasing = new Set(); 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 { 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(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): Promise { 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(), 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(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(), 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 { 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 { 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 { const device = this.scrypted.findPluginDeviceById(this.id); this.scrypted.stateManager.setPluginDeviceState(device, ScryptedInterfaceProperty.type, type); this.scrypted.stateManager.updateDescriptor(device); } async probe(): Promise { try { await this.ensureProxy(); return true; } catch (e) { return false; } } async applyMixin(method: string, argArray?: any): Promise { 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 { 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); } }