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