diff --git a/src/importer/index.ts b/src/importer/index.ts new file mode 100644 index 0000000..89c1ba0 --- /dev/null +++ b/src/importer/index.ts @@ -0,0 +1,7 @@ +import { Thing } from "../openhab/types"; +import { Adapter } from "./types"; + +export const importThings = async (adapter: Adapter): Promise => { + const things = await adapter.loadThings(); + return things; +} \ No newline at end of file diff --git a/src/importer/types.ts b/src/importer/types.ts new file mode 100644 index 0000000..fec29a1 --- /dev/null +++ b/src/importer/types.ts @@ -0,0 +1,5 @@ +import { Thing } from "../openhab/types"; + +export interface Adapter { + loadThings(): Promise; +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 97cc172..a3b160a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,23 +1,19 @@ -import { fromZ2M } from "./z2m/loader"; +import { importThings } from "./importer"; +import { snakecase } from "./utils/string"; +import { Z2MAdapter } from "./z2m"; -export type Config = { - bridgeID: string; - prefix: String; - username: string; - password: string; - clientId?: string; -}; - -async function run(config: Config) { - const things = await fromZ2M(config); - console.log(things); -} - -const config: Config = { +const adapter = new Z2MAdapter({ bridgeID: "main", prefix: "zigbee", username: 'test', password: 'test', -}; + idTransform: snakecase("-") +}); -run(config); \ No newline at end of file + +async function run() { + const things = await importThings(adapter); + console.log(things); +} + +run(); \ No newline at end of file diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..4c1f95f --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,5 @@ +export const snakecase = (join = "_") => (text: string): string => text + .split(/(? n.trim() !== "") + .join(join) + .toLowerCase(); \ No newline at end of file diff --git a/src/z2m/loader.ts b/src/z2m/index.ts similarity index 58% rename from src/z2m/loader.ts rename to src/z2m/index.ts index 21487ce..e49644b 100644 --- a/src/z2m/loader.ts +++ b/src/z2m/index.ts @@ -1,19 +1,28 @@ import mqtt from "mqtt"; import { Device, Feature, FeatureType } from "./types"; -import { Config } from ".."; +import { Config } from "./types"; import { Channel, ChannelType, ItemType, Thing } from "../openhab/types"; +import { Adapter } from "../importer/types"; -/** - * Fetches devices from Exposes API of Zigbee2MQTT and converts them - * to OpenHAB Thing payload. - * @param config - the configuration of MQTT network - * @returns promise with list of OpenHAB things payload - */ -export const fromZ2M = async (config: Config): Promise => { - const devices = await fetchModel(config); - return devices.flatMap(toThing(config)); -} +export class Z2MAdapter implements Adapter { + _config: Config; + + constructor(config: Config) { + this._config = config; + } + + /** + * Fetches devices from Exposes API of Zigbee2MQTT and converts them + * to OpenHAB Thing payload. + * @param config - the configuration of MQTT network + * @returns promise with list of OpenHAB things payload + */ + async loadThings(): Promise { + const devices = await fetchModel(this._config); + return devices.flatMap(toThing(this._config)); + } +}; /** * Fetches devices model from Exposes API of Zigbee2MQTT service. @@ -24,7 +33,7 @@ const fetchModel = ({ username, password, prefix, clientId }: Config): PromiseopenHAB importer" }); client.on("message", (_, message) => { - client.end(); + client.end(); resolve(JSON.parse(message.toString()) as Device[]); }); @@ -57,30 +66,50 @@ const isWriteable = isBitSet(1); */ const isActiveReadable = isBitSet(2); -const toChannels = (parentTopic: string, parentUID: string, type?: string) => (feature: Feature): Channel[] => { +/** + * Maps Z2M feature to list of OpenHAB channels. + * It can support both atomic features as well as complex one (like composites, + * specific items like fans, switches, lights etc.). + * @param parentTopic topic of parent node + * @param parentUID UID of parent node + * @param type optionakl type which will be used as a prefix for description of channel + * @returns list of channels mapped from supported features + */ +const toChannels = (config: Config, parentTopic: string, parentUID: string, type?: string) => (feature: Feature): Channel[] => { if (feature.features) { const topic = feature.property ? `${parentTopic}/${feature.property}` : parentTopic; - return feature.features.flatMap(parseAtomFeature(topic, parentUID, type)); + return feature.features.flatMap(parseAtomFeature(config, topic, parentUID, type)); } - return parseAtomFeature(parentTopic, parentUID, type)(feature); + return parseAtomFeature(config, parentTopic, parentUID, type)(feature); }; -const parseAtomFeature = (parentTopic: string, parentUID: string, type?: string) => (feature: Feature): Channel[] => { - const readable = isPassiveReadable(feature.access); - const writeable = isWriteable(feature.access); +/** + * Maps Z2M atomic feature (numeric, binary, enum etc.) to list of OpenHAB channels. + * It will always either return a list with single channel or throw an exception for unsupported feature, + * as it is not capable to support the complex features. + * @param parentTopic topic of parent node + * @param parentUID UID of parent node + * @param type optionakl type which will be used as a prefix for description of channel + * @returns list of channels mapped from atomic features (like numeric, binary, enum etc.) + */ +const parseAtomFeature = ({ idTransform }: Config, parentTopic: string, parentUID: string, type?: string) => (feature: Feature): Channel[] => { + const readable = isPassiveReadable(feature.access); + const writeable = isWriteable(feature.access); + + const isTrigger = feature.type === 'enum' && readable && !writeable && !isActiveReadable(feature.access); const channelTypes: Partial> = { binary: writeable ? 'mqtt:switch' : 'mqtt:contact', numeric: 'mqtt:number', - enum: 'mqtt:string', + enum: isTrigger ? 'mqtt:trigger' : 'mqtt:string', text: 'mqtt:string', }; const itemTypes: Partial> = { binary: writeable ? 'Switch' : 'Contact', numeric: 'Number', - enum: 'String', + enum: !isTrigger ? 'String' : undefined, text: 'String', }; @@ -91,14 +120,15 @@ const parseAtomFeature = (parentTopic: string, parentUID: string, type?: string) throw new Error(`Unsupported feature type ${feature.type} for: ${JSON.stringify(feature)}`) } + const id = idTransform?.(feature.name) || feature.name; const category = type ?? feature.category; const prefix = category ? `${category[0].toUpperCase()}${category.slice(1)}: ` : ""; return [{ + id, channelTypeUID, - itemType: itemTypes[feature.type], - id: feature.name, - uid: `${parentUID}:${feature.name}`, + itemType: itemTypes[feature.type], + uid: `${parentUID}:${id}`, kind: "STATE", label: feature.label, description: `${prefix}${feature.description}`, @@ -117,11 +147,16 @@ const parseAtomFeature = (parentTopic: string, parentUID: string, type?: string) }]; }; +/** + * Maps Z2M device from Exposes API to OpenHAB Thing model. + * @param config MQTT configuration + * @returns mapped thing + */ const toThing = (config: Config) => (device: Device): Thing => { const UID = `mqtt:topic:${config.bridgeID}:${device.friendly_name}`; const thingTopic = `${config.prefix}/${device.friendly_name}` - const exposes = device.definition?.exposes?.flatMap(toChannels(thingTopic, UID)) ?? []; - const options = device.definition?.options?.flatMap(toChannels(thingTopic, UID, "option")) ?? []; + const exposes = device.definition?.exposes?.flatMap(toChannels(config, thingTopic, UID)) ?? []; + const options = device.definition?.options?.flatMap(toChannels(config, thingTopic, UID, "option")) ?? []; return { UID, diff --git a/src/z2m/types.ts b/src/z2m/types.ts index 56e9370..2799cb7 100644 --- a/src/z2m/types.ts +++ b/src/z2m/types.ts @@ -1,3 +1,35 @@ +export type Config = { + /** + * ID of the MQTT bridge in OpenHAB + */ + bridgeID: string; + + /** + * Prefix of the Z2M topic in MQTT network + */ + prefix: String; + + /** + * Username of MQTT user + */ + username: string; + + /** + * Password of MQTT user + */ + password: string; + + /** + * Optional client ID displayed in MQTT broker logs + */ + clientId?: string; + + /** + * Optional transformation applied to ID + */ + idTransform?: (text: string) => string; +}; + export type Device = { definition?: Definition; disabled: boolean;