Files
overland-controller/firmware/esp32/overland-controller/overland-controller.ino
T
2026-06-08 08:38:11 -06:00

3247 lines
102 KiB
Arduino

#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <esp_wifi.h>
#include <esp_system.h>
#include <ArduinoJson.h>
#include "config.h"
#include "protocol.h"
#include "relays.h"
#include "sensors.h"
#include "bms.h"
#include "alarms.h"
#include "system_status.h"
#include "app_config.h"
#include "logger.h"
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Overland Controller</title>
<style>
:root{--bg:#0b0f14;--card:#141b24;--muted:#7f8da3;--text:#eef4ff;--good:#48d17a;--warn:#ffd166;--bad:#ff5c7a;--line:#223044;--btn:#233249}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif}
.wrap{max-width:980px;margin:0 auto;padding:14px 14px 90px}
.top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}
.topRight{display:grid;gap:6px;justify-items:end}
.weatherBadge{font-size:20px;font-weight:900;color:var(--text);padding:8px 12px}
h1{font-size:21px;margin:0}.sub{color:var(--muted);font-size:13px;margin-top:4px}
.pill{border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:var(--muted);font-size:12px;white-space:nowrap}
.tabs{position:fixed;left:0;right:0;bottom:0;background:#080c11;border-top:1px solid var(--line);display:grid;grid-template-columns:repeat(3,1fr);z-index:10}
.subtabs{display:flex;gap:8px;overflow-x:auto;padding:2px 0 12px;margin-bottom:4px}.subtabbtn{width:auto;white-space:nowrap;border:1px solid var(--line);background:#101722;color:var(--muted);padding:10px 12px;border-radius:999px;font-size:13px}.subtabbtn.active{background:#1f2b3c;color:var(--text);border-color:#3b536f}.configSubpage{display:none}.configSubpage.active{display:block}
.tabbtn{border:0;border-radius:0;background:#080c11;color:var(--muted);padding:13px 8px;font-weight:800}
.tabbtn.active{color:var(--text);background:#111923}
.page{display:none}.page.active{display:block}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:0 10px 28px rgba(0,0,0,.22)}
.span4{grid-column:span 4}.span6{grid-column:span 6}.span12{grid-column:span 12}
.label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}
.big{font-size:42px;font-weight:800;margin:8px 0}
.medium{font-size:25px;font-weight:800;margin:6px 0}
.row{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding-top:10px;margin-top:10px}
.v{font-weight:700}.good{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}.muted{color:var(--muted)}
.bar{height:10px;background:#0e141c;border-radius:999px;overflow:hidden;border:1px solid var(--line)}
.fill{height:100%;background:linear-gradient(90deg,var(--good),#9be15d);width:0%}
button{width:100%;border:0;border-radius:12px;padding:12px;background:var(--btn);color:var(--text);font-weight:800;font-size:15px}
button.on{background:#17462a;color:#dfffea}button.off{background:#46202a;color:#ffe2e8}
.relayBadge{
padding:4px 8px;
border-radius:999px;
font-size:11px;
font-weight:700;
border:1px solid var(--line);
}
.relayBadge.on{
background:#173c22;
color:#dfffea;
}
.relayBadge.off{
background:#3f1820;
color:#ffe2e8;
}
.relayBadge.pending{
background:#4d3f12;
color:#fff0b3;
}
.relayFeedback{
font-size:11px;
color:var(--muted);
margin-top:4px;
}
input{width:100%;border:1px solid var(--line);border-radius:12px;padding:12px;background:#0f151d;color:var(--text);font-size:15px}
.list{display:grid;gap:8px;margin-top:12px}
.item{display:flex;justify-content:space-between;gap:8px;background:#0f151d;border:1px solid var(--line);border-radius:12px;padding:10px}
.btnrow{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px}
.configrow{display:grid;grid-template-columns:90px 1fr 90px;gap:8px;align-items:center;background:#0f151d;border:1px solid var(--line);border-radius:12px;padding:10px}
.configrow label{color:var(--muted);font-size:12px}
.configrow input[type="checkbox"]{width:auto}
.configrow input{margin:0}
.tempConfigRow{grid-template-columns:90px 1fr auto}
.checkGroup{display:flex;gap:8px;align-items:center;justify-content:flex-end;white-space:nowrap}
.checkPill{display:flex;gap:5px;align-items:center;background:#111923;border:1px solid var(--line);border-radius:999px;padding:7px 10px;color:var(--muted);font-size:12px}
.checkPill input{width:auto;margin:0}
.tempAddress{grid-column:2 / 4;color:var(--muted);font-size:11px;font-family:monospace;margin-top:-6px;overflow-wrap:anywhere}
.kpi{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:12px}
.kpi div{background:#0f151d;border:1px solid var(--line);border-radius:12px;padding:10px}
.kpi .num{font-size:20px;font-weight:800;margin-top:4px}
@media(max-width:760px){.span4,.span6{grid-column:span 12}.big{font-size:36px}.wrap{padding:12px 12px 82px}.kpi{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<div>
<h1 id="device">Overland Controller</h1>
<div class="sub" id="subtitle">Local camp dashboard</div>
</div>
<div class="topRight">
<div class="pill weatherBadge" id="weatherBadge" style="display:none">--</div>
<div class="pill" id="conn">API Connecting...</div>
</div>
</div>
<div id="overviewPage" class="page active">
<div class="grid">
<section class="card span6">
<div class="label">Battery</div>
<div class="big"><span id="soc">--</span>%</div>
<div class="bar"><div class="fill" id="socbar"></div></div>
<div class="kpi">
<div><div class="muted">Voltage</div><div class="num" id="voltage">--</div></div>
<div><div class="muted">Current</div><div class="num" id="current">--</div></div>
<div><div class="muted" id="runtimeLabel">Runtime</div><div class="num" id="runtime">--</div></div>
</div>
</section>
<section class="card span6">
<div class="label">Relays</div>
<div class="list" id="relays"></div>
</section>
<section class="card span6">
<div class="label">Temperatures</div>
<div class="list" id="temps"></div>
</section>
<section class="card span6">
<div class="label">Network / Alarms</div>
<div class="row"><span class="muted">STA</span><span class="v" id="wifiSta">--</span></div>
<div class="row"><span class="muted">STA IP</span><span class="v" id="wifiIp">--</span></div>
<div class="list" id="alarmSummary"></div>
</section>
</div>
</div>
<div id="batteryPage" class="page">
<div class="grid">
<section class="card span6">
<div class="label">Battery Detail</div>
<div class="medium" id="bmsState">--</div>
<div class="row"><span class="muted">Source</span><span class="v" id="battSource">--</span></div>
<div class="row"><span class="muted">Remaining</span><span class="v" id="ah">--</span></div>
<div class="row"><span class="muted">Capacity</span><span class="v" id="capacity">--</span></div>
<div class="row"><span class="muted">Battery temp</span><span class="v" id="battTemp">--</span></div>
<div class="row"><span class="muted">Cycles</span><span class="v" id="cycles">--</span></div>
</section>
<section class="card span6">
<div class="label">Cells</div>
<div class="row"><span class="muted">Cell count</span><span class="v" id="cellCount">--</span></div>
<div class="row"><span class="muted">Cell delta</span><span class="v" id="delta">--</span></div>
<div class="list" id="cells"></div>
</section>
<section class="card span12">
<div class="label">BMS Control</div>
<div class="row"><span class="muted">BMS Polling</span><span class="v" id="bmsEnabledText">--</span></div>
<div class="btnrow">
<button onclick="setBmsEnabled(true)">Enable BMS</button>
<button class="off" onclick="setBmsEnabled(false)">Disable BMS</button>
</div>
</section>
</div>
</div>
<div id="configPage" class="page">
<div class="subtabs">
<button id="configTab-general" class="subtabbtn active" onclick="showConfigSubtab('general')">General</button>
<button id="configTab-wifi" class="subtabbtn" onclick="showConfigSubtab('wifi')">WiFi / AP</button>
<button id="configTab-relays" class="subtabbtn" onclick="showConfigSubtab('relays')">Relays</button>
<button id="configTab-temps" class="subtabbtn" onclick="showConfigSubtab('temps')">Temperatures</button>
<button id="configTab-bms" class="subtabbtn" onclick="showConfigSubtab('bms')">BMS</button>
<button id="configTab-maintenance" class="subtabbtn" onclick="showConfigSubtab('maintenance')">Maintenance</button>
</div>
<div id="config-general" class="configSubpage active">
<div class="grid">
<section class="card span12 configIntro">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Settings</div>
<div class="configGroupHelp">Configure the Cargo ESP32 controller. The Cargo ESP remains the source of truth for relays, sensors, WiFi, and AP settings.</div>
</div>
</div>
</section>
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">General</div>
<div class="configGroupHelp">Controller identity shown in the WebUI and dashboard.</div>
</div>
</div>
<div class="list">
<input id="deviceNameInput" placeholder="Device name">
<button class="on" onclick="saveDeviceConfig()">Save Device Name</button>
</div>
</section>
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">System</div>
<div class="configGroupHelp">Read-only firmware and network status.</div>
</div>
</div>
<div class="row"><span class="muted">Firmware</span><span class="v" id="fw">--</span></div>
<div class="row"><span class="muted">Uptime</span><span class="v" id="uptime">--</span></div>
<div class="row"><span class="muted">AP IP</span><span class="v" id="apIp">--</span></div>
</section>
</div>
</div>
<div id="config-wifi" class="configSubpage">
<div class="grid">
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Access Point</div>
<div class="configGroupHelp">This is the local camp network hosted by the Cargo ESP32. Phones and the dashboard connect here when no Starlink/home WiFi is available.</div>
</div>
</div>
<div class="list">
<div class="row"><span class="muted">Current AP</span><span class="v" id="apCurrentSsid">--</span></div>
<div class="row"><span class="muted">Security</span><span class="v" id="apSecurity">--</span></div>
<input id="apSsidInput" placeholder="AP SSID, example: Xterra-Camp">
<div class="inputHelp">SSID must be 1-32 characters.</div>
<input id="apPasswordInput" placeholder="New AP password" type="password">
<div class="inputHelp">Password must be 8-63 characters. The current password is never displayed.</div>
<div class="inlineActions">
<button onclick="toggleApPassword()">Show / Hide Password</button>
<button class="on" onclick="saveApConfig()">Save AP Settings</button>
</div>
<div class="configWarning">Saving AP settings restarts the access point. Your phone may disconnect and need to reconnect to the new SSID/password. Dashboard auto-migration is planned but not implemented yet.</div>
</div>
</section>
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Home / Starlink WiFi</div>
<div class="configGroupHelp">Optional client networks the Cargo ESP can join. Lower priority number is tried first. The AP remains available for local access.</div>
</div>
</div>
<div class="list">
<input id="w1s" placeholder="Network 1 SSID">
<input id="w1p" placeholder="Network 1 password" type="password">
<input id="w1r" placeholder="Network 1 priority" inputmode="numeric">
<input id="w2s" placeholder="Network 2 SSID">
<input id="w2p" placeholder="Network 2 password" type="password">
<input id="w2r" placeholder="Network 2 priority" inputmode="numeric">
<input id="w3s" placeholder="Network 3 SSID">
<input id="w3p" placeholder="Network 3 password" type="password">
<input id="w3r" placeholder="Network 3 priority" inputmode="numeric">
<button onclick="saveWifi()">Save WiFi</button>
<button onclick="connectWifi()">Connect Now</button>
</div>
</section>
</div>
</div>
<div id="config-relays" class="configSubpage">
<div class="grid">
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Relay Outputs</div>
<div class="configGroupHelp">Rename relay outputs. IDs stay fixed for API/UART compatibility.</div>
</div>
</div>
<div class="list" id="relayConfigList"></div>
<button onclick="saveRelayConfig()">Save Relay Names</button>
</section>
</div>
</div>
<div id="config-temps" class="configSubpage">
<div class="grid">
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Temperature Assignment</div>
<div class="configGroupHelp">Scan probes, then assign discovered sensors to configured temp slots.</div>
</div>
</div>
<div class="list">
<button onclick="scanTemps()">Scan Temp Probes</button>
<div id="tempScanResults" class="list"></div>
</div>
</section>
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Temperature Sensor Config</div>
<div class="configGroupHelp">Set how many temp sensors are enabled, rename each slot, and pick the weather badge sensor.</div>
</div>
</div>
<div class="list">
<input id="tempEnabledCount" placeholder="Enabled sensor count, 0-8" inputmode="numeric">
<div id="tempConfigList" class="list"></div>
<button onclick="saveTempConfig()">Save Temperature Config</button>
</div>
</section>
</div>
</div>
<div id="config-bms" class="configSubpage">
<div class="grid">
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">BMS Config</div>
<div class="configGroupHelp">JBD/Xiaoxiang BLE settings. Use this for initial setup, bench mode, or replacing the battery/BMS.</div>
</div>
</div>
<div class="list">
<input id="bmsNameInput" placeholder="BMS name">
<input id="bmsAddressInput" placeholder="BMS BLE address">
<input id="bmsAddressTypeInput" placeholder="Address type: public or random">
<div class="btnrow">
<button onclick="saveBmsFullConfig()">Save BMS</button>
<button class="off" onclick="setBmsEnabled(false)">Disable BMS</button>
</div>
<div class="btnrow">
<button onclick="setBmsEnabled(true)">Enable BMS</button>
</div>
<div class="row"><span class="muted">BMS Polling</span><span class="v" id="bmsEnabledText">--</span></div>
</div>
</section>
</div>
</div>
<div id="config-maintenance" class="configSubpage">
<div class="grid">
<section class="card span12">
<div class="configGroupHeader">
<div>
<div class="configGroupTitle">Maintenance</div>
<div class="configGroupHelp">Reset and recovery actions. These can disrupt the controller.</div>
</div>
</div>
<div class="btnrow">
<button class="off" onclick="factoryReset()">Factory Reset</button>
</div>
</section>
</div>
</div>
</div>
<div class="tabs">
<button class="tabbtn active" onclick="showTab('overview')">Overview</button>
<button class="tabbtn" onclick="showTab('battery')">Battery</button>
<button class="tabbtn" onclick="showTab('config')">Config</button>
</div>
<script>
const API_BASE="/api/v1";
function api(path){return API_BASE+path;}
const relayPending={};
const relayFeedback={};
function relayStateLabel(r){
if(relayPending[r.id]!==undefined) return "SENDING";
return r.state ? "ON" : "OFF";
}
function relayStateClass(r){
if(relayPending[r.id]!==undefined) return "pending";
return r.state ? "on" : "off";
}
const STATUS_FIELDS_OVERVIEW="battery,temps,relays,vehicle,network,alarms,system";
function isConfigTabActive(){
return $("configPage")?.classList.contains("active") || false;
}
function statusUrl(){
const needsFullStatus=isConfigTabActive() || !window.lastStatus?.config;
return needsFullStatus ? api("/status") : api("/status?fields="+STATUS_FIELDS_OVERVIEW);
}
function mergeStatus(previous,next){
if(!previous) return next;
return Object.assign({},previous,next);
}
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"}
function weatherEmoji(f){
if(typeof f!=="number") return "";
if(f < 50) return "🧊";
if(f < 75) return "🙂";
if(f < 85) return "☀️";
return "🔥";
}
function toggleApPassword(){
const input=$("apPasswordInput");
if(!input) return;
input.type=input.type==="password" ? "text" : "password";
}
function renderApConfig(data){
const n=data.network||{};
if($("apCurrentSsid")) $("apCurrentSsid").textContent=n.ap_ssid||"--";
if($("apSecurity")) $("apSecurity").textContent=n.ap_auth||"--";
if($("apSsidInput") && !$("apSsidInput").dataset.touched){
$("apSsidInput").value=n.ap_ssid||"";
}
}
async function saveApConfig(){
const ssid=$("apSsidInput")?.value.trim()||"";
const password=$("apPasswordInput")?.value||"";
if(!ssid){
alert("AP SSID is required.");
return;
}
if(password.length<8 || password.length>63){
alert("AP password must be 8-63 characters.");
return;
}
if(!confirm("Saving AP settings will restart the controller access point. Your phone may disconnect. Continue?")){
return;
}
const r=await fetch(api("/config/ap"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({ssid,password})
});
const text=await r.text();
let body=null;
try{ body=JSON.parse(text); }catch(e){}
if(!r.ok || body?.ok===false){
alert("Failed to save AP settings: "+(body?.error||text||r.status));
return;
}
$("apPasswordInput").value="";
alert("AP settings saved. Reconnect to the new WiFi network if this page stops updating.");
}
function renderWeatherBadge(data){
const badge=$("weatherBadge");
if(!badge) return;
const sensor=(data.temps||[]).find(t=>t.enabled && t.weather && t.online && typeof t.temperature_f==="number");
if(!sensor){
badge.style.display="none";
badge.textContent="--";
return;
}
const rounded=Math.round(sensor.temperature_f);
badge.textContent=`${weatherEmoji(rounded)} ${rounded}°F`;
badge.title=sensor.name||sensor.id||"Weather sensor";
badge.style.display="";
}
function formatHours(hours){
if(typeof hours!=="number" || !isFinite(hours) || hours<=0){
return "Idle";
}
const totalMinutes=Math.round(hours*60);
if(totalMinutes < 1){
return "<1m";
}
const h=Math.floor(totalMinutes/60);
const m=totalMinutes%60;
if(h===0){
return `${m}m`;
}
if(m===0){
return `${h}h`;
}
return `${h}h ${m}m`;
}
function batteryTimeInfo(b){
if(!b.connected) return {label:"Runtime", value:"--"};
const current = b.current;
const remaining = b.remaining_ah;
const capacity = b.capacity_ah;
if(typeof current==="number" && current>.1 &&
typeof remaining==="number" && typeof capacity==="number" &&
capacity>remaining){
return {label:"Time to full", value:formatHours((capacity-remaining)/current)};
}
if(typeof b.runtime_hours==="number" && b.runtime_hours>0){
return {label:"Runtime", value:formatHours(b.runtime_hours)};
}
if(typeof current==="number" && current<-.1 && typeof remaining==="number"){
return {label:"Runtime", value:formatHours(remaining/Math.abs(current))};
}
return {label:"Battery time", value:"Idle"};
}
function showConfigSubtab(name){
["general","wifi","relays","temps","bms","maintenance"].forEach(t=>{
const page=$("config-"+t);
const btn=$("configTab-"+t);
if(page) page.classList.toggle("active",t===name);
if(btn) btn.classList.toggle("active",t===name);
});
}
function showTab(name){
["overview","battery","config"].forEach(t=>{
$(t+"Page").classList.toggle("active",t===name);
});
document.querySelectorAll(".tabbtn").forEach((b,i)=>{
b.classList.toggle("active",["overview","battery","config"][i]===name);
});
}
async function setBmsEnabled(enabled){
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){
relayPending[id]=on;
relayFeedback[id]=on ? "Turning on..." : "Turning off...";
if(window.lastStatus) render(window.lastStatus);
try{
const r=await fetch(api("/relay/set"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id,state:on})
});
if(!r.ok){
throw new Error("HTTP "+r.status);
}
relayFeedback[id]=on ? "Relay ON" : "Relay OFF";
await load();
}catch(err){
console.error(err);
relayFeedback[id]="Command failed";
if(window.lastStatus) render(window.lastStatus);
}finally{
delete relayPending[id];
setTimeout(()=>{
delete relayFeedback[id];
if(window.lastStatus) render(window.lastStatus);
},1500);
}
}
function render(data){
data=mergeStatus(window.lastStatus,data);
window.lastStatus=data;
$("conn").textContent="API Live";
$("conn").className="pill good";
$("device").textContent=data.config?.device_name||"Overland Controller";
renderWeatherBadge(data);
renderApConfig(data);
const b=data.battery||{};
$("soc").textContent=fmt(b.soc,0);
$("socbar").style.width=Math.max(0,Math.min(100,b.soc||0))+"%";
$("voltage").textContent=fmt(b.voltage,2)+" V";
$("current").textContent=fmt(b.current,2)+" A";
const timeInfo=batteryTimeInfo(b);
$("runtimeLabel").textContent=timeInfo.label;
$("runtime").textContent=timeInfo.value;
$("bmsState").textContent=b.connected?"BMS Online":"BMS Offline";
$("bmsState").className=b.connected?"medium good":"medium bad";
$("battSource").textContent=b.source||"--";
$("ah").textContent=fmt(b.remaining_ah,1)+" Ah";
$("capacity").textContent=fmt(b.capacity_ah,1)+" Ah";
$("battTemp").textContent=temp(b.temperature_f);
$("cycles").textContent=b.cycle_count??"--";
$("cellCount").textContent=b.cell_count??"--";
$("delta").textContent=(typeof b.cell_delta_mv==="number"?b.cell_delta_mv:"--")+" mV";
const bmsCfg=data.config?.bms||{};
$("bmsEnabledText").textContent=bmsCfg.enabled ? "Enabled" : "Disabled";
$("cells").innerHTML=(b.cell_voltages||[]).map((v,i)=>`
<div class="item"><span>Cell ${i+1}</span><strong>${fmt(v,3)} V</strong></div>`).join("") ||
`<div class="item"><span>No cell data</span><strong class="muted">--</strong></div>`;
const visibleTemps=(data.temps||[]).filter(t=>t.enabled && t.online && typeof t.temperature_f==="number");
$("temps").innerHTML=visibleTemps.map(t=>`
<div class="item">
<span>${t.name||t.id}</span>
<strong class="good">${temp(t.temperature_f)}</strong>
</div>`).join("");
$("relays").innerHTML=(data.relays||[]).map(r=>`
<div class="item">
<div>
<div class="row" style="gap:8px">
<span>${r.name||r.id}</span>
<span class="relayBadge ${relayStateClass(r)}">${relayStateLabel(r)}</span>
</div>
<div class="relayFeedback">${relayFeedback[r.id]||""}</div>
</div>
<span class="row" style="gap:6px">
<button class="on"
${relayPending[r.id]!==undefined?"disabled":""}
onclick="relay('${r.id}',true)">
ON
</button>
<button class="off"
${relayPending[r.id]!==undefined?"disabled":""}
onclick="relay('${r.id}',false)">
OFF
</button>
</span>
</div>`).join("");
const alarms=data.alarms||{};
const active=Object.keys(alarms).filter(k=>alarms[k]);
$("alarmSummary").innerHTML=active.length
? active.map(k=>`<div class="item"><span>${k.replaceAll("_"," ")}</span><strong class="bad">ALARM</strong></div>`).join("")
: `<div class="item"><span>Alarms</span><strong class="good">OK</strong></div>`;
const s=data.system||{};
const n=data.network||{};
$("wifiSta").textContent=n.sta_connected ? (n.sta_ssid||"connected") : "Not connected";
$("wifiIp").textContent=n.sta_ip||"--";
$("apIp").textContent=n.ap_ip||"--";
$("fw").textContent=(s.firmware_name||"--")+" "+(s.firmware_version||"");
$("uptime").textContent=(s.uptime_seconds||0)+" sec";
renderConfigControls(data);
populateSetupFields(data);
}
function populateSetupFields(data){
const cfg=data.config||{};
if($("deviceNameInput")) $("deviceNameInput").value=cfg.device_name||"";
const bms=cfg.bms||{};
if($("bmsNameInput")) $("bmsNameInput").value=bms.name||"";
if($("bmsAddressInput")) $("bmsAddressInput").value=bms.address||"";
if($("bmsAddressTypeInput")) $("bmsAddressTypeInput").value=bms.address_type||"public";
}
async function saveDeviceConfig(){
const name=$("deviceNameInput")?.value.trim();
if(!name){ alert("Device name is required"); return; }
await fetch(api("/config/device"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({device_name:name})
});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Device name saved");
}
async function saveBmsFullConfig(){
const name=$("bmsNameInput")?.value.trim()||"BMS";
const address=$("bmsAddressInput")?.value.trim()||"";
let address_type=($("bmsAddressTypeInput")?.value.trim()||"public").toLowerCase();
if(address_type!=="public" && address_type!=="random"){
alert("Address type must be public or random");
return;
}
await fetch(api("/config/bms"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
enabled:true,
name:name,
address:address,
address_type:address_type
})
});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("BMS config saved");
}
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(api("/temps/scan"),{method:"POST"});
const d=await r.json();
const devices=d.devices||[];
if(!box) return;
if(!devices.length){
box.innerHTML=`<div class="item"><span>No probes found</span><strong class="bad">0</strong></div>`;
return;
}
const cfg=window.lastStatus?.config||{};
const temps=cfg.temperature_sensors||[];
box.innerHTML=devices.map(dev=>`
<div class="item" style="display:block">
<div style="display:flex;justify-content:space-between;gap:8px">
<span>${dev.address}</span>
<strong>#${dev.index}</strong>
</div>
<div class="btnrow">
${temps.slice(0,4).map((t,i)=>`
<button onclick="assignTemp('${t.id}',${dev.index})">${t.name||t.id}</button>
`).join("")}
</div>
</div>`).join("");
}
async function assignTemp(id,index){
await fetch(api("/temps/assign"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id,index:index})
});
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Temp probe assigned to "+id);
}
async function clearTempAssignment(id){
if(!confirm("Clear probe assignment for "+id+"?")) return;
const r=await fetch(api("/temps/clear"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({id:id})
});
if(!r.ok){
const msg=await r.text();
alert("Failed to clear temp assignment: "+msg);
return;
}
await load();
alert("Temp probe assignment cleared for "+id);
}
async function factoryReset(){
if(!confirm("Factory reset all controller configuration?")) return;
if(!confirm("This clears WiFi/BMS/relay/temp config. Continue?")) return;
await fetch(api("/config/factory-reset"),{method:"POST"});
alert("Factory reset requested. Reconnect to the controller AP if needed.");
}
function renderConfigControls(data){
const cfg=data.config||{};
const relays=cfg.relays||[];
const temps=cfg.temperature_sensors||[];
const relayBox=$("relayConfigList");
if(relayBox){
relayBox.innerHTML=relays.map((r,i)=>`
<div class="configrow">
<label>${r.id}</label>
<input id="relayName${i}" value="${r.name||""}" placeholder="Relay name">
<label>${r.enabled?"Enabled":"Disabled"}</label>
</div>`).join("");
}
const tempCount=$("tempEnabledCount");
if(tempCount){
tempCount.value=temps.filter(t=>t.enabled).length;
}
const tempBox=$("tempConfigList");
if(tempBox){
tempBox.innerHTML=temps.map((t,i)=>`
<div class="configrow tempConfigRow">
<label>${t.id}</label>
<input id="tempName${i}" value="${t.name||""}" placeholder="Temp name">
<div class="checkGroup">
<label class="checkPill">
<input id="tempEnabled${i}" type="checkbox" ${t.enabled?"checked":""} onchange="saveTempConfig()">
On
</label>
<label class="checkPill">
<input id="tempWeather${i}" type="checkbox" ${t.weather?"checked":""} onchange="saveTempConfig()">
Weather
</label>
<button class="off" onclick="clearTempAssignment('${t.id}')">Clear</button>
</div>
<div class="tempAddress">Address: ${t.address||"unassigned"}</div>
</div>`).join("");
}
}
async function saveRelayConfig(){
const r=await fetch(api("/status?fields=config"),{cache:"no-store"});
const data=await r.json();
const relays=data.config?.relays||[];
for(let i=0;i<relays.length;i++){
const name=$("relayName"+i)?.value.trim();
if(!name) continue;
await fetch(api("/config/relay"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
id:relays[i].id,
name:name,
enabled:relays[i].enabled
})
});
}
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Relay names saved");
}
async function saveTempConfig(){
const r=await fetch(api("/status?fields=config"),{cache:"no-store"});
const data=await r.json();
const temps=data.config?.temperature_sensors||[];
let count=parseInt($("tempEnabledCount")?.value||"0",10);
if(isNaN(count)) count=0;
if(count<0) count=0;
if(count>temps.length) count=temps.length;
for(let i=0;i<temps.length;i++){
const name=$("tempName"+i)?.value.trim() || temps[i].name || temps[i].id;
const enabled=$("tempEnabled"+i)?.checked || false;
const weather=$("tempWeather"+i)?.checked || false;
await fetch(api("/config/temp"),{
method:"POST",
headers:{"Content-Type":"application/json"},
body:JSON.stringify({
id:temps[i].id,
name:name,
address:temps[i].address||"",
enabled:enabled,
weather:weather
})
});
}
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Temperature config saved");
}
async function loadWifiConfig(){
try{
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++){
const n=nets[i]||{};
$("w"+(i+1)+"s").value=n.ssid||"";
$("w"+(i+1)+"r").value=n.priority||i+1;
}
}catch(e){}
}
async function saveWifi(){
const networks=[];
for(let i=1;i<=3;i++){
const ssid=$("w"+i+"s").value.trim();
const password=$("w"+i+"p").value;
const priority=parseInt($("w"+i+"r").value||i,10);
if(ssid) networks.push({ssid,password,priority});
}
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(api("/wifi/connect"),{method:"POST"});
await load();
}
async function load(){
try{
const r=await fetch(statusUrl(),{cache:"no-store"});
render(await r.json());
}catch(e){
$("conn").textContent="API Error";
$("conn").className="pill bad";
}
}
load();
loadWifiConfig();
setInterval(load,3000);
</script>
</body>
</html>
)rawliteral";
WebServer server(80);
#define API_V1(path) "/api/v1" path
HardwareSerial DashboardSerial(2);
String uartLineBuffer;
Preferences wifiPrefs;
Preferences apPrefs;
String apSsid = "OverlandController";
String apPassword = "";
const int AP_PASSWORD_MIN_LEN = 8;
const int AP_PASSWORD_MAX_LEN = 63;
const int MAX_WIFI_NETWORKS = 3;
String staSsids[MAX_WIFI_NETWORKS];
String staPasswords[MAX_WIFI_NETWORKS];
int staPriorities[MAX_WIFI_NETWORKS];
int wifiNetworkCount = 0;
String activeStaSsid = "";
unsigned long lastStaReconnectAttempt = 0;
const unsigned long STA_RECONNECT_INTERVAL_MS = 30000;
void loadWifiConfig() {
wifiPrefs.begin("wifi", true);
wifiNetworkCount = wifiPrefs.getInt("count", 0);
if (wifiNetworkCount < 0) wifiNetworkCount = 0;
if (wifiNetworkCount > MAX_WIFI_NETWORKS) wifiNetworkCount = MAX_WIFI_NETWORKS;
for (int i = 0; i < MAX_WIFI_NETWORKS; i++) {
staSsids[i] = wifiPrefs.getString(("ssid" + String(i)).c_str(), "");
staPasswords[i] = wifiPrefs.getString(("pass" + String(i)).c_str(), "");
staPriorities[i] = wifiPrefs.getInt(("priority" + String(i)).c_str(), i + 1);
}
// Migration path from old single-network config
if (wifiNetworkCount == 0) {
String oldSsid = wifiPrefs.getString("ssid", "");
String oldPassword = wifiPrefs.getString("password", "");
if (oldSsid.length() > 0) {
staSsids[0] = oldSsid;
staPasswords[0] = oldPassword;
staPriorities[0] = 1;
wifiNetworkCount = 1;
}
}
wifiPrefs.end();
}
void saveWifiConfig() {
wifiPrefs.begin("wifi", false);
wifiPrefs.putInt("count", wifiNetworkCount);
for (int i = 0; i < MAX_WIFI_NETWORKS; i++) {
wifiPrefs.putString(("ssid" + String(i)).c_str(), staSsids[i]);
wifiPrefs.putString(("pass" + String(i)).c_str(), staPasswords[i]);
wifiPrefs.putInt(("priority" + String(i)).c_str(), staPriorities[i]);
}
// Keep legacy keys updated for easier debugging
wifiPrefs.putString("ssid", wifiNetworkCount > 0 ? staSsids[0] : "");
wifiPrefs.putString("password", wifiNetworkCount > 0 ? staPasswords[0] : "");
wifiPrefs.end();
}
void clearWifiConfig() {
wifiPrefs.begin("wifi", false);
wifiPrefs.clear();
wifiPrefs.end();
wifiNetworkCount = 0;
activeStaSsid = "";
for (int i = 0; i < MAX_WIFI_NETWORKS; i++) {
staSsids[i] = "";
staPasswords[i] = "";
staPriorities[i] = i + 1;
}
}
String generateDefaultApPassword() {
uint64_t mac = ESP.getEfuseMac();
uint32_t a = esp_random();
uint32_t b = ((uint32_t)(mac >> 16)) ^ esp_random();
char buffer[17];
snprintf(buffer, sizeof(buffer), "%08lX%08lX", (unsigned long)a, (unsigned long)b);
return String(buffer);
}
bool validApSsid(const String& ssid) {
return ssid.length() > 0 && ssid.length() <= 32;
}
bool validApPassword(const String& password) {
return password.length() >= AP_PASSWORD_MIN_LEN && password.length() <= AP_PASSWORD_MAX_LEN;
}
void loadApConfig() {
apPrefs.begin("ap", false);
apSsid = apPrefs.getString("ssid", "OverlandController");
apPassword = apPrefs.getString("password", "");
if (!validApSsid(apSsid)) {
apSsid = "OverlandController";
apPrefs.putString("ssid", apSsid);
}
if (!validApPassword(apPassword)) {
apPassword = generateDefaultApPassword();
apPrefs.putString("password", apPassword);
}
apPrefs.end();
}
void saveApConfig(const String& ssid, const String& password) {
apSsid = ssid;
apPassword = password;
apPrefs.begin("ap", false);
apPrefs.putString("ssid", apSsid);
apPrefs.putString("password", apPassword);
apPrefs.end();
}
bool startAccessPoint() {
bool ok = WiFi.softAP(apSsid.c_str(), apPassword.c_str());
if (ok) {
Serial.println("AP Started");
Serial.print("AP SSID: ");
Serial.println(apSsid);
Serial.print("AP Password: ");
Serial.println(apPassword);
Serial.print("AP IP: ");
Serial.println(WiFi.softAPIP());
} else {
Serial.println("AP failed to start");
}
return ok;
}
void restartAccessPoint() {
WiFi.softAPdisconnect(true);
delay(250);
startAccessPoint();
}
void sendSimpleJsonError(int status, const char* error, const char* detail) {
DynamicJsonDocument doc(512);
doc["ok"] = false;
doc["error"] = error;
if (detail && detail[0]) {
JsonArray details = doc.createNestedArray("details");
details.add(detail);
}
String output;
serializeJson(doc, output);
server.send(status, "application/json", output);
}
void handleGetApConfig() {
DynamicJsonDocument response(512);
response["ok"] = true;
response["ssid"] = apSsid;
response["password_set"] = validApPassword(apPassword);
response["password_min_length"] = AP_PASSWORD_MIN_LEN;
response["password_max_length"] = AP_PASSWORD_MAX_LEN;
response["auth"] = "wpa2";
String output;
serializeJson(response, output);
server.send(200, "application/json", output);
}
void handleUpdateApConfig() {
DynamicJsonDocument doc(512);
DeserializationError err = deserializeJson(doc, server.arg("plain"));
if (err) {
sendSimpleJsonError(400, "invalid_json", err.c_str());
return;
}
String ssid = doc["ssid"] | apSsid;
String password = doc["password"] | "";
ssid.trim();
password.trim();
if (!validApSsid(ssid)) {
sendSimpleJsonError(400, "invalid_ap_ssid", "AP SSID must be 1-32 characters.");
return;
}
if (!validApPassword(password)) {
sendSimpleJsonError(400, "invalid_ap_password", "AP password must be 8-63 characters.");
return;
}
saveApConfig(ssid, password);
restartAccessPoint();
DynamicJsonDocument response(512);
response["ok"] = true;
response["ssid"] = apSsid;
response["password_set"] = true;
response["auth"] = "wpa2";
response["ap_ip"] = WiFi.softAPIP().toString();
String output;
serializeJson(response, output);
server.send(200, "application/json", output);
}
int findNextWifiIndexByPriority(bool tried[]) {
int bestIndex = -1;
int bestPriority = 1000000;
for (int i = 0; i < wifiNetworkCount; i++) {
if (tried[i]) continue;
if (staSsids[i].length() == 0) continue;
int priority = staPriorities[i];
if (priority <= 0) priority = i + 1;
if (priority < bestPriority) {
bestPriority = priority;
bestIndex = i;
}
}
return bestIndex;
}
void connectStaWifi() {
if (wifiNetworkCount <= 0) {
Serial.println("STA WiFi not configured.");
return;
}
WiFi.disconnect(false);
activeStaSsid = "";
bool tried[MAX_WIFI_NETWORKS] = {false, false, false};
for (int attempt = 0; attempt < wifiNetworkCount; attempt++) {
int i = findNextWifiIndexByPriority(tried);
if (i < 0) {
break;
}
tried[i] = true;
Serial.print("Connecting STA WiFi priority ");
Serial.print(staPriorities[i]);
Serial.print(" to: ");
Serial.println(staSsids[i]);
WiFi.disconnect(false, false);
delay(500);
WiFi.mode(WIFI_AP_STA);
delay(250);
WiFi.begin(staSsids[i].c_str(), staPasswords[i].c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) {
delay(500);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
activeStaSsid = staSsids[i];
Serial.println("STA WiFi connected");
Serial.print("STA SSID: ");
Serial.println(activeStaSsid);
Serial.print("STA IP: ");
Serial.println(WiFi.localIP());
return;
}
wl_status_t status = WiFi.status();
Serial.print("STA WiFi attempt failed. Status: ");
Serial.println((int)status);
wifi_ap_record_t apInfo;
if (esp_wifi_sta_get_ap_info(&apInfo) == ESP_OK) {
Serial.print("Connected AP RSSI: ");
Serial.println(apInfo.rssi);
} else {
Serial.println("No AP association info available.");
}
}
Serial.println("All STA WiFi attempts failed. AP remains available.");
}
void maintainStaWifi() {
if (wifiNetworkCount <= 0) return;
if (WiFi.status() == WL_CONNECTED) return;
if (millis() - lastStaReconnectAttempt < STA_RECONNECT_INTERVAL_MS) return;
lastStaReconnectAttempt = millis();
Serial.println("STA WiFi disconnected. Trying saved networks by priority...");
connectStaWifi();
}
void printNetworkStatus() {
Serial.println("Network:");
Serial.print(" AP IP: ");
Serial.println(WiFi.softAPIP());
Serial.print(" Saved STA Networks: ");
Serial.println(wifiNetworkCount);
Serial.print(" Active STA SSID: ");
Serial.println(activeStaSsid.length() ? activeStaSsid : "(none)");
Serial.print(" STA Connected: ");
Serial.println(WiFi.status() == WL_CONNECTED ? "true" : "false");
if (WiFi.status() == WL_CONNECTED) {
Serial.print(" STA IP: ");
Serial.println(WiFi.localIP());
}
}
void buildConfigDocument(JsonObject doc) {
doc["device_name"] = appConfig.deviceName;
JsonArray relaysArray = doc.createNestedArray("relays");
for (int i = 0; i < MAX_RELAYS; i++) {
JsonObject relay = relaysArray.createNestedObject();
relay["id"] = appConfig.relays[i].id;
relay["name"] = appConfig.relays[i].name;
relay["pin"] = appConfig.relays[i].pin;
relay["enabled"] = appConfig.relays[i].enabled;
}
JsonObject bms = doc.createNestedObject("bms");
bms["enabled"] = appConfig.bms.enabled;
bms["name"] = appConfig.bms.name;
bms["address"] = appConfig.bms.address;
bms["address_type"] = appConfig.bms.addressType;
doc["temperature_sensor_count"] = appConfig.tempSensorCount;
JsonArray temps = doc.createNestedArray("temperature_sensors");
for (int i = 0; i < MAX_TEMP_SENSORS; i++) {
JsonObject temp = temps.createNestedObject();
temp["id"] = appConfig.tempSensors[i].id;
temp["name"] = appConfig.tempSensors[i].name;
temp["address"] = appConfig.tempSensors[i].address;
temp["enabled"] = appConfig.tempSensors[i].enabled;
temp["weather"] = appConfig.tempSensors[i].weather;
}
}
void buildWifiConfigDocument(JsonObject wifi, bool includePasswords) {
wifi["ap_enabled"] = true;
wifi["sta_enabled"] = wifiNetworkCount > 0;
wifi["network_count"] = wifiNetworkCount;
wifi["active_ssid"] = activeStaSsid;
wifi["sta_connected"] = WiFi.status() == WL_CONNECTED;
wifi["ap_ip"] = WiFi.softAPIP().toString();
if (WiFi.status() == WL_CONNECTED) {
wifi["sta_ip"] = WiFi.localIP().toString();
} else {
wifi["sta_ip"] = "";
}
JsonArray networks = wifi.createNestedArray("networks");
for (int i = 0; i < wifiNetworkCount; i++) {
JsonObject network = networks.createNestedObject();
network["index"] = i + 1;
network["ssid"] = staSsids[i];
network["priority"] = staPriorities[i];
network["password_set"] = staPasswords[i].length() > 0;
if (includePasswords) {
network["password"] = staPasswords[i];
}
}
}
void applyWifiNetworks(JsonArray networks) {
wifiNetworkCount = 0;
for (JsonObject network : networks) {
if (wifiNetworkCount >= MAX_WIFI_NETWORKS) break;
String ssid = network["ssid"] | "";
String password = network["password"] | "";
ssid.trim();
password.trim();
if (ssid.length() == 0) {
continue;
}
if (password.length() == 0) {
for (int oldIndex = 0; oldIndex < MAX_WIFI_NETWORKS; oldIndex++) {
if (staSsids[oldIndex] == ssid && staPasswords[oldIndex].length() > 0) {
password = staPasswords[oldIndex];
break;
}
}
}
int priority = network["priority"] | (wifiNetworkCount + 1);
if (priority <= 0) priority = wifiNetworkCount + 1;
staSsids[wifiNetworkCount] = ssid;
staPasswords[wifiNetworkCount] = password;
staPriorities[wifiNetworkCount] = priority;
wifiNetworkCount++;
}
for (int i = wifiNetworkCount; i < MAX_WIFI_NETWORKS; i++) {
staSsids[i] = "";
staPasswords[i] = "";
staPriorities[i] = i + 1;
}
}
int findRelayConfigIndex(const String& id);
int findTempSensorConfigIndex(const String& id);
bool validateConfigImport(JsonObject doc, const char*& errorCode) {
if (doc["device_name"].isNull() || !doc["device_name"].is<String>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["relays"].isNull() && !doc["relays"].is<JsonArray>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["temperature_sensors"].isNull() && !doc["temperature_sensors"].is<JsonArray>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["bms"].isNull() && !doc["bms"].is<JsonObject>()) {
errorCode = "invalid_config";
return false;
}
if (!doc["wifi"].isNull() && !doc["wifi"].is<JsonObject>()) {
errorCode = "invalid_config";
return false;
}
if (doc["temperature_sensor_count"].is<int>()) {
int count = doc["temperature_sensor_count"].as<int>();
if (count < 0 || count > MAX_TEMP_SENSORS) {
errorCode = "invalid_config";
return false;
}
}
JsonArray relays = doc["relays"].as<JsonArray>();
if (!relays.isNull()) {
for (JsonObject relay : relays) {
String id = relay["id"] | "";
if (findRelayConfigIndex(id) < 0) {
errorCode = "unknown_relay";
return false;
}
}
}
JsonArray temps = doc["temperature_sensors"].as<JsonArray>();
if (!temps.isNull()) {
for (JsonObject temp : temps) {
String id = temp["id"] | "";
if (findTempSensorConfigIndex(id) < 0) {
errorCode = "unknown_temp_sensor";
return false;
}
}
}
JsonObject bms = doc["bms"].as<JsonObject>();
if (!bms.isNull() && bms["address_type"].is<String>()) {
String addressType = bms["address_type"].as<String>();
addressType.toLowerCase();
if (addressType != "public" && addressType != "random") {
errorCode = "invalid_config";
return false;
}
}
JsonObject wifi = doc["wifi"].as<JsonObject>();
if (!wifi.isNull()) {
JsonArray networks = wifi["networks"].as<JsonArray>();
if (networks.isNull()) {
errorCode = "invalid_config";
return false;
}
}
return true;
}
void applyImportedConfig(JsonObject doc) {
if (doc["device_name"].is<String>()) {
appConfig.deviceName = doc["device_name"].as<String>();
}
if (doc["temperature_sensor_count"].is<int>()) {
appConfig.tempSensorCount = doc["temperature_sensor_count"].as<int>();
}
JsonArray relays = doc["relays"].as<JsonArray>();
if (!relays.isNull()) {
for (JsonObject relay : relays) {
int index = findRelayConfigIndex(relay["id"] | "");
if (index < 0) continue;
if (relay["name"].is<String>()) appConfig.relays[index].name = relay["name"].as<String>();
if (relay["enabled"].is<bool>()) appConfig.relays[index].enabled = relay["enabled"].as<bool>();
}
}
JsonObject bms = doc["bms"].as<JsonObject>();
if (!bms.isNull()) {
if (bms["enabled"].is<bool>()) appConfig.bms.enabled = bms["enabled"].as<bool>();
if (bms["name"].is<String>()) appConfig.bms.name = bms["name"].as<String>();
if (bms["address"].is<String>()) appConfig.bms.address = bms["address"].as<String>();
if (bms["address_type"].is<String>()) {
appConfig.bms.addressType = bms["address_type"].as<String>();
appConfig.bms.addressType.toLowerCase();
}
}
JsonArray temps = doc["temperature_sensors"].as<JsonArray>();
if (!temps.isNull()) {
for (JsonObject temp : temps) {
int index = findTempSensorConfigIndex(temp["id"] | "");
if (index < 0) continue;
if (temp["name"].is<String>()) appConfig.tempSensors[index].name = temp["name"].as<String>();
if (temp["address"].is<String>()) appConfig.tempSensors[index].address = temp["address"].as<String>();
if (temp["enabled"].is<bool>()) appConfig.tempSensors[index].enabled = temp["enabled"].as<bool>();
if (temp["weather"].is<bool>()) appConfig.tempSensors[index].weather = temp["weather"].as<bool>();
}
}
JsonObject wifi = doc["wifi"].as<JsonObject>();
if (!wifi.isNull()) {
JsonArray networks = wifi["networks"].as<JsonArray>();
applyWifiNetworks(networks);
saveWifiConfig();
}
saveConfig();
}
float calculateRuntimeHours() {
if (!bmsData.valid || bmsData.current >= 0) return 0;
float dischargeAmps = -bmsData.current;
if (dischargeAmps <= 0.1) return 0;
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;
}
void buildStatusDocument(JsonDocument& doc) {
doc["type"] = MSG_STATUS_RESPONSE;
doc["timestamp"] = millis();
JsonObject battery = doc.createNestedObject("battery");
if (bmsData.valid) {
battery["source"] = "jbd_bms";
battery["connected"] = bmsData.connected;
battery["soc"] = bmsData.soc;
battery["voltage"] = bmsData.voltage;
battery["current"] = bmsData.current;
battery["remaining_ah"] = bmsData.remainingAh;
battery["capacity_ah"] = bmsData.capacityAh;
battery["runtime_hours"] = calculateRuntimeHours();
battery["temperature_f"] = bmsData.temperatureF;
battery["cycle_count"] = bmsData.cycleCount;
battery["cell_count"] = bmsData.cellCount;
battery["ntc_count"] = bmsData.ntcCount;
JsonArray cells = battery.createNestedArray("cell_voltages");
if (bmsData.cellsValid) {
for (int i = 0; i < bmsData.cellCount; i++) cells.add(bmsData.cellVoltages[i]);
}
battery["cell_min_voltage"] = bmsData.cellMinVoltage;
battery["cell_max_voltage"] = bmsData.cellMaxVoltage;
battery["cell_delta_mv"] = bmsData.cellDeltaMv;
battery["cells_valid"] = bmsData.cellsValid;
} else {
if (appConfig.bms.enabled && appConfig.bms.address.length() > 0) {
battery["source"] = "jbd_bms";
} else {
if (appConfig.bms.enabled && appConfig.bms.address.length() > 0) {
battery["source"] = "jbd_bms";
} else {
if (appConfig.bms.enabled && appConfig.bms.address.length() > 0) {
battery["source"] = "jbd_bms";
} else {
battery["source"] = "unconfigured";
}
}
}
battery["connected"] = false;
battery["soc"] = 0;
battery["voltage"] = 0;
battery["current"] = 0;
battery["remaining_ah"] = 0;
battery["capacity_ah"] = 0;
battery["runtime_hours"] = 0;
battery["temperature_f"] = 0;
battery["cycle_count"] = 0;
battery["cell_count"] = 0;
battery["ntc_count"] = 0;
battery["cell_delta_mv"] = 0;
battery["cells_valid"] = false;
}
JsonArray temps = doc.createNestedArray("temps");
int tempCount = appConfig.tempSensorCount > 4 ? 4 : appConfig.tempSensorCount;
for (int i = 0; i < tempCount; i++) {
JsonObject temp = temps.createNestedObject();
temp["id"] = appConfig.tempSensors[i].id;
temp["name"] = appConfig.tempSensors[i].name;
temp["enabled"] = appConfig.tempSensors[i].enabled;
temp["weather"] = appConfig.tempSensors[i].weather;
temp["online"] = sensors.online[i];
if (sensors.online[i]) {
temp["temperature_f"] = sensors.tempsF[i];
} else {
temp["temperature_f"] = nullptr;
}
}
JsonArray relayStates = doc.createNestedArray("relays");
JsonObject relay1 = relayStates.createNestedObject();
relay1["id"] = appConfig.relays[0].id;
relay1["name"] = appConfig.relays[0].name;
relay1["pin"] = appConfig.relays[0].pin;
relay1["enabled"] = appConfig.relays[0].enabled;
relay1["state"] = relays.relay1;
JsonObject relay2 = relayStates.createNestedObject();
relay2["id"] = appConfig.relays[1].id;
relay2["name"] = appConfig.relays[1].name;
relay2["pin"] = appConfig.relays[1].pin;
relay2["enabled"] = appConfig.relays[1].enabled;
relay2["state"] = relays.relay2;
JsonObject vehicle = doc.createNestedObject("vehicle");
vehicle["ignition_on"] = digitalRead(IGNITION_PIN);
JsonObject network = doc.createNestedObject("network");
network["wifi_enabled"] = true;
network["uart_connected"] = true;
network["ap_enabled"] = true;
network["ap_ssid"] = apSsid;
network["ap_auth"] = "wpa2";
network["ap_ip"] = WiFi.softAPIP().toString();
network["sta_enabled"] = wifiNetworkCount > 0;
network["sta_connected"] = WiFi.status() == WL_CONNECTED;
network["sta_ssid"] = activeStaSsid;
network["sta_ip"] = WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "";
network["saved_network_count"] = wifiNetworkCount;
JsonArray savedNetworks = network.createNestedArray("saved_networks");
for (int i = 0; i < wifiNetworkCount; i++) {
JsonObject saved = savedNetworks.createNestedObject();
saved["index"] = i + 1;
saved["ssid"] = staSsids[i];
saved["priority"] = staPriorities[i];
saved["active"] = staSsids[i] == activeStaSsid;
}
JsonObject alarmObj = doc.createNestedObject("alarms");
alarmObj["low_soc"] = alarms.lowSoc;
alarmObj["critical_soc"] = alarms.criticalSoc;
alarmObj["low_voltage"] = alarms.lowVoltage;
alarmObj["high_battery_temp"] = alarms.highBatteryTemp;
alarmObj["cell_imbalance"] = alarms.cellImbalance;
alarmObj["bms_disconnected"] = alarms.bmsDisconnected;
JsonObject system = doc.createNestedObject("system");
system["firmware_name"] = FIRMWARE_NAME;
system["firmware_version"] = FIRMWARE_VERSION;
system["build_date"] = __DATE__;
system["build_time"] = __TIME__;
system["uptime_seconds"] = millis() / 1000;
JsonObject configObj = doc.createNestedObject("config");
buildConfigDocument(configObj);
}
void sendStatus(Stream& output, bool pretty = false) {
DynamicJsonDocument doc(4096);
buildStatusDocument(doc);
if (pretty) serializeJsonPretty(doc, output);
else serializeJson(doc, output);
output.println();
}
void sendError(Stream& output, const char* message) {
DynamicJsonDocument doc(256);
doc["type"] = MSG_ERROR;
doc["message"] = message;
serializeJson(doc, output);
output.println();
}
bool setRelayById(const String& relayId, bool enabled) {
if (relayId == "relay_1") relays.relay1 = enabled;
else if (relayId == "relay_2") relays.relay2 = enabled;
else return false;
updateRelayOutputs();
return true;
}
void sendRelayResponse(Stream& output, const String& relayId, bool enabled) {
DynamicJsonDocument doc(256);
doc["type"] = MSG_RELAY_RESPONSE;
doc["id"] = relayId;
doc["state"] = enabled;
doc["ok"] = true;
serializeJson(doc, output);
output.println();
}
void sendConfigResponse(Stream& output, bool ok = true) {
DynamicJsonDocument doc(4096);
doc["type"] = "config_response";
doc["ok"] = ok;
JsonObject config = doc.createNestedObject("config");
config["device_name"] = appConfig.deviceName;
JsonArray relaysArray = config.createNestedArray("relays");
for (int i = 0; i < MAX_RELAYS; i++) {
JsonObject relay = relaysArray.createNestedObject();
relay["id"] = appConfig.relays[i].id;
relay["name"] = appConfig.relays[i].name;
relay["pin"] = appConfig.relays[i].pin;
relay["enabled"] = appConfig.relays[i].enabled;
}
JsonObject bms = config.createNestedObject("bms");
bms["enabled"] = appConfig.bms.enabled;
bms["name"] = appConfig.bms.name;
bms["address"] = appConfig.bms.address;
bms["address_type"] = appConfig.bms.addressType;
JsonArray temps = config.createNestedArray("temperature_sensors");
for (int i = 0; i < MAX_TEMP_SENSORS; i++) {
JsonObject temp = temps.createNestedObject();
temp["id"] = appConfig.tempSensors[i].id;
temp["name"] = appConfig.tempSensors[i].name;
temp["address"] = appConfig.tempSensors[i].address;
temp["enabled"] = appConfig.tempSensors[i].enabled;
}
serializeJson(doc, output);
output.println();
}
void sendSimpleOk(Stream& output, const char* type) {
DynamicJsonDocument doc(256);
doc["type"] = type;
doc["ok"] = true;
serializeJson(doc, output);
output.println();
}
void sendBleScanResponse(Stream& output) {
DynamicJsonDocument doc(2048);
doc["type"] = "ble_scan_response";
JsonArray devices = doc.createNestedArray("devices");
int count = getBleScanResultCount();
for (int i = 0; i < count; i++) {
const BleScanResult* result = getBleScanResult(i);
if (!result) {
continue;
}
JsonObject device = devices.createNestedObject();
device["index"] = i + 1;
device["name"] = result->name;
device["address"] = result->address;
device["rssi"] = result->rssi;
}
serializeJson(doc, output);
output.println();
}
int findRelayConfigIndexForUart(const String& id) {
for (int i = 0; i < MAX_RELAYS; i++) {
if (appConfig.relays[i].id == id) {
return i;
}
}
return -1;
}
int findTempConfigIndexForUart(const String& id) {
for (int i = 0; i < MAX_TEMP_SENSORS; i++) {
if (appConfig.tempSensors[i].id == id) {
return i;
}
}
return -1;
}
void handleUartMessage(const String& line) {
DynamicJsonDocument doc(1024);
DeserializationError error = deserializeJson(doc, line);
if (error) {
sendError(DashboardSerial, "invalid_json");
return;
}
const char* type = doc["type"] | "";
if (strcmp(type, MSG_STATUS_REQUEST) == 0 || strcmp(type, "status_request") == 0) {
sendStatus(DashboardSerial);
return;
}
if (strcmp(type, "config_request") == 0) {
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, MSG_SET_RELAY) == 0 || strcmp(type, "set_relay") == 0) {
String relayId = doc["id"] | "";
if (relayId.length() == 0) {
relayId = doc["relay"] | "";
}
bool enabled = doc["state"] | false;
if (doc["enabled"].is<bool>()) {
enabled = doc["enabled"].as<bool>();
}
if (!setRelayById(relayId, enabled)) {
sendError(DashboardSerial, "unknown_relay");
return;
}
sendRelayResponse(DashboardSerial, relayId, enabled);
return;
}
if (strcmp(type, "config_device") == 0) {
if (!doc["device_name"].is<String>()) {
sendError(DashboardSerial, "missing_device_name");
return;
}
appConfig.deviceName = doc["device_name"].as<String>();
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "config_relay") == 0) {
String id = doc["id"] | "";
int index = findRelayConfigIndexForUart(id);
if (index < 0) {
sendError(DashboardSerial, "unknown_relay");
return;
}
if (doc["name"].is<String>()) {
appConfig.relays[index].name = doc["name"].as<String>();
}
if (doc["enabled"].is<bool>()) {
appConfig.relays[index].enabled = doc["enabled"].as<bool>();
}
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "config_temp") == 0) {
String id = doc["id"] | "";
int index = findTempConfigIndexForUart(id);
if (index < 0) {
sendError(DashboardSerial, "unknown_temp_sensor");
return;
}
if (doc["name"].is<String>()) {
appConfig.tempSensors[index].name = doc["name"].as<String>();
}
if (doc["address"].is<String>()) {
appConfig.tempSensors[index].address = doc["address"].as<String>();
}
if (doc["enabled"].is<bool>()) {
appConfig.tempSensors[index].enabled = doc["enabled"].as<bool>();
}
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "config_bms") == 0) {
if (doc["enabled"].is<bool>()) {
appConfig.bms.enabled = doc["enabled"].as<bool>();
}
if (doc["name"].is<String>()) {
appConfig.bms.name = doc["name"].as<String>();
}
if (doc["address"].is<String>()) {
appConfig.bms.address = doc["address"].as<String>();
}
if (doc["address_type"].is<String>()) {
appConfig.bms.addressType = doc["address_type"].as<String>();
}
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "wifi_request") == 0) {
DynamicJsonDocument response(1024);
response["type"] = "wifi_config_response";
response["ok"] = true;
JsonObject wifi = response.createNestedObject("wifi");
wifi["ap_enabled"] = true;
wifi["sta_enabled"] = wifiNetworkCount > 0;
wifi["network_count"] = wifiNetworkCount;
wifi["active_ssid"] = activeStaSsid;
wifi["sta_connected"] = WiFi.status() == WL_CONNECTED;
wifi["ap_ip"] = WiFi.softAPIP().toString();
JsonArray networks = wifi.createNestedArray("networks");
for (int i = 0; i < wifiNetworkCount; i++) {
JsonObject network = networks.createNestedObject();
network["index"] = i + 1;
network["ssid"] = staSsids[i];
network["password_set"] = staPasswords[i].length() > 0;
}
if (WiFi.status() == WL_CONNECTED) {
wifi["sta_ip"] = WiFi.localIP().toString();
} else {
wifi["sta_ip"] = "";
}
serializeJson(response, DashboardSerial);
DashboardSerial.println();
return;
}
if (strcmp(type, "config_wifi") == 0) {
JsonArray networks = doc["networks"].as<JsonArray>();
if (!networks.isNull()) {
wifiNetworkCount = 0;
for (JsonObject network : networks) {
if (wifiNetworkCount >= MAX_WIFI_NETWORKS) break;
String ssid = network["ssid"] | "";
String password = network["password"] | "";
ssid.trim();
if (ssid.length() == 0) continue;
staSsids[wifiNetworkCount] = ssid;
staPasswords[wifiNetworkCount] = password;
wifiNetworkCount++;
}
} else {
if (doc["ssid"].is<String>()) {
staSsids[0] = doc["ssid"].as<String>();
if (wifiNetworkCount < 1) wifiNetworkCount = 1;
}
if (doc["password"].is<String>()) {
staPasswords[0] = doc["password"].as<String>();
if (wifiNetworkCount < 1) wifiNetworkCount = 1;
}
}
saveWifiConfig();
DynamicJsonDocument response(512);
response["type"] = "wifi_config_response";
response["ok"] = true;
response["network_count"] = wifiNetworkCount;
serializeJson(response, DashboardSerial);
DashboardSerial.println();
return;
}
if (strcmp(type, "wifi_connect") == 0) {
connectStaWifi();
DynamicJsonDocument response(512);
response["type"] = "wifi_config_response";
response["ok"] = true;
response["sta_connected"] = WiFi.status() == WL_CONNECTED;
response["sta_ip"] = WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "";
serializeJson(response, DashboardSerial);
DashboardSerial.println();
return;
}
if (strcmp(type, "wifi_clear") == 0) {
clearWifiConfig();
DynamicJsonDocument response(512);
response["type"] = "wifi_config_response";
response["ok"] = true;
response["ssid"] = "";
response["password_set"] = false;
serializeJson(response, DashboardSerial);
DashboardSerial.println();
return;
}
if (strcmp(type, "scan_temps") == 0) {
scanTempSensors();
DynamicJsonDocument response(2048);
response["type"] = "temp_scan_response";
response["ok"] = true;
JsonArray devices = response.createNestedArray("devices");
int count = getTempScanResultCount();
for (int i = 0; i < count; i++) {
JsonObject device = devices.createNestedObject();
device["index"] = i + 1;
device["address"] = getTempScanResultAddress(i);
}
serializeJson(response, DashboardSerial);
DashboardSerial.println();
return;
}
if (strcmp(type, "assign_temp") == 0) {
String id = doc["id"] | "";
int slot = doc["slot"] | 0;
int index = doc["index"] | 0;
int configIndex = -1;
if (id.length() > 0) {
configIndex = findTempConfigIndexForUart(id);
} else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) {
configIndex = slot - 1;
}
if (configIndex < 0) {
sendError(DashboardSerial, "unknown_temp_sensor");
return;
}
if (!assignTempSensorByIndex(configIndex, index - 1)) {
sendError(DashboardSerial, "invalid_temp_selection");
return;
}
saveConfig();
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "clear_temp") == 0) {
String id = doc["id"] | "";
int slot = doc["slot"] | 0;
int configIndex = -1;
if (id.length() > 0) {
configIndex = findTempConfigIndexForUart(id);
} else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) {
configIndex = slot - 1;
}
if (configIndex < 0) {
sendError(DashboardSerial, "unknown_temp_sensor");
return;
}
if (!clearTempSensor(configIndex)) {
sendError(DashboardSerial, "unknown_temp_sensor");
return;
}
saveConfig();
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "save_config") == 0) {
saveConfig();
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "factory_reset") == 0) {
factoryResetConfig();
sendConfigResponse(DashboardSerial);
return;
}
if (strcmp(type, "enter_bms_setup") == 0) {
enterBmsSetupMode();
sendSimpleOk(DashboardSerial, "bms_setup_response");
return;
}
if (strcmp(type, "exit_bms_setup") == 0) {
exitBmsSetupMode();
sendSimpleOk(DashboardSerial, "bms_setup_response");
return;
}
if (strcmp(type, "scan_ble") == 0) {
scanBleDevices(20);
sendBleScanResponse(DashboardSerial);
return;
}
if (strcmp(type, "select_bms") == 0) {
int selected = doc["index"] | 0;
if (selected < 1 || selected > getBleScanResultCount()) {
sendError(DashboardSerial, "invalid_bms_selection");
return;
}
const BleScanResult* result = getBleScanResult(selected - 1);
if (!result) {
sendError(DashboardSerial, "invalid_bms_selection");
return;
}
appConfig.bms.enabled = true;
appConfig.bms.address = result->address;
appConfig.bms.addressType = "public";
if (result->name.length() > 0) {
appConfig.bms.name = result->name;
} else {
appConfig.bms.name = "BMS";
}
saveConfig();
exitBmsSetupMode();
DynamicJsonDocument response(512);
response["type"] = "bms_setup_response";
response["ok"] = true;
response["name"] = appConfig.bms.name;
response["address"] = appConfig.bms.address;
serializeJson(response, DashboardSerial);
DashboardSerial.println();
return;
}
sendError(DashboardSerial, "unknown_message_type");
}
void pollDashboardUart() {
while (DashboardSerial.available()) {
char c = DashboardSerial.read();
if (c == '\n') {
uartLineBuffer.trim();
if (uartLineBuffer.length() > 0) handleUartMessage(uartLineBuffer);
uartLineBuffer = "";
} else if (c != '\r') {
uartLineBuffer += c;
if (uartLineBuffer.length() > 512) {
uartLineBuffer = "";
sendError(DashboardSerial, "message_too_long");
}
}
}
}
void handleDebugSerial() {
if (!Serial.available()) return;
String command = Serial.readStringUntil('\n');
command.trim();
if (command == "status") {
sendStatus(Serial, true);
return;
}
if (command == "config") {
printConfig();
return;
}
if (command == "log quiet") {
setLogLevel(LOG_QUIET);
Serial.println("OK log level quiet");
return;
}
if (command == "log info") {
setLogLevel(LOG_INFO);
Serial.println("OK log level info");
return;
}
if (command == "log debug") {
setLogLevel(LOG_DEBUG);
Serial.println("OK log level debug");
return;
}
if (command == "wifi status") {
printNetworkStatus();
return;
}
if (command == "wifi list") {
Serial.println("Saved WiFi networks:");
for (int i = 0; i < wifiNetworkCount; i++) {
Serial.print(" ");
Serial.print(i + 1);
Serial.print(") priority ");
Serial.print(staPriorities[i]);
Serial.print(" / ");
Serial.print(staSsids[i]);
Serial.print(" / password_set ");
Serial.println(staPasswords[i].length() > 0 ? "true" : "false");
}
return;
}
if (command.startsWith("wifi ssid ")) {
String ssid = command.substring(10);
ssid.trim();
staSsids[0] = ssid;
staPriorities[0] = 1;
if (wifiNetworkCount < 1) wifiNetworkCount = 1;
Serial.println("OK WiFi SSID 1 updated");
Serial.println("Run: wifi save");
return;
}
if (command.startsWith("wifi pass ")) {
String password = command.substring(10);
password.trim();
staPasswords[0] = password;
staPriorities[0] = 1;
if (wifiNetworkCount < 1) wifiNetworkCount = 1;
Serial.println("OK WiFi password 1 updated");
Serial.println("Run: wifi save");
return;
}
if (command.startsWith("wifi add ")) {
String rest = command.substring(9);
int split = rest.indexOf('|');
if (split < 0) {
Serial.println("Invalid command. Use: wifi add SSID|PASSWORD");
return;
}
if (wifiNetworkCount >= MAX_WIFI_NETWORKS) {
Serial.println("WiFi network list full");
return;
}
String ssid = rest.substring(0, split);
String passAndPriority = rest.substring(split + 1);
int secondSplit = passAndPriority.indexOf('|');
String password = passAndPriority;
int priority = wifiNetworkCount + 1;
if (secondSplit >= 0) {
password = passAndPriority.substring(0, secondSplit);
priority = passAndPriority.substring(secondSplit + 1).toInt();
if (priority <= 0) priority = wifiNetworkCount + 1;
}
ssid.trim();
password.trim();
staSsids[wifiNetworkCount] = ssid;
staPasswords[wifiNetworkCount] = password;
staPriorities[wifiNetworkCount] = priority;
wifiNetworkCount++;
Serial.println("OK WiFi network added");
Serial.println("Run: wifi save");
return;
}
if (command == "wifi save") {
saveWifiConfig();
Serial.println("OK WiFi config saved");
return;
}
if (command == "wifi connect") {
connectStaWifi();
return;
}
if (command == "wifi clear") {
clearWifiConfig();
Serial.println("OK WiFi config cleared");
return;
}
if (command == "factory reset") {
factoryResetConfig();
Serial.println("OK factory reset complete");
return;
}
if (command == "save") {
saveConfig();
Serial.println("OK config saved");
return;
}
if (command.startsWith("relay ")) {
int firstSpace = command.indexOf(' ');
int secondSpace = command.indexOf(' ', firstSpace + 1);
if (secondSpace > 0) {
int relayNumber = command.substring(firstSpace + 1, secondSpace).toInt();
String action = command.substring(secondSpace + 1);
String relayId = "relay_" + String(relayNumber);
if (relayNumber >= 1 && relayNumber <= MAX_RELAYS && (action == "on" || action == "off")) {
bool enabled = action == "on";
if (setRelayById(relayId, enabled)) {
Serial.print("OK ");
Serial.print(relayId);
Serial.print(" ");
Serial.println(action);
return;
}
}
}
Serial.println("Invalid relay command. Use: relay 1 on, relay 1 off, relay 2 on, relay 2 off");
return;
}
if (command.startsWith("devicename ")) {
appConfig.deviceName = command.substring(11);
Serial.println("OK device name updated");
return;
}
if (command.startsWith("relayname ")) {
int firstSpace = command.indexOf(' ');
int secondSpace = command.indexOf(' ', firstSpace + 1);
if (secondSpace > 0) {
int relayNumber = command.substring(firstSpace + 1, secondSpace).toInt();
if (relayNumber >= 1 && relayNumber <= MAX_RELAYS) {
appConfig.relays[relayNumber - 1].name = command.substring(secondSpace + 1);
Serial.println("OK relay name updated");
return;
}
}
}
if (command == "scan temps") {
scanTempSensors();
printSensorAddresses();
return;
}
if (command.startsWith("assign temp ")) {
String rest = command.substring(12);
int space = rest.indexOf(' ');
if (space < 0) {
Serial.println("Invalid command. Use: assign temp <slot> <number>");
return;
}
int slot = rest.substring(0, space).toInt();
int index = rest.substring(space + 1).toInt();
if (!assignTempSensorByIndex(slot - 1, index - 1)) {
Serial.println("Invalid temp slot or sensor number.");
return;
}
saveConfig();
Serial.println("OK temp sensor assigned");
return;
}
if (command.startsWith("clear temp ")) {
int slot = command.substring(11).toInt();
if (!clearTempSensor(slot - 1)) {
Serial.println("Invalid temp slot.");
return;
}
saveConfig();
Serial.println("OK temp sensor cleared");
return;
}
if (command.startsWith("tempname ")) {
int firstSpace = command.indexOf(' ');
int secondSpace = command.indexOf(' ', firstSpace + 1);
if (secondSpace > 0) {
int sensorNumber = command.substring(firstSpace + 1, secondSpace).toInt();
if (sensorNumber >= 1 && sensorNumber <= MAX_TEMP_SENSORS) {
appConfig.tempSensors[sensorNumber - 1].name = command.substring(secondSpace + 1);
Serial.println("OK temp sensor name updated");
return;
}
}
}
if (command.startsWith("bmsname ")) {
appConfig.bms.name = command.substring(8);
Serial.println("OK bms name updated");
return;
}
if (command.startsWith("bmsaddr ")) {
appConfig.bms.address = command.substring(8);
appConfig.bms.enabled = appConfig.bms.address.length() > 0;
Serial.println("OK bms address updated");
return;
}
if (command == "enter setup") {
enterBmsSetupMode();
return;
}
if (command == "exit setup") {
exitBmsSetupMode();
return;
}
if (command == "scan ble") {
scanBleDevices(20);
return;
}
if (command.startsWith("select bms ")) {
int selected = command.substring(11).toInt();
if (selected < 1 || selected > getBleScanResultCount()) {
Serial.println("Invalid BMS selection. Run: scan ble");
return;
}
const BleScanResult* result = getBleScanResult(selected - 1);
if (!result) {
Serial.println("Invalid BMS selection. Run: scan ble");
return;
}
appConfig.bms.enabled = true;
appConfig.bms.address = result->address;
appConfig.bms.addressType = "public";
appConfig.bms.name = result->name.length() > 0 ? result->name : "BMS";
saveConfig();
Serial.print("OK selected BMS: ");
Serial.print(appConfig.bms.name);
Serial.print(" / ");
Serial.println(appConfig.bms.address);
Serial.println("BMS will reconnect on next update cycle.");
exitBmsSetupMode();
return;
}
if (command.length() > 0) {
Serial.print("Unknown command: ");
Serial.println(command);
Serial.println("Commands: status, config, save, relay 1 on/off, relay 2 on/off, relayname N <name>, tempname N <name>, bmsname <name>, bmsaddr <addr>, enter setup, scan ble, select bms N, exit setup");
}
}
void sendConfigJson() {
DynamicJsonDocument doc(4096);
JsonObject root = doc.to<JsonObject>();
buildConfigDocument(root);
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void sendOkConfig() {
saveConfig();
sendConfigJson();
}
int findRelayConfigIndex(const String& id) {
for (int i = 0; i < MAX_RELAYS; i++) {
if (appConfig.relays[i].id == id) return i;
}
return -1;
}
int findTempSensorConfigIndex(const String& id) {
for (int i = 0; i < MAX_TEMP_SENSORS; i++) {
if (appConfig.tempSensors[i].id == id) return i;
}
return -1;
}
void handleGetConfig() {
sendConfigJson();
}
void handleHealth() {
DynamicJsonDocument doc(1024);
doc["type"] = "health_response";
doc["ok"] = true;
doc["api_version"] = "v1";
doc["firmware_name"] = FIRMWARE_NAME;
doc["firmware_version"] = FIRMWARE_VERSION;
doc["uptime_seconds"] = millis() / 1000;
JsonObject network = doc.createNestedObject("network");
network["ap_enabled"] = true;
network["ap_ssid"] = apSsid;
network["ap_auth"] = "wpa2";
network["ap_ip"] = WiFi.softAPIP().toString();
network["sta_enabled"] = wifiNetworkCount > 0;
network["sta_connected"] = WiFi.status() == WL_CONNECTED;
network["sta_ssid"] = activeStaSsid;
network["sta_ip"] = WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "";
JsonObject bms = doc.createNestedObject("bms");
bms["configured"] = appConfig.bms.enabled && appConfig.bms.address.length() > 0;
bms["connected"] = bmsData.connected;
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void handleCapabilities() {
DynamicJsonDocument doc(2048);
doc["type"] = "capabilities_response";
doc["ok"] = true;
doc["api_version"] = "v1";
doc["firmware_name"] = FIRMWARE_NAME;
doc["firmware_version"] = FIRMWARE_VERSION;
JsonArray endpoints = doc.createNestedArray("endpoints");
endpoints.add("GET /api/v1/health");
endpoints.add("GET /api/v1/capabilities");
endpoints.add("GET /api/v1/status");
endpoints.add("GET /api/v1/config");
endpoints.add("GET /api/v1/config/export");
endpoints.add("POST /api/v1/config/import");
endpoints.add("POST /api/v1/relay/set");
endpoints.add("GET /api/v1/config/wifi");
endpoints.add("POST /api/v1/config/wifi");
endpoints.add("POST /api/v1/wifi/connect");
endpoints.add("POST /api/v1/wifi/clear");
endpoints.add("POST /api/v1/temps/scan");
endpoints.add("POST /api/v1/temps/assign");
endpoints.add("POST /api/v1/temps/clear");
endpoints.add("POST /api/v1/bms/setup/enter");
endpoints.add("POST /api/v1/bms/setup/exit");
endpoints.add("POST /api/v1/bms/scan");
endpoints.add("POST /api/v1/bms/select");
JsonObject limits = doc.createNestedObject("limits");
limits["relay_count"] = MAX_RELAYS;
limits["temperature_sensor_count"] = MAX_TEMP_SENSORS;
limits["runtime_temperature_status_count"] = appConfig.tempSensorCount > 4 ? 4 : appConfig.tempSensorCount;
limits["wifi_network_count"] = MAX_WIFI_NETWORKS;
JsonObject features = doc.createNestedObject("features");
features["relay_control"] = true;
features["temperature_scan"] = true;
features["wifi_config"] = true;
features["config_backup_restore"] = true;
features["bms_setup"] = true;
features["uart_json"] = true;
features["root_compatibility_aliases"] = true;
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void handleExportConfig() {
DynamicJsonDocument doc(6144);
doc["type"] = "config_export_response";
doc["ok"] = true;
doc["api_version"] = "v1";
doc["firmware_name"] = FIRMWARE_NAME;
doc["firmware_version"] = FIRMWARE_VERSION;
JsonObject config = doc.createNestedObject("config");
buildConfigDocument(config);
JsonObject wifi = doc.createNestedObject("wifi");
buildWifiConfigDocument(wifi, true);
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void handleImportConfig() {
DynamicJsonDocument request(6144);
DeserializationError error = deserializeJson(request, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
JsonObject config = request["config"].as<JsonObject>();
JsonObject source = config.isNull() ? request.as<JsonObject>() : config;
if (!request["wifi"].isNull()) {
source["wifi"] = request["wifi"];
}
const char* errorCode = nullptr;
if (!validateConfigImport(source, errorCode)) {
String response = "{\"ok\":false,\"error\":\"";
response += errorCode ? errorCode : "invalid_config";
response += "\"}";
server.send(400, "application/json", response);
return;
}
applyImportedConfig(source);
sendConfigJson();
}
void handleUpdateDeviceConfig() {
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
if (doc["device_name"].is<String>()) {
appConfig.deviceName = doc["device_name"].as<String>();
}
sendOkConfig();
}
void handleUpdateRelayConfig() {
DynamicJsonDocument doc(1024);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
String id = doc["id"] | "";
int index = findRelayConfigIndex(id);
if (index < 0) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
if (doc["name"].is<String>()) appConfig.relays[index].name = doc["name"].as<String>();
if (doc["enabled"].is<bool>()) appConfig.relays[index].enabled = doc["enabled"].as<bool>();
sendOkConfig();
}
void handleUpdateBmsConfig() {
DynamicJsonDocument doc(1024);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
if (doc["enabled"].is<bool>()) appConfig.bms.enabled = doc["enabled"].as<bool>();
if (doc["name"].is<String>()) appConfig.bms.name = doc["name"].as<String>();
if (doc["address"].is<String>()) appConfig.bms.address = doc["address"].as<String>();
if (doc["address_type"].is<String>()) appConfig.bms.addressType = doc["address_type"].as<String>();
sendOkConfig();
}
void handleUpdateTempSensorConfig() {
DynamicJsonDocument doc(1024);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
String id = doc["id"] | "";
int index = findTempSensorConfigIndex(id);
if (index < 0) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_temp_sensor\"}");
return;
}
if (doc["name"].is<String>()) appConfig.tempSensors[index].name = doc["name"].as<String>();
if (doc["address"].is<String>()) appConfig.tempSensors[index].address = doc["address"].as<String>();
if (doc["enabled"].is<bool>()) appConfig.tempSensors[index].enabled = doc["enabled"].as<bool>();
if (doc["weather"].is<bool>()) appConfig.tempSensors[index].weather = doc["weather"].as<bool>();
sendOkConfig();
}
void handleFactoryResetConfig() {
factoryResetConfig();
sendConfigJson();
}
void sendWifiConfigJson() {
DynamicJsonDocument doc(2048);
doc["type"] = "wifi_config_response";
doc["ok"] = true;
JsonObject wifi = doc.createNestedObject("wifi");
buildWifiConfigDocument(wifi, false);
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void handleGetWifiConfig() {
sendWifiConfigJson();
}
void handleUpdateWifiConfig() {
DynamicJsonDocument doc(2048);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
JsonArray networks = doc["networks"].as<JsonArray>();
if (!networks.isNull()) {
applyWifiNetworks(networks);
} else {
if (doc["ssid"].is<String>()) {
staSsids[0] = doc["ssid"].as<String>();
staPriorities[0] = 1;
if (wifiNetworkCount < 1) wifiNetworkCount = 1;
}
if (doc["password"].is<String>()) {
staPasswords[0] = doc["password"].as<String>();
staPriorities[0] = 1;
if (wifiNetworkCount < 1) wifiNetworkCount = 1;
}
}
saveWifiConfig();
sendWifiConfigJson();
}
void handleWifiConnect() {
connectStaWifi();
sendWifiConfigJson();
}
void handleWifiClear() {
clearWifiConfig();
sendWifiConfigJson();
}
void sendTempScanJson() {
DynamicJsonDocument doc(2048);
doc["type"] = "temp_scan_response";
doc["ok"] = true;
JsonArray devices = doc.createNestedArray("devices");
int count = getTempScanResultCount();
for (int i = 0; i < count; i++) {
JsonObject device = devices.createNestedObject();
device["index"] = i + 1;
device["address"] = getTempScanResultAddress(i);
}
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void handleTempScan() {
scanTempSensors();
sendTempScanJson();
}
void handleTempAssign() {
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
String id = doc["id"] | "";
int slot = doc["slot"] | 0;
int index = doc["index"] | 0;
int configIndex = -1;
if (id.length() > 0) {
configIndex = findTempConfigIndexForUart(id);
} else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) {
configIndex = slot - 1;
}
if (configIndex < 0) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"unknown_temp_sensor\"}");
return;
}
if (!assignTempSensorByIndex(configIndex, index - 1)) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_temp_selection\"}");
return;
}
saveConfig();
sendConfigJson();
}
void handleTempClear() {
String body = server.arg("plain");
if (body.length() == 0) {
for (int i = 0; i < MAX_TEMP_SENSORS; i++) {
appConfig.tempSensors[i].address = "";
appConfig.tempSensors[i].enabled = false;
appConfig.tempSensors[i].weather = false;
sensors.tempsF[i] = -127.0;
sensors.online[i] = false;
}
saveConfig();
sendConfigJson();
return;
}
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, body);
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
String id = doc["id"] | "";
int slot = doc["slot"] | 0;
int configIndex = -1;
if (id.length() > 0) {
configIndex = findTempConfigIndexForUart(id);
} else if (slot >= 1 && slot <= MAX_TEMP_SENSORS) {
configIndex = slot - 1;
}
if (configIndex < 0) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"unknown_temp_sensor\"}");
return;
}
appConfig.tempSensors[configIndex].address = "";
appConfig.tempSensors[configIndex].enabled = false;
appConfig.tempSensors[configIndex].weather = false;
sensors.tempsF[configIndex] = -127.0;
sensors.online[configIndex] = false;
saveConfig();
sendConfigJson();
}
void handleSaveConfig() {
saveConfig();
sendConfigJson();
}
void handleEnterBmsSetup() {
enterBmsSetupMode();
server.send(200, "application/json", "{\"type\":\"bms_setup_response\",\"ok\":true,\"mode\":\"setup\"}");
}
void handleExitBmsSetup() {
exitBmsSetupMode();
server.send(200, "application/json", "{\"type\":\"bms_setup_response\",\"ok\":true,\"mode\":\"normal\"}");
}
void handleBleScan() {
scanBleDevices(20);
DynamicJsonDocument doc(2048);
doc["type"] = "ble_scan_response";
JsonArray devices = doc.createNestedArray("devices");
int count = getBleScanResultCount();
for (int i = 0; i < count; i++) {
const BleScanResult* result = getBleScanResult(i);
if (!result) {
continue;
}
JsonObject device = devices.createNestedObject();
device["index"] = i + 1;
device["name"] = result->name;
device["address"] = result->address;
device["rssi"] = result->rssi;
}
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
void handleSelectBms() {
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
int selected = doc["index"] | 0;
if (selected < 1 || selected > getBleScanResultCount()) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_bms_selection\"}");
return;
}
const BleScanResult* result = getBleScanResult(selected - 1);
if (!result) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_bms_selection\"}");
return;
}
appConfig.bms.enabled = true;
appConfig.bms.address = result->address;
appConfig.bms.addressType = "public";
if (result->name.length() > 0) {
appConfig.bms.name = result->name;
} else {
appConfig.bms.name = "BMS";
}
saveConfig();
exitBmsSetupMode();
DynamicJsonDocument response(512);
response["type"] = "bms_setup_response";
response["ok"] = true;
response["name"] = appConfig.bms.name;
response["address"] = appConfig.bms.address;
String output;
serializeJson(response, output);
server.send(200, "application/json", output);
}
void handleSetRelayPost() {
DynamicJsonDocument doc(512);
DeserializationError error = deserializeJson(doc, server.arg("plain"));
if (error) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_json\"}");
return;
}
String relayId = doc["id"] | "";
bool state = doc["state"] | false;
if (!setRelayById(relayId, state)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
DynamicJsonDocument response(512);
response["type"] = "relay_response";
response["ok"] = true;
response["id"] = relayId;
response["state"] = state;
String output;
serializeJson(response, output);
server.send(200, "application/json", output);
}
void handleGenericRelayRoute() {
String uri = server.uri();
String prefix = "/relay/";
if (!uri.startsWith(prefix)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"invalid_relay_route\"}");
return;
}
String rest = uri.substring(prefix.length());
int slash = rest.indexOf('/');
if (slash < 0) {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"missing_relay_action\"}");
return;
}
String relayId = rest.substring(0, slash);
String action = rest.substring(slash + 1);
bool enabled;
if (action == "on") enabled = true;
else if (action == "off") enabled = false;
else {
server.send(400, "application/json", "{\"ok\":false,\"error\":\"invalid_relay_action\"}");
return;
}
if (!setRelayById(relayId, enabled)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
DynamicJsonDocument doc(512);
doc["ok"] = true;
doc["id"] = relayId;
doc["state"] = enabled;
String output;
serializeJson(doc, output);
server.send(200, "application/json", output);
}
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);
server.send(200, "application/json", output);
}
void setup() {
Serial.begin(115200);
DashboardSerial.begin(
DASHBOARD_UART_BAUD,
SERIAL_8N1,
DASHBOARD_UART_RX_PIN,
DASHBOARD_UART_TX_PIN
);
Serial.println();
Serial.println("==================================");
Serial.println("Overland Controller Booting");
Serial.println("==================================");
loadConfig();
printConfig();
pinMode(IGNITION_PIN, INPUT);
initRelays();
initSensors();
initBms();
loadWifiConfig();
loadApConfig();
WiFi.mode(WIFI_AP_STA);
startAccessPoint();
connectStaWifi();
server.on("/", HTTP_GET, []() {
server.send_P(200, "text/html", INDEX_HTML);
});
server.on(API_V1("/status"), handleStatus);
server.on(API_V1("/health"), HTTP_GET, handleHealth);
server.on(API_V1("/capabilities"), HTTP_GET, handleCapabilities);
server.on(API_V1("/relay/set"), HTTP_POST, handleSetRelayPost);
server.on(API_V1("/config"), HTTP_GET, handleGetConfig);
server.on(API_V1("/config/export"), HTTP_GET, handleExportConfig);
server.on(API_V1("/config/import"), HTTP_POST, handleImportConfig);
server.on(API_V1("/config/wifi"), HTTP_GET, handleGetWifiConfig);
server.on(API_V1("/config/ap"), HTTP_GET, handleGetApConfig);
server.on(API_V1("/config/ap"), HTTP_POST, handleUpdateApConfig);
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);
server.on("/relay/relay_1/off", HTTP_GET, handleGenericRelayRoute);
server.on("/relay/relay_2/on", HTTP_GET, handleGenericRelayRoute);
server.on("/relay/relay_2/off", HTTP_GET, handleGenericRelayRoute);
server.on("/config", HTTP_GET, handleGetConfig);
server.on("/config/wifi", HTTP_GET, handleGetWifiConfig);
server.on("/config/wifi", HTTP_POST, handleUpdateWifiConfig);
server.on("/wifi/connect", HTTP_POST, handleWifiConnect);
server.on("/wifi/clear", HTTP_POST, handleWifiClear);
server.on("/config/device", HTTP_POST, handleUpdateDeviceConfig);
server.on("/config/relay", HTTP_POST, handleUpdateRelayConfig);
server.on("/config/bms", HTTP_POST, handleUpdateBmsConfig);
server.on("/config/temp", HTTP_POST, handleUpdateTempSensorConfig);
server.on("/config/factory-reset", HTTP_POST, handleFactoryResetConfig);
server.on("/config/save", HTTP_POST, handleSaveConfig);
server.on("/temps/scan", HTTP_POST, handleTempScan);
server.on("/temps/assign", HTTP_POST, handleTempAssign);
server.on("/temps/clear", HTTP_POST, handleTempClear);
server.on("/bms/setup/enter", HTTP_POST, handleEnterBmsSetup);
server.on("/bms/setup/exit", HTTP_POST, handleExitBmsSetup);
server.on("/bms/scan", HTTP_POST, handleBleScan);
server.on("/bms/select", HTTP_POST, handleSelectBms);
server.begin();
Serial.println("Web Server Started");
Serial.println("Dashboard UART Started");
}
void loop() {
server.handleClient();
maintainStaWifi();
handleDebugSerial();
updateBms();
updateAlarms();
pollDashboardUart();
static unsigned long lastSensorUpdate = 0;
if (millis() - lastSensorUpdate > 5000) {
updateSensors();
lastSensorUpdate = millis();
logDebug("Sensor Update");
}
}