Skip to content

Commit

Permalink
automatic service and characteristic creation using device definition
Browse files Browse the repository at this point in the history
  • Loading branch information
mdaskalov committed Oct 29, 2024
1 parent 2f69658 commit d6cc77d
Show file tree
Hide file tree
Showing 3 changed files with 372 additions and 22 deletions.
35 changes: 13 additions & 22 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -277,10 +277,10 @@
"enum": [ "LIGHT" ]
}, {
"title": "Dimmable Light",
"enum": [ "Dimmer" ]
"enum": [ "LIGHT_B" ]
}, {
"title": "RGB Light",
"enum": [ "HSBColor" ]
"enum": [ "LIGHT_HSB" ]
}, {
"title": "Switch 1",
"enum": [ "POWER1" ]
Expand All @@ -295,16 +295,16 @@
"enum": [ "POWER4" ]
}, {
"title": "ContactSensor 1",
"enum": [ "Switch1" ]
"enum": [ "CONTACT1" ]
}, {
"title": "ContactSensor 2",
"enum": [ "Switch2" ]
"enum": [ "CONTACT2" ]
}, {
"title": "ContactSensor 3",
"enum": [ "Switch3" ]
"enum": [ "CONTACT3" ]
}, {
"title": "ContactSensor 4",
"enum": [ "Switch4" ]
"enum": [ "CONTACT4" ]
}, {
"title": "Light 1",
"enum": [ "LIGHT1" ]
Expand All @@ -321,29 +321,20 @@
"title": "Analog Temperature",
"enum": [ "StatusSNS.ANALOG.Temperature" ]
}, {
"title": "AM2301 Temperature",
"enum": [ "StatusSNS.AM2301.Temperature" ]
}, {
"title": "AM2301 Humidity",
"enum": [ "StatusSNS.AM2301.Humidity" ]
"title": "AM2301 Temperature & Humidity",
"enum": [ "AM2301_TH" ]
}, {
"title": "BMP280 Temperature",
"enum": [ "StatusSNS.BMP280.Temperature" ]
}, {
"title": "DHT11 Temperature",
"enum": [ "StatusSNS.DHT11.Temperature" ]
"enum": [ "BMP280_T" ]
}, {
"title": "DHT11 Humidity",
"enum": [ "StatusSNS.DHT11.Humidity" ]
"title": "DHT11 Temperature & Humidity",
"enum": [ "DHT11_TH" ]
}, {
"title": "DS18B20 Temperature",
"enum": [ "StatusSNS.DS18B20.Temperature" ]
"enum": [ "DS18B20_T" ]
}, {
"title": "HTU21 Temperature",
"enum": [ "StatusSNS.HTU21.Temperature" ]
}, {
"title": "HTU21 Humidity",
"enum": [ "StatusSNS.HTU21.Humidity" ]
"enum": [ "HTU21_T" ]
} ],
"required": true
},
Expand Down
291 changes: 291 additions & 0 deletions src/tasmotaCharacteristic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
import {
Service,
PlatformAccessory,
CharacteristicValue,
CharacteristicProps,
Formats,
Characteristic,
} from 'homebridge';

import { TasmotaZbBridgePlatform } from './platform';

const EXEC_TIMEOUT = 1500;

export type Mapping = {
from: string,
to: CharacteristicValue
}[];

export type TasmotaCommand = {
cmd: string;
topic?: string;
valuePath?: string;
};

export type TasmotaCharacteristicDefinition = {
get?: TasmotaCommand;
set?: TasmotaCommand;
statTopic?: string;
statValuePath?: string;
statDisabled?: boolean;
teleTopic?: string;
teleValuePath?: string;
props?: object
mapping?: Mapping;
defaultValue?: CharacteristicValue;
};

