/* JK-BMS Web + JSON (16 cells, signed power by current)
* Sprzęt: Mega2560 + RS485 (RE&DE -> D7, RO->19 RX1, DI->18 TX1) + Ethernet W5100/W5500
* UART: 115200 8N1
*
* HTTP:
* GET / -> strona WWW (odświeża /json co 2 s)
* GET /json -> JSON z polami: uptime, voltage, current, power (ZE ZNAKIEM wg prądu),
* temp{t1,t2}, soc, soh, cells[16], error (0/1), error_msg
*
* Modbus: FC=0x03 z automatycznym fallbackiem na FC=0x04.
* Polling: co ~1 s jedno zapytanie (stabilnie dla JK).
*/
#include <SPI.h>
#include <Ethernet.h>
#include <Arduino.h>
#include <math.h> // fabsf, isnan
// ======== KONFIG ========
#define RS485_DIR_PIN 7
#define SLAVE_ID 0x01
#define BMS_BAUD 115200
#define N_CELLS 16 // liczba cel
// Timingi Modbus
#define RX_WINDOW_MS 2000
#define IB_GAP_MS 60
#define DIR_SETTLE_US 600
#define STEP_INTERVAL_MS 1000 // 1 zapytanie / sek.
// Ethernet
#define USE_DHCP 0
byte mac[] = {0xDE,0xAD,0xBE,0xEF,0xFE,0xED};
// Statyczne IP (gdy DHCP zawiedzie lub USE_DHCP==0)
IPAddress ip(192,168,1,34), dns(192,168,1,1), gw(192,168,1,1), mask(255,255,255,0);
EthernetServer server(80);
// ======== POMOCNICZE: uptime w sekundach (bez problemu przepełnienia w praktyce) ========
static inline uint32_t uptime_seconds() {
return millis() / 1000UL;
}
// ======== CRC16 MODBUS ========
static uint16_t crc16(const uint8_t* d, size_t n){
uint16_t c=0xFFFF;
for(size_t i=0;i<n;i++){ c^=d[i]; for(uint8_t b=0;b<8;b++) c=(c&1)?(c>>1)^0xA001:(c>>1); }
return c;
}
static inline void rs485_tx(bool tx){ digitalWrite(RS485_DIR_PIN, tx?HIGH:LOW); delayMicroseconds(DIR_SETTLE_US); }
// TX/RX jednej ramki
static bool xchg(const uint8_t* tx, size_t ntx, uint8_t* rx, size_t cap, size_t &n){
while(Serial1.available()) Serial1.read();
rs485_tx(true); Serial1.write(tx,ntx); Serial1.flush(); rs485_tx(false);
n=0; uint32_t t0=millis(), last=millis();
while(millis()-t0 < RX_WINDOW_MS){
bool any=false;
while(Serial1.available()){
if(n<cap) rx[n++] = (uint8_t)Serial1.read();
any=true; last=millis();
}
if(!any && (millis()-last) >= IB_GAP_MS) break;
delay(1);
}
return n>0;
}
// Czytaj FC=fn (0x03/0x04). True jeśli ramka OK (len+CRC).
static bool read_fn(uint8_t fn, uint16_t addr, uint16_t regs, uint8_t* rx, size_t& n, String* why=nullptr){
uint8_t req[8] = { SLAVE_ID, fn, (uint8_t)(addr>>8), (uint8_t)addr, (uint8_t)(regs>>8), (uint8_t)regs, 0, 0 };
uint16_t c=crc16(req,6); req[6]=(uint8_t)c; req[7]=(uint8_t)(c>>8);
if(!xchg(req,sizeof req,rx,256,n)){ if(why) *why="timeout"; return false; }
if(n<5){ if(why) *why="short"; return false; }
if(rx[0]!=SLAVE_ID){ if(why) *why="slave_id"; return false; }
if(rx[1]==(fn|0x80)){ if(why){ *why="exception "; *why+=String(rx[2],DEC);} return false; }
if(rx[1]!=fn){ if(why) *why="wrong_func"; return false; }
{ uint8_t bc=rx[2]; uint16_t need=3+bc+2; if(n!=need){ if(why){*why="len "; *why+=String(need);} return false; } }
{ uint16_t rcrc=(uint16_t)rx[n-2] | ((uint16_t)rx[n-1]<<8); if(rcrc!=crc16(rx,n-2)){ if(why) *why="crc"; return false; } }
return true;
}
// Auto: spróbuj 0x03, jak nie › 0x04
static bool read_auto(uint16_t addr, uint16_t regs, uint8_t* rx, size_t& n, String& why, uint8_t& fn_used){
if(read_fn(0x03, addr, regs, rx, n, &why)){ fn_used=0x03; return true; }
if(why=="timeout" || why=="short" || why=="crc" || why=="wrong_func"){
if(read_fn(0x04, addr, regs, rx, n, &why)){ fn_used=0x04; return true; }
}
return false;
}
// ======== Dekodery typów ========
static uint32_t u32_LEw(const uint8_t* p){ uint16_t lw=((uint16_t)p[0]<<8)|p[1]; uint16_t hw=((uint16_t)p[2]<<8)|p[3]; return ((uint32_t)lw)|((uint32_t)hw<<16); }
static int32_t i32_LEw(const uint8_t* p){ return (int32_t)u32_LEw(p); }
static uint16_t u16_BE (const uint8_t* p){ return ((uint16_t)p[0]<<8)|p[1]; }
// ======== Pomocnik: znak mocy wg prądu ========
static float signed_power_by_current(float p, float i){
if (isnan(p) || isnan(i)) return p;
return (i < 0.0f) ? -fabsf(p) : fabsf(p);
}
// ======== Globalne dane ========
struct Data {
float V=NAN, I=NAN, P=NAN, T1=NAN, T2=NAN;
int SOC=-1, SOH=-1;
uint16_t cells[N_CELLS] = {0};
uint32_t last_update_s = 0; // sekundy od startu
uint8_t error = 1; // 0 OK, 1 błąd
String error_msg = "not ready";
} g;
// Flagi kompletności jednej rundy
static bool fV=false,fI=false,fP=false,fT=false,fSOC=false,fSOH=false,fC=false;
static bool hadErr=false; String lastErr="";
// ======== Poller (1 krok / sek.) ========
enum Step { S_V, S_P, S_I, S_T, S_SOC, S_SOH, S_CELLS, S__N };
static Step step = S_V;
static uint32_t t_next = 0;
static void poll_one_step(){
uint8_t rx[260]; size_t n=0; String why; uint8_t fn=0;
switch(step){
case S_V: // 0x1290 U32 mV
if(read_auto(0x1290,2,rx,n,why,fn)){ g.V = u32_LEw(&rx[3])/1000.0f; fV=true; }
else { hadErr=true; lastErr="0x1290 "+why; }
break;
case S_P: // 0x1294 U32 mW -> W, znak wg prądu (jeśli już znany)
if(read_auto(0x1294,2,rx,n,why,fn)){
g.P = u32_LEw(&rx[3]) / 1000.0f;
if (fI) g.P = signed_power_by_current(g.P, g.I);
fP=true;
} else { hadErr=true; lastErr="0x1294 "+why; }
break;
case S_I: // 0x1298 S32 mA -> A
if(read_auto(0x1298,2,rx,n,why,fn)){
g.I = i32_LEw(&rx[3]) / 1000.0f;
if (fP) g.P = signed_power_by_current(g.P, g.I);
fI=true;
} else { hadErr=true; lastErr="0x1298 "+why; }
break;
case S_T: // 0x129C/0x129E INT16 0.1°C
if(read_auto(0x129C,2,rx,n,why,fn)){
g.T1 = ((int16_t)u16_BE(&rx[3]))/10.0f;
g.T2 = ((int16_t)u16_BE(&rx[5]))/10.0f;
fT=true;
} else { hadErr=true; lastErr="0x129C "+why; }
break;
case S_SOC: // 0x12A6, SOC w HI
if(read_auto(0x12A6,1,rx,n,why,fn)){ g.SOC = (int)rx[3]; fSOC=true; }
else { hadErr=true; lastErr="0x12A6 "+why; }
break;
case S_SOH: // 0x12B8, SOH w LO
if(read_auto(0x12B8,1,rx,n,why,fn)){ g.SOH = (int)rx[4]; fSOH=true; }
else { hadErr=true; lastErr="0x12B8 "+why; }
break;
case S_CELLS: // 0x1200.. N_CELLS × UINT16 mV
if(read_auto(0x1200, N_CELLS, rx, n, why, fn)){
for(int i=0;i<N_CELLS;i++) g.cells[i] = u16_BE(&rx[3+2*i]);
fC=true;
} else { hadErr=true; lastErr="0x1200 "+why; }
break;
default: break;
}
// Koniec rundy
if(step == S_CELLS){
if(fV && fP && fI && fT && fSOC && fSOH && fC){
g.P = signed_power_by_current(g.P, g.I);
g.error = 0; g.error_msg = "";
} else { g.error = 1; g.error_msg = lastErr; }
g.last_update_s = uptime_seconds(); // zapis w sekundach
// reset flag
fV=fP=fI=fT=fSOC=fSOH=fC=false; hadErr=false; lastErr="";
}
step = (Step)((step + 1) % S__N);
}
// ======== HTTP: /json i / ========
static void send_http_json(EthernetClient &cl){
cl.println(F("HTTP/1.1 200 OK"));
cl.println(F("Content-Type: application/json; charset=utf-8"));
cl.println(F("Connection: close"));
cl.println();
// JSON (streaming bez dużych Stringów)
cl.print(F("{\"uptime\":")); cl.print(uptime_seconds()); // sekundy zamiast millis
cl.print(F(",\"voltage\":")); cl.print(g.V,3);
cl.print(F(",\"current\":")); cl.print(g.I,3);
float p_signed = signed_power_by_current(g.P, g.I);
cl.print(F(",\"power\":")); cl.print(p_signed,3);
cl.print(F(",\"temp\":{\"t1\":")); cl.print(g.T1,1); cl.print(F(",\"t2\":")); cl.print(g.T2,1); cl.print('}');
cl.print(F(",\"soc\":")); cl.print(g.SOC);
cl.print(F(",\"soh\":")); cl.print(g.SOH);
cl.print(F(",\"cells\":["));
for(int i=0;i<N_CELLS;i++){ cl.print(g.cells[i]); if(i<N_CELLS-1) cl.print(','); }
cl.print(']');
cl.print(F(",\"error\":")); cl.print((int)g.error);
cl.print(F(",\"error_msg\":\""));
for(size_t i=0;i<g.error_msg.length();i++){ char c=g.error_msg[i]; if(c=='\"') cl.print('\\'); cl.print(c); }
cl.print(F("\"}"));
}
static void send_http_index(EthernetClient &cl){
cl.println(F("HTTP/1.1 200 OK"));
cl.println(F("Content-Type: text/html; charset=utf-8"));
cl.println(F("Connection: close"));
cl.println();
cl.println(F(
"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>JK-BMS Live (16S)</title>"
"<style>"
"body{font-family:system-ui,Segoe UI,Arial;margin:20px;background:#0b1220;color:#eaeef3}"
".card{background:#111a2b;padding:16px;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,.3);max-width:980px}"
"h1{margin:0 0 12px;font-size:22px}"
"table{width:100%;border-collapse:collapse}td{padding:6px 8px;border-bottom:1px solid #1b2942}"
".ok{color:#62d26f}.err{color:#ff6b6b}.mono{font-family:ui-monospace,Consolas,monospace;word-break:break-word}"
".cells{display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));gap:6px;margin-top:6px}"
".pill{background:#0e203b;border:1px solid #1c335a;border-radius:8px;padding:6px 8px}"
"</style></head><body><div class='card'><h1>JK-BMS Live (16S)</h1>"
"<div id='status'>wczytywanie…</div>"
"<table><tbody>"
"<tr><td>Napięcie [V]</td><td class='mono' id='v'>-</td></tr>"
"<tr><td>Prąd [A]</td><td class='mono' id='i'>-</td></tr>"
"<tr><td>Moc [W]</td><td class='mono' id='p'>-</td></tr>"
"<tr><td>Temp 1/2 [°C]</td><td class='mono' id='t12'>-</td></tr>"
"<tr><td>SOC / SOH [%]</td><td class='mono' id='socsoh'>-</td></tr>"
"</tbody></table>"
"<div class='mono cells' id='cells'></div>"
"<p style='margin-top:10px'>JSON: <a href='/json' style='color:#9bd'>/json</a></p>"
"</div><script>"
"function el(id){return document.getElementById(id)};"
"function renderCells(arr){const c=el('cells');c.innerHTML='';(arr||[]).forEach((v,i)=>{const d=document.createElement('div');d.className='cell';d.textContent='C'+(i+1)+': '+v+' mV';c.appendChild(d);});}"
"function fmtTime(sec){sec=Math.floor(sec);const h=Math.floor(sec/3600);const m=Math.floor((sec%3600)/60);const s=sec%60;return String(h).padStart(2,'0')+':'+String(m).padStart(2,'0')+':'+String(s).padStart(2,'0');}"
"async function tick(){try{const r=await fetch('/json',{cache:'no-store'});const j=await r.json();"
"el('v').textContent=(j.voltage??NaN).toFixed(3);"
"el('i').textContent=(j.current??NaN).toFixed(3);"
"el('p').textContent=(j.power??NaN).toFixed(3);"
"el('t12').textContent=(j.temp?.t1??NaN).toFixed(1)+' / '+(j.temp?.t2??NaN).toFixed(1);"
"el('socsoh').textContent=(j.soc??-1)+' / '+(j.soh??-1);"
"renderCells(j.cells);"
"const s=el('status');if(j.error){s.innerHTML='Status: <span class=\"err\">BŁĄD</span> – '+(j.error_msg||'');}"
"else{s.innerHTML='Status: <span class=\"ok\">OK</span> (uptime='+fmtTime(j.uptime)+')';}}catch(e){el('status').innerHTML='Status: <span class=\"err\">BŁĄD fetch</span> – '+e;}}"
"tick(); setInterval(tick,2000);"
"</script></body></html>"
));
}
// ======== SETUP / LOOP ========
void setup(){
pinMode(RS485_DIR_PIN, OUTPUT); rs485_tx(false);
Serial.begin(115200); while(!Serial){}
Serial.println(F("\n== JK-BMS Web + JSON (16 cells, signed power)=="));
Serial1.begin(BMS_BAUD, SERIAL_8N1);
#if USE_DHCP
if(Ethernet.begin(mac)==0){
Serial.println(F("DHCP FAIL, używam statycznego IP"));
Ethernet.begin(mac, ip, dns, gw, mask);
}
#else
Ethernet.begin(mac, ip, dns, gw, mask);
#endif
delay(200);
Serial.print(F("IP: ")); Serial.println(Ethernet.localIP());
server.begin();
t_next = millis(); // harmonogram w ms (bezpieczny dzięki różnicom)
}
void loop(){
// Poller (1 krok / s)
if((int32_t)(millis()-t_next) >= 0){
poll_one_step();
t_next += STEP_INTERVAL_MS;
}
// HTTP
EthernetClient client = server.available();
if(client){
// proste parsowanie 1. linii
String line = client.readStringUntil('\n');
bool wantJson = line.startsWith("GET /json");
// zjedz nagłówki
while(client.connected()){
String l = client.readStringUntil('\n');
if(l=="\r" || l.length()==0) break;
}
if(wantJson) send_http_json(client);
else send_http_index(client);
client.stop();
}
}