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 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. 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: Current firmware version:
@@ -20,34 +26,36 @@ Current firmware version:
```text ```text
GET / GET /
GET /status GET /api/v1/status
GET /config GET /api/v1/config
POST /relay/set POST /api/v1/relay/set
POST /config/device POST /api/v1/config/device
POST /config/relay POST /api/v1/config/relay
POST /config/temp POST /api/v1/config/temp
POST /config/bms POST /api/v1/config/bms
POST /config/save POST /api/v1/config/save
POST /config/factory-reset POST /api/v1/config/factory-reset
GET /config/wifi GET /api/v1/config/wifi
POST /config/wifi POST /api/v1/config/wifi
POST /wifi/connect POST /api/v1/wifi/connect
POST /wifi/clear POST /api/v1/wifi/clear
POST /temps/scan POST /api/v1/temps/scan
POST /temps/assign POST /api/v1/temps/assign
POST /temps/clear POST /api/v1/temps/clear
POST /bms/setup/enter POST /api/v1/bms/setup/enter
POST /bms/setup/exit POST /api/v1/bms/setup/exit
POST /bms/scan POST /api/v1/bms/scan
POST /bms/select 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 ```text
GET /relay/relay_1/on GET /relay/relay_1/on
@@ -61,15 +69,15 @@ GET /relay/relay_2/off
The Waveshare ESP32-S3 dashboard MVP should use only: The Waveshare ESP32-S3 dashboard MVP should use only:
```text ```text
GET /status GET /api/v1/status
POST /relay/set 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. 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 ## Status
### GET /status ### GET /api/v1/status
Returns complete controller status. Returns complete controller status.
@@ -144,7 +152,7 @@ Fields:
| `online` | Current sensor detection state | | `online` | Current sensor detection state |
| `temperature_f` | Current Fahrenheit value, or `null` when offline | | `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 ### relays
@@ -228,11 +236,11 @@ The AP remains enabled even when STA WiFi is configured or connected.
### config ### 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 ## Configuration
### GET /config ### GET /api/v1/config
Returns saved controller configuration. 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. Updates the controller display name.
@@ -278,7 +286,7 @@ Updates the controller display name.
Returns the updated config. Returns the updated config.
### POST /config/relay ### POST /api/v1/config/relay
Updates relay configuration. Updates relay configuration.
@@ -300,7 +308,7 @@ Fields:
Returns the updated config. Returns the updated config.
### POST /config/temp ### POST /api/v1/config/temp
Updates temperature sensor configuration. Updates temperature sensor configuration.
@@ -326,7 +334,7 @@ Fields:
Returns the updated config. Returns the updated config.
### POST /config/bms ### POST /api/v1/config/bms
Updates BMS configuration. Updates BMS configuration.
@@ -348,13 +356,13 @@ random
Returns the updated config. Returns the updated config.
### POST /config/save ### POST /api/v1/config/save
Persists the current active configuration. Persists the current active configuration.
Returns the current config. Returns the current config.
### POST /config/factory-reset ### POST /api/v1/config/factory-reset
Clears saved configuration and restores firmware defaults. Clears saved configuration and restores firmware defaults.
@@ -362,7 +370,7 @@ Returns the reset config.
## Relay Control ## Relay Control
### POST /relay/set ### POST /api/v1/relay/set
Preferred relay command endpoint. Preferred relay command endpoint.
@@ -407,7 +415,7 @@ Response:
## WiFi ## WiFi
### GET /config/wifi ### GET /api/v1/config/wifi
Returns AP/STA WiFi configuration status. Passwords are not returned. 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: Preferred multi-network request:
@@ -473,13 +481,13 @@ Runtime behavior:
- Lower priority numbers are tried first. - Lower priority numbers are tried first.
- If STA disconnects, saved networks are retried by priority. - 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. Attempts STA connection using saved networks by priority.
Returns WiFi config status. Returns WiFi config status.
### POST /wifi/clear ### POST /api/v1/wifi/clear
Clears saved STA WiFi networks. Clears saved STA WiFi networks.
@@ -487,7 +495,7 @@ Returns WiFi config status.
## Temperature Probe Setup ## Temperature Probe Setup
### POST /temps/scan ### POST /api/v1/temps/scan
Scans the DS18B20 bus and returns unassigned probe addresses. 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. 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. Assigns a scanned probe to a logical temperature slot.
@@ -530,7 +538,7 @@ By slot number:
Returns the updated config. Returns the updated config.
### POST /temps/clear ### POST /api/v1/temps/clear
Clears one or all temperature assignments. 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. 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. Enters BMS setup mode.
@@ -574,7 +582,7 @@ Response:
} }
``` ```
### POST /bms/setup/exit ### POST /api/v1/bms/setup/exit
Exits BMS setup mode. Exits BMS setup mode.
@@ -588,7 +596,7 @@ Response:
} }
``` ```
### POST /bms/scan ### POST /api/v1/bms/scan
Scans for BLE devices. Scans for BLE devices.
@@ -610,7 +618,7 @@ Response:
Some BMS devices advertise intermittently. Repeated scans may be needed. 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. 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 ## 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. 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: These routes are not part of the current registered HTTP API:
```text ```text
POST /bms/reconnect POST /api/v1/bms/reconnect
POST /system/restart POST /api/v1/system/restart
``` ```
If added later, update this document and add contract tests. 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: Preferred direction:
GET /status GET /api/v1/status
GET /config GET /api/v1/config
POST /relay/set 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 ## Generic IDs
+1 -1
View File
@@ -21,7 +21,7 @@
- WiFi priority and runtime reconnect behavior. - WiFi priority and runtime reconnect behavior.
- HTTP/UART/USB serial command parity documentation. - HTTP/UART/USB serial command parity documentation.
- DS18B20 configurable temperature probe support. - DS18B20 configurable temperature probe support.
- Preferred relay command endpoint: `POST /relay/set`. - Preferred relay command endpoint: `POST /api/v1/relay/set`.
### Changed ### Changed
+5 -5
View File
@@ -38,7 +38,7 @@ Serial command:
HTTP endpoint: HTTP endpoint:
POST /config/device POST /api/v1/config/device
## Relay Configuration ## Relay Configuration
@@ -67,7 +67,7 @@ Serial commands:
HTTP endpoint: HTTP endpoint:
POST /config/relay POST /api/v1/config/relay
## Temperature Sensor Configuration ## Temperature Sensor Configuration
@@ -102,7 +102,7 @@ Serial commands:
HTTP endpoint: HTTP endpoint:
POST /config/temp POST /api/v1/config/temp
## BMS Configuration ## BMS Configuration
@@ -127,7 +127,7 @@ Serial commands:
HTTP endpoint: HTTP endpoint:
POST /config/bms POST /api/v1/config/bms
## BMS Setup Mode ## BMS Setup Mode
@@ -157,7 +157,7 @@ Serial command:
HTTP endpoint: HTTP endpoint:
POST /config/factory-reset POST /api/v1/config/factory-reset
After factory reset: After factory reset:
+10 -10
View File
@@ -12,14 +12,14 @@ Control surfaces:
| Feature | USB Serial | UART JSON | HTTP API | Notes | | Feature | USB Serial | UART JSON | HTTP API | Notes |
|---|---:|---:|---:|---| |---|---:|---:|---:|---|
| Status | Yes | Yes | Yes | `/status` equals `status_request` | | Status | Yes | Yes | Yes | `/api/v1/status` equals `status_request` |
| Config view | Yes | Yes | Yes | `/config` equals `config_request` | | Config view | Yes | Yes | Yes | `/api/v1/config` equals `config_request` |
| Relay control | Yes | Yes | Yes | Preferred HTTP endpoint is `POST /relay/set` | | Relay control | Yes | Yes | Yes | Preferred HTTP endpoint is `POST /api/v1/relay/set` |
| Device name config | Yes | Yes | Yes | `devicename`, `config_device`, `/config/device` | | Device name config | Yes | Yes | Yes | `devicename`, `config_device`, `/api/v1/config/device` |
| Relay config | Yes | Yes | Yes | `relayname`, `config_relay`, `/config/relay` | | Relay config | Yes | Yes | Yes | `relayname`, `config_relay`, `/api/v1/config/relay` |
| Temperature config | Yes | Yes | Yes | `tempname`, `config_temp`, `/config/temp` | | Temperature config | Yes | Yes | Yes | `tempname`, `config_temp`, `/api/v1/config/temp` |
| BMS config | Yes | Yes | Yes | `bmsname`/`bmsaddr`, `config_bms`, `/config/bms` | | BMS config | Yes | Yes | Yes | `bmsname`/`bmsaddr`, `config_bms`, `/api/v1/config/bms` |
| Save config | Yes | Yes | Yes | `save`, `save_config`, `/config/save` | | Save config | Yes | Yes | Yes | `save`, `save_config`, `/api/v1/config/save` |
| Factory reset | Yes | Yes | Yes | AP remains recovery path | | Factory reset | Yes | Yes | Yes | AP remains recovery path |
| BMS setup enter | Yes | Yes | Yes | Setup pauses normal BMS reconnect behavior | | BMS setup enter | Yes | Yes | Yes | Setup pauses normal BMS reconnect behavior |
| BMS setup exit | Yes | Yes | Yes | Returns to normal mode | | 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: 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/on
GET /relay/relay_1/off GET /relay/relay_1/off
+9 -9
View File
@@ -47,15 +47,15 @@ All grounds remain common.
## API endpoints consumed ## API endpoints consumed
Minimum dashboard MVP: Minimum dashboard MVP:
- GET /status - GET /api/v1/status
- POST /relay/set - POST /api/v1/relay/set
Potential later use: Potential later use:
- GET /config - GET /api/v1/config
- POST /config/save - POST /api/v1/config/save
- POST /temps/scan - POST /api/v1/temps/scan
- POST /temps/assign - POST /api/v1/temps/assign
- POST /temps/clear - POST /api/v1/temps/clear
## MVP Display Goals ## MVP Display Goals
@@ -120,10 +120,10 @@ Then interactive:
1. Dashboard boots. 1. Dashboard boots.
2. Connects to cargo ESP32 AP. 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. 4. Shows disconnected state until valid JSON is received.
5. Updates dashboard on a fixed interval. 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 ## Polling Guidance
@@ -209,7 +209,6 @@ input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;ba
</div> </div>
<div class="btnrow"> <div class="btnrow">
<button onclick="setBmsEnabled(true)">Enable BMS</button> <button onclick="setBmsEnabled(true)">Enable BMS</button>
<button onclick="reconnectBms()">Reconnect BMS</button>
</div> </div>
</div> </div>
</section> </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="label">System Actions</div>
<div class="sub">Use carefully. Factory reset clears install-specific configuration.</div> <div class="sub">Use carefully. Factory reset clears install-specific configuration.</div>
<div class="btnrow"> <div class="btnrow">
<button onclick="restartController()">Restart</button>
<button class="off" onclick="factoryReset()">Factory Reset</button> <button class="off" onclick="factoryReset()">Factory Reset</button>
</div> </div>
</section> </section>
@@ -240,6 +238,8 @@ input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;ba
</div> </div>
<script> <script>
const API_BASE="/api/v1";
function api(path){return API_BASE+path;}
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
function fmt(n,d=1){return typeof n==="number"?n.toFixed(d):"--"} function fmt(n,d=1){return typeof n==="number"?n.toFixed(d):"--"}
function temp(v){return typeof v==="number"?fmt(v,1)+"°F":"Offline"} function temp(v){return typeof v==="number"?fmt(v,1)+"°F":"Offline"}
@@ -323,12 +323,12 @@ function showTab(name){
}); });
} }
async function setBmsEnabled(enabled){ async function setBmsEnabled(enabled){
await fetch("/config/bms",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled})}); await fetch(api("/config/bms"),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled})});
await fetch("/config/save",{method:"POST"}); await fetch(api("/config/save"),{method:"POST"});
await load(); await load();
} }
async function relay(id,on){ 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(); await load();
} }
function render(data){ function render(data){
@@ -411,13 +411,13 @@ async function saveDeviceConfig(){
const name=$("deviceNameInput")?.value.trim(); const name=$("deviceNameInput")?.value.trim();
if(!name){ alert("Device name is required"); return; } if(!name){ alert("Device name is required"); return; }
await fetch("/config/device",{ await fetch(api("/config/device"),{
method:"POST", method:"POST",
headers:{"Content-Type":"application/json"}, headers:{"Content-Type":"application/json"},
body:JSON.stringify({device_name:name}) body:JSON.stringify({device_name:name})
}); });
await fetch("/config/save",{method:"POST"}); await fetch(api("/config/save"),{method:"POST"});
await load(); await load();
alert("Device name saved"); alert("Device name saved");
} }
@@ -432,7 +432,7 @@ async function saveBmsFullConfig(){
return; return;
} }
await fetch("/config/bms",{ await fetch(api("/config/bms"),{
method:"POST", method:"POST",
headers:{"Content-Type":"application/json"}, headers:{"Content-Type":"application/json"},
body:JSON.stringify({ body:JSON.stringify({
@@ -443,22 +443,16 @@ async function saveBmsFullConfig(){
}) })
}); });
await fetch("/config/save",{method:"POST"}); await fetch(api("/config/save"),{method:"POST"});
await reconnectBms(false);
await load(); await load();
alert("BMS config saved"); alert("BMS config saved");
} }
async function reconnectBms(showAlert=true){
await fetch("/bms/reconnect",{method:"POST"});
if(showAlert) alert("BMS reconnect requested");
}
async function scanTemps(){ async function scanTemps(){
const box=$("tempScanResults"); const box=$("tempScanResults");
if(box) box.innerHTML=`<div class="item"><span>Scanning...</span><strong class="muted">wait</strong></div>`; 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 d=await r.json();
const devices=d.devices||[]; const devices=d.devices||[];
@@ -487,13 +481,13 @@ async function scanTemps(){
} }
async function assignTemp(id,index){ async function assignTemp(id,index){
await fetch("/temps/assign",{ await fetch(api("/temps/assign"),{
method:"POST", method:"POST",
headers:{"Content-Type":"application/json"}, headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id,index:index}) body:JSON.stringify({id:id,index:index})
}); });
await fetch("/config/save",{method:"POST"}); await fetch(api("/config/save"),{method:"POST"});
await load(); await load();
alert("Temp probe assigned to "+id); alert("Temp probe assigned to "+id);
} }
@@ -501,7 +495,7 @@ async function assignTemp(id,index){
async function clearTempAssignment(id){ async function clearTempAssignment(id){
if(!confirm("Clear probe assignment for "+id+"?")) return; if(!confirm("Clear probe assignment for "+id+"?")) return;
const r=await fetch("/temps/clear",{ const r=await fetch(api("/temps/clear"),{
method:"POST", method:"POST",
headers:{"Content-Type":"application/json"}, headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id}) body:JSON.stringify({id:id})
@@ -517,17 +511,11 @@ async function clearTempAssignment(id){
alert("Temp probe assignment cleared for "+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(){ async function factoryReset(){
if(!confirm("Factory reset all controller configuration?")) return; if(!confirm("Factory reset all controller configuration?")) return;
if(!confirm("This clears WiFi/BMS/relay/temp config. Continue?")) 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."); alert("Factory reset requested. Reconnect to the controller AP if needed.");
} }
@@ -574,7 +562,7 @@ function renderConfigControls(data){
} }
async function saveRelayConfig(){ 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 data=await r.json();
const relays=data.config?.relays||[]; const relays=data.config?.relays||[];
@@ -582,7 +570,7 @@ async function saveRelayConfig(){
const name=$("relayName"+i)?.value.trim(); const name=$("relayName"+i)?.value.trim();
if(!name) continue; if(!name) continue;
await fetch("/config/relay",{ await fetch(api("/config/relay"),{
method:"POST", method:"POST",
headers:{"Content-Type":"application/json"}, headers:{"Content-Type":"application/json"},
body:JSON.stringify({ 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(); await load();
alert("Relay names saved"); alert("Relay names saved");
} }
async function saveTempConfig(){ 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 data=await r.json();
const temps=data.config?.temperature_sensors||[]; const temps=data.config?.temperature_sensors||[];
@@ -613,7 +601,7 @@ async function saveTempConfig(){
const enabled=$("tempEnabled"+i)?.checked || false; const enabled=$("tempEnabled"+i)?.checked || false;
const weather=$("tempWeather"+i)?.checked || false; const weather=$("tempWeather"+i)?.checked || false;
await fetch("/config/temp",{ await fetch(api("/config/temp"),{
method:"POST", method:"POST",
headers:{"Content-Type":"application/json"}, headers:{"Content-Type":"application/json"},
body:JSON.stringify({ body:JSON.stringify({
@@ -621,20 +609,19 @@ async function saveTempConfig(){
name:name, name:name,
address:temps[i].address||"", address:temps[i].address||"",
enabled:enabled, enabled:enabled,
weather:weather,
weather:weather weather:weather
}) })
}); });
} }
await fetch("/config/save",{method:"POST"}); await fetch(api("/config/save"),{method:"POST"});
await load(); await load();
alert("Temperature config saved"); alert("Temperature config saved");
} }
async function loadWifiConfig(){ async function loadWifiConfig(){
try{ 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 d=await r.json();
const nets=d.wifi?.networks||[]; const nets=d.wifi?.networks||[];
for(let i=0;i<3;i++){ for(let i=0;i<3;i++){
@@ -652,17 +639,17 @@ async function saveWifi(){
const priority=parseInt($("w"+i+"r").value||i,10); const priority=parseInt($("w"+i+"r").value||i,10);
if(ssid) networks.push({ssid,password,priority}); 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 loadWifiConfig();
await load(); await load();
} }
async function connectWifi(){ async function connectWifi(){
await fetch("/wifi/connect",{method:"POST"}); await fetch(api("/wifi/connect"),{method:"POST"});
await load(); await load();
} }
async function load(){ async function load(){
try{ try{
const r=await fetch("/status",{cache:"no-store"}); const r=await fetch(api("/status"),{cache:"no-store"});
render(await r.json()); render(await r.json());
}catch(e){ }catch(e){
$("conn").textContent="Offline"; $("conn").textContent="Offline";
@@ -678,6 +665,8 @@ setInterval(load,3000);
)rawliteral"; )rawliteral";
WebServer server(80); WebServer server(80);
#define API_V1(path) "/api/v1" path
HardwareSerial DashboardSerial(2); HardwareSerial DashboardSerial(2);
String uartLineBuffer; String uartLineBuffer;
@@ -2445,6 +2434,32 @@ void setup() {
server.send_P(200, "text/html", INDEX_HTML); 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("/status", handleStatus);
server.on("/relay/set", HTTP_POST, handleSetRelayPost); server.on("/relay/set", HTTP_POST, handleSetRelayPost);
server.on("/relay/relay_1/on", HTTP_GET, handleGenericRelayRoute); server.on("/relay/relay_1/on", HTTP_GET, handleGenericRelayRoute);
+6 -5
View File
@@ -1,16 +1,17 @@
import json
class HttpClient: class HttpClient:
def __init__(self, requests_module, base_url="http://192.168.4.1"): def __init__(self, requests_module, base_url="http://192.168.4.1"):
self.requests = requests_module self.requests = requests_module
self.base_url = base_url.rstrip("/") 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): def get_status(self):
response = self.requests.get(f"{self.base_url}/status") response = self.requests.get(f"{self.base_url}/status")
return response.json() return response.json()
def set_relay(self, relay, enabled): def set_relay(self, relay, enabled):
state = "on" if enabled else "off" response = self.requests.post(
response = self.requests.get(f"{self.base_url}/relay/{relay}/{state}") f"{self.base_url}/relay/set",
json={"id": relay, "state": enabled},
)
return response.json() return response.json()
+4 -2
View File
@@ -24,6 +24,8 @@ class AppState:
} }
self.relays = { self.relays = {
"relay_1": False,
"relay_2": False,
"starlink": False, "starlink": False,
"fridge": False, "fridge": False,
} }
@@ -51,8 +53,8 @@ class AppState:
self.network.update(message.get("network", {})) self.network.update(message.get("network", {}))
def update_from_relay_response(self, message): def update_from_relay_response(self, message):
relay = message.get("relay") relay = message.get("relay", message.get("id"))
enabled = message.get("enabled") enabled = message.get("enabled", message.get("state"))
if relay in self.relays and enabled is not None: if relay in self.relays and enabled is not None:
self.relays[relay] = bool(enabled) self.relays[relay] = bool(enabled)
+44 -3
View File
@@ -22,14 +22,41 @@ def assert_keys(payload, keys):
def registered_routes(): def registered_routes():
pattern = re.compile(r'server\.on\("([^"]+)"(?:,\s*(HTTP_[A-Z]+))?') source = firmware_source()
return {(match.group(1), match.group(2) or "ANY") for match in pattern.finditer(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(): def test_firmware_registers_current_http_contract_routes():
routes = registered_routes() routes = registered_routes()
expected_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"), ("/status", "ANY"),
("/config", "HTTP_GET"), ("/config", "HTTP_GET"),
("/relay/set", "HTTP_POST"), ("/relay/set", "HTTP_POST"),
@@ -42,7 +69,21 @@ def test_firmware_registers_current_http_contract_routes():
("/temps/clear", "HTTP_POST"), ("/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(): def test_status_fixture_matches_dashboard_contract_shape():
+33 -11
View File
@@ -241,6 +241,7 @@ class FakeResponse:
class FakeRequests: class FakeRequests:
def __init__(self): def __init__(self):
self.urls = [] self.urls = []
self.posts = []
self.responses = [] self.responses = []
def get(self, url): def get(self, url):
@@ -251,6 +252,15 @@ class FakeRequests:
return FakeResponse({"ok": True}) 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(): def test_http_client_get_status():
from comms.http_client import HttpClient from comms.http_client import HttpClient
@@ -264,7 +274,7 @@ def test_http_client_get_status():
payload = 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["type"] == "status_response"
assert payload["battery"]["soc"] == 82 assert payload["battery"]["soc"] == 82
@@ -275,9 +285,13 @@ def test_http_client_set_relay_on():
fake_requests = FakeRequests() fake_requests = FakeRequests()
client = HttpClient(fake_requests) 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} assert payload == {"ok": True}
@@ -287,9 +301,13 @@ def test_http_client_set_relay_off():
fake_requests = FakeRequests() fake_requests = FakeRequests()
client = HttpClient(fake_requests) 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} assert payload == {"ok": True}
@@ -317,7 +335,7 @@ def test_communication_service_http_fallback_status():
assert response["type"] == "status_response" assert response["type"] == "status_response"
assert state.battery["soc"] == 66 assert state.battery["soc"] == 66
assert fake_uart.writes == [] 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(): def test_communication_service_http_fallback_relay():
@@ -330,8 +348,8 @@ def test_communication_service_http_fallback_relay():
fake_requests.responses = [ fake_requests.responses = [
{ {
"type": "relay_response", "type": "relay_response",
"relay": "fridge", "id": "relay_1",
"enabled": True, "state": True,
"ok": True, "ok": True,
} }
] ]
@@ -344,12 +362,16 @@ def test_communication_service_http_fallback_relay():
) )
service.enable_http_fallback() service.enable_http_fallback()
response = service.set_relay("fridge", True) response = service.set_relay("relay_1", True)
assert response["type"] == "relay_response" assert response["type"] == "relay_response"
assert state.relays["fridge"] is True assert state.relays["relay_1"] is True
assert fake_uart.writes == [] 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(): def test_communication_service_marks_uart_connected_on_status():