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