"""@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-шине")