diff --git a/docs/API.md b/docs/API.md index 015c063..211f433 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,7 +149,12 @@ Returns API and feature discovery data for clients. Returns complete controller status. -Top-level response: +Example requests: + + GET /api/v1/status + GET /api/v1/status?fields=battery,relays,temps + +Top-level full response: ```json { @@ -166,6 +171,40 @@ Top-level response: } ``` +#### Optional `fields` query parameter + +`GET /api/v1/status` accepts an optional comma-separated `fields` query parameter. + +Valid fields: + + battery, temps, relays, vehicle, network, alarms, system, config + +If `fields` is omitted, the endpoint returns the complete status payload. + +If an unknown field is requested, the Cargo ESP32 returns HTTP `400`: + + { + "ok": false, + "error": "invalid_field", + "details": ["Unknown field 'xyz'"] + } + +#### HTTP caching + +`GET /api/v1/status` returns these cache validation headers: + + ETag + Last-Modified + Cache-Control: no-cache + +Clients may revalidate with `If-None-Match` or `If-Modified-Since`. + +If the selected status payload has not changed, the Cargo ESP32 returns: + + HTTP/1.1 304 Not Modified + +A `304` response has no JSON body. + ### battery ```json diff --git a/firmware/esp32/overland-controller/overland-controller.ino b/firmware/esp32/overland-controller/overland-controller.ino index 111f39e..7b1fbc7 100644 --- a/firmware/esp32/overland-controller/overland-controller.ino +++ b/firmware/esp32/overland-controller/overland-controller.ino @@ -1103,6 +1103,45 @@ float calculateRuntimeHours() { return bmsData.remainingAh / dischargeAmps; } + +const int STATUS_FIELD_COUNT = 8; +const char* STATUS_FIELDS[STATUS_FIELD_COUNT] = { + "battery", "temps", "relays", "vehicle", + "network", "alarms", "system", "config" +}; + +int findStatusFieldIndex(const String& field) { + for (int i = 0; i < STATUS_FIELD_COUNT; i++) { + if (field == STATUS_FIELDS[i]) return i; + } + return -1; +} + +uint32_t fnv1a32(const String& value) { + uint32_t hash = 2166136261UL; + for (unsigned int i = 0; i < value.length(); i++) { + hash ^= static_cast(value[i]); + hash *= 16777619UL; + } + return hash; +} + +String statusEtagForPayload(const String& payload) { + return String("\"") + String(fnv1a32(payload), HEX) + "-" + String(payload.length(), HEX) + "\""; +} + +String lastStatusValidatorEtag = ""; +String lastStatusModified = "Thu, 01 Jan 1970 00:00:00 GMT"; + +String formatHttpDateFromUptime(unsigned long seconds) { + char buffer[32]; + snprintf(buffer, sizeof(buffer), "Thu, 01 Jan 1970 %02lu:%02lu:%02lu GMT", + (seconds / 3600UL) % 24UL, + (seconds / 60UL) % 60UL, + seconds % 60UL); + return String(buffer); +} + void buildStatusDocument(JsonDocument& doc) { doc["type"] = MSG_STATUS_RESPONSE; doc["timestamp"] = millis(); @@ -2640,9 +2679,75 @@ void handleStatus() { DynamicJsonDocument doc(4096); buildStatusDocument(doc); + if (server.hasArg("fields")) { + String fields = server.arg("fields"); + DynamicJsonDocument filtered(4096); + filtered["type"] = doc["type"]; + filtered["timestamp"] = doc["timestamp"]; + + int start = 0; + while (start <= fields.length()) { + int comma = fields.indexOf(',', start); + if (comma < 0) comma = fields.length(); + + String field = fields.substring(start, comma); + field.trim(); + + int fieldIndex = findStatusFieldIndex(field); + if (fieldIndex < 0) { + DynamicJsonDocument errorDoc(512); + errorDoc["ok"] = false; + errorDoc["error"] = "invalid_field"; + JsonArray details = errorDoc.createNestedArray("details"); + details.add(String("Unknown field '") + field + "'"); + + String errorOutput; + serializeJson(errorDoc, errorOutput); + server.send(400, "application/json", errorOutput); + return; + } + + filtered[field] = doc[field]; + + start = comma + 1; + if (comma == fields.length()) break; + } + + doc.clear(); + doc.set(filtered); + } + String output; serializeJson(doc, output); + DynamicJsonDocument validatorDoc(4096); + deserializeJson(validatorDoc, output); + validatorDoc["timestamp"] = 0; + if (validatorDoc["system"].is()) { + validatorDoc["system"]["uptime_seconds"] = 0; + } + + String validatorPayload; + serializeJson(validatorDoc, validatorPayload); + + String etag = statusEtagForPayload(validatorPayload); + if (etag != lastStatusValidatorEtag) { + lastStatusValidatorEtag = etag; + lastStatusModified = formatHttpDateFromUptime(millis() / 1000); + } + + server.sendHeader("ETag", etag); + server.sendHeader("Last-Modified", lastStatusModified); + server.sendHeader("Cache-Control", "no-cache"); + + String ifNoneMatch = server.header("If-None-Match"); + String ifModifiedSince = server.header("If-Modified-Since"); + + if (ifNoneMatch == etag || ifModifiedSince == lastStatusModified) { + server.send(304); + return; + } + server.send(200, "application/json", output); } @@ -2747,6 +2852,9 @@ void setup() { server.on("/bms/select", HTTP_POST, handleSelectBms); + const char* statusCacheHeaders[] = {"If-None-Match", "If-Modified-Since"}; + server.collectHeaders(statusCacheHeaders, 2); + server.begin(); Serial.println("Web Server Started"); diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py index 8ef2642..51eb9d9 100644 --- a/tests/test_http_api_contract.py +++ b/tests/test_http_api_contract.py @@ -282,3 +282,21 @@ def test_http_error_fixture_and_known_error_codes_are_stable(): "invalid_relay_action", ]: assert error_code in source + + +def test_status_endpoint_supports_field_selection_and_cache_headers(): + source = firmware_source() + + assert 'server.hasArg("fields")' in source + assert 'server.arg("fields")' in source + assert 'findStatusFieldIndex' in source + assert '"invalid_field"' in source + assert 'Unknown field' in source + assert 'server.send(400, "application/json", errorOutput)' in source + assert 'server.sendHeader("ETag", etag)' in source + assert 'server.sendHeader("Last-Modified", lastStatusModified)' in source + assert 'server.sendHeader("Cache-Control", "no-cache")' in source + assert 'server.header("If-None-Match")' in source + assert 'server.header("If-Modified-Since")' in source + assert 'server.send(304)' in source + assert 'server.collectHeaders(statusCacheHeaders, 2)' in source