This commit is contained in:
jingna 2026-06-12 11:30:51 +08:00
commit 2db17ffe77
13 changed files with 1246 additions and 18 deletions

3
.gitignore vendored
View File

@ -30,6 +30,8 @@ backend/data/
# Environment files
.env
.env.*
!.env.example
!backend/.env.example
# Frontend
frontend/node_modules/
@ -47,4 +49,3 @@ pnpm-debug.log*
*.swp
Thumbs.db
.DS_Store

27
backend/.env.example Normal file
View File

@ -0,0 +1,27 @@
EMCP_DATA_DIR=/userdata/emcp/data
EMCP_ALARM_DB_PATH=/userdata/emcp/data/alarm.db
EMCP_BACKUP_DIR=/userdata/emcp/data/backups
# true: use MockDeviceClient
# false: use real Modbus client
EMCP_USE_MOCK_DEVICE=true
# mock | rtu | tcp
EMCP_MODBUS_TRANSPORT=rtu
EMCP_MODBUS_SLAVE_ID=1
EMCP_MODBUS_TIMEOUT_SECONDS=1.0
# float decode options
EMCP_MODBUS_FLOAT_BYTE_ORDER=big
EMCP_MODBUS_FLOAT_WORD_ORDER=big
# RTU settings
EMCP_MODBUS_SERIAL_DEVICE=/dev/ttyS1
EMCP_MODBUS_SERIAL_BAUD=115200
EMCP_MODBUS_SERIAL_PARITY=N
EMCP_MODBUS_SERIAL_DATA_BITS=8
EMCP_MODBUS_SERIAL_STOP_BITS=1
# TCP settings
EMCP_MODBUS_TCP_HOST=127.0.0.1
EMCP_MODBUS_TCP_PORT=502

View File

