3247 lines
102 KiB
Arduino
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");
|
|
}
|
|
}
|