Часто SDET-инженеры, работающие со встраиваемыми системами, не приступают к работе, пока не получат реальное железо: датчик, микроконтроллер или плату с новым чипом. Такой подход обычно оправдывают тем, что без физического девайса «на столе» писать корректно работающий софт невозможно. Очевидный минус: увеличивается время выхода продукта и нового функционала на рынок. Но разработку можно начать без устройства: все дело в договоренности между командами.
Меня зовут Рустам Ахмадуллин, я старший инженер по системной верификации аппаратуры в YADRO. Расскажу на примере датчика температуры LM75A, как написать API без физического доступа к устройству и его прошивке. Разберем методологию Test Driven Development, при которой разработка начинается с написания автоматизированных тестов, а не самого кода.

Как начать разработку без железа
Для начала посмотрим, как передаются данные: обычно это байты, которые интерпретируются в зависимости от протокола. Предлагаю обратиться к прикладному уровню, где бегают определенные метрики для конкретного устройства. Их можно обернуть в структуры, то есть договориться об интерфейсе.
Поэтому первый шаг к разработке — это описание интерфейса. Затем перед нами встает более сложная задача: поддержка нужного интерфейса. Нам помогут юнит-тесты: методов и функций определенного модуля приложения. Также нам пригодятся интеграционные тесты, которые проверят, что поведение наших интерфейсов сохранится в рамках системы.
Рассмотрим пошагово данные тезисы и отработаем их на примере устройства датчика температуры LM75A:
Фиксируем интерфейсы взаимодействия, чтобы команды разработки занимались реализацией API на своих уровнях.
Проводим юнит-тестирование интерфейсов.
Тестируем API с виртуальными интерфейсами.
Работаем с реальным устройством.
Если вам нравится тема эмуляторов и тестов с ними — ждем вас в отделе разработки встраиваемого ПО:
Фиксируем интерфейсы взаимодействия
В нашем примере есть даташит, поэтому будем отталкиваться от него. Но в командах можно фиксировать интерфейсы в виде некоторых словарей, структур или других типов данных в рамках рабочих протоколов. Главное — высокоуровнево договориться и следовать предложенным конвенциям.
Чтобы получить API для LM75A, можно использовать данные из даташита и получить все методы, но мы в методических целях посмотрим только на чтение температуры.
В даташите есть кусок с формулой преобразования байтовых данных в температуру. В представлении на Python функция чтения температуры будет выглядеть так:
def read_temperature(self): """Чтение температуры (11-бит, 2's complement, 0.125°C)""" data = self._read_register(self.REG_TEMP, 2) if not data: return None temperature = self.convert_from_bytes(data) return temperature @staticmethod def convert_from_bytes(data: bytes | list) -> float: raw = (data[0] << 8) | data[1] temp_raw = raw >> 5 if temp_raw & 0x400: temp_raw -= 0x800 temperature = temp_raw * 0.125 logger.debug(f"{data=}, {raw=}, {temp_raw=}, {temperature=}") return temperature
Важно, что методы парсинга сырых данных лучше определять в отдельные сущности. Проще будет протестировать уровни API, так как разложение байтовых данных в представление определенных метрик (в нашем примере — температура) — это важная часть приложения, а значит требует повторяемости и контроля со стороны юнит-тестов.
Полный пример реализации класса — под спойлером ниже.
Код класса
import numpy as np import smbus2 from loguru import logger class LM75A: # I2C адрес по умолчанию (A2, A1, A0 = GND → 0x48) # Адрес зависит от подключения: 0x48, 0x49, ..., 0x4F (всего 8 устройств) DEFAULT_ADDRESS = 0x48 # Регистры (по документации) REG_TEMP = 0x00 # Температура (только чтение) REG_CONF = 0x01 # Конфигурация REG_TOS = 0x03 # Порог перегрева (Tos) REG_THYST = 0x02 # Гистерезис (Thyst) # Биты в регистре конфигурации SHUTDOWN = 0x01 # Бит 0: 1 = Shutdown, 0 = Normal OS_COMP_INT = 0x02 # Бит 1: 1 = Interrupt, 0 = Comparator OS_POL = 0x04 # Бит 2: 1 = Active HIGH, 0 = Active LOW OS_F_QUE = 0x18 # Биты 3-4: Fault queue (00=1, 01=2, 10=4, 11=6) def __init__(self, address=DEFAULT_ADDRESS, bus_number=1): self.address = address self.bus_number = bus_number def _write_register(self, reg, value): """Запись одного байта в регистр""" try: with smbus2.SMBus(self.bus_number) as bus: bus.write_i2c_block_data(self.address, reg, value) except Exception as e: logger.error(f"Ошибка записи в регистр {reg}: {e}") def _read_register(self, reg, num_bytes=1): """Чтение байтов из регистра""" try: with smbus2.SMBus(self.bus_number) as bus: return bus.read_i2c_block_data(self.address, reg, num_bytes) except Exception as e: logger.error(f"Ошибка чтения из регистра {reg}: {e}") return None def read_temperature(self): """Чтение температуры (11-бит, 2's complement, 0.125°C)""" data = self._read_register(self.REG_TEMP, 2) if not data: return None temperature = self.convert_from_bytes(data) return temperature def write_temperature(self, temperature): """ Метод для эмулятора, запись в i2c stub """ data = self.convert_to_bytes(temperature) self._write_register(self.REG_TEMP, data) @staticmethod def convert_to_bytes(temperature: float) -> bytes: temp_raw = temperature / 0.125 raw = int(temp_raw) << 5 data = np.int16(raw).tobytes()[::-1] logger.debug(f"{data=}, {raw=}, {temp_raw=}, {temperature=}") return data @staticmethod def convert_from_bytes(data: bytes | list) -> float: raw = (data[0] << 8) | data[1] temp_raw = raw >> 5 if temp_raw & 0x400: temp_raw -= 0x800 temperature = temp_raw * 0.125 logger.debug(f"{data=}, {raw=}, {temp_raw=}, {temperature=}") return temperature
Настройки проекта
ОС: ubuntu 24.04,
используем пакетный менеджер uv,
упрощаем жизнь скриптами на базе poethepoet,
тесты пишем на pytest.
Проект с примерами на github.
Подробнее об удобстве и особенностях UV можно прочитать в этих статьях:
Проводим юнит-тестирование интерфейсов API
Пишем юнит-тесты для таблицы:
11-bit binary (2's complement) | Hexadecimal value | Decimal value | Value |
|---|---|---|---|
011 1111 1000 | 3F8 | 1016 | +127.000 °C |
011 1111 0111 | 3F7 | 1015 | +126.875 °C |
011 1111 0001 | 3F1 | 1009 | +126.125 °C |
011 1110 1000 | 3E8 | 1000 | +125.000 °C |
000 1100 1000 | 0C8 | 200 | +25.000 °C |
000 0000 0001 | 001 | 1 | +0.125 °C |
000 0000 0000 | 000 | 0 | 0.000 °C |
111 1111 1111 | 7FF | -1 | -0.125 °C |
111 0011 1000 | 738 | -200 | -25.000 °C |
110 0100 1001 | 649 | -439 | -54.875 °C |
110 0100 1000 | 648 | -440 | -55.000 °C |
Таблица 10. Значение регистра температуры из даташита к датчику температуры LM75A
В этой таблице приведены уже конвертированные данные, но для тестов их нужно преобразовать:
(127.0, [0x7f, 0x00]), (25.0, [0x19, 0x00]), (0, [0x00, 0x00]), (0.125, [0x00, 0x20]), (-0.125, [0xFF, 0xE0]), (-25.0, [0xE7, 0x00]), (-55.0, [0xC9, 0x00]),
В первую очередь напишем юнит-тест для конвертера из байт в вещественное число:
from demo_lm75.lm75 import LM75A def test_converter_25(): assert LM75A.convert_to_bytes(25.0) == b'\x19\x00' assert LM75A.convert_from_bytes(b'\x19\x00') == 25.0 def test_converter_n25(): assert LM75A.convert_to_bytes(-25.0) == b'\xe7\x00' assert LM75A.convert_from_bytes(b'\xe7\x00') == -25.0 def test_converter_0(): assert LM75A.convert_to_bytes(0.0) == b'\x00\x00' assert LM75A.convert_from_bytes(b'\x00\x00') == 0.0
Запускаем, как обычно, на pytest:
(demo-lm75) rustam:~/python-progs/demo-lm75$ pytest -v tests/test_converter.py ================================================================== test session starts ================================================================== platform linux -- Python 3.12.10, pytest-8.4.2, pluggy-1.6.0 -- /home/rustam/python-progs/demo-lm75/.venv/bin/python3 cachedir: .pytest_cache rootdir: /home/rustam/python-progs/demo-lm75 configfile: pyproject.toml plugins: mock-3.15.0 collected 3 items tests/test_converter.py::test_converter_25 PASSED [ 33%] tests/test_converter.py::test_converter_n25 PASSED [ 66%] tests/test_converter.py::test_converter_0 PASSED [100%] =================================================================== 3 passed in 0.14s ===================================================================
Следующий уровень — это мок-функции записи в I2C-устройство:
import pytest from demo_lm75.lm75 import LM75A @pytest.fixture def lm75a(mocker): """Фикстура: создание экземпляра LM75A с подменой SMBus""" address = 0x48 lm75a = LM75A(address=address, bus_number=1) return lm75a def get_data(): return [ (127.0, [0x7f, 0x00]), (25.0, [0x19, 0x00]), (0, [0x00, 0x00]), (0.125, [0x00, 0x20]), (-0.125, [0xFF, 0xE0]), (-25.0, [0xE7, 0x00]), (-55.0, [0xC9, 0x00]), ] @pytest.mark.parametrize('temp,vals', get_data()) def test_read_temperature(mocker, lm75a, temp, vals): """Тест: чтение положительной температуры""" mocker.patch.object( lm75a, '_read_register', return_value=vals, ) temp_ret = lm75a.read_temperature() assert temp_ret == temp
На этом этапе мы получили покрытие тестами для основных методов общения с платой на базе LM75A.
Тестируем API с виртуальными интерфейсами
Можно сказать, что юнит-тестирования достаточно для реализации API, но стоит проработать интеграционный тест для большего приближения работы с устройством на функциональном уровне. Эту задачу помогают решить виртуальные интерфейсы. К счастью, средствами ОС на базе Linux легко этого добиться. Рассмотрим пример для I2C.
Небольшое введение в виртуальный I2C-стаб
Воспользуемся возможностью i2c-tools создавать виртуальный стаб для I2C:
sudo modprobe i2c-dev sudo modprobe i2c-stub chip_addr=0x08 i2cdetect -l
Далее конфигурируем устройство с нужным нам адресом:
i2c-stub-from-dump 0x48 tools/setup_stub/chip_zeros.dump
tools/setup_stub/chip_zeros.dump — это текстовый дамп с нулями, в общем случае может иметь любое содержимое и изначально эмулировать поведение устройства.
На выходе на виртуальной шине будет устройство. В него можно писать и считывать данные:
i2cdump -y 16 0x48 No size specified (using byte-data access) 0 1 2 3 4 5 6 7 8 9 a b c d e f 0123456789abcdef 00: 19 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ?............... 10: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 20: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 30: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 40: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 50: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 60: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 70: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ 90: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ e0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................ f0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
Мы можем, к примеру, записать в область памяти данные и считать их из скрипта как из реального устройства.
Пример кода записи данных от эмулятора:
import time from demo_lm75.lm75 import LM75A from loguru import logger import numpy as np def main(): """ Пример для записи в i2c stub, после поднятия виртуальной шины .. code-block:: poe activate-emulate """ dev = LM75A(address=0x48, bus_number=16) for i in range(30): emulatetemp = np.random.randint(10, 110) logger.info(f"{emulate_temp=}") dev.write_temperature(temperature=emulate_temp) time.sleep(1) if name == "__main__": main()
Запускаем модель в терминале:
(demo-lm75) rustam:~/python-progs/demo-lm75$ python examples/emulator_lm75.py 2026-01-22 12:41:09.905 | INFO | __main__:main:21 - emulate_temp=13 2026-01-22 12:41:09.905 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'\r\x00', raw=3328, temp_raw=104.0, temperature=13 2026-01-22 12:41:10.905 | INFO | __main__:main:21 - emulate_temp=16 2026-01-22 12:41:10.906 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'\x10\x00', raw=4096, temp_raw=128.0, temperature=16 2026-01-22 12:41:11.907 | INFO | __main__:main:21 - emulate_temp=42 2026-01-22 12:41:11.907 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'*\x00', raw=10752, temp_raw=336.0, temperature=42 2026-01-22 12:41:12.908 | INFO | __main__:main:21 - emulate_temp=42 2026-01-22 12:41:12.908 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'*\x00', raw=10752, temp_raw=336.0, temperature=42 2026-01-22 12:41:13.908 | INFO | __main__:main:21 - emulate_temp=100 2026-01-22 12:41:13.909 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'd\x00', raw=25600, temp_raw=800.0, temperature=100 2026-01-22 12:41:14.909 | INFO | __main__:main:21 - emulate_temp=58 2026-01-22 12:41:14.910 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b':\x00', raw=14848, temp_raw=464.0, temperature=58 2026-01-22 12:41:15.910 | INFO | __main__:main:21 - emulate_temp=99 2026-01-22 12:41:15.911 | DEBUG | demo_lm75.lm75:convert_to_bytes:65 - data=b'c\x00', raw=25344, temp_raw=792.0, temperature=99
Код для чтения температуры:
import time from demo_lm75.lm75 import LM75A from loguru import logger def main(): dev = LM75A(address=0x48, bus_number=16) for _i in range(30): logger.info(f"{dev.read_temperature()=}") time.sleep(1) if __name__ == "__main__": main()
В другом окне терминала можно запустить боевой код:
(demo-lm75) rustam:~/python-progs/demo-lm75$ python examples/ex_lm75.py 2026-01-22 12:44:57.588 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[47, 0], raw=12032, temp_raw=376, temperature=47.0 2026-01-22 12:44:57.588 | INFO | __main__:main:10 - dev.read_temperature()=47.0 2026-01-22 12:44:58.588 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[70, 0], raw=17920, temp_raw=560, temperature=70.0 2026-01-22 12:44:58.589 | INFO | __main__:main:10 - dev.read_temperature()=70.0 2026-01-22 12:44:59.589 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[69, 0], raw=17664, temp_raw=552, temperature=69.0 2026-01-22 12:44:59.589 | INFO | __main__:main:10 - dev.read_temperature()=69.0 2026-01-22 12:45:00.590 | DEBUG | demo_lm75.lm75:convert_from_bytes:76 - data=[46, 0], raw=11776, temp_raw=368, temperature=46.0
Пример тестов на pytest с использованием рассмотренного I2C-стаба:
import pytest import subprocess from demo_lm75.lm75 import LM75A @pytest.fixture(scope='session') def create_stub(): subprocess.run("poe activate-emulate".split()) dev = LM75A(address=0x48, bus_number=16) emulator = LM75A(address=0x48, bus_number=16) yield dev, emulator subprocess.run("poe deactivate-emulate".split()) @pytest.mark.parametrize("val", [0.0, -25.0, 25.0, 127.0]) def test_write(create_stub, val): dev, emulator = create_stub emulator.write_temperature(val) temp = dev.read_temperature() assert temp == val
Мы получили эмулятор устройства и возможность отлаживать сценарии взаимодействия с ним в рамках его интерфейсов. Отмечу, что мы не эмулируем устройство полностью, а только взаимодействие по шине I2C.
Работаем с реальным устройством
Наконец, к нам приехал заказанный датчик — в вашем случае это может быть плата, заказанная из Китая. Теперь все программно-аппаратные компоненты у нас «на столе» и мы можем запустить пример для взаимодействия с устройством:
import time from demo_lm75.lm75 import LM75A from loguru import logger def main(): dev = LM75A(address=0x48, bus_number=16) for _i in range(30): logger.info(f"{dev.read_temperature()=}") time.sleep(1) if __name__ == "__main__": main()
Убеждаемся, что с реальной железкой мы получили корректные данные.
Выводы
Использование эмуляции и интеграционного/юнит-тестирования позволяет с минимальной отладкой получить полностью рабочий API без реальной железки на столе. При этом юнит-тесты помогают вносить атомарные и контролируемые изменения в ходе командной разработки, что заметно снижает риск случайных ошибок.
Так мы улучшаем взаимодействие между командами тестирования и разработки за счет общего понимания, какие именно данные и метрики циркулируют в интерфейсах устройства.
Если у вас есть вопросы — пишите в комментариях, с удовольствием пообщаюсь.