@ -1,9 +1,12 @@
from __future__ import annotations
import struct
from abc import ABC, abstractmethod
from datetime import datetime
from random import Random
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional, Sequence
from app.core.config import settings
from app.schemas.platform import (
AlarmEvent,
@ -57,6 +60,204 @@ class DeviceClient(ABC):
raise NotImplementedError
VALUE_GROUP_FIELDS = [
"Ua",
"Ub",
"Uc",
"Ia",
"Ib",
"Ic",
"Pa",
"Pb",
"Pc",
"Pt",
"Qa",
"Qb",
"Qc",
"Qt",
"Sa",
"Sb",
"Sc",
"St",
"PFa",
"PFb",
"PFc",
"PFt",
"Uab",
"Ubc",
"Uca",
"frq",
]
LINE_START_REGISTERS = [0x0000, 0x0034, 0x0068, 0x009C]
LINE_REGISTER_COUNT = 0x34
AI_INPUT_START_REGISTER = 0x00D0
AI_INPUT_COUNT = 8
DI_INPUT_REGISTER = 0x00E3
EVENT_FLAG_REGISTER = 0x00E4
EVENT_HOLDING_START_REGISTER = 0x0000
UART_CONFIG_START_REGISTER = 0x0004
UART_CONFIG_REGISTER_COUNT = 16
LINE_ALARM_START_REGISTER = 0x0014
LINE_ALARM_REGISTERS_PER_LINE = 14
AI_CHANNEL_START_REGISTER = 0x004C
AO_CHANNEL_START_REGISTER = 0x006A
AI_ALARM_START_REGISTER = 0x0088
ANALOG_BLOCK_REGISTER_COUNT = 30
DO_STATE_REGISTER = 0x00A6
TIME_SYNC_START_REGISTER = 0x0000
def _clamp_byte(value: Any) -> int:
try:
number = int(round(float(value)))
except (TypeError, ValueError):
number = 0
return max(0, min(255, number))
def _normalize_text(value: str) -> str:
return value.strip().upper().replace(" ", "")
def _serial_parity_flag(value: str) -> str:
normalized = _normalize_text(value)
return {"NONE": "N", "N": "N", "ODD": "O", "O": "O", "EVEN": "E", "E": "E"}.get(normalized, "N")
class ModbusTransport(ABC):
@abstractmethod
def read_input_registers(self, address: int, count: int) -> List[int]:
raise NotImplementedError
@abstractmethod
def read_holding_registers(self, address: int, count: int) -> List[int]:
raise NotImplementedError
@abstractmethod
def read_discrete_inputs(self, address: int, count: int) -> List[bool]:
raise NotImplementedError
@abstractmethod
def read_coils(self, address: int, count: int) -> List[bool]:
raise NotImplementedError
@abstractmethod
def write_registers(self, address: int, values: Sequence[int]) -> None:
raise NotImplementedError
@abstractmethod
def write_coil(self, address: int, value: bool) -> None:
raise NotImplementedError
class PymodbusTransport(ModbusTransport):
def __init__(self) -> None:
self._client: Any = None
def _build_client(self) -> Any:
try:
from pymodbus.client import ModbusSerialClient, ModbusTcpClient
except ImportError as exc:
raise RuntimeError("未安装 pymodbus无法启用 Modbus 采集") from exc
transport = settings.modbus_transport.strip().lower()
if transport == "tcp":
return ModbusTcpClient(
host=settings.modbus_tcp_host,
port=settings.modbus_tcp_port,
timeout=settings.modbus_timeout_seconds,
)
try:
from pymodbus import FramerType
return ModbusSerialClient(
port=settings.modbus_serial_device,
framer=FramerType.RTU,
baudrate=settings.modbus_serial_baud,
parity=_serial_parity_flag(settings.modbus_serial_parity),
stopbits=settings.modbus_serial_stop_bits,
bytesize=settings.modbus_serial_data_bits,
timeout=settings.modbus_timeout_seconds,
)
except Exception:
return ModbusSerialClient(
method="rtu",
port=settings.modbus_serial_device,
baudrate=settings.modbus_serial_baud,
parity=_serial_parity_flag(settings.modbus_serial_parity),
stopbits=settings.modbus_serial_stop_bits,
bytesize=settings.modbus_serial_data_bits,
timeout=settings.modbus_timeout_seconds,
)
def _ensure_client(self) -> Any:
if self._client is None:
self._client = self._build_client()
connect = getattr(self._client, "connect", None)
if callable(connect):
result = connect()
if result is False:
raise RuntimeError("Modbus 连接失败")
return self._client
def _call(self, method_name: str, *args: Any, **kwargs: Any) -> Any:
client = self._ensure_client()
method = getattr(client, method_name)
try:
return method(*args, slave=settings.modbus_slave_id, **kwargs)
except TypeError:
return method(*args, unit=settings.modbus_slave_id, **kwargs)
def _check_response(self, response: Any, action: str) -> Any:
if response is None:
raise RuntimeError(f"Modbus {action} 返回为空")
is_error = getattr(response, "isError", None)
if callable(is_error) and response.isError():
raise RuntimeError(f"Modbus {action} 返回错误: {response}")
return response
def read_input_registers(self, address: int, count: int) -> List[int]:
response = self._check_response(
self._call("read_input_registers", address, count),
f"读输入寄存器[{hex(address)}:{count}]",
)
return list(getattr(response, "registers", []) or [])
def read_holding_registers(self, address: int, count: int) -> List[int]:
response = self._check_response(
self._call("read_holding_registers", address, count),
f"读保持寄存器[{hex(address)}:{count}]",
)
return list(getattr(response, "registers", []) or [])
def read_discrete_inputs(self, address: int, count: int) -> List[bool]:
response = self._check_response(
self._call("read_discrete_inputs", address, count),
f"读离散输入[{hex(address)}:{count}]",
)
return [bool(value) for value in (getattr(response, "bits", []) or [])[:count]]
def read_coils(self, address: int, count: int) -> List[bool]:
response = self._check_response(
self._call("read_coils", address, count),
f"读线圈[{hex(address)}:{count}]",
)
return [bool(value) for value in (getattr(response, "bits", []) or [])[:count]]
def write_registers(self, address: int, values: Sequence[int]) -> None:
self._check_response(
self._call("write_registers", address, list(values)),
f"写保持寄存器[{hex(address)}:{len(values)}]",
)
def write_coil(self, address: int, value: bool) -> None:
self._check_response(
self._call("write_coil", address, bool(value)),
f"写线圈[{hex(address)}]",
)
class MockDeviceClient(DeviceClient):
def __init__(self) -> None:
self._random = Random(3568)
@ -153,5 +354,347 @@ class MockDeviceClient(DeviceClient):
return {"control_status": f"执行成功: 开关{payload.ch}{action_text}"}
class CDeviceClient(MockDeviceClient):
"""真实 C 程序接入前先复用 mock 行为,后续在此类中替换协议实现。"""
class CDeviceClient(DeviceClient):
"""基于 Modbus 协议的真实设备客户端。"""
def __init__(self, transport: Optional[ModbusTransport] = None) -> None:
self.transport = transport or PymodbusTransport()
self._do_state = {index: 0 for index in range(1, 13)}
self._last_event_signature: Optional[tuple[int, int, int, int]] = None
def _registers_to_float(self, registers: Sequence[int]) -> float:
ordered = list(registers)
if settings.modbus_float_word_order.strip().lower() == "little":
ordered.reverse()
raw = bytearray()
for register in ordered:
high = (int(register) >> 8) & 0xFF
low = int(register) & 0xFF
if settings.modbus_float_byte_order.strip().lower() == "little":
raw.extend([low, high])
else:
raw.extend([high, low])
return float(struct.unpack(">f", bytes(raw))[0])
def _float_to_registers(self, value: float) -> List[int]:
raw = struct.pack(">f", float(value))
words = [bytearray(raw[0:2]), bytearray(raw[2:4])]
if settings.modbus_float_byte_order.strip().lower() == "little":
words = [bytearray(reversed(word)) for word in words]
registers = [(word[0] << 8) | word[1] for word in words]
if settings.modbus_float_word_order.strip().lower() == "little":
registers.reverse()
return registers
def _bytes_to_registers(self, payload: bytes) -> List[int]:
if len(payload) % 2:
payload += b"\x00"
return [(payload[index] << 8) | payload[index + 1] for index in range(0, len(payload), 2)]
def _build_value_group(self, registers: Sequence[int]) -> ValueGroup:
values: Dict[str, float] = {}
for index, field_name in enumerate(VALUE_GROUP_FIELDS):
start = index * 2
values[field_name] = self._registers_to_float(registers[start : start + 2])
return ValueGroup(**values)
def _read_line_data(self, line_no: int, start_register: int) -> LineData:
registers = self.transport.read_input_registers(start_register, LINE_REGISTER_COUNT)
value_group = self._build_value_group(registers)
return LineData(line_no=line_no, pri_val=value_group, sec_val=value_group)
def _read_ai_collect(self) -> Dict[str, float]:
registers = self.transport.read_input_registers(AI_INPUT_START_REGISTER, AI_INPUT_COUNT * 2)
values = {
f"ai{index + 1}": self._registers_to_float(registers[index * 2 : index * 2 + 2])
for index in range(AI_INPUT_COUNT)
}
for index in range(AI_INPUT_COUNT + 1, 13):
values[f"ai{index}"] = 0.0
return values
def _read_di_state(self) -> Dict[str, int]:
try:
bits = self.transport.read_discrete_inputs(0x0000, 12)
return {f"di{index + 1}": int(bits[index]) for index in range(min(12, len(bits)))}
except Exception:
register = self.transport.read_input_registers(DI_INPUT_REGISTER, 1)[0]
return {f"di{index}": int(bool(register & (1 << (index - 1)))) for index in range(1, 13)}
def _read_do_state(self) -> Dict[str, int]:
try:
bits = self.transport.read_coils(0x0000, 12)
if bits:
for index, bit in enumerate(bits[:12], start=1):
self._do_state[index] = int(bool(bit))
except Exception:
pass
return {f"do{index}": self._do_state.get(index, 0) for index in range(1, 13)}
def _build_status(self) -> DeviceStatus:
transport = settings.modbus_transport.strip().lower()
if transport == "tcp":
return DeviceStatus(self_check="正常", net1="正常", net2="正常", uart1="断开", uart2="断开")
return DeviceStatus(self_check="正常", net1="断开", net2="断开", uart1="正常", uart2="正常")
def _category_map(self, category: str) -> str:
normalized = category.strip().upper()
return {
"UA": "UA",
"UB": "UB",
"UC": "UC",
"UAB": "UAB",
"UBC": "UBC",
"UCA": "UCA",
"P": "P",
"Q": "Q",
"F": "F",
"FRQ": "F",
"频率": "F",
"功率": "P",
}.get(normalized, normalized)
def _signal_type_code(self, signal_type: str) -> int:
normalized = _normalize_text(signal_type)
return 0 if normalized in {"4-20MA", "4~20MA", "4-20MA"} else 1
def _measure_type_code(self, measure_type: str) -> int:
normalized = self._category_map(measure_type)
return {
"UA": 0,
"UB": 1,
"UC": 2,
"UAB": 3,
"UBC": 4,
"UCA": 5,
"P": 6,
"Q": 7,
"F": 8,
}.get(normalized, 0)
def _output_node_code(self, output_node: str) -> int:
digits = "".join(character for character in output_node if character.isdigit())
return _clamp_byte(digits or 0)
def _parity_code(self, parity: str) -> int:
normalized = _normalize_text(parity)
return {"NONE": 0, "N": 0, "ODD": 1, "O": 1, "EVEN": 2, "E": 2}.get(normalized, 0)
def _data_bits_code(self, data_bits: int) -> int:
return 0 if int(data_bits) == 8 else _clamp_byte(data_bits)
def _stop_bits_code(self, stop_bits: int) -> int:
return 1 if int(stop_bits) >= 2 else 0
def _protocol_code(self, protocol: str) -> int:
return 1 if "MODBUS" in _normalize_text(protocol) else 0
def _line_rule_payload(self, limit: Any, delay: Any, output_node: str, enabled: bool) -> bytes:
return bytes(
[
_clamp_byte(limit),
_clamp_byte(delay),
self._output_node_code(output_node),
1 if enabled else 0,
]
)
def read_realtime_data(self) -> RealtimeData:
line_list = [
self._read_line_data(line_no=index + 1, start_register=start_register)
for index, start_register in enumerate(LINE_START_REGISTERS)
]
switch = self._read_di_state()
switch.update(self._read_do_state())
return RealtimeData(line_list=line_list, switch=switch, ai_collect=self._read_ai_collect())
def read_device_status(self) -> DeviceStatus:
self.transport.read_input_registers(EVENT_FLAG_REGISTER, 1)
return self._build_status()
def read_alarm_events(self) -> List[AlarmEvent]:
event_flag = self.transport.read_input_registers(EVENT_FLAG_REGISTER, 1)[0]
if event_flag == 0:
return []
registers = self.transport.read_holding_registers(EVENT_HOLDING_START_REGISTER, 4)
signature = tuple(int(value) for value in registers)
if signature == self._last_event_signature:
return []
self._last_event_signature = signature
timestamp = int(registers[0]) | (int(registers[1]) << 16)
milliseconds = int(registers[2]) & 0xFFFF
line_and_type = int(registers[3]) & 0xFFFF
line_no = (line_and_type >> 8) & 0xFF
event_type = line_and_type & 0xFF
event_name = {
1: "PT断线",
2: "CT断线",
}.get(event_type, f"事件{event_type}")
event_time = datetime.fromtimestamp(timestamp).replace(microsecond=min(milliseconds, 999) * 1000)
line_text = "总路" if line_no == 0 else f"线路{line_no}"
return [
AlarmEvent(
alarm_type="modbus_event",
time=event_time,
no="" if line_no == 0 else str(line_no),
type=event_name,
content=f"{line_text}{event_name}",
level="",
)
]
def send_device_config(self, payload: DeviceConfigIn) -> Dict[str, Any]:
uart_map = {item.port.upper(): item for item in payload.uart}
raw = bytearray()
for port_name in ("COM1", "COM2", "COM3", "COM4"):
item = uart_map.get(port_name)
baud = item.baud if item is not None else 0
parity = self._parity_code(item.parity) if item is not None else 0
data_bits = self._data_bits_code(item.data_bits) if item is not None else 0
stop_bits = self._stop_bits_code(item.stop_bits) if item is not None else 0
protocol = self._protocol_code(item.protocol) if item is not None else 0
raw.extend(struct.pack("<I4B", int(baud), parity, data_bits, stop_bits, protocol))
registers = self._bytes_to_registers(bytes(raw))
self.transport.write_registers(UART_CONFIG_START_REGISTER, registers)
return {
"send_status": "成功",
"target": "device",
"modbus_written": {"uart_registers": len(registers)},
"ignored_fields": ["password", "hardware_version", "software_version", "net"],
}
def send_channel_config(self, payload: ChannelConfigIn) -> Dict[str, Any]:
def build_channel_payload(items: List[Any]) -> List[int]:
raw = bytearray()
channel_map = {int(item.ch): item for item in items}
for channel_no in range(1, 13):
item = channel_map.get(channel_no)
if item is None:
raw.extend(b"\x00" * 5)
continue
raw.extend(
bytes(
[
self._signal_type_code(item.singal_type),
_clamp_byte(item.line_no),
self._measure_type_code(item.type),
_clamp_byte(item.limit_high),
_clamp_byte(item.limit_low),
]
)
)
return self._bytes_to_registers(bytes(raw))
ai_registers = build_channel_payload(payload.ai_channel)
ao_registers = build_channel_payload(payload.ao_channel)
self.transport.write_registers(AI_CHANNEL_START_REGISTER, ai_registers)
self.transport.write_registers(AO_CHANNEL_START_REGISTER, ao_registers)
return {
"send_status": "成功",
"target": "channel",
"modbus_written": {
"ai_registers": len(ai_registers),
"ao_registers": len(ao_registers),
},
}
def send_line_alarm_setting(self, payload: LineAlarmSettingIn) -> Dict[str, Any]:
over_limit_map = {rule.category: rule for rule in payload.over_limit_alarm}
fault_map = {rule.category: rule for rule in payload.fault_alarm}
ordered_rules = [
over_limit_map.get("电压"),
over_limit_map.get("电流"),
over_limit_map.get("差流"),
over_limit_map.get("功率"),
over_limit_map.get("频率"),
fault_map.get("PT断线"),
fault_map.get("CT断线"),
]
raw = bytearray()
for rule in ordered_rules:
if rule is None:
raw.extend(b"\x00" * 4)
continue
raw.extend(self._line_rule_payload(rule.limit, rule.delay, rule.output_node, rule.enabled))
start_register = LINE_ALARM_START_REGISTER + max(0, payload.line_no - 1) * LINE_ALARM_REGISTERS_PER_LINE
registers = self._bytes_to_registers(bytes(raw))
self.transport.write_registers(start_register, registers)
return {
"send_status": "成功",
"target": "line_alarm",
"line_no": payload.line_no,
"modbus_written": {"start_register": start_register, "registers": len(registers)},
}
def send_ai_alarm_setting(self, payload: List[Dict[str, Any]]) -> Dict[str, Any]:
raw = bytearray()
item_map = {int(item.get("channel_no", 0)): item for item in payload}
for channel_no in range(1, 13):
item = item_map.get(channel_no)
if item is None:
raw.extend(b"\x00" * 5)
continue
raw.extend(
bytes(
[
_clamp_byte(item.get("limit_high", 0)),
_clamp_byte(item.get("limit_low", 0)),
_clamp_byte(item.get("delay", 0)),
self._output_node_code(str(item.get("output_node", ""))),
1 if bool(item.get("enabled", False)) else 0,
]
)
)
registers = self._bytes_to_registers(bytes(raw))
self.transport.write_registers(AI_ALARM_START_REGISTER, registers)
return {
"send_status": "成功",
"target": "ai_alarm",
"items": len(payload),
"modbus_written": {"registers": len(registers)},
}
def send_system_config(self, payload: SystemConfigIn) -> Dict[str, Any]:
time_text = str(payload.time_sync).strip()
if time_text and time_text.lower() not in {"auto", "manual"}:
timestamp = int(datetime.fromisoformat(time_text).timestamp())
else:
timestamp = int(datetime.now().timestamp())
registers = [timestamp & 0xFFFF, (timestamp >> 16) & 0xFFFF]
self.transport.write_registers(TIME_SYNC_START_REGISTER, registers)
return {
"send_status": "成功",
"target": "system",
"brightness": payload.brightness,
"screen_saver": payload.screen_saver,
"modbus_written": {"time_registers": len(registers)},
"ignored_fields": ["brightness", "screen_saver"],
}
def send_switch_control(self, payload: SwitchControlIn) -> Dict[str, Any]:
coil_address = max(0, payload.ch - 1)
coil_value = bool(payload.action)
self.transport.write_coil(coil_address, coil_value)
self._do_state[payload.ch] = int(coil_value)
bitmask = 0
for index, state in self._do_state.items():
if state:
bitmask |= 1 << (index - 1)
self.transport.write_registers(DO_STATE_REGISTER, [bitmask & 0xFFFF])
action_text = "" if payload.action == 1 else ""
return {
"control_status": f"执行成功: 开关{payload.ch}{action_text}",
"modbus_written": {"coil_address": coil_address, "state_register": DO_STATE_REGISTER},
}

View File

@ -15,6 +15,18 @@ class Settings(BaseSettings):
poll_interval_seconds: float = 0.5
auth_password: str = "admin123"
use_mock_device: bool = True
modbus_transport: str = "rtu"
modbus_slave_id: int = 1
modbus_tcp_host: str = "127.0.0.1"
modbus_tcp_port: int = 502
modbus_serial_device: str = "/dev/ttyS1"
modbus_serial_baud: int = 115200
modbus_serial_parity: str = "N"
modbus_serial_data_bits: int = 8
modbus_serial_stop_bits: int = 1
modbus_timeout_seconds: float = 1.0
modbus_float_byte_order: str = "big"
modbus_float_word_order: str = "big"
config_dir: Path = BASE_DIR / "config"
data_dir: Path = BASE_DIR / "data"
alarm_db_path: Path = BASE_DIR / "data" / "alarm.db"

View File

@ -9,6 +9,7 @@ dependencies = [
"uvicorn[standard]>=0.30,<1.0",
"pydantic>=2.7,<3.0",
"pydantic-settings>=2.3,<3.0",
"pymodbus>=3.6,<4.0",
]
[project.optional-dependencies]

View File

@ -0,0 +1,226 @@
from __future__ import annotations
from datetime import datetime
from typing import Dict, List, Sequence, Tuple
from app.adapters.device_client import (
AI_ALARM_START_REGISTER,
AI_CHANNEL_START_REGISTER,
AI_INPUT_COUNT,
AI_INPUT_START_REGISTER,
AO_CHANNEL_START_REGISTER,
CDeviceClient,
DI_INPUT_REGISTER,
DO_STATE_REGISTER,
EVENT_FLAG_REGISTER,
EVENT_HOLDING_START_REGISTER,
LINE_ALARM_REGISTERS_PER_LINE,
LINE_ALARM_START_REGISTER,
LINE_START_REGISTERS,
ModbusTransport,
TIME_SYNC_START_REGISTER,
UART_CONFIG_REGISTER_COUNT,
UART_CONFIG_START_REGISTER,
VALUE_GROUP_FIELDS,
)
from app.schemas.platform import (
AiAlarmSettingIn,
AlarmRule,
ChannelConfigIn,
ChannelItem,
DeviceConfigIn,
LineAlarmSettingIn,
NetConfigItem,
SoftwareVersion,
HardwareVersion,
SwitchControlIn,
SystemConfigIn,
UartConfigItem,
)
class FakeTransport(ModbusTransport):
def __init__(self) -> None:
self.input_registers: Dict[int, int] = {}
self.holding_registers: Dict[int, int] = {}
self.discrete_inputs: Dict[int, bool] = {}
self.coils: Dict[int, bool] = {}
self.written_registers: List[Tuple[int, List[int]]] = []
self.written_coils: List[Tuple[int, bool]] = []
def set_input_block(self, address: int, values: Sequence[int]) -> None:
for offset, value in enumerate(values):
self.input_registers[address + offset] = int(value)
def set_holding_block(self, address: int, values: Sequence[int]) -> None:
for offset, value in enumerate(values):
self.holding_registers[address + offset] = int(value)
def set_discrete_block(self, address: int, values: Sequence[bool]) -> None:
for offset, value in enumerate(values):
self.discrete_inputs[address + offset] = bool(value)
def set_coil_block(self, address: int, values: Sequence[bool]) -> None:
for offset, value in enumerate(values):
self.coils[address + offset] = bool(value)
def read_input_registers(self, address: int, count: int) -> List[int]:
return [self.input_registers.get(address + offset, 0) for offset in range(count)]
def read_holding_registers(self, address: int, count: int) -> List[int]:
return [self.holding_registers.get(address + offset, 0) for offset in range(count)]
def read_discrete_inputs(self, address: int, count: int) -> List[bool]:
return [self.discrete_inputs.get(address + offset, False) for offset in range(count)]
def read_coils(self, address: int, count: int) -> List[bool]:
return [self.coils.get(address + offset, False) for offset in range(count)]
def write_registers(self, address: int, values: Sequence[int]) -> None:
block = [int(value) for value in values]
self.written_registers.append((address, block))
for offset, value in enumerate(block):
self.holding_registers[address + offset] = value
def write_coil(self, address: int, value: bool) -> None:
self.written_coils.append((address, bool(value)))
self.coils[address] = bool(value)
def test_modbus_client_reads_upstream_data() -> None:
transport = FakeTransport()
client = CDeviceClient(transport=transport)
for line_index, start_register in enumerate(LINE_START_REGISTERS, start=1):
values: List[int] = []
base = float(line_index * 100)
for field_offset, _field_name in enumerate(VALUE_GROUP_FIELDS, start=1):
values.extend(client._float_to_registers(base + field_offset))
transport.set_input_block(start_register, values)
ai_values: List[int] = []
for index in range(AI_INPUT_COUNT):
ai_values.extend(client._float_to_registers(1.25 + index))
transport.set_input_block(AI_INPUT_START_REGISTER, ai_values)
transport.set_discrete_block(0x0000, [True, False, True, False, True, False, True, False, True, False, True, False])
transport.set_coil_block(0x0000, [False, True, False, True, False, True, False, True, False, True, False, True])
transport.set_input_block(DI_INPUT_REGISTER, [0x0555])
realtime = client.read_realtime_data()
assert len(realtime.line_list) == 4
assert realtime.line_list[0].sec_val.Ua == 101.0
assert realtime.line_list[1].sec_val.Pt == 210.0
assert realtime.line_list[3].sec_val.frq == 426.0
assert realtime.ai_collect["ai1"] == 1.25
assert realtime.ai_collect["ai8"] == 8.25
assert realtime.ai_collect["ai9"] == 0.0
assert realtime.switch["di1"] == 1
assert realtime.switch["di2"] == 0
assert realtime.switch["do1"] == 0
assert realtime.switch["do2"] == 1
def test_modbus_client_reads_alarm_event_once() -> None:
transport = FakeTransport()
client = CDeviceClient(transport=transport)
timestamp = int(datetime(2026, 5, 25, 12, 0, 0).timestamp())
transport.set_input_block(EVENT_FLAG_REGISTER, [1])
transport.set_holding_block(
EVENT_HOLDING_START_REGISTER,
[
timestamp & 0xFFFF,
(timestamp >> 16) & 0xFFFF,
123,
(2 << 8) | 1,
],
)
alarms = client.read_alarm_events()
duplicate = client.read_alarm_events()
assert len(alarms) == 1
assert alarms[0].no == "2"
assert alarms[0].type == "PT断线"
assert "线路2PT断线" in alarms[0].content
assert duplicate == []
def test_modbus_client_writes_downstream_registers() -> None:
transport = FakeTransport()
client = CDeviceClient(transport=transport)
device_payload = DeviceConfigIn(
password="",
hardware_version=HardwareVersion(),
software_version=SoftwareVersion(),
net=[NetConfigItem(nic="网卡一", ip="192.168.1.10", mask="255.255.255.0", gateway="192.168.1.1", protocol="Modbus TCP")],
uart=[
UartConfigItem(port="COM1", baud=9600, parity="NONE", data_bits=8, stop_bits=1, protocol="Modbus RTU"),
UartConfigItem(port="COM2", baud=115200, parity="EVEN", data_bits=8, stop_bits=2, protocol="Modbus RTU"),
],
)
channel_payload = ChannelConfigIn(
ai_channel=[ChannelItem(ch=1, singal_type="4-20mA", line_no=1, type="UA", limit_low=0, limit_high=20)],
ao_channel=[ChannelItem(ch=2, singal_type="1~5V", line_no=2, type="Q", limit_low=1, limit_high=8)],
)
line_alarm_payload = LineAlarmSettingIn(
line_no=2,
over_limit_alarm=[
AlarmRule(category="电压", limit=180, delay=5, output_node="开出1", enabled=True),
AlarmRule(category="电流", limit=200, delay=6, output_node="开出2", enabled=False),
AlarmRule(category="差流", limit=20, delay=7, output_node="开出3", enabled=True),
AlarmRule(category="功率", limit=30, delay=8, output_node="开出4", enabled=False),
AlarmRule(category="频率", limit=50, delay=9, output_node="开出5", enabled=True),
],
fault_alarm=[
AlarmRule(category="PT断线", delay=10, output_node="开出6", enabled=True),
AlarmRule(category="CT断线", delay=11, output_node="开出7", enabled=False),
],
)
ai_alarm_payload = [
AiAlarmSettingIn(
channel_no=1,
singal_type="4-20mA",
limit_low=0,
limit_high=20,
delay=3,
output_node="开出1",
enabled=True,
).model_dump()
]
system_payload = SystemConfigIn(time_sync="2026-05-25 12:34:56", brightness=80, screen_saver=60)
switch_payload = SwitchControlIn(ch=3, action=1)
device_result = client.send_device_config(device_payload)
channel_result = client.send_channel_config(channel_payload)
line_result = client.send_line_alarm_setting(line_alarm_payload)
ai_alarm_result = client.send_ai_alarm_setting(ai_alarm_payload)
system_result = client.send_system_config(system_payload)
control_result = client.send_switch_control(switch_payload)
register_map = {address: values for address, values in transport.written_registers}
assert UART_CONFIG_START_REGISTER in register_map
assert len(register_map[UART_CONFIG_START_REGISTER]) == UART_CONFIG_REGISTER_COUNT
assert AI_CHANNEL_START_REGISTER in register_map
assert len(register_map[AI_CHANNEL_START_REGISTER]) == 30
assert AO_CHANNEL_START_REGISTER in register_map
assert len(register_map[AO_CHANNEL_START_REGISTER]) == 30
assert LINE_ALARM_START_REGISTER + LINE_ALARM_REGISTERS_PER_LINE in register_map
assert len(register_map[LINE_ALARM_START_REGISTER + LINE_ALARM_REGISTERS_PER_LINE]) == 14
assert AI_ALARM_START_REGISTER in register_map
assert len(register_map[AI_ALARM_START_REGISTER]) == 30
assert TIME_SYNC_START_REGISTER in register_map
assert len(register_map[TIME_SYNC_START_REGISTER]) == 2
assert DO_STATE_REGISTER in register_map
assert register_map[DO_STATE_REGISTER][0] == 0b100
assert transport.written_coils == [(2, True)]
assert device_result["send_status"] == "成功"
assert channel_result["modbus_written"]["ai_registers"] == 30
assert line_result["line_no"] == 2
assert ai_alarm_result["items"] == 1
assert system_result["send_status"] == "成功"
assert "执行成功" in control_result["control_status"]

205
document/gui_1.h Normal file
View File

@ -0,0 +1,205 @@
#ifndef __GUI_H
#define __GUI_H
#include "time.h"
#define LINE_TOTAL_NUM 4 //总测量线路数量
#define ANALOG_CH_NUM 12 //模拟量通道数
/***********************************************************************小核数据 -> 大核****************************************************************************/
//实时量
typedef struct
{
//三相电压
float Ua; //A相电压
float Ub; //B相电压
float Uc; //C相电压
//三相电流
float Ia; //A相电流
float Ib; //B相电流
float Ic; //C相电流
//有功功率
float Pa;
float Pb;
float Pc;
float Pt; //总有功功率
//无功功率
float Qa;
float Qb;
float Qc;
float Qt; //总无功功率
//视在功率
float Sa;
float Sb;
float Sc;
float St; //总视在功率
//功率因数
float PFa;
float PFb;
float PFc;
float PFt; //总功率因数
//线电压
float Uab;
float Ubc;
float Uca;
//频率
float frq;
}measure_unit_t;
//B码对时时间数据
typedef union
{
uint8_t b_code[6];
struct
{
uint8_t ascii_s_unit : 4;
uint8_t ascii_s_ten : 4;
uint8_t ascii_min_unit : 4;
uint8_t ascii_min_ten : 4;
uint8_t ascii_hour_unit : 4;
uint8_t ascii_hour_ten : 4;
uint8_t ascii_m_day_unit: 4;
uint8_t ascii_m_day_ten : 4;
uint8_t ascii_month_unit: 4; //4bit
uint8_t ascii_month_ten : 4; //4bit
uint8_t ascii_year_unit : 4; //4bit
uint8_t ascii_year_ten : 4; //4bit
}time;
}b_code_t;
//事件标志
typedef struct
{
uint32_t timestamp; //事件发生时间,时间戳
uin16_t ms; //事件发生的毫秒时刻
uint8_t line_num; //线路号0 = 其它1 = 1路2 =2路 3 = 3路 4 = 4路
uint8_t type; //事件类型1 = pt 断线, 2 = CT断线其它事件依次编号
}event_t;
// //波形数据
// typedef struct
// { uint32_t stamp; //时标
// int16_t ch[8]; //4路波形数据时8个通道ua、ub、uc, ia、ib、ic、频率系数、开入量
// //AI波形数据时 8个通道对应各个输入点
// }ch_data_t;
// //波形数据
// typedef struct
// {
// uint8_t line_index; //线路号 0 ~ 3 = 4路电压电流采样数据4 = AI采集数据
// ch_data_t dot[64]; //64个点组成一个周波
// }waveform_data_t;
//小核数据 -> 大核
typedef struct
{
measure_unit_t measure_unit[LINE_TOTAL_NUM];//总共4条线路按序排列
float d_in[8]; //模拟量输入
b_code_t b_code; //B码对时
uint16_t a_in; //数字量,开入
uint16_t evt_f; //事件标志,读取到时间标志后,再读取保持寄存器里的事件内容
}gui_in_04reg_t;//读输入寄存器数据
typedef struct
{
event_t event;
}gui_in_03reg_t;//读保持寄存器事件数据
typedef struct
{
uint16_t a_in; //数字量,开入
}gui_in_02reg_t;//读离散输入寄存器数据
/***********************************************************************大核数据 -> 小核****************************************************************************/
//串口参数设置
typedef struct
{
uint32_t baud_tate; //波特率 9600 115200默认
uint8_t parity; //校验位 0 = 无校验, 1 = 奇校验, 2 = 偶校验
uint8_t data; //数据位 0 = 8位
uint8_t stop; //停止位 0 = 1位 1 = 2位
uint8_t protocol; //通讯协议 0 = 无协议1 = modbus
}com_param_t;
//模拟通道参数值
typedef struct
{
uint8_t sig_type;//信号类型 0 = 4~20ma信号 1 = 1~5V信号
uint8_t line_sub;//所属线路 1 = 线路1 2 = 线路2 3 =线路3 4 = 线路4 0 = 装置
uint8_t att; //类别 : 0 = UA, 1 = UB, 2 = UC, 3 = UAB, 4 = UBC, 5 = UCA, 6 = P, 7 = Q, 8 = F
uint8_t u_limit; //上限值 :
uint8_t d_limit; //下限值 :
}analog_ch_param_t;
//线路定值
typedef struct
{
// uint8_t index; //线路号: 1 = 线路1, 2 = 线路2 3 = 线路3, 4 = 线路4
uint8_t limit; //限值:
uint8_t delay; //延时: [1 ~ 10秒]
uint8_t out_num; //输出节点: [1 = 开出1、 2 = 开出2、...]
uint8_t select; //投退: [0 = 不投, 1 = 投入]
}line_param_t;
typedef struct
{
line_param_t type[7]; //7个类别电压电流差流, 功率频率PT断线CT断线
}line_type_t;
//AI 报警
typedef struct
{
uint8_t u_limit; //上限值 :
uint8_t d_limit; //下限值 :
uint8_t delay; //延时: [1 ~ 10秒]
uint8_t out_num; //输出节点: [1 = 开出1、 2 = 开出2、...]
uint8_t select; //投退: [0 = 不投, 1 = 投入]
}ai_alarm_param_t;
//大核数据 -> 小核
//设置模拟量,数字量输出
typedef struct
{
uint32_t timestamp; //时间戳,设置时间
com_param_t com_param[4]; //串口参数设置: 小核串口FPGA串口
line_type_t line_param[4]; //线路定值设置: 4路定值一起下发
analog_ch_param_t a_in_param[12]; //12路模拟输入通道设置
analog_ch_param_t a_out_param[12]; //12路模拟输出通道设置
ai_alarm_param_t ai_alarm_param[12];//12路模拟输入 报警参数设置
}gui_out_03reg_t;//放入保持寄存器中的参数
typedef struct
{
uint16_t a_in; //数字量,开出
}gui_out_01reg_t;//放入线圈寄存器中的数
#endif

View File

@ -229,7 +229,7 @@ EMCP_BACKUP_DIR=/userdata/emcp/data/backups
```bash
cd /userdata/emcp/backend
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log >/dev/null 2>&1 &
```
验证方式:
@ -405,6 +405,7 @@ sudo chown -R linaro:linaro /userdata/emcp/backend/config
```bash
sudo systemctl daemon-reload
sudo systemctl enable emcp-backend.service
sudo systemctl disable emcp-backend.service
sudo systemctl enable emcp-frontend.service
sudo systemctl start emcp-backend.service
sudo systemctl start emcp-frontend.service

View File

@ -0,0 +1,212 @@
# Modbus 协议寄存器定义文档
## 一、协议基本配置
- **协议类型**Modbus RTU
- **从机地址**1
- **串口配置**
- 波特率115200
- 数据位8
- 校验位:无
- 停止位1
---
## 二、读数据功能码04
### 1. 实时显示数据
- **寄存器类型**:输入寄存器
- **功能码**0x04
- **数据格式**单精度浮点数32位占2个寄存器
#### 线路 #1
- **寄存器地址范围**0x0000 ~ 0x0032
| 寄存器地址 | 参数名 | 寄存器地址 | 参数名 | 寄存器地址 | 参数名 |
| ---------- | ------ | ---------- | ------ | ---------- | ------ |
| 0x0000 | Ua | 0x0012 | Pt | 0x0024 | PFa |
| 0x0002 | Ub | 0x0014 | Qa | 0x0026 | PFb |
| 0x0004 | Uc | 0x0016 | Qb | 0x0028 | PFc |
| 0x0006 | Ia | 0x0018 | Qc | 0x002A | PFt |
| 0x0008 | Ib | 0x001A | Qt | 0x002C | Uab |
| 0x000A | Ic | 0x001C | Sa | 0x002E | Ubc |
| 0x000C | Pa | 0x001E | Sb | 0x0030 | Uca |
| 0x000E | Pb | 0x0020 | Sc | 0x0032 | Frq |
| 0x0010 | Pc | 0x0022 | St | - | - |
#### 线路 #2
- **起始地址**0x0034
- **结束地址**0x0066
- **参数顺序**:同线路 #1
#### 线路 #3
- **起始地址**0x0068
- **结束地址**0x009A
- **参数顺序**:同线路 #1
#### 线路 #4
- **起始地址**0x009C
- **结束地址**0x00CE
- **参数顺序**:同线路 #1
---
### 2. 模拟量输入AI
- **寄存器类型**:输入寄存器
- **功能码**0x04
- **数据格式**单精度浮点数32位
| 寄存器地址 | 输入编号 |
| ---------- | -------- |
| 0x00D0 | Ai1 |
| 0x00D2 | Ai2 |
| 0x00D4 | Ai3 |
| 0x00D6 | Ai4 |
| 0x00D8 | Ai5 |
| 0x00DA | Ai6 |
| 0x00DC | Ai7 |
| 0x00DE | Ai8 |
---
### 3. B码对时
- **寄存器类型**:输入寄存器
- **功能码**0x04
- **数据格式**无符号16位整型
| 寄存器地址 | 位分配 | 内容 |
| ---------- | ------------------------- | ----- |
| 0x00E0 | [15:8] = 秒<br>[7:0] = 分 | 秒/分 |
| 0x00E1 | [15:8] = 时<br>[7:0] = 日 | 时/日 |
| 0x00E2 | [15:8] = 月<br>[7:0] = 年 | 月/年 |
---
### 4. 数字量输入(开入)
- **寄存器类型**:输入寄存器
- **功能码**0x04
- **寄存器地址**0x00E3
- **说明**共16路每个bit位表示一路开入量
---
### 5. 事件总标记
- **寄存器类型**:输入寄存器
- **功能码**0x04
- **寄存器地址**0x00E4
- **说明**:当该位被置位时,表示有新事件,需要读取事件内容
---
## 三、读事件功能码03
- **触发条件**事件总标记0x00E4被置位
- **寄存器类型**:保持寄存器
- **功能码**0x03
- **寄存器地址范围**0x0000 ~ 0x0003
| 寄存器地址 | 数据名 | 格式 | 说明 |
| ---------- | ---------------- | ---------- | ---------------------------- |
| 0x0000 | 时间戳低16位 | 32位无符号 | 占用0x0000~0x0001 |
| 0x0001 | 时间戳高16位 | | |
| 0x0002 | 毫秒计时 | 16位无符号 | |
| 0x0003 | 线路号+事件类型 | 16位无符号 | 高字节线路号,低字节事件类型 |
---
## 四、读输入数字量开入功能码02
- **寄存器类型**:离散输入
- **功能码**0x02
| 寄存器地址 | 说明 |
| ---------- | --------------------- |
| 0x0000 | 16路开入量每bit一路 |
| 0x0001 | 保留16路 |
---
## 五、写数据功能码10写多个保持寄存器
### 1. 时间设置
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x0000
- **寄存器长度**2
- **说明**写入时间戳32位
---
### 2. 串口设置
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x0004
- **寄存器长度**16
- **说明**4路 × 48字节数据/2 = 16个寄存器
---
### 3. 线路定值设置
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x0014
- **寄存器长度**56
- **说明**4路 × 7类别 × 24字节数据/2 = 56个寄存器
---
### 4. AI通道设置
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x004C
- **寄存器长度**30
- **说明**12路 × 5字节数据 / 2 = 30个寄存器整体下发
---
### 5. AO通道设置
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x006A
- **寄存器长度**30
- **说明**12路 × 5字节数据 / 2 = 30个寄存器整体下发
---
### 6. AI报警设置
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x0088
- **寄存器长度**30
- **说明**12路 × 5字节数据 / 2 = 30个寄存器整体下发
---
### 7. 开出(写继电器输出)
- **寄存器类型**:保持寄存器
- **功能码**0x10
- **起始地址**0x00A6
- **寄存器长度**1
- **数据格式**16位每一位表示一路开出量
---
## 六、写开出线圈功能码05
- **寄存器类型**:线圈
- **功能码**0x05
- **起始地址**0x0000
- **寄存器长度**12
- **说明**单路线圈写操作12路分别操作

BIN
frontend/dist.zip Normal file

Binary file not shown.

View File

@ -1,7 +1,7 @@
import axios from 'axios'
export const http = axios.create({
baseURL: 'http://192.168.1.135:8000/api',
baseURL: 'http://127.0.0.1:8000/api',
timeout: 5000,
headers: {
'Content-Type': 'application/json',

View File

@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=EMCP Browser
Comment=Launch EMCP browser
Exec=/userdata/emcp/scripts/start-emcp-browser.sh
Path=/userdata/emcp/scripts
Icon=web-browser
Terminal=false
StartupNotify=false
Categories=Utility;

View File

@ -1,14 +1,3 @@
#!/bin/bash
LOG_FILE="/userdata/emcp/data/browser-autostart.log"
mkdir -p /userdata/emcp/data
exec >>"$LOG_FILE" 2>&1
echo "==== $(date '+%F %T') start-emcp-browser ===="
echo "USER=$(whoami)"
echo "DISPLAY=${DISPLAY:-}"
echo "XDG_SESSION_TYPE=${XDG_SESSION_TYPE:-}"
echo "DBUS_SESSION_BUS_ADDRESS=${DBUS_SESSION_BUS_ADDRESS:-}"
URL="${EMCP_BROWSER_URL:-http://127.0.0.1:5173/}"
BACKEND_HEALTH_URL="${EMCP_BACKEND_HEALTH_URL:-http://127.0.0.1:8000/}"
FRONTEND_HEALTH_URL="${EMCP_FRONTEND_HEALTH_URL:-$URL}"