Допустим, вы сделали свое собственное устройство «Умного дома» и хотите интегрировать его в платформу домашней автоматизации Samsung SmartThings. Тогда вы сможете включить его в общую экосистему, поддерживающую множество устройств от разных производителей. Пользователи вашего устройства смогут инициализировать его удобным образом, задавать сценарии автоматизации, взаимодействовать с ним через мобильное приложение.

Я покажу, как это сделать, на примере самодельного устройства «Умный чайник». На функциональном уровне оно повторяет существующие на рынке устройства такого типа. Конечно, сам «чайник» мы собирать не будем, сделаем только очень простой прототип. Мой пример будет иллюстрировать в основном программный уровень. На железном уровне, я обошелся минимумом периферийных устройств, а часть из них реализовал как «заглушки». Чтобы вы могли повторить все описанные в статье шаги самостоятельно, в качестве целевой платформы я выбрал плату микроконтроллера ESP8266 - одну из самых доступных и популярных на рынке. Данный пример я сделал в качестве стажировки в Исследовательском центре Samsung, и он будет полезен всем, кто еще только начинает заниматься разработкой умных устройств.

Архитектура системы «Умный чайник»

Типичная система в SmartThings состоит из 3 основных частей:

  • Устройство - в нашем случае это плата, которая играет роль «умного чайника»

  • Мобильное приложение, с которого управляем «чайником» и видим его показатели 

  • Облако SmartThings. Оно является посредником между устройством и мобильным приложением. 

На картинке ниже видно, что архитектура SmartThings состоит из большего числа задействованных систем, но минимальный набор - это устройство, облако и мобильное приложение.

Устройство будем собирать из следующих частей: 

  • плата микроконтроллера ESP8266 - в нашем случае это Amperka Troyka Wi-Fi

  • Кнопка тактовая

  • Трехцветный светодиод

  • Пьезо-пищалка/зуммер (активный)

  • Резисторы 330 Ом

  • Перемычки и соединительные провода

В примере посложнее будет датчик температуры, а пока значения температуры в «чайнике» будем генерировать программно. 

Ниже в формате таблицы представлены: 

  • Функции «чайника»

  • Какие из частей устройства, облака и телефона будут выполнять эти функции

  • Как это будет происходить

Сборка на макетной плате

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

На схеме Wi-Fi плата отличается от выбранной нами - Amperka Troyka. Но это не страшно, потому что нужно просто подключить соединительные провода от светодиода, кнопки и зуммера к любым доступным GPIO пинам платы. Собранное по схеме устройство представлено на фотографии:

Шаги создания системы

Весь процесс создания рабочего примера можно разбить на следующие шаги: 

Настройка среды разработки

  • Скачать SDK

  • Докачать подмодули (iot-core и выбранный bsp)

  • Сгенерировать ключи

  • Установить инструменты разработки

Регистрация устройства

  • Создать проект

  • Создать профиль устройства

  • Создать схему авторизации

  • Зарегистрировать тестовое устройство (Загрузить ключи)

  • Скачать onboarding_config.json 

Разработка приложения

  • Создать новый проект

  • Добавить информацию об устройстве (2 json файла)

  • Разработать прошивку

  • Собрать и загрузить прошивку на устройство

Тестирование приложения

  • Включить в мобильном приложении режим разработчика

  • Добавить устройство

  • Проверить функции 

Эта статья будет сфокусирована на том, как разрабатывать приложение и прошивку. Остальные процессы из списка выше описаны в этом туториале, рекомендую сначала пройти его. Также эта информация задокументирована на сайте SmartThings.

Разработка приложения

Разработка приложения включает в себя работу в workspace проекта и создание прошивки устройства. 

При разработке прошивки работаем с 4 основными частями программы:

  1. Capabilities

  2. Управление периферией

  3. Инициализация

  4. Бизнес-логика взаимодействия capabilities и периферии

Далее расскажу о каждой из частей, а затем посмотрим, как это выглядит в коде.

Код моего демо-проекта доступен в репозитории на гитхабе. Рекомендую скачать его себе и для лучшего понимания сопоставлять части кода, которые описаны в статье, с кодом из проекта. API всех функций ST SDK удобно описано на этой странице.

Что такое Capability

Основной объект внимания при написании кода прошивки - Capability. Capability - это возможность/функция системы. Capabilities предоставляются командой разработчиков SmartThings, но также можно создавать свои кастомные capabilities. В интерфейсе приложения у каждого Capability есть своё отображение. У Capability есть заданные идентификатор, имя, статус, атрибуты/свойства и команды. Разработчик может менять значения атрибутов в коде прошивки, и эти изменения будут переданы и видны в интерфейсе мобильного приложения. Также разработчик может обрабатывать команды, которые в том же интерфейсе доступны пользователю для запуска/выполнения. Для лучшего понимания, разберем пример Capability Thermostat Heating Setpoint. Это Capability позволяет задать значение температуры, до которой обогреватель должен согреть рабочее пространство. 

На скриншоте ниже показано как добавить это Capability в проект. При выборе уже можно понять, за что отвечает это Capability, какие атрибуты и команды имеет.

На скриншоте ниже показан подробный вид выбранного устройства в мобильном приложении со списком всех его capabilities. С этого экрана видны значения атрибутов всех capabilities. На следующем скриншоте - выбрано Capability Thermostat Heating Setpoint, и пользователь может отправить команду по изменению температуры, задав желаемое значение температуры в диалоговом окошке. 

Подробный вид устройства

Окошко выбора температуры

На скриншоте ниже представление того же Capability, но уже в коде.

У Capability есть id = "thermostatHeatingSetpoint", атрибут heatingSetpoint с обязательным значением, команда setHeatingSetpoint(setpoint) с обязательным аргументом. 

Таким образом, Capability позволяет абстрагироваться от того, как и каким железом реализованы возможности, и взаимодействовать мобильному приложению с устройством на основе его возможностей/функционала.

Информация о Capability доступна на странице официальной документации SmartThings.

