Сегодня сломаем привычный мир инженеров и разработчиков встраиваемых систем на микроконтроллерах. В .NET существует замечательный паттерн программирования, как внедрение зависимостей (Dependency injection, DI). Суть паттерна заключается в предоставление механизма, который позволяет сделать взаимодействующие в приложение объекты слабосвязанными. Эти объекты будут связаны между собой через абстракции, например, через интерфейсы, что делает всю систему более гибкой, более адаптируемой и расширяемой. Но когда ведется разработка для микроконтроллеров, все зависимости обычно жестко завязаны на используемых устройствах, и замена датчика иногда приводит к существенному переписыванию программного кода. Напишем приложение на .NET nanoFramework для микроконтроллера ESP32, используя паттерн DI с возможностью легкой замены датчиков и LCD экрана.
Паттерн внедрение зависимостей
Паттерн внедрение зависимостей в основном используют для разработки Веб-приложений на ASP.NET. На самой концепцией паттерна не будем останавливаться, многие .NET разработчики хорошо знакомы с данным паттерном, более подробно можно почитать статью Сервисы и Dependency Injection на metanit.
Библиотека DI для nanoFramework предоставляется в виде nuget-пакета nanoFramework.DependencyInjection. Контейнер DI автоматизирует многие задачи, связывает объекты, управляет жизненным циклом приложения. API библиотеки максимально приближен к официальному .NET Dependency Injection. Исключения в основном возникают из-за отсутствия поддержки дженериков в .NET nanoFramework.
Для создания контейнера DI необходимо три основных компонента:
- Композиция объектов (Object Composition) — композиция объектов, определяющий набор объектов для создания и сопряжения;
- Регистрация сервисов (Registering Services) — необходимо определить экземпляр ServiceCollection и зарегистрировать в нем объекты с определенным временем жизни;
- Поставщик услуг (Service Provider) — создание поставщика услуг для извлечения объекта.
DI был бы неполным без Generic Host (общий хост). В nanoFramework доступен в виде nuget-пакета nanoFramework.Hosting. Generic Host конфигурирует контейнер приложения DI, а также предоставляет доступ к сервисам в контейнере DI и управляет жизненным циклом. Когда запускается Host , то вызывается Start() для каждой реализации IHostedService , которые зарегистрированы в коллекции сервисов хоста. В контейнере приложения для всех объектов IHostedService , таких как BackgroundService или SchedulerService , вызывается метод ExecuteAsync() . API библиотеки максимально приближен к официальному .NET Generic Host. Рассмотрим на практике применение паттерна DI.
Архитектура
В качестве примера применения DI, разработаем небольшое устройство метеостанцию. Принцип работы устройства достаточно прост. Пользователь взаимодействует с устройством путем нажатия на кнопки переключения состояний экрана. Всего доступно три состояния экрана для отображения данных:
- Текущая дата и время;
- Температура и влажность;
- Давление.
Таким образом, пользователь может узнать текущее время и температуру окружающей среды. Время на устройстве автоматически синхронизируется с NTP-сервером в сети Интернет.
Метеостанция состоит из следующих компонентов:
- Плата ESP32 DevKit v1 на базе микроконтроллера ESP-WROOM-32;
- Цветной дисплей на контроллере SSD1306 подключали в посте Что нового в .NET nanoFramework? Подключаем LCD экран, сканируем Wi-Fi сети;
- Датчик BME280 для измерения атмосферного давления, температуры и влажности подключали в посте Программируем микроконтроллеры ESP32 и STM32 на C# (nanoFramework);
- Емкостная панель клавиатуры на базе датчика MPR121.
Работа устройства:
Схема подключения
Все датчики подключаются по шине I2C. Единственно, был использован LCD SSD1306 c 7-pin контактами для которого требуется дополнительно подключать контакт GPIO для инициализации. В случае использования 4-pin дисплея этого не требуется делать.
Устройства для подключения:
- Датчик BME280, шина I2C, контакты: 21-pin DATA, 22-pin CLOCK;
- Экран SSD1306 OLED с 7-pin I2C/SPI, шина I2C, контакты: 21-pin DATA, 22-pin CLOCK, 18-pin RES для инициализации;
- Емкостная панель клавиатуры на базе датчика MPR121, шина I2C, контакты: 21-pin DATA, 22-pin.
Итоговая схема будет выглядеть следующим образом:

