Files
templates/python/set_devices/panel.py

321 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""@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)))