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

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