Управление периферией

Здесь описываем все функции управления периферией и их реализацию. Реализация зависит от процессора и используемых под него библиотек и фреймворков. В нашем случае используется esp8266 и Espressif ESP8266 RTOS SDK от разработчиков esp. Написан весь пример на языке C.

Инициализация

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

Бизнес-логика

Описывается в main.c и использует функции, объявленные для capabilities и для управления периферией. В бизнес-логике обрабатываются события. Есть 2 источника событий: сигналы от периферии, в том числе действия пользователя с железом/устройством, и действие пользователя в мобильном приложении.

Обработка событий от железа

Обработка событий с периферии происходит в отдельном потоке/таске. При срабатывании определенного события от периферии, мы можем поменять состояние периферии или поменять состояние Capability. Например, после нажатия на физическую кнопку мигаем светодиодом. Или после нажатия на кнопку отправляем в облако новое значение виртуальной кнопки, чтобы в интерфейсе состояние кнопки тоже поменялось. 

Обработка событий с облака/мобильного приложения

Чтобы обработать события с облака, нужно в каждое используемое Capability добавить колбэки. Колбэк - это функция, которая вызывается при получении команды для Capability. При подготовке Capability мы регистрируем колбэки для каждой команды. В этих колбэк-функциях мы можем управлять периферией или отправлять в облако измененные значения атрибутов каких-либо Capability. Например, при назначении температуры подогрева с мобильного приложения помигать светодиодом устройства. 

Пример Smart Kettle

Пример кода, который я предлагаю рассмотреть, основан на примере от разработчиков ST SDK. Этот код нельзя назвать идеальным с точки зрения стиля, там есть, что рефакторить. Но делая этот простой пример, я старался придерживаться логики разработчиков, не удаляясь далеко от их примера, чтобы все промежуточные шаги были максимально прозрачны.

Инициализация

Программа начинается с функции app_main, на неё и обратим сначала внимание. Сначала происходит инициализация устройства для подключения к облаку ST:

void app_main(void)
{
  unsigned char *onboarding_config = (unsigned char *) onboarding_config_start;
  unsigned int onboarding_config_len = onboarding_config_end - onboarding_config_start;
  unsigned char *device_info = (unsigned char *) device_info_start;
  unsigned int device_info_len = device_info_end - device_info_start;
  int iot_err;
  // st_dev.h
  ctx = st_conn_init(onboarding_config, onboarding_config_len, device_info, device_info_len);
  if (ctx != NULL) {
    iot_err = st_conn_set_noti_cb(ctx, iot_noti_cb, NULL);
    if (iot_err)
      printf("fail to set notification callback function\n");
  } else {
    printf("fail to create the iot_context\n");
  }
	/* . . . */
}

В этих функциях используются константы и переменные, которые нужно объявить глобально. 

// onboarding_config_start is null-terminated string
extern const uint8_t onboarding_config_start[]   asm("_binary_onboarding_config_json_start");
extern const uint8_t onboarding_config_end[]    asm("_binary_onboarding_config_json_end");
 
// device_info_start is null-terminated string
extern const uint8_t device_info_start[]    asm("_binary_device_info_json_start");
extern const uint8_t device_info_end[]      asm("_binary_device_info_json_end");
 
IOT_CTX* ctx = NULL;

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

static void iot_noti_cb(iot_noti_data_t *noti_data, void *noti_usr_data)
{
  printf("Notification message received\n");
  if (noti_data->type == IOT_NOTI_TYPE_DEV_DELETED) {
    printf("[device deleted]\n");
  } else if (noti_data->type == IOT_NOTI_TYPE_RATE_LIMIT) {
    printf("[rate limit] Remaining time:%d, sequence number:%d\n",
    noti_data->raw.rate_limit.remainingTime, noti_data->raw.rate_limit.sequenceNumber);
  }
}

Список таких событий определен в перечислении/enum iot_noti_type в st_dev.h. 

Далее в app_main вызывается функция инициализации всех Capability приложения, capability_init. У нас три Capability: Switch, Temperature Measurement и Heating Setpoint. Помимо вызова функции инициализации для каждого Capability можно добавить дополнительную колбэк функцию для обработки команд от пользователя, которая, например, может управлять периферией. Подробнее об этом в разделе бизнес-логики приложения. Также можно установить начальные значения атрибутов Capability. 

Функция capability_init
static void capability_init()
{	
  // вызываем функцию инициализации Capability переключателя
  cap_switch_data = caps_switch_initialize(ctx, "main", NULL, NULL);
  if (cap_switch_data) {
    // устанавливаем дополнительные колбэк функции
    cap_switch_data->cmd_on_usr_cb = cap_switch_cmd_cb;
    cap_switch_data->cmd_off_usr_cb = cap_switch_cmd_cb;
    // устанавливаем значение атрибута переключателя по умолчанию
    cap_switch_data->set_switch_value(cap_switch_data, caps_helper_switch.attr_switch.value_off);
  }
  // вызываем функцию инициализации Capability показа температуры
  cap_temperature_data = caps_temperatureMeasurement_initialize(ctx, "main", NULL, NULL);
  if (cap_temperature_data) {
    // устанавливаем значение и меру/unit температуры по умолчанию
    cap_temperature_data->set_temperature_unit(cap_temperature_data, caps_helper_temperatureMeasurement.attr_temperature.unit_C);
    cap_temperature_data->set_temperature_value(cap_temperature_data, 0);
  }
  // вызываем функцию инициализации Capability выбора температуры нагревания
  cap_heatingSetpoint_data = caps_thermostatHeatingSetpoint_initialize(ctx, "main", NULL, NULL);
  if (cap_heatingSetpoint_data) {
    // устанавливаем дополнительную колбэк функцию
    cap_heatingSetpoint_data->cmd_setHeatingSetpoint_usr_cb = cap_thermostat_cmd_cb;
    // устанавливаем меру/unit температуры нагревания
    cap_heatingSetpoint_data->set_unit(cap_heatingSetpoint_data, caps_helper_thermostatHeatingSetpoint.attr_heatingSetpoint.unit_C);
  }
}

