initial commit

This commit is contained in:
Vincent van der Wal
2025-09-06 16:11:19 +02:00
parent 7fbd789e21
commit 16ff3e7a47
17 changed files with 1952 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
declare module '*.css';
+190
View File
@@ -0,0 +1,190 @@
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include "LittleFS.h"
#include <ArduinoJson.h>
#include "max6675.h"
#include <ElegantOTA.h>
#include "secrets.h"
// Create AsyncWebServer object on port 80
AsyncWebServer server(80);
// Create a WebSocket object
AsyncWebSocket ws("/ws");
// Json Variable to Hold Sensor Readings
JsonDocument readings;
String jsonString;
// Timer variables
unsigned long lastTime = 0;
unsigned long timerDelay = 250;
int relay = 21;
int thermoDO = 19;
int thermoCS = 23;
int thermoCLK = 5;
MAX6675 thermocouple(thermoCLK, thermoCS, thermoDO);
int targetTemp = 300;
int overShoot = 10;
int underShoot = 5;
unsigned long lastSwitch = 0;
unsigned long switchDelay = 60000;
bool autoSwitch = true;
// Get Sensor Readings and return JSON object
String getSensorReadings()
{
readings["temperature"] = thermocouple.readCelsius();
if (digitalRead(relay) == HIGH)
{
readings["relais"] = 1;
}
else
{
readings["relais"] = 0;
}
serializeJson(readings, jsonString);
return jsonString;
}
// Initialize LittleFS
void initLittleFS()
{
if (!LittleFS.begin(true))
{
Serial.println("An error has occurred while mounting LittleFS");
}
Serial.println("LittleFS mounted successfully");
}
// Initialize WiFi
void initWiFi()
{
WiFi.mode(WIFI_STA);
WiFi.begin(ssid, password);
Serial.print("Connecting to WiFi ..");
while (WiFi.status() != WL_CONNECTED)
{
Serial.print('.');
delay(1000);
}
Serial.println(WiFi.localIP());
}
void notifyClients(String sensorReadings)
{
ws.textAll(sensorReadings);
}
void handleWebSocketMessage(void *arg, uint8_t *data, size_t len)
{
AwsFrameInfo *info = (AwsFrameInfo *)arg;
if (info->final && info->index == 0 && info->len == len && info->opcode == WS_TEXT)
{
data[len] = 0;
String message = (char *)data;
if (strcmp((char *)data, "getReadings") == 0)
{
String sensorReadings = getSensorReadings();
notifyClients(sensorReadings);
};
if (strcmp((char *)data, "switchRelais") == 0)
{
Serial.printf("\nSwitch Relais");
if (digitalRead(relay) == HIGH)
{
digitalWrite(relay, LOW);
lastSwitch = millis();
}
else
{
digitalWrite(relay, HIGH);
lastSwitch = millis();
}
}
if (message.startsWith("setTargetTemp"))
{
String target = message.substring(15, 18);
targetTemp = target.toInt();
Serial.printf("\nTarget temp set to ");
Serial.print(targetTemp);
Serial.printf("°C");
}
}
}
void onEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t len)
{
switch (type)
{
case WS_EVT_CONNECT:
Serial.printf("WebSocket client #%u connected from %s\n", client->id(), client->remoteIP().toString().c_str());
break;
case WS_EVT_DISCONNECT:
Serial.printf("WebSocket client #%u disconnected\n", client->id());
break;
case WS_EVT_DATA:
handleWebSocketMessage(arg, data, len);
break;
case WS_EVT_PONG:
case WS_EVT_ERROR:
break;
}
}
void initWebSocket()
{
ws.onEvent(onEvent);
server.addHandler(&ws);
}
void setup()
{
Serial.begin(9600);
pinMode(relay, OUTPUT);
initWiFi();
initLittleFS();
initWebSocket();
// Web Server Root URL
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request)
{ request->send(LittleFS, "/index.html", "text/html"); });
server.serveStatic("/", LittleFS, "/");
ElegantOTA.begin(&server);
// Start server
server.begin();
}
void loop()
{
if ((millis() - lastTime) > timerDelay)
{
String sensorReadings = getSensorReadings();
notifyClients(sensorReadings);
if (((millis() - lastSwitch) > switchDelay) && thermocouple.readCelsius() >= (targetTemp - overShoot)) // >= 285°C
{
digitalWrite(relay, LOW);
lastSwitch = millis();
}
if (((millis() - lastSwitch) > switchDelay) && thermocouple.readCelsius() < (targetTemp - underShoot)) // < 270°C
{
digitalWrite(relay, HIGH);
lastSwitch = millis();
}
lastTime = millis();
}
ws.cleanupClients();
ElegantOTA.loop();
}
+345
View File
@@ -0,0 +1,345 @@
import Highcharts, { type DashStyleValue } from 'highcharts/highstock'
import 'highcharts/modules/accessibility'
import './styles.css'
export const pad = (n: string | number, amount = 2) => {
if (amount == 2) {
return ('0' + n).slice(-2);
} else {
return ('00' + n).slice(-3);
}
};
let gateway = `ws://${window.location.hostname}/ws`;
let ws: WebSocket;
const now = new Date()
let container: HTMLElement | null = document.querySelector('#plot')
let temperatureData = [
[now.getTime(), 25]
]
let relais = 0
let relaisData = [
[now.getTime(), 0]
]
let temp = 25;
let targetTemp = 350
const oven = document.querySelector('#oven')
const temperature = document.querySelector('#temperature')
const relaisSwitch = document.querySelector('#relais_switch')
const relaisSwitchInput: HTMLInputElement | null = document.querySelector('#relais_switch_input')
const targetTempInput: HTMLInputElement | null = document.querySelector('#target_temperature')
let maxValues = 4 * 60 * 60 // 60 minutes (4 values/sec)
let hs: Highcharts.StockChart;
let hsOptions: Highcharts.Options
const getRelaisBands = () => {
let relaisBands = []
let active = false;
for (let r of relaisData) {
if (r[1] === 1 && !active) {
active = true
relaisBands.push({
from: r[0],
to: relaisData[relaisData.length - 1][0] + 1000,
color: 'rgba(255, 0, 0, 0.25)'
})
}
if (r[1] === 0 && active) {
relaisBands[relaisBands.length - 1]['to'] = r[0]
active = false
}
}
return relaisBands
}
const dashStyle: DashStyleValue = 'Dash'
const getTargetLine = () => {
return [
{ value: targetTemp, width: 3, dashStyle: dashStyle }
]
}
const createChart = () => {
hsOptions = {
chart: {
animation: {
duration: 250,
easing: 'linear'
},
},
credits: {
enabled: false
},
title: {
text: 'Temperature',
align: 'left',
},
rangeSelector: {
enabled: false,
inputDateFormat: '%H:%M:%S'
},
yAxis: {
title: {
text: '&deg;C'
},
opposite: false,
min: 0,
max: 600,
tickInterval: 100,
plotBands: [{
from: 0,
to: 250,
color: 'rgba(69, 171, 255, 0.34)'
}, {
from: 250,
to: 420,
color: 'rgba(0, 255, 60, 0.34)'
},
{
from: 420,
to: 530,
color: 'rgba(255, 162, 0, 0.4)'
},
{
from: 530,
to: 600,
color: 'rgba(255, 0, 0, 0.4)'
}],
plotLines: getTargetLine()
},
xAxis: {
type: 'datetime',
tickInterval: 30 * 1000, // 30 seconds
labels: {
rotation: -45,
formatter: (f) => {
let d = new Date(f.pos)
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds())
},
style: {
fontSize: '0.55em'
}
},
plotBands: getRelaisBands(),
},
legend: {
enabled: false,
},
tooltip: {
formatter: function () {
let d = new Date(this.x)
return [pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds()), `<b>${this.y?.toFixed(1)}&deg;C</b>`]
}
},
plotOptions: {
series: {
animation: false,
tooltip: {
valueDecimals: 2,
},
}
},
data: {
enablePolling: true,
dataRefreshRate: 0.5
},
series: [{
type: 'areaspline',
name: 'Temperature',
data: temperatureData
}],
responsive: {
rules: [{
condition: {
maxWidth: 1000
},
chartOptions: {
legend: {
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
}
}
}]
}
}
if (container && hsOptions)
hs = Highcharts.stockChart(container, hsOptions)
}
const onLoad = () => {
initWebSocket();
if (container) {
createChart()
relaisSwitch?.addEventListener('click', switchRelais)
targetTempInput?.addEventListener('change', changeTargetTemp)
if (import.meta.env.DEV) {
// spoof some test data
setInterval(() => {
if (temperatureData.length > maxValues) {
temperatureData.shift()
relaisData.shift()
}
let d = new Date()
if (temp < 150) {
temp = temp + Math.random() * 4 - 1
} else if (temp < 20) {
temp = temp + Math.random() * 4 - 1.3
} else if (temp < targetTemp) {
temp = temp + Math.random() * 4 - 1.5
} else if (temp >= targetTemp) {
temp = temp + Math.random() * 3 - 1.6
}
if (relais) {
temp = temp + 0.2
} else {
temp = temp - 0.1
}
if (temperature)
temperature.innerHTML = temp.toFixed(2);
temperatureData.push([d.getTime(), temp])
relaisData.push([d.getTime(), relais])
hs.xAxis[0].update({ plotBands: getRelaisBands() })
hs.yAxis[0].update({ plotLines: getTargetLine() })
hs.series[0].setData(temperatureData, true)
}, 250)
}
}
}
window.addEventListener('load', onLoad);
const getReadings = () => {
ws.send("getReadings");
}
const initWebSocket = () => {
if (!import.meta.env.DEV) {
console.log('Trying to open a WebSocket connection…');
ws = new WebSocket(gateway);
ws.onopen = onOpen;
ws.onclose = onClose;
ws.onmessage = onMessage;
}
}
// When websocket is established, call the getReadings() function
const onOpen = () => {
console.log('Connection opened');
getReadings();
}
const onClose = () => {
console.log('Connection closed');
setTimeout(initWebSocket, 2000);
}
// Function that receives the message from the ESP32 with the readings
const onMessage = (event: MessageEvent) => {
let myObj = JSON.parse(event.data);
let keys = Object.keys(myObj);
for (var i = 0; i < keys.length; i++) {
let key = keys[i];
let element = document.getElementById(key)
if (element) {
element.innerHTML = myObj[key].toFixed(2);
}
let d = new Date()
if (key === 'temperature') {
if (temperatureData.length > maxValues) {
temperatureData.shift()
}
temperatureData.push([d.getTime(), myObj[key]])
} else if (key === 'relais') {
if (relaisData.length > maxValues) {
relaisData.shift()
}
relaisData.push([d.getTime(), myObj[key]])
if (myObj[key]) {
oven?.classList.add('on')
relaisSwitch?.classList.add('on')
if (relaisSwitchInput)
relaisSwitchInput.checked = true
} else {
oven?.classList.remove('on')
relaisSwitch?.classList.remove('on')
if (relaisSwitchInput)
relaisSwitchInput.checked = false
}
}
hs.xAxis[0].update({ plotBands: getRelaisBands() })
hs.yAxis[0].update({ plotLines: getTargetLine() })
hs.series[0].setData(temperatureData, true)
}
}
const switchRelais = () => {
console.log('Send switch relais')
if (!import.meta.env.DEV) {
ws.send("switchRelais");
} else {
// spoof some test data
if (!relais) {
relais = 1
oven?.classList.add('on')
relaisSwitch?.classList.add('on')
if (relaisSwitchInput)
relaisSwitchInput.checked = true
} else {
relais = 0
oven?.classList.remove('on')
relaisSwitch?.classList.remove('on')
if (relaisSwitchInput)
relaisSwitchInput.checked = false
}
}
}
const changeTargetTemp = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value = Number(target?.value)
targetTemp = value
console.log('Changed target temp to ' + targetTemp)
if (!import.meta.env.DEV) {
ws.send("setTargetTemp: " + pad(targetTemp, 3));
}
}
+3
View File
@@ -0,0 +1,3 @@
// Replace with your network credentials
const char *ssid = "WIFI_SSID";
const char *password = "WIFI_PWD";
+204
View File
@@ -0,0 +1,204 @@
html,
body {
font-family:
ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
margin: 0
}
p {
margin: 0;
}
.nav {
background-color: black;
color: white;
width: full;
text-align: center;
padding: 1em;
}
.nav-title {
font-size: xx-large;
}
.content {
max-width: 1000px;
margin: auto;
}
.card-grid {
margin-top: 15px;
display: flex;
justify-content: center;
align-items: center;
gap: 2em;
}
.graph {
height: 310px;
overflow: hidden;
display: flex;
justify-content: center;
}
.plot-wrapper {
height: 400px;
}
.card {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-width: 25%;
padding: 1em;
min-height: 120px;
box-shadow: 2px 2px 12px 1px rgba(140, 140, 140, .5);
border-radius: 10px;
}
.reading {
font-weight: bold;
margin-top: 18px;
}
@property --ovenColor1 {
syntax: '<color>';
initial-value: rgba(255, 0, 0, 0);
inherits: false;
}
@property --ovenColor2 {
syntax: '<color>';
initial-value: rgba(255, 0, 0, 0);
inherits: false;
}
.oven {
display: flex;
justify-content: center;
margin: auto;
}
.oven-wrapper {
background: radial-gradient(var(--ovenColor1), var(--ovenColor2), rgba(255, 0, 0, 0), rgba(255, 0, 0, 0));
width: 50%;
display: flex;
justify-content: center;
overflow: visible;
padding: 30px;
transition: --ovenColor1 3s, --ovenColor2 3s;
}
.oven.on .oven-wrapper {
--ovenColor1: rgb(255, 60, 0);
--ovenColor2: rgba(255, 60, 0, 0.4);
}
.oven .oven-img-wrapper {
width: 65%;
}
.oven img {
width: 100%;
}
.relais {
cursor: pointer;
}
.onoffswitch {
pointer-events: none;
margin-top: 10px;
position: relative;
width: 90px;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.onoffswitch-checkbox {
position: absolute;
opacity: 0;
pointer-events: none;
}
.onoffswitch-label {
display: block;
overflow: hidden;
cursor: pointer;
border: 2px solid #999999;
border-radius: 20px;
}
.onoffswitch-inner {
display: block;
width: 200%;
margin-left: -100%;
transition: margin 0.3s ease-in 0s;
}
.onoffswitch-inner:before,
.onoffswitch-inner:after {
display: block;
float: left;
width: 50%;
height: 30px;
padding: 0;
line-height: 30px;
font-size: 14px;
color: white;
font-family: Trebuchet, Arial, sans-serif;
font-weight: bold;
box-sizing: border-box;
}
.onoffswitch-inner:before {
content: "ON";
padding-left: 10px;
background-color: #C43535;
color: #FFFFFF;
}
.onoffswitch-inner:after {
content: "OFF";
padding-right: 10px;
background-color: #EEEEEE;
color: #999999;
text-align: right;
}
.onoffswitch-switch {
display: block;
width: 18px;
margin: 6px;
background: #FFFFFF;
position: absolute;
top: 0;
bottom: 0;
right: 56px;
border: 2px solid #999999;
border-radius: 20px;
transition: all 0.3s ease-in 0s;
}
.onoffswitch-checkbox:checked+.onoffswitch-label .onoffswitch-inner {
margin-left: 0;
}
.onoffswitch-checkbox:checked+.onoffswitch-label .onoffswitch-switch {
right: 0px;
}
.settings {
margin-top: 50px;
}
.settings input {
max-width: 50px;
font-size: larger;
font-weight: bold;
}
+7
View File
@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly DEV: boolean;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}