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