Последней функцией инициализации является iot_gpio_init, которая конфигурирует и инициализирует пины. Эта часть кода зависит от выбранного фреймворка/библиотек под микроконтроллер. В нашем случае код инициализации написан с помощью ESP8266 RTOS SDK от разработчиков esp. 

Функция iot_gpio_init
void iot_gpio_init(void)
{  
  // esp sdk specific
  gpio_config_t io_conf;
  // отключаем прерывания
  io_conf.intr_type = GPIO_INTR_DISABLE;
  // устанавливаем режим пина на выход
  io_conf.mode = GPIO_MODE_OUTPUT;
  // выбираем пин
  io_conf.pin_bit_mask = 1 << GPIO_OUTPUT_MAINLED;
  // выбираем для пина pull-down 
  io_conf.pull_down_en = 1;
  io_conf.pull_up_en = 0;
  // конфигурируем пин встроенного светодиода
  gpio_config(&io_conf);
  // оставляем ту же конфигурацию, но применим ее для пина зуммера
  io_conf.pin_bit_mask = 1 << GPIO_OUTPUT_BUZZER;
  // конфигурируем этот пин
  gpio_config(&io_conf);
  // конфигурируем пины rgb светодиода
  io_conf.pin_bit_mask = 1 << GPIO_OUTPUT_RGBLED_R;
  gpio_config(&io_conf);
  io_conf.pin_bit_mask = 1 << GPIO_OUTPUT_RGBLED_G;
  gpio_config(&io_conf);
  io_conf.pin_bit_mask = 1 << GPIO_OUTPUT_RGBLED_B;
  gpio_config(&io_conf);
  // конфигурируем пин кнопки-переключателя
  io_conf.intr_type = GPIO_INTR_ANYEDGE;
  io_conf.mode = GPIO_MODE_INPUT;
  io_conf.pin_bit_mask = 1 << GPIO_INPUT_SWITCH;
  io_conf.pull_down_en = 0;
  io_conf.pull_up_en = 1;
  gpio_config(&io_conf);
  // отключаем сервис прерываний
  gpio_install_isr_service(0);
  // устанавливаем значения пинов по умолчанию
  gpio_set_level(GPIO_OUTPUT_MAINLED, LED_GPIO_ON);
  gpio_set_level(GPIO_OUTPUT_BUZZER, LED_GPIO_OFF);
  gpio_set_level(GPIO_OUTPUT_RGBLED_R, LED_GPIO_OFF);
  gpio_set_level(GPIO_OUTPUT_RGBLED_G, LED_GPIO_OFF);
  gpio_set_level(GPIO_OUTPUT_RGBLED_B, LED_GPIO_ON);
}

После инициализации в app_main выполняется подключение к облаку ST при помощи функции connection_start.

static void connection_start(void)
{
  iot_pin_t *pin_num = NULL;
  int err;
  // process on-boarding procedure. There is nothing more to do on the app side than call the API.
  err = st_conn_start(ctx, (st_status_cb)&iot_status_cb, IOT_STATUS_ALL, NULL, pin_num);
  if (err) {
    printf("fail to start connection. err:%d\n", err);
  }
}

Остается только запуск потока/таска для обработки событий от периферии, который разберем далее. Запускать поток/таск можно используя или ST SDK, или выбранную RTOS напрямую. В нашем примере есть возможность использовать FreeRTOS, но мы вызовем функцию iot_os_thread_create из ST SDK.

void app_main(void)
{
  /* . . . */
  // обработка событий от периферии в отдельном потоке/таске
  iot_os_thread_create(app_main_task, "app_main_task", 2048, NULL, 10, NULL);
}

Бизнес-логика

Как уже описывалось ранее, в слое бизнес-логики мы обрабатываем события из 2 источников: от облака и от периферии. Начнём с событий от периферии. 

События с периферии

В app_main мы запустили функцию app_main_task в отдельном потоке/таске. Внутри функции бесконечный цикл с проверкой состояний и событий периферии. Главное здесь в правильном порядке управлять периферией и отправлять в облако значения тех Capability, которые имеют отражение в физическом устройстве - это переключатель и температура. 

Если была нажата физическая кнопка, значит процесс “кипячения” запущен.  Меняется состояние thermostat_enable и дальше можно периодически получать значение текущей температуры. Используется таймер iot_os_timer для получения значений температуры раз в несколько секунд. 

if (get_button_event()) {
  // изменить значение переключателя на включенное
  cap_switch_data->set_switch_value(cap_switch_data, caps_helper_switch.attr_switch.value_on);
  // отправить в облако новое значение переключателя
  cap_switch_data->attr_switch_send(cap_switch_data);
  change_switch_state(get_switch_state());
  // допустить процесс нагрева/кипячения
  thermostat_enable = true;
}

При получении температуры, она отправляется в облако с помощью структуры/объекта Temperature Capability. Подробнее о структуре в разделе об использовании Capability. Также меняется цвет светодиода с синего на красный. 

// Если идёт процесс нагрева и можно получить значение температуры
if (thermostat_enable && get_temperature_event(timer)) {
  // получить значение температурыtemperature_value =  temperature_event(temperature_value);
  // выключить синий и включить красный цвет светодиода
  change_rgb_state(GPIO_OUTPUT_RGBLED_B, LED_GPIO_OFF);
  change_rgb_state(GPIO_OUTPUT_RGBLED_R, LED_GPIO_ON);
  // сохранить новое значение температуры
  cap_temperature_data->set_temperature_value(cap_temperature_data, temperature_value);
  // отправить значение температуры в облако
  cap_temperature_data->attr_temperature_send(cap_temperature_data);
}

Если температура больше температуры кипения, то процесс “кипячения” завершается. Меняется состояние thermostat_enable, цвет светодиода на зелёный и становится доступен процесс пищания зуммера - меняется состояние buzzer_enable.

