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,23 @@
# -*- coding: utf-8 -*-
# hub/ticket/services/__init__.py
"""Публичный сервисный контур Ticket."""
from .base_service import BaseService
from .hardware_gateway import TicketHardwareGateway, TicketHardwareObserver
from .mock_service import MockService
from .serial_service import SERIAL_BAUDRATE, SERIAL_PORT, SERIAL_TIMEOUT, SerialService, probe_serial_port
from .service_manager import ServiceManager
__all__ = [
"BaseService",
"MockService",
"SERIAL_BAUDRATE",
"SERIAL_PORT",
"SERIAL_TIMEOUT",
"SerialService",
"ServiceManager",
"TicketHardwareGateway",
"TicketHardwareObserver",
"probe_serial_port",
]

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
# hub/ticket/services/base_service.py
"""Базовый транспортный сервис Ticket без доменной логики."""
from __future__ import annotations
from dataclasses import replace
from PySide6.QtCore import QObject, Signal
from domain import TicketConnectionStatus, TicketHardwareStatus
from domain.ticket_constants import STATE_TODO
class BaseService(QObject):
"""Общий контракт transport-сервисов Ticket."""
action_triggered = Signal(object)
error_occurred = Signal(str)
com_status_changed = Signal(bool, str)
buttons_initialized = Signal(bool, int)
port_disconnected = Signal()
def __init__(self, parent: QObject | None = None):
super().__init__(parent)
self._button_states: dict[int, int] = {}
self._status = TicketHardwareStatus()
def start(self) -> None:
raise NotImplementedError
def stop(self) -> None:
raise NotImplementedError
def is_running(self) -> bool:
raise NotImplementedError
def get_status(self) -> TicketHardwareStatus:
"""Вернуть последний известный статус transport-сервиса."""
return self._status
def set_button_state(self, button_id: int, state_code: int) -> None:
"""Сохранить последнее известное состояние кнопки."""
self._button_states[int(button_id)] = int(state_code)
def remove_button_state(self, button_id: int) -> None:
"""Удалить состояние кнопки из локального transport-кэша."""
self._button_states.pop(int(button_id), None)
def reset_button_states(self) -> None:
"""Сбросить все известные состояния кнопок."""
self._button_states.clear()
def _get_button_state(self, button_id: int) -> int:
return self._button_states.get(int(button_id), STATE_TODO)
def _set_connection_status(
self,
connection_status: TicketConnectionStatus,
message: str,
) -> None:
if (
self._status.connection_status == connection_status
and self._status.message == message
):
return
self._status = replace(
self._status,
connection_status=connection_status,
message=message,
)
self.com_status_changed.emit(
connection_status == TicketConnectionStatus.CONNECTED,
message,
)
def _set_button_initialization(
self,
is_initialized: bool,
button_count: int,
) -> None:
if (
self._status.buttons_initialized == is_initialized
and self._status.button_count == button_count
):
return
self._status = replace(
self._status,
buttons_initialized=is_initialized,
button_count=button_count,
)
self.buttons_initialized.emit(is_initialized, button_count)

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# hub/ticket/services/hardware_gateway.py
"""Публичный контракт аппаратного шлюза Ticket."""
from __future__ import annotations
from typing import Any, Mapping, Protocol
from domain import TicketHardwareStatus
class TicketHardwareObserver(Protocol):
"""Получатель событий от аппаратного или mock-шлюза."""
def on_task_action(self, raw_action: Mapping[str, Any]) -> None:
"""Принять внешнее действие по задаче."""
def on_gateway_status(self, status: TicketHardwareStatus) -> None:
"""Принять обновление статуса подключения и инициализации."""
def on_gateway_error(self, message: str) -> None:
"""Принять сообщение об ошибке шлюза."""
class TicketHardwareGateway(Protocol):
"""Минимальный публичный API сервиса ввода событий Ticket."""
def start(self) -> None:
"""Запустить внешний источник событий."""
def stop(self) -> None:
"""Остановить внешний источник событий."""
def get_status(self) -> TicketHardwareStatus:
"""Вернуть текущий статус шлюза."""
def set_observer(self, observer: TicketHardwareObserver | None) -> None:
"""Назначить получателя событий приложения."""
def set_button_state(self, button_id: int, state_code: int) -> None:
"""Синхронизировать каноническое состояние кнопки для аппаратного шлюза."""
def remove_button_state(self, button_id: int) -> None:
"""Удалить известное состояние кнопки из шлюза."""
def reset_button_states(self) -> None:
"""Сбросить все известные состояния кнопок при смене контекста."""

View File

@@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# hub/ticket/services/mock_service.py
"""Offline transport-сервис Ticket."""
from __future__ import annotations
from PySide6.QtCore import QObject
from domain import TicketConnectionStatus
from .base_service import BaseService
class MockService(BaseService):
"""Безопасный offline-режим при недоступном COM-порте."""
def __init__(self, parent: QObject | None = None):
super().__init__(parent)
self._running = False
def start(self) -> None:
self._running = True
self._set_connection_status(
TicketConnectionStatus.DISCONNECTED,
"Оффлайн-режим",
)
self._set_button_initialization(False, 0)
def stop(self) -> None:
self._running = False
def is_running(self) -> bool:
return self._running

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