export class TasmotaCharacteristic {
private cmndTopic: string;
private statTopic: string;
private teleTopic: string;

private characteristic: Characteristic;
private props: CharacteristicProps;
private value: CharacteristicValue;

constructor(
readonly platform: TasmotaZbBridgePlatform,
readonly accessory: PlatformAccessory,
readonly service: Service,
readonly name: string,
readonly definition: TasmotaCharacteristicDefinition,
) {
this.cmndTopic = 'cmnd/' + this.accessory.context.device.topic + '/';
this.statTopic = 'stat/' + this.accessory.context.device.topic + '/';
this.teleTopic = 'tele/' + this.accessory.context.device.topic + '/';

this.characteristic = this.service.getCharacteristic(this.platform.Characteristic[this.name]);
if (this.characteristic !== undefined) {
if (definition.props !== undefined) {
for (const [name, value] of Object.entries(definition.props as object)) {
if (this.characteristic.props[name] !== undefined) {
this.log('props.%s set to %s', name, value);
this.characteristic.props[name] = value;
} else {
this.platform.log.error('%s: %s Invalid property: props.%s - ignored',
this.accessory.context.device.name,
this.name,
name,
);
}
}
}
this.props = this.characteristic.props;
//this.log('characteristic props: %s', JSON.stringify(this.props));
this.value = this.initValue();
const onGetEnabled = this.props.perms.includes(this.platform.api.hap.Perms.PAIRED_READ);
const onSetEnabled = this.props.perms.includes(this.platform.api.hap.Perms.PAIRED_WRITE);
if (onGetEnabled) {
this.characteristic.onGet(this.onGet.bind(this));
}
if (onSetEnabled) {
this.characteristic.onSet(this.onSet.bind(this));
}
// statValuePath defaults to get.cmd if not set
const onStatEnabled = (definition.statValuePath !== undefined || definition.get?.cmd !== undefined);
if (onStatEnabled && definition.statDisabled !== true) {
const statTopic = this.statTopic + (definition.statTopic || 'RESULT');
const valuePath = this.definition.statValuePath || this.definition?.get?.cmd;
this.log('Configure statUpdate on topic: %s %s', statTopic, valuePath);
this.platform.mqttClient.subscribeTopic(statTopic, message => {
if (valuePath !== undefined) {
this.setValue('statUpdate', this.getValueByPath(message, valuePath));
}
});
}
// teleValuePath must be set to enable
if (definition.teleValuePath !== undefined) {
const teleTopic = this.teleTopic + (definition.teleTopic || 'SENSOR');
const valuePath = definition.teleValuePath;
this.log('Configure teleUpdate on topic: %s %s', teleTopic, valuePath);
this.platform.mqttClient.subscribeTopic(teleTopic, message => {
this.setValue('teleUpdate', this.getValueByPath(message, valuePath));
});
}
} else {
throw new Error (`Unable to initialize characteristic: ${this.name}`);
}
}

private async onGet(): Promise<CharacteristicValue> {
if (this.definition.get !== undefined) {
try {
const value = await this.exec(this.definition.get);
this.setValue('onGet', value);
} catch (err) {
this.platform.log.error(err as string);
}
}
return this.value;
}

private async onSet(value: CharacteristicValue) {
const command = this.definition.get ? this.definition.get : this.definition.set;
const payload = this.mapFromHB(value);
if (command !== undefined && payload !== undefined) {
try {
const valueToConfirm = await this.exec(command, payload);
if (valueToConfirm === payload) {
this.setValue('onSet', payload);
} else {
this.platform.log.warn('%s:%s Set value: %s (%s (%s)) confirmation differs: %s (%s)',
this.accessory.context.device.name,
this.name,
value,
payload,
typeof(payload),
valueToConfirm,
typeof(valueToConfirm),
);
}
} catch (err) {
this.platform.log.error(err as string);
}
}
}

async exec(command: TasmotaCommand, payload?: string): Promise<string> {
return new Promise((resolve: (value: string) => void, reject: (error: string) => void) => {
const split = command.cmd.split(' ');
const cmd = split[0];
const message = payload || split[1] || '';
const reqTopic = this.cmndTopic + cmd;
const resTopic = this.statTopic + (command.topic || 'RESULT');
const valuePath = command.valuePath || cmd;

const start = Date.now();
let timeout: NodeJS.Timeout | undefined = undefined;
let handlerId: string | undefined = undefined;
handlerId = this.platform.mqttClient.subscribeTopic(resTopic, responseMessage => {
if (timeout !== undefined) {
clearTimeout(timeout);
}
const response = this.getValueByPath(responseMessage, valuePath);
if (response !== undefined) {
if (handlerId !== undefined) {
this.platform.mqttClient.unsubscribe(handlerId);
}
resolve(response);
return true; // consume message
}
}, true);
timeout = setTimeout(() => {
if (handlerId !== undefined) {
this.platform.mqttClient.unsubscribe(handlerId);
}
const elapsed = Date.now() - start;
reject(`${this.accessory.context.device.name}:${this.name} Command "${reqTopic} ${message}" timeouted after ${elapsed}ms`);
}, EXEC_TIMEOUT);
this.platform.mqttClient.publish(reqTopic, message);
});
}

setValue(origin: string, value: string | undefined) {
if (value !== undefined) {
const hbValue = this.checkHBValue(this.mapToHB(value));
if (hbValue !== undefined) {
const prevValue = this.value;
const ignored = (hbValue === prevValue);
if (!ignored){
this.service.getCharacteristic(this.platform.Characteristic[this.name]).updateValue(hbValue);
this.value = hbValue;
}
this.log('%s valueSet%s: %s (hb: %s), prev: %s',
origin,
ignored ? ' (unchanged)' : '',
value,
hbValue,
prevValue,
);
}
}
}

private getValueByPath(json: string, path: string): string | undefined {
let obj = Object();
try {
obj = JSON.parse(json);
} catch {
return undefined; // not parsed
}
const result = path.split('.').reduce((a, v) => a ? a[v] : undefined, obj);
return result !== undefined ? String(result) : undefined;
}

private mapFromHB(value: CharacteristicValue): string | undefined {
if (Array.isArray(this.definition.mapping)) {
const mapEntry = this.definition.mapping.find(m => m.to === value);
if (mapEntry !== undefined) {
return mapEntry.from;
}
}
switch (this.props.format) {
case Formats.BOOL:
return value ? 'ON' : 'OFF';
default:
return String(value);
}
}

private mapToHB(value: string): CharacteristicValue | undefined {
if (Array.isArray(this.definition.mapping)) {
const mapEntry = this.definition.mapping.find(m => m.from === value);
if (mapEntry !== undefined) {
return mapEntry.to;
}
}
switch (this.props.format) {
case Formats.BOOL:
return (value === 'ON' || value === '1' || value === 'True') ? true :false;
case Formats.STRING:
case Formats.DATA:
case Formats.TLV8:
return value;
default:
return this.checkHBValue(value);
}
}

private checkHBValue(value: CharacteristicValue | undefined): CharacteristicValue | undefined {
if (value === undefined) {
return value;
}
//this.log('return: %s :- min: %s, max: %s', value, this.props.minValue, this.props.maxValue);
if (this.props.minValue !== undefined && value as number < this.props.minValue) {
return this.props.minValue;
}
if (this.props.maxValue !== undefined && value as number > this.props.maxValue) {
return this.props.maxValue;
}
return value;
}

private initValue(): CharacteristicValue {
if (this.definition.defaultValue !== undefined) {
const value = this.checkHBValue(this.definition.defaultValue);
if (value !== undefined) {
return value;
}
}
switch (this.props.format) {
case Formats.BOOL:
return false;
case Formats.STRING:
case Formats.DATA:
case Formats.TLV8:
return '';
default: {
const value = this.checkHBValue(0);
return value !== undefined ? value : 0;
}
}
}

log(message: string, ...parameters: unknown[]): void {
this.platform.log.debug(this.accessory.context.device.name + ':' + this.name + ' ' + message,
...parameters,
);
}

}
Loading

0 comments on commit d6cc77d

Please sign in to comment.