Initial release — Growatt SPF TCP proxy with decoder and HA MQTT integration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>invraw — Growatt Raw Logger</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--bg: #0d1117; --surface: #161b22; --surface2: #1c2128;
|
||||
--border: #30363d; --text: #e6edf3; --muted: #8b949e;
|
||||
--inv: #388bfd; --cloud: #3fb950; --warn: #d29922; --danger: #f85149;
|
||||
--solar: #f0a500; --battery: #3fb950; --grid: #58a6ff;
|
||||
--output: #bc8cff; --temp: #ff7b72; --energy: #79c0ff;
|
||||
--mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
}
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
/* ── Header ── */
|
||||
header {
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
padding: 10px 20px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface); position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
header h1 { font-size: 15px; font-weight: 600; }
|
||||
header h1 span { color: var(--inv); }
|
||||
.stats { display: flex; gap: 20px; color: var(--muted); font-size: 12px; }
|
||||
.stats b { color: var(--text); }
|
||||
.controls { margin-left: auto; display: flex; gap: 8px; }
|
||||
button {
|
||||
background: var(--border); border: 1px solid #484f58; color: var(--text);
|
||||
padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-family: var(--mono);
|
||||
}
|
||||
button:hover { background: #484f58; }
|
||||
button.paused { background: #3d2b00; border-color: var(--warn); color: var(--warn); }
|
||||
|
||||
/* ── Filter bar ── */
|
||||
.filter-bar {
|
||||
padding: 7px 20px; border-bottom: 1px solid var(--border);
|
||||
display: flex; gap: 14px; background: var(--bg);
|
||||
}
|
||||
.filter-bar label { color: var(--muted); font-size: 12px; display: flex; align-items: center; gap: 6px; cursor: pointer; }
|
||||
input[type=checkbox] { accent-color: var(--inv); }
|
||||
|
||||
/* ── Packet list ── */
|
||||
#packets { padding: 10px 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
#empty { color: var(--muted); padding: 40px 20px; text-align: center; }
|
||||
|
||||
.packet { border: 1px solid var(--border); border-radius: 8px; background: var(--surface); overflow: hidden; }
|
||||
|
||||
.packet-header {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 8px 14px; cursor: pointer; user-select: none;
|
||||
}
|
||||
.packet-header:hover { background: var(--surface2); }
|
||||
.ts { color: var(--muted); font-size: 11px; min-width: 195px; }
|
||||
.dir { font-weight: 700; min-width: 130px; }
|
||||
.dir-inv { color: var(--inv); }
|
||||
.dir-cloud { color: var(--cloud); }
|
||||
.sz { color: var(--muted); font-size: 11px; min-width: 55px; }
|
||||
.badge {
|
||||
background: #21262d; border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 1px 7px; font-size: 11px; white-space: nowrap;
|
||||
}
|
||||
.badge-proto { color: var(--warn); }
|
||||
.badge-cmd { color: #bc8cff; }
|
||||
.badge-soc { color: var(--battery); }
|
||||
.badge-pv { color: var(--solar); }
|
||||
.badge-status-ok { color: var(--cloud); }
|
||||
.badge-status-warn { color: var(--warn); }
|
||||
.badge-status-err { color: var(--danger); }
|
||||
.expand-icon { margin-left: auto; color: var(--muted); font-size: 10px; }
|
||||
|
||||
.packet-body { display: none; border-top: 1px solid var(--border); }
|
||||
.packet-body.open { display: block; }
|
||||
|
||||
/* ── Decoded section ── */
|
||||
.decoded-section { border-bottom: 1px solid var(--border); padding: 12px 16px; }
|
||||
.decoded-title { font-size: 11px; color: var(--muted); letter-spacing: .5px; text-transform: uppercase; margin-bottom: 10px; }
|
||||
.decoded-meta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.decoded-meta span { font-size: 11px; }
|
||||
.decoded-meta .serial { color: var(--text); }
|
||||
.decoded-meta .layout { color: var(--muted); }
|
||||
.decoded-meta .buffered { color: var(--warn); }
|
||||
|
||||
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; }
|
||||
.cat-card {
|
||||
background: #21262d; border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.cat-title {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: .5px;
|
||||
margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.cat-solar .cat-title { color: var(--solar); }
|
||||
.cat-battery .cat-title { color: var(--battery); }
|
||||
.cat-grid .cat-title { color: var(--grid); }
|
||||
.cat-output .cat-title { color: var(--output); }
|
||||
.cat-temp .cat-title { color: var(--temp); }
|
||||
.cat-energy .cat-title { color: var(--energy); }
|
||||
.cat-status .cat-title { color: var(--warn); }
|
||||
|
||||
.field-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; padding: 1px 0; }
|
||||
.field-lbl { color: var(--muted); font-size: 11px; }
|
||||
.field-val { font-weight: 600; }
|
||||
.field-unit { color: var(--muted); font-size: 11px; margin-left: 3px; }
|
||||
.field-note { color: var(--muted); font-size: 10px; display: block; }
|
||||
.val-zero { color: #484f58; }
|
||||
.val-ok { color: var(--cloud); }
|
||||
.val-warn { color: var(--warn); }
|
||||
.val-neg { color: var(--battery); }
|
||||
|
||||
/* ── Hex dump ── */
|
||||
.hex-section pre {
|
||||
padding: 12px 16px; font-size: 12px; line-height: 1.6;
|
||||
white-space: pre; overflow-x: auto; color: #a5d6ff;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.raw-hex { padding: 6px 16px 12px; }
|
||||
.raw-hex label { color: var(--muted); font-size: 11px; display: block; margin-bottom: 3px; }
|
||||
.raw-hex code { color: #7ee787; font-size: 11px; word-break: break-all; display: block; max-height: 80px; overflow-y: auto; }
|
||||
|
||||
/* ── Header decode grid ── */
|
||||
.hdr-grid { padding: 8px 16px 10px; display: grid; grid-template-columns: 140px 1fr; gap: 2px 14px; font-size: 12px; border-bottom: 1px solid var(--border); }
|
||||
.hdr-grid dt { color: var(--muted); }
|
||||
.hdr-grid dd { color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1><span>inv</span>raw — Growatt decoder</h1>
|
||||
<div class="stats">
|
||||
<span>Paquets : <b id="stat-count">0</b></span>
|
||||
<span>Octets : <b id="stat-bytes">0</b></span>
|
||||
<span>Dernier : <b id="stat-last">—</b></span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="btn-pause">⏸ Pause</button>
|
||||
<button id="btn-clear">🗑 Vider</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="filter-bar">
|
||||
<label><input type="checkbox" id="f-inv" checked> <span class="dir-inv">inverter→cloud</span></label>
|
||||
<label><input type="checkbox" id="f-cloud" checked> <span class="dir-cloud">cloud→inverter</span></label>
|
||||
<label><input type="checkbox" id="f-data" checked> Données seulement</label>
|
||||
</div>
|
||||
|
||||
<div id="packets">
|
||||
<div id="empty">En attente de paquets… L'onduleur doit pointer vers ce proxy sur le port 5279.</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let paused = false;
|
||||
let totalBytes = 0, packetCount = 0;
|
||||
|
||||
const listEl = document.getElementById("packets");
|
||||
const statCount = document.getElementById("stat-count");
|
||||
const statBytes = document.getElementById("stat-bytes");
|
||||
const statLast = document.getElementById("stat-last");
|
||||
|
||||
const CAT_ORDER = ["solar","battery","grid","output","temp","energy","status"];
|
||||
const CAT_LABELS = {
|
||||
solar:"Panneaux solaires", battery:"Batterie", grid:"Réseau",
|
||||
output:"Sortie", temp:"Température", energy:"Énergie", status:"Statut",
|
||||
};
|
||||
const STATUS_NAMES = {0:"En attente", 1:"Normal", 2:"Défaut"};
|
||||
|
||||
function esc(s) { return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">"); }
|
||||
function fmtBytes(n) {
|
||||
if (n<1024) return n+" B";
|
||||
if (n<1048576) return (n/1024).toFixed(1)+" KB";
|
||||
return (n/1048576).toFixed(2)+" MB";
|
||||
}
|
||||
|
||||
function buildDecodedHtml(dec) {
|
||||
if (!dec) return "";
|
||||
// group fields by category
|
||||
const cats = {};
|
||||
let invSerial = "", dataSerial = "", isBuffered = dec._buffered;
|
||||
for (const [key, val] of Object.entries(dec)) {
|
||||
if (key.startsWith("_")) continue;
|
||||
if (key === "pvserial") { invSerial = val.text || ""; continue; }
|
||||
if (key === "datalogserial") { dataSerial = val.text || ""; continue; }
|
||||
const cat = val.cat || "other";
|
||||
if (!cats[cat]) cats[cat] = [];
|
||||
cats[cat].push({key, ...val});
|
||||
}
|
||||
|
||||
let metaHtml = `<div class="decoded-meta">`;
|
||||
if (invSerial) metaHtml += `<span class="serial">Onduleur : <b>${esc(invSerial)}</b></span>`;
|
||||
if (dataSerial) metaHtml += `<span class="serial">Datalogger : <b>${esc(dataSerial)}</b></span>`;
|
||||
if (isBuffered) metaHtml += `<span class="buffered">⚡ Enregistrement tampon</span>`;
|
||||
metaHtml += `<span class="layout">${esc(dec._layout)}</span></div>`;
|
||||
|
||||
let gridHtml = `<div class="cat-grid">`;
|
||||
for (const cat of CAT_ORDER) {
|
||||
if (!cats[cat] || cats[cat].length === 0) continue;
|
||||
gridHtml += `<div class="cat-card cat-${cat}"><div class="cat-title">${CAT_LABELS[cat]||cat}</div>`;
|
||||
for (const f of cats[cat]) {
|
||||
let valClass = "";
|
||||
let displayVal = f.value;
|
||||
if (f.key === "pvstatus") {
|
||||
displayVal = (f.status_name || f.value) + ` (${f.value})`;
|
||||
valClass = f.value === 1 ? "val-ok" : f.value === 0 ? "" : "val-warn";
|
||||
} else if (typeof f.value === "number") {
|
||||
if (f.value === 0) valClass = "val-zero";
|
||||
else if (f.value < 0) valClass = "val-neg";
|
||||
else if (f.key.includes("fault") && f.value !== 0) valClass = "val-warn";
|
||||
}
|
||||
const noteHtml = f.note ? `<span class="field-note">${esc(f.note)}</span>` : "";
|
||||
gridHtml += `<div class="field-row">
|
||||
<span class="field-lbl">${esc(f.lbl)}</span>
|
||||
<span><span class="field-val ${valClass}">${esc(displayVal)}</span><span class="field-unit">${esc(f.unit||"")}</span></span>
|
||||
</div>${noteHtml}`;
|
||||
}
|
||||
gridHtml += `</div>`;
|
||||
}
|
||||
gridHtml += `</div>`;
|
||||
|
||||
return `<div class="decoded-section">
|
||||
<div class="decoded-title">Données décodées — SPF</div>
|
||||
${metaHtml}
|
||||
${gridHtml}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function buildPacketEl(p) {
|
||||
const hdr = p.header || {};
|
||||
const dec = p.decoded;
|
||||
const div = document.createElement("div");
|
||||
div.className = "packet";
|
||||
div.dataset.dir = p.direction;
|
||||
div.dataset.hasData = dec ? "1" : "0";
|
||||
|
||||
// Summary badges
|
||||
let extraBadges = "";
|
||||
if (dec) {
|
||||
const soc = dec.batterySoc;
|
||||
const ppv1 = dec.ppv1, ppv2 = dec.ppv2;
|
||||
const st = dec.pvstatus;
|
||||
if (st) {
|
||||
const cls = st.value===1?"badge-status-ok":st.value===0?"":"badge-status-err";
|
||||
extraBadges += `<span class="badge ${cls}">${esc(st.status_name||st.value)}</span>`;
|
||||
}
|
||||
if (soc) extraBadges += `<span class="badge badge-soc">🔋 ${soc.value}%</span>`;
|
||||
const totalPV = ((ppv1?.value||0)+(ppv2?.value||0));
|
||||
if (totalPV > 0) extraBadges += `<span class="badge badge-pv">☀️ ${totalPV.toFixed(1)}W</span>`;
|
||||
}
|
||||
|
||||
const dirCls = p.direction.startsWith("inverter") ? "dir-inv" : "dir-cloud";
|
||||
const dirLbl = p.direction.startsWith("inverter") ? "inverter → cloud" : "cloud → inverter";
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="packet-header">
|
||||
<span class="ts">${p.ts}</span>
|
||||
<span class="dir ${dirCls}">${dirLbl}</span>
|
||||
<span class="sz">${p.size} B</span>
|
||||
<span class="badge badge-proto">${esc(hdr.protocol_name||"?")}</span>
|
||||
<span class="badge badge-cmd">${esc(hdr.cmd_name||"?")}</span>
|
||||
${extraBadges}
|
||||
<span class="expand-icon">▼</span>
|
||||
</div>
|
||||
<div class="packet-body">
|
||||
${buildDecodedHtml(dec)}
|
||||
<dl class="hdr-grid">
|
||||
<dt>Device prefix</dt><dd>${hdr.device_prefix||"—"}</dd>
|
||||
<dt>Protocole</dt><dd>${hdr.protocol||"—"} — ${esc(hdr.protocol_name||"")}</dd>
|
||||
<dt>Type brut</dt><dd>0x${hdr.rec_type||"—"}</dd>
|
||||
<dt>Commande</dt><dd>0x${hdr.cmd||"—"} — ${esc(hdr.cmd_name||"")}</dd>
|
||||
<dt>Payload length</dt><dd>${hdr.payload_len!=null?hdr.payload_len+" B":"—"}</dd>
|
||||
<dt>Header hex</dt><dd>${hdr.raw_header||"—"}</dd>
|
||||
</dl>
|
||||
<div class="hex-section"><pre>${esc(p.hex_dump)}</pre></div>
|
||||
<div class="raw-hex"><label>Hex brut complet</label><code>${p.raw_hex}</code></div>
|
||||
</div>
|
||||
`;
|
||||
div.querySelector(".packet-header").addEventListener("click", () => {
|
||||
const body = div.querySelector(".packet-body");
|
||||
const icon = div.querySelector(".expand-icon");
|
||||
body.classList.toggle("open");
|
||||
icon.textContent = body.classList.contains("open") ? "▲" : "▼";
|
||||
});
|
||||
return div;
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
const dir = el.dataset.dir;
|
||||
const hasData = el.dataset.hasData === "1";
|
||||
const showInv = document.getElementById("f-inv").checked;
|
||||
const showCloud = document.getElementById("f-cloud").checked;
|
||||
const dataOnly = document.getElementById("f-data").checked;
|
||||
const dirOk = (dir.startsWith("inverter") && showInv) || (dir.startsWith("cloud") && showCloud);
|
||||
const dataOk = !dataOnly || hasData;
|
||||
return dirOk && dataOk;
|
||||
}
|
||||
|
||||
function addPacket(p) {
|
||||
totalBytes += p.size; packetCount++;
|
||||
statCount.textContent = packetCount;
|
||||
statBytes.textContent = fmtBytes(totalBytes);
|
||||
statLast.textContent = p.ts.split("T")[1];
|
||||
const emptyEl = document.getElementById("empty");
|
||||
if (emptyEl) emptyEl.remove();
|
||||
const el = buildPacketEl(p);
|
||||
if (!isVisible(el)) el.style.display = "none";
|
||||
listEl.insertBefore(el, listEl.firstChild);
|
||||
}
|
||||
|
||||
// SSE
|
||||
const evtSrc = new EventSource("/sse");
|
||||
evtSrc.onmessage = e => { if (!paused) addPacket(JSON.parse(e.data)); };
|
||||
|
||||
// Controls
|
||||
document.getElementById("btn-pause").addEventListener("click", function() {
|
||||
paused = !paused;
|
||||
this.textContent = paused ? "▶ Reprendre" : "⏸ Pause";
|
||||
this.classList.toggle("paused", paused);
|
||||
});
|
||||
document.getElementById("btn-clear").addEventListener("click", () => {
|
||||
fetch("/api/clear", {method:"POST"});
|
||||
listEl.innerHTML = '<div id="empty">Liste vidée.</div>';
|
||||
totalBytes = packetCount = 0;
|
||||
statCount.textContent = statBytes.textContent = "0";
|
||||
statLast.textContent = "—";
|
||||
});
|
||||
|
||||
// Filters
|
||||
["f-inv","f-cloud","f-data"].forEach(id => {
|
||||
document.getElementById(id).addEventListener("change", () => {
|
||||
document.querySelectorAll(".packet").forEach(el => {
|
||||
el.style.display = isVisible(el) ? "" : "none";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Initial load
|
||||
fetch("/api/packets").then(r=>r.json()).then(list => list.forEach(addPacket));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user