2810 lines
72 KiB
TypeScript
2810 lines
72 KiB
TypeScript
import * as fs from 'fs'
|
|
import * as path from 'path'
|
|
import * as utils from './utils'
|
|
import { AlarmSensorType, SetValueAPIOptions } from 'zwave-js'
|
|
import { CommandClasses, ValueID } from '@zwave-js/core'
|
|
import * as Constants from './Constants'
|
|
import { LogLevel, module } from './logger'
|
|
import hassCfg, { ColorMode } from '../hass/configurations'
|
|
import hassDevices from '../hass/devices'
|
|
import { storeDir } from '../config/app'
|
|
import { IClientPublishOptions } from 'mqtt'
|
|
import MqttClient from './MqttClient'
|
|
import ZwaveClient, {
|
|
AllowedApis,
|
|
CallAPIResult,
|
|
EventSource,
|
|
HassDevice,
|
|
ZUINode,
|
|
ZUIValueId,
|
|
ZUIValueIdState,
|
|
} from './ZwaveClient'
|
|
import Cron from 'croner'
|
|
|
|
import crypto from 'crypto'
|
|
import { IMeterCCSpecific } from './Constants'
|
|
|
|
const logger = module('Gateway')
|
|
|
|
const NODE_PREFIX = 'nodeID_'
|
|
|
|
const UID_DISCOVERY_PREFIX = process.env.UID_DISCOVERY_PREFIX || 'zwavejs2mqtt_'
|
|
|
|
const GATEWAY_TYPE = {
|
|
VALUEID: 0,
|
|
NAMED: 1,
|
|
MANUAL: 2,
|
|
}
|
|
|
|
const PAYLOAD_TYPE = {
|
|
TIME_VALUE: 0,
|
|
VALUEID: 1,
|
|
RAW: 2,
|
|
}
|
|
|
|
const CUSTOM_DEVICES = storeDir + '/customDevices'
|
|
let allDevices = hassDevices // will contain customDevices + hassDevices
|
|
|
|
// watcher initiates a watch on a file. if this fails (e.g., because the file
|
|
// doesn't exist), instead watch the directory. If the directory watch
|
|
// triggers, cancel it and try to watch the file again. Meanwhile spam `fn()`
|
|
// on any change, trusting that it's idempotent.
|
|
const watchers: Map<string, fs.FSWatcher> = new Map()
|
|
const watch = (filename: string, fn: () => void) => {
|
|
try {
|
|
watchers.set(
|
|
filename,
|
|
fs.watch(filename, (e: string) => {
|
|
fn()
|
|
if (e === 'rename') {
|
|
watchers.get(filename).close()
|
|
watch(filename, fn)
|
|
}
|
|
}),
|
|
)
|
|
} catch {
|
|
watchers.set(
|
|
filename,
|
|
fs.watch(path.dirname(filename), (e, f) => {
|
|
if (
|
|
!f ||
|
|
f === 'customDevices.js' ||
|
|
f === 'customDevices.json'
|
|
) {
|
|
watchers.get(filename).close()
|
|
watch(filename, fn)
|
|
fn()
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
}
|
|
|
|
const customDevicesJsPath = CUSTOM_DEVICES + '.js'
|
|
const customDevicesJsonPath = CUSTOM_DEVICES + '.json'
|
|
|
|
let lastCustomDevicesLoad = null
|
|
// loadCustomDevices attempts to load a custom devices file, preferring `.js`
|
|
// but falling back to `.json` only if a `.js` file does not exist. It stores
|
|
// a sha of the loaded data, and will skip re-loading any time the data has
|
|
// not changed.
|
|
const loadCustomDevices = () => {
|
|
let loaded = ''
|
|
let devices = null
|
|
|
|
try {
|
|
if (fs.existsSync(customDevicesJsPath)) {
|
|
loaded = customDevicesJsPath
|
|
devices = require(CUSTOM_DEVICES)
|
|
} else if (fs.existsSync(customDevicesJsonPath)) {
|
|
loaded = customDevicesJsonPath
|
|
devices = JSON.parse(fs.readFileSync(loaded).toString())
|
|
} else {
|
|
return
|
|
}
|
|
} catch (error) {
|
|
logger.error(`Failed to load ${loaded}:`, error)
|
|
return
|
|
}
|
|
|
|
const sha = crypto
|
|
.createHash('sha256')
|
|
.update(JSON.stringify(devices))
|
|
.digest('hex')
|
|
if (lastCustomDevicesLoad === sha) {
|
|
return
|
|
}
|
|
|
|
logger.info(`Loading custom devices from ${loaded}`)
|
|
|
|
lastCustomDevicesLoad = sha
|
|
|
|
allDevices = Object.assign({}, hassDevices, devices)
|
|
logger.info(
|
|
`Loaded ${
|
|
Object.keys(devices).length
|
|
} custom Hass devices configurations`,
|
|
)
|
|
}
|
|
|
|
loadCustomDevices()
|
|
watch(customDevicesJsPath, loadCustomDevices)
|
|
watch(customDevicesJsonPath, loadCustomDevices)
|
|
|
|
export function closeWatchers() {
|
|
for (const [, watcher] of watchers) {
|
|
watcher.close()
|
|
}
|
|
}
|
|
|
|
export enum GatewayType {
|
|
VALUEID,
|
|
NAMED,
|
|
MANUAL,
|
|
}
|
|
|
|
export enum PayloadType {
|
|
JSON_TIME_VALUE,
|
|
VALUEID,
|
|
RAW,
|
|
}
|
|
|
|
export type GatewayValue = {
|
|
device: string
|
|
value: ZUIValueId
|
|
topic?: string
|
|
device_class?: string
|
|
icon?: string
|
|
postOperation?: string
|
|
enablePoll?: boolean
|
|
pollInterval?: number
|
|
parseSend?: boolean
|
|
sendFunction?: string
|
|
parseReceive?: boolean
|
|
receiveFunction?: string
|
|
qos?: 0 | 1 | 2
|
|
retain?: boolean
|
|
}
|
|
|
|
export type ScheduledJob = {
|
|
name: string
|
|
cron?: string
|
|
enabled: boolean
|
|
runOnInit: boolean
|
|
code: string
|
|
}
|
|
|
|
export type GatewayConfig = {
|
|
type: GatewayType
|
|
payloadType?: PayloadType
|
|
nodeNames?: boolean
|
|
ignoreLoc?: boolean
|
|
sendEvents?: boolean
|
|
ignoreStatus?: boolean
|
|
includeNodeInfo?: boolean
|
|
publishNodeDetails?: boolean
|
|
retainedDiscovery?: boolean
|
|
entityTemplate?: string
|
|
hassDiscovery?: boolean
|
|
discoveryPrefix?: string
|
|
logEnabled?: boolean
|
|
logLevel?: LogLevel
|
|
logToFile?: boolean
|
|
values?: GatewayValue[]
|
|
jobs?: ScheduledJob[]
|
|
plugins?: string[]
|
|
logFileName?: string
|
|
manualDiscovery?: boolean
|
|
authEnabled?: boolean
|
|
versions?: {
|
|
driver?: string
|
|
app?: string
|
|
server?: string
|
|
}
|
|
disableChangelog?: boolean
|
|
notifyNewVersions?: boolean
|
|
}
|
|
|
|
interface ValueIdTopic {
|
|
topic: string
|
|
valueConf: GatewayValue
|
|
targetTopic?: string
|
|
}
|
|
|
|
interface DeviceInfo {
|
|
identifiers: string[]
|
|
manufacturer: string
|
|
model: string
|
|
name: string
|
|
sw_version: string
|
|
}
|
|
|
|
export default class Gateway {
|
|
private config: GatewayConfig
|
|
private _mqtt: MqttClient
|
|
private _zwave: ZwaveClient
|
|
private topicValues: { [key: string]: ZUIValueId }
|
|
private discovered: { [key: string]: HassDevice }
|
|
private topicLevels: number[]
|
|
private _closed: boolean
|
|
private jobs: Map<string, Cron> = new Map()
|
|
|
|
public get mqtt() {
|
|
return this._mqtt
|
|
}
|
|
|
|
public get zwave() {
|
|
return this._zwave
|
|
}
|
|
|
|
public get closed() {
|
|
return this._closed
|
|
}
|
|
|
|
private get mqttEnabled() {
|
|
return this.mqtt && !this.mqtt.disabled
|
|
}
|
|
|
|
constructor(config: GatewayConfig, zwave: ZwaveClient, mqtt: MqttClient) {
|
|
this.config = config || { type: 1 }
|
|
// clients
|
|
this._mqtt = mqtt
|
|
this._zwave = zwave
|
|
}
|
|
|
|
async start(): Promise<void> {
|
|
// gateway configuration
|
|
this.config.values = this.config.values || []
|
|
|
|
// Object where keys are topic and values can be both zwave valueId object
|
|
// or a valueConf if the topic is a broadcast topic
|
|
this.topicValues = {}
|
|
|
|
this.discovered = {}
|
|
|
|
this._closed = false
|
|
|
|
// topic levels for subscribes using wildecards
|
|
this.topicLevels = []
|
|
|
|
if (this.mqttEnabled) {
|
|
this._mqtt.on('writeRequest', this._onWriteRequest.bind(this))
|
|
this._mqtt.on('broadcastRequest', this._onBroadRequest.bind(this))
|
|
this._mqtt.on(
|
|
'multicastRequest',
|
|
this._onMulticastRequest.bind(this),
|
|
)
|
|
this._mqtt.on('apiCall', this._onApiRequest.bind(this))
|
|
this._mqtt.on('hassStatus', this._onHassStatus.bind(this))
|
|
this._mqtt.on('brokerStatus', this._onBrokerStatus.bind(this))
|
|
}
|
|
|
|
if (this._zwave) {
|
|
// needed in order to apply gateway values configs like polling
|
|
this._zwave.on('nodeInited', this._onNodeInited.bind(this))
|
|
// needed to init scheduled jobs
|
|
this._zwave.on('driverStatus', this._onDriverStatus.bind(this))
|
|
|
|
if (this.mqttEnabled) {
|
|
this._zwave.on('nodeStatus', this._onNodeStatus.bind(this))
|
|
this._zwave.on(
|
|
'nodeLastActive',
|
|
this._onNodeLastActive.bind(this),
|
|
)
|
|
|
|
this._zwave.on('valueChanged', this._onValueChanged.bind(this))
|
|
this._zwave.on('nodeRemoved', this._onNodeRemoved.bind(this))
|
|
this._zwave.on('notification', this._onNotification.bind(this))
|
|
|
|
if (this.config.sendEvents) {
|
|
this._zwave.on('event', this._onEvent.bind(this))
|
|
}
|
|
}
|
|
|
|
// this is async but doesn't need to be awaited
|
|
await this._zwave.connect()
|
|
} else {
|
|
logger.error('Z-Wave settings are not valid')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule a job
|
|
*/
|
|
scheduleJob(jobConfig: ScheduledJob) {
|
|
if (jobConfig.enabled) {
|
|
if (jobConfig.runOnInit) {
|
|
this.runJob(jobConfig).catch((error) => {
|
|
logger.error(
|
|
`Error while executing scheduled job "${jobConfig.name}": ${error.message}`,
|
|
)
|
|
})
|
|
}
|
|
|
|
if (jobConfig.cron) {
|
|
try {
|
|
const job = new Cron(
|
|
jobConfig.cron,
|
|
this.runJob.bind(this, jobConfig),
|
|
)
|
|
|
|
if (job?.nextRun()) {
|
|
this.jobs.set(jobConfig.name, job)
|
|
logger.info(
|
|
`Scheduled job "${jobConfig.name}" will run at ${job
|
|
.nextRun()
|
|
.toISOString()}`,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while scheduling job "${jobConfig.name}": ${error.message}`,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Executes a scheduled job
|
|
*/
|
|
private async runJob(jobConfig: ScheduledJob) {
|
|
logger.info(`Executing scheduled job "${jobConfig.name}"...`)
|
|
try {
|
|
await this.zwave.driverFunction(jobConfig.code)
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error executing scheduled job "${jobConfig.name}": ${error.message}`,
|
|
)
|
|
}
|
|
|
|
const job = this.jobs.get(jobConfig.name)
|
|
|
|
if (job?.nextRun()) {
|
|
logger.info(
|
|
`Next scheduled job "${jobConfig.name}" will run at ${job
|
|
.nextRun()
|
|
.toISOString()}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse the value of the payload received from mqtt
|
|
* based on the type of the payload and the gateway config
|
|
*/
|
|
parsePayload(payload: any, valueId: ZUIValueId, valueConf: GatewayValue) {
|
|
try {
|
|
payload =
|
|
typeof payload === 'object' &&
|
|
utils.hasProperty(payload, 'value')
|
|
? payload.value
|
|
: payload
|
|
|
|
// try to parse string to bools
|
|
if (typeof payload === 'string' && isNaN(parseInt(payload))) {
|
|
if (/\btrue\b|\bon\b|\block\b/gi.test(payload)) payload = true
|
|
else if (/\bfalse\b|\boff\b|\bunlock\b/gi.test(payload)) {
|
|
payload = false
|
|
}
|
|
}
|
|
|
|
// on/off becomes 100%/0%
|
|
if (typeof payload === 'boolean' && valueId.type === 'number') {
|
|
payload = payload ? 0xff : valueId.min
|
|
}
|
|
|
|
// 1/0 becomes true/false
|
|
if (typeof payload === 'number' && valueId.type === 'boolean') {
|
|
payload = payload > 0
|
|
}
|
|
|
|
if (
|
|
valueId.commandClass === CommandClasses['Binary Toggle Switch']
|
|
) {
|
|
payload = 1
|
|
} else if (
|
|
valueId.commandClass ===
|
|
CommandClasses['Multilevel Toggle Switch']
|
|
) {
|
|
payload = valueId.value > 0 ? 0 : 0xff
|
|
}
|
|
|
|
const hassDevice = this.discovered[valueId.id]
|
|
|
|
// Hass payload parsing
|
|
if (hassDevice) {
|
|
// map modes coming from hass
|
|
if (valueId.list && isNaN(parseInt(payload))) {
|
|
// for thermostat_fan_mode command class use the fan_mode_map
|
|
if (
|
|
valueId.commandClass ===
|
|
CommandClasses['Thermostat Fan Mode'] &&
|
|
hassDevice.fan_mode_map
|
|
) {
|
|
payload = hassDevice.fan_mode_map[payload]
|
|
} else if (
|
|
valueId.commandClass ===
|
|
CommandClasses['Thermostat Mode'] &&
|
|
hassDevice.mode_map
|
|
) {
|
|
// for other command classes use the mode_map
|
|
payload = hassDevice.mode_map[payload]
|
|
}
|
|
} else if (
|
|
hassDevice.type === 'cover' &&
|
|
valueId.property === 'targetValue'
|
|
) {
|
|
// ref issue https://github.com/zwave-js/zwave-js-ui/issues/3862
|
|
if (
|
|
payload ===
|
|
(hassDevice.discovery_payload.payload_stop ?? 'STOP')
|
|
) {
|
|
this._zwave
|
|
.writeValue(
|
|
{
|
|
...valueId,
|
|
property: 'Up',
|
|
},
|
|
false,
|
|
)
|
|
.catch(() => {})
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
if (valueConf) {
|
|
if (this._isValidOperation(valueConf.postOperation)) {
|
|
let op = valueConf.postOperation
|
|
|
|
// revert operation to write
|
|
if (op.includes('/')) op = op.replace(/\//, '*')
|
|
else if (op.includes('*')) op = op.replace(/\*/g, '/')
|
|
else if (op.includes('+')) op = op.replace(/\+/, '-')
|
|
else if (op.includes('-')) op = op.replace(/-/, '+')
|
|
|
|
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
|
payload = eval(`${payload}${op}`)
|
|
}
|
|
|
|
if (valueConf.parseReceive) {
|
|
const node = this._zwave.nodes.get(valueId.nodeId)
|
|
const parsedVal = this._evalFunction(
|
|
valueConf.receiveFunction,
|
|
valueId,
|
|
payload,
|
|
node,
|
|
)
|
|
if (parsedVal != null) {
|
|
payload = parsedVal
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while parsing payload ${payload} for valueID ${valueId.id}`,
|
|
)
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
/**
|
|
* Method used to cancel all scheduled jobs
|
|
*/
|
|
cancelJobs() {
|
|
// cancel jobs
|
|
for (const [, job] of this.jobs) {
|
|
job.stop()
|
|
}
|
|
|
|
this.jobs.clear()
|
|
}
|
|
|
|
/**
|
|
* Method used to close clients connection, use this before destroy
|
|
*/
|
|
async close(): Promise<void> {
|
|
this._closed = true
|
|
|
|
logger.info('Closing Gateway...')
|
|
|
|
if (this._zwave) {
|
|
await this._zwave.close()
|
|
}
|
|
|
|
this.cancelJobs()
|
|
|
|
// close mqtt client after zwave connection is closed
|
|
if (this.mqttEnabled) {
|
|
await this._mqtt.close()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculates the node topic based on gateway settings
|
|
*/
|
|
nodeTopic(node: ZUINode): string {
|
|
const topic = []
|
|
|
|
if (node.loc && !this.config.ignoreLoc) topic.push(node.loc)
|
|
|
|
switch (this.config.type) {
|
|
case GATEWAY_TYPE.MANUAL:
|
|
case GATEWAY_TYPE.NAMED:
|
|
topic.push(node.name ? node.name : NODE_PREFIX + node.id)
|
|
break
|
|
case GATEWAY_TYPE.VALUEID:
|
|
if (!this.config.nodeNames) {
|
|
topic.push(node.id)
|
|
} else {
|
|
topic.push(node.name ? node.name : NODE_PREFIX + node.id)
|
|
}
|
|
break
|
|
default:
|
|
topic.push(NODE_PREFIX + node.id)
|
|
}
|
|
|
|
// clean topic parts
|
|
for (let i = 0; i < topic.length; i++) {
|
|
topic[i] = utils.sanitizeTopic(topic[i])
|
|
}
|
|
|
|
return topic.join('/')
|
|
}
|
|
|
|
/**
|
|
* Calculates the valueId topic based on gateway settings
|
|
*
|
|
*/
|
|
valueTopic(
|
|
node: ZUINode,
|
|
valueId: ZUIValueId,
|
|
returnObject = false,
|
|
): string | ValueIdTopic {
|
|
const topic = []
|
|
let valueConf: GatewayValue
|
|
|
|
// check if this value is in configuration values array
|
|
const values = this.config.values.filter(
|
|
(v: GatewayValue) => v.device === node.deviceId,
|
|
)
|
|
if (values && values.length > 0) {
|
|
const vID = this._getIdWithoutNode(valueId)
|
|
valueConf = values.find((v: GatewayValue) => v.value.id === vID)
|
|
}
|
|
|
|
if (valueConf && valueConf.topic) {
|
|
topic.push(node.name ? node.name : NODE_PREFIX + valueId.nodeId)
|
|
topic.push(valueConf.topic)
|
|
}
|
|
|
|
let targetTopic: string
|
|
|
|
if (returnObject && valueId.targetValue) {
|
|
const targetValue = node.values[valueId.targetValue]
|
|
if (targetValue) {
|
|
targetTopic = this.valueTopic(
|
|
node,
|
|
targetValue,
|
|
false,
|
|
) as string
|
|
}
|
|
}
|
|
|
|
// if is not in configuration values array get the topic
|
|
// based on gateway type if manual type this will be skipped
|
|
if (topic.length === 0) {
|
|
switch (this.config.type) {
|
|
case GATEWAY_TYPE.NAMED:
|
|
topic.push(
|
|
node.name ? node.name : NODE_PREFIX + valueId.nodeId,
|
|
)
|
|
topic.push(Constants.commandClass(valueId.commandClass))
|
|
|
|
topic.push('endpoint_' + (valueId.endpoint || 0))
|
|
|
|
topic.push(utils.removeSlash(valueId.propertyName))
|
|
if (valueId.propertyKey !== undefined) {
|
|
topic.push(utils.removeSlash(valueId.propertyKey))
|
|
}
|
|
break
|
|
case GATEWAY_TYPE.VALUEID:
|
|
if (!this.config.nodeNames) {
|
|
topic.push(valueId.nodeId)
|
|
} else {
|
|
topic.push(
|
|
node.name
|
|
? node.name
|
|
: NODE_PREFIX + valueId.nodeId,
|
|
)
|
|
}
|
|
topic.push(valueId.commandClass)
|
|
topic.push(valueId.endpoint || '0')
|
|
topic.push(utils.removeSlash(valueId.property))
|
|
if (valueId.propertyKey !== undefined) {
|
|
topic.push(utils.removeSlash(valueId.propertyKey))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// if there is a valid topic for this value publish it
|
|
if (topic.length > 0) {
|
|
// add location prefix
|
|
if (node.loc && !this.config.ignoreLoc) topic.unshift(node.loc)
|
|
|
|
// clean topic parts
|
|
for (let i = 0; i < topic.length; i++) {
|
|
topic[i] = utils.sanitizeTopic(topic[i])
|
|
}
|
|
|
|
const toReturn = {
|
|
topic: topic.join('/'),
|
|
valueConf: valueConf,
|
|
targetTopic: targetTopic,
|
|
}
|
|
|
|
return returnObject ? toReturn : toReturn.topic
|
|
} else {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rediscover all hass devices of this node
|
|
*/
|
|
rediscoverNode(nodeID: number): void {
|
|
const node = this._zwave.nodes.get(nodeID)
|
|
if (node) {
|
|
// delete all discovered values
|
|
this._onNodeRemoved(node)
|
|
node.hassDevices = {}
|
|
|
|
// rediscover all values
|
|
const nodeDevices = allDevices[node.deviceId] || []
|
|
nodeDevices.forEach((device) => this.discoverDevice(node, device))
|
|
|
|
// discover node values (that are not part of a device)
|
|
// iterate prioritized first, then the remaining
|
|
for (const id of this._getPriorityCCFirst(node.values)) {
|
|
this.discoverValue(node, id)
|
|
}
|
|
|
|
this._zwave.emitNodeUpdate(node, {
|
|
hassDevices: node.hassDevices,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disable the discovery of all devices of this node
|
|
*
|
|
*/
|
|
disableDiscovery(nodeId: number): void {
|
|
const node = this._zwave.nodes.get(nodeId)
|
|
if (node && node.hassDevices) {
|
|
for (const id in node.hassDevices) {
|
|
node.hassDevices[id].ignoreDiscovery = true
|
|
}
|
|
|
|
this._zwave.emitNodeUpdate(node, {
|
|
hassDevices: node.hassDevices,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Publish a discovery payload to discover a device in hass using mqtt auto discovery
|
|
*
|
|
*/
|
|
publishDiscovery(
|
|
hassDevice: HassDevice,
|
|
nodeId: number,
|
|
options: { deleteDevice?: boolean; forceUpdate?: boolean } = {},
|
|
): void {
|
|
try {
|
|
if (!this.mqttEnabled || !this.config.hassDiscovery) {
|
|
logger.debug(
|
|
'Enable MQTT gateway and hass discovery to use this function',
|
|
)
|
|
return
|
|
}
|
|
|
|
logger.log(
|
|
'debug',
|
|
`${
|
|
options.deleteDevice ? 'Removing' : 'Publishing'
|
|
} discovery: %o`,
|
|
hassDevice,
|
|
)
|
|
|
|
this.setDiscovery(nodeId, hassDevice, options.deleteDevice)
|
|
|
|
if (this.config.payloadType === PAYLOAD_TYPE.RAW) {
|
|
const p = hassDevice.discovery_payload
|
|
const template =
|
|
'value' +
|
|
(utils.hasProperty(p, 'payload_on') &&
|
|
utils.hasProperty(p, 'payload_off')
|
|
? " == 'true'"
|
|
: '')
|
|
|
|
for (const k in p) {
|
|
if (typeof p[k] === 'string') {
|
|
p[k] = p[k].replace(/value_json\.value/g, template)
|
|
}
|
|
}
|
|
}
|
|
|
|
const skipDiscovery =
|
|
hassDevice.ignoreDiscovery ||
|
|
(this.config.manualDiscovery && !options.forceUpdate)
|
|
|
|
if (!skipDiscovery) {
|
|
this._mqtt.publish(
|
|
hassDevice.discoveryTopic,
|
|
options.deleteDevice ? '' : hassDevice.discovery_payload,
|
|
{ qos: 0, retain: this.config.retainedDiscovery || false },
|
|
this.config.discoveryPrefix,
|
|
)
|
|
}
|
|
|
|
if (options.forceUpdate) {
|
|
this._zwave.updateDevice(
|
|
hassDevice,
|
|
nodeId,
|
|
options.deleteDevice,
|
|
)
|
|
}
|
|
} catch (error) {
|
|
logger.log(
|
|
'error',
|
|
`Error while publishing discovery for node ${nodeId}: ${error.message}. Hass device: %o`,
|
|
hassDevice,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set internal discovery reference of a valueId
|
|
*
|
|
*/
|
|
setDiscovery(
|
|
nodeId: number,
|
|
hassDevice: HassDevice,
|
|
deleteDevice = false,
|
|
): void {
|
|
for (let k = 0; k < hassDevice.values.length; k++) {
|
|
const vId = nodeId + '-' + hassDevice.values[k]
|
|
if (deleteDevice && this.discovered[vId]) {
|
|
delete this.discovered[vId]
|
|
} else {
|
|
this.discovered[vId] = hassDevice
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rediscover all nodes and their values/devices
|
|
*
|
|
*/
|
|
rediscoverAll(): void {
|
|
// skip discovery if discovery not enabled
|
|
if (!this.config.hassDiscovery) return
|
|
|
|
const nodes = this._zwave.nodes ?? []
|
|
for (const [nodeId, node] of nodes) {
|
|
const devices = node.hassDevices || {}
|
|
for (const id in devices) {
|
|
const d = devices[id]
|
|
if (d && d.discoveryTopic && d.discovery_payload) {
|
|
this.publishDiscovery(d, nodeId)
|
|
}
|
|
} // end foreach hassdevice
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discover an hass device (from customDevices.js|json)
|
|
*/
|
|
discoverDevice(node: ZUINode, hassDevice: HassDevice): void {
|
|
if (!this.mqttEnabled || !this.config.hassDiscovery) {
|
|
logger.info(
|
|
'Enable MQTT gateway and hass discovery to use this function',
|
|
)
|
|
return
|
|
}
|
|
|
|
const hassID = hassDevice
|
|
? hassDevice.type + '_' + hassDevice.object_id
|
|
: null
|
|
|
|
try {
|
|
if (hassID && !node.hassDevices[hassID]) {
|
|
// discover the device
|
|
let payload
|
|
|
|
// copy the configuration without edit the original object
|
|
hassDevice = utils.copy(hassDevice)
|
|
|
|
if (hassDevice.type === 'climate') {
|
|
payload = hassDevice.discovery_payload
|
|
|
|
const mode = node.values[payload.mode_state_topic]
|
|
let setId: string | number
|
|
|
|
if (mode !== undefined) {
|
|
setId =
|
|
hassDevice.setpoint_topic &&
|
|
hassDevice.setpoint_topic[mode.value]
|
|
? hassDevice.setpoint_topic[mode.value]
|
|
: hassDevice.default_setpoint
|
|
// only setup modes if a state topic was defined
|
|
payload.mode_state_template =
|
|
this._getMappedValuesInverseTemplate(
|
|
hassDevice.mode_map,
|
|
'off',
|
|
)
|
|
payload.mode_state_topic = this._mqtt.getTopic(
|
|
this.valueTopic(node, mode) as string,
|
|
)
|
|
payload.mode_command_topic =
|
|
payload.mode_state_topic + '/set'
|
|
} else {
|
|
setId = hassDevice.default_setpoint
|
|
}
|
|
|
|
// set properties dynamically using their configuration values
|
|
this._setDiscoveryValue(payload, 'max_temp', node)
|
|
this._setDiscoveryValue(payload, 'min_temp', node)
|
|
|
|
const setpoint = node.values[setId]
|
|
payload.temperature_state_topic = this._mqtt.getTopic(
|
|
this.valueTopic(node, setpoint) as string,
|
|
)
|
|
payload.temperature_command_topic =
|
|
payload.temperature_state_topic + '/set'
|
|
|
|
const action = node.values[payload.action_topic]
|
|
if (action) {
|
|
payload.action_topic = this._mqtt.getTopic(
|
|
this.valueTopic(node, action) as string,
|
|
)
|
|
if (hassDevice.action_map) {
|
|
payload.action_template =
|
|
this._getMappedValuesTemplate(
|
|
hassDevice.action_map,
|
|
'idle',
|
|
)
|
|
}
|
|
}
|
|
|
|
const fan = node.values[payload.fan_mode_state_topic]
|
|
if (fan !== undefined) {
|
|
payload.fan_mode_state_topic = this._mqtt.getTopic(
|
|
this.valueTopic(node, fan) as string,
|
|
)
|
|
payload.fan_mode_command_topic =
|
|
payload.fan_mode_state_topic + '/set'
|
|
|
|
if (hassDevice.fan_mode_map) {
|
|
payload.fan_mode_state_template =
|
|
this._getMappedValuesInverseTemplate(
|
|
hassDevice.fan_mode_map,
|
|
'auto',
|
|
)
|
|
}
|
|
}
|
|
|
|
const currTemp =
|
|
node.values[payload.current_temperature_topic]
|
|
if (currTemp !== undefined) {
|
|
payload.current_temperature_topic = this._mqtt.getTopic(
|
|
this.valueTopic(node, currTemp) as string,
|
|
)
|
|
|
|
if (currTemp.unit) {
|
|
payload.temperature_unit = currTemp.unit.includes(
|
|
'C',
|
|
)
|
|
? 'C'
|
|
: 'F'
|
|
}
|
|
// hass will default the precision to 0.1 for Celsius and 1.0 for Fahrenheit.
|
|
// 1.0 is not granular enough as a default and there seems to be no harm in making it more precise.
|
|
if (!payload.precision) payload.precision = 0.1
|
|
}
|
|
} else {
|
|
payload = hassDevice.discovery_payload
|
|
|
|
const topics = {}
|
|
|
|
// populate topics object with valueId: valueTopic
|
|
for (let i = 0; i < hassDevice.values.length; i++) {
|
|
const v = hassDevice.values[i] // the value id
|
|
topics[v] = node.values[v]
|
|
? this._mqtt.getTopic(
|
|
this.valueTopic(
|
|
node,
|
|
node.values[v],
|
|
) as string,
|
|
)
|
|
: null
|
|
}
|
|
|
|
// set the correct command/state topics
|
|
for (const key in payload) {
|
|
if (key.indexOf('topic') >= 0 && topics[payload[key]]) {
|
|
payload[key] =
|
|
topics[payload[key]] +
|
|
(key.indexOf('command') >= 0 ||
|
|
key.indexOf('set_') >= 0
|
|
? '/set'
|
|
: '')
|
|
}
|
|
}
|
|
}
|
|
|
|
if (payload) {
|
|
const nodeName = this._getNodeName(
|
|
node,
|
|
this.config.ignoreLoc,
|
|
)
|
|
|
|
// Set device information using node info
|
|
payload.device = this._deviceInfo(node, nodeName)
|
|
|
|
this.setDiscoveryAvailability(node, payload)
|
|
|
|
hassDevice.object_id = utils
|
|
.sanitizeTopic(hassDevice.object_id, true)
|
|
.toLocaleLowerCase()
|
|
|
|
// Set a friendly name for this component
|
|
payload.name = this._getEntityName(
|
|
node,
|
|
undefined,
|
|
hassDevice,
|
|
this.config.entityTemplate,
|
|
this.config.ignoreLoc,
|
|
)
|
|
|
|
// set a unique id for the component
|
|
payload.unique_id =
|
|
UID_DISCOVERY_PREFIX +
|
|
this._zwave.homeHex +
|
|
'_Node' +
|
|
node.id +
|
|
'_' +
|
|
hassDevice.object_id
|
|
|
|
const discoveryTopic = this._getDiscoveryTopic(
|
|
hassDevice,
|
|
nodeName,
|
|
)
|
|
hassDevice.discoveryTopic = discoveryTopic
|
|
|
|
// This configuration is not stored in nodes.json
|
|
hassDevice.persistent = false
|
|
|
|
hassDevice.ignoreDiscovery = !!hassDevice.ignoreDiscovery
|
|
|
|
node.hassDevices[hassID] = hassDevice
|
|
|
|
this.publishDiscovery(hassDevice, node.id)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while discovering device ${hassID} of node ${node.id}: ${error.message}`,
|
|
error,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Discover climate devices
|
|
*
|
|
*/
|
|
discoverClimates(node: ZUINode): void {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json#L177
|
|
// check if device it's a thermostat
|
|
if (!node.deviceClass || node.deviceClass.generic !== 0x08) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const nodeDevices = allDevices[node.deviceId] || []
|
|
|
|
// skip if there is already a climate device
|
|
if (
|
|
nodeDevices.length > 0 &&
|
|
nodeDevices.find((d: { type: string }) => d.type === 'climate')
|
|
) {
|
|
return
|
|
}
|
|
|
|
// arrays of strings valueIds (without the node prefix)
|
|
const setpoints = []
|
|
const temperatures = []
|
|
const modes = []
|
|
const actions = []
|
|
|
|
for (const vId in node.values) {
|
|
const v = node.values[vId]
|
|
if (
|
|
v.commandClass === CommandClasses['Thermostat Setpoint'] &&
|
|
v.property === 'setpoint'
|
|
) {
|
|
setpoints.push(vId)
|
|
} else if (
|
|
v.commandClass === CommandClasses['Multilevel Sensor'] &&
|
|
v.property === 'Air temperature'
|
|
) {
|
|
temperatures.push(vId)
|
|
} else if (
|
|
v.commandClass === CommandClasses['Thermostat Mode'] &&
|
|
v.property === 'mode'
|
|
) {
|
|
modes.push(vId)
|
|
} else if (
|
|
v.commandClass ===
|
|
CommandClasses['Thermostat Operating State'] &&
|
|
v.property === 'state'
|
|
) {
|
|
actions.push(vId)
|
|
}
|
|
}
|
|
|
|
// TODO: if the device supports multiple endpoints how could we identify the correct one to use?
|
|
const temperatureId = temperatures[0]
|
|
|
|
if (setpoints.length === 0) {
|
|
logger.warn(
|
|
'Unable to discover climate device, there is no valid setpoint valueId',
|
|
)
|
|
return
|
|
}
|
|
|
|
// generic configuration
|
|
const config = utils.copy(hassCfg.thermostat)
|
|
// set empty config.values
|
|
config.values = []
|
|
|
|
if (temperatureId) {
|
|
config.discovery_payload.current_temperature_topic =
|
|
temperatureId
|
|
config.values.push(temperatureId)
|
|
} else {
|
|
delete config.discovery_payload.current_temperature_template
|
|
delete config.discovery_payload.current_temperature_topic
|
|
}
|
|
|
|
// take the first as valid
|
|
const modeId = modes[0]
|
|
|
|
// some thermostats could support just one mode so haven't a thermostat mode CC
|
|
if (modeId) {
|
|
config.values.push(modeId)
|
|
|
|
const mode = node.values[modeId]
|
|
|
|
config.discovery_payload.mode_state_topic = modeId
|
|
config.discovery_payload.mode_command_topic = modeId + '/set'
|
|
|
|
// [0, 1, 2 ... ] (['off', 'heat', 'cold', ...])
|
|
const availableModes = <number[]>mode.states.map((s) => s.value)
|
|
|
|
// Hass accepted modes as per: https://www.home-assistant.io/integrations/climate.mqtt/#modes
|
|
const allowedModes = [
|
|
'off',
|
|
'heat',
|
|
'cool',
|
|
'auto',
|
|
'dry',
|
|
'fan_only',
|
|
]
|
|
// Z-Wave modes: https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/ThermostatModeCC.ts#L54
|
|
// up to 0x1F modes
|
|
const hassModes = [
|
|
'off', // Off
|
|
'heat', // Heat
|
|
'cool', // Cool
|
|
'auto', // Auto
|
|
undefined, // Aux
|
|
undefined, // Resume (on)
|
|
'fan_only', // Fan
|
|
undefined, // Furnance
|
|
'dry', // Dry
|
|
undefined, // Moist
|
|
'auto', // Auto changeover
|
|
'heat', // Energy heat
|
|
'cool', // Energy cool
|
|
'off', // Away
|
|
undefined, // No Z-Wave mode 0x0e
|
|
'heat', // Full power
|
|
undefined, // Up to 0x1f (manufacturer specific)
|
|
]
|
|
|
|
config.mode_map = {}
|
|
config.setpoint_topic = {}
|
|
|
|
// for all available modes update the modes map and setpoint topics
|
|
for (const m of availableModes) {
|
|
if (hassModes[m] === undefined) continue
|
|
|
|
let hM = hassModes[m]
|
|
|
|
// it could happen that mode_map already have defined a mode for this value, in this case
|
|
// map that mode to the first one available in the allowed hass modes
|
|
let i = 1 // skip 'off'
|
|
while (
|
|
config.discovery_payload.modes.includes(hM) &&
|
|
i < allowedModes.length
|
|
) {
|
|
hM = allowedModes[i++]
|
|
}
|
|
|
|
config.mode_map[hM] = m
|
|
config.discovery_payload.modes.push(hM)
|
|
if (m > 0) {
|
|
// find the mode setpoint, ignore off
|
|
const setId = setpoints.find((v) => v.endsWith('-' + m))
|
|
const setpoint = node.values[setId]
|
|
if (setpoint) {
|
|
config.values.push(setId)
|
|
config.setpoint_topic[m] = setId
|
|
} else {
|
|
// Use first one, if no specific SP found
|
|
config.values.push(setpoints[0])
|
|
config.setpoint_topic[m] = setpoints[0]
|
|
}
|
|
}
|
|
}
|
|
|
|
// set the default setpoint to 'heat' or to the first setpoint available
|
|
config.default_setpoint =
|
|
config.setpoint_topic[1] ||
|
|
config.setpoint_topic[Object.keys(config.setpoint_topic)[0]]
|
|
} else {
|
|
config.default_setpoint = setpoints[0]
|
|
delete config.discovery_payload.modes
|
|
delete config.discovery_payload.mode_state_template
|
|
}
|
|
|
|
if (actions.length > 0) {
|
|
const actionId = actions[0]
|
|
config.values.push(actionId)
|
|
config.discovery_payload.action_topic = actionId
|
|
|
|
const action = node.values[actionId]
|
|
// [0, 1, 2 ... ] list of value fields from objects in states list
|
|
const availableActions = <number[]>(
|
|
action.states.map((state) => state.value)
|
|
)
|
|
// Hass accepted actions as per https://www.home-assistant.io/integrations/climate.mqtt/#action_topic:
|
|
// ['off', 'heating', 'cooling', 'drying', 'idle', 'fan']
|
|
// Z-Wave actions/states: https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/ThermostatOperatingStateCC.ts#L43
|
|
const hassActionMap = [
|
|
'idle',
|
|
'heating',
|
|
'cooling',
|
|
'fan',
|
|
'idle',
|
|
'idle',
|
|
'fan',
|
|
'heating',
|
|
'heating',
|
|
'cooling',
|
|
'heating',
|
|
'heating', // 3rd Stage Aux Heat
|
|
]
|
|
|
|
config.action_map = {}
|
|
// for all available actions update the actions map
|
|
for (const availableAction of availableActions) {
|
|
const hassAction = hassActionMap[availableAction]
|
|
if (hassAction === undefined) continue
|
|
config.action_map[availableAction] = hassAction
|
|
}
|
|
}
|
|
|
|
// add the new climate config to the nodeDevices so it will be
|
|
// discovered later when we call `discoverDevice`
|
|
nodeDevices.push(config)
|
|
|
|
logger.log('info', 'New climate device discovered: %o', config)
|
|
|
|
allDevices[node.deviceId] = nodeDevices
|
|
} catch (error) {
|
|
logger.error('Unable to discover climate device.', error)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try to guess the best way to discover this valueId in Hass
|
|
*/
|
|
discoverValue(node: ZUINode, vId: string): void {
|
|
if (!this.mqttEnabled || !this.config.hassDiscovery) {
|
|
logger.debug(
|
|
'Enable MQTT gateway and hass discovery to use this function',
|
|
)
|
|
return
|
|
}
|
|
|
|
const valueId = node.values[vId]
|
|
|
|
// if the node is not ready means we don't have all values added yet so we are not sure to discover this value properly
|
|
if (!valueId || this.discovered[valueId.id] || !node.ready) return
|
|
|
|
try {
|
|
const result = this.valueTopic(node, valueId, true) as ValueIdTopic
|
|
|
|
if (!result || !result.topic) return
|
|
|
|
const valueConf = result.valueConf
|
|
|
|
const getTopic = this._mqtt.getTopic(result.topic)
|
|
const setTopic = result.targetTopic
|
|
? this._mqtt.getTopic(result.targetTopic, true)
|
|
: null
|
|
|
|
const nodeName = this._getNodeName(node, this.config.ignoreLoc)
|
|
|
|
let cfg: HassDevice
|
|
|
|
const cmdClass = valueId.commandClass
|
|
|
|
const deviceClass =
|
|
node.endpoints[valueId.endpoint]?.deviceClass ??
|
|
node.deviceClass
|
|
|
|
switch (cmdClass) {
|
|
case CommandClasses['Binary Switch']:
|
|
case CommandClasses['All Switch']:
|
|
case CommandClasses['Binary Toggle Switch']:
|
|
if (valueId.isCurrentValue) {
|
|
cfg = utils.copy(hassCfg.switch)
|
|
} else return
|
|
break
|
|
case CommandClasses['Barrier Operator']:
|
|
if (valueId.isCurrentValue) {
|
|
cfg = utils.copy(hassCfg.barrier_state)
|
|
cfg.discovery_payload.position_topic = getTopic
|
|
} else return
|
|
break
|
|
case CommandClasses['Multilevel Switch']:
|
|
case CommandClasses['Multilevel Toggle Switch']:
|
|
if (valueId.isCurrentValue) {
|
|
const specificDeviceClass =
|
|
Constants.specificDeviceClass(
|
|
deviceClass.generic,
|
|
deviceClass.specific,
|
|
)
|
|
// Use a cover_position configuration if ...
|
|
if (
|
|
[
|
|
'specific_type_class_a_motor_control',
|
|
'specific_type_class_b_motor_control',
|
|
'specific_type_class_c_motor_control',
|
|
'specific_type_class_motor_multiposition',
|
|
'specific_type_motor_multiposition',
|
|
].includes(specificDeviceClass) ||
|
|
node.deviceId === '615-0-258' // Issue #3088
|
|
) {
|
|
cfg = utils.copy(hassCfg.cover_position)
|
|
cfg.discovery_payload.command_topic = setTopic
|
|
cfg.discovery_payload.position_topic = getTopic
|
|
cfg.discovery_payload.set_position_topic =
|
|
cfg.discovery_payload.command_topic
|
|
cfg.discovery_payload.position_template =
|
|
'{{ value_json.value | round(0) }}'
|
|
cfg.discovery_payload.position_open = 99
|
|
cfg.discovery_payload.position_closed = 0
|
|
cfg.discovery_payload.payload_open = 99
|
|
cfg.discovery_payload.payload_close = 0
|
|
} else {
|
|
cfg = utils.copy(hassCfg.light_dimmer)
|
|
cfg.discovery_payload.supported_color_modes = [
|
|
'brightness',
|
|
] as ColorMode[]
|
|
cfg.discovery_payload.brightness_state_topic =
|
|
getTopic
|
|
cfg.discovery_payload.brightness_command_topic =
|
|
setTopic
|
|
}
|
|
} else return
|
|
break
|
|
case CommandClasses['Door Lock']:
|
|
if (valueId.isCurrentValue) {
|
|
// lock state
|
|
cfg = utils.copy(hassCfg.lock)
|
|
} else {
|
|
return
|
|
}
|
|
break
|
|
case CommandClasses['Sound Switch']:
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/SoundSwitchCC.ts
|
|
if (valueId.property === 'volume') {
|
|
// volume control
|
|
cfg = utils.copy(hassCfg.volume_dimmer)
|
|
cfg.discovery_payload.brightness_state_topic = getTopic
|
|
cfg.discovery_payload.command_topic = getTopic + '/set'
|
|
cfg.discovery_payload.brightness_command_topic =
|
|
cfg.discovery_payload.command_topic
|
|
} else {
|
|
return
|
|
}
|
|
break
|
|
case CommandClasses['Color Switch']:
|
|
if (
|
|
valueId.property === 'currentColor' &&
|
|
valueId.propertyKey === undefined
|
|
) {
|
|
cfg = this._addRgbColorSwitch(node, valueId)
|
|
} else return
|
|
break
|
|
case CommandClasses['Central Scene']:
|
|
case CommandClasses['Scene Activation']:
|
|
cfg = utils.copy(hassCfg.central_scene)
|
|
|
|
// Combile unique Object id, by using all possible scenarios
|
|
cfg.object_id = utils.joinProps(
|
|
cfg.object_id,
|
|
valueId.property,
|
|
valueId.propertyKey,
|
|
)
|
|
if (valueId.value?.unit) {
|
|
cfg.discovery_payload.value_template =
|
|
"{{ value_json.value.value | default('') }}"
|
|
}
|
|
break
|
|
case CommandClasses['Binary Sensor']: {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/BinarySensorCC.ts#L41
|
|
// change the sensorTypeName to use directly valueId.property, as the old way was returning a number
|
|
// add a comment which shows the old way of achieving this value. This change fixes the Binary Sensor
|
|
// discovery
|
|
let sensorTypeName = valueId.property.toString()
|
|
|
|
if (sensorTypeName) {
|
|
sensorTypeName = utils.sanitizeTopic(
|
|
sensorTypeName.toLocaleLowerCase(),
|
|
true,
|
|
)
|
|
}
|
|
|
|
// TODO: Implement all BinarySensorTypes
|
|
// Use default Binary sensor, and replace based on sensorTypeName
|
|
// till now only one type is using the reverse on/off values as states
|
|
switch (sensorTypeName) {
|
|
// normal
|
|
case 'presence':
|
|
case 'smoke':
|
|
case 'gas':
|
|
cfg = this._getBinarySensorConfig(sensorTypeName)
|
|
break
|
|
// reverse
|
|
case 'lock':
|
|
cfg = this._getBinarySensorConfig(
|
|
sensorTypeName,
|
|
true,
|
|
)
|
|
break
|
|
// moisture - normal
|
|
case 'contact':
|
|
case 'water':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.MOISTURE,
|
|
)
|
|
break
|
|
// safety - normal
|
|
case 'co':
|
|
case 'co2':
|
|
case 'tamper':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.SAFETY,
|
|
)
|
|
break
|
|
// problem - normal
|
|
case 'alarm':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.PROBLEM,
|
|
)
|
|
break
|
|
// connectivity - normal
|
|
case 'router':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary
|
|
.CONNECTIVITY,
|
|
)
|
|
break
|
|
// battery - normal
|
|
case 'battery_low':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.BATTERY,
|
|
)
|
|
break
|
|
default:
|
|
// in the end build the basic cfg if all fails
|
|
cfg = utils.copy(hassCfg.binary_sensor)
|
|
}
|
|
cfg.object_id = sensorTypeName
|
|
|
|
if (valueConf) {
|
|
if (valueConf.device_class) {
|
|
cfg.discovery_payload.device_class =
|
|
valueConf.device_class
|
|
cfg.object_id = valueConf.device_class
|
|
}
|
|
// binary sensors doesn't support icons
|
|
}
|
|
|
|
break
|
|
}
|
|
case CommandClasses['Alarm Sensor']:
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/AlarmSensorCC.ts#L40
|
|
if (valueId.property === 'state') {
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.PROBLEM,
|
|
)
|
|
cfg.object_id += AlarmSensorType[valueId.propertyKey]
|
|
? '_' + AlarmSensorType[valueId.propertyKey]
|
|
: ''
|
|
} else {
|
|
return
|
|
}
|
|
break
|
|
case CommandClasses.Basic:
|
|
case CommandClasses.Notification:
|
|
// only support basic events
|
|
if (
|
|
cmdClass === CommandClasses.Basic &&
|
|
valueId.property !== 'event'
|
|
) {
|
|
return
|
|
}
|
|
|
|
// Try to define Binary sensor
|
|
if (valueId.states?.length === 2) {
|
|
let off = 0 // set default off to 0.
|
|
let discoveredObjectId = valueId.propertyKey
|
|
switch (valueId.propertyKeyName) {
|
|
case 'Access Control':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.LOCK,
|
|
)
|
|
off = 23 // Closed state
|
|
break
|
|
case 'Cover status':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.OPENING,
|
|
)
|
|
break
|
|
case 'Door state (simple)':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.DOOR,
|
|
)
|
|
off = 1 // Door closed on payload 1
|
|
break
|
|
case 'Alarm status':
|
|
case 'Dust in device status':
|
|
case 'Load error status':
|
|
case 'Over-current status':
|
|
case 'Over-load status':
|
|
case 'Hardware status':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.PROBLEM,
|
|
)
|
|
break
|
|
case 'Heat sensor status':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.HEAT,
|
|
)
|
|
break
|
|
case 'Motion sensor status':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.MOTION,
|
|
)
|
|
break
|
|
case 'Water Alarm':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary
|
|
.MOISTURE,
|
|
)
|
|
break
|
|
// sensor status has multiple Properties. therefore is good to work
|
|
// on property basis... user friendly
|
|
case 'Sensor status':
|
|
switch (valueId.propertyName) {
|
|
case 'Smoke Alarm':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary
|
|
.SMOKE,
|
|
)
|
|
break
|
|
case 'Water Alarm':
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary
|
|
.MOISTURE,
|
|
)
|
|
break
|
|
default:
|
|
}
|
|
discoveredObjectId = valueId.propertyName
|
|
break
|
|
default:
|
|
}
|
|
// cfg not there?
|
|
cfg = cfg || utils.copy(hassCfg.binary_sensor)
|
|
// correct payload from true/false to numeric values
|
|
this._setBinaryPayloadFromSensor(cfg, valueId, off)
|
|
// finally update object_id
|
|
cfg.object_id = discoveredObjectId.toString()
|
|
} else if (valueId.states?.length > 2) {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/NotificationCC.ts
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json
|
|
cfg = utils.copy(hassCfg.sensor_generic)
|
|
cfg.object_id = utils.joinProps(
|
|
'notification',
|
|
valueId.property,
|
|
valueId.propertyKey,
|
|
)
|
|
// TODO: Improve the icons for different propertyKeys!
|
|
switch (valueId.propertyKey) {
|
|
case 'Motion sensor status':
|
|
cfg.discovery_payload.icon = 'mdi:motion-sensor'
|
|
break
|
|
default:
|
|
cfg.discovery_payload.icon = 'mdi:alarm-light'
|
|
}
|
|
cfg.discovery_payload.value_template =
|
|
this._getMappedStateTemplate(
|
|
valueId.states,
|
|
valueId.default,
|
|
)
|
|
} else {
|
|
return
|
|
}
|
|
break
|
|
case CommandClasses['Multilevel Sensor']:
|
|
case CommandClasses.Meter:
|
|
case CommandClasses['Pulse Meter']:
|
|
case CommandClasses.Time:
|
|
case CommandClasses['Energy Production']:
|
|
case CommandClasses.Battery: {
|
|
let sensor = null
|
|
// set it as been sensor (ex not Binary)
|
|
let isSensor = true
|
|
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/MultilevelSensorCC.ts
|
|
if (cmdClass === CommandClasses['Multilevel Sensor']) {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/sensorTypes.json
|
|
// In some cases Multilevel Sensors offer Reset option or DeltaTime sensors, but do not include ccSpecific
|
|
// information. With this change, we target only the sensors and not the additional Properties.
|
|
if (valueId.ccSpecific) {
|
|
sensor = Constants.sensorType(
|
|
valueId.ccSpecific.sensorType,
|
|
)
|
|
} else {
|
|
return
|
|
}
|
|
} else if (cmdClass === CommandClasses.Meter) {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/MeterCC.ts
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/meters.json
|
|
// In some cases Metering devices offer Reset option or DeltaTime sensors, but do not include ccSpecific
|
|
// information. With this change, we target only the sensors and not the additional Properties.
|
|
if (valueId.ccSpecific) {
|
|
sensor = Constants.meterType(
|
|
valueId.ccSpecific as IMeterCCSpecific,
|
|
)
|
|
|
|
sensor.objectId += '_' + valueId.property
|
|
} else {
|
|
return
|
|
}
|
|
} else if (cmdClass === CommandClasses['Pulse Meter']) {
|
|
sensor = {
|
|
sensor: 'pulse',
|
|
objectId: 'meter',
|
|
props: {},
|
|
}
|
|
} else if (cmdClass === CommandClasses.Time) {
|
|
if (valueId.isCurrentValue) {
|
|
sensor = {
|
|
sensor: 'date',
|
|
objectId: 'current',
|
|
props: {
|
|
device_class:
|
|
Constants.deviceClass.sensor.TIMESTAMP,
|
|
},
|
|
}
|
|
} else return
|
|
} else if (
|
|
cmdClass === CommandClasses['Energy Production']
|
|
) {
|
|
// TODO: class not yet supported by zwavejs
|
|
logger.warn(
|
|
'Energy Production CC not supported so value cannot be discovered',
|
|
)
|
|
// sensor = Constants.productionType(valueId.property)
|
|
return
|
|
} else if (cmdClass === CommandClasses.Battery) {
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/zwave-js/src/lib/commandclass/BatteryCC.ts#L258
|
|
if (valueId.property === 'level') {
|
|
sensor = {
|
|
sensor: 'battery',
|
|
objectId: 'level',
|
|
props: {
|
|
device_class:
|
|
Constants.deviceClass.sensor.BATTERY,
|
|
unit_of_measurement: '%', // this is set if Driver doesn't offer unit of measurement
|
|
},
|
|
}
|
|
} else if (valueId.property === 'isLow') {
|
|
sensor = {
|
|
sensor: 'battery',
|
|
objectId: 'isLow',
|
|
props: {
|
|
device_class:
|
|
Constants.deviceClass.sensor.BATTERY,
|
|
},
|
|
}
|
|
|
|
// use battery_low binary sensor
|
|
cfg = this._getBinarySensorConfig(
|
|
Constants.deviceClass.sensor_binary.BATTERY,
|
|
)
|
|
// support the case a binary sensor is served under multilevel sensor CC
|
|
isSensor = false
|
|
} else return
|
|
}
|
|
|
|
// check if is a sensor
|
|
if (isSensor) {
|
|
cfg = utils.copy(hassCfg.sensor_generic)
|
|
}
|
|
|
|
cfg.object_id = utils.joinProps(
|
|
sensor.sensor,
|
|
sensor.objectId,
|
|
)
|
|
|
|
let unit = null
|
|
// https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/scales.json
|
|
if (valueId.unit) {
|
|
unit = valueId.unit
|
|
} else if (valueId.value?.unit) {
|
|
unit = valueId.value.unit
|
|
}
|
|
|
|
if (unit) {
|
|
// Home Assistant requires time units to be abbreviated
|
|
// https://github.com/home-assistant/core/blob/d7ac4bd65379e11461c7ce0893d3533d8d8b8cbf/homeassistant/const.py#L408
|
|
if (unit === 'seconds') {
|
|
unit = 's'
|
|
} else if (unit === 'minutes') {
|
|
unit = 'min'
|
|
} else if (unit === 'hours') {
|
|
unit = 'h'
|
|
}
|
|
cfg.discovery_payload.unit_of_measurement = unit
|
|
}
|
|
|
|
Object.assign(cfg.discovery_payload, sensor.props || {})
|
|
|
|
// check if there is a custom value configuration for this valueID
|
|
if (valueConf) {
|
|
if (valueConf.device_class) {
|
|
cfg.discovery_payload.device_class =
|
|
valueConf.device_class
|
|
cfg.object_id = valueConf.device_class
|
|
}
|
|
if (valueConf.icon)
|
|
cfg.discovery_payload.icon = valueConf.icon
|
|
}
|
|
break
|
|
}
|
|
case CommandClasses.Configuration: {
|
|
if (
|
|
!valueId.writeable ||
|
|
process.env.DISCOVERY_DISABLE_CC_CONFIGURATION ===
|
|
'true'
|
|
) {
|
|
return
|
|
}
|
|
let type = valueId.type
|
|
if (
|
|
type === 'number' &&
|
|
valueId.min === 0 &&
|
|
valueId.max === 1
|
|
) {
|
|
type = 'boolean'
|
|
}
|
|
switch (type) {
|
|
case 'boolean':
|
|
cfg = utils.copy(hassCfg.config_switch)
|
|
|
|
// Combine unique Object id, by using all possible scenarios
|
|
cfg.object_id = utils.joinProps(
|
|
cfg.object_id,
|
|
valueId.property,
|
|
valueId.propertyKey,
|
|
)
|
|
break
|
|
case 'number':
|
|
cfg = utils.copy(hassCfg.config_number)
|
|
|
|
// Combine unique Object id, by using all possible scenarios
|
|
cfg.object_id = utils.joinProps(
|
|
cfg.object_id,
|
|
valueId.property,
|
|
valueId.propertyKey,
|
|
)
|
|
if (valueId.min !== 1) {
|
|
cfg.discovery_payload.min = valueId.min
|
|
}
|
|
if (valueId.max !== 100) {
|
|
cfg.discovery_payload.max = valueId.max
|
|
}
|
|
|
|
break
|
|
default:
|
|
return
|
|
}
|
|
break
|
|
}
|
|
default:
|
|
return
|
|
}
|
|
|
|
const payload = cfg.discovery_payload
|
|
|
|
if (
|
|
!utils.hasProperty(payload, 'state_topic') ||
|
|
payload.state_topic === true
|
|
) {
|
|
payload.state_topic = getTopic
|
|
} else if (payload.state_topic === false) {
|
|
delete payload.state_topic
|
|
}
|
|
|
|
if (payload.command_topic === true) {
|
|
payload.command_topic = setTopic || getTopic + '/set'
|
|
}
|
|
|
|
this.setDiscoveryAvailability(node, payload)
|
|
|
|
if (
|
|
['binary_sensor', 'sensor', 'lock', 'climate', 'fan'].includes(
|
|
cfg.type,
|
|
)
|
|
) {
|
|
payload.json_attributes_topic = payload.state_topic
|
|
}
|
|
|
|
// Set device information using node info
|
|
payload.device = this._deviceInfo(node, nodeName)
|
|
|
|
// multi instance devices would have same object_id
|
|
if (valueId.endpoint) cfg.object_id += '_' + valueId.endpoint
|
|
|
|
// remove chars that are not allowed in object ids
|
|
cfg.object_id = utils
|
|
.sanitizeTopic(cfg.object_id, true)
|
|
.toLocaleLowerCase()
|
|
|
|
// Check if another value already exists and add the index to object_id to make it unique
|
|
if (node.hassDevices[cfg.type + '_' + cfg.object_id]) {
|
|
cfg.object_id += '_' + valueId.endpoint
|
|
}
|
|
|
|
// Set a friendly name for this component
|
|
payload.name = this._getEntityName(
|
|
node,
|
|
valueId,
|
|
cfg,
|
|
this.config.entityTemplate,
|
|
this.config.ignoreLoc,
|
|
)
|
|
|
|
// Set a unique id for the component
|
|
payload.unique_id =
|
|
UID_DISCOVERY_PREFIX +
|
|
this._zwave.homeHex +
|
|
'_' +
|
|
utils.sanitizeTopic(valueId.id, true)
|
|
|
|
const discoveryTopic = this._getDiscoveryTopic(cfg, nodeName)
|
|
|
|
cfg.discoveryTopic = discoveryTopic
|
|
cfg.values = cfg.values || []
|
|
|
|
if (!cfg.values.includes(vId)) {
|
|
cfg.values.push(vId)
|
|
}
|
|
|
|
if (valueId.targetValue) {
|
|
cfg.values.push(valueId.targetValue)
|
|
}
|
|
|
|
// This configuration is not stored in nodes.json
|
|
cfg.persistent = false
|
|
|
|
// skip discovery flag, default to false
|
|
cfg.ignoreDiscovery = false
|
|
|
|
node.hassDevices[cfg.type + '_' + cfg.object_id] = cfg
|
|
|
|
this.publishDiscovery(cfg, node.id)
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while discovering value ${valueId.id} of node ${node.id}: ${error.message}`,
|
|
error,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update all in memory node topics
|
|
*
|
|
*/
|
|
updateNodeTopics(nodeId: number): void {
|
|
const node = this._zwave.nodes.get(nodeId)
|
|
if (node) {
|
|
const topics = Object.keys(this.topicValues).filter(
|
|
(k) => this.topicValues[k].nodeId === node.id,
|
|
)
|
|
|
|
for (const t of topics) {
|
|
const valueId = this.topicValues[t]
|
|
delete this.topicValues[t]
|
|
const topic = this.valueTopic(node, valueId) as string
|
|
this.topicValues[topic] = valueId
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Removes all retained messages of the specified node
|
|
*/
|
|
removeNodeRetained(nodeId: number): void {
|
|
if (!this.mqttEnabled) {
|
|
logger.info('Enable MQTT gateway to use this function')
|
|
return
|
|
}
|
|
|
|
const node = this._zwave.nodes.get(nodeId)
|
|
if (node) {
|
|
const topics = Object.keys(node.values).map(
|
|
(v) => this.valueTopic(node, node.values[v]) as string,
|
|
)
|
|
|
|
for (const t of topics) {
|
|
this._mqtt.publish(t, '', { retain: true })
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Catch all Z-Wave events
|
|
*/
|
|
private _onEvent(
|
|
emitter: EventSource,
|
|
eventName: string,
|
|
...args: any[]
|
|
): void {
|
|
const topic = `${MqttClient.EVENTS_PREFIX}/${
|
|
this._mqtt.clientID
|
|
}/${emitter}/${eventName.replace(/\s/g, '_')}`
|
|
|
|
this._mqtt.publish(topic, { data: args }, { qos: 1, retain: false })
|
|
}
|
|
|
|
/**
|
|
* Z-Wave event triggered when a node is removed
|
|
*/
|
|
private _onNodeRemoved(node: Partial<ZUINode>): void {
|
|
const prefix = node.id + '-'
|
|
|
|
// delete discovered values
|
|
for (const id in this.discovered) {
|
|
if (id.startsWith(prefix)) {
|
|
delete this.discovered[id]
|
|
}
|
|
}
|
|
|
|
// clean topicValues
|
|
for (const topic in this.topicValues) {
|
|
if (this.topicValues[topic].nodeId === node.id) {
|
|
delete this.topicValues[topic]
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggered when a value change is detected in Z-Wave Network
|
|
*/
|
|
private _onValueChanged(
|
|
valueId: ZUIValueId,
|
|
node: ZUINode,
|
|
changed: boolean,
|
|
): void {
|
|
const isDiscovered = this.discovered[valueId.id]
|
|
|
|
// check if this value isn't discovered yet (values added after node is ready)
|
|
if (this.config.hassDiscovery && !isDiscovered) {
|
|
this.discoverValue(node, this._getIdWithoutNode(valueId))
|
|
}
|
|
|
|
const result = this.valueTopic(node, valueId, true) as ValueIdTopic
|
|
|
|
if (!result) {
|
|
if (this.config.type !== GATEWAY_TYPE.MANUAL) {
|
|
// if config is manual it is normal that some values are not mapped
|
|
logger.debug(`No topic found for value ${valueId.id}`)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// if there is a valid topic for this value publish it
|
|
|
|
const topic = result.topic
|
|
const valueConf = result.valueConf
|
|
// Parse valueId value and create the payload
|
|
let tmpVal = valueId.value
|
|
|
|
if (valueConf) {
|
|
if (this._isValidOperation(valueConf.postOperation)) {
|
|
tmpVal = eval(valueId.value + valueConf.postOperation)
|
|
}
|
|
|
|
if (valueConf.parseSend) {
|
|
const parsedVal = this._evalFunction(
|
|
valueConf.sendFunction,
|
|
valueId,
|
|
tmpVal,
|
|
node,
|
|
)
|
|
if (parsedVal != null) {
|
|
tmpVal = parsedVal
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if I need to update discovery topics of this device
|
|
if (
|
|
this.config.hassDiscovery &&
|
|
changed &&
|
|
valueId.list &&
|
|
this.discovered[valueId.id]
|
|
) {
|
|
const hassDevice = this.discovered[valueId.id]
|
|
const isOff = hassDevice.mode_map
|
|
? hassDevice.mode_map.off === valueId.value
|
|
: false
|
|
|
|
if (hassDevice && hassDevice.setpoint_topic && !isOff) {
|
|
const setId = hassDevice.setpoint_topic[valueId.value]
|
|
if (setId && node.values[setId]) {
|
|
// check if the setpoint topic has changed
|
|
const setpoint = node.values[setId]
|
|
const setTopic = this._mqtt.getTopic(
|
|
this.valueTopic(node, setpoint) as string,
|
|
)
|
|
if (
|
|
setTopic !==
|
|
hassDevice.discovery_payload.temperature_state_topic
|
|
) {
|
|
hassDevice.discovery_payload.temperature_state_topic =
|
|
setTopic
|
|
hassDevice.discovery_payload.temperature_command_topic =
|
|
setTopic + '/set'
|
|
this.publishDiscovery(hassDevice, node.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let data: Record<string, any>
|
|
|
|
switch (this.config.payloadType) {
|
|
case PAYLOAD_TYPE.VALUEID:
|
|
data = utils.copy(valueId)
|
|
data.value = tmpVal
|
|
|
|
break
|
|
case PAYLOAD_TYPE.RAW:
|
|
data = tmpVal
|
|
break
|
|
default:
|
|
data = { time: valueId.lastUpdate, value: tmpVal }
|
|
}
|
|
|
|
if (this.config.includeNodeInfo && typeof data === 'object') {
|
|
data.nodeName = node.name
|
|
data.nodeLocation = node.loc
|
|
}
|
|
|
|
const shouldSubscribe = valueId.writeable || valueId.targetValue
|
|
|
|
// valueId is writeable or it has a target value, subscribe for updates
|
|
if (shouldSubscribe && !this.topicValues[topic]) {
|
|
const levels = topic.split('/').length
|
|
|
|
logger.debug(`Subscribing to updates of ${valueId.id}`)
|
|
|
|
if (this.topicLevels.indexOf(levels) < 0) {
|
|
this.topicLevels.push(levels)
|
|
this._mqtt
|
|
.subscribe('+'.repeat(levels).split('').join('/'))
|
|
.catch(() => {
|
|
// ignore, handled by mqtt client
|
|
})
|
|
}
|
|
|
|
// I need to add the conf to the valueId but I don't want to edit
|
|
// original valueId object so I create a copy
|
|
if (valueConf) {
|
|
valueId = utils.copy(valueId)
|
|
valueId.conf = valueConf
|
|
}
|
|
|
|
// handle the case the conf is set on current value but not in target value
|
|
if (valueId.targetValue && node.values[valueId.targetValue]) {
|
|
const targetValueId = utils.copy(
|
|
node.values[valueId.targetValue],
|
|
)
|
|
targetValueId.conf = valueConf
|
|
this.topicValues[topic] = targetValueId
|
|
} else {
|
|
this.topicValues[topic] = valueId
|
|
}
|
|
}
|
|
|
|
let mqttOptions: IClientPublishOptions = valueId.stateless
|
|
? { retain: false }
|
|
: null
|
|
|
|
if (valueConf) {
|
|
mqttOptions = mqttOptions || {}
|
|
|
|
if (valueConf.qos !== undefined) {
|
|
mqttOptions.qos = valueConf.qos
|
|
}
|
|
|
|
if (valueConf.retain !== undefined) {
|
|
mqttOptions.retain = valueConf.retain
|
|
}
|
|
}
|
|
|
|
const isFromCache = !node.ready
|
|
|
|
// prevent to send cached values if them are stateless
|
|
if (isFromCache && valueId.stateless) {
|
|
logger.debug(
|
|
`Skipping send of stateless value ${valueId.id}: it's from cache`,
|
|
)
|
|
} else {
|
|
this._mqtt.publish(topic, data, mqttOptions)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Triggered when a notification is received from a node in Z-Wave Client
|
|
*/
|
|
private _onNotification(
|
|
node: ZUINode,
|
|
valueId: ZUIValueId,
|
|
data: Record<string, any>,
|
|
): void {
|
|
const topic = this.valueTopic(node, valueId) as string
|
|
|
|
if (this.config.payloadType !== PAYLOAD_TYPE.RAW) {
|
|
data = { time: Date.now(), value: data }
|
|
}
|
|
|
|
this._mqtt.publish(topic, data, { retain: false })
|
|
}
|
|
|
|
private _onNodeInited(node: ZUINode): void {
|
|
// enable poll if required
|
|
const values = this.config.values?.filter(
|
|
(v: GatewayValue) => v.enablePoll && v.device === node.deviceId,
|
|
)
|
|
for (let i = 0; i < values.length; i++) {
|
|
// don't edit the original object, copy it
|
|
const valueId = utils.copy(values[i].value)
|
|
valueId.nodeId = node.id
|
|
valueId.id = node.id + '-' + valueId.id
|
|
|
|
try {
|
|
this._zwave.setPollInterval(valueId, values[i].pollInterval)
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error while enabling poll interval: ${error.message}`,
|
|
)
|
|
}
|
|
}
|
|
|
|
if (this.mqttEnabled && this.config.hassDiscovery) {
|
|
for (const id in node.hassDevices) {
|
|
if (node.hassDevices[id].persistent) {
|
|
this.publishDiscovery(node.hassDevices[id], node.id)
|
|
}
|
|
}
|
|
|
|
// check if there are climates to discover
|
|
this.discoverClimates(node)
|
|
|
|
const nodeDevices = allDevices[node.deviceId] || []
|
|
nodeDevices.forEach((device) => this.discoverDevice(node, device))
|
|
|
|
// discover node values (that are not part of a device)
|
|
// iterate prioritized first, then the remaining
|
|
for (const id of this._getPriorityCCFirst(node.values)) {
|
|
this.discoverValue(node, id)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When there is a node status update
|
|
*
|
|
*/
|
|
private _onNodeStatus(node: ZUINode): void {
|
|
if (!this.mqttEnabled) {
|
|
return
|
|
}
|
|
|
|
const nodeTopic = this.nodeTopic(node)
|
|
|
|
if (!this.config.ignoreStatus) {
|
|
let data: any
|
|
|
|
if (this.config.payloadType === PAYLOAD_TYPE.RAW) {
|
|
data = node.available
|
|
} else {
|
|
data = {
|
|
time: Date.now(),
|
|
value: node.available,
|
|
status: node.status,
|
|
nodeId: node.id,
|
|
}
|
|
}
|
|
|
|
this._mqtt.publish(nodeTopic + '/status', data)
|
|
}
|
|
|
|
// Publish Node Info on separate topic
|
|
// remove bulky data like hassDevices, Groups and values
|
|
if (this.config.publishNodeDetails) {
|
|
const nodeData = utils.copy(node)
|
|
delete nodeData.groups
|
|
delete nodeData.hassDevices
|
|
delete nodeData.values
|
|
|
|
this._mqtt.publish(nodeTopic + '/nodeinfo', nodeData)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When a packet is received from a node to update it's last activity timestamp
|
|
*
|
|
*/
|
|
private _onNodeLastActive(node: ZUINode): void {
|
|
if (!this.mqttEnabled) {
|
|
return
|
|
}
|
|
|
|
const nodeTopic = this.nodeTopic(node)
|
|
|
|
if (!this.config.ignoreStatus) {
|
|
let data: any
|
|
|
|
if (this.config.payloadType === PAYLOAD_TYPE.RAW) {
|
|
data = node.lastActive
|
|
} else {
|
|
data = {
|
|
time: Date.now(),
|
|
value: node.lastActive,
|
|
}
|
|
}
|
|
|
|
this._mqtt.publish(nodeTopic + '/lastActive', data)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Driver status updates
|
|
*/
|
|
private _onDriverStatus(ready: boolean): void {
|
|
logger.info(`Driver is ${ready ? 'READY' : 'CLOSED'}`)
|
|
|
|
this.cancelJobs()
|
|
|
|
if (ready) {
|
|
if (this.config.jobs?.length > 0) {
|
|
for (const jobConfig of this.config.jobs) {
|
|
this.scheduleJob(jobConfig)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.mqttEnabled) {
|
|
this._mqtt.publish('driver/status', ready)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* When mqtt client goes online/offline
|
|
*
|
|
*/
|
|
private _onBrokerStatus(online: boolean): void {
|
|
if (online) {
|
|
this.rediscoverAll()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hass will/birth
|
|
*
|
|
*/
|
|
private _onHassStatus(online: boolean): void {
|
|
logger.info(`Home Assistant is ${online ? 'ONLINE' : 'OFFLINE'}`)
|
|
|
|
if (online) {
|
|
this.rediscoverAll()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle api requests reeceived from MQTT client
|
|
*
|
|
*/
|
|
private async _onApiRequest(
|
|
topic: string,
|
|
apiName: AllowedApis,
|
|
payload: { args: Parameters<ZwaveClient[AllowedApis]> },
|
|
): Promise<void> {
|
|
if (this._zwave) {
|
|
const args = payload.args || []
|
|
|
|
let result: CallAPIResult<AllowedApis> & { origin?: any }
|
|
|
|
if (Array.isArray(args)) {
|
|
result = await this._zwave.callApi(apiName, ...args)
|
|
result.origin = payload
|
|
} else {
|
|
result = {
|
|
success: false,
|
|
message: 'Args must be an array',
|
|
origin: payload,
|
|
}
|
|
}
|
|
this._mqtt.publish(topic, result, { retain: false })
|
|
} else {
|
|
logger.error(`Requested Z-Wave api ${apiName} doesn't exist`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle broadcast request received from Mqtt client
|
|
*/
|
|
private async _onBroadRequest(
|
|
parts: string[],
|
|
payload: ValueID & { value: unknown; options?: SetValueAPIOptions },
|
|
): Promise<void> {
|
|
if (parts.length > 0) {
|
|
// multiple writes (back compatibility mode)
|
|
const topic = parts.join('/')
|
|
const values = Object.keys(this.topicValues).filter((t) =>
|
|
t.endsWith(topic),
|
|
)
|
|
if (values.length > 0) {
|
|
// all values are the same type just different node,parse the Payload by using the first one
|
|
payload = this.parsePayload(
|
|
payload,
|
|
this.topicValues[values[0]],
|
|
this.topicValues[values[0]].conf,
|
|
)
|
|
|
|
if (payload === null) {
|
|
return
|
|
}
|
|
|
|
for (let i = 0; i < values.length; i++) {
|
|
await this._zwave.writeValue(
|
|
this.topicValues[values[i]],
|
|
payload,
|
|
payload?.options,
|
|
)
|
|
}
|
|
}
|
|
} else {
|
|
// try real zwave broadcast
|
|
if (payload.value === undefined) {
|
|
logger.error('No value found in broadcast request')
|
|
return
|
|
}
|
|
|
|
const error = utils.isValueId(payload)
|
|
|
|
if (typeof error === 'string') {
|
|
logger.error('Invalid valueId: ' + error)
|
|
return
|
|
}
|
|
await this._zwave.writeBroadcast(
|
|
payload,
|
|
payload.value,
|
|
payload.options,
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle write request received from Mqtt Client
|
|
*
|
|
*/
|
|
private async _onWriteRequest(
|
|
parts: string[],
|
|
payload: any,
|
|
): Promise<void> {
|
|
const valueTopic = parts.join('/')
|
|
const valueId = this.topicValues[valueTopic]
|
|
|
|
if (valueId) {
|
|
const value = this.parsePayload(payload, valueId, valueId.conf)
|
|
|
|
if (value === null) {
|
|
return
|
|
}
|
|
|
|
await this._zwave.writeValue(valueId, value, payload?.options)
|
|
} else {
|
|
logger.debug(`No writeable valueId found for ${valueTopic}`)
|
|
}
|
|
}
|
|
|
|
private async _onMulticastRequest(
|
|
payload: ZUIValueId & {
|
|
nodes: number[]
|
|
value: any
|
|
options?: SetValueAPIOptions
|
|
},
|
|
): Promise<void> {
|
|
const nodes = payload.nodes
|
|
const valueId: ValueID = {
|
|
commandClass: payload.commandClass,
|
|
property: payload.property,
|
|
propertyKey: payload.propertyKey,
|
|
endpoint: payload.endpoint,
|
|
}
|
|
const value = payload.value
|
|
|
|
if (!nodes || nodes.length === 0) {
|
|
logger.error('No nodes found in multicast request')
|
|
return
|
|
}
|
|
|
|
const error = utils.isValueId(valueId)
|
|
|
|
if (typeof error === 'string') {
|
|
logger.error('Invalid valueId: ' + error)
|
|
return
|
|
}
|
|
|
|
if (payload.value === undefined) {
|
|
logger.error('No value found in multicast request')
|
|
return
|
|
}
|
|
|
|
await this._zwave.writeMulticast(
|
|
nodes,
|
|
valueId as ZUIValueId,
|
|
value,
|
|
payload.options,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Checks if an operation is valid, it must exist and must contains
|
|
* only numbers and operators
|
|
*/
|
|
private _isValidOperation(op: string): boolean {
|
|
return op && !/[^0-9.()\-+*/,]/g.test(op)
|
|
}
|
|
|
|
/**
|
|
* Evaluate the return value of a custom parse Function
|
|
*
|
|
*/
|
|
private _evalFunction(
|
|
code: string,
|
|
valueId: ZUIValueId,
|
|
value: unknown,
|
|
node: ZUINode,
|
|
) {
|
|
let result = null
|
|
|
|
try {
|
|
/* eslint-disable no-new-func */
|
|
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
const parseFunc = new Function(
|
|
'value',
|
|
'valueId',
|
|
'node',
|
|
'logger',
|
|
code,
|
|
)
|
|
result = parseFunc(value, valueId, node, logger)
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error eval function of value ${valueId.id} ${error.message}`,
|
|
)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Get node name from node object
|
|
*/
|
|
private _getNodeName(node: ZUINode, ignoreLoc: boolean): string {
|
|
return (
|
|
(!ignoreLoc && node.loc ? node.loc + '-' : '') +
|
|
(node.name ? node.name : NODE_PREFIX + node.id)
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Return re-arranged based on critical CCs
|
|
*/
|
|
|
|
private _getPriorityCCFirst(values: {
|
|
[key: string]: ZUIValueId
|
|
}): string[] {
|
|
const priorityCC = [CommandClasses['Color Switch']]
|
|
const prioritizedValueIds = []
|
|
|
|
for (const id in values) {
|
|
if (priorityCC.includes(values[id].commandClass)) {
|
|
prioritizedValueIds.unshift(id)
|
|
} else {
|
|
prioritizedValueIds.push(id)
|
|
}
|
|
}
|
|
return prioritizedValueIds
|
|
}
|
|
|
|
/**
|
|
* Returns the value id without the node prefix
|
|
*/
|
|
private _getIdWithoutNode(valueId: ZUIValueId): string {
|
|
return valueId.id.replace(valueId.nodeId + '-', '')
|
|
}
|
|
|
|
/**
|
|
* Get the device Object to send in discovery payload
|
|
*/
|
|
private _deviceInfo(node: ZUINode, nodeName: string): DeviceInfo {
|
|
return {
|
|
identifiers: [
|
|
UID_DISCOVERY_PREFIX + this._zwave.homeHex + '_node' + node.id,
|
|
],
|
|
manufacturer: node.manufacturer,
|
|
model: node.productDescription + ' (' + node.productLabel + ')',
|
|
name: nodeName,
|
|
sw_version: node.firmwareVersion || utils.getVersion(),
|
|
}
|
|
}
|
|
|
|
private setDiscoveryAvailability(
|
|
node: ZUINode,
|
|
payload: { [key: string]: any },
|
|
) {
|
|
// Set availability config using node status topic, client status topic
|
|
// (which is the LWT), and driver status topic
|
|
payload.availability = [
|
|
{
|
|
payload_available: 'true',
|
|
payload_not_available: 'false',
|
|
topic: this.mqtt.getTopic(this.nodeTopic(node)) + '/status',
|
|
},
|
|
{
|
|
topic: this.mqtt.getStatusTopic(),
|
|
value_template:
|
|
"{{'online' if value_json.value else 'offline'}}",
|
|
},
|
|
{
|
|
payload_available: 'true',
|
|
payload_not_available: 'false',
|
|
topic: this.mqtt.getTopic('driver/status'),
|
|
},
|
|
]
|
|
if (this.config.payloadType !== PAYLOAD_TYPE.RAW) {
|
|
payload.availability[0].value_template =
|
|
"{{'true' if value_json.value else 'false'}}"
|
|
}
|
|
payload.availability_mode = 'all'
|
|
}
|
|
|
|
/**
|
|
* Get the Hass discovery topic for the specific node and hassDevice
|
|
*/
|
|
private _getDiscoveryTopic(
|
|
hassDevice: HassDevice,
|
|
nodeName: string,
|
|
): string {
|
|
return `${hassDevice.type}/${utils.sanitizeTopic(nodeName, true)}/${
|
|
hassDevice.object_id
|
|
}/config`
|
|
}
|
|
|
|
/**
|
|
* Generate the template string to use for value templates.
|
|
* Note that the keys need to be numeric.
|
|
*/
|
|
private _getMappedValuesTemplate(
|
|
valueMap: { [x: string]: any },
|
|
defaultValue: string,
|
|
): string {
|
|
const map = []
|
|
// JSON.stringify converts props to strings and this breaks the template
|
|
// Error: "0": "off", Working: 0: "off"
|
|
for (const key in valueMap) {
|
|
map.push(`${key}: "${valueMap[key]}"`)
|
|
}
|
|
|
|
return `{{ {${map.join(
|
|
', ',
|
|
)}}[value_json.value] | default('${defaultValue}') }}`
|
|
}
|
|
|
|
/**
|
|
* Generate the template string to use for value templates
|
|
* by inverting the value map
|
|
*/
|
|
private _getMappedValuesInverseTemplate(
|
|
valueMap: { [x: string]: number },
|
|
defaultValue: string,
|
|
): string {
|
|
const map = []
|
|
// JSON.stringify converts props to strings and this breaks the template
|
|
// Error: "0": "off" Working: 0: "off"
|
|
for (const key in valueMap) {
|
|
map.push(`${valueMap[key]}: "${key}"`)
|
|
}
|
|
|
|
return `{{ {${map.join(
|
|
', ',
|
|
)}}[value_json.value] | default('${defaultValue}') }}`
|
|
}
|
|
|
|
/**
|
|
* Calculate the correct template string to use for templates with state
|
|
* list based on gateway settings and mapped mode values
|
|
*/
|
|
private _getMappedStateTemplate(
|
|
states: ZUIValueIdState[],
|
|
defaultValueKey: string | number,
|
|
): string {
|
|
const map = []
|
|
let defaultValue = 'value_json.value'
|
|
for (const s of states) {
|
|
map.push(
|
|
`${
|
|
typeof s.value === 'number' ? s.value : '"' + s.value + '"'
|
|
}: "${s.text}"`,
|
|
)
|
|
if (s.value === defaultValueKey) {
|
|
defaultValue = `'${s.text}'`
|
|
}
|
|
}
|
|
|
|
return `{{ {${map.join(
|
|
',',
|
|
)}}[value_json.value] | default(${defaultValue}) }}`
|
|
}
|
|
|
|
/**
|
|
* Generates payload for Binary use from a state object
|
|
*/
|
|
private _setBinaryPayloadFromSensor(
|
|
cfg: HassDevice,
|
|
valueId: ZUIValueId,
|
|
offStateValue = 0,
|
|
): HassDevice {
|
|
const stateKeys = valueId.states.map((s) => s.value)
|
|
// Set on/off state from keys
|
|
if (stateKeys[0] === offStateValue) {
|
|
cfg.discovery_payload.payload_off = stateKeys[0]
|
|
cfg.discovery_payload.payload_on = stateKeys[1]
|
|
} else {
|
|
cfg.discovery_payload.payload_off = stateKeys[1]
|
|
cfg.discovery_payload.payload_on = stateKeys[0]
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
/**
|
|
* Create a binary sensor configuration with a specific device class
|
|
*/
|
|
private _getBinarySensorConfig(
|
|
devClass: string,
|
|
reversePayload = false,
|
|
): HassDevice {
|
|
const cfg = utils.copy(hassCfg.binary_sensor)
|
|
cfg.discovery_payload.device_class = devClass
|
|
if (reversePayload) {
|
|
cfg.discovery_payload.payload_on = false
|
|
cfg.discovery_payload.payload_off = true
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
/**
|
|
* Retrieves the value of a property from the node valueId
|
|
*/
|
|
private _setDiscoveryValue(
|
|
payload: any,
|
|
prop: string,
|
|
node: ZUINode,
|
|
): void {
|
|
if (typeof payload[prop] === 'string') {
|
|
const valueId = node.values[payload[prop]]
|
|
if (valueId && valueId.value != null) {
|
|
payload[prop] = valueId.value
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if this node supports rgb and if so add it to discovery configuration
|
|
*/
|
|
private _addRgbColorSwitch(
|
|
node: ZUINode,
|
|
currentColorValue: ZUIValueId,
|
|
): HassDevice {
|
|
const cfg = utils.copy(hassCfg.light_rgb_dimmer)
|
|
|
|
const currentColorTopics = this.valueTopic(
|
|
node,
|
|
currentColorValue,
|
|
true,
|
|
) as ValueIdTopic
|
|
|
|
const endpoint = currentColorValue.endpoint
|
|
|
|
const supportedColors: ColorMode[] = []
|
|
|
|
cfg.discovery_payload.supported_color_modes = supportedColors
|
|
|
|
supportedColors.push('rgb')
|
|
|
|
// current color values are automatically added later in discoverValue function
|
|
cfg.values = []
|
|
|
|
cfg.discovery_payload.rgb_state_topic = this._mqtt.getTopic(
|
|
currentColorTopics.topic,
|
|
)
|
|
cfg.discovery_payload.rgb_command_topic = this._mqtt.getTopic(
|
|
currentColorTopics.targetTopic,
|
|
true,
|
|
)
|
|
|
|
// The following part of code, checks if ML or Binary works. If one exists the other
|
|
let brightnessValue: string
|
|
let switchValue: string
|
|
if (node.values[`38-${endpoint}-currentValue`]) {
|
|
brightnessValue = `38-${endpoint}-currentValue`
|
|
// Next if is about Fibaro like RGBW which use the endpoint 1 as multilevel
|
|
} else if (endpoint === 0 && node.values['38-1-currentValue']) {
|
|
brightnessValue = '38-1-currentValue'
|
|
} else if (node.values[`37-${endpoint}-currentValue`]) {
|
|
switchValue = `37-${endpoint}-currentValue`
|
|
}
|
|
|
|
/*
|
|
Find the control switch of the device Brightness or Binary
|
|
If multilevel is not there use binary
|
|
Some devices use also endpoint + 1 as on/off/brightness... try to guess that too!
|
|
*/
|
|
let discoveredStateTopic: string
|
|
let discoveredCommandTopic: string
|
|
|
|
if (brightnessValue || switchValue) {
|
|
const vID = brightnessValue || switchValue
|
|
|
|
const valueIdState = node.values[vID]
|
|
const topics = this.valueTopic(
|
|
node,
|
|
valueIdState,
|
|
true,
|
|
) as ValueIdTopic
|
|
|
|
if (!topics) {
|
|
throw Error(`Can't find topics for ${vID}`)
|
|
}
|
|
|
|
cfg.values.push(vID, valueIdState.targetValue)
|
|
|
|
discoveredStateTopic = this._mqtt.getTopic(topics.topic)
|
|
discoveredCommandTopic = this._mqtt.getTopic(
|
|
topics.targetTopic,
|
|
true,
|
|
)
|
|
}
|
|
|
|
if (brightnessValue) {
|
|
supportedColors.push('brightness')
|
|
cfg.discovery_payload.brightness_state_topic = discoveredStateTopic
|
|
cfg.discovery_payload.brightness_command_topic =
|
|
discoveredCommandTopic
|
|
cfg.discovery_payload.state_topic = discoveredStateTopic
|
|
cfg.discovery_payload.command_topic = discoveredCommandTopic
|
|
} else if (switchValue) {
|
|
supportedColors.push('onoff')
|
|
cfg.discovery_payload.state_topic = discoveredStateTopic
|
|
cfg.discovery_payload.command_topic = discoveredCommandTopic
|
|
|
|
cfg.discovery_payload.state_value_template =
|
|
'{{ value_template.json }}'
|
|
cfg.discovery_payload.on_command_type = 'last'
|
|
}
|
|
|
|
const whiteValue = node.values[`51-${endpoint}-currentcolor-0`]
|
|
|
|
// if whitevalue exists, use currentColor value to get/set white
|
|
if (whiteValue && currentColorValue) {
|
|
supportedColors.push('white')
|
|
// still use currentColor but change the template
|
|
cfg.discovery_payload.color_temp_state_topic =
|
|
cfg.discovery_payload.rgb_state_topic
|
|
cfg.discovery_payload.color_temp_command_topic =
|
|
cfg.discovery_payload.rgb_command_topic
|
|
|
|
cfg.discovery_payload.color_temp_command_template =
|
|
"{{ {'warmWhite': ((value - 245)|round(0)), 'coldWhite': (255 - (value - 245))|round(0))}|to_json }}"
|
|
cfg.discovery_payload.color_temp_value_template =
|
|
"{{ '%03d%03d' | format((value_json.value.warmWhite || 0), (value_json.value.coldWhite || 0)) }}"
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
private _getEntityName(
|
|
node: ZUINode,
|
|
valueId: ZUIValueId,
|
|
cfg: HassDevice,
|
|
entityTemplate: string,
|
|
ignoreLoc: boolean,
|
|
): string {
|
|
entityTemplate = entityTemplate || '%ln_%o'
|
|
// when getting the entity name of a device use node props
|
|
let propertyKey: string = cfg.type
|
|
let propertyName: string = cfg.type
|
|
let property: string = cfg.type
|
|
let label: string = cfg.object_id
|
|
|
|
if (valueId) {
|
|
property = valueId.property?.toString()
|
|
propertyKey = valueId.propertyKey?.toString()
|
|
propertyName = valueId.propertyName?.toString()
|
|
label = valueId.label
|
|
}
|
|
|
|
return entityTemplate
|
|
.replace(/%nid/g, NODE_PREFIX + node.id)
|
|
.replace(/%ln/g, this._getNodeName(node, ignoreLoc))
|
|
.replace(/%loc/g, node.loc || '')
|
|
.replace(/%pk/g, propertyKey)
|
|
.replace(/%pn/g, propertyName)
|
|
.replace(/%p/g, property)
|
|
.replace(/%o/g, cfg.object_id)
|
|
.replace(/%n/g, node.name || NODE_PREFIX + node.id)
|
|
.replace(/%l/g, label)
|
|
}
|
|
}
|