- Add optional microSD CSV logging per sender/day on receiver - Wire logger into receiver packet handling - Document new batch header fields, build envs, and SD logging - Make sender links open in a new tab
261 lines
9.4 KiB
C++
261 lines
9.4 KiB
C++
#include "web_server.h"
|
|
#include <WebServer.h>
|
|
#include "wifi_manager.h"
|
|
#include "config.h"
|
|
|
|
static WebServer server(80);
|
|
static const SenderStatus *g_statuses = nullptr;
|
|
static uint8_t g_status_count = 0;
|
|
static WifiMqttConfig g_config;
|
|
static bool g_is_ap = false;
|
|
static const FaultCounters *g_sender_faults = nullptr;
|
|
static const FaultType *g_sender_last_errors = nullptr;
|
|
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
|
|
static uint8_t g_last_batch_count[NUM_SENDERS] = {};
|
|
|
|
static String html_header(const String &title) {
|
|
String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
|
|
h += "<title>" + title + "</title></head><body>";
|
|
h += "<h2>" + title + "</h2>";
|
|
return h;
|
|
}
|
|
|
|
static String html_footer() {
|
|
return "</body></html>";
|
|
}
|
|
|
|
static String format_faults(uint8_t idx) {
|
|
if (!g_sender_faults || !g_sender_last_errors || idx >= g_status_count) {
|
|
return "";
|
|
}
|
|
String s;
|
|
s += " faults m:";
|
|
s += String(g_sender_faults[idx].meter_read_fail);
|
|
s += " d:";
|
|
s += String(g_sender_faults[idx].decode_fail);
|
|
s += " tx:";
|
|
s += String(g_sender_faults[idx].lora_tx_fail);
|
|
s += " last:";
|
|
s += String(static_cast<uint8_t>(g_sender_last_errors[idx]));
|
|
return s;
|
|
}
|
|
|
|
static String render_sender_block(const SenderStatus &status) {
|
|
String s;
|
|
s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
|
|
uint8_t idx = 0;
|
|
if (g_statuses) {
|
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
|
if (&g_statuses[i] == &status) {
|
|
idx = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
String device_id = status.last_data.device_id;
|
|
s += "<strong><a href='/sender/" + device_id + "' target='_blank' rel='noopener noreferrer'>" + device_id + "</a></strong>";
|
|
if (status.has_data && status.last_data.link_valid) {
|
|
s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
|
|
}
|
|
if (status.has_data) {
|
|
s += " err_tx:" + String(status.last_data.err_lora_tx);
|
|
s += " err_last:" + String(static_cast<uint8_t>(status.last_data.last_error));
|
|
}
|
|
s += format_faults(idx);
|
|
s += "<br>";
|
|
if (!status.has_data) {
|
|
s += "No data";
|
|
} else {
|
|
s += "Energy: " + String(status.last_data.energy_total_kwh, 3) + " kWh<br>";
|
|
s += "Power: " + String(status.last_data.total_power_w, 1) + " W<br>";
|
|
s += "P1/P2/P3: " + String(status.last_data.phase_power_w[0], 1) + " / " + String(status.last_data.phase_power_w[1], 1) +
|
|
" / " + String(status.last_data.phase_power_w[2], 1) + " W<br>";
|
|
s += "Battery: " + String(status.last_data.battery_percent) + "% (" + String(status.last_data.battery_voltage_v, 2) + " V)";
|
|
}
|
|
s += "</div>";
|
|
return s;
|
|
}
|
|
|
|
static void handle_root() {
|
|
String html = html_header("DD3 Bridge Status");
|
|
html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";
|
|
|
|
if (g_statuses) {
|
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
|
html += render_sender_block(g_statuses[i]);
|
|
}
|
|
}
|
|
|
|
html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
|
|
html += "<p><a href='/manual'>Manual</a></p>";
|
|
html += html_footer();
|
|
server.send(200, "text/html", html);
|
|
}
|
|
|
|
static void handle_wifi_get() {
|
|
String html = html_header("WiFi/MQTT Config");
|
|
html += "<form method='POST' action='/wifi'>";
|
|
html += "SSID: <input name='ssid' value='" + g_config.ssid + "'><br>";
|
|
html += "Password: <input name='pass' type='password' value='" + g_config.password + "'><br>";
|
|
html += "MQTT Host: <input name='mqhost' value='" + g_config.mqtt_host + "'><br>";
|
|
html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
|
|
html += "MQTT User: <input name='mquser' value='" + g_config.mqtt_user + "'><br>";
|
|
html += "MQTT Pass: <input name='mqpass' type='password' value='" + g_config.mqtt_pass + "'><br>";
|
|
html += "NTP Server 1: <input name='ntp1' value='" + g_config.ntp_server_1 + "'><br>";
|
|
html += "NTP Server 2: <input name='ntp2' value='" + g_config.ntp_server_2 + "'><br>";
|
|
html += "<button type='submit'>Save</button>";
|
|
html += "</form>";
|
|
html += html_footer();
|
|
server.send(200, "text/html", html);
|
|
}
|
|
|
|
static void handle_wifi_post() {
|
|
WifiMqttConfig cfg;
|
|
cfg.ntp_server_1 = "pool.ntp.org";
|
|
cfg.ntp_server_2 = "time.nist.gov";
|
|
cfg.ssid = server.arg("ssid");
|
|
cfg.password = server.arg("pass");
|
|
cfg.mqtt_host = server.arg("mqhost");
|
|
cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
|
|
cfg.mqtt_user = server.arg("mquser");
|
|
cfg.mqtt_pass = server.arg("mqpass");
|
|
if (server.arg("ntp1").length() > 0) {
|
|
cfg.ntp_server_1 = server.arg("ntp1");
|
|
}
|
|
if (server.arg("ntp2").length() > 0) {
|
|
cfg.ntp_server_2 = server.arg("ntp2");
|
|
}
|
|
cfg.valid = true;
|
|
wifi_save_config(cfg);
|
|
server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
|
|
delay(1000);
|
|
ESP.restart();
|
|
}
|
|
|
|
static void handle_sender() {
|
|
if (!g_statuses) {
|
|
server.send(404, "text/plain", "No senders");
|
|
return;
|
|
}
|
|
String uri = server.uri();
|
|
String device_id = uri.substring(String("/sender/").length());
|
|
for (uint8_t i = 0; i < g_status_count; ++i) {
|
|
if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
|
|
String html = html_header("Sender " + device_id);
|
|
html += render_sender_block(g_statuses[i]);
|
|
if (g_last_batch_count[i] > 0) {
|
|
html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>";
|
|
html += "<table border='1' cellspacing='0' cellpadding='3'>";
|
|
html += "<tr><th>#</th><th>ts</th><th>e_kwh</th><th>p_w</th><th>p1_w</th><th>p2_w</th><th>p3_w</th>";
|
|
html += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th></tr>";
|
|
for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) {
|
|
const MeterData &d = g_last_batch[i][r];
|
|
html += "<tr>";
|
|
html += "<td>" + String(r) + "</td>";
|
|
html += "<td>" + String(d.ts_utc) + "</td>";
|
|
html += "<td>" + String(d.energy_total_kwh, 3) + "</td>";
|
|
html += "<td>" + String(d.total_power_w, 1) + "</td>";
|
|
html += "<td>" + String(d.phase_power_w[0], 1) + "</td>";
|
|
html += "<td>" + String(d.phase_power_w[1], 1) + "</td>";
|
|
html += "<td>" + String(d.phase_power_w[2], 1) + "</td>";
|
|
html += "<td>" + String(d.battery_voltage_v, 2) + "</td>";
|
|
html += "<td>" + String(d.battery_percent) + "</td>";
|
|
html += "<td>" + String(d.link_rssi_dbm) + "</td>";
|
|
html += "<td>" + String(d.link_snr_db, 1) + "</td>";
|
|
html += "<td>" + String(d.err_lora_tx) + "</td>";
|
|
html += "<td>" + String(static_cast<uint8_t>(d.last_error)) + "</td>";
|
|
html += "</tr>";
|
|
}
|
|
html += "</table>";
|
|
}
|
|
html += html_footer();
|
|
server.send(200, "text/html", html);
|
|
return;
|
|
}
|
|
}
|
|
server.send(404, "text/plain", "Not found");
|
|
}
|
|
|
|
static void handle_manual() {
|
|
String html = html_header("DD3 Manual");
|
|
html += "<ul>";
|
|
html += "<li>Energy: total kWh since meter start.</li>";
|
|
html += "<li>Power: total active power in W.</li>";
|
|
html += "<li>P1/P2/P3: phase power in W.</li>";
|
|
html += "<li>Battery: percent with voltage in V.</li>";
|
|
html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>";
|
|
html += "<li>err_tx: sender-side LoRa TX error counter.</li>";
|
|
html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx).</li>";
|
|
html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>";
|
|
html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>";
|
|
html += "</ul>";
|
|
html += html_footer();
|
|
server.send(200, "text/html", html);
|
|
}
|
|
|
|
void web_server_set_config(const WifiMqttConfig &config) {
|
|
g_config = config;
|
|
}
|
|
|
|
void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {
|
|
g_sender_faults = faults;
|
|
g_sender_last_errors = last_errors;
|
|
}
|
|
|
|
void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count) {
|
|
if (!samples || sender_index >= NUM_SENDERS) {
|
|
return;
|
|
}
|
|
if (count > METER_BATCH_MAX_SAMPLES) {
|
|
count = METER_BATCH_MAX_SAMPLES;
|
|
}
|
|
g_last_batch_count[sender_index] = static_cast<uint8_t>(count);
|
|
for (size_t i = 0; i < count; ++i) {
|
|
g_last_batch[sender_index][i] = samples[i];
|
|
}
|
|
}
|
|
|
|
void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) {
|
|
g_statuses = statuses;
|
|
g_status_count = count;
|
|
g_is_ap = true;
|
|
|
|
server.on("/", handle_root);
|
|
server.on("/manual", handle_manual);
|
|
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
|
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
|
server.on("/sender/", handle_sender);
|
|
server.onNotFound([]() {
|
|
if (server.uri().startsWith("/sender/")) {
|
|
handle_sender();
|
|
return;
|
|
}
|
|
server.send(404, "text/plain", "Not found");
|
|
});
|
|
server.begin();
|
|
}
|
|
|
|
void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) {
|
|
g_statuses = statuses;
|
|
g_status_count = count;
|
|
g_is_ap = false;
|
|
|
|
server.on("/", handle_root);
|
|
server.on("/manual", handle_manual);
|
|
server.on("/sender/", handle_sender);
|
|
server.on("/wifi", HTTP_GET, handle_wifi_get);
|
|
server.on("/wifi", HTTP_POST, handle_wifi_post);
|
|
server.onNotFound([]() {
|
|
if (server.uri().startsWith("/sender/")) {
|
|
handle_sender();
|
|
return;
|
|
}
|
|
server.send(404, "text/plain", "Not found");
|
|
});
|
|
server.begin();
|
|
}
|
|
|
|
void web_server_loop() {
|
|
server.handleClient();
|
|
}
|