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