if (thermostat_enable && temperature_value >= heating_setpoint) {
	thermostat_enable = false;
	change_rgb_state(GPIO_OUTPUT_RGBLED_R, LED_GPIO_OFF);
	change_rgb_state(GPIO_OUTPUT_RGBLED_G, LED_GPIO_ON);
	temperature_value = 0;
	// сделать зуммер доступным
	buzzer_enable = true;
} 

Когда зуммер пропищит, цвет светодиода меняется с зелёного обратно на синий, а значение атрибута Switch Capability изменится с включенного на выключенный и отправится в облако для отображения в UI приложения.

Код функции app_main_task целиком:
static void app_main_task(void *arg)
{  
   // подготовка таймера
   iot_os_timer timer = NULL;
   int iot_err;
   iot_err = iot_os_timer_init(&timer);
   if (iot_err) {
       printf("fail to init timer: %d\n", iot_err);
   }
   iot_os_timer_count_ms(timer, TEMPERATURE_EVENT_MS_RATE);
 
   double temperature_value = 0;
 
   for (;;) {
      // если кнопка нажата
   	if (get_button_event()) {
			// изменить значение переключателя на включенное
			cap_switch_data->set_switch_value(cap_switch_data,
caps_helper_switch.attr_switch.value_on);
			// отправить в облако новое значение переключателя
      cap_switch_data->attr_switch_send(cap_switch_data);
 
      change_switch_state(get_switch_state());
			// допустить процесс нагрева/кипячения
      thermostat_enable = true;
    }
		// Если идёт процесс нагрева и можно получить значение температуры
    if (thermostat_enable && get_temperature_event(timer)) {
			// получить значение температуры
    	temperature_value = temperature_event(temperature_value);
			// выключить синий и включить красный цвет светодиода
			change_rgb_state(GPIO_OUTPUT_RGBLED_B, LED_GPIO_OFF);
			change_rgb_state(GPIO_OUTPUT_RGBLED_R, LED_GPIO_ON);
      // сохранить новое значение температуры     cap_temperature_data->set_temperature_value(cap_temperature_data, temperature_value);
      // отправить значение температуры в облако
			cap_temperature_data->attr_temperature_send(cap_temperature_data);
		}
		if (thermostat_enable && temperature_value >= heating_setpoint) {
			thermostat_enable = false;
			change_rgb_state(GPIO_OUTPUT_RGBLED_R, LED_GPIO_OFF);
			change_rgb_state(GPIO_OUTPUT_RGBLED_G, LED_GPIO_ON);
			temperature_value = 0;
			// сделать зуммер доступным
			buzzer_enable = true;
    }
    if (buzzer_enable) {
			// включить зуммер на несколько секунд
			beep();
			// сделать зуммер недоступным
			buzzer_enable = false;
			change_rgb_state(GPIO_OUTPUT_RGBLED_G, LED_GPIO_OFF);
			change_rgb_state(GPIO_OUTPUT_RGBLED_B, LED_GPIO_ON);
			cap_switch_data->set_switch_value(cap_switch_data, caps_helper_switch.attr_switch.value_off);
			// отправить в облако измененное значение переключателя
			cap_switch_data->attr_switch_send(cap_switch_data);
			change_switch_state(get_switch_state());
		}
    iot_os_delay(10);
	}	
  iot_os_timer_destroy(&timer);
}

События из облака/мобильного приложения

При инициализации Capability можно добавить колбэки из слоя бизнес-логики, которые позволяют управлять периферией, если пришло событие из облака/мобильного приложения. У нас это колбэки для Switch Capability и Thermostat Heating Setpoint Capability. 

Для Switch Capability при получении команды щелчка переключателя мы меняем состояние встроенного в wifi-плату светодиода и меняем значение флага thermostat_enable. Этого достаточно, чтобы в app_main_task запустился процесс нагрева/кипячения. 

Для Thermostat Heating Setpoint Capability при получении команды на установку нового значения температуры нагрева мы мигаем встроенным светодиодом и меняем значение heating_setpoint, которое определяет завершение процесса нагрева.

static void capability_init()
{
  /* . . . */
  cap_switch_data->cmd_on_usr_cb = cap_switch_cmd_cb;
  cap_switch_data->cmd_off_usr_cb = cap_switch_cmd_cb;
  /* . . . */

  cap_heatingSetpoint_data->cmd_setHeatingSetpoint_usr_cb = cap_thermostat_cmd_cb;
  /* . . . */
}
 
static void cap_switch_cmd_cb(struct caps_switch_data *caps_data)
{
  int switch_state = get_switch_state();
  change_switch_state(switch_state);
  thermostat_enable = !thermostat_enable;
}
 
static void cap_thermostat_cmd_cb(struct caps_thermostatHeatingSetpoint_data *caps_data)
{  
  // устанавливаем полученное значение температуры нагрева
  heating_setpoint = caps_data->get_value(caps_data);
  int led_state = get_switch_state();
  // мигаем встроенным светодиодом
  change_led_state(heating_setpoint, led_state);
}

Управление периферией

Все функции управления периферией объявлены в файле device_control.h и реализованы в device_control.c. В device_control.h также определены константы, такие как номера пинов и длительность событий. Например, пин кнопки и длительность пищания зуммера:

#define GPIO_INPUT_SWITCH 5
#define BUZZER_SOUND_DURATION 3000

В нашем примере используется кнопка, встроенный и rgb светодиоды и зуммер. С кнопки нужно считывать состояние пина, а на остальную периферию подавать напряжение. Также мы эмулируем датчик температуры. Разберем функции для каждого элемента.

Кнопка

Функция get_button_event проверяет, изменилось ли состояние пина и, если изменилось, возвращает 1, что говорит о событии от кнопки. 

