diff --git a/.gitignore b/.gitignore index b7df8b9..94bbcd7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 - diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..894e114 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/app/adapters/device_client.py b/backend/app/adapters/device_client.py index e524523..1344f8e 100644 --- a/backend/app/adapters/device_client.py +++ b/backend/app/adapters/device_client.py @@ -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(" 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}, + } diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 89d30ae..7e177bc 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index dafa401..2f6cf37 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/tests/test_modbus_client.py b/backend/tests/test_modbus_client.py new file mode 100644 index 0000000..34f9787 --- /dev/null +++ b/backend/tests/test_modbus_client.py @@ -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"] diff --git a/document/gui_1.h b/document/gui_1.h new file mode 100644 index 0000000..79196cf --- /dev/null +++ b/document/gui_1.h @@ -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ͨuaubuc, iaibicƵϵ +// //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;//뱣ּĴеIJ + +typedef struct +{ + + uint16_t a_in; // + +}gui_out_01reg_t;//ȦĴе + + +#endif diff --git a/document/在RK3568设备上系统部署及使用说明.md b/document/在RK3568设备上系统部署及使用说明.md index 006bc26..bc0f080 100644 --- a/document/在RK3568设备上系统部署及使用说明.md +++ b/document/在RK3568设备上系统部署及使用说明.md @@ -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 diff --git a/document/显示界面modbus协议寄存器定义.md b/document/显示界面modbus协议寄存器定义.md new file mode 100644 index 0000000..d29e3c1 --- /dev/null +++ b/document/显示界面modbus协议寄存器定义.md @@ -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] = 秒
[7:0] = 分 | 秒/分 | +| 0x00E1 | [15:8] = 时
[7:0] = 日 | 时/日 | +| 0x00E2 | [15:8] = 月
[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路 × 4(8字节数据/2) = 16个寄存器 + +--- + +### 3. 线路定值设置 + +- **寄存器类型**:保持寄存器 +- **功能码**:0x10 +- **起始地址**:0x0014 +- **寄存器长度**:56 +- **说明**:4路 × 7类别 × 2(4字节数据/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路分别操作) \ No newline at end of file diff --git a/frontend/dist.zip b/frontend/dist.zip new file mode 100644 index 0000000..f7e81b2 Binary files /dev/null and b/frontend/dist.zip differ diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index 2133dad..0784a2c 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -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', diff --git a/scripts/emcp-browser-launcher.desktop b/scripts/emcp-browser-launcher.desktop new file mode 100644 index 0000000..41b35cb --- /dev/null +++ b/scripts/emcp-browser-launcher.desktop @@ -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; diff --git a/scripts/start-emcp-browser.sh b/scripts/start-emcp-browser.sh index 89623f1..ae8a450 100644 --- a/scripts/start-emcp-browser.sh +++ b/scripts/start-emcp-browser.sh @@ -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}" @@ -73,4 +62,4 @@ chromium \ --disable-infobars \ --disk-cache-size=10485760 \ --user-data-dir="$BROWSER_PROFILE_DIR/chromium" \ - "$URL" >/dev/null 2>&1 & + "$URL" >/dev/null 2>&1 & \ No newline at end of file