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

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