From cdc1987d9f189b433d3570715ce60179b57aea57 Mon Sep 17 00:00:00 2001 From: Peter Kretz Date: Tue, 22 Sep 2026 20:39:17 +0200 Subject: [PATCH 1/5] Add SetSterilizationTemp and SetSterilizationMaxTime with input validation. Prevent sending rejected commands and add documentation. Hardware verified on Aquarea L. --- HeishaMon/HeishaMon.ino | 2 ++ HeishaMon/commands.cpp | 40 ++++++++++++++++++++++++++++++++++++++++ HeishaMon/commands.h | 6 ++++++ MQTT-Topics.md | 2 ++ 4 files changed, 50 insertions(+) diff --git a/HeishaMon/HeishaMon.ino b/HeishaMon/HeishaMon.ino index aab4640a..62f2160a 100644 --- a/HeishaMon/HeishaMon.ino +++ b/HeishaMon/HeishaMon.ino @@ -924,6 +924,7 @@ void serialTXTask(void *pvParameters) { } } bool send_command(byte* command, int length) { + if (length <= 0) return false; if ( heishamonSettings.listenonly ) { log_message(_F("Not sending this command. Heishamon in listen only mode!")); return false; @@ -938,6 +939,7 @@ bool send_command(byte* command, int length) { #else bool send_command(byte* command, int length) { + if (length <= 0) return false; if ( heishamonSettings.listenonly ) { log_message(_F("Not sending this command. Heishamon in listen only mode!")); return false; diff --git a/HeishaMon/commands.cpp b/HeishaMon/commands.cpp index ca001d01..0e28e023 100644 --- a/HeishaMon/commands.cpp +++ b/HeishaMon/commands.cpp @@ -313,6 +313,46 @@ unsigned int set_force_defrost(char *msg, unsigned char *cmd, char *log_msg) { return sizeof(panasonicSendQuery); } +unsigned int set_sterilization_temp(char *msg, unsigned char *cmd, char *log_msg) { + if (msg == NULL || strlen(msg) != 2 || msg[0] < '0' || msg[0] > '9' || msg[1] < '0' || msg[1] > '9') { + snprintf_P(log_msg, 256, PSTR("Invalid sterilization temperature: expected integer 55-75 C")); + return 0; + } + const int temperature = (msg[0] - '0') * 10 + (msg[1] - '0'); + if (temperature < 55 || temperature > 75) { + snprintf_P(log_msg, 256, PSTR("Invalid sterilization temperature: expected integer 55-75 C")); + return 0; + } + + memcpy_P(cmd, panasonicSendQuery, sizeof(panasonicSendQuery)); + cmd[100] = temperature + 128; + snprintf_P(log_msg, 256, PSTR("set sterilization temperature to %d C"), temperature); + return sizeof(panasonicSendQuery); +} + +unsigned int set_sterilization_max_time(char *msg, unsigned char *cmd, char *log_msg) { + int minutes = 0; + const size_t length = msg == NULL ? 0 : strlen(msg); + if (length > 0 && length <= 2) { + for (size_t i = 0; i < length; ++i) { + if (msg[i] < '0' || msg[i] > '9') { + minutes = 0; + break; + } + minutes = minutes * 10 + (msg[i] - '0'); + } + } + if (minutes < 5 || minutes > 60 || minutes % 5 != 0) { + snprintf_P(log_msg, 256, PSTR("Invalid sterilization holding time: expected multiple of 5 minutes from 5 to 60")); + return 0; + } + + memcpy_P(cmd, panasonicSendQuery, sizeof(panasonicSendQuery)); + cmd[101] = minutes + 1; + snprintf_P(log_msg, 256, PSTR("set sterilization holding time to %d minutes"), minutes); + return sizeof(panasonicSendQuery); +} + unsigned int set_force_sterilization(char *msg, unsigned char *cmd, char *log_msg) { String set_force_sterilization_string(msg); diff --git a/HeishaMon/commands.h b/HeishaMon/commands.h index 25dd908b..cfa2c74c 100644 --- a/HeishaMon/commands.h +++ b/HeishaMon/commands.h @@ -39,6 +39,8 @@ unsigned int set_z2_heat_request_temperature(char *msg, unsigned char *cmd, char unsigned int set_z2_cool_request_temperature(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_force_DHW(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_force_defrost(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_sterilization_max_time(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_sterilization_temp(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_force_sterilization(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_force_heater(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_holiday_mode(char *msg, unsigned char *cmd, char *log_msg); @@ -122,6 +124,10 @@ const cmdStruct commands[] PROGMEM = { { "SetForceDefrost", set_force_defrost }, // set mode to force sterilization by sending 1 { "SetForceSterilization", set_force_sterilization }, + // set sterilization target temperature in whole degrees Celsius (55-75) + { "SetSterilizationTemp", set_sterilization_temp }, + // set sterilization holding time in 5-minute steps (5-60) + { "SetSterilizationMaxTime", set_sterilization_max_time }, // set mode to force heater (emergency heating) by sending 1, off will be 0 { "SetForceHeater", set_force_heater }, // set Holiday mode by sending 1, off will be 0 diff --git a/MQTT-Topics.md b/MQTT-Topics.md index 7d22ca5d..970ae3ee 100644 --- a/MQTT-Topics.md +++ b/MQTT-Topics.md @@ -251,6 +251,8 @@ SET45 | SetRoomHeaterState | Allow Room backup/booster heater | 0=blocked, 1=fre SET46 | SetHeaterOnOutdoorTemp | Outdoor temperature for heater ON | -15 to 20 SET47 | SetForceHeater | Force heater mode (emergency heating), same as the heater button on the remote. State is reported in TOP68 | 0=off, 1=on SET48 | SetReset | Reset/confirm active heatpump fault code (e.g. H72). Equivalent to pressing "Reset" on the CZ-TAW1 remote / indoor unit panel. Writes byte 8 of the outgoing query. Clears latched errors that soft power-cycle (`SetHeatpump` 0→1) cannot clear. | 0=no action, 1=reset +SET49 | SetSterilizationTemp | Set DHW sterilization target temperature; experimental, L-series write acceptance is hardware-verified | integer 55-75 (degrees C) +SET50 | SetSterilizationMaxTime | Set DHW sterilization holding time after reaching target temperature; experimental, L-series write acceptance is hardware-verified | 5, 10, 15, ..., 60 minutes; other values are rejected without sending a command *If you operate your heatpump in water mode with direct temperature setup: topics ending xxxRequestTemperature will set the absolute target temperature.* From 51ca7628fad2e3da8e7583a560575f51c2676ecf Mon Sep 17 00:00:00 2001 From: Peter Kretz Date: Sun, 11 Oct 2026 14:33:38 +0200 Subject: [PATCH 2/5] feat: expose documented heat pump settings and controls over MQTT Add 26 readback topics (TOP144-TOP169) for documented fields in the normal heat pump data block, including SG Ready capacity settings, concrete drying, heater configuration, actual operating states, zone activity and room/DHW operation time limits. Add five commands for automatic heat/cool switching temperatures, room and DHW operation time limits, and quiet schedule activation. Validate complete integer payloads before constructing a telegram; reject malformed, out-of-range and invalid-step inputs without sending a command. Preserve existing topic IDs and reuse the shared decoder and command tables so the additions are also available through HTTP and rules. Document byte mappings, payload encodings, readback topics and the distinction between protocol ranges and model-specific menu limits. Leave uncertain protocol fields and zone sensor configuration unchanged. Validation: - 78 setter/dispatch cases passed - 26 decoder/MQTT cases passed - 24 rules regression scenarios passed - ESP8266 build passed with core 3.1.2 - ESP32-S3 build passed with core 3.3.11 New writes follow documented telegram examples; hardware acceptance and model-specific menu limits remain to be verified. --- HeishaMon/commands.cpp | 48 ++++++++++ HeishaMon/commands.h | 10 +++ HeishaMon/decode.cpp | 21 +++++ HeishaMon/decode.h | 117 ++++++++++++++++++++++++- MQTT-Topics.md | 38 ++++++++ ProtocolByteDecrypt.md | 44 ++++++---- Tools/test_commands.py | 140 ++++++++++++++++++++++++++++++ Tools/test_decode.py | 193 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 591 insertions(+), 20 deletions(-) create mode 100644 Tools/test_commands.py create mode 100644 Tools/test_decode.py diff --git a/HeishaMon/commands.cpp b/HeishaMon/commands.cpp index 0e28e023..5a05312c 100644 --- a/HeishaMon/commands.cpp +++ b/HeishaMon/commands.cpp @@ -313,6 +313,54 @@ unsigned int set_force_defrost(char *msg, unsigned char *cmd, char *log_msg) { return sizeof(panasonicSendQuery); } +// Validate the complete payload before touching the command buffer. The limits +// below describe the nonzero wire encoding; the heat pump may impose narrower +// model-specific installer-menu limits. +static unsigned int set_integer_field(char *msg, unsigned char *cmd, char *log_msg, + byte address, int minimum, int maximum, + int step, int divisor, int offset, int multiplier) { + const size_t length = msg == NULL ? 0 : strlen(msg); + bool valid = length > 0 && length <= 5; + size_t index = valid && msg[0] == '-' ? 1 : 0; + const bool negative = index == 1; + valid = valid && index < length; + int value = 0; + for (; valid && index < length; ++index) { + valid = msg[index] >= '0' && msg[index] <= '9'; + if (valid) value = value * 10 + msg[index] - '0'; + } + if (negative) value = -value; + if (!valid || value < minimum || value > maximum || value % step != 0) { + snprintf_P(log_msg, 256, PSTR("Invalid setting for byte %u: expected integer %d..%d, step %d"), + address, minimum, maximum, step); + return 0; + } + memcpy_P(cmd, panasonicSendQuery, sizeof(panasonicSendQuery)); + cmd[address] = (value / divisor + offset) * multiplier; + snprintf_P(log_msg, 256, PSTR("set byte %u to value %d"), address, value); + return sizeof(panasonicSendQuery); +} + +unsigned int set_heat_to_cool_temp(char *msg, unsigned char *cmd, char *log_msg) { + return set_integer_field(msg, cmd, log_msg, 95, -127, 127, 1, 1, 128, 1); +} + +unsigned int set_cool_to_heat_temp(char *msg, unsigned char *cmd, char *log_msg) { + return set_integer_field(msg, cmd, log_msg, 96, -127, 127, 1, 1, 128, 1); +} + +unsigned int set_room_operation_max_time(char *msg, unsigned char *cmd, char *log_msg) { + return set_integer_field(msg, cmd, log_msg, 97, 30, 7620, 30, 30, 1, 1); +} + +unsigned int set_dhw_heatup_max_time(char *msg, unsigned char *cmd, char *log_msg) { + return set_integer_field(msg, cmd, log_msg, 98, 1, 254, 1, 1, 1, 1); +} + +unsigned int set_quiet_mode_schedule(char *msg, unsigned char *cmd, char *log_msg) { + return set_integer_field(msg, cmd, log_msg, 7, 0, 1, 1, 1, 1, 64); +} + unsigned int set_sterilization_temp(char *msg, unsigned char *cmd, char *log_msg) { if (msg == NULL || strlen(msg) != 2 || msg[0] < '0' || msg[0] > '9' || msg[1] < '0' || msg[1] > '9') { snprintf_P(log_msg, 256, PSTR("Invalid sterilization temperature: expected integer 55-75 C")); diff --git a/HeishaMon/commands.h b/HeishaMon/commands.h index cfa2c74c..0cb78022 100644 --- a/HeishaMon/commands.h +++ b/HeishaMon/commands.h @@ -77,6 +77,11 @@ unsigned int set_dhw_sensor_selection(char *msg, unsigned char *cmd, char *log_m unsigned int set_dhw_heater_state(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_room_heater_state(char *msg, unsigned char *cmd, char *log_msg); unsigned int set_heater_on_outdoor_temp(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_heat_to_cool_temp(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_cool_to_heat_temp(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_room_operation_max_time(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_dhw_heatup_max_time(char *msg, unsigned char *cmd, char *log_msg); +unsigned int set_quiet_mode_schedule(char *msg, unsigned char *cmd, char *log_msg); //optional pcb commands unsigned int set_heat_cool_mode(char *msg, char *log_msg); @@ -178,6 +183,11 @@ const cmdStruct commands[] PROGMEM = { { "SetDHWHeaterState", set_dhw_heater_state }, { "SetRoomHeaterState", set_room_heater_state }, { "SetHeaterOnOutdoorTemp", set_heater_on_outdoor_temp }, + { "SetHeatToCoolTemp", set_heat_to_cool_temp }, + { "SetCoolToHeatTemp", set_cool_to_heat_temp }, + { "SetRoomOperationMaxTime", set_room_operation_max_time }, + { "SetDHWHeatupMaxTime", set_dhw_heatup_max_time }, + { "SetQuietModeSchedule", set_quiet_mode_schedule }, }; struct optCmdStruct{ diff --git a/HeishaMon/decode.cpp b/HeishaMon/decode.cpp index 4d341ad4..dd65221c 100644 --- a/HeishaMon/decode.cpp +++ b/HeishaMon/decode.cpp @@ -9,6 +9,27 @@ unsigned long lastalldatatime = 0; unsigned long lastallextradatatime = 0; unsigned long lastalloptdatatime = 0; +String getRawByte(byte input) { + return String(input); +} + +String getHeaterCapacity(byte input) { + int encoded = (input >> 2) & 0b11; + return String(encoded == 0 ? -1 : encoded * 3); +} + +String getIntMinus1Times30(byte input) { + return String(((int)input - 1) * 30); +} + +String getZone2Active(byte input) { + return String((input >> 1) & 1); +} + +String getZone1Active(byte input) { + return String(input & 1); +} + String getBit1(byte input) { return String(input >> 7); } diff --git a/HeishaMon/decode.h b/HeishaMon/decode.h index b88c2024..3a0ca558 100644 --- a/HeishaMon/decode.h +++ b/HeishaMon/decode.h @@ -14,6 +14,11 @@ void decode_heatpump_data(char* data, char* actData, PubSubClient &mqtt_client, void decode_heatpump_data_extra(char* data, char* actDataExtra, PubSubClient &mqtt_client, void (*log_message)(char*), char* mqtt_topic_base, unsigned int updateAllTime); void decode_optional_heatpump_data(char* data, char* actOptDat, PubSubClient &mqtt_client, void (*log_message)(char*), char* mqtt_topic_base, unsigned int updateAllTime); +String getRawByte(byte input); +String getHeaterCapacity(byte input); +String getIntMinus1Times30(byte input); +String getZone2Active(byte input); +String getZone1Active(byte input); String unknown(byte input); String getBit1(byte input); String getBit1and2(byte input); @@ -42,7 +47,7 @@ static const char _unknown[] PROGMEM = "unknown"; -#define NUMBER_OF_TOPICS 144 //last topic number + 1 +#define NUMBER_OF_TOPICS 170 //last topic number + 1 #define NUMBER_OF_TOPICS_EXTRA 6 //last topic number + 1 #define NUMBER_OF_OPT_TOPICS 7 //last topic number + 1 #define MAX_TOPIC_LEN 42 // max length + 1 @@ -220,6 +225,32 @@ static const char topics[][MAX_TOPIC_LEN] PROGMEM = { "Quiet_Mode_Priority", //TOP141 "Expansion_Valve", //TOP142 "DHW_Sensor_Selection", //TOP143 + "Dry_Concrete_State", //TOP144 + "DHW_Capacity_Mode", //TOP145 + "Zone_Configuration", //TOP146 + "Internal_Heater_Capacity", //TOP147 + "DHW_Heater_Type", //TOP148 + "SG_Ready_Control", //TOP149 + "Demand_Control", //TOP150 + "DHW_Defrost", //TOP151 + "Dry_Concrete_Target_Temp", //TOP152 + "Pool_Delta", //TOP153 + "SG_Ready_1_Heat_Capacity", //TOP154 + "SG_Ready_1_DHW_Capacity", //TOP155 + "SG_Ready_2_Heat_Capacity", //TOP156 + "SG_Ready_2_DHW_Capacity", //TOP157 + "Room_Operation_Max_Time", //TOP158 + "DHW_Heatup_Max_Time", //TOP159 + "Quiet_Mode_Actual_State", //TOP160 + "Powerful_Mode_Actual_State", //TOP161 + "Heat_Cool_Switch_State", //TOP162 + "External_Switch_State", //TOP163 + "Bivalent_Boiler_Contact_State", //TOP164 + "Z2_Active", //TOP165 + "Z1_Active", //TOP166 + "Backup_Heater_Enable", //TOP167 + "Cooling_Enable", //TOP168 + "Heat_Request_Temp_With_Buffer", //TOP169 }; static const byte topicBytes[] PROGMEM = { //can store the index as byte (8-bit unsigned humber) as there aren't more then 255 bytes (actually only 203 bytes) to decode @@ -367,6 +398,32 @@ static const byte topicBytes[] PROGMEM = { //can store the index as byte (8-bit 11, //TOP141 175, //TOP142 11, //TOP143 + 5, //TOP144 + 9, //TOP145 + 21, //TOP146 + 25, //TOP147 + 25, //TOP148 + 27, //TOP149 + 27, //TOP150 + 30, //TOP151 + 46, //TOP152 + 58, //TOP153 + 71, //TOP154 + 72, //TOP155 + 73, //TOP156 + 74, //TOP157 + 97, //TOP158 + 98, //TOP159 + 110, //TOP160 + 110, //TOP161 + 110, //TOP162 + 110, //TOP163 + 112, //TOP164 + 117, //TOP165 + 117, //TOP166 + 120, //TOP167 + 120, //TOP168 + 152, //TOP169 }; @@ -526,6 +583,32 @@ static const topicFP topicFunctions[] PROGMEM = { getBit3and4, //TOP141 getIntMinus1, //TOP142 getBit7and8, //TOP143 + getBit7and8, //TOP144 + getBit3and4, //TOP145 + getRawByte, //TOP146 + getHeaterCapacity, //TOP147 + getBit7and8, //TOP148 + getBit5and6, //TOP149 + getBit7and8, //TOP150 + getBit7and8, //TOP151 + getIntMinus128, //TOP152 + getIntMinus128, //TOP153 + getIntMinus1, //TOP154 + getIntMinus1, //TOP155 + getIntMinus1, //TOP156 + getIntMinus1, //TOP157 + getIntMinus1Times30, //TOP158 + getIntMinus1, //TOP159 + getBit1and2, //TOP160 + getBit3and4, //TOP161 + getBit5and6, //TOP162 + getBit7and8, //TOP163 + getBit1and2, //TOP164 + getZone2Active, //TOP165 + getZone1Active, //TOP166 + getBit3and4, //TOP167 + getBit5and6, //TOP168 + getIntMinus128, //TOP169 }; static const char *DisabledEnabled[] PROGMEM = {"2", "Disabled", "Enabled"}; @@ -567,6 +650,12 @@ static const char *HeatingControl[] PROGMEM = {"2", "Comfort", "Efficiency"}; static const char *SmartDHW[] PROGMEM = {"2", "Variable", "Standard"}; static const char *QuietModePriority[] PROGMEM = {"2", "Sound", "Capacity"}; static const char *DHWSensorType[] PROGMEM = {"2", "Top", "Center"}; +static const char *DHWCapacityMode[] PROGMEM = {"2", "Standard", "Variable"}; +static const char *DHWHeaterType[] PROGMEM = {"2", "Internal", "External"}; +static const char *Kilowatt[] PROGMEM = {"0", "kW"}; +static const char *HeatCoolSwitch[] PROGMEM = {"2", "Heat", "Cool"}; +static const char *ClosedOpen[] PROGMEM = {"2", "Closed", "Open"}; +static const char *ZoneConfiguration[] PROGMEM = {"0", "Configuration code"}; static const char *Steps[] PROGMEM = {"0", "Steps"}; static const char **opttopicDescription[] PROGMEM = { @@ -733,4 +822,30 @@ static const char **topicDescription[] PROGMEM = { QuietModePriority, //TOP141 Steps, //TOP142 DHWSensorType, //TOP143 + OffOn, //TOP144 + DHWCapacityMode, //TOP145 + ZoneConfiguration, //TOP146 + Kilowatt, //TOP147 + DHWHeaterType, //TOP148 + DisabledEnabled, //TOP149 + DisabledEnabled, //TOP150 + DisabledEnabled, //TOP151 + Celsius, //TOP152 + Kelvin, //TOP153 + Percent, //TOP154 + Percent, //TOP155 + Percent, //TOP156 + Percent, //TOP157 + Minutes, //TOP158 + Minutes, //TOP159 + OffOn, //TOP160 + OffOn, //TOP161 + HeatCoolSwitch, //TOP162 + ClosedOpen, //TOP163 + OffOn, //TOP164 + InactiveActive, //TOP165 + InactiveActive, //TOP166 + InactiveActive, //TOP167 + InactiveActive, //TOP168 + Celsius, //TOP169 }; diff --git a/MQTT-Topics.md b/MQTT-Topics.md index 6fe71f83..5a3044b6 100644 --- a/MQTT-Topics.md +++ b/MQTT-Topics.md @@ -160,6 +160,35 @@ TOP140 | main/Smart_DHW | Smart DHW TOP141 | main/Quiet_Mode_Priority | Quiet Mode Priority (0=sound, 1=capacity) TOP142 | main/Expansion_Valve | Expansion Valve (Steps) TOP143 | main/DHW_Sensor_Selection | DHW tank sensor selection (0=Top, 1=Center) (K/L series All-In-One only) +TOP144 | main/Dry_Concrete_State | Concrete drying (0=off, 1=on) +TOP145 | main/DHW_Capacity_Mode | DHW capacity (0=Standard, 1=Variable; J series) +TOP146 | main/Zone_Configuration | Zone configuration (decimal raw code: 21=one zone, Z1 room; 25=one zone, Z1 pool; 22=two zones, Z2 room; 38=two zones, Z2 pool) +TOP147 | main/Internal_Heater_Capacity | Configured internal heater capacity (3/6/9 kW; -1=unavailable) +TOP148 | main/DHW_Heater_Type | DHW heater type (0=internal, 1=external) +TOP149 | main/SG_Ready_Control | SG Ready control (0=disabled, 1=enabled) +TOP150 | main/Demand_Control | Demand control (0=disabled, 1=enabled) +TOP151 | main/DHW_Defrost | DHW defrost option (0=no, 1=yes; K/L series) +TOP152 | main/Dry_Concrete_Target_Temp | Current stage target temperature for concrete drying (°C) +TOP153 | main/Pool_Delta | Pool temperature delta (K) +TOP154 | main/SG_Ready_1_Heat_Capacity | SG Ready setting 1 heating capacity (%) +TOP155 | main/SG_Ready_1_DHW_Capacity | SG Ready setting 1 DHW capacity (%) +TOP156 | main/SG_Ready_2_Heat_Capacity | SG Ready setting 2 heating capacity (%) +TOP157 | main/SG_Ready_2_DHW_Capacity | SG Ready setting 2 DHW capacity (%) +TOP158 | main/Room_Operation_Max_Time | Maximum room operation time during alternating room/DHW operation (minutes) +TOP159 | main/DHW_Heatup_Max_Time | Maximum DHW heat-up time (minutes) +TOP160 | main/Quiet_Mode_Actual_State | Actual quiet mode state (0=off, 1=on) +TOP161 | main/Powerful_Mode_Actual_State | Actual powerful mode state (0=off, 1=on) +TOP162 | main/Heat_Cool_Switch_State | Actual heat/cool switch state (0=heat, 1=cool) +TOP163 | main/External_Switch_State | Actual external switch state (0=closed, 1=open) +TOP164 | main/Bivalent_Boiler_Contact_State | Actual bivalent boiler contact state (0=off, 1=on) +TOP165 | main/Z2_Active | Actual zone 2 activity (0=inactive, 1=active) +TOP166 | main/Z1_Active | Actual zone 1 activity (0=inactive, 1=active) +TOP167 | main/Backup_Heater_Enable | Backup heater custom-menu setting (0=inactive, 1=active) +TOP168 | main/Cooling_Enable | Cooling custom-menu setting (0=inactive, 1=active) +TOP169 | main/Heat_Request_Temp_With_Buffer | Heating water shift/direct target including buffer delta (°C) + +The topics above are relative to the configured MQTT base topic. New topics TOP144–TOP169 use the documented normal data block (0x10); availability depends on the heat pump model and configuration. Two-bit status fields use -1 for an unset encoding (00). Z1_Active and Z2_Active are individual activity bits, distinct from Zones_State. Quiet/Powerful actual states are distinct from the configured level/time topics. + @@ -253,6 +282,15 @@ SET47 | SetForceHeater | Force heater mode (emergency heating), same as the heat SET48 | SetReset | Reset/confirm active heatpump fault code (e.g. H72). Equivalent to pressing "Reset" on the CZ-TAW1 remote / indoor unit panel. Writes byte 8 of the outgoing query. Clears latched errors that soft power-cycle (`SetHeatpump` 0→1) cannot clear. | 0=no action, 1=reset SET49 | SetSterilizationTemp | Set DHW sterilization target temperature; experimental, L-series write acceptance is hardware-verified | integer 55-75 (degrees C) SET50 | SetSterilizationMaxTime | Set DHW sterilization holding time after reaching target temperature; experimental, L-series write acceptance is hardware-verified | 5, 10, 15, ..., 60 minutes; other values are rejected without sending a command +SET51 | SetHeatToCoolTemp | Outdoor temperature for automatic heat-to-cool switching; readback TOP79 | integer -127..127 °C (wire range) +SET52 | SetCoolToHeatTemp | Outdoor temperature for automatic cool-to-heat switching; readback TOP80 | integer -127..127 °C (wire range) +SET53 | SetRoomOperationMaxTime | Maximum room heating operation time when alternating with DHW; readback TOP158 | 30..7620 minutes, multiples of 30 (wire range) +SET54 | SetDHWHeatupMaxTime | Maximum DHW heat-up time; readback TOP159 | integer 1..254 minutes (wire range) +SET55 | SetQuietModeSchedule | Enable/disable the existing quiet-mode schedule; readback TOP3 | 0=off, 1=on + +SET51–SET55 require complete integer payloads; empty, malformed, out-of-range or invalid-step values are rejected without sending a telegram. The ranges above describe the nonzero protocol encoding, not verified installer-menu limits. Use settings supported by your model; the heat pump may reject values outside its own menu range. These writes follow the documented telegram examples but have not been hardware-tested here. SetQuietModeSchedule toggles the existing schedule and does not configure its times or change the quiet level. + +Example: publish `21` to `panasonic_heat_pump/commands/SetHeatToCoolTemp`, `90` to `panasonic_heat_pump/commands/SetRoomOperationMaxTime`, or `1` to `panasonic_heat_pump/commands/SetQuietModeSchedule` (replace the base topic as configured). *If you operate your heatpump in water mode with direct temperature setup: topics ending xxxRequestTemperature will set the absolute target temperature.* diff --git a/ProtocolByteDecrypt.md b/ProtocolByteDecrypt.md index 7e9e47e8..6091c1cc 100644 --- a/ProtocolByteDecrypt.md +++ b/ProtocolByteDecrypt.md @@ -1,5 +1,9 @@ ## Protocol byte decrypt info: +Byte numbers are decimal; example values are hexadecimal. For the documented bit-pair fields, bits 1–2 are the most significant pair (mask 0xC0), bits 3–4 use 0x30, bits 5–6 use 0x0C, and bits 7–8 use 0x03. The individual zone-activity bits in byte 117 use masks 0x02 (Z2) and 0x01 (Z1). + +TOP144–TOP169 expose the additional documented fields below; see MQTT-Topics.md for topic names and payloads. Byte 21 is published as its decimal configuration code, without inferring undocumented bit fields. Byte 60 (external-heater time), byte 176 (possible bypass valve), byte 191 (meaning of heat-pump power), and byte 192 (possible T-CAP indicator) remain unexposed pending verification. The conflicting RC-2 offsets (documented byte 200 versus existing decoder byte 128) are unchanged. + | Topic# | Byte# | Possible Value | Value decrypt | Value Description | | :---- | ---- | ---- | ----- | ----:| | | 00 | 71 | | Header | @@ -7,11 +11,11 @@ | | 02 | 01| | Header | | | 03 | 10 | | Header | | TOP0+TOP2 | 04 | 56 | Force DHW status 56=off,96=on, 55 = heat pump off, 56= heat pump on, Service Setup: Water pump on=65, Air Purge=75, Pump Down=F0?| Force dhw, Heat pump on/off, Service setup (Water Flow, Air Purge , Pump Down) | -| TOP19+TOP13+TOP68 | 05 | 55 | Holiday mode off/on (bit3and4), weekly shedule off/on (bit 1and2) force heater off/on (bit5and6) Dry Concrete off/on (bit7and8) | Holiday mode, Sheduler status, force heater state , Dry Concrete | +| TOP19+TOP13+TOP68+TOP144 | 05 | 55 | Holiday mode off/on (bit3and4), weekly shedule off/on (bit 1and2) force heater off/on (bit5and6) Dry Concrete off/on (bit7and8) | Holiday mode, Sheduler status, force heater state , Dry Concrete | | TOP94+TOP4 | 06 | 62 | 1st Bit = Zone2
2nd Bit = Zone1
3rd & 4th bit = b01 DHW off ,b10 DHW on
5th ,6th,7th & 8th bit = b0001 - only DHW , b0010 - Heat , b0011 - Cool , b1001 - Auto(Heat) , b1010 - Auto(Cool) | Zone on/off
Mode status | | TOP18+TOP17 | 07 | 49 | Left 5 bits = quiet level (0b01001 = Off, 0b01010 = level 1, 0b01011 = level 2, 0b01100 - level 3, 0b10001 = scheduled) last 3 bits is powermode level (0b001= Off, 0b010 - power mode 30min, 0b011 -60min, 0b100-90 min) | Quiet Mode status + Powerful mode status | | TOP | 08 | 00 | | 0 byte | -| TOP58+TOP59 | 09 | 05 | 3rd & 4th bit = b01 Standard, b10 - DHW Standard/Variable (J-series only)
5th & 6th bit = b01 DHW heater off, b10 - DHW heater on
7rd & 8th bit = b01 Water heater off, b10 - Water heater on | DHW capacity (J-series only)
Heaters enable allowed status| +| TOP58+TOP59+TOP145 | 09 | 05 | 3rd & 4th bit = b01 Standard, b10 - DHW Standard/Variable (J-series only)
5th & 6th bit = b01 DHW heater off, b10 - DHW heater on
7rd & 8th bit = b01 Water heater off, b10 - Water heater on | DHW capacity (J-series only)
Heaters enable allowed status| | TOP | 10 | 00 | | 0 byte | | TOP141+TOP143 | 11 | 00 | 3rd & 4th bit = b01 - Sound , b10 - Capacity
7th & 8th bit = b01 - DHW Top sensor , b10 - DHW Center Sensor | Quiet Mode Priority (K/L series)
DHW Sensor Selection (K/L series All-In-One only) | | TOP | 12 | 00 | | 0 byte | @@ -23,16 +27,16 @@ | TOP | 18 | 00 | | 0 byte | | TOP | 19 | 00 | | 0 byte | | TOP107/108/109/110 | 20 | 19 | 1st Bit = b0 Water , b1 Glycol
3rd & 4th bit = b01 Alternative Sensor Off ,b10 Alternative Sensor On
5rd & 6th bit = b01 Antifreezing Off ,b10 Antifreezing on
7rd & 8th bit = b01 Optional PCB Off ,b10 Optional PCB on
| Circulation liquid
,Alternative outdoor temp sensor
Anti freezing
Optional PCB | -| TOP | 21 | 15 | (hex) 15 - One Zone and Z1 as room , 19 - One Zone and Z1 as pool, 16 - Two Zones and Z2 as room, 26 - Two Zones ,Z2 as pool| No. of Zones and Zone Destination | +| TOP146 | 21 | 15 | (hex) 15 - One Zone and Z1 as room , 19 - One Zone and Z1 as pool, 16 - Two Zones and Z2 as room, 26 - Two Zones ,Z2 as pool| No. of Zones and Zone Destination | | TOP111+TOP112 | 22 | 11 |First digit -Z2 ,Second digit Z1 (hex) 1 - water temperature,2 - External Thermostat, 3 - Internal Thermostat, , 4 - Thermistor | Zone & sensor settings ( system setup - Installer ) | | TOP119/120/121/122 | 23 | 55 | 1st/2nd bit = external compressor control, 3rd/4th bit = external error signal, 5th/6h bit = heat/cool-switch, 7th/8th bit = external control switch | External control, heat/cool switch, error signal and External compressor control switch (menu settings)| | TOP140+TOP99+TOP100+TOP101 | 24 | 16 | 1st & 2nd bit Smart DHW (L series) (b01=Variable, b10=Standard)
3rd & 4th bit = solar buffer (0b01=no solar, 0b10=solar buffer, 0b11=solar dhw)
5th & 6th bit = buffer installed
7th & 8th bit = DHW installed | -| TOP114 | 25 | 5e | 1st & 2nd bit = 10
3rd & 4th bit = b01 no Pad Heater, b10 - Type A, b11 Type B
5th & 6th bit = b01 - Internal Heater 3kW, b10 - 6kW, b11 - 9kW
7th & 8th bit = b01 DHW Internal Heater , b10 - DHW External Heater | External Pad Heater
Power of internal heater
DHW heater Internal/External | +| TOP114+TOP147+TOP148 | 25 | 5e | 1st & 2nd bit = 10
3rd & 4th bit = b01 no Pad Heater, b10 - Type A, b11 Type B
5th & 6th bit = b01 - Internal Heater 3kW, b10 - 6kW, b11 - 9kW
7th & 8th bit = b01 DHW Internal Heater , b10 - DHW External Heater | External Pad Heater
Power of internal heater
DHW heater Internal/External | | TOP129/130/13/132 | 26 | 55 | (hex) Bivalent control, mode and ap settings. | Bivalent settings | -| TOP | 27 | 05 | SG Ready Control on/off (bit5and6) ,Demand Control on/off (bit7and8) | SG Ready Control, Demand Control | +| TOP149+TOP150 | 27 | 05 | SG Ready Control on/off (bit5and6) ,Demand Control on/off (bit7and8) | SG Ready Control, Demand Control | | TOP76+TOP81 | 28 | 09 | (hex) 09 - Compensation curve heat and direct cool, 05 - both compensation curves , 0a - direct heat and direct cool, 06 - heat direct, cool compensation curve | Operation Setup -Installer -water temperature heating on status and cooling | | TOP106 | 29 | 00 | 3rd & 4th bit = b01 - deltaT , b10 - Max. Duty | Pump flowrate (J/K/L series) | -| TOP139 | 30 | 00 | 5th & 6th bit = Heating Control (b01=Comfort, b10=Efficiency)
7th & 8th bit = DHW Defrost (b01=No, b10=Yes) | Heating Control (K/L series)
DHW Defrost (K/L series) | +| TOP139+TOP151 | 30 | 00 | 5th & 6th bit = Heating Control (b01=Comfort, b10=Efficiency)
7th & 8th bit = DHW Defrost (b01=No, b10=Yes) | Heating Control (K/L series)
DHW Defrost (K/L series) | | TOP | 31 | 00 | | 0 byte | | TOP | 32 | 00 | | 0 byte | | TOP | 33 | 00 | | 0 byte | @@ -48,7 +52,7 @@ | TOP45 | 43 | 71 | Convert to DEC 113-128 =-15 | Heat Shift for Holiday mode [°K] | | TOP25 | 44 | 71 | Convert to DEC 113-128 =-15 | Heat Shift for DHW mode [°K] | | TOP95 | 45 | 97 | (hex) 96 = 97 | Maximum set pump speed | -| TOP | 46 | 99 | Convert to DEC 153-128 = 25 | Dry concrete target temperature for actual stage [°C] | +| TOP152 | 46 | 99 | Convert to DEC 153-128 = 25 | Dry concrete target temperature for actual stage [°C] | | TOP | 47 | 00 | | 0 byte | | TOP | 48 | 00 | | 0 byte | | TOP | 49 | 00 | | 0 byte | @@ -60,7 +64,7 @@ | TOP | 55 | 00 | | 0 byte | | TOP | 56 | 00 | | 0 byte | | TOP | 57 | 00 | | 0 byte | -| TOP | 58 | 80 | Convert to DEC-128 | Delta T for Pool [°K] | +| TOP153 | 58 | 80 | Convert to DEC-128 | Delta T for Pool [°K] | | TOP113 | 59 | 85 | Convert to DEC-128 | Delta T for buffer tank [°K]| | TOP | 60 | 15 | Convert to DEC X-1 | Time set for external heaters 20min-3h, step 5min. | | TOP102 | 61 | 8a | Convert to DEC-128 | Solar Connection Set delta T for tank ON (DHW or Buffer) | @@ -73,10 +77,10 @@ | TOP136 | 68 | 7e | Convert to DEC-128 | Control pattern in Bivalent set temperature source to stop the bivalent heat source | | TOP137 | 69 | 1f | Convert to DEC X-1 | Bivalent Delay timer to stop the bivalent heat source | | TOP138 | 70 | 1f | Convert to DEC X-1 | Bivalent Control pattern for DHW delay timer to start the bivalent source | -| TOP | 71 | 79 | Convert to DEC X-1 |SG Setting 1 Heating Capacity | -| TOP | 72 | 79 | Convert to DEC X-1 |SG Setting 1 DHW Capacity | -| TOP | 73 | 8d | Convert to DEC X-1 |SG Setting 2 Heating Capacity | -| TOP | 74 | 8d | Convert to DEC X-1 |SG Setting 2 DHW Capacity | +| TOP154 | 71 | 79 | Convert to DEC X-1 |SG Setting 1 Heating Capacity | +| TOP155 | 72 | 79 | Convert to DEC X-1 |SG Setting 1 DHW Capacity | +| TOP156 | 73 | 8d | Convert to DEC X-1 |SG Setting 2 Heating Capacity | +| TOP157 | 74 | 8d | Convert to DEC X-1 |SG Setting 2 DHW Capacity | | TOP29 | 75 | 9e | Convert to DEC 158-128 =30 | Z1 Heating Curve Outlet Water Temperature Highest Set [°C] | | TOP30 | 76 | 96 | Convert to DEC 150-128 =22 | Z1 Heating Curve Outlet Water Temperature Lowest Set [°C] | | TOP32 | 77 | 71 | Convert to DEC 113-128 =-15 | Z1 Heating Curve Outside Temperature Lowest Set [°C] | @@ -99,8 +103,8 @@ | TOP24 | 94 | 82 | Convert to DEC 130-128 =2 | Floor cooling set delta [°C] | | TOP79 | 95 | 90 | Convert to DEC 144-128=16| Outdoor temperature for (heat to cool) [°C] | | TOP80 | 96 | 8b | Convert to DEC 139-128=11| Outdoor temperature for (cool to heat) [°C] | -| TOP | 97 | 05 | Donvert to DEC (X-1) x 30 | DHW settings - Room operation max time [min] | -| TOP | 98 | 65 | Convert to DEC 101-1=100 | DHW heat up time (max) [min] | +| TOP158 | 97 | 05 | Donvert to DEC (X-1) x 30 | DHW settings - Room operation max time [min] | +| TOP159 | 98 | 65 | Convert to DEC 101-1=100 | DHW heat up time (max) [min] | | TOP22 | 99 | 78 | Convert to DEC 120-128=-8 | DHW Delta for re-heat [°K] | | TOP70 | 100 | c1 | Convert to DEC 193-128=65 | Sterilization boiling temperature [°C] | | TOP71 | 101 | 0b | Convert to DEC 11 - 1 = 10 | Sterilization max operation time [min] | @@ -112,17 +116,17 @@ | TOP | 107 | 00 | | 0 byte | | TOP | 108 | 00 | | 0 byte | | TOP | 109 | 00 | | 0 byte | -| TOP | 110 | 55 | 1st & 2nd bit - Quiet mode
3rd&4th bit - Powerful
5th & 6th bit - Heat-Cool SW b10 - Cool
7th & 8th bit - External SW b10-Open | Actual/Real states of
Quiet
Powerful
Heat-Cool SW
External SW | +| TOP160+TOP161+TOP162+TOP163 | 110 | 55 | 1st & 2nd bit - Quiet mode
3rd&4th bit - Powerful
5th & 6th bit - Heat-Cool SW b10 - Cool
7th & 8th bit - External SW b10-Open | Actual/Real states of
Quiet
Powerful
Heat-Cool SW
External SW | | TOP20+TOP26 | 111 | 56 | right 2 bits: 0b10=DHW 0b01=Room 3-Way Valve. Next 2 bits (from right) is defrosting state (0b01 = defrosting not active, 0b10 = defrosting active) | 3 way valve + Defrost status | -| TOP60+TOP61 | 112 | 55 | 1st & 2nd bit - Boiler Contact
5th & 6th bit - External Heater
7th & 8th bit - Internal Heater (room or DHW)
( b01 - OFF, b10 - ON )| Real status of Bivalent Boiler contact and Heaters relays (Line under icons) | +| TOP60+TOP61+TOP164 | 112 | 55 | 1st & 2nd bit - Boiler Contact
5th & 6th bit - External Heater
7th & 8th bit - Internal Heater (room or DHW)
( b01 - OFF, b10 - ON )| Real status of Bivalent Boiler contact and Heaters relays (Line under icons) | | TOP44 | 113 | 21 | Hex B1 - F type error, A1 - H type error. After H error reset value 21, F error reset 31 | Error code type | | TOP44 | 114 | 53 | F45 error in HEX 56, calulation 45 treat as HEX and convert to DEC 69 + 17 = 86 (Hex 56) | Error code number | | TOP | 115 | 15 | | ? | | TOP123/124/125/126 | 116 | 5a | 1st & 2nd bit Zone 2 Pump ( b01 - OFF, b10 - ON ) , 3rd & 4th bit = Zone 1 Pump ( b01 - OFF, b10 - ON ), 5th & 6th bit = 2way Valve ( b01 - Cooling, b10 - Heating) , 7th & 8th bit = 3way Valve ( b01 - Room, b10 - Tank) | Z1 & Z2 Pump 2way & 3wa Valve staus | -| TOP69 | 117 | 05 | Sterilization on/off (bit5and6) , Z2 active (bit7) ,Z1 active (bit8)| Sterilization status Zone active information (look byte #6) | +| TOP69+TOP165+TOP166 | 117 | 05 | Sterilization on/off (bit5and6) , Z2 active (bit7) ,Z1 active (bit8)| Sterilization status Zone active information (look byte #6) | | TOP | 118 | 12 | | fractional info for TOP5 and TOP6 values | | TOP | 119 | 12 | | ? | -| TOP | 120 | 19 | 3rd & 4th bit = b01 Heater Inactive ,b10 Heater Active
5th & 6th bit = b01 Cooling Inactive ,b10 Cooling Active
| Custom menu settings
Back-up Heater
Cool Mode | +| TOP167+TOP168 | 120 | 19 | 3rd & 4th bit = b01 Heater Inactive ,b10 Heater Active
5th & 6th bit = b01 Cooling Inactive ,b10 Cooling Active
| Custom menu settings
Back-up Heater
Cool Mode | | TOP | 121 | 00 | | 0 byte | | TOP | 122 | 00 | | 0 byte | | TOP | 123 | 00 | | 0 byte | @@ -154,7 +158,7 @@ | TOP46 | 149 | 32 | Convert to DEC-128 | Buffer: Water Temperature [°C] | | TOP47 | 150 | 32 | Convert to DEC-128 | Solar: Water Temperature [°C] | | TOP48 | 151 | 32 | Convert to DEC-128| Pool: Water Temperature [°C] | -| TOP | 152 | 80 | Convert to DEC 128-128 = 0 | Water shift set or direct mode value Temperature For Heat Mode [°C] including Delta T for Buffer | +| TOP169 | 152 | 80 | Convert to DEC 128-128 = 0 | Water shift set or direct mode value Temperature For Heat Mode [°C] including Delta T for Buffer | | TOP7 | 153 | b7 | Convert to DEC-128 | Outlet Water Temperature (Target) [°C] | | TOP49 | 154 | af | Convert to DEC-128 | Outlet 2 heat exchanger water temperature [°C] | | TOP50 | 155 | cd | Convert to DEC-128 | Discharge Temperature [°C] | @@ -828,6 +832,8 @@ d0 7b 78 1f 7e 1f 1f 79 79 8d 8d 00 00 00 00 00 Operation Setup --> Installer +MQTT setters for the examples below: `SetHeatToCoolTemp` writes byte 95 as temperature + 128; `SetCoolToHeatTemp` writes byte 96 with the same encoding; `SetRoomOperationMaxTime` writes byte 97 as minutes / 30 + 1; `SetDHWHeatupMaxTime` writes byte 98 as minutes + 1. `SetQuietModeSchedule` writes byte 7 as 0x40 (off) or 0x80 (on), leaving the quiet-level and powerful-mode fields unset. Byte offsets include the four header bytes. See MQTT-Topics.md for validation and supported wire ranges. + Water temperature for heating on Compensationio Curve `f1 6c 01 10 00 00 00 00 00 00 00 00 00 00 00 00 diff --git a/Tools/test_commands.py b/Tools/test_commands.py new file mode 100644 index 00000000..c237ac3f --- /dev/null +++ b/Tools/test_commands.py @@ -0,0 +1,140 @@ +"""Test the new setters and real command dispatch with g++ on the host. + +Run: python3 Tools/test_commands.py +""" + +import re +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SETTERS = { + "SetHeatToCoolTemp": ( + 95, + [("21", 149), ("-127", 1), ("127", 255), ("0", 128)], + ["-128", "128"], + ), + "SetCoolToHeatTemp": ( + 96, + [("12", 140), ("-127", 1), ("127", 255)], + ["-128", "128"], + ), + "SetRoomOperationMaxTime": ( + 97, + [("90", 4), ("30", 2), ("7620", 255)], + ["0", "29", "31", "7650"], + ), + "SetDHWHeatupMaxTime": ( + 98, + [("105", 106), ("1", 2), ("254", 255)], + ["0", "255", "-1"], + ), + "SetQuietModeSchedule": (7, [("0", 64), ("1", 128)], ["2", "-1"]), +} + +SHIM = r""" +#include +#include +#include +#include +using byte = unsigned char; +#define PROGMEM +#define memcpy_P memcpy +#define snprintf_P snprintf +#define PSTR(x) x +std::vector sent; +int send_count; +bool send_command(byte* data, int length) { + ++send_count; sent.assign(data, data + length); return true; +} +void log_message(char*) {} +""" + + +def main(): + header = (ROOT / "HeishaMon/commands.h").read_text() + source = (ROOT / "HeishaMon/commands.cpp").read_text() + docs = (ROOT / "MQTT-Topics.md").read_text() + additions = source[ + source.index("static unsigned int set_integer_field") : source.index( + "unsigned int set_sterilization_temp" + ) + ] + real_functions = set(re.findall(r"unsigned int (\w+)\(", additions)) + stubs = [] + for name, args in re.findall(r"unsigned int (set_\w+)\(([^;]+)\);", header): + if name not in real_functions: + stubs.append(f"unsigned int {name}({args}) {{ return 0; }}") + template = re.search(r"byte panasonicSendQuery\[\].*?\};", source, re.S)[0] + dispatcher = source[ + source.index("void send_heatpump_command(") : source.index( + "bool saveOptionalPCB(" + ) + ] + header = re.sub(r"^#include.*$", "", header, flags=re.M) + checks = [] + cases = 0 + for topic, (address, accepted, rejected) in SETTERS.items(): + assert f"| {topic} |" in docs + function = re.search(r'\{\s*"' + topic + r'",\s*(\w+)\s*\}', header)[1] + for payload, encoded in accepted: + checks.append( + f""" + {{ char topic[] = "{topic}", payload[] = "{payload}"; + sent.clear(); send_count = 0; + send_heatpump_command(topic, payload, send_command, log_message, false); + assert(send_count == 1 && sent.size() == sizeof(panasonicSendQuery)); + for (unsigned int i = 0; i < sent.size(); ++i) + assert(sent[i] == (i == {address} ? {encoded} : panasonicSendQuery[i])); + }} +""" + ) + cases += 1 + for payload in rejected + [ + "", + "abc", + "1x", + "1.0", + " 1", + "+1", + "1 ", + "-", + "999999999999999", + ]: + checks.append( + f""" + {{ char topic[] = "{topic}", payload[] = "{payload}"; + sent.clear(); send_count = 0; + send_heatpump_command(topic, payload, send_command, log_message, false); + assert(send_count == 0 && sent.empty()); + byte command[256]; memset(command, 0xa5, sizeof(command)); + char log[256]; + assert({function}(payload, command, log) == 0); + for (byte value : command) assert(value == 0xa5); + }} +""" + ) + cases += 1 + checks.append( + f""" + {{ byte command[256]; memset(command, 0xa5, sizeof(command)); char log[256]; + assert({function}(nullptr, command, log) == 0); + for (byte value : command) assert(value == 0xa5); + }} +""" + ) + cases += 1 + program = SHIM + header + template + additions + "\n".join(stubs) + dispatcher + program += "\nint main() {\n" + "".join(checks) + "\n}\n" + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / "test.cpp" + binary = Path(directory) / "test" + cpp.write_text(program) + subprocess.run(["g++", "-std=c++17", str(cpp), "-o", str(binary)], check=True) + subprocess.run([str(binary)], check=True) + print(f"PASS: {cases} setter/dispatch cases (bytes, boundaries, rejected payloads)") + + +if __name__ == "__main__": + main() diff --git a/Tools/test_decode.py b/Tools/test_decode.py new file mode 100644 index 00000000..c57ba5d4 --- /dev/null +++ b/Tools/test_decode.py @@ -0,0 +1,193 @@ +"""Compile the real decoder with a small host shim and test MQTT output. + +Run with python3 Tools/test_decode.py (requires g++). +""" + +import os +import re +import subprocess +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + +SHIM = r""" +#include +#include +#include +#include +#include +#include +#include +#include +using byte = unsigned char; +class String { + std::string value; +public: + String() {} + String(const char* s): value(s) {} + template::value, int>::type = 0> + String(T n): value(std::to_string(n)) {} + String(float n, int digits) { + char buf[64]; snprintf(buf, sizeof(buf), "%.*f", digits, n); value = buf; + } + const char* c_str() const { return value.c_str(); } + int toInt() const { return atoi(c_str()); } + String operator+(const char* rhs) const { return String((value + rhs).c_str()); } + bool operator!=(const String& rhs) const { return value != rhs.value; } +}; +#define PROGMEM +#define PSTR(x) x +#define _F(x) x +#define memcpy_P memcpy +#define sprintf_P sprintf +unsigned long millis() { return 1; } +unsigned int word(char high, char low) { + return (static_cast(high) << 8) | static_cast(low); +} +struct Publication { std::string topic, value; bool retained; }; +class PubSubClient { +public: + std::vector messages; + bool publish(const char* topic, const char* value, bool retained) { + messages.push_back({topic, value, retained}); return true; + } +}; +constexpr int DATASIZE = 203, OPTDATASIZE = 20; +byte optionalPCBQuery[20]; +const char* mqtt_topic_values = "main"; +const char* mqtt_topic_xvalues = "extra"; +const char* mqtt_topic_pcbvalues = "optional"; +void websocket_write_all(char*, uint16_t) {} +void rules_event_cb(const char*, const char*) {} +void log_message(char*) {} +""" + +# Captured/documented encodings, including independent fields sharing bytes. +CASES = [ + (144, 5, 0x56, "1"), + (145, 9, 0x25, "1"), + (146, 21, 0x26, "38"), + (147, 25, 0x5E, "9"), + (148, 25, 0x5E, "1"), + (149, 27, 0x09, "1"), + (150, 27, 0x06, "1"), + (151, 30, 0x06, "1"), + (152, 46, 0x99, "25"), + (153, 58, 0x85, "5"), + (154, 71, 0x79, "120"), + (155, 72, 0x79, "120"), + (156, 73, 0x8D, "140"), + (157, 74, 0x8D, "140"), + (158, 97, 0x05, "120"), + (159, 98, 0x65, "100"), + (160, 110, 0x95, "1"), + (161, 110, 0x65, "1"), + (162, 110, 0x59, "1"), + (163, 110, 0x56, "1"), + (164, 112, 0x95, "1"), + (165, 117, 0x06, "1"), + (166, 117, 0x05, "1"), + (167, 120, 0x29, "1"), + (168, 120, 0x19, "1"), + (169, 152, 0x7B, "-5"), +] + + +def main(): + header = (ROOT / "HeishaMon/decode.h").read_text() + source = (ROOT / "HeishaMon/decode.cpp").read_text() + docs = (ROOT / "MQTT-Topics.md").read_text() + count = int(re.search(r"#define NUMBER_OF_TOPICS (\d+)", header)[1]) + tables = {} + for name in ("topics", "topicBytes", "topicFunctions", "topicDescription"): + body = re.search(r"\b" + name + r"\[\].*?=\s*\{(.*?)\};", header, re.S)[1] + tables[name] = [ + line.split("//")[0].strip().rstrip(",") + for line in body.splitlines() + if line.split("//")[0].strip() + ] + assert len(tables[name]) == count, name + assert len(set(tables["topics"])) == count + assert all(len(name.strip('"')) < 42 for name in tables["topics"]) + checks = [] + for topic, address, raw, expected in CASES: + name = tables["topics"][topic].strip('"') + assert f"TOP{topic} | main/{name} |" in docs + checks.append( + f""" + memset(data, 0x55, sizeof(data)); + data[{address}] = {raw}; + assert(std::string(getDataValue(data, {topic}).c_str()) == "{expected}"); + resetlastalldatatime(); client.messages.clear(); + decode_heatpump_data(data, previous, client, log_message, base, 60); + assert(client.messages.size() == NUMBER_OF_TOPICS); + assert(client.messages[{topic}].topic == "test/main/{name}"); + assert(client.messages[{topic}].value == "{expected}"); + assert(client.messages[{topic}].retained); + client.messages.clear(); + decode_heatpump_data(data, previous, client, log_message, base, 60); + assert(client.messages.empty()); +""" + ) + checks.append( + """ + data[152] = 0x80; + client.messages.clear(); + decode_heatpump_data(data, previous, client, log_message, base, 60); + assert(client.messages.size() == 1); + assert(client.messages[0].topic == "test/main/Heat_Request_Temp_With_Buffer"); + assert(client.messages[0].value == "0"); + assert(getHeaterCapacity(0).toInt() == -1); + assert(getHeaterCapacity(4).toInt() == 3); + assert(getHeaterCapacity(8).toInt() == 6); + assert(getIntMinus1Times30(0).toInt() == -30); + assert(getIntMinus1Times30(255).toInt() == 7620); + for (int n = 0; n < 4; ++n) { + assert(getZone1Active(n).toInt() == (n & 1)); + assert(getZone2Active(n).toInt() == ((n >> 1) & 1)); + } + assert(getBit1and2(0).toInt() == -1); + assert(getBit3and4(0).toInt() == -1); + assert(getBit5and6(0).toInt() == -1); + assert(getBit7and8(0).toInt() == -1); +""" + ) + header = re.sub(r"^#include.*$", "", header, flags=re.M) + source = re.sub(r"^#include.*$", "", source, flags=re.M) + program = ( + SHIM + + header + + source + + """ +int main() { + char data[DATASIZE], previous[DATASIZE] = {}, base[] = "test"; + PubSubClient client; +""" + + "".join(checks) + + "\n}\n" + ) + with tempfile.TemporaryDirectory() as directory: + cpp = Path(directory) / "test.cpp" + binary = Path(directory) / "test" + cpp.write_text(program) + subprocess.run( + [ + os.environ.get("CXX", "g++"), + "-std=c++17", + "-funsigned-char", + "-Wno-narrowing", + str(cpp), + "-o", + str(binary), + ], + check=True, + ) + subprocess.run([str(binary)], check=True) + print( + f"PASS: {len(CASES)} decoder/MQTT cases, tables, documentation and boundaries" + ) + + +if __name__ == "__main__": + main() From bf0c083d13a420f8fbfdcf26b95befff26988f5d Mon Sep 17 00:00:00 2001 From: Peter Kretz Date: Tue, 20 Oct 2026 22:14:28 +0200 Subject: [PATCH 3/5] feat(mqtt): publish retained relay state updates Publish both onboard relay states under gpio/relay/{one,two}/state using retained 0/1 payloads. Report states after MQTT relay commands, on MQTT reconnect, and at the configured full-data resend interval. Detect changes made by rules at the end of each main loop and retry failed publications. Document the status topics and clarify that they report GPIO output levels rather than mechanical relay contact feedback. --- HeishaMon/HeishaMon.ino | 4 +++- HeishaMon/gpio.cpp | 31 ++++++++++++++++++++++++++++++- HeishaMon/gpio.h | 4 +++- README.md | 3 +++ README_DE.md | 10 ++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) diff --git a/HeishaMon/HeishaMon.ino b/HeishaMon/HeishaMon.ino index 62f2160a..c19e7ceb 100644 --- a/HeishaMon/HeishaMon.ino +++ b/HeishaMon/HeishaMon.ino @@ -484,6 +484,7 @@ void mqtt_reconnect() if (mqtt_client.connect(heishamonSettings.wifi_hostname, heishamonSettings.mqtt_username, heishamonSettings.mqtt_password, topic, 1, true, "Offline")) { mqttReconnects++; + publishRelayStates(mqtt_client, heishamonSettings.mqtt_topic_base, heishamonSettings.updateAllTime, true); if (heishamonSettings.opentherm) { sprintf(topic, "%s/%s/#", heishamonSettings.mqtt_topic_base, mqtt_topic_opentherm_read); mqtt_client.subscribe(topic); @@ -1023,7 +1024,7 @@ void mqtt_callback(char* topic, byte* payload, unsigned int length) { mqttOTCallback(topic_otcommand, msg); } else if (strncmp(topic_command, mqtt_topic_gpio, strlen(mqtt_topic_gpio)) == 0) { char* topic_gpiocommand = topic_command + strlen(mqtt_topic_gpio) + 1; //strip the gpio subtopic from the topic - mqttGPIOCallback(topic_gpiocommand, msg); + mqttGPIOCallback(topic_gpiocommand, msg, mqtt_client, heishamonSettings.mqtt_topic_base); } else if (strncmp(topic_command, mqtt_topic_1wire, strlen(mqtt_topic_1wire)) == 0) { //this is a 1wire address topic, restore its retained value at boot char* topic_1wire_address = topic_command + strlen(mqtt_topic_1wire) + 1; //strip the "1wire/" from the topic to get the sensor address if ((strchr(topic_1wire_address, '/') == NULL) && (length > 0)) { //only handle the address topic itself (not .../alias) and skip empty (cleared) retained messages @@ -2236,6 +2237,7 @@ void loop() { } timerqueue_update(); + publishRelayStates(mqtt_client, heishamonSettings.mqtt_topic_base, heishamonSettings.updateAllTime); #ifdef ESP32 delay(1); // to keep watchdog happy #endif diff --git a/HeishaMon/gpio.cpp b/HeishaMon/gpio.cpp index 1d86491a..17bdb211 100644 --- a/HeishaMon/gpio.cpp +++ b/HeishaMon/gpio.cpp @@ -11,7 +11,34 @@ void setupGPIO(gpioSettingsStruct gpioSettings) { } } -void mqttGPIOCallback(char* topic, char* value) { +void publishRelayStates(PubSubClient &mqtt, const char* base, unsigned int interval, bool force) { +#ifdef ESP32 + static int reported[2] = {-1, -1}; + static unsigned long lastPublish = 0; + const unsigned long now = millis(); + const bool periodic = (unsigned long)(now - lastPublish) >= interval * 1000UL; + const int pins[2] = {relayOnePin, relayTwoPin}; + const char* names[2] = {"one", "two"}; + if (!mqtt.connected()) return; + bool success = true; + for (int i = 0; i < 2; i++) { + const int state = digitalRead(pins[i]) ? 1 : 0; + if (force || periodic || reported[i] != state) { + char topic[256]; + snprintf(topic, sizeof(topic), "%s/gpio/relay/%s/state", base, names[i]); + if (mqtt.publish(topic, state ? "1" : "0", true)) { + reported[i] = state; + } else { + reported[i] = -1; // Retry failed publications on the next loop. + success = false; + } + } + } + if ((force || periodic) && success) lastPublish = now; +#endif +} + +void mqttGPIOCallback(char* topic, char* value, PubSubClient &mqtt, const char* base) { char log_msg[256]; sprintf_P(log_msg, PSTR("GPIO: MQTT message received on subtopic '%s' value '%s'"), topic, value); log_message(log_msg); @@ -19,9 +46,11 @@ void mqttGPIOCallback(char* topic, char* value) { if (strcmp_P(topic, PSTR("relay/one")) == 0) { log_message(_F("GPIO: MQTT message received 'relay/one'")); digitalWrite(relayOnePin,((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 ))); + publishRelayStates(mqtt, base, 0, true); } else if (strcmp_P(topic,PSTR("relay/two")) == 0) { log_message(_F("GPIO: MQTT message received 'relay/two'")); digitalWrite(relayTwoPin,((stricmp((char*)"true", value) == 0) || (stricmp((char*)"on", value) == 0) || (stricmp((char*)"enable", value) == 0)|| (String(value).toInt() == 1 ))); + publishRelayStates(mqtt, base, 0, true); } #endif } diff --git a/HeishaMon/gpio.h b/HeishaMon/gpio.h index aea5191d..12d1070e 100644 --- a/HeishaMon/gpio.h +++ b/HeishaMon/gpio.h @@ -11,6 +11,7 @@ #define relayTwoPin 47 #endif +#include extern const char* mqtt_topic_gpio; struct gpioSettingsStruct { @@ -24,6 +25,7 @@ struct gpioSettingsStruct { }; void setupGPIO(gpioSettingsStruct gpioSettings); -void mqttGPIOCallback(char* topic, char* value); +void mqttGPIOCallback(char* topic, char* value, PubSubClient &mqtt, const char* base); +void publishRelayStates(PubSubClient &mqtt, const char* base, unsigned int interval, bool force = false); diff --git a/README.md b/README.md index bf64a311..a11fc7c0 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,9 @@ As until today we don't know how the commands sent to the heatpump are processed The software also supports ds18b20 1-wire temperature sensors reading. A proper 1-wire configuration (with 4.7kohm pull-up resistor) connected to GPIO4 will be read each configured secs (minimal 5) and send at the panasonic_heat_pump/1wire/"sensor-hex-address" topic. On the pre-made boards this 4.7kohm resistor is already installed. ## Large board relay control + +Relay output states are published as `0` (off) or `1` (on), with MQTT retain, to `/gpio/relay/one/state` and `/gpio/relay/two/state`. The default base is `panasonic_heat_pump`. Both states are published after an MQTT relay command, on MQTT connection, and at the configured full-data resend interval (`updateAllTime`). Changes made by rules are detected and published at the end of the main loop. Failed publications are retried. These values report the GPIO output levels, not mechanical contact feedback. + The newer, large, heishamon contains two onboard relays which can be switched on and off using MQTT commands. The relays can be used for any contact switching, even 230V mains (max 5A). For example to switch the 230V contacts in the heatpump for controlling the 'external thermostat', switching a pump on or off or other lower power devices. I do not recommend to use the relay as a switch for a electric heater as they use too much power. To control the relay just send a value of 1 or 0 to the MQTT topic "panasonic_heat_pump/gpio/relay/one" for relay one or "panasonic_heat_pump/gpio/relay/two" for relay two. ## Opentherm support diff --git a/README_DE.md b/README_DE.md index 0c329491..6601f04b 100644 --- a/README_DE.md +++ b/README_DE.md @@ -364,6 +364,16 @@ Bis heute wissen wir nicht, wie die an die Wärmepumpe gesendeten Befehle in der Die Software unterstützt auch das Auslesen von DS18B20 1-Wire-Temperatursensoren. Eine korrekte 1-Wire-Konfiguration (mit 4,7kOhm Pull-up-Widerstand) an GPIO4 wird alle konfigurierten Sekunden (mindestens 5) gelesen und an das Topic panasonic_heat_pump/1wire/"sensor-hex-adresse" gesendet. Auf den vorgefertigten Boards ist dieser 4,7kOhm-Widerstand bereits installiert. ## Relaissteuerung auf der großen Platine + +Die Firmware veröffentlicht die ausgelesenen Relais-Ausgangszustände als `0` (aus) oder `1` (ein) mit MQTT-Retain: + +| Relais | Status-Topic (bei Standard-Basistopic) | +| --- | --- | +| 1 | `panasonic_heat_pump/gpio/relay/one/state` | +| 2 | `panasonic_heat_pump/gpio/relay/two/state` | + +Die Meldung erfolgt direkt nach einem MQTT-Schaltbefehl, bei jeder MQTT-Verbindung und regelmäßig im konfigurierten Intervall für die vollständige Datenversendung (`updateAllTime`). Änderungen durch die Rules Engine werden am Ende des Hauptschleifendurchlaufs erkannt und veröffentlicht. Bei einem anderen MQTT-Basistopic wird dieses auch für die Status-Topics verwendet. Der Status bestätigt den GPIO-Ausgangszustand; eine mechanische Rückmeldung des Relaiskontakts gibt es nicht. + Der neuere, große, Heishamon enthält zwei eingebaute Relais, die über MQTT-Befehle ein- und ausgeschaltet werden können. Die Relais können für jede Kontaktschaltung verwendet werden, auch 230V Netzspannung (max. 5A). Um das Relais zu steuern, sende einfach einen Wert von 1 oder 0 an das MQTT-Topic "panasonic_heat_pump/gpio/relay/one" für Relais eins oder "panasonic_heat_pump/gpio/relay/two" für Relais zwei. ## Benutzerkonfigurierbare Extra-GPIOs From f606f493b54fd927e3cedd7bc2fa62b298cbee56 Mon Sep 17 00:00:00 2001 From: Peter Kretz Date: Tue, 20 Oct 2026 22:33:13 +0200 Subject: [PATCH 4/5] feat(web): display onboard relay states and MQTT topics Add TOP170 and TOP171 to the main page for Relay 1 and Relay 2. Read their states directly from GPIO21 and GPIO47 and display On/Off alongside the configured MQTT state topics. Refresh relay states every five seconds independently of MQTT connectivity and document the new topic IDs and JSON output. --- HeishaMon/htmlcode.h | 28 ++++++++++++++++++++++++++++ HeishaMon/webfunctions.cpp | 18 ++++++++++++++++++ MQTT-Topics.md | 4 ++++ 3 files changed, 50 insertions(+) diff --git a/HeishaMon/htmlcode.h b/HeishaMon/htmlcode.h index 7e552f99..1bd42e1e 100644 --- a/HeishaMon/htmlcode.h +++ b/HeishaMon/htmlcode.h @@ -1027,6 +1027,7 @@ document.body.onload=function(){ startWebsockets(); monitorWebSocket(); refreshTable(); + setInterval(refreshRelayStates,5000); }; var dallasAliasEdit=function(){ isEditing=false; @@ -1108,6 +1109,7 @@ async function refreshTable(){ var tb=document.getElementById('heishavalues');tb.innerHTML=''; d.heatpump.forEach(function(item){tb.appendChild(buildRow(item,'Topic'));}); } + renderRelayStates(d); if(d&&d['heatpump extra']&&Array.isArray(d['heatpump extra'])){ var tb=document.getElementById('heishavalues'); d['heatpump extra'].forEach(function(item){tb.appendChild(buildRow(item,'Topic'));}); @@ -1144,6 +1146,32 @@ async function refreshTable(){ } } catch(e){console.error(e);} } +var hasRelays=false; +function renderRelayStates(data){ + hasRelays=!!(data&&Array.isArray(data.relays)&&data.relays.length); + if(!hasRelays)return; + var tb=document.getElementById('heishavalues'); + data.relays.forEach(function(item){ + var row=document.getElementById(item.Topic+'-relay'); + if(!row){ + row=document.createElement('tr');row.id=item.Topic+'-relay'; + for(var i=0;i<4;i++)row.appendChild(document.createElement('td')); + tb.appendChild(row); + } + row.children[0].textContent=item.Topic; + row.children[1].textContent=item.Name; + row.children[2].textContent=item.Value===1?'On':'Off'; + row.children[3].textContent=item['MQTT topic']; + row.children[3].title='GPIO output state; no mechanical contact feedback'; + }); +} +async function refreshRelayStates(){ + if(!hasRelays||document.hidden)return; + try{ + var response=await fetch('/json'); + renderRelayStates(await response.json()); + }catch(e){console.error(e);} +} function buildRow(item,idKey){ var row=document.createElement('tr'); var topic=item[idKey]; diff --git a/HeishaMon/webfunctions.cpp b/HeishaMon/webfunctions.cpp index 38efded6..23ef2ce0 100644 --- a/HeishaMon/webfunctions.cpp +++ b/HeishaMon/webfunctions.cpp @@ -1232,6 +1232,24 @@ int handleJsonOutput(struct webserver_t *client, char* actData, char* actDataExt client->content--; // The webserver also increases by 1 } else if (client->content == (NUMBER_OF_TOPICS + extraTopics + numOptTopics + 1)) { webserver_send_content_P(client, PSTR("]"), 1); +#ifdef ESP32 + // Onboard outputs are independent of the heat pump's serial data block. + JsonDocument relaysDoc; + JsonArray relays = relaysDoc["relays"].to(); + const int relayPins[2] = {relayOnePin, relayTwoPin}; + const char* relayNames[2] = {"one", "two"}; + for (int i = 0; i < 2; i++) { + JsonObject relay = relays.add(); + relay["Topic"] = String("TOP") + (NUMBER_OF_TOPICS + i); + relay["Name"] = String("Relay ") + (i + 1); + relay["Value"] = digitalRead(relayPins[i]) ? 1 : 0; + relay["MQTT topic"] = String(heishamonSettings->mqtt_topic_base) + "/gpio/relay/" + relayNames[i] + "/state"; + } + String relaysJson; + serializeJson(relays, relaysJson); + webserver_send_content_P(client, PSTR(",\"relays\":"), 10); + webserver_send_content(client, (char*)relaysJson.c_str(), relaysJson.length()); +#endif if (heishamonSettings->use_1wire) { webserver_send_content_P(client, PSTR(",\"1wire\":"), 9); dallasJsonOutput(client); diff --git a/MQTT-Topics.md b/MQTT-Topics.md index 5a3044b6..8081b829 100644 --- a/MQTT-Topics.md +++ b/MQTT-Topics.md @@ -186,6 +186,10 @@ TOP166 | main/Z1_Active | Actual zone 1 activity (0=inactive, 1=active) TOP167 | main/Backup_Heater_Enable | Backup heater custom-menu setting (0=inactive, 1=active) TOP168 | main/Cooling_Enable | Cooling custom-menu setting (0=inactive, 1=active) TOP169 | main/Heat_Request_Temp_With_Buffer | Heating water shift/direct target including buffer delta (°C) +TOP170 | gpio/relay/one/state | Onboard relay 1 GPIO output state (ESP32 only, 0=off, 1=on, retained) +TOP171 | gpio/relay/two/state | Onboard relay 2 GPIO output state (ESP32 only, 0=off, 1=on, retained) + +TOP170 and TOP171 are displayed on the main web page as Relay 1 and Relay 2 with On/Off and their full configured MQTT topics. They are read directly from GPIO21 and GPIO47, independent of MQTT connectivity, and exposed in the `/json` response's `relays` array. The page refreshes these states every five seconds. These values report GPIO output levels, not mechanical contact feedback. The topics above are relative to the configured MQTT base topic. New topics TOP144–TOP169 use the documented normal data block (0x10); availability depends on the heat pump model and configuration. Two-bit status fields use -1 for an unset encoding (00). Z1_Active and Z2_Active are individual activity bits, distinct from Zones_State. Quiet/Powerful actual states are distinct from the configured level/time topics. From c5f6eeeba86a457743ac9f4d18a0a8f3dfd9d7a6 Mon Sep 17 00:00:00 2001 From: Peter Kretz Date: Fri, 23 Oct 2026 20:29:09 +0200 Subject: [PATCH 5/5] feat: add REST relay control and rules engine MQTT telemetry - Add SetRelayOne and SetRelayTwo commands to the REST endpoint - Publish retained rules engine state and rule execution events - Include rule names, IDs, results, uptime, and outer execution duration - Document relay commands, telemetry, and topic numbering in English and German --- HeishaMon/HeishaMon.ino | 53 +++++++++++++++++++++++++++++++++++++++++ HeishaMon/rules.cpp | 7 ++++++ MQTT-Topics.md | 29 ++++++++++++++++++++++ Manage-Topics.md | 2 ++ README.md | 26 +++++++++++++++++--- README_DE.md | 4 ++++ 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/HeishaMon/HeishaMon.ino b/HeishaMon/HeishaMon.ino index c19e7ceb..3f3b79cd 100644 --- a/HeishaMon/HeishaMon.ino +++ b/HeishaMon/HeishaMon.ino @@ -439,6 +439,41 @@ bool loadTlsCaFromFS(WiFiClientSecure *client) { #endif +void publishRuleExecution(const char *name, uint8_t id, int result, uint32_t duration, bool nested) { + if (!mqtt_client.connected()) return; + JsonDocument doc; + doc["rule"] = name; + doc["rule_id"] = id; + doc["result"] = result; + doc["uptime_ms"] = millis(); + doc["nested"] = nested; + if (!nested) doc["duration_us"] = duration; + String payload; + serializeJson(doc, payload); + char topic[256]; + snprintf(topic, sizeof(topic), "%s/rules/executed", heishamonSettings.mqtt_topic_base); + mqtt_client.publish(topic, payload.c_str(), false); +} + +void publishRulesState(bool force) { + static int reported = -1; + static unsigned long lastPublish = 0; + const unsigned long now = millis(); + const int state = nrrules > 0 ? 1 : 0; + const bool periodic = (unsigned long)(now - lastPublish) >= heishamonSettings.updateAllTime * 1000UL; + if (!mqtt_client.connected()) return; + if (force || periodic || reported != state) { + char topic[256]; + snprintf(topic, sizeof(topic), "%s/rules/state", heishamonSettings.mqtt_topic_base); + if (mqtt_client.publish(topic, state ? "1" : "0", true)) { + reported = state; + lastPublish = now; + } else { + reported = -1; // Retry on the next loop. + } + } +} + void mqtt_reconnect() { unsigned long now = millis(); @@ -484,6 +519,7 @@ void mqtt_reconnect() if (mqtt_client.connect(heishamonSettings.wifi_hostname, heishamonSettings.mqtt_username, heishamonSettings.mqtt_password, topic, 1, true, "Offline")) { mqttReconnects++; + publishRulesState(true); publishRelayStates(mqtt_client, heishamonSettings.mqtt_topic_base, heishamonSettings.updateAllTime, true); if (heishamonSettings.opentherm) { sprintf(topic, "%s/%s/#", heishamonSettings.mqtt_topic_base, mqtt_topic_opentherm_read); @@ -1198,6 +1234,22 @@ int8_t webserver_cb(struct webserver_t *client, void *dat) { memset(&cpy, 0, args->len + 1); snprintf((char *)&cpy, args->len + 1, "%.*s", args->len, args->value); +#ifdef ESP32 + if (strcmp((char *)args->name, "SetRelayOne") == 0 || strcmp((char *)args->name, "SetRelayTwo") == 0) { + const bool firstRelay = strcmp((char *)args->name, "SetRelayOne") == 0; + mqttGPIOCallback((char *)(firstRelay ? "relay/one" : "relay/two"), cpy, mqtt_client, heishamonSettings.mqtt_topic_base); + snprintf(log_msg, sizeof(log_msg), "%s: %d", (char *)args->name, digitalRead(firstRelay ? relayOnePin : relayTwoPin) ? 1 : 0); + if ((client->userdata = realloc(client->userdata, strlen((char *)client->userdata) + strlen(log_msg) + 2)) == NULL) { + loggingSerial.printf(PSTR("Out of memory %s:#%d\n"), __FUNCTION__, __LINE__); + ESP.restart(); + exit(-1); + } + strcat((char *)client->userdata, log_msg); + strcat((char *)client->userdata, "\n"); + return 0; + } +#endif + for (uint8_t x = 0; x < sizeof(commands) / sizeof(commands[0]); x++) { cmdStruct tmp; memcpy_P(&tmp, &commands[x], sizeof(tmp)); @@ -2237,6 +2289,7 @@ void loop() { } timerqueue_update(); + publishRulesState(false); publishRelayStates(mqtt_client, heishamonSettings.mqtt_topic_base, heishamonSettings.updateAllTime); #ifdef ESP32 delay(1); // to keep watchdog happy diff --git a/HeishaMon/rules.cpp b/HeishaMon/rules.cpp index c452bdbf..bcff7f35 100644 --- a/HeishaMon/rules.cpp +++ b/HeishaMon/rules.cpp @@ -32,6 +32,7 @@ #define OPTDATASIZE 20 bool send_command(byte* command, int length); +void publishRuleExecution(const char *name, uint8_t id, int result, uint32_t duration, bool nested); #ifdef ESP32 extern QueueHandle_t pcbQueue; #endif @@ -364,6 +365,9 @@ if(strncmp(text, "s0#", 3) == 0 && (size == 9 || size == 13 || size == 18)) { } static void rule_done_cb(struct rules_t *obj) { + if (!parsing && obj->ctx.ret != NULL) { + publishRuleExecution(obj->name, obj->nr, 0, 0, true); + } return; } @@ -892,6 +896,7 @@ void rules_timer_cb(int nr) { int ret = rule_run(rules[nr], 0); timestamp.second = micros(); + publishRuleExecution(rules[nr]->name, rules[nr]->nr, ret, timestamp.second - timestamp.first, false); if(ret == 0) { logprintf_P(F("%s%d %s %d %s"), F("rule #"), rules[nr]->nr, F("was executed in"), timestamp.second - timestamp.first, F("microseconds")); @@ -1053,6 +1058,7 @@ void rules_event_cb(const char *prefix, const char *name) { int ret = rule_run(rules[nr], 0); timestamp.second = micros(); + publishRuleExecution(rules[nr]->name, rules[nr]->nr, ret, timestamp.second - timestamp.first, false); if(ret == 0) { logprintf_P(F("%s%d %s %d %s"), F("rule #"), rules[nr]->nr, F("was executed in"), timestamp.second - timestamp.first, F("microseconds")); @@ -1077,6 +1083,7 @@ void rules_boot(void) { int ret = rule_run(rules[nr], 0); timestamp.second = micros(); + publishRuleExecution(rules[nr]->name, rules[nr]->nr, ret, timestamp.second - timestamp.first, false); if(ret == 0) { logprintf_P(F("%s%d %s %d %s"), F("rule #"), rules[nr]->nr, F("was executed in"), timestamp.second - timestamp.first, F("microseconds")); diff --git a/MQTT-Topics.md b/MQTT-Topics.md index 8081b829..1b5138dd 100644 --- a/MQTT-Topics.md +++ b/MQTT-Topics.md @@ -1,11 +1,40 @@ # MQTT Topics for HeishaMon +`TOP...` IDs identify the normal decoded heat pump values; `XTOP...` and `OPT...` identify values from the extra data block and optional PCB. Not every MQTT topic needs such an ID: device status and event topics such as `LWT`, `log`, `stats`, `rules/state`, and `rules/executed` are documented separately without a TOP number. The ESP32 relay states are a separate extension with `TOP170` and `TOP171` IDs for the web/JSON display. + ## Availability Topic: ID | Topic | Response --- | --- | --- || LWT | Online/Offline (automatically returns to Offline if connection with the HeishaMon lost) +## Rules Engine Status: + +Topic | Response/Description +--- | --- +rules/state | Read-only, retained: `1` if at least one rule block is loaded, `0` if no rule blocks are loaded (both ESP8266 and ESP32). + +The full topic is `/rules/state`, for example `panasonic_heat_pump/rules/state`. It is published on MQTT connection/reconnection, when the state changes, and at the configured full-data resend interval (`updateAllTime`). Failed publications are retried. There is no command to set this topic; publishing to it does not enable or disable rules. + +This reports loaded rules, not whether a rule is executing at that instant. `0` can mean that no ruleset exists, parsing failed, rules were deinitialized, or loading was skipped after a crash reboot. It does not distinguish these causes. The existing `/stats` JSON field `"rules active"` provides the number of loaded rule blocks. Check `/LWT` for device availability; a retained rules state can outlive a disconnected device. + +## Rule Execution Events: + +`/rules/executed` (for example `panasonic_heat_pump/rules/executed`) publishes a read-only JSON event after a rule execution returns. It is **not retained**. + +```json +{"rule":"timer=1","rule_id":2,"result":0,"uptime_ms":123456,"nested":false,"duration_us":245} +``` + +- `rule`: block name, such as `System#Boot`, `timer=1`, `@Outside_Temp`, or a custom helper name. +- `rule_id`: the engine's numeric block ID; it may change when the ruleset is edited. +- `result`: interpreter return code (`0` = successful return, nonzero = error). Success does not imply that a command was emitted or acknowledged by the heat pump. +- `uptime_ms`: device uptime at publication, in milliseconds (wraps after approximately 49.7 days). +- `nested`: `true` for a successfully completed helper block invoked by another rule. Each completed helper produces its own event before the outer block's event. +- `duration_us`: elapsed microseconds for an outer Boot, timer, or topic/event invocation, including its helper calls. Omitted for nested helper events. Nested event publication time is included in the outer duration. + +Ruleset validation does not publish execution events. A returned error is reported for the outer invocation; a helper that fails before returning successfully has no separate completion event. A crash cannot produce a completion event. Events use MQTT QoS 0, are sent only while connected, and are neither queued nor replayed after reconnecting. Boot events may therefore be absent if MQTT is not connected yet. Every execution can produce traffic, including frequent timer/topic handlers and their helper calls. + ## Log Topic: ID | Topic | Response diff --git a/Manage-Topics.md b/Manage-Topics.md index 222a5a5a..4909cd27 100644 --- a/Manage-Topics.md +++ b/Manage-Topics.md @@ -1,5 +1,7 @@ # How to add MQTT Topics for HeishaMon +The steps below apply to decoded heat pump protocol fields. Device-generated monitoring topics, such as [`rules/state` and `rules/executed`](MQTT-Topics.md#rules-engine-status), are published separately and do not have protocol byte mappings or `@` rules variables. The ESP32 relay outputs are also device-generated; see [relay control](README.md#large-board-relay-control) for MQTT commands, REST `/command` calls, and `/json` readback. + 1. Add a new topic line in file [MQTT-Topics.md](MQTT-Topics.md) and define a new line on the table *Sensors Topics:* diff --git a/README.md b/README.md index a11fc7c0..0eef4759 100644 --- a/README.md +++ b/README.md @@ -35,13 +35,20 @@ The software is also able to measure Watt on a S0 port of two kWh meters. You on Updating the firmware is as easy as going to the firmware menu and, after authentication with username 'admin' and password 'heisha' (or other provided during setup), uploading the binary there. -A json output of all received data (heatpump and 1wire) is available at the url http://heishamon.local/json (replace heishamon.local with the ip address of your heishamon device if MDNS is not working for you). +A JSON output of heat pump data and enabled 1-wire/S0 sensors is available at http://heishamon.local/json (replace heishamon.local with the IP address of your HeishaMon device if mDNS is not working). On the ESP32 large board, it also includes the two relay output states in the `relays` array. See [relay control](#large-board-relay-control) for reading and switching those outputs. Within the 'integrations' folder you can find examples how to connect your automation platform to the HeishaMon. # Rules functionality The rules functionality allows you to control the heatpump from within the HeishaMon itself. Which makes it much more reliable then having to deal with external domotica over WiFi. When posting a new ruleset, it is immidiatly validated and when valid used. When a new ruleset is invalid it will be ignored and the old ruleset will be loaded again. You can check the console for feedback on this. If somehow a new valid ruleset crashes the HeishaMon, it will be automatically disabled the next reboot, allowing you to make changes. This prevents the HeishaMon getting into a boot loop. +Rules monitoring is available through two read-only MQTT topics on both ESP8266 and ESP32: + +- `/rules/state` is retained and reports `1` when at least one rule block is loaded, or `0` otherwise. It is sent on connection, changes, and the full-data resend interval. The existing `/stats` field `"rules active"` gives the number of loaded blocks. Use `/LWT` to check device availability. +- `/rules/executed` publishes a non-retained JSON event after an execution returns. It includes the block name and ID, interpreter result, device uptime, and whether it is a nested helper call. Outer invocations also include elapsed microseconds. Validation does not generate events; disconnected events are not replayed. + +The default base is `panasonic_heat_pump`. See [rules status](MQTT-Topics.md#rules-engine-status) and [execution events](MQTT-Topics.md#rule-execution-events) for the payload format and limitations. These are monitoring topics for MQTT clients, not `@` variables or commands in a ruleset. + The techniques used in the rule library allows you to work with very large rulesets, but best practice is to keep it below 10.000 bytes. Notice that sending commands to the heatpump is done asynced. So, commands sent to the heatpump at the beginning of your syntax will not immediatly be reflected in the values from the heatpump later on. Therefor, heatpump values should be read from the heatpump itself instead of those based on the values you keep yourself. @@ -62,7 +69,7 @@ These variables live inside a rule block. When a rule block finishes, these vari These are the same as listed in the Manage Topics documentation page and as found on the HeishaMon homepage. The ruleset also follows the R/W logic as used through the MQTT and REST API. That means that the read topics differ from the write topics. So reading the heatpump state is done through `@Heatpump_State`, changing the heatpump state through `@SetHeatpump`. - `%`: Datetime variables -These can be used for date and time based rules. Currently `%hour` (0 - 23), `%minute` (0 - 59), `%month` (1 - 12), and `day` (1 - 7) are supported. All are plain integers. A proper NTP configuration is needed to set the correct system date and time on the HeishaMon. +These can be used for date and time based rules. Currently `%hour` (0 - 23), `%minute` (0 - 59), `%month` (1 - 12), and `%day` (1 - 7) are supported. All are plain integers. A proper NTP configuration is needed to set the correct system date and time on the HeishaMon. - `?`: Thermostat parameters These variables reflect parameters read from the connected thermostat when using the OpenTherm functionality. When OpenTherm is supported this documentation will be extended with more precise information. The can check the opentherm tab for the variables that can be used. The names are the same for reading and writing, but not all values support reading and/or writing. The opentherm tab also lists this. @@ -369,10 +376,23 @@ The software also supports ds18b20 1-wire temperature sensors reading. A proper ## Large board relay control -Relay output states are published as `0` (off) or `1` (on), with MQTT retain, to `/gpio/relay/one/state` and `/gpio/relay/two/state`. The default base is `panasonic_heat_pump`. Both states are published after an MQTT relay command, on MQTT connection, and at the configured full-data resend interval (`updateAllTime`). Changes made by rules are detected and published at the end of the main loop. Failed publications are retried. These values report the GPIO output levels, not mechanical contact feedback. +Relay output states are published as `0` (off) or `1` (on), with MQTT retain, to `/gpio/relay/one/state` and `/gpio/relay/two/state`. The default base is `panasonic_heat_pump`. Both states are published after an MQTT or REST relay command, on MQTT connection, and at the configured full-data resend interval (`updateAllTime`). Changes made by rules are detected and published at the end of the main loop. Failed publications are retried. These values report the GPIO output levels, not mechanical contact feedback. The newer, large, heishamon contains two onboard relays which can be switched on and off using MQTT commands. The relays can be used for any contact switching, even 230V mains (max 5A). For example to switch the 230V contacts in the heatpump for controlling the 'external thermostat', switching a pump on or off or other lower power devices. I do not recommend to use the relay as a switch for a electric heater as they use too much power. To control the relay just send a value of 1 or 0 to the MQTT topic "panasonic_heat_pump/gpio/relay/one" for relay one or "panasonic_heat_pump/gpio/relay/two" for relay two. +The ESP32 large board also supports relay control through the existing HTTP REST `/command` endpoint (GET): + +| Action | Request | +| --- | --- | +| Relay 1 on | `http:///command?SetRelayOne=1` | +| Relay 1 off | `http:///command?SetRelayOne=0` | +| Relay 2 on | `http:///command?SetRelayTwo=1` | +| Relay 2 off | `http:///command?SetRelayTwo=0` | + +Both parameters may be combined, for example `/command?SetRelayOne=1&SetRelayTwo=0`. The plain-text response reports the resulting output level, for example `SetRelayOne: 1`. As with MQTT relay commands, `1`, `true`, `on`, and `enable` turn the relay on (text is case-insensitive); other values turn it off. REST switching works without an MQTT connection and publishes retained relay states when MQTT is connected. These commands are only available on ESP32. + +Read both output states with `GET http:///json`: the `relays` array contains `Name` (`Relay 1` / `Relay 2`) and numeric `Value` (`0` / `1`). These are GPIO output levels, not mechanical contact feedback. + ## Opentherm support If your heishamon board supports opentherm the software can also be used to bridge opentherm information from a compatible thermostat to your home automation over MQTT or JSON and as mentioned above it can also be connected directly in the rules to connect opentherm information to the heatpump and back, for example to display the outside temperature from the heatpump on your opentherm thermostat. If you enable opentherm support in settings there will be a new tab visible in the web page. On that tab you will see opentherm values. Some are of type R(ead) and some are W(rite), and some are both. Read means that the thermostat can read that information from the heishamon. You provide that information over MQTT (or using the rules) by updating this value on the mqtt 'opentherm/read' topic, for example 'panasonic_heat_pump/opentherm/read/outsideTemp'. The write values are information from the thermostat, like 'roomTemp'. These are available on mqtt topic 'opentherm/write'. You can use these values to change the heatpump behaviour in anyway you want using your home automation and mqtt set-commands to heishamon on using the internal rules. diff --git a/README_DE.md b/README_DE.md index 6601f04b..f0ffedf6 100644 --- a/README_DE.md +++ b/README_DE.md @@ -40,6 +40,10 @@ Im Ordner 'integrations' findest du Beispiele, wie du deine Automatisierungsplat # Regeln-Funktionalität Die Regeln-Funktionalität ermöglicht es dir, die Wärmepumpe direkt vom HeishaMon aus zu steuern. Das macht es viel zuverlässiger als die Steuerung über externe Hausautomatisierung über WLAN. Wenn ein neues Regelwerk veröffentlicht wird, wird es sofort validiert und bei Gültigkeit übernommen. Wenn ein neues Regelwerk ungültig ist, wird es ignoriert und das alte Regelwerk wieder geladen. Du kannst die Konsole auf Rückmeldungen überprüfen. Falls ein neues gültiges Regelwerk den HeishaMon zum Absturz bringt, wird es beim nächsten Neustart automatisch deaktiviert, sodass du Änderungen vornehmen kannst. Dies verhindert eine Boot-Schleife des HeishaMon. +Zur Überwachung gibt es die schreibgeschützten MQTT-Topics `/rules/state` und `/rules/executed`. `rules/state` meldet mit Retain `1`, wenn mindestens ein Regelblock geladen ist, andernfalls `0`. `rules/executed` meldet nach einer Regelausführung ein JSON-Ereignis ohne Retain mit Regelname, Block-ID, Ergebniscode, Geräte-Uptime und der Kennzeichnung verschachtelter Hilfsblöcke. Bei äußeren Aufrufen wird zusätzlich die Laufzeit in Mikrosekunden mitgesendet. Der Standardwert für `` ist `panasonic_heat_pump`. Payload-Details und Einschränkungen stehen in der [MQTT-Dokumentation](MQTT-Topics.md#rules-engine-status). + +Nicht jedes MQTT-Topic erhält eine `TOP…`-Nummer: `TOP…` kennzeichnet die normalen decodierten Wärmepumpenwerte, `XTOP…` den zusätzlichen Datenblock und `OPT…` die optionale Platine. Geräteinformationen und Ereignisse wie `LWT`, `log`, `stats`, `rules/state` und `rules/executed` werden separat ohne TOP-Nummer dokumentiert. Die ESP32-Relaiszustände sind eine gesonderte Erweiterung mit `TOP170` und `TOP171` für die Web-/JSON-Anzeige. Die beiden Rules-Topics sind MQTT-Meldungen für externe Abonnenten und keine `@`-Variablen im Regelwerk. + Die im Regelwerk verwendeten Techniken ermöglichen die Arbeit mit sehr großen Regelwerken, aber Best Practice ist es, sie unter 10.000 Bytes zu halten. Beachte, dass das Senden von Befehlen an die Wärmepumpe asynchron erfolgt. Befehle, die zu Beginn deiner Syntax an die Wärmepumpe gesendet werden, spiegeln sich nicht sofort in den Werten der Wärmepumpe wider. Daher sollten Wärmepumpen-Werte direkt von der Wärmepumpe gelesen werden, nicht aus selbst gespeicherten Werten.