int get_button_event()
{
	static uint32_t button_last_state = 0;
  uint32_t gpio_level = 0;
 
  gpio_level = gpio_get_level(GPIO_INPUT_SWITCH);
  if (button_last_state != gpio_level) {
	 	// устраняем дребезг контактов небольшой задержкой
		IOT_DELAY(BUTTON_DEBOUNCE_TIME_MS);
    gpio_level = gpio_get_level(GPIO_INPUT_SWITCH);
		if (button_last_state != gpio_level) {
			printf("Button event, val: %d, \n", gpio_level);
			button_last_state = gpio_level;          
    }
		return 1;
	}
return 0;
}

RGB светодиод

Функция change_rgb_state получает на вход пин и значение для пина. Далее в функции нужному пину светодиода устанавливается значение.

void change_rgb_state(int pin, int value)
{
	if (pin == GPIO_OUTPUT_RGBLED_R) {
		gpio_set_level(GPIO_OUTPUT_RGBLED_R, value);
	}
  else if (pin == GPIO_OUTPUT_RGBLED_G) {
		gpio_set_level(GPIO_OUTPUT_RGBLED_G, value);
  }
  else if (pin == GPIO_OUTPUT_RGBLED_B) {
		gpio_set_level(GPIO_OUTPUT_RGBLED_B, value);
  }
}

Встроенный светодиод

Функция change_switch_state в зависимости от состояния переключателя включает или выключает встроенный светодиод.

void change_switch_state(int switch_state)
{
	if (switch_state == SWITCH_OFF) {
		gpio_set_level(GPIO_OUTPUT_MAINLED, LED_GPIO_OFF);
  } else {
		gpio_set_level(GPIO_OUTPUT_MAINLED, LED_GPIO_ON);
  }
}

Функция blink_led в зависимости от заданной температуры нагрева мигает несколько раз.

void blink_led(double heating_setpoint, int led_state)
{
	// переменная, которая определяет, сколько раз мигать светодиоду
  int blinks = 0;
  if (heating_setpoint <= 10) {
		blinks = 1;
  }
  else if (heating_setpoint <= 30) {
		blinks = 2;
  }
  else if (heating_setpoint <= 50) {
		blinks = 3;
  }
  else if (heating_setpoint <= 100) {
		blinks = 4;
  }
  else {
		printf("heating setpoint > 100 or < 0!\nPlease, set correct number");
  }
  for (int i = 0; i < blinks; i++) {
		change_switch_state(1 - led_state);
		iot_os_delay(BLINK_DURATION);
		change_switch_state(led_state);
		iot_os_delay(BLINK_DURATION);
  }
}

Зуммер

Функция beep подаёт напряжение пину, который соединен с зуммером и через несколько секунд останавливает подачу напряжения.  

void beep() {
	change_buzzer_state(BUZZER_ON);
  IOT_DELAY(BUZZER_SOUND_DURATION);
  change_buzzer_state(BUZZER_OFF);
}
 
void change_buzzer_state(int buzzer_state)
{  
	if (buzzer_state == BUZZER_OFF) {
	gpio_set_level(GPIO_OUTPUT_BUZZER, BUZZER_OFF);
  } else {
		gpio_set_level(GPIO_OUTPUT_BUZZER, BUZZER_ON);
  }
}

 

Эмулятор датчика температуры

Функция get_temperature_event проверяет, прошло ли достаточно времени для считывания данных температуры. Если таймер истек, таймеру задается новое значение для отсчета и возвращается 1, что говорит о доступном событии от датчика температуры. 

int get_temperature_event(iot_os_timer timer)
{
	if (iot_os_timer_isexpired(timer)) {
		iot_os_timer_count_ms(timer, TEMPERATURE_EVENT_MS_RATE);
		return 1;
  }
  else {
		return 0;
  }
}

Функция temperature_event эмулирует получение температуры от датчика, увеличивая после каждого вызова значение температуры на 5.

double temperature_event(double temperature_value) {
   return temperature_value + 5;
}

Как используются Capability 

Почти все взаимодействие с Capability в коде прошивки будет происходить через структуру caps_{capabilityName}_data_t. Эта структура будет содержать полезные данные Capability и функции, с помощью которых можно отправлять данные Capability в облако ST. Бывают Capability без атрибутов, только с командами от пользователя. Например, кнопка включения звукового сигнала для поиска устройства. Бывают Capability без команд от пользователя. Например, измеритель температуры, который только отображает значение температуры. Структуры объявлены в файлах с названиями caps_{capabilityName}.h и реализованы в файлах caps_{capabilityName}.Рассмотрим описанное выше на примере Switch Capability. 

Пример Switch Capability

Switch Capability - это переключатель, который имеет два состояния: включен или выключен. 

В директории main созданы caps_switch.c и caps_switch.h. 

В caps_switch.h объявлены структура caps_switch_data_t и функция caps_switch_initialize

Структура caps_switch_data_t содержит поле switch_value типа char. Это значение атрибута Switch Capability. В других Capability тип может быть, например, int. Также у некоторых атрибутов Capability есть unit - единица измерения, которую в нашей структуре нужно хранить в отдельном поле. 

typedef struct caps_switch_data
{
   char *switch_value;
   /* . . . */
} caps_switch_data_t;

Далее в структуре есть геттер и сеттер, которые позволяют получить switch_value или задать ему значение. Функция attr_switch_send отвечает за отправку значения switch_value в облако ST, чтобы в мобильном приложении обновился UI кнопки/свитча. 

typedef struct caps_switch_data
{
   /* . . . */
   const char *(*get_switch_value)(struct caps_switch_data *caps_data);
   void (*set_switch_value)(struct caps_switch_data *caps_data, const char *value);
   void (*attr_switch_send)(struct caps_switch_data *caps_data);
   /* . . . */
} caps_switch_data_t;

Ещё есть 2 функции-колбэки: cmd_on_usr_cb и cmd_off_usr_cb. Они будут выполняться, если пользователь в мобильном приложении щелкнет переключателем.

