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) {
|
||||
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);
|
||||
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;
|
||||
};
|
||||
|
||||
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>;
|
||||
Reference in New Issue
Block a user