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

View File

@@ -0,0 +1,403 @@
"""Каталог «датчик — позиция» во внешней EEPROM прибора DS18B20."""
from __future__ import annotations
import csv
import io
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from set_devices.ds18b20 import ROM_SIZE, format_rom
from set_devices.protocol import ProtocolError
#: Заголовки колонок экспорта каталога — общие для CSV и Markdown.
EXPORT_COLUMNS = (
"№", "ID (ROM)", "Серийный", "Позиция", "Позиция (hex)",
"Сборка", "Сборка (hex)",
)
#: Длина полезной нагрузки ответа ``EEPROM_INFO``.
MEMORY_INFO_SIZE = 20
#: Длина одной записи каталога в кадре ``EEPROM_LIST``.
CATALOG_RECORD_SIZE = 12
#: Заголовок кадра ``EEPROM_LIST`` перед записями.
CATALOG_HEADER_SIZE = 5
#: Признак «позиция не назначена» в записи каталога.
NO_POSITION = 0xFFFF
#: Признак «номер сборки не назначен» в записи каталога.
NO_SERIAL = 0xFFFF
@dataclass(frozen=True)
class MemoryInfo:
"""@brief Состояние внешней памяти и каталога позиций.
@param present Кристалл ответил на свой адрес при последнем опросе.
@param persistent Каталог прочитан из памяти и переживёт отключение.
@param count Число заполненных записей каталога.
@param capacity Вместимость каталога в записях.
@param i2c_address Адрес кристалла на шине I2C.
@param page_size Размер страницы записи, байт.
@param size Объём кристалла, байт.
@param base_address Адрес блока каталога в памяти.
@param bus_errors Счётчик отказов шины с момента запуска прибора.
@param blob_size Длина блока каталога в памяти, байт.
"""
present: bool
persistent: bool
count: int
capacity: int
i2c_address: int
page_size: int
size: int
base_address: int
bus_errors: int
blob_size: int
@property
def size_text(self) -> str:
"""Объём кристалла в килобайтах для строки состояния."""
return f"{self.size // 1024} КБ" if self.size >= 1024 else f"{self.size} Б"
def summary(self) -> str:
"""@brief Собирает описание памяти одной строкой.
@return Текст для панели вкладки DS18B20.
"""
if not self.present:
return "Память не найдена: проверьте плату EEPROM на выводах PB6 и PB7"
storage = "энергонезависимо" if self.persistent else "только в ОЗУ прибора"
return (
f"FT24C256 по адресу 0x{self.i2c_address:02X}, {self.size_text}, "
f"страница {self.page_size} Б; каталог по адресу 0x{self.base_address:04X}, "
f"занято {self.count} из {self.capacity} записей, {storage}"
)
@dataclass(frozen=True)
class CatalogEntry:
"""@brief Одна запись каталога: датчик, позиция и номер сборки.
Номер сборки — учётное поле: несколько датчиков одной партии (например,
10 штук на одной плате) несут один и тот же номер. По шине CAN он не
передаётся, узлу-приёмнику важна только позиция.
@param rom Идентификатор датчика, 8 байт.
@param position Позиция в таблице узла-приёмника, два байта.
@param assembly_serial Серийный номер сборки (партии), два байта.
"""
rom: bytes
position: int
assembly_serial: int = NO_SERIAL
@property
def rom_hex(self) -> str:
"""Идентификатор в виде разделённого HEX."""
return format_rom(self.rom)
@property
def serial(self) -> str:
"""48-битный серийный номер датчика без кода семейства и CRC8."""
return "".join(f"{byte:02X}" for byte in self.rom[1:7])
@property
def assigned(self) -> bool:
"""Признак того, что позиция действительно назначена."""
return self.position != NO_POSITION
@property
def position_text(self) -> str:
"""Позиция в десятичном и шестнадцатеричном виде."""
if not self.assigned:
return "не назначена"
return f"{self.position} (0x{self.position:04X})"
@property
def assembly_serial_assigned(self) -> bool:
"""Признак того, что номер сборки действительно назначен."""
return self.assembly_serial != NO_SERIAL
@property
def assembly_serial_text(self) -> str:
"""Номер сборки в десятичном и шестнадцатеричном виде."""
if not self.assembly_serial_assigned:
return "не назначен"
return f"{self.assembly_serial} (0x{self.assembly_serial:04X})"
def position_conflicts(
entries: list[CatalogEntry],
) -> dict[tuple[int, int], list[CatalogEntry]]:
"""Возвращает пары «сборка, позиция» с несколькими различными ROM.
Неназначенные записи ``0xFFFF`` не участвуют в проверке. Результат
сохраняет порядок строк каталога, чтобы GUI мог однозначно подсветить и
показать оператору конфликтующие датчики.
"""
by_position: dict[tuple[int, int], list[CatalogEntry]] = {}
for entry in entries:
if entry.assigned and entry.assembly_serial_assigned:
key = (entry.assembly_serial, entry.position)
by_position.setdefault(key, []).append(entry)
return {
key: owners
for key, owners in by_position.items()
if len({entry.rom for entry in owners}) > 1
}
def decode_memory_info(payload: bytes) -> MemoryInfo:
"""@brief Разбирает ответ ``EEPROM_INFO``.
@param payload Полезная нагрузка кадра без заголовка.
@return Состояние памяти и каталога.
@raises ProtocolError При слишком короткой полезной нагрузке.
"""
if len(payload) < MEMORY_INFO_SIZE:
raise ProtocolError("EEPROM_INFO короче 20 байт")
return MemoryInfo(
present=payload[0] != 0,
persistent=payload[1] != 0,
count=payload[2],
capacity=payload[3],
i2c_address=payload[4],
page_size=int.from_bytes(payload[6:8], "little"),
size=int.from_bytes(payload[8:12], "little"),
base_address=int.from_bytes(payload[12:16], "little"),
bus_errors=int.from_bytes(payload[16:18], "little"),
blob_size=int.from_bytes(payload[18:20], "little"),
)
def decode_catalog(payload: bytes) -> tuple[int, int, list[CatalogEntry]]:
"""@brief Разбирает ответ ``EEPROM_LIST``.
@param payload Полезная нагрузка кадра без заголовка.
@return Смещение окна, полное число записей и сами записи.
@raises ProtocolError При неверной длине кадра или счётчике записей.
"""
if len(payload) < CATALOG_HEADER_SIZE:
raise ProtocolError("EEPROM_LIST короче заголовка")
offset = int.from_bytes(payload[0:2], "little")
total = int.from_bytes(payload[2:4], "little")
count = payload[4]
if len(payload) != CATALOG_HEADER_SIZE + count * CATALOG_RECORD_SIZE:
raise ProtocolError("EEPROM_LIST содержит повреждённые записи")
entries: list[CatalogEntry] = []
for index in range(count):
base = CATALOG_HEADER_SIZE + index * CATALOG_RECORD_SIZE
position_offset = base + ROM_SIZE
serial_offset = position_offset + 2
entries.append(
CatalogEntry(
rom=payload[base : base + ROM_SIZE],
position=int.from_bytes(payload[position_offset:position_offset + 2],
"little"),
assembly_serial=int.from_bytes(payload[serial_offset:serial_offset + 2],
"little"),
)
)
return offset, total, entries
def encode_catalog_request(offset: int = 0, count: int = 0) -> bytes:
"""@brief Формирует запрос ``EEPROM_READ``.
Пустой запрос читает каталог целиком с начала, поэтому значения по
умолчанию дают самый частый случай — «показать всё».
@param offset Номер первой запрашиваемой записи.
@param count Число записей; 0 означает «сколько есть».
@return Пустая строка байтов либо три байта окна.
@raises ProtocolError При выходе аргументов за диапазон.
"""
if offset == 0 and count == 0:
return b""
if not 0 <= offset <= 0xFFFF:
raise ProtocolError("Смещение каталога задаётся в диапазоне 0..65535")
if not 0 <= count <= 0xFF:
raise ProtocolError("Число записей задаётся в диапазоне 0..255")
return offset.to_bytes(2, "little") + bytes((count,))
def encode_catalog(entries: list[CatalogEntry], offset: int = 0,
total: int | None = None) -> bytes:
"""@brief Кодирует записи обратно в кадр ``EEPROM_LIST``.
Общая реализация формата для тестов и mock-прибора исключает расхождение
кодера и декодера.
@param entries Записи каталога.
@param offset Смещение окна.
@param total Полное число записей; по умолчанию равно длине окна.
@return Полезная нагрузка кадра.
@raises ProtocolError Если записей больше, чем помещается в кадр.
"""
if len(entries) > 0xFF:
raise ProtocolError("В кадр EEPROM_LIST помещается не более 255 записей")
payload = (
offset.to_bytes(2, "little")
+ (len(entries) if total is None else total).to_bytes(2, "little")
+ bytes((len(entries),))
)
for entry in entries:
if len(entry.rom) != ROM_SIZE:
raise ProtocolError("ROM должен содержать ровно 8 байт")
payload += (
entry.rom
+ (entry.position & 0xFFFF).to_bytes(2, "little")
+ (entry.assembly_serial & 0xFFFF).to_bytes(2, "little")
)
return payload
#: Код команды преамбулы CAN «записать датчика в позицию» (см. lib/can на МК).
CAN_CMD_WRITE_POSITION = 0xA1
#: Код команды преамбулы CAN «очистить позицию».
CAN_CMD_CLEAR_POSITION = 0xA2
def encode_assign_position(rom: bytes, position: int, assembly_serial: int = 0,
command: int = CAN_CMD_WRITE_POSITION) -> bytes:
"""@brief Формирует payload команды ``SEND_ID_CAN``.
Прибор сначала сохраняет пару «датчик — позиция» вместе с номером сборки
в каталоге EEPROM — независимо от того, есть ли кто-то на шине CAN — и
только потом передаёт идентификатор в CAN двумя кадрами, поэтому один
запрос одновременно обновляет каталог прибора и назначает позицию
узлу-приёмнику. Номер сборки в кадры CAN не попадает — это учётное поле
каталога.
@param rom Идентификатор датчика, полученный при поиске.
@param position Позиция в таблице узла-приёмника, 0..65535.
@param assembly_serial Серийный номер сборки (партии), 0..65535.
@param command Код команды преамбулы: запись или очистка позиции.
@return Тринадцать байт запроса.
@raises ProtocolError При недопустимом ROM или значении полей.
"""
if len(rom) != ROM_SIZE:
raise ProtocolError("ROM должен содержать ровно 8 байт")
if not 0 <= position <= 0xFFFF:
raise ProtocolError("Позиция задаётся в диапазоне 0..65535")
if not 0 <= assembly_serial <= 0xFFFF:
raise ProtocolError("Номер сборки задаётся в диапазоне 0..65535")
return (
rom
+ position.to_bytes(2, "little")
+ assembly_serial.to_bytes(2, "little")
+ bytes((command,))
)
def encode_memory_info(info: MemoryInfo) -> bytes:
"""@brief Кодирует состояние памяти в кадр ``EEPROM_INFO``.
@param info Состояние памяти и каталога.
@return Полезная нагрузка кадра длиной ``MEMORY_INFO_SIZE``.
"""
return (
bytes((int(info.present), int(info.persistent), info.count, info.capacity,
info.i2c_address, 0))
+ info.page_size.to_bytes(2, "little")
+ info.size.to_bytes(4, "little")
+ info.base_address.to_bytes(4, "little")
+ info.bus_errors.to_bytes(2, "little")
+ info.blob_size.to_bytes(2, "little")
)
def _export_row(index: int, entry: CatalogEntry) -> tuple[str, ...]:
"""@brief Собирает одну строку экспорта из записи каталога.
Общая для CSV и Markdown реализация исключает расхождение колонок между
форматами: обе таблицы читаются одинаково.
@param index Порядковый номер строки, с единицы.
@param entry Запись каталога.
@return Кортеж строковых значений в порядке EXPORT_COLUMNS.
"""
position = "" if not entry.assigned else str(entry.position)
position_hex = "" if not entry.assigned else f"0x{entry.position:04X}"
serial = "" if not entry.assembly_serial_assigned else str(entry.assembly_serial)
serial_hex = (
"" if not entry.assembly_serial_assigned else f"0x{entry.assembly_serial:04X}"
)
return (str(index), entry.rom_hex, entry.serial, position, position_hex,
serial, serial_hex)
def export_catalog_csv(entries: list[CatalogEntry]) -> str:
"""@brief Формирует содержимое CSV-файла каталога для Excel.
Разделитель — точка с запятой: в русской локали Excel открывает такой
CSV с готовой разбивкой по колонкам без диалога импорта, в отличие от
запятой. Пустое значение позиции или номера сборки означает, что запись
не назначена.
@param entries Записи каталога в порядке отображения.
@return Текст файла с завершающим переводом строки.
"""
buffer = io.StringIO()
writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n")
writer.writerow(EXPORT_COLUMNS)
for index, entry in enumerate(entries, start=1):
writer.writerow(_export_row(index, entry))
return buffer.getvalue()
def export_catalog_json(entries: list[CatalogEntry]) -> str:
"""@brief Формирует содержимое JSON-файла каталога.
Годится для повторной загрузки в другой программе: значения остаются
числами (а не отформатированным текстом), отсутствие позиции или номера
сборки — явный ``null``, а не признак-заглушка вроде 0xFFFF.
@param entries Записи каталога в порядке отображения.
@return Текст файла в кодировке UTF-8 с отступом в 2 пробела.
"""
payload = {
"exported_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"count": len(entries),
"entries": [
{
"index": index,
"rom": entry.rom_hex,
"serial": entry.serial,
"position": entry.position if entry.assigned else None,
"assembly_serial": (
entry.assembly_serial if entry.assembly_serial_assigned else None
),
}
for index, entry in enumerate(entries, start=1)
],
}
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
def export_catalog_markdown(entries: list[CatalogEntry]) -> str:
"""@brief Формирует содержимое Markdown-файла каталога для отчёта.
@param entries Записи каталога в порядке отображения.
@return Текст файла: заголовок, время выгрузки и таблица GFM.
"""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lines = [
"# Каталог EEPROM DS18B20",
"",
f"Выгружено: {timestamp}. Записей: {len(entries)}.",
"",
"| " + " | ".join(EXPORT_COLUMNS) + " |",
"|" + "|".join("---" for _ in EXPORT_COLUMNS) + "|",
]
for index, entry in enumerate(entries, start=1):
row = _export_row(index, entry)
lines.append("| " + " | ".join(value or "—" for value in row) + " |")
return "\n".join(lines) + "\n"