typedef struct caps_switch_data
{
   /* . . . */
   void (*cmd_on_usr_cb)(struct caps_switch_data *caps_data);
   void (*cmd_off_usr_cb)(struct caps_switch_data *caps_data);
} caps_switch_data_t;
Вся структура целиком:
typedef struct caps_switch_data
{
   IOT_CAP_HANDLE* handle;
   void *usr_data;
   void *cmd_data;
 
   char *switch_value;
 
   const char *(*get_switch_value)(struct caps_switch_data *caps_data);
   void (*set_switch_value)(struct caps_switch_data *caps_data, const char *value);
   int (*attr_switch_str2idx)(const char *value);
   void (*attr_switch_send)(struct caps_switch_data *caps_data);
 
   void (*init_usr_cb)(struct caps_switch_data *caps_data);
 
   void (*cmd_on_usr_cb)(struct caps_switch_data *caps_data);
   void (*cmd_off_usr_cb)(struct caps_switch_data *caps_data);
} caps_switch_data_t;

Все функции содержат в параметрах указатель на конкретный экземпляр структуры. В ООП-языках мы бы просто создали объект и обращались к функциям прямо через него. В языке Си мы вынуждены передавать сам “объект” в те функции, в которых нужно как-то поменять или использовать данные конкретного экземпляра структуры. 

Функция caps_switch_initialize возвращает новый инициализированный экземпляр структуры. В таком экземпляре назначены реализации всех функций, зарегистрированы колбэки всех команд и назначены значения атрибутов по умолчанию.

caps_switch_data_t *caps_switch_initialize(IOT_CTX *ctx, const char *component, void *init_usr_cb, void *usr_data);

Описание caps_switch.с

В caps_switch.c будем реализовывать все функции, объявленные в caps_switch.h.

Начнём с функции caps_switch_initialize, т.к. она использует все остальные функции. 

В этой функции 3 основных действия: выделение памяти для caps_switch_data_t структуры, присваивание всех функций, необходимых для структуры, и регистрация колбэков. Для чего и какие нужны колбэки подробнее разберем позже.

Функция  caps_switch_initialize
caps_switch_data_t *caps_switch_initialize(IOT_CTX *ctx, const char *component, void *init_usr_cb, void *usr_data)
{	
	// выделение памяти
  caps_switch_data_t *caps_data = NULL;
  int err;
  caps_data = malloc(sizeof(caps_switch_data_t));
  if (!caps_data) {
		printf("fail to malloc for caps_switch_data\n");
		return NULL;
  }
  memset(caps_data, 0, sizeof(caps_switch_data_t));
 
  caps_data->init_usr_cb = init_usr_cb;
  caps_data->usr_data = usr_data;
	// присваивание всех функций
  caps_data->get_switch_value = caps_switch_get_switch_value;
  caps_data->set_switch_value = caps_switch_set_switch_value;
  caps_data->attr_switch_send = caps_switch_attr_switch_send;
	// регистрация колбэков
  if(ctx) {
		caps_data->handle = st_cap_handle_init(ctx, component, caps_helper_switch.id, caps_switch_init_cb, caps_data);
  }
  if(caps_data->handle) {
		err = st_cap_cmd_set_cb(caps_data->handle, caps_helper_switch.cmd_on.name, caps_switch_cmd_on_cb, caps_data);
		if (err) {
      printf("fail to set cmd_cb for on of switch\n");
		}
		err = st_cap_cmd_set_cb(caps_data->handle, caps_helper_switch.cmd_off.name, caps_switch_cmd_off_cb, caps_data);
		if (err) {
			printf("fail to set cmd_cb for off of switch\n");
		}
  }
  else {
		printf("fail to init switch handle\n");
  }
  return caps_data;
}

Геттеры и сеттеры 

В геттерах и сеттерах атрибута Capability добавлены проверки на то, что структура существует, что она не равна NULL. Далее или возвращаем switch_value, или изменяем его. 

static const char *caps_switch_get_switch_value(caps_switch_data_t *caps_data)
{
  if (!caps_data) {
    printf("caps_data is NULL\n");
    return NULL;
  }
  return caps_data->switch_value;
}
 
static void caps_switch_set_switch_value(caps_switch_data_t *caps_data, const char *value)
{
	if (!caps_data) {
    printf("caps_data is NULL\n");
    return;
  }
  if (caps_data->switch_value) {
    free(caps_data->switch_value);
  }
  caps_data->switch_value = strdup(value);
}

 

Функция caps_switch_attr_switch_send

Она нужна, чтобы отправить в облако новое значение атрибута switch. Здесь помимо очередных проверок самое главное - это вызов макроса ST_CAP_SEND_ATTR_STRING из SDK. В данном случае значение атрибута текстовое, поэтому используем ST_CAP_SEND_ATTR_STRING. Если же в Capability числовой атрибут temperature, то мы вызовем ST_CAP_SEND_ATTR_NUMBER. При этом помимо значения атрибута, в макрос нужно будет передать unit - единицу измерения температуры.

static void caps_switch_attr_switch_send(caps_switch_data_t *caps_data)
{
  int sequence_no = -1;

  if (!caps_data || !caps_data->handle) {
    printf("fail to get handle\n");
    return;
  }
  if (!caps_data->switch_value) {
    printf("value is NULL\n");
    return;
  }

  ST_CAP_SEND_ATTR_STRING(caps_data->handle,
                          (char *)caps_helper_switch.attr_switch.name,
                          caps_data->switch_value,
                          NULL,
                          NULL,
                          sequence_no);

  if (sequence_no < 0)
    printf("fail to send switch value\n");
  else
    printf("Sequence number return : %d\n", sequence_no);
}

Регистрация колбэков

У каждого Capability есть колбэк, который исполняется при инициализации Capability. При помощи этого колбэка можно установить начальные значения атрибутов Capability. В основной функции инициализации Capability это следующие строчки:

if(ctx) {
  caps_data->handle = st_cap_handle_init(ctx, component, caps_helper_switch.id, caps_switch_init_cb, caps_data);
}

А далее сама функция-колбэк. В ней отправляется проинициализированный атрибут и вызывается функция init_usr_cb из слоя бизнес-логики, если она была передана. В нашем примере эта функция не передается ни в каком из Capability.

