Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
.DS_Store
.vs
.claude
.agents
.cursor
.codex
logs
bin
obj
Expand Down
47 changes: 47 additions & 0 deletions ThinkingHome.DeviceModel.Drivers.NooLite/IMtrfTransport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using ThinkingHome.NooLite;
using ThinkingHome.NooLite.Internal;

namespace ThinkingHome.DeviceModel.Drivers.NooLite;

/// <summary>
/// Транспорт к адаптеру MTRF-64: тонкий шов над <see cref="MTRFXXAdapter"/> для подмены в тестах.
/// Реализация не содержит логики устройств — только отправку с воротами (интервал между записями,
/// общий на адаптер; см. <see cref="MtrfTransport"/>) и проброс событий. Ответы адаптера с командами
/// не сопоставляются: состояние блоков приходит событием <see cref="PowerUnitState"/> независимо от
/// того, кто и когда отправил команду (design D1).
/// </summary>
internal interface IMtrfTransport
{
/// <summary>Порт открыт и адаптер готов к обмену.</summary>
bool IsOpen { get; }

/// <summary>Число пакетов, отброшенных адаптером при переполнении очереди приёма.</summary>
int DroppedPackets { get; }

/// <summary>Открыть порт. Ошибка открытия приходит событием <see cref="Error"/>, а не исключением.</summary>
void Open();

/// <summary>Закрыть порт, дождавшись доставки уже принятых пакетов.</summary>
Task CloseAsync();

/// <summary>
/// Отправить пакет адаптеру, дождавшись своей очереди и интервала после предыдущей записи.
/// Завершается сразу после записи в порт — ответ не ожидается. Ошибка записи поднимает событие
/// <see cref="Error"/> (сигнал жизненному циклу переоткрыть порт) и пробрасывается вызывающему.
/// </summary>
Task SendAsync(MTRFXXMode mode, MTRFXXAction action, byte channel, MTRFXXCommand command,
MTRFXXDataFormat format = MTRFXXDataFormat.NoData, byte[]? data = null, uint target = 0,
CancellationToken ct = default);

/// <summary>Пришёл входящий пакет (ответ на команду, состояние блока или приём от передатчика).</summary>
event Action<ReceivedData> Received;

/// <summary>Разобранное состояние силового блока nooLite-F (Send_State, FMT 0).</summary>
event Action<PowerUnitStateData> PowerUnitState;

/// <summary>Ошибка адаптера/порта (в т.ч. ошибка открытия и ошибки чтения/записи).</summary>
event Action<Exception> Error;

/// <summary>Порт закрыт (штатно или из-за пропажи адаптера). Последнее событие транспорта.</summary>
event Action Disconnected;
}
28 changes: 28 additions & 0 deletions ThinkingHome.DeviceModel.Drivers.NooLite/MtrfCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using ThinkingHome.NooLite.Internal;

namespace ThinkingHome.DeviceModel.Drivers.NooLite;

/// <summary>
/// Сборка пакетов nooLite-F, которые использует драйвер, поверх <see cref="IMtrfTransport.SendAsync"/>.
/// Повторяет режим/действие/команду из соответствующих расширений библиотеки для адресации по каналу
/// (без указания ID блока — <c>CTR = 0</c>, обычная передача по каналу). Каждый метод завершается
/// после записи в порт; ответ адаптера/блока не ожидается.
/// </summary>
internal static class MtrfCommands
{
/// <summary>Выйти из режима обновления ПО в рабочий режим (MODE=4); ответ (адрес адаптера) не ожидается.</summary>
public static Task ExitServiceModeAsync(this IMtrfTransport transport, CancellationToken ct = default)
=> transport.SendAsync(MTRFXXMode.Service, MTRFXXAction.SendCommand, 0, MTRFXXCommand.None, ct: ct);

/// <summary>Включить нагрузку на канале (nooLite-F).</summary>
public static Task OnFAsync(this IMtrfTransport transport, byte channel, CancellationToken ct = default)
=> transport.SendAsync(MTRFXXMode.TXF, MTRFXXAction.SendCommand, channel, MTRFXXCommand.On, ct: ct);

/// <summary>Выключить нагрузку на канале (nooLite-F).</summary>
public static Task OffFAsync(this IMtrfTransport transport, byte channel, CancellationToken ct = default)
=> transport.SendAsync(MTRFXXMode.TXF, MTRFXXAction.SendCommand, channel, MTRFXXCommand.Off, ct: ct);

/// <summary>Запросить состояние блоков на канале (Read_State, основная строка таблицы).</summary>
public static Task ReadStateFAsync(this IMtrfTransport transport, byte channel, CancellationToken ct = default)
=> transport.SendAsync(MTRFXXMode.TXF, MTRFXXAction.SendCommand, channel, MTRFXXCommand.ReadState, ct: ct);
}
113 changes: 113 additions & 0 deletions ThinkingHome.DeviceModel.Drivers.NooLite/MtrfTransport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.Diagnostics;
using ThinkingHome.NooLite;
using ThinkingHome.NooLite.Internal;

namespace ThinkingHome.DeviceModel.Drivers.NooLite;

/// <summary>
/// Транспорт над реальным адаптером <see cref="MTRFXXAdapter"/> библиотеки ThinkingHome.NooLite.
/// Адаптер пересобирается на каждое открытие порта (он владеет портом на весь свой жизненный цикл).
/// Ворота отправки — здесь же: одна запись за раз, следующая не раньше, чем через
/// <see cref="DefaultSendInterval"/> после предыдущей; момент последней записи переживает переоткрытие.
/// </summary>
internal sealed class MtrfTransport(string port, TimeSpan? sendInterval = null) : IMtrfTransport
{
/// <summary>
/// Интервал между записями по умолчанию: измеренный ответ блока 90–155 мс с запасом. Окно
/// занятости адаптера при молчащем блоке не измерено — значение уточняется по замеру (tasks 1.4).
/// </summary>
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;

/// <inheritdoc />
public bool IsOpen => adapter?.IsOpened ?? false;

/// <inheritdoc />
public int DroppedPackets => adapter?.DroppedPacketsCount ?? 0;

/// <inheritdoc />
public event Action<ReceivedData>? Received;

/// <inheritdoc />
public event Action<PowerUnitStateData>? PowerUnitState;

/// <inheritdoc />
public event Action<Exception>? Error;

/// <inheritdoc />
public event Action? Disconnected;

/// <inheritdoc />
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();
}

/// <inheritdoc />
public async Task CloseAsync()
{
var a = adapter;
adapter = null;
if (a is null) return;

try
{
await a.FlushAndCloseAsync();
}
finally
{
a.Dispose();
}
}

/// <inheritdoc />
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();
}
}
}
Loading