Enable conversion from Z2M Exposes API to OpenHAB things

This commit is contained in:
2024-11-11 18:06:17 +01:00
parent 71799a5b9e
commit e583bc19ad
5 changed files with 252 additions and 27 deletions

View File

@@ -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);
run(config);

View File

@@ -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<Device[]> {
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[]);
});
});
}

66
src/openhab/types.ts Normal file
View File

@@ -0,0 +1,66 @@
export type Thing = {
label: string;
bridgeUID: string;
configuration: ThingConfig;
properties: Record<string, string>;
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<string, string>;
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'

139
src/z2m/loader.ts Normal file
View File

@@ -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<Thing[]> => {
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<Device[]> => 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<<bit));
/**
* 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
*/
const isPassiveReadable = 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
*/
const isWriteable = 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
*/
const isActiveReadable = isBitSet(2);
const toChannels = (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 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<Record<FeatureType, ChannelType|undefined>> = {
binary: writeable ? 'mqtt:switch' : 'mqtt:contact',
numeric: 'mqtt:number',
enum: 'mqtt:string',
text: 'mqtt:string',
};
const itemTypes: Partial<Record<FeatureType, ItemType|undefined>> = {
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]
}
}

View File

@@ -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<string, Endpoint>;