View File

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
# hub/ticket/services/service_manager.py
"""Единый hardware gateway Ticket поверх serial/mock transport-сервисов."""
from __future__ import annotations
from PySide6.QtCore import QObject, QTimer, Signal
from domain import TicketHardwareStatus
from .base_service import BaseService
from .hardware_gateway import TicketHardwareObserver
from .mock_service import MockService
from .serial_service import SERIAL_BAUDRATE, SERIAL_PORT, SerialService, probe_serial_port
PROBE_INTERVAL_SEC = 3
class ServiceManager(QObject):
"""Канонический hardware gateway Ticket."""
service_changed = Signal(str, bool)
action_triggered = Signal(object)
error_occurred = Signal(str)
com_status_changed = Signal(bool, str)
buttons_initialized = Signal(bool, int)
port_disconnected = Signal()
def __init__(
self,
serial_port: str = SERIAL_PORT,
serial_baudrate: int = SERIAL_BAUDRATE,
probe_interval_sec: int = PROBE_INTERVAL_SEC,
parent: QObject | None = None,
):
super().__init__(parent)
self._serial_port = serial_port
self._serial_baudrate = serial_baudrate
self._observer: TicketHardwareObserver | None = None
self._current_service: BaseService | None = None
self._current_service_name = "none"
self._button_states: dict[int, int] = {}
self._status = TicketHardwareStatus()
self._probe_timer = QTimer(self)
self._probe_timer.setInterval(probe_interval_sec * 1000)
self._probe_timer.timeout.connect(self._on_probe_timer)
def start(self) -> None:
if self._current_service is not None:
return
if probe_serial_port(self._serial_port):
self._start_serial_service()
else:
self._start_mock_service()
def stop(self) -> None:
self._probe_timer.stop()
self._shutdown_current_service()
def get_status(self) -> TicketHardwareStatus:
return self._status
def set_observer(self, observer: TicketHardwareObserver | None) -> None:
self._observer = observer
if observer is not None:
observer.on_gateway_status(self._status)
def set_button_state(self, button_id: int, state_code: int) -> None:
self._button_states[int(button_id)] = int(state_code)
if self._current_service is not None:
self._current_service.set_button_state(button_id, state_code)
def remove_button_state(self, button_id: int) -> None:
self._button_states.pop(int(button_id), None)
if self._current_service is not None:
self._current_service.remove_button_state(button_id)
def reset_button_states(self) -> None:
self._button_states.clear()
if self._current_service is not None:
self._current_service.reset_button_states()
def _start_serial_service(self) -> None:
service = SerialService(
port=self._serial_port,
baudrate=self._serial_baudrate,
parent=self,
)
if not self._activate_service(service, "serial", True):
self._start_mock_service()
return
self._probe_timer.stop()
def _start_mock_service(self) -> None:
service = MockService(parent=self)
self._activate_service(service, "mock", False)
self._probe_timer.start()
def _activate_service(
self,
service: BaseService,
service_name: str,
is_connected: bool,
) -> bool:
self._shutdown_current_service()
self._connect_service_signals(service)
for button_id, state_code in self._button_states.items():
service.set_button_state(button_id, state_code)
service.start()
if not service.is_running() and service_name == "serial":
service.deleteLater()
return False
self._current_service = service
self._current_service_name = service_name
self._refresh_status()
self.service_changed.emit(service_name, is_connected)
return True
def _connect_service_signals(self, service: BaseService) -> None:
service.action_triggered.connect(self._on_action_triggered)
service.error_occurred.connect(self._on_error_occurred)
service.com_status_changed.connect(self._on_com_status_changed)
service.buttons_initialized.connect(self._on_buttons_initialized)
service.port_disconnected.connect(self._on_port_disconnected)
def _shutdown_current_service(self) -> None:
if self._current_service is None:
return
current_service = self._current_service
self._current_service = None
self._current_service_name = "none"
current_service.stop()
current_service.deleteLater()
self._status = TicketHardwareStatus()
self._notify_status_observer()
def _refresh_status(self) -> None:
if self._current_service is None:
self._status = TicketHardwareStatus()
else:
self._status = self._current_service.get_status()
self._notify_status_observer()
def _notify_status_observer(self) -> None:
if self._observer is not None:
self._observer.on_gateway_status(self._status)
def _on_action_triggered(self, raw_action: object) -> None:
self.action_triggered.emit(raw_action)
if self._observer is not None and isinstance(raw_action, dict):
self._observer.on_task_action(raw_action)
def _on_error_occurred(self, message: str) -> None:
self.error_occurred.emit(message)
if self._observer is not None:
self._observer.on_gateway_error(message)
def _on_com_status_changed(self, is_connected: bool, message: str) -> None:
self._refresh_status()
self.com_status_changed.emit(is_connected, message)
def _on_buttons_initialized(self, is_initialized: bool, button_count: int) -> None:
self._refresh_status()
self.buttons_initialized.emit(is_initialized, button_count)
def _on_port_disconnected(self) -> None:
self.port_disconnected.emit()
if self._current_service_name == "serial":
self._start_mock_service()
def _on_probe_timer(self) -> None:
port_available = probe_serial_port(self._serial_port)
if port_available and self._current_service_name == "mock":
self._start_serial_service()
elif not port_available and self._current_service_name == "serial":
self._start_mock_service()