Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View File

@@ -0,0 +1,316 @@
# -*- coding: utf-8 -*-
# hub/ticket/services/serial_service.py
"""Serial transport-сервис Ticket с подтверждением состояний кнопок."""
from __future__ import annotations
import threading
import time
from typing import Any
from PySide6.QtCore import QObject
from error_logger import log_exception
from domain import TicketConnectionStatus
from domain.ticket_constants import HARDWARE_SIGNAL_INITIALIZE
from .base_service import BaseService
try:
import serial
from serial.tools import list_ports
except ImportError:
serial = None
list_ports = None
SERIAL_PORT = "COM21"
SERIAL_BAUDRATE = 9600
SERIAL_TIMEOUT = 0.1
def probe_serial_port(port: str) -> bool:
"""Проверить доступность COM-порта без запуска transport-потока."""
if serial is None or list_ports is None:
return False
try:
available_ports = {item.device for item in list_ports.comports()}
except Exception as exc:
log_exception(__name__, "probe_serial_port", exc)
return False
if port not in available_ports:
return False
serial_port = None
try:
serial_port = serial.Serial(port=port, baudrate=SERIAL_BAUDRATE, timeout=SERIAL_TIMEOUT)
return True
except Exception as exc:
log_exception(__name__, "probe_serial_port.serial_open", exc)
return False
finally:
if serial_port is not None and serial_port.is_open:
serial_port.close()
class SerialService(BaseService):
"""Transport-сервис чтения и записи пакетов COM-порта."""
def __init__(
self,
port: str = SERIAL_PORT,
baudrate: int = SERIAL_BAUDRATE,
timeout: float = SERIAL_TIMEOUT,
resend_interval_sec: float = 1.0,
reconnect_delay_sec: float = 1.0,
max_connect_attempts: int = 3,
parent: QObject | None = None,
):
super().__init__(parent)
self._port_name = port
self._baudrate = baudrate
self._timeout = timeout
self._resend_interval_sec = resend_interval_sec
self._reconnect_delay_sec = reconnect_delay_sec
self._max_connect_attempts = max_connect_attempts
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._serial_port: Any | None = None
self._buffer = bytearray()
self._initialized_buttons: set[int] = set()
self._pending_states: dict[int, int] = {}
self._retry_deadlines: dict[int, float] = {}
def start(self) -> None:
if self.is_running():
return
if serial is None:
message = "pyserial недоступен, serial transport не может быть запущен."
self.error_occurred.emit(message)
self._set_connection_status(TicketConnectionStatus.ERROR, message)
self._set_button_initialization(False, 0)
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run_loop, name="TicketSerialService", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop_event.set()
self._close_port("Порт закрыт")
if self._thread is not None:
self._thread.join(timeout=1.0)
self._thread = None
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
def set_button_state(self, button_id: int, state_code: int) -> None:
super().set_button_state(button_id, state_code)
with self._lock:
button_id = int(button_id)
state_code = int(state_code)
self._pending_states[button_id] = state_code
serial_port = self._serial_port
port_open = serial_port is not None and serial_port.is_open
is_initialized = button_id in self._initialized_buttons
if port_open:
self._send_state(button_id, state_code, schedule_retry=is_initialized)
def remove_button_state(self, button_id: int) -> None:
super().remove_button_state(button_id)
with self._lock:
button_id = int(button_id)
self._pending_states.pop(button_id, None)
self._retry_deadlines.pop(button_id, None)
def reset_button_states(self) -> None:
super().reset_button_states()
with self._lock:
self._pending_states.clear()
self._retry_deadlines.clear()
def _run_loop(self) -> None:
attempts = 0
while not self._stop_event.is_set():
if not self._is_port_open():
if self._connect_port():
attempts = 0
self._flush_known_states()
else:
attempts += 1
if attempts >= self._max_connect_attempts:
try:
self.error_occurred.emit(
f"Не удалось подключиться к {self._port_name}."
)
self.port_disconnected.emit()
except RuntimeError:
pass
return
self._stop_event.wait(self._reconnect_delay_sec)
continue
try:
self._read_available_packets()
self._retry_pending_states_if_needed()
except Exception as exc:
log_exception(__name__, "SerialService._run_loop", exc)
try:
self.port_disconnected.emit()
except RuntimeError:
return
self._close_port("Порт закрыт")
self._stop_event.wait(self._reconnect_delay_sec)
continue
self._stop_event.wait(0.05)
def _connect_port(self) -> bool:
try:
serial_port = serial.Serial(
port=self._port_name,
baudrate=self._baudrate,
timeout=self._timeout,
write_timeout=0.5,
inter_byte_timeout=0.005,
)
serial_port.reset_input_buffer()
serial_port.reset_output_buffer()
except Exception as exc:
log_exception(__name__, "SerialService._connect_port", exc)
self._set_connection_status(
TicketConnectionStatus.ERROR,
f"Ошибка подключения к {self._port_name}: {exc}",
)
self._set_button_initialization(False, 0)
return False
with self._lock:
self._serial_port = serial_port
self._buffer.clear()
self._set_connection_status(
TicketConnectionStatus.CONNECTED,
f"{self._port_name} ({self._baudrate} бод)",
)
self._set_button_initialization(False, 0)
return True
def _read_available_packets(self) -> None:
with self._lock:
serial_port = self._serial_port
if serial_port is None or not serial_port.is_open:
return
data_available = getattr(serial_port, "in_waiting", 0)
if data_available <= 0:
return
payload = serial_port.read(data_available)
if not payload:
return
with self._lock:
self._buffer.extend(payload)
self._process_buffer()
def _process_buffer(self) -> None:
while True:
with self._lock:
if len(self._buffer) < 4:
return
packet_index = self._find_packet_start()
if packet_index is None:
if len(self._buffer) > 100:
self._buffer.clear()
return
button_id = self._buffer[packet_index]
hardware_state = self._buffer[packet_index + 1]
del self._buffer[: packet_index + 4]
self._handle_packet(button_id, hardware_state)
def _find_packet_start(self) -> int | None:
buffer_length = len(self._buffer)
for index in range(buffer_length - 3):
if self._buffer[index + 2] == 0x0D and self._buffer[index + 3] == 0x0A:
return index
return None
def _handle_packet(self, button_id: int, hardware_state: int) -> None:
if not 1 <= button_id <= 8:
return
if hardware_state == HARDWARE_SIGNAL_INITIALIZE:
self._handle_initialization_request(button_id)
return
if hardware_state == 0xAA:
self._handle_confirmation(button_id)
return
if hardware_state not in (0, 1, 2, 3):
self.error_occurred.emit(
f"Неизвестное аппаратное состояние: {hardware_state:02X}"
)
return
self.action_triggered.emit(
{
"event": "advance",
"button_id": button_id,
"hardware_state": hardware_state,
"current_state_code": self._get_button_state(button_id),
}
)
def _handle_initialization_request(self, button_id: int) -> None:
with self._lock:
self._initialized_buttons.add(button_id)
button_count = len(self._initialized_buttons)
self._set_button_initialization(True, button_count)
self._send_state(button_id, self._get_button_state(button_id))
def _handle_confirmation(self, button_id: int) -> None:
with self._lock:
self._pending_states.pop(button_id, None)
self._retry_deadlines.pop(button_id, None)
def _retry_pending_states_if_needed(self) -> None:
now = time.monotonic()
with self._lock:
pending_items = list(self._retry_deadlines.items())
for button_id, retry_deadline in pending_items:
if retry_deadline <= now:
self._send_state(button_id, self._get_button_state(button_id))
def _flush_known_states(self) -> None:
"""Отправить все известные состояния кнопок однократно (без retry)."""
for button_id, state_code in list(self._button_states.items()):
self._send_state(button_id, state_code, schedule_retry=False)
def _send_state(self, button_id: int, state_code: int, *, schedule_retry: bool = True) -> bool:
packet = bytes([int(button_id), int(state_code) & 0xFF, 0x0D, 0x0A])
try:
with self._lock:
serial_port = self._serial_port
if serial_port is None or not serial_port.is_open:
return False
serial_port.write(packet)
serial_port.flush()
if schedule_retry:
self._pending_states[int(button_id)] = int(state_code)
self._retry_deadlines[int(button_id)] = time.monotonic() + self._resend_interval_sec
except Exception as exc:
log_exception(__name__, "SerialService._send_state", exc)
try:
self.port_disconnected.emit()
except RuntimeError:
return False
self._close_port("Порт закрыт")
return False
return True
def _close_port(self, message: str) -> None:
with self._lock:
serial_port = self._serial_port
self._serial_port = None
self._buffer.clear()
self._initialized_buttons.clear()
self._pending_states.clear()
self._retry_deadlines.clear()
if serial_port is not None and serial_port.is_open:
try:
serial_port.close()
except Exception as exc:
log_exception(__name__, "SerialService._close_port", exc)
self._set_button_initialization(False, 0)
self._set_connection_status(TicketConnectionStatus.DISCONNECTED, message)
def _is_port_open(self) -> bool:
with self._lock:
serial_port = self._serial_port
return serial_port is not None and serial_port.is_open