This commit is contained in:
Sergey Yarkov 2023-10-25 19:07:54 +03:00
parent 67ce4abf78
commit 8e1481016e
No known key found for this signature in database
GPG Key ID: E91CE1FABC619B6F
43 changed files with 3877 additions and 3686 deletions

2
.gitignore vendored
View File

@ -1,2 +1,2 @@
node_modules
node_modules
.env

View File

@ -1,6 +1,6 @@
{
"tabWidth": 2,
"printWidth": 120,
"semi": true,
"trailingComma": "all"
}
{
"tabWidth": 2,
"printWidth": 120,
"semi": true,
"trailingComma": "all"
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}

0
DRAFT.txt Normal file
View File

View File

@ -1,47 +1 @@
```
modbus-logger-app
├─ .gitignore
├─ .prettierrc
├─ app
│ └─ index.js
├─ config
│ ├─ database.config.js
│ ├─ env.config.js
│ └─ modbus-client.config.js
├─ data.db
├─ index.d.ts
├─ jsconfig.json
├─ middlewares
│ ├─ index.js
│ └─ validate-body.middleware.js
├─ nodemon.json
├─ package-lock.json
├─ package.json
├─ public
│ ├─ css
│ │ ├─ dist.css
│ │ ├─ dygraphs.css
│ │ └─ main.css
│ ├─ index.html
│ └─ js
│ ├─ alpine.js
│ ├─ app.js
│ └─ dygraphs.min.js
├─ routes
│ ├─ api.router.js
│ └─ index.js
├─ scripts
│ └─ db-init.script.js
├─ server.js
├─ services
│ ├─ app.service.js
│ └─ index.js
├─ sql
│ └─ init-schema.sql
├─ tailwind.config.js
├─ utils
│ └─ database.util.js
└─ validators
└─ index.js
```
# Modbus Logger App

View File

@ -1,33 +1,33 @@
import express from "express";
import pino from "pino-http";
/**
* Routers
*/
import { apiRouter } from "#root/routes/api.router.js";
/**
* Middlewares
*/
import { errorHandlerMiddleware } from "#root/middlewares/index.js";
const app = express();
app.use(
pino(
process.env.NODE_ENV === "development" && {
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
},
),
);
app.use(express.urlencoded({ extended: true }));
app.use(express.static("public"));
app.use("/api", apiRouter);
app.use(errorHandlerMiddleware());
export default app;
import express from "express";
import pino from "pino-http";
/**
* Routers
*/
import { apiRouter } from "#root/routes/api.router.js";
/**
* Middlewares
*/
import { errorHandlerMiddleware } from "#root/middlewares/index.js";
const app = express();
app.use(
pino(
process.env.NODE_ENV === "development" && {
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
},
),
);
app.use(express.urlencoded({ extended: true }));
app.use(express.static("public"));
app.use("/api", apiRouter);
app.use(errorHandlerMiddleware());
export default app;

View File

@ -1,9 +1,9 @@
// @ts-nocheck
import sqlite3 from "sqlite3";
import { open } from "sqlite";
const sqlite = process.env.NODE_ENV == "development" ? sqlite3.verbose() : sqlite3;
const db = await open({ filename: "./data.db", driver: sqlite.Database });
export default db;
// @ts-nocheck
import sqlite3 from "sqlite3";
import { open } from "sqlite";
const sqlite = process.env.NODE_ENV == "development" ? sqlite3.verbose() : sqlite3;
const db = await open({ filename: "./data.db", driver: sqlite.Database });
export default db;

View File

@ -1,5 +1,5 @@
import 'dotenv/config';
export default {
SERVER_PORT: Number.parseInt(process.env?.SERVER_PORT, 10) || 3000,
};
import 'dotenv/config';
export default {
SERVER_PORT: Number.parseInt(process.env?.SERVER_PORT, 10) || 3000,
};

View File

@ -1,7 +1,7 @@
import Modbus from "modbus-serial";
const modbusClient = new Modbus();
modbusClient.on("close", () => console.log("Modbus connection closed."));
export default modbusClient;
import Modbus from "modbus-serial";
const modbusClient = new Modbus();
modbusClient.on("close", () => console.log("Modbus connection closed."));
export default modbusClient;

View File

