/******************************************************************************** * Copyright (c) 2018 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional * information regarding copyright ownership. * * This program and the accompanying materials are made available under the * terms of the Eclipse Public License v. 2.0 which is available at * http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and * Document License (2015-05-13) which is available at * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document. * * SPDX-License-Identifier: EPL-2.0 OR W3C-20150513 ********************************************************************************/ import * as WoT from "wot-typescript-definitions"; import * as TDT from "wot-thing-description-types"; import * as TD from "./thing-description"; import { serializeTD, setContextLanguage } from "./serdes"; import Servient from "./servient"; import Helpers from "./helpers"; import { InteractionOutput } from "./interaction-output"; import { Readable } from "stream"; import ProtocolHelpers from "./protocol-helpers"; import { ReadableStream as PolyfillStream } from "web-streams-polyfill"; import { Content, ContentSerdes, PropertyContentMap } from "./core"; import ContentManager from "./content-serdes"; import { ActionHandlerMap, ContentListener, EventHandlerMap, EventHandlers, PropertyHandlerMap, PropertyHandlers, } from "./protocol-interfaces"; import ProtocolListenerRegistry from "./protocol-listener-registry"; import { createLoggers } from "./logger"; const { debug } = createLoggers("core", "exposed-thing"); export default class ExposedThing extends TD.Thing implements WoT.ExposedThing { security: string | [string, ...string[]]; securityDefinitions: { [key: string]: TDT.SecurityScheme; }; id: string; title: string; base?: string; forms?: Array; /** A map of interactable Thing Properties with read()/write()/subscribe() functions */ properties: { [key: string]: TDT.PropertyElement; }; /** A map of interactable Thing Actions with invoke() function */ actions: { [key: string]: TDT.ActionElement; }; /** A map of interactable Thing Events with emit() function */ events: { [key: string]: TDT.EventElement; }; /** * A map of property (read & write) handler callback functions. * * By using the private modifier `#`, this class member is excluded from * the Thing Description generated by {@link getThingDescription}. */ #propertyHandlers: PropertyHandlerMap = new Map(); /** * A map of action handler callback functions. * * By using the private modifier `#`, this class member is excluded from * the Thing Description generated by {@link getThingDescription}. */ #actionHandlers: ActionHandlerMap = new Map(); /** * A map of event handler callback functions. * * By using the private modifier `#`, this class member is excluded from * the Thing Description generated by {@link getThingDescription}. */ #eventHandlers: EventHandlerMap = new Map(); /** * A map of property listener callback functions. * * By using the private modifier `#`, this class member is excluded from * the Thing Description generated by {@link getThingDescription}. */ #propertyListeners: ProtocolListenerRegistry = new ProtocolListenerRegistry(); /** * A map of event listener callback functions. * * By using the private modifier `#`, this class member is excluded from * the Thing Description generated by {@link getThingDescription}. */ #eventListeners: ProtocolListenerRegistry = new ProtocolListenerRegistry(); #servient: Servient; constructor(servient: Servient, thingModel: WoT.ExposedThingInit = {}) { super(); this.#servient = servient; // The init object might still have undefined values, so initialize them here. // TODO: who checks that those are valid? this.id = thingModel.id ?? ""; this.title = thingModel.title ?? ""; this.security = ""; this.securityDefinitions = {}; this.properties = {}; this.actions = {}; this.events = {}; // Deep clone the Thing Model // without functions or methods const deepClonedModel = Helpers.structuredClone(thingModel); Object.assign(this, deepClonedModel); // unset "@type":"tm:ThingModel" ? // see https://github.com/eclipse-thingweb/node-wot/issues/426 /* if (this["@type"]) { if (typeof this["@type"] === 'string' && this["@type"] === "tm:ThingModel") { delete this["@type"]; } else if (Array.isArray(this["@type"])) { let arr: Array = this["@type"]; for (var i = 0; i < arr.length; i++) { if (arr[i] === "tm:ThingModel") { arr.splice(i, 1); i--; } } } } */ // set @language to "en" if no @language available setContextLanguage(this as WoT.ThingDescription, TD.DEFAULT_CONTEXT_LANGUAGE, false); } public getThingDescription(): WoT.ThingDescription { return JSON.parse(serializeTD(this)); } public emitEvent(name: string, data: WoT.InteractionInput): void { if (this.events[name] != null) { const eventAffordance = this.events[name]; this.#eventListeners.notify(eventAffordance, data, eventAffordance.data); } else { // NotFoundError throw new Error("NotFoundError for event '" + name + "'"); } } public async emitPropertyChange(name: string, data?: WoT.InteractionInput): Promise { if (this.properties[name] != null) { const property = this.properties[name]; if (data === undefined) { const readHandler = this.#propertyHandlers.get(name)?.readHandler; if (!readHandler) { throw new Error( "Can't read property readHandler is not defined. Did you forget to register a readHandler?" ); } data = await readHandler(); } this.#propertyListeners.notify(property, data, property); } else { // NotFoundError throw new Error("NotFoundError for property '" + name + "'"); } } /** @inheritDoc */ expose(): Promise { debug(`ExposedThing '${this.title}' exposing all Interactions and TD`); return new Promise((resolve, reject) => { // let servient forward exposure to the servers this.#servient .expose(this) .then(() => { resolve(); }) .catch((err) => reject(err)); }); } /** @inheritDoc */ async destroy(): Promise { debug(`ExposedThing '${this.title}' destroying the thing and its interactions`); await this.#servient.destroyThing(this.id); this.#eventListeners.unregisterAll(); this.#propertyListeners.unregisterAll(); this.#eventHandlers.clear(); this.#propertyHandlers.clear(); this.#eventHandlers.clear(); } /** @inheritDoc */ setPropertyReadHandler(propertyName: string, handler: WoT.PropertyReadHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting read handler for '${propertyName}'`); if (this.properties[propertyName] != null) { // setting read handler for writeOnly not allowed if (this.properties[propertyName].writeOnly === true) { throw new Error( `ExposedThing '${this.title}' cannot set read handler for property '${propertyName}' due to writeOnly flag` ); } else { let propertyHandler = this.#propertyHandlers.get(propertyName); if (propertyHandler) { propertyHandler.readHandler = handler; } else { propertyHandler = { readHandler: handler }; } this.#propertyHandlers.set(propertyName, propertyHandler); } } else { throw new Error(`ExposedThing '${this.title}' has no Property '${propertyName}'`); } return this; } /** @inheritDoc */ setPropertyWriteHandler(propertyName: string, handler: WoT.PropertyWriteHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting write handler for '${propertyName}'`); if (this.properties[propertyName] != null) { // setting write handler for readOnly not allowed if (this.properties[propertyName].readOnly === true) { throw new Error( `ExposedThing '${this.title}' cannot set write handler for property '${propertyName}' due to readOnly flag` ); } else { let propertyHandler = this.#propertyHandlers.get(propertyName); if (propertyHandler) { propertyHandler.writeHandler = handler; } else { propertyHandler = { writeHandler: handler }; } this.#propertyHandlers.set(propertyName, propertyHandler); } } else { throw new Error(`ExposedThing '${this.title}' has no Property '${propertyName}'`); } return this; } /** @inheritDoc */ setPropertyObserveHandler(name: string, handler: WoT.PropertyReadHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting property observe handler for '${name}'`); if (this.properties[name] != null) { if (this.properties[name].observable !== true) { throw new Error( `ExposedThing '${this.title}' cannot set observe handler for property '${name}' since the observable flag is set to false` ); } else { let propertyHandler = this.#propertyHandlers.get(name); if (propertyHandler) { propertyHandler.observeHandler = handler; } else { propertyHandler = { observeHandler: handler }; } this.#propertyHandlers.set(name, propertyHandler); } } else { throw new Error(`ExposedThing '${this.title}' has no Property '${name}'`); } return this; } /** @inheritDoc */ setPropertyUnobserveHandler(name: string, handler: WoT.PropertyReadHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting property unobserve handler for '${name}'`); if (this.properties[name] != null) { if (this.properties[name].observable !== true) { throw new Error( `ExposedThing '${this.title}' cannot set unobserve handler for property '${name}' due to missing observable flag` ); } else { let propertyHandler = this.#propertyHandlers.get(name); if (propertyHandler) { propertyHandler.unobserveHandler = handler; } else { propertyHandler = { unobserveHandler: handler }; } this.#propertyHandlers.set(name, propertyHandler); } } else { throw new Error(`ExposedThing '${this.title}' has no Property '${name}'`); } return this; } /** @inheritDoc */ setActionHandler(actionName: string, handler: WoT.ActionHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting action handler for '${actionName}'`); if (this.actions[actionName] != null) { this.#actionHandlers.set(actionName, handler); } else { throw new Error(`ExposedThing '${this.title}' has no Action '${actionName}'`); } return this; } /** @inheritDoc */ setEventSubscribeHandler(name: string, handler: WoT.EventSubscriptionHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting event subscribe handler for '${name}'`); if (this.events[name] != null) { let eventHandler = this.#eventHandlers.get(name); if (eventHandler) { eventHandler.subscribe = handler; } else { eventHandler = { subscribe: handler }; } this.#eventHandlers.set(name, eventHandler); } else { throw new Error(`ExposedThing '${this.title}' has no Event '${name}'`); } return this; } /** @inheritDoc */ setEventUnsubscribeHandler(name: string, handler: WoT.EventSubscriptionHandler): WoT.ExposedThing { debug(`ExposedThing '${this.title}' setting event unsubscribe handler for '${name}'`); if (this.events[name] != null) { let eventHandler = this.#eventHandlers.get(name); if (eventHandler) { eventHandler.unsubscribe = handler; } else { eventHandler = { unsubscribe: handler }; } this.#eventHandlers.set(name, eventHandler); } else { throw new Error(`ExposedThing '${this.title}' has no Event '${name}'`); } return this; } /** * Handle the request of an action invocation form the protocol binding level * @experimental */ public async handleInvokeAction( name: string, inputContent: Content, options: WoT.InteractionOptions & { formIndex: number } ): Promise { // TODO: handling URI variables? if (this.actions[name] != null) { debug(`ExposedThing '${this.title}' has Action state of '${name}'`); const handler = this.#actionHandlers.get(name); if (handler != null) { debug(`ExposedThing '${this.title}' calls registered handler for Action '${name}'`); Helpers.validateInteractionOptions(this, this.actions[name], options); const form = this.actions[name].forms[options.formIndex] ?? { contentType: "application/json" }; const result: WoT.InteractionInput | void = await handler( new InteractionOutput(inputContent, form, this.actions[name].input), options ); if (result !== undefined) { // TODO: handle form.response.contentType return ContentManager.valueToContent(result, this.actions[name].output, form.contentType); } } else { throw new Error(`ExposedThing '${this.title}' has no handler for Action '${name}'`); } } else { throw new Error(`ExposedThing '${this.title}', no action found for '${name}'`); } } /** * Handle the request of a property read operation from the protocol binding level * @experimental */ public async handleReadProperty( propertyName: string, options: WoT.InteractionOptions & { formIndex: number } ): Promise { if (this.properties[propertyName] != null) { debug(`ExposedThing '${this.title}' has Action state of '${propertyName}'`); const readHandler = this.#propertyHandlers.get(propertyName)?.readHandler; if (readHandler != null) { debug(`ExposedThing '${this.title}' calls registered readHandler for Property '${propertyName}'`); Helpers.validateInteractionOptions(this, this.properties[propertyName], options); const result: WoT.InteractionInput | void = await readHandler(options); const form = this.properties[propertyName]?.forms[options.formIndex] ?? { contentType: "application/json", }; return ContentManager.valueToContent( result, this.properties[propertyName], form?.contentType ?? "application/json" ); } else { throw new Error(`ExposedThing '${this.title}' has no readHandler for Property '${propertyName}'`); } } else { throw new Error(`ExposedThing '${this.title}', no property found for '${propertyName}'`); } } /** * Handle the request of a read operation for multiple properties from the protocol binding level * @experimental */ public async _handleReadProperties( propertyNames: string[], options: WoT.InteractionOptions & { formIndex: number } ): Promise { try { // collect all single promises const output = new Map(); for (const propertyName of propertyNames) { // Note: currently only JSON DataSchema properties are supported const form = this.properties[propertyName].forms.find( (form) => form.contentType === ContentSerdes.DEFAULT || form.contentType == null ); if (!form) { continue; } const contentResponse = await this.handleReadProperty(propertyName, options); output.set(propertyName, contentResponse); } return output; } catch (error) { throw new Error( `ConsumedThing '${this.title}', failed to read properties: ${propertyNames}.\n Error: ${error}` ); } } /** * @experimental */ public async handleReadAllProperties( options: WoT.InteractionOptions & { formIndex: number } ): Promise { const propertyNames = Object.keys(this.properties); return await this._handleReadProperties(propertyNames, options); } /** * @experimental */ public async handleReadMultipleProperties( propertyNames: string[], options: WoT.InteractionOptions & { formIndex: number } ): Promise { return await this._handleReadProperties(propertyNames, options); } /** * Handle the request of an property write operation to the protocol binding level * @experimental */ public async handleWriteProperty( propertyName: string, inputContent: Content, options: WoT.InteractionOptions & { formIndex: number } ): Promise { // TODO: to be removed next api does not allow an ExposedThing to be also a ConsumeThing if (this.properties[propertyName] != null) { if (this.properties[propertyName].readOnly === true) { throw new Error(`ExposedThing '${this.title}', property '${propertyName}' is readOnly`); } Helpers.validateInteractionOptions(this, this.properties[propertyName], options); const writeHandler = this.#propertyHandlers.get(propertyName)?.writeHandler; const form = this.properties[propertyName]?.forms[options.formIndex] ?? {}; // call write handler (if any) if (writeHandler != null) { await writeHandler(new InteractionOutput(inputContent, form, this.properties[propertyName]), options); } else { throw new Error(`ExposedThing '${this.title}' has no writeHandler for Property '${propertyName}'`); } } else { throw new Error(`ExposedThing '${this.title}', no property found for '${propertyName}'`); } } /** * * @experimental */ public async handleWriteMultipleProperties( valueMap: PropertyContentMap, options: WoT.InteractionOptions & { formIndex: number } ): Promise { // collect all single promises into array const promises: Promise[] = []; for (const [propertyName, property] of Object.entries(valueMap)) { // Note: currently only DataSchema properties are supported const form = this.properties[propertyName].forms.find( (form) => form.contentType === "application/json" || form.contentType == null ); if (form == null) { continue; } promises.push(this.handleWriteProperty(propertyName, property, options)); } try { await Promise.all(promises); } catch (error) { throw new Error( `ExposedThing '${this.title}', failed to write multiple properties. ${(error).message}` ); } } /** * * @experimental */ public async handleSubscribeEvent( name: string, listener: ContentListener, options: WoT.InteractionOptions & { formIndex: number } ): Promise { if (this.events[name] != null) { Helpers.validateInteractionOptions(this, this.events[name], options); const formIndex = ProtocolHelpers.getFormIndexForOperation( this.events[name], "event", "subscribeevent", options.formIndex ); if (formIndex !== -1) { this.#eventListeners.register(this.events[name], formIndex, listener); debug(`ExposedThing '${this.title}' subscribes to event '${name}'`); } else { throw new Error( `ExposedThing '${this.title}', no property listener from found for '${name}' with form index '${options.formIndex}'` ); } const subscribe = this.#eventHandlers.get(name)?.subscribe; if (subscribe) { await subscribe(options); } debug(`ExposedThing '${this.title}' subscribes to event '${name}'`); } else { throw new Error(`ExposedThing '${this.title}', no event found for '${name}'`); } } /** * * @experimental */ public handleUnsubscribeEvent( name: string, listener: ContentListener, options: WoT.InteractionOptions & { formIndex: number } ): void { if (this.events[name] != null) { Helpers.validateInteractionOptions(this, this.events[name], options); const formIndex = ProtocolHelpers.getFormIndexForOperation( this.events[name], "event", "unsubscribeevent", options.formIndex ); if (formIndex !== -1) { this.#eventListeners.unregister(this.events[name], formIndex, listener); } else { throw new Error( `ExposedThing '${this.title}', no event listener from found for '${name}' with form index '${options.formIndex}'` ); } const unsubscribe = this.#eventHandlers.get(name)?.unsubscribe; if (unsubscribe) { unsubscribe(options); } debug(`ExposedThing '${this.title}' unsubscribes from event '${name}'`); } else { throw new Error(`ExposedThing '${this.title}', no event found for '${name}'`); } } /** * * @experimental */ public async handleObserveProperty( name: string, listener: ContentListener, options: WoT.InteractionOptions & { formIndex: number } ): Promise { if (this.properties[name] != null) { Helpers.validateInteractionOptions(this, this.properties[name], options); const formIndex = ProtocolHelpers.getFormIndexForOperation( this.properties[name], "property", "observeproperty", options.formIndex ); if (formIndex !== -1) { this.#propertyListeners.register(this.properties[name], formIndex, listener); debug(`ExposedThing '${this.title}' subscribes to property '${name}'`); } else { throw new Error( `ExposedThing '${this.title}', no property listener from found for '${name}' with form index '${options.formIndex}'` ); } const observeHandler = this.#propertyHandlers.get(name)?.observeHandler; if (observeHandler) { await observeHandler(options); } } else { throw new Error(`ExposedThing '${this.title}', no property found for '${name}'`); } } public handleUnobserveProperty( name: string, listener: ContentListener, options: WoT.InteractionOptions & { formIndex: number } ): void { if (this.properties[name] != null) { Helpers.validateInteractionOptions(this, this.properties[name], options); const formIndex = ProtocolHelpers.getFormIndexForOperation( this.properties[name], "property", "unobserveproperty", options.formIndex ); if (formIndex !== -1) { this.#propertyListeners.unregister(this.properties[name], formIndex, listener); } else { throw new Error( `ExposedThing '${this.title}', no property listener from found for '${name}' with form index '${options.formIndex}'` ); } const unobserveHandler = this.#propertyHandlers.get(name)?.unobserveHandler; if (unobserveHandler) { unobserveHandler(options); } } else { throw new Error(`ExposedThing '${this.title}', no property found for '${name}'`); } } private static interactionInputToReadable(input: WoT.InteractionInput): Readable { let body; if (typeof ReadableStream !== "undefined" && input instanceof ReadableStream) { body = ProtocolHelpers.toNodeStream(input); } else if (input instanceof PolyfillStream) { body = ProtocolHelpers.toNodeStream(input); } else if (Array.isArray(input) || typeof input === "object") { body = Readable.from(Buffer.from(JSON.stringify(input), "utf-8")); } else { body = Readable.from(Buffer.from(input.toString(), "utf-8")); } return body; } }