"""@file mock_port.py @brief Qt-таймер, имитирующий подключённый контроллер без COM-порта. Реализует mock-устройство для тестирования и разработки без физического оборудования. Использует детерминированные значения для надёжного тестирования. """ from __future__ import annotations from .qt_compat import QElapsedTimer, QObject, QTimer, Signal from set_devices.ds18b20 import MockSensorBus from set_devices.eeprom import CatalogEntry, MemoryInfo from set_devices.models import MockSignalSource, SignalCatalog from set_devices.panel import MockPanel class MockDevicePort(QObject): """@brief Асинхронный mock-порт с интерфейсом реального порта. Qt-таймер является платформенным планировщиком, а генерация значений делегируется переносимому ``MockSignalSource``. """ connected_changed = Signal(bool, str) snapshot_received = Signal(object) sensors_received = Signal(object) memory_info_received = Signal(object) catalog_received = Signal(object) sensor_list_received = Signal(object) panel_received = Signal(object) tx_logged = Signal(str) rx_logged = Signal(str) error_occurred = Signal(str) def __init__(self, catalog: SignalCatalog, parent: QObject | None = None) -> None: super().__init__(parent) self._source = MockSignalSource(catalog) self._bus = MockSensorBus() # Каталог mock-прибора: два датчика уже разложены по позициям одной # сборки (assembly_serial=7), третий остаётся без записи. self._catalog = [ CatalogEntry(rom=self._bus.roms()[0], position=1, assembly_serial=7), CatalogEntry(rom=self._bus.roms()[2], position=258, assembly_serial=7), ] self._panel = MockPanel(self._panel_rows) self._elapsed = QElapsedTimer() self._timer = QTimer(self) self._timer.setInterval(250) self._timer.timeout.connect(self._publish_snapshot) def open(self) -> None: """@brief Запускает генерацию четырёх снимков в секунду. Повторный вызов безопасен и не создаёт второй таймер. """ if self._timer.isActive(): return self._elapsed.start() self._timer.start() self.connected_changed.emit(True, "MOCK") self.rx_logged.emit("Mock-контроллер подключён") self._publish_snapshot() def close(self) -> None: """@brief Останавливает генератор и публикует отключение. Повторное закрытие не создаёт ложное событие изменения состояния. """ was_active = self._timer.isActive() self._timer.stop() if was_active: self.connected_changed.emit(False, "MOCK") def scan_sensors(self) -> None: """@brief Публикует список ROM mock-шины 1-Wire. Поиск выполняется мгновенно: модель шины известна заранее. """ self.tx_logged.emit("SENSOR_SCAN mock") self.sensor_list_received.emit(self._bus.roms()) self.publish_sensors() def publish_sensors(self) -> None: """@brief Публикует очередной набор измерений DS18B20.""" self.sensors_received.emit(self._bus.snapshot(self._timestamp_s())) def scan_memory(self) -> None: """@brief Публикует состояние mock-памяти и каталога позиций.""" self.tx_logged.emit("EEPROM_SCAN mock") self.memory_info_received.emit( MemoryInfo( present=True, persistent=True, count=len(self._catalog), capacity=32, i2c_address=0x50, page_size=64, size=32768, base_address=1024, bus_errors=0, blob_size=332, ) ) def read_catalog(self) -> None: """@brief Публикует записи каталога mock-прибора.""" self.tx_logged.emit("EEPROM_READ mock") self.catalog_received.emit(list(self._catalog)) def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None: """@brief Применяет пользовательские байты к mock-датчику. @param rom Идентификатор датчика на mock-шине. @param user_byte1 Новое значение TH. @param user_byte2 Новое значение TL. """ try: self._bus.set_user_bytes(rom, user_byte1, user_byte2) except (KeyError, ValueError) as error: self.error_occurred.emit(str(error)) return self.tx_logged.emit(f"SET_USER_BYTES mock {user_byte1:#04x} {user_byte2:#04x}") self.publish_sensors() def set_resolution(self, rom: bytes, bits: int) -> None: """@brief Меняет разрешение mock-датчика. @param rom Идентификатор датчика на mock-шине. @param bits Разрешение 9..12 бит. """ try: self._bus.set_resolution(rom, bits) except (KeyError, ValueError) as error: self.error_occurred.emit(str(error)) return self.tx_logged.emit(f"SET_RESOLUTION mock {bits} бит") self.publish_sensors() def read_panel(self) -> None: """@brief Публикует снимок экрана mock-панели.""" self.panel_received.emit(self._panel.screen()) def press_panel_key(self, key: int, hold: bool = False) -> None: """@brief Применяет нажатие кнопки к mock-панели. @param key Код кнопки из PanelKey. @param hold Длинное нажатие. """ self._panel.press(key, hold) self.tx_logged.emit(f"UI_KEY mock {int(key)}{' hold' if hold else ''}") self.read_panel() def _panel_rows(self) -> list[tuple[str, str]]: """Строки экрана датчиков mock-панели: номер и температура.""" rows: list[tuple[str, str]] = [] for index, reading in enumerate(self._bus.snapshot(self._timestamp_s())): temperature = reading.temperature rows.append(( f"{index + 1} {reading.serial}", "---" if temperature is None else f"{temperature:+.1f}", )) return rows def set_output(self, key: str, value: bool) -> None: """@brief Применяет управляющую дискрету к mock-модели. @param key Ключ разрешённого выхода. @param value Новое состояние; ошибка неизвестного ключа идёт в signal. """ try: self._source.set_output(key, value) except KeyError as error: self.error_occurred.emit(str(error)) return self.tx_logged.emit(f"{key} = {int(value)}") self._publish_snapshot() def _timestamp_s(self) -> float: """Возвращает монотонное время mock-порта в секундах.""" return self._elapsed.elapsed() / 1000.0 def _publish_snapshot(self) -> None: """@brief Создаёт и публикует атомарный снимок вне UI-виджетов.""" timestamp_s = self._timestamp_s() self.snapshot_received.emit(self._source.snapshot(timestamp_s)) self.sensors_received.emit(self._bus.snapshot(timestamp_s))