Create CLI interface

This commit is contained in:
2024-11-15 16:38:19 +01:00
parent 1eabb801db
commit 5fc9e9cbf2
15 changed files with 470 additions and 339 deletions

36
package-lock.json generated
View File

@@ -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"
}
}
}
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
import { Z2MAdapter } from "./z2m";
export const adapters = {
zigbee2mqtt: Z2MAdapter,
}

304
src/adapters/z2m.ts Normal file
View 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
View 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
View 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;

View File

@@ -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;
}

View File

@@ -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
View File

@@ -0,0 +1,5 @@
export type CLIOptions = {
config: string;
set: string[];
sources?: string[];
};

12
src/types/config.ts Normal file
View 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>;
};

View File

@@ -1,5 +0,0 @@
import { Thing } from "./openhab";
export interface Adapter {
loadThings(): Promise<Thing[]>;
};

View File

@@ -1,3 +1,4 @@
export * from "./importer";
export * from "./openhab";
export * from "./z2m";
export * from "./z2m";
export * from "./config";
export * from "./cli";

View File

@@ -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;
};

View File

@@ -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]
}
}