Starting anew, before I changed to a custom chart
This commit is contained in:
38
.eslintrc.json
Normal file
38
.eslintrc.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"linebreak-style": [
|
||||||
|
"error",
|
||||||
|
"unix"
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
"error",
|
||||||
|
"double"
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
.idea
|
||||||
|
node_modules
|
||||||
|
app-dist/server
|
||||||
|
climate-server
|
||||||
82
README.md
Normal file
82
README.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Quick Documentation
|
||||||
|
|
||||||
|
In the following, a datetime parameter is always a unix timestamp in milliseconds and an ISO string in UTC.
|
||||||
|
|
||||||
|
### Dashboard:
|
||||||
|
|
||||||
|
- `GET /dashboard`
|
||||||
|
- Display the dashboard page, by default the chart will show the last 60 minutes, updating every 30 seconds.
|
||||||
|
|
||||||
|
Available parameters:
|
||||||
|
|
||||||
|
| Name | Type | Usage |
|
||||||
|
|--------|------|---------|
|
||||||
|
| last-minutes | number | alone, in order to start with a display of snapshots from the last number of minutes specified, auto-updating.|
|
||||||
|
| from | datetime | alone or with `to` in order to start with a display of snapshots beginning at the datetime specified. If `to` is not specified it will default to the current time.|
|
||||||
|
| to | datetime | always with `from` in order to start with a display of snapshots until the datetime specified|
|
||||||
|
|
||||||
|
### API:
|
||||||
|
|
||||||
|
All API endpoints that return a snapshot can specify the `timeFormat` parameter, with the value `unix` or `iso` to override default behaviour.
|
||||||
|
|
||||||
|
A request to the API whose parameters are all given in a particular time format (either an ISO string or a unix timetamp) are all consistent will always return with all timestamps in the corresponding fomat unless the request specifies the "timeFormat" parameter to override.
|
||||||
|
If the request has mixed use of the time formats, the default will be an ISO string in UTC.
|
||||||
|
|
||||||
|
|
||||||
|
- `GET /api/snapshots`
|
||||||
|
- Retrieve a json of data snapshots over the last 60 minutes in the following format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"snapshots": [
|
||||||
|
{
|
||||||
|
"id": 8868,
|
||||||
|
"time": "2020-11-13T20:00:12Z", // iso string OR unix timestamp e.g. 1615015310862
|
||||||
|
"temp": 21.7, // number, expressed in degrees celsius
|
||||||
|
"humidity": 57.3, // number, expressed in %
|
||||||
|
"co2": 958 // number, expressed in ppm
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8867,
|
||||||
|
"time": "2020-11-13T19:59:42Z",
|
||||||
|
"temp": 21.7,
|
||||||
|
"humidity": 57.3,
|
||||||
|
"co2": 957
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Snapshots are ordered descending.
|
||||||
|
|
||||||
|
Available parameters:
|
||||||
|
|
||||||
|
| Name | Type | Usage |
|
||||||
|
|--------|------|---------|
|
||||||
|
| last-minutes | number | alone, in order to return snapshots from the last number of minutes specified|
|
||||||
|
| from | datetime | alone or with `to` in order to return snapshots within a range|
|
||||||
|
| to | datetime | always with `from` in order to return snapshots within a range|
|
||||||
|
|
||||||
|
Example:
|
||||||
|
`GET http://<host>/<project-root>/api/snapshots?from=2021-03-06T07:21:50.862Z&to=1615015610862`
|
||||||
|
|
||||||
|
- `GET /api/snapshots/latest`
|
||||||
|
- In the same format as above, but the snapshots array will only contain the most recent snapshot.
|
||||||
|
- Clearly, the since, from and to parameters have no effect here.
|
||||||
|
|
||||||
|
- `POST /api/snapshots`
|
||||||
|
- Submit one or more snapshots to the database in the format of the following example JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
snapshots: [
|
||||||
|
{
|
||||||
|
"time": "2020-11-13T22:20:44Z", // iso string OR unix timestamp e.g. 1615015310862
|
||||||
|
"temp": 25.2, // number, expressed in degrees celsius
|
||||||
|
"humidity": 65.4, // number, expressed in %
|
||||||
|
"co2": 450 // number, expressed in ppm
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
29
app-dist/scripts/climate-pinger.py
Executable file
29
app-dist/scripts/climate-pinger.py
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import adafruit_dht
|
||||||
|
import mh_z19
|
||||||
|
import sys
|
||||||
|
from board import D4
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
dhtDevice = adafruit_dht.DHT22(D4)
|
||||||
|
temp = dhtDevice.temperature
|
||||||
|
humidity = dhtDevice.humidity
|
||||||
|
co2 = mh_z19.read()
|
||||||
|
if co2 is not None:
|
||||||
|
co2 = co2['co2']
|
||||||
|
else:
|
||||||
|
raise RuntimeError()
|
||||||
|
print(
|
||||||
|
'Time:', str(datetime.isoformat(datetime.now())),
|
||||||
|
'\nTemp:', temp,
|
||||||
|
'\nHumidity:', humidity,
|
||||||
|
'\nCO2:', co2,
|
||||||
|
sep='\t',
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
print('err:', error, file=sys.stderr)
|
||||||
|
|
||||||
|
sys.stdout = None
|
||||||
|
dhtDevice.exit()
|
||||||
9
app-dist/scripts/pinger-test.py
Normal file
9
app-dist/scripts/pinger-test.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
print(
|
||||||
|
'Time:', str(datetime.isoformat(datetime.now())),
|
||||||
|
'\nTemp:', 20,
|
||||||
|
'\nHumidity:', 60,
|
||||||
|
'\nCO2:', 500,
|
||||||
|
sep='\t',
|
||||||
|
)
|
||||||
1282
app-dist/static/dashboard.js
Normal file
1282
app-dist/static/dashboard.js
Normal file
File diff suppressed because one or more lines are too long
12
app-dist/static/index.ejs
Normal file
12
app-dist/static/index.ejs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Ledda's Room Climate</title>
|
||||||
|
<link type="text/css" href="<%= rootUrl %>/static/styles.css" rel="stylesheet" />
|
||||||
|
<script type="application/javascript" src="<%= rootUrl %>/static/dashboard.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
130
app-dist/static/styles.css
Normal file
130
app-dist/static/styles.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #fff1de;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
transition: opacity 1s;
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content-grid {
|
||||||
|
height: 80%;
|
||||||
|
width: 80%;
|
||||||
|
display: grid;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.main-content-grid > * {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center > * {
|
||||||
|
align-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
display: block;
|
||||||
|
font-family: "Georgia", serif;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #7b5b2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: 1em;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 1em;
|
||||||
|
border: 0.2em #c7ab82 solid;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget h2 {
|
||||||
|
font-family: "Georgia", serif;
|
||||||
|
font-weight: normal;
|
||||||
|
color: #7b5b2f;
|
||||||
|
font-size: 1em;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-body {
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minus-button,
|
||||||
|
.plus-button {
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 50%;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.display-mode-widget-mins .minus-button,
|
||||||
|
.display-mode-widget-mins .plus-button {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.minus-button:hover,
|
||||||
|
.plus-button:hover {
|
||||||
|
opacity: 100%;
|
||||||
|
}
|
||||||
|
.minus-button:active,
|
||||||
|
.plus-button:active {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
.minus-button::before {
|
||||||
|
content: "−";
|
||||||
|
}
|
||||||
|
.plus-button::before {
|
||||||
|
content: "+";
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-mode-widget-mins .min-count {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 30px;
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
}
|
||||||
|
.display-mode-widget-mins input {
|
||||||
|
border: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 30px;
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 10px 0 10px 0;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-mode-widget-date {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin: 5px 0 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-widget .last-update {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: gray;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
3
build.sh
Normal file
3
build.sh
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
(cd ./server && tsc -w) & (cd ./server nodemon ./dist/main.js & (cd ./dashboard/ && cp -- *.log ~/Desktop)
|
||||||
8093
dashboard/package-lock.json
generated
Normal file
8093
dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
dashboard/package.json
Normal file
30
dashboard/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "climate-ranger-frontend",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Frontend for displaying info about room climate",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"build": "webpack",
|
||||||
|
"dev": "webpack serve",
|
||||||
|
"build-watch": "webpack -w -h"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^17.0.3",
|
||||||
|
"@webpack-cli/init": "^1.0.3",
|
||||||
|
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||||
|
"css-loader": "^5.0.1",
|
||||||
|
"style-loader": "^2.0.0",
|
||||||
|
"terser-webpack-plugin": "^5.0.3",
|
||||||
|
"ts-loader": "^8.0.18",
|
||||||
|
"webpack": "^5.4.0",
|
||||||
|
"webpack-cli": "^4.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/chart.js": "^2.9.27",
|
||||||
|
"chart.js": "^2.9.4",
|
||||||
|
"webpack-dev-server": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
72
dashboard/src/AppUI.ts
Normal file
72
dashboard/src/AppUI.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import TimezoneWidget from "./TimezoneWidget";
|
||||||
|
import DisplayModeWidget from "./DisplayModeWidget";
|
||||||
|
import TimerWidget from "./TimerWidget";
|
||||||
|
import ClimateChartWidget from "./ClimateChartWidget";
|
||||||
|
import {GridSize} from "./GridWidget";
|
||||||
|
import MessageOverlay from "./MessageOverlay";
|
||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
import SelectDisplayModeWidget from "./SelectDisplayModeWidget";
|
||||||
|
|
||||||
|
class AppUI extends UIComponent {
|
||||||
|
private timezoneWidget: TimezoneWidget;
|
||||||
|
private selectModeWidget: SelectDisplayModeWidget;
|
||||||
|
private displayModeSettingsWidget: DisplayModeWidget;
|
||||||
|
private timerWidget: TimerWidget;
|
||||||
|
private chartWidget: ClimateChartWidget;
|
||||||
|
private element: HTMLDivElement = document.createElement("div");
|
||||||
|
private grid: HTMLDivElement = document.createElement("div");
|
||||||
|
private messageOverlay: MessageOverlay = new MessageOverlay();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.setupGrid({width: 5, height: 10});
|
||||||
|
this.element.append(
|
||||||
|
Object.assign(document.createElement("h1"), { innerText: "Ledda's Room Climate" }),
|
||||||
|
this.grid,
|
||||||
|
this.messageOverlay.current(),
|
||||||
|
);
|
||||||
|
this.element.className = "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupGrid(size: GridSize) {
|
||||||
|
this.setupWidgets();
|
||||||
|
this.grid.append(
|
||||||
|
this.chartWidget.current(),
|
||||||
|
this.displayModeSettingsWidget.current(),
|
||||||
|
this.selectModeWidget.current(),
|
||||||
|
this.timerWidget.current(),
|
||||||
|
this.timezoneWidget.current(),
|
||||||
|
);
|
||||||
|
this.grid.className = "main-content-grid";
|
||||||
|
this.grid.style.gridTemplateRows = `repeat(${size.height}, 1fr)`;
|
||||||
|
this.grid.style.gridTemplateColumns = `repeat(${size.width}, 1fr)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWidgets() {
|
||||||
|
this.displayModeSettingsWidget = new DisplayModeWidget({
|
||||||
|
row: "auto", col: 5, width: 1, height: 3,
|
||||||
|
});
|
||||||
|
this.selectModeWidget = new SelectDisplayModeWidget({
|
||||||
|
row: "auto", col: 5, width: 1, height: 2,
|
||||||
|
});
|
||||||
|
this.timezoneWidget = new TimezoneWidget({
|
||||||
|
row: "auto", col: 5, width: 1, height: 2,
|
||||||
|
});
|
||||||
|
this.timerWidget = new TimerWidget({
|
||||||
|
row: "auto", col: 5, width: 1, height: 3,
|
||||||
|
});
|
||||||
|
this.chartWidget = new ClimateChartWidget({
|
||||||
|
row: 1, col: 1, width: 4, height: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap(rootNode: string) {
|
||||||
|
document.getElementById(rootNode).append(this.element);
|
||||||
|
}
|
||||||
|
|
||||||
|
current(): HTMLElement {
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppUI;
|
||||||
77
dashboard/src/Chart.ts
Normal file
77
dashboard/src/Chart.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Snapshot from "./Snapshot";
|
||||||
|
|
||||||
|
export default class Chart {
|
||||||
|
private readonly ctx: CanvasRenderingContext2D;
|
||||||
|
constructor(context: CanvasRenderingContext2D) {
|
||||||
|
this.ctx = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
render(snapshots: Snapshot[]) {
|
||||||
|
const snapshotWidth = this.ctx.canvas.width;
|
||||||
|
let minTemp = Infinity;
|
||||||
|
let maxTemp = -Infinity;
|
||||||
|
let minCo2 = Infinity;
|
||||||
|
let maxCo2 = -Infinity;
|
||||||
|
let minHumidity = Infinity;
|
||||||
|
let maxHumidity = -Infinity;
|
||||||
|
for (const snapshot of snapshots) {
|
||||||
|
if (snapshot.temp < minTemp) {
|
||||||
|
minTemp = snapshot.temp;
|
||||||
|
}
|
||||||
|
if (snapshot.temp > maxTemp) {
|
||||||
|
maxTemp = snapshot.temp;
|
||||||
|
}
|
||||||
|
if (snapshot.co2 < minCo2) {
|
||||||
|
minCo2 = snapshot.co2;
|
||||||
|
}
|
||||||
|
if (snapshot.co2 > maxCo2) {
|
||||||
|
maxCo2 = snapshot.co2;
|
||||||
|
}
|
||||||
|
if (snapshot.humidity < minHumidity) {
|
||||||
|
minHumidity = snapshot.humidity;
|
||||||
|
}
|
||||||
|
if (snapshot.humidity > maxHumidity) {
|
||||||
|
maxHumidity = snapshot.humidity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const humidityRange = maxHumidity - minHumidity;
|
||||||
|
|
||||||
|
let x = snapshotWidth / 2;
|
||||||
|
let y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||||
|
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||||
|
this.ctx.moveTo(x, y);
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
x += snapshotWidth;
|
||||||
|
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||||
|
this.ctx.lineTo(x, y);
|
||||||
|
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
const co2Range = maxCo2 - minCo2;
|
||||||
|
x = snapshotWidth / 2;
|
||||||
|
y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||||
|
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||||
|
this.ctx.moveTo(x, y);
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
x += snapshotWidth;
|
||||||
|
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||||
|
this.ctx.lineTo(x, y);
|
||||||
|
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
const tempRange = maxTemp - minTemp;
|
||||||
|
x = snapshotWidth / 2;
|
||||||
|
y = (snapshots[0].humidity - minHumidity) / humidityRange;
|
||||||
|
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||||
|
this.ctx.moveTo(x, y);
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
x += snapshotWidth;
|
||||||
|
y = (snapshots[i].humidity - minHumidity) / humidityRange;
|
||||||
|
this.ctx.lineTo(x, y);
|
||||||
|
this.ctx.ellipse(x, y, 3, 3, 0, 0, 2 * Math.PI);
|
||||||
|
}
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
127
dashboard/src/ClimateChartWidget.ts
Normal file
127
dashboard/src/ClimateChartWidget.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import Chart from "chart.js/dist/Chart.bundle.min";
|
||||||
|
import {generateClimateChartConfig} from "./climateChartConfig";
|
||||||
|
import Snapshot from "./Snapshot";
|
||||||
|
import {AppStore, DisplayMode, getAppState, TimeWindow} from "./StateStore";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
|
||||||
|
interface ClimatePoint {
|
||||||
|
x: string;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ClimateChartWidget extends UIComponent {
|
||||||
|
private readonly skeleton: GridWidget;
|
||||||
|
private chart: Chart | null;
|
||||||
|
private initialised: boolean;
|
||||||
|
private displayMode: DisplayMode = "pastMins";
|
||||||
|
private latestSnapshotInChartTime: number;
|
||||||
|
private readonly canvasElement: HTMLCanvasElement = document.createElement("canvas");
|
||||||
|
private body = document.createElement("div");
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
super();
|
||||||
|
this.initialised = false;
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
body: this.body,
|
||||||
|
});
|
||||||
|
const now = new Date().getTime();
|
||||||
|
this.latestSnapshotInChartTime = now - getAppState().minutesDisplayed * 60000;
|
||||||
|
this.setupListeners();
|
||||||
|
AppStore().subscribe("documentReady", async () => {
|
||||||
|
try {
|
||||||
|
AppStore().addLoad();
|
||||||
|
await this.initChart();
|
||||||
|
this.initialised = true;
|
||||||
|
} catch (e) {
|
||||||
|
AppStore().fatalError(e);
|
||||||
|
} finally {
|
||||||
|
AppStore().finishLoad();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupListeners() {
|
||||||
|
AppStore().subscribe("displayMode", () => this.updateDisplayMode());
|
||||||
|
AppStore().subscribe("minutesDisplayed", () => this.update());
|
||||||
|
AppStore().subscribe("displayWindow", () => this.update());
|
||||||
|
AppStore().subscribe("snapshots", () => this.update());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initChart() {
|
||||||
|
const ctx = this.canvasElement.getContext("2d");
|
||||||
|
this.chart = new Chart(ctx, generateClimateChartConfig({}));
|
||||||
|
await this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateDisplayMode() {
|
||||||
|
this.displayMode = getAppState().displayMode;
|
||||||
|
await this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async update() {
|
||||||
|
if (this.initialised) {
|
||||||
|
if (this.displayMode === "window") {
|
||||||
|
await this.updateChartFromTimeWindow();
|
||||||
|
} else if (this.displayMode === "pastMins") {
|
||||||
|
await this.updateChartFromMinuteSpan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateChartFromTimeWindow() {
|
||||||
|
this.clearChart();
|
||||||
|
this.appendSnapshots(await AppStore().snapshotsBetween(
|
||||||
|
getAppState().displayWindow.start, getAppState().displayWindow.stop));
|
||||||
|
this.chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateChartFromMinuteSpan() {
|
||||||
|
const mins = getAppState().minutesDisplayed;
|
||||||
|
this.clearChart();
|
||||||
|
this.appendSnapshots(await AppStore().snapshotsBetween(
|
||||||
|
getAppState().lastUpdateTime - mins * 60000,
|
||||||
|
getAppState().lastUpdateTime));
|
||||||
|
this.chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendSnapshots(snapshots: Snapshot[]) {
|
||||||
|
for (const snapshot of snapshots.reverse()) {
|
||||||
|
this.humidityPointList().push({x: snapshot.time, y: snapshot.humidity});
|
||||||
|
this.tempPointList().push({x: snapshot.time, y: snapshot.temp});
|
||||||
|
this.co2PointList().push({x: snapshot.time, y: snapshot.co2});
|
||||||
|
}
|
||||||
|
this.latestSnapshotInChartTime = snapshots[0] && new Date(snapshots[0].time).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
private removePoint(index: number) {
|
||||||
|
this.humidityPointList().splice(index, 1);
|
||||||
|
this.tempPointList().splice(index, 1);
|
||||||
|
this.co2PointList().splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearChart() {
|
||||||
|
for (const dataset of this.chart.data.datasets) {
|
||||||
|
dataset.data = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private humidityPointList(): ClimatePoint[] {
|
||||||
|
return this.chart.data.datasets[0].data as ClimatePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private tempPointList(): ClimatePoint[] {
|
||||||
|
return this.chart.data.datasets[1].data as ClimatePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
private co2PointList(): ClimatePoint[] {
|
||||||
|
return this.chart.data.datasets[2].data as ClimatePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ClimateChartWidget;
|
||||||
164
dashboard/src/DisplayModeWidget.tsx
Normal file
164
dashboard/src/DisplayModeWidget.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {AppStore, DisplayMode, getAppState} from "./StateStore";
|
||||||
|
import * as JSX from "./JSXFactory";
|
||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
|
||||||
|
class DisplayModeWidget extends UIComponent {
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private minsCounterRef: number;
|
||||||
|
private windowStartTimeRef: number;
|
||||||
|
private windowStopTimeRef: number;
|
||||||
|
private windowedDisplayRef: number;
|
||||||
|
private minsDisplayRef: number;
|
||||||
|
private mainDisplay: HTMLElement;
|
||||||
|
private minsInputRef: number;
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
super();
|
||||||
|
this.mainDisplay = this.MainDisplay({ctx: this});
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
title: "Displaying:",
|
||||||
|
body: this.mainDisplay,
|
||||||
|
});
|
||||||
|
AppStore().subscribe("minutesDisplayed", () => this.updateDisplay());
|
||||||
|
AppStore().subscribe("displayMode", () => this.updateDisplay());
|
||||||
|
AppStore().subscribe("displayWindow", () => this.updateDisplay());
|
||||||
|
}
|
||||||
|
|
||||||
|
private WindowStartTime({ ctx }: {ctx: DisplayModeWidget}) {
|
||||||
|
ctx.windowStartTimeRef = ctx.makeRef(<div
|
||||||
|
className={"display-mode-widget-date"}>
|
||||||
|
{new Date(getAppState().displayWindow.start).toLocaleString()}
|
||||||
|
</div>);
|
||||||
|
return ctx.fromRef(ctx.windowStartTimeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WindowStopTime({ctx}: {ctx: DisplayModeWidget}) {
|
||||||
|
ctx.windowStopTimeRef = ctx.makeRef(<div
|
||||||
|
className={"display-mode-widget-date"}>
|
||||||
|
{new Date(getAppState().displayWindow.stop).toLocaleString()}
|
||||||
|
</div>);
|
||||||
|
return ctx.fromRef(ctx.windowStopTimeRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MinutesCounter({ctx, onclick}: {ctx: DisplayModeWidget, onclick: () => any}) {
|
||||||
|
ctx.minsInputRef = ctx.makeRef(
|
||||||
|
<input
|
||||||
|
value={getAppState().minutesDisplayed.toString()}
|
||||||
|
onblur={(e: FocusEvent) => ctx.onMinutesCounterInputBlur(e)}/>);
|
||||||
|
ctx.minsCounterRef = ctx.makeRef(
|
||||||
|
<div className={"min-count"} onclick={onclick}>
|
||||||
|
{getAppState().minutesDisplayed.toString()}
|
||||||
|
</div>);
|
||||||
|
return ctx.fromRef(ctx.minsCounterRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMinutesCounterInputBlur(e: FocusEvent) {
|
||||||
|
const input = Number((e.target as HTMLInputElement).value);
|
||||||
|
if (!isNaN(input)) {
|
||||||
|
if (input >= 1) {
|
||||||
|
AppStore().setMinutesDisplayed(input);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(e.target as HTMLInputElement).value = getAppState().minutesDisplayed.toString();
|
||||||
|
}
|
||||||
|
this.fromRef(this.minsInputRef).replaceWith(this.fromRef(this.minsCounterRef));
|
||||||
|
}
|
||||||
|
|
||||||
|
private MinutesDisplay({ctx}: {ctx: DisplayModeWidget}) {
|
||||||
|
return (<div className={"display-mode-widget-mins"}>
|
||||||
|
<div>Last</div>
|
||||||
|
<ctx.MinusButton onclick={() => {
|
||||||
|
const mins = AppStore().getState().minutesDisplayed;
|
||||||
|
AppStore().setMinutesDisplayed(mins - 1);
|
||||||
|
}}/>
|
||||||
|
<ctx.MinutesCounter ctx={ctx} onclick={() => ctx.onMinutesCounterClick()}/>
|
||||||
|
<ctx.PlusButton onclick={() => {
|
||||||
|
const mins = AppStore().getState().minutesDisplayed;
|
||||||
|
AppStore().setMinutesDisplayed(mins + 1);
|
||||||
|
}}/>
|
||||||
|
<div>minutes</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMinutesCounterClick() {
|
||||||
|
const input = this.fromRef(this.minsInputRef) as HTMLInputElement;
|
||||||
|
this.fromRef(this.minsCounterRef).replaceWith(input);
|
||||||
|
input.focus();
|
||||||
|
input.selectionStart = 0;
|
||||||
|
input.selectionEnd = input.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MinusButton(props: {onclick: () => any}) {
|
||||||
|
return <div
|
||||||
|
className={"minus-button"}
|
||||||
|
onclick={props.onclick}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlusButton(props: {onclick: () => any}) {
|
||||||
|
return <div
|
||||||
|
className={"plus-button"}
|
||||||
|
onclick={props.onclick}
|
||||||
|
/>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WindowedDisplay({ctx}: {ctx: DisplayModeWidget}) {
|
||||||
|
return (<div>
|
||||||
|
<div>From</div>
|
||||||
|
<ctx.MinusButton onclick={() => {
|
||||||
|
const displayWindow = AppStore().getState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({start: displayWindow.start - 60000, stop: displayWindow.stop});
|
||||||
|
}}/>
|
||||||
|
<ctx.WindowStartTime ctx={ctx}/>
|
||||||
|
<ctx.PlusButton onclick={() => {
|
||||||
|
const displayWindow = AppStore().getState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({start: displayWindow.start + 60000, stop: displayWindow.stop});
|
||||||
|
}}/>
|
||||||
|
<div>to</div>
|
||||||
|
<ctx.MinusButton onclick={() => {
|
||||||
|
const displayWindow = AppStore().getState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop - 60000});
|
||||||
|
}}/>
|
||||||
|
<ctx.WindowStopTime ctx={ctx}/>
|
||||||
|
<ctx.PlusButton onclick={() => {
|
||||||
|
const displayWindow = AppStore().getState().displayWindow;
|
||||||
|
AppStore().setDisplayWindow({start: displayWindow.start, stop: displayWindow.stop + 60000});
|
||||||
|
}}/>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainDisplay({ ctx }: { ctx: DisplayModeWidget }) {
|
||||||
|
const windowMode = getAppState().displayMode === "window";
|
||||||
|
ctx.windowedDisplayRef = ctx.makeRef(<ctx.WindowedDisplay ctx={ctx}/>);
|
||||||
|
ctx.minsDisplayRef = ctx.makeRef(<ctx.MinutesDisplay ctx={ctx}/>);
|
||||||
|
return <div className={"display-mode-widget"}>
|
||||||
|
{windowMode
|
||||||
|
? ctx.fromRef(ctx.windowedDisplayRef)
|
||||||
|
: ctx.fromRef(ctx.minsDisplayRef)}
|
||||||
|
</div> as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSelectMode(mode: DisplayMode) {
|
||||||
|
AppStore().setDisplayMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay() {
|
||||||
|
if (getAppState().displayMode === "window") {
|
||||||
|
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.windowedDisplayRef));
|
||||||
|
this.fromRef(this.windowStartTimeRef).innerText = new Date(getAppState().displayWindow.start).toLocaleString();
|
||||||
|
this.fromRef(this.windowStopTimeRef).innerText = new Date(getAppState().displayWindow.stop).toLocaleString();
|
||||||
|
} else {
|
||||||
|
this.mainDisplay.children.item(0).replaceWith(this.fromRef(this.minsDisplayRef));
|
||||||
|
this.fromRef(this.minsCounterRef).innerText = getAppState().minutesDisplayed.toString();
|
||||||
|
(this.fromRef(this.minsInputRef) as HTMLInputElement).value = getAppState().minutesDisplayed.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DisplayModeWidget;
|
||||||
66
dashboard/src/GridWidget.ts
Normal file
66
dashboard/src/GridWidget.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
|
||||||
|
export interface GridPosition {
|
||||||
|
row: number | "auto";
|
||||||
|
col: number | "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSize {
|
||||||
|
width: number | "auto";
|
||||||
|
height: number | "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridProps extends GridSize, GridPosition {}
|
||||||
|
|
||||||
|
interface GridWidgetProps extends GridProps {
|
||||||
|
title?: string;
|
||||||
|
body?: HTMLElement;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class GridWidget extends UIComponent {
|
||||||
|
private container: HTMLDivElement = document.createElement("div");
|
||||||
|
private title: HTMLHeadingElement = document.createElement("h2");
|
||||||
|
private body: HTMLElement = document.createElement("div");
|
||||||
|
|
||||||
|
constructor(props: GridWidgetProps) {
|
||||||
|
super();
|
||||||
|
this.container.className = `widget${props.className ? ` ${props.className}` : ""}`;
|
||||||
|
this.title.className = "widget-title";
|
||||||
|
this.body.className = "widget-body";
|
||||||
|
this.setTitle(props.title);
|
||||||
|
this.setPosition({ row: props.row, col: props.col });
|
||||||
|
this.setSize({ width: props.width, height: props.height });
|
||||||
|
if (props.title) {
|
||||||
|
this.container.append(this.title);
|
||||||
|
}
|
||||||
|
if (props.body) {
|
||||||
|
this.body.append(props.body);
|
||||||
|
}
|
||||||
|
this.container.append(this.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosition(pos: GridPosition) {
|
||||||
|
this.container.style.gridRowStart = `${pos.row}`;
|
||||||
|
this.container.style.gridColumnStart = `${pos.col}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSize(size: GridSize) {
|
||||||
|
this.container.style.gridRowEnd = `span ${size.height}`;
|
||||||
|
this.container.style.gridColumnEnd = `span ${size.width}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTitle(newTitle: string) {
|
||||||
|
this.title.innerText = newTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
replaceBody(newEl: HTMLElement) {
|
||||||
|
this.body.replaceWith(newEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.container;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GridWidget;
|
||||||
71
dashboard/src/JSXFactory.ts
Normal file
71
dashboard/src/JSXFactory.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
export namespace JSX {
|
||||||
|
export interface AttributeCollection {
|
||||||
|
[name: string]: string | boolean | (() => any);
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
|
export type Element = HTMLElement;
|
||||||
|
export interface ElementClass {
|
||||||
|
render(): HTMLElement;
|
||||||
|
}
|
||||||
|
export interface IntrinsicElements {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FunctionComponent<PropsType> = (props: PropsType, children?: any[]) => HTMLElement;
|
||||||
|
|
||||||
|
export function createElement(tagName: string | FunctionComponent<JSX.AttributeCollection>, attributes: JSX.AttributeCollection | null, ...children: any[]): HTMLElement {
|
||||||
|
if (typeof tagName === "function") {
|
||||||
|
if (children.length >= 1) {
|
||||||
|
return tagName({...attributes}, children);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return tagName({...attributes});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return standardElement(tagName, attributes, ...children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function standardElement(tagName: string, attributes: JSX.AttributeCollection | null, ...children: any[]) {
|
||||||
|
const element = document.createElement(tagName);
|
||||||
|
for (const key in attributes) {
|
||||||
|
const attributeValue = attributes[key];
|
||||||
|
if (key.startsWith("on") && typeof attributeValue === "function") {
|
||||||
|
element.addEventListener(key.substring(2), attributeValue);
|
||||||
|
}
|
||||||
|
else if (typeof attributeValue === "boolean" && attributeValue === true) {
|
||||||
|
element.setAttribute(key, "");
|
||||||
|
}
|
||||||
|
else if (typeof attributeValue === "string") {
|
||||||
|
if (key === "className") {
|
||||||
|
element.setAttribute("class", attributes[key]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
element.setAttribute(key, attributeValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
element.append(...createChildren(children));
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChildren(children: any[]): Node[] {
|
||||||
|
const childrenNodes: Node[] = [];
|
||||||
|
for (const child of children) {
|
||||||
|
if (typeof child === "undefined" || child === null || typeof child === "boolean") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
childrenNodes.push(...createChildren(child));
|
||||||
|
}
|
||||||
|
else if (typeof child === "string") {
|
||||||
|
childrenNodes.push(document.createTextNode(String(child)));
|
||||||
|
}
|
||||||
|
else if (child instanceof Node) {
|
||||||
|
childrenNodes.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return childrenNodes;
|
||||||
|
}
|
||||||
74
dashboard/src/ListCache.ts
Normal file
74
dashboard/src/ListCache.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import {AppStore} from "./StateStore";
|
||||||
|
|
||||||
|
type ListCacheLoader<I, D> = (start: I, stop: I) => Promise<D[]>;
|
||||||
|
type ListCacheComparator<I, D> = (data: D, index: I) => -1 | 1 | 0;
|
||||||
|
|
||||||
|
class ListCache<IndexType, DataType> {
|
||||||
|
private cache: DataType[];
|
||||||
|
private loader: ListCacheLoader<IndexType, DataType>;
|
||||||
|
private comparator: ListCacheComparator<IndexType, DataType>;
|
||||||
|
|
||||||
|
constructor(loader: ListCacheLoader<IndexType, DataType>, comparator: ListCacheComparator<IndexType, DataType>) {
|
||||||
|
this.cache = [];
|
||||||
|
this.loader = loader;
|
||||||
|
this.comparator = comparator;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCache() {
|
||||||
|
return this.cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotsBetween(start: IndexType, stop: IndexType): Promise<DataType[]> {
|
||||||
|
return this.cachedBetween(start, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFromWindow(start: IndexType, stop: IndexType) {
|
||||||
|
if (!this.cacheValidForWindow(start, stop)) {
|
||||||
|
await this.fetchMissingElementsBetween(start, stop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cacheValidForWindow(start: IndexType, stop: IndexType) {
|
||||||
|
if (this.cache.length > 0) {
|
||||||
|
return this.comparator(this.cache[0], start) !== 1
|
||||||
|
&& this.comparator(this.cache[this.cache.length - 1], stop) !== -1;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchMissingElementsBetween(start: IndexType, stop: IndexType) {
|
||||||
|
AppStore().addLoad();
|
||||||
|
this.cache = await this.loader(start, stop);
|
||||||
|
AppStore().finishLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
private cachedBetween(start: IndexType, stop: IndexType): DataType[] {
|
||||||
|
if (this.cache.length <= 0) {
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
return this.cache.slice(
|
||||||
|
this.findIndexInCache(start),
|
||||||
|
this.findIndexInCache(stop)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findIndexInCache(soughtVal: IndexType, listStart = 0, listStop: number = this.cache.length): number {
|
||||||
|
if (listStop - listStart === 1) {
|
||||||
|
return listStart;
|
||||||
|
} else {
|
||||||
|
const middle = Math.floor((listStop + listStart) / 2);
|
||||||
|
const comparison = this.comparator(this.cache[middle], soughtVal);
|
||||||
|
if (comparison === 1) {
|
||||||
|
return this.findIndexInCache(soughtVal, listStart, middle);
|
||||||
|
} else if (comparison === -1) {
|
||||||
|
return this.findIndexInCache(soughtVal, middle, listStop);
|
||||||
|
} else {
|
||||||
|
return middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ListCache;
|
||||||
62
dashboard/src/MessageOverlay.ts
Normal file
62
dashboard/src/MessageOverlay.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {AppStore, getAppState} from "./StateStore";
|
||||||
|
import {UIComponent} from "./AppUI";
|
||||||
|
|
||||||
|
class MessageOverlay implements UIComponent {
|
||||||
|
private element: HTMLDivElement;
|
||||||
|
private textElement: HTMLSpanElement;
|
||||||
|
private showingError: boolean = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.build();
|
||||||
|
AppStore().subscribe("overlayText", () => this.update());
|
||||||
|
AppStore().subscribe("isLoading", () => this.update());
|
||||||
|
AppStore().subscribe("fatalError", () => this.showError())
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
private build() {
|
||||||
|
this.element = document.createElement('div');
|
||||||
|
this.element.classList.add('overlay', 'center');
|
||||||
|
this.textElement = document.createElement('span');
|
||||||
|
this.textElement.innerText = "";
|
||||||
|
this.element.appendChild(this.textElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
private show() {
|
||||||
|
this.element.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private hide() {
|
||||||
|
this.element.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
private showError() {
|
||||||
|
const err = getAppState().fatalError;
|
||||||
|
this.showingError = true;
|
||||||
|
this.element.innerText = `${err.name}: ${err.message}!`;
|
||||||
|
this.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
if (!this.showingError) {
|
||||||
|
let text: string;
|
||||||
|
if (getAppState().isLoading) {
|
||||||
|
text = "Loading...";
|
||||||
|
} else if (getAppState().overlayText) {
|
||||||
|
text = getAppState().overlayText;
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
this.textElement.innerText = text;
|
||||||
|
this.show();
|
||||||
|
} else {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MessageOverlay;
|
||||||
61
dashboard/src/SelectDisplayModeWidget.tsx
Normal file
61
dashboard/src/SelectDisplayModeWidget.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
import * as JSX from "./JSXFactory";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {AppStore, DisplayMode, getAppState} from "./StateStore";
|
||||||
|
|
||||||
|
export default class SelectDisplayModeWidget extends UIComponent {
|
||||||
|
private mainBody: HTMLElement;
|
||||||
|
private gridWidgetSkeleton: GridWidget;
|
||||||
|
private windowInputRef: number;
|
||||||
|
private minSpanInputRef: number;
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
super();
|
||||||
|
this.mainBody = this.MainBody({ctx: this});
|
||||||
|
this.gridWidgetSkeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
title: "Display Mode:",
|
||||||
|
body: this.mainBody,
|
||||||
|
});
|
||||||
|
AppStore().subscribe("displayMode", () => this.update());
|
||||||
|
}
|
||||||
|
|
||||||
|
private selectMode(mode: DisplayMode) {
|
||||||
|
AppStore().setDisplayMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
const windowedMode = getAppState().displayMode === "window";
|
||||||
|
(this.fromRef(this.windowInputRef) as HTMLInputElement).checked = windowedMode;
|
||||||
|
(this.fromRef(this.minSpanInputRef) as HTMLInputElement).checked = !windowedMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainBody({ ctx }: { ctx: SelectDisplayModeWidget }) {
|
||||||
|
const isInWindowMode = getAppState().displayMode === "window";
|
||||||
|
ctx.windowInputRef = this.makeRef(<input
|
||||||
|
type={"radio"}
|
||||||
|
id={"window"}
|
||||||
|
name={"display-mode"}
|
||||||
|
checked={isInWindowMode}
|
||||||
|
onclick={() => ctx.selectMode("window")}/>);
|
||||||
|
ctx.minSpanInputRef = this.makeRef(<input
|
||||||
|
type={"radio"}
|
||||||
|
id={"min-span"}
|
||||||
|
name={"display-mode"}
|
||||||
|
checked={!isInWindowMode}
|
||||||
|
onclick={() => ctx.selectMode("pastMins")}/>);
|
||||||
|
return (<div>
|
||||||
|
<div>
|
||||||
|
{this.fromRef(ctx.windowInputRef)}
|
||||||
|
<label htmlFor={"window"}>Time Window</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{this.fromRef(ctx.minSpanInputRef)}
|
||||||
|
<label htmlFor={"minSpan"}>Rolling Minute Span</label>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.gridWidgetSkeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
9
dashboard/src/Snapshot.ts
Normal file
9
dashboard/src/Snapshot.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
interface Snapshot {
|
||||||
|
id: number,
|
||||||
|
temp: number,
|
||||||
|
humidity: number,
|
||||||
|
co2: number,
|
||||||
|
time: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Snapshot;
|
||||||
212
dashboard/src/StateStore.ts
Normal file
212
dashboard/src/StateStore.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Snapshot from "./Snapshot";
|
||||||
|
import ListCache from "./ListCache";
|
||||||
|
|
||||||
|
export class AppStateError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AppStateError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DisplayMode = "window" | "pastMins";
|
||||||
|
|
||||||
|
export interface TimeWindow {
|
||||||
|
start: number;
|
||||||
|
stop: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
lastUpdateTime: number;
|
||||||
|
displayWindow: TimeWindow;
|
||||||
|
minutesDisplayed: number;
|
||||||
|
utcOffset: number;
|
||||||
|
snapshots: Snapshot[];
|
||||||
|
overlayText: string;
|
||||||
|
dataEndpointBase: string;
|
||||||
|
updateIntervalSeconds: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
displayMode: DisplayMode;
|
||||||
|
fatalError: Error | null;
|
||||||
|
documentReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoreUpdateCallback<T> = (newValue?: T, oldValue?: T) => void;
|
||||||
|
type SubscriptionType<T, K extends keyof T> = Record<K, StoreUpdateCallback<T[K]>[]>;
|
||||||
|
type IAppStateSubscriptions = SubscriptionType<AppState, keyof AppState>;
|
||||||
|
|
||||||
|
class AppStateStore {
|
||||||
|
private readonly subscriptions: IAppStateSubscriptions;
|
||||||
|
private readonly state: AppState;
|
||||||
|
private initialised = false;
|
||||||
|
private loaders = 0;
|
||||||
|
private readonly climateDataStore: ListCache<number, Snapshot> = new ListCache<number, Snapshot>(
|
||||||
|
async (start, stop) => {
|
||||||
|
const dataEndpoint = `${ this.state.dataEndpointBase }/snapshots?from=${ new Date(start).toISOString() }${stop ? `&to=${new Date(stop).toISOString()}` : ""}`;
|
||||||
|
const payload = await fetch(dataEndpoint);
|
||||||
|
return ((await payload.json()) as any).snapshots.reverse();
|
||||||
|
},
|
||||||
|
(data, index) => {
|
||||||
|
const time = new Date(data.time).getTime();
|
||||||
|
if (time + getAppState().updateIntervalSeconds * 1000 > index) {
|
||||||
|
return 1;
|
||||||
|
} else if (time - getAppState().updateIntervalSeconds * 1000< index) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor(initialState: AppState) {
|
||||||
|
this.state = initialState;
|
||||||
|
const subscriptions: Record<string, (() => unknown)[]> = {};
|
||||||
|
for (const key in this.state) {
|
||||||
|
subscriptions[key] = [];
|
||||||
|
}
|
||||||
|
this.subscriptions = subscriptions as IAppStateSubscriptions;
|
||||||
|
setInterval(() => this.updateClimateData(), this.state.updateIntervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
if (!this.initialised) {
|
||||||
|
await this.updateClimateData();
|
||||||
|
this.initialised = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private notify(subscribedValue: keyof AppState) {
|
||||||
|
for (const subscriptionCallback of this.subscriptions[subscribedValue]) {
|
||||||
|
new Promise(() => subscriptionCallback());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateClimateData() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
if (this.state.displayMode === "window") {
|
||||||
|
await this.climateDataStore.updateFromWindow(
|
||||||
|
this.state.displayWindow.start,
|
||||||
|
this.state.displayWindow.stop
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.climateDataStore.updateFromWindow(
|
||||||
|
now - this.state.minutesDisplayed * 60000,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.setLastUpdateTime(now);
|
||||||
|
this.setSnapshots(this.climateDataStore.getCache());
|
||||||
|
}
|
||||||
|
|
||||||
|
async snapshotsBetween(start: number, stop: number) {
|
||||||
|
return this.climateDataStore.snapshotsBetween(start, stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): AppState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<T extends keyof AppState>(dataName: T, callback: StoreUpdateCallback<AppState[T]>) {
|
||||||
|
this.subscriptions[dataName].push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayMode(mode: DisplayMode) {
|
||||||
|
this.state.displayMode = mode;
|
||||||
|
this.notify("displayMode");
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplayWindow(newWin: TimeWindow) {
|
||||||
|
if (newWin.start < newWin.stop) {
|
||||||
|
this.state.displayWindow = {...newWin};
|
||||||
|
this.notify("displayWindow");
|
||||||
|
this.updateClimateData();
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Invalid display window from ${newWin.start} to ${newWin.stop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMinutesDisplayed(mins: number) {
|
||||||
|
if (mins > 0) {
|
||||||
|
this.state.minutesDisplayed = Math.ceil(mins);
|
||||||
|
this.notify("minutesDisplayed");
|
||||||
|
this.updateClimateData();
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Invalid minutes passed: ${mins}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUtcOffset(newOffset: number) {
|
||||||
|
if (Math.floor(newOffset) === newOffset && newOffset <= 14 && newOffset >= -12) {
|
||||||
|
this.state.utcOffset = newOffset;
|
||||||
|
this.notify("snapshots");
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Invalid UTC offset: ${newOffset}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setLastUpdateTime(newTime: number) {
|
||||||
|
if (this.state.lastUpdateTime <= newTime) {
|
||||||
|
this.state.lastUpdateTime = newTime;
|
||||||
|
this.notify("lastUpdateTime");
|
||||||
|
} else {
|
||||||
|
throw new AppStateError(`Bad new update time was before last update time. Old: ${this.state.lastUpdateTime}, New: ${newTime}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOverlayText(text: string) {
|
||||||
|
this.state.overlayText = text;
|
||||||
|
this.notify("overlayText");
|
||||||
|
}
|
||||||
|
|
||||||
|
addLoad() {
|
||||||
|
this.loaders += 1;
|
||||||
|
this.state.isLoading = this.loaders > 0;
|
||||||
|
this.notify("isLoading");
|
||||||
|
}
|
||||||
|
|
||||||
|
finishLoad() {
|
||||||
|
this.loaders -= 1;
|
||||||
|
this.state.isLoading = this.loaders > 0;
|
||||||
|
this.notify("isLoading");
|
||||||
|
}
|
||||||
|
|
||||||
|
fatalError(err: Error) {
|
||||||
|
if (!this.state.fatalError) {
|
||||||
|
this.state.fatalError = err;
|
||||||
|
this.notify("fatalError");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setDocumentReady(isReady: boolean) {
|
||||||
|
this.state.documentReady = isReady;
|
||||||
|
this.notify("documentReady");
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSnapshots(snapshots: Snapshot[]) {
|
||||||
|
this.state.snapshots = snapshots;
|
||||||
|
this.notify("snapshots");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let store: AppStateStore;
|
||||||
|
|
||||||
|
export async function initStore(initialState: AppState) {
|
||||||
|
store = new AppStateStore(initialState);
|
||||||
|
await store.init();
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppStore() {
|
||||||
|
if (store) {
|
||||||
|
return store;
|
||||||
|
} else {
|
||||||
|
throw new AppStateError("Store not yet initialised!");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppState() {
|
||||||
|
if (store) {
|
||||||
|
return store.getState();
|
||||||
|
} else {
|
||||||
|
throw new AppStateError("Store not yet initialised!");
|
||||||
|
}
|
||||||
|
}
|
||||||
62
dashboard/src/TimerWidget.tsx
Normal file
62
dashboard/src/TimerWidget.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import {AppStore, getAppState} from "./StateStore";
|
||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
import * as JSX from "./JSXFactory";
|
||||||
|
|
||||||
|
class TimerWidget extends UIComponent {
|
||||||
|
private readonly display: HTMLElement;
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private nextUpdateTime: number;
|
||||||
|
private timerRef: number;
|
||||||
|
private lastUpdateRef: number;
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
super();
|
||||||
|
this.display = <this.MainDisplay ctx={this}/>;
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
className: "timer-widget",
|
||||||
|
title: "Next update in:",
|
||||||
|
body: this.display,
|
||||||
|
});
|
||||||
|
AppStore().subscribe("lastUpdateTime", () => this.resetTimer());
|
||||||
|
setInterval(() => this.refreshTimer(), 10);
|
||||||
|
this.resetTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTimer() {
|
||||||
|
this.nextUpdateTime = getAppState().lastUpdateTime + getAppState().updateIntervalSeconds * 1000;
|
||||||
|
this.fromRef(this.lastUpdateRef).innerText = new Date(getAppState().lastUpdateTime).toLocaleString();
|
||||||
|
this.refreshTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MainDisplay({ ctx }: { ctx: TimerWidget }) {
|
||||||
|
ctx.timerRef = ctx.makeRef(<div className={"countdown"}/>);
|
||||||
|
ctx.lastUpdateRef = ctx.makeRef(
|
||||||
|
<span className={"last-update"}>
|
||||||
|
{new Date(getAppState().lastUpdateTime).toLocaleString()}
|
||||||
|
</span>);
|
||||||
|
return (<div>
|
||||||
|
{ctx.fromRef(ctx.timerRef)}
|
||||||
|
<div>
|
||||||
|
<div className={"last-update"}>Last update was at:</div>
|
||||||
|
<div>{ctx.fromRef(ctx.lastUpdateRef)}</div>
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshTimer() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
if (now <= this.nextUpdateTime) {
|
||||||
|
this.fromRef(this.timerRef).innerText = `${((this.nextUpdateTime - now)/1000).toFixed(2)}s`;
|
||||||
|
} else {
|
||||||
|
this.fromRef(this.timerRef).innerText = "0.00s";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimerWidget;
|
||||||
30
dashboard/src/TimezoneWidget.ts
Normal file
30
dashboard/src/TimezoneWidget.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import GridWidget, {GridProps} from "./GridWidget";
|
||||||
|
import {AppStore} from "./StateStore";
|
||||||
|
import UIComponent from "./UIComponent";
|
||||||
|
|
||||||
|
class TimezoneWidget extends UIComponent {
|
||||||
|
private skeleton: GridWidget;
|
||||||
|
private display: HTMLSpanElement = document.createElement("span");
|
||||||
|
|
||||||
|
constructor(gridProps: GridProps) {
|
||||||
|
super();
|
||||||
|
this.skeleton = new GridWidget({
|
||||||
|
...gridProps,
|
||||||
|
title: "Displayed Timezone:",
|
||||||
|
body: this.display,
|
||||||
|
});
|
||||||
|
AppStore().subscribe("utcOffset", () => this.updateDisplay());
|
||||||
|
this.updateDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateDisplay() {
|
||||||
|
const offset = AppStore().getState().utcOffset;
|
||||||
|
this.display.innerText = `UTC ${offset > 1 ? "+" : "-"} ${Math.abs(offset)}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
current() {
|
||||||
|
return this.skeleton.current();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimezoneWidget;
|
||||||
22
dashboard/src/UIComponent.ts
Normal file
22
dashboard/src/UIComponent.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export default abstract class UIComponent {
|
||||||
|
public readonly id: number;
|
||||||
|
private static componentCount = 0;
|
||||||
|
private static reffedComponentCount = 0;
|
||||||
|
private static readonly reffedComponents: HTMLElement[] = [];
|
||||||
|
|
||||||
|
protected constructor() {
|
||||||
|
this.id = UIComponent.componentCount;
|
||||||
|
UIComponent.componentCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected makeRef(el: HTMLElement | DocumentFragment): number {
|
||||||
|
UIComponent.reffedComponents.push(el as HTMLElement);
|
||||||
|
return UIComponent.reffedComponentCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fromRef(ref: number): HTMLElement | null {
|
||||||
|
return UIComponent.reffedComponents[ref] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract current(): HTMLElement;
|
||||||
|
}
|
||||||
97
dashboard/src/climateChartConfig.ts
Normal file
97
dashboard/src/climateChartConfig.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {ChartConfiguration, ChartPoint, TimeUnit} from "chart.js";
|
||||||
|
|
||||||
|
interface ClimateChartSettings {
|
||||||
|
humidity?: ChartPoint[];
|
||||||
|
temp?: ChartPoint[];
|
||||||
|
co2?: ChartPoint[];
|
||||||
|
colors?: {
|
||||||
|
humidity?: string;
|
||||||
|
temp?: string;
|
||||||
|
co2?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultHumidityColor = "rgb(196,107,107)";
|
||||||
|
const defaultTempColor = "rgb(173,136,68)";
|
||||||
|
const defaultCo2Color = "rgb(52,133,141)";
|
||||||
|
|
||||||
|
export function generateClimateChartConfig(settings: ClimateChartSettings): ChartConfiguration {
|
||||||
|
return {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
label: "Humidity",
|
||||||
|
data: settings.humidity,
|
||||||
|
borderColor: settings.colors?.humidity ?? defaultHumidityColor,
|
||||||
|
fill: false,
|
||||||
|
yAxisID: "y-axis-3",
|
||||||
|
}, {
|
||||||
|
label: "Temperature",
|
||||||
|
data: settings.temp,
|
||||||
|
borderColor: settings.colors?.temp ?? defaultTempColor,
|
||||||
|
fill: false,
|
||||||
|
yAxisID: "y-axis-2",
|
||||||
|
}, {
|
||||||
|
label: "Co2 Concentration",
|
||||||
|
data: settings.co2,
|
||||||
|
borderColor: settings.colors?.co2 ?? defaultCo2Color,
|
||||||
|
fill: false,
|
||||||
|
yAxisID: "y-axis-1",
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: {animateRotate: false, duration: 0, animateScale: false},
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
legend: {
|
||||||
|
position: "top",
|
||||||
|
align: "end",
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
xAxes: [{
|
||||||
|
type: "time",
|
||||||
|
time: {
|
||||||
|
unit: "second" as TimeUnit
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
yAxes: [{
|
||||||
|
type: "linear",
|
||||||
|
display: true,
|
||||||
|
position: "right",
|
||||||
|
id: "y-axis-1",
|
||||||
|
ticks: {
|
||||||
|
fontColor: settings.colors?.co2 ?? defaultCo2Color,
|
||||||
|
suggestedMin: 400,
|
||||||
|
suggestedMax: 1100,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
type: "linear",
|
||||||
|
display: true,
|
||||||
|
position: "left",
|
||||||
|
id: "y-axis-2",
|
||||||
|
ticks: {
|
||||||
|
fontColor: settings.colors?.temp ?? defaultTempColor,
|
||||||
|
suggestedMin: 10,
|
||||||
|
suggestedMax: 35,
|
||||||
|
},
|
||||||
|
gridLines: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
type: "linear",
|
||||||
|
display: true,
|
||||||
|
position: "left",
|
||||||
|
id: "y-axis-3",
|
||||||
|
ticks: {
|
||||||
|
fontColor: settings.colors?.humidity ?? defaultHumidityColor,
|
||||||
|
suggestedMin: 15,
|
||||||
|
suggestedMax: 85,
|
||||||
|
},
|
||||||
|
gridLines: {
|
||||||
|
drawOnChartArea: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
6
dashboard/src/config.json
Normal file
6
dashboard/src/config.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"development": true,
|
||||||
|
"defaultMinuteSpan": 60,
|
||||||
|
"reloadIntervalSec": 30,
|
||||||
|
"dataEndpoint": "/climate/api"
|
||||||
|
}
|
||||||
48
dashboard/src/main.ts
Normal file
48
dashboard/src/main.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import config from "./config.json";
|
||||||
|
import {AppStore, initStore} from "./StateStore";
|
||||||
|
import AppUI from "./AppUI";
|
||||||
|
export {config};
|
||||||
|
|
||||||
|
function getDisplayedMinutes() {
|
||||||
|
let minutesDisplayed = config.defaultMinuteSpan;
|
||||||
|
const argsStart = window.location.search.search(/\?minute-span=/);
|
||||||
|
if (argsStart !== -1) {
|
||||||
|
const parsedMins = Number(window.location.search.substring(13));
|
||||||
|
if (!isNaN(parsedMins) && parsedMins > 0) {
|
||||||
|
minutesDisplayed = parsedMins;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return minutesDisplayed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUtcOffset() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
await initStore({
|
||||||
|
overlayText: "",
|
||||||
|
lastUpdateTime: now,
|
||||||
|
minutesDisplayed: getDisplayedMinutes(),
|
||||||
|
utcOffset: getUtcOffset(),
|
||||||
|
snapshots: [],
|
||||||
|
dataEndpointBase: config.dataEndpoint,
|
||||||
|
isLoading: false,
|
||||||
|
updateIntervalSeconds: config.reloadIntervalSec,
|
||||||
|
displayMode: "pastMins",
|
||||||
|
fatalError: null,
|
||||||
|
displayWindow: {start: now - getDisplayedMinutes() * 60000, stop: now},
|
||||||
|
documentReady: false,
|
||||||
|
});
|
||||||
|
const ui = new AppUI();
|
||||||
|
ui.bootstrap("root");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.onreadystatechange = async () => {
|
||||||
|
await init();
|
||||||
|
AppStore().setDocumentReady(true);
|
||||||
|
// @ts-ignore
|
||||||
|
window.store = AppStore();
|
||||||
|
document.onreadystatechange = null;
|
||||||
|
};
|
||||||
4
dashboard/src/types.d.ts
vendored
Normal file
4
dashboard/src/types.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "chart.js/dist/Chart.bundle.min" {
|
||||||
|
import * as Charts from "chart.js";
|
||||||
|
export default Charts;
|
||||||
|
}
|
||||||
14
dashboard/tsconfig.json
Normal file
14
dashboard/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"target": "esnext",
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "JSX.createElement"
|
||||||
|
},
|
||||||
|
"include": ["./src/**/*"]
|
||||||
|
}
|
||||||
71
dashboard/webpack.config.js
Normal file
71
dashboard/webpack.config.js
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const webpack = require("webpack");
|
||||||
|
const config = require("./src/config.json");
|
||||||
|
|
||||||
|
const TerserPlugin = require("terser-webpack-plugin");
|
||||||
|
|
||||||
|
const webpackConfig = {
|
||||||
|
mode: "development",
|
||||||
|
entry: "./src/main.ts",
|
||||||
|
plugins: [new webpack.ProgressPlugin()],
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [{
|
||||||
|
test: /\.(ts|tsx)$/,
|
||||||
|
loader: "ts-loader",
|
||||||
|
include: [path.resolve(__dirname, "src")],
|
||||||
|
exclude: [/node_modules/]
|
||||||
|
}, {
|
||||||
|
test: /.css$/,
|
||||||
|
|
||||||
|
use: [{
|
||||||
|
loader: "style-loader"
|
||||||
|
}, {
|
||||||
|
loader: "css-loader",
|
||||||
|
options: {
|
||||||
|
sourceMap: true
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
extensions: [".tsx", ".ts", ".js"]
|
||||||
|
},
|
||||||
|
|
||||||
|
output: {
|
||||||
|
filename: "dashboard.js",
|
||||||
|
path: path.resolve(__dirname, "../app-dist/static/"),
|
||||||
|
},
|
||||||
|
|
||||||
|
devServer: {
|
||||||
|
contentBase: path.join(__dirname, "../app-dist/static/"),
|
||||||
|
contentBasePublicPath: "/",
|
||||||
|
port: 3000,
|
||||||
|
publicPath: "http://localhost:3000/",
|
||||||
|
hotOnly: true
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.development) {
|
||||||
|
webpackConfig.optimization = {
|
||||||
|
minimizer: [new TerserPlugin()],
|
||||||
|
|
||||||
|
splitChunks: {
|
||||||
|
cacheGroups: {
|
||||||
|
vendors: {
|
||||||
|
priority: -10,
|
||||||
|
test: /[\\/]node_modules[\\/]/
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
chunks: "async",
|
||||||
|
minChunks: 1,
|
||||||
|
minSize: 30000,
|
||||||
|
name: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
webpackConfig.mode = "production";
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {...webpackConfig};
|
||||||
2293
package-lock.json
generated
Normal file
2293
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "climate-server",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "Server API and frontend dashboard for monitoring climate stats with the Raspberry Pi",
|
||||||
|
"scripts": {
|
||||||
|
"start": "concurrently -p \"[{name}]\" -n \"server,dashboard\" -c \"bgBlue.gray.bold,bgMagenta.gray.bold\" \"npm:server\" \"npm:dashboard-static\"",
|
||||||
|
"build": "sh ./build.sh",
|
||||||
|
"server": "cd ./server && npm run dev",
|
||||||
|
"dashboard-static": "cd ./dashboard && npm run build-watch",
|
||||||
|
"dashboard-serve": "cd ./dashboard && npm run dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"child-process-promise": "^2.2.1",
|
||||||
|
"dotenv": "^8.2.0",
|
||||||
|
"ejs": "^3.1.6",
|
||||||
|
"express": "^5.0.0-alpha.8",
|
||||||
|
"mysql2": "^2.2.5",
|
||||||
|
"node-fetch": "^2.6.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/child-process-promise": "^2.2.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.17.0",
|
||||||
|
"@typescript-eslint/parser": "^4.17.0",
|
||||||
|
"concurrently": "^6.0.0",
|
||||||
|
"eslint": "^7.21.0",
|
||||||
|
"prettier": "^2.1.2",
|
||||||
|
"typescript": "^4.2.3"
|
||||||
|
},
|
||||||
|
"author": "Daniel Ledda",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
6
server/.env
Normal file
6
server/.env
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
PORT=4040
|
||||||
|
SERVER_ROOT=/climate
|
||||||
|
MYSQL_ADDRESS=192.168.0.198
|
||||||
|
MYSQL_USERNAME=admin
|
||||||
|
MYSQL_PW=sekna123jk
|
||||||
|
SENSOR_PING_INTERVAL=30
|
||||||
156
server/package-lock.json
generated
Normal file
156
server/package-lock.json
generated
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
{
|
||||||
|
"name": "climate-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"requires": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/body-parser": {
|
||||||
|
"version": "1.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz",
|
||||||
|
"integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/connect": {
|
||||||
|
"version": "3.4.34",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.34.tgz",
|
||||||
|
"integrity": "sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/dotenv": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"dotenv": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/express": {
|
||||||
|
"version": "4.17.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.11.tgz",
|
||||||
|
"integrity": "sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^4.17.18",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/serve-static": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/express-serve-static-core": {
|
||||||
|
"version": "4.17.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.18.tgz",
|
||||||
|
"integrity": "sha512-m4JTwx5RUBNZvky/JJ8swEJPKFd8si08pPF2PfizYjGZOKr/svUWPcoUmLow6MmPzhasphB7gSTINY67xn3JNA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/mime": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"version": "14.14.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.31.tgz",
|
||||||
|
"integrity": "sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"@types/node-fetch": {
|
||||||
|
"version": "2.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz",
|
||||||
|
"integrity": "sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"form-data": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/qs": {
|
||||||
|
"version": "6.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.5.tgz",
|
||||||
|
"integrity": "sha512-/JHkVHtx/REVG0VVToGRGH2+23hsYLHdyG+GrvoUGlGAd0ErauXDyvHtRI/7H7mzLm+tBCKA7pfcpkQ1lf58iQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"@types/range-parser": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"@types/serve-static": {
|
||||||
|
"version": "1.13.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.9.tgz",
|
||||||
|
"integrity": "sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/mime": "^1",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"asynckit": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
|
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"combined-stream": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"delayed-stream": "~1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delayed-stream": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"dotenv": {
|
||||||
|
"version": "8.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
|
||||||
|
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"form-data": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"asynckit": "^0.4.0",
|
||||||
|
"combined-stream": "^1.0.8",
|
||||||
|
"mime-types": "^2.1.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mime-db": {
|
||||||
|
"version": "1.46.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz",
|
||||||
|
"integrity": "sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"mime-types": {
|
||||||
|
"version": "2.1.29",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz",
|
||||||
|
"integrity": "sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"mime-db": "1.46.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
server/package.json
Normal file
12
server/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "climate-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node-fetch": "^2.5.8",
|
||||||
|
"@types/dotenv": "^8.2.0",
|
||||||
|
"@types/express": "^4.17.11"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n \"typescript,nodemon\" -c \"bgGreen.gray.bold,bgYellow.gray.bold\" \"tsc -w\" \"nodemon ../app-dist/server/main.js\""
|
||||||
|
}
|
||||||
|
}
|
||||||
13
server/src/Collections.ts
Normal file
13
server/src/Collections.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import SnapshotCollection from "./SnapshotCollection";
|
||||||
|
import {establishDatabaseConnection} from "./database";
|
||||||
|
|
||||||
|
export interface CollectionRegistry {
|
||||||
|
snapshots: SnapshotCollection
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setupCollections(): Promise<CollectionRegistry> {
|
||||||
|
const dbConnection = await establishDatabaseConnection();
|
||||||
|
return {
|
||||||
|
snapshots: new SnapshotCollection(dbConnection),
|
||||||
|
};
|
||||||
|
}
|
||||||
15
server/src/Snapshot.ts
Normal file
15
server/src/Snapshot.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface Snapshot {
|
||||||
|
time: number | string,
|
||||||
|
temp: number,
|
||||||
|
co2: number,
|
||||||
|
humidity: number,
|
||||||
|
id: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISOSnapshot extends Snapshot {
|
||||||
|
time: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnixTimeSnapshot extends Snapshot {
|
||||||
|
time: number,
|
||||||
|
}
|
||||||
86
server/src/SnapshotCollection.ts
Normal file
86
server/src/SnapshotCollection.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import {Connection, ResultSetHeader, RowDataPacket} from "mysql2/promise";
|
||||||
|
import {Snapshot, ISOSnapshot} from "./Snapshot";
|
||||||
|
import {isValidDatetime, toISOTime, toMySQLDatetime, toUnixTime} from "./utils";
|
||||||
|
import {DatabaseConnection, tryQuery} from "./database";
|
||||||
|
|
||||||
|
class SnapshotCollection {
|
||||||
|
private readonly db: Connection;
|
||||||
|
|
||||||
|
constructor(db: DatabaseConnection) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
async insertSnapshots(...snapshots: Omit<Snapshot, "id">[]) {
|
||||||
|
return tryQuery(async () => {
|
||||||
|
const query = "INSERT INTO `snapshots` (`time`, `co2`, `humidity`, `temp`) VALUES ?";
|
||||||
|
const [resultSetHeader] = await this.db.query(query, [SnapshotCollection.toMySQLRows(...snapshots)]);
|
||||||
|
return {
|
||||||
|
affectedRows: (resultSetHeader as ResultSetHeader).affectedRows,
|
||||||
|
insertId: (resultSetHeader as ResultSetHeader).insertId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestSnapshot(): Promise<ISOSnapshot | null> {
|
||||||
|
return tryQuery(async () => {
|
||||||
|
const query = "SELECT `id`, DATE_FORMAT(`time`, '%Y-%m-%dT%TZ') `time`, `co2`, `humidity`, `temp` FROM `snapshots` ORDER BY `id` DESC LIMIT 1;";
|
||||||
|
const [rows] = await this.db.query(query);
|
||||||
|
if ((rows as RowDataPacket[]).length === 0) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return SnapshotCollection.rowsToSnapshots(...(rows as RowDataPacket[]))[0];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshotsInRange(start: number | string, stop: number | string): Promise<ISOSnapshot[]> {
|
||||||
|
start = toMySQLDatetime(start);
|
||||||
|
stop = toMySQLDatetime(stop);
|
||||||
|
return tryQuery(async () => {
|
||||||
|
const query = "SELECT `id`, DATE_FORMAT(`time`, '%Y-%m-%dT%TZ') `time`, `co2`, `humidity`, `temp` FROM `snapshots` WHERE `time` BETWEEN ? AND ? ORDER BY `id` DESC;";
|
||||||
|
const [rows] = await this.db.query(query, [start, stop]);
|
||||||
|
return SnapshotCollection.rowsToSnapshots(...(rows as RowDataPacket[]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSnapshotsSince(timeSince: number | string): Promise<ISOSnapshot[]> {
|
||||||
|
timeSince = toMySQLDatetime(timeSince);
|
||||||
|
return tryQuery(async () => {
|
||||||
|
const query = "SELECT `id`, DATE_FORMAT(`time`, '%Y-%m-%dT%TZ') `time`, `co2`, `humidity`, `temp` FROM `snapshots` WHERE TIMESTAMPDIFF(SECOND, `time`, ?) < 0 ORDER BY `id` DESC;";
|
||||||
|
const [rows] = await this.db.query(query, [timeSince]);
|
||||||
|
return SnapshotCollection.rowsToSnapshots(...(rows as RowDataPacket[]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static toUnixTime<T extends {time: string | number}>(...snapshots: T[]): (T & {time: number})[] {
|
||||||
|
return snapshots.map(s => ({...s, time: toUnixTime(s.time)}));
|
||||||
|
}
|
||||||
|
|
||||||
|
static toISOTime<T extends {time: string | number}>(...snapshots: T[]): (T & {time: string})[] {
|
||||||
|
return snapshots.map(s => ({...s, time: toISOTime(s.time)}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static toMySQLRows(...snapshots: Omit<Snapshot, "id">[]): (number | string | Date)[][] {
|
||||||
|
return snapshots.map(s => [new Date(s.time), s.co2, s.humidity, s.temp]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isSubmissibleSnapshot(potentialSnapshot: Record<string, unknown>): potentialSnapshot is Omit<Snapshot, "id"> {
|
||||||
|
return typeof potentialSnapshot.temp === "number"
|
||||||
|
&& typeof potentialSnapshot.co2 === "number"
|
||||||
|
&& typeof potentialSnapshot.humidity === "number"
|
||||||
|
&& (typeof potentialSnapshot.time === "number"
|
||||||
|
|| typeof potentialSnapshot.time === "string" && isValidDatetime(potentialSnapshot.time));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static rowsToSnapshots(...rows: RowDataPacket[]): ISOSnapshot[] {
|
||||||
|
return rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
temp: row.temp,
|
||||||
|
co2: row.co2,
|
||||||
|
humidity: row.humidity,
|
||||||
|
time: row.time,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SnapshotCollection;
|
||||||
28
server/src/database.ts
Normal file
28
server/src/database.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import {GenericPersistenceError} from "./errors";
|
||||||
|
|
||||||
|
export type DatabaseConnection = mysql.Connection;
|
||||||
|
|
||||||
|
export async function establishDatabaseConnection() {
|
||||||
|
let dbConnection;
|
||||||
|
try {
|
||||||
|
dbConnection = await mysql.createConnection({
|
||||||
|
host: process.env.MYSQL_ADDRESS,
|
||||||
|
user: process.env.MYSQL_USERNAME,
|
||||||
|
password: process.env.MYSQL_PW,
|
||||||
|
database: "climate",
|
||||||
|
connectTimeout: 30000,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Couldn't establish a connection with the database: ${e.message}`);
|
||||||
|
}
|
||||||
|
return dbConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function tryQuery<T>(cb: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await cb();
|
||||||
|
} catch (err) {
|
||||||
|
throw new GenericPersistenceError(err.message, "There was an error querying the database.");
|
||||||
|
}
|
||||||
|
}
|
||||||
22
server/src/errors.ts
Normal file
22
server/src/errors.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export class ClayPIError extends Error {
|
||||||
|
displayMessage: string;
|
||||||
|
constructor(message: string, displayMessage?: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ClayPIError";
|
||||||
|
this.displayMessage = displayMessage ?? message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenericPersistenceError extends ClayPIError {
|
||||||
|
constructor(message: string, displayMessage: string) {
|
||||||
|
super(message, displayMessage);
|
||||||
|
this.name = "GenericPersistenceError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataValidationError extends ClayPIError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "DataValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
36
server/src/main.ts
Normal file
36
server/src/main.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import dotenv from "dotenv";
|
||||||
|
import express from "express";
|
||||||
|
import {newMainRouter} from "./mainRouter";
|
||||||
|
import {setupCollections} from "./Collections";
|
||||||
|
import path from "path";
|
||||||
|
import {startSensorPinger} from "./pingSensors";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const SERVER_ROOT = process.env.SERVER_ROOT ?? "/";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const collections = await setupCollections();
|
||||||
|
const mainRouter = newMainRouter(collections);
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.set("port", process.env.PORT || 3000);
|
||||||
|
app.use(express.urlencoded({ extended: false}));
|
||||||
|
app.locals = {
|
||||||
|
rootUrl: SERVER_ROOT,
|
||||||
|
};
|
||||||
|
app.set("view-engine", "ejs");
|
||||||
|
app.set("views", path.resolve(__dirname + "/../static"));
|
||||||
|
app.use(SERVER_ROOT + "/static", express.static(path.resolve(__dirname + "/../static")));
|
||||||
|
app.use(SERVER_ROOT, mainRouter);
|
||||||
|
app.listen(app.get("port"), () => {
|
||||||
|
console.log("ClayPI running on http://localhost:%d", app.get("port"));
|
||||||
|
});
|
||||||
|
startSensorPinger();
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Problem setting up the server: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
||||||
35
server/src/mainRouter.ts
Normal file
35
server/src/mainRouter.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {ClayPIError, GenericPersistenceError} from "./errors";
|
||||||
|
import newSnapshotRouter from "./snapshotRouter";
|
||||||
|
import {CollectionRegistry} from "./Collections";
|
||||||
|
|
||||||
|
export function newMainRouter(collections: CollectionRegistry) {
|
||||||
|
const router = express.Router();
|
||||||
|
const snapshotRouter = newSnapshotRouter(collections);
|
||||||
|
|
||||||
|
router.get("/dashboard", (req, res) => {
|
||||||
|
res.render("index.ejs", { rootUrl: req.app.locals.rootUrl });
|
||||||
|
});
|
||||||
|
router.use("/api/snapshots", snapshotRouter);
|
||||||
|
router.use(topLevelErrorHandler);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topLevelErrorHandler: express.ErrorRequestHandler = (err, req, res, next) => {
|
||||||
|
const errOutput = {
|
||||||
|
error: true,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
if (err instanceof GenericPersistenceError) {
|
||||||
|
errOutput.message = `An error occurred accessing the database: ${err.displayMessage}`;
|
||||||
|
}
|
||||||
|
else if (err instanceof ClayPIError) {
|
||||||
|
errOutput.message = `An error occurred: ${err.displayMessage}`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
errOutput.message = "An unknown error occurred!";
|
||||||
|
}
|
||||||
|
console.log({...errOutput, internalMessage: err.message});
|
||||||
|
res.status(500).send(errOutput);
|
||||||
|
};
|
||||||
46
server/src/pingSensors.ts
Normal file
46
server/src/pingSensors.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {ISOSnapshot} from "./Snapshot";
|
||||||
|
import {exec} from "child-process-promise";
|
||||||
|
import path from "path";
|
||||||
|
import fetch from "node-fetch";
|
||||||
|
import {ClayPIError} from "./errors";
|
||||||
|
|
||||||
|
async function pingSensors(): Promise<Omit<ISOSnapshot, "id">> {
|
||||||
|
try {
|
||||||
|
const process = await exec(`python3 ${path.resolve(__dirname + "/../scripts/pinger-test.py")}`);
|
||||||
|
const result = process.stdout;
|
||||||
|
const snapshotArray = result.split("\t").map(piece => piece.trim());
|
||||||
|
return {
|
||||||
|
time: snapshotArray[1],
|
||||||
|
temp: Number(snapshotArray[3]),
|
||||||
|
humidity: Number(snapshotArray[5]),
|
||||||
|
co2: Number(snapshotArray[7]),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw new ClayPIError(
|
||||||
|
`Could not generate a new snapshot: Python error: ${err}. Have you installed python 3 and the necessary requirements?`,
|
||||||
|
"Could not generate a new snapshot."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitToServer(snapshot: Omit<ISOSnapshot, "id">) {
|
||||||
|
await fetch(`http://localhost:${process.env.PORT}${process.env.SERVER_ROOT}/api/snapshots`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ snapshots: [snapshot] }),
|
||||||
|
headers: {
|
||||||
|
"Content-type": "application/json"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSensorPinger() {
|
||||||
|
const createAndSubmitNewSnapshot = async () => {
|
||||||
|
try {
|
||||||
|
await submitToServer(await pingSensors());
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
createAndSubmitNewSnapshot();
|
||||||
|
setInterval(createAndSubmitNewSnapshot, (Number(process.env.SENSOR_PING_INTERVAL) ?? 30) * 1000);
|
||||||
|
}
|
||||||
93
server/src/snapshotRouter.ts
Normal file
93
server/src/snapshotRouter.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import {Snapshot} from "./Snapshot";
|
||||||
|
import SnapshotCollection from "./SnapshotCollection";
|
||||||
|
import express, {Router} from "express";
|
||||||
|
import {CollectionRegistry} from "./Collections";
|
||||||
|
import {ClayPIError} from "./errors";
|
||||||
|
import {toMySQLDatetime} from "./utils";
|
||||||
|
|
||||||
|
function newSnapshotRouter(collections: CollectionRegistry) {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(unixTimeParamMiddleware);
|
||||||
|
|
||||||
|
router.get("", async (req, res) => {
|
||||||
|
const query = req.query as Record<string, string>;
|
||||||
|
const isMinutesQuery = typeof query["last-minutes"] !== "undefined" && !query.from && !query.to;
|
||||||
|
const isFromToQuery = typeof query.from !== "undefined";
|
||||||
|
let snapshots: Snapshot[] = [];
|
||||||
|
let timeFormat = res.locals.timeFormat;
|
||||||
|
if (!isMinutesQuery && !isFromToQuery) {
|
||||||
|
if (query.to) {
|
||||||
|
throw new ClayPIError("The parameter 'to' must always be accompanied by a 'from'.");
|
||||||
|
}
|
||||||
|
snapshots = await collections.snapshots.getSnapshotsSince(new Date().getTime() - 60 * 60000);
|
||||||
|
} else if (isMinutesQuery) {
|
||||||
|
const lastMinutes = Math.floor(Number(query["last-minutes"]));
|
||||||
|
if (isNaN(lastMinutes)) {
|
||||||
|
throw new ClayPIError("The parameter 'last-minutes' must be a number.");
|
||||||
|
} else {
|
||||||
|
snapshots = await collections.snapshots.getSnapshotsSince(new Date().getTime() - lastMinutes * 60000);
|
||||||
|
}
|
||||||
|
} else if (isFromToQuery) {
|
||||||
|
const timeFrom = isNaN(Number(query.from)) ? query.from : Number(query.from);
|
||||||
|
const timeTo = isNaN(Number(query.to)) ? query.to : Number(query.to);
|
||||||
|
if (timeTo) {
|
||||||
|
if (!timeFormat && typeof timeFrom === typeof timeTo) {
|
||||||
|
timeFormat = typeof timeFrom === "string" ? "iso" : "unix";
|
||||||
|
}
|
||||||
|
snapshots = await collections.snapshots.getSnapshotsInRange(timeFrom, timeTo);
|
||||||
|
} else {
|
||||||
|
snapshots = await collections.snapshots.getSnapshotsSince(timeFrom);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new ClayPIError("Malformed request.");
|
||||||
|
}
|
||||||
|
if (timeFormat === "unix") {
|
||||||
|
snapshots = SnapshotCollection.toUnixTime(...snapshots);
|
||||||
|
} else if (timeFormat === "iso") {
|
||||||
|
snapshots = SnapshotCollection.toISOTime(...snapshots);
|
||||||
|
}
|
||||||
|
res.send({snapshots});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/latest", async (req, res) => {
|
||||||
|
let snapshot;
|
||||||
|
snapshot = await collections.snapshots.getLatestSnapshot();
|
||||||
|
if (!snapshot) {
|
||||||
|
res.send({snapshots: []});
|
||||||
|
} else {
|
||||||
|
if (res.locals.timeFormat === "unix") {
|
||||||
|
snapshot = SnapshotCollection.toUnixTime(...[snapshot])[0];
|
||||||
|
} else if (res.locals.timeFormat === "iso") {
|
||||||
|
snapshot = SnapshotCollection.toISOTime(...[snapshot])[0];
|
||||||
|
}
|
||||||
|
res.send({snapshots: [snapshot]});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/", async (req, res) => {
|
||||||
|
const goodRequest = req.body.snapshots
|
||||||
|
&& req.body.snapshots.length === 1
|
||||||
|
&& SnapshotCollection.isSubmissibleSnapshot(req.body.snapshots[0]);
|
||||||
|
if (!goodRequest) {
|
||||||
|
throw new ClayPIError("The request must contain the property 'snapshots' as an array with exactly one snapshot.");
|
||||||
|
} else {
|
||||||
|
const result = await collections.snapshots.insertSnapshots(req.body.snapshots[0]);
|
||||||
|
res.send({message: "Success!", ...result});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unixTimeParamMiddleware: express.Handler = (req, res, next) => {
|
||||||
|
const timeFormat = req.query.timeFormat;
|
||||||
|
if (typeof timeFormat !== "undefined" && timeFormat !== "iso" && timeFormat !== "unix") {
|
||||||
|
throw new ClayPIError("Parameter 'timeFormat' must be either 'iso' or 'unix'");
|
||||||
|
} else {
|
||||||
|
res.locals.timeFormat = timeFormat;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default newSnapshotRouter;
|
||||||
26
server/src/utils.ts
Normal file
26
server/src/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {DataValidationError} from "./errors";
|
||||||
|
|
||||||
|
export function toMySQLDatetime(datetime: number | string) {
|
||||||
|
try {
|
||||||
|
return new Date(datetime).toISOString().slice(0, 19).replace("T", " ");
|
||||||
|
} catch (e) {
|
||||||
|
throw new DataValidationError(`Bad datetime value: ${datetime}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDatetime(datetime: string) {
|
||||||
|
try {
|
||||||
|
new Date(datetime);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUnixTime(datetime: string | number) {
|
||||||
|
return new Date(datetime).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISOTime(datetime: string | number) {
|
||||||
|
return new Date(datetime).toISOString();
|
||||||
|
}
|
||||||
23
server/tsconfig.json
Normal file
23
server/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../app-dist/server",
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "ES6",
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
},
|
||||||
|
"include": ["./src/**/*"],
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user