Создание плагинов

Весь функционал системы реализован в виде плагинов (модулей). Даже такие базовые возможности как поддержка сценариев и обработка HTTP запросов – это отдельные плагины (если очень захотеть, система будет работать и без них).

Вы можете написать собственные плагины и подключить их к системе. Сделать это очень легко. При помощи собственных плагинов можно выполнять любые действия, которые может выполнять компьютер (взаимодействовать с любым оборудованием, получать и отправлять данные через Интернет и еще много всего интересного).

По сути, плагин – это класс .NET, написанный по некоторым правилам. Этот класс нужно скомпилировать в файл .dll и скопировать в папку для плагинов. Сервис при старте создаст экземпляр вашего плагина и в нужные моменты будет запускать логику, которая в нем реализована.

Для создания собственного плагина необходимо выполнить 5 простых шагов:

  1. Создать проект в любой IDE и добавить в него ссылку на библиотеку ThinkingHome.Core.Plugins (самый простой способ сделать это – набрать в консоли Nuget Package Manager Install-Package ThinkingHome.Core.Plugins). Бесплатные IDE типа Visual Studio Express и SharpDevelop вполне подойдут.
  2. Создать класс и пометить его атрибутом [ThinkingHome.Core.Plugins.PluginAttribute];
  3. Унаследовать созданный класс от базового класса ThinkingHome.Core.Plugins.PluginBase и, при необходимости, переопределить его виртуальные методы void InitPlugin() (инициализация),void StartPlugin()(выполняется при запуске сервиса, когда все плагины уже инициализорованы),void StopPlugin()` (выполняется при остановке сервиса).
  4. Реализовать нужную логику плагина.
    ...
  5. PROFIT!!!

Ниже приведен пример простого плагина. Как видите, это, действительно, очень просто.

using ThinkingHome.Core.Plugins;
...
[Plugin]
public class ExamplePlugin : PluginBase
{
    private System.Timers.Timer timer;

    public override void InitPlugin()
    {
        timer = new System.Timers.Timer(30000);
        timer.Elapsed += OnTimedEvent;
    }

    public override void StartPlugin()
    {
        timer.Enabled = true;
    }

    public override void StopPlugin()
    {
        timer.Enabled = false;
    }

    private void OnTimedEvent(object source, ElapsedEventArgs e)
    {
        // по таймеру каждые 30 секунд пишем сообщение в лог
        Logger.Info("Hello, world!");
    }
}

Подключение плагина к сервису и отладка

Как мы уже знаем, плагин – это класс .NET, находящийся в скомпилированной сборке (т.е. в файле .dll). Чтобы ваш плагин был загружен сервисом, необходимо положить его в папку с плагинами. Путь к папке с плагинами задается в конфигурационном файле сервиса (ThinkingHome.Service.exe.config) в разделе appSettings при помощи параметра с названием pluginsFolder.

<appSettings>
    <add key="pluginsFolder" value="Plugins"/>
</appSettings>

По умолчанию используется подпапка Plugins, находящаяся в папке, куда был установлен сервис.

Внутри папки с плагинами находятся несколько вложенных папок – по одной на каждый плагин. Вам нужно создать новую подпапку для своего плагина и положить туда все файлы, необходимые для его работы (т.е. саму сборку с плагином и внешние компоненты, которые он использует). Таким образом, структура папок будет примерно такая:

Program Files
└─ ThinkingHome
    └─ service
        └─ Plugins
            ├─ Plugin.A
            ├─ Plugin.B
            │  ...
            └─ MyPlugin
                ├─ MyPlugin.dll
                ├─ ExternalLibrary1.dll
                └─ ExternalLibrary2.dll

Для отладки используйте консольное приложение, ThinkingHome.TestConsole.exe, которое находится в одной папке с сервисом. Это консольное приложение работает точно так же, как сервис, но с ним немного удобнее работать при отладке.

Для того, чтобы войти в режим отладки, добавьте в нужное место вызов статического метода Launch() класса System.Diagnostics.Debugger и скомпилируйте сборку в режиме Debug. После этого положите скомпилированную DLL в папку с плагинами и запустите отладочное консольное приложение. Когда выполнение дойдет до вызова Debugger.Launch(), приложение будет остановлено и вы увидите диалоговое окно с предложением запустить отладчик (например, Visual Studio). В отладчике вы увидите написанный вами исходный код плагина и сможете выполнить его по шагам.

using System.Diagnostics;
...
[Plugin]
public class MyPlugin : PluginBase
{
    ...
    public void MyMethod(int arg1, int arg2)
    {
        Debugger.Launch();

        ...
    }
}

В свойствах проекта на вкладке Build можно указать в качестве значения параметра Output path путь к папке плагина (например, C:\Program Files\ThinkingHome\service\Plugins\MyPlugin). После этого на вкладке Debug в разделе Start application можно выбрать вариант Start external program и указать путь к тестовому консольному приложению. После этого вы сможете запускать отладку из Visual Studio (нажатием кнопки F5). Это очень удобный способ работы с проектом, попробуйте его :)

Логирование

Логирование – самый простой способ получить информацию из плагина. Базовый класс ThinkingHome.Core.Plugins.PluginBase (от которого наследуются все плагины) имеет поле Logger, с помощью которого можно записывать сообщения в лог.

Logger.Info("Hello, world!");
Logger.Error("Error message");

По умолчанию лог сохраняется в текстовый файл в папке Log. Для каждого плагина создается отдельный файл, имя которго соответствует названию класса плагина. Также лог делится по датам (лог за каждый день сохраняется в отдельном файле).

Для логирования в системе используется библиотека NLog. Настройки логирования можно задать в конфигурационном файле приложения (файл ThinkingHome.Service.exe.config в корневой директории сервиса). Например, можно настроить, чтобы ошибки сохранялись в БД или отправлялись на e-mail (подробности – в документации NLog).

Вызов команд плагинов

Как мы знаем, каждый плагин – это класс .NET, содержащий некоторый функционал. Из любого плагина можно получить экземпляр любого другого плагина и вызывать его открытые (public) методы. Например, ваш плагин может получить экземпляр плагина, управляющего освещением через nooLite и вызвать у него метод выключения света в заданном канале.

Получить экземпляр другого плагина можно получить через свойство Context базового класса ThinkingHome.Core.Plugins.PluginBase. Это свойство содержит специальный объект, реализующий интерфейс ThinkingHome.Core.Plugins.IServiceContext. Для получения экземпляра другого плагина используйте метод TPlugin GetPlugin<TPlugin>(). Перед этим необходимо добавить в проект вашего плагина ссылку на сборку, в которой находится используемый плагин.

var noolitePlugin = Context.GetPlugin<NooLitePlugin>(); 

// устанавливаем уровень яркости 50 в третьем канале
noolitePlugin.SetLevel(50, 3);

Метод IReadOnlyCollection<PluginBase> GetAllPlugins() возвращает коллекцию всех плагинов, доступных в приложении.

// вывод в лог названий всех доступных плагинов  
foreach (PluginBase plugin in Context.GetAllPlugins())
{
    var typeName = plugin.GetType().Name;
    Logger.Info(typeName);
}

Подписка на события плагинов

Плагины могут подписываться на события друг друга. Например, плагин "будильник" может подписаться на событие "срабатывание таймера" плагина "таймер" и, если наступило нужное время, подать звуковой сигнал.

Плагин, генерирующий событие, определяет у себя коллекцию делегатов – обработчиков события. Другие плагины объявляют у себя методы-обработчики для заданного события и связывают их с коллекцией делегатов. Связывание происходит при помощи MEF (является чатью .NET Framework, начиная с версии 4.0).

Для того, чтобы разрешить другим плагинам подписываться на событие, нужно определить коллекцию делегатов с нужными параметрами и отметить ее атрибутом System.ComponentModel.Composition.ImportManyAttribute. Чтобы в список обработчиков добавлялись только нужные обработчики, а не все методы с такой же сигнатурой (такими же типами параметров и возвращаемого значения), укажите для атрибута [ImportMany] уникальный идентификатор (например, сгенерируйте Guid). Ниже приведен пример описания коллекции обработчиков для события срабатывание таймера плагина таймер (он генерирует событие срабатывание таймера каждые 30 секунд).

// описывается в плагине, генерирующем событие
[ImportMany("E62C804C-B96B-4CA8-822E-B1725B363534")]
public Action<DateTime>[] OnEvent { get; set; }

Теперь другие плагины могут создавать обработчики для этого события. Сигнатура обработчика события задается типом элемента коллекции обработчиков. В нашем примере тип элемента – Action<DateTime>, значит, обработчик события должен принимать один параметр типа DateTime (текущее время) и не возвращать никакого значения (void).

Ниже приведен пример обработчика события срабатывание таймера, описанного в предыдущем примере. Этот обработчик включает будильник, если настал установленный момент времени.

// описывается в плагине, обрабатывающем событие
public void OnTimerElapsed(DateTime now)
{
    // время звонка будильника – 8:15
    var alarmTime = now.Date.AddHours(8).AddMinutes(15);	

    if (now >= alarmTime &&               // если пришло время звонка будильника
        now < alarmTime.AddMinutes(5) &&  // и еще не прошло 5 минут
        lastAlarmTime < alarmTime)        // и будильник сегодня еще не звонил
    {
        lastAlarmTime = now;
        PlaySound();
    }
}

Для того, чтобы этот метод попал в коллекцию обработчиков события, нужно пометить его атрибутом System.ComponentModel.Composition.ExportAttribute и указать в его параметрах тот же самый идентификатор, который был указан в атрибуте [ImportMany] (у коллекции обработчиков).

[Export("E62C804C-B96B-4CA8-822E-B1725B363534")]
public void OnTimerElapsed(DateTime now)
{
    ...
}

Для более удобной подписки на события (без необходимости указывать идентификатор) можно создать собственный атрибут, унаследованный от System.ComponentModel.Composition.ExportAttribute и передать нужный идертификатор при вызове базового конструктора.

// собственный атрибут
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class OnTimerElapsedAttribute : ExportAttribute
{
    public OnTimerElapsedAttribute()
        : base("E62C804C-B96B-4CA8-822E-B1725B363534")
    {
    }
}

...
	
// подписка на событие
[OnTimerElapsed]
public void OnTimerElapsed(DateTime now)
{
    ...
}

Итак, мы пометили атрибутами коллекцию делегатов и нужные методы плагинов. При старте сервиса указатели на нужные методы автоматически будут добавлены в коллекцию делегатов. После этого остается только вызвать все подписанные методы при возникновении нужного события.

Для вызова обработчиков события лучше всего воспользоваться методом Run из базового класса плагина. В качестве параметров передается сама коллекция делегатов и лямбда-выражение, задающее действие над каждым из них (т.е. вызов делегата с нужными параметрами). Внутри метод Run проходит в цикле по всем обработчикам и вызывает для каждого из них выражение, переданное во втором параметре (с обработкой исключений).

Пример, приведенный ниже – из плагина таймер: при срабатывании системного таймера вызываются все подписанные обработчики, в каждый из которых передается текущие дата и время.

// коллекция обработчиков события
[ImportMany("E62C804C-B96B-4CA8-822E-B1725B363534")]
public Action<DateTime>[] OnEvent { get; set; }

// инициализируем таймер
public override void Init()
{
    timer = new System.Timers.Timer(TIMER_INTERVAL);
    timer.Elapsed += OnTimedEvent;
}

// при срабатывании таймера вызываем обработчики события
private void OnTimedEvent(object source, ElapsedEventArgs e)
{
    var now = DateTime.Now;
    Run(OnEvent, x => x(now));
}

Доступ к БД

Плагины могут хранить свои данные в системной БД (используется MS SQL Server CE). Например, плагин будильник может хранить там список настроек будильников.

Создание таблиц

Плагины могут автоматически создавать себе необходимые таблицы в БД. Для этого используется ECM7.Migrator – инструмент контроля версий БД. С его помощью можно описать "миграции" – небольшие порции изменений БД. Каждая миграция имеет номер версии, в которой будет находиться БД после ее применения (нумерация версий ведется отдельно по каждому плагину). Мигратор автоматически выполняет миграции в нужной последовательности. При старте сервиса выполняются миграции, описанные в плагинах и автоматически создаются все нужные объекты БД.

Каждая миграция – это класс .NET, унаследованный от базового класса Migration (нужно добавить ссылку на сборку ECM7.Migrator.Framework.dll) и реализующий его абстрактные методы Apply и Revert. В этих методах при помощи специального API описываются изменения БД, необходимые для перехода к следующей версии и для отката к изначальной версии. Номер версии задается с помощью атрибута [MigrationAttribute].

[Migration(1)]
public class Migration01UserScriptTable : Migration
{
    public override void Apply()
    {
        Database.AddTable("Scripts_UserScript",
            new Column("Id", DbType.Guid, ColumnProperty.PrimaryKey, "newid()"),
            new Column("Name", DbType.String.WithSize(200), ColumnProperty.NotNull),
            new Column("Body", DbType.String.WithSize(int.MaxValue), ColumnProperty.NotNull)
        );
    }
    public override void Revert()
    {
        Database.RemoveTable("Scripts_UserScript");
    }
}

Внимание, сборка с плагином обязательно должна быть помечена атрибутом MigrationAssembly, в параметрах которого должно быть указано уникальное пространство имен для миграций (в качестве пространства имен рекомендуется использовать имя сборки). Это нужно, чтобы изменения из разных плагинов выполнялись независимо друг от друга. Например:

[assembly: MigrationAssembly("ThinkingHome.Plugins.Scripts")]

Модель данных

Работа с БД происходит с помощью ORM NHibernate. Инфраструктуру для доступа к данным ядро системы инициализирует самостоятельно. В плагине остается только определить классы модели данных и настроить мэппинг полей классов на поля таблиц.

Для настройки мэппинга переопределите в своем плагине виртуальный метод InitDbModel. В качестве входного параметра этот метод получает экземпляр классса NHibernate.Mapping.ByCode.ModelMapper. Вызывая его методы, можно полностью настроить мэппинг для собственной модели. Например, плагин Scripts настраивает мэппинг для своей модели примерно так:

public class ScriptEventHandler
{
    public virtual Guid Id { get; set; }
    ...
}

[Plugin]
public class ScriptsPlugin : PluginBase
{
    public override void InitDbModel(ModelMapper mapper)
    {
        mapper.Class<ScriptEventHandler>(cfg => cfg.Table("Scripts_UserScript"));
        ...
    }
    ...
}

По умолчанию при мэппинге действуют следующие правила наименования:

Внимание! Все свойства классов модели должны быть виртуальными! Это связано с особенностями реализации NHibernate.

Например:

public class ScriptEventHandler
{
    // будет связано с полем "Id"
    public virtual Guid Id { get; set; }

    // будет связано с полем "EventAlias"
    public virtual string EventAlias { get; set; }

    // будет связано с полем "UserScriptId"
    public virtual UserScript UserScript { get; set; }
}

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

Работа с данными

Для работы с данными необходимо открыть подключение к БД (в терминах NHibernate это называется session). Для этого нужно вызвать метод ISession OpenSession() у контекста приложения (свойство Context базового класса плагина). Метод OpenSession() вернет объект, реализующий интерфейс ISession, через методы которого можно работать с БД (например, получать данныые из БД или изменять их).

Метод ISession.Query<TEntity>() возвращает значение IQueryable<TEntity> с которым можно дальше работать через Linq. Например, плагин Scripts получает список сценариев следующим образом:

using (var session = Context.OpenSession())
{
    var list = session.Query<UserScript>().ToArray();
    ...
}

Для сохранения объектов в БД используйте метод сессии Save, для удаления – метод Delete. Ниже приведен пример их использования. Для фиксации изменений в БД в конце вызовите у сессии метод Flush.

using (var session = Context.OpenSession())
{
    // создаем новй объект UserScript
    var newScript = new UserScript
        { 
            Id = Guid.NewGuid(),
            Name = "script name",
            Body = "script body"
        };

    // сохраняем его в БД
    session.Save(newScript);

    // ищем в БД объект с именем "test"
    var scriptForDelete = session
        .Query<UserScript>()
        .FirstOrDefault(s => s.Name == "test");

    // удаляем его из БД
    session.Delete(scriptForDelete);
    session.Flush();
}

Внимание! Не забываете вызывать метод Dispose() у объекта session (или используйте конструкцию using).

API стандартных плагинов

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

Работа с таймером

Стандартный плагин ThinkingHome.Plugins.Timer позволяет выполнять необходимые действия при наступлении некоторого момента времени. Этот плагин генерирует событие срабатывание таймера через равные промежутки времени (каждые 30 секунд).

Вы можете подписаться на это событие при помощи атрибута ThinkingHome.Plugins.Timer.OnTimerElapsedAttribute. Обработчик события должен принимать один параметр типа DateTime – в нем будет передаваться текущие дата и время. По значению этого параметра можно проверить, наступил ли нужный момент времени. Обработчик не должен возвращать ничего (void).

[OnTimerElapsed]
public void MyHandler(DateTime now)
{
    ...
}

Периодическое выполнение действий

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

Для периодического выполнения нужных вам действий используйте атрибут ThinkingHome.Plugins.Timer.Attributes.RunPeriodically, описанный в библиотеке ThinkingHome.Plugins.Timer. Просто добавьте этот атрибут к нужному методу своего плагина (метод должен быть без параметров и без возвращаемого значения) и передайте в качестве входного параметра нужную длительность периода между запусками (в минутах).

[RunPeriodically(15)]
public void AutomaticUpdate()
{
    // обновляем данные раз в 15 минут
    Logger.Info("automatic update data");
    ReloadData();
    Logger.Info("update completed");
}

Чтобы не произошел массовый запуск периодических действий при старте сервиса, перед первым выполнением каждого действия будет добавлена случайная задержка. Ее длительность не превышает период, заданный для действия.

Т. е. если вы добавите для какого-то метода атрибут [RunPeriodically(10)], то этот метод будет выполнен не позже, чем через 10 минут после старта сервиса, а после этого будет запускаться через каждые 10 минут.

Обработка HTTP-запросов

Стандартный плагин ThinkingHome.Plugins.Listener позволяет вызывать методы других плагинов по протоколу HTTP. Listener обрабатывает запросы на порт, указанный в настройках, и перенаправляет их плагинам, для которых они предназначены. Другими словами, с помощью плагина Listener другие плагины могут расшаривать свои команды для внешних приложений. Например, веб-интерфейс работает именно таким образом.

Самый легкий способ подключить плагин ThinkingHome.Plugins.Listener в свой проект – через менеджер пакетов NuGet. Просто наберите в консоли менеджера пакетов Install-Package ThinkingHome.Plugins.Listener.

Для того, чтобы метод плагина мог быть вызван по HTTP, необходимо, чтобы он принимал один параметр типа ThinkingHome.Plugins.Listener.Api.HttpRequestParams, возвращад значение типа object и был помечен атрибутом ThinkingHome.Plugins.Listener.Attributes.HttpCommandAttribute.

using ThinkingHome.Plugins.Listener.Api;
using ThinkingHome.Plugins.Listener.Attributes;
...

[HttpCommand("/api/my-plugin/my-method")]
public object MyMethod(HttpRequestParams request)
{
    var result = ...
    ...
    return result;
}

По сути, при получении HTTP-запроса Listener генерирует событие получен HTTP-запрос и вызывает обраобтчики, подписанные на это событие. Атрибут [HttpCommand] нужен как раз для того, чтобы назначить помеченный им метод в качестве обработчика этого события. Атрибут [HttpCommand] имеет единственный параметр – относительный URL запроса (должен начинаться с символа /). Обработчик будет вызван, если относительный URL запроса совпадает со значением, указанным в атрибуте [HttpCommand].

Например, описанный выше метод будет вызываться для запросов http://localhost:41831/api/my-plugin/my-method.

Внимание! Если в плагинах, подключенных к системе, будет содержаться более одного обработчика для обного и того же адреса, в методе InitPlugin плагина Listener будет сгенерировано исключение и сервис не будет запущен. Информация об исключении будет записана в лог.

Как было указано выше, методы-обработчики HTTP запросов принимают единственный параметр – экземпляр класса ThinkingHome.Plugins.Listener.Api.HttpRequestParams. С его помощью можно получить значения параметров запроса (как параметров URL, так и POST-параметров).

public class HttpRequestParams
{
    // коллекция параметров URL
    public readonly NameValueCollection UrlData;

    // коллекция POST параметров
    public readonly NameValueCollection FormData;

    // получение необзятельных параметров (переданых любым способом)
    // если параметр отсутствует или не может быть преобразован
    // в значение нужного типа, то возвращается null
    public string GetString(string name);
    public int? GetInt32(string name);
    public Guid? GetGuid(string name);
    public bool? GetBool(string name);

    // получение обзятельных параметров (переданых любым способом)
    // если параметр отсутствует или не может быть преобразован
    // в значение нужного типа, генерируется исключение
    public string GetRequiredString(string name);
    public int GetRequiredInt32(string name);
    public Guid GetRequiredGuid(string name);
    public bool GetRequiredBool(string name);
}

Обработчики HTTP запросов должный возвращать значение object. Оно будет сериализовано в JSON и передано на клиент. Если вы не хотите возвращать на клиент значение, напишите в обработчике return null;.

Пример обработки HTTP запроса из плагина WeatherUIPlugin (обновление прогноза погоды для выбранного города):

[HttpCommand("/api/weather/locations/update")]
public object UpdateLocationWeather(HttpRequestParams request)
{
    // получаем ID города из параметров запроса
    var locationId = request.GetRequiredGuid("locationId");

    // обновляем погоду
    Context.GetPlugin<WeatherPlugin>().ReloadWeatherData  (locationId);

    // возвращаем null
    return null;
}

Обращение по HTTP к файлам из ресурсов плагина

Плагин ListenerPlugin также позволяет назначать URL для файлов, находящихся в ресурсах плагина (Embedded Resource). Т.е. вы можете пометить плагин специальным атрибутом ThinkingHome.Plugins.Listener.Attributes.HttpEmbeddedResourceAttributeи в его параметрах указать, какой файл нужно вернуть по какому URL. Например, эта возможность используется для подключения в веб-интерфейс шрифта с погодными иконками:

// eot-версия шрифта
[HttpEmbeddedResource(
    "/webapp/weather/fonts/weathericons-regular-webfont.eot",
    "ThinkingHome.Plugins.Weather.Resources.fonts.weathericons-regular-webfont.eot",
    "application/vnd.ms-fontobject")]

// svg-версия шрифта
[HttpEmbeddedResource(
    "/webapp/weather/fonts/weathericons-regular-webfont.svg",
    "ThinkingHome.Plugins.Weather.Resources.fonts.weathericons-regular-webfont.svg",
    "image/svg+xml")]

    ...

Собственные типы ресурсов

Бывают ситуации, когда файлы, которые нужно возвращать на клиент, неудобно хранить во встроенных ресурсах DLL. Например, они могут находиться в файловой системе, в БД или в любых других местах. Также иногда необходимо выполнить дополнительную обработку содержимого файла, перед тем, как он будет передан на клиент. В этом случае вы можете описать собственные типы http-ресурсов.

Собственный тип ресурсов позволяет описать, каким образом формируется содержимое файла, который нужно вернуть в ответ на http-запрос. При этом к вашему типу ресурсов будет автоматически применяться та же логика, связанная с обработкой URL и кэшированием, которая применяется к стандартным типам ресурсов.

Для описания собственного типа ресурсов создайте класс, унаследованный от абстрактного класса ThinkingHome.Plugins.Listener.Attributes.HttpResourceAttribute, опишите для него конструктор с нужными параметрами (в нем вызовите конструктор базового класса) и реализуйте метод byte[] GetContent(Assembly assembly).

Например, примерно вот так можно описать тип ресурсов, отдающий файлы из файловой системы:

public class FileSystemResourceAttribute : HttpResourceAttribute
{
    // поле для хранения пути к файлу
    private readonly string path;

    // конструктор принимает параметры: url и путь к  файлу
    // внутри вызывается конструктор базового класса HttpResourceAttribute
    public FileSystemResourceAttribute(string url, string path)
        : base(url, "application/octet-stream")
    {
        this.path = path;
    }

    // этот метод формирует контент файла, отдаваемого на клиент
    public override byte[] GetContent(System.Reflection.Assembly assembly)
    {
        return File.ReadAllBytes(path);
    }
}

Теперь вы можете пометить свой плагин атрибутом FileSystemResource, который мы только-что описали, и указать ему в параметрах url и путь к файлу на диске. После этого по указанному url в браузере будет открываться файл, путь к которому вы указали.

Выбор URL для методов и ресурсов плагина

Стандартные плагины следуют правилу формирования URL, согласно которому:

Рекомендуем вам в собственных плагинах выбирать адреса аналогичным способом.

Клиент-серверная шина сообщений

Плагин Listener имеет средства для передачи сообщений между сервером и клиентом (например, браузером), причем инициировать отправку сообщения может как клиент, так и сервер. В начале работы плагин Listener создает SignalR Hub (сейчас используется SignalR версии 2.2.0) с именем messagequeuehub. В него можно отправлять сообщения с клиента и сервера и эти сообщения будут получены всеми клиентами, подключенными в текущий момент.

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

Для отправки сообщений на стороне сервера используйте метод Send плагина Listener, передав в качестве параметров данные для отправки (объект, он будет сериализован в JSON) и название канала (по сути это идентификатор типа сообщения).

Например, плагин, собирающий информацию с датчиков микроклимата, отправляет на клиент обновленную информацию о температуре и влажности примерно так:

Context.GetPlugin<ListenerPlugin>().Send(
    "microclimate:sensor:update",
    new { id = sensor.Id, t = intTemperature, h = humidity });

При этом на клиент будет отправлена следующая информация:

Чтобы получить отправленные сообщения в браузере: подключите на страницу библиотеки jQuery и ASP.NET SignalR 2.2.0 (например, из CDN).

var connection = $.hubConnection(),
    hub = connection.createHubProxy('messageQueueHub');

// получение сообщений
hub.on('serverMessage', function(message){
    /* message:
    {
        "guid":"f7d14f74-b5d9-4439-926f-e30f4a686a77",
        "timestamp":"2016-09-12T22:59:41.5679055+03:00",
        "channel":"channel:name",
        "data": { "field1": "value1", "field2": "value2"}
    } */
    ...
});

// открываем соединение
connection.start();

// если подключаемся с другого домена, нужно добавить параметр jsonp: true 
// connection.start({ jsonp: true });

// отправка сообщений
hub.invoke('Send', channel, data);

Параметры плагина Listener

Вы можете настраивать параметры работы плагина Listener в настройках приложения: в файле ThinkingHome.Service.exe.config (параметры тестового приложения находятся в файле ThinkingHome.TestConsole.exe.config).

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <!-- номер порта -->
        <add key="Listener.Port" value="41831" />
        <!-- разрешить/запретить отправку сообщений в шину с других доменов через JSONP -->
        <add key="Listener.Hub.EnableJSONP" value="true" />
        ...
    </appSettings>
    ...
</configuration>

Управление электроприборами

Плагин ThinkingHome.Plugins.NooLite.NooLitePlugin позволяет управлять электроприборами через систему nooLite и обрабатывать команды от пультов и датчиков nooLite, полученные USB-адаптером RX1164/2164. Подключить плагин ThinkingHome.Plugins.NooLite в свой проект можно через менеджер пакетов NuGet. Наберите в консоли менеджера пакетов Install-Package ThinkingHome.Plugins.NooLite.

Для отправки команд силовым блокам nooLite через USB-адаптер PC11xx используйте метод void SendCommand(int command, int channel, int level). Этот метод принимает 3 аргумента: код команды, номер канала и уровень яркости, который нужно установить в канале (если для команды не нужен уровень яркости, передайте любое значение, например, 0).

// устанавливаем в 7 канале уровень яркости = 100%
// 6 – код команды "установить уровень яркости"
// 7 – номер канала
// 255 – уровень яркости (byte)
Context.GetPlugin<NooLitePlugin>().SendCommand(6, 7, 255);

Для отправки команд силовому блоку nooLite SD111-180 (управляет светодиодными RGB-лентами) используйте метод void SendLedCommand(int ledCommand, int channel, int levelR = 0, int levelG = 0, int levelB = 0). Он принимает такой же набор аргументов, как метод SendCommand, но уровень яркости задается тремя значениями: отдельно для красного, зеленого и синего каналов.

// устанавливаем в 4 канале фиолетовый цвет светодиодной RGB-ленты
Context.GetPlugin<NooLitePlugin>().SendLedCommand(6, 4, 255, 0, 255);

Для удобства изменения уровня яркости плагин NooLitePlugin имеет еще две команды: void SetLevel(int channel, int level) и void SetRgbLevel(int channel, int levelR, int levelG, int levelB). Они работают аналогично командам SendCommand и SendLedCommand соответственно, но им не нужно передавать первый аргумент (код команды). Можно считать, что он всегда имеет значение 6 (установить уровень яркости).

События плагина NooLitePlugin

Для подписки на событие получена команда USB-адаптера RX1164/2164 используйте атрибут ThinkingHome.Plugins.NooLite.OnRXCommandReceivedAttribute. Обработчики этого события получают три аргумента:

[OnRXCommandReceived]
public void MyNooLiteRXHandler(int cmd, int channel, byte[] data)
{
    Logger.Info("Получена команда {0} в {1} канале", cmd, channel);
}

Плагин NooLitePlugin также предоставляет специальное событие получены данные микроклимата, которое генерируется, если полученная команда содержит информацию о температуре/влажности с датчиков nooLite.

Для добавления собственного обработчика события получены данные микроклимата используйте атрибут ThinkingHome.Plugins.NooLite.OnMicroclimateDataReceivedAttribute. Обработчики этого события не должны возвращать никакого значения (void) и должны принимать три входных параметра:

[OnMicroclimateDataReceived]
public void MyMicroclimateHandler(int channel, decimal temperature, int humidity)
{
    Logger.Info("Microclimate: c={0}, t={1}, h={2}", channel, temperature, humidity);
}

HTTP API управления электроприборами

Плагин ThinkingHome.Plugins.NooApi реализует web-API для управления электроприборами. Управление происходит при помощи плагина ThinkingHome.Plugins.NooLite, описанного выше.

Обработчик api-запросов находится по адресу http://ip-of-server/api/noolite. Параметры команды передаются через GET или POST параметры запроса. Например, запрос, устанавливающий уровень яркости 100% в 1-ом канале адаптера PC11XX будет выглядеть примерно так: http://ip-of-server/api/noolite?ch=1&cmd=6&br=100. При успешном выполнении команды возвращается ответ с кодом 200 и текстом "OK". Если произошла ошибка, возвращается ответ с кодом 500 и краткой информацией об ошибке.

Аргументы запроса (значения всех аргументов – целые числа):

Команда 6 (Set) имеет несколько режимов управления, в зависимости от переданных аргументов.

Взаимодействие с устройствами по протоколу MQTT

Основы MQTT

MQTT (Message Queue Telemetry Transport) – лёгкий сетевой протокол работающий поверх TCP/IP. Он используется для обмена сообщения между устройствами по принципу издатель-подписчик (publish–subscribe).

Протокол MQTT требует обязательного наличия брокера данных. Брокер — это программа, выполняющая функции TCP сервера и организующая обмен сообщений между клиентами. Все устройства (клиенты) посылают данные только брокеру и принимают данные тоже только от него.

Сообщение MQTT – это набор байтов. Для каждого отправляемого сообщения необходимо указать название канала (Topic name) – идентификатор, зная который клиенты могут подписаться на сообщения в канале. При поступлении сообщения его автоматически получат все клиенты, подписанные на канал, в который оно было отправлено.

Названия каналов – это строки в кодировке UTF-8. Названия каналов могут состоять из одной или нескольких частей, котрые называются "уровни" (topic level). Для разделения уровней используют символ / (прямой слэш).

MQTT – структура названия канала

Название канала должно состоять хотя бы из одного символа. Название канала может содержать пробелы. Название канала зависит от регистра: назвния myhome/temperature и MyHome/Temperature соответствуют двум разным каналам.

Для подписки сразу на несколько каналов вы можете использовать маски (wildcards).

Маска + означает один любой уровень.

MQTT – маска + MQTT – примеры с маской +

Маска # означает любое количество уровней.

MQTT – маска + MQTT – примеры с маской +

Плагин ThinkingHome.Plugins.Mqtt

Плагин ThinkingHome.Plugins.Mqtt – это MQTT-клиент. При старте сервиса он подключается к MQTT-брокеру, заданному в настройках приложения и получает все сообщения в заданном канале. Полученные сообщения сохраняются в БД (хранится только последнее сообщение для каждого канала).

Вы можете получить из БД последнее сообщение в канале с помощью метода MqttMessage Read(string path, ISession session = null). Первым параметром необходимо передать полное название канала. Второй параметр (необязательный) – сессия NHibernate, которая будет использоваться для получения информации из БД. Передавайте второй параметр, если хотите прочитать данные для нескольких каналов в одной сессии.

var msg = Context.GetPlugin<MqttPlugin>().Read("parts/of/topic/name");

Кроме того, плагин ThinkingHome.Plugins.Mqtt предоставляет событие, которое происходит в момент получения нового MQTT-сообщения и сохранения его в БД. Для того, чтобы вызвать метод своего плагина при получении MQTT-сообщения, пометьте его атрибутом ThinkingHome.Plugins.Mqtt.OnMqttMessageReceivedAttribute. Обработчик события должен принимать один параметр класса ThinkingHome.Plugins.Mqtt.MqttMessage и не должен возвращать значение (void).

[OnMqttMessageReceived]
public void MyMethod(MqttMessage message)
{
    Logger.Debug("TMP: new mqtt message {{ path: \"{0}\", data: \"{1}\" }}", message.path, message.GetUtf8String());
}

В примерах выше вы видели, что из метода Read и в обработчик [OnMqttMessageReceived] приходит экземпляр класса ThinkingHome.Plugins.Mqtt.MqttMessage. Это специальный класс, экземпляры которого содержат информацию о принятом MQTT-сообщении и предоставляют API для работы с ним.

namespace ThinkingHome.Plugins.Mqtt
{
    public class MqttMessage
    {
        // название канала
        public string path;

        // дата получения сообщения
        public DateTime timestamp;

        // полученные данные
        public byte[] message;

        // преобразовать данные в строку в кодировке UTF8
        public string GetUtf8String();

        // преобразовать данные в строку Base64
        public string GetBase64String();
    }
}

Настройки MQTT

Для подключения к MQTT-брокеру в настройках приложения необходимо указать его адрес и топик, в котором нужно получать сообщения. Сделать это можно в конфигурационном файле приложения: ThinkingHome.Service.exe.config (параметры тестового консольного приложения находятся в файле ThinkingHome.TestConsole.exe.config).

Укажите нужные значения параметров "Mqtt.Host" (название или IP-адрес компьютера, на котором работает MQTT-брокер) и "Mqtt.Path" (топик, в котором нужно получать сообщения). Эти два параметра являются обязательными.

Также вы можете указать необязательные параметры:

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <appSettings>
        <!-- топик, в котором нужно получать сообщения -->
        <add key="Mqtt.Path" value="#" />
        <!-- адрес MQTT-брокера -->
        <add key="Mqtt.Host" value="192.168.0.37" />
        <!-- номер порта -->
        <add key="Mqtt.Port" value="1883" />
        <!-- логин для подключения-->
        <add key="Mqtt.Login" value="mqtt-user" />
        <!-- пароль для подключения -->
        <add key="Mqtt.Password" value="123" />
        ...
    </appSettings>
    ...
</configuration>

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

Проигрывание звука

Вы можете проигрывать небольшие WAV файлы с помощью плагина ThinkingHome.Plugins.Audio.AudioPlugin. Он содержит единственный метод IPlayback Play(Stream stream, int loop = 1) который принимает на вход поток, содержащий WAV файл для проигрывания, и количество повторов (необязательный параметр, по умолчанию файл проигрывается один раз). Метод Play возвращает объект, реализующий интерфейс ThinkingHome.Plugins.Audio.IPlayback. Он содержит единственный метод void Stop(), с помощью которго можно остановить проигрывание файла до того, как он будет проигран заданное количество раз.

var playback = Context.GetPlugin<AudioPlugin>().Play(stream, 10);
...
playback.Stop();

Предоставление доступа сценариям к методам плагинов

В разделе о сценариях мы видели, что можно вызывать из сценариев методы плагинов. Примерно так:

host.executeMethod("methodAlias", "parameter1", 100500);

Таким образом можно вызывать не все методы плагинов, а только те, к которым разработчик плагина разрешил доступ из сценариев. Чтобы метод вашего плагина можно было вызывать из сценариев, необходимо пометить его специальным атрибутом ThinkingHome.Plugins.Scripts.ScriptCommandAttribute. Этот атрибут имеет обязательный параметр aliasназвание метода. По этому имени можно обращаться к методу из сценариев. В приведенном выше примере строка "methodAlias" – это название метода.

Например, вот так описан метод nooliteSetLevel плагина NooLitePlugin:

// название метода: nooliteSetLevel
[ScriptCommand("nooliteSetLevel")]
public void SetLevel(int channel, int level)
{
    ...
}

В качестве названия метода можно использовать любую строку (в т.ч. с пробелами). Старайтесь выбирать название так, чтобы оно было понятно и не повторялось в других плагинах. Если несколько плагинов будут содержать одинаковые названия сценарных методов, при запуске сервиса будет сгенерировано исключение.

Генерация сценарных событий

Плагины могут описывать события, в качестве обработчиков которых можно назначить сценарии. Т.е. пользователь может написать сценарий (через UI), зайти в раздел подписки на события (там автоматически отобразятся все доступные для сценариев события плагинов) и указать, при наступлении какого события какие сценарии нужно выполнить. Механизм подписки сценариев на события реализован в плагине ThinkingHome.Plugins.Scripts.ScriptsPlugin.

Для того, чтобы событие плагина было доступно для сценариев, необходимо при описании коллекции его обработчиков использовать специальный делегат ThinkingHome.Plugins.Scripts.ScriptEventHandlerDelegate. Также необходимо пометить коллекцию обработчиков специальным атрибутом ThinkingHome.Plugins.Scripts.ScriptEventAttribute (вместо атрибута ImportMany). Этот атрибут имеет обязательный параметр название события.

Пример описания сценарного события (из плагина ThinkingHome.Plugins.NooLite.NooLitePlugin):

[ScriptEvent("noolite.commandReceived")]
public ScriptEventHandlerDelegate[] OnCommandReceivedForScripts { get; set; }

Мы только-что разобрались, как описать коллекцию обработчиков для сценарного события. Плагин Scripts автоматически подпишется на это событие и будет автоматически запускать подписанные на него сценарии.

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

Например, вот так плагин NooLitePlugin запускает обработчики для события получена команда с адаптера RX1164/2164:

void rx_CommandReceived(ReceivedCommandData obj)
{
    ...
    this.RaiseScriptEvent(x => x.OnCommandReceivedForScripts, obj.Cmd, obj.Channel, obj.Data);
}

Что дальше?

В этом разделе мы узнали, как писать собственные плагины. В разделе UI для плагинов написано, как добавить в собственные плагины элементы пользовательского интерфейса, которые будут автоматически подключены в веб-интерфейс системы.