Паттерн внедрение зависимостей в .NET nanoFramework для микроконтроллеров

Сегодня сломаем привычный мир инженеров и разработчиков встраиваемых систем на микроконтроллерах. В .NET существует замечательный паттерн программирования, как внедрение зависимостей (Dependency injection, DI). Суть паттерна заключается в предоставление механизма, который позволяет сделать взаимодействующие в приложение объекты слабосвязанными. Эти объекты будут связаны между собой через абстракции, например, через интерфейсы, что делает всю систему более гибкой, более адаптируемой и расширяемой. Но когда ведется разработка для микроконтроллеров, все зависимости обычно жестко завязаны на используемых устройствах, и замена датчика иногда приводит к существенному переписыванию программного кода. Напишем приложение на .NET nanoFramework для микроконтроллера ESP32, используя паттерн DI с возможностью легкой замены датчиков и LCD экрана.

Данный материал был подготовлен ​​при финансовой поддержке компании Timeweb Cloud в рамках публикации в блоге компании на Хабре. Timeweb Cloud — это облачная инфраструктура для бизнеса и разработки. Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud в Telegram-канале.

Паттерн внедрение зависимостей

Паттерн внедрение зависимостей в основном используют для разработки Веб-приложений на 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-сервером в сети Интернет.

Метеостанция состоит из следующих компонентов:

Работа устройства:

Схема подключения

Все датчики подключаются по шине 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.

Итоговая схема будет выглядеть следующим образом:

.NET nanoFramework Weatherstation
Принципиальная схема подключения устройств к 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.

.NET nanoFramework Weatherstation
Модуль 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 дисплей.

Файл MonitorService.cs
public void Start()
{
  ...
  _handlerThread = new Thread(() =>
  {
    while (!csToken.IsCancellationRequested)
    {
      //sensors
      sensorsResult = _sensorsService.GetSensorsResult();      
      //key
      key = _keyboardService.ReadKey();
      switch (key)
      {
        case 8:
          currentScreen = Screen.DateTime_1;
          break;
        ...
      }
      //screen
      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;
        ...
      }
      Thread.Sleep(500);
    }
  });
  _handlerThread.Start();
}
Используя интерфейс, получаем показания датчиков:

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-сервера времени можно выставить любой.

Файл ConnectionService.cs
protected override void ExecuteAsync()
{
  //connecting to WiFi
  const string Ssid = "ssid";
  const string Password = "password";
  // Give 60 seconds to the wifi join to happen
  CancellationTokenSource cs = new(60000);
  bool flag = false;
  while (!flag)
  {
    var success = WifiNetworkHelper.ConnectDhcp(Ssid, Password, System.Device.Wifi.WifiReconnectionKind.Manual, requiresDateTime: false, token: cs.Token);
    if (!success)
    {
      // Something went wrong, you can get details with the ConnectionError property:
      Debug.WriteLine($"Can't connect to the network, error: {WifiNetworkHelper.Status}");
      if (WifiNetworkHelper.HelperException != null)
        Debug.WriteLine($"ex: {WifiNetworkHelper.HelperException}");
    }
    else
    {
      Debug.WriteLine($"success");
      flag = true;
    }
  }
  //time synchronization           
  Sntp.Server1 = "0.fr.pool.ntp.org";
  Sntp.UpdateNow();
  Debug.WriteLine($"Now: {DateTime.UtcNow}");
}

 

Исходный код приложения: 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

Ресурсы

Вам также может понравиться

About the Author: Anton

Programistik