api: make relay outputs hardware-profile aware

This commit is contained in:
2026-06-09 15:13:57 -06:00
parent f2966518da
commit c166b38730
10 changed files with 183 additions and 37 deletions
+23
View File
@@ -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. - 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 render outputs dynamically from the API response.
- Clients should not hardcode relay count. - 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.
+14
View File
@@ -263,3 +263,17 @@ Primary goal:
- Keep 2-channel testing functional now. - Keep 2-channel testing functional now.
- Support future 6-output rendering/control cleanly through the same API shape. - 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.
@@ -13,14 +13,20 @@ void loadDefaultConfig() {
appConfig.relays[0] = { appConfig.relays[0] = {
"relay_1", "relay_1",
"Relay 1", "Relay 1",
"aux",
1,
RELAY_1_PIN, RELAY_1_PIN,
true,
true true
}; };
appConfig.relays[1] = { appConfig.relays[1] = {
"relay_2", "relay_2",
"Relay 2", "Relay 2",
"fridge",
2,
RELAY_2_PIN, RELAY_2_PIN,
true,
true true
}; };
@@ -160,8 +166,14 @@ void printConfig() {
Serial.print(appConfig.relays[i].id); Serial.print(appConfig.relays[i].id);
Serial.print(" / "); Serial.print(" / ");
Serial.print(appConfig.relays[i].name); 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(" / GPIO ");
Serial.print(appConfig.relays[i].pin); Serial.print(appConfig.relays[i].pin);
Serial.print(" / available ");
Serial.print(appConfig.relays[i].available ? "true" : "false");
Serial.print(" / enabled "); Serial.print(" / enabled ");
Serial.println(appConfig.relays[i].enabled ? "true" : "false"); Serial.println(appConfig.relays[i].enabled ? "true" : "false");
} }
@@ -4,12 +4,16 @@
#define MAX_RELAYS 2 #define MAX_RELAYS 2
#define MAX_TEMP_SENSORS 8 #define MAX_TEMP_SENSORS 8
#define HARDWARE_PROFILE "generic_esp32_2ch_relay"
struct RelayConfig { struct RelayConfig {
String id; String id;
String name; String name;
String role;
uint8_t hardwareChannel;
uint8_t pin; uint8_t pin;
bool enabled; bool enabled;
bool available;
}; };
struct TempSensorConfig { struct TempSensorConfig {
@@ -1703,11 +1703,11 @@ void oledShowRelayPage() {
oled.print(appConfig.relays[0].name); oled.print(appConfig.relays[0].name);
oled.print(": "); oled.print(": ");
oled.println(relays.relay1 ? "ON" : "OFF"); oled.println(relays.state[0] ? "ON" : "OFF");
oled.print(appConfig.relays[1].name); oled.print(appConfig.relays[1].name);
oled.print(": "); oled.print(": ");
oled.println(relays.relay2 ? "ON" : "OFF"); oled.println(relays.state[1] ? "ON" : "OFF");
oled.display(); oled.display();
} }
@@ -1945,13 +1945,20 @@ void printNetworkStatus() {
void buildConfigDocument(JsonObject doc) { void buildConfigDocument(JsonObject doc) {
doc["device_name"] = appConfig.deviceName; doc["device_name"] = appConfig.deviceName;
doc["hardware_profile"] = HARDWARE_PROFILE;
doc["output_count"] = MAX_RELAYS;
JsonArray relaysArray = doc.createNestedArray("relays"); JsonArray relaysArray = doc.createNestedArray("relays");
for (int i = 0; i < MAX_RELAYS; i++) { for (int i = 0; i < MAX_RELAYS; i++) {
JsonObject relay = relaysArray.createNestedObject(); JsonObject relay = relaysArray.createNestedObject();
relay["id"] = appConfig.relays[i].id; relay["id"] = appConfig.relays[i].id;
relay["name"] = appConfig.relays[i].name; 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["pin"] = appConfig.relays[i].pin;
relay["enabled"] = appConfig.relays[i].enabled; relay["enabled"] = appConfig.relays[i].enabled;
relay["available"] = appConfig.relays[i].available;
} }
JsonObject bms = doc.createNestedObject("bms"); JsonObject bms = doc.createNestedObject("bms");
@@ -2277,20 +2284,18 @@ void buildStatusDocument(JsonDocument& doc) {
} }
JsonArray relayStates = doc.createNestedArray("relays"); JsonArray relayStates = doc.createNestedArray("relays");
for (int i = 0; i < MAX_RELAYS; i++) {
JsonObject relay1 = relayStates.createNestedObject(); JsonObject relay = relayStates.createNestedObject();
relay1["id"] = appConfig.relays[0].id; relay["id"] = appConfig.relays[i].id;
relay1["name"] = appConfig.relays[0].name; relay["name"] = appConfig.relays[i].name;
relay1["pin"] = appConfig.relays[0].pin; relay["role"] = appConfig.relays[i].role;
relay1["enabled"] = appConfig.relays[0].enabled; relay["hardware_channel"] = appConfig.relays[i].hardwareChannel;
relay1["state"] = relays.relay1; relay["hardware_profile"] = HARDWARE_PROFILE;
relay["pin"] = appConfig.relays[i].pin;
JsonObject relay2 = relayStates.createNestedObject(); relay["enabled"] = appConfig.relays[i].enabled;
relay2["id"] = appConfig.relays[1].id; relay["available"] = appConfig.relays[i].available;
relay2["name"] = appConfig.relays[1].name; relay["state"] = relays.state[i];
relay2["pin"] = appConfig.relays[1].pin; }
relay2["enabled"] = appConfig.relays[1].enabled;
relay2["state"] = relays.relay2;
JsonObject vehicle = doc.createNestedObject("vehicle"); JsonObject vehicle = doc.createNestedObject("vehicle");
vehicle["ignition_on"] = digitalRead(IGNITION_PIN); vehicle["ignition_on"] = digitalRead(IGNITION_PIN);
@@ -2351,11 +2356,15 @@ void sendError(Stream& output, const char* message) {
output.println(); output.println();
} }
bool setRelayById(const String& relayId, bool enabled) { int findRelayConfigIndex(const String& id);
if (relayId == "relay_1") relays.relay1 = enabled;
else if (relayId == "relay_2") relays.relay2 = enabled;
else return false;
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(); updateRelayOutputs();
return true; return true;
} }
@@ -2380,13 +2389,20 @@ void sendConfigResponse(Stream& output, bool ok = true) {
JsonObject config = doc.createNestedObject("config"); JsonObject config = doc.createNestedObject("config");
config["device_name"] = appConfig.deviceName; config["device_name"] = appConfig.deviceName;
config["hardware_profile"] = HARDWARE_PROFILE;
config["output_count"] = MAX_RELAYS;
JsonArray relaysArray = config.createNestedArray("relays"); JsonArray relaysArray = config.createNestedArray("relays");
for (int i = 0; i < MAX_RELAYS; i++) { for (int i = 0; i < MAX_RELAYS; i++) {
JsonObject relay = relaysArray.createNestedObject(); JsonObject relay = relaysArray.createNestedObject();
relay["id"] = appConfig.relays[i].id; relay["id"] = appConfig.relays[i].id;
relay["name"] = appConfig.relays[i].name; 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["pin"] = appConfig.relays[i].pin;
relay["enabled"] = appConfig.relays[i].enabled; relay["enabled"] = appConfig.relays[i].enabled;
relay["available"] = appConfig.relays[i].available;
} }
JsonObject bms = config.createNestedObject("bms"); JsonObject bms = config.createNestedObject("bms");
+11 -4
View File
@@ -1,17 +1,24 @@
#include <Arduino.h> #include <Arduino.h>
#include "config.h" #include "config.h"
#include "app_config.h"
#include "relays.h" #include "relays.h"
RelayState relays; RelayState relays;
void initRelays() { void initRelays() {
pinMode(RELAY_1_PIN, OUTPUT); for (int i = 0; i < MAX_RELAYS; i++) {
pinMode(RELAY_2_PIN, OUTPUT); if (appConfig.relays[i].available) {
pinMode(appConfig.relays[i].pin, OUTPUT);
}
}
updateRelayOutputs(); updateRelayOutputs();
} }
void updateRelayOutputs() { void updateRelayOutputs() {
digitalWrite(RELAY_1_PIN, relays.relay1); for (int i = 0; i < MAX_RELAYS; i++) {
digitalWrite(RELAY_2_PIN, relays.relay2); if (appConfig.relays[i].available) {
digitalWrite(appConfig.relays[i].pin, relays.state[i]);
}
}
} }
+3 -2
View File
@@ -1,8 +1,9 @@
#pragma once #pragma once
#include "app_config.h"
struct RelayState { struct RelayState {
bool relay1 = false; bool state[MAX_RELAYS] = {false};
bool relay2 = false;
}; };
extern RelayState relays; extern RelayState relays;
+13 -3
View File
@@ -5,13 +5,21 @@
"id": "relay_1", "id": "relay_1",
"name": "Aux Power", "name": "Aux Power",
"pin": 16, "pin": 16,
"enabled": true "enabled": true,
"role": "aux",
"hardware_channel": 1,
"hardware_profile": "generic_esp32_2ch_relay",
"available": true
}, },
{ {
"id": "relay_2", "id": "relay_2",
"name": "Fridge", "name": "Fridge",
"pin": 17, "pin": 17,
"enabled": true "enabled": true,
"role": "fridge",
"hardware_channel": 2,
"hardware_profile": "generic_esp32_2ch_relay",
"available": true
} }
], ],
"bms": { "bms": {
@@ -29,5 +37,7 @@
"enabled": true, "enabled": true,
"weather": false "weather": false
} }
] ],
"hardware_profile": "generic_esp32_2ch_relay",
"output_count": 2
} }
+27 -6
View File
@@ -14,7 +14,12 @@
"cycle_count": 3, "cycle_count": 3,
"cell_count": 4, "cell_count": 4,
"ntc_count": 2, "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_min_voltage": 3.333,
"cell_max_voltage": 3.334, "cell_max_voltage": 3.334,
"cell_delta_mv": 1, "cell_delta_mv": 1,
@@ -36,14 +41,22 @@
"name": "Aux Power", "name": "Aux Power",
"pin": 16, "pin": 16,
"enabled": true, "enabled": true,
"state": false "state": false,
"role": "aux",
"hardware_channel": 1,
"hardware_profile": "generic_esp32_2ch_relay",
"available": true
}, },
{ {
"id": "relay_2", "id": "relay_2",
"name": "Fridge", "name": "Fridge",
"pin": 17, "pin": 17,
"enabled": true, "enabled": true,
"state": true "state": true,
"role": "fridge",
"hardware_channel": 2,
"hardware_profile": "generic_esp32_2ch_relay",
"available": true
} }
], ],
"vehicle": { "vehicle": {
@@ -90,7 +103,11 @@
"id": "relay_1", "id": "relay_1",
"name": "Aux Power", "name": "Aux Power",
"pin": 16, "pin": 16,
"enabled": true "enabled": true,
"role": "aux",
"hardware_channel": 1,
"hardware_profile": "generic_esp32_2ch_relay",
"available": true
} }
], ],
"bms": { "bms": {
@@ -108,6 +125,10 @@
"enabled": true, "enabled": true,
"weather": false "weather": false
} }
] ],
} "hardware_profile": "generic_esp32_2ch_relay",
"output_count": 2
},
"hardware_profile": "generic_esp32_2ch_relay",
"output_count": 2
} }
+40 -2
View File
@@ -134,7 +134,19 @@ def test_status_fixture_matches_dashboard_contract_shape():
"online", "online",
"temperature_f", "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"], [ assert_keys(payload["network"], [
"wifi_enabled", "wifi_enabled",
"uart_connected", "uart_connected",
@@ -161,12 +173,25 @@ def test_config_fixture_matches_current_config_response_shape():
assert_keys(payload, [ assert_keys(payload, [
"device_name", "device_name",
"hardware_profile",
"output_count",
"relays", "relays",
"bms", "bms",
"temperature_sensor_count", "temperature_sensor_count",
"temperature_sensors", "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["bms"], ["enabled", "name", "address", "address_type"])
assert_keys(payload["temperature_sensors"][0], [ assert_keys(payload["temperature_sensors"][0], [
"id", "id",
@@ -211,6 +236,19 @@ def test_relay_set_contract_fixtures_and_firmware_errors():
assert '"unknown_relay"' in source 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(): def test_temp_scan_assign_clear_contract_fixtures():
scan = load_fixture("temp_scan_response.json") scan = load_fixture("temp_scan_response.json")
assign_request = load_fixture("temp_assign_request.json") assign_request = load_fixture("temp_assign_request.json")