Create CLI interface
This commit is contained in:
36
package-lock.json
generated
36
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
25
src/adapters/abstract.ts
Normal file
25
src/adapters/abstract.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Thing } from "@types";
|
||||
|
||||
export abstract class Adapter<C = unknown> {
|
||||
protected name: string;
|
||||
protected config: C;
|
||||
|
||||
protected abstract get requiredFields(): readonly (keyof C)[];
|
||||
|
||||
constructor(name: string, config: Partial<C>) {
|
||||
this.name = name;
|
||||
this.config = this.#validateRequiredFields(config);
|
||||
}
|
||||
|
||||
#validateRequiredFields(config: Partial<C>): 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<Thing[]>;
|
||||
};
|
||||
5
src/adapters/index.ts
Normal file
5
src/adapters/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Z2MAdapter } from "./z2m";
|
||||
|
||||
export const adapters = {
|
||||
zigbee2mqtt: Z2MAdapter,
|
||||
}
|
||||
304
src/adapters/z2m.ts
Normal file
304
src/adapters/z2m.ts
Normal file
@@ -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<string, Endpoint>;
|
||||
|
||||
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<Z2MConfig> {
|
||||
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<Thing[]> {
|
||||
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<Device[]>((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<Record<FeatureType, ChannelType|undefined>> = {
|
||||
binary: writeable ? 'mqtt:switch' : 'mqtt:contact',
|
||||
numeric: 'mqtt:number',
|
||||
enum: isTrigger ? 'mqtt:trigger' : 'mqtt:string',
|
||||
text: 'mqtt:string',
|
||||
};
|
||||
|
||||
const itemTypes: Partial<Record<FeatureType, ItemType|undefined>> = {
|
||||
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<<bit));
|
||||
}
|
||||
};
|
||||
41
src/cli/index.ts
Normal file
41
src/cli/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { program } from "commander";
|
||||
|
||||
import { CLIOptions } from "@types";
|
||||
import { parseConfig } from "../config";
|
||||
import { importThings } from "../importer";
|
||||
|
||||
const getOptions = () => program
|
||||
.name("oh-import")
|
||||
.version("0.0.1")
|
||||
.requiredOption("-c, --config <file>", "sets the path to the YAML file with configuration")
|
||||
.option("-x, --set <arg>", "overrides the config option for this specific run (arg: <key>=<name>, 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<CLIOptions>();
|
||||
|
||||
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);
|
||||
}
|
||||
5
src/config/index.ts
Normal file
5
src/config/index.ts
Normal file
@@ -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;
|
||||
@@ -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<Thing[]>('/things');
|
||||
const existingThingsUIDs = getThingsResponse.data.map(t => t.UID);
|
||||
// const getThingsResponse = await openhab.get<Thing[]>('/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;
|
||||
}
|
||||
25
src/index.ts
25
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();
|
||||
5
src/types/cli.ts
Normal file
5
src/types/cli.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type CLIOptions = {
|
||||
config: string;
|
||||
set: string[];
|
||||
sources?: string[];
|
||||
};
|
||||
12
src/types/config.ts
Normal file
12
src/types/config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type Config = {
|
||||
baseURL: string;
|
||||
token: string;
|
||||
override?: boolean;
|
||||
|
||||
sources: Record<string, SourceConfig>;
|
||||
};
|
||||
|
||||
type SourceConfig = {
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
import { Thing } from "./openhab";
|
||||
|
||||
export interface Adapter {
|
||||
loadThings(): Promise<Thing[]>;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./importer";
|
||||
export * from "./openhab";
|
||||
export * from "./z2m";
|
||||
export * from "./z2m";
|
||||
export * from "./config";
|
||||
export * from "./cli";
|
||||
128
src/types/z2m.ts
128
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<string, Endpoint>;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
170
src/z2m/index.ts
170
src/z2m/index.ts
@@ -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<Thing[]> {
|
||||
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<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);
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const toChannels = (config: Config, 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(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<Record<FeatureType, ChannelType|undefined>> = {
|
||||
binary: writeable ? 'mqtt:switch' : 'mqtt:contact',
|
||||
numeric: 'mqtt:number',
|
||||
enum: isTrigger ? 'mqtt:trigger' : 'mqtt:string',
|
||||
text: 'mqtt:string',
|
||||
};
|
||||
|
||||
const itemTypes: Partial<Record<FeatureType, ItemType|undefined>> = {
|
||||
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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user