mirror of
https://github.com/sergeyyarkov/modbus-logger.git
synced 2026-10-11 02:18:51 +03:00
progress
This commit is contained in:
parent
67ce4abf78
commit
8e1481016e
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,2 +1,2 @@
|
||||
node_modules
|
||||
node_modules
|
||||
.env
|
||||
12
.prettierrc
12
.prettierrc
@ -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
3
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
48
README.md
48
README.md
@ -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
|
||||
66
app/index.js
66
app/index.js
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
102
controllers/app.controller.js
Normal file
102
controllers/app.controller.js
Normal 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
2
controllers/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './app.controller.js'
|
||||
export * from './modbus.controller.js'
|
||||
139
controllers/modbus.controller.js
Normal file
139
controllers/modbus.controller.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
29
index.d.ts
vendored
@ -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;
|
||||
}
|
||||
@ -1,9 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#root/*": ["./*"]
|
||||
},
|
||||
"checkJs": true
|
||||
}
|
||||
}
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#root/*": ["./*"]
|
||||
},
|
||||
"checkJs": true
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 : {},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
45
migrations/001-init.sql
Normal 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;
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
"watch": [".env", "*/*.js"],
|
||||
"ignore": ["./public"]
|
||||
}
|
||||
{
|
||||
"watch": [".env", "*/*.js"],
|
||||
"ignore": ["./public"]
|
||||
}
|
||||
|
||||
5392
package-lock.json
generated
5392
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
65
package.json
65
package.json
@ -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/*": "./*"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
178
public/js/app.js
178
public/js/app.js
@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
4
public/js/dygraphs.min.js
vendored
4
public/js/dygraphs.min.js
vendored
File diff suppressed because one or more lines are too long
1
public/js/utils/index.js
Normal file
1
public/js/utils/index.js
Normal file
@ -0,0 +1 @@
|
||||
export * from './object.js'
|
||||
11
public/js/utils/object.js
Normal file
11
public/js/utils/object.js
Normal 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;
|
||||
}
|
||||
@ -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 };
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from './api.router.js';
|
||||
export * from './api.router.js';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}.`));
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@ -1 +1 @@
|
||||
export * from "./app.service.js";
|
||||
export * from "./app.service.js";
|
||||
|
||||
@ -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")
|
||||
);
|
||||
@ -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: [],
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user