From 46eb0a5280eefe797bbede39593cf64c01d99337 Mon Sep 17 00:00:00 2001 From: "Christoph M. Wintersteiger" Date: Sat, 10 Apr 2021 15:07:52 +0100 Subject: [PATCH] Add Honeywell CM921/BDR91/Evohome decoder (#1336) * Add Honeywell CM921/BDR91 decoder. --- README.md | 1 + conf/rtl_433.example.conf | 1 + include/rtl_433_devices.h | 2 + src/CMakeLists.txt | 1 + src/devices/honeywell_cm921.c | 453 ++++++++++++++++++++++++++++++++++ vs15/rtl_433.vcxproj | 1 + 6 files changed, 459 insertions(+) create mode 100644 src/devices/honeywell_cm921.c diff --git a/README.md b/README.md index c4e67db7..a0e070e9 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,7 @@ See [CONTRIBUTING.md](./docs/CONTRIBUTING.md). [181] Amazon Basics Meat Thermometer [182] TFA Marbella Pool Thermometer [183] Auriol AHFL temperature/humidity sensor + [185] Honeywell CM921/BDR91 (partial Evohome) * Disabled by default, use -R n or -G diff --git a/conf/rtl_433.example.conf b/conf/rtl_433.example.conf index 457cf829..bcd24710 100644 --- a/conf/rtl_433.example.conf +++ b/conf/rtl_433.example.conf @@ -366,6 +366,7 @@ stop_after_successful_events false protocol 182 # TFA Marbella Pool Thermometer protocol 183 # Auriol AHFL temperature/humidity sensor protocol 184 # Auriol AFT 77 B2 temperature sensor + protocol 185 # Honeywell CM921 Wireless Programmable Room Thermostat ## Flex devices (command line option "-X") diff --git a/include/rtl_433_devices.h b/include/rtl_433_devices.h index f9f1cfd1..010d84dd 100644 --- a/include/rtl_433_devices.h +++ b/include/rtl_433_devices.h @@ -192,6 +192,8 @@ DECL(tfa_marbella) \ DECL(auriol_ahfl) \ DECL(auriol_aft77b2) \ + DECL(honeywell_cm921) \ + /* Add new decoders here. */ #define DECL(name) extern r_device name; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b3d8fc35..96d44d1a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -101,6 +101,7 @@ add_library(r_433 STATIC devices/holman_ws5029.c devices/hondaremote.c devices/honeywell.c + devices/honeywell_cm921.c devices/honeywell_wdb.c devices/ht680.c devices/ibis_beacon.c diff --git a/src/devices/honeywell_cm921.c b/src/devices/honeywell_cm921.c new file mode 100644 index 00000000..e034766a --- /dev/null +++ b/src/devices/honeywell_cm921.c @@ -0,0 +1,453 @@ +/** @file + Honeywell CM921 Thermostat + + Copyright (C) 2020 Christoph M. Wintersteiger + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. +*/ +/** +Honeywell CM921 Thermostat (subset of Evohome). + +868Mhz FSK, PCM, Start/Stop bits, reversed, Manchester. +*/ + +#include "decoder.h" + +// #define _DEBUG + +static int get_byte(const bitbuffer_t *b, int row, int pos, int end, uint8_t *out) +{ + *out = 0; + int bend = pos + 10; + + while (pos < bend) { + if (pos >= end) + return DECODE_ABORT_LENGTH; + + if (bitrow_get_bit(b->bb[row], pos++) != 0) + return DECODE_FAIL_SANITY; + + if (pos + 8 >= end) + return DECODE_ABORT_LENGTH; + + for (unsigned i = 0; i < 8; i++) + *out = *out << 1 | bitrow_get_bit(b->bb[row], pos++); + + if (pos >= end) + return DECODE_ABORT_LENGTH; + + if (bitrow_get_bit(b->bb[row], pos++) != 1) + return DECODE_FAIL_SANITY; + + if (pos >= end) + return DECODE_ABORT_LENGTH; + } + + return 10; +} + +uint8_t next(const uint8_t* bb, unsigned *ipos, unsigned num_bytes) { + uint8_t r = bitrow_get_byte(bb, *ipos); + *ipos += 8; + if (*ipos >= num_bytes*8) + return DECODE_FAIL_SANITY; + return r; +} + +typedef struct { + uint8_t header; + uint8_t num_device_ids : 2; + uint8_t device_id[4][3]; + uint16_t command; + uint8_t payload_length; + uint8_t payload[256]; + uint8_t unparsed_length; + uint8_t unparsed[256]; + uint8_t crc; +} message_t; + + +data_t *add_hex_string(data_t *data, const char *name, const uint8_t *buf, size_t buf_sz) +{ + if (buf && buf_sz > 0) { + char tstr[(buf_sz * 2) + 1]; + char *p = tstr; + for (unsigned i = 0; i < buf_sz; i++, p+=2) + sprintf(p, "%02x", buf[i]); + *p = '\0'; + data = data_append(data, name, "", DATA_STRING, tstr, NULL); + } + return data; +} + +typedef struct { uint8_t t; const char s[4]; } dev_map_entry_t; + +static const dev_map_entry_t device_map[] = { + { .t = 01, .s = "CTL" }, /* Controller */ + { .t = 02, .s = "UFH" }, /* Underfloor heating (HCC80, HCE80) */ + { .t = 03, .s = " 30" }, /* HCW82?? */ + { .t = 04, .s = "TRV" }, /* Thermostatic radiator valve (HR80, HR91, HR92) */ + { .t = 07, .s = "DHW" }, /* DHW sensor (CS92) */ + { .t = 10, .s = "OTB" }, /* OpenTherm bridge (R8810) */ + { .t = 12, .s = "THm" }, /* Thermostat with setpoint schedule control (DTS92E, CME921) */ + { .t = 13, .s = "BDR" }, /* Wireless relay box (BDR91) (HC60NG too?) */ + { .t = 17, .s = " 17" }, /* Dunno - Outside weather sensor? */ + { .t = 18, .s = "HGI" }, /* Honeywell Gateway Interface (HGI80, HGS80) */ + { .t = 22, .s = "THM" }, /* Thermostat with setpoint schedule control (DTS92E) */ + { .t = 30, .s = "GWY" }, /* Gateway (e.g. RFG100?) */ + { .t = 32, .s = "VNT" }, /* (HCE80) Ventilation (Nuaire VMS-23HB33, VMN-23LMH23) */ + { .t = 34, .s = "STA" }, /* Thermostat (T87RF) */ + { .t = 63, .s = "NUL" }, /* No device */ +}; + +void decode_device_id(const uint8_t device_id[3], char *buf, size_t buf_sz) +{ + uint8_t dev_type = device_id[0] >> 2; + const char *dev_name = " --"; + for (size_t i = 0; i < sizeof(device_map) / sizeof(dev_map_entry_t); i++) + if (device_map[i].t == dev_type) + dev_name = device_map[i].s; + + if (buf_sz < (6 + strlen(dev_name) + 1)) + return; + + sprintf(buf, "%3s:%06d", dev_name, (device_id[0] & 0x03) << 16 | (device_id[1] << 8) | device_id[2]); +} + +data_t *decode_device_ids(const message_t *msg, data_t *data, int style) { + char ds[64] = {0}; + + for (unsigned i = 0; i < msg->num_device_ids; i++) { + if (i != 0) strcat(ds, " "); + + char buf[256] = {0}; + if (style == 0) + decode_device_id(msg->device_id[i], buf, 256); + else { + for (unsigned j = 0; j < 3; j++) + sprintf(buf + 2*j, "%02x", msg->device_id[i][j]); + } + strcat(ds, buf); + } + + return data_append(data, "Device IDs", "", DATA_STRING, ds, NULL); +} + +#define UNKNOWN_IF(C) if (C) { return data_append(data, "unknown", "", DATA_FORMAT, "%04x", DATA_INT, msg->command, NULL); } + +data_t *interpret_message(const message_t *msg, data_t *data, int verbose) +{ + /* Sources of inspiration: + https://github.com/Evsdd/The-Evohome-Protocol/wiki + https://www.domoticaforum.eu/viewtopic.php?f=7&t=5806&start=30 + (specifically https://www.domoticaforum.eu/download/file.php?id=1396) */ + + data = decode_device_ids(msg, data, 1); + + data_t *r = data; + switch(msg->command) { + case 0x1030: { + UNKNOWN_IF(msg->payload_length != 16); + data = data_append(data, "zone_idx", "", DATA_FORMAT, "%02x", DATA_INT, msg->payload[0], NULL); + for (unsigned i = 0; i < 5; i++) { // order fixed? + const uint8_t *p = &msg->payload[1 + 3*i]; + // *(p+1) == 0x01 always? + int value = *(p+2); + switch (*p) { + case 0xC8: data = data_append(data, "max_flow_temp", "", DATA_INT, value, NULL); break; + case 0xC9: data = data_append(data, "pump_run_time", "", DATA_INT, value, NULL); break; + case 0xCA: data = data_append(data, "actuator_run_time", "", DATA_INT, value, NULL); break; + case 0xCB: data = data_append(data, "min_flow_temp", "", DATA_INT, value, NULL); break; + case 0xCC: /* Unknown, always 0x01? */ break; + default: + if (verbose) + printf("Unknown parameter to 0x1030: %x02d=%04d\n", *p, value); + } + } + break; + } + case 0x313F: { + UNKNOWN_IF(msg->payload_length != 1 && msg->payload_length != 9); + switch (msg->payload_length) { + case 1: + data = data_append(data, "time_request", "", DATA_INT, msg->payload[0], NULL); break; + case 9: { + uint8_t unknown_0 = msg->payload[0]; /* always == 0? */ + uint8_t unknown_1 = msg->payload[1]; /* direction? */ + uint8_t second = msg->payload[2]; + uint8_t minute = msg->payload[3]; + uint8_t day_of_week = msg->payload[4] >> 5; + uint8_t hour = msg->payload[4] & 0x1F; + uint8_t day = msg->payload[5]; + uint8_t month = msg->payload[6]; + uint8_t year[2] = { msg->payload[7], msg->payload[8] }; + char time_str[256]; + sprintf(time_str, "%02d:%02d:%02d %02d-%02d-%04d", hour, minute, second, day, month, (year[0] << 8) | year[1]); + data = data_append(data, "datetime", "", DATA_STRING, time_str, NULL); + break; + } + } + break; + } + case 0x0008: { + UNKNOWN_IF(msg->payload_length != 2); + data = data_append(data, "domain_id", "", DATA_INT, msg->payload[0], NULL); + data = data_append(data, "demand", "", DATA_DOUBLE, msg->payload[1] / 200.0 /* 0xC8 */, NULL); + break; + } + case 0x3ef0: { + UNKNOWN_IF(msg->payload_length != 3 && msg->payload_length != 6); + switch (msg->payload_length) { + case 3: + data = data_append(data, "status", "", DATA_DOUBLE, msg->payload[1] / 200.0 /* 0xC8 */, NULL); + break; + case 6: + data = data_append(data, "boiler_modulation_level", "", DATA_DOUBLE, msg->payload[1] / 200.0 /* 0xC8 */, NULL); + data = data_append(data, "flame_status", "", DATA_INT, msg->payload[3], NULL); + break; + } + break; + } + case 0x2309: { + UNKNOWN_IF(msg->payload_length != 3); + data = data_append(data, "zone", "", DATA_INT, msg->payload[0], NULL); + // Observation: CM921 reports a very high setpoint during binding (0x7eff); packet: 143255c1230903017efff7 + data = data_append(data, "setpoint", "", DATA_DOUBLE, ((msg->payload[1] << 8) | msg->payload[2]) / 100.0, NULL); + break; + } + case 0x1100: { + UNKNOWN_IF(msg->payload_length != 5 && msg->payload_length != 8); + data = data_append(data, "domain_id", "", DATA_INT, msg->payload[0], NULL); + data = data_append(data, "cycle_rate", "", DATA_DOUBLE, msg->payload[1] / 4.0, NULL); + data = data_append(data, "minimum_on_time", "", DATA_DOUBLE, msg->payload[2] / 4.0, NULL); + data = data_append(data, "minimum_off_time", "", DATA_DOUBLE, msg->payload[3] / 4.0, NULL); + if (msg->payload_length == 8) + data = data_append(data, "proportional_band_width", "", DATA_DOUBLE, (msg->payload[5] << 8 | msg->payload[6]) / 100.0, NULL); + break; + } + case 0x0009: { + UNKNOWN_IF(msg->payload_length != 3); + data = data_append(data, "device_number", "", DATA_INT, msg->payload[0], NULL); + switch (msg->payload[1]) { + case 0: data = data_append(data, "failsafe_mode", "", DATA_STRING, "off", NULL); break; + case 1: data = data_append(data, "failsafe_mode", "", DATA_STRING, "20-80", NULL); break; + default: data = data_append(data, "failsafe_mode", "", DATA_STRING, "unknown", NULL); + } + break; + } + case 0x3B00: { + UNKNOWN_IF(msg->payload_length != 2); + data = data_append(data, "domain_id", "", DATA_INT, msg->payload[0], NULL); + data = data_append(data, "state", "", DATA_DOUBLE, msg->payload[1] / 200.0 /* 0xC8 */, NULL); + break; + } + case 0x30C9: { + size_t num_zones = msg->payload_length/3; + for (size_t i=0; i < num_zones; i++) { + char name[256]; + snprintf(name, sizeof(name), "temperature (zone %u)", msg->payload[3*i]); + int16_t temp = msg->payload[3*i+1] << 8 | msg->payload[3*i+1]; + data = data_append(data, name, "", DATA_DOUBLE, temp/100.0, NULL); + } + break; + } + default: /* Unknown command */ + UNKNOWN_IF(1); + } + return r; +} + +int parse_msg(bitbuffer_t *bmsg, int row, message_t *msg) { + if (!bmsg || row >= bmsg->num_rows || bmsg->bits_per_row[row] < 8) + return DECODE_ABORT_LENGTH; + + unsigned num_bytes = bmsg->bits_per_row[0]/8; + unsigned num_bits = bmsg->bits_per_row[0]; + unsigned ipos = 0; + const uint8_t *bb = bmsg->bb[row]; + memset(msg, 0, sizeof(message_t)); + + // Checksum: All bytes add up to 0. + uint8_t bsum = 0; + for (unsigned i = 0; i < num_bytes; i++) + bsum += bitrow_get_byte(bmsg->bb[row], i*8); + uint8_t checksum_ok = bsum == 0; + msg->crc = bitrow_get_byte(bmsg->bb[row], bmsg->bits_per_row[row] - 8); + + if (!checksum_ok) + return DECODE_FAIL_MIC; + + msg->header = next(bb, &ipos, num_bytes); + + msg->num_device_ids = msg->header == 0x14 ? 1 : + msg->header == 0x18 ? 2 : + msg->header == 0x1c ? 2 : + msg->header == 0x10 ? 2 : + msg->header == 0x3c ? 2 : + (msg->header >> 2) & 0x03; // total speculation. + + for (unsigned i = 0; i < msg->num_device_ids; i++) + for (unsigned j = 0; j < 3; j++) + msg->device_id[i][j] = next(bb, &ipos, num_bytes); + + msg->command = (next(bb, &ipos, num_bytes) << 8) | next(bb, &ipos, num_bytes); + msg->payload_length = next(bb, &ipos, num_bytes); + + for (unsigned i = 0; i < msg->payload_length; i++) + msg->payload[i] = next(bb, &ipos, num_bytes); + + if (ipos < num_bits - 8) + { + unsigned num_unparsed_bits = (bmsg->bits_per_row[row] - 8) - ipos; + msg->unparsed_length = (num_unparsed_bits / 8) + (num_unparsed_bits % 8) ? 1 : 0; + if (msg->unparsed_length != 0) + bitbuffer_extract_bytes(bmsg, row, ipos, msg->unparsed, num_unparsed_bits); + } + + return ipos; +} + +static int honeywell_cm921_decode(r_device *decoder, bitbuffer_t *bitbuffer) +{ + // Sources of inspiration: + // https://www.domoticaforum.eu/viewtopic.php?f=7&t=5806&start=240 + + // preamble=0x55 0xFF 0x00 + // preamble with start/stop bits=0101010101 0111111111 0000000001 + // =0101 0101 0101 1111 1111 0000 0000 01 + // =0x 5 5 5 F F 0 0 4 + // post=10101100 + // each byte surrounded by start/stop bits (0byte1) + // then manchester decode. + const uint8_t preamble_pattern[4] = { 0x55, 0x5F, 0xF0, 0x04 }; + const uint8_t preamble_bit_length = 30; + const int row = 0; // we expect a single row only. + + if (bitbuffer->num_rows != 1 || bitbuffer->bits_per_row[row] < 60) + return DECODE_ABORT_LENGTH; + + if (decoder->verbose) + bitrow_printf(bitbuffer->bb[row], bitbuffer->bits_per_row[row], "%s: ", __func__); + + int preamble_start = bitbuffer_search(bitbuffer, row, 0, preamble_pattern, preamble_bit_length); + int start = preamble_start + preamble_bit_length; + int len = bitbuffer->bits_per_row[row] - start; + if (decoder->verbose) + printf("preamble_start=%d start=%d len=%d\n", preamble_start, start, len); + if (len < 8) return DECODE_ABORT_LENGTH; + int end = start + len; + + bitbuffer_t bytes = {0}; + int pos = start; + while (pos < end) { + uint8_t byte = 0; + if (get_byte(bitbuffer, row, pos, end, &byte) != 10) + break; + for (unsigned i = 0; i < 8; i++) + bitbuffer_add_bit(&bytes, (byte >> i) & 0x1); + pos += 10; + } + + // Skip Manchester breaking header + uint8_t header[3] = { 0x33, 0x55, 0x53 }; + if (bitrow_get_byte(bytes.bb[row], 0) != header[0] || + bitrow_get_byte(bytes.bb[row], 8) != header[1] || + bitrow_get_byte(bytes.bb[row], 16) != header[2]) + return DECODE_FAIL_SANITY; + + // Find Footer 0x35 (0x55*) + int fi = bytes.bits_per_row[row] - 8; + int seen_aa = 0; + while (bitrow_get_byte(bytes.bb[row], fi) == 0x55) { + seen_aa = 1; + fi -= 8; + } + if (!seen_aa || bitrow_get_byte(bytes.bb[row], fi) != 0x35) + return DECODE_FAIL_SANITY; + + unsigned first_byte = 24; + unsigned end_byte = fi; + unsigned num_bits = end_byte - first_byte; + unsigned num_bytes = num_bits/8 / 2; + + bitbuffer_t packet = {0}; + unsigned fpos = bitbuffer_manchester_decode(&bytes, row, first_byte, &packet, num_bits); + unsigned man_errors = num_bits - (fpos - first_byte - 2); + +#ifndef _DEBUG + if (man_errors != 0) + return DECODE_FAIL_SANITY; +#endif + + message_t message; + + int pr = parse_msg(&packet, row, &message); + + if (pr <= 0) + return pr; + + /* clang-format off */ + data_t *data = data_make( + "model", "", DATA_STRING, "Honeywell CM921", + "mic", "MIC", DATA_STRING, "CHECKSUM", + NULL); + /* clang-format on */ + + data = interpret_message(&message, data, decoder->verbose); + +#ifdef _DEBUG + data = add_hex_string(data, "Packet", packet.bb[row], packet.bits_per_row[row]/8); + data = add_hex_string(data, "Header", &message.header, 1); + uint8_t cmd[2] = { message.command >> 8, message.command & 0x00FF}; + data = add_hex_string(data, "Command", cmd, 2); + data = add_hex_string(data, "Payload", message.payload, message.payload_length); + data = add_hex_string(data, "Unparsed", message.unparsed, message.unparsed_length); + data = add_hex_string(data, "CRC", &message.crc, 1); + data = data_append(data, "# man errors", "", DATA_INT, man_errors, NULL); +#endif + + decoder_output_data(decoder, data); + + return 1; +} + +static char *output_fields[] = { + "model", + "Device IDs", +#ifdef _DEBUG + "Packet", + "Header", + "Command", + "Payload", + "Unparsed", + "CRC", + "# man errors", +#endif + "datetime", + "domain_id", + "state", + "demand", + "status", + "zone_idx", + "max_flow_temp", + "pump_run_time", + "actuator_run_time", + "min_flow_temp", + NULL, +}; + +r_device honeywell_cm921 = { + .name = "Honeywell CM921 Wireless Programmable Room Thermostat", + .modulation = FSK_PULSE_PCM, + .short_width = 26, + .long_width = 26, + .sync_width = 0, + .tolerance = 5, + .reset_limit = 2000, + .decode_fn = &honeywell_cm921_decode, + .disabled = 0, + .fields = output_fields, +}; diff --git a/vs15/rtl_433.vcxproj b/vs15/rtl_433.vcxproj index 27995b91..0baca3f5 100644 --- a/vs15/rtl_433.vcxproj +++ b/vs15/rtl_433.vcxproj @@ -230,6 +230,7 @@ COPY ..\..\libusb\MS64\dll\libusb*.dll $(TargetDir) +