Create CLI interface
This commit is contained in:
36
package-lock.json
generated
36
package-lock.json
generated
@@ -10,7 +10,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.9.0",
|
"@types/node": "^22.9.0",
|
||||||
@@ -640,12 +645,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "9.5.0",
|
"version": "12.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
|
||||||
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
|
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^12.20.0 || >=14"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commist": {
|
"node_modules/commist": {
|
||||||
@@ -1372,6 +1376,15 @@
|
|||||||
"tsc-alias": "dist/bin/index.js"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
@@ -1474,6 +1487,17 @@
|
|||||||
"optional": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.7",
|
"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 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({
|
const openhab = axios.create({
|
||||||
baseURL,
|
baseURL: config.baseURL,
|
||||||
headers: {
|
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];
|
||||||
|
|
||||||
if (override) {
|
const constructor = adapters[type as keyof (typeof adapters)];
|
||||||
|
|
||||||
|
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}`));
|
// things.forEach(t => openhab.delete(`/things/${t.UID}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
const getThingsResponse = await openhab.get<Thing[]>('/things');
|
// const getThingsResponse = await openhab.get<Thing[]>('/things');
|
||||||
const existingThingsUIDs = getThingsResponse.data.map(t => t.UID);
|
// 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);
|
// 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
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { importThings } from "./importer";
|
import { run } from "./cli";
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
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 "./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 = {
|
export type Z2MConfig = {
|
||||||
/**
|
brokerURL: string;
|
||||||
* ID of the MQTT bridge in OpenHAB
|
|
||||||
*/
|
|
||||||
bridgeID: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefix of the Z2M topic in MQTT network
|
|
||||||
*/
|
|
||||||
prefix: String;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Username of MQTT user
|
* Username of MQTT user
|
||||||
@@ -19,6 +11,16 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
password: string;
|
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
|
* Optional client ID displayed in MQTT broker logs
|
||||||
*/
|
*/
|
||||||
@@ -27,110 +29,6 @@ export type Config = {
|
|||||||
/**
|
/**
|
||||||
* Optional transformation applied to ID
|
* 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