449 lines
19 KiB
Python
449 lines
19 KiB
Python
"""@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-шине")
|