This commit is contained in:
Sergey Yarkov 2023-11-02 21:46:50 +03:00
parent d06edd791a
commit d6f7444266
No known key found for this signature in database
GPG Key ID: E91CE1FABC619B6F
6 changed files with 231 additions and 161 deletions

View File

@ -13,7 +13,7 @@ export const modbusController = {
try {
const appConfig = await appService.getConfig();
if (!appConfig) return res.status(400).json({ message: "Application is not configured" });
await modbusService.connect(appConfig)
await modbusService.connect(appConfig);
return res.status(200).json({ message: "Connected." });
} catch (error) {
next(error);
@ -60,7 +60,8 @@ export const modbusController = {
*/
async createDevice(req, res, next) {
try {
const { id, name, g_display_reg_addr, g_display_reg_format, g_display_reg_type, g_y_label, is_logging } = req.body;
const { id, name, g_display_reg_addr, g_display_reg_format, g_display_reg_type, g_y_label, is_logging } =
req.body;
await db.run(
`INSERT INTO "modbus_slaves" (
"id",
@ -168,37 +169,46 @@ export const modbusController = {
*/
async streamData(req, res, next) {
try {
if (!modbusClient.isOpen) return res.status(503).json({ error: { message: "Modbus connection closed." } });
const { slave_id } = req.query;
if (!slave_id)
return res.status(400).json({ error: { message: "'slave_id' parameter is required." } });
const device = await db.get(`SELECT * FROM "modbus_slaves" WHERE id = ?`, [slave_id]);
if (!device)
return res.status(404).json({ error: { message: "Device not found." } });
// modbusService.createPollInterval(req, res, device).poll(1000);
if (!modbusClient.isOpen) return res.status(503).json({ error: { message: "Modbus connection closed." } });
if (!slave_id) return res.status(400).json({ error: { message: "'slave_id' parameter is required." } });
const device = await db.get(`SELECT * FROM "modbus_slaves" WHERE id = ?`, [slave_id]);
if (!device) return res.status(404).json({ error: { message: "Device not found." } });
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Connection", "keep-alive");
const streamInterval = setInterval(async () => {
try {
device.display_values = await db.all(`SELECT * FROM "display_values" WHERE slave_id = ?`, [slave_id])
const data = await modbusService.readDataFromDevice(device);
const intervalId = modbusService.startDevicePollInterval(
device,
(data, error) => {
if (error) {
next(error);
return;
}
res.write("event: message\n");
res.write(`data: ${JSON.stringify(data)}\n\n`);
} catch (error) {
clearInterval(streamInterval);
next(error);
}
}, 1000);
},
1000,
);
req.on("close", () => clearInterval(streamInterval));
req.on("close", () => clearInterval(intervalId));
// const streamInterval = setInterval(async () => {
// try {
// device.display_values = await db.all(`SELECT * FROM "display_values" WHERE slave_id = ?`, [slave_id])
// const data = await modbusService.readDataFromDevice(device);
// res.write("event: message\n");
// res.write(`data: ${JSON.stringify(data)}\n\n`);
// } catch (error) {
// clearInterval(streamInterval);
// next(error);
// }
// }, 1000);
// req.on("close", () => clearInterval(streamInterval));
} catch (error) {
next(error);
}

BIN
data.db

Binary file not shown.

View File

@ -766,18 +766,10 @@ video {
min-width: 256px;
}
.max-w-2xl {
max-width: 42rem;
}
.max-w-5xl {
max-width: 64rem;
}
.max-w-xl {
max-width: 36rem;
}
.max-w-md {
max-width: 28rem;
}
@ -854,6 +846,10 @@ video {
justify-content: space-between;
}
.justify-evenly {
justify-content: space-evenly;
}
.gap-2 {
gap: 0.5rem;
}
@ -956,6 +952,10 @@ video {
padding: 10rem;
}
.p-5 {
padding: 1.25rem;
}
.p-60 {
padding: 15rem;
}
@ -964,10 +964,6 @@ video {
padding: 20rem;
}
.p-5 {
padding: 1.25rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@ -983,21 +979,6 @@ video {
padding-bottom: 0.75rem;
}
.px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.py-4 {
padding-top: 1rem;
padding-bottom: 1rem;
}
.px-5 {
padding-left: 1.25rem;
padding-right: 1.25rem;
}
.pl-0 {
padding-left: 0px;
}

View File

@ -28,12 +28,16 @@
<span x-show="devices.length === 0" class="text-gray-500 text-sm text-center block">
No devices added.
</span>
<div @add-device.window="devices = [...devices, $event.detail];" class="flex flex-col max-h-[400px] overflow-y-auto space-y-2">
<div
@add-device.window="devices = [...devices, $event.detail];"
class="flex flex-col max-h-[400px] overflow-y-auto space-y-2"
>
<template x-for="device in devices" :key="device.id">
<button
@click="selectDevice(device)"
:class="{'bg-gray-100': selectedDevice?.id === device.id}"
class="w-full min-h-[50px] select-none border px-2.5 py-3 text-left rounded-sm hover:bg-gray-100 cursor-pointer">
class="w-full min-h-[50px] select-none border px-2.5 py-3 text-left rounded-sm hover:bg-gray-100 cursor-pointer"
>
<span x-text="device.name" class="text-md font-bold"></span>
</button>
</template>
@ -45,7 +49,8 @@
@open-create-device-modal.window="open()"
@keyup.escape.window="close()"
class="backdrop z-50"
x-show="isOpen">
x-show="isOpen"
>
<div class="modal">
<div class="flex justify-between items-center">
<h2 class="font-bold text-lg">Create new device</h2>
@ -60,14 +65,33 @@
</div>
<hr />
<form @submit.prevent="create(() => graphView = 'none')" action="#">
<div x-id="['id', 'name', 'graph-display', 'g_display_reg_addr', 'g_display_reg_format', 'g_y_label', 'is_logging']" class="flex flex-col space-y-2">
<div
x-id="['id', 'name', 'graph-display', 'g_display_reg_addr', 'g_display_reg_format', 'g_y_label', 'is_logging']"
class="flex flex-col space-y-2"
>
<div>
<label :for="$id('id')"><span class="text-red-500">*</span>Slave ID:</label>
<input x-model.number="data.id" :id="$id('id')" name="id" placeholder="1" type="number" min="1" max="255" required />
<input
x-model.number="data.id"
:id="$id('id')"
name="id"
placeholder="1"
type="number"
min="1"
max="255"
required
/>
</div>
<div>
<label :for="$id('name')"><span class="text-red-500">*</span>Name:</label>
<input x-model="data.name" placeholder="Device #1" :id="$id('name')" name="name" type="text" required />
<input
x-model="data.name"
placeholder="Device #1"
:id="$id('name')"
name="name"
type="text"
required
/>
</div>
<div>
<label :for="$id('graph-display')">Graph Display</label>
@ -79,22 +103,54 @@
<div x-show="graphView === 'new-value'">
<div>
<label :for="$id('g_y_label')"><span class="text-red-500">*</span>Value name:</label>
<input x-model="data.g_y_label" :id="$id('g_y_label')" :required="graphView === 'new-value'" name="g_y_label" placeholder="PV" type="text" />
<input
x-model="data.g_y_label"
:id="$id('g_y_label')"
:required="graphView === 'new-value'"
name="g_y_label"
placeholder="PV"
type="text"
/>
</div>
<div>
<label :for="$id('g_display_reg_addr')"><span class="text-red-500">*</span>Register address:</label>
<input x-model="data.g_display_reg_addr" :id="$id('g_display_reg_addr')" name="g_display_reg_addr" placeholder="1" type="number" min="0" max="65534" :required="graphView === 'new-value'" />
<label :for="$id('g_display_reg_addr')"
><span class="text-red-500">*</span>Register address:</label
>
<input
x-model="data.g_display_reg_addr"
:id="$id('g_display_reg_addr')"
name="g_display_reg_addr"
placeholder="1"
type="number"
min="0"
max="65534"
:required="graphView === 'new-value'"
/>
</div>
<div>
<label :for="$id('g_display_reg_type')"><span class="text-red-500">*</span>Registers type:</label>
<select x-model="data.g_display_reg_type" :id="$id('g_display_reg_type')" name="g_display_reg_type" :required="graphView === 'new-value'">
<label :for="$id('g_display_reg_type')"
><span class="text-red-500">*</span>Registers type:</label
>
<select
x-model="data.g_display_reg_type"
:id="$id('g_display_reg_type')"
name="g_display_reg_type"
:required="graphView === 'new-value'"
>
<option value="IR">Input Registers</option>
<option value="HR">Holding Registers</option>
</select>
</div>
<div>
<label :for="$id('g_display_reg_format')"><span class="text-red-500">*</span>Format (type):</label>
<select x-model="data.g_display_reg_format" :id="$id('g_display_reg_format')" name="g_display_reg_format" :required="graphView === 'new-value'">
<label :for="$id('g_display_reg_format')"
><span class="text-red-500">*</span>Format (type):</label
>
<select
x-model="data.g_display_reg_format"
:id="$id('g_display_reg_format')"
name="g_display_reg_format"
:required="graphView === 'new-value'"
>
<option value="UI16">Unsigned Int 16</option>
<option value="I16">Signed Int 16</option>
<option value="UI32">Unsigned Int 32</option>
@ -103,7 +159,12 @@
</select>
</div>
<div>
<input x-model="data.is_logging" :id="$id('is_logging')" name="is_logging" type="checkbox" />
<input
x-model="data.is_logging"
:id="$id('is_logging')"
name="is_logging"
type="checkbox"
/>
<label :for="$id('is_logging')">Logging</label>
</div>
</div>
@ -112,7 +173,14 @@
<span x-text="error?.message" class="text-red-500"></span>
</div>
<div class="mt-4 flex justify-end space-x-2">
<button x-bind:disabled="isLoading" x-bind:class="isLoading && 'opacity-70'" type="submit" class="btn">Create</button>
<button
x-bind:disabled="isLoading"
x-bind:class="isLoading && 'opacity-70'"
type="submit"
class="btn"
>
Create
</button>
<button @click="close()" class="btn bg-red-500">Cancel</button>
</div>
</form>
@ -122,7 +190,8 @@
<!-- Create new device -->
<button
@click="$dispatch('open-create-device-modal')"
class="w-full min-h-[50px] text-center border-2 border-dashed rounded-sm hover:bg-gray-50 cursor-pointer">
class="w-full min-h-[50px] text-center border-2 border-dashed rounded-sm hover:bg-gray-50 cursor-pointer"
>
<span class="text-4xl text-gray-400 select-none">+</span>
</button>
</div>
@ -150,14 +219,16 @@
<!-- Device create, delete buttons -->
<div>
<a href="#">Edit</a>
<a @click="$dispatch('dialog', {
<a
@click="$dispatch('dialog', {
title: 'Remove device',
body: `Remove ${selectedDevice.name}? All logs will be cleared.`,
isOpen: true,
onConfirm: async () => await removeDevice(selectedDevice.id)
});"
class="text-red-500"
href="#">
href="#"
>
Delete
</a>
</div>
@ -169,10 +240,19 @@
<!-- Display Graph -->
<div x-show="!isLoading && selectedDevice?.g_display_reg_addr !== null" class="mt-4">
<div id="div_g" style="width: 720px; height: 300px"></div>
<div class="flex space-x-2 mt-2">
<span class="italic text-sm">Register: <span x-text="selectedDevice?.g_display_reg_addr"></span>&comma;</span>
<span class="italic text-sm">Format: <span x-text="selectedDevice?.g_display_reg_format"></span>&comma;</span>
<span class="italic text-sm">Register type: <span x-text="selectedDevice?.g_display_reg_type"></span></span>
<div class="flex justify-evenly space-x-2 mt-3">
<span class="italic text-sm"
>Value: <span class="font-bold" x-text="selectedDevice?.g_value"></span
></span>
<span class="italic text-sm">
Register: <span x-text="selectedDevice?.g_display_reg_addr"></span
></span>
<span class="italic text-sm"
>Format: <span x-text="selectedDevice?.g_display_reg_format"></span
></span>
<span class="italic text-sm"
>Register type: <span x-text="selectedDevice?.g_display_reg_type"></span
></span>
</div>
<hr class="my-3" />
</div>
@ -219,12 +299,14 @@
</svg>
</button>
<!-- Remove display value -->
<button @click="$dispatch('dialog', {
<button
@click="$dispatch('dialog', {
title: 'Remove display value',
body: `Remove display value ${value.name}?`,
isOpen: true,
onConfirm: async () => await removeDisplayValue(value.id)
});">
});"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<path
fill="#ef4444"
@ -236,7 +318,12 @@
</div>
<hr />
<div class="flex flex-col">
<span>Value: <span x-text="value?.data"></span></span>
<span
>Value:
<span
x-text="Number.isInteger(value?.data) ? value?.data : value?.data?.toFixed(3)"
></span
></span>
<span>Reg Addr: <span x-text="value?.reg_addr"></span></span>
<span>Reg Type: <span x-text="value?.reg_type"></span></span>
<span>Format: <span x-text="value?.reg_format"></span></span>

View File

@ -62,13 +62,13 @@ document.addEventListener("alpine:init", async () => {
isOpen: false,
isLoading: false,
error: null,
graphView: '',
graphView: "",
data: {
id: null,
name: "",
g_display_reg_addr: null,
g_display_reg_format: "UI16",
g_display_reg_type: 'IR',
g_display_reg_type: "IR",
g_y_label: "",
is_logging: false,
},
@ -77,7 +77,7 @@ document.addEventListener("alpine:init", async () => {
this.data.name = "";
this.data.g_display_reg_addr = null;
this.data.g_display_reg_format = "UI16";
this.g_display_reg_type = 'IR';
this.g_display_reg_type = "IR";
this.data.g_y_label = "";
this.data.is_logging = false;
},
@ -103,7 +103,7 @@ document.addEventListener("alpine:init", async () => {
close() {
this.isOpen = false;
this.resetDataFields();
this.graphView = '';
this.graphView = "";
},
}));
@ -130,26 +130,27 @@ document.addEventListener("alpine:init", async () => {
}
},
selectDevice(device) {
if (device.id === this.selectedDevice?.id) return;
if (device.id === this.selectedDevice?.id) return;
this.error = null;
this.selectedDevice = device;
this.resetGraph();
// if (device.g_display_reg_addr !== null)
this.isLoading = true;
// if (device.g_display_reg_addr !== null)
this.isLoading = true;
dataStreamSource?.close();
dataStreamSource = new EventSource(`/api/modbus/data_stream?slave_id=${device.id}`);
dataStreamSource.onerror = (e) => {
dataStreamSource.close();
this.isLoading = false;
this.error = { message: 'Something went wrong.' }
}
this.error = { message: "Something went wrong." };
};
dataStreamSource.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log('[SSE Message]', data);
console.log("[SSE Message]", data);
if (data.graph !== null) {
this.selectedDevice.g_value = data.graph.value;
graphData.push([new Date(), data.graph.value]);
if (g === null) {
g = new Dygraph(document.getElementById("div_g"), graphData, {
@ -161,7 +162,7 @@ document.addEventListener("alpine:init", async () => {
}
g.updateOptions({ file: graphData });
}
data.displayValues.forEach((v, i) => this.selectedDevice.display_values[i] = v);
data.displayValues.forEach((v, i) => (this.selectedDevice.display_values[i] = v));
this.isLoading = false;
};
},

View File

@ -1,6 +1,6 @@
import modbusClient from "#root/config/modbus-client.config.js";
import db from "#root/config/database.config.js";
import * as utils from '#root/utils/index.js'
import * as utils from "#root/utils/index.js";
export const modbusService = {
/**
@ -9,26 +9,30 @@ export const modbusService = {
*/
async readDataFromDevice(device) {
const { g_display_reg_format, g_display_reg_addr, g_display_reg_type, display_values } = device;
/** @type {import("..").EventDataStream} */
const data = { graph: null, displayValues: [] };
modbusClient.setID(device.id);
/* Read data for graph */
if (g_display_reg_addr !== null) {
const graphValueBuf = await this.readModbusRegisters(g_display_reg_addr, g_display_reg_type, g_display_reg_format);
const graphValueBuf = await this.readModbusRegisters(
g_display_reg_addr,
g_display_reg_type,
g_display_reg_format,
);
data.graph = {
value: utils.readNumberFromBuf(graphValueBuf, device.g_display_reg_format, 'BE'),
format: g_display_reg_format
}
value: utils.readNumberFromBuf(graphValueBuf, device.g_display_reg_format, "BE"),
format: g_display_reg_format,
};
}
/* Read data for display values */
if (display_values) {
for (const v of device.display_values) {
const displayValueBuf = await this.readModbusRegisters(v.reg_addr, v.reg_type, v.reg_format)
v.data = utils.readNumberFromBuf(displayValueBuf, v.reg_format, 'BE')
const displayValueBuf = await this.readModbusRegisters(v.reg_addr, v.reg_type, v.reg_format);
v.data = utils.readNumberFromBuf(displayValueBuf, v.reg_format, "BE");
data.displayValues.push(v);
}
}
@ -41,16 +45,16 @@ export const modbusService = {
* @return {Promise<void>}
*/
async connect(appConfig) {
const {
mb_connection_type,
mb_tcp_ip,
mb_tcp_port,
mb_rtu_path,
mb_rtu_baud,
mb_rtu_data_bits,
mb_rtu_parity,
mb_rtu_stop_bits
} = appConfig
const {
mb_connection_type,
mb_tcp_ip,
mb_tcp_port,
mb_rtu_path,
mb_rtu_baud,
mb_rtu_data_bits,
mb_rtu_parity,
mb_rtu_stop_bits,
} = appConfig;
if (modbusClient.isOpen) {
modbusClient.close(undefined);
@ -58,23 +62,21 @@ export const modbusService = {
}
switch (mb_connection_type) {
case 'TCP':
{
await modbusClient.connectTCP(mb_tcp_ip, { port: mb_tcp_port });
break;
}
case "RTU":
{
await modbusClient.connectRTU(mb_rtu_path, {
baudRate: mb_rtu_baud,
dataBits: mb_rtu_data_bits,
parity: mb_rtu_parity,
stopBits: mb_rtu_stop_bits,
});
break;
}
case "TCP": {
await modbusClient.connectTCP(mb_tcp_ip, { port: mb_tcp_port });
break;
}
case "RTU": {
await modbusClient.connectRTU(mb_rtu_path, {
baudRate: mb_rtu_baud,
dataBits: mb_rtu_data_bits,
parity: mb_rtu_parity,
stopBits: mb_rtu_stop_bits,
});
break;
}
default:
throw new Error('Unsupported connection type!')
throw new Error("Unsupported connection type!");
}
},
@ -89,29 +91,29 @@ export const modbusService = {
let len = 1;
switch (format) {
case 'UI32':
case 'I32':
case 'FP32':
case "UI32":
case "I32":
case "FP32":
len = 2;
break;
default:
break;
}
switch (type) {
case 'HR':
data = await modbusClient.readHoldingRegisters(addr, len);
case "HR":
data = await modbusClient.readHoldingRegisters(addr, len);
break;
case 'IR':
case "IR":
data = await modbusClient.readInputRegisters(addr, len);
break;
case 'DI':
case "DI":
data = await modbusClient.readDiscreteInputs(addr, len);
break;
default:
throw new Error('Incorrect modbus register type!');
throw new Error("Incorrect modbus register type!");
}
return data.buffer;
},
@ -136,35 +138,24 @@ export const modbusService = {
`);
// @ts-ignore
devices.forEach((s) => (s.display_values = JSON.parse(s.display_values)));
return devices
return devices;
},
/**
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import("..").ModbusDevice} device
* @param {(data: import("..").EventDataStream, error: any) => void} callback
* @param {number} intervalMs
*/
// createPollInterval(req, res, device, pollIntervalMs) {
// let intervalId;
// res.setHeader("Cache-Control", "no-cache");
// res.setHeader("Content-Type", "text/event-stream");
// res.setHeader("Connection", "keep-alive");
// return {
// /**
// * @param {number} pollIntervalMs
// */
// poll(pollIntervalMs) {
// intervalId = setInterval(async () => {
// device.display_values = await db.all(`SELECT * FROM "display_values" WHERE slave_id = ?`, [device.id])
// const data = await modbusService.readDataFromDevice(device);
// res.write("event: message\n");
// res.wriste(`data: ${JSON.stringify(data)}\n\n`);
// }, pollIntervalMs)
// req.on("close", () => clearInterval(intervalId));
// }
// }
// }
}
startDevicePollInterval(device, callback, intervalMs) {
const poll = async () => {
try {
device.display_values = await db.all(`SELECT * FROM "display_values" WHERE slave_id = ?`, [device.id]);
const data = await this.readDataFromDevice(device);
callback(data);
} catch (error) {
callback(null, error);
}
};
return setInterval(poll, intervalMs);
},
};