.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)


  1. ENGIN33RRR
    15.09.2022 15:44

    Да что-то вы замудрили. В таком небольшом проекте заменить дисплей можно и так без проблем. Обработка данных с датчиков это отдельные функции. А вот взять взамен маленького олед даже если большой TFT, графический интерфейс в любом случае надо переписывать, а данные которые они показывают никуда не денутся.

    И да- писать под такие проекты под NET это то еще извращение. Тут даже без RTOS на чистых плюсах все будет работать быстрее и лучше.


    1. devzona Автор
      15.09.2022 15:49
      +1

      Речь в основном не о конкретном проекте, а об использование Dependency injection в проекте, демонстрация как это выглядит. DI в дальнейшем потребуется для написания REST API как это реализовано в "большом" .NET, но уже для микроконтроллеров.


      1. oblakooblako
        16.09.2022 08:37
        +2

        Очень узко специализировано, чисто для пет проектов и прототипов. В реальной жизни NET и DI в микроконтроллерах никому не сдался.


        1. devzona Автор
          16.09.2022 14:20

          Раньше сказали бы и nF никому в реальной жизни не нужен. Однако nF живет и развивается. Классический шаблон для реализации REST API как раз таки DI. Весь смысл этой затеи перетянуть подход к проектированию приложений с "большого" .NET на микроконтроллеры. Для разработчиков стереть грань программирования между ПК и микроконтроллеров. Переквалифицировать текущих разработчиков .NET под задачи малой автоматизации на nF.


          1. oblakooblako
            16.09.2022 15:31

            Я не эксперт в области программирование на микроконтроллерах. По моему маленькому опыту, один из самых важных аспектов это подбор оборудования, максимально дешево и чтобы можно было выполнить поставленную задачу. Возможно, для простых задач на микроконтроллерах, которые не требует высокой скорости обработки входных сигналов и относительной дешевизны stm, такая архитектура будет к месту. На текущий момент мне сложно представить, такого специалиста, который будет программировать что-то на C# и DI, а где-то будет использовать что-то другое более производительное без усложнений. На каждый экземпляр устройства будет тратиться больше денег, для того чтобы потенциально удешевить скорость замены модулей оборудования. Также мне очень трудно представить сложную архитектуру в устройствах на микроконтроллерах. Из личного опыта, последний хоум проект был связан с программирование робо руки. Прочитав несколько книжек по DI и набравшись опыта в C#, я решил, да будет ООП, сделаю все по "уму". Не буду вдаваться в подробности, но скорость поворота членов устройства меня сильно разочаровала и я перешел на старый добрый спагети код.


            1. devzona Автор
              17.09.2022 03:20

              Вы забываете добавить к стоимости итоговой продукции еще затраты человеко*часы. За счет высокой абстракции nF позволяет писать код существенно быстрее. Задачи нефтедобычи и энергетики для вас это простые задачи? Устройство PalThree — используется для частого и точного мониторинга нефтегазовых месторождений от компании OrgPal.IoT, работает на nanoFramework. Более подробно в предыдущих постах тут и тут. Еще не забываем про родовую ветку microFramework, которую ведет компания GHI Electronics.

              На большом .NET зарабатывает компания Toradex. Самостоятельно выпускает модули SoM и SBC. Платы работают на Linux, для разработки прикладного ПО используется:

              Невозможно что-то конкретно сказать про ваш проект. Но если он у вас не получился на C#, точнее не были достигнуты целевые показатели, то из этого не следует что .NET на Linux или микроконтроллерах плохо работает.

              В nanoFramework есть возможность блоки исходного управляемого кода переносить на ниже лежащий неуправляемый код, т.е. переводить на C+. Переносить функции особо критичные по скорости выполнения на уровень прошивки. А все остальное приложение остается на C#. Этот момент не был подробно пока рассмотрен в статьях, в следующих публикациях более подробно на этом остановимся. В частности, переносить на уровень прошивки потребуется драйвер и графическую библиотеку для больших экранов, для достижения максимального fps. Сделаем парочку интересных игр на большом дисплее со всеми плюшками в виде кнопочек, менюшек, и емкостной панели.


  1. Soorin
    16.09.2022 10:39

    Ну и раскладка кнопок (красные)! Никогда такой бредовой не видел - да ещё и до 11....


    1. devzona Автор
      16.09.2022 14:27

      Отсчет идет с 0 до 11, всего 12 кнопок, что тут странного? А мне, например, нравится красный цвет. Красный фирменный цвет SparkFun. Представленная модель не что иное, как клон SparkFun Capacitive Touch Keypad - MPR121.


  1. Rusrst
    17.09.2022 02:29
    +1

    Хм, прикольно. Но я бы ещё сравнил объемы программы с di и без. Ну и тут конечно удобно на c# писать. В какой-нибудь с уже di так легко не воткнешь... Ни конструктора нет, ни места куда инжектить зависимость.