Starting anew, before I changed to a custom chart
This commit is contained in:
13
server/src/Collections.ts
Normal file
13
server/src/Collections.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import SnapshotCollection from "./SnapshotCollection";
|
||||
import {establishDatabaseConnection} from "./database";
|
||||
|
||||
export interface CollectionRegistry {
|
||||
snapshots: SnapshotCollection
|
||||
}
|
||||
|
||||
export async function setupCollections(): Promise<CollectionRegistry> {
|
||||
const dbConnection = await establishDatabaseConnection();
|
||||
return {
|
||||
snapshots: new SnapshotCollection(dbConnection),
|
||||
};
|
||||
}
|
||||
15
server/src/Snapshot.ts
Normal file
15
server/src/Snapshot.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export interface Snapshot {
|
||||
time: number | string,
|
||||
temp: number,
|
||||
co2: number,
|
||||
humidity: number,
|
||||
id: number,
|
||||
}
|
||||
|
||||
export interface ISOSnapshot extends Snapshot {
|
||||
time: string,
|
||||
}
|
||||
|
||||
export interface UnixTimeSnapshot extends Snapshot {
|
||||
time: number,
|
||||
}
|
||||
86
server/src/SnapshotCollection.ts
Normal file
86
server/src/SnapshotCollection.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {Connection, ResultSetHeader, RowDataPacket} from "mysql2/promise";
|
||||
import {Snapshot, ISOSnapshot} from "./Snapshot";
|
||||
import {isValidDatetime, toISOTime, toMySQLDatetime, toUnixTime} from "./utils";
|
||||
import {DatabaseConnection, tryQuery} from "./database";
|
||||
|
||||
class SnapshotCollection {
|
||||
private readonly db: Connection;
|
||||
|
||||
constructor(db: DatabaseConnection) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
async insertSnapshots(...snapshots: Omit<Snapshot, "id">[]) {
|
||||
return tryQuery(async () => {
|
||||
const query = "INSERT INTO `snapshots` (`time`, `co2`, `humidity`, `temp`) VALUES ?";
|
||||
const [resultSetHeader] = await this.db.query(query, [SnapshotCollection.toMySQLRows(...snapshots)]);
|
||||
return {
|
||||
affectedRows: (resultSetHeader as ResultSetHeader).affectedRows,
|
||||
insertId: (resultSetHeader as ResultSetHeader).insertId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getLatestSnapshot(): Promise<ISOSnapshot | null> {
|
||||
return tryQuery(async () => {
|
||||
const query = "SELECT `id`, DATE_FORMAT(`time`, '%Y-%m-%dT%TZ') `time`, `co2`, `humidity`, `temp` FROM `snapshots` ORDER BY `id` DESC LIMIT 1;";
|
||||
const [rows] = await this.db.query(query);
|
||||
if ((rows as RowDataPacket[]).length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
return SnapshotCollection.rowsToSnapshots(...(rows as RowDataPacket[]))[0];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getSnapshotsInRange(start: number | string, stop: number | string): Promise<ISOSnapshot[]> {
|
||||
start = toMySQLDatetime(start);
|
||||
stop = toMySQLDatetime(stop);
|
||||
return tryQuery(async () => {
|
||||
const query = "SELECT `id`, DATE_FORMAT(`time`, '%Y-%m-%dT%TZ') `time`, `co2`, `humidity`, `temp` FROM `snapshots` WHERE `time` BETWEEN ? AND ? ORDER BY `id` DESC;";
|
||||
const [rows] = await this.db.query(query, [start, stop]);
|
||||
return SnapshotCollection.rowsToSnapshots(...(rows as RowDataPacket[]));
|
||||
});
|
||||
}
|
||||
|
||||
async getSnapshotsSince(timeSince: number | string): Promise<ISOSnapshot[]> {
|
||||
timeSince = toMySQLDatetime(timeSince);
|
||||
return tryQuery(async () => {
|
||||
const query = "SELECT `id`, DATE_FORMAT(`time`, '%Y-%m-%dT%TZ') `time`, `co2`, `humidity`, `temp` FROM `snapshots` WHERE TIMESTAMPDIFF(SECOND, `time`, ?) < 0 ORDER BY `id` DESC;";
|
||||
const [rows] = await this.db.query(query, [timeSince]);
|
||||
return SnapshotCollection.rowsToSnapshots(...(rows as RowDataPacket[]));
|
||||
});
|
||||
}
|
||||
|
||||
static toUnixTime<T extends {time: string | number}>(...snapshots: T[]): (T & {time: number})[] {
|
||||
return snapshots.map(s => ({...s, time: toUnixTime(s.time)}));
|
||||
}
|
||||
|
||||
static toISOTime<T extends {time: string | number}>(...snapshots: T[]): (T & {time: string})[] {
|
||||
return snapshots.map(s => ({...s, time: toISOTime(s.time)}));
|
||||
}
|
||||
|
||||
private static toMySQLRows(...snapshots: Omit<Snapshot, "id">[]): (number | string | Date)[][] {
|
||||
return snapshots.map(s => [new Date(s.time), s.co2, s.humidity, s.temp]);
|
||||
}
|
||||
|
||||
static isSubmissibleSnapshot(potentialSnapshot: Record<string, unknown>): potentialSnapshot is Omit<Snapshot, "id"> {
|
||||
return typeof potentialSnapshot.temp === "number"
|
||||
&& typeof potentialSnapshot.co2 === "number"
|
||||
&& typeof potentialSnapshot.humidity === "number"
|
||||
&& (typeof potentialSnapshot.time === "number"
|
||||
|| typeof potentialSnapshot.time === "string" && isValidDatetime(potentialSnapshot.time));
|
||||
}
|
||||
|
||||
private static rowsToSnapshots(...rows: RowDataPacket[]): ISOSnapshot[] {
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
temp: row.temp,
|
||||
co2: row.co2,
|
||||
humidity: row.humidity,
|
||||
time: row.time,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export default SnapshotCollection;
|
||||
28
server/src/database.ts
Normal file
28
server/src/database.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import {GenericPersistenceError} from "./errors";
|
||||
|
||||
export type DatabaseConnection = mysql.Connection;
|
||||
|
||||
export async function establishDatabaseConnection() {
|
||||
let dbConnection;
|
||||
try {
|
||||
dbConnection = await mysql.createConnection({
|
||||
host: process.env.MYSQL_ADDRESS,
|
||||
user: process.env.MYSQL_USERNAME,
|
||||
password: process.env.MYSQL_PW,
|
||||
database: "climate",
|
||||
connectTimeout: 30000,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`Couldn't establish a connection with the database: ${e.message}`);
|
||||
}
|
||||
return dbConnection;
|
||||
}
|
||||
|
||||
export async function tryQuery<T>(cb: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await cb();
|
||||
} catch (err) {
|
||||
throw new GenericPersistenceError(err.message, "There was an error querying the database.");
|
||||
}
|
||||
}
|
||||
22
server/src/errors.ts
Normal file
22
server/src/errors.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export class ClayPIError extends Error {
|
||||
displayMessage: string;
|
||||
constructor(message: string, displayMessage?: string) {
|
||||
super(message);
|
||||
this.name = "ClayPIError";
|
||||
this.displayMessage = displayMessage ?? message;
|
||||
}
|
||||
}
|
||||
|
||||
export class GenericPersistenceError extends ClayPIError {
|
||||
constructor(message: string, displayMessage: string) {
|
||||
super(message, displayMessage);
|
||||
this.name = "GenericPersistenceError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DataValidationError extends ClayPIError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "DataValidationError";
|
||||
}
|
||||
}
|
||||
36
server/src/main.ts
Normal file
36
server/src/main.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import dotenv from "dotenv";
|
||||
import express from "express";
|
||||
import {newMainRouter} from "./mainRouter";
|
||||
import {setupCollections} from "./Collections";
|
||||
import path from "path";
|
||||
import {startSensorPinger} from "./pingSensors";
|
||||
|
||||
dotenv.config();
|
||||
const SERVER_ROOT = process.env.SERVER_ROOT ?? "/";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
const collections = await setupCollections();
|
||||
const mainRouter = newMainRouter(collections);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.set("port", process.env.PORT || 3000);
|
||||
app.use(express.urlencoded({ extended: false}));
|
||||
app.locals = {
|
||||
rootUrl: SERVER_ROOT,
|
||||
};
|
||||
app.set("view-engine", "ejs");
|
||||
app.set("views", path.resolve(__dirname + "/../static"));
|
||||
app.use(SERVER_ROOT + "/static", express.static(path.resolve(__dirname + "/../static")));
|
||||
app.use(SERVER_ROOT, mainRouter);
|
||||
app.listen(app.get("port"), () => {
|
||||
console.log("ClayPI running on http://localhost:%d", app.get("port"));
|
||||
});
|
||||
startSensorPinger();
|
||||
} catch (e) {
|
||||
throw new Error(`Problem setting up the server: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
35
server/src/mainRouter.ts
Normal file
35
server/src/mainRouter.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import express from "express";
|
||||
import {ClayPIError, GenericPersistenceError} from "./errors";
|
||||
import newSnapshotRouter from "./snapshotRouter";
|
||||
import {CollectionRegistry} from "./Collections";
|
||||
|
||||
export function newMainRouter(collections: CollectionRegistry) {
|
||||
const router = express.Router();
|
||||
const snapshotRouter = newSnapshotRouter(collections);
|
||||
|
||||
router.get("/dashboard", (req, res) => {
|
||||
res.render("index.ejs", { rootUrl: req.app.locals.rootUrl });
|
||||
});
|
||||
router.use("/api/snapshots", snapshotRouter);
|
||||
router.use(topLevelErrorHandler);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
const topLevelErrorHandler: express.ErrorRequestHandler = (err, req, res, next) => {
|
||||
const errOutput = {
|
||||
error: true,
|
||||
message: "",
|
||||
};
|
||||
if (err instanceof GenericPersistenceError) {
|
||||
errOutput.message = `An error occurred accessing the database: ${err.displayMessage}`;
|
||||
}
|
||||
else if (err instanceof ClayPIError) {
|
||||
errOutput.message = `An error occurred: ${err.displayMessage}`;
|
||||
}
|
||||
else {
|
||||
errOutput.message = "An unknown error occurred!";
|
||||
}
|
||||
console.log({...errOutput, internalMessage: err.message});
|
||||
res.status(500).send(errOutput);
|
||||
};
|
||||
46
server/src/pingSensors.ts
Normal file
46
server/src/pingSensors.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {ISOSnapshot} from "./Snapshot";
|
||||
import {exec} from "child-process-promise";
|
||||
import path from "path";
|
||||
import fetch from "node-fetch";
|
||||
import {ClayPIError} from "./errors";
|
||||
|
||||
async function pingSensors(): Promise<Omit<ISOSnapshot, "id">> {
|
||||
try {
|
||||
const process = await exec(`python3 ${path.resolve(__dirname + "/../scripts/pinger-test.py")}`);
|
||||
const result = process.stdout;
|
||||
const snapshotArray = result.split("\t").map(piece => piece.trim());
|
||||
return {
|
||||
time: snapshotArray[1],
|
||||
temp: Number(snapshotArray[3]),
|
||||
humidity: Number(snapshotArray[5]),
|
||||
co2: Number(snapshotArray[7]),
|
||||
};
|
||||
} catch (err) {
|
||||
throw new ClayPIError(
|
||||
`Could not generate a new snapshot: Python error: ${err}. Have you installed python 3 and the necessary requirements?`,
|
||||
"Could not generate a new snapshot."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitToServer(snapshot: Omit<ISOSnapshot, "id">) {
|
||||
await fetch(`http://localhost:${process.env.PORT}${process.env.SERVER_ROOT}/api/snapshots`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ snapshots: [snapshot] }),
|
||||
headers: {
|
||||
"Content-type": "application/json"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function startSensorPinger() {
|
||||
const createAndSubmitNewSnapshot = async () => {
|
||||
try {
|
||||
await submitToServer(await pingSensors());
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
};
|
||||
createAndSubmitNewSnapshot();
|
||||
setInterval(createAndSubmitNewSnapshot, (Number(process.env.SENSOR_PING_INTERVAL) ?? 30) * 1000);
|
||||
}
|
||||
93
server/src/snapshotRouter.ts
Normal file
93
server/src/snapshotRouter.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {Snapshot} from "./Snapshot";
|
||||
import SnapshotCollection from "./SnapshotCollection";
|
||||
import express, {Router} from "express";
|
||||
import {CollectionRegistry} from "./Collections";
|
||||
import {ClayPIError} from "./errors";
|
||||
import {toMySQLDatetime} from "./utils";
|
||||
|
||||
function newSnapshotRouter(collections: CollectionRegistry) {
|
||||
const router = Router();
|
||||
|
||||
router.use(unixTimeParamMiddleware);
|
||||
|
||||
router.get("", async (req, res) => {
|
||||
const query = req.query as Record<string, string>;
|
||||
const isMinutesQuery = typeof query["last-minutes"] !== "undefined" && !query.from && !query.to;
|
||||
const isFromToQuery = typeof query.from !== "undefined";
|
||||
let snapshots: Snapshot[] = [];
|
||||
let timeFormat = res.locals.timeFormat;
|
||||
if (!isMinutesQuery && !isFromToQuery) {
|
||||
if (query.to) {
|
||||
throw new ClayPIError("The parameter 'to' must always be accompanied by a 'from'.");
|
||||
}
|
||||
snapshots = await collections.snapshots.getSnapshotsSince(new Date().getTime() - 60 * 60000);
|
||||
} else if (isMinutesQuery) {
|
||||
const lastMinutes = Math.floor(Number(query["last-minutes"]));
|
||||
if (isNaN(lastMinutes)) {
|
||||
throw new ClayPIError("The parameter 'last-minutes' must be a number.");
|
||||
} else {
|
||||
snapshots = await collections.snapshots.getSnapshotsSince(new Date().getTime() - lastMinutes * 60000);
|
||||
}
|
||||
} else if (isFromToQuery) {
|
||||
const timeFrom = isNaN(Number(query.from)) ? query.from : Number(query.from);
|
||||
const timeTo = isNaN(Number(query.to)) ? query.to : Number(query.to);
|
||||
if (timeTo) {
|
||||
if (!timeFormat && typeof timeFrom === typeof timeTo) {
|
||||
timeFormat = typeof timeFrom === "string" ? "iso" : "unix";
|
||||
}
|
||||
snapshots = await collections.snapshots.getSnapshotsInRange(timeFrom, timeTo);
|
||||
} else {
|
||||
snapshots = await collections.snapshots.getSnapshotsSince(timeFrom);
|
||||
}
|
||||
} else {
|
||||
throw new ClayPIError("Malformed request.");
|
||||
}
|
||||
if (timeFormat === "unix") {
|
||||
snapshots = SnapshotCollection.toUnixTime(...snapshots);
|
||||
} else if (timeFormat === "iso") {
|
||||
snapshots = SnapshotCollection.toISOTime(...snapshots);
|
||||
}
|
||||
res.send({snapshots});
|
||||
});
|
||||
|
||||
router.get("/latest", async (req, res) => {
|
||||
let snapshot;
|
||||
snapshot = await collections.snapshots.getLatestSnapshot();
|
||||
if (!snapshot) {
|
||||
res.send({snapshots: []});
|
||||
} else {
|
||||
if (res.locals.timeFormat === "unix") {
|
||||
snapshot = SnapshotCollection.toUnixTime(...[snapshot])[0];
|
||||
} else if (res.locals.timeFormat === "iso") {
|
||||
snapshot = SnapshotCollection.toISOTime(...[snapshot])[0];
|
||||
}
|
||||
res.send({snapshots: [snapshot]});
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req, res) => {
|
||||
const goodRequest = req.body.snapshots
|
||||
&& req.body.snapshots.length === 1
|
||||
&& SnapshotCollection.isSubmissibleSnapshot(req.body.snapshots[0]);
|
||||
if (!goodRequest) {
|
||||
throw new ClayPIError("The request must contain the property 'snapshots' as an array with exactly one snapshot.");
|
||||
} else {
|
||||
const result = await collections.snapshots.insertSnapshots(req.body.snapshots[0]);
|
||||
res.send({message: "Success!", ...result});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
const unixTimeParamMiddleware: express.Handler = (req, res, next) => {
|
||||
const timeFormat = req.query.timeFormat;
|
||||
if (typeof timeFormat !== "undefined" && timeFormat !== "iso" && timeFormat !== "unix") {
|
||||
throw new ClayPIError("Parameter 'timeFormat' must be either 'iso' or 'unix'");
|
||||
} else {
|
||||
res.locals.timeFormat = timeFormat;
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
export default newSnapshotRouter;
|
||||
26
server/src/utils.ts
Normal file
26
server/src/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {DataValidationError} from "./errors";
|
||||
|
||||
export function toMySQLDatetime(datetime: number | string) {
|
||||
try {
|
||||
return new Date(datetime).toISOString().slice(0, 19).replace("T", " ");
|
||||
} catch (e) {
|
||||
throw new DataValidationError(`Bad datetime value: ${datetime}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidDatetime(datetime: string) {
|
||||
try {
|
||||
new Date(datetime);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function toUnixTime(datetime: string | number) {
|
||||
return new Date(datetime).getTime();
|
||||
}
|
||||
|
||||
export function toISOTime(datetime: string | number) {
|
||||
return new Date(datetime).toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user