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,448 @@
"""@file ds18b20.py
@brief Модель и кодеки датчиков DS18B20 на шине 1-Wire STM32F103C8T6.
Содержит определения структур данных для работы с температурными датчиками,
кодеки для кодирования/декодирования информации о датчиках, и вспомогательные
функции для проверки целостности и форматирования данных.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, replace
from enum import IntFlag
from set_devices.protocol import ProtocolError, crc8_maxim
ROM_SIZE = 8
SENSOR_RECORD_SIZE = 14
FAMILY_DS18B20 = 0x28
MAX_SENSORS = 8
MIN_RESOLUTION_BITS = 9
MAX_RESOLUTION_BITS = 12
RAW_STEP_C = 1.0 / 16.0
#: Время преобразования в миллисекундах для 9..12 бит.
CONVERSION_TIME_MS = {9: 94, 10: 188, 11: 375, 12: 750}
class SensorStatus(IntFlag):
"""@brief Биты байта статуса записи ``SENSOR_DATA``.
Флаги независимы: датчик может ответить presence-импульсом и всё равно
вернуть scratchpad с неверным CRC8.
"""
VALID = 0x01
CRC_ERROR = 0x02
NO_PRESENCE = 0x04
PARASITE_POWER = 0x08
def format_rom(rom: bytes) -> str:
"""@brief Приводит 64-битный ROM к виду ``28-FF-64-1E-0C-3D-2A-91``.
@param rom Восемь байт в порядке передачи по шине.
@return Строка верхнего регистра для журнала и таблицы.
"""
return "-".join(f"{byte:02X}" for byte in rom)
def parse_rom(text: str) -> bytes:
"""@brief Разбирает ROM из строки с любыми разделителями.
@param text Текст вида ``28-FF-64-...`` либо непрерывный HEX.
@return Ровно восемь байт ROM.
@raises ProtocolError При недопустимых символах или длине.
"""
cleaned = "".join(char for char in text if char.isalnum())
try:
rom = bytes.fromhex(cleaned)
except ValueError as error:
raise ProtocolError(f"ROM содержит не HEX-символы: {text}") from error
if len(rom) != ROM_SIZE:
raise ProtocolError("ROM должен содержать ровно 8 байт")
return rom
def resolution_from_config(config: int) -> int:
"""@brief Извлекает разрешение из байта конфигурации scratchpad.
@param config Байт 4 scratchpad, значащие биты 6:5.
@return Разрешение в битах от 9 до 12.
"""
return MIN_RESOLUTION_BITS + ((config >> 5) & 0x03)
def config_from_resolution(bits: int) -> int:
"""@brief Собирает байт конфигурации из требуемого разрешения.
@param bits Разрешение 9..12 бит.
@return Байт конфигурации с обязательными единицами резервных бит.
@raises ProtocolError При разрешении вне диапазона датчика.
"""
if not MIN_RESOLUTION_BITS <= bits <= MAX_RESOLUTION_BITS:
raise ProtocolError("Разрешение DS18B20 задаётся в диапазоне 9..12 бит")
return 0x1F | ((bits - MIN_RESOLUTION_BITS) << 5)
def raw_to_celsius(raw: int, bits: int) -> float:
"""@brief Переводит код scratchpad в °C с учётом неопределённых бит.
При разрешении ниже 12 бит младшие биты кода не определены датчиком и
маскируются до перевода в градусы.
@param raw Знаковый 16-битный код из байтов 0 и 1 scratchpad.
@param bits Текущее разрешение датчика 9..12 бит.
@return Температура в градусах Цельсия с шагом 1/16 °C.
"""
limited = max(MIN_RESOLUTION_BITS, min(bits, MAX_RESOLUTION_BITS))
undefined = MAX_RESOLUTION_BITS - limited
mask = -1 << undefined
return (raw & mask) * RAW_STEP_C
@dataclass(frozen=True)
class SensorReading:
"""@brief Одна запись ``SENSOR_DATA`` без привязки к Qt.
@param rom 64-битный уникальный идентификатор датчика.
@param raw Знаковый код температуры из scratchpad.
@param user_byte1 Пользовательский байт 1 (регистр TH).
@param user_byte2 Пользовательский байт 2 (регистр TL).
@param config Байт конфигурации с разрешением преобразования.
@param status Битовая маска ``SensorStatus``.
"""
rom: bytes
raw: int
user_byte1: int
user_byte2: int
config: int
status: int
@property
def rom_hex(self) -> str:
"""Идентификатор датчика в виде разделённого HEX."""
return format_rom(self.rom)
@property
def family(self) -> int:
"""Код семейства 1-Wire; у DS18B20 это ``0x28``."""
return self.rom[0]
@property
def serial(self) -> str:
"""48-битный серийный номер без кода семейства и CRC8."""
return "".join(f"{byte:02X}" for byte in self.rom[1:7])
@property
def rom_crc_valid(self) -> bool:
"""Проверяет CRC8 самого ROM, а не принятых данных."""
return crc8_maxim(self.rom[:7]) == self.rom[7]
@property
def resolution(self) -> int:
"""Текущее разрешение датчика в битах."""
return resolution_from_config(self.config)
@property
def conversion_time_ms(self) -> int:
"""Время преобразования, соответствующее разрешению."""
return CONVERSION_TIME_MS[self.resolution]
@property
def is_valid(self) -> bool:
"""Признак того, что температуру можно показывать оператору."""
return bool(self.status & SensorStatus.VALID)
@property
def temperature(self) -> float | None:
"""Температура в °C либо ``None`` для недостоверного измерения."""
if not self.is_valid:
return None
return raw_to_celsius(self.raw, self.resolution)
@property
def user_byte1_signed(self) -> int:
"""Пользовательский байт 1 как знаковое значение TH в °C."""
return self.user_byte1 - 256 if self.user_byte1 > 127 else self.user_byte1
@property
def user_byte2_signed(self) -> int:
"""Пользовательский байт 2 как знаковое значение TL в °C."""
return self.user_byte2 - 256 if self.user_byte2 > 127 else self.user_byte2
def status_text(self) -> str:
"""@brief Собирает читаемое описание битов статуса.
@return Перечисление активных признаков через запятую.
"""
if self.status & SensorStatus.NO_PRESENCE:
parts = ["нет ответа"]
elif self.status & SensorStatus.CRC_ERROR:
parts = ["ошибка CRC8"]
elif self.is_valid:
parts = ["норма"]
else:
parts = ["нет данных"]
if self.status & SensorStatus.PARASITE_POWER:
parts.append("паразитное питание")
return ", ".join(parts)
def decode_sensor_list(payload: bytes) -> list[bytes]:
"""@brief Разбирает ответ ``SENSOR_LIST`` в список ROM.
@param payload Полезная нагрузка кадра без заголовка.
@return Список 8-байтовых идентификаторов в порядке обхода шины.
@raises ProtocolError При неверной длине или несогласованном счётчике.
"""
if not payload:
raise ProtocolError("SENSOR_LIST не содержит счётчик датчиков")
count = payload[0]
if len(payload) != 1 + count * ROM_SIZE:
raise ProtocolError("SENSOR_LIST содержит неверное число ROM")
return [
payload[1 + index * ROM_SIZE : 1 + (index + 1) * ROM_SIZE]
for index in range(count)
]
def decode_sensor_data(payload: bytes) -> list[SensorReading]:
"""@brief Разбирает ответ или push-кадр ``SENSOR_DATA``.
@param payload Полезная нагрузка кадра без заголовка.
@return Список измерений в том же порядке, что и ``SENSOR_LIST``.
@raises ProtocolError При неверной длине записи или счётчике.
"""
if not payload:
raise ProtocolError("SENSOR_DATA не содержит счётчик датчиков")
count = payload[0]
if len(payload) != 1 + count * SENSOR_RECORD_SIZE:
raise ProtocolError("SENSOR_DATA содержит повреждённые записи")
readings: list[SensorReading] = []
for index in range(count):
base = 1 + index * SENSOR_RECORD_SIZE
record = payload[base : base + SENSOR_RECORD_SIZE]
readings.append(
SensorReading(
rom=record[0:ROM_SIZE],
raw=int.from_bytes(record[8:10], "little", signed=True),
user_byte1=record[10],
user_byte2=record[11],
config=record[12],
status=record[13],
)
)
return readings
def encode_sensor_reading(reading: SensorReading) -> bytes:
"""@brief Кодирует запись обратно в wire-формат.
Общая реализация формата записи для тестов и mock-источника исключает
расхождение кодера и декодера.
@param reading Проверенное измерение.
@return Ровно ``SENSOR_RECORD_SIZE`` байт.
"""
return (
reading.rom
+ int(reading.raw).to_bytes(2, "little", signed=True)
+ bytes(
(
reading.user_byte1 & 0xFF,
reading.user_byte2 & 0xFF,
reading.config & 0xFF,
reading.status & 0xFF,
)
)
)
def encode_sensor_data(readings: list[SensorReading]) -> bytes:
"""@brief Собирает payload ``SENSOR_DATA`` из списка измерений.
@param readings Не более ``MAX_SENSORS`` записей.
@return Счётчик и последовательность записей.
@raises ProtocolError Если записей больше, чем помещается в кадр.
"""
if len(readings) > MAX_SENSORS:
raise ProtocolError(f"На шину рассчитано не более {MAX_SENSORS} датчиков")
return bytes((len(readings),)) + b"".join(
encode_sensor_reading(reading) for reading in readings
)
def encode_user_bytes(
rom: bytes, user_byte1: int, user_byte2: int, save_to_eeprom: bool = False
) -> bytes:
"""@brief Формирует payload ``SET_USER_BYTES``.
@param rom Идентификатор целевого датчика.
@param user_byte1 Значение TH в диапазоне -128..255.
@param user_byte2 Значение TL в диапазоне -128..255.
@param save_to_eeprom Признак выполнения ``COPY SCRATCHPAD``.
@return Одиннадцать байт запроса.
@raises ProtocolError При недопустимом ROM или значениях байтов.
"""
_validate_rom(rom)
return rom + bytes(
(
_validate_byte(user_byte1, "user byte 1"),
_validate_byte(user_byte2, "user byte 2"),
0x01 if save_to_eeprom else 0x00,
)
)
def encode_resolution(rom: bytes, bits: int, save_to_eeprom: bool = False) -> bytes:
"""@brief Формирует payload ``SET_RESOLUTION``.
@param rom Идентификатор целевого датчика.
@param bits Разрешение 9..12 бит.
@param save_to_eeprom Признак выполнения ``COPY SCRATCHPAD``.
@return Десять байт запроса.
@raises ProtocolError При недопустимом ROM или разрешении.
"""
_validate_rom(rom)
if not MIN_RESOLUTION_BITS <= bits <= MAX_RESOLUTION_BITS:
raise ProtocolError("Разрешение DS18B20 задаётся в диапазоне 9..12 бит")
return rom + bytes((bits, 0x01 if save_to_eeprom else 0x00))
def encode_poll_period(period_ms: int) -> bytes:
"""@brief Формирует payload ``SET_POLL_PERIOD``.
@param period_ms Период автоматической выдачи, 0 отключает push.
@return Два байта в little-endian.
@raises ProtocolError При выходе периода за 16 бит.
"""
if not 0 <= period_ms <= 0xFFFF:
raise ProtocolError("Период опроса задаётся в диапазоне 0..65535 мс")
return period_ms.to_bytes(2, "little")
def decode_bus_info(payload: bytes) -> dict[str, int | str]:
"""@brief Извлекает хвост ``DEVICE_INFO`` с полями шины 1-Wire.
Первые 16 байт совпадают с общим форматом транспорта, поэтому разбор
хвоста не мешает существующему декодеру устройства.
@param payload Полезная нагрузка ``DEVICE_INFO`` не короче 24 байт.
@return Словарь с числом датчиков, разрешением и периодом выдачи.
@raises ProtocolError При слишком короткой полезной нагрузке.
"""
if len(payload) < 24:
raise ProtocolError("DEVICE_INFO не содержит блок 1-Wire")
info: dict[str, int | str] = {
"sensor_count": payload[16],
"max_sensors": payload[17],
"default_resolution": payload[18],
"bus_flags": payload[19],
"poll_period_ms": int.from_bytes(payload[20:22], "little"),
}
name = payload[24:].split(b"\x00")[0].decode("ascii", "replace").strip()
if name:
info["product"] = name
return info
def _validate_rom(rom: bytes) -> None:
"""Проверяет длину ROM до формирования запроса."""
if len(rom) != ROM_SIZE:
raise ProtocolError("ROM должен содержать ровно 8 байт")
def _validate_byte(value: int, title: str) -> int:
"""Приводит знаковое или беззнаковое значение к одному байту."""
if not -128 <= value <= 255:
raise ProtocolError(f"{title} вне диапазона -128..255")
return value & 0xFF
class MockSensorBus:
"""@brief Детерминированный источник трёх датчиков для mock-режима.
Класс не зависит от Qt: планировщик передаёт время, а объект отвечает за
модель шины, пользовательские байты и разрешение каждого датчика.
"""
ROMS = (
bytes((0x28, 0xFF, 0x64, 0x1E, 0x0C, 0x3D, 0x2A, 0x00)),
bytes((0x28, 0x1A, 0x77, 0x91, 0x05, 0x00, 0x00, 0x00)),
bytes((0x28, 0x0B, 0xC2, 0x54, 0x0A, 0x11, 0x03, 0x00)),
)
BASE_TEMPERATURES = (24.5, 36.6, -12.0)
def __init__(self) -> None:
self._sensors = [
SensorReading(
rom=self._with_crc(rom),
raw=int(round(base / RAW_STEP_C)),
user_byte1=0x4B,
user_byte2=0xC9,
config=config_from_resolution(12),
status=int(SensorStatus.VALID),
)
for rom, base in zip(self.ROMS, self.BASE_TEMPERATURES)
]
@staticmethod
def _with_crc(rom: bytes) -> bytes:
"""Дописывает корректный CRC8, чтобы ROM проходил проверку GUI."""
return rom[:7] + bytes((crc8_maxim(rom[:7]),))
def roms(self) -> list[bytes]:
"""Возвращает список ROM в порядке обхода шины."""
return [sensor.rom for sensor in self._sensors]
def snapshot(self, timestamp_s: float) -> list[SensorReading]:
"""@brief Считает очередной набор измерений без побочных эффектов.
@param timestamp_s Монотонное время в секундах от старта mock-порта.
@return Список измерений с плавным дрейфом температуры.
"""
readings: list[SensorReading] = []
for index, sensor in enumerate(self._sensors):
drift = math.sin(timestamp_s / (4.0 + index) + index) * (0.5 + index * 0.25)
base = self.BASE_TEMPERATURES[index] + drift
step = 1 << (MAX_RESOLUTION_BITS - sensor.resolution)
raw = int(round(base / RAW_STEP_C / step)) * step
readings.append(replace(sensor, raw=raw))
return readings
def set_user_bytes(self, rom: bytes, user_byte1: int, user_byte2: int) -> None:
"""@brief Применяет пользовательские байты к mock-датчику.
@param rom Идентификатор датчика на mock-шине.
@param user_byte1 Новое значение TH.
@param user_byte2 Новое значение TL.
@raises KeyError Если ROM отсутствует на mock-шине.
"""
index = self._index_of(rom)
self._sensors[index] = replace(
self._sensors[index],
user_byte1=_validate_byte(user_byte1, "user byte 1"),
user_byte2=_validate_byte(user_byte2, "user byte 2"),
)
def set_resolution(self, rom: bytes, bits: int) -> None:
"""@brief Меняет разрешение mock-датчика.
@param rom Идентификатор датчика на mock-шине.
@param bits Разрешение 9..12 бит.
@raises KeyError Если ROM отсутствует на mock-шине.
"""
index = self._index_of(rom)
self._sensors[index] = replace(
self._sensors[index], config=config_from_resolution(bits)
)
def _index_of(self, rom: bytes) -> int:
"""Ищет датчик по ROM и сообщает об отсутствии через KeyError."""
for index, sensor in enumerate(self._sensors):
if sensor.rom == rom:
return index
raise KeyError(f"Датчик {format_rom(rom)} отсутствует на mock-шине")