Files
templates/python/set_devices/ds18b20.py

449 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""@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-шине")