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