switch to svelte and chart.js

This commit is contained in:
Vincent van der Wal
2025-09-11 21:09:23 +02:00
parent e4f93be75e
commit e0fda01f7a
27 changed files with 8000 additions and 1335 deletions
+27 -5
View File
@@ -1,8 +1,30 @@
.pio
.vscode
node_modules
src/secrets.h
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
/data
data
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
],
"tailwindStylesheet": "./src/app.css"
}
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack"
]
}
+40
View File
@@ -0,0 +1,40 @@
import prettier from 'eslint-config-prettier';
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
}
}
);
-48
View File
@@ -8,55 +8,7 @@
</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">
Sensor 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_small.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="300" id="target_temperature">
</input> <label for="target_temperature">
&deg;C
</p>
</label>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
+3338 -8
View File
File diff suppressed because it is too large Load Diff
+45 -7
View File
@@ -1,14 +1,52 @@
{
"dependencies": {
"highcharts": "^12.3.0",
"typescript": "^5.9.2"
},
"name": "crust-craft",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint ."
},
"devDependencies": {
"vite": "^7.1.3"
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/vite": "^4.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
"globals": "^16.0.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^7.0.4",
"vite-plugin-devtools-json": "^1.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"dependencies": {
"@cubejs-client/core": "^1.3.67",
"chart.js": "^4.5.0",
"chartjs-adapter-date-fns": "^3.0.0",
"chartjs-plugin-annotation": "^3.1.0",
"date-fns": "^4.1.0"
}
}
+2645
View File
File diff suppressed because it is too large Load Diff
+31 -5
View File
@@ -1,3 +1,8 @@
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
html,
body {
font-family:
@@ -36,7 +41,7 @@ p {
}
.graph {
height: 570px;
height: 400px;
overflow: hidden;
display: flex;
justify-content: center;
@@ -158,22 +163,25 @@ p {
.onoffswitch-inner:before {
content: "ON";
padding-left: 10px;
background-color: #C43535;
color: #FFFFFF;
padding-right: 8px;
text-align: center;
}
.onoffswitch-inner:after {
content: "OFF";
padding-right: 10px;
padding-left: 10px;
background-color: #EEEEEE;
color: #999999;
text-align: right;
text-align: center;
}
.onoffswitch-switch {
display: block;
width: 18px;
width: 22px;
height: 22px;
margin: 6px;
background: #FFFFFF;
position: absolute;
@@ -194,6 +202,7 @@ p {
}
.settings {
margin-bottom: 200px;
margin-top: 50px;
}
@@ -202,3 +211,20 @@ p {
font-size: larger;
font-weight: bold;
}
.hidden {
display: none;
}
#mode_span::first-letter {
text-transform: capitalize;
}
.chart div {
font: 10px sans-serif;
background-color: steelblue;
text-align: right;
padding: 3px;
margin: 1px;
color: white;
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+7
View File
@@ -0,0 +1,7 @@
export const pad = (n: string | number, amount = 2) => {
if (amount === 2) {
return ('0' + n).slice(-2);
} else {
return ('00' + n).slice(-3);
}
};
+45 -5
View File
@@ -34,8 +34,10 @@ float setUnderShoot = 20;
float derivedOverShoot = targetTemp;
float derivedUnderShoot = targetTemp;
unsigned long lastSwitch = 0;
unsigned long switchDelay = 30000; // 15s
bool autoSwitch = true;
unsigned long autoSwitchDelay = 20000; // 20s
unsigned long pwmSwitchDelay = 5000; // 5s
String mode = "off";
// Get Sensor Readings and return JSON object
String getSensorReadings()
@@ -56,6 +58,8 @@ String getSensorReadings()
readings["target_temp"] = targetTemp;
readings["derived_overshoot"] = derivedOverShoot;
readings["derived_undershoot"] = derivedUnderShoot;
readings["mode"] = mode;
serializeJson(readings, jsonString);
return jsonString;
}
@@ -131,6 +135,25 @@ void handleWebSocketMessage(void *arg, uint8_t *data, size_t len)
Serial.print(targetTemp);
Serial.printf("°C");
}
if (message.startsWith("setMode"))
{
if (message.indexOf("auto_switch") > 0)
{
mode = "auto_switch";
}
else if (message.indexOf("pwm") > 0)
{
mode = "pwm";
}
else
{
mode = "off";
digitalWrite(relay, LOW);
lastSwitch = millis();
}
Serial.printf("\nMode set to ");
Serial.print(mode);
}
}
}
@@ -161,7 +184,7 @@ void initWebSocket()
void regulateRelais()
{
if (autoSwitch)
if (mode == "auto_switch")
{
float sinceLastSwitch = millis() - lastSwitch;
float fiveMinutesInMillis = 5 * 60 * 1000;
@@ -176,7 +199,7 @@ void regulateRelais()
float overShootCorrectedTarget = derivedOverShoot = targetTemp - dirivedOvershoot;
float underShootCorrectedTarget = derivedUnderShoot = targetTemp + dirivedUndershoot;
if ((millis() - lastSwitch) > switchDelay)
if ((millis() - lastSwitch) > autoSwitchDelay)
{
if (temperature > overShootCorrectedTarget) // > 280°C
@@ -189,7 +212,7 @@ void regulateRelais()
}
}
}
if ((millis() - lastSwitch) > switchDelay)
if ((millis() - lastSwitch) > autoSwitchDelay)
{
if (temperature < underShootCorrectedTarget) // < 320°C
{
@@ -202,6 +225,23 @@ void regulateRelais()
}
}
}
if (mode == "pwm")
{
if ((millis() - lastSwitch) > pwmSwitchDelay)
{
if (digitalRead(relay) == HIGH)
{
digitalWrite(relay, LOW);
lastSwitch = millis();
}
else if (digitalRead(relay) == LOW)
{
digitalWrite(relay, HIGH);
lastSwitch = millis();
}
}
}
}
void setup()
+111 -154
View File
@@ -1,17 +1,9 @@
import Highcharts, { type DashStyleValue } from 'highcharts/highstock'
import 'highcharts/modules/accessibility'
import * as d3 from "d3";
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;
@@ -20,39 +12,41 @@ const now = new Date()
let container: HTMLElement | null = document.querySelector('#plot')
let relais = 0
let relaisData = [
[now.getTime(), 0]
]
let mode = 'off'
let temp = 300;
let relais = 0
let targetTemp = 300
let setOverShoot = 30
let setUnderShoot = 30
let targetTempOverShoot = 300
let targetTempUnderShoot = 300
let setOverShoot = 20
let setUnderShoot = 20
let targetTempOverShoot = targetTemp
let targetTempUnderShoot = targetTemp
let lastSwitch = new Date()
let lastSwitchDuration = 0
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')
const modeSpan: HTMLInputElement | null = document.querySelector('#mode_span')
const modeSelector: HTMLInputElement | null = document.querySelector('#mode_selector')
const prepareDownload: HTMLElement | null = document.querySelector('#prepare_download')
const downloadCSVButton: HTMLElement | null = document.querySelector('#download_csv')
let spoofInterval = 10
let spoofInterval = 250
let speedFactor = 250 / spoofInterval
const switchDelay = 60 / speedFactor * 1000
const switchDelay = (100 / speedFactor) * 1000
let maxValues = 4 * 60 * 60 // 60 minutes (4 values/sec)
let hs: Highcharts.StockChart;
let hsOptions: Highcharts.Options
let temperatureData = [
[now.getTime(), temp]
]
let relaisData = [
[now.getTime(), 0]
]
const getRelaisBands = () => {
let relaisBands = []
@@ -76,134 +70,61 @@ const getRelaisBands = () => {
return relaisBands
}
const dashStyle: DashStyleValue = 'Dash'
const getTargetLines = () => {
return [
{ value: targetTemp, width: 3, dashStyle: dashStyle },
{ value: targetTempOverShoot, width: 1, dashStyle: dashStyle, color: "red" },
{ value: targetTempUnderShoot, width: 1, dashStyle: dashStyle, color: "red" }
]
}
// set the dimensions and margins of the graph
const margin = { top: 10, right: 30, bottom: 30, left: 60 };
const width = 900 - margin.left - margin.right;
const height = 500 - margin.top - margin.bottom;
let svg: d3.Selection<SVGGElement, unknown, null, undefined>;
const createChart = () => {
hsOptions = {
chart: {
height: '650px',
animation: {
duration: 250,
easing: 'linear'
},
},
credits: {
enabled: false
},
// append the svg object to the body of the page
svg = d3.select(container)
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform",
"translate(" + margin.left + "," + margin.top + ")");
// title: {
// text: 'Temperature',
// align: 'left',
// },
// Declare the x (horizontal position) scale.
const x = d3.scaleUtc(d3.extent(aapl, d => d.date), [margin.left, width - margin.right]);
rangeSelector: {
enabled: false,
inputDateFormat: '%H:%M:%S'
},
// Declare the y (vertical position) scale.
const y = d3.scaleLinear([0, d3.max(aapl, d => d.close)], [height - margin.bottom, margin.top]);
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: getTargetLines()
},
// Declare the line generator.
const line = d3.line()
.x(d => x(d.date))
.y(d => y(d.close));
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(),
// Add the x-axis.
svg.append("g")
.attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).ticks(width / 80).tickSizeOuter(0));
},
// Add the y-axis, remove the domain line, add grid lines and a label.
svg.append("g")
.attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(y).ticks(height / 40))
.call(g => g.select(".domain").remove())
.call(g => g.selectAll(".tick line").clone()
.attr("x2", width - margin.left - margin.right)
.attr("stroke-opacity", 0.1))
.call(g => g.append("text")
.attr("x", -margin.left)
.attr("y", 10)
.attr("fill", "currentColor")
.attr("text-anchor", "start")
.text("↑ Daily close ($)"));
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)
// Append a path for the line.
svg.append("path")
.attr("fill", "none")
.attr("stroke", "steelblue")
.attr("stroke-width", 1.5)
.attr("d", line(aapl));
}
const onLoad = () => {
@@ -214,7 +135,9 @@ const onLoad = () => {
createChart()
relaisSwitch?.addEventListener('click', switchRelais)
modeSelector?.addEventListener('change', changeMode)
targetTempInput?.addEventListener('change', changeTargetTemp)
prepareDownload?.addEventListener('click', downloadCSV)
if (import.meta.env.DEV) {
@@ -234,13 +157,13 @@ const onLoad = () => {
temp = temp + Math.random() * 0.5 - 0.25
}
let shootFactor = Math.min(((((d.getTime() - lastSwitch.getTime()) / speedFactor) - ((lastSwitchDuration * 0.5) / speedFactor))) / ((3 * 60 * 1000) / speedFactor), 1) // 5mins
let shootFactor = Math.min(((((d.getTime() - lastSwitch.getTime()) / speedFactor) - ((lastSwitchDuration * 0.5) / speedFactor))) / ((5 * 60 * 1000) / speedFactor), 1) // 5mins
shootFactor = shootFactor
if (relais == 0) {
temp = temp - 0.8 * shootFactor
temp = temp - 0.3 * shootFactor
} else {
temp = temp + 1 * shootFactor
temp = temp + 0.3 * shootFactor
}
let mod = temp % 0.25
@@ -253,7 +176,6 @@ const onLoad = () => {
if (temperature)
temperature.innerHTML = temp.toFixed(2);
temperatureData.push([d.getTime(), temp])
let derivedShootFactor = Math.min(((d.getTime() - lastSwitch.getTime()) / speedFactor) / ((3 * 60 * 1000) / speedFactor), 1) // 5mins
@@ -266,6 +188,7 @@ const onLoad = () => {
targetTempOverShoot = targetTemp - dirivedOvershoot;
targetTempUnderShoot = targetTemp + dirivedUndershoot;
if (mode === 'auto_switch') {
if ((d.getTime() - lastSwitch.getTime()) > switchDelay) {
if (temp > targetTempOverShoot) // > 270 °C
{
@@ -300,13 +223,12 @@ const onLoad = () => {
}
}
}
}
temperatureData.push([d.getTime(), temp])
relaisData.push([d.getTime(), relais])
hs.xAxis[0].update({ plotBands: getRelaisBands() })
hs.yAxis[0].update({ plotLines: getTargetLines() })
hs.series[0].setData(temperatureData, true)
//update chart
}, spoofInterval)
}
}
@@ -382,11 +304,14 @@ const onMessage = (event: MessageEvent) => {
targetTempOverShoot = myObj[key]
} else if (key === 'derived_undershoot') {
targetTempUnderShoot = myObj[key]
} else if (key === 'mode') {
mode = myObj[key]
if (modeSpan) {
modeSpan.innerText = mode;
}
}
hs.xAxis[0].update({ plotBands: getRelaisBands() })
hs.yAxis[0].update({ plotLines: getTargetLines() })
hs.series[0].setData(temperatureData, true)
}
}
@@ -428,3 +353,35 @@ const changeTargetTemp = (e: Event) => {
}
}
const changeMode = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value = target?.value
console.log('Mode set to ' + value)
if (!import.meta.env.DEV) {
ws.send("setMode: " + value);
} else {
mode = value
if (modeSpan) {
modeSpan.innerText = mode + " ";
}
}
}
const downloadCSV = () => {
let csvContent = 'Time,Temp,Relais\n'
console.log(temperatureData.length, relaisData.length)
for (let [i, temp] of temperatureData.entries()) {
csvContent += `${temp[0]},${temp[1]},${relaisData[i] ? relaisData[i][1] : ''}\n`
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8,' })
const objUrl = URL.createObjectURL(blob)
if (downloadCSVButton) {
downloadCSVButton.classList.remove('hidden')
downloadCSVButton.setAttribute('href', objUrl)
downloadCSVButton.setAttribute('download', 'data.csv')
}
}
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.ico';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
{@render children?.()}
+1
View File
@@ -0,0 +1 @@
export const prerender = true
+522
View File
@@ -0,0 +1,522 @@
<script lang="ts">
import { onMount } from 'svelte';
import Chart from 'chart.js/auto';
import 'chartjs-adapter-date-fns';
import annotationPlugin from 'chartjs-plugin-annotation';
import { nl } from 'date-fns/locale';
import { browser } from '$app/environment';
import { pad } from '$lib/utils';
import { page } from '$app/state';
let gateway = `ws://${page.url.host}/ws`;
let ws: WebSocket;
const now = new Date();
let mode = 'off';
let temp = 300;
let relais = 0;
let targetTemp = 300;
let setOverShoot = 20;
let setUnderShoot = 20;
let targetTempOverShoot = targetTemp;
let targetTempUnderShoot = targetTemp;
let lastSwitch = new Date();
let lastSwitchDuration = 0;
let canvas: HTMLCanvasElement;
let pauseGraphUpdate = false;
let spoofInterval = 250;
let speedFactor = 250 / spoofInterval;
const switchDelay = (30 / speedFactor) * 1000;
const pwmDelay = (5 / speedFactor) * 1000;
let maxValues = 4 * 60 * 60; // 60 minutes (4 values/sec)
let temperatureData = [];
let relaisData = [];
let downloadCSVButton: HTMLElement;
let modeSpan: HTMLElement;
const getRelaisBands = () => {
let relaisBands = {};
let active = false;
let count = 0;
for (let r of relaisData) {
let key = 'relais' + count;
if (r[1] === 1 && !active) {
active = true;
relaisBands[key] = {
type: 'box',
backgroundColor: 'rgba(255, 0, 0, 0.1)',
borderWidth: 1,
borderColor: 'rgba(255, 0, 0, 0.5)',
drawTime: 'beforeDraw',
xMax: relaisData[relaisData.length - 1][0] + 10,
xMin: r[0]
// label: {
// display: true,
// content: 'Heat',
// position: {
// x: 'center',
// y: 'start'
// }
// }
};
}
if (r[1] === 0 && active) {
relaisBands[key]['xMax'] = r[0];
active = false;
count++;
}
}
return relaisBands;
};
const getAnnotations = () => {
return {
annotations: {
limitGreen: {
type: 'box',
drawTime: 'beforeDraw',
backgroundColor: 'rgba(0, 255, 133, 0.25)',
borderWidth: 0,
yMax: 0,
yMin: 400
},
limitOrange: {
type: 'box',
drawTime: 'beforeDraw',
backgroundColor: 'rgba(255, 138, 0 ,0.25)',
borderWidth: 0,
yMax: 400,
yMin: 500
},
limitRed: {
type: 'box',
drawTime: 'beforeDraw',
backgroundColor: 'rgba(255, 0, 0, 0.25)',
borderWidth: 0,
yMax: 500,
yMin: 600
},
targetTemp: {
type: 'line',
drawTime: 'beforeDraw',
borderColor: 'black',
borderWidth: 2,
scaleID: 'y',
value: targetTemp,
borderDash: [6, 6],
borderDashOffset: 0,
label: {
backgroundColor: 'transparent',
display: true,
color: 'rgba(0,0,0, 1)',
content: 'Target temp',
position: 'center',
yAdjust: -9
}
},
...getRelaisBands()
}
};
};
let chart: Chart;
Chart.register(annotationPlugin);
onMount(() => {
initWebSocket();
chart = new Chart(canvas, {
type: 'line',
options: {
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'nearest',
intersect: false,
animation: false
},
annotation: getAnnotations()
},
scales: {
x: {
type: 'time',
time: {
tooltipFormat: 'HH:mm:ss',
displayFormats: {
millisecond: 'HH:mm:ss.S',
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'HH:mm'
}
},
adapters: {
date: {
locale: nl
}
}
},
y: {
title: 'Temperature C',
min: 0
}
}
},
data: {
labels: temperatureData.map((row) => row[0]),
datasets: [
{
label: 'Temperature',
data: temperatureData.map((row) => row[1]),
fill: true,
tension: 0.25
}
]
}
});
if (import.meta.env.DEV) {
// spoof some test data
setInterval(() => {
if (temperatureData.length > maxValues) {
temperatureData.shift();
relaisData.shift();
}
let d = new Date();
if (temp < targetTemp / 2) {
temp = temp + Math.random() * 0.5 - 0.125;
} else if (temp < targetTemp / (3 / 2)) {
temp = temp + Math.random() * 0.5 - 0.15;
} else {
temp = temp + Math.random() * 0.5 - 0.25;
}
let shootFactor = Math.min(
((d.getTime() - lastSwitch.getTime()) / speedFactor -
(lastSwitchDuration * 0.25) / speedFactor) /
((3 * 60 * 1000) / speedFactor),
1
); // 5mins
shootFactor = shootFactor;
if (relais == 0) {
temp = temp - 0.3 * shootFactor;
} else {
temp = temp + 0.3 * shootFactor;
}
let mod = temp % 0.25;
if (mod > 0.125) {
temp = temp - mod + 0.25;
} else {
temp = temp - mod;
}
let derivedShootFactor = Math.min(
(d.getTime() - lastSwitch.getTime()) / speedFactor / ((3 * 60 * 1000) / speedFactor),
1
); // 5mins
let overshoot = setOverShoot * derivedShootFactor; // derive over 5 mins -> 1 min eq. 3°C, 5mins eq. 15°C
let dirivedOvershoot = Math.min(overshoot, setOverShoot);
let undershoot = setUnderShoot * derivedShootFactor; // derive over 5 min -> 1 min eq. 3°C, 5mins eq. 15°C
let dirivedUndershoot = Math.min(undershoot, setUnderShoot);
targetTempOverShoot = targetTemp - dirivedOvershoot;
targetTempUnderShoot = targetTemp + dirivedUndershoot;
if (mode === 'auto_switch') {
if (d.getTime() - lastSwitch.getTime() > switchDelay) {
if (temp > targetTempOverShoot) {
// > 270 °C
if (relais == 1) {
relais = 0;
targetTempOverShoot = targetTemp;
targetTempUnderShoot = targetTemp;
lastSwitchDuration = d.getTime() - lastSwitch.getTime();
lastSwitch = new Date();
}
}
}
if (d.getTime() - lastSwitch.getTime() > switchDelay) {
if (temp < targetTempUnderShoot) {
// < 310°C
if (relais == 0) {
relais = 1;
targetTempOverShoot = targetTemp;
targetTempUnderShoot = targetTemp;
lastSwitchDuration = d.getTime() - lastSwitch.getTime();
lastSwitch = new Date();
}
}
}
}
if (mode === 'pwm') {
if (d.getTime() - lastSwitch.getTime() > pwmDelay) {
if (relais) {
relais = 0;
lastSwitch = new Date();
} else if (!relais) {
relais = 1;
lastSwitch = new Date();
}
}
}
temperatureData.push([d.getTime(), temp]);
relaisData.push([d.getTime(), relais]);
if (!pauseGraphUpdate) {
chart.options.plugins.annotation = getAnnotations();
chart.data.labels.push(d.getTime());
chart.data.datasets.forEach((dataset) => {
dataset.data.push(temp);
});
chart.update('none');
}
}, spoofInterval);
}
});
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]]);
temp = myObj[key];
} else if (key === 'relais') {
if (relaisData.length > maxValues) {
relaisData.shift();
}
relaisData.push([d.getTime(), myObj[key]]);
if (myObj[key]) {
relais = 1;
} else {
relais = 0;
}
} else if (key === 'target_temp') {
targetTemp = myObj[key];
} else if (key === 'derived_overshoot') {
targetTempOverShoot = myObj[key];
} else if (key === 'derived_undershoot') {
targetTempUnderShoot = myObj[key];
} else if (key === 'mode') {
mode = myObj[key];
modeSpan.innerText = mode;
}
if (!pauseGraphUpdate) {
chart.options.plugins.annotation = getAnnotations();
chart.data.labels.push(d.getTime());
chart.data.datasets.forEach((dataset) => {
dataset.data.push(temp);
});
chart.update('none');
}
}
};
const switchRelais = () => {
console.log('Send switch relais');
if (!import.meta.env.DEV) {
ws.send('switchRelais');
} else {
// spoof some test data
let d = new Date();
lastSwitchDuration = d.getTime() - lastSwitch.getTime();
lastSwitch = new Date();
if (!relais) {
relais = 1;
} else {
relais = 0;
}
}
};
const changeTargetTemp = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value = Number(target?.value);
console.log('Changed target temp to ' + value);
if (!import.meta.env.DEV) {
ws.send('setTargetTemp: ' + pad(value, 3));
} else {
targetTemp = value;
targetTempOverShoot = value;
targetTempUnderShoot = value;
}
};
const changeMode = (e: Event) => {
const target = e.target as HTMLTextAreaElement;
let value = target?.value;
console.log('Mode set to ' + value);
if (!import.meta.env.DEV) {
ws.send('setMode: ' + value);
} else {
mode = value;
modeSpan.innerText = value;
}
};
const prepareDownloadCSV = () => {
let csvContent = 'Time,Temp,Relais\n';
for (let [i, temp] of temperatureData.entries()) {
csvContent += `${temp[0]},${temp[1]},${relaisData[i] ? relaisData[i][1] : ''}\n`;
}
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8,' });
const objUrl = URL.createObjectURL(blob);
if (downloadCSVButton) {
downloadCSVButton.classList.remove('hidden');
downloadCSVButton.setAttribute('href', objUrl);
downloadCSVButton.setAttribute('download', 'data.csv');
}
};
</script>
<div class="nav">
<div class="nav-title">CrustCraft</div>
</div>
<div class="content">
<div class="card-grid">
<div class="card">
<p class="card-title">Sensor Temperature</p>
<p class="reading">{temp.toFixed(2)} &deg;C</p>
</div>
<button class="card relais" onclick={switchRelais}>
<p>Relais</p>
<div class="onoffswitch">
<input
type="checkbox"
name="onoffswitch"
class="onoffswitch-checkbox h-0"
id="relais_switch_input"
tabindex="0"
checked={relais}
/>
<label class="onoffswitch-label" for="relais_switch_input">
<span class="onoffswitch-inner"></span>
<span class="onoffswitch-switch"></span>
</label>
</div>
</button>
</div>
<div class="oven {relais ? 'on' : ''}" id="oven">
<div class="oven-wrapper">
<div class="oven-img-wrapper">
<img src="/oven_small.png" alt="Oven" />
</div>
</div>
</div>
<div class="graph">
<div class="plot-wrapper w-[800px]">
<div id="plot"></div>
<canvas bind:this={canvas} id="acquisitions"></canvas>
</div>
</div>
<div class="card-grid settings">
<div class="card">
<p class="card-title">Target temperature</p>
<p class="reading">
<input
class="min-w-[88px] rounded"
type="number"
min="0"
max="600"
placeholder="300"
id="target_temperature"
onchange={changeTargetTemp}
/>
<label for="target_temperature"> &deg;C </label>
</p>
</div>
<div class="card">
<p class="card-title">
Mode: <span bind:this={modeSpan} class="mode" id="mode_span">Manual</span>
</p>
<p class="reading">
<select class="rounded" name="modes" id="mode_selector" onchange={changeMode}>
<option value="auto_switch">Auto Switch</option>
<option value="pwm">PWM</option>
<option value="off" selected>Manual</option>
</select>
</p>
</div>
<div class="card flex cursor-pointer gap-2">
<button
class="cursor-pointer rounded border p-2"
onclick={() => (pauseGraphUpdate = !pauseGraphUpdate)}>Pause Graph</button
>
<button
class="cursor-pointer rounded border p-2"
id="prepare_download"
onclick={prepareDownloadCSV}>Prepare Download</button
>
<a
bind:this={downloadCSVButton}
class="hidden cursor-pointer rounded border p-2"
id="download_csv">Download CSV</a
>
</div>
</div>
</div>
View File

Before

Width:  |  Height:  |  Size: 517 KiB

After

Width:  |  Height:  |  Size: 517 KiB

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+17
View File
@@ -0,0 +1,17 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'data',
assets: 'data',
})
}
};
export default config;
+14 -24
View File
@@ -1,29 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"sourceMap": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}
+4 -4
View File
@@ -1,8 +1,8 @@
import devtoolsJson from 'vite-plugin-devtools-json';
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
build: {
outDir: 'data',
chunkSizeWarningLimit: 1000
}
plugins: [tailwindcss(), sveltekit(), devtoolsJson()]
});