321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""@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)))
|