Files
templates/python/set_devices/eeprom.py

404 lines
19 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.
"""Каталог «датчик — позиция» во внешней 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"