diff --git a/src/index.ts b/src/index.ts index b617183..97cc172 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,23 @@ -import { fetchModel } from "./mqtt"; +import { fromZ2M } from "./z2m/loader"; -async function run(username: string, password: string) { - const response = await fetchModel(username, password); - console.log(response); +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 username = "test"; -const password = "test"; +const config: Config = { + bridgeID: "main", + prefix: "zigbee", + username: 'test', + password: 'test', +}; -run(username, password); \ No newline at end of file +run(config); \ No newline at end of file diff --git a/src/mqtt.ts b/src/mqtt.ts deleted file mode 100644 index 4e89d4c..0000000 --- a/src/mqtt.ts +++ /dev/null @@ -1,19 +0,0 @@ -import mqtt from "mqtt"; -import { Device } from "./types/model"; - -export function fetchModel(username: string, password: string, topic = "zigbee/bridge/devices", clientId = "zigbee2mqtt->openHAB importer"): Promise { - const client = mqtt.connect("mqtt://mqtt.lan", { username, password, clientId }); - - return new Promise((resolve, reject) => { - client.on("connect", () => { - client.subscribe(topic, err => { - if(err) reject(err); - }); - }); - - client.on("message", (topic, message) => { - client.end(); - resolve(JSON.parse(message.toString()) as Device[]); - }); - }); -} \ No newline at end of file diff --git a/src/openhab/types.ts b/src/openhab/types.ts new file mode 100644 index 0000000..e31694d --- /dev/null +++ b/src/openhab/types.ts @@ -0,0 +1,66 @@ +export type Thing = { + label: string; + bridgeUID: string; + configuration: ThingConfig; + properties: Record; + UID: string; + thingTypeUID: string; + location?: string; + channels: Channel[]; +}; + +export type ThingConfig = { + availabilityTopic: string; + payloadNotAvailable: string; + payloadAvailable: string; +}; + +export type Channel = { + uid: string; + id: string; + channelTypeUID: string; + itemType?: ItemType; + kind: 'STATE' | 'TRIGGER'; + label: string; + description: string; + defaultTags: string[]; + configuration: ChannelConfig; + properties: Record; + autoUpdatePolicy?: string; +}; + +export type ChannelConfig = { + unit?: string; + min?: number; + max?: number; + on?: unknown; + off?: unknown; + allowedStates?: string; + stateTopic?: string; + commandTopic?: string; + qos?: number; + retained?: boolean; + postCommand?: boolean; + nullValue?: string; +}; + +export type ItemType = + 'String' + | 'Switch' + | 'Contact' + | 'Number' + | 'Dimmer' + | 'Color' + | 'Image' + | 'Location' + +export type ChannelType = + 'mqtt:string' + | 'mqtt:switch' + | 'mqtt:contact' + | 'mqtt:number' + | 'mqtt:dimmer' + | 'mqtt:color' + | 'mqtt:image' + | 'mqtt:location' + | 'mqtt:trigger' \ No newline at end of file diff --git a/src/z2m/loader.ts b/src/z2m/loader.ts new file mode 100644 index 0000000..21487ce --- /dev/null +++ b/src/z2m/loader.ts @@ -0,0 +1,139 @@ +import mqtt from "mqtt"; +import { Device, Feature, FeatureType } from "./types"; +import { Config } from ".."; +import { Channel, ChannelType, ItemType, Thing } from "../openhab/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)); +} + +/** + * 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(topic, parentUID, type)); + } + + return parseAtomFeature(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); + + const channelTypes: Partial> = { + binary: writeable ? 'mqtt:switch' : 'mqtt:contact', + numeric: 'mqtt:number', + enum: 'mqtt:string', + text: 'mqtt:string', + }; + + const itemTypes: Partial> = { + binary: writeable ? 'Switch' : 'Contact', + numeric: 'Number', + enum: 'String', + text: 'String', + }; + + + const channelTypeUID = channelTypes[feature.type]; + + if (channelTypeUID === undefined) { + throw new Error(`Unsupported feature type ${feature.type} for: ${JSON.stringify(feature)}`) + } + + const category = type ?? feature.category; + const prefix = category ? `${category[0].toUpperCase()}${category.slice(1)}: ` : ""; + + return [{ + channelTypeUID, + itemType: itemTypes[feature.type], + id: feature.name, + uid: `${parentUID}:${feature.name}`, + kind: "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: [], + }]; +}; + +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")) ?? []; + + 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] + } +} diff --git a/src/types/model.ts b/src/z2m/types.ts similarity index 76% rename from src/types/model.ts rename to src/z2m/types.ts index ea92a75..56e9370 100644 --- a/src/types/model.ts +++ b/src/z2m/types.ts @@ -26,13 +26,28 @@ export type Definition = { 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: string; + type: FeatureType; + category?: FeatureCategory; value_max?: number; value_min?: number; value_off?: boolean; @@ -41,6 +56,18 @@ export type Feature = { 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;