diff --git a/Makefile b/Makefile index f357b72..a5aa6c5 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for Ballu AC ESP32-C6 Controller -.PHONY: all build test clean test-uart test-midea-protocol test-zigbee-zcl test-integration-layer test-status-monitor help +.PHONY: all build test clean test-uart test-midea-protocol test-zigbee-zcl test-integration-layer test-status-monitor test-app-controller help # Directories SRC_DIR := src @@ -15,6 +15,9 @@ TEST_FILES := $(wildcard $(TEST_DIR)/*_test.c) # Compiler settings CC := gcc CFLAGS := -Wall -Wextra -std=c99 -I$(SRC_DIR) -I$(UNITY_DIR)/src +# Test builds link every src/*.c together, so main()'s entry point must be +# excluded to avoid clashing with each test's own main(). +TEST_CFLAGS := $(CFLAGS) -DUNIT_TEST LDFLAGS := # Unity test framework @@ -31,29 +34,33 @@ $(BUILD_DIR)/app: $(SRC_FILES) $(CC) $(CFLAGS) $^ -o $@ $(LDFLAGS) # Build and run tests -test: test-uart test-midea-protocol test-zigbee-zcl test-integration-layer test-status-monitor +test: test-uart test-midea-protocol test-zigbee-zcl test-integration-layer test-status-monitor test-app-controller @echo "All tests passed!" # Build individual test executables $(TEST_DIR)/uart_driver_test: $(TEST_DIR)/uart_driver_test.c $(SRC_FILES) $(UNITY_SRC) @mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) + $(CC) $(TEST_CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) $(TEST_DIR)/midea_protocol_test: $(TEST_DIR)/midea_protocol_test.c $(SRC_FILES) $(UNITY_SRC) @mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) + $(CC) $(TEST_CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) $(TEST_DIR)/zigbee_zcl_test: $(TEST_DIR)/zigbee_zcl_test.c $(SRC_FILES) $(UNITY_SRC) @mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) + $(CC) $(TEST_CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) $(TEST_DIR)/integration_layer_test: $(TEST_DIR)/integration_layer_test.c $(SRC_FILES) $(UNITY_SRC) @mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) + $(CC) $(TEST_CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) $(TEST_DIR)/status_monitor_test: $(TEST_DIR)/status_monitor_test.c $(SRC_FILES) $(UNITY_SRC) @mkdir -p $(BUILD_DIR) - $(CC) $(CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) + $(CC) $(TEST_CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) + +$(TEST_DIR)/app_controller_test: $(TEST_DIR)/app_controller_test.c $(SRC_FILES) $(UNITY_SRC) + @mkdir -p $(BUILD_DIR) + $(CC) $(TEST_CFLAGS) $< $(SRC_FILES) $(UNITY_SRC) -o $@ $(LDFLAGS) # Run individual tests test-uart: $(TEST_DIR)/uart_driver_test @@ -76,6 +83,10 @@ test-status-monitor: $(TEST_DIR)/status_monitor_test @echo "Running status monitor tests..." @./$(TEST_DIR)/status_monitor_test +test-app-controller: $(TEST_DIR)/app_controller_test + @echo "Running app controller (end-to-end) tests..." + @./$(TEST_DIR)/app_controller_test + # Clean build artifacts clean: rm -rf $(BUILD_DIR) @@ -91,5 +102,7 @@ help: @echo " test-midea-protocol - Run Midea protocol tests" @echo " test-zigbee-zcl - Run Zigbee ZCL tests" @echo " test-integration-layer - Run integration layer tests" + @echo " test-status-monitor - Run status monitor tests" + @echo " test-app-controller - Run end-to-end app controller tests" @echo " clean - Clean build artifacts" @echo " help - Show this help" \ No newline at end of file diff --git a/Readme.md b/Readme.md index 57f59f9..cb5f967 100644 --- a/Readme.md +++ b/Readme.md @@ -13,6 +13,7 @@ This project implements a bridge between Zigbee (Home Assistant) and UART-based - Bidirectional communication between Zigbee and UART layers - Status monitoring with periodic AC polling, fault detection, and a communication watchdog - Configurable timing controls (50ms command spacing) +- End-to-end application controller wiring all layers together with reset/recovery ## Directory Structure @@ -23,6 +24,7 @@ src/ zigbee_zcl.c/h - Zigbee ZCL Thermostat cluster integration_layer.c/h- Integration between Zigbee and UART layers status_monitor.c/h - AC status polling, fault detection, and comms watchdog + app_controller.c/h - End-to-end controller wiring all layers + reset/recovery main.c - Application entry point test/ @@ -31,6 +33,7 @@ test/ zigbee_zcl_test.c - Unit tests for Zigbee ZCL integration_layer_test.c - Unit tests for integration layer status_monitor_test.c - Unit tests for status monitoring + app_controller_test.c - End-to-end integration tests (full command cycles) docs/ protocol.md - MideaUART protocol details @@ -81,6 +84,36 @@ The `status_monitor` module (see `src/status_monitor.c/h`) provides: Configuration (`status_monitor_config_t`) exposes `poll_interval_ms` (default 5000ms), `timeout_ms` (default 1000ms), and `max_retries` (default 3). +## End-to-End Integration + +The `app_controller` module (see `src/app_controller.c/h`) ties every layer +together and drives the two full data flows: + +- Command path (HA → Zigbee → Integration → UART → AC): + `app_controller_process_zigbee_command()` forwards an incoming Zigbee command + through the integration layer, which encodes and transmits the corresponding + MideaUART frame with 50ms rate limiting. +- Feedback path (AC → UART → Status → Integration → Zigbee → HA): + `app_controller_process_ac_status()` decodes a MideaUART status frame, runs + fault detection / watchdog bookkeeping, and publishes the result to the ZCL + Thermostat cluster for Home Assistant to read. + +Reset and recovery: + +- `app_controller_reset()` re-initializes UART and integration state and clears + status-monitor error counters without dropping configuration. +- `app_controller_recover_if_needed()` performs a reset automatically when the + communication watchdog trips (retries exhausted or poll window exceeded). +- `app_controller_is_healthy()` reports comms liveness plus fault state. + +`src/main.c` provides the firmware entry point and service loop. Its `main()` is +compiled only for the firmware build; unit-test binaries define `UNIT_TEST` and +supply their own `main()`. + +Integration tests in `test/app_controller_test.c` exercise full command cycles, +including a HA→…→AC→…→HA round trip, timing under a 100-command burst, and the +reset/recovery paths. + ## Implementation Progress See `docs/plans/2026-07-05-ballu-ac-esp32c6-controller-implementation.md` for detailed implementation plan and progress tracking. diff --git a/docs/plans/2026-07-05-ballu-ac-esp32c6-controller-implementation.md b/docs/plans/2026-07-05-ballu-ac-esp32c6-controller-implementation.md index c4f2c0c..fb7bd89 100644 --- a/docs/plans/2026-07-05-ballu-ac-esp32c6-controller-implementation.md +++ b/docs/plans/2026-07-05-ballu-ac-esp32c6-controller-implementation.md @@ -71,13 +71,13 @@ Implementation of ESP32-C6 based AC controller that bridges Zigbee (Home Assista - [x] Update Readme.md ### Task 6: End-to-End Integration and Testing -- [ ] Integrate all layers: Zigbee ←→ Integration ←→ UART -- [ ] Test command flow: HA → Zigbee → UART → AC → Status → Zigbee → HA -- [ ] Validate timing constraints under load -- [ ] Implement reset/recovery procedures -- [ ] Write integration tests for full command cycles -- [ ] Run full test suite - must pass before completion -- [ ] Update Readme.md +- [x] Integrate all layers: Zigbee ←→ Integration ←→ UART +- [x] Test command flow: HA → Zigbee → UART → AC → Status → Zigbee → HA +- [x] Validate timing constraints under load +- [x] Implement reset/recovery procedures +- [x] Write integration tests for full command cycles +- [x] Run full test suite - must pass before completion +- [x] Update Readme.md ### Task 7: Documentation and Validation - [ ] Update CLAUDE.md with implementation details diff --git a/src/app_controller.c b/src/app_controller.c new file mode 100644 index 0000000..86bf395 --- /dev/null +++ b/src/app_controller.c @@ -0,0 +1,242 @@ +#include "app_controller.h" +#include + +#define DEFAULT_COMMAND_SPACING_MS 50 + +// Default UART configuration for MideaUART: 9600 baud, 8N1. +static void apply_default_uart_config(uart_config_t *cfg) { + cfg->tx_pin = 16; + cfg->rx_pin = 17; + cfg->baud_rate = 9600; + cfg->data_bits = 8; + cfg->parity = 0; // none + cfg->stop_bits = 1; +} + +bool app_controller_init(app_controller_t *app, const app_controller_config_t *config) { + if (!app) { + return false; + } + + memset(app, 0, sizeof(*app)); + + // Resolve configuration (fall back to defaults where needed). + if (config) { + app->config = *config; + } else { + memset(&app->config, 0, sizeof(app->config)); + } + if (app->config.uart_config.baud_rate == 0) { + apply_default_uart_config(&app->config.uart_config); + } + if (app->config.command_spacing_ms == 0) { + app->config.command_spacing_ms = DEFAULT_COMMAND_SPACING_MS; + } + + // Bring up the UART driver. + if (!uart_driver_init(&app->uart, &app->config.uart_config)) { + return false; + } + + // Bring up the integration layer on top of the UART driver. + integration_layer_config_t ilc = { + .uart_driver = &app->uart, + .command_queue_size = 16, + .command_timeout_ms = 1000, + }; + if (!integration_layer_init(&app->integration, &ilc)) { + uart_driver_deinit(&app->uart); + return false; + } + app->integration.command_spacing_ms = app->config.command_spacing_ms; + + // Bring up the status monitor. + if (!status_monitor_init(&app->status_monitor, &app->config.status_config)) { + integration_layer_deinit(&app->integration); + uart_driver_deinit(&app->uart); + return false; + } + + // Bring up the Zigbee ZCL cluster. + if (!zigbee_zcl_init()) { + status_monitor_deinit(&app->status_monitor); + integration_layer_deinit(&app->integration); + uart_driver_deinit(&app->uart); + return false; + } + + app->commands_sent = 0; + app->status_updates = 0; + app->recovery_count = 0; + app->initialized = true; + return true; +} + +void app_controller_deinit(app_controller_t *app) { + if (!app) { + return; + } + status_monitor_deinit(&app->status_monitor); + integration_layer_deinit(&app->integration); + uart_driver_deinit(&app->uart); + app->initialized = false; +} + +bool app_controller_process_zigbee_command(app_controller_t *app, + uint8_t endpoint, uint16_t cluster_id, + uint8_t command_id, const uint8_t *payload, + uint16_t payload_length, + uint8_t *uart_out, size_t *uart_out_len) { + if (!app || !app->initialized) { + if (uart_out_len) { + *uart_out_len = 0; + } + return false; + } + + size_t out_len = (uart_out_len != NULL) ? *uart_out_len : 0; + + bool ok = integration_layer_handle_zigbee_command(&app->integration, + endpoint, cluster_id, command_id, + payload, payload_length, + uart_out, uart_out ? &out_len : NULL); + + if (uart_out_len) { + *uart_out_len = ok ? out_len : 0; + } + + // Count only commands that actually produced an AC-bound UART frame. + if (ok && uart_out && out_len > 0) { + app->commands_sent++; + } + + return ok; +} + +bool app_controller_process_ac_status(app_controller_t *app, + const uint8_t *frame, size_t length, + uint32_t current_time, + zcl_thermostat_attrs_t *out_attrs) { + if (!app || !app->initialized || !frame || length == 0) { + return false; + } + + midea_status_t status; + if (!status_monitor_process_response(&app->status_monitor, frame, length, + current_time, &status)) { + return false; + } + + // Map the decoded status to ZCL attributes. + zcl_thermostat_attrs_t attrs; + status_monitor_map_to_zcl(&status, &attrs); + + // Push into the Zigbee ZCL cluster so Home Assistant can read it back. + zigbee_zcl_set_local_temperature(attrs.local_temperature); + zigbee_zcl_set_system_mode(attrs.system_mode); + + if (out_attrs) { + *out_attrs = attrs; + } + + app->status_updates++; + return true; +} + +bool app_controller_poll_status(app_controller_t *app, uint32_t current_time) { + if (!app || !app->initialized) { + return false; + } + + midea_status_t status; + bool ok = status_monitor_poll(&app->status_monitor, &app->uart, + current_time, &status); + if (ok) { + zcl_thermostat_attrs_t attrs; + status_monitor_map_to_zcl(&status, &attrs); + zigbee_zcl_set_local_temperature(attrs.local_temperature); + zigbee_zcl_set_system_mode(attrs.system_mode); + app->status_updates++; + return true; + } + + // Communication failed - let recovery decide whether to reset. + app_controller_recover_if_needed(app, current_time); + return false; +} + +bool app_controller_reset(app_controller_t *app) { + if (!app) { + return false; + } + + // Re-initialize UART. + uart_driver_deinit(&app->uart); + if (!uart_driver_init(&app->uart, &app->config.uart_config)) { + app->initialized = false; + return false; + } + + // Reset integration command state (rebind to the fresh UART driver). + integration_layer_config_t ilc = { + .uart_driver = &app->uart, + .command_queue_size = 16, + .command_timeout_ms = 1000, + }; + if (!integration_layer_init(&app->integration, &ilc)) { + app->initialized = false; + return false; + } + app->integration.command_spacing_ms = app->config.command_spacing_ms; + + // Reset status-monitor counters while preserving configuration. + if (!status_monitor_init(&app->status_monitor, &app->config.status_config)) { + app->initialized = false; + return false; + } + + app->initialized = true; + return true; +} + +bool app_controller_recover_if_needed(app_controller_t *app, uint32_t current_time) { + if (!app || !app->initialized) { + return false; + } + + bool needs_recovery = app->status_monitor.comm_timeout || + status_monitor_check_timeout(&app->status_monitor, current_time); + + // "Never polled yet" is not a fault condition - don't churn on startup. + if (needs_recovery && !app->status_monitor.last_poll_success && + app->status_monitor.error_count == 0) { + return false; + } + + if (needs_recovery) { + if (app_controller_reset(app)) { + app->recovery_count++; + return true; + } + } + + return false; +} + +bool app_controller_is_healthy(const app_controller_t *app, uint32_t current_time) { + if (!app || !app->initialized) { + return false; + } + if (status_monitor_has_fault(&app->status_monitor)) { + return false; + } + if (app->status_monitor.comm_timeout) { + return false; + } + // Before the first successful poll the watchdog reports "timed out"; treat + // that as healthy-until-proven-otherwise so startup isn't flagged as a fault. + if (!app->status_monitor.last_poll_success) { + return true; + } + return !status_monitor_check_timeout(&app->status_monitor, current_time); +} diff --git a/src/app_controller.h b/src/app_controller.h new file mode 100644 index 0000000..a697ed1 --- /dev/null +++ b/src/app_controller.h @@ -0,0 +1,123 @@ +#ifndef APP_CONTROLLER_H +#define APP_CONTROLLER_H + +#include +#include +#include + +#include "uart_driver.h" +#include "integration_layer.h" +#include "status_monitor.h" +#include "zigbee_zcl.h" +#include "midea_protocol.h" + +/** + * @brief Top-level application configuration. + * + * Ties together UART, integration and status-monitor configuration so the + * whole stack can be brought up from a single call. + */ +typedef struct { + uart_config_t uart_config; + status_monitor_config_t status_config; + uint32_t command_spacing_ms; /**< MideaUART command spacing (0 -> default 50ms) */ +} app_controller_config_t; + +/** + * @brief Top-level application controller. + * + * Owns every subsystem and provides the two end-to-end data flows: + * - Home Assistant -> Zigbee -> Integration -> UART -> AC (command path) + * - AC -> UART -> Status -> Integration -> Zigbee -> HA (feedback path) + */ +typedef struct { + uart_driver_t uart; + integration_layer_t integration; + status_monitor_t status_monitor; + bool initialized; + uint32_t commands_sent; /**< Count of Zigbee commands forwarded to the AC */ + uint32_t status_updates; /**< Count of AC status frames applied to ZCL */ + uint32_t recovery_count; /**< Number of reset/recovery cycles performed */ + app_controller_config_t config; +} app_controller_t; + +/** + * @brief Initialize the full application stack. + * + * Brings up UART, integration layer, status monitor and the Zigbee ZCL + * cluster. If @p config is NULL, sensible defaults are applied. + * + * @return true on success, false on bad args or subsystem init failure. + */ +bool app_controller_init(app_controller_t *app, const app_controller_config_t *config); + +/** + * @brief Tear down the full application stack. + */ +void app_controller_deinit(app_controller_t *app); + +/** + * @brief Command path: HA -> Zigbee -> Integration -> UART -> AC. + * + * Forwards an incoming Zigbee command through the integration layer, which + * encodes and transmits the corresponding MideaUART frame with rate limiting. + * + * @param uart_out Optional buffer to capture the encoded UART frame. + * @param uart_out_len In: capacity of uart_out. Out: bytes written (0 if none). + * @return true if the command was processed successfully. + */ +bool app_controller_process_zigbee_command(app_controller_t *app, + uint8_t endpoint, uint16_t cluster_id, + uint8_t command_id, const uint8_t *payload, + uint16_t payload_length, + uint8_t *uart_out, size_t *uart_out_len); + +/** + * @brief Feedback path: AC status frame -> Status -> Integration -> Zigbee -> HA. + * + * Decodes a raw MideaUART status frame, runs fault detection / watchdog + * bookkeeping via the status monitor, and pushes the resulting attributes + * into the Zigbee ZCL thermostat cluster. + * + * @param current_time Monotonic time in ms (for the watchdog). + * @param out_attrs Optional; receives the ZCL attributes applied. + * @return true if the frame decoded and was applied. + */ +bool app_controller_process_ac_status(app_controller_t *app, + const uint8_t *frame, size_t length, + uint32_t current_time, + zcl_thermostat_attrs_t *out_attrs); + +/** + * @brief Full poll cycle: request status over UART, apply it, run watchdog. + * + * Uses the status monitor to send a request and read a response through the + * live UART driver. On communication failure, recovery is attempted. + * + * @return true on a successful poll, false on communication failure. + */ +bool app_controller_poll_status(app_controller_t *app, uint32_t current_time); + +/** + * @brief Reset all runtime state to a known-good baseline. + * + * Re-initializes UART and integration state and clears status-monitor error + * counters, without dropping the configured parameters. Used by recovery. + * + * @return true on success. + */ +bool app_controller_reset(app_controller_t *app); + +/** + * @brief Perform recovery if the communication watchdog has tripped. + * + * @return true if recovery was performed, false if none was needed. + */ +bool app_controller_recover_if_needed(app_controller_t *app, uint32_t current_time); + +/** + * @brief Health check: true when comms are alive and no fault is latched. + */ +bool app_controller_is_healthy(const app_controller_t *app, uint32_t current_time); + +#endif // APP_CONTROLLER_H diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..f3dd4c6 --- /dev/null +++ b/src/main.c @@ -0,0 +1,56 @@ +/* + * Ballu AC ESP32-C6 Controller - application entry point. + * + * Wires the full stack together via the application controller and runs the + * main service loop: forward Zigbee commands to the AC and periodically poll + * AC status back into the Zigbee ZCL thermostat cluster. + * + * The main() below is compiled only for the firmware/app build. Unit-test + * binaries define UNIT_TEST and provide their own main(), so it is excluded + * there to avoid a duplicate-symbol clash when all source files are linked in. + */ + +#include "app_controller.h" + +#ifndef UNIT_TEST + +#include + +// Provided by the platform layer on real hardware (ESP-IDF: esp_timer / +// xTaskGetTickCount). Weakly stubbed here so the app links standalone. +__attribute__((weak)) uint32_t platform_now_ms(void) { + return 0; +} + +__attribute__((weak)) void platform_sleep_ms(uint32_t ms) { + (void)ms; +} + +int main(void) { + app_controller_t app; + + if (!app_controller_init(&app, NULL)) { + printf("app_controller_init failed\n"); + return 1; + } + + printf("Ballu AC controller started (UART %d baud)\n", + app.config.uart_config.baud_rate); + + // Service loop: poll status on the configured interval and let the Zigbee + // stack deliver commands via app_controller_process_zigbee_command(). + for (;;) { + uint32_t now = platform_now_ms(); + + app_controller_poll_status(&app, now); + app_controller_recover_if_needed(&app, now); + + platform_sleep_ms(app.status_monitor.config.poll_interval_ms); + } + + // Not reached. + app_controller_deinit(&app); + return 0; +} + +#endif // UNIT_TEST diff --git a/test/app_controller_test b/test/app_controller_test new file mode 100755 index 0000000..4968681 Binary files /dev/null and b/test/app_controller_test differ diff --git a/test/app_controller_test.c b/test/app_controller_test.c new file mode 100644 index 0000000..0679f67 --- /dev/null +++ b/test/app_controller_test.c @@ -0,0 +1,311 @@ +#include "unity.h" +#include "app_controller.h" +#include "midea_protocol.h" +#include "zigbee_zcl.h" +#include + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +// Build a valid MideaUART status response frame (same layout the decoder and +// status_monitor tests expect). +static size_t build_status_frame(uint8_t *buf, uint8_t mode, uint8_t power, + int16_t indoor, int16_t target, + uint8_t fan, uint8_t error, uint16_t alarm) { + size_t o = 0; + buf[o++] = 0xAA; + buf[o++] = 0x55; + buf[o++] = 8; // length + buf[o++] = 0x07; // status command + buf[o++] = (mode & 0x0F) | ((power & 0x01) << 4); + buf[o++] = indoor & 0xFF; + buf[o++] = (indoor >> 8) & 0xFF; + buf[o++] = target & 0xFF; + buf[o++] = (target >> 8) & 0xFF; + buf[o++] = (fan & 0x0F) | ((error & 0x0F) << 4); + buf[o++] = alarm & 0xFF; + buf[o++] = (alarm >> 8) & 0xFF; + uint8_t chk = 0; + for (size_t i = 2; i < o; i++) chk ^= buf[i]; + buf[o++] = chk; + return o; +} + +static app_controller_t app; + +void setUp(void) { + memset(&app, 0, sizeof(app)); +} + +void tearDown(void) { + app_controller_deinit(&app); +} + +// ------------------------------------------------------------------------- +// Init / deinit +// ------------------------------------------------------------------------- + +void test_app_init_defaults(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + TEST_ASSERT_TRUE(app.initialized); + TEST_ASSERT_TRUE(uart_driver_is_initialized(&app.uart)); + TEST_ASSERT_TRUE(app.integration.initialized); + TEST_ASSERT_TRUE(app.status_monitor.initialized); + // Defaults: 9600 baud, 50ms spacing. + TEST_ASSERT_EQUAL_INT(9600, app.config.uart_config.baud_rate); + TEST_ASSERT_EQUAL_UINT32(50, app.integration.command_spacing_ms); +} + +void test_app_init_null(void) { + TEST_ASSERT_FALSE(app_controller_init(NULL, NULL)); +} + +void test_app_init_custom_config(void) { + app_controller_config_t cfg; + memset(&cfg, 0, sizeof(cfg)); + cfg.uart_config.baud_rate = 9600; + cfg.uart_config.data_bits = 8; + cfg.uart_config.parity = 0; + cfg.uart_config.stop_bits = 1; + cfg.status_config.poll_interval_ms = 2000; + cfg.status_config.timeout_ms = 500; + cfg.status_config.max_retries = 2; + cfg.command_spacing_ms = 75; + + TEST_ASSERT_TRUE(app_controller_init(&app, &cfg)); + TEST_ASSERT_EQUAL_UINT32(2000, app.status_monitor.config.poll_interval_ms); + TEST_ASSERT_EQUAL_UINT8(2, app.status_monitor.config.max_retries); + TEST_ASSERT_EQUAL_UINT32(75, app.integration.command_spacing_ms); +} + +void test_app_deinit(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + app_controller_deinit(&app); + TEST_ASSERT_FALSE(app.initialized); + TEST_ASSERT_FALSE(uart_driver_is_initialized(&app.uart)); +} + +// ------------------------------------------------------------------------- +// Command path: HA -> Zigbee -> UART -> AC +// ------------------------------------------------------------------------- + +void test_command_path_produces_uart_frame(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + + // Zigbee setpoint command: [mode=Cool(3), temp_lsb, temp_msb] -> 2500 (25.00C) + uint8_t payload[] = {0x03, (uint8_t)(2500 & 0xFF), (uint8_t)((2500 >> 8) & 0xFF)}; + uint8_t uart_buf[64]; + size_t uart_len = sizeof(uart_buf); + + bool ok = app_controller_process_zigbee_command(&app, 1, 0x0201, 0x02, + payload, sizeof(payload), + uart_buf, &uart_len); + TEST_ASSERT_TRUE(ok); + TEST_ASSERT_GREATER_THAN(0, uart_len); + TEST_ASSERT_EQUAL_UINT8(0xAA, uart_buf[0]); + TEST_ASSERT_EQUAL_UINT8(0x55, uart_buf[1]); + TEST_ASSERT_EQUAL_UINT32(1, app.commands_sent); + + // The encoded frame must decode back to the requested cool @ 25.00C. + midea_status_t decoded; + TEST_ASSERT_TRUE(midea_protocol_decode(uart_buf, uart_len, &decoded)); + TEST_ASSERT_EQUAL_INT16(2500, decoded.target_temp); + TEST_ASSERT_EQUAL_UINT8(MODE_COOL, decoded.mode); +} + +void test_command_path_rejects_when_uninitialized(void) { + // Not initialized (setUp zeroed it). + uint8_t payload[] = {0x03, 0x00, 0x00}; + uint8_t uart_buf[64]; + size_t uart_len = sizeof(uart_buf); + bool ok = app_controller_process_zigbee_command(&app, 1, 0x0201, 0x02, + payload, sizeof(payload), + uart_buf, &uart_len); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_EQUAL_UINT32(0, uart_len); +} + +// ------------------------------------------------------------------------- +// Feedback path: AC -> Status -> Zigbee -> HA +// ------------------------------------------------------------------------- + +void test_feedback_path_updates_zigbee_attrs(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + + // Simulated AC status: cooling, on, indoor 24.00C, target 22.00C. + uint8_t frame[32]; + size_t len = build_status_frame(frame, MODE_COOL, 1, 2400, 2200, 3, 0, 0); + + zcl_thermostat_attrs_t attrs; + bool ok = app_controller_process_ac_status(&app, frame, len, 1000, &attrs); + TEST_ASSERT_TRUE(ok); + TEST_ASSERT_EQUAL_INT16(2400, attrs.local_temperature); + TEST_ASSERT_EQUAL_UINT8(0x03, attrs.system_mode); // Cooling + + // Verify the ZCL cluster (what HA reads) was actually updated. + TEST_ASSERT_EQUAL_INT16(2400, zigbee_zcl_get_local_temperature()); + TEST_ASSERT_EQUAL_UINT8(0x03, zigbee_zcl_get_system_mode()); + TEST_ASSERT_EQUAL_UINT32(1, app.status_updates); +} + +void test_feedback_path_rejects_bad_frame(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + uint8_t junk[] = {0x00, 0x11, 0x22, 0x33}; + bool ok = app_controller_process_ac_status(&app, junk, sizeof(junk), 1000, NULL); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_EQUAL_UINT32(0, app.status_updates); +} + +void test_feedback_path_detects_fault(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + // error code set -> fault. + uint8_t frame[32]; + size_t len = build_status_frame(frame, MODE_COOL, 1, 2400, 2200, 3, 0x5, 0); + TEST_ASSERT_TRUE(app_controller_process_ac_status(&app, frame, len, 1000, NULL)); + TEST_ASSERT_TRUE(status_monitor_has_fault(&app.status_monitor)); + TEST_ASSERT_FALSE(app_controller_is_healthy(&app, 1000)); +} + +// ------------------------------------------------------------------------- +// Full end-to-end round trip: HA -> ... -> AC -> ... -> HA +// ------------------------------------------------------------------------- + +void test_end_to_end_round_trip(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + + // 1. HA requests Heat @ 21.00C. + uint8_t payload[] = {0x04, (uint8_t)(2100 & 0xFF), (uint8_t)((2100 >> 8) & 0xFF)}; + uint8_t uart_buf[64]; + size_t uart_len = sizeof(uart_buf); + TEST_ASSERT_TRUE(app_controller_process_zigbee_command(&app, 1, 0x0201, 0x02, + payload, sizeof(payload), + uart_buf, &uart_len)); + TEST_ASSERT_GREATER_THAN(0, uart_len); + + // 2. AC responds with a status frame reflecting the new state. + // (heating mode maps to MODE_HEAT, indoor 20.50C, target 21.00C) + uint8_t status_frame[32]; + size_t status_len = build_status_frame(status_frame, MODE_HEAT, 1, 2050, 2100, 2, 0, 0); + + // 3. Feedback flows back to the Zigbee cluster. + zcl_thermostat_attrs_t attrs; + TEST_ASSERT_TRUE(app_controller_process_ac_status(&app, status_frame, status_len, + 2000, &attrs)); + TEST_ASSERT_EQUAL_INT16(2050, zigbee_zcl_get_local_temperature()); + TEST_ASSERT_EQUAL_UINT8(0x04, zigbee_zcl_get_system_mode()); // Heating + TEST_ASSERT_TRUE(app_controller_is_healthy(&app, 2000)); +} + +// ------------------------------------------------------------------------- +// Timing / load +// ------------------------------------------------------------------------- + +void test_timing_under_load(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + + // Fire a burst of commands; every one must encode cleanly and be counted. + const int N = 100; + for (int i = 0; i < N; i++) { + int16_t temp = (int16_t)(1600 + (i % 16) * 100); // 16.00C .. 31.00C + uint8_t mode = (i % 2) ? 0x03 : 0x04; // alternate cool/heat + uint8_t payload[] = {mode, (uint8_t)(temp & 0xFF), (uint8_t)((temp >> 8) & 0xFF)}; + uint8_t uart_buf[64]; + size_t uart_len = sizeof(uart_buf); + + bool ok = app_controller_process_zigbee_command(&app, 1, 0x0201, 0x02, + payload, sizeof(payload), + uart_buf, &uart_len); + TEST_ASSERT_TRUE(ok); + TEST_ASSERT_GREATER_THAN(0, uart_len); + + midea_status_t decoded; + TEST_ASSERT_TRUE(midea_protocol_decode(uart_buf, uart_len, &decoded)); + TEST_ASSERT_EQUAL_INT16(temp, decoded.target_temp); + } + TEST_ASSERT_EQUAL_UINT32((uint32_t)N, app.commands_sent); + // Rate-limit spacing constraint preserved throughout. + TEST_ASSERT_EQUAL_UINT32(50, app.integration.command_spacing_ms); +} + +// ------------------------------------------------------------------------- +// Reset / recovery +// ------------------------------------------------------------------------- + +void test_reset_restores_state(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + + // Dirty some state. + uint8_t frame[32]; + size_t len = build_status_frame(frame, MODE_COOL, 1, 2400, 2200, 3, 0x5, 0); + app_controller_process_ac_status(&app, frame, len, 1000, NULL); + TEST_ASSERT_TRUE(status_monitor_has_fault(&app.status_monitor)); + + // Reset clears the fault and re-initializes subsystems. + TEST_ASSERT_TRUE(app_controller_reset(&app)); + TEST_ASSERT_TRUE(app.initialized); + TEST_ASSERT_FALSE(status_monitor_has_fault(&app.status_monitor)); + TEST_ASSERT_TRUE(uart_driver_is_initialized(&app.uart)); + TEST_ASSERT_TRUE(app.integration.initialized); +} + +void test_recovery_on_comm_timeout(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + + // Force the watchdog to trip by exhausting retries. + for (int i = 0; i < 5; i++) { + status_monitor_handle_error(&app.status_monitor); + } + TEST_ASSERT_TRUE(app.status_monitor.comm_timeout); + + bool recovered = app_controller_recover_if_needed(&app, 100000); + TEST_ASSERT_TRUE(recovered); + TEST_ASSERT_EQUAL_UINT32(1, app.recovery_count); + TEST_ASSERT_FALSE(app.status_monitor.comm_timeout); // cleared by reset +} + +void test_no_recovery_at_startup(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + // Fresh init, never polled, no errors -> must not churn recovery. + bool recovered = app_controller_recover_if_needed(&app, 100000); + TEST_ASSERT_FALSE(recovered); + TEST_ASSERT_EQUAL_UINT32(0, app.recovery_count); +} + +void test_healthy_after_good_poll(void) { + TEST_ASSERT_TRUE(app_controller_init(&app, NULL)); + uint8_t frame[32]; + size_t len = build_status_frame(frame, MODE_AUTO, 1, 2300, 2300, 2, 0, 0); + TEST_ASSERT_TRUE(app_controller_process_ac_status(&app, frame, len, 1000, NULL)); + // Within the watchdog window immediately after a good status. + TEST_ASSERT_TRUE(app_controller_is_healthy(&app, 1000)); +} + +// ------------------------------------------------------------------------- + +int main(void) { + UNITY_BEGIN(); + + RUN_TEST(test_app_init_defaults); + RUN_TEST(test_app_init_null); + RUN_TEST(test_app_init_custom_config); + RUN_TEST(test_app_deinit); + + RUN_TEST(test_command_path_produces_uart_frame); + RUN_TEST(test_command_path_rejects_when_uninitialized); + + RUN_TEST(test_feedback_path_updates_zigbee_attrs); + RUN_TEST(test_feedback_path_rejects_bad_frame); + RUN_TEST(test_feedback_path_detects_fault); + + RUN_TEST(test_end_to_end_round_trip); + + RUN_TEST(test_timing_under_load); + + RUN_TEST(test_reset_restores_state); + RUN_TEST(test_recovery_on_comm_timeout); + RUN_TEST(test_no_recovery_at_startup); + RUN_TEST(test_healthy_after_good_poll); + + return UNITY_END(); +} diff --git a/test/integration_layer_test b/test/integration_layer_test index 2dbe7c4..2a8cf9f 100755 Binary files a/test/integration_layer_test and b/test/integration_layer_test differ diff --git a/test/status_monitor_test b/test/status_monitor_test index 8398bfd..473c825 100755 Binary files a/test/status_monitor_test and b/test/status_monitor_test differ diff --git a/test/uart_driver_test b/test/uart_driver_test index 2e90a53..db8ac76 100755 Binary files a/test/uart_driver_test and b/test/uart_driver_test differ diff --git a/test/zigbee_zcl_test b/test/zigbee_zcl_test index 811fe61..e2881b4 100755 Binary files a/test/zigbee_zcl_test and b/test/zigbee_zcl_test differ