@ -0,0 +1,102 @@
import db from "#root/config/database.config.js";
/**
* Services
*/
import { appService } from "#root/services/index.js";
export const appController = {
/**
* Get application config from database
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async getConfig(req, res, next) {
try {
const data = await appService.getConfig(db);
if (!data) return res.status(200).json({ config: null });
return res.status(200).json({ config: data });
} catch (error) {
next(error);
}
},
/**
* Update application config
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async updateConfig(req, res, next) {
try {
const config = await appService.getConfig(db);
const payload = req.body;
if (!config) await db.run(`INSERT INTO "app_config" (id) VALUES (?)`, [0]);
/* Update TCP configuration */
if (payload.mb_connection_type === "TCP") {
const { mb_tcp_ip, mb_tcp_port } = payload;
await db.run(
`
UPDATE "app_config" SET id = 0, mb_connection_type = "TCP", mb_tcp_ip = ?, mb_tcp_port = ?, log_interval_ms = ?
`,
[mb_tcp_ip, mb_tcp_port, payload.log_interval_ms],
);
return res.status(200).json({ message: "Config Updated." });
}
/* Update RTU configuration */
if (payload.mb_connection_type === "RTU") {
const { mb_rtu_path, mb_rtu_baud, mb_rtu_parity, mb_rtu_data_bits, mb_rtu_stop_bits } = payload;
await db.run(
`
UPDATE "app_config" SET id = 0,
mb_connection_type = "RTU",
mb_rtu_path = ?,
mb_rtu_baud = ?,
mb_rtu_parity = ?,
mb_rtu_data_bits = ?,
mb_rtu_stop_bits = ?,
log_interval_ms = ?
`,
[mb_rtu_path, mb_rtu_baud, mb_rtu_parity, mb_rtu_data_bits, mb_rtu_stop_bits, payload.log_interval_ms],
);
return res.status(200).json({ message: "Config updated." });
}
return res.status(400).json({ message: "Undefined Modbus connection type." });
} catch (error) {
next(error);
}
},
/**
* Check whether the application is configured or not
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async isConfigured(req, res, next) {
try {
const config = await appService.getConfig(db);
if (!config) return res.status(200).json({ is_configured: false });
for (const colPrefix of ['mb_tcp', 'mb_rtu']) {
if (config.mb_connection_type.toLocaleLowerCase() !== colPrefix.substring(3))
continue;
const keys = Object.keys(config).filter((k) => k.includes(colPrefix));
for (const k of keys) {
if (!config[k])
return res.status(200).json({ is_configured: false });
}
}
return res.status(200).json({ is_configured: true });
} catch (error) {
next(error);
}
}
}

2
controllers/index.js Normal file
View File

@ -0,0 +1,2 @@
export * from './app.controller.js'
export * from './modbus.controller.js'

View File

@ -0,0 +1,139 @@
import db from "#root/config/database.config.js";
import modbusClient from "#root/config/modbus-client.config.js";
import { appService } from "#root/services/index.js";
export const modbusController = {
/**
* Connect to modbus network using configuration from database
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async connect(req, res, next) {
try {
const config = await appService.getConfig(db);
if (!config) return res.status(400).json({ message: "Application is not configured" });
if (modbusClient.isOpen) {
modbusClient.close(undefined);
modbusClient.destroy(undefined);
}
if (config.mb_connection_type === "TCP") {
await modbusClient.connectTCP(config.mb_tcp_ip, { port: config.mb_tcp_port });
return res.status(200).json({ message: "Connected." });
}
if (config.mb_connection_type === "RTU") {
await modbusClient.connectRTU(config.mb_rtu_path, {
baudRate: config.mb_rtu_baud,
dataBits: config.mb_rtu_data_bits,
parity: config.mb_rtu_parity,
stopBits: config.mb_rtu_stop_bits,
});
return res.status(200).json({ message: "Connected." });
}
return res.status(400).json({ message: "Connection type is invalid." });
} catch (error) {
next(error);
}
},
/**
* Close the modbus connection
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async close(req, res, next) {
try {
if (!modbusClient.isOpen) return res.status(400).json({ message: "Already closed." });
modbusClient.close(undefined);
modbusClient.destroy(undefined);
} catch (error) {
next(error);
}
},
/**
* Get list of modbus slave devices
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async list(req, res, next) {
try {
const slaves = await db.all('SELECT * from modbus_slaves');
return res.status(200).json(slaves);
} catch (error) {
next(error)
}
},
/**
* Create new modbus slave device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async createSlaveDevice(req, res, next) {},
/**
* Remove modbus slave device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async removeSlaveDevice(req, res, next) {},
/**
* Create display value for modbus slave device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async createDisplayValue(req, res, next) {},
/**
* Remove display value from modbus slave device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async removeDisplayValue(req, res, next) {},
/**
* Get latest data log from modbus slave device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async getLatestLog(req, res, next) {},
/**
* Create event stream for sending data from modbus device
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
async streamData(req, res, next) {
try {
if (!modbusClient.isOpen) return res.status(503).json({ message: "Modbus connection closed." });
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
let interval = setInterval(async () => {
const { data } = await modbusClient.readHoldingRegisters(0, 1);
res.write("event: messsage\n");
res.write(`data: ${JSON.stringify({ data })}`);
res.write("\n\n");
}, 1000);
req.on("close", () => clearInterval(interval));
} catch (error) {
next(error);
}
}
}

BIN
data.db

Binary file not shown.

View File

@ -1,12 +1,12 @@
import EventEmmiter from "events";
const globalEventEmmiter = new EventEmmiter();
/**
* Start logging data from modbus slaves
*/
globalEventEmmiter.on("log:start", (pollInterval) => {});
globalEventEmmiter.on("log:stop", () => {});
export { globalEventEmmiter };
import EventEmmiter from "events";
const globalEventEmmiter = new EventEmmiter();
/**
* Start logging data from modbus slaves
*/
globalEventEmmiter.on("log:start", (pollInterval) => {});
globalEventEmmiter.on("log:stop", () => {});
export { globalEventEmmiter };

29
index.d.ts vendored
View File

@ -1,11 +1,18 @@
export type AppConfig = {
mb_connection_type: "TCP" | "RTU";
mb_tcp_ip: string | null;
mb_tcp_port: number | null;
mb_rtu_path: string | null;
mb_rtu_baud: number | null;
mb_rtu_parity: "even" | "odd" | "none" | null;
mb_rtu_data_bits: number | null;
mb_rtu_stop_bits: number | null;
log_interval_ms: number;
};
export type AppConfig = {
mb_connection_type: "TCP" | "RTU";
mb_tcp_ip: string | null;
mb_tcp_port: number | null;
mb_rtu_path: string | null;
mb_rtu_baud: number | null;
mb_rtu_parity: "even" | "odd" | "none" | null;
mb_rtu_data_bits: number | null;
mb_rtu_stop_bits: number | null;
log_interval_ms: number;
};
export type ModbusSlave = {
id: number;
name: string;
display_addr: number;
is_logging: boolean;
}

View File

@ -1,9 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#root/*": ["./*"]
},
"checkJs": true
}
}
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#root/*": ["./*"]
},
"checkJs": true
}
}

View File

