diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cce0de..2ef7a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Note about CMD 0x05 observed with payload 0x00. Added to PROTOCOL.md and new sample trace. - Note about CMD 0x12 observed with payload 0x01 0x00. Added to PROTOCOL.md and new sample trace. - Parsing for CMD 0x12 updated so `0x01 0x00` no longer reported as unexpected. - +- Full decoding of CMD 0x12 status byte for Gas Heaters (HiNRG `0x0072` and ICI (`0x0074`)) ### Removed ### Fixed ### Deprecated diff --git a/PROTOCOL.md b/PROTOCOL.md index ffdf29b..4290522 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -463,7 +463,7 @@ Data fields: --- -#### ICI Gas Heater (`0x0074`) ✅ +#### Gas Heaters: HiNRG (`0x0072`) & ICI (`0x0074`) ✅ Pattern: `02 00 74 FF FF 80 00 12 10 16` @@ -471,9 +471,9 @@ Examples: ``` 02 00 74 FF FF 80 00 12 10 16 00 00 00 00 00 03 Idle / off -02 00 74 FF FF 80 00 12 10 16 00 01 00 00 01 03 On and Lighting +02 00 74 FF FF 80 00 12 10 16 00 01 00 00 01 03 Heater On, no water flow yet 02 00 74 FF FF 80 00 12 10 16 00 03 00 00 03 03 At Setpoint (on but not heating) -02 00 74 FF FF 80 00 12 10 16 00 07 00 00 07 03 (transitional?) +02 00 74 FF FF 80 00 12 10 16 00 07 00 00 07 03 Igniting 02 00 74 FF FF 80 00 12 10 16 00 0F 00 00 0F 03 Heater Lit and Running ^^ Status byte ``` @@ -486,13 +486,30 @@ Data fields: Observed status values (payload[1]): -| Value | Meaning | -|--------|---------| -| `0x00` | Idle / off | -| `0x01` | On and Lighting (attempting ignition) | -| `0x03` | At Setpoint — on but not heating | -| `0x07` | Transitional? (observed briefly between `0x01` and `0x0F`) | -| `0x0F` | Heater Lit and Running | +| Value | Bits 7–5 Diagnostics | Bit 4
Locked Out | Bit 3
Flame | Bit 2
Gas Valve | Bit 1
Pressure / Flow | Bit 0
Heater On | Meaning | +|--------|---------|-------|-------|-------|-------|-------|---------| +| `0x00` | X | 0 | 0 | 0 | 0 | 0 | System Idle (Heater Off, No Water Flow)| +| `0x01` | X | 0 | 0 | 0 | 0 | 1 | Heater On / No Flow (temporary state if heater is turned on while pump is off)| +| `0x02` | X | 0 | 0 | 0 | 1 | 0 | Heater Off / Water Flow (Normal state when heater is off and pump is running) | +| `0x03` | X | 0 | 0 | 0 | 1 | 1 | Setpoint Reached | +| `0x07` | X | 0 | 0 | 1 | 1 | 1 | Igniting | +| `0x0F` | X | 0 | 1 | 1 | 1 | 1 | Heating | +| `0x12` | X | 1 | 0 | 0 | 1 | 0 | Cooling Down (Heater Off, Pump Forced On)| +| `0x13` | X | 1 | 0 | 0 | 1 | 1 | Locked Out (Heater On, Pump Forced On) | + +Some notes: + + - 'Heater On' is required for 'Gas Valve' to open. + - 'Pressure / Flow' is required for 'Gas Valve' to open. + - 'Gas Valve' is required for 'Flame'. + - 'Pressure / Flow' is required for 'Locked Out'. + - 'Locked Out' implies 'Gas Valve' is closed. + +Diagnostic Bits + - Bit 5: General Service Required + - Bit 6: Ignition Service Required + - Bit 7: Cooling Available (Heatpump installed?) +It is unclear whether the diagnostic bits can be set at the same time as the functional status bits. Payload[1] is the only byte that varies; bytes 10, 12, and 13 are always `0x00`. The data checksum (byte 14) equals payload[1] since all other payload bytes are zero. diff --git a/dependencies.lock b/dependencies.lock index b967c2c..f89c2b1 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -22,7 +22,7 @@ dependencies: idf: source: type: idf - version: 5.5.1 + version: 5.5.4 direct_dependencies: - espressif/led_strip - espressif/mdns diff --git a/main/message_decoder.c b/main/message_decoder.c index 6628ba6..8991f96 100644 --- a/main/message_decoder.c +++ b/main/message_decoder.c @@ -169,6 +169,18 @@ static const channel_type_entry_t CHANNEL_TYPE_TABLE[] = { #define CHANNEL_TYPE_TABLE_SIZE (sizeof(CHANNEL_TYPE_TABLE) / sizeof(CHANNEL_TYPE_TABLE[0])) +// Gas Heater Status Field Bitmasks +static const int GAS_HEATER_BITMASK_HEATER_ON = 0x01; +static const int GAS_HEATER_BITMASK_WATER_FLOW = 0x02; +static const int GAS_HEATER_BITMASK_GAS_VALVE = 0x04; +static const int GAS_HEATER_BITMASK_BURNER_ALIGHT = 0x08; +static const int GAS_HEATER_BITMASK_LOCKED_OUT = 0x10; +static const int GAS_HEATER_BITMASK_GENERAL_SERVICE_REQUIRED = 0x20; +static const int GAS_HEATER_BITMASK_IGNITION_SERVICE_REQUIRED = 0x40; +static const int GAS_HEATER_BITMASK_COOLING_AVAILABLE = 0x80; +// The lower 5 bits are for general heater functions +static const int GAS_HEATER_BITMASK_FUNCTIONAL_STATUS = 0x1F; + /** * Get channel type name from type code * @param type_code Channel type code (0x00-0x12, 0xFD, 0xFE) @@ -267,6 +279,24 @@ const char *LIGHT_ZONE_NAME_TABLE[] = { "Waterfall 3", // 0x05 }; +// Gas heater status names (indexed by gas_heater_status_t) +const char *HEATER_STATUS_NAMES[] = { + "Off", // HEATER_OFF + "No Flow", // HEATER_ON_NO_FLOW + "Igniting", // HEATER_IGNITING + "Heating", // HEATER_HEATING + "Setpoint Reached", // HEATER_SETPOINT_REACHED + "Cooldown", // HEATER_COOLDOWN + "Locked Out", // HEATER_LOCKED_OUT +}; + +// Gas heater burner state names (indexed by gas_heater_burner_state_t) +const char *BURNER_STATE_NAMES[] = { + "Off", // BURNER_OFF + "Igniting", // BURNER_IGNITING + "Alight", // BURNER_ALIGHT +}; + // Day of week names const char *DAY_OF_WEEK_NAMES[] = { "Monday", // 0 @@ -897,8 +927,98 @@ static bool handle_gas_heater_status( message_decoder_context_t *ctx) { if (payload_len < 4) return false; - ESP_LOGI(TAG, "%s Gas heater status - [%02X %02X %02X %02X]", - addr_info, payload[0], payload[1], payload[2], payload[3]); + + + const uint8_t raw_status = payload[1]; + const uint8_t functional_status = raw_status & GAS_HEATER_BITMASK_FUNCTIONAL_STATUS; + gas_heater_status_t heater_status; + + switch (functional_status) { + case 0x00: + heater_status = HEATER_OFF; + break; + case 0x01: + heater_status = HEATER_ON_NO_FLOW; + break; + case 0x02: + heater_status = HEATER_OFF; + break; + case 0x03: + heater_status = HEATER_SETPOINT_REACHED; + break; + case 0x07: + heater_status = HEATER_IGNITING; + break; + case 0x0F: + heater_status = HEATER_HEATING; + break; + case 0x12: + heater_status = HEATER_COOLDOWN; + break; + case 0x13: + heater_status = HEATER_LOCKED_OUT; + break; + default: + ESP_LOGW(TAG, "Invalid gas heater status:%d", functional_status); + return false; + } + + const bool heater_on = (raw_status & GAS_HEATER_BITMASK_HEATER_ON) != 0; + const bool water_flow_detected = (raw_status & GAS_HEATER_BITMASK_WATER_FLOW) != 0; + const bool gas_valve_open = (raw_status & GAS_HEATER_BITMASK_GAS_VALVE) != 0; + const bool burner_alight = (raw_status & GAS_HEATER_BITMASK_BURNER_ALIGHT) != 0; + const bool locked_out = (raw_status & GAS_HEATER_BITMASK_LOCKED_OUT) != 0; + const bool general_service_required = (raw_status & GAS_HEATER_BITMASK_GENERAL_SERVICE_REQUIRED) != 0; + const bool ignition_service_required = (raw_status & GAS_HEATER_BITMASK_IGNITION_SERVICE_REQUIRED) != 0; + const bool cooling_available = (raw_status & GAS_HEATER_BITMASK_COOLING_AVAILABLE) != 0; + + gas_heater_burner_state_t burner_state; + if (!gas_valve_open && !burner_alight) { + burner_state = BURNER_OFF; + } else if (gas_valve_open && !burner_alight) { + burner_state = BURNER_IGNITING; + } else if (gas_valve_open && burner_alight) { + burner_state = BURNER_ALIGHT; + } else { + // This should never happen given the valid status filter above + ESP_LOGW(TAG, "Invalid gas burner state: gas_valve=%d, alight=%d", gas_valve_open, burner_alight); + return false; + } + + ESP_LOGI(TAG, "%s Gas heater raw status - [%02X %02X %02X %02X], decoded status - \"%s\"", + addr_info, payload[0], payload[1], payload[2], payload[3], HEATER_STATUS_NAMES[heater_status]); + if (general_service_required || ignition_service_required) { + ESP_LOGI(TAG, "%s Gas heater service required - general %s, ignition %s", + addr_info, general_service_required ? "true" : "false", ignition_service_required ? "true" : "false"); + } + + // Update state and publish + pool_state_t snapshot; + if (!ctx->state_mutex || xSemaphoreTake(ctx->state_mutex, pdMS_TO_TICKS(MUTEX_TIMEOUT_MS)) != pdTRUE) { + ESP_LOGW(TAG, "Failed to acquire mutex for heater"); + return true; + } + pool_heater_t* const heater_state = &(ctx->pool_state->heaters[0]); + heater_state->valid = true; + heater_state->on = heater_on; + + heater_state->gas_heater_valid = true; + heater_state->water_flow_detected = water_flow_detected; + heater_state->locked_out = locked_out; + heater_state->burner_state = burner_state; + heater_state->general_service_required = general_service_required; + heater_state->ignition_service_required = ignition_service_required; + heater_state->cooling_available = cooling_available; + heater_state->status = heater_status; + + ctx->pool_state->last_update_ms = xTaskGetTickCount() * portTICK_PERIOD_MS; + snapshot = *ctx->pool_state; + xSemaphoreGive(ctx->state_mutex); + + if (ctx->enable_mqtt) { + mqtt_publish_gas_heater(&snapshot, 0); + } + return true; } @@ -3008,7 +3128,7 @@ static bool dispatch_message( return handle_heater(data, len, payload, payload_len, addr_info, ctx); } - // Genus Heater (0x0070) messages + // Heater temperature setting messages if (match_pattern(data, len, MSG_TYPE_GENUS_HEATER_TEMP_SETTING) || match_pattern(data, len, MSG_TYPE_HINRG_HEATER_TEMP_SETTING) || match_pattern(data, len, MSG_TYPE_ICI_HEATER_TEMP_SETTING)) { diff --git a/main/mqtt_discovery.c b/main/mqtt_discovery.c index 128f23a..f00168c 100644 --- a/main/mqtt_discovery.c +++ b/main/mqtt_discovery.c @@ -293,6 +293,172 @@ void mqtt_publish_heater_discovery_single(int index) publish_heater_discovery(device_id, mac_suffix, index); } +// ====================================================== +// Gas Heater Detail Discovery +// ====================================================== + +static void publish_gas_heater_detail_discovery(const char *device_id, const char *mac_suffix, int index) +{ + char avail_topic[128]; + char state_topic[128]; + snprintf(avail_topic, sizeof(avail_topic), "pool/%s/availability", device_id); + snprintf(state_topic, sizeof(state_topic), "pool/%s/heater/%d/gas_status/state", device_id, index); + + char display_name[48]; + char uid[72]; + + // Status sensor — the gas_heater_status_t string (off/heating/igniting/…) + snprintf(display_name, sizeof(display_name), "Heater %d Status", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_gas_status", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "device_class", "enum"); + cJSON_AddStringToObject(root, "icon", "mdi:thermometer-lines"); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ value_json.status }}"); + cJSON *status_opts = cJSON_CreateArray(); + for (int i = 0; i < HEATER_STATUS_NAME_COUNT; i++) { + cJSON_AddItemToArray(status_opts, cJSON_CreateString(HEATER_STATUS_NAMES[i])); + } + cJSON_AddItemToObject(root, "options", status_opts); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } + + // Water flow binary sensor + snprintf(display_name, sizeof(display_name), "Heater %d Water Flow", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_water_flow", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "device_class", "running"); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ 'ON' if value_json.water_flow else 'OFF' }}"); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("binary_sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } + + // Locked out binary sensor + snprintf(display_name, sizeof(display_name), "Heater %d Locked Out", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_locked_out", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ 'ON' if value_json.locked_out else 'OFF' }}"); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("binary_sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } + + // Burner state sensor (off/igniting/alight) + snprintf(display_name, sizeof(display_name), "Heater %d Burner", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_burner", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "device_class", "enum"); + cJSON_AddStringToObject(root, "icon", "mdi:fire"); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ value_json.burner }}"); + cJSON *burner_opts = cJSON_CreateArray(); + for (int i = 0; i < BURNER_STATE_NAME_COUNT; i++) { + cJSON_AddItemToArray(burner_opts, cJSON_CreateString(BURNER_STATE_NAMES[i])); + } + cJSON_AddItemToObject(root, "options", burner_opts); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } + + // General service required binary sensor + snprintf(display_name, sizeof(display_name), "Heater %d General Service Required", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_general_service", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "device_class", "problem"); + cJSON_AddStringToObject(root, "icon", "mdi:wrench-alert"); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ 'ON' if value_json.general_service_required else 'OFF' }}"); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("binary_sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } + + // Ignition service required binary sensor + snprintf(display_name, sizeof(display_name), "Heater %d Ignition Service Required", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_ignition_service", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "device_class", "problem"); + cJSON_AddStringToObject(root, "icon", "mdi:fire-alert"); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ 'ON' if value_json.ignition_service_required else 'OFF' }}"); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("binary_sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } + + // Cooling available binary sensor + snprintf(display_name, sizeof(display_name), "Heater %d Cooling Available", index + 1); + snprintf(uid, sizeof(uid), DISCOVERY_ID_PREFIX "_%s_heater_%d_cooling_available", mac_suffix, index); + { + cJSON *root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "name", display_name); + cJSON_AddStringToObject(root, "icon", "mdi:snowflake"); + cJSON_AddStringToObject(root, "state_topic", state_topic); + cJSON_AddStringToObject(root, "value_template", "{{ 'ON' if value_json.cooling_available else 'OFF' }}"); + cJSON_AddStringToObject(root, "unique_id", uid); + cJSON_AddStringToObject(root, "object_id", uid); + cJSON_AddStringToObject(root, "availability_topic", avail_topic); + cJSON_AddItemToObject(root, "device", build_device_cjson(device_id, mac_suffix)); + char *json_str = cJSON_PrintUnformatted(root); + if (json_str) { publish_discovery("binary_sensor", uid, json_str); cJSON_free(json_str); } + cJSON_Delete(root); + } +} + +void mqtt_publish_gas_heater_discovery_single(int index) +{ + char device_id[32]; + mqtt_get_device_id(device_id, sizeof(device_id)); + + char mac_suffix[DEVICE_MAC_SUFFIX_LEN]; + device_get_mac_suffix(mac_suffix, sizeof(mac_suffix)); + + ESP_LOGI(TAG, "Publishing gas heater detail discovery for heater %d", index); + publish_gas_heater_detail_discovery(device_id, mac_suffix, index); +} + // ====================================================== // Mode Select Discovery // ====================================================== diff --git a/main/mqtt_discovery.h b/main/mqtt_discovery.h index f04272c..7657d07 100644 --- a/main/mqtt_discovery.h +++ b/main/mqtt_discovery.h @@ -19,6 +19,10 @@ void mqtt_publish_valve_discovery_single(int valve_num, const char *valve_name); // Publish individual heater discovery (called when heater first publishes state) void mqtt_publish_heater_discovery_single(int index); +// Publish gas heater detail discovery: status sensor, water_flow/locked_out binary +// sensors, and burner sensor — all reading from pool//heater//gas_status/state. +void mqtt_publish_gas_heater_discovery_single(int index); + // Publish a heater's pool + spa setpoint Number entities (called when the heater // first publishes setpoints). void mqtt_publish_heater_setpoint_discovery_single(int index); diff --git a/main/mqtt_publish.c b/main/mqtt_publish.c index 5bbb86a..ca33369 100644 --- a/main/mqtt_publish.c +++ b/main/mqtt_publish.c @@ -19,6 +19,7 @@ static struct { bool lights[MAX_LIGHT_ZONES]; bool valves[MAX_VALVE_SLOTS]; bool heaters[MAX_HEATERS]; + bool gas_heaters[MAX_HEATERS]; bool heater_setpoints[MAX_HEATERS]; bool favourite; bool temp_sensors[MAX_SEEN_DEVICES][2]; // [dev_idx][sensor_index-1] @@ -175,6 +176,69 @@ void mqtt_publish_heater(const pool_state_t *current_state, int index) ESP_LOGI(TAG, "Published heater %d: %s", index, payload); } +void mqtt_publish_gas_heater(const pool_state_t *current_state, int index) +{ + if (index < 0 || index >= MAX_HEATERS) { + ESP_LOGE(TAG, "mqtt_publish_gas_heater: index %d out of range", index); + return; + } + + const pool_heater_t *heater = ¤t_state->heaters[index]; + + if (!heater->gas_heater_valid) { + ESP_LOGE(TAG, "mqtt_publish_gas_heater: called with gas_heater_valid=false for index %d", index); + return; + } + + // status is the single source of truth — all derived fields follow from it + const pool_heater_t *last = &s_last_published_state.heaters[index]; + if (last->gas_heater_valid && + last->status == heater->status && + last->general_service_required == heater->general_service_required && + last->ignition_service_required == heater->ignition_service_required && + last->cooling_available == heater->cooling_available) { + return; + } + + if (!s_discovery_published.gas_heaters[index]) { + mqtt_publish_gas_heater_discovery_single(index); + s_discovery_published.gas_heaters[index] = true; + } + + char device_id[32]; + mqtt_get_device_id(device_id, sizeof(device_id)); + + char topic[128]; + snprintf(topic, sizeof(topic), "pool/%s/heater/%d/gas_status/state", device_id, index); + + const char *status_name = (heater->status < HEATER_STATUS_NAME_COUNT) + ? HEATER_STATUS_NAMES[heater->status] : "unknown"; + const char *burner_name = (heater->burner_state < BURNER_STATE_NAME_COUNT) + ? BURNER_STATE_NAMES[heater->burner_state] : "unknown"; + + char payload[256]; + snprintf(payload, sizeof(payload), + "{\"status\":\"%s\",\"water_flow\":%s,\"locked_out\":%s,\"burner\":\"%s\"," + "\"general_service_required\":%s,\"ignition_service_required\":%s,\"cooling_available\":%s}", + status_name, + heater->water_flow_detected ? "true" : "false", + heater->locked_out ? "true" : "false", + burner_name, + heater->general_service_required ? "true" : "false", + heater->ignition_service_required ? "true" : "false", + heater->cooling_available ? "true" : "false"); + + mqtt_publish(topic, payload, 0, true); + + s_last_published_state.heaters[index].gas_heater_valid = true; + s_last_published_state.heaters[index].status = heater->status; + s_last_published_state.heaters[index].general_service_required = heater->general_service_required; + s_last_published_state.heaters[index].ignition_service_required = heater->ignition_service_required; + s_last_published_state.heaters[index].cooling_available = heater->cooling_available; + + ESP_LOGI(TAG, "Published gas heater %d: %s", index, payload); +} + // ====================================================== // Mode Publishing // ====================================================== diff --git a/main/mqtt_publish.h b/main/mqtt_publish.h index e799fd0..c809ac6 100644 --- a/main/mqtt_publish.h +++ b/main/mqtt_publish.h @@ -19,6 +19,10 @@ void mqtt_publish_temperature_reading(const pool_state_t *current_state, int dev // Publish heater state from pool state (index 0-based) void mqtt_publish_heater(const pool_state_t *current_state, int index); +// Publish gas heater detailed status (status, water_flow, locked_out, burner) as JSON +// to pool//heater//gas_status/state. Called only when gas_heater_valid is set. +void mqtt_publish_gas_heater(const pool_state_t *current_state, int index); + // Publish mode (Pool/Spa) from pool state void mqtt_publish_mode(const pool_state_t *current_state); diff --git a/main/pool_state.h b/main/pool_state.h index 7353ca0..9d4b535 100644 --- a/main/pool_state.h +++ b/main/pool_state.h @@ -24,6 +24,14 @@ extern const char *LIGHTING_COLOR_NAMES[]; #define LIGHT_ZONE_NAME_COUNT 6 extern const char *LIGHT_ZONE_NAME_TABLE[]; +// Gas heater status names (indexed by gas_heater_status_t) +#define HEATER_STATUS_NAME_COUNT 7 +extern const char *HEATER_STATUS_NAMES[]; + +// Gas heater burner state names (indexed by gas_heater_burner_state_t) +#define BURNER_STATE_NAME_COUNT 3 +extern const char *BURNER_STATE_NAMES[]; + // Struct definitions typedef struct { uint8_t id; @@ -53,16 +61,45 @@ typedef struct { bool configured; // true if this slot is occupied by a configured valve } valve_state_t; +typedef enum { + BURNER_OFF, + BURNER_IGNITING, + BURNER_ALIGHT, +} gas_heater_burner_state_t; + +typedef enum { + HEATER_OFF, + HEATER_ON_NO_FLOW, + HEATER_IGNITING, + HEATER_HEATING, + HEATER_SETPOINT_REACHED, + HEATER_COOLDOWN, + HEATER_LOCKED_OUT, +} gas_heater_status_t; + typedef struct { - bool on; bool valid; + bool on; + // Temperature setpoints (per heater): 0xE7/0xE8 (Heater 1), 0xEA/0xEB (Heater 2) uint8_t pool_setpoint; // °C uint8_t spa_setpoint; // °C uint8_t pool_setpoint_f; // °F uint8_t spa_setpoint_f; // °F bool setpoint_valid; // true once a setpoint has been received + + // These fields are only valid for gas heaters. + // Some may be valid for other types, this is not yet experimentally verified. + bool gas_heater_valid; + gas_heater_burner_state_t burner_state; + bool water_flow_detected; + bool locked_out; + bool general_service_required; + bool ignition_service_required; + bool cooling_available; + gas_heater_status_t status; + // End gas heater fields } pool_heater_t; typedef struct { diff --git a/main/web_handlers.c b/main/web_handlers.c index 75571e4..755313d 100644 --- a/main/web_handlers.c +++ b/main/web_handlers.c @@ -679,6 +679,20 @@ static esp_err_t status_get_handler(httpd_req_t *req) if (state.heaters[i].valid) { cJSON_AddStringToObject(heater, "state", state.heaters[i].on ? "On" : "Off"); } + if (state.heaters[i].gas_heater_valid) { + const pool_heater_t *h = &state.heaters[i]; + cJSON *gs = cJSON_CreateObject(); + cJSON_AddStringToObject(gs, "status", + h->status < HEATER_STATUS_NAME_COUNT ? HEATER_STATUS_NAMES[h->status] : "unknown"); + cJSON_AddBoolToObject(gs, "water_flow", h->water_flow_detected); + cJSON_AddStringToObject(gs, "burner", + h->burner_state < BURNER_STATE_NAME_COUNT ? BURNER_STATE_NAMES[h->burner_state] : "unknown"); + cJSON_AddBoolToObject(gs, "locked_out", h->locked_out); + cJSON_AddBoolToObject(gs, "general_service_required", h->general_service_required); + cJSON_AddBoolToObject(gs, "ignition_service_required", h->ignition_service_required); + cJSON_AddBoolToObject(gs, "cooling_available", h->cooling_available); + cJSON_AddItemToObject(heater, "gas_status", gs); + } if (state.heaters[i].setpoint_valid) { cJSON_AddNumberToObject(heater, "pool_setpoint", state.heaters[i].pool_setpoint); cJSON_AddNumberToObject(heater, "spa_setpoint", state.heaters[i].spa_setpoint); diff --git a/test/samples/heater.txt b/test/samples/heater.txt new file mode 100644 index 0000000..4670fb8 --- /dev/null +++ b/test/samples/heater.txt @@ -0,0 +1,9 @@ +# Trace from lawther + +I (07:49:14.367) MSG_DECODER: RX MSG: 02 00 74 FF FF 80 00 12 10 16 00 00 00 00 00 03 +I (07:49:14.369) MSG_DECODER: [ICI Gas Heater -> Broadcast] Gas heater raw status - [00 00 00 00], decoded status - "Off" +02 00 74 FF FF 80 00 12 10 16 00 00 00 00 00 03 + +I (07:54:46.285) MSG_DECODER: RX MSG: 02 00 74 FF FF 80 00 12 10 16 00 02 00 00 02 03 +I (07:54:46.287) MSG_DECODER: [ICI Gas Heater -> Broadcast] Gas heater raw status - [00 02 00 00], decoded status - "Off" +02 00 74 FF FF 80 00 12 10 16 00 02 00 00 02 03 diff --git a/test/test_message_decoder.c b/test/test_message_decoder.c index 869d477..2a0474e 100644 --- a/test/test_message_decoder.c +++ b/test/test_message_decoder.c @@ -38,6 +38,7 @@ void mqtt_publish_mode(const pool_state_t *state) {} void mqtt_publish_heater_setpoints(const pool_state_t *state, int index) {} void mqtt_publish_temperature_reading(const pool_state_t *state, int dev_idx, uint8_t sensor_index) {} void mqtt_publish_heater(const pool_state_t *state, int index) {} +void mqtt_publish_gas_heater(const pool_state_t *state, int index) {} void mqtt_publish_chlorinator(const pool_state_t *state) {} void mqtt_publish_pump(const pool_state_t *state) {} void mqtt_publish_light(const pool_state_t *state, uint8_t zone) {} @@ -730,6 +731,174 @@ void test_decode_pump_activity(void) "pump_speed_valid should remain unset after activity-only message"); } +// ====================================================== +// Gas Heater Status Tests (CMD 0x12) +// +// ICI frame: 02 00 74 FF FF 80 00 12 10 16 00 SS 00 00 SS 03 +// HiNRG frame: 02 00 72 FF FF 80 00 12 10 14 00 SS 00 00 SS 03 +// Data checksum = SS (payload[1] only; all other payload bytes are 0x00) +// ====================================================== + +static void build_ici_gas_heater_msg(uint8_t status_byte, uint8_t *buf) +{ + buf[0] = 0x02; buf[1] = 0x00; buf[2] = 0x74; + buf[3] = 0xFF; buf[4] = 0xFF; buf[5] = 0x80; + buf[6] = 0x00; buf[7] = 0x12; buf[8] = 0x10; + buf[9] = 0x16; // header checksum + buf[10] = 0x00; buf[11] = status_byte; + buf[12] = 0x00; buf[13] = 0x00; + buf[14] = status_byte; // data checksum = sum of payload bytes + buf[15] = 0x03; +} + +void test_gas_heater_ici_off(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x00, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x00: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x00: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_OFF, "0x00: status=HEATER_OFF"); + TEST_ASSERT(!h->on, "0x00: on=false"); + TEST_ASSERT(!h->water_flow_detected, "0x00: no water flow"); + TEST_ASSERT(!h->locked_out, "0x00: not locked out"); + TEST_ASSERT(h->burner_state == BURNER_OFF, "0x00: burner off"); +} + +void test_gas_heater_ici_no_flow(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x01, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x01: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x01: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_ON_NO_FLOW, "0x01: status=HEATER_ON_NO_FLOW"); + TEST_ASSERT(h->on, "0x01: on=true"); + TEST_ASSERT(!h->water_flow_detected, "0x01: no water flow"); + TEST_ASSERT(!h->locked_out, "0x01: not locked out"); + TEST_ASSERT(h->burner_state == BURNER_OFF, "0x01: burner off"); +} + +void test_gas_heater_ici_off_with_flow(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x02, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x02: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x02: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_OFF, "0x02: status=HEATER_OFF"); + TEST_ASSERT(!h->on, "0x02: on=false"); + TEST_ASSERT(h->water_flow_detected, "0x02: water flow detected"); + TEST_ASSERT(!h->locked_out, "0x02: not locked out"); + TEST_ASSERT(h->burner_state == BURNER_OFF, "0x02: burner off"); +} + +void test_gas_heater_ici_setpoint_reached(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x03, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x03: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x03: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_SETPOINT_REACHED, "0x03: status=HEATER_SETPOINT_REACHED"); + TEST_ASSERT(h->on, "0x03: on=true"); + TEST_ASSERT(h->water_flow_detected, "0x03: water flow detected"); + TEST_ASSERT(!h->locked_out, "0x03: not locked out"); + TEST_ASSERT(h->burner_state == BURNER_OFF, "0x03: burner off"); +} + +void test_gas_heater_ici_igniting(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x07, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x07: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x07: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_IGNITING, "0x07: status=HEATER_IGNITING"); + TEST_ASSERT(h->on, "0x07: on=true"); + TEST_ASSERT(h->water_flow_detected, "0x07: water flow detected"); + TEST_ASSERT(!h->locked_out, "0x07: not locked out"); + TEST_ASSERT(h->burner_state == BURNER_IGNITING, "0x07: burner igniting"); +} + +void test_gas_heater_ici_heating(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x0F, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x0F: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x0F: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_HEATING, "0x0F: status=HEATER_HEATING"); + TEST_ASSERT(h->on, "0x0F: on=true"); + TEST_ASSERT(h->water_flow_detected, "0x0F: water flow detected"); + TEST_ASSERT(!h->locked_out, "0x0F: not locked out"); + TEST_ASSERT(h->burner_state == BURNER_ALIGHT, "0x0F: burner alight"); +} + +void test_gas_heater_ici_cooldown(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x12, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x12: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x12: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_COOLDOWN, "0x12: status=HEATER_COOLDOWN"); + TEST_ASSERT(!h->on, "0x12: on=false"); + TEST_ASSERT(h->water_flow_detected, "0x12: water flow detected"); + TEST_ASSERT(h->locked_out, "0x12: locked out"); + TEST_ASSERT(h->burner_state == BURNER_OFF, "0x12: burner off"); +} + +void test_gas_heater_ici_locked_out(void) +{ + init_test_context(); + uint8_t msg[16]; + build_ici_gas_heater_msg(0x13, msg); + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "0x13: should decode"); + TEST_ASSERT(h->gas_heater_valid, "0x13: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_LOCKED_OUT, "0x13: status=HEATER_LOCKED_OUT"); + TEST_ASSERT(h->on, "0x13: on=true"); + TEST_ASSERT(h->water_flow_detected, "0x13: water flow detected"); + TEST_ASSERT(h->locked_out, "0x13: locked out"); + TEST_ASSERT(h->burner_state == BURNER_OFF, "0x13: burner off"); +} + +void test_gas_heater_hinrg_heating(void) +{ + // Confirms the HiNRG (0x0072) pattern also dispatches to the same handler + init_test_context(); + uint8_t msg[] = { + 0x02, 0x00, 0x72, 0xFF, 0xFF, 0x80, 0x00, + 0x12, 0x10, // cmd, length (16) + 0x14, // header checksum + 0x00, 0x0F, 0x00, 0x00, // payload: status=0x0F (heating) + 0x0F, // data checksum + 0x03 + }; + bool decoded = decode_message(msg, sizeof(msg), &test_ctx); + const pool_heater_t *h = &test_pool_state.heaters[0]; + TEST_ASSERT(decoded, "HiNRG 0x0F: should decode"); + TEST_ASSERT(h->gas_heater_valid, "HiNRG 0x0F: gas_heater_valid"); + TEST_ASSERT(h->status == HEATER_HEATING, "HiNRG 0x0F: status=HEATER_HEATING"); + TEST_ASSERT(h->burner_state == BURNER_ALIGHT, "HiNRG 0x0F: burner alight"); +} + /** * Run all tests */ @@ -775,6 +944,18 @@ int main(void) test_decode_pump_speed_zero(); test_decode_pump_activity(); + // Gas heater status tests (CMD 0x12) + printf("\n--- Gas Heater Status Tests ---\n"); + test_gas_heater_ici_off(); + test_gas_heater_ici_no_flow(); + test_gas_heater_ici_off_with_flow(); + test_gas_heater_ici_setpoint_reached(); + test_gas_heater_ici_igniting(); + test_gas_heater_ici_heating(); + test_gas_heater_ici_cooldown(); + test_gas_heater_ici_locked_out(); + test_gas_heater_hinrg_heating(); + // Malformed message tests printf("\n--- Malformed Message Tests ---\n"); test_decode_malformed_start(); diff --git a/test/test_replay.c b/test/test_replay.c index 7ce3e8d..7d4786a 100644 --- a/test/test_replay.c +++ b/test/test_replay.c @@ -42,6 +42,7 @@ void xSemaphoreGive(SemaphoreHandle_t s) { (void)s; } void mqtt_publish_mode(const pool_state_t *s) { (void)s; } void mqtt_publish_heater(const pool_state_t *s, int i) { (void)s; (void)i; } +void mqtt_publish_gas_heater(const pool_state_t *s, int i) { (void)s; (void)i; } void mqtt_publish_chlorinator(const pool_state_t *s) { (void)s; } void mqtt_publish_light(const pool_state_t *s, uint8_t z) { (void)s; (void)z; } void mqtt_publish_channel(const pool_state_t *s, uint8_t c) { (void)s; (void)c; }