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:
BIN
test/app_controller_test
Executable file
BIN
test/app_controller_test
Executable file
Binary file not shown.
311
test/app_controller_test.c
Normal file
311
test/app_controller_test.c
Normal 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.
Reference in New Issue
Block a user