Как стать автором
Обновить
2068.85
Timeweb Cloud
Облако для бизнеса

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

Время на прочтение11 мин
Количество просмотров3.9K
.NET nanoFramework Weatherstation

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

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

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

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


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

Ресурсы



Теги:
Хабы:
Всего голосов 9: ↑8 и ↓1+7
Комментарии12

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud