/********************************************************************************
* 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;
}
}