Extract reusable device protocols, ports and logic analyzers from SETGUI

This commit is contained in:
2026-09-23 20:11:37 +03:00
parent 80ba17d77d
commit 795a1279b1
63 changed files with 10181 additions and 6 deletions

320
python/set_devices/panel.py Normal file
View 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)))