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

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

Как начать разработку без железа

Для начала посмотрим, как передаются данные: обычно это байты, которые интерпретируются в зависимости от протокола. Предлагаю обратиться к прикладному уровню, где бегают определенные метрики для конкретного устройства. Их можно обернуть в структуры, то есть договориться об интерфейсе. 

Поэтому первый шаг к разработке — это описание интерфейса. Затем перед нами встает более сложная задача: поддержка нужного интерфейса. Нам помогут юнит-тесты: методов и функций определенного модуля приложения. Также нам пригодятся интеграционные тесты, которые проверят, что поведение наших интерфейсов сохранится в рамках системы.

Рассмотрим пошагово данные тезисы и отработаем их на примере устройства датчика температуры LM75A: 

  1. Фиксируем интерфейсы взаимодействия, чтобы команды разработки занимались реализацией API на своих уровнях.

  2. Проводим юнит-тестирование интерфейсов.

  3. Тестируем API с виртуальными интерфейсами.

  4. Работаем с реальным устройством.

Если вам нравится тема эмуляторов и тестов с ними — ждем вас в отделе разработки встраиваемого ПО: 

Фиксируем интерфейсы взаимодействия

В нашем примере есть даташит, поэтому будем отталкиваться от него. Но в командах можно фиксировать интерфейсы в виде некоторых словарей, структур или других типов данных в рамках рабочих протоколов. Главное — высокоуровнево договориться и следовать предложенным конвенциям. 

Чтобы получить 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

Настройки проекта 

Проект с примерами на 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 без реальной железки на столе. При этом юнит-тесты помогают вносить атомарные и контролируемые изменения в ходе командной разработки, что заметно снижает риск случайных ошибок. 

Так мы улучшаем взаимодействие между командами тестирования и разработки за счет общего понимания, какие именно данные и метрики циркулируют в интерфейсах устройства.

Если у вас есть вопросы — пишите в комментариях, с удовольствием пообщаюсь.

Полезные ссылки