Extract reusable device protocols, ports and logic analyzers from SETGUI
This commit is contained in:
320
python/set_devices/panel.py
Normal file
320
python/set_devices/panel.py
Normal file
@@ -0,0 +1,320 @@
|
||||
"""@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)))
|
||||
Reference in New Issue
Block a user