vilocal
Version:
Local capture of Viessmann ViCare network data to MQTT with Home Assistant device discovery
344 lines (306 loc) • 14.6 kB
JavaScript
import { env, stdin } from 'node:process';
env.ZBTK_FORMAT_EUI_SEPARATOR = '-'; // like Viessmann uses it for their devices
import fs from 'node:fs/promises';
import { Buffer } from 'node:buffer';
async function exists(path) {
try {
await fs.access(path);
return true;
} catch (err) {
if (err.code === 'ENOENT') {
return false;
} else {
throw err;
}
}
}
import { parse as parseToml } from 'smol-toml';
import { toFormat as bufferFormat } from 'buffer-to-str';
import { connectAsync as mqttConnect } from 'mqtt';
import { process as processCap } from 'zbtk/cap';
import { pk } from 'zbtk/crypto';
import { fromHex } from 'zbtk/utils';
import { eui as formatEui } from 'zbtk/format';
import packageJson from './package.json' with { type: 'json' };
const TYPE_THERMOSTAT = 'thermostat', TYPE_CLIMATE_SENSOR = 'climate_sensor';
const configFile = (await exists('local_config.toml')) ? 'local_config.toml' : 'config.toml';
const config = parseToml(await fs.readFile(configFile, 'utf8'));
async function iterateDevices(callback) {
for (const [type, devices] of Object.entries({
[TYPE_THERMOSTAT]: config.thermostats ?? {},
[TYPE_CLIMATE_SENSOR]: config.climate_sensors ?? {}
})) {
for (const [id, options] of Object.entries(devices)) {
await callback(type, id, options);
}
}
}
// validate / check configuration for basic consistency
if (!config.named_pipe && stdin.isTTY) { // note that stdin.isTTY is true in case there is NO input and falsy (undefined) if there is data piped to stdin, see https://nodejs.org/api/tty.html#tty_tty
throw new TypeError(`No "named_pipe" configured in "${ configFile }", nor anything is piped into standard input (stdin). Please provide a (P)CAP compatible stream either via a named pipe or stdin.`);
}
if (!config.network_key && !env.ZBTK_CRYPTO_PKS) {
console.warn(`Neither a "network_key" is configured in "${ configFile }", nor the "ZBTK_CRYPTO_PKS" environment variable is set. Without a network key, packets will not get decrypted, which will likely cause that no attributes are being reported. Please capture a transport key / configure a network key to enable proper functionality.`)
}
if (!config.mqtt || !config.mqtt.url) {
throw new TypeError(`No MQTT (URL) configuration provided in "${ configFile }"`);
}
config.mqtt.online ??= true; // if not set, use a online topic
const configDevices = new Set();
iterateDevices((type, id, options) => {
if (!options?.serial_no) {
throw new TypeError(`Missing "serial_no" of ${type.replace('_', ' ')} "${id}" in "${ configFile }"`);
}
const hexEui = fromHex(options.serial_no).toString('hex');
if (configDevices.has(hexEui)) {
throw new TypeError(`Duplicate serial number "${formatEui(options.serial_no)}" found for ${type.replace('_', ' ')} "${id}" in "${ configFile }"`);
} else {
configDevices.add(hexEui);
}
});
const mqttTopic = config.mqtt.topic ?? 'ViLocal', mqttAvailabilityTopic =
`${mqttTopic}/${typeof config.mqtt.online === 'string' ? config.mqtt.online : 'online'}`;
const mqttOptions = {
username: config.mqtt.username,
password: config.mqtt.password,
...(config.mqtt.options || {}),
...(config.mqtt.online ? {
will: { // last will to set ViLocal offline
topic: mqttAvailabilityTopic,
payload: `${false}`,
retain: true
}
} : {})
};
// connect to MQTT broker
const mqttClient = await mqttConnect(config.mqtt.url, mqttOptions);
const mqttConnected = async () => {
config.mqtt.online && await mqttClient.publishAsync(
mqttAvailabilityTopic, `${true}`, { retain: true });
};
await mqttConnected(); // set ViLocal online
mqttClient.on('connect', mqttConnected);
const knownDevices = new Set();
/**
* Publish a device to Home Assistant's MQTT discovery service.
*
* @param {Buffer} eui the EUI-64 of the device derived from the options.serial_no
* @param {string} type either "thermostat" or "climate_sensor"
* @param {string} [id] the ID of the device
* @param {object} [options] config. options for the device
* @param {string} [options.name] the name of the device
* @param {Buffer} [options.climate_sensor_serial_no] the EUI-64 of the climate sensor associated with the thermostat, only for type "thermostat"
*/
async function publishDevice(eui, type, id, options) {
if (!type) {
throw new TypeError('Type of device must be provided');
}
if (!id) {
id = type; // e.g. "thermostat", or user specified "thermostat_bedroom"
}
const hexEui = eui.toString('hex');
const formattedEui = formatEui(eui);
const deviceTopic = `${mqttTopic}/${formattedEui}`;
const genericName = type === TYPE_THERMOSTAT ? 'Thermostat' : 'Climate Sensor';
const name = options?.name ?? genericName;
// add device to the known devices list
knownDevices.add(hexEui);
function discoveryComponent(suffix, platform, options) {
if (!options) {
options = platform;
platform = suffix;
suffix = '';
}
if (suffix && !suffix.startsWith('_')) {
suffix = `_${suffix}`;
}
const unique_id = `vilocal_${hexEui}${suffix}`;
return {
[unique_id]: {
platform,
default_entity_id: `${platform}.vilocal_${type}${id !== type ? `_${id}` : ''}${suffix}`,
name: options?.name, // will automatically get prefixed with the device name
unique_id,
...(options ?? {})
}
};
}
// build the discovery record
const discovery = {
device: {
name,
identifiers: formattedEui,
serial_number: formattedEui,
manufacturer: 'Viessmann',
model: `ViCare ${genericName}`,
model_id: type === TYPE_THERMOSTAT ? 'ZK03840' : 'ZK05991'
},
origin: { // see https://www.home-assistant.io/integrations/mqtt/#adding-information-about-the-origin-of-a-discovery-message
name: 'ViLocal',
sw_version: packageJson.version,
support_url: 'https://github.com/kristian/ViLocal/issues'
},
...(config.mqtt.online ? {
availability: [ // see https://www.home-assistant.io/integrations/mqtt/#using-availability-topics
{
topic: mqttAvailabilityTopic,
payload_available: 'true',
payload_not_available: 'false',
}
]
} : {}),
// depending of the type of device, different components will be announced:
// - thermostats expose as a climate component
// - climate sensors expose as a temperature and humidity sensor
// all devices also expose a sensor showing the battery level, as well as the current link quality
components: {
...(type === TYPE_THERMOSTAT ? {
// each component requires a id for discovery (the name of the attribute in the components object)
// as well as a object_id that is used for deriving the entity_id in home assistant, a unique_id
// in order to be able to change the entity_id and a display name, see https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities
...discoveryComponent('climate', {
name: null, // use the name of the device
modes: ['auto'],
swing_modes: [],
fan_modes: [],
temp_step: 0.5,
optimistic: false,
precision: 0.1,
// use an arbitrary existing topic (the availability topic is always retained) to disable optimistic mode ...
mode_state_topic: config.mqtt.online ? mqttAvailabilityTopic : '$SYS/broker/version',
mode_state_template: 'auto', // ... as state of thermostats is always auto (they will turn on if needed)
temperature_state_topic: `${deviceTopic}/0x0201/0x0012`, // Thermostat / Occupied Heating Setpoint
temperature_state_template: '{{ (value | float ) / 100 | round(2) }}',
...(!options?.climate_sensor_serial_no ? { // thermostats can be associated to a climate sensor for example in the same room
current_temperature_topic: `${deviceTopic}/0x0201/0x0000`, // Thermostat / Local Temperature
current_temperature_template: '{{ (value | float ) / 100 | round(2) }}',
} : { // in case of an associated climate sensor, display its temperature / humidity values instead of the ones reported by the thermostat
current_temperature_topic: `${mqttTopic}/${formatEui(options.climate_sensor_serial_no)}/0x0402/0x0000`,
current_temperature_template: '{{ (value | float ) / 100 | round(2) }}',
current_humidity_topic: `${mqttTopic}/${formatEui(options.climate_sensor_serial_no)}/0x0405/0x0000`,
current_humidity_template: '{{ (value | float ) / 100 | round(2) }}',
})
}),
// also expose the individual values as sensors
...discoveryComponent('heating_setpoint', 'sensor', {
name: 'Heating Setpoint', // will automatically get prefixed with the device name
device_class: 'temperature',
unit_of_measurement: '°C',
suggested_display_precision: 1,
state_topic: `${deviceTopic}/0x0201/0x0012`, // Thermostat / Occupied Heating Setpoint
value_template: '{{ (value | float ) / 100 | round(2) }}',
state_class: 'measurement'
}),
...discoveryComponent('temperature', 'sensor', {
name: 'Temperature', // will automatically get prefixed with the device name
device_class: 'temperature',
unit_of_measurement: '°C',
suggested_display_precision: 1,
state_topic: `${deviceTopic}/0x0201/0x0000`, // Thermostat / Local Temperature
value_template: '{{ (value | float ) / 100 | round(2) }}',
state_class: 'measurement'
}),
...discoveryComponent('window_open', 'binary_sensor', {
name: 'Window Open', // will automatically get prefixed with the device name
device_class: 'window',
state_topic: `${deviceTopic}/0x0201/0x4000`, // Thermostat / Manufacturer Specific (OpenWindowDetection)
value_template: '{{ "ON" if (value | int) == 3 else "OFF" }}'
})
} : { // Climate Sensor
...discoveryComponent('temperature', 'sensor', {
name: 'Temperature', // will automatically get prefixed with the device name
device_class: 'temperature',
unit_of_measurement: '°C',
suggested_display_precision: 1,
state_topic: `${deviceTopic}/0x0402/0x0000`, // Temperature Measurement / Value
value_template: '{{ (value | float ) / 100 | round(2) }}',
state_class: 'measurement'
}),
...discoveryComponent('humidity', 'sensor', {
name: 'Humidity', // will automatically get prefixed with the device name
device_class: 'humidity',
unit_of_measurement: '%',
suggested_display_precision: 0,
state_topic: `${deviceTopic}/0x0405/0x0000`, // Relative Humidity / Measurement Value
value_template: '{{ (value | float ) / 100 | round(2) }}',
state_class: 'measurement'
})
}),
...discoveryComponent('battery_level', 'sensor', {
name: 'Battery Level', // will automatically get prefixed with the device name
entity_category: 'diagnostic',
device_class: 'battery',
unit_of_measurement: '%',
suggested_display_precision: 0,
state_topic: `${deviceTopic}/0x0001/0x0021`, // Power Configuration / Battery Percentage Remaining
value_template: '{{ (value | float ) / 2 }}'
}),
...discoveryComponent('link_quality', 'sensor', {
name: 'Link Quality', // will automatically get prefixed with the device name
entity_category: 'diagnostic',
icon: 'mdi:signal',
unit_of_measurement: '%',
suggested_display_precision: 0,
state_topic: `${deviceTopic}/0x0B05/0x011C`, // Diagnostics / Last LQI
value_template: '{{ ((value | float) / 255) * 100 | round(1) }}'
}),
...discoveryComponent('signal_strength', 'sensor', {
name: 'Signal Strength', // will automatically get prefixed with the device name
entity_category: 'diagnostic',
device_class: 'signal_strength',
unit_of_measurement: 'dBm',
suggested_display_precision: 0,
state_topic: `${deviceTopic}/0x0B05/0x011D`, // Diagnostics / Last RSSI
value_template: '{{ value | int }}'
})
}
};
// publish to device discovery topic, see: https://www.home-assistant.io/integrations/mqtt/#discovery-messages
// config messages are retained, see: https://www.home-assistant.io/integrations/mqtt/#using-retained-config-messages
await mqttClient.publishAsync(`${config.device_discovery_prefix ?? 'homeassistant'}/device/vilocal_${formattedEui.replaceAll(':', '-')}/config`,
JSON.stringify(discovery), { retain: true });
}
// register devices
await iterateDevices(async (type, id, options) => {
await publishDevice(fromHex(options.serial_no), type, id, options);
});
// set the pre-configured network key
config.network_key && pk(config.network_key);
// process capture packets
const capEmitter = await processCap(config.named_pipe ?? stdin, {
unwrapLayers: config.unwrap_layers ?? [],
bufferFormat: buffer => bufferFormat(buffer, config.buffer_format ?? 'hex'),
emit: ['attribute'],
out: {
log: [config.log_level ?? 'warn'].flat(),
mqtt: {
client: mqttClient,
topic: mqttTopic
}
}
});
capEmitter.on('attribute', async function(attr, context) {
const { eui, cluster } = context;
let type;
switch (cluster.readUInt16BE(0)) {
case 0x0201: // Thermostat
type = TYPE_THERMOSTAT;
break;
case 0x0402: case 0x0405: // Temperature Measurement / Relative Humidity
type = TYPE_CLIMATE_SENSOR;
break;
default:
return; // ignore any other clusters, such as Diagnostics, etc.
}
if (!knownDevices.has(eui.toString('hex'))) {
console.warn(`Received attributes from unknown ${type.replace('_', ' ')} with serial number "${formatEui(eui)}". Consider adding it to the "${ configFile }" file to provide a better device discovery record. Device will ${ (config.publish_unknown_devices ?? true) ? 'be published using generic information' : 'not be published, as "publish_unknown_devices" is set to "false"' }.`);
if (config.publish_unknown_devices ?? true) {
await publishDevice(eui, type);
} else {
// add it to the known-device list anyways / without publishing it to
// device discovery, in order to not repeat log outputs for this device
knownDevices.add(eui.toString('hex'));
}
}
});