api: optimize status endpoint payload and caching

This commit is contained in:
2026-06-07 19:04:30 -06:00
parent a721f6b539
commit 512a1aa302
3 changed files with 166 additions and 1 deletions
+40 -1
View File
@@ -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
@@ -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<uint8_t>(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<JsonObject>()) {
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");
+18
View File
@@ -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