@ -1,20 +1,20 @@
export function errorHandlerMiddleware() {
/**
* @param {any} error
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
return (error, req, res, next) => {
req.log.error(error, "Something went wrong");
const status = error.statusCode || 500;
const message = error.message || "Internal Server Error";
res.status(500).json({
error: {
status,
message,
stack: process.env.NODE_ENV === "development" ? error.stack : {},
},
});
};
}
export function errorHandlerMiddleware() {
/**
* @param {any} error
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
return (error, req, res, next) => {
req.log.error(error, "Something went wrong");
const status = error.statusCode || 500;
const message = error.message || "Internal Server Error";
res.status(500).json({
error: {
status,
message,
stack: process.env.NODE_ENV === "development" ? error.stack : {},
},
});
};
}

View File

@ -1,2 +1,2 @@
export * from "./validate-body.middleware.js";
export * from "./error-handler.middleware.js";
export * from "./validate-body.middleware.js";
export * from "./error-handler.middleware.js";

View File

@ -1,19 +1,21 @@
/**
* Validate request body
* @param {import('yup').Schema} schema
*/
export function validateBodyMiddleware(schema) {
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
return async (req, res, next) => {
try {
await schema.validate(req.body);
next();
} catch (error) {
return res.status(400).send(error.message);
}
};
}
/**
* Validate request body
* @param {import('yup').Schema} schema
*/
export function validateBodyMiddleware(schema) {
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
return async (req, res, next) => {
try {
await schema.validate(req.body);
next();
} catch (error) {
return res.status(400).json({ error: {
message: error.errors[0]
}});
}
};
}

45
migrations/001-init.sql Normal file
View File

@ -0,0 +1,45 @@
--------------------------------------------------------------------------------
-- Up
--------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS "app_config" (
"id" INTEGER,
"mb_connection_type" VARCHAR(3) NOT NULL DEFAULT "RTU" CHECK(mb_connection_type IN ('TCP','RTU')),
"mb_tcp_ip" VARCHAR(15) NOT NULL DEFAULT "127.0.0.1",
"mb_tcp_port" INTEGER NOT NULL DEFAULT 502,
"mb_rtu_path" VARCHAR(10) NOT NULL DEFAULT "",
"mb_rtu_baud" INTEGER NOT NULL DEFAULT 9600,
"mb_rtu_parity" VARCHAR(5) NOT NULL DEFAULT "none" CHECK(mb_rtu_parity IN ("even", "odd", "none")),
"mb_rtu_data_bits" INTEGER NOT NULL DEFAULT 8,
"mb_rtu_stop_bits" INTEGER NOT NULL DEFAULT 1,
"log_interval_ms" INTEGER DEFAULT 1000 NOT NULL,
CONSTRAINT "id_unique" CHECK(id=0),
CONSTRAINT "min_interval_check" CHECK(log_interval_ms >= 1000)
PRIMARY KEY("id")
);
CREATE TABLE IF NOT EXISTS "modbus_slaves" (
"id" INTEGER CHECK (id >= 1 AND id <= 255),
"name" VARCHAR(256) NOT NULL,
"display_addr" INTEGER CHECK(display_addr >= 0 AND display_addr <= 65534),
-- "display_val_format" INTEGER CHECK IN(16, 32) NOT NULL,
"is_logging" BOOLEAN NOT NULL,
PRIMARY KEY("id")
);
CREATE TABLE IF NOT EXISTS "display_values" (
"id" INTEGER,
"name" VARCHAR(32),
"slave_id" INTEGER NOT NULL,
"reg_addr" INTEGER DEFAULT 0 NOT NULL CHECK(reg_addr >= 0 AND reg_addr <= 65534),
PRIMARY KEY("id"),
FOREIGN KEY("slave_id") REFERENCES modbus_slaves("id") ON DELETE CASCADE
);
--------------------------------------------------------------------------------
-- Down
--------------------------------------------------------------------------------
DROP TABLE app_config;
DROP TABLE modbus_slaves;
DROP TABLE display_values;

View File

@ -1,4 +1,4 @@
{
"watch": [".env", "*/*.js"],
"ignore": ["./public"]
}
{
"watch": [".env", "*/*.js"],
"ignore": ["./public"]
}

5392
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,32 +1,33 @@
{
"name": "modbus-logger-app",
"version": "0.0.1",
"description": "Monitoring Modbus slave devices",
"main": "app.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"db:init": "node ./scripts/db-init.script.js"
},
"keywords": [],
"author": "Sergey Yarkov",
"license": "MIT",
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"modbus-serial": "^8.0.13",
"pino-http": "^8.5.0",
"sqlite": "^5.0.1",
"sqlite3": "^5.1.6",
"yup": "^1.3.2"
},
"devDependencies": {
"nodemon": "^3.0.1",
"pino-pretty": "^10.2.3",
"tailwindcss": "^3.3.3"
},
"imports": {
"#root/*": "./*"
}
}
{
"name": "modbus-logger-app",
"version": "0.0.1",
"description": "Monitoring Modbus slave devices",
"main": "app.js",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"tailwind-build": "tailwindcss -i ./public/css/main.css -o ./public/css/dist.css --watch",
"db:init": "node ./scripts/db-init.script.js"
},
"keywords": [],
"author": "Sergey Yarkov",
"license": "MIT",
"dependencies": {
"dotenv": "^16.3.1",
"express": "^4.18.2",
"modbus-serial": "^8.0.13",
"pino-http": "^8.5.0",
"sqlite": "^5.0.1",
"sqlite3": "^5.1.6",
"yup": "^1.3.2"
},
"devDependencies": {
"nodemon": "^3.0.1",
"pino-pretty": "^10.2.3",
"tailwindcss": "^3.3.3"
},
"imports": {
"#root/*": "./*"
}
}

View File

