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; }