diff --git a/docs/cargo-api-contract.md b/docs/cargo-api-contract.md index 4efe092..5e3177b 100644 --- a/docs/cargo-api-contract.md +++ b/docs/cargo-api-contract.md @@ -183,3 +183,26 @@ Compatibility rule: - The API should support the current 2-channel board while allowing a future 6-output profile without changing dashboard/client assumptions. - Clients should render outputs dynamically from the API response. - Clients should not hardcode relay count. + +## Implemented Output Compatibility Layer + +The Cargo ESP firmware now exposes relay/output state as an output-count-agnostic list. + +The current active hardware profile remains: + + generic_esp32_2ch_relay + +The API includes: + +- `hardware_profile` +- `output_count` +- `relays[]` +- `relays[].role` +- `relays[].hardware_channel` +- `relays[].hardware_profile` +- `relays[].available` +- `relays[].state` + +This keeps the current 2-channel relay board working while preparing the API shape for the future LilyGO T-Relay-S3 6-way profile. + +When the LilyGO board arrives, the expected migration should be a hardware profile and relay driver change, not a dashboard/API redesign. diff --git a/docs/project-state.md b/docs/project-state.md index b1253a3..0df3a98 100644 --- a/docs/project-state.md +++ b/docs/project-state.md @@ -263,3 +263,17 @@ Primary goal: - Keep 2-channel testing functional now. - Support future 6-output rendering/control cleanly through the same API shape. + +## Output-Count-Agnostic Relay API + +The Cargo ESP API now represents relays/outputs as a hardware-profile-aware list instead of hardcoding client assumptions around exactly two relays. + +Current active hardware profile: + + generic_esp32_2ch_relay + +Future planned profile: + + lilygo_t_relay_s3_6ch_wupp + +Current 2-channel testing should continue until the LilyGO board arrives and is bench tested. diff --git a/firmware/esp32/overland-controller/app_config.cpp b/firmware/esp32/overland-controller/app_config.cpp index 1391aae..5d80a8a 100644 --- a/firmware/esp32/overland-controller/app_config.cpp +++ b/firmware/esp32/overland-controller/app_config.cpp @@ -13,14 +13,20 @@ void loadDefaultConfig() { appConfig.relays[0] = { "relay_1", "Relay 1", + "aux", + 1, RELAY_1_PIN, + true, true }; appConfig.relays[1] = { "relay_2", "Relay 2", + "fridge", + 2, RELAY_2_PIN, + true, true }; @@ -160,8 +166,14 @@ void printConfig() { Serial.print(appConfig.relays[i].id); Serial.print(" / "); Serial.print(appConfig.relays[i].name); + Serial.print(" / role "); + Serial.print(appConfig.relays[i].role); + Serial.print(" / channel "); + Serial.print(appConfig.relays[i].hardwareChannel); Serial.print(" / GPIO "); Serial.print(appConfig.relays[i].pin); + Serial.print(" / available "); + Serial.print(appConfig.relays[i].available ? "true" : "false"); Serial.print(" / enabled "); Serial.println(appConfig.relays[i].enabled ? "true" : "false"); } diff --git a/firmware/esp32/overland-controller/app_config.h b/firmware/esp32/overland-controller/app_config.h index 561886c..3cfe7e0 100644 --- a/firmware/esp32/overland-controller/app_config.h +++ b/firmware/esp32/overland-controller/app_config.h @@ -4,12 +4,16 @@ #define MAX_RELAYS 2 #define MAX_TEMP_SENSORS 8 +#define HARDWARE_PROFILE "generic_esp32_2ch_relay" struct RelayConfig { String id; String name; + String role; + uint8_t hardwareChannel; uint8_t pin; bool enabled; + bool available; }; struct TempSensorConfig { diff --git a/firmware/esp32/overland-controller/overland-controller.ino b/firmware/esp32/overland-controller/overland-controller.ino index 01d6227..5dee9bd 100644 --- a/firmware/esp32/overland-controller/overland-controller.ino +++ b/firmware/esp32/overland-controller/overland-controller.ino @@ -1703,11 +1703,11 @@ void oledShowRelayPage() { oled.print(appConfig.relays[0].name); oled.print(": "); - oled.println(relays.relay1 ? "ON" : "OFF"); + oled.println(relays.state[0] ? "ON" : "OFF"); oled.print(appConfig.relays[1].name); oled.print(": "); - oled.println(relays.relay2 ? "ON" : "OFF"); + oled.println(relays.state[1] ? "ON" : "OFF"); oled.display(); } @@ -1945,13 +1945,20 @@ void printNetworkStatus() { void buildConfigDocument(JsonObject doc) { doc["device_name"] = appConfig.deviceName; + doc["hardware_profile"] = HARDWARE_PROFILE; + doc["output_count"] = MAX_RELAYS; + JsonArray relaysArray = doc.createNestedArray("relays"); for (int i = 0; i < MAX_RELAYS; i++) { JsonObject relay = relaysArray.createNestedObject(); relay["id"] = appConfig.relays[i].id; relay["name"] = appConfig.relays[i].name; + relay["role"] = appConfig.relays[i].role; + relay["hardware_channel"] = appConfig.relays[i].hardwareChannel; + relay["hardware_profile"] = HARDWARE_PROFILE; relay["pin"] = appConfig.relays[i].pin; relay["enabled"] = appConfig.relays[i].enabled; + relay["available"] = appConfig.relays[i].available; } JsonObject bms = doc.createNestedObject("bms"); @@ -2277,20 +2284,18 @@ void buildStatusDocument(JsonDocument& doc) { } JsonArray relayStates = doc.createNestedArray("relays"); - - JsonObject relay1 = relayStates.createNestedObject(); - relay1["id"] = appConfig.relays[0].id; - relay1["name"] = appConfig.relays[0].name; - relay1["pin"] = appConfig.relays[0].pin; - relay1["enabled"] = appConfig.relays[0].enabled; - relay1["state"] = relays.relay1; - - JsonObject relay2 = relayStates.createNestedObject(); - relay2["id"] = appConfig.relays[1].id; - relay2["name"] = appConfig.relays[1].name; - relay2["pin"] = appConfig.relays[1].pin; - relay2["enabled"] = appConfig.relays[1].enabled; - relay2["state"] = relays.relay2; + for (int i = 0; i < MAX_RELAYS; i++) { + JsonObject relay = relayStates.createNestedObject(); + relay["id"] = appConfig.relays[i].id; + relay["name"] = appConfig.relays[i].name; + relay["role"] = appConfig.relays[i].role; + relay["hardware_channel"] = appConfig.relays[i].hardwareChannel; + relay["hardware_profile"] = HARDWARE_PROFILE; + relay["pin"] = appConfig.relays[i].pin; + relay["enabled"] = appConfig.relays[i].enabled; + relay["available"] = appConfig.relays[i].available; + relay["state"] = relays.state[i]; + } JsonObject vehicle = doc.createNestedObject("vehicle"); vehicle["ignition_on"] = digitalRead(IGNITION_PIN); @@ -2351,11 +2356,15 @@ void sendError(Stream& output, const char* message) { output.println(); } -bool setRelayById(const String& relayId, bool enabled) { - if (relayId == "relay_1") relays.relay1 = enabled; - else if (relayId == "relay_2") relays.relay2 = enabled; - else return false; +int findRelayConfigIndex(const String& id); +bool setRelayById(const String& relayId, bool enabled) { + int index = findRelayConfigIndex(relayId); + if (index < 0 || !appConfig.relays[index].available) { + return false; + } + + relays.state[index] = enabled; updateRelayOutputs(); return true; } @@ -2380,13 +2389,20 @@ void sendConfigResponse(Stream& output, bool ok = true) { JsonObject config = doc.createNestedObject("config"); config["device_name"] = appConfig.deviceName; + config["hardware_profile"] = HARDWARE_PROFILE; + config["output_count"] = MAX_RELAYS; + JsonArray relaysArray = config.createNestedArray("relays"); for (int i = 0; i < MAX_RELAYS; i++) { JsonObject relay = relaysArray.createNestedObject(); relay["id"] = appConfig.relays[i].id; relay["name"] = appConfig.relays[i].name; + relay["role"] = appConfig.relays[i].role; + relay["hardware_channel"] = appConfig.relays[i].hardwareChannel; + relay["hardware_profile"] = HARDWARE_PROFILE; relay["pin"] = appConfig.relays[i].pin; relay["enabled"] = appConfig.relays[i].enabled; + relay["available"] = appConfig.relays[i].available; } JsonObject bms = config.createNestedObject("bms"); diff --git a/firmware/esp32/overland-controller/relays.cpp b/firmware/esp32/overland-controller/relays.cpp index 7fa1ec5..0c5381a 100644 --- a/firmware/esp32/overland-controller/relays.cpp +++ b/firmware/esp32/overland-controller/relays.cpp @@ -1,17 +1,24 @@ #include #include "config.h" +#include "app_config.h" #include "relays.h" RelayState relays; void initRelays() { - pinMode(RELAY_1_PIN, OUTPUT); - pinMode(RELAY_2_PIN, OUTPUT); + for (int i = 0; i < MAX_RELAYS; i++) { + if (appConfig.relays[i].available) { + pinMode(appConfig.relays[i].pin, OUTPUT); + } + } updateRelayOutputs(); } void updateRelayOutputs() { - digitalWrite(RELAY_1_PIN, relays.relay1); - digitalWrite(RELAY_2_PIN, relays.relay2); + for (int i = 0; i < MAX_RELAYS; i++) { + if (appConfig.relays[i].available) { + digitalWrite(appConfig.relays[i].pin, relays.state[i]); + } + } } diff --git a/firmware/esp32/overland-controller/relays.h b/firmware/esp32/overland-controller/relays.h index 735510e..48ed575 100644 --- a/firmware/esp32/overland-controller/relays.h +++ b/firmware/esp32/overland-controller/relays.h @@ -1,8 +1,9 @@ #pragma once +#include "app_config.h" + struct RelayState { - bool relay1 = false; - bool relay2 = false; + bool state[MAX_RELAYS] = {false}; }; extern RelayState relays; diff --git a/tests/fixtures/http_api/config_response.json b/tests/fixtures/http_api/config_response.json index f4777ed..8e04bf1 100644 --- a/tests/fixtures/http_api/config_response.json +++ b/tests/fixtures/http_api/config_response.json @@ -5,13 +5,21 @@ "id": "relay_1", "name": "Aux Power", "pin": 16, - "enabled": true + "enabled": true, + "role": "aux", + "hardware_channel": 1, + "hardware_profile": "generic_esp32_2ch_relay", + "available": true }, { "id": "relay_2", "name": "Fridge", "pin": 17, - "enabled": true + "enabled": true, + "role": "fridge", + "hardware_channel": 2, + "hardware_profile": "generic_esp32_2ch_relay", + "available": true } ], "bms": { @@ -29,5 +37,7 @@ "enabled": true, "weather": false } - ] + ], + "hardware_profile": "generic_esp32_2ch_relay", + "output_count": 2 } diff --git a/tests/fixtures/http_api/status_response.json b/tests/fixtures/http_api/status_response.json index c180936..0040ebb 100644 --- a/tests/fixtures/http_api/status_response.json +++ b/tests/fixtures/http_api/status_response.json @@ -14,7 +14,12 @@ "cycle_count": 3, "cell_count": 4, "ntc_count": 2, - "cell_voltages": [3.334, 3.333, 3.334, 3.333], + "cell_voltages": [ + 3.334, + 3.333, + 3.334, + 3.333 + ], "cell_min_voltage": 3.333, "cell_max_voltage": 3.334, "cell_delta_mv": 1, @@ -36,14 +41,22 @@ "name": "Aux Power", "pin": 16, "enabled": true, - "state": false + "state": false, + "role": "aux", + "hardware_channel": 1, + "hardware_profile": "generic_esp32_2ch_relay", + "available": true }, { "id": "relay_2", "name": "Fridge", "pin": 17, "enabled": true, - "state": true + "state": true, + "role": "fridge", + "hardware_channel": 2, + "hardware_profile": "generic_esp32_2ch_relay", + "available": true } ], "vehicle": { @@ -90,7 +103,11 @@ "id": "relay_1", "name": "Aux Power", "pin": 16, - "enabled": true + "enabled": true, + "role": "aux", + "hardware_channel": 1, + "hardware_profile": "generic_esp32_2ch_relay", + "available": true } ], "bms": { @@ -108,6 +125,10 @@ "enabled": true, "weather": false } - ] - } + ], + "hardware_profile": "generic_esp32_2ch_relay", + "output_count": 2 + }, + "hardware_profile": "generic_esp32_2ch_relay", + "output_count": 2 } diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py index 02cf10e..014510c 100644 --- a/tests/test_http_api_contract.py +++ b/tests/test_http_api_contract.py @@ -134,7 +134,19 @@ def test_status_fixture_matches_dashboard_contract_shape(): "online", "temperature_f", ]) - assert_keys(payload["relays"][0], ["id", "name", "pin", "enabled", "state"]) + assert payload["hardware_profile"] == "generic_esp32_2ch_relay" + assert payload["output_count"] == 2 + assert_keys(payload["relays"][0], [ + "id", + "name", + "role", + "hardware_channel", + "hardware_profile", + "pin", + "enabled", + "available", + "state", + ]) assert_keys(payload["network"], [ "wifi_enabled", "uart_connected", @@ -161,12 +173,25 @@ def test_config_fixture_matches_current_config_response_shape(): assert_keys(payload, [ "device_name", + "hardware_profile", + "output_count", "relays", "bms", "temperature_sensor_count", "temperature_sensors", ]) - assert_keys(payload["relays"][0], ["id", "name", "pin", "enabled"]) + assert payload["hardware_profile"] == "generic_esp32_2ch_relay" + assert payload["output_count"] == 2 + assert_keys(payload["relays"][0], [ + "id", + "name", + "role", + "hardware_channel", + "hardware_profile", + "pin", + "enabled", + "available", + ]) assert_keys(payload["bms"], ["enabled", "name", "address", "address_type"]) assert_keys(payload["temperature_sensors"][0], [ "id", @@ -211,6 +236,19 @@ def test_relay_set_contract_fixtures_and_firmware_errors(): assert '"unknown_relay"' in source +def test_relay_api_is_output_count_agnostic(): + source = firmware_source() + + assert 'doc["hardware_profile"] = HARDWARE_PROFILE;' in source + assert 'doc["output_count"] = MAX_RELAYS;' in source + assert 'for (int i = 0; i < MAX_RELAYS; i++)' in source + assert 'relay["hardware_channel"] = appConfig.relays[i].hardwareChannel;' in source + assert 'relay["available"] = appConfig.relays[i].available;' in source + assert 'relays.state[index] = enabled;' in source + assert 'relays.relay1' not in source + assert 'relays.relay2' not in source + + def test_temp_scan_assign_clear_contract_fixtures(): scan = load_fixture("temp_scan_response.json") assign_request = load_fixture("temp_assign_request.json")