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