add software

This commit is contained in:
dustinbrun
2026-07-05 10:48:19 +02:00
parent 79f5830bbc
commit 7b6edc542d
6 changed files with 672 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
#include <ModbusRTUMaster.h> // https://github.com/CMB27/ModbusRTUMaster/
#include <Wire.h>
#include <Adafruit_GFX.h> // https://github.com/adafruit/Adafruit-GFX-Library
#include <Adafruit_SSD1306.h> // https://github.com/adafruit/Adafruit_SSD1306
#include <Arduino.h>
#include <math.h>
#include <WiFi.h>
#include "ESPAsyncWebServer.h"
#include <Arduino_JSON.h> // http://github.com/arduino-libraries/Arduino_JSON
#include "LittleFS.h"
const char* ssid = "PWR_METER";
const char* password = "123456";
// Configure Modbus device to 9600 Baud, SERIAL_8N1
uint8_t unitId = 8; // Modubus device address
// Define RS485 pins
#define TX_PIN 5 // RS485 TX
#define RX_PIN 6 // RS485 RX
#define DE_PIN 8 // Driver Enable (tie RE & DE together)
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 32
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
// --- Configure Modbus Interface ---
// -- Code valid for usage with Arduino Nano ---
// #include <SoftwareSerial.h>
// SoftwareSerial mySerial(RX_PIN, TX_PIN);
// #define MODBUS_SERIAL mySerial
// --- Code valid for usage with ESP32
#include <HardwareSerial.h>
HardwareSerial MySerial1(1); // Define Serial device mapped to the on of the internal UARTs
#define MODBUS_SERIAL MySerial1
ModbusRTUMaster modbus(MODBUS_SERIAL, DE_PIN);
// Webserver
AsyncWebServer server(80);
JSONVar readings_json;
// --- Configure Peak Hold ---
const int peakHoldTime = 500; // Peak hold duration (ms)
// --- Configure low pass filter ---
float cutoff_freq = 0.1; // Cutoff frequency in Hz
float dt = 0.1; // Sampling interval in seconds
// Other variable definitions
float filter_y_prev = 0.0;
float RC = 1.0 / (2.0 * M_PI * cutoff_freq);
float filter_alpha = dt / (RC + dt);
uint8_t error = 0;
uint16_t value[6]; // Buffer for multiple 16-bit registers
uint16_t address = 0x2000;
float peakValue = 0;
int peakIndex = 0; // Stores the highest LED level
unsigned long peakTime = 0; // Stores when peak LED was last updated
int dt_delay = dt * 1000;
const uint8_t numErrorStrings = 12;
const char* errorStrings[numErrorStrings] = {
"Response timeout",
"Frame error in response",
"CRC error in response",
"Unknown communication error",
"Unexpected unit ID in response",
"Exception response ",
"Unexpected function code in response",
"Unexpected response length",
"Unexpected byte count in response",
"Unexpected data address in response",
"Unexpected value in response",
"Unexpected quantity in response"
};
const uint8_t numExceptionResponseStrings = 4;
const char* exceptionResponseStrings[] = { "illegal function", "illegal data address", "illegal data value", "server device failure" };
union {
uint16_t words[2];
float value;
} U_V;
union {
uint16_t words[2];
float value;
} I_A;
union {
uint16_t words[2];
float value;
} P_W;
// Get Sensor Readings and return JSON object
String format_readings_json() {
readings_json["P_W"] = String(P_W.value, 1);
readings_json["U_V"] = String(U_V.value, 1);
readings_json["I_A"] = String(I_A.value, 3);
String jsonString = JSON.stringify(readings_json);
return jsonString;
}
void setup() {
Wire.begin(2, 3);
Serial.begin(115200);
Serial.println("\n\nModbusRTUMasterProbe");
delay(1000);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) { // Address 0x3D for 128x64
Serial.println(F("SSD1306 allocation failed"));
for (;;)
; // Don't proceed, loop forever
}
Serial.println(F("SSD1306 allocation OK"));
if (!LittleFS.begin(true)) {
Serial.println("An error has occurred while mounting LittleFS");
}
Serial.println("LittleFS mounted successfully");
MODBUS_SERIAL.begin(9600, SERIAL_8N1, RX_PIN, TX_PIN);
modbus.begin(9600); // SERIAL_8N1
// Setup Wifi
WiFi.softAP(ssid); //, password);
Serial.print("AP IP address: ");
Serial.println(WiFi.softAPIP());
// ----------- Spalsh Screen ---------------
display.clearDisplay();
display.setRotation(2);
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
display.setCursor(0, 0);
display.println("Powermeter");
display.setTextSize(1);
display.setCursor(0, 10);
display.println("by dustinbrun, 2026");
display.setCursor(0, 20);
display.println(WiFi.softAPIP());
display.display();
delay(2000);
display.clearDisplay();
// main webpage handler
server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(LittleFS, "/index.html", "text/html");
});
server.serveStatic("/", LittleFS, "/");
// readings webpage handler
server.on("/readings", HTTP_GET, [](AsyncWebServerRequest* request) {
request->send(200, "application/json", format_readings_json());
});
server.begin();
}
void update_display() {
display.clearDisplay();
display.setRotation(2);
display.setTextSize(2);
display.setTextColor(SSD1306_WHITE);
// Power value
display.setCursor(10, 0);
if (P_W.value < 10)
display.print("000");
else if (P_W.value < 100)
display.print("00");
else if (P_W.value < 1000)
display.print("0");
display.print(P_W.value, 1);
display.setCursor(95, 0);
display.print("W");
// Voltage value
display.setCursor(10, 15);
if (U_V.value < 10)
display.print("0");
else if (U_V.value < 10)
display.print("00");
display.print(U_V.value, 1);
display.setCursor(95, 15);
display.print("V");
// Current value
display.setRotation(3);
display.setTextSize(1);
display.setCursor(0, 0);
display.print(I_A.value, 2);
// error code (if possible)
if (error) {
display.setTextSize(1);
display.setCursor(115, 0);
display.print("ERR");
display.print(error);
}
//display.setRotation(0);
display.setRotation(2);
// Bar graph display
// Low pass filtering of the bar graph scale maximum value
float new_target = P_W.value * 2; // Target is double of the current value -> stable power value should end up as a line filling half of the screen width
filter_y_prev = filter_alpha * new_target + (1 - filter_alpha) * filter_y_prev;
// Serial.print(new_target);
// Serial.print("\t");
// Serial.println(filter_y_prev);
// map value to screen width
int percent = map(P_W.value, 0, int(filter_y_prev), 0, SCREEN_WIDTH);
display.fillRect(1, 29, percent, 3, SSD1306_WHITE); // x Top left corner x coordinate, y Top left corner y coordinate, w Width in pixels, h Height in pixels
// Peak hold and decay
if (P_W.value > peakValue) {
peakValue = P_W.value;
peakTime = millis();
} else if (millis() - peakTime > peakHoldTime) {
peakValue = max(peakValue - 1, P_W.value);
peakTime = millis();
}
// Draw peak value on bar graph display
peakIndex = peakValue * SCREEN_WIDTH / filter_y_prev; // map peak value to current index on display
if (peakIndex > SCREEN_WIDTH-2)
peakIndex = SCREEN_WIDTH-2;
display.fillRect(peakIndex, 30, 2, 1, SSD1306_WHITE);
display.fillRect(peakIndex+2, 29, 2, 3, SSD1306_WHITE);
display.display();
}
void loop() {
// Query modbus device registers
error = modbus.readHoldingRegisters(unitId, address, value, 6);
if (!error) {
// 0x2000 = Voltage [V]
U_V.words[0] = value[1];
U_V.words[1] = value[0];
// 0x2002 = Current [A]
I_A.words[0] = value[3];
I_A.words[1] = value[2];
// 0x2004 = active Power [kW]
P_W.words[0] = value[5];
P_W.words[1] = value[4];
P_W.value *= 1000; // convert to [W]
// Debug output to console
Serial.print(U_V.value, 1);
Serial.print("\t");
Serial.print(I_A.value, 3);
Serial.print("\t");
Serial.print(P_W.value, 1);
Serial.println();
} else {
Serial.print("Error: ");
if (error >= 4 && error < (numErrorStrings + 4)) {
Serial.print(errorStrings[error - 4]);
if (error == MODBUS_RTU_MASTER_EXCEPTION_RESPONSE) {
uint8_t exceptionResponse = modbus.getExceptionResponse();
Serial.print(exceptionResponse);
if (exceptionResponse >= 1 && exceptionResponse <= numExceptionResponseStrings) {
Serial.print(" - ");
Serial.print(exceptionResponseStrings[exceptionResponse - 1]);
}
}
} else Serial.print("Unknown error");
Serial.println();
}
update_display();
delay(dt_delay);
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+70
View File
@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html>
<head>
<title>Powermeter Dashboard</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="icon" type="image/png" href="icon.png">
<script src="chart.js"></script>
</head>
<body>
<div class="topnav">
<h1>Powermeter Dashboard</h1>
</div>
<div class="content">
<div class="card-grid">
<div class="card">
<p class="card-title">Power</p>
<p class="reading"><span id="P_W"></span> W</p>
</div>
<div class="card">
<p class="card-title">Current</p>
<p class="reading"><span id="I_A"></span> A</p>
</div>
<div class="card">
<p class="card-title">Voltage</p>
<p class="reading"><span id="U_V"></span> V</p>
</div>
<div class="card">
<p class="card-title">Configuration</p>
<p>Update Interval: <input type="number" id="interval" min="1" value="500"></p>
<p>Plot Diagrams?: <input type="checkbox" id="plot_diagrams" name="plot_diagrams" checked></p>
<p>Diagram max. Number of Values: <input type="number" id="num_values" min="1" value="100"></p>
<button onclick="button_export()">Export Data</button>
<button onclick="button_clear()">Clear Diagrams</button>
<button onclick="button_apply()">Apply</button>
<p id="output_last_update"></p>
<p id="output_online"></p>
</div>
</div>
</div>
<div class="content">
<div class="card-grid_graph">
<div class="card">
<p class="card-title">Power Graph</p>
<canvas id="graph_P_W" width="8" height="6"></canvas>
</div>
<div class="card">
<p class="card-title">Current Graph</p>
<canvas id="graph_I_A" width="8" height="6"></canvas>
</div>
<div class="card">
<p class="card-title">Voltage Graph</p>
<canvas id="graph_U_V" width="8" height="6"></canvas>
</div>
</div>
</div>
<script src="updater_script.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
html {
font-family: Arial, Helvetica, sans-serif;
display: inline-block;
text-align: center;
}
h1 {
font-size: 1.8rem;
color: white;
}
.topnav {
overflow: hidden;
background-color: #0A1128;
}
body {
margin: 0;
}
.content {
padding: 50px;
}
.card-grid {
max-width: 800px;
margin: 0 auto;
display: grid;
grid-gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.card-grid_graph {
max-width: 2000px;
margin: 0 auto;
display: grid;
grid-gap: 2rem;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.card {
background-color: white;
box-shadow: 2px 2px 12px 1px rgba(140, 140, 140, .5);
}
.card-title {
font-size: 1.2rem;
font-weight: bold;
color: #034078
}
.reading {
font-size: 1.2rem;
color: #1282A2;
}
+226
View File
@@ -0,0 +1,226 @@
window.addEventListener('load', button_apply); // Virtually press apply button when webpage loads the first time
var myObj = 0;
var was_error;
function get_readings() {
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function () {
if (this.readyState == 4) { // request finished, response ready
if (this.status == 200) {
myObj = JSON.parse(this.responseText);
was_error = false;
console.log("Received JSON");
console.log(myObj);
}
else {
was_error = true;
console.log("JSON response error or timeout");
}
}
};
xhr.open("GET", "/readings", true);
xhr.timeout = 500;
xhr.send();
}
function update_values() {
get_readings();
if (was_error == true) {
document.getElementById("output_online").innerText = "Offline";
document.getElementById("output_online").style.color = "#FF0000";
document.getElementById('P_W').innerHTML = "nan";
document.getElementById('I_A').innerHTML = "nan";
document.getElementById('U_V').innerHTML = "nan";
}
else {
document.getElementById("output_online").innerText = "Online";
document.getElementById("output_online").style.color = "#008000";
document.getElementById('P_W').innerHTML = myObj.P_W;
document.getElementById('I_A').innerHTML = myObj.I_A;
document.getElementById('U_V').innerHTML = myObj.U_V;
var timestamp = new Date().toLocaleTimeString();
var plot_diagrams = document.getElementById("plot_diagrams");
if (plot_diagrams.checked == true) {
chart_P_W.data.datasets[0].data.push(myObj.P_W);
chart_I_A.data.datasets[0].data.push(myObj.I_A);
chart_U_V.data.datasets[0].data.push(myObj.U_V);
chart_P_W.data.labels.push(timestamp);
chart_I_A.data.labels.push(timestamp);
chart_U_V.data.labels.push(timestamp);
if (chart_P_W.data.labels.length >= MAX_DATA_SET_LENGTH) {
chart_P_W.data.datasets[0].data.shift();
chart_I_A.data.datasets[0].data.shift();
chart_U_V.data.datasets[0].data.shift();
chart_P_W.data.labels.shift();
chart_I_A.data.labels.shift();
chart_U_V.data.labels.shift();
}
chart_P_W.update();
chart_I_A.update();
chart_U_V.update();
}
}
document.getElementById("output_last_update").innerText = "Last update: " + new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: "3" });
}
let intervalId;
function button_apply() {
let mseconds = parseInt(document.getElementById("interval").value);
if (isNaN(mseconds) || mseconds < 100) {
alert("Please enter a valid number 100ms or larger");
return;
}
clearInterval(intervalId);
intervalId = setInterval(update_values, mseconds);
MAX_DATA_SET_LENGTH = parseInt(document.getElementById("num_values").value);
}
function button_clear() {
chart_P_W.data.datasets[0].data = [];
chart_I_A.data.datasets[0].data = [];
chart_U_V.data.datasets[0].data = [];
chart_P_W.data.labels = [];
chart_I_A.data.labels = [];
chart_U_V.data.labels = [];
chart_P_W.update();
chart_I_A.update();
chart_U_V.update();
}
var MAX_DATA_SET_LENGTH = 100;
let data_P_W = [];
let labels_P_W = [];
let labels_I_A = [];
let labels_U_V = [];
let data_I_A = [];
let data_U_V = [];
var options = {
scales: {
yAxes: [{
type: 'linear',
ticks: {
maxTicksLimit: 5,
},
}],
xAxes: [{
type: 'time',
ticks: {
maxTicksLimit: 3,
},
time: {
unit: 'second',
displayFormats: {
'second': 'HH:mm:ss',
},
tooltipFormat: 'HH:mm:ss',
}
}]
},
scales: {
x: { title: { display: true, text: "Time" } },
y: { title: { display: false, text: "Value" } }
},
animation: {
duration: 50,
},
pointRadius: 2,
responsive: true,
showLines: true
};
const graph_P_W = document.getElementById("graph_P_W").getContext("2d");
const chart_P_W = new Chart(graph_P_W, {
type: "line",
data: {
labels: labels_P_W,
datasets: [{
label: "Power [W]",
data: data_P_W,
borderColor: "blue",
borderWidth: 2,
fill: false,
}]
},
options: options
});
const graph_I_A = document.getElementById("graph_I_A").getContext("2d");
const chart_I_A = new Chart(graph_I_A, {
type: "line",
data: {
labels: labels_I_A,
datasets: [{
label: "Current [A]",
data: data_I_A,
borderColor: "red",
borderWidth: 2,
fill: false,
}]
},
options: options
});
const graph_U_V = document.getElementById("graph_U_V").getContext("2d");
const chart_U_V = new Chart(graph_U_V, {
type: "line",
data: {
labels: labels_U_V,
datasets: [{
label: "Voltage [V]",
data: data_U_V,
borderColor: "green",
borderWidth: 2,
fill: false,
}]
},
options: options
});
function exportToCSV(filename, data) {
}
function button_export() {
const csvContent = chart_P_W.data.labels + "\n" +
chart_P_W.data.datasets[0].data + "\n" +
chart_I_A.data.datasets[0].data + "\n" +
chart_U_V.data.datasets[0].data;
const blob = new Blob([csvContent], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "data.csv.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}