Сегодня сломаем привычный мир инженеров и разработчиков встраиваемых систем на микроконтроллерах. В .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