diff --git a/docs/API.md b/docs/API.md
index f36f9d6..48f888d 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -396,7 +396,7 @@ Fields:
|---|---:|---|
| `id` | Yes | `relay_1` or `relay_2` |
| `name` | No | User display name |
-| `enabled` | No | Configured enabled state |
+| `enabled` | No | Configured enabled state. When set to `false`, the relay output is forced OFF and future relay control requests are rejected with `relay_disabled`. |
Returns the updated config.
diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md
index 09b0062..d6c42e4 100644
--- a/docs/CONFIGURATION.md
+++ b/docs/CONFIGURATION.md
@@ -69,6 +69,8 @@ HTTP endpoint:
POST /api/v1/config/relay
+Disabled relays are forced OFF immediately, hidden from normal dashboard controls, and protected from `/api/v1/relay/set` commands until re-enabled.
+
## Temperature Sensor Configuration
Temperature sensors use generic firmware IDs:
diff --git a/firmware/esp32/overland-controller/overland-controller.ino b/firmware/esp32/overland-controller/overland-controller.ino
index 1cb1996..85261fa 100644
--- a/firmware/esp32/overland-controller/overland-controller.ino
+++ b/firmware/esp32/overland-controller/overland-controller.ino
@@ -1249,11 +1249,20 @@ function renderConfigControls(data){
-
+
`).join("");
}
- relays.forEach((r,i)=>setConfigValue("relayName"+i,r.name||""));
+ relays.forEach((r,i)=>{
+ setConfigValue("relayName"+i,r.name||"");
+ const enabled=$("relayEnabled"+i);
+ if(enabled && !configTouched["relayEnabled"+i] && document.activeElement!==enabled){
+ enabled.checked=!!r.enabled;
+ }
+ });
}
setConfigValue("tempEnabledCount", temps.filter(t=>t.enabled).length);
@@ -1359,8 +1368,8 @@ async function saveRelayConfig(){
const relays=data.config?.relays||[];
for(let i=0;i= 0 && appConfig.relays[index].available;
+}
+
+bool relayIsEnabled(const String& relayId) {
+ int index = findRelayConfigIndex(relayId);
+ return index >= 0 && appConfig.relays[index].enabled;
+}
+
bool setRelayById(const String& relayId, bool enabled) {
int index = findRelayConfigIndex(relayId);
- if (index < 0 || !appConfig.relays[index].available) {
+ if (index < 0 || !appConfig.relays[index].available || !appConfig.relays[index].enabled) {
return false;
}
@@ -3651,7 +3670,13 @@ void handleUpdateRelayConfig() {
}
if (doc["name"].is()) appConfig.relays[index].name = doc["name"].as();
- if (doc["enabled"].is()) appConfig.relays[index].enabled = doc["enabled"].as();
+ if (doc["enabled"].is()) {
+ appConfig.relays[index].enabled = doc["enabled"].as();
+ if (!appConfig.relays[index].enabled) {
+ relays.state[index] = false;
+ updateRelayOutputs();
+ }
+ }
sendOkConfig();
}
@@ -3994,10 +4019,18 @@ void handleSetRelayPost() {
bool state = doc["state"] | false;
unsigned long applyStartMs = millis();
- if (!setRelayById(relayId, state)) {
+ if (!relayExistsAndAvailable(relayId)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
+ if (!relayIsEnabled(relayId)) {
+ server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
+ return;
+ }
+ if (!setRelayById(relayId, state)) {
+ server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
+ return;
+ }
unsigned long appliedMs = millis() - applyStartMs;
DynamicJsonDocument response(512);
@@ -4052,10 +4085,18 @@ void handleGenericRelayRoute() {
return;
}
- if (!setRelayById(relayId, enabled)) {
+ if (!relayExistsAndAvailable(relayId)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
+ if (!relayIsEnabled(relayId)) {
+ server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
+ return;
+ }
+ if (!setRelayById(relayId, enabled)) {
+ server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
+ return;
+ }
DynamicJsonDocument doc(512);
doc["ok"] = true;
diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py
index 844e24d..c7323c4 100644
--- a/tests/test_http_api_contract.py
+++ b/tests/test_http_api_contract.py
@@ -248,6 +248,8 @@ def test_relay_set_contract_fixtures_and_firmware_errors():
source = firmware_source()
assert '"invalid_json"' in source
assert '"unknown_relay"' in source
+ assert "relay_disabled" in source
+ assert "relayIsEnabled" in source
def test_relay_api_is_output_count_agnostic():
@@ -259,6 +261,7 @@ def test_relay_api_is_output_count_agnostic():
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 '!appConfig.relays[index].enabled' in source
assert 'relays.relay1' not in source
assert 'relays.relay2' not in source