Starting anew, before I changed to a custom chart

This commit is contained in:
Daniel Ledda
2021-03-14 18:42:25 +01:00
commit 50362860ae
47 changed files with 13982 additions and 0 deletions

38
.eslintrc.json Normal file
View 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
View File

@@ -0,0 +1,4 @@
.idea
node_modules
app-dist/server
climate-server

82
README.md Normal file
View 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
},
// ...
]
}
```

View 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()

View 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

File diff suppressed because one or more lines are too long

12
app-dist/static/index.ejs Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
dashboard/package.json Normal file
View 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
View 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
View 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();
}
}

View 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;

View 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;

View 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;

View 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;
}

View 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;

View 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;

View 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();
}
}

View 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
View 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!");
}
}

View 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;

View 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;

View 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;
}

View 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,
},
}],
}
},
};
}

View File

@@ -0,0 +1,6 @@
{
"development": true,
"defaultMinuteSpan": 60,
"reloadIntervalSec": 30,
"dataEndpoint": "/climate/api"
}

48
dashboard/src/main.ts Normal file
View 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
View 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
View 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/**/*"]
}

View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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,
}

View 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
View 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
View 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
View 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
View 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
View 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);
}

View 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
View 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
View 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"
]
}