Принципиальная схема подключения устройств к ESP32 DevKit v1 (fzz)
Приложение
Взаимодействие с датчиками реализовано посредством интерфейсов, которые позволяют легко заменить физический датчик на любой другой, включая виртуальный. Виртуальные датчики очень удобны для процедуры тестирования приложения, когда нет доступа к физическим датчикам. Основная логика приложения максимально абстрактна от механизма работы датчиков. Так в приложение реализуются следующие интерфейсы:
- ISensorsService — получение данных о состоянии окружающей среды;
- IKeyboardService — получение номера кнопки;
- IDisplayService — отображение информации.
Интерфейсы
ISensorsService
В интерфейсе ISensorsService декларирована только одна функция получения данных с датчика.
public interface ISensorsService
{
public SensorsResult GetSensorsResult();
}
public class SensorsResult
{
public SensorsResult() { }
public SensorsResult(double temperature, double pressure, double humidity)
{
Temperature = temperature;
Pressure = pressure;
Humidity = humidity;
}
public double Temperature { get; }
public double Pressure { get; }
public double Humidity { get; }
}
Как видно из примера, отсутствует жесткая привязка к внутренней реализации работы датчика.
IKeyboardService
Задача интерфейса IKeyboardService заключается в получение кода кнопки. Каким образом будет реализована клавиатура абсолютно неважно, это может быть и обычная кнопочная клавиатура, подключаемая к аналоговому контакту, например клавиатура Analog ADKeyboard Module.

