Enable conversion from Z2M Exposes API to OpenHAB things
This commit is contained in:
26
src/index.ts
26
src/index.ts
@@ -1,11 +1,23 @@
|
|||||||
import { fetchModel } from "./mqtt";
|
import { fromZ2M } from "./z2m/loader";
|
||||||
|
|
||||||
async function run(username: string, password: string) {
|
export type Config = {
|
||||||
const response = await fetchModel(username, password);
|
bridgeID: string;
|
||||||
console.log(response);
|
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 config: Config = {
|
||||||
const password = "test";
|
bridgeID: "main",
|
||||||
|
prefix: "zigbee",
|
||||||
|
username: 'test',
|
||||||
|
password: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
run(username, password);
|
run(config);
|
||||||
19
src/mqtt.ts
19
src/mqtt.ts
@@ -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
66
src/openhab/types.ts
Normal 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
139
src/z2m/loader.ts
Normal 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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,13 +26,28 @@ export type Definition = {
|
|||||||
vendor: string;
|
vendor: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FeatureType =
|
||||||
|
'binary'
|
||||||
|
| 'numeric'
|
||||||
|
| 'enum'
|
||||||
|
| 'text'
|
||||||
|
| 'composite'
|
||||||
|
| 'list'
|
||||||
|
| 'light'
|
||||||
|
| 'switch'
|
||||||
|
| 'fan'
|
||||||
|
| 'cover'
|
||||||
|
| 'lock'
|
||||||
|
| 'climate'
|
||||||
|
|
||||||
export type Feature = {
|
export type Feature = {
|
||||||
access: number;
|
access: number;
|
||||||
description: string;
|
description: string;
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
property: string;
|
property: string;
|
||||||
type: string;
|
type: FeatureType;
|
||||||
|
category?: FeatureCategory;
|
||||||
value_max?: number;
|
value_max?: number;
|
||||||
value_min?: number;
|
value_min?: number;
|
||||||
value_off?: boolean;
|
value_off?: boolean;
|
||||||
@@ -41,6 +56,18 @@ export type Feature = {
|
|||||||
values?: string[];
|
values?: string[];
|
||||||
unit?: string;
|
unit?: string;
|
||||||
features?: Feature[];
|
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>;
|
export type Endpoints = Record<string, Endpoint>;
|
||||||
Reference in New Issue
Block a user