feat: end-to-end app controller integration and tests

Add app_controller wiring UART, integration, status monitor and Zigbee
ZCL layers together with command/feedback flows and reset/recovery.
Add main.c entry point (guarded for test builds), app_controller_test
integration tests (full command cycle, timing under load, recovery),
Makefile target, and Readme updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 19:27:26 +03:00
parent 0c0883480d
commit b3e09d991d
12 changed files with 792 additions and 14 deletions

BIN
test/app_controller_test Executable file

Binary file not shown.

311
test/app_controller_test.c Normal file
View File

@@ -0,0 +1,311 @@
#include "unity.h"
#include "app_controller.h"
#include "midea_protocol.h"
#include "zigbee_zcl.h"
#include <string.h>
// -------------------------------------------------------------------------
// 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();
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.