Модуль Analog ADKeyboard Module
Просто считываем код кнопки, который привязан к вариантам состояния экрана.
public interface IKeyboardService
{
public int ReadKey();
}
IDisplayService
Для интерфейса дисплея IDisplayService передается тип экрана и объект содержащий данные для отображения.
public interface IDisplayService
{
public void Show(Screen screen, object obj);
}
public enum Screen
{
Clear_0,
DateTime_1,
TempHum_2,
Pressure_3,
}
Датчики
Рассмотрим клавиатуру на базе датчика Mpr121. Класс KeyboardSingleton наследуется от интерфейсов IKeyboardService и IDisposable . Содержит функцию инициализации и функцию ReadKey() которая объявлена в интерфейсе IKeyboardService .
Файл KeyboardSingleton.cs:
internal class KeyboardSingleton : IKeyboardService, IDisposable
{
private const int busId = 1; // bus id on the MCU
private I2cDevice _i2cDevice;
private Mpr121 _mpr121;
public KeyboardSingleton()
{
this.InitMpr121();
}
private void InitMpr121()
{
Debug.WriteLine("Init InitMpr121!");
I2cConnectionSettings i2cSettings = new(busId, Mpr121.DefaultI2cAddress);
_i2cDevice = I2cDevice.Create(i2cSettings);
_mpr121 = new Mpr121(_i2cDevice);
}
public int ReadKey()
{
bool[] channelStatuses = _mpr121.ReadChannelStatuses();
int key = -1;
for (int i = 0; i < channelStatuses.Length; i++)
{
if (channelStatuses[i])
{
key = i;
break;
}
}
return key;
}
}
Работа с другими устройствами, такими как клавиатура и дисплей организованна так же.
Сервисы IHostedService
Сервисы являются основными самостоятельными единицами приложения. Все сервисы добавляются в коллекцию сервисов ServiceCollection() . Host builder вызывает методы Start() и Stop() для соответствующих сервисов. Можно создать несколько реализаций IHostedService и зарегистрировать с помощью метода ConfigureService() в контейнере DI.
Пример класса сервиса:
public class CustomService : IHostedService
{
public void Start() { }
public void Stop() { }
}
Сервис MonitorService
Вся основная логика приложения размещается в сервисе MonitorService . Рассмотрим объявленные переменные в классе и конструктор класса.
Файл MonitorService.cs:
internal class MonitorService : IHostedService
{
private ISensorsService _sensorsService { get; }
private IDisplayService _displayService { get; set; }
private IKeyboardService _keyboardService { get; }
private Thread _handlerThread;
private CancellationTokenSource _cs;
public MonitorService(ISensorsService sensorsService, IDisplayService displayService, IKeyboardService keyboardService)
{
_sensorsService = sensorsService;
_displayService = displayService;
_keyboardService = keyboardService;
}
В конструкторе класса присутствует композиция объектов, с которыми доступно взаимодействие. Как видим, вместо класса KeyboardSingleton присутствует интерфейс IKeyboardService . Сам класс MonitorService не завязан на реализации конечных используемых датчиков, таким образом, реализуется концепция слабосвязанного приложения. Подобным образом строится взаимодействие с датчиками интерфейс ISensorsService , и LCD дисплеем интерфейс IDisplayService .
Далее метод Start() запускает поток, в задачу которого входит считывание состояния клавиатуры, показаний датчиков и отправка данных на LCD дисплей.
sensorsResult = _sensorsService.GetSensorsResult();
Далее, считываем код кнопки:
key = _keyboardService.ReadKey();
В зависимости от нажатой кнопки отправляем на интерфейс дисплея необходимые данные для отображения. Где Screen.DateTime_1 и Screen.TempHum_2 тип экрана для отображения, второй параметр тип object данные для отображения.
switch (currentScreen)
{
case Screen.DateTime_1:
DateTime currentDateTime = DateTime.UtcNow + TimeSpan.FromHours(3); // +3 GMT
_displayService.Show(Screen.DateTime_1, currentDateTime);
break;
case Screen.TempHum_2:
_displayService.Show(Screen.TempHum_2, sensorsResult);
break;
...
Для завершения работы сервиса вызывается метод Stop() .
public void Stop()
{
Debug.WriteLine("MonitorService stopped");
_cs.Cancel();
Thread.Sleep(2000);
if (_handlerThread.ThreadState == ThreadState.Running) _handlerThread.Abort();
}
Теперь перейдем к основному host builder .
Generic Host
Для создания хоста запускается построитель хоста (host builder), в задачу которого входит создание контейнера сервисов, т.е. создается ServiceProvider содержащий коллекцию сервисов.
Основная функция Main() , файл Program.cs:
public static void Main()
{
//////////////////////////////////////////////////////////////////////
// when connecting to an ESP32 device, need to configure the I2C GPIOs
Configuration.SetPinFunction(21, DeviceFunction.I2C1_DATA);
Configuration.SetPinFunction(22, DeviceFunction.I2C1_CLOCK);
//////////////////////////////////////////////////////////////////////
IHost host = CreateHostBuilder().Build();
// starts application and blocks the main calling thread
host.Run();
}
До использования датчиков на шине I2C необходимо объявить соответствующие контакты 21-pin и 22-pin.
Затем построитель хоста создает host и затем его запускаем методом Run() .
Регистрация сервисов выполняется в отдельной функции CreateHostBuilder() :
public static IHostBuilder CreateHostBuilder() =>
Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
//Receiving data from sensors
services.AddSingleton(typeof(ISensorsService), typeof(SensorsSingleton));
//Data output to the display
services.AddSingleton(typeof(IDisplayService), typeof(DisplaySingleton));
//Keyboard
services.AddSingleton(typeof(IKeyboardService), typeof(KeyboardSingleton));
//MonitorService
services.AddHostedService(typeof(MonitorService));
//Connecting to WiFi and time synchronization
services.AddHostedService(typeof(ConnectionService));
});
Необходимо обратить внимание, что жизненный цикл Singleton начинается только тогда, когда они вызывются из HostedService .
Последним из сервисов вызывается сервис подключения по беспроводному соединению Wi-Fi к сети Интернет для синхронизации времени. Рассмотрим его подробнее.
Классы BackgroundService и SchedulerService
Дополнительно в библиотеке есть классы SchedulerService и BackgroundService , образованные от IHostedService .
Класс SchedulerService
Данных класс предназначен для выполнения повторяющихся действий с заданным интервалом времени. Класс содержит объект Timer и запускает в указанное время с заданным интервалом асинхронный метод ExecuteAsync() . Таймер выключается вызовом метода Stop() .
Пример сервиса на базе класса SchedulerService:
public class DisplayService : SchedulerService
{
// represents a timer control that involks ExecuteAsync at a
// specified interval of time repeatedly
public DisplayService() : base(TimeSpan.FromSeconds(1)) {}
protected override void ExecuteAsync(object state)
{
}
}
Класс BackgroundService
Класс предназначен для выполнения долгоработающей фоновой задачи. Для запуска сервиса вызывается асинхронный метод ExecuteAsync() . Работа ExecuteAsync() должна завершиться сразу после вызова CancellationRequested для корректного завершения работы сервиса.
Пример сервиса на базе класса BackgroundService:
public class SensorService : BackgroundService
{
protected override void ExecuteAsync()
{
while (!CancellationRequested)
{
// to allow other threads time to process include
// at least one millsecond sleep in loop
Thread.Sleep(1);
}
}
}
На основе класса BackgroundService реазизована задача подключения к беспроводной сети Wi-Fi.
Сервис ConnectionService
В задачу сервиса входит подключение к беспроводной сети с последующей синхроизацией времени. После подключение к сети отправляется запрос точного времени на сервер «0.fr.pool.ntp.org», адрес NTP-сервера времени можно выставить любой.
Исходный код приложения: GitHub — nanoframework-esp32-di-weatherstation
Доработка библиотеки для дисплея SSD1306 OLED
В прошлый раз библиотека nanoFramework.Iot.Device.Ssd13xx была доработана для поддержки 7-pin контатного варианта дисплея, Pull requests #550. Но на этом работа с дисплеем оказалась не закончена. Во время написания приложения обнаружился неприятный эффект в виде большой паузы во время перерисовки экрана при выводе времени. Перерисовка экрана выполнялась 1 раз в секунду. Проблема заключалась в неверном подходе работы с дисплеем. Рассмотрим текущий алгоритм работы с дисплеем:
using Ssd1306 device = new Ssd1306(I2cDevice.Create(new I2cConnectionSettings(1, Ssd1306.SecondaryI2cAddress)), Ssd13xx.DisplayResolution.OLED128x64, 18); device.ClearScreen(); device.Font = new BasicFont(); device.DrawString(2, 2, "nF IOT!", 2);//large size 2 font device.DrawString(2, 32, "nanoFramework", 1, true);//centered text device.Display();
Метод ClearScreen() очищает экран от изображения. Далее методами DrawString() выполняется формирование изображения в буфере дисплея. Метод Display() формирует изображение на дисплее исходя из матрицы данных в буфере. Таким образом, видна очевидная проблема при повторной отрисовки изображения, которая заключается в том, что в интервал времени между вызовами методов ClearScreen() и Display() дисплей заполнен черным цветом.
Для решения этой проблемы в библиотеку необходимо добавить новый метод очистки буфера дисплея. В результате после вызова метода очистки буфера дисплея при формирования нового изображения, на дисплее будет отображаться текущее изображение. Далее при вызове Display() изображения отрисуется минуя стадию заполнения дисплея черным цветом, и мигание при отрисовке со стороны пользователя не будет видно.
Для упрощения реализации, в методе ClearScreen() , просто была закомментирована строка вызова метода Display() для перерисовки экрана дисплея.
Файл Ssd13xx.cs, метод ClearScreen() :
public void ClearScreen()
{
Array.Clear(_genericBuffer, 0, _genericBuffer.Length);
//---> Display();
}
Обсуждение на Habr.com