"""@file panel.py @brief Зеркало экрана прибора и кнопки его панели. Модуль описывает кадры ``UI_KEY``, ``UI_READ`` и ``UI_STATE``: они позволяют управлять меню прибора из GUI параллельно физическим кнопкам и показывать в окне то же, что видно на дисплее. Разбор здесь не зависит от Qt, поэтому тесты обходятся без графической подсистемы. Формат снимка описан в ``PROTOCOL.md`` прошивки STM32F103C8T6; зеркало на стороне МК — ``app_send_ui_state`` в ``src/main.c``. """ from __future__ import annotations from collections.abc import Callable, Sequence from dataclasses import dataclass from enum import IntEnum from set_devices.protocol import ProtocolError #: Длина заголовка ``UI_STATE`` до первой строки. STATE_HEADER_SIZE = 6 #: Предельная длина строки снимка; прошивка обрезает по этой границе. TEXT_MAX = 31 #: Бит 0 первого байта ``UI_STATE``: панель найдена и работает. FLAG_ACTIVE = 0x01 class PanelKey(IntEnum): """@brief Кнопки панели прибора. Значения совпадают с ``Menu_Key`` прошивки и являются частью протокола. """ UP = 0 DOWN = 1 LEFT = 2 RIGHT = 3 ENTER = 4 BACK = 5 #: Подписи кнопок для интерфейса в порядке их размещения на пульте. KEY_TITLES: dict[PanelKey, str] = { PanelKey.UP: "▲", PanelKey.DOWN: "▼", PanelKey.LEFT: "◀", PanelKey.RIGHT: "▶", PanelKey.ENTER: "OK", PanelKey.BACK: "Назад", } @dataclass(frozen=True) class PanelRow: """@brief Одна видимая строка списка на экране прибора.""" label: str value: str selected: bool @dataclass(frozen=True) class PanelScreen: """@brief Снимок видимой части экрана прибора. Повторяет изображение построчно: прошивка отдаёт содержимое из кэша последней отрисовки, поэтому снимок совпадает с панелью. """ active: bool title: str status: str rows: tuple[PanelRow, ...] cursor: int first: int total: int depth: int @property def cursor_row(self) -> int | None: """@brief Номер выделенной строки в пределах снимка либо ``None``.""" for index, row in enumerate(self.rows): if row.selected: return index return None @property def scrollable(self) -> bool: """@brief Сообщает, что список не поместился в окно экрана целиком.""" return self.total > len(self.rows) def encode_key(key: PanelKey, hold: bool = False) -> bytes: """@brief Формирует payload ``UI_KEY``. @param key Нажимаемая кнопка. @param hold Длинное нажатие; для «назад» означает возврат в корень меню. @return Два байта полезной нагрузки. """ if not isinstance(key, PanelKey): key = PanelKey(key) return bytes((int(key), 0x01 if hold else 0x00)) def encode_read(subscribe: bool | None = None) -> bytes: """@brief Формирует payload ``UI_READ``. @param subscribe ``True`` — подписаться на инициативную выдачу снимков, ``False`` — отписаться, ``None`` — только запросить снимок, не меняя подписку. @return Ноль или один байт полезной нагрузки. """ if subscribe is None: return b"" return bytes((0x01 if subscribe else 0x00,)) def _take_text(payload: bytes, offset: int) -> tuple[str, int]: """Читает строку «длина и байты» и возвращает её вместе с новой позицией.""" if offset >= len(payload): raise ProtocolError("UI_STATE: строка выходит за границу payload") length = payload[offset] offset += 1 if length > TEXT_MAX: raise ProtocolError(f"UI_STATE: строка длиннее {TEXT_MAX} байт: {length}") end = offset + length if end > len(payload): raise ProtocolError("UI_STATE: строка обрывается на границе payload") # Прошивка печатает только ASCII, но битый байт не должен ронять разбор. return payload[offset:end].decode("ascii", errors="replace"), end def decode_state(payload: bytes) -> PanelScreen: """@brief Разбирает payload ``UI_STATE`` в снимок экрана. @param payload Полезная нагрузка кадра. @return Снимок экрана прибора. @throws ProtocolError Если длина или структура снимка нарушена. """ if len(payload) < STATE_HEADER_SIZE: raise ProtocolError( f"UI_STATE: ожидалось не менее {STATE_HEADER_SIZE} байт, получено {len(payload)}" ) flags = payload[0] depth = payload[1] total = payload[2] cursor = payload[3] first = payload[4] row_count = payload[5] offset = STATE_HEADER_SIZE title, offset = _take_text(payload, offset) status, offset = _take_text(payload, offset) rows: list[PanelRow] = [] for _ in range(row_count): if offset >= len(payload): raise ProtocolError("UI_STATE: строк меньше, чем объявлено в заголовке") selected = payload[offset] != 0 offset += 1 label, offset = _take_text(payload, offset) value, offset = _take_text(payload, offset) rows.append(PanelRow(label=label, value=value, selected=selected)) return PanelScreen( active=(flags & FLAG_ACTIVE) != 0, title=title, status=status, rows=tuple(rows), cursor=cursor, first=first, total=total, depth=depth, ) def encode_state(screen: PanelScreen) -> bytes: """@brief Собирает payload ``UI_STATE`` из снимка. Нужен mock-режиму и тестам: прошивка формирует кадр сама. @param screen Снимок экрана. @return Полезная нагрузка кадра. """ def text(value: str) -> bytes: raw = value.encode("ascii", errors="replace")[:TEXT_MAX] return bytes((len(raw),)) + raw payload = bytearray( ( FLAG_ACTIVE if screen.active else 0x00, screen.depth, screen.total, screen.cursor, screen.first, len(screen.rows), ) ) payload += text(screen.title) payload += text(screen.status) for row in screen.rows: payload.append(0x01 if row.selected else 0x00) payload += text(row.label) payload += text(row.value) return bytes(payload) #: Пункты корневого экрана прошивки; порядок совпадает с ``ui_root_label``. ROOT_ITEMS = ("SENSORS", "RESCAN", "CAN", "SETTINGS", "ABOUT") #: Сколько строк помещается на панели 135x240 при оформлении по умолчанию. ROWS_VISIBLE = 5 #: Подсказка по кнопкам, которую прошивка ставит в строку состояния. HINT = "ENT-OPEN BACK-UP HOLD" class MockPanel: """@brief Модель меню прибора для работы GUI без оборудования. Повторяет структуру экранов прошивки настолько, чтобы пультом можно было пользоваться в mock-режиме: корневой экран и список датчиков с прокруткой. Значения берутся у поставщика, поэтому модель не знает про 1-Wire. """ def __init__(self, sensors: Callable[[], Sequence[tuple[str, str]]] | None = None) -> None: """@param sensors Поставщик строк экрана датчиков: название и значение.""" self._sensors = sensors self._depth = 1 self._cursor = [0, 0] self._first = [0, 0] self._status = HINT def press(self, key: int, hold: bool = False) -> None: """@brief Применяет нажатие кнопки к модели меню. @param key Код кнопки из PanelKey. @param hold Длинное нажатие; для «назад» возвращает в корень. """ key = PanelKey(key) level = self._depth - 1 total = self._total() if key is PanelKey.BACK: if hold or self._depth > 1: self._depth = 1 self._status = HINT return if key is PanelKey.ENTER: if self._depth == 1 and self._cursor[0] == 0: self._depth = 2 self._cursor[1] = 0 self._first[1] = 0 elif self._depth == 1 and self._cursor[0] == 1: self._status = f"{len(self._rows())} SENSORS FOUND" return if total == 0: return if key is PanelKey.UP: self._cursor[level] = (self._cursor[level] - 1) % total elif key is PanelKey.DOWN: self._cursor[level] = (self._cursor[level] + 1) % total else: # Влево и вправо правят значения, которых у mock-экранов нет. return self._scroll(level, total) def screen(self) -> PanelScreen: """@brief Возвращает снимок текущего экрана модели.""" level = self._depth - 1 total = self._total() first = self._first[level] items = self._items()[first:first + ROWS_VISIBLE] rows = tuple( PanelRow(label=label, value=value, selected=(first + index) == self._cursor[level]) for index, (label, value) in enumerate(items) ) marks = "" if total > ROWS_VISIBLE: marks = ("^" if first != 0 else "") + ("v" if first + ROWS_VISIBLE < total else "") return PanelScreen( active=True, title="DS18B20" if self._depth == 1 else "SENSORS", status=self._status + marks, rows=rows, cursor=self._cursor[level], first=first, total=total, depth=self._depth, ) def _rows(self) -> Sequence[tuple[str, str]]: """Строки экрана датчиков от поставщика; пустой список без него.""" return () if self._sensors is None else self._sensors() def _items(self) -> Sequence[tuple[str, str]]: """Пункты открытого экрана вместе со значениями.""" if self._depth == 1: values = (str(len(self._rows())), "", "OFF", "", "") return tuple(zip(ROOT_ITEMS, values)) return self._rows() def _total(self) -> int: """Число пунктов открытого экрана.""" return len(self._items()) def _scroll(self, level: int, total: int) -> None: """Держит курсор внутри окна прокрутки после перемещения.""" cursor = self._cursor[level] first = self._first[level] if cursor < first: first = cursor elif cursor >= first + ROWS_VISIBLE: first = cursor - ROWS_VISIBLE + 1 self._first[level] = max(0, min(first, max(0, total - ROWS_VISIBLE)))