diff --git a/.gitignore b/.gitignore index b3fb23c..fd1c1e9 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .DS_Store .vs .claude +.agents +.cursor +.codex logs bin obj diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/IMtrfTransport.cs b/ThinkingHome.DeviceModel.Drivers.NooLite/IMtrfTransport.cs new file mode 100644 index 0000000..2c2c6ef --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/IMtrfTransport.cs @@ -0,0 +1,47 @@ +using ThinkingHome.NooLite; +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Drivers.NooLite; + +/// +/// Транспорт к адаптеру MTRF-64: тонкий шов над для подмены в тестах. +/// Реализация не содержит логики устройств — только отправку с воротами (интервал между записями, +/// общий на адаптер; см. ) и проброс событий. Ответы адаптера с командами +/// не сопоставляются: состояние блоков приходит событием независимо от +/// того, кто и когда отправил команду (design D1). +/// +internal interface IMtrfTransport +{ + /// Порт открыт и адаптер готов к обмену. + bool IsOpen { get; } + + /// Число пакетов, отброшенных адаптером при переполнении очереди приёма. + int DroppedPackets { get; } + + /// Открыть порт. Ошибка открытия приходит событием , а не исключением. + void Open(); + + /// Закрыть порт, дождавшись доставки уже принятых пакетов. + Task CloseAsync(); + + /// + /// Отправить пакет адаптеру, дождавшись своей очереди и интервала после предыдущей записи. + /// Завершается сразу после записи в порт — ответ не ожидается. Ошибка записи поднимает событие + /// (сигнал жизненному циклу переоткрыть порт) и пробрасывается вызывающему. + /// + Task SendAsync(MTRFXXMode mode, MTRFXXAction action, byte channel, MTRFXXCommand command, + MTRFXXDataFormat format = MTRFXXDataFormat.NoData, byte[]? data = null, uint target = 0, + CancellationToken ct = default); + + /// Пришёл входящий пакет (ответ на команду, состояние блока или приём от передатчика). + event Action Received; + + /// Разобранное состояние силового блока nooLite-F (Send_State, FMT 0). + event Action PowerUnitState; + + /// Ошибка адаптера/порта (в т.ч. ошибка открытия и ошибки чтения/записи). + event Action Error; + + /// Порт закрыт (штатно или из-за пропажи адаптера). Последнее событие транспорта. + event Action Disconnected; +} diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/MtrfCommands.cs b/ThinkingHome.DeviceModel.Drivers.NooLite/MtrfCommands.cs new file mode 100644 index 0000000..9bee6fd --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/MtrfCommands.cs @@ -0,0 +1,28 @@ +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Drivers.NooLite; + +/// +/// Сборка пакетов nooLite-F, которые использует драйвер, поверх . +/// Повторяет режим/действие/команду из соответствующих расширений библиотеки для адресации по каналу +/// (без указания ID блока — CTR = 0, обычная передача по каналу). Каждый метод завершается +/// после записи в порт; ответ адаптера/блока не ожидается. +/// +internal static class MtrfCommands +{ + /// Выйти из режима обновления ПО в рабочий режим (MODE=4); ответ (адрес адаптера) не ожидается. + public static Task ExitServiceModeAsync(this IMtrfTransport transport, CancellationToken ct = default) + => transport.SendAsync(MTRFXXMode.Service, MTRFXXAction.SendCommand, 0, MTRFXXCommand.None, ct: ct); + + /// Включить нагрузку на канале (nooLite-F). + public static Task OnFAsync(this IMtrfTransport transport, byte channel, CancellationToken ct = default) + => transport.SendAsync(MTRFXXMode.TXF, MTRFXXAction.SendCommand, channel, MTRFXXCommand.On, ct: ct); + + /// Выключить нагрузку на канале (nooLite-F). + public static Task OffFAsync(this IMtrfTransport transport, byte channel, CancellationToken ct = default) + => transport.SendAsync(MTRFXXMode.TXF, MTRFXXAction.SendCommand, channel, MTRFXXCommand.Off, ct: ct); + + /// Запросить состояние блоков на канале (Read_State, основная строка таблицы). + public static Task ReadStateFAsync(this IMtrfTransport transport, byte channel, CancellationToken ct = default) + => transport.SendAsync(MTRFXXMode.TXF, MTRFXXAction.SendCommand, channel, MTRFXXCommand.ReadState, ct: ct); +} diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/MtrfTransport.cs b/ThinkingHome.DeviceModel.Drivers.NooLite/MtrfTransport.cs new file mode 100644 index 0000000..aa6a2e3 --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/MtrfTransport.cs @@ -0,0 +1,113 @@ +using System.Diagnostics; +using ThinkingHome.NooLite; +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Drivers.NooLite; + +/// +/// Транспорт над реальным адаптером библиотеки ThinkingHome.NooLite. +/// Адаптер пересобирается на каждое открытие порта (он владеет портом на весь свой жизненный цикл). +/// Ворота отправки — здесь же: одна запись за раз, следующая не раньше, чем через +/// после предыдущей; момент последней записи переживает переоткрытие. +/// +internal sealed class MtrfTransport(string port, TimeSpan? sendInterval = null) : IMtrfTransport +{ + /// + /// Интервал между записями по умолчанию: измеренный ответ блока 90–155 мс с запасом. Окно + /// занятости адаптера при молчащем блоке не измерено — значение уточняется по замеру (tasks 1.4). + /// + public static readonly TimeSpan DefaultSendInterval = TimeSpan.FromMilliseconds(200); + + private readonly SemaphoreSlim sendLock = new(1, 1); + private readonly TimeSpan interval = sendInterval ?? DefaultSendInterval; + private long? lastWrite; // Stopwatch-метка последней записи; null — записей ещё не было (только под sendLock) + private MTRFXXAdapter? adapter; + + /// + public bool IsOpen => adapter?.IsOpened ?? false; + + /// + public int DroppedPackets => adapter?.DroppedPacketsCount ?? 0; + + /// + public event Action? Received; + + /// + public event Action? PowerUnitState; + + /// + public event Action? Error; + + /// + public event Action? Disconnected; + + /// + public void Open() + { + var a = new MTRFXXAdapter(port); + a.ReceiveData += (_, data) => Received?.Invoke(data); + a.ReceivePowerUnitState += (_, data) => PowerUnitState?.Invoke(data); + a.Error += (_, ex) => Error?.Invoke(ex); + a.Disconnect += _ => Disconnected?.Invoke(); + adapter = a; + + // ошибка открытия придёт событием Error (не исключением); готовность проверяет вызывающий по IsOpen + a.Open(); + } + + /// + public async Task CloseAsync() + { + var a = adapter; + adapter = null; + if (a is null) return; + + try + { + await a.FlushAndCloseAsync(); + } + finally + { + a.Dispose(); + } + } + + /// + public async Task SendAsync(MTRFXXMode mode, MTRFXXAction action, byte channel, MTRFXXCommand command, + MTRFXXDataFormat format = MTRFXXDataFormat.NoData, byte[]? data = null, uint target = 0, + CancellationToken ct = default) + { + // ворота: правило адаптера «новую команду — только после ответа на предыдущую» выдерживается + // интервалом времени, а не ожиданием ответа (design D1); ожидающие проходят по очереди + await sendLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (lastWrite is { } last) + { + var remaining = interval - Stopwatch.GetElapsedTime(last); + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining, ct).ConfigureAwait(false); + } + } + + lastWrite = Stopwatch.GetTimestamp(); // момент попытки записи: даже неудачная занимает окно + + var a = adapter ?? throw new InvalidOperationException("Адаптер не открыт."); + try + { + // библиотека намеренно не перехватывает ошибки записи: вызывающему нужно знать, ушла команда или нет + a.SendCommand(mode, action, channel, command, MTRFXXRepeatCount.NoRepeat, format, data, target); + } + catch (Exception ex) + { + Error?.Invoke(ex); // ошибка записи — признак пропажи адаптера: сигнал жизненному циклу переоткрыть порт + throw; + } + } + finally + { + sendLock.Release(); + } + } +} diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/NooLitePlugin.cs b/ThinkingHome.DeviceModel.Drivers.NooLite/NooLitePlugin.cs new file mode 100644 index 0000000..0d66e76 --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/NooLitePlugin.cs @@ -0,0 +1,314 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ThinkingHome.NooLite; +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Drivers.NooLite; + +/// +/// Плагин драйвера nooLite: читает секцию NooLite конфигурации, регистрирует устройства и +/// владеет одним адаптером MTRF-64-USB. Реализует — держит соединение с +/// адаптером (открытие, выход в рабочий режим, запрос состояния блоков, переоткрытие при пропаже). +/// Ответы адаптера с командами не сопоставляются (design D1): состояние блоков расходится по +/// устройствам событием транспорта, остальные пакеты только журналируются. +/// +public sealed class NooLitePlugin : IDevicePlugin, IHostedService +{ + /// Имя секции конфигурации, которую читает плагин. + public const string SectionName = "NooLite"; + + private static readonly HashSet AllowedRelayTypes = + [DeviceType.OnOffLight, DeviceType.OnOffSocket, DeviceType.OnOffSwitch]; + + private readonly IConfiguration configuration; + private readonly ILoggerFactory loggerFactory; + private readonly ILogger logger; + private readonly ILogger packetLogger; + private readonly Func transportFactory; + private readonly TimeSpan baseBackoff; + + private NooLitePluginConfig config = new(); + private IMtrfTransport? transport; + private byte[] channels = []; + + private CancellationTokenSource? cts; + private Task? loop; + private int lastDropped; + + /// Конструктор для DI хаба: реальный адаптер через COM-порт. + public NooLitePlugin(IConfiguration configuration, ILoggerFactory loggerFactory) + : this(configuration, loggerFactory, port => new MtrfTransport(port)) + { + } + + // конструктор со швом транспорта и базовой задержкой реконнекта — для тестов + internal NooLitePlugin(IConfiguration configuration, ILoggerFactory loggerFactory, + Func transportFactory, TimeSpan? reconnectDelay = null) + { + this.configuration = configuration; + this.loggerFactory = loggerFactory; + this.transportFactory = transportFactory; + baseBackoff = reconnectDelay ?? TimeSpan.FromSeconds(5); + logger = loggerFactory.CreateLogger("ThinkingHome.DeviceModel.Drivers.NooLite"); + packetLogger = loggerFactory.CreateLogger("ThinkingHome.DeviceModel.Drivers.NooLite.Adapter"); + } + + /// + public void RegisterDevices(IDeviceRegistry registry) + { + config = configuration.GetSection(SectionName).Get() ?? new NooLitePluginConfig(); + + if (string.IsNullOrWhiteSpace(config.Port)) + { + throw new InvalidOperationException($"{SectionName}: не задан COM-порт адаптера (Port)."); + } + + if (config.ChannelCount is < 1 or > NooLitePluginConfig.MaxChannelCount) + { + throw new InvalidOperationException( + $"{SectionName}: недопустимое число каналов адаптера (ChannelCount) {config.ChannelCount}; " + + $"допустимо 1–{NooLitePluginConfig.MaxChannelCount}."); + } + + var maxChannel = config.ChannelCount - 1; + transport = transportFactory(config.Port); + transport.Received += OnReceived; + transport.PowerUnitState += OnPowerUnitState; + + var ids = new HashSet(StringComparer.Ordinal); + var usedChannels = new List(); + + for (var i = 0; i < config.Devices.Count; i++) + { + var entry = config.Devices[i]; + var where = $"{SectionName}:Devices[{i}]"; + + if (string.IsNullOrWhiteSpace(entry.Id)) + { + throw new InvalidOperationException($"{where}: не задан Id."); + } + + if (!ids.Add(entry.Id)) + { + throw new InvalidOperationException($"{where}: повторяющийся Id '{entry.Id}'."); + } + + if (entry.Channel < 0 || entry.Channel > maxChannel) + { + throw new InvalidOperationException( + $"{where}: устройство '{entry.Id}' — канал {entry.Channel} вне диапазона 0–{maxChannel}."); + } + + var kind = ParseKind(entry, where); + var device = kind switch + { + NooLiteDeviceKind.Relay => new NooLiteRelay(entry, ResolveRelayType(entry, where), transport, + loggerFactory.CreateLogger($"ThinkingHome.DeviceModel.Drivers.NooLite.{entry.Id}")), + _ => throw new InvalidOperationException( + $"{where}: устройство '{entry.Id}' — Kind '{kind}' не поддержан драйвером."), + }; + + registry.Register(device); + usedChannels.Add((byte)entry.Channel); + } + + channels = usedChannels.Distinct().ToArray(); + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + if (transport is null) + { + return Task.CompletedTask; // RegisterDevices не вызывался (например, в тесте) + } + + cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + loop = Task.Run(() => RunLoopAsync(cts.Token)); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + cts?.Cancel(); + + if (loop is not null) + { + try { await loop.ConfigureAwait(false); } + catch (OperationCanceledException) { /* штатная остановка */ } + } + + if (transport is not null) + { + try { await transport.CloseAsync().ConfigureAwait(false); } + catch (Exception ex) { logger.LogDebug(ex, "nooLite: ошибка при закрытии порта на остановке"); } + } + } + + private async Task RunLoopAsync(CancellationToken ct) + { + var backoff = baseBackoff; + var maxBackoff = TimeSpan.FromSeconds(30); + + while (!ct.IsCancellationRequested) + { + // сбой адаптера: ошибка порта (в т.ч. ошибка записи, которую транспорт поднимает как Error) или Disconnect + var fault = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnError(Exception ex) { logger.LogWarning(ex, "nooLite: ошибка адаптера {Port}", config.Port); fault.TrySetResult(); } + void OnDisconnected() => fault.TrySetResult(); + + transport!.Error += OnError; + transport.Disconnected += OnDisconnected; + + var opened = false; + try + { + transport.Open(); + await Task.Delay(200, ct).ConfigureAwait(false); // дать открытию/ошибке проявиться + + if (!transport.IsOpen) + { + logger.LogWarning("nooLite: не удалось открыть порт {Port}; повтор через {Delay}", + config.Port, backoff); + } + else + { + opened = true; + await PrepareAsync(ct).ConfigureAwait(false); + logger.LogInformation("nooLite: адаптер {Port} готов; устройств {Count}", config.Port, channels.Length); + backoff = baseBackoff; // сброс задержки после успешного открытия + await ConnectedAsync(fault.Task, ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogWarning(ex, "nooLite: сбой в цикле адаптера {Port}", config.Port); + } + finally + { + transport.Error -= OnError; + transport.Disconnected -= OnDisconnected; + try { await transport.CloseAsync().ConfigureAwait(false); } catch { /* закрываемся молча */ } + } + + if (ct.IsCancellationRequested) break; + + try { await Task.Delay(backoff, ct).ConfigureAwait(false); } + catch (OperationCanceledException) { break; } + + // наращиваем задержку только при неудачном открытии; после нормальной работы она уже сброшена + if (!opened) backoff = TimeSpan.FromTicks(Math.Min(backoff.Ticks * 2, maxBackoff.Ticks)); + } + } + + // выход в рабочий режим (MODE=4) и запрос состояния блоков; ответы не ожидаются — придут событиями + private async Task PrepareAsync(CancellationToken ct) + { + var t = transport!; + await t.ExitServiceModeAsync(ct).ConfigureAwait(false); + + foreach (var channel in channels) + { + await t.ReadStateFAsync(channel, ct).ConfigureAwait(false); + } + } + + // рабочая фаза: ждём сбой; при заданном PollInterval периодически запрашиваем состояние блоков + private async Task ConnectedAsync(Task faultTask, CancellationToken ct) + { + while (!ct.IsCancellationRequested && !faultTask.IsCompleted) + { + Task wake = config.PollInterval is TimeSpan interval && interval > TimeSpan.Zero + ? Task.Delay(interval, ct) + : Task.Delay(Timeout.Infinite, ct); + + var finished = await Task.WhenAny(faultTask, wake).ConfigureAwait(false); + if (finished == faultTask) return; + if (ct.IsCancellationRequested) return; + + // сработал PollInterval — запросить состояние блоков (публикация — только при отличии, в реле) + var t = transport!; + foreach (var channel in channels) + { + await t.ReadStateFAsync(channel, ct).ConfigureAwait(false); + } + + WarnOnDroppedPackets(); + } + } + + private void WarnOnDroppedPackets() + { + var dropped = transport?.DroppedPackets ?? 0; + if (dropped > lastDropped) + { + logger.LogWarning("nooLite: адаптер отбросил пакеты приёма из-за переполнения очереди: {Count}", dropped); + lastDropped = dropped; + } + } + + // входящие пакеты — только журнал: с командами они не сопоставляются, состояние блоков идёт отдельным событием + private void OnReceived(ReceivedData data) + { + if (data.Mode is MTRFXXMode.RX or MTRFXXMode.RXF) + { + // приём от передатчиков (датчики/пульты) — задел под следующий этап + packetLogger.LogDebug("nooLite: пакет приёма канал {Channel} команда {Command} toggle {Toggle}", + data.Channel, data.Command, data.ToggleCounter); + return; + } + + if (data.Result is ResultCode.NoResponse or ResultCode.Error) + { + // блок не ответил (CTR=1) или ошибка адаптера (CTR=2, например ReadState на канал без блока): + // на исход команды не влияет — «принято адаптером» уже отдано; состояние остаётся последним принятым + packetLogger.LogWarning("nooLite: адаптер сообщил {Result} (канал {Channel}, команда {Command})", + data.Result, data.Channel, data.Command); + return; + } + + packetLogger.LogDebug("nooLite: ответ адаптера: {Data}", data); + } + + private void OnPowerUnitState(PowerUnitStateData data) + => packetLogger.LogDebug("nooLite: состояние блока канал {Channel} state {State} level {Level} type {Type} id {Id}", + data.Channel, data.State, data.PowerLevel, data.DeviceType, data.DeviceId); + + private static NooLiteDeviceKind ParseKind(NooLiteDeviceEntry entry, string where) + { + // Kind в схеме — строка (см. NooLiteDeviceEntry): парсим сами с внятной ошибкой + if (!Enum.TryParse(entry.Kind, ignoreCase: true, out var kind) || !Enum.IsDefined(kind)) + { + throw new InvalidOperationException( + $"{where}: устройство '{entry.Id}' — неизвестный Kind '{entry.Kind}'. " + + $"Допустимые значения: {string.Join(", ", Enum.GetNames())}."); + } + + return kind; + } + + private static DeviceType ResolveRelayType(NooLiteDeviceEntry entry, string where) + { + if (string.IsNullOrWhiteSpace(entry.Type)) + { + return DeviceType.OnOffLight; // тип по умолчанию для реле + } + + if (!Enum.TryParse(entry.Type, ignoreCase: true, out var type) + || !Enum.IsDefined(type) + || !AllowedRelayTypes.Contains(type)) + { + throw new InvalidOperationException( + $"{where}: устройство '{entry.Id}' — недопустимый Type '{entry.Type}'. " + + $"Допустимые значения: {string.Join(", ", AllowedRelayTypes)}."); + } + + return type; + } +} diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/NooLitePluginConfig.cs b/ThinkingHome.DeviceModel.Drivers.NooLite/NooLitePluginConfig.cs new file mode 100644 index 0000000..2cf6d4a --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/NooLitePluginConfig.cs @@ -0,0 +1,66 @@ +namespace ThinkingHome.DeviceModel.Drivers.NooLite; + +/// Схема секции NooLite — адаптер и список устройств. +public sealed class NooLitePluginConfig +{ + /// Имя COM-порта адаптера MTRF-64-USB (на Windows — например, COM3). + public string Port { get; set; } = ""; + + /// + /// Число каналов адаптера (у MTRF-64 — 64). Каналы устройств должны быть в диапазоне + /// 0 … ChannelCount − 1. Не больше 256: канал в пакете адаптера занимает один байт. + /// + public int ChannelCount { get; set; } = DefaultChannelCount; + + /// Число каналов по умолчанию — адаптер MTRF-64. + public const int DefaultChannelCount = 64; + + /// Наибольшее допустимое число каналов: канал в пакете адаптера — один байт. + public const int MaxChannelCount = 256; + + /// + /// Интервал периодического опроса состояния блоков. Не задан — периодический опрос выключен + /// (состояние обновляется по ответам на команды и по запросу от хаба). + /// + public TimeSpan? PollInterval { get; set; } + + /// Устройства nooLite, которыми управляет плагин. + public List Devices { get; set; } = []; +} + +/// Одно устройство nooLite в конфигурации. +public sealed class NooLiteDeviceEntry +{ + /// Стабильный идентификатор устройства (не зависит от канала). + public string Id { get; set; } = ""; + + /// + /// Вид устройства — имя значения . Строка, а не enum: биндер + /// конфигурации молча выбрасывает записи списка с неконвертируемыми значениями, а строка биндится + /// всегда — ошибка парсинга остаётся нашей и получает внятный текст (как в плагине стабов). + /// + public string Kind { get; set; } = ""; + + /// Канал адаптера, к которому привязано устройство (от 0 до − 1). + public int Channel { get; set; } + + /// + /// Тип устройства в модели для силовых блоков — реле nooLite универсальны, назначение знает + /// только пользователь. Имя значения из допустимых: OnOffLight + /// (по умолчанию), OnOffSocket, OnOffSwitch. Пусто — тип по умолчанию для вида. + /// + public string? Type { get; set; } + + /// Человекочитаемое название. + public string Title { get; set; } = ""; + + /// Комната (если есть). + public string? Room { get; set; } +} + +/// Виды устройств nooLite, поддержанные драйвером. +public enum NooLiteDeviceKind +{ + /// Силовой блок nooLite-F в режиме реле (вкл/выкл, с обратной связью). + Relay, +} diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/NooLiteRelay.cs b/ThinkingHome.DeviceModel.Drivers.NooLite/NooLiteRelay.cs new file mode 100644 index 0000000..590f8b3 --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/NooLiteRelay.cs @@ -0,0 +1,147 @@ +using Microsoft.Extensions.Logging; +using ThinkingHome.DeviceModel.Capabilities; +using ThinkingHome.DeviceModel.Commands; +using ThinkingHome.DeviceModel.State; +using ThinkingHome.NooLite; + +namespace ThinkingHome.DeviceModel.Drivers.NooLite; + +/// +/// Силовой блок nooLite-F в режиме реле: одна способность . +/// Исход команды — «принято адаптером»: Done сразу после записи в порт, ответ блока не +/// ожидается. Состояние отдаётся только по принятым от блока ответам (), +/// которые приходят событием транспорта независимо от команд. Отправка идёт через ворота транспорта +/// (интервал между записями, общий на адаптер). +/// +internal sealed class NooLiteRelay : IDevice +{ + private readonly NooLiteDeviceEntry config; + private readonly DeviceType type; + private readonly byte channel; + private readonly IMtrfTransport transport; + private readonly ILogger logger; + + // последнее принятое состояние; null — от блока ещё ничего не приходило + private volatile object? lastState; // bool в упаковке, чтобы читать/писать атомарно + + public NooLiteRelay(NooLiteDeviceEntry config, DeviceType type, IMtrfTransport transport, ILogger logger) + { + this.config = config; + this.type = type; + this.channel = (byte)config.Channel; + this.transport = transport; + this.logger = logger; + + transport.PowerUnitState += OnPowerUnitState; + } + + /// + public string Id => config.Id; + + /// + public event Action? Changed; + + /// + public DeviceDescriptor Describe() => new() + { + Id = config.Id, + Title = config.Title, + Room = config.Room, + Manufacturer = new DeviceManufacturer { Name = "nooLite", Model = "MTRF-64" }, + Endpoints = + [ + new Endpoint + { + Id = 0, + Type = type, + Capabilities = [new OnOffCapability { Instance = OnOffCapability.InstanceName }], + }, + ], + }; + + /// + public async Task QueryAsync(CancellationToken ct = default) + { + // снимок — из последнего принятого; запрос состояния лишь отправляется, ответ придёт событием + // и опубликуется через Changed (при отличии). Порт закрыт — без отправки. + if (transport.IsOpen) + { + try + { + await transport.ReadStateFAsync(channel, ct).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogDebug(ex, "nooLite: не удалось отправить запрос состояния (канал {Channel})", channel); + } + } + + return Snapshot(); + } + + /// + public async Task ExecuteAsync(DeviceCommand command, CancellationToken ct = default) + { + if (command is not OnOffCommand onOff) + { + return CommandOutcome.Unsupported; + } + + if (!transport.IsOpen) + { + return CommandOutcome.Error(CommandErrorCode.DeviceUnreachable, "адаптер не подключён"); + } + + try + { + if (onOff.Value) + { + await transport.OnFAsync(channel, ct).ConfigureAwait(false); + } + else + { + await transport.OffFAsync(channel, ct).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + return CommandOutcome.Error(CommandErrorCode.DeviceUnreachable, "не удалось записать команду в порт адаптера"); + } + + // «принято адаптером»: команда записана в порт; состояние придёт по Send_State и опубликуется через Changed + return CommandOutcome.Done; + } + + private DeviceSnapshot Snapshot() + { + var state = lastState; + return new DeviceSnapshot + { + DeviceId = config.Id, + Values = state is bool on + ? [new OnOffState { Instance = OnOffCapability.InstanceName, Value = on }] + : [], + }; + } + + private void OnPowerUnitState(PowerUnitStateData data) + { + if (data.Channel != channel) return; // состояние не нашего блока + + // при CTR=1/2 байты данных — не состояние блока (он не ответил / ошибка адаптера): состояние только принятое + if (data.Result != ResultCode.Success) return; + + var on = data.State != PowerUnitState.Off; // On / TemporaryOn / (reserved) → включено + var previous = lastState; + lastState = on; + + // публикуем изменение только при отличии от последнего принятого (ответы, запросы и опрос — одинаково) + if (previous is bool prev && prev == on) return; + + Changed?.Invoke(new StateChange + { + DeviceId = config.Id, + Value = new OnOffState { Instance = OnOffCapability.InstanceName, Value = on }, + }); + } +} diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/README.md b/ThinkingHome.DeviceModel.Drivers.NooLite/README.md new file mode 100644 index 0000000..99fcc23 --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/README.md @@ -0,0 +1,199 @@ +# ThinkingHome.DeviceModel.Drivers.NooLite + +Драйвер устройств [nooLite / nooLite-F](https://www.noo.com.by/) для хаба +`ThinkingHome.DeviceModel` через USB-адаптер [MTRF-64-USB](https://www.noo.com.by/mtrf-64-usb.html). +Плагин владеет одним адаптером (один COM-порт), регистрирует устройства из своей секции конфигурации +и держит соединение с адаптером как фоновый сервис. + +Драйвер построен на библиотеке [`ThinkingHome.NooLite`](https://www.nuget.org/packages/ThinkingHome.NooLite) +(пакетный протокол адаптера), поверх которой добавляет то, чего требует контракт `IDevice`: темп +отправки по правилу адаптера, исход команды «принято адаптером», состояние по принятым данным и +переоткрытие порта. + +## Поддерживаемые устройства + +Первый этап — **силовой блок nooLite-F в режиме реле** (`Kind: "Relay"`), например SUF-1-300: +способность `OnOff`, обратная связь (состояние блока приходит в ответ на команду и запрос). + +Следующие этапы (отдельные доработки): классические реле nooLite (без обратной связи), диммер +(`Brightness`), датчики PT111/PT112 (температура, влажность) и PM111 (движение). + +## Подключение плагина + +Плагин включается строкой в списке `Hub:Plugins` конфигурации хаба. Тип плагина — +`NooLitePlugin`, сборка — `ThinkingHome.DeviceModel.Drivers.NooLite`: + +```json +"Hub": { + "Plugins": [ + "ThinkingHome.DeviceModel.Drivers.NooLite.NooLitePlugin, ThinkingHome.DeviceModel.Drivers.NooLite" + ] +} +``` + +Плагин читает секцию `NooLite` (константа `NooLitePlugin.SectionName`). Если плагин включён, +секция обязательна: без неё хаб не стартует с ошибкой о незаданном порте. + +## Формат конфигурации + +Полный пример секции: + +```json +"NooLite": { + "Port": "COM3", + "PollInterval": "00:05:00", + "Devices": [ + { "Id": "relay-1", "Kind": "Relay", "Channel": 0, "Title": "Свет в коридоре", "Room": "Коридор" }, + { "Id": "socket-1", "Kind": "Relay", "Channel": 2, "Type": "OnOffSocket", "Title": "Розетка у стола", "Room": "Кабинет" }, + { "Id": "boiler", "Kind": "Relay", "Channel": 5, "Type": "OnOffSwitch", "Title": "Бойлер" } + ] +} +``` + +Минимальный рабочий вариант — только порт и одно устройство: + +```json +"NooLite": { + "Port": "COM3", + "Devices": [ + { "Id": "relay-1", "Kind": "Relay", "Channel": 0, "Title": "Свет в коридоре" } + ] +} +``` + +### Секция `NooLite` + +| Поле | Тип | Обязательно | По умолчанию | Описание | +| --- | --- | --- | --- | --- | +| `Port` | строка | да | — | Имя COM-порта адаптера MTRF-64-USB: на Windows `COM3`, на Linux `/dev/ttyUSB0`. Один плагин — один адаптер. | +| `ChannelCount` | число | нет | `64` | Число каналов адаптера (у MTRF-64 — 64). Задаёт допустимый диапазон `Channel` устройств: от `0` до `ChannelCount − 1`. Допустимо от `1` до `256`: канал в пакете адаптера занимает один байт. | +| `PollInterval` | интервал | нет | не задан | Период опроса состояния блоков. Не задан, `null` или `00:00:00` — периодический опрос выключен. Формат — ниже. | +| `Devices` | массив | нет | пустой | Список устройств. Пустой список допустим: плагин откроет порт и будет держать соединение, не регистрируя устройств. | + +### Запись устройства (`Devices[i]`) + +| Поле | Тип | Обязательно | По умолчанию | Описание | +| --- | --- | --- | --- | --- | +| `Id` | строка | да | — | Стабильный идентификатор устройства в хабе. Уникален в пределах секции. Не зависит от канала: перепривязка блока на другой канал не создаёт новое устройство и не теряет его историю в экосистемах. | +| `Kind` | строка | да | — | Вид устройства с точки зрения драйвера. Пока одно значение — `Relay`. Регистр не важен. | +| `Channel` | число | да | `0` | Канал адаптера, к которому привязан блок: целое от `0` до `ChannelCount − 1` (по умолчанию до `63`). Если поле пропущено, биндер подставит `0` — это не ошибка, но легко получить два устройства на одном канале. | +| `Type` | строка | нет | зависит от `Kind` | Тип устройства в модели хаба. Реле nooLite универсальны, и назначение блока знает только пользователь: лампа, розетка или выключатель. Допустимые значения — в таблице видов. Регистр не важен. | +| `Title` | строка | нет | пустая | Человекочитаемое название для интерфейса ассистента. | +| `Room` | строка | нет | не задана | Комната для интерфейса ассистента. | + +### Виды устройств (`Kind`) + +| `Kind` | Что это | Способности | Допустимые `Type` | `Type` по умолчанию | +| --- | --- | --- | --- | --- | +| `Relay` | Силовой блок nooLite-F в режиме реле (SUF-1-300 и подобные) | `OnOff` (с отчётом об изменении) | `OnOffLight`, `OnOffSocket`, `OnOffSwitch` | `OnOffLight` | + +Значения `Type` — имена типов словаря ядра `DeviceType`. Каждое устройство описывается одним +эндпоинтом с идентификатором `0`; производитель в описании — `nooLite`, модель — `MTRF-64`. + +### Формат `PollInterval` + +Значение биндится в `TimeSpan`, поэтому записывается строкой в формате `[д.]чч:мм:сс`: + +| Значение | Смысл | +| --- | --- | +| `"00:00:30"` | каждые 30 секунд | +| `"00:05:00"` | каждые 5 минут | +| `"1.00:00:00"` | раз в сутки | +| `"00:00:00"`, `null`, поле отсутствует | опрос выключен | + +Число без разделителей (`"5"`) `TimeSpan` понимает как **дни**, а не минуты — задавайте интервал +полностью. + +Опрос нужен, чтобы хаб узнавал о переключениях, сделанных мимо него (с пульта или кнопкой на блоке): +блок nooLite-F не присылает изменения сам, только в ответ на команду или запрос. Каждый тик опроса — +по одному запросу `ReadStateF` на каждый канал из списка устройств. Изменение публикуется только +если состояние отличается от последнего принятого; при равном состоянии опрос не создаёт событий. + +### Проверка при старте + +Секция читается один раз при регистрации устройств. Любая ошибка ниже — исключение +`InvalidOperationException` с позицией записи (`NooLite:Devices[i]`), хаб не стартует: + +| Условие | Сообщение | +| --- | --- | +| `Port` пуст или отсутствует | `NooLite: не задан COM-порт адаптера (Port).` | +| `ChannelCount` вне `1–256` | `NooLite: недопустимое число каналов адаптера (ChannelCount) 0; допустимо 1–256.` | +| `Id` пуст | `NooLite:Devices[1]: не задан Id.` | +| `Id` повторяется | `NooLite:Devices[2]: повторяющийся Id 'relay-1'.` | +| `Channel` вне `0–ChannelCount − 1` | `NooLite:Devices[0]: устройство 'relay-1' — канал 64 вне диапазона 0–63.` | +| `Kind` неизвестен | `NooLite:Devices[0]: устройство 'relay-1' — неизвестный Kind 'Dimmer'. Допустимые значения: Relay.` | +| `Type` не из допустимых для вида | `NooLite:Devices[0]: устройство 'relay-1' — недопустимый Type 'Fan'. Допустимые значения: OnOffLight, OnOffSocket, OnOffSwitch.` | + +Уникальность `Channel` не проверяется: два устройства на одном канале будут отражать состояние +одного и того же блока. + +Наличие адаптера на старте **не** проверяется: устройства регистрируются и хаб запускается, даже +если порт занят или адаптер не подключён. Команды до появления адаптера возвращают +`DeviceUnreachable`, а снимок состояния пуст. + +### Переопределение через окружение и командную строку + +Хаб читает конфигурацию по общему правилу: `appsettings.json` → `appsettings.{THINKINGHOME_ENVIRONMENT}.json` +→ user-secrets → переменные окружения с префиксом `THINKINGHOME_` → аргументы командной строки. +Порт адаптера и устройства можно задать или переопределить без правки файла; вложенность +обозначается двойным подчёркиванием, индекс массива — числом: + +```bash +THINKINGHOME_NooLite__Port=/dev/ttyUSB0 +THINKINGHOME_NooLite__PollInterval=00:05:00 +THINKINGHOME_NooLite__Devices__0__Id=relay-1 +THINKINGHOME_NooLite__Devices__0__Kind=Relay +THINKINGHOME_NooLite__Devices__0__Channel=0 +``` + +В командной строке те же ключи пишутся через двоеточие: `--NooLite:Port=COM4`. + +## Предусловие: привязка устройств + +Устройства должны быть заранее **привязаны к каналам адаптера** — драйвер этого не делает. Привязать +можно утилитой `noolite` (`noolite bind -f`) или программой nooLite ONE. Драйвер лишь +адресует уже привязанные блоки по каналу: номер `Channel` в конфигурации — это канал адаптера, +на который выполнена привязка. + +## Поведение + +- **Исход команды — «принято адаптером».** `Execute` возвращает `Done` сразу после успешной записи + команды в порт адаптера, не дожидаясь ответа блока. `DeviceUnreachable` — только если порт закрыт + (адаптера нет) или запись в порт не удалась (адаптер извлечён). `Unsupported` — для команд кроме + `OnOff`. Ответ адаптера «нет ответа от блока» или «ошибка», как и его отсутствие, на исход не + влияет: он попадает в журнал, а состояние остаётся последним принятым. Экосистема (Алиса) получает + `DONE` и покажет новое состояние, когда придёт ответ блока — обычно через 100–150 мс. +- **Состояние — только принятое.** В снимке состояния отдаётся только то, что реально прислал блок + (`Send_State`), никогда не предполагаемое по отправленной команде. Пока блок не ответил — состояние + неизвестно, снимок пуст (это корректно: блок могли переключить с пульта). При открытии порта + драйвер сразу запрашивает состояние по всем каналам из конфигурации, так что состояние обычно + известно с первых секунд работы. Любой принятый `Send_State` на канале устройства — его состояние, + запрашивали его или нет. +- **`Query` не ждёт блока.** Запрос состояния от хаба возвращает последнее принятое (или пустой + снимок) и отправляет блоку `ReadStateF`; ответ, когда придёт, публикуется как изменение состояния + (`Report`). При закрытом порте запрос не отправляется. +- **Интервал между записями.** По правилу протокола следующая команда уходит адаптеру только после + ответа на предыдущую (адаптер молча теряет команды, отправленные без ожидания ответа). Драйвер + выдерживает это интервалом 200 мс между записями в порт, общим для всех устройств адаптера: + команды и запросы разным устройствам записываются по очереди, каждая — не раньше чем через + интервал после предыдущей. Ответы адаптера с командами не сопоставляются. Значение подобрано по + измеренному времени ответа блока (90–155 мс) и уточняется по замеру с молчащим блоком. +- **Устойчивость к пропаже адаптера.** Порт переоткрывается без перезапуска хаба: после ошибки + (в том числе ошибки записи) или отключения драйвер ждёт 5 с и пробует снова, при повторных + неудачах пауза растёт до 30 с. После успешного переоткрытия состояние блоков запрашивается заново. + +## Журнал + +Категории логгера, по которым можно настроить уровень в секции `Logging`: + +| Категория | Что пишет | +| --- | --- | +| `ThinkingHome.DeviceModel.Drivers.NooLite` | открытие и потеря порта, ошибки адаптера и записи, повторы подключения, отброшенные адаптером пакеты | +| `ThinkingHome.DeviceModel.Drivers.NooLite.Adapter` | ответы адаптера «нет ответа от блока» и «ошибка» (`Warning`); на уровне `Debug` — все ответы адаптера, принятые состояния блоков, пакеты от пультов и датчиков (RX) | +| `ThinkingHome.DeviceModel.Drivers.NooLite.` | на уровне `Debug` — неудачная отправка запроса состояния конкретным устройством | + +## Ограничения этапа + +- Один блок на канал (адресация по 32-битному ID блока — на будущих этапах). +- Только nooLite-F (обратная связь). Классические устройства, диммер, датчики, пульты, RGB — вне скоупа. +- Один адаптер на плагин. diff --git a/ThinkingHome.DeviceModel.Drivers.NooLite/ThinkingHome.DeviceModel.Drivers.NooLite.csproj b/ThinkingHome.DeviceModel.Drivers.NooLite/ThinkingHome.DeviceModel.Drivers.NooLite.csproj new file mode 100644 index 0000000..40c909c --- /dev/null +++ b/ThinkingHome.DeviceModel.Drivers.NooLite/ThinkingHome.DeviceModel.Drivers.NooLite.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + true + true + $(WarningsAsErrors);CS1591 + Драйвер устройств nooLite / nooLite-F для хаба ThinkingHome через USB-адаптер MTRF-64. Первый этап — реле nooLite-F + smart-home;iot;drivers;noolite + + + + + + + + + + + + + + + + + + diff --git a/ThinkingHome.DeviceModel.Hub/ThinkingHome.DeviceModel.Hub.csproj b/ThinkingHome.DeviceModel.Hub/ThinkingHome.DeviceModel.Hub.csproj index 52641e9..c0c2d3b 100644 --- a/ThinkingHome.DeviceModel.Hub/ThinkingHome.DeviceModel.Hub.csproj +++ b/ThinkingHome.DeviceModel.Hub/ThinkingHome.DeviceModel.Hub.csproj @@ -28,6 +28,7 @@ + diff --git a/ThinkingHome.DeviceModel.Hub/appsettings.json b/ThinkingHome.DeviceModel.Hub/appsettings.json index ffc0793..f86a459 100644 --- a/ThinkingHome.DeviceModel.Hub/appsettings.json +++ b/ThinkingHome.DeviceModel.Hub/appsettings.json @@ -2,6 +2,16 @@ "Hub": { "Plugins": [ "ThinkingHome.DeviceModel.Drivers.Stubs.StubsPlugin, ThinkingHome.DeviceModel.Drivers.Stubs" + // Драйвер nooLite (устройства через USB-адаптер MTRF-64) — раскомментируйте и настройте секцию NooLite ниже: + // ,"ThinkingHome.DeviceModel.Drivers.NooLite.NooLitePlugin, ThinkingHome.DeviceModel.Drivers.NooLite" + ] + }, + // Драйвер nooLite: Port — COM-порт адаптера; Devices — реле nooLite-F по каналам. + // Устройства должны быть заранее привязаны к каналам адаптера (см. документацию драйвера). + "NooLite": { + "Port": "COM3", + "Devices": [ + // { "Id": "relay-1", "Kind": "Relay", "Channel": 0, "Title": "Свет в коридоре", "Room": "Коридор" } ] }, "StubDevices": { diff --git a/ThinkingHome.DeviceModel.Tests/NooLite/NooLiteAdapterTests.cs b/ThinkingHome.DeviceModel.Tests/NooLite/NooLiteAdapterTests.cs new file mode 100644 index 0000000..5b03fdb --- /dev/null +++ b/ThinkingHome.DeviceModel.Tests/NooLite/NooLiteAdapterTests.cs @@ -0,0 +1,289 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using ThinkingHome.DeviceModel.Commands; +using ThinkingHome.DeviceModel.Drivers.NooLite; +using ThinkingHome.DeviceModel.State; +using ThinkingHome.NooLite; +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Tests.NooLite; + +/// Реле nooLite-F (4.2) и транспорт с воротами отправки (3.4) на фейковом транспорте. +public class NooLiteAdapterTests +{ + private static NooLiteRelay Relay(IMtrfTransport transport, byte channel = 0) + => new(new NooLiteDeviceEntry { Id = "relay-1", Kind = "Relay", Channel = channel, Title = "Реле" }, + DeviceType.OnOffLight, transport, NullLogger.Instance); + + private static OnOffCommand OnOff(bool value) => new() { Instance = "on_off", Value = value }; + + // блок отвечает Send_State на команды включения/выключения (как живой SUF-1-300) + private static IReadOnlyList StateResponder(SentCommand cmd) + => cmd.Command is MTRFXXCommand.On or MTRFXXCommand.Off + ? [Frames.PowerUnitState(cmd.Channel, cmd.Command == MTRFXXCommand.On)] + : []; + + private static bool OnOffValue(DeviceSnapshot snapshot) + => Assert.IsType(Assert.Single(snapshot.Values)).Value; + + // ── реле (4.2) ────────────────────────────────────────────────────────────── + + [Fact] + public async Task Written_command_returns_done_before_block_answers() + { + var transport = new FakeMtrfTransport { DeliveryDelayMs = 300, Responder = StateResponder }; + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + + var sw = Stopwatch.StartNew(); + var outcome = await relay.ExecuteAsync(OnOff(true)); + sw.Stop(); + + Assert.Equal(CommandStatus.Done, outcome.Status); + Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(200), + $"Done вернулся за {sw.ElapsedMilliseconds} мс — ответа блока ждать не должны"); + Assert.Empty((await relay.QueryAsync()).Values); // состояние ещё не принято + + var change = await changes.WaitOneAsync(); // ответ блока пришёл позже — опубликован + Assert.True(Assert.IsType(change.Value).Value); + } + + [Fact] + public async Task Command_without_answer_is_done_but_snapshot_unchanged() + { + var transport = new FakeMtrfTransport { Responder = _ => [] }; + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + + var outcome = await relay.ExecuteAsync(OnOff(true)); + await Task.Delay(50); + + Assert.Equal(CommandStatus.Done, outcome.Status); // «принято адаптером» + Assert.Empty((await relay.QueryAsync()).Values); // состояние из команды не выводится + Assert.False(changes.Any); + } + + [Fact] + public async Task Received_state_is_published_once_per_change() + { + // одно и то же состояние приходит трижды (команда, два запроса) → одно изменение + var transport = new FakeMtrfTransport { Responder = _ => [Frames.PowerUnitState(0, on: true)] }; + var relay = Relay(transport); + var count = 0; + relay.Changed += _ => Interlocked.Increment(ref count); + + await relay.ExecuteAsync(OnOff(true)); + await relay.QueryAsync(); + await relay.QueryAsync(); + await Task.Delay(100); // дать доставке событий завершиться + + Assert.Equal(1, count); + Assert.True(OnOffValue(await relay.QueryAsync())); + } + + [Fact] + public async Task Query_returns_last_received_without_waiting_and_sends_read_state() + { + var transport = new FakeMtrfTransport + { + DeliveryDelayMs = 300, + Responder = cmd => cmd.Command == MTRFXXCommand.ReadState ? [Frames.PowerUnitState(0, on: true)] : [], + }; + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + + var sw = Stopwatch.StartNew(); + var first = await relay.QueryAsync(); + sw.Stop(); + + Assert.Empty(first.Values); // последнее принятое — ничего + Assert.True(sw.Elapsed < TimeSpan.FromMilliseconds(200), + $"Query вернулся за {sw.ElapsedMilliseconds} мс — ответа блока ждать не должны"); + Assert.Contains(transport.Sent, c => c.Command == MTRFXXCommand.ReadState); // запрос отправлен + + await changes.WaitOneAsync(); // ответ пришёл позже — опубликован + Assert.True(OnOffValue(await relay.QueryAsync())); // и стал последним принятым + } + + [Fact] + public async Task Unsolicited_state_is_accepted() + { + // ответ блока без нашей команды (поздний ответ, ответ на прайминг) — истинное состояние + var transport = new FakeMtrfTransport(); + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + + transport.Deliver(Frames.PowerUnitState(0, on: true)); + + var change = await changes.WaitOneAsync(); + Assert.True(Assert.IsType(change.Value).Value); + Assert.Empty(transport.Sent); + } + + [Fact] + public async Task State_of_other_channel_is_ignored() + { + var transport = new FakeMtrfTransport(); + var relay = Relay(transport, channel: 0); + var changes = new StateChangeWaiter(relay); + + transport.Deliver(Frames.PowerUnitState(1, on: true)); + await Task.Delay(50); + + Assert.False(changes.Any); + Assert.Empty((await relay.QueryAsync()).Values); + } + + [Fact] + public async Task Non_onoff_command_is_unsupported() + { + var relay = Relay(new FakeMtrfTransport()); + + var outcome = await relay.ExecuteAsync(new BrightnessCommand { Instance = "brightness", Value = 50 }); + + Assert.Equal(CommandErrorCode.NotSupported, outcome.ErrorCode); + } + + [Fact] + public async Task Closed_port_returns_unreachable_without_write() + { + var transport = new FakeMtrfTransport { IsOpen = false }; + var relay = Relay(transport); + + var outcome = await relay.ExecuteAsync(OnOff(true)); + var snapshot = await relay.QueryAsync(); + + Assert.Equal(CommandErrorCode.DeviceUnreachable, outcome.ErrorCode); + Assert.Empty(snapshot.Values); + Assert.Empty(transport.Sent); // ни команда, ни запрос состояния не записывались + } + + [Fact] + public async Task Write_failure_returns_unreachable_and_raises_error() + { + var transport = new FakeMtrfTransport { SendFault = new IOException("порт пропал") }; + var relay = Relay(transport); + Exception? error = null; + transport.Error += ex => error = ex; + + var outcome = await relay.ExecuteAsync(OnOff(true)); + + Assert.Equal(CommandErrorCode.DeviceUnreachable, outcome.ErrorCode); + Assert.IsType(error); // сигнал жизненному циклу переоткрыть порт + } + + // ── транспорт и ворота отправки (3.4) ─────────────────────────────────────── + + [Fact] + public async Task Consecutive_writes_are_spaced_by_interval_and_all_done() + { + // интервал здесь выдерживает фейк по контракту транспорта; реальный MtrfTransport проверяется на железе (6.4) + var interval = TimeSpan.FromMilliseconds(60); + var transport = new FakeMtrfTransport(interval); + var relay = Relay(transport); + + var outcomes = await Task.WhenAll( + relay.ExecuteAsync(OnOff(true)), + relay.ExecuteAsync(OnOff(false)), + relay.ExecuteAsync(OnOff(true))); + + Assert.All(outcomes, o => Assert.Equal(CommandStatus.Done, o.Status)); + var stamps = transport.Sent.Select(c => c.Timestamp).ToArray(); + Assert.Equal(3, stamps.Length); + for (var i = 1; i < stamps.Length; i++) + { + var gap = Stopwatch.GetElapsedTime(stamps[i - 1], stamps[i]); + Assert.True(gap >= interval - TimeSpan.FromMilliseconds(5), + $"записи {i - 1} и {i} разнесены на {gap.TotalMilliseconds:F0} мс при интервале {interval.TotalMilliseconds} мс"); + } + } + + [Theory] + [InlineData("no-response")] // CTR=1: блок не ответил + [InlineData("error")] // CTR=2: ошибка адаптера + [InlineData("silence")] // адаптер молчит (команда в пустой канал) + public async Task Adapter_errors_and_silence_do_not_affect_outcome_or_state(string kind) + { + var transport = new FakeMtrfTransport + { + Responder = _ => kind switch + { + "no-response" => [Frames.NoResponse(0)], + "error" => [Frames.Error(0)], + _ => [], + }, + }; + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + + var outcome = await relay.ExecuteAsync(OnOff(true)); + await Task.Delay(50); + + Assert.Equal(CommandStatus.Done, outcome.Status); + Assert.Empty((await relay.QueryAsync()).Values); + Assert.False(changes.Any); + } + + [Fact] + public async Task Multi_packet_series_yields_state_from_each_packet() + { + // два блока на канале: первый пакет (Remains=1) включён, последний (Remains=0) выключен — + // состояние берётся из каждого; серию как целое никто не ждёт + var transport = new FakeMtrfTransport + { + Responder = _ => + [ + Frames.PowerUnitState(0, on: true, deviceId: 33347, remains: 1), + Frames.PowerUnitState(0, on: false, deviceId: 33348), + ], + }; + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + + await relay.QueryAsync(); + + Assert.True(Assert.IsType((await changes.WaitOneAsync()).Value).Value); + Assert.False(Assert.IsType((await changes.WaitOneAsync()).Value).Value); + } + + [Fact] + public async Task Sensor_rx_packet_does_not_change_state() + { + var transport = new FakeMtrfTransport(); + var relay = Relay(transport); + var changes = new StateChangeWaiter(relay); + ReceivedData? received = null; + transport.Received += p => received = p; + + transport.Deliver(Frames.SensorRx(0)); // тот же канал, но режим RX + await Task.Delay(50); + + Assert.NotNull(received); // пакет виден подписчикам транспорта (журнал; датчики — следующий этап) + Assert.False(changes.Any); + Assert.Empty((await relay.QueryAsync()).Values); + } +} + +/// Собирает изменения состояния устройства и позволяет дождаться первого с таймаутом. +internal sealed class StateChangeWaiter +{ + private readonly SemaphoreSlim signal = new(0); + private readonly System.Collections.Concurrent.ConcurrentQueue changes = new(); + + public StateChangeWaiter(IDevice device) => device.Changed += OnChanged; + + public bool Any => !changes.IsEmpty; + + private void OnChanged(StateChange change) + { + changes.Enqueue(change); + signal.Release(); + } + + public async Task WaitOneAsync(int timeoutMs = 2000) + { + Assert.True(await signal.WaitAsync(timeoutMs), "изменение состояния не пришло за отведённое время"); + Assert.True(changes.TryDequeue(out var change)); + return change!; + } +} diff --git a/ThinkingHome.DeviceModel.Tests/NooLite/NooLitePluginTests.cs b/ThinkingHome.DeviceModel.Tests/NooLite/NooLitePluginTests.cs new file mode 100644 index 0000000..20f0336 --- /dev/null +++ b/ThinkingHome.DeviceModel.Tests/NooLite/NooLitePluginTests.cs @@ -0,0 +1,342 @@ +using System.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using ThinkingHome.DeviceModel.Capabilities; +using ThinkingHome.DeviceModel.Commands; +using ThinkingHome.DeviceModel.Drivers.NooLite; +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Tests.NooLite; + +public class NooLitePluginTests +{ + private static NooLitePlugin Plugin(FakeMtrfTransport transport, params (string Key, string? Value)[] values) + => new(Config(values), NullLoggerFactory.Instance, _ => transport, TimeSpan.FromMilliseconds(20)); + + private static NooLitePlugin Plugin(params (string Key, string? Value)[] values) + => Plugin(new FakeMtrfTransport(), values); + + private static (string, string?)[] RelayOnChannel0(params (string, string?)[] extra) => + [ + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "relay-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Channel", "0"), + .. extra, + ]; + + // ── конфигурация и полнота (2.4) ───────────────────────────────────────────── + + [Fact] + public async Task Plugin_creates_relay_from_section() + { + var host = new DeviceHost(); + Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "relay-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Channel", "0"), + ("NooLite:Devices:0:Title", "Свет в коридоре"), + ("NooLite:Devices:0:Room", "Коридор")).RegisterDevices(host); + + Assert.Equal(1, host.Count); + + var relay = await host.GetDeviceAsync("relay-1"); + Assert.NotNull(relay); + Assert.Equal("Коридор", relay.Room); + var endpoint = Assert.Single(relay.Endpoints); + Assert.Equal(DeviceType.OnOffLight, endpoint.Type); + var onOff = Assert.IsType(Assert.Single(endpoint.Capabilities)); + Assert.True(onOff.Reportable); // nooLite-F с обратной связью + } + + [Fact] + public async Task Type_from_config_is_applied() + { + var host = new DeviceHost(); + Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "socket-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Channel", "2"), + ("NooLite:Devices:0:Type", "OnOffSocket")).RegisterDevices(host); + + var socket = await host.GetDeviceAsync("socket-1"); + Assert.NotNull(socket); + Assert.Equal(DeviceType.OnOffSocket, Assert.Single(socket.Endpoints).Type); + } + + [Fact] + public void Every_kind_is_creatable() // защита от забытой ветки switch при добавлении Kind + { + var kinds = Enum.GetValues(); + var values = new List<(string, string?)> { ("NooLite:Port", "COM3") }; + for (var i = 0; i < kinds.Length; i++) + { + values.Add(($"NooLite:Devices:{i}:Id", $"dev-{i}")); + values.Add(($"NooLite:Devices:{i}:Kind", kinds[i].ToString())); + values.Add(($"NooLite:Devices:{i}:Channel", i.ToString())); + } + + var host = new DeviceHost(); + Plugin(values.ToArray()).RegisterDevices(host); + + Assert.Equal(kinds.Length, host.Count); + } + + [Fact] + public void Missing_port_fails() + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Devices:0:Id", "relay-1"), + ("NooLite:Devices:0:Kind", "Relay")).RegisterDevices(new DeviceHost())); + Assert.Contains("Port", ex.Message); + } + + [Fact] + public void Empty_id_fails_with_position() + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "ok-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:1:Id", ""), + ("NooLite:Devices:1:Kind", "Relay")).RegisterDevices(new DeviceHost())); + Assert.Contains("Devices[1]", ex.Message); + } + + [Fact] + public void Duplicate_id_fails() + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "dup"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:1:Id", "dup"), + ("NooLite:Devices:1:Kind", "Relay"), + ("NooLite:Devices:1:Channel", "1")).RegisterDevices(new DeviceHost())); + Assert.Contains("dup", ex.Message); + } + + [Theory] + [InlineData("Teleport")] + [InlineData("42")] + public void Unknown_kind_fails_with_id_and_allowed_values(string kind) + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "dev-1"), + ("NooLite:Devices:0:Kind", kind)).RegisterDevices(new DeviceHost())); + Assert.Contains("dev-1", ex.Message); + Assert.Contains(kind, ex.Message); + Assert.Contains(nameof(NooLiteDeviceKind.Relay), ex.Message); + } + + [Theory] + [InlineData("-1")] + [InlineData("64")] + public void Channel_out_of_range_fails(string channel) + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "dev-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Channel", channel)).RegisterDevices(new DeviceHost())); + Assert.Contains("dev-1", ex.Message); + Assert.Contains("0–63", ex.Message); + } + + [Fact] + public async Task Channel_count_from_config_widens_range() + { + var host = new DeviceHost(); + Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:ChannelCount", "128"), + ("NooLite:Devices:0:Id", "relay-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Channel", "100")).RegisterDevices(host); // за пределами 0–63, но в пределах 0–127 + + Assert.NotNull(await host.GetDeviceAsync("relay-1")); + } + + [Fact] + public void Channel_count_from_config_narrows_range() + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:ChannelCount", "32"), + ("NooLite:Devices:0:Id", "dev-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Channel", "40")).RegisterDevices(new DeviceHost())); + Assert.Contains("dev-1", ex.Message); + Assert.Contains("0–31", ex.Message); + } + + [Theory] + [InlineData("0")] + [InlineData("257")] // канал в пакете — один байт + public void Invalid_channel_count_fails(string count) + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:ChannelCount", count)).RegisterDevices(new DeviceHost())); + Assert.Contains("ChannelCount", ex.Message); + Assert.Contains("1–256", ex.Message); + } + + [Fact] + public void Invalid_type_fails() + { + var ex = Assert.Throws(() => Plugin( + ("NooLite:Port", "COM3"), + ("NooLite:Devices:0:Id", "dev-1"), + ("NooLite:Devices:0:Kind", "Relay"), + ("NooLite:Devices:0:Type", "Curtain")).RegisterDevices(new DeviceHost())); // не из разрешённых для реле + Assert.Contains("dev-1", ex.Message); + Assert.Contains("Type", ex.Message); + } + + // ── жизненный цикл (5.3) ───────────────────────────────────────────────────── + + [Fact] + public async Task Starts_without_adapter_and_keeps_devices() + { + var transport = new FakeMtrfTransport { OpenSucceeds = false }; // адаптера нет + var plugin = Plugin(transport, RelayOnChannel0()); + var host = new DeviceHost(); + plugin.RegisterDevices(host); + + var sw = Stopwatch.StartNew(); + await plugin.StartAsync(CancellationToken.None); + sw.Stop(); + + Assert.Equal(1, host.Count); // устройство зарегистрировано несмотря на отсутствие адаптера + Assert.True(sw.Elapsed < TimeSpan.FromSeconds(1), "StartAsync не должен блокироваться открытием порта"); + + await plugin.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task Closed_port_commands_are_unreachable_without_write() + { + var transport = new FakeMtrfTransport { OpenSucceeds = false, IsOpen = false }; // адаптера нет с самого начала + var plugin = Plugin(transport, RelayOnChannel0()); + var host = new DeviceHost(); + plugin.RegisterDevices(host); + await plugin.StartAsync(CancellationToken.None); + + var outcome = await host.ExecuteAsync("relay-1", new OnOffCommand { Instance = "on_off", Value = true }); + var snapshot = await host.QueryAsync("relay-1"); + + Assert.Equal(CommandErrorCode.DeviceUnreachable, outcome.ErrorCode); + Assert.Empty(snapshot.Values); + Assert.Empty(transport.Sent); // ничего не записывалось — порт закрыт + + await plugin.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task Reopen_primes_state_again() + { + var transport = new FakeMtrfTransport + { + Responder = cmd => cmd.Command switch + { + MTRFXXCommand.None => [Frames.Service()], // MODE=4 + MTRFXXCommand.ReadState => [Frames.PowerUnitState(cmd.Channel, on: false)], // прайминг + _ => [], + }, + }; + var plugin = Plugin(transport, RelayOnChannel0()); + plugin.RegisterDevices(new DeviceHost()); + + await plugin.StartAsync(CancellationToken.None); + + await WaitUntil(() => PrimeCount(transport) >= 1); // первый прайминг после открытия + transport.RaiseDisconnected(); // адаптер пропал + await WaitUntil(() => PrimeCount(transport) >= 2); // прайминг повторился после переоткрытия + + await plugin.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task Write_failure_triggers_reopen_and_priming() + { + var transport = new FakeMtrfTransport(); + var plugin = Plugin(transport, RelayOnChannel0()); + var host = new DeviceHost(); + plugin.RegisterDevices(host); + await plugin.StartAsync(CancellationToken.None); + await WaitUntil(() => PrimeCount(transport) >= 1); + + transport.SendFault = new IOException("адаптер выдернут"); + var outcome = await host.ExecuteAsync("relay-1", new OnOffCommand { Instance = "on_off", Value = true }); + transport.SendFault = null; // «адаптер вернули» + + Assert.Equal(CommandErrorCode.DeviceUnreachable, outcome.ErrorCode); + await WaitUntil(() => PrimeCount(transport) >= 2); // порт переоткрыт, состояние запрошено заново + + await plugin.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task Poll_publishes_only_changes() + { + var transport = new FakeMtrfTransport + { + Responder = cmd => cmd.Command == MTRFXXCommand.ReadState + ? [Frames.PowerUnitState(cmd.Channel, on: true)] // блок всегда «включён» + : [], + }; + var plugin = Plugin(transport, RelayOnChannel0(("NooLite:PollInterval", "00:00:00.050"))); + var registry = new CapturingRegistry(); + plugin.RegisterDevices(registry); + var count = 0; + Assert.Single(registry.Devices).Changed += _ => Interlocked.Increment(ref count); + + await plugin.StartAsync(CancellationToken.None); + await WaitUntil(() => PrimeCount(transport) >= 4); // прайминг + не меньше трёх тиков опроса + await Task.Delay(50); // дать доставке ответов завершиться + + Assert.Equal(1, count); // состояние не менялось — одна публикация + + await plugin.StopAsync(CancellationToken.None); + } + + private static int PrimeCount(FakeMtrfTransport transport) + => transport.Sent.Count(c => c.Command == MTRFXXCommand.ReadState); + + private static async Task WaitUntil(Func condition, int timeoutMs = 3000) + { + var sw = Stopwatch.StartNew(); + while (!condition()) + { + Assert.True(sw.ElapsedMilliseconds < timeoutMs, "условие не выполнилось за отведённое время"); + await Task.Delay(10); + } + } + + private static IConfiguration Config((string Key, string? Value)[] values) + => new ConfigurationBuilder() + .AddInMemoryCollection(values.ToDictionary(v => v.Key, v => v.Value)) + .Build(); + + /// Реестр, отдающий зарегистрированные устройства тесту напрямую (без хоста). + private sealed class CapturingRegistry : IDeviceRegistry + { + public List Devices { get; } = []; + + public IDisposable Register(IDevice device) + { + Devices.Add(device); + return new Registration(); + } + + private sealed class Registration : IDisposable + { + public void Dispose() { } + } + } +} diff --git a/ThinkingHome.DeviceModel.Tests/NooLite/NooLiteTestKit.cs b/ThinkingHome.DeviceModel.Tests/NooLite/NooLiteTestKit.cs new file mode 100644 index 0000000..68aff02 --- /dev/null +++ b/ThinkingHome.DeviceModel.Tests/NooLite/NooLiteTestKit.cs @@ -0,0 +1,189 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using ThinkingHome.DeviceModel.Drivers.NooLite; +using ThinkingHome.NooLite; +using ThinkingHome.NooLite.Internal; + +namespace ThinkingHome.DeviceModel.Tests.NooLite; + +/// Команда, записанная в фейковый транспорт; — Stopwatch-метка записи. +internal sealed record SentCommand( + MTRFXXMode Mode, MTRFXXAction Action, byte Channel, MTRFXXCommand Command, MTRFXXDataFormat Format, long Timestamp); + +/// +/// Фейковый транспорт: воспроизводит контракт транспорта и библиотеки — ворота отправки (одна запись +/// за раз с интервалом, как в ; по умолчанию без интервала, чтобы тесты были +/// быстрыми), ответ на записанную команду доставляется из ФОНОВОГО потока, последовательно (сначала +/// Received, затем типизированное PowerUnitState для Send_State FMT 0 — как у библиотеки, без оглядки +/// на CTR). Позволяет ронять запись (ошибка → Error + исключение, как у реального транспорта), рвать +/// соединение и доставлять кадры «с эфира». +/// +internal sealed class FakeMtrfTransport(TimeSpan? sendInterval = null) : IMtrfTransport +{ + private readonly SemaphoreSlim sendLock = new(1, 1); + private readonly TimeSpan interval = sendInterval ?? TimeSpan.Zero; + private long? lastWrite; + private readonly SemaphoreSlim delivery = new(1, 1); // доставка последовательная, как у библиотеки + + public bool IsOpen { get; set; } = true; + + /// Успех открытия порта: false — адаптера нет (Open оставит порт закрытым). + public bool OpenSucceeds { get; set; } = true; + + public int DroppedPackets { get; set; } + public ConcurrentQueue Sent { get; } = new(); + + /// Ответ на команду — список 17-байтных кадров (см. ). + public Func>? Responder { get; set; } + + /// Если задано — запись бросает это исключение (адаптер выдернут), предварительно подняв Error. + public Exception? SendFault { get; set; } + + /// Задержка доставки ответа, мс (мимикрия под поллинг порта и ответ блока по эфиру). + public int DeliveryDelayMs { get; set; } + + public event Action? Received; + public event Action? PowerUnitState; + public event Action? Error; + public event Action? Disconnected; + + public void Open() => IsOpen = OpenSucceeds; + + public Task CloseAsync() + { + IsOpen = false; + Disconnected?.Invoke(); + return Task.CompletedTask; + } + + public void RaiseError(Exception ex) => Error?.Invoke(ex); + + public void RaiseDisconnected() => Disconnected?.Invoke(); + + public async Task SendAsync(MTRFXXMode mode, MTRFXXAction action, byte channel, MTRFXXCommand command, + MTRFXXDataFormat format = MTRFXXDataFormat.NoData, byte[]? data = null, uint target = 0, + CancellationToken ct = default) + { + await sendLock.WaitAsync(ct); + try + { + if (lastWrite is { } last) + { + var remaining = interval - Stopwatch.GetElapsedTime(last); + if (remaining > TimeSpan.Zero) await Task.Delay(remaining, ct); + } + + lastWrite = Stopwatch.GetTimestamp(); + + if (SendFault is not null) + { + Error?.Invoke(SendFault); + throw SendFault; + } + + var cmd = new SentCommand(mode, action, channel, command, format, lastWrite.Value); + Sent.Enqueue(cmd); + + var frames = Responder?.Invoke(cmd) ?? []; + if (frames.Count == 0) return; + + _ = Task.Run(async () => + { + await delivery.WaitAsync(); + try + { + if (DeliveryDelayMs > 0) await Task.Delay(DeliveryDelayMs); + foreach (var frame in frames) DeliverCore(frame); + } + finally + { + delivery.Release(); + } + }); + } + finally + { + sendLock.Release(); + } + } + + /// Доставить кадр «с эфира» — как если бы адаптер прислал его без нашей команды. + public void Deliver(byte[] frame) + { + delivery.Wait(); + try + { + DeliverCore(frame); + } + finally + { + delivery.Release(); + } + } + + private void DeliverCore(byte[] frame) + { + var rd = ReceivedData.Parse(frame); + Received?.Invoke(rd); + if (rd.Command == MTRFXXCommand.SendState && rd.DataFormat == PowerUnitStateData.MAIN_INFO_FORMAT) + PowerUnitState?.Invoke(new PowerUnitStateData(frame)); + } +} + +/// Сборка тестовых кадров ответа адаптера (start=173 … stop=174), как их разбирает библиотека. +internal static class Frames +{ + /// Произвольный кадр ответа адаптера. + public static byte[] Response(MTRFXXMode mode, ResultCode ctr, byte togl, byte channel, + MTRFXXCommand command, byte fmt = 0, byte d0 = 0, byte d1 = 0, byte d2 = 0, byte d3 = 0, uint deviceId = 0) + { + var b = new byte[17]; + b[0] = ReceivedData.START_MARKER; // 173 + b[1] = (byte)mode; + b[2] = (byte)ctr; + b[3] = togl; + b[4] = channel; + b[5] = (byte)command; + b[6] = fmt; + b[7] = d0; + b[8] = d1; + b[9] = d2; + b[10] = d3; + b[11] = (byte)(deviceId >> 24); + b[12] = (byte)(deviceId >> 16); + b[13] = (byte)(deviceId >> 8); + b[14] = (byte)deviceId; + b[15] = 0; // CRC библиотекой при разборе не проверяется + b[16] = ReceivedData.STOP_MARKER; // 174 + return b; + } + + /// + /// Send_State силового блока (FMT 0): по данным живого SUF-1-300 (ID 33347) — тип 5, уровень 255 + /// при включении, 0 при выключении; биты 1–0 D2 — состояние нагрузки. — + /// сколько пакетов серии осталось (несколько блоков на канале; последний → 0). + /// + public static byte[] PowerUnitState(byte channel, bool on, uint deviceId = 33347, byte remains = 0) + => Response(MTRFXXMode.TXF, ResultCode.Success, togl: remains, channel, MTRFXXCommand.SendState, + fmt: 0, d0: 5, d1: 0, d2: (byte)(on ? 1 : 0), d3: (byte)(on ? 255 : 0), deviceId: deviceId); + + /// + /// Ответ «нет ответа от блока» (CTR=1) на команду TXF. Команда в кадре — Send_State с нулевыми данными: + /// библиотека типизирует его как состояние, драйвер обязан отбросить по CTR. + /// + public static byte[] NoResponse(byte channel) + => Response(MTRFXXMode.TXF, ResultCode.NoResponse, togl: 0, channel, MTRFXXCommand.SendState); + + /// Ответ-ошибка (CTR=2), как на ReadState по каналу без блоков (проверено на железе). + public static byte[] Error(byte channel) + => Response(MTRFXXMode.TXF, ResultCode.Error, togl: 0, channel, MTRFXXCommand.ReadState); + + /// Ответ сервисного режима (MODE=4): адрес адаптера, Remains == null. + public static byte[] Service(uint adapterId = 4311) + => Response(MTRFXXMode.Service, ResultCode.Success, togl: 0, channel: 0, MTRFXXCommand.None, + fmt: 0, d0: 0, d1: 1, d2: 1, d3: 0, deviceId: adapterId); + + /// Пакет приёма от передатчика (RX) — посторонний для реле. + public static byte[] SensorRx(byte channel, byte toggle = 3) + => Response(MTRFXXMode.RX, ResultCode.Success, togl: toggle, channel, MTRFXXCommand.On); +} diff --git a/ThinkingHome.DeviceModel.Tests/ThinkingHome.DeviceModel.Tests.csproj b/ThinkingHome.DeviceModel.Tests/ThinkingHome.DeviceModel.Tests.csproj index 2f60fa8..3a4c795 100644 --- a/ThinkingHome.DeviceModel.Tests/ThinkingHome.DeviceModel.Tests.csproj +++ b/ThinkingHome.DeviceModel.Tests/ThinkingHome.DeviceModel.Tests.csproj @@ -26,6 +26,7 @@ + \ No newline at end of file diff --git a/ThinkingHome.DeviceModel.sln b/ThinkingHome.DeviceModel.sln index c3d8be9..115c123 100644 --- a/ThinkingHome.DeviceModel.sln +++ b/ThinkingHome.DeviceModel.sln @@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThinkingHome.DeviceModel.Fl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThinkingHome.DeviceModel.Drivers.Stubs", "ThinkingHome.DeviceModel.Drivers.Stubs\ThinkingHome.DeviceModel.Drivers.Stubs.csproj", "{73596FF0-F67F-4875-A289-0F88993B9840}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ThinkingHome.DeviceModel.Drivers.NooLite", "ThinkingHome.DeviceModel.Drivers.NooLite\ThinkingHome.DeviceModel.Drivers.NooLite.csproj", "{2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -158,6 +160,18 @@ Global {73596FF0-F67F-4875-A289-0F88993B9840}.Release|x64.Build.0 = Release|Any CPU {73596FF0-F67F-4875-A289-0F88993B9840}.Release|x86.ActiveCfg = Release|Any CPU {73596FF0-F67F-4875-A289-0F88993B9840}.Release|x86.Build.0 = Release|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Debug|x64.Build.0 = Debug|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Debug|x86.Build.0 = Debug|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Release|Any CPU.Build.0 = Release|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Release|x64.ActiveCfg = Release|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Release|x64.Build.0 = Release|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Release|x86.ActiveCfg = Release|Any CPU + {2D0E1D1C-C2D3-4A25-B5FA-6D04C3ECA33B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/ThinkingHome.DeviceModel/README.md b/ThinkingHome.DeviceModel/README.md index 68a9565..a8d6014 100644 --- a/ThinkingHome.DeviceModel/README.md +++ b/ThinkingHome.DeviceModel/README.md @@ -176,9 +176,13 @@ public sealed record LevelCommand : DeviceCommand { public required int Value { Правила, без которых интеграция не работает: 1. **Стабильный `Id`** — переживает рестарты и переподключения. Иначе ассистент не свяжет устройство. -2. **Достоверный результат команды** — `Execute` возвращает исход (`Done` / `Error` + код), а не - «выстрелил и забыл». Односторонние транспорты без ACK (например, NooLite на передачу) честно - помечаются как `Reportable = false` / best-effort. +2. **Достоверный результат команды** — `Execute` возвращает исход (`Done` / `Error` + код) по тому, + что драйвер знает достоверно, а не «выстрелил и забыл»: `Done` означает, что команда принята + транспортом устройства настолько, насколько транспорт это подтверждает (записана в порт адаптера, + передана в эфир, подтверждена блоком), `Error` с кодом — что этого не произошло. Что именно + означает его `Done`, драйвер документирует (у nooLite — «записана в порт адаптера»); состояние при + этом никогда не выводится из команды — только по принятым данным через `Report`. Односторонние + транспорты без ACK помечают способности как `Reportable = false` / best-effort. 3. **Нормализация значений** — ядро оперирует нормализованными единицами (проценты, °C, кельвины для цвета). Перевод в единицы экосистемы (упаковка hsv в int у Алисы, 0..255 у NooLite) живёт в адаптерах и драйверах, не в ядре. @@ -363,6 +367,9 @@ using var all = host.OnChanged(change => Log(change)); - **`ThinkingHome.Alice`** — адаптер Яндекса: маппер нейтральной модели в DTO Алисы. - **`ThinkingHome.DeviceModel.Proxy`** — транспорт/прокси: даёт Алисе внешний IP и связь с локальным сервером через SignalR (см. корневой README решения). +- **`ThinkingHome.DeviceModel.Drivers.Stubs`** — виртуальные устройства (демонстрация и тесты хаба). +- **`ThinkingHome.DeviceModel.Drivers.NooLite`** — драйвер устройств nooLite / nooLite-F через + USB-адаптер MTRF-64 (первый реальный драйвер; первый этап — реле nooLite-F). ## Статус @@ -430,6 +437,14 @@ using var all = host.OnChanged(change => Log(change)); на `/service/v1.0/*` + `ClaimHostIdResolver` (hostId из claim). Прокси остаётся stateless. Проверен живой сквозной прогон: authorize → OTP из лога → code → token → `/devices` (401 без токена, лампы с ним). +- Первый реальный драйвер физических устройств — `ThinkingHome.DeviceModel.Drivers.NooLite` + (nooLite / nooLite-F через USB-адаптер MTRF-64). Первый этап: реле nooLite-F (`OnOff`) — темп + отправки по правилу адаптера (интервал между записями, без сопоставления ответов с командами), + исход команды «принято адаптером», состояние только по принятому от блока, переоткрытие порта. + Проверено юнит-тестами; живой прогон на железе (реле SUF-1-300) прошёл на прежней схеме с ожиданием + ответа блока, повторный по новой схеме впереди. Дальше по этому драйверу — классика без обратной + связи, диммер, датчики. + Остальные способности/свойства пока не добавлены — заводим по одному **полному** набору за раз, со сверкой по машиночитаемому словарю Matter, чтобы не держать неполную иерархию. diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c0d1027..70d235f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -51,6 +51,7 @@ export default defineConfig({ { text: 'ProxyServer', link: '/packages/proxy-server' }, { text: 'Alice', link: '/packages/alice' }, { text: 'Drivers.Stubs', link: '/packages/drivers-stubs' }, + { text: 'Drivers.NooLite', link: '/packages/drivers-noolite' }, ], }, { diff --git a/docs/apps/hub.md b/docs/apps/hub.md index 9b72e4c..1997211 100644 --- a/docs/apps/hub.md +++ b/docs/apps/hub.md @@ -10,6 +10,13 @@ _Здесь будет: готовое приложение хаба — пла ## Устройства-заглушки: секция StubDevices +## Устройства nooLite: секция NooLite + +Драйвер [`Drivers.NooLite`](../packages/drivers-noolite.md) подключает устройства nooLite / nooLite-F +через USB-адаптер MTRF-64. Плагин включается строкой в `Hub:Plugins`, адаптер и устройства +описываются в секции `NooLite` (COM-порт и список реле по каналам). Устройства должны быть заранее +привязаны к каналам адаптера. Первый этап — реле nooLite-F. + ## Подключение к облачному прокси ## Запуск diff --git a/docs/packages/drivers-noolite.md b/docs/packages/drivers-noolite.md new file mode 100644 index 0000000..c7b7207 --- /dev/null +++ b/docs/packages/drivers-noolite.md @@ -0,0 +1,56 @@ +# ThinkingHome.DeviceModel.Drivers.NooLite + +Драйвер устройств [nooLite / nooLite-F](https://www.noo.com.by/) через USB-адаптер +[MTRF-64-USB](https://www.noo.com.by/mtrf-64-usb.html). Плагин хаба: владеет одним адаптером, +регистрирует устройства из секции конфигурации `NooLite` и держит соединение с адаптером как фоновый +сервис. Построен на библиотеке [`ThinkingHome.NooLite`](https://www.nuget.org/packages/ThinkingHome.NooLite). + +## Набор устройств + +Силовой блок nooLite-F в режиме реле (`Kind: "Relay"`) — способность `OnOff` с обратной связью. +Следующие этапы: классические реле, диммер, датчики температуры/влажности и движения. + +## Конфигурация + +```json +"NooLite": { + "Port": "COM3", + "Devices": [ + { "Id": "relay-1", "Kind": "Relay", "Channel": 0, "Title": "Свет в коридоре", "Room": "Коридор" } + ] +} +``` + +`Port` — COM-порт адаптера; необязательный `ChannelCount` — число каналов адаптера (по умолчанию 64); +для каждого устройства: стабильный `Id`, `Kind`, `Channel` (от 0 до `ChannelCount − 1`), +необязательный `Type` (`OnOffLight` по умолчанию, `OnOffSocket`, `OnOffSwitch`), `Title`, `Room`. +Необязательный `PollInterval` включает периодический опрос состояния блоков. + +Подключение плагина — строкой в `Hub:Plugins`: + +``` +ThinkingHome.DeviceModel.Drivers.NooLite.NooLitePlugin, ThinkingHome.DeviceModel.Drivers.NooLite +``` + +## Предусловие + +Устройства должны быть заранее привязаны к каналам адаптера (утилита `noolite bind` или nooLite ONE) — +драйвер адресует уже привязанные блоки по каналу, но сам привязку не выполняет. + +## Принципы + +- **Исход команды — «принято адаптером»** — `Execute` возвращает `Done` сразу после записи команды в + порт адаптера; `DeviceUnreachable` — только при закрытом порте или ошибке записи. Ответ блока на + исход не влияет: он обновляет состояние. +- **Состояние — только принятое** — снимок содержит лишь то, что реально прислал блок (`Send_State`); + предполагаемое по команде состояние не выдаётся. `Query` возвращает последнее принятое и отправляет + запрос блоку; свежее состояние приходит через `Report`. +- **Интервал между записями** — по правилу адаптера следующая команда уходит только после ответа на + предыдущую; драйвер выдерживает это интервалом 200 мс между записями, общим для всех устройств + адаптера, не сопоставляя ответы с командами. +- **Устойчивость к пропаже адаптера** — порт переоткрывается без перезапуска хаба. + +## Ограничения + +Один блок на канал; только nooLite-F (обратная связь); один адаптер на плагин. +Полное описание — в README проекта драйвера. diff --git a/notes/plan-matter.md b/notes/plan-matter.md new file mode 100644 index 0000000..530a761 --- /dev/null +++ b/notes/plan-matter.md @@ -0,0 +1,181 @@ +# План: Matter-мост на .NET + +Рабочий документ (не входит в содержимое сайта — лежит в `notes/`, как `status.md`). +Зафиксирован по итогам исследования 2026-08-14. Очерёдность: **после двух других задач**. + +## Решение + +Реализовать **device-side стек Matter на .NET самостоятельно** и на нём — мост (Matter Bridge / +Aggregator), выставляющий устройства хаба в Matter-экосистемы: Apple Home, Google Home, станции +Яндекса. Ожидание: за несколько месяцев получить работающую связку — как сейчас получилось с Алисой. + +Границы: +- Это **bridge** (наши устройства наружу, адаптер «сверху» модели). **Controller** (чужие + Matter-устройства внутрь, драйвер «снизу») — отдельная задача, вне скоупа. +- Транспорт — только IP (Wi-Fi/Ethernet). Thread/BLE не нужны: мост коммишенится по IP; + станции Яндекса Thread не поддерживают. +- Следующая задача после Matter — **HomeKit-мост** (Apple bridge); тестовое железо общее. + +### Рассмотренные варианты и почему выбран .NET + +| Вариант | Свой код | Против | +|---|---|---| +| Sidecar на matterbridge (Node, плагин) | ~0.2–0.5 KLOC TS | второй стек в продукте; второй процесс — ещё одна точка отказа; версионирование привязано к чужому релизному циклу | +| Тонкий matter.js-app (Node) | ~1–3 KLOC TS | то же + воспроизводить хелперы | +| chip-bridge-app (C++) | форк примера | GN/ninja-тулчейн, Linux-центричность | +| **Собственный .NET-стек** | ~десятки KLOC | самый большой объём кода; interop-отладка; сопровождение (гонка за спекой) | + +Ключевые факты по .NET: +- Device-side реализаций Matter на .NET **нет**. Существующие ([MatterDotNet](https://github.com/SmartHomeOS/MatterDotNet), + [MatterNet](https://github.com/dotMorten/MatterNet), [dotnet-matter](https://github.com/tomasmcguinness/dotnet-matter)) — + только контроллеры; MatterDotNet к тому же AGPL. Это будет первая такая реализация. +- Криптография **не** дифференциатор стеков: стандартные примитивы (AES-CCM, SHA-256, HKDF, PBKDF2, + ECDH/ECDSA, X.509) — из `System.Security.Cryptography`; арифметика точек EC — BouncyCastle + (в Node — pure-JS `@noble/curves`); SPAKE2+ нигде нативно нет — своя реализация (~150–300 строк) + по RFC 9383 с официальными тест-векторами, ровно как в matter.js. + +## Что уже есть в device-model (по слоям TS-моста) + +| Уровень | Аналог в device-model | Статус | +|---|---|---| +| Сетевой стек Matter: UDP+MRP, сессии PASE/CASE, mDNS, коммишенинг, fabrics | по роли — SignalR-туннель + OTP + JWT для Алисы | ❌ Matter-протокола нет — **предмет этого плана** | +| Interaction Model: Read / Invoke / Subscribe → Report | протокол Discovery / Query / Execute / Report (`IDeviceHost`) — изоморфен | ✅ по семантике 1:1 | +| Node-модель: Endpoint / Behavior, персистентность атрибутов и номеров endpoint'ов | `DeviceDescriptor` / `Endpoint` / `Capability` / `Property`; стабильный `Id` — закон ядра | ⚠️ модель есть; состояние — только in-memory кэш, персистентности нет | +| Словарь: ~90 device types, кластеры, команды | `DeviceType` (18), 8 способностей, 11 свойств — списаны с Matter | ⚠️ уже, но по тем же правилам | +| Перехват команд экосистемы → источник устройств | роутинг `Execute` → `IDevice`; `AliceMapper.ToCommand` | ✅ схема обкатана | +| Плагины-источники устройств | `HubConfigurator` + `IDevicePlugin` | ✅ | +| Эксплуатация: UI, QR-пейринг, конфиг-формы | OTP в лог, `appsettings.json` | ❌ | +| Адаптер модель ↔ экосистема | `AliceMapper` (чистые функции) + оркестрация в `Alice.Service` | ⚠️ роль есть для Алисы; для Matter — ❌ | +| Ядро `IDeviceHost` | `DeviceHost` + кэш + драйверы + стабы | ✅ | + +## План работ + +Порядок «снизу вверх»: у каждого слоя есть свои тест-векторы и свой оракул, слои проверяются +изолированно. Оракулы — три референсные реализации: connectedhomeip (C++), matter.js, rs-matter. +Софтовый эталонный контроллер — **chip-tool**: через него проходит вся петля коммишенинг → read → +invoke → subscribe → report без реальных устройств. + +### Фаза 0. Стенд (параллельно с фазой 1) + +- chip-tool: сборка или готовый Docker-образ на **Linux** (ВМ с bridged-сетью или отдельный бокс; + Docker Desktop / WSL2 на Windows 10 не годятся — mDNS не проходит). +- matter.js как второй оракул: `ServerNode` (устройство) и контроллер — для дифференциальных тестов. +- Wireshark с диссекторами Matter/mDNS; `dns-sd` / `avahi-browse`. +- Клон connectedhomeip: `data_model/` XML (генерация словаря — уже запланирована в README ядра), + тестовые сертификаты `credentials/test/`, сертификационные YAML-сценарии TC-*. + +### Фаза 1. «Чистые» слои — без сети + +| # | Слой | Проверка | +|---|---|---| +| 1 | TLV — кодек, структурные и примитивные типы | тест-векторы спеки; дифференциальные тесты против matter.js (закодировать в TS ↔ декодировать в .NET) | +| 2 | Base38 / QR-код / manual pairing code | тест-векторы; [официальный QR-декодер](https://project-chip.github.io/connectedhomeip/qrcode.html) как оракул | +| 3 | Криптопримитивы в терминах Matter (AES-CCM, SHA-256, HKDF, PBKDF2) | стандартные тест-векторы | +| 4 | SPAKE2+ (PASE) | тест-векторы RFC 9383 | +| 5 | Сертификаты: Matter-TLV-формат, X.509 ↔ Matter-TLV, разбор цепочек | тест-векторы спеки; тестовые PAA/PAI/DAC из connectedhomeip | + +### Фаза 2. Транспорт и сессии — с chip-tool + +| # | Слой | Проверка | +|---|---|---| +| 6 | UDP-транспорт, MRP (ack, retransmit с backoff), счётчики сообщений (анти-replay, персистятся), session layer (AES-CCM), exchange layer | юнит-тесты машины состояний MRP; live — chip-tool + Wireshark | +| 7 | CASE (Sigma-хендшейк, resumption) | тест-векторы; chip-tool после коммишенинга | +| 8 | mDNS-респондер: commissionable (`_matterc._udp`) и operational (`_matter._tcp`) реклама, IPv6 | `dns-sd -B`, `avahi-browse`; сравнение с рекламой matter.js | + +### Фаза 3. Interaction Model и коммишенинг — end-to-end с chip-tool + +| # | Слой | Проверка | +|---|---|---| +| 9 | IM-сервер: Read / Write / Invoke / Subscribe, path-выборки (wildcard), data versions, чанкование репортов | chip-tool `read` / `write` / `subscribe`; YAML TC-* | +| 10 | Системные кластеры: BasicInformation, Descriptor, GeneralCommissioning (fail-safe с откатом), OperationalCredentials (CSR / AddNOC / AddTrustedRootCertificate), AccessControl, AdministratorCommissioning (окно привязки для мульти-админа), NetworkCommissioning (Ethernet-заглушка) | chip-tool `pairing`; YAML TC-* | + +Веха: **chip-tool коммишенит мост, читает атрибуты, шлёт команды, получает репорты по подписке**. + +### Фаза 4. Мост + +| # | Слой | Проверка | +|---|---|---| +| 11 | Aggregator + bridged endpoint'ы: Descriptor/PartsList, BridgedDeviceBasicInformation (`nodeLabel`, `uniqueId` ← стабильный `Id`, `reachable` ← оффлайн) | chip-tool `descriptor read parts-list`; YAML TC-BR-* | +| 12 | Адаптер нейтральной модели: `IDeviceHost` → bridged endpoint'ы; Invoke → `Execute` (честный исход: `Error` → ошибка Invoke); `Changed` → Report; единицы (%, °C, K → Level 1–254, 0.01 °C, mireds, log-люксы, инверсия Window Covering) | стабы `Drivers.Stubs`; тесты полноты по образцу `AliceMapperCompletenessTests` | +| 13 | Персистентность: fabrics, NOC, операционные ключи, ACL, resumption, счётчики, endpoint-id → number | тесты «коммишенинг → рестарт → chip-tool ещё говорит» | + +Веха: **стаб-лампа из хаба видна и управляется через chip-tool как bridged-устройство**. + +### Фаза 5. Interop с реальными экосистемами + +- Apple Home (iPhone + HomePod mini) — самый строгий контролёр: mDNS/IPv6, стабильность `uniqueId`. + Правило сообщества: работает с Apple → почти наверняка работает везде. +- Станция Яндекса — целевая экосистема; проверка **гипотезы о поддержке мостов** (см. риски). +- Google Home — опционально; требует регистрации тестового VID в Developer Console. + +Веха: **лампа видна и управляется в Apple Home и со станции Яндекса; повторная привязка после +рестарта не теряет устройства**. + +## Оценка + +Ориентир: **~3–6 месяцев календарных** до «надёжно живёт в основных экосистемах». Оценка с поправкой +на LLM-разработку (калибровка по практике этого репозитория, а не по до-LLM соло-прецедентам). + +| Фаза | Порядок | Что определяет срок | +|---|---|---| +| 0 — стенд | параллельно | сборка chip-tool, сеть | +| 1 — чистые слои | 1–3 нед | код по спеке с тест-векторами — сжимается сильно | +| 2 — транспорт и сессии | 2–4 нед | машины состояний MRP/сессий; первый live-контакт с chip-tool | +| 3 — IM + коммишенинг | 3–5 нед | самая насыщенная часть спеки; отладка fail-safe и подписок | +| 4 — мост | 2–3 нед | в основном адаптер к уже готовому ядру | +| 5 — interop | 4–8 нед | **wall-clock**: физический пейринг, капризы контроллеров, сеть — сжимается слабо | + +Что сжимается LLM: написание кода по спеке, кодогенерация словаря из `data_model/` XML, +дифференциальные тесты против референсов, YAML-корпус сертификации как готовые тесты. +Что сжимается слабо: interop-отладка (циклы ограничены физикой), сопровождение (спека уже 1.6, +релизы регулярные — постоянные затраты). + +## Стенд для тестирования + +**Софт (0 ₽):** chip-tool на Linux; matter.js; Wireshark; клон connectedhomeip; CI — chip-tool на +Linux-раннере GitHub Actions (мост и контроллер на одном хосте — полностью скриптуемая петля). + +**Железо (минимум):** +- одна станция Яндекса из [списка контроллеров](https://alice.yandex.ru/support/ru/smart-home/supported-matter-devices) + (самая дешёвая — Лайт 2; подойдёт любая из списка); +- iPhone (не новая модель) + HomePod mini (или Apple TV 4K — нужно одно из двух как домашний хаб; + HomePod понадобится и для HomeKit-моста — удалённый доступ и автоматизации). +- Не нужны: физические умные устройства (bridged — стабы), Alexa/SmartThings, Thread border router, + облачная ВМ (Matter строго локален), сертификационный стенд CSA. + +**Сеть:** один плоский L2-сегмент, живой multicast/mDNS, IPv6 link-local, **без изоляции +клиентов**; хост моста — по проводу. Windows 10 нативно — ок (открыть UDP 5540 и 5353); +WSL2 (без mirrored-режима) и Docker Desktop — нет; ВМ — только bridged-адаптер. + +## Риски и открытые вопросы + +- **Поддержка мостов станциями Яндекса не подтверждена официально**: справка перечисляет только + прямые устройства Matter over Wi-Fi (свет, розетки, реле, датчики присутствия), про Aggregator + ни слова; по сообщениям сообщества ([форум Wiren Board](https://support.wirenboard.com/t/offlajn-matter-bridge-most-v-yandeks-stancziyu-iz-ustrojstv-wb/30374)) + мосты подключают. Проверить экспериментально как можно раньше — от этого зависит ценность + главного сценария (локальное управление без облачного прокси). +- **CSA-membership**: без него — тестовые VID 0xFFF1–0xFFF4 и предупреждение «непроверенный + аксессуар» при привязке (все DIY-мосты живут так); сертификация — вне скоупа. +- **Сопровождение**: спека развивается (1.6), контроллеры обновляются — гонка постоянная. +- **Проектные развилки — не решены, решать в design при оформлении change:** + - многоэндпоинтные устройства: composed device (один bridged-узел с детьми — буквально по Matter) + vs отдельные bridged endpoint'ы (проще, понятнее в UI контроллеров); + - `TargetTemperature` (одна уставка) → Thermostat с раздельными heat/cool setpoint; + - `WaterMeter` — вендорский кластер: Apple/Google его не покажут; + - Brightness 0 vs OnOff (Level Control 1–254); + - **персистентное состояние на домашней стороне впервые** (fabrics, ключи, счётчики): формат, + расположение, бэкап — потеря = отвязка у всех пользователей во всех экосистемах; + - точка входа для локального потребителя на хабе (сейчас хаб только исходяще подключается + к облачному прокси) — актуально, если стек будет жить не в процессе хаба. +- `notes/status.md`: строка про отсутствие Matter-адаптера остаётся до реализации. + +## Источники + +- Спецификации Matter (Core / Device Library / Cluster Library): https://csa-iot.org/developer-resource/specifications-download-request/ +- connectedhomeip (референс, `data_model/`, chip-tool, YAML-тесты): https://github.com/project-chip/connectedhomeip +- matter.js: https://github.com/project-chip/matter.js — SPAKE2+: `packages/general/src/crypto/Spake2p.ts` +- rs-matter (референс на Rust, CSA): https://github.com/project-chip/rs-matter +- matterbridge (референс архитектуры моста поверх matter.js, локальный клон `D:\Source\external\matterbridge`): https://github.com/Luligu/matterbridge +- SPAKE2+: RFC 9383 +- Matter у Яндекса: [поддерживаемые устройства](https://alice.yandex.ru/support/ru/smart-home/supported-matter-devices), + [как добавить](https://alice.yandex.ru/support/ru/smart-home/turn-on/matter) diff --git a/notes/plan-noolite-hardware.md b/notes/plan-noolite-hardware.md new file mode 100644 index 0000000..d9015d0 --- /dev/null +++ b/notes/plan-noolite-hardware.md @@ -0,0 +1,85 @@ +# NooLite: результаты проверки на живом железе + +Рабочий документ (не попадает на сайт). Данные для тестов драйвера `ThinkingHome.DeviceModel.Drivers.NooLite` +(change `add-noolite-driver`, tasks 1.x). Библиотека `ThinkingHome.NooLite` 5.0.0, адаптер MTRF-64-USB на COM3, +реле SUF-1-300 (nooLite-F, ID 33347) на канале 0. Пробник — scratchpad, не в репозитории. + +## Стенд + +| | | +|---|---| +| Адаптер | MTRF-64-USB, COM3; собственный nooLite-F адрес (ответ на MODE=4): **4311** | +| Реле | SUF-1-300, nooLite-F, ID **33347**, канал **0**; `DeviceType = 5`, `FirmwareVersion = 0` | +| Пустой канал | 40 (ничего не привязано) | + +## Пакеты (то, что печатает `ReceivedData.ToString()` 5.0.0) + +``` +MODE=4 (ExitServiceMode), ответ через ~100 мс: + mode: Service, command: None, result: Success, channel: 0, fmt: 0, data: [0, 1, 1, 0], device ID: 4311 + +ReadStateF(0), ответ через ~140 мс, серия из ОДНОГО пакета (один блок на канале): + mode: TXF, command: SendState, result: Success, channel: 0, remains: 0, fmt: 0, data: [5, 0, 0, 0], device ID: 33347 + → PowerUnitStateData: type=5 fw=0 state=Off svc=False level=0 + +OnF(0), ответ через ~140 мс: + mode: TXF, command: SendState, result: Success, channel: 0, remains: 0, fmt: 0, data: [5, 0, 1, 255], device ID: 33347 + → state=On level=255 ← PowerLevel 255 при включении (не проценты) + +OffF(0), ответ через ~110 мс: + mode: TXF, command: SendState, result: Success, channel: 0, remains: 0, fmt: 0, data: [5, 0, 0, 0], device ID: 33347 + → state=Off level=0 + +ReadStateF(40) — пустой канал, ответ через ~60 мс: + mode: TXF, command: ReadState, result: Error, channel: 40, remains: 0, fmt: 0, data: [0, 0, 0, 0], device ID: 0 + → CTR=2 «ошибка», команда в ответе — ReadState (не SendState), ID 0 + +OnF(40) — пустой канал: ОТВЕТА НЕТ (5 с тишины). Не CTR=1, а полное молчание. +``` + +## Наблюдения для дизайна транзакций (D1) + +1. **Строгая последовательность подтверждена.** Схема «команда → дождаться пакета с `Remains == 0` + → сразу следующая» — 8/8 команд без потерь, 90–155 мс на команду (без пауз между). +2. **Команды подряд без ожидания ответа теряются молча.** `OnF` + `OffF` подряд → один ответ (через + ~280 мс), состояние реле — `On` (вторая потеряна). `OffF`+`OnF`+`OffF` подряд → один ответ (~260 мс), + состояние `On`. Потерянные команды **не дают никакого пакета** — ни ошибки, ни `CTR=1`. + Вывод: очередь «одна в полёте» обязательна, а таймаут — реальный предохранитель, а не формальность. +3. **Время ответа блока**: 90–155 мс на команду/`ReadStateF`, ~60 мс на ошибку `ReadStateF` пустого + канала. Таймаут транзакции 3 с — с большим запасом (в 20 раз). +4. **`CTR=1` («нет ответа от блока») не воспроизведён**: для непривязанного канала адаптер молчит на + `OnF`. `CTR=1`, по руководству, — для привязанного, но не отвечающего блока (обесточен); не проверялось + (нужно физически обесточить реле). Для драйвера оба случая → таймаут/`CTR=1` → `DeviceUnreachable`. +5. **`ReadStateF` на канал без блоков → `CTR=2`** с `Command = ReadState`, `DeviceId = 0`. Для `Query` + это «состояния нет» (снимок пуст), для прайминга — журнал (вероятная ошибка конфига: канал без блока). +6. `Remains` в ответах на один блок всегда 0 (серия из одного пакета); многопакетная серия (несколько + блоков на канале) на этом стенде не воспроизводима — тест на неё строится по руководству + (`TOGL` убывает до 0). +7. `MODE=4` при уже работающем адаптере (не после подачи питания) отвечает нормально через ~100 мс — + транзакция подготовки адаптера может ждать этот ответ как обычный. +8. `PowerLevel`: 255 при `On`, 0 при `Off` для SUF-1-300 в режиме реле — не интерпретировать как проценты + (design D3). `FirmwareVersion` = 0. + +## Сквозной прогон драйвера на железе (task 6.2) + +Через публичный `NooLitePlugin` (адаптер COM3, реле на канале 0), стек `MtrfTransport` (реальный) → +`AdapterSession` → `NooLiteRelay` → `DeviceHost`: + +``` +registered devices: 1 +прайминг при старте: состояние блока канал 0 → state Off → Report OnOff=False +discovery: 1 endpoint, OnOffCapability +query (initial): [OnOff=False] ← из принятого Send_State +EXECUTE On → Report OnOff=True, outcome Done ; query (after On): [OnOff=True] +EXECUTE Off → Report OnOff=False, outcome Done ; query (after Off): [OnOff=False] +``` + +Подтверждено: честный исход команды (`Done` по `Send_State`), состояние только по принятому, +`Report` при изменении, реле физически переключается. Извлечение/возврат USB вручную не выполнялось — +логика реконнекта и повторного прайминга покрыта юнит-тестом `Reopen_primes_state_again`. + +## Что не проверялось (следующие этапы / по возможности) + +- `CTR=1` от обесточенного привязанного блока (время до ответа). +- Датчики (PT-111 на канале 1): MODE пакетов, `ToggleCounter` на повторах — этап «датчики». +- Диммер: формат `Set_Brightness`, шкала `PowerLevel` — этап «диммер». diff --git a/docs/.status.md b/notes/status.md similarity index 58% rename from docs/.status.md rename to notes/status.md index e6f4eef..e200a8d 100644 --- a/docs/.status.md +++ b/notes/status.md @@ -4,12 +4,12 @@ Тексты документации описывают **целевую картину** и не содержат пометок о готовности: иначе при каждой реализации пришлось бы переписывать тексты. Всё, что описано, но пока -не сделано, фиксируется здесь. Файл не попадает на сайт (имя начинается с точки). +не сделано, фиксируется здесь. Файл лежит в `notes/` и не входит в содержимое сайта. | Описано в текстах | Фактическое состояние | | --- | --- | | Хаб работает с несколькими экосистемами | Реализован адаптер Яндекс Алисы. Matter-адаптера нет: словарь модели выровнен на Matter структурно, но стек Matter (mDNS, TLV, PASE/CASE, fabric) не реализован | -| Хаб связывается с устройствами по распространённым протоколам — MQTT, Zigbee | Драйверов к физическим устройствам нет; есть только библиотека стабов `ThinkingHome.DeviceModel.Drivers.Stubs` | -| Пользователь подключает к хабу свои устройства | Приложение `ThinkingHome.DeviceModel.Hub` подключает плагины из конфигурации (Hub:Plugins); каждый плагин сам создаёт устройства по своей секции. Ограничение: доступны только плагины из сборок, на которые ссылается приложение (встроены стабы и коннектор прокси) | -| Драйвер устройства — небольшая прослойка на .NET | Контракт `IDevice` готов и на нём написаны заглушки, но реального драйвера к физическому устройству ещё не было — оценка «небольшая» не проверена практикой | +| Хаб связывается с устройствами по распространённым протоколам — MQTT, Zigbee | Из физических протоколов реализован драйвер nooLite / nooLite-F через USB-адаптер MTRF-64 (`ThinkingHome.DeviceModel.Drivers.NooLite`, первый этап — реле nooLite-F). MQTT/Zigbee пока нет; есть также библиотека стабов `ThinkingHome.DeviceModel.Drivers.Stubs` | +| Пользователь подключает к хабу свои устройства | Приложение `ThinkingHome.DeviceModel.Hub` подключает плагины из конфигурации (Hub:Plugins); каждый плагин сам создаёт устройства по своей секции. Ограничение: доступны только плагины из сборок, на которые ссылается приложение (встроены стабы, драйвер nooLite и коннектор прокси) | +| Драйвер устройства — небольшая прослойка на .NET | Проверено на первом реальном драйвере (nooLite-F, реле): контракт `IDevice` + транспорт с интервалом между записями — компактный. Живой прогон на железе прошёл на прежней схеме (ожидание ответа блока ради исхода); после перехода на «принято адаптером» повторный прогон впереди. Специфика транспорта (темп отправки по правилу адаптера, состояние только по принятому) добавляет объём сверх «прослойки», но меньше, чем слой транзакций с корреляцией | | `dotnet add package ThinkingHome.*` на странице ядра (путь с кодом) | Пакеты пока не опубликованы в NuGet: `dotnet pack` собирает их локально, публикация не настроена. Сам код примера проверен запуском на ссылках к проектам решения | diff --git a/openspec/changes/add-noolite-driver/.openspec.yaml b/openspec/changes/add-noolite-driver/.openspec.yaml new file mode 100644 index 0000000..0c73c8f --- /dev/null +++ b/openspec/changes/add-noolite-driver/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-15 diff --git a/openspec/changes/add-noolite-driver/design.md b/openspec/changes/add-noolite-driver/design.md new file mode 100644 index 0000000..e5df029 --- /dev/null +++ b/openspec/changes/add-noolite-driver/design.md @@ -0,0 +1,266 @@ +## Context + +Мотивация — в proposal.md; требования — в `specs/noolite-driver/spec.md`. Здесь — как это устроить. +Дизайн закладывает каркас на все этапы (классика, диммер, датчики — следующие change'и), но реализует +в этом change только реле nooLite-F. + +Исходные условия: + +- Контракт драйвера — `IDevice` (Describe / QueryAsync / ExecuteAsync / Changed) и плагин + `IDevicePlugin.RegisterDevices(IDeviceRegistry)`; плагин, реализующий `IHostedService`, + `HubConfigurator` регистрирует тем же экземпляром. Образец — `Drivers.Stubs`. +- Хост держит кэш состояния: `Query` отвечает из кэша, драйвер — фолбэк на первом запросе, + изменения приходят через `Changed`. Значит, драйвер обязан **публиковать** всё, что принял. +- Библиотека `ThinkingHome.NooLite` **5.0.0** (net10.0, NuGet): `MTRFXXAdapter` над `SerialPort` + (9600, таймаут записи 500 мс), таймер-поллинг чтения (50 мс) кладёт пакеты в ограниченную очередь + (128, `DroppedPacketsCount`), **фоновый диспетчер** вызывает обработчики по одному и в порядке прихода + (сначала `ReceiveData`, затем типизированное событие); исключение обработчика → `Error`, приём не + ломается; `Disconnect` — гарантированно последнее событие. Отправка (`SendCommand`, `OnF`/`OffF`/…, + `ReadStateF(channel, deviceId?, format)`) атомарна относительно других записей и чтения. + Типизированный разбор: `PowerUnitStateData` (`Send_State` FMT 0: `DeviceType`, `FirmwareVersion`, + `State` Off/On/TemporaryOn, `ServiceMode`, `PowerLevel` — тип и мощность «без интерпретации»), + `StateFormatErrorData` (FMT 255), `MicroclimateData` (датчики — следующий этап). `ReceivedData`: + `Remains` (int?, TX/TXF — сколько пакетов ответа осталось), `ToggleCounter` (int?, RX/RXF), `Togl`. + **Ошибка открытия порта — событие `Error`, а не исключение**; после `Open()` проверять `IsOpened`. + `Close()`/`Dispose()` отбрасывают недоставленное; `FlushAndCloseAsync()` дожидается доставки. + Fire-and-forget остаётся: темп отправки, реконнект — **в драйвере** (решение владельца, + зафиксировано в handoff библиотеки). Интервал между записями библиотека не выдерживает: + `SendCommand` только сериализует запись байтов под замком. +- Протокол MTRF-64-USB-A (руководство): адаптер отвечает на каждый принятый пакет; `CTR` ответа — + 0 выполнена / 1 нет ответа от блока / 2 ошибка / 3 привязка; `TOGL` в TXF — сколько пакетов + ответа осталось (последний → 0); после любой команды в TXF блок присылает `Send_State`; + `Read_State` на канал → `Send_State` от каждого блока канала; «новую команду — только после ответа + на предыдущую»; после подачи питания 12 с режим обновления, MODE=4 выводит сразу. +- Известно с живого железа (сессия библиотеки и tasks 1.x): адаптер на COM3; реле **SUF-1-300 — + nooLite-F** (ID 33347) на канале 0; SUF-1-300 отдаёт тип устройства 5 и `PowerLevel = 255` при + включении (руководство для ревизии -A обещает 9 и 100 — константы со справкой не сходятся). + Ответ блока на команду/`ReadStateF` — 90–155 мс; команды, отправленные без ожидания ответа, + теряются молча; команда в пустой канал — полное молчание адаптера. +- Решения владельца: Id устройства — из конфига; состояние — только принятое от устройства; этапы — + отдельными change'ами, первый — только реле nooLite-F. Решения 2026-09-20: исход команды — + «принято адаптером» (успешная запись в порт); ответы адаптера с командами не сопоставляются; + `Query` возвращает последнее принятое и отправляет запрос; `Execute` и `Query` оба ждут ворот + отправки; интервал отправки 200 мс, корректируется после замера. + +## Goals / Non-Goals + +**Goals:** +- Один общий на адаптер механизм темпа отправки (интервал между записями), через который идут + команды, запросы состояния и подготовка адаптера — и на который лягут следующие этапы. +- Устройства — тонкие объекты без I/O-логики: описание, разбор своих пакетов, сборка своих команд. +- Тестируемость без железа: транспорт за интерфейсом, тесты на реальных байтовых пакетах из руководства + и с живого железа. +- Опираться на API библиотеки 5.0.0 (типизированные события, `ReadStateF`, thread-safe отправка) — + без дублирования разбора пакетов в драйвере. +- Формат конфига и структура кода расширяемы следующими этапами без слома: новые `Kind`, поле признака + обратной связи со значением по умолчанию «есть», диспетчер входящих RX-пакетов. + +**Non-Goals (этого change'а):** +- Классические реле nooLite (TX), диммер / `Brightness`, датчики PT111/PT112/PM111 и разбор RX-пакетов, + пульты, RGB — следующие change'и. +- Привязка/отвязка устройств из хаба, несколько адаптеров, адресация блоков по 32-битному ID + (несколько блоков на одном канале), настройки блоков (`Write_State`). +- Изменения кода ядра, FluentApi, адаптера Алисы (формулировка правила о достоверном исходе в README + ядра — документация, см. proposal). + +## Decisions + +### D1. Темп отправки вместо транзакций + +``` +ExecuteAsync / QueryAsync / подготовка адаптера / периодический опрос + │ порт закрыт? → Execute: DeviceUnreachable; Query: последнее принятое (без отправки) + ▼ + ворота отправки (одни на адаптер): дождаться, пока с последней записи пройдёт ≥ 200 мс, + │ затем записать пакет; ожидающие проходят по очереди + ▼ + IMtrfTransport { Open/Close/IsOpen; Send(mode, action, ch, cmd, fmt, data, target); + event Received(ReceivedData); event PowerUnitState(PowerUnitStateData); + event Error; event Disconnected } + реализация — тонкая обёртка над MTRFXXAdapter 5.0.0; исключение записи → событие Error (сигнал D5) + входящие: PowerUnitState → реле своего канала → Changed при отличии от последнего принятого (D3) + RX/RXF → Debug-лог (D4) +``` + +- **Два обязательства разделены.** Правило протокола «новую команду — только после ответа на + предыдущую» — это окно занятости адаптера. Ожидание ответа блока — отдельный вопрос, нужный только + ради исхода команды. Прежняя редакция (`AdapterSession`: очередь + корреляция по каналу + таймаут + 3 с) держала очередь до ответа блока и обещала в API больше, чем даёт адаптер. Теперь окно + занятости выдерживается **интервалом времени**, а ответы блоков потребляются как состояние + независимо от команд. +- **Интервал.** На железе (`notes/plan-noolite-hardware.md`): ответ на команду/`ReadStateF` 90–155 мс, + ошибка `ReadStateF` пустого канала ~60 мс; команды без ожидания ответа теряются молча (без `CTR=1`). + Окно занятости при **молчащем** блоке (команда в пустой канал — адаптер молчит; `CTR=1` от + обесточенного привязанного блока) не измерено. Исходное значение 200 мс — константа драйвера; + корректируется после замера (tasks 1.4) без изменения схемы. +- **Исход команды — по факту записи.** `Done` после успешной записи в порт; `DeviceUnreachable` — + порт закрыт (без попытки записи) или запись бросила исключение (→ `Error` транспорта → переоткрытие, + D5); `Unsupported` — команда не `OnOff`. `CTR=1`, `CTR=2` и молчание адаптера на исход не влияют — + только журнал. +- **Без корреляции.** Любой `Send_State` на канале устройства — истинное состояние блока, запрошено + оно или нет (поздний ответ, ответ на прайминг/опрос, пакеты многопакетной серии). Многопакетные + серии (`Remains`) и ответ на MODE=4 никто не ждёт. Список отправленных команд не нужен: у состояния + нет потребителя, которому важно, была ли команда. +- **Владелец ворот — транспорт** (решение владельца 2026-09-20): интервал выдерживает + `MtrfTransport.SendAsync` (семафор + момент последней записи, константа `DefaultSendInterval`); + устройства и плагин о воротах не знают. Фейковый транспорт в тестах повторяет тот же контракт; + интервал реального транспорта проверяется на железе (tasks 6.4). +- **Обработчики событий библиотеки — короткие.** Библиотека доставляет пакеты последовательно из + одного фонового потока: долгий обработчик задержит следующие пакеты. Реле в обработчике только + обновляет последнее принятое и поднимает `Changed`. +- **Отвергнутая альтернатива**: ожидание ответа блока ради исхода (прежний D1) — отвергнута владельцем + 2026-09-20: склеивает окно занятости адаптера с ожиданием блока; в худшем случае очередь копит + таймауты (3 с на каждый неотвечающий блок); при таймауте или отмене ворота открывались раньше + ответа, и поздний пакет мог закрыть чужую транзакцию. + +### D2. Устройство ↔ канал; Id из конфига + +Единица адресации первого шага — **канал адаптера**: одно устройство на канал. Команды идут `CTR=0` +по каналу; `Send_State` от блока сопоставляется устройству по каналу (ID блока из ответа пишется в +журнал; отдавать ли его в описании — Open Questions). Стабильный `Id` задаёт пользователь в конфиге +(как у стабов) — перепривязка канала не меняет идентичность устройства для Алисы. + +Альтернатива — вывод Id из адресации (`noolite:ch5`) — отвергнута владельцем; адресация по ID блока +(`CTR=8`, несколько блоков на канале; `ReadStateF`/команды по `deviceId` библиотека уже умеет) — +расширение конфига полем адреса позже, без слома формата. + +### D3. Состояние — только принятое + +- Состояние реле — из `PowerUnitStateData.State` (Off → false, On/TemporaryOn → true). `PowerLevel` + игнорируется (живой SUF-1-300 отдаёт 255 при включении — не проценты; шкала для диммера — следующий + этап). `DeviceType`/`FirmwareVersion` — только в журнал. +- Источники `Send_State`: ответ на команду, прайминг `ReadStateF` при готовности адаптера, + `QueryAsync`, опциональный периодический опрос (конфиг `PollInterval`, по умолчанию выключен) — + нужен, если блоком управляют и с пультов: адаптер этого не видит. +- `QueryAsync` возвращает последнее принятое (или пусто), отправив `ReadStateF` через ворота; свежее + состояние придёт через `Changed`. При закрытом порте — только последнее принятое, без отправки. +- Ничего не выводится из отправленной команды: команда без ответа блока не меняет снимок. +- `ReadStateF` на канал без привязанных блоков возвращает `CTR=2` (`Command = ReadState`, `DeviceId = 0`, + проверено на железе): на снимок не влияет (остаётся пустым); в журнал — предупреждение (вероятная + ошибка конфига: канал без блока). +- Каждый принятый `Send_State` обновляет последнее принятое; `Changed` публикуется при отличии от + последнего принятого — одинаково для ответов на команды, запросы и периодический опрос, чтобы не + шуметь в Алису (хост дедупит по слоту сам, но лишние события не нужны). +- Хост-кэш работает без изменений: пустой снимок → у Алисы устройство без состояния (маппер это уже + умеет: `ToDeviceState` отдаёт пустые списки). + +### D4. Входящие пакеты приёма (RX/RXF) + +Если к адаптеру привязаны датчики или пульты, их пакеты приходят в любой момент. В этом change они +не относятся ни к одному устройству: транспорт различает их по `Mode` — типизированное +`PowerUnitState` приходит только для ответов блоков, а RX/RXF идут в журнал (Debug). Точка расширения — +диспетчер по каналу с дедупом по `ToggleCounter` (следующий этап, датчики). + +### D5. Жизненный цикл адаптера (плагин = IHostedService) + +``` +StartAsync → цикл: Open() → IsOpened? (ошибка открытия приходит событием Error, не исключением) + → MODE=4 через ворота (ответ = адрес адаптера, не ожидается) + → прайминг ReadStateF по каналам устройств через ворота + → рабочий режим (+ опциональный таймер PollInterval) + при Disconnect / Error (ошибка порта: IOException, таймаут записи; исключение записи из Send) + → закрыть → пауза (5 с, экспоненциально до 30 с) → заново +StopAsync → отменить цикл, FlushAndCloseAsync() (не отбрасывать уже принятое) +``` + +Пока порт закрыт: `Execute` → `DeviceUnreachable` сразу; `Query` → последнее принятое/пусто. +Устройства регистрируются в `RegisterDevices` независимо от наличия адаптера — хаб стартует без +железа. `SerialPort` не даёт события «USB извлечён» надёжно: признаком служат ошибки чтения/записи +(`Error` библиотеки с `IOException`/`TimeoutException`; таймаут записи 500 мс в 5.0.0 гарантирует, +что отправка не повиснет; исключение записи транспорт переводит в своё событие `Error`), после которых +порт переоткрывается. `Error` от исключений в собственных обработчиках драйвера — в журнал, без +переоткрытия. Рост `DroppedPacketsCount` — предупреждение в журнал. + +### D6. Конфигурация + +Секция `NooLite` (имя — константа плагина, по образцу `StubDevices`): + +```json +"NooLite": { + "Port": "COM3", + "PollInterval": null, + "Devices": [ + { "Id": "relay-1", "Kind": "Relay", "Channel": 0, "Title": "Свет в коридоре", "Room": "Коридор" }, + { "Id": "socket-1", "Kind": "Relay", "Channel": 2, "Type": "OnOffSocket", "Title": "Розетка у стола" } + ] +} +``` + +- `Kind` — строка (как у стабов: биндер молча теряет неконвертируемые enum-значения списка); в этом + change одно значение — `Relay` (реле nooLite-F). Следующие этапы добавляют значения (`Dimmer`, + `ClimateSensor`, …) и для силовых блоков — признак обратной связи (`Feedback`) со значением по + умолчанию `true`, чтобы конфиги этого этапа не меняли смысла. +- `Type` — необязательный тип устройства в модели: `OnOffLight` (по умолчанию), `OnOffSocket`, + `OnOffSwitch` — реле nooLite универсальны, «что это» знает только пользователь. +- `PollInterval` — необязательный интервал периодического опроса (`TimeSpan`), по умолчанию выключен. +- `ChannelCount` — необязательное число каналов адаптера, по умолчанию 64 (MTRF-64); задаёт диапазон + `Channel` устройств `0 … ChannelCount − 1`; не больше 256 — канал в пакете адаптера занимает один байт. +- Валидация: порт, `ChannelCount` (1–256), уникальность `Id`, `Kind`, канал в пределах `ChannelCount`, + `Type` — с позицией записи; ошибки бросаются из `RegisterDevices` (хаб останавливается, как со стабами). + +### D7. Зависимость от библиотеки + +NuGet `ThinkingHome.NooLite` 5.0.0 (net10.0). Драйвер использует её API напрямую: отправка — +`OnF`/`OffF`/`ReadStateF`/`ExitServiceMode` (или `SendCommand` для общего случая); разбор — +`ReceivedData`, `PowerUnitStateData`, `StateFormatErrorData` через типизированные события. +Собственного разбора пакетов и собственного lock'а на отправку в драйвере нет. Интервал между +записями (ворота) — в драйвере: `SendCommand` библиотеки только сериализует запись байтов и не +выдерживает пауз. + +Точка внимания при обновлениях библиотеки: контракт доставки событий (последовательно, из фонового +потока, `Disconnect` последним) и семантика `Close`/`FlushAndCloseAsync` — на них опирается D1/D5. + +### D8. Тестирование без железа + +`IMtrfTransport` подменяется фейком, который на `Send` отвечает заранее заданной последовательностью +`ReceivedData` (байты собираются `MTRFXXAdapter.BuildCommand`/разбираются `ReceivedData.Parse` — раскладка +из руководства и дампы с живого железа: `Send_State` реле SUF-1-300 с ID 33347, `CTR=1`, `CTR=2`, серия +с убывающим `Remains`, посторонний RX-пакет). Фейк воспроизводит контракт библиотеки: доставка +последовательная, из другого потока. Проверяются: интервал между записями (две команды подряд — +вторая записана не раньше чем через интервал, обе `Done`); `Done` сразу после записи без ожидания +ответа; `DeviceUnreachable` при закрытом порте (без записи) и при исключении записи (+ событие `Error`); +`CTR=1`, `CTR=2`, молчание и многопакетная серия не влияют на исход, а состояние берётся из каждого +`Send_State`; `Send_State` без предшествующей команды принимается; `Query` возвращает последнее +принятое, не дожидаясь ответа, и отправляет `ReadStateF`; посторонние RX-пакеты не меняют состояние; +реконнект (транспорт бросает при `Send` / поднимает `Disconnected`); валидация конфига (по образцу +`HubConfiguratorTests`/`StubsPluginTests`); полнота: каждый `Kind` создаётся и описывается только +концептами словаря ядра. + +## Risks / Trade-offs + +- [Константы блоков расходятся со справкой: SUF-1-300 отдаёт тип 5 и `PowerLevel` 255 вместо 9 и 100] + → `DeviceType`/`PowerLevel` не интерпретировать; для реле смотреть только `State`. +- [Несколько блоков на одном канале → их `Send_State` смешиваются в одно устройство] → ограничение + первого шага, документировать; расширение полем адреса блока. +- [Интервал 200 мс подобран по измерениям с отвечающим блоком; окно занятости адаптера при молчащем + блоке не измерено — если оно длиннее, следующая команда потеряется молча] → замер (tasks 1.4); + значение — константа, корректируется без изменения схемы. +- [`Done` не означает, что блок выполнил команду: Алиса получит `DONE`, а состояние останется прежним, + пока блок не ответит] → принято владельцем: состояние только принятое, интерфейс покажет истину + после `Send_State`; документировать в README драйвера и согласовать правило о достоверном исходе в + README ядра. +- [Обработчики библиотеки последовательные: долгий обработчик задерживает следующие пакеты] + → обработчики реле короткие (обновить последнее принятое, поднять `Changed`). +- [Стейт блока может разъезжаться при управлении с пультов] → опциональный `PollInterval`; + без него — честно «последнее принятое». +- [`SerialPort` на Windows при извлечении USB может держать порт/бросать из таймера] → порт + переоткрывается по ошибкам порта; при повторяющихся ошибках — экспоненциальная пауза до 30 с; + таймаут записи 500 мс не даёт зависнуть отправке. +- [Каркас закладывается «на вырост» (RX-ветка), а проверяется одним видом устройств] → держать точки + расширения минимальными и покрытыми тестами на «посторонний пакет»; не реализовывать заранее + диспетчер датчиков. + +## Migration Plan + +Новый проект и конфиг-секция; хаб без секции `NooLite` в конфиге не меняет поведения (плагин +подключается только строкой в `Hub:Plugins`). Откат — убрать строку плагина. Следующие этапы добавляют +`Kind`/поля с обратно совместимыми значениями по умолчанию. + +## Open Questions + +- Интервал отправки: константа драйвера или настройка в секции `NooLite` — после замера. +- Нужна ли диагностика полного молчания адаптера на команду (таймер на каждую отправку, только для + журнала): без неё ошибка конфига (канал без блока) видна лишь как пустой снимок. +- Значение `PollInterval` по умолчанию, если опрос окажется нужен на практике (после проверки с пультом). +- Стоит ли отдавать ID блока nooLite-F (например, 33347) в `DeviceManufacturer` как серийник — + решается при реализации `Describe`, на спеку не влияет. diff --git a/openspec/changes/add-noolite-driver/proposal.md b/openspec/changes/add-noolite-driver/proposal.md new file mode 100644 index 0000000..10b6fbf --- /dev/null +++ b/openspec/changes/add-noolite-driver/proposal.md @@ -0,0 +1,77 @@ +## Why + +Хаб умеет работать только со стабовыми устройствами: контракт `IDevice` готов, адаптер Алисы обкатан, +но ни одного драйвера к физическому железу нет — и оценка «драйвер — небольшая прослойка на .NET» +(см. `notes/status.md`) не проверена практикой. Первый реальный драйвер — устройства nooLite через +USB-адаптер MTRF-64: у автора есть адаптер и реле nooLite-F, а библиотека `ThinkingHome.NooLite` 5.0.0 +уже реализует пакетный протокол адаптера. + +Работа разбита на этапы (отдельные change'и). **Этот change — первый этап**: каркас плагина и +работа с адаптером (темп отправки, состояние по принятым данным), а из устройств — только +**реле nooLite-F**. Дальнейшие этапы (отдельные change'и): классические реле nooLite (без обратной +связи), диммер (`Brightness`), датчики PT111/PT112 и PM111. + +## What Changes + +- Новый проект **`ThinkingHome.DeviceModel.Drivers.NooLite`** — плагин хаба (`IDevicePlugin` + + `IHostedService`) с NuGet-зависимостью на `ThinkingHome.NooLite` 5.0.0: владеет одним адаптером + MTRF-64-USB (COM-порт из конфига), регистрирует устройства из своей секции конфигурации. +- Первое поддерживаемое устройство — **силовой блок nooLite-F в режиме реле** (`OnOff`), например + SUF-1-300. +- **Честное состояние**: драйвер отдаёт только состояние, **принятое от блока** (`Send_State`), + никогда не предполагаемое по отправленной команде. Пока блок не ответил — состояние неизвестно. +- **Исход команды — «принято адаптером»**: `Execute` возвращает `Done` сразу после успешной записи + команды в порт адаптера; `DeviceUnreachable` — только если порт закрыт или запись не удалась. + Ответ блока на исход не влияет: он обновляет состояние. (Решение владельца 2026-09-20; прежняя + редакция ждала ответа блока ради исхода.) +- **Темп отправки** по правилу протокола «новую команду — только после ответа на предыдущую»: между + записями в порт выдерживается интервал (исходно 200 мс, уточняется по замеру), общий для всех + устройств адаптера; ответы адаптера и блоков с командами не сопоставляются — любой `Send_State` + на канале принимается как состояние. Посторонние входящие пакеты (приём от датчиков и пультов, + если они привязаны к адаптеру) на состояние не влияют и в этом этапе только журналируются. +- Запрос состояния блоков при готовности адаптера и по запросу хаба (`Query` возвращает последнее + принятое и отправляет запрос; свежее состояние приходит через `Report`); опционально — + периодический. +- Переоткрытие порта при пропаже/возврате USB-адаптера; пока адаптера нет — команды возвращают + `DeviceUnreachable`. +- Стабильный `Id` устройства задаётся в конфиге (как у стабов); адресация (канал адаптера) — + деталь конфига, перепривязка канала не меняет `Id`. +- Хаб получает ссылку на новый проект (плагины доступны только из сборок, на которые ссылается + приложение); пример конфигурации в `appsettings.json` хаба; страница драйвера в документации. + +Вне скоупа этого этапа (следующие change'и): классические реле nooLite (TX, без обратной связи), +режим диммера / `Brightness`, датчики PT111/PT112/PM111, пульты (в ядре нет набора «кнопка»), +RGB-контроллеры, привязка устройств из хаба (делается инструментом `noolite`/`nooLite ONE`), +несколько адаптеров, адресация блоков по ID (несколько блоков на канале), изменения библиотеки +`ThinkingHome.NooLite`. + +## Capabilities + +### New Capabilities +- `noolite-driver`: драйвер устройств nooLite через адаптер MTRF-64-USB — конфигурация, discovery, + исполнение команд с исходом «принято адаптером», состояние только по принятым данным, темп + отправки по правилу адаптера, устойчивость к пропаже адаптера. В этом change — реле nooLite-F; + последующие change'и расширяют спеку новыми видами устройств. + +### Modified Capabilities + + +## Impact + +- Новый проект `ThinkingHome.DeviceModel.Drivers.NooLite` (+ тесты в `ThinkingHome.DeviceModel.Tests`); + NuGet-зависимости: `ThinkingHome.NooLite` 5.0.0 (net10.0; даёт thread-safe отправку, `ReadStateF`, + типизированный разбор `Send_State`, асинхронную последовательную доставку событий), + `Microsoft.Extensions.Configuration.Binder`, `Microsoft.Extensions.Hosting.Abstractions`, + `Microsoft.Extensions.Logging.Abstractions`. +- `ThinkingHome.DeviceModel.Hub`: `ProjectReference` на драйвер, пример секции в `appsettings.json`. +- Ядро, FluentApi, Alice, Remoting — код без изменений: `OnOff` уже в словаре. Семантика `Done` у + этого драйвера — «принято адаптером», а не «подтверждено блоком»; правило о достоверном результате + команды в README ядра допускало это только для односторонних транспортов — формулировку согласовать + (задача документации). +- Документация: `README.md` драйвера, `docs/packages/drivers-noolite.md` (+ сайдбар), правки + `docs/apps/hub.md`, строки в `notes/status.md` о драйверах. +- Зависимость от железа: реле подтверждено как nooLite-F (SUF-1-300, ID 33347, канал 0, адаптер на + COM3); измерено: ответ блока на команду/запрос 90–155 мс, команды без ожидания ответа теряются + молча. Не измерено окно занятости адаптера при молчащем блоке (команда в пустой канал, `CTR=1`) — + от него зависит интервал отправки; замер — задача этого change'а. diff --git a/openspec/changes/add-noolite-driver/specs/noolite-driver/spec.md b/openspec/changes/add-noolite-driver/specs/noolite-driver/spec.md new file mode 100644 index 0000000..cf74d0d --- /dev/null +++ b/openspec/changes/add-noolite-driver/specs/noolite-driver/spec.md @@ -0,0 +1,146 @@ +## Purpose + +Драйвер устройств nooLite через USB-адаптер MTRF-64: подключает устройства nooLite как устройства +нейтральной модели хаба с исходом команд «принято адаптером» и состоянием только по данным, принятым +от устройств. Первый этап — силовые блоки nooLite-F в режиме реле. + +## ADDED Requirements + +### Requirement: Конфигурация адаптера и устройств +Драйвер SHALL читать из своей секции конфигурации хаба имя COM-порта адаптера, необязательное число +каналов адаптера (по умолчанию 64; не больше 256 — канал в пакете адаптера занимает один байт) и +список устройств; для каждого устройства задаются стабильный идентификатор (`Id`), вид устройства +(`Kind`), канал адаптера (от 0 до числа каналов − 1), название и, опционально, комната и тип +устройства в модели (лампа / розетка / выключатель). Ошибки конфигурации MUST останавливать запуск +хаба с сообщением, указывающим позицию записи и суть ошибки. + +#### Scenario: Корректная конфигурация +- **WHEN** секция содержит порт и список устройств с уникальными `Id`, допустимыми `Kind` и каналами + в пределах числа каналов адаптера +- **THEN** каждое устройство регистрируется в хабе под своим `Id`, а `Id` не зависит от канала + (смена канала в конфиге не создаёт новое устройство) + +#### Scenario: Число каналов из конфигурации +- **WHEN** в секции задано число каналов адаптера, отличное от 64 +- **THEN** допустимый диапазон каналов устройств — от 0 до этого числа − 1; число вне 1–256 — + ошибка конфигурации + +#### Scenario: Ошибка конфигурации +- **WHEN** у записи не задан `Id`, `Id` повторяется, `Kind` неизвестен, канал вне допустимого диапазона + или не задан порт +- **THEN** запуск хаба прерывается с сообщением, содержащим позицию записи (или `Id`) и описание ошибки + +### Requirement: Описание реле nooLite-F в нейтральной модели +Драйвер SHALL описывать реле nooLite-F одним endpoint'ом со способностью `OnOff` (`Reportable = true`) +и типом из конфига (по умолчанию — источник света; допустимы розетка и выключатель), используя только +концепты существующего словаря ядра. + +#### Scenario: Discovery реле +- **WHEN** в конфиге устройство вида «реле» на канале N +- **THEN** его описание содержит один endpoint со способностью `OnOff` с `Reportable = true` и типом + из конфига (или типом по умолчанию) + +### Requirement: Состояние только по принятым данным +Драйвер SHALL отдавать в состоянии устройства только значения, фактически принятые от блока (ответ +блока о своём состоянии), и MUST NOT выводить состояние из отправленных команд. Пока от блока ничего +не принято, соответствующие значения отсутствуют в снимке состояния. + +#### Scenario: Реле до первого ответа +- **WHEN** от блока ещё не приходило ответа о состоянии (адаптер недоступен или блок не отвечает) +- **THEN** снимок состояния реле не содержит значения `OnOff` + +#### Scenario: Реле после ответа +- **WHEN** блок ответил своим состоянием «включено» (в ответ на команду или на запрос состояния) +- **THEN** снимок состояния содержит `OnOff = true`, и потребителям хаба рассылается изменение + состояния, если оно отличается от последнего принятого + +#### Scenario: Команда без ответа блока +- **WHEN** на реле отправлена команда включения, а блок не ответил +- **THEN** снимок состояния не меняется (остаётся последнее принятое или пустой) + +### Requirement: Исполнение команд: исход «принято адаптером» +Драйвер SHALL возвращать `Done` сразу после успешной записи команды в порт адаптера, не дожидаясь +ответа адаптера или блока; `DeviceUnreachable` — если порт адаптера закрыт или запись в порт не +удалась. Ответ адаптера (в том числе «нет ответа от блока» и «ошибка») и его отсутствие MUST NOT +влиять на исход команды; они журналируются. Неподдерживаемая устройством команда MUST возвращать +`Unsupported`. + +#### Scenario: Команда записана +- **WHEN** отправлена команда `OnOff` на реле и запись в порт адаптера прошла успешно +- **THEN** результат — `Done`, без ожидания ответа адаптера или блока + +#### Scenario: Ответ адаптера после записи +- **WHEN** после записи команды адаптер сообщил «нет ответа от блока» или «ошибка», либо не ответил вовсе +- **THEN** исход команды не меняется (остаётся `Done`), снимок состояния не меняется, событие + записывается в журнал + +#### Scenario: Неподдерживаемая команда +- **WHEN** на реле отправлена команда, отличная от `OnOff` (например, яркости) +- **THEN** результат — `Unsupported` + +#### Scenario: Адаптер отключён +- **WHEN** USB-адаптер не подключён (порт не открыт) +- **THEN** любая команда возвращает `DeviceUnreachable` немедленно, без попытки записи + +#### Scenario: Ошибка записи +- **WHEN** порт открыт, но запись команды в порт завершилась исключением (адаптер извлечён) +- **THEN** результат — `DeviceUnreachable`, и драйвер инициирует переоткрытие порта + +### Requirement: Темп отправки команд адаптеру +Драйвер SHALL выдерживать между двумя последовательными записями в порт адаптера интервал не меньше +заданного (исходно 200 мс), общий для всех устройств одного адаптера; команды и запросы к разным +устройствам MUST ждать своей очереди, а не отбрасываться. Драйвер MUST NOT сопоставлять ответы +адаптера с отправленными командами: каждый принятый ответ блока о состоянии обрабатывается как +состояние независимо от того, отправлялась ли команда на этот канал. Входящие пакеты приёма от +передатчиков nooLite (режим RX) MUST NOT влиять на состояние устройств; в этом этапе они только +журналируются. + +#### Scenario: Две команды подряд +- **WHEN** две команды на разные устройства отправлены одновременно +- **THEN** вторая записывается в порт не раньше, чем через интервал после первой, и обе получают + исход `Done` + +#### Scenario: Ответ без запроса +- **WHEN** от блока на канале устройства пришёл ответ о состоянии, а команда на этот канал не + отправлялась (или была отправлена давно) +- **THEN** состояние принимается и публикуется как обычно + +#### Scenario: Посторонний пакет +- **WHEN** приходит пакет приёма от передатчика (режим RX) +- **THEN** пакет записывается в журнал и не влияет на состояние устройств + +### Requirement: Опрос состояния блоков +Драйвер SHALL запрашивать состояние блоков у устройства при готовности адаптера (после запуска и +после каждого переоткрытия порта) и при запросе состояния от хаба; полученные состояния MUST +публиковаться как изменения состояния, если отличаются от последнего принятого. Периодический опрос — +по настройке, по умолчанию выключен. + +#### Scenario: Прайминг при готовности адаптера +- **WHEN** адаптер открыт и готов +- **THEN** для каждого канала с блоками отправляется запрос состояния и полученные состояния становятся + доступны в снимках устройств + +#### Scenario: Запрос состояния от хаба +- **WHEN** хаб запрашивает состояние реле +- **THEN** драйвер возвращает последнее принятое состояние (или пустой снимок, если его не было), + отправив блоку запрос состояния с соблюдением темпа отправки; ответ блока, когда придёт, + публикуется как изменение состояния. При закрытом порте запрос не отправляется + +#### Scenario: Периодический опрос включён +- **WHEN** в конфигурации задан интервал опроса и он истёк +- **THEN** драйвер запрашивает состояние блоков и публикует изменения, если состояние отличается + от последнего принятого + +### Requirement: Устойчивость к пропаже адаптера +Драйвер SHALL переоткрывать порт адаптера при его пропаже и возврате без перезапуска хаба; после +переоткрытия MUST повторяться подготовка адаптера и опрос состояния блоков. Отсутствие адаптера при +старте хаба MUST NOT препятствовать запуску хаба. + +#### Scenario: Адаптер отключили и вернули +- **WHEN** USB-адаптер извлечён во время работы, а затем подключён снова +- **THEN** через ограниченное время команды снова исполняются, состояния блоков опрошены заново + +#### Scenario: Адаптера нет при старте +- **WHEN** хаб стартует, а порт адаптера недоступен +- **THEN** хаб запускается, устройства зарегистрированы, команды возвращают `DeviceUnreachable`, + драйвер периодически пытается открыть порт diff --git a/openspec/changes/add-noolite-driver/tasks.md b/openspec/changes/add-noolite-driver/tasks.md new file mode 100644 index 0000000..45dc81a --- /dev/null +++ b/openspec/changes/add-noolite-driver/tasks.md @@ -0,0 +1,55 @@ +> Разделы 3–5 переписаны 2026-09-20 под схему «темп отправки вместо транзакций» (design D1) и в тот же +> день реализованы (ворота внутри транспорта — выбор владельца); добавлены 1.4, 6.4, 7.5. Открытыми +> остаются задачи, требующие железа или окружения: 1.4, 6.3, 6.4. + +## 1. Проверка на живом железе (адаптер + реле) + +Уже известно из сессии библиотеки (5.0.0): адаптер на COM3; реле SUF-1-300 — nooLite-F, ID 33347, +канал 0, на команды и `ReadStateF` отвечает `Send_State` (тип устройства 5, `PowerLevel` 255 при +включении). Инструменты: `ThinkingHome.NooLite.DebugConsole` из `D:\Source\noolite` +(режимы `ports`/`listen`/`on`/`off`/`state`/…) и dotnet tool `noolite`. + +- [x] 1.1 `ReadStateF` по каналу 0 — зафиксировать серию ответов (`CTR`, `Remains`, `Send_State`) и её длительность; отправить две команды без паузы — зафиксировать реакцию адаптера на нарушение правила «после ответа» (нужно для таймаута транзакции) +- [x] 1.2 Отправить команду на канал без блока (или на выключенный из сети блок) — зафиксировать ответ `CTR=1` («нет ответа») и время до него (нижняя граница таймаута транзакции) +- [x] 1.3 Записать результаты (байтовые дампы) в `notes/plan-noolite-hardware.md` (рабочий документ) — они станут данными тестов; уточнить D1/D3 в design.md при расхождениях +- [ ] 1.4 Замер окна занятости адаптера при молчащем блоке: `OnF` в пустой канал (40) и на обесточенный привязанный блок, затем через 200 мс команда на живой блок — теряется ли; записать в `notes/plan-noolite-hardware.md`; при необходимости скорректировать интервал (D1) и тесты 3.4 + +## 2. Каркас проекта и конфигурация + +- [x] 2.1 Создать проект `ThinkingHome.DeviceModel.Drivers.NooLite` (net10.0, Nullable, ImplicitUsings, `IsPackable`, документация как у `Drivers.Stubs`), добавить в решение; NuGet `ThinkingHome.NooLite` 5.0.0, `Microsoft.Extensions.Configuration.Binder`, `Microsoft.Extensions.Hosting.Abstractions`, `Microsoft.Extensions.Logging.Abstractions`; `InternalsVisibleTo` для тестов +- [x] 2.2 Модель конфигурации: `NooLitePluginConfig` (`Port`, `ChannelCount` — по умолчанию 64, `PollInterval`, `Devices`), `NooLiteDeviceEntry` (`Id`, `Kind` строкой, `Channel`, `Type`, `Title`, `Room`), enum `NooLiteDeviceKind` (пока `Relay`) +- [x] 2.3 `NooLitePlugin : IDevicePlugin, IHostedService` — чтение секции `NooLite`, валидация с позицией записи (порт, `ChannelCount` 1–256, уникальность Id, Kind, канал в пределах `ChannelCount`, Type), создание устройств по `Kind`, регистрация в реестре независимо от наличия адаптера +- [x] 2.4 Тесты конфигурации и полноты (по образцу `StubsPluginTests`): каждый `Kind` создаётся; ошибки — отсутствие порта, недопустимый `ChannelCount`, пустой/дублирующийся Id, неизвестный Kind, канал вне диапазона (по умолчанию и по заданному `ChannelCount`), недопустимый Type; описания используют только типы словаря ядра + +## 3. Транспорт и ворота отправки + +- [x] 3.1 `IMtrfTransport` (Open/Close/IsOpen/Send/события Received/PowerUnitState/Error/Disconnected) и тонкая реализация над `MTRFXXAdapter` 5.0.0: события библиотеки → события транспорта; `Open` учитывает, что ошибка открытия приходит событием `Error` (проверка `IsOpened`); закрытие через `FlushAndCloseAsync` +- [x] 3.2 Ворота отправки: одно общее на адаптер состояние — момент последней записи; ожидающие проходят по очереди, запись не раньше чем через 200 мс (константа) после предыдущей; владелец — транспорт (интервал внутри `MtrfTransport.SendAsync`; фейк в тестах повторяет контракт); исключение записи в порт → событие `Error` транспорта; RX/RXF-пакеты — Debug-лог; удалены `AdapterSession`, `Transaction`, `TransactionResult`/`TransactionStatus`, `Faulted` +- [x] 3.3 Использование типизированных данных библиотеки: `PowerUnitStateData` (`State` → OnOff; `PowerLevel`/`DeviceType` — только в журнал), `StateFormatErrorData` — в журнал; `ReadStateF` для прайминга, `Query` и периодического опроса +- [x] 3.4 Тесты на фейковом транспорте с пакетами из руководства и из 1.3 (в т.ч. `Send_State` SUF-1-300 с ID 33347): две записи подряд разнесены не меньше чем на интервал; `Send_State` без предшествующей команды принимается как состояние; `CTR=1`, `CTR=2`, молчание и многопакетная серия не влияют на исход (журнал), состояние берётся из каждого `Send_State`; RX-пакет не меняет состояние; исключение `Send` → событие `Error`; удалить тесты транзакций (таймаут, завершение по `Remains`, корреляция, медленный потребитель `Changed`) + +## 4. Устройство: реле nooLite-F + +- [x] 4.1 `NooLiteRelay`: `Describe` (endpoint 0, `OnOff` с `Reportable = true`, тип по конфигу); `ExecuteAsync` — порт закрыт → `DeviceUnreachable` без записи, иначе ворота → `OnF`/`OffF` → `Done`; исключение записи → `DeviceUnreachable`; `QueryAsync` — снимок из последнего принятого (или пустой), перед этим ворота → `ReadStateF` (при закрытом порте — без отправки); приём `PowerUnitStateData` своего канала → последнее принятое, `Changed` при отличии; команды кроме `OnOff` → `Unsupported`; удалить маппинг `TransactionResult` → `CommandOutcome` +- [x] 4.2 Тесты реле: `Done` сразу после записи, без ответа блока; после команды без ответа снимок не меняется; после `Send_State` — есть и опубликовано, повтор того же состояния не публикуется; `Query` возвращает последнее принятое, не дожидаясь ответа, и записывает `ReadStateF`; `Unsupported` на команду яркости; `DeviceUnreachable` при закрытом порте (без записи) и при исключении записи + +## 5. Жизненный цикл адаптера + +- [x] 5.1 Цикл `IHostedService`: `Open` + проверка `IsOpened` → MODE=4 через ворота (ответ не ожидается) → прайминг `ReadStateF` по каналам устройств через ворота → рабочий режим; на `Error`/`Disconnect` транспорта (включая исключение записи) — закрыть, пауза 5 с (экспоненциально до 30 с), заново; `StopAsync` — отмена и `FlushAndCloseAsync`; предупреждение в журнал при росте `DroppedPacketsCount` +- [x] 5.2 Опциональный периодический опрос по `PollInterval` (выключен по умолчанию) через ворота; публикация через `Changed` только при отличии от последнего принятого (в реле) +- [x] 5.3 Тесты: старт без адаптера не мешает регистрации и запуску; после «возврата» транспорта прайминг повторяется; команды при закрытом порте → `DeviceUnreachable` без записи; исключение записи → переоткрытие; периодический опрос публикует только изменения + +## 6. Интеграция в хаб и проверка сквозного пути + +- [x] 6.1 `ProjectReference` из `ThinkingHome.DeviceModel.Hub` на драйвер; пример секции `NooLite` в `appsettings.json` хаба (пустой список устройств, чтобы дефолтный запуск не требовал адаптера) и строка плагина в комментарии к `Hub:Plugins` +- [x] 6.2 Прогон на железе (прежняя схема с ожиданием блока): хаб + адаптер + реле — discovery, `Execute`, `Query`, `Report` после `Send_State`; извлечь и вернуть адаптер — реконнект и повторный прайминг +- [ ] 6.3 Сквозной прогон до Алисы (прокси + хаб): реле видно и переключается, состояние доезжает callback'ом; зафиксировать результат в 1.3 +- [ ] 6.4 Повторный прогон на железе по новой схеме: `Execute` возвращает `Done` до ответа блока, `Report` приходит по `Send_State`, `Query` отдаёт последнее принятое и инициирует `ReadStateF`, серия команд подряд на 1–2 канала проходит без потерь при интервале 200 мс; извлечь и вернуть адаптер — реконнект; результат в `notes/plan-noolite-hardware.md` + +## 7. Документация + +- [x] 7.1 `README.md` проекта драйвера: конфигурация, поддерживаемые устройства этапа (реле nooLite-F) и план следующих этапов, предусловие привязки (`noolite bind` / nooLite ONE), ограничения (один блок на канал), принцип «состояние — только принятое» +- [x] 7.2 `docs/packages/drivers-noolite.md` + пункт в сайдбаре `docs/.vitepress/config.mts`; раздел о драйвере в `docs/apps/hub.md` +- [x] 7.3 Обновить `notes/status.md`: строки о драйверах к физическим устройствам и об оценке «драйвер — небольшая прослойка» (что подтвердилось/нет) +- [x] 7.4 Обновить `ThinkingHome.DeviceModel/README.md` («Связь с проектами решения», статус) — новый проект драйвера +- [x] 7.5 Привести документацию к новой схеме: `README.md` драйвера и `docs/packages/drivers-noolite.md` (исход «принято адаптером», `Query` — последнее принятое + запрос, интервал отправки вместо «одна команда в полёте», журнал без категории сессии); согласовать правило 2 «достоверный результат команды» в `ThinkingHome.DeviceModel/README.md` с семантикой «принято транспортом» для этого драйвера; строка в `notes/status.md` об оценке «драйвер — небольшая прослойка» diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..7f701c3 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,59 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +context: | + ThinkingHome.DeviceModel — мост между локальным сервером умного дома и Яндекс Алисой. + Стек: C# / .NET (net10.0), xUnit для тестов, SignalR для туннеля, System.Text.Json (STJ-полиморфизм с $type). + Nullable+ImplicitUsings включены везде, кроме Alice, Alice.Service и Proxy (там старый стиль). + CI собирает только docs (VitePress); workflow сборки/тестов решения пока нет. + + Архитектура (слои): драйверы устройств → нейтральное ядро (ThinkingHome.DeviceModel: + модель + протокол + реестр) → адаптеры экосистем (ThinkingHome.Alice — маппер в DTO Алисы). + Словарь и структура модели намеренно повторяют модель данных Matter (Device=Node, Endpoint, + Capability=управляемый кластер, Property=read-only сенсор, State, Command, Instance). + + Проекты решения: + - ThinkingHome.DeviceModel — ядро: IDevice/IDeviceRegistry/IDeviceHost, DeviceHost с кэшем + состояния (Query из кэша, single-flight), закрытые иерархии Capability/Property/Command/State. + - ThinkingHome.DeviceModel.FluentApi — сахар над IDeviceHost: хендлы устройств/endpoint'ов/способностей. + - ThinkingHome.DeviceModel.Hub — конфигурируемый домашний хаб, устройства подключаются плагинами из конфига. + - ThinkingHome.DeviceModel.Drivers.Stubs — стабовые устройства (отдельный проект). + - ThinkingHome.DeviceModel.Remoting.ProxyClient / .ProxyServer — SignalR-туннель (Connector дома, + RemoteHost/RemoteHostRegistry/DeviceHub на прокси); контракт ремоутинга влит в ядро. + - ThinkingHome.Alice — адаптер Алисы (AliceMapper, чистые функции без I/O). + - ThinkingHome.Alice.Service — контроллеры протокола умного дома Яндекса + OAuth/JWT. + - ThinkingHome.DeviceModel.Proxy — сборка прокси (SignalR hub + реестр + контроллеры). + - ThinkingHome.DeviceModel.Tests — xUnit-тесты. + + Ключевые правила проекта: + - Нейтральные идентификаторы immutable после публикации; выводятся из имён .NET-типов + (instance = snake_case, $type = camelCase), не выбираются вручную. + - Способности/свойства добавляются только полными наборами (описание/команда/состояние) + со сверкой по словарю Matter; вендорские расширения помечаются [VendorExtension]. + - Execute возвращает честный исход (Done/Error), push через Report, ядро оперирует + нормализованными единицами (%, °C, K) — перевод единиц в адаптерах/драйверах. + - Fluent API — только сахар: каждый метод = ровно один вызов ядра, без ретраев/кэшей/агрегации. + - Коммиты и документация — на русском языке. + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours + +# Per-operation guidance (optional) +# Add advisory guidance for how apply and archive work should be conducted. +# This is separate from artifact rules above. +# Example: +# operations: +# apply: +# guidance: +# - Keep test summaries concise +# archive: +# guidance: +# - Summarize the archive outcome before finishing diff --git a/openspec/schemas/agentic/schema.yaml b/openspec/schemas/agentic/schema.yaml new file mode 100644 index 0000000..ae6fdff --- /dev/null +++ b/openspec/schemas/agentic/schema.yaml @@ -0,0 +1,321 @@ +name: agentic +version: 1 +description: Стандартный процесс OpenSpec — proposal → specs → design → tasks +artifacts: + - id: proposal + generates: proposal.md + description: Исходное предложение с описанием изменения + template: proposal.md + instruction: | + Создай предложение: объясни, ЗАЧЕМ нужно изменение, и определи его границы. + + ## Зачем + + - 1–2 предложения о проблеме или возможности; + - какую проблему это решает; + - почему сейчас. + + ## Границы изменения + + ### Входит + + - область изменения на высоком уровне; + - ожидаемые результаты в границах изменения. + + ### Не входит + + - смежные результаты, которые явно остаются за пределами изменения. + + ## Функциональности + + - укажи, какие спецификации будут созданы или изменены: этот раздел определяет набор delta-спецификаций; + - нет изменения поведения на уровне спецификаций — оставь оба подраздела пустыми и запиши `skip_specs: true` в `.openspec.yaml` изменения; + - иначе укажи хотя бы одну новую или изменяемую функциональность, но не выдумывай её ради валидации; + - источник существующих id и area — `npx openspec list --specs`; + - пиши кратко, сосредоточься на мотивации, границах и карте функциональностей. + + ### Новые функциональности + + - перечисли вводимые функциональности; + - идентификатор — `/` (например, `billing/invoices`, `auth/sessions`); + - оба сегмента в kebab-case, без пробелов; + - `` — устойчивая область поведения или контракта, а не способ реализации; + - `` — первый сегмент id из этого списка; + - для новой функциональности выбери существующую area по смыслу; + - новую area вводи только если ни одна не подходит; + - сверяй area и занятые id с этим списком; + - не создавай area или capability только для именования реестра, адаптера, фабрики, внутреннего модуля или рефакторинга. + + ### Изменяемые функциональности + + - перечисли существующие функциональности, у которых меняются требования; + - включай только изменение поведения на уровне спецификации, не деталей реализации; + - для каждой нужна delta-спека; + - бери точные id из этого списка; + - для изменяемой функциональности сохрани её area; + - если меняется контракт существующей функциональности, укажи её как изменяемую; + - оставь раздел пустым, если требования не меняются. + + ## Внешнее влияние + + - карта затронутых потребителей, публичных контрактов и внешних зависимостей; + - несовместимость помечай **BREAKING**; + - здесь только сигнал влияния, без нормативного поведения и способа адаптации; + - упоминай идентификатор публичного контракта только когда без него нельзя обозначить влияние; + - имена файлов, функций, классов и внутренних модулей заменяй названием области поведения. + + Не включай: + + - точные требования, поля контрактов и сценарии; + - внутренние структуры данных; + - алгоритмы, технические решения, альтернативы и детали хранения; + - стратегию проверки и задачи реализации. + requires: [] + + - id: specs + generates: specs/**/*.md + description: Подробные спецификации изменения + template: spec.md + instruction: | + Создай файлы спецификаций: определи, ЧТО должна делать система. + + - перед записью прочитай все завершённые зависимости и используй их как принятый контекст; + - оставь в delta-файле только применимые разделы шаблона; + - спека — наблюдаемый контракт поведения, а не план реализации. + + ## Файлы функциональностей + + - создай по одному файлу для каждой функциональности из завершённых зависимостей; + - идентификатор — `/`; + - путь: `specs///spec.md`; + - для новой бери id из proposal, для изменяемой зеркаль `openspec/specs///`; + - не сокращай id и не используй плоский путь `specs//spec.md`; + - если в зависимостях нет функциональностей — не создавай файлы и не выдумывай требования. + + ## Purpose + + - только для новой функциональности, в начале её delta-файла; + - одно-два содержательных предложения без TBD, не короче 50 символов; + - в delta изменяемой функциональности `## Purpose` не добавляй. + + ## Операции + + Выбери разделы по смыслу изменения: + + - RENAMED — тот же смысл, новый заголовок; + - ADDED — требования ещё нет в постоянной спецификации; + - MODIFIED — тот же смысл и те же заголовки сценариев, меняется содержимое; + - REMOVED — требование уходит; + - REMOVED + ADDED — замена: меняется смысл либо нужно удалить, переименовать или объединить сценарии. + + Не подменяй MODIFIED формальным переименованием ради обхода проверки сценариев. Если смысл сохраняется, а новый заголовок для REMOVED + ADDED нельзя обосновать — остановись и запроси решение. + + ### RENAMED Requirements + + - только для существующей спеки в `openspec/specs/`; + - формат: `- FROM: \`### Requirement: <старое имя>\`` и `- TO: \`### Requirement: <новое имя>\``; + - FROM совпадает с постоянной спецификацией точно, TO ещё не занят; + - только имя — одна пара FROM/TO, без MODIFIED, REMOVED и ADDED для этого требования; + - имя и содержимое — пара FROM/TO плюс полный блок в MODIFIED под TO; + - запрещено: FROM в REMOVED, TO в ADDED, MODIFIED под FROM, повтор одного FROM или TO. + + ### ADDED Requirements + + - новые требования новой или изменяемой функциональности; + - оформляй по разделу «Формат»; + - при замене (REMOVED + ADDED) пиши полное новое требование под заголовком, отличным от удалённого; + - заголовки сценариев удалённого требования можно переиспользовать как новые. + + ### MODIFIED Requirements + + - скопируй из постоянной спецификации весь блок от `### Requirement:` до конца сценариев; + - вставь в `## MODIFIED Requirements` и обнови утверждения и тела сценариев; + - заголовок требования сохрани, при RENAMED — TO; + - каждый заголовок `#### Scenario:` из постоянной спецификации сохрани без изменений и столько же раз; + - новое поведение добавляй отдельными сценариями; + - не удаляй, не переименовывай и не объединяй существующие сценарии через MODIFIED — для этого REMOVED + ADDED. + + ### REMOVED Requirements + + - заголовок точно как в постоянной спецификации; + - для каждого укажи `**Причина**` и `**Миграция**`; + - при замене (REMOVED + ADDED) укажи прежнее требование здесь, новое — в ADDED под другим заголовком; + - REMOVED-only delta, которая снимает все требования функциональности, допустима; + - если REMOVED снимает все оставшиеся требования функциональности — запиши `retire_capabilities: true` в `.openspec.yaml` изменения. + + ## Формат + + ### Requirement + + - для ADDED и MODIFIED: `### Requirement: `, затем список утверждений; + - каждое утверждение — `- **SHALL**` или `- **MUST NOT**` в начале пункта; + - после маркера — русское предложение со спрягаемым глаголом в настоящем времени; + - используй только SHALL и MUST NOT, без MUST, SHOULD, MAY; + - описывай наблюдаемое поведение, не механизм реализации; + - в каждом требовании — хотя бы один сценарий. + + ### Scenario + + - заголовок ровно `#### Scenario: `; + - GIVEN / WHEN / THEN, **AND** продолжает предыдущий шаг; + - один сценарий — одно поведение, предпочитай один WHEN; + - не переписывай существующий сценарий только ради GIVEN/WHEN/THEN; + - описывай поведение системы, не детали реализации. + + ## Примеры + + Новая функциональность: + + ``` + ## Purpose + + Функциональность позволяет пользователям получать свои данные в переносимом формате. + + ## ADDED Requirements + + ### Requirement: Пользователь может экспортировать данные + + - **SHALL** система позволяет пользователю экспортировать свои данные в формате CSV. + - **MUST NOT** экспорт содержит секреты аутентификации. + + #### Scenario: Успешный экспорт + + - **GIVEN** пользователь авторизован + - **AND** имеет право на экспорт + - **WHEN** пользователь выбирает формат CSV + - **AND** нажимает кнопку «Экспорт» + - **THEN** система загружает CSV-файл + - **AND** файл содержит все данные пользователя + - **AND** файл не содержит секретов аутентификации + ``` + + Переименование и изменение поведения: + + ``` + ## RENAMED Requirements + + - FROM: `### Requirement: Пользователь может экспортировать данные` + - TO: `### Requirement: Пользователь может экспортировать данные в CSV` + + ## MODIFIED Requirements + + ### Requirement: Пользователь может экспортировать данные в CSV + + - **SHALL** система позволяет пользователю экспортировать свои данные в формате CSV. + - **SHALL** система записывает событие аудита при каждом экспорте. + + #### Scenario: Успешный экспорт + + - **GIVEN** пользователь авторизован + - **AND** имеет право на экспорт + - **WHEN** пользователь выбирает формат CSV + - **AND** нажимает кнопку «Экспорт» + - **THEN** система загружает CSV-файл + - **AND** система записывает событие аудита + ``` + requires: + - proposal + + - id: design + generates: design.md + description: Технический проект с деталями реализации + template: design.md + instruction: | + Создай технический проект: объясни, КАК реализовать изменение. + + - перед записью прочитай все завершённые зависимости и используй их как принятый контекст; + - не повторяй мотивацию, границы, требования, сценарии и ожидаемые результаты изменения; + - сосредоточься на архитектуре и подходе, а не на построчной реализации; + - артефакт должен содержать новую информацию о способе реализации и причинах технического выбора. + + ## Технический контекст + + - факты текущей реализации, которые влияют на выбор и ещё не зафиксированы в зависимостях; + - описывай существующее состояние, а не требования к будущей реализации. + + ## Ограничения + + - технические условия, которым обязана соответствовать реализация: стек, совместимость, производительность, эксплуатация. + + ## Решения + + - ключевые технические решения; + - для каждого укажи само решение, обоснование, рассмотренные альтернативы и последствия; + - проверяй заявленные свойства решения по всей цепочке затронутых и сохраняемых контрактов; + - если неизменяемая зависимость ограничивает расширяемость или обобщённость решения — сузь обещание либо включи изменение этого контракта в проект. + + ## Риски / Компромиссы + + - известные ограничения и возможные проблемы; + - формат: [Риск] → Мера снижения. + + ## План миграции и отката + + - шаги перехода и возврата, если применимо. + + ## Стратегия проверки + + - виды доказательств и подход к подтверждению контракта и решений; + - что и на каком уровне проверять; + - не превращай раздел в чеклист исполняемых шагов; + - стратегия должна непосредственно доказывать новый или изменённый контракт; + - одного отсутствия регрессий в существующих сценариях недостаточно. + + ## Открытые вопросы + + - только неизвестные, которые можно отложить без изменения требований, выбранного подхода и состава задач; + - если вопрос влияет на них — запроси решение пользователя до записи артефакта. + requires: + - proposal + - specs + + - id: tasks + generates: tasks.md + description: Проверяемый список задач реализации + template: tasks.md + instruction: | + Создай список задач, разбивающий работу по реализации. + + - перед записью прочитай все завершённые зависимости и используй их как источник требований и выбранного способа реализации; + - точно следуй шаблону: формат флажков используется для отслеживания выполнения; + - объединяй связанные задачи под нумерованными заголовками `##`; + - оформляй каждую задачу флажком `- [ ] X.Y Описание задачи`; + - задачи без `- [ ]` не отслеживаются; + - задачи должны быть достаточно малы, чтобы выполнить их за одну сессию; + - упорядочивай задачи по зависимостям: сначала то, что необходимо для последующей работы; + - каждая задача на реализацию должна ссылаться на требование или сценарий либо быть явно помечена как техническая или вспомогательная; + - для каждого нового или изменённого контракта включай прямую проверку заявленного свойства; + - существующие регрессионные проверки сами по себе недостаточны; + - добавляй задачи на проверки изменённого кода согласно проектным инструкциям: тесты, типы, линтер и другие; + - не добавляй новых требований и не пересматривай технические решения — только план исполнения; + - каждая задача должна быть проверяемой: должно быть понятно, когда она выполнена. + + Пример: + + ``` + ## 1. Подготовка + + - [ ] 1.1 Создать структуру нового модуля + - [ ] 1.2 Добавить зависимости в package.json + + ## 2. Основная реализация + + - [ ] 2.1 Реализовать экспорт данных + - [ ] 2.2 Добавить средства форматирования CSV + ``` + requires: + - specs + - design + +apply: + requires: + - tasks + tracks: tasks.md + instruction: | + Выполни утверждённый план изменения. + + - контекстные файлы задают требования и принятый способ реализации; + - выполняй ожидающие рабочие пункты в порядке их зависимостей; + - отмечай пункт завершённым только после фактического выполнения и проверки; + - отметка готовности — единственная допустимая правка планирующих артефактов на этом этапе; + - при блокере остановись и не переопределяй план по ходу. diff --git a/openspec/schemas/agentic/templates/design.md b/openspec/schemas/agentic/templates/design.md new file mode 100644 index 0000000..bde4213 --- /dev/null +++ b/openspec/schemas/agentic/templates/design.md @@ -0,0 +1,43 @@ +## Технический контекст + + + +## Ограничения + + + +## Решения + +### + +#### Решение + + + +#### Обоснование + + + +#### Рассмотренные альтернативы + + + +#### Последствия + + + +## Риски / Компромиссы + + + +## План миграции и отката + + + +## Стратегия проверки + + + +## Открытые вопросы + + diff --git a/openspec/schemas/agentic/templates/proposal.md b/openspec/schemas/agentic/templates/proposal.md new file mode 100644 index 0000000..beaef87 --- /dev/null +++ b/openspec/schemas/agentic/templates/proposal.md @@ -0,0 +1,31 @@ +## Зачем + + + +## Границы изменения + +### Входит + + + +### Не входит + + + +## Функциональности + +### Новые функциональности + + + +### Изменяемые функциональности + + + +## Внешнее влияние + + diff --git a/openspec/schemas/agentic/templates/spec.md b/openspec/schemas/agentic/templates/spec.md new file mode 100644 index 0000000..6131b99 --- /dev/null +++ b/openspec/schemas/agentic/templates/spec.md @@ -0,0 +1,43 @@ + + + + +## RENAMED Requirements + +- FROM: `### Requirement: <старое имя>` +- TO: `### Requirement: <новое имя>` + +## ADDED Requirements + +### Requirement: + +- **** + +#### Scenario: + +- **GIVEN** +- **WHEN** +- **THEN** + +## MODIFIED Requirements + +### Requirement: + +- **** + +#### Scenario: + +- **GIVEN** +- **WHEN** +- **THEN** + +## REMOVED Requirements + +### Requirement: + +**Причина**: +**Миграция**: diff --git a/openspec/schemas/agentic/templates/tasks.md b/openspec/schemas/agentic/templates/tasks.md new file mode 100644 index 0000000..014f557 --- /dev/null +++ b/openspec/schemas/agentic/templates/tasks.md @@ -0,0 +1,9 @@ +## 1. + +- [ ] 1.1 +- [ ] 1.2 + +## 2. + +- [ ] 2.1 +- [ ] 2.2