static void caps_switch_init_cb(IOT_CAP_HANDLE *handle, void *usr_data)
{
  caps_switch_data_t *caps_data = usr_data;
  if (caps_data && caps_data->init_usr_cb)
    caps_data->init_usr_cb(caps_data);
  caps_switch_attr_switch_send(caps_data);
}

Колбэки caps_switch_cmd_off_cb и caps_switch_cmd_on_cb

Колбэки команд от пользователя будут выполняться, если пользователь в мобильном приложении щелкнет переключателем. Нам нужно зарегистрировать эти колбэки. В основной функции инициализации Capability это следующие строчки:

if(caps_data->handle) {
  err = st_cap_cmd_set_cb(caps_data->handle, caps_helper_switch.cmd_on.name, caps_switch_cmd_on_cb, caps_data);
  if (err) {
    printf("fail to set cmd_cb for on of switch\n");
  }
  err = st_cap_cmd_set_cb(caps_data->handle, caps_helper_switch.cmd_off.name, caps_switch_cmd_off_cb, caps_data);
  if (err) {
    printf("fail to set cmd_cb for off of switch\n");
  }
}
else {
  printf("fail to init switch handle\n");
}

А далее сами функции-колбэки. Команда приходит с облака после нажатия пользователем кнопки в интерфейсе. Команда содержит значение атрибута Capability - у нас это значение кнопки “вкл” или “выкл”. В колбэке мы забираем это значение, присваиваем его структуре caps_switch_data_t *caps_data, и отправляем это значение атрибута в облако, чтобы и в UI приложения обновилось значение кнопки. Также вызывается функция cmd_on_usr_cb или cmd_off_usr_cb из слоя бизнес-логики, если она была передана. В нашем примере эта функция передается, и она отвечает за то, чтобы вызвать функции управления периферии - помигать светодиодом, включить нагреватель. Таким образом, когда пользователь в UI приложения нажимает на кнопку, на устройстве исполняется колбэк функция, которая обновляет значение атрибута в UI и производит какую-то работу с периферией.  

static void caps_switch_cmd_on_cb(IOT_CAP_HANDLE *handle, iot_cap_cmd_data_t *cmd_data, void *usr_data)
{
  caps_switch_data_t *caps_data = (caps_switch_data_t *)usr_data;
  const char* value = caps_helper_switch.attr_switch.values[CAP_ENUM_SWITCH_SWITCH_VALUE_ON];

  printf("called [%s] func with num_args:%u\n", __func__, cmd_data->num_args);

  caps_switch_set_switch_value(caps_data, value);
  if (caps_data && caps_data->cmd_on_usr_cb)
    caps_data->cmd_on_usr_cb(caps_data);
  caps_switch_attr_switch_send(caps_data);
}
 
static void caps_switch_cmd_off_cb(IOT_CAP_HANDLE *handle, iot_cap_cmd_data_t *cmd_data, void *usr_data)
{
  caps_switch_data_t *caps_data = (caps_switch_data_t *)usr_data;
  const char* value = caps_helper_switch.attr_switch.values[CAP_ENUM_SWITCH_SWITCH_VALUE_OFF];

  printf("called [%s] func with num_args:%u\n", __func__, cmd_data->num_args);

  caps_switch_set_switch_value(caps_data, value);
  if (caps_data && caps_data->cmd_off_usr_cb)
    caps_data->cmd_off_usr_cb(caps_data);
  caps_switch_attr_switch_send(caps_data);
}

Другие Capability

Работа с другими capabilities не отличается от примера Switch Capability. Итого нужно выполнить следующие шаги с каждым Capability:

  1. Создать структуру, которая будет содержать атрибуты, функции получения, изменения и сохранения в облако значений атрибутов, колбэки команд и другие служебные поля.

  2. Проинициализировать структуру: задать значения по умолчанию для атрибутов и назначить функции-колбэки

  3. Зарегистрировать все колбэк-функции запросом в облако

  4. Использовать в слое бизнес-логики функции получения, изменения и сохранения в облако значений атрибутов

О каждом из доступных Capability вы можете прочитать на странице документации SmartThings, посвященной capabilities.

Кастомные Capability

Если каких-то Capability не хватает или хочется подкорректировать имеющиеся, ST даёт возможность создавать свои кастомные Capability. В нашем примере мы подкорректируем Thermostat Heating Setpoint, у которого в UI мобильного приложения можно задать температуру нагрева лишь до 40 °C. 

Очевидно, что эта Capability была создана для домашних обогревателей и потому имеет такие границы. Есть и другое Capability под названием Oven Setpoint, где границы нам подходят, но если будем его использовать, то в графическом интерфейсе чайника название секции будет ровно такое же, про духовку. Таким образом, нам все равно придется редактировать Capability.

Скриншот ограничений выбора температуы

Чтобы использовать своё кастомное Capability в прошивке, нужно:

  1. Создать Capability и Capability Presentation при помощи консольной утилиты smartthings-cli

  2. В Workspace в Профиле устройства добавить созданное Capability

  3. В коде прошивки заменить идентификатор Thermostat Heating Setpoint Capability на идентификатор нашего кастомного Capability

По кастомным Capability есть страница в документации и туториал на форуме ST. Рекомендую к ним обращаться, если останутся вопросы и непонятные моменты. 

Создание кастомного Capability

Для создания кастомного Capability нужно иметь 2 файла: файл, содержащий информацию о Capability и файл, содержащий информацию о UI-представлении этого Capability в мобильном приложении. Содержание этих файлов можно написать на основе стандартных capabilities, изменив некоторые строчки, которые нас не устраивают. Я уже создал измененные файлы Thermostat Heating Setpoint Capability, чтобы убрать ограничение в 40 °C, они доступны в проекте в директории custom-capability. 