@ -616,6 +616,10 @@ video {
margin-left: auto;
}
.mr-1 {
margin-right: 0.25rem;
}
.mr-2 {
margin-right: 0.5rem;
}
@ -660,6 +664,10 @@ video {
height: 5rem;
}
.h-screen {
height: 100vh;
}
.w-1 {
width: 0.25rem;
}
@ -668,6 +676,34 @@ video {
width: 5rem;
}
.w-48 {
width: 12rem;
}
.w-72 {
width: 18rem;
}
.w-full {
width: 100%;
}
.w-24 {
width: 6rem;
}
.w-16 {
width: 4rem;
}
.w-12 {
width: 3rem;
}
.w-64 {
width: 16rem;
}
.max-w-2xl {
max-width: 42rem;
}
@ -676,6 +712,10 @@ video {
max-width: 72rem;
}
.flex-auto {
flex: 1 1 auto;
}
.transform {
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
}
@ -710,6 +750,12 @@ video {
justify-content: space-between;
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
@ -724,6 +770,14 @@ video {
border-width: 1px;
}
.border-l {
border-left-width: 1px;
}
.border-r {
border-right-width: 1px;
}
.fill-blue-600 {
fill: #2563eb;
}
@ -752,6 +806,26 @@ video {
padding: 20rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.px-2\.5 {
padding-left: 0.625rem;
padding-right: 0.625rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.py-1\.5 {
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
@ -761,10 +835,6 @@ video {
text-align: center;
}
.align-middle {
vertical-align: middle;
}
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
@ -775,6 +845,11 @@ video {
line-height: 2.25rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.font-bold {
font-weight: 700;
}
@ -818,7 +893,7 @@ video {
display: none !important;
}
input,
input,
select {
margin-top: 0.125rem;
margin-bottom: 0.125rem;
@ -851,4 +926,4 @@ button:disabled {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity));
}
}
}

View File

@ -1,121 +1,121 @@
/**
* Default styles for the dygraphs charting library.
*/
.dygraph-legend {
position: absolute;
font-size: 14px;
z-index: 10;
width: 250px; /* labelsDivWidth */
/*
dygraphs determines these based on the presence of chart labels.
It might make more sense to create a wrapper div around the chart proper.
top: 0px;
right: 2px;
*/
background: white;
line-height: normal;
text-align: left;
overflow: hidden;
}
.dygraph-legend[dir="rtl"] {
text-align: right;
}
/* styles for a solid line in the legend */
.dygraph-legend-line {
display: inline-block;
position: relative;
bottom: .5ex;
padding-left: 1em;
height: 1px;
border-bottom-width: 2px;
border-bottom-style: solid;
/* border-bottom-color is set based on the series color */
}
/* styles for a dashed line in the legend, e.g. when strokePattern is set */
.dygraph-legend-dash {
display: inline-block;
position: relative;
bottom: .5ex;
height: 1px;
border-bottom-width: 2px;
border-bottom-style: solid;
/* border-bottom-color is set based on the series color */
/* margin-right is set based on the stroke pattern */
/* padding-left is set based on the stroke pattern */
}
.dygraph-roller {
position: absolute;
z-index: 10;
}
/* This class is shared by all annotations, including those with icons */
.dygraph-annotation {
position: absolute;
z-index: 10;
overflow: hidden;
}
/* This class only applies to annotations without icons */
/* Old class name: .dygraphDefaultAnnotation */
.dygraph-default-annotation {
border: 1px solid black;
background-color: white;
text-align: center;
}
.dygraph-axis-label {
/* position: absolute; */
/* font-size: 14px; */
z-index: 10;
line-height: normal;
overflow: hidden;
color: black; /* replaces old axisLabelColor option */
}
.dygraph-axis-label-x {
}
.dygraph-axis-label-y {
}
.dygraph-axis-label-y2 {
}
.dygraph-title {
font-weight: bold;
z-index: 10;
text-align: center;
/* font-size: based on titleHeight option */
}
.dygraph-xlabel {
text-align: center;
/* font-size: based on xLabelHeight option */
}
/* For y-axis label */
.dygraph-label-rotate-left {
text-align: center;
/* See http://caniuse.com/#feat=transforms2d */
transform: rotate(90deg);
-webkit-transform: rotate(90deg);
-moz-transform: rotate(90deg);
-o-transform: rotate(90deg);
-ms-transform: rotate(90deg);
}
/* For y2-axis label */
.dygraph-label-rotate-right {
text-align: center;
/* See http://caniuse.com/#feat=transforms2d */
transform: rotate(-90deg);
-webkit-transform: rotate(-90deg);
-moz-transform: rotate(-90deg);
-o-transform: rotate(-90deg);
-ms-transform: rotate(-90deg);
}
/**
* Default styles for the dygraphs charting library.
*/
.dygraph-legend {
position: absolute;
font-size: 14px;
z-index: 10;
width: 250px; /* labelsDivWidth */
/*
dygraphs determines these based on the presence of chart labels.
It might make more sense to create a wrapper div around the chart proper.
top: 0px;
right: 2px;
*/
background: white;
line-height: normal;
text-align: left;
overflow: hidden;
}
.dygraph-legend[dir="rtl"] {
text-align: right;
}
/* styles for a solid line in the legend */
.dygraph-legend-line {
display: inline-block;
position: relative;
bottom: .5ex;
padding-left: 1em;
height: 1px;
border-bottom-width: 2px;
border-bottom-style: solid;
/* border-bottom-color is set based on the series color */
}
/* styles for a dashed line in the legend, e.g. when strokePattern is set */
.dygraph-legend-dash {
display: inline-block;
position: relative;
bottom: .5ex;
height: 1px;
border-bottom-width: 2px;
border-bottom-style: solid;
/* border-bottom-color is set based on the series color */
/* margin-right is set based on the stroke pattern */
/* padding-left is set based on the stroke pattern */
}
.dygraph-roller {
position: absolute;
z-index: 10;
}
/* This class is shared by all annotations, including those with icons */
.dygraph-annotation {
position: absolute;
z-index: 10;
overflow: hidden;
}
/* This class only applies to annotations without icons */
/* Old class name: .dygraphDefaultAnnotation */
.dygraph-default-annotation {
border: 1px solid black;
background-color: white;
text-align: center;
}
.dygraph-axis-label {
/* position: absolute; */
/* font-size: 14px; */
z-index: 10;
line-height: normal;
overflow: hidden;
color: black; /* replaces old axisLabelColor option */
}
.dygraph-axis-label-x {
}
.dygraph-axis-label-y {
}
.dygraph-axis-label-y2 {
}
.dygraph-title {
font-weight: bold;
z-index: 10;
text-align: center;
/* font-size: based on titleHeight option */
}
.dygraph-xlabel {
text-align: center;
/* font-size: based on xLabelHeight option */
}
/* For y-axis label */
.dygraph-label-rotate-left {
text-align: center;
/* See http://caniuse.com/#feat=transforms2d */
transform: rotate(90deg);
-webkit-transform: rotate(90deg);
-moz-transform: rotate(90deg);
-o-transform: rotate(90deg);
-ms-transform: rotate(90deg);
}
/* For y2-axis label */
.dygraph-label-rotate-right {
text-align: center;
/* See http://caniuse.com/#feat=transforms2d */
transform: rotate(-90deg);
-webkit-transform: rotate(-90deg);
-moz-transform: rotate(-90deg);
-o-transform: rotate(-90deg);
-ms-transform: rotate(-90deg);
}

View File

@ -1,31 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply bg-blue-600 text-white py-2 px-3.5 font-semibold text-sm rounded-sm hover:opacity-70;
}
}
[x-cloak] {
display: none !important;
}
input,
select {
@apply w-full border py-1 rounded-sm my-0.5 bg-white;
}
input {
@apply px-1.5;
}
hr {
@apply my-2;
}
button:disabled {
cursor: not-allowed;
pointer-events: all !important;
}
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply bg-blue-600 text-white py-2 px-3.5 font-semibold text-sm rounded-sm hover:opacity-70;
}
}
[x-cloak] {
display: none !important;
}
input,
select {
@apply w-full border py-1 rounded-sm my-0.5 bg-white;
}
input {
@apply px-1.5;
}
hr {
@apply my-2;
}
button:disabled {
cursor: not-allowed;
pointer-events: all !important;
}

View File

@ -1,205 +1,239 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modbus Logger App</title>
<link rel="stylesheet" href="/css/dist.css" />
<script defer type="module" src="/js/app.js"></script>
</head>
<body class="m-2">
<!-- Root element -->
<div x-data x-cloak class="mx-auto max-w-6xl m-2 py-8">
<!-- Monitoring content -->
<main x-bind:hidden="$store.app.currentPage !== 'monitoring'">
<div>
<h1 class="text-3xl font-bold">Monitoring page</h1>
<button @click="$store.app.currentPage = 'configuration'" class="btn">Config</button>
</div>
</main>
<!-- Configuration content -->
<main x-data="configApp" x-bind:hidden="$store.app.currentPage !== 'configuration'">
<div class="border rounded-sm p-4 max-w-2xl mx-auto">
<h2 class="text-2xl font-bold">Setup configuration</h2>
<p class="text-gray-700 italic">
Fill in the required configuration settings to connect to the Modbus network.
</p>
<hr />
<!-- <div>
<span>app config state:</span>
<pre x-text="JSON.stringify($store.app.config, undefined, 2)"></pre>
</div> -->
<form @submit.prevent="configurateApp()" action="#">
<div>
<div>
<label class="block" for="connection_type">Network type</label>
<select
x-model="$store.app.config.mb_connection_type"
name="mb_connection_type"
id="connection_type"
required
>
<option value="TCP">TCP</option>
<option value="RTU">RTU</option>
</select>
</div>
<div x-show="$store.app.config.mb_connection_type === 'TCP'" class="flex flex-col space-y-3">
<div>
<label for="tcp_ip">IP</label>
<input
x-model="$store.app.config.mb_tcp_ip"
id="tcp_ip"
name="tcp_ip"
placeholder="e.g 127.0.0.1"
type="text"
x-bind:required="$store.app.config.mb_connection_type === 'TCP'"
/>
</div>
<div>
<label for="rtu_port">Port</label>
<input
x-model.number="$store.app.config.mb_tcp_port"
id="rtu_port"
name="mb_rtu_port"
placeholder="e.g 502"
type="number"
x-bind:required="$store.app.config.mb_connection_type === 'TCP'"
/>
</div>
</div>
<div x-show="$store.app.config.mb_connection_type === 'RTU'" class="flex flex-col space-y-3">
<div>
<label for="rtu_path">Path</label>
<input
id="rtu_path"
name="mb_rtu_path"
placeholder="e.g COM1"
type="text"
x-model="$store.app.config.mb_rtu_path"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
/>
</div>
<div>
<label for="rtu_baud">Baud</label>
<select
name="mb_rtu_baud"
id="rtu_baud"
x-model.number="$store.app.config.mb_rtu_baud"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="115200">115200</option>
<option value="57600">57600</option>
<option value="38400">38400</option>
<option value="19200">19200</option>
<option selected value="9600">9600</option>
<option value="1200">1200</option>
</select>
</div>
<div>
<label for="rtu_parity">Parity</label>
<select
name="mb_rtu_parity"
id="rtu_parity"
x-model.number="$store.app.config.mb_rtu_parity"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="even">even</option>
<option value="odd">odd</option>
<option value="none">none</option>
</select>
</div>
<div>
<label for="rtu_data_bits">Data bits</label>
<select
name="mb_rtu_data_bits"
id="rtu_data_bits"
x-model.number="$store.app.config.mb_rtu_data_bits"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option selected value="8">8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
</div>
<div>
<label for="rtu_stop_bits">Stop bits</label>
<select
name="mb_rtu_stop_bits"
id="rtu_stop_bits"
x-model.number="$store.app.config.mb_rtu_stop_bits"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
</div>
</div>
<div class="mt-2">
<label for="rtu_path">Log Interval (ms)</label>
<input
id="log_interval_ms"
name="log_interval_ms"
type="number"
min="1000"
x-model.number="$store.app.config.log_interval_ms"
required
/>
</div>
<div class="mt-3 flex flex-row justify-between items-center">
<div>
<button
x-bind:disabled="isLoading"
x-bind:class="isLoading && 'opacity-70'"
class="btn ml-auto"
type="submit"
>
Save
</button>
</div>
<div>
<span class="text-red-600" x-text="errorMessage">Error while connection to Modbus network</span>
</div>
</div>
</form>
</div>
</main>
<!-- Loading content -->
<main x-bind:hidden="$store.app.currentPage !== 'loading'">
<div class="text-center mt-16">
<div role="status">
<svg
aria-hidden="true"
class="inline w-20 h-20 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<p class="mt-4">Loading...</p>
</div>
</main>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Modbus Logger App</title>
<link rel="stylesheet" href="/css/dist.css" />
<script defer type="module" src="/js/app.js"></script>
</head>
<body class="m-2">
<!-- <div class="border p-4" x-data>
<span>app state:</span>
<pre x-text="JSON.stringify($store.app, undefined, 2)"></pre>
</div> -->
<!-- Root element -->
<div x-data x-cloak class="mx-auto max-w-6xl m-2 py-8">
<!-- Monitoring content -->
<main x-bind:hidden="$store.app.currentPage !== 'monitoring'">
<!-- <div>
<h1 class="text-3xl font-bold">Monitoring page</h1>
<button @click="$store.app.setPage('configuration')" class="btn">Config</button>
</div> -->
<div class="flex flex-auto flex-row h-screen space-x-2">
<div class="border-r w-64 p-4">
<div class="w-full border px-2.5 py-1.5 rounded-sm">
<h2 class="text-sm">Modbus slave #1</h2>
</div>
</div>
<div class="w-full">
graph...
</div>
<div class="border-l w-48 p-4">
</div>
</div>
</main>
<!-- Configuration content -->
<main x-data="configAppPage" x-bind:hidden="$store.app.currentPage !== 'configuration'">
<div class="border rounded-sm p-4 max-w-2xl mx-auto">
<h2 class="text-2xl font-bold">Setup configuration</h2>
<p class="text-gray-700 italic">
Fill in the required configuration settings to connect to the Modbus network.
</p>
<hr />
<form @submit.prevent="configurateApp()" action="#">
<div>
<div>
<label class="block" for="connection_type">Network type</label>
<select
x-model="$store.app.config.mb_connection_type"
name="mb_connection_type"
id="connection_type"
required
>
<option value="TCP">TCP</option>
<option value="RTU">RTU</option>
</select>
</div>
<div x-show="$store.app.config.mb_connection_type === 'TCP'" class="flex flex-col space-y-3">
<div>
<label for="tcp_ip">IP</label>
<input
x-model="$store.app.config.mb_tcp_ip"
id="tcp_ip"
name="tcp_ip"
placeholder="e.g 127.0.0.1"
type="text"
x-bind:required="$store.app.config.mb_connection_type === 'TCP'"
/>
</div>
<div>
<label for="rtu_port">Port</label>
<input
x-model.number="$store.app.config.mb_tcp_port"
id="rtu_port"
name="mb_rtu_port"
placeholder="e.g 502"
type="number"
x-bind:required="$store.app.config.mb_connection_type === 'TCP'"
/>
</div>
</div>
<div x-show="$store.app.config.mb_connection_type === 'RTU'" class="flex flex-col space-y-3">
<div>
<label for="rtu_path">Path</label>
<input
id="rtu_path"
name="mb_rtu_path"
placeholder="e.g COM1"
type="text"
x-model="$store.app.config.mb_rtu_path"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
/>
</div>
<div>
<label for="rtu_baud">Baud</label>
<select
name="mb_rtu_baud"
id="rtu_baud"
x-model.number="$store.app.config.mb_rtu_baud"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="115200">115200</option>
<option value="57600">57600</option>
<option value="38400">38400</option>
<option value="19200">19200</option>
<option value="9600">9600</option>
<option value="1200">1200</option>
</select>
</div>
<div>
<label for="rtu_parity">Parity</label>
<select
name="mb_rtu_parity"
id="rtu_parity"
x-model.number="$store.app.config.mb_rtu_parity"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="even">even</option>
<option value="odd">odd</option>
<option value="none">none</option>
</select>
</div>
<div>
<label for="rtu_data_bits">Data bits</label>
<select
name="mb_rtu_data_bits"
id="rtu_data_bits"
x-model.number="$store.app.config.mb_rtu_data_bits"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="8">8</option>
<option value="7">7</option>
<option value="6">6</option>
<option value="5">5</option>
</select>
</div>
<div>
<label for="rtu_stop_bits">Stop bits</label>
<select
name="mb_rtu_stop_bits"
id="rtu_stop_bits"
x-model.number="$store.app.config.mb_rtu_stop_bits"
x-bind:required="$store.app.config.mb_connection_type === 'RTU'"
>
<option value="1">1</option>
<option value="2">2</option>
</select>
</div>
</div>
</div>
<div class="mt-2">
<label for="rtu_path">Log Interval (ms)</label>
<input
id="log_interval_ms"
name="log_interval_ms"
type="number"
min="1000"
x-model.number="$store.app.config.log_interval_ms"
required
/>
</div>
<div class="mt-3 flex flex-row justify-between items-center">
<div class="mr-1">
<button
x-bind:disabled="isLoading"
x-bind:class="isLoading && 'opacity-70'"
class="btn ml-auto"
type="submit"
>
Save
</button>
</div>
<div>
<span class="text-red-600" x-text="errorMessage"></span>
</div>
</div>
</form>
</div>
</main>
<!-- Loading content -->
<main x-bind:hidden="$store.app.currentPage !== 'loading'">
<div class="text-center mt-16">
<div role="status">
<svg
aria-hidden="true"
class="inline w-20 h-20 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
<p class="mt-4">Loading...</p>
</div>
</main>
<!-- Error content -->
<main x-bind:hidden="$store.app.currentPage !== 'error'">
<div>
<h1 class="text-2xl font-bold">Something went wrong.</h1>
<p>Refresh page to try again.</p>
<br />
<div class="border p-4">
<pre x-text="JSON.stringify($store.app.error, undefined, 2)" x-show="$store.app.error">
</pre>
</div>
</div>
</main>
<!-- 404 content -->
<main x-bind:hidden="$store.app.currentPage !== '404'">
<div>
<h1 class="text 2xl font-bold">Page not found</h1>
</div>
</main>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -1,42 +1,42 @@
const urlPrefix = "/api";
/**
* Response handler
* @param {Response} response
*/
async function handle(response) {
const data = await response.json();
if (!response.ok) return Promise.reject(data.error || response.statusText);
return data;
}
export default {
async patch(path, data) {
const response = await fetch(`${urlPrefix}${path}`, {
method: "PATCH",
body: new URLSearchParams(data),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return handle(response);
},
async post(path, data) {
const response = await fetch(`${urlPrefix}${path}`, {
method: "POST",
body: new URLSearchParams(data),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return await handle(response);
},
async get(path) {
const response = await fetch(`${urlPrefix}${path}`, {
method: "GET",
});
return await handle(response);
},
};
const urlPrefix = "/api";
/**
* Response handler
* @param {Response} response
*/
async function handle(response) {
const data = await response.json();
if (!response.ok) return Promise.reject(data.error || response.statusText);
return data;
}
export default {
async patch(path, data) {
const response = await fetch(`${urlPrefix}${path}`, {
method: "PATCH",
body: new URLSearchParams(data),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return handle(response);
},
async post(path, data) {
const response = await fetch(`${urlPrefix}${path}`, {
method: "POST",
body: new URLSearchParams(data),
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return await handle(response);
},
async get(path) {
const response = await fetch(`${urlPrefix}${path}`, {
method: "GET",
});
return await handle(response);
},
};

View File

@ -1,98 +1,80 @@
// @ts-nocheck
import "./alpine.js";
import api from "./api.js";
document.addEventListener("alpine:init", async () => {
Alpine.store("app", {
is_configured: false,
/**
* Default configuration
*/
config: {
mb_connection_type: "TCP",
mb_tcp_ip: "127.0.0.1",
mb_tcp_port: 502,
mb_rtu_path: "",
mb_rtu_baud: 9600,
mb_rtu_data_bits: 8,
mb_rtu_stop_bits: 1,
mb_rtu_parity: "even",
log_interval_ms: 1000,
},
currentPage: "loading",
async init() {
try {
const { config } = await api.get("/app/config");
const { is_configured } = await api.get("/app/is_configured");
this.is_configured = is_configured;
if (config) this.config = config;
if (is_configured) {
this.currentPage = "monitoring";
return;
}
this.currentPage = "configuration";
} catch (error) {
throw new Error("Error on initializing app.");
}
},
});
Alpine.data("configApp", () => ({
selectedConnectionType: "TCP",
isLoading: false,
errorMessage: "",
async configurateApp() {
this.isLoading = true;
this.errorMessage = "";
let data = {};
const {
mb_connection_type,
mb_tcp_ip,
mb_tcp_port,
mb_rtu_path,
mb_rtu_baud,
mb_rtu_parity,
mb_rtu_data_bits,
mb_rtu_stop_bits,
log_interval_ms,
} = this.$store.app.config;
if (mb_connection_type === "TCP") {
data = {
mb_connection_type,
mb_tcp_ip,
mb_tcp_port,
log_interval_ms,
};
}
if (mb_connection_type === "RTU") {
data = {
mb_connection_type,
mb_rtu_path,
mb_rtu_parity,
mb_rtu_data_bits,
mb_rtu_stop_bits,
mb_rtu_baud,
log_interval_ms,
};
}
if (data) {
await api.patch("/app/config", data);
try {
await api.post("/modbus/connect");
this.$store.app.currentPage = "monitoring";
} catch (error) {
this.errorMessage = `Modbus connection error: ${error.message}`;
}
}
this.isLoading = false;
},
}));
});
// @ts-nocheck
import "./alpine.js";
import api from "./api.js";
import * as utils from './utils/index.js'
const pages = ['loading', 'monitoring', 'configuration', '404']
document.addEventListener("alpine:init", async () => {
Alpine.store("app", {
is_configured: false,
config: {
mb_connection_type: "TCP",
mb_tcp_ip: "127.0.0.1",
mb_tcp_port: 502,
mb_rtu_path: "",
mb_rtu_baud: 9600,
mb_rtu_data_bits: 8,
mb_rtu_stop_bits: 1,
mb_rtu_parity: "even",
log_interval_ms: 1000,
},
currentPage: "loading",
error: null,
devices: [],
async init() {
try {
const { config } = await api.get("/app/config");
const { is_configured } = await api.get("/app/is_configured");
this.is_configured = is_configured;
if (config) this.config = config;
if (is_configured) {
const devices = await api.get('/modbus/devices');
this.devices = devices;
this.currentPage = "monitoring";
return;
}
this.currentPage = "configuration";
} catch (error) {
this.currentPage = 'error'
this.error = error;
}
},
setPage(page) {
if (!pages.includes(page)) {
this.currentPage = '404'
return;
}
this.currentPage = page;
}
});
Alpine.data("configAppPage", () => ({
selectedConnectionType: "TCP",
isLoading: false,
errorMessage: "",
async configurateApp() {
try {
this.isLoading = true;
this.errorMessage = "";
const data = { ...this.$store.app.config };
const body = {
RTU: utils.objKeysExclude(data, ['mb_tcp_ip', 'mb_tcp_port']),
TCP: utils.objKeysExclude(data, ['mb_rtu_path', 'mb_rtu_baud', 'mb_rtu_parity', 'mb_rtu_data_bits', 'mb_rtu_stop_bits'])
}
await api.patch("/app/config", body[data.mb_connection_type]);
await api.post("/modbus/connect");
this.$store.app.currentPage = "monitoring";
} catch (error) {
this.errorMessage = `${error.message}`;
} finally {
this.isLoading = false;
}
},
}));
});

File diff suppressed because one or more lines are too long

1
public/js/utils/index.js Normal file
View File

@ -0,0 +1 @@
export * from './object.js'

11
public/js/utils/object.js Normal file
View File

@ -0,0 +1,11 @@
/**
* Deletes specified keys from object
* @param {object} obj
* @param {string[]} keys
* @returns new object
*/
export function objKeysExclude(obj, keys) {
const res = structuredClone(obj);
Object.keys(res).forEach(k => keys.includes(k) && delete res[k]);
return res;
}

View File

@ -1,172 +1,30 @@
import express from "express";
import db from "#root/config/database.config.js";
import modbusClient from "#root/config/modbus-client.config.js";
/**
* Validators
*/
import { appConfigSchema } from "#root/validators/index.js";
/**
* Services
*/
import { appService } from "#root/services/index.js";
/**
* Middlewares
*/
import { validateBodyMiddleware } from "#root/middlewares/index.js";
const apiRouter = express.Router();
/**
* Get application config
*/
apiRouter.get("/app/config", async (req, res, next) => {
try {
const data = await appService.getConfig(db);
if (!data) return res.status(200).json({ config: null });
return res.status(200).json({ config: data });
} catch (error) {
next(error);
}
});
/**
* Update app configuration
*/
apiRouter.patch("/app/config", validateBodyMiddleware(appConfigSchema), async (req, res, next) => {
try {
const config = await appService.getConfig(db);
const payload = req.body;
if (!config) await db.run(`INSERT INTO "app_config" (id) VALUES (?)`, [0]);
/* Update TCP configuration */
if (payload.mb_connection_type === "TCP") {
const { mb_tcp_ip, mb_tcp_port } = payload;
await db.run(
`
UPDATE "app_config" SET id = 0, mb_connection_type = "TCP", mb_tcp_ip = ?, mb_tcp_port = ?, log_interval_ms = ?
`,
[mb_tcp_ip, mb_tcp_port, payload.log_interval_ms],
);
return res.status(200).json({ message: "Config Updated." });
}
/* Update RTU configuration */
if (payload.mb_connection_type === "RTU") {
const { mb_rtu_path, mb_rtu_baud, mb_rtu_parity, mb_rtu_data_bits, mb_rtu_stop_bits } = payload;
await db.run(
`
UPDATE "app_config" SET id = 0,
mb_connection_type = "RTU",
mb_rtu_path = ?,
mb_rtu_baud = ?,
mb_rtu_parity = ?,
mb_rtu_data_bits = ?,
mb_rtu_stop_bits = ?,
log_interval_ms = ?
`,
[mb_rtu_path, mb_rtu_baud, mb_rtu_parity, mb_rtu_data_bits, mb_rtu_stop_bits, payload.log_interval_ms],
);
return res.status(200).json({ message: "Config updated." });
}
return res.status(400).json({ message: "Undefined Modbus connection type." });
} catch (error) {
next(error);
}
});
/**
* Check whether the application is configured or not
*/
apiRouter.get("/app/is_configured", async (req, res, next) => {
try {
const config = await appService.getConfig(db);
let is_configured = false;
if (!config) return res.status(200).json({ is_configured: false });
const isValuesExists = (colPrefix) => {
const keys = Object.keys(config).filter((k) => k.includes(colPrefix));
for (const k of keys) if (!config[k]) return false;
return true;
};
if (config.mb_connection_type === "TCP") is_configured = isValuesExists("mb_tcp");
if (config.mb_connection_type === "RTU") is_configured = isValuesExists("mb_rtu");
return res.status(200).json({ is_configured });
} catch (error) {
next(error);
}
});
/**
* Connect to modbus network using configuration from database
*/
apiRouter.post("/modbus/connect", async (req, res, next) => {
try {
const config = await appService.getConfig(db);
if (!config) return res.status(400).json({ message: "Application is not configured" });
if (modbusClient.isOpen) {
modbusClient.close(undefined);
modbusClient.destroy(undefined);
}
if (config.mb_connection_type === "TCP") {
await modbusClient.connectTCP(config.mb_tcp_ip, { port: config.mb_tcp_port });
return res.status(200).json({ message: "Connected." });
}
if (config.mb_connection_type === "RTU") {
await modbusClient.connectRTU(config.mb_rtu_path, {
baudRate: config.mb_rtu_baud,
dataBits: config.mb_rtu_data_bits,
parity: config.mb_rtu_parity,
stopBits: config.mb_rtu_stop_bits,
});
return res.status(200).json({ message: "Connected." });
}
return res.status(400).json({ message: "Connection type is invalid." });
} catch (error) {
next(error);
}
});
apiRouter.post("/modbus/close", (req, res, next) => {
try {
if (!modbusClient.isOpen) return res.status(400).json({ message: "Already closed." });
modbusClient.close(undefined);
modbusClient.destroy(undefined);
} catch (error) {
next(error);
}
});
apiRouter.get("/modbus/data_stream", async (req, res, next) => {
try {
if (!modbusClient.isOpen) return res.status(503).json({ message: "Modbus connection closed." });
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
let interval = setInterval(async () => {
const { data } = await modbusClient.readHoldingRegisters(0, 1);
res.write("event: messsage\n");
res.write(`data: ${JSON.stringify({ data })}`);
res.write("\n\n");
}, 1000);
req.on("close", () => clearInterval(interval));
} catch (error) {
next(error);
}
});
export { apiRouter };
import express from "express";
import modbusClient from "#root/config/modbus-client.config.js";
/**
* Validators
*/
import { appConfigSchema } from "#root/validators/index.js";
/**
* Controllers
*/
import { appController, modbusController } from '#root/controllers/index.js'
/**
* Middlewares
*/
import { validateBodyMiddleware } from "#root/middlewares/index.js";
const apiRouter = express.Router();
apiRouter.get("/app/config", appController.getConfig);
apiRouter.patch("/app/config", validateBodyMiddleware(appConfigSchema), appController.updateConfig);
apiRouter.get("/app/is_configured", appController.isConfigured);
apiRouter.get('/modbus/devices', modbusController.list);
apiRouter.post("/modbus/connect", modbusController.connect);
apiRouter.post("/modbus/close", modbusController.close);
apiRouter.get("/modbus/data_stream", modbusController.streamData);
export { apiRouter };

View File

@ -1 +1 @@
export * from './api.router.js';
export * from './api.router.js';

View File

@ -1,7 +1,12 @@
import { runQueryFromFile } from "#root/utils/database.util.js";
async function run() {
await runQueryFromFile("sql/init-schema.sql");
}
run();
import db from '#root/config/database.config.js'
async function run() {
try {
await db.migrate({ force: false })
console.log('Database initialized.');
} catch (error) {
throw error;
}
}
run();

View File

@ -1,4 +1,4 @@
import app from "#root/app/index.js";
import env from "#root/config/env.config.js";
app.listen(env.SERVER_PORT, () => console.log(`Web server started: http://127.0.0.1:${env.SERVER_PORT}.`));
import app from "#root/app/index.js";
import env from "#root/config/env.config.js";
app.listen(env.SERVER_PORT, () => console.log(`Web server started: http://localhost:${env.SERVER_PORT}.`));

View File

@ -1,22 +1,22 @@
export const appService = {
/**
* Get configuration of app
* @param {import('sqlite').Database} db
* @returns {Promise<import('../index').AppConfig | undefined>}
*/
async getConfig(db) {
const data = await db.get(`
SELECT "mb_connection_type",
"mb_tcp_ip",
"mb_tcp_port",
"mb_rtu_path",
"mb_rtu_baud",
"mb_rtu_parity",
"mb_rtu_data_bits",
"mb_rtu_stop_bits",
"log_interval_ms"
FROM "app_config"
`);
return data;
},
};
export const appService = {
/**
* Get configuration of app
* @param {import('sqlite').Database} db
* @returns {Promise<import('../index').AppConfig | undefined>}
*/
async getConfig(db) {
const data = await db.get(`
SELECT "mb_connection_type",
"mb_tcp_ip",
"mb_tcp_port",
"mb_rtu_path",
"mb_rtu_baud",
"mb_rtu_parity",
"mb_rtu_data_bits",
"mb_rtu_stop_bits",
"log_interval_ms"
FROM "app_config"
`);
return data;
},
};

View File

@ -1 +1 @@
export * from "./app.service.js";
export * from "./app.service.js";

View File

@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS "app_config" (
"id" INTEGER,
"mb_connection_type" VARCHAR(3) NOT NULL DEFAULT "RTU" CHECK(mb_connection_type IN ('TCP','RTU')),
"mb_tcp_ip" VARCHAR(15) NOT NULL DEFAULT "127.0.0.1",
"mb_tcp_port" INTEGER NOT NULL DEFAULT 502,
"mb_rtu_path" VARCHAR(10) NOT NULL DEFAULT "",
"mb_rtu_baud" INTEGER NOT NULL DEFAULT 9600,
"mb_rtu_parity" VARCHAR(5) NOT NULL DEFAULT "none" CHECK(mb_rtu_parity IN ("even", "odd", "none")),
"mb_rtu_data_bits" INTEGER NOT NULL DEFAULT 8,
"mb_rtu_stop_bits" INTEGER NOT NULL DEFAULT 1,
"log_interval_ms" INTEGER DEFAULT 1000 NOT NULL,
CONSTRAINT "id_unique" CHECK(id=0),
CONSTRAINT "min_interval_check" CHECK(log_interval_ms >= 1000)
PRIMARY KEY("id")
);

View File

@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./public/**/*.{html,js}'],
theme: {
extend: {},
},
plugins: [],
};
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./public/**/*.{html,js}'],
theme: {
extend: {},
},
plugins: [],
};

View File

@ -1,15 +0,0 @@
import fs from "fs/promises";
import db from "#root/config/database.config.js";
/**
* Reads the `.sql` file and executes the query
* @param {string} filepath
*/
export async function runQueryFromFile(filepath) {
try {
const query = await fs.readFile(filepath);
await db.run(query.toString());
} catch (error) {
throw error;
}
}

View File

@ -1,18 +1,18 @@
import { object, number, string } from "yup";
const requiredIfConnTypeEquals = (type) => ({
is: (v) => v === type,
then: (schema) => schema.required(),
});
export const appConfigSchema = object({
mb_connection_type: string().oneOf(["TCP", "RTU"]).required(),
mb_tcp_ip: string().when("mb_connection_type", requiredIfConnTypeEquals("TCP")),
mb_tcp_port: number().when("mb_connection_type", requiredIfConnTypeEquals("TCP")),
mb_rtu_path: string().when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_baud: number().when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_parity: string().oneOf(["none", "even", "odd"]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_data_bits: number().oneOf([7, 8, 9]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_stop_bits: number().oneOf([1, 2]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
log_interval_ms: number().min(1000).required(),
});
import { object, number, string } from "yup";
const requiredIfConnTypeEquals = (type) => ({
is: (v) => v === type,
then: (schema) => schema.required(),
});
export const appConfigSchema = object({
mb_connection_type: string().oneOf(["TCP", "RTU"]).required(),
mb_tcp_ip: string().when("mb_connection_type", requiredIfConnTypeEquals("TCP")),
mb_tcp_port: number().when("mb_connection_type", requiredIfConnTypeEquals("TCP")),
mb_rtu_path: string().when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_baud: number().when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_parity: string().oneOf(["none", "even", "odd"]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_data_bits: number().oneOf([7, 8, 9]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
mb_rtu_stop_bits: number().oneOf([1, 2]).when("mb_connection_type", requiredIfConnTypeEquals("RTU")),
log_interval_ms: number().min(1000).required(),
});