Add versioned HTTP API routes

This commit is contained in:
2026-06-07 10:24:08 -06:00
parent ec39182bbd
commit d87080d23f
11 changed files with 226 additions and 137 deletions
+58 -50
View File
@@ -8,6 +8,12 @@ Default AP address:
http://192.168.4.1
```
Current API base path:
```text
/api/v1
```
All documented endpoints are local-first and must continue to work without internet access. Consumers should ignore unknown response fields for forward compatibility.
Current firmware version:
@@ -20,34 +26,36 @@ Current firmware version:
```text
GET /
GET /status
GET /config
GET /api/v1/status
GET /api/v1/config
POST /relay/set
POST /api/v1/relay/set
POST /config/device
POST /config/relay
POST /config/temp
POST /config/bms
POST /config/save
POST /config/factory-reset
POST /api/v1/config/device
POST /api/v1/config/relay
POST /api/v1/config/temp
POST /api/v1/config/bms
POST /api/v1/config/save
POST /api/v1/config/factory-reset
GET /config/wifi
POST /config/wifi
POST /wifi/connect
POST /wifi/clear
GET /api/v1/config/wifi
POST /api/v1/config/wifi
POST /api/v1/wifi/connect
POST /api/v1/wifi/clear
POST /temps/scan
POST /temps/assign
POST /temps/clear
POST /api/v1/temps/scan
POST /api/v1/temps/assign
POST /api/v1/temps/clear
POST /bms/setup/enter
POST /bms/setup/exit
POST /bms/scan
POST /bms/select
POST /api/v1/bms/setup/enter
POST /api/v1/bms/setup/exit
POST /api/v1/bms/scan
POST /api/v1/bms/select
```
Legacy relay routes remain available:
Pre-versioned root routes remain registered as compatibility aliases for existing local clients. New clients should use `/api/v1`.
Legacy GET relay routes remain available only as root compatibility aliases:
```text
GET /relay/relay_1/on
@@ -61,15 +69,15 @@ GET /relay/relay_2/off
The Waveshare ESP32-S3 dashboard MVP should use only:
```text
GET /status
POST /relay/set
GET /api/v1/status
POST /api/v1/relay/set
```
The dashboard is a client only. It may cache last-known values for display, but the Cargo ESP32 remains the source of truth for relay state, BMS state, alarms, and configuration.
## Status
### GET /status
### GET /api/v1/status
Returns complete controller status.
@@ -144,7 +152,7 @@ Fields:
| `online` | Current sensor detection state |
| `temperature_f` | Current Fahrenheit value, or `null` when offline |
Config supports `temp_1` through `temp_8`. Current `/status` runtime output is limited to the configured count capped at four sensors.
Config supports `temp_1` through `temp_8`. Current `/api/v1/status` runtime output is limited to the configured count capped at four sensors.
### relays
@@ -228,11 +236,11 @@ The AP remains enabled even when STA WiFi is configured or connected.
### config
`/status.config` embeds the same core configuration model used by `GET /config`.
`/api/v1/status.config` embeds the same core configuration model used by `GET /api/v1/config`.
## Configuration
### GET /config
### GET /api/v1/config
Returns saved controller configuration.
@@ -264,9 +272,9 @@ Returns saved controller configuration.
}
```
Note: current `GET /config` omits the `weather` field, while `/status.config.temperature_sensors[]` includes it.
Note: current `GET /api/v1/config` omits the `weather` field, while `/api/v1/status.config.temperature_sensors[]` includes it.
### POST /config/device
### POST /api/v1/config/device
Updates the controller display name.
@@ -278,7 +286,7 @@ Updates the controller display name.
Returns the updated config.
### POST /config/relay
### POST /api/v1/config/relay
Updates relay configuration.
@@ -300,7 +308,7 @@ Fields:
Returns the updated config.
### POST /config/temp
### POST /api/v1/config/temp
Updates temperature sensor configuration.
@@ -326,7 +334,7 @@ Fields:
Returns the updated config.
### POST /config/bms
### POST /api/v1/config/bms
Updates BMS configuration.
@@ -348,13 +356,13 @@ random
Returns the updated config.
### POST /config/save
### POST /api/v1/config/save
Persists the current active configuration.
Returns the current config.
### POST /config/factory-reset
### POST /api/v1/config/factory-reset
Clears saved configuration and restores firmware defaults.
@@ -362,7 +370,7 @@ Returns the reset config.
## Relay Control
### POST /relay/set
### POST /api/v1/relay/set
Preferred relay command endpoint.
@@ -407,7 +415,7 @@ Response:
## WiFi
### GET /config/wifi
### GET /api/v1/config/wifi
Returns AP/STA WiFi configuration status. Passwords are not returned.
@@ -435,7 +443,7 @@ Returns AP/STA WiFi configuration status. Passwords are not returned.
}
```
### POST /config/wifi
### POST /api/v1/config/wifi
Preferred multi-network request:
@@ -473,13 +481,13 @@ Runtime behavior:
- Lower priority numbers are tried first.
- If STA disconnects, saved networks are retried by priority.
### POST /wifi/connect
### POST /api/v1/wifi/connect
Attempts STA connection using saved networks by priority.
Returns WiFi config status.
### POST /wifi/clear
### POST /api/v1/wifi/clear
Clears saved STA WiFi networks.
@@ -487,7 +495,7 @@ Returns WiFi config status.
## Temperature Probe Setup
### POST /temps/scan
### POST /api/v1/temps/scan
Scans the DS18B20 bus and returns unassigned probe addresses.
@@ -506,7 +514,7 @@ Scans the DS18B20 bus and returns unassigned probe addresses.
Already-assigned probe addresses are hidden from scan results until cleared.
### POST /temps/assign
### POST /api/v1/temps/assign
Assigns a scanned probe to a logical temperature slot.
@@ -530,7 +538,7 @@ By slot number:
Returns the updated config.
### POST /temps/clear
### POST /api/v1/temps/clear
Clears one or all temperature assignments.
@@ -560,7 +568,7 @@ Returns the updated config.
These endpoints are for BMS discovery and selection. Do not change JBD/Xiaoxiang BLE behavior without explicit approval.
### POST /bms/setup/enter
### POST /api/v1/bms/setup/enter
Enters BMS setup mode.
@@ -574,7 +582,7 @@ Response:
}
```
### POST /bms/setup/exit
### POST /api/v1/bms/setup/exit
Exits BMS setup mode.
@@ -588,7 +596,7 @@ Response:
}
```
### POST /bms/scan
### POST /api/v1/bms/scan
Scans for BLE devices.
@@ -610,7 +618,7 @@ Response:
Some BMS devices advertise intermittently. Repeated scans may be needed.
### POST /bms/select
### POST /api/v1/bms/select
Selects a BMS from the most recent scan result.
@@ -676,9 +684,9 @@ Some serial/UART errors use `message` instead of `error`; HTTP clients should re
## Compatibility
Current stable route prefix is the root API path, for example `GET /status`.
Current stable API prefix is `/api/v1`, for example `GET /api/v1/status`.
Future versioning may move to `/api/v1`. Do not introduce versioned routes until consumers and docs are updated together.
Pre-versioned root routes remain registered as local compatibility aliases. Do not add new root-only HTTP API routes.
New fields may be added to existing JSON objects. Consumers should ignore unknown fields.
@@ -687,8 +695,8 @@ New fields may be added to existing JSON objects. Consumers should ignore unknow
These routes are not part of the current registered HTTP API:
```text
POST /bms/reconnect
POST /system/restart
POST /api/v1/bms/reconnect
POST /api/v1/system/restart
```
If added later, update this document and add contract tests.
+4 -4
View File
@@ -93,11 +93,11 @@ HTTP is the primary integration contract for:
Preferred direction:
GET /status
GET /config
POST /relay/set
GET /api/v1/status
GET /api/v1/config
POST /api/v1/relay/set
Future versioning may move to /api/v1.
Pre-versioned root routes remain registered as compatibility aliases for existing local clients.
## Generic IDs
+1 -1
View File
@@ -21,7 +21,7 @@
- WiFi priority and runtime reconnect behavior.
- HTTP/UART/USB serial command parity documentation.
- DS18B20 configurable temperature probe support.
- Preferred relay command endpoint: `POST /relay/set`.
- Preferred relay command endpoint: `POST /api/v1/relay/set`.
### Changed
+5 -5
View File
@@ -38,7 +38,7 @@ Serial command:
HTTP endpoint:
POST /config/device
POST /api/v1/config/device
## Relay Configuration
@@ -67,7 +67,7 @@ Serial commands:
HTTP endpoint:
POST /config/relay
POST /api/v1/config/relay
## Temperature Sensor Configuration
@@ -102,7 +102,7 @@ Serial commands:
HTTP endpoint:
POST /config/temp
POST /api/v1/config/temp
## BMS Configuration
@@ -127,7 +127,7 @@ Serial commands:
HTTP endpoint:
POST /config/bms
POST /api/v1/config/bms
## BMS Setup Mode
@@ -157,7 +157,7 @@ Serial command:
HTTP endpoint:
POST /config/factory-reset
POST /api/v1/config/factory-reset
After factory reset:
+10 -10
View File
@@ -12,14 +12,14 @@ Control surfaces:
| Feature | USB Serial | UART JSON | HTTP API | Notes |
|---|---:|---:|---:|---|
| Status | Yes | Yes | Yes | `/status` equals `status_request` |
| Config view | Yes | Yes | Yes | `/config` equals `config_request` |
| Relay control | Yes | Yes | Yes | Preferred HTTP endpoint is `POST /relay/set` |
| Device name config | Yes | Yes | Yes | `devicename`, `config_device`, `/config/device` |
| Relay config | Yes | Yes | Yes | `relayname`, `config_relay`, `/config/relay` |
| Temperature config | Yes | Yes | Yes | `tempname`, `config_temp`, `/config/temp` |
| BMS config | Yes | Yes | Yes | `bmsname`/`bmsaddr`, `config_bms`, `/config/bms` |
| Save config | Yes | Yes | Yes | `save`, `save_config`, `/config/save` |
| Status | Yes | Yes | Yes | `/api/v1/status` equals `status_request` |
| Config view | Yes | Yes | Yes | `/api/v1/config` equals `config_request` |
| Relay control | Yes | Yes | Yes | Preferred HTTP endpoint is `POST /api/v1/relay/set` |
| Device name config | Yes | Yes | Yes | `devicename`, `config_device`, `/api/v1/config/device` |
| Relay config | Yes | Yes | Yes | `relayname`, `config_relay`, `/api/v1/config/relay` |
| Temperature config | Yes | Yes | Yes | `tempname`, `config_temp`, `/api/v1/config/temp` |
| BMS config | Yes | Yes | Yes | `bmsname`/`bmsaddr`, `config_bms`, `/api/v1/config/bms` |
| Save config | Yes | Yes | Yes | `save`, `save_config`, `/api/v1/config/save` |
| Factory reset | Yes | Yes | Yes | AP remains recovery path |
| BMS setup enter | Yes | Yes | Yes | Setup pauses normal BMS reconnect behavior |
| BMS setup exit | Yes | Yes | Yes | Returns to normal mode |
@@ -58,9 +58,9 @@ Legacy UART relay fields may still be accepted:
Preferred HTTP relay endpoint:
POST /relay/set
POST /api/v1/relay/set
Legacy HTTP relay routes may remain for compatibility:
Legacy root HTTP relay routes remain for compatibility:
GET /relay/relay_1/on
GET /relay/relay_1/off
+9 -9
View File
@@ -47,15 +47,15 @@ All grounds remain common.
## API endpoints consumed
Minimum dashboard MVP:
- GET /status
- POST /relay/set
- GET /api/v1/status
- POST /api/v1/relay/set
Potential later use:
- GET /config
- POST /config/save
- POST /temps/scan
- POST /temps/assign
- POST /temps/clear
- GET /api/v1/config
- POST /api/v1/config/save
- POST /api/v1/temps/scan
- POST /api/v1/temps/assign
- POST /api/v1/temps/clear
## MVP Display Goals
@@ -120,10 +120,10 @@ Then interactive:
1. Dashboard boots.
2. Connects to cargo ESP32 AP.
3. Polls GET /status.
3. Polls GET /api/v1/status.
4. Shows disconnected state until valid JSON is received.
5. Updates dashboard on a fixed interval.
6. Relay buttons call POST /relay/set.
6. Relay buttons call POST /api/v1/relay/set.
## Polling Guidance
@@ -209,7 +209,6 @@ input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;ba
</div>
<div class="btnrow">
<button onclick="setBmsEnabled(true)">Enable BMS</button>
<button onclick="reconnectBms()">Reconnect BMS</button>
</div>
</div>
</section>
@@ -218,7 +217,6 @@ input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;ba
<div class="label">System Actions</div>
<div class="sub">Use carefully. Factory reset clears install-specific configuration.</div>
<div class="btnrow">
<button onclick="restartController()">Restart</button>
<button class="off" onclick="factoryReset()">Factory Reset</button>
</div>
</section>
@@ -240,6 +238,8 @@ input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;ba
</div>
<script>
const API_BASE="/api/v1";
function api(path){return API_BASE+path;}
const $=id=>document.getElementById(id);
function fmt(n,d=1){return typeof n==="number"?n.toFixed(d):"--"}
function temp(v){return typeof v==="number"?fmt(v,1)+"°F":"Offline"}
@@ -323,12 +323,12 @@ function showTab(name){
});
}
async function setBmsEnabled(enabled){
await fetch("/config/bms",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled})});
await fetch("/config/save",{method:"POST"});
await fetch(api("/config/bms"),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled})});
await fetch(api("/config/save"),{method:"POST"});
await load();
}
async function relay(id,on){
await fetch("/relay/set",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id,state:on})});
await fetch(api("/relay/set"),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id,state:on})});
await load();
}
function render(data){
@@ -411,13 +411,13 @@ async function saveDeviceConfig(){
const name=$("deviceNameInput")?.value.trim();
if(!name){ alert("Device name is required"); return; }
await fetch("/config/device",{
await fetch(api("/config/device"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({device_name:name})
});
await fetch("/config/save",{method:"POST"});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Device name saved");
}
@@ -432,7 +432,7 @@ async function saveBmsFullConfig(){
return;
}
await fetch("/config/bms",{
await fetch(api("/config/bms"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
@@ -443,22 +443,16 @@ async function saveBmsFullConfig(){
})
});
await fetch("/config/save",{method:"POST"});
await reconnectBms(false);
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("BMS config saved");
}
async function reconnectBms(showAlert=true){
await fetch("/bms/reconnect",{method:"POST"});
if(showAlert) alert("BMS reconnect requested");
}
async function scanTemps(){
const box=$("tempScanResults");
if(box) box.innerHTML=`<div class="item"><span>Scanning...</span><strong class="muted">wait</strong></div>`;
const r=await fetch("/temps/scan",{method:"POST"});
const r=await fetch(api("/temps/scan"),{method:"POST"});
const d=await r.json();
const devices=d.devices||[];
@@ -487,13 +481,13 @@ async function scanTemps(){
}
async function assignTemp(id,index){
await fetch("/temps/assign",{
await fetch(api("/temps/assign"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id,index:index})
});
await fetch("/config/save",{method:"POST"});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Temp probe assigned to "+id);
}
@@ -501,7 +495,7 @@ async function assignTemp(id,index){
async function clearTempAssignment(id){
if(!confirm("Clear probe assignment for "+id+"?")) return;
const r=await fetch("/temps/clear",{
const r=await fetch(api("/temps/clear"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id})
@@ -517,17 +511,11 @@ async function clearTempAssignment(id){
alert("Temp probe assignment cleared for "+id);
}
async function restartController(){
if(!confirm("Restart the controller now?")) return;
await fetch("/system/restart",{method:"POST"});
alert("Restart requested");
}
async function factoryReset(){
if(!confirm("Factory reset all controller configuration?")) return;
if(!confirm("This clears WiFi/BMS/relay/temp config. Continue?")) return;
await fetch("/config/factory-reset",{method:"POST"});
await fetch(api("/config/factory-reset"),{method:"POST"});
alert("Factory reset requested. Reconnect to the controller AP if needed.");
}
@@ -574,7 +562,7 @@ function renderConfigControls(data){
}
async function saveRelayConfig(){
const r=await fetch("/status",{cache:"no-store"});
const r=await fetch(api("/status"),{cache:"no-store"});
const data=await r.json();
const relays=data.config?.relays||[];
@@ -582,7 +570,7 @@ async function saveRelayConfig(){
const name=$("relayName"+i)?.value.trim();
if(!name) continue;
await fetch("/config/relay",{
await fetch(api("/config/relay"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
@@ -593,13 +581,13 @@ async function saveRelayConfig(){
});
}
await fetch("/config/save",{method:"POST"});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Relay names saved");
}
async function saveTempConfig(){
const r=await fetch("/status",{cache:"no-store"});
const r=await fetch(api("/status"),{cache:"no-store"});
const data=await r.json();
const temps=data.config?.temperature_sensors||[];
@@ -613,7 +601,7 @@ async function saveTempConfig(){
const enabled=$("tempEnabled"+i)?.checked || false;
const weather=$("tempWeather"+i)?.checked || false;
await fetch("/config/temp",{
await fetch(api("/config/temp"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
@@ -621,20 +609,19 @@ async function saveTempConfig(){
name:name,
address:temps[i].address||"",
enabled:enabled,
weather:weather,
weather:weather
})
});
}
await fetch("/config/save",{method:"POST"});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Temperature config saved");
}
async function loadWifiConfig(){
try{
const r=await fetch("/config/wifi",{cache:"no-store"});
const r=await fetch(api("/config/wifi"),{cache:"no-store"});
const d=await r.json();
const nets=d.wifi?.networks||[];
for(let i=0;i<3;i++){
@@ -652,17 +639,17 @@ async function saveWifi(){
const priority=parseInt($("w"+i+"r").value||i,10);
if(ssid) networks.push({ssid,password,priority});
}
await fetch("/config/wifi",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({networks})});
await fetch(api("/config/wifi"),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({networks})});
await loadWifiConfig();
await load();
}
async function connectWifi(){
await fetch("/wifi/connect",{method:"POST"});
await fetch(api("/wifi/connect"),{method:"POST"});
await load();
}
async function load(){
try{
const r=await fetch("/status",{cache:"no-store"});
const r=await fetch(api("/status"),{cache:"no-store"});
render(await r.json());
}catch(e){
$("conn").textContent="Offline";
@@ -678,6 +665,8 @@ setInterval(load,3000);
)rawliteral";
WebServer server(80);
#define API_V1(path) "/api/v1" path
HardwareSerial DashboardSerial(2);
String uartLineBuffer;
@@ -2445,6 +2434,32 @@ void setup() {
server.send_P(200, "text/html", INDEX_HTML);
});
server.on(API_V1("/status"), handleStatus);
server.on(API_V1("/relay/set"), HTTP_POST, handleSetRelayPost);
server.on(API_V1("/config"), HTTP_GET, handleGetConfig);
server.on(API_V1("/config/wifi"), HTTP_GET, handleGetWifiConfig);
server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig);
server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect);
server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear);
server.on(API_V1("/config/device"), HTTP_POST, handleUpdateDeviceConfig);
server.on(API_V1("/config/relay"), HTTP_POST, handleUpdateRelayConfig);
server.on(API_V1("/config/bms"), HTTP_POST, handleUpdateBmsConfig);
server.on(API_V1("/config/temp"), HTTP_POST, handleUpdateTempSensorConfig);
server.on(API_V1("/config/factory-reset"), HTTP_POST, handleFactoryResetConfig);
server.on(API_V1("/config/save"), HTTP_POST, handleSaveConfig);
server.on(API_V1("/temps/scan"), HTTP_POST, handleTempScan);
server.on(API_V1("/temps/assign"), HTTP_POST, handleTempAssign);
server.on(API_V1("/temps/clear"), HTTP_POST, handleTempClear);
server.on(API_V1("/bms/setup/enter"), HTTP_POST, handleEnterBmsSetup);
server.on(API_V1("/bms/setup/exit"), HTTP_POST, handleExitBmsSetup);
server.on(API_V1("/bms/scan"), HTTP_POST, handleBleScan);
server.on(API_V1("/bms/select"), HTTP_POST, handleSelectBms);
// Compatibility aliases for pre-versioned local clients.
server.on("/status", handleStatus);
server.on("/relay/set", HTTP_POST, handleSetRelayPost);
server.on("/relay/relay_1/on", HTTP_GET, handleGenericRelayRoute);
+6 -5
View File
@@ -1,16 +1,17 @@
import json
class HttpClient:
def __init__(self, requests_module, base_url="http://192.168.4.1"):
self.requests = requests_module
self.base_url = base_url.rstrip("/")
if not self.base_url.endswith("/api/v1"):
self.base_url = f"{self.base_url}/api/v1"
def get_status(self):
response = self.requests.get(f"{self.base_url}/status")
return response.json()
def set_relay(self, relay, enabled):
state = "on" if enabled else "off"
response = self.requests.get(f"{self.base_url}/relay/{relay}/{state}")
response = self.requests.post(
f"{self.base_url}/relay/set",
json={"id": relay, "state": enabled},
)
return response.json()
+4 -2
View File
@@ -24,6 +24,8 @@ class AppState:
}
self.relays = {
"relay_1": False,
"relay_2": False,
"starlink": False,
"fridge": False,
}
@@ -51,8 +53,8 @@ class AppState:
self.network.update(message.get("network", {}))
def update_from_relay_response(self, message):
relay = message.get("relay")
enabled = message.get("enabled")
relay = message.get("relay", message.get("id"))
enabled = message.get("enabled", message.get("state"))
if relay in self.relays and enabled is not None:
self.relays[relay] = bool(enabled)
+44 -3
View File
@@ -22,14 +22,41 @@ def assert_keys(payload, keys):
def registered_routes():
pattern = re.compile(r'server\.on\("([^"]+)"(?:,\s*(HTTP_[A-Z]+))?')
return {(match.group(1), match.group(2) or "ANY") for match in pattern.finditer(firmware_source())}
source = firmware_source()
literal_pattern = re.compile(r'server\.on\("([^"]+)"(?:,\s*(HTTP_[A-Z]+))?')
macro_pattern = re.compile(r'server\.on\(API_V1\("([^"]+)"\)(?:,\s*(HTTP_[A-Z]+))?')
routes = {(match.group(1), match.group(2) or "ANY") for match in literal_pattern.finditer(source)}
routes.update({
(f"/api/v1{match.group(1)}", match.group(2) or "ANY")
for match in macro_pattern.finditer(source)
})
return routes
def test_firmware_registers_current_http_contract_routes():
routes = registered_routes()
expected_routes = {
("/api/v1/status", "ANY"),
("/api/v1/config", "HTTP_GET"),
("/api/v1/relay/set", "HTTP_POST"),
("/api/v1/config/wifi", "HTTP_GET"),
("/api/v1/config/wifi", "HTTP_POST"),
("/api/v1/wifi/connect", "HTTP_POST"),
("/api/v1/wifi/clear", "HTTP_POST"),
("/api/v1/temps/scan", "HTTP_POST"),
("/api/v1/temps/assign", "HTTP_POST"),
("/api/v1/temps/clear", "HTTP_POST"),
}
assert expected_routes <= routes
def test_firmware_keeps_root_compatibility_aliases():
routes = registered_routes()
compatibility_routes = {
("/status", "ANY"),
("/config", "HTTP_GET"),
("/relay/set", "HTTP_POST"),
@@ -42,7 +69,21 @@ def test_firmware_registers_current_http_contract_routes():
("/temps/clear", "HTTP_POST"),
}
assert expected_routes <= routes
assert compatibility_routes <= routes
def test_embedded_webui_uses_versioned_api_routes():
source = firmware_source()
assert 'const API_BASE="/api/v1";' in source
assert 'fetch(api("' in source
assert 'fetch("/status"' not in source
assert 'fetch("/config' not in source
assert 'fetch("/relay' not in source
assert 'fetch("/temps' not in source
assert 'fetch("/wifi' not in source
assert "/bms/reconnect" not in source
assert "/system/restart" not in source
def test_status_fixture_matches_dashboard_contract_shape():
+33 -11
View File
@@ -241,6 +241,7 @@ class FakeResponse:
class FakeRequests:
def __init__(self):
self.urls = []
self.posts = []
self.responses = []
def get(self, url):
@@ -251,6 +252,15 @@ class FakeRequests:
return FakeResponse({"ok": True})
def post(self, url, json=None):
self.urls.append(url)
self.posts.append({"url": url, "json": json})
if self.responses:
return FakeResponse(self.responses.pop(0))
return FakeResponse({"ok": True})
def test_http_client_get_status():
from comms.http_client import HttpClient
@@ -264,7 +274,7 @@ def test_http_client_get_status():
payload = client.get_status()
assert fake_requests.urls == ["http://192.168.4.1/status"]
assert fake_requests.urls == ["http://192.168.4.1/api/v1/status"]
assert payload["type"] == "status_response"
assert payload["battery"]["soc"] == 82
@@ -275,9 +285,13 @@ def test_http_client_set_relay_on():
fake_requests = FakeRequests()
client = HttpClient(fake_requests)
payload = client.set_relay("starlink", True)
payload = client.set_relay("relay_1", True)
assert fake_requests.urls == ["http://192.168.4.1/relay/starlink/on"]
assert fake_requests.urls == ["http://192.168.4.1/api/v1/relay/set"]
assert fake_requests.posts == [{
"url": "http://192.168.4.1/api/v1/relay/set",
"json": {"id": "relay_1", "state": True},
}]
assert payload == {"ok": True}
@@ -287,9 +301,13 @@ def test_http_client_set_relay_off():
fake_requests = FakeRequests()
client = HttpClient(fake_requests)
payload = client.set_relay("fridge", False)
payload = client.set_relay("relay_2", False)
assert fake_requests.urls == ["http://192.168.4.1/relay/fridge/off"]
assert fake_requests.urls == ["http://192.168.4.1/api/v1/relay/set"]
assert fake_requests.posts == [{
"url": "http://192.168.4.1/api/v1/relay/set",
"json": {"id": "relay_2", "state": False},
}]
assert payload == {"ok": True}
@@ -317,7 +335,7 @@ def test_communication_service_http_fallback_status():
assert response["type"] == "status_response"
assert state.battery["soc"] == 66
assert fake_uart.writes == []
assert fake_requests.urls == ["http://192.168.4.1/status"]
assert fake_requests.urls == ["http://192.168.4.1/api/v1/status"]
def test_communication_service_http_fallback_relay():
@@ -330,8 +348,8 @@ def test_communication_service_http_fallback_relay():
fake_requests.responses = [
{
"type": "relay_response",
"relay": "fridge",
"enabled": True,
"id": "relay_1",
"state": True,
"ok": True,
}
]
@@ -344,12 +362,16 @@ def test_communication_service_http_fallback_relay():
)
service.enable_http_fallback()
response = service.set_relay("fridge", True)
response = service.set_relay("relay_1", True)
assert response["type"] == "relay_response"
assert state.relays["fridge"] is True
assert state.relays["relay_1"] is True
assert fake_uart.writes == []
assert fake_requests.urls == ["http://192.168.4.1/relay/fridge/on"]
assert fake_requests.urls == ["http://192.168.4.1/api/v1/relay/set"]
assert fake_requests.posts == [{
"url": "http://192.168.4.1/api/v1/relay/set",
"json": {"id": "relay_1", "state": True},
}]
def test_communication_service_marks_uart_connected_on_status():