Используем эти файлы для создания кастомного Capability:

  1. Cкачиваем утилиту smartthings-cli с гитхаба SmartThingsCommunity. Распаковать для удобства можно в директорию нашего проекта. 

  2. Используем готовый файл описания Capability из папки custom-capability нашего проекта. Его прописываем в команду создания Capability:

    $./smartthings capabilities:create -j -i heatingCustom.json

  3. В выводе команды получим json созданного Capability, который отличается от нашего json файла строчками с идентификатором и версией. Нам нужно скопировать идентификатор.

  4. Открыть файл heatingCustomPresentation.json и заменить идентификатор на тот, который скопировали

  5. Создать UI-представление Capability следующей командой:

    $./smartthings capabilities:create -j -i heatingCustomPresentation.json

Выполнив эти шаги, мы получим готовое кастомное Capability, которое отличается от Thermostat Heating Setpoint Capability верхней границей температуры нагревания в UI представлении.

Если вы хотите самостоятельно создать эти файлы, легче будет взять за основу файлы из проекта и заменить необходимые части, или взять за основу файлы стандартных Capability. Следующие команды smartthings позволят получить файлы Thermostat Heating Setpoint Capability:

  1. Получить json Thermostat Heating Setpoint Capability и записать его в файл custom.json

    $./smartthings capabilities thermostatHeatingSetpoint 1 -j -o custom.json

  2. Получить json UI-представления Thermostat Heating Setpoint Capability и записать его в файл customPresentation.json

    $./smartthings capabilities:presentation thermostatHeatingSetpoint 1 -j -o customPresentation.json

Изменять скорее всего придется UI-представление. Оно состоит из 3 частей: Dashboard, Detail View и Automation. В нашем примере не планировалось использование кастомного Capability в дашборде и в сценариях автоматизации. Поэтому изменения сделаны в части Detail View. Detail View содержит информацию о том, какие компоненты будут отображаться для этого Capability. Например, переключатель, список, текстовое поле и др. У нас это stepper, который позволяет по кнопкам +/- увеличивать и уменьшать значение температуры нагрева. Диапазон значений stepper’а задан от 0 до 100 - это изменение диапазона и было нашей целью.Подробнее о компонентах, которые можно использовать в Detail View написано в документации capabilities.  

Добавление в профиль устройства

В профиле устройств Capability и его представление становятся доступными для выбора не сразу. Иногда это занимает пару минут после создания, иногда больше часа. Далее выбор не отличается от выбора стандартных Capability. Но упростить поиск можно, отметив в фильтре “My capabilities” вместо “SmartThings”:

Использование в коде прошивки  

Использование кастомных capabilities в коде прошивки также не отличается от использования стандартных capabilities. Главное здесь правильно передавать идентификатор, названия атрибутов и команд. С этим помогают заголовочные файлы-помощники. Каждому Capability соответствует файл-помощник с именем iot_caps_helper_{capabilityName}.h. Далее в коде происходит обращение к структуре из файла-помощника, которая содержат правильные идентификатор, название атрибута, команды и другие свойства Capability. Мы можем создать такой файл-помощник для нашего кастомного Capability или заменить идентификатор у Capability, которое мы взяли за основу. Второй вариант подразумевает, что файлом-помощником базового Capability мы уже не сможем воспользоваться, он превратится в файл-помощник нашего кастомного Capability. Ниже представлено содержание файла-помощника iot_caps_helper_thermostatHeatingSetpoint.h с измененными идентификатором, именем атрибута и названием команды.

enum {
  CAP_ENUM_THERMOSTATHEATINGSETPOINT_HEATINGSETPOINT_UNIT_F,
  CAP_ENUM_THERMOSTATHEATINGSETPOINT_HEATINGSETPOINT_UNIT_C,
  CAP_ENUM_THERMOSTATHEATINGSETPOINT_HEATINGSETPOINT_UNIT_MAX
};
 
const static struct iot_caps_thermostatHeatingSetpoint {
  const char *id;
  const struct thermostatHeatingSetpoint_attr_heatingSetpoint {
    const char *name;
    const unsigned char property;
    const unsigned char valueType;
    const char *units[CAP_ENUM_THERMOSTATHEATINGSETPOINT_HEATINGSETPOINT_UNIT_MAX];
    const char *unit_F;
    const char *unit_C;
    const double min;
    const double max;
  } attr_heatingSetpoint;
  const struct thermostatHeatingSetpoint_cmd_setHeatingSetpoint { const char* name; } cmd_setHeatingSetpoint;
} caps_helper_thermostatHeatingSetpoint = {
  // заменяем старый идентификатор нашим
  // .id = "thermostatHeatingSetpoint",
  .id = "titledouble20168.heatingSetpoint",
  .attr_heatingSetpoint = {
    // Если в нашем Capability название атрибута отличается, то меняем
    // .name = "heatingSetpoint",
    .name = "temperature",
    .property = ATTR_SET_VALUE_MIN | ATTR_SET_VALUE_MAX | ATTR_SET_VALUE_REQUIRED | ATTR_SET_UNIT_REQUIRED,
    .valueType = VALUE_TYPE_NUMBER,
    .units = {"F", "C"},
    .unit_F = "F",
    .unit_C = "C",
    .min = -460,
    .max = 10000,
  },
  // Если в нашем Capability название команды отличается, то меняем
  // .cmd_setHeatingSetpoint = { .name = "setHeatingSetpoint" },
  .cmd_setHeatingSetpoint = { .name = "setTemperature" },
};

Заключение

На примере устройства "Умный чайник" мы разобрали, как пишется код прошивки устройства, интегрированного в платформу SmartThings. Мы разобрали структуру типичного проекта, узнали, как связывать Capability с возможностями устройства и что делать, если каких-то Capability не хватает. Теперь вы сможете интегрировать ваше устройство умного дома в экосистему SmartThings, взаимодействовать с ним через мобильное приложение, включать его в сценарии автоматизации.

Видео того, что получилось:

Об авторе

Ниез Юлдашев - Студент магистратуры ИТИС КФУ по специальности Программная инженерия (профиль: Аналитика, управление разработкой и FinTech),  стажёр Исследовательского центра Samsung.