initial commit

This commit is contained in:
Vincent van der Wal
2025-09-06 16:11:19 +02:00
parent 3bbde1ed96
commit d6ce2858e8
17 changed files with 1952 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.pio
.vscode
node_modules
src/secrets.h
data
+2
View File
@@ -0,0 +1,2 @@
# CrustCraft
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<title>CrustCraft</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/ico" href="favicon.ico">
</head>
<body>
<div class="nav">
<div class="nav-title">CrustCraft</div>
</div>
<div class="content">
<div class="card-grid">
<div class="card">
<p class="card-title">
Temperature</p>
<p class="reading"><span id="temperature"></span> &deg;C</p>
</div>
<div class="card relais" id="relais_switch">
<p>Relais</p>
<div class="onoffswitch">
<input type="checkbox" name="onoffswitch" class="onoffswitch-checkbox" id="relais_switch_input"
tabindex="0">
<label class="onoffswitch-label" for="relais_switch_input">
<span class="onoffswitch-inner"></span>
<span class="onoffswitch-switch"></span>
</label>
</div>
</div>
</div>
<div class="oven" id="oven">
<div class="oven-wrapper">
<div class="oven-img-wrapper">
<img src="/oven.png">
</div>
</div>
</div>
<div class="graph">
<div class="plot-wrapper">
<div id="plot"></div>
</div>
</div>
<div class="card-grid settings">
<div class="card">
<p class="card-title">
Target temperature</p>
<p class="reading"><input type="number" min="0" max="600" placeholder="350" id="target_temperature">
</input> <label for="target_temperature">
&deg;C
</p>
</label>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1049
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
{
"dependencies": {
"highcharts": "^12.3.0",
"typescript": "^5.9.2"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"vite": "^7.1.3"
}
}
+29
View File
@@ -0,0 +1,29 @@
; PlatformIO Project Configuration File
;
; Build options: build flags, source filter
; Upload options: custom upload port, speed and extra flags
; Library options: dependencies, extra library storages
; Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html
[env:esp32doit-devkit-v1]
platform = espressif32
board = esp32doit-devkit-v1
framework = arduino
lib_compat_mode = strict
lib_ldf_mode = chain
lib_deps =
adafruit/MAX6675 library@^1.1.2
bblanchon/ArduinoJson@^7.4.2
ayushsharma82/ElegantOTA@^3.1.7
ESP32Async/AsyncTCP
ESP32Async/ESPAsyncWebServer
board_build.filesystem = littlefs
build_flags =
-D ELEGANTOTA_USE_ASYNC_WEBSERVER=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

+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;
}
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'data',
chunkSizeWarningLimit: 1000
}
});