Commit 744cee89 by James Yuan

feat: Modbus 模拟器与网关实采联调

本机三端口模拟约 800 点,Windows 网关真实轮询 Holding 并上报 snapshot;平台可切 gateway 模式对接。

Co-authored-by: Cursor <cursoragent@cursor.com>
parent f2d7804e
......@@ -9,3 +9,5 @@ server/data/*.json
web/dist
.vscode
.idea
.venv
**/__pycache__/
......@@ -50,7 +50,7 @@ services:
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-12h}
DATA_MODE: ${DATA_MODE:-mock}
# 临时 Windows 网关已弃用;本机无网关时留空
# Windows 协议网关,例如 http://192.168.32.121:38080
GATEWAY_URL: ${GATEWAY_URL:-}
# 容器内走服务名;浏览器 ICE 仍用宿主机端口 8889
WEBRTC_WHEP_BASE: ${WEBRTC_WHEP_BASE:-http://mediamtx:8889}
......
......@@ -6,9 +6,9 @@
"adminUsername": "admin",
"adminPassword": "Admin@123",
"modbusHosts": [
{ "id": "mb-102", "name": "PAU-201-1", "host": "192.168.10.102", "port": 502, "enabled": true },
{ "id": "mb-103", "name": "计量/通风柜", "host": "192.168.10.103", "port": 502, "enabled": true },
{ "id": "mb-104", "name": "房间/冰箱", "host": "192.168.10.104", "port": 502, "enabled": true }
{ "id": "mb-102", "name": "PAU-201-1(本机模拟)", "host": "192.168.32.122", "port": 1502, "enabled": true },
{ "id": "mb-103", "name": "计量/通风柜(本机模拟)", "host": "192.168.32.122", "port": 1503, "enabled": true },
{ "id": "mb-104", "name": "房间/冰箱(本机模拟)", "host": "192.168.32.122", "port": 1504, "enabled": true }
],
"nvr": {
"id": "nvr-140",
......
......@@ -3,11 +3,15 @@
* - Platform API: /health /snapshot /config/points/reload ...
* - Admin UI: /ui/ and /admin/api/
*/
// Winsock2 必须先于 windows.h / httplib
#include "modbus_tcp.hpp"
#include "httplib.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iostream>
......@@ -16,12 +20,9 @@
#include <random>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#ifdef _WIN32
#include <windows.h>
#endif
namespace {
struct PointCfg {
......@@ -37,6 +38,13 @@ struct PointCfg {
bool enabled = true;
};
struct PointValue {
double raw = 0;
double eng = 0;
std::string quality = "bad";
std::string ts;
};
struct ModbusHost {
std::string id;
std::string name;
......@@ -77,8 +85,12 @@ struct AppState {
std::vector<HikDevice> doors;
int pointsVersion = 0;
std::vector<PointCfg> points;
std::string version = "0.2.0-ui";
std::map<std::string, PointValue> values; // key = point id
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatencyMs;
std::string version = "0.3.0-modbus";
std::map<std::string, Session> sessions;
bool pollRunning = false;
};
AppState g;
......@@ -412,6 +424,154 @@ void unauthorized(httplib::Response& res) {
res.set_content("{\"ok\":false,\"message\":\"未登录或会话过期\"}", "application/json; charset=utf-8");
}
int wordCountForType(const std::string& dataType) {
std::string t = dataType;
for (char& c : t) c = static_cast<char>(::tolower(c));
if (t.find("double") != std::string::npos) return 4;
if (t.find("float") != std::string::npos) return 2;
return 1;
}
void decodePoint(const PointCfg& p, const std::vector<uint16_t>& regs, size_t off, PointValue& v,
const std::string& ts) {
const int n = wordCountForType(p.dataType);
if (off + static_cast<size_t>(n) > regs.size()) {
v.quality = "bad";
return;
}
std::string t = p.dataType;
for (char& c : t) c = static_cast<char>(::tolower(c));
double raw = 0;
double eng = 0;
if (t.find("float") != std::string::npos) {
eng = modbus_tcp::regsToFloat(regs[off], regs[off + 1]);
raw = eng;
} else if (t.find("double") != std::string::npos) {
eng = modbus_tcp::regsToDouble(regs[off], regs[off + 1], regs[off + 2], regs[off + 3]);
raw = eng;
} else if (t.find("bool") != std::string::npos) {
raw = regs[off] ? 1 : 0;
eng = raw;
} else if (t.find("short") != std::string::npos) {
raw = modbus_tcp::asI16(regs[off]);
eng = raw * p.scale + p.bias;
} else {
raw = regs[off];
eng = raw * p.scale + p.bias;
}
v.raw = raw;
v.eng = eng;
v.quality = "good";
v.ts = ts;
}
void pollOnce() {
std::vector<ModbusHost> hosts;
std::vector<PointCfg> points;
const int chunk = 100;
{
std::lock_guard<std::mutex> lock(g.mu);
hosts = g.modbusHosts;
points = g.points;
}
const std::string ts = isoNow();
std::map<std::string, PointValue> nextValues;
std::map<std::string, bool> hostOk;
std::map<std::string, int> hostLatency;
for (const auto& h : hosts) {
if (!h.enabled || h.host.empty()) continue;
std::vector<PointCfg> mine;
for (const auto& p : points) {
if (!p.enabled) continue;
if (p.gatewayId == h.id || (p.gatewayId.empty() && hosts.size() == 1)) mine.push_back(p);
}
bool anyOk = false;
int lastLat = 0;
int unit = mine.empty() ? 1 : mine.front().unitId;
if (mine.empty()) {
std::vector<uint16_t> tmp;
int lat = 0;
bool ok = modbus_tcp::readHolding(h.host, h.port, unit, 1, 1, tmp, &lat);
hostOk[h.id] = ok;
hostLatency[h.id] = lat;
continue;
}
int minAddr = mine.front().address;
int maxEnd = mine.front().address + wordCountForType(mine.front().dataType);
for (const auto& p : mine) {
minAddr = std::min(minAddr, p.address);
maxEnd = std::max(maxEnd, p.address + wordCountForType(p.dataType));
}
if (minAddr < 0) minAddr = 0;
std::map<int, uint16_t> flat;
for (int start = minAddr; start < maxEnd;) {
int cnt = std::min(chunk, maxEnd - start);
std::vector<uint16_t> regs;
int lat = 0;
if (modbus_tcp::readHolding(h.host, h.port, unit, start, cnt, regs, &lat) &&
static_cast<int>(regs.size()) == cnt) {
anyOk = true;
lastLat = lat;
for (int i = 0; i < cnt; ++i) flat[start + i] = regs[static_cast<size_t>(i)];
start += cnt;
} else {
// 单点回退,避免整段失败
std::vector<uint16_t> one;
if (modbus_tcp::readHolding(h.host, h.port, unit, start, 1, one, &lat) && !one.empty()) {
anyOk = true;
lastLat = lat;
flat[start] = one[0];
}
start += 1;
}
}
hostOk[h.id] = anyOk;
hostLatency[h.id] = lastLat;
for (const auto& p : mine) {
const int n = wordCountForType(p.dataType);
std::vector<uint16_t> regs(static_cast<size_t>(n), 0);
bool complete = true;
for (int i = 0; i < n; ++i) {
auto it = flat.find(p.address + i);
if (it == flat.end()) {
complete = false;
break;
}
regs[static_cast<size_t>(i)] = it->second;
}
PointValue v;
v.ts = ts;
if (complete) decodePoint(p, regs, 0, v, ts);
else v.quality = "bad";
nextValues[p.id.empty() ? p.itemId : p.id] = v;
}
}
{
std::lock_guard<std::mutex> lock(g.mu);
g.values.swap(nextValues);
g.hostOk.swap(hostOk);
g.hostLatencyMs.swap(hostLatency);
}
}
void pollLoop() {
while (true) {
try {
pollOnce();
} catch (...) {
std::cerr << "[gateway] modbus poll exception\n";
}
int ms = 2000;
{
std::lock_guard<std::mutex> lock(g.mu);
ms = g.pollMs > 200 ? g.pollMs : 200;
}
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
}
}
std::string buildHealthJson() {
std::lock_guard<std::mutex> lock(g.mu);
std::ostringstream o;
......@@ -419,8 +579,16 @@ std::string buildHealthJson() {
<< "\",\"pointsVersion\":" << g.pointsVersion << ",\"modbus\":[";
for (size_t i = 0; i < g.modbusHosts.size(); ++i) {
if (i) o << ',';
o << "{\"host\":\"" << jsonEscape(g.modbusHosts[i].host) << "\",\"port\":" << g.modbusHosts[i].port
<< ",\"ok\":" << (g.modbusHosts[i].enabled ? "true" : "false") << ",\"latencyMs\":0}";
const auto& h = g.modbusHosts[i];
bool ok = false;
int lat = 0;
auto it = g.hostOk.find(h.id);
if (it != g.hostOk.end()) ok = it->second;
auto lit = g.hostLatencyMs.find(h.id);
if (lit != g.hostLatencyMs.end()) lat = lit->second;
o << "{\"id\":\"" << jsonEscape(h.id) << "\",\"host\":\"" << jsonEscape(h.host)
<< "\",\"port\":" << h.port << ",\"ok\":" << (ok ? "true" : "false")
<< ",\"latencyMs\":" << lat << "}";
}
o << "],\"hikvision\":{\"loggedIn\":0,\"alarms\":false,\"note\":\"HCNetSDK pending\"}}";
return o.str();
......@@ -461,10 +629,21 @@ std::string buildSnapshotJson() {
if (!p.enabled) continue;
if (!first) o << ',';
first = false;
const double raw = static_cast<double>((p.address * 17 + 250) % 400);
const double eng = raw * p.scale + p.bias;
const std::string key = p.id.empty() ? p.itemId : p.id;
auto it = g.values.find(key);
double raw = 0;
double eng = 0;
std::string quality = "bad";
std::string pts = ts;
if (it != g.values.end()) {
raw = it->second.raw;
eng = it->second.eng;
quality = it->second.quality;
if (!it->second.ts.empty()) pts = it->second.ts;
}
o << "{\"id\":\"" << jsonEscape(p.id) << "\",\"itemId\":\"" << jsonEscape(p.itemId)
<< "\",\"raw\":" << raw << ",\"eng\":" << eng << ",\"quality\":\"good\",\"ts\":\"" << ts
<< "\",\"gatewayId\":\"" << jsonEscape(p.gatewayId) << "\",\"raw\":" << raw
<< ",\"eng\":" << eng << ",\"quality\":\"" << jsonEscape(quality) << "\",\"ts\":\"" << pts
<< "\"}";
}
o << "]}";
......@@ -557,10 +736,26 @@ int main(int argc, char** argv) {
size_t count = 0;
{
std::lock_guard<std::mutex> lock(g.mu);
// 同步平台下发的 gateways(host/port)
auto gwObjs = splitObjects(req.body, "gateways");
if (!gwObjs.empty()) {
g.modbusHosts.clear();
for (const auto& obj : gwObjs) {
ModbusHost m;
m.id = extractString(obj, "id");
m.name = extractString(obj, "name");
m.host = extractString(obj, "host");
m.port = extractInt(obj, "port", 502);
m.enabled = extractBool(obj, "enabled", true);
if (!m.id.empty() && !m.host.empty()) g.modbusHosts.push_back(m);
}
saveConfigLocked();
}
g.points = std::move(parsed);
g.pointsVersion += 1;
ver = g.pointsVersion;
count = g.points.size();
g.values.clear();
}
std::ostringstream o;
o << "{\"ok\":true,\"pointsVersion\":" << ver << ",\"count\":" << count << "}";
......@@ -749,8 +944,16 @@ int main(int argc, char** argv) {
port = g.listenPort;
host = g.listenHost;
}
{
std::lock_guard<std::mutex> lock(g.mu);
if (!g.pollRunning) {
g.pollRunning = true;
std::thread(pollLoop).detach();
}
}
std::cout << "[gateway] JhLabGateway " << g.version << " http://" << host << ":" << port << "\n";
std::cout << "[gateway] Admin UI: http://127.0.0.1:" << port << "/ui/\n";
std::cout << "[gateway] Modbus poll thread started\n";
if (!svr.listen(host.c_str(), port)) {
std::cerr << "[gateway] failed to bind\n";
return 1;
......
#pragma once
/**
* 最小 Modbus TCP 客户端(FC03 Holding Register),Winsock。
*/
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#endif
#include <cstdint>
#include <cstring>
#include <string>
#include <vector>
namespace modbus_tcp {
inline bool ensureWsa() {
#ifdef _WIN32
static bool ok = false;
static bool tried = false;
if (tried) return ok;
tried = true;
WSADATA wsa{};
ok = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0);
return ok;
#else
return false;
#endif
}
inline uint16_t rdBe16(const uint8_t* p) {
return static_cast<uint16_t>((p[0] << 8) | p[1]);
}
inline void wrBe16(uint8_t* p, uint16_t v) {
p[0] = static_cast<uint8_t>((v >> 8) & 0xFF);
p[1] = static_cast<uint8_t>(v & 0xFF);
}
/** 读 Holding Registers;address 与主站配置一致(0-based PDU)。成功返回 true。 */
inline bool readHolding(const std::string& host, int port, int unitId, int address, int count,
std::vector<uint16_t>& out, int* latencyMs = nullptr) {
out.clear();
if (count <= 0 || count > 125) return false;
#ifdef _WIN32
if (!ensureWsa()) return false;
const auto t0 = GetTickCount64();
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) return false;
DWORD timeout = 2000;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&timeout), sizeof(timeout));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<u_short>(port));
if (InetPtonA(AF_INET, host.c_str(), &addr.sin_addr) != 1) {
// 尝试解析主机名
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* res = nullptr;
if (getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || !res) {
closesocket(sock);
return false;
}
addr.sin_addr = reinterpret_cast<sockaddr_in*>(res->ai_addr)->sin_addr;
freeaddrinfo(res);
}
if (connect(sock, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
closesocket(sock);
return false;
}
static uint16_t txn = 1;
uint16_t tid = txn++;
uint8_t req[12];
wrBe16(req + 0, tid);
wrBe16(req + 2, 0); // protocol
wrBe16(req + 4, 6); // length
req[6] = static_cast<uint8_t>(unitId & 0xFF);
req[7] = 0x03; // FC03
wrBe16(req + 8, static_cast<uint16_t>(address));
wrBe16(req + 10, static_cast<uint16_t>(count));
int sent = send(sock, reinterpret_cast<const char*>(req), 12, 0);
if (sent != 12) {
closesocket(sock);
return false;
}
uint8_t hdr[7];
int got = 0;
while (got < 7) {
int n = recv(sock, reinterpret_cast<char*>(hdr + got), 7 - got, 0);
if (n <= 0) {
closesocket(sock);
return false;
}
got += n;
}
uint16_t len = rdBe16(hdr + 4);
if (len < 3 || len > 260) {
closesocket(sock);
return false;
}
std::vector<uint8_t> body(len);
// unit already in hdr[6]; need remaining len-1 bytes after unit
body[0] = hdr[6];
int need = static_cast<int>(len) - 1;
got = 0;
while (got < need) {
int n = recv(sock, reinterpret_cast<char*>(body.data() + 1 + got), need - got, 0);
if (n <= 0) {
closesocket(sock);
return false;
}
got += n;
}
closesocket(sock);
if (latencyMs) *latencyMs = static_cast<int>(GetTickCount64() - t0);
// body: [unit][fc][bc][data...]
if (body.size() < 3) return false;
if (body[1] & 0x80) return false; // exception
if (body[1] != 0x03) return false;
uint8_t bc = body[2];
if (bc != count * 2 || body.size() < static_cast<size_t>(3 + bc)) return false;
out.resize(count);
for (int i = 0; i < count; ++i) {
out[i] = rdBe16(body.data() + 3 + i * 2);
}
return true;
#else
(void)host;
(void)port;
(void)unitId;
(void)address;
(void)latencyMs;
return false;
#endif
}
inline float regsToFloat(uint16_t hi, uint16_t lo) {
uint32_t bits = (uint32_t(hi) << 16) | uint32_t(lo);
float f = 0;
std::memcpy(&f, &bits, sizeof(f));
return f;
}
inline double regsToDouble(uint16_t a, uint16_t b, uint16_t c, uint16_t d) {
uint64_t bits =
(uint64_t(a) << 48) | (uint64_t(b) << 32) | (uint64_t(c) << 16) | uint64_t(d);
double x = 0;
std::memcpy(&x, &bits, sizeof(x));
return x;
}
inline int16_t asI16(uint16_t v) { return static_cast<int16_t>(v); }
} // namespace modbus_tcp
......@@ -338,10 +338,14 @@ export async function updateGateway(
}
const cur = (await listGateways()).find((g) => g.id === id)
if (!cur) return null
const next = { ...cur, ...patch, updatedAt: now() }
const next = {
...cur,
...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)),
updatedAt: now(),
} as Gateway
await query(
`UPDATE gateways SET name=$2, host=$3, port=$4, enabled=$5, meta=$6::jsonb, updated_at=NOW() WHERE id=$1`,
[id, next.name, next.host, next.port, next.enabled, JSON.stringify(next.meta)],
[id, next.name, next.host, next.port, next.enabled, JSON.stringify(next.meta ?? {})],
)
return next
}
......
# Modbus TCP 三网关模拟器
按现场协议 CSV 加载约 **800** 个点位,在本机开三个 Modbus TCP 从站,对应:
| 网关 | 现场 IP | 本机默认端口 | 点位数(约) | 内容 |
|---|---|---|---|---|
| mb-102 | 192.168.10.102 | **1502** | 302 | PAU / 深度除湿 |
| mb-103 | 192.168.10.103 | **1503** | 77 | 电表 / 能量计 / 通风柜 / 流量计 |
| mb-104 | 192.168.10.104 | **1504** | 421 | 房间屏 / PLC / 冰箱 / N·E 电 |
全部映射为 **Holding Register(4X)**;`Float`/`Double` 按 IEEE754 大端多寄存器写入。数值按正弦 + 噪声持续变化。
## 安装与运行
```bash
cd tools/modbus_sim
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# 默认读取仓库旁「设备对接」协议包
python -u simulate.py
# 或指定 CSV 根目录 / 端口 / 绑定地址
python -u simulate.py \
--csv-root "/path/to/澳门镜湖医院协议20261019" \
--host 0.0.0.0 \
--port-102 1502 --port-103 1503 --port-104 1504
```
## 联调
本机 Windows 网关或其它主站:
```
102 → 127.0.0.1:1502
103 → 127.0.0.1:1503
104 → 127.0.0.1:1504
```
快速读测:
```bash
python - <<'PY'
from pymodbus.client import ModbusTcpClient
c = ModbusTcpClient('127.0.0.1', port=1504)
assert c.connect()
r = c.read_holding_registers(701, count=8)
regs = r.registers
# Short 冰箱温度:有符号 /10 → ℃
temps = [((v - 65536) if v >= 32768 else v) / 10 for v in regs]
print(regs, temps)
c.close()
PY
```
`.104` 冰箱温度地址 `701–708`(Short,×0.1℃)会随时间缓慢变化。
#!/usr/bin/env python3
"""
三网关 Modbus TCP 模拟器:从现场协议 CSV 加载约 800 点,持续刷新 Holding 寄存器。
"""
from __future__ import annotations
import argparse
import asyncio
import math
import struct
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
try:
from pymodbus.datastore import (
ModbusSequentialDataBlock,
ModbusServerContext,
ModbusSlaveContext,
)
from pymodbus.server import ModbusTcpServer
except ImportError:
print("请先安装依赖: pip install -r requirements.txt", file=sys.stderr)
raise
try:
from pymodbus.device import ModbusDeviceIdentification # type: ignore
except ImportError: # pragma: no cover
from pymodbus.server.device import ModbusDeviceIdentification # type: ignore
@dataclass
class PointDef:
name: str
address: int
data_type: str
item_id: str
scale: float = 0.0
gateway: str = ""
file: str = ""
@dataclass
class GatewaySim:
key: str
label: str
port: int
points: list[PointDef] = field(default_factory=list)
block: ModbusSequentialDataBlock | None = None
context: ModbusServerContext | None = None
GATEWAY_FOLDERS = {
"102": ("机组01", "PAU / 除湿"),
"103": ("机组02", "计量 / 通风柜 / 流量计"),
"104": ("房间", "屏 / PLC / 冰箱 / N·E电"),
}
BLOCK_SIZE = 2048 # 覆盖地址 1–882(含 Double 占 4 字)
# pymodbus 3.x SlaveContext 读时 address+=1,直接写 block 需同样偏移
BLOCK_ADDR_OFFSET = 1
def decode_csv_bytes(raw: bytes) -> str:
for enc in ("utf-8-sig", "gbk", "gb18030", "utf-8"):
try:
return raw.decode(enc)
except UnicodeDecodeError:
continue
return raw.decode("gbk", errors="replace")
def split_csv_line(line: str) -> list[str]:
out: list[str] = []
cur = ""
in_q = False
i = 0
while i < len(line):
ch = line[i]
if in_q:
if ch == '"' and i + 1 < len(line) and line[i + 1] == '"':
cur += '"'
i += 2
continue
if ch == '"':
in_q = False
i += 1
continue
cur += ch
i += 1
continue
if ch == '"':
in_q = True
i += 1
continue
if ch == ",":
out.append(cur)
cur = ""
i += 1
continue
cur += ch
i += 1
out.append(cur)
return out
def parse_points_csv(path: Path, gateway: str) -> list[PointDef]:
text = decode_csv_bytes(path.read_bytes())
lines = [ln.strip() for ln in text.replace("\r\n", "\n").split("\n") if ln.strip()]
if len(lines) < 2:
return []
header = [h.strip() for h in split_csv_line(lines[0])]
def col(*names: str) -> int:
lower = {h.lower(): i for i, h in enumerate(header)}
for n in names:
if n.lower() in lower:
return lower[n.lower()]
return -1
i_name = col("Name")
i_type = col("DataType")
i_addr = col("Modbus Address", "ModbusAddress")
i_item = col("ItemID", "ItemId")
i_scale = col("Scale")
if i_name < 0 or i_addr < 0:
print(f"[skip] 缺 Name/Modbus Address: {path.name}")
return []
points: list[PointDef] = []
for ln in lines[1:]:
cols = split_csv_line(ln)
# 现场表偶发多列,ItemID 在最后
item = cols[i_item].strip() if 0 <= i_item < len(cols) else ""
if len(cols) > len(header):
last = cols[-1].strip()
if last and ("RTU" in last or "." in last):
item = last
name = cols[i_name].strip() if i_name < len(cols) else ""
try:
addr = int(float(cols[i_addr].strip()))
except (ValueError, IndexError):
continue
dtype = cols[i_type].strip() if 0 <= i_type < len(cols) else "Short"
try:
scale = float(cols[i_scale]) if 0 <= i_scale < len(cols) and cols[i_scale].strip() else 0.0
except ValueError:
scale = 0.0
if not name and not item:
continue
points.append(
PointDef(
name=name or item or f"addr-{addr}",
address=addr,
data_type=dtype or "Short",
item_id=item or f"{name}@{addr}",
scale=scale,
gateway=gateway,
file=path.name,
)
)
return points
def find_csv_root(explicit: str | None) -> Path:
if explicit:
p = Path(explicit).expanduser().resolve()
if not p.is_dir():
raise SystemExit(f"CSV 目录不存在: {p}")
return p
here = Path(__file__).resolve()
# simulate.py → modbus_sim → tools → web → code → 00-澳门镜湖医院
hospital_root = here.parents[4]
candidates = [
hospital_root / "设备对接" / "澳门镜湖医院协议20261019" / "澳门镜湖医院协议20261019",
here.parents[2] / ".." / ".." / "设备对接" / "澳门镜湖医院协议20261019" / "澳门镜湖医院协议20261019",
]
for c in candidates:
c = c.resolve()
if c.is_dir() and any(c.rglob("*.csv")):
return c
raise SystemExit(
"未找到协议 CSV。请用 --csv-root 指定 "
"「设备对接/澳门镜湖医院协议20261019/澳门镜湖医院协议20261019」目录"
)
def load_gateways(csv_root: Path, ports: dict[str, int]) -> list[GatewaySim]:
sims: list[GatewaySim] = []
for key, (folder, label) in GATEWAY_FOLDERS.items():
d = csv_root / folder
if not d.is_dir():
print(f"[warn] 缺少目录 {d}")
continue
pts: list[PointDef] = []
for csv_path in sorted(d.glob("*.csv")):
pts.extend(parse_points_csv(csv_path, key))
# 同地址保留第一条
uniq: dict[int, PointDef] = {}
for p in pts:
uniq.setdefault(p.address, p)
ordered = sorted(uniq.values(), key=lambda x: x.address)
sims.append(
GatewaySim(key=key, label=label, port=ports[key], points=ordered)
)
print(f"[load] .{key} {label}: {len(ordered)} 点 ← {folder}/ ({len(list(d.glob('*.csv')))} 文件)")
return sims
def float_to_regs(value: float) -> tuple[int, int]:
raw = struct.pack(">f", float(value))
hi, lo = struct.unpack(">HH", raw)
return hi, lo
def double_to_regs(value: float) -> tuple[int, int, int, int]:
raw = struct.pack(">d", float(value))
return struct.unpack(">HHHH", raw)
def u16(n: int) -> int:
return int(n) & 0xFFFF
def write_point(block: ModbusSequentialDataBlock, point: PointDef, eng: float, t: float) -> None:
"""按类型写入;CSV Modbus Address 与主站读地址一致,写入时补偿 pymodbus +1。"""
addr = point.address + BLOCK_ADDR_OFFSET
if addr < 0 or addr >= BLOCK_SIZE - 4:
return
dtype = point.data_type.lower()
scale = point.scale if point.scale not in (0, 0.0) else None
if "bool" in dtype:
block.setValues(addr, [1 if eng >= 0.5 else 0])
return
if "float" in dtype:
hi, lo = float_to_regs(eng)
block.setValues(addr, [hi, lo])
return
if "double" in dtype:
regs = list(double_to_regs(eng))
block.setValues(addr, regs)
return
# Short / Word / 其它整数:若 scale 为 0.1 一类,寄存器存 raw=eng/scale
if scale and abs(scale) > 1e-12:
raw = int(round(eng / scale))
else:
# 默认温度/湿度类按 *0.1 存储
if any(k in point.name for k in ("温", "湿", "temp", "humi")) or "冰箱" in point.item_id:
raw = int(round(eng * 10))
else:
raw = int(round(eng))
if "short" in dtype:
raw = max(-32768, min(32767, raw))
if raw < 0:
raw = raw + 65536
else:
raw = u16(raw)
block.setValues(addr, [u16(raw)])
def base_engineering(point: PointDef) -> float:
"""按点名给出合理基线工程值。"""
n = point.name + point.item_id
addr = point.address
if any(k in n for k in ("温", "Temp", "温度")):
if "冰箱" in n or "冷" in n:
return -20.0 + (addr % 7) * 0.3
return 22.0 + (addr % 10) * 0.15
if any(k in n for k in ("湿", "Humid")):
return 45.0 + (addr % 20) * 0.2
if any(k in n for k in ("压差", "压力", "Pa")):
return -15.0 + (addr % 8) * 0.5
if any(k in n for k in ("风量", "风速", "频率", "Hz")):
return 35.0 + (addr % 15)
if any(k in n for k in ("电", "功率", "电流", "电压", "kWh", "电能")):
return 100.0 + (addr % 50) * 1.7
if any(k in n for k in ("流量", "冷量", "热量")):
return 12.0 + (addr % 9) * 0.8
if "bool" in point.data_type.lower() or any(k in n for k in ("启停", "报警", "故障", "开", "关")):
return 1.0 if (addr % 3) else 0.0
return 10.0 + (addr % 100) * 0.1
def refresh_values(sims: list[GatewaySim], t0: float) -> None:
t = time.time() - t0
for sim in sims:
if not sim.block:
continue
for i, p in enumerate(sim.points):
base = base_engineering(p)
# 慢变正弦 + 轻微噪声感(确定性,便于复现)
wave = math.sin(t * 0.15 + i * 0.17 + p.address * 0.01)
if "bool" in p.data_type.lower() or any(
k in (p.name + p.item_id) for k in ("启停", "报警", "故障")
):
eng = 1.0 if (int(t / 5) + i) % 4 else 0.0
else:
eng = base * (1.0 + 0.03 * wave)
write_point(sim.block, p, eng, t)
def build_context(sim: GatewaySim) -> ModbusServerContext:
block = ModbusSequentialDataBlock(0, [0] * BLOCK_SIZE)
sim.block = block
store = ModbusSlaveContext(hr=block)
context = ModbusServerContext(slaves=store, single=True)
sim.context = context
refresh_values([sim], time.time())
return context
def identity() -> ModbusDeviceIdentification:
ident = ModbusDeviceIdentification()
ident.VendorName = "JhLab"
ident.ProductCode = "MODBUS-SIM"
ident.VendorUrl = "https://localhost"
ident.ProductName = "KiangWu Lab Gateway Simulator"
ident.ModelName = "3-Gateway-800pts"
ident.MajorMinorRevision = "1.0.0"
return ident
async def updater_loop(sims: list[GatewaySim], interval: float) -> None:
t0 = time.time()
while True:
try:
refresh_values(sims, t0)
except Exception as e: # noqa: BLE001
print(f"[update] error: {e}")
await asyncio.sleep(interval)
async def run_async(sims: list[GatewaySim], host: str, interval: float) -> None:
for sim in sims:
build_context(sim)
print(f"[listen] .{sim.key} {sim.label} → {host}:{sim.port} points={len(sim.points)}")
servers = [
ModbusTcpServer(
context=sim.context,
identity=identity(),
address=(host, sim.port),
)
for sim in sims
]
print("[ready] Ctrl+C 退出")
await asyncio.gather(
updater_loop(sims, interval),
*[srv.serve_forever() for srv in servers],
)
def main() -> None:
ap = argparse.ArgumentParser(description="三网关 Modbus TCP 模拟器(~800 点)")
ap.add_argument("--csv-root", default=None, help="协议 CSV 根目录(含 机组01/机组02/房间)")
ap.add_argument("--host", default="0.0.0.0", help="监听地址")
ap.add_argument("--port-102", type=int, default=1502)
ap.add_argument("--port-103", type=int, default=1503)
ap.add_argument("--port-104", type=int, default=1504)
ap.add_argument("--interval", type=float, default=1.0, help="刷新周期秒")
args = ap.parse_args()
csv_root = find_csv_root(args.csv_root)
print(f"[csv] {csv_root}")
ports = {"102": args.port_102, "103": args.port_103, "104": args.port_104}
sims = load_gateways(csv_root, ports)
total = sum(len(s.points) for s in sims)
print(f"[total] {total} 唯一地址点位(三网关)")
if total == 0:
raise SystemExit("未加载到任何点位")
try:
asyncio.run(run_async(sims, args.host, args.interval))
except KeyboardInterrupt:
print("\n[exit]")
sys.exit(0)
if __name__ == "__main__":
main()
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { api } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
......@@ -42,7 +42,7 @@ interface DevicePoint {
}
type Tab = 'devices' | 'points' | 'gateways'
type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | null
type Dialog = 'import' | 'createPoint' | 'createDevice' | 'editPoint' | 'viewDevice' | null
const auth = useAuthStore()
const tab = ref<Tab>('devices')
......@@ -57,6 +57,9 @@ const devices = ref<Device[]>([])
const points = ref<DevicePoint[]>([])
const pointsVersion = ref(1)
const gatewayHealth = ref<{ ok: boolean; message?: string; raw?: unknown } | null>(null)
const selectedDevice = ref<Device | null>(null)
const returnToDeviceView = ref(false)
let viewRefreshTimer: number | undefined
const filterGatewayId = ref('')
const filterDeviceId = ref('')
......@@ -106,11 +109,67 @@ const devicesWithPointCount = computed(() =>
})),
)
const selectedDevicePoints = computed(() => {
if (!selectedDevice.value) return []
return points.value
.filter((p) => p.deviceId === selectedDevice.value!.id)
.sort((a, b) => a.modbusAddress - b.modbusAddress || a.name.localeCompare(b.name, 'zh'))
})
function clearFlash() {
message.value = ''
error.value = ''
}
function formatEng(v: number | null) {
if (v === null || v === undefined || Number.isNaN(v)) return '—'
return Number.isInteger(v) ? String(v) : v.toFixed(2)
}
function formatTs(ts: string | null) {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString('zh-CN', { hour12: false })
} catch {
return ts
}
}
function stopViewRefresh() {
if (viewRefreshTimer !== undefined) {
window.clearInterval(viewRefreshTimer)
viewRefreshTimer = undefined
}
}
async function refreshPointsOnly() {
try {
const pts = await api<{ points: DevicePoint[]; pointsVersion: number }>('/api/devices/points', {
token: auth.token,
})
points.value = pts.points
pointsVersion.value = pts.pointsVersion
} catch {
/* keep last values */
}
}
function startViewRefresh() {
stopViewRefresh()
void refreshPointsOnly()
viewRefreshTimer = window.setInterval(() => {
void refreshPointsOnly()
}, 3000)
}
function openDeviceDetail(d: Device) {
clearFlash()
selectedDevice.value = d
returnToDeviceView.value = false
dialog.value = 'viewDevice'
startViewRefresh()
}
function openDialog(kind: Dialog) {
clearFlash()
if (kind === 'import') {
......@@ -140,13 +199,26 @@ function openDialog(kind: Dialog) {
}
function closeDialog() {
if (dialog.value === 'editPoint' && returnToDeviceView.value && selectedDevice.value) {
editPoint.value = null
error.value = ''
returnToDeviceView.value = false
dialog.value = 'viewDevice'
startViewRefresh()
return
}
stopViewRefresh()
dialog.value = null
editPoint.value = null
selectedDevice.value = null
returnToDeviceView.value = false
error.value = ''
}
function openEditPoint(p: DevicePoint) {
clearFlash()
returnToDeviceView.value = dialog.value === 'viewDevice'
stopViewRefresh()
editPoint.value = { ...p }
dialog.value = 'editPoint'
}
......@@ -204,8 +276,17 @@ async function savePoint() {
}),
})
message.value = '点位已保存'
editPoint.value = null
error.value = ''
if (returnToDeviceView.value && selectedDevice.value) {
returnToDeviceView.value = false
dialog.value = 'viewDevice'
await loadAll()
startViewRefresh()
} else {
closeDialog()
await loadAll()
}
} catch (e) {
error.value = e instanceof Error ? e.message : '保存失败'
} finally {
......@@ -363,6 +444,7 @@ function deviceName(id: string | null) {
}
onMounted(loadAll)
onUnmounted(stopViewRefresh)
</script>
<template>
......@@ -465,7 +547,12 @@ onMounted(loadAll)
</tr>
</thead>
<tbody>
<tr v-for="d in devicesWithPointCount" :key="d.id">
<tr
v-for="d in devicesWithPointCount"
:key="d.id"
class="row-click"
@click="openDeviceDetail(d)"
>
<td>{{ d.name }}</td>
<td class="mono">{{ d.code }}</td>
<td>{{ d.pointCount }}</td>
......@@ -539,6 +626,79 @@ onMounted(loadAll)
</div>
</section>
<!-- 设备详情 / 点位数值 -->
<div v-if="dialog === 'viewDevice' && selectedDevice" class="modal-mask" @click.self="closeDialog">
<div class="modal panel modal-lg">
<div class="panel-head">
<div class="panel-title">
<div class="vbar" />
<h3>{{ selectedDevice.name }}</h3>
<div class="badge">{{ selectedDevicePoints.length }} 个点位</div>
</div>
<div class="head-actions">
<button class="btn ghost" type="button" @click="refreshPointsOnly">刷新数值</button>
<button class="btn ghost" type="button" @click="closeDialog">关闭</button>
</div>
</div>
<div class="device-meta">
<span><small>编码</small><b class="mono">{{ selectedDevice.code }}</b></span>
<span>
<small>网关</small>
<b>{{ selectedDevice.gatewayId ? gatewayName(selectedDevice.gatewayId) : '—' }}</b>
</span>
<span><small>状态</small><b>{{ selectedDevice.status }}</b></span>
<span v-if="selectedDevice.meta?.sourceCsv">
<small>来源</small>
<b class="mono">{{ selectedDevice.meta.sourceCsv }}</b>
</span>
</div>
<div class="detail-table-wrap">
<table class="table">
<thead>
<tr>
<th>点位名称</th>
<th>地址</th>
<th>类型</th>
<th>工程值</th>
<th>倍率</th>
<th>更新时间</th>
<th>ItemID</th>
<th v-if="auth.isAdmin">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="p in selectedDevicePoints" :key="p.id">
<td>{{ p.name }}</td>
<td>{{ p.modbusAddress }}</td>
<td>{{ p.dataType }}</td>
<td class="eng" :class="{ muted: p.lastEng === null }">
{{ formatEng(p.lastEng) }}
</td>
<td>{{ p.scaleOverride ?? p.scale }}</td>
<td class="mono ts">{{ formatTs(p.lastTs) }}</td>
<td class="mono">{{ p.itemId }}</td>
<td v-if="auth.isAdmin" class="ops">
<button
class="btn ghost"
type="button"
@click.stop="openEditPoint(p)"
>
编辑
</button>
</td>
</tr>
<tr v-if="!selectedDevicePoints.length">
<td :colspan="auth.isAdmin ? 8 : 7" class="empty">该设备暂无绑定点位</td>
</tr>
</tbody>
</table>
</div>
<p class="hint">对话框打开期间每 3 秒自动刷新点位数值;无采集数据时工程值显示为 —。</p>
</div>
</div>
<!-- 导入 CSV -->
<div v-if="dialog === 'import'" class="modal-mask" @click.self="closeDialog">
<div class="modal panel modal-lg">
......@@ -802,6 +962,46 @@ onMounted(loadAll)
white-space: nowrap;
}
.ops { display: flex; gap: 6px; }
.row-click { cursor: pointer; }
.row-click:hover td { background: rgba(61, 190, 255, 0.12); }
.device-meta {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
margin-bottom: 12px;
}
.device-meta span {
display: inline-flex;
align-items: baseline;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--panel-border);
border-radius: 2px;
background: rgba(6, 12, 20, 0.45);
}
.device-meta small {
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--panel-muted);
}
.device-meta b {
font-size: 12px;
font-weight: 600;
color: var(--panel-title);
}
.detail-table-wrap {
max-height: min(56vh, 520px);
overflow: auto;
}
.eng {
font-family: var(--digital-font), ui-monospace, monospace;
font-size: 14px;
font-weight: 700;
color: var(--accent);
}
.eng.muted { color: var(--panel-muted); font-weight: 600; }
.ts { font-size: 11px; color: var(--panel-muted); }
.empty {
text-align: center;
color: var(--panel-muted);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment