From 5fc9e9cbf22a315a812433332740ccb59db1bf8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Pluta?= Date: Fri, 15 Nov 2024 16:38:19 +0100 Subject: [PATCH] Create CLI interface --- package-lock.json | 36 ++++- package.json | 6 +- src/adapters/abstract.ts | 25 ++++ src/adapters/index.ts | 5 + src/adapters/z2m.ts | 304 +++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 41 ++++++ src/config/index.ts | 5 + src/importer/index.ts | 37 +++-- src/index.ts | 25 +--- src/types/cli.ts | 5 + src/types/config.ts | 12 ++ src/types/importer.ts | 5 - src/types/index.ts | 5 +- src/types/z2m.ts | 128 ++--------------- src/z2m/index.ts | 170 ---------------------- 15 files changed, 470 insertions(+), 339 deletions(-) create mode 100644 src/adapters/abstract.ts create mode 100644 src/adapters/index.ts create mode 100644 src/adapters/z2m.ts create mode 100644 src/cli/index.ts create mode 100644 src/config/index.ts create mode 100644 src/types/cli.ts create mode 100644 src/types/config.ts delete mode 100644 src/types/importer.ts delete mode 100644 src/z2m/index.ts diff --git a/package-lock.json b/package-lock.json index 43b690b..12274e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,12 @@ "license": "ISC", "dependencies": { "axios": "^1.7.7", - "mqtt": "^5.10.1" + "commander": "^12.1.0", + "mqtt": "^5.10.1", + "yaml": "^2.6.0" + }, + "bin": { + "oh-import": "dist/index.js" }, "devDependencies": { "@types/node": "^22.9.0", @@ -640,12 +645,11 @@ } }, "node_modules/commander": { - "version": "9.5.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", - "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "engines": { - "node": "^12.20.0 || >=14" + "node": ">=18" } }, "node_modules/commist": { @@ -1372,6 +1376,15 @@ "tsc-alias": "dist/bin/index.js" } }, + "node_modules/tsc-alias/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1474,6 +1487,17 @@ "optional": true } } + }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } } } } diff --git a/package.json b/package.json index 725f83c..fe719a7 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "index.ts", "scripts": { "app": "tsx src/index.ts", - "build": "tsc && tsc-alias" + "build": "tsc && tsc-alias" }, "bin": { "oh-import": "./dist/index.js" @@ -20,6 +20,8 @@ }, "dependencies": { "axios": "^1.7.7", - "mqtt": "^5.10.1" + "commander": "^12.1.0", + "mqtt": "^5.10.1", + "yaml": "^2.6.0" } } diff --git a/src/adapters/abstract.ts b/src/adapters/abstract.ts new file mode 100644 index 0000000..d4b351a --- /dev/null +++ b/src/adapters/abstract.ts @@ -0,0 +1,25 @@ +import { Thing } from "@types"; + +export abstract class Adapter { + protected name: string; + protected config: C; + + protected abstract get requiredFields(): readonly (keyof C)[]; + + constructor(name: string, config: Partial) { + this.name = name; + this.config = this.#validateRequiredFields(config); + } + + #validateRequiredFields(config: Partial): C { + for(const field of this.requiredFields) { + if (!config[field]) { + throw new Error(`Required config field '${field.toString()}' is missing in ${this.name} profile`); + } + } + + return config as C; + } + + abstract loadThings(): Promise; +}; \ No newline at end of file diff --git a/src/adapters/index.ts b/src/adapters/index.ts new file mode 100644 index 0000000..c4dfec5 --- /dev/null +++ b/src/adapters/index.ts @@ -0,0 +1,5 @@ +import { Z2MAdapter } from "./z2m"; + +export const adapters = { + zigbee2mqtt: Z2MAdapter, +} \ No newline at end of file diff --git a/src/adapters/z2m.ts b/src/adapters/z2m.ts new file mode 100644 index 0000000..d785690 --- /dev/null +++ b/src/adapters/z2m.ts @@ -0,0 +1,304 @@ +import mqtt from "mqtt"; +import { Z2MConfig, Thing, Channel, ChannelType, ItemType } from "@types"; +import { snakecase } from "../utils/string"; +import { Adapter } from "./abstract"; + +type Device = { + definition?: Definition; + disabled: boolean; + endpoints: Endpoints; + friendly_name: string; + ieee_address: string; + interview_completed: boolean; + interviewing: boolean; + network_address: number; + supported: boolean; + type: string; + date_code?: string; + description?: string; + manufacturer?: string; + model_id?: string; + power_source?: string; + software_build_id?: string; +}; + +type Definition = { + description: string; + exposes: Feature[]; + model: string; + options: Feature[]; + supports_ota: boolean; + vendor: string; +}; + +type FeatureType = + 'binary' + | 'numeric' + | 'enum' + | 'text' + | 'composite' + | 'list' + | 'light' + | 'switch' + | 'fan' + | 'cover' + | 'lock' + | 'climate' + +type Feature = { + access: number; + description: string; + label: string; + name: string; + property: string; + type: FeatureType; + category?: FeatureCategory; + value_max?: number; + value_min?: number; + value_off?: boolean; + value_on?: boolean; + value_toggle?: string; + values?: string[]; + unit?: string; + features?: Feature[]; + presets?: Preset[]; + item_type?: Feature; + length_min?: number; + length_max?: number; +}; + +type FeatureCategory = 'diagnostic' | 'config'; + +type Preset = { + name: string; + value: number; + description?: number; +}; + +type Endpoints = Record; + +type Endpoint = { + bindings: Binding[]; + clusters: Clusters; + configured_reportings: ConfiguredReporting[]; + scenes: unknown[]; +}; + +type Clusters = { + input: string[]; + output: string[]; +}; + +type Binding = { + cluster: string; + target: Target; +}; + +type Target = { + endpoint: number; + ieee_address: string; + type: string; +}; + +type ConfiguredReporting = { + attribute: string; + cluster: string; + maximum_report_interval: number; + minimum_report_interval: number; + reportable_change: number; +}; + + +export class Z2MAdapter extends Adapter { + protected get requiredFields() { + return [ + 'brokerURL', + 'username', + 'password', + 'bridgeID', + 'prefix', + ] as const; + } + + /** + * 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 this.#fetchModel(); + return devices.flatMap(this.#mapDeviceToThing.bind(this)); + } + + /** + * Fetches devices model from Exposes API of Zigbee2MQTT service. + * @param config - the configuration of MQTT network + * @returns promise with list of Z2M devices discovered on Exposes API + */ + async #fetchModel() { + const { brokerURL, username, password, clientId, prefix } = this.config; + + return new Promise((resolve, reject) => { + const client = mqtt.connect(brokerURL, { username, password, clientId: clientId || "oh-importer" }); + + client.on("message", (_, message) => { + client.end(); + resolve(JSON.parse(message.toString()) as Device[]); + }); + + client.on("connect", () => { + client.subscribe(`${prefix}/bridge/devices`, err => { + if(err) reject(err); + }); + }); + }); + }; + + /** + * Maps Z2M device from Exposes API to OpenHAB Thing model. + * @param config MQTT configuration + * @returns mapped thing + */ + #mapDeviceToThing(device: Device): Thing { + const { bridgeID, prefix } = this.config; + const UID = `mqtt:topic:${bridgeID}:${this.#idTransform(device.friendly_name)}`; + const thingTopic = `${prefix}/${device.friendly_name}` + + const exposes = device.definition?.exposes?.flatMap(this.#getFeatureToChannelMapper(thingTopic, UID)) ?? []; + const options = device.definition?.options?.flatMap(this.#getFeatureToChannelMapper(thingTopic, UID, "option")) ?? []; + + return { + UID, + bridgeUID: `mqtt:broker:${bridgeID}`, + thingTypeUID: "mqtt:topic", + label: device.description || device.friendly_name, + configuration: { + availabilityTopic: `${thingTopic}/availability`, + payloadNotAvailable: "offline", + payloadAvailable: "online" + }, + properties: {}, + channels: [...exposes, ...options] + } + }; + + /** + * 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 + */ + #getFeatureToChannelMapper(parentTopic: string, parentUID: string, descriptionPrefix?: string) { + return (feature: Feature): Channel[] => { + if (feature.features) { + const topic = feature.property ? `${parentTopic}/${feature.property}` : parentTopic; + return feature.features.flatMap(this.#getAtomFeatureToChannelMapper(topic, parentUID, descriptionPrefix)); + } + + return this.#getAtomFeatureToChannelMapper(parentTopic, parentUID, descriptionPrefix)(feature); + } + } + + /** + * 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.) + */ + #getAtomFeatureToChannelMapper(parentTopic: string, parentUID: string, descriptionPrefix?: string) { + return (feature: Feature): Channel[] => { + const readable = this.#isPassiveReadable(feature.access); + const writeable = this.#isWriteable(feature.access); + + const isTrigger = feature.type === 'enum' && readable && !writeable && !this.#isActiveReadable(feature.access); + + const channelTypes: Partial> = { + binary: writeable ? 'mqtt:switch' : 'mqtt:contact', + numeric: 'mqtt:number', + enum: isTrigger ? 'mqtt:trigger' : 'mqtt:string', + text: 'mqtt:string', + }; + + const itemTypes: Partial> = { + binary: writeable ? 'Switch' : 'Contact', + numeric: 'Number', + enum: !isTrigger ? 'String' : undefined, + text: 'String', + }; + + + const channelTypeUID = channelTypes[feature.type]; + + if (channelTypeUID === undefined) { + throw new Error(`Unsupported feature type ${feature.type} for: ${JSON.stringify(feature)}`) + } + + const id = this.#idTransform(feature.name); + const category = descriptionPrefix ?? feature.category; + const prefix = category ? `${category[0].toUpperCase()}${category.slice(1)}: ` : ""; + + return [{ + id, + channelTypeUID, + itemType: itemTypes[feature.type], + uid: `${parentUID}:${id}`, + kind: isTrigger ? 'TRIGGER' : "STATE", + label: feature.label, + description: `${prefix}${feature.description}`, + configuration: { + unit: feature.unit, + min: feature.value_min, + max: feature.value_max, + on: feature.value_on, + off: feature.value_off, + allowedStates: feature.values?.join(","), + stateTopic: readable ? `${parentTopic}/${feature.property}` : undefined, + commandTopic: writeable ? `${parentTopic}/set/${feature.property}` : undefined, + }, + properties: {}, + defaultTags: [], + }]; + }; + } + + #idTransform(text: string): string { + const transform = { + 'snake-case': snakecase('-'), + 'snake_case': snakecase('_') + }[this.config.idTransform || 'snake-case']; + + return transform(text) || snakecase('-')(text); + } + + /** + * Checks whether the access code allows to read the published state of the device (bit 0) + * @param accessCode access code to test + * @returns true if accessible + */ + #isPassiveReadable = this.#isBitSet(0); + + /** + * Checks whether the access code allows to set the state of the device (bit 1) + * @param accessCode access code to test + * @returns true if accessible + */ + #isWriteable = this.#isBitSet(1); + + /** + * Checks whether the access code allows to read the device state on demand (bit 2) + * @param accessCode access code to test + * @returns true if accessible + */ + #isActiveReadable = this.#isBitSet(2); + + #isBitSet(bit: number) { + return (code: number) => !!(code & (1< program + .name("oh-import") + .version("0.0.1") + .requiredOption("-c, --config ", "sets the path to the YAML file with configuration") + .option("-x, --set ", "overrides the config option for this specific run (arg: =, i.e. mqtt.brokerURL=mqtt://localhost:1883", (v: string, prev: string[]) => prev.concat([v]), []) + .option("-s, --sources [source...]", "forces the given sources to be queried for new things") + .parse() + .opts(); + +export const run = async () => { + const options = getOptions(); + const config = parseConfig(options.config); + + for (const override of options.set) { + const [path, value] = override.split("="); + + const segments = path.trim().split(".") + + let current: any = config; + segments.map(s => s.trim()).forEach((segment: string, idx) => { + if(!current[segment]) { + current[segment] = {}; + } + + if(idx === segments.length - 1) { + current[segment] = JSON.parse(value.trim()); + } + + current = current[segment]; + }); + } + + const things = await importThings(config, options.sources ?? []); + console.log(things); +} \ No newline at end of file diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..4a5fbb8 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,5 @@ +import { Config } from '@types'; +import { readFileSync } from 'fs'; +import { parse } from 'yaml'; + +export const parseConfig = (filePath: string) => parse(readFileSync(filePath, 'utf8')) as Config; diff --git a/src/importer/index.ts b/src/importer/index.ts index 48144fd..97ca08c 100644 --- a/src/importer/index.ts +++ b/src/importer/index.ts @@ -1,32 +1,39 @@ import axios from "axios"; -import { Thing, Adapter } from "@types"; +import { Thing, Config } from "@types"; +import { adapters } from "../adapters"; -export type Config = { - baseURL: string; - token: string; - override?: boolean; -}; -export const importThings = async ({baseURL, token, override}: Config, adapter: Adapter) => { +export const importThings = async (config: Config, sources: string[]) => { const openhab = axios.create({ - baseURL, + baseURL: config.baseURL, headers: { - Authorization: `Bearer ${token}` + Authorization: `Bearer ${config.token}` } }); - const things = await adapter.loadThings(); + const things = (await Promise.all(sources.map(source => { + const { type, config: cfg } = config.sources[source]; + + const constructor = adapters[type as keyof (typeof adapters)]; - if (override) { + if (!constructor) { + throw new Error(`Unknown source type '${type}'`); + } + + return new constructor(source, cfg).loadThings(); + }))).flat(); + + if (config.override) { // things.forEach(t => openhab.delete(`/things/${t.UID}`)); } - const getThingsResponse = await openhab.get('/things'); - const existingThingsUIDs = getThingsResponse.data.map(t => t.UID); + // const getThingsResponse = await openhab.get('/things'); + // const existingThingsUIDs = getThingsResponse.data.map(t => t.UID); - const thingsToImport = things.filter(t => !existingThingsUIDs.includes(t.UID)); + // const thingsToImport = things.filter(t => !existingThingsUIDs.includes(t.UID)); // openhab.post('/things', thingsToImport); - return thingsToImport; + // return thingsToImport; + return things; } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 1393036..5f78216 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,5 @@ #!/usr/bin/env node -import { importThings } from "./importer"; -import { snakecase } from "./utils/string"; -import { Z2MAdapter } from "./z2m"; - -const adapter = new Z2MAdapter({ - bridgeID: "main", - prefix: "zigbee", - username: 'test', - password: 'test', - idTransform: snakecase("-") -}); - -const baseURL = 'openhab.url'; -const token = 'openhab-token'; - -async function run() { - const config = { - baseURL, - token - }; - - const things = await importThings(config, adapter); - console.log(JSON.stringify(things, undefined, 2)) -} +import { run } from "./cli"; run(); \ No newline at end of file diff --git a/src/types/cli.ts b/src/types/cli.ts new file mode 100644 index 0000000..81a868b --- /dev/null +++ b/src/types/cli.ts @@ -0,0 +1,5 @@ +export type CLIOptions = { + config: string; + set: string[]; + sources?: string[]; +}; \ No newline at end of file diff --git a/src/types/config.ts b/src/types/config.ts new file mode 100644 index 0000000..2d3ea11 --- /dev/null +++ b/src/types/config.ts @@ -0,0 +1,12 @@ +export type Config = { + baseURL: string; + token: string; + override?: boolean; + + sources: Record; +}; + +type SourceConfig = { + type: string; + config: Record; +}; \ No newline at end of file diff --git a/src/types/importer.ts b/src/types/importer.ts deleted file mode 100644 index 1ab2ca8..0000000 --- a/src/types/importer.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Thing } from "./openhab"; - -export interface Adapter { - loadThings(): Promise; -}; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 5409fa4..8f9116e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ -export * from "./importer"; export * from "./openhab"; -export * from "./z2m"; \ No newline at end of file +export * from "./z2m"; +export * from "./config"; +export * from "./cli"; \ No newline at end of file diff --git a/src/types/z2m.ts b/src/types/z2m.ts index 2799cb7..12f850f 100644 --- a/src/types/z2m.ts +++ b/src/types/z2m.ts @@ -1,13 +1,5 @@ -export type Config = { - /** - * ID of the MQTT bridge in OpenHAB - */ - bridgeID: string; - - /** - * Prefix of the Z2M topic in MQTT network - */ - prefix: String; +export type Z2MConfig = { + brokerURL: string; /** * Username of MQTT user @@ -19,6 +11,16 @@ export type Config = { */ password: string; + /** + * ID of the MQTT bridge in OpenHAB + */ + bridgeID: string; + + /** + * Prefix of the Z2M topic in MQTT network + */ + prefix: string; + /** * Optional client ID displayed in MQTT broker logs */ @@ -27,110 +29,6 @@ export type Config = { /** * Optional transformation applied to ID */ - idTransform?: (text: string) => string; + idTransform?: 'snake-case' | 'snake_case'; }; -export type Device = { - definition?: Definition; - disabled: boolean; - endpoints: Endpoints; - friendly_name: string; - ieee_address: string; - interview_completed: boolean; - interviewing: boolean; - network_address: number; - supported: boolean; - type: string; - date_code?: string; - description?: string; - manufacturer?: string; - model_id?: string; - power_source?: string; - software_build_id?: string; -}; - -export type Definition = { - description: string; - exposes: Feature[]; - model: string; - options: Feature[]; - supports_ota: boolean; - vendor: string; -}; - -export type FeatureType = - 'binary' - | 'numeric' - | 'enum' - | 'text' - | 'composite' - | 'list' - | 'light' - | 'switch' - | 'fan' - | 'cover' - | 'lock' - | 'climate' - -export type Feature = { - access: number; - description: string; - label: string; - name: string; - property: string; - type: FeatureType; - category?: FeatureCategory; - value_max?: number; - value_min?: number; - value_off?: boolean; - value_on?: boolean; - value_toggle?: string; - values?: string[]; - unit?: string; - features?: Feature[]; - presets?: Preset[]; - item_type?: Feature; - length_min?: number; - length_max?: number; -}; - -export type FeatureCategory = 'diagnostic' | 'config'; - -export type Preset = { - name: string; - value: number; - description?: number; -}; - -export type Endpoints = Record; - -export type Endpoint = { - bindings: Binding[]; - clusters: Clusters; - configured_reportings: ConfiguredReporting[]; - scenes: unknown[]; -}; - -export type Clusters = { - input: string[]; - output: string[]; -}; - -export type Binding = { - cluster: string; - target: Target; -}; - -export type Target = { - endpoint: number; - ieee_address: string; - type: string; -}; - -export type ConfiguredReporting = { - attribute: string; - cluster: string; - maximum_report_interval: number; - minimum_report_interval: number; - reportable_change: number; -}; diff --git a/src/z2m/index.ts b/src/z2m/index.ts deleted file mode 100644 index 974b0aa..0000000 --- a/src/z2m/index.ts +++ /dev/null @@ -1,170 +0,0 @@ -import mqtt from "mqtt"; -import { Device, Feature, FeatureType, Config, Channel, ChannelType, ItemType, Thing, Adapter } from "@types"; - -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. - * @param config - the configuration of MQTT network - * @returns promise with list of Z2M devices discovered on Exposes API - */ -const fetchModel = ({ username, password, prefix, clientId }: Config): Promise => new Promise((resolve, reject) => { - const client = mqtt.connect("mqtt://mqtt.lan", { username, password, clientId: clientId || "zigbee2mqtt->openHAB importer" }); - - client.on("message", (_, message) => { - client.end(); - resolve(JSON.parse(message.toString()) as Device[]); - }); - - client.on("connect", () => { - client.subscribe(`${prefix}/bridge/devices`, err => { - if(err) reject(err); - }); - }); -}); - -const isBitSet = (bit: number) => (code: number) => !!(code & (1< (feature: Feature): Channel[] => { - if (feature.features) { - const topic = feature.property ? `${parentTopic}/${feature.property}` : parentTopic; - return feature.features.flatMap(parseAtomFeature(config, topic, parentUID, type)); - } - - return parseAtomFeature(config, parentTopic, parentUID, type)(feature); -}; - -/** - * 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: isTrigger ? 'mqtt:trigger' : 'mqtt:string', - text: 'mqtt:string', - }; - - const itemTypes: Partial> = { - binary: writeable ? 'Switch' : 'Contact', - numeric: 'Number', - enum: !isTrigger ? 'String' : undefined, - text: 'String', - }; - - - const channelTypeUID = channelTypes[feature.type]; - - if (channelTypeUID === undefined) { - 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], - uid: `${parentUID}:${id}`, - kind: isTrigger ? 'TRIGGER' : "STATE", - label: feature.label, - description: `${prefix}${feature.description}`, - configuration: { - unit: feature.unit, - min: feature.value_min, - max: feature.value_max, - on: feature.value_on, - off: feature.value_off, - allowedStates: feature.values?.join(","), - stateTopic: readable ? `${parentTopic}/${feature.property}` : undefined, - commandTopic: writeable ? `${parentTopic}/set/${feature.property}` : undefined, - }, - properties: {}, - defaultTags: [], - }]; -}; - -/** - * 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}:${config.idTransform?.(device.friendly_name) || device.friendly_name}`; - const thingTopic = `${config.prefix}/${device.friendly_name}` - const exposes = device.definition?.exposes?.flatMap(toChannels(config, thingTopic, UID)) ?? []; - const options = device.definition?.options?.flatMap(toChannels(config, thingTopic, UID, "option")) ?? []; - - return { - UID, - bridgeUID: `mqtt:broker:${config.bridgeID}`, - thingTypeUID: "mqtt:topic", - label: device.description || device.friendly_name, - configuration: { - availabilityTopic: `${thingTopic}/availability`, - payloadNotAvailable: "offline", - payloadAvailable: "online" - }, - properties: {}, - channels: [...exposes, ...options] - } -}