Skip to content
Merged
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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,10 @@ Data flow:
firmware query). There is **no ICMP watchdog** — liveness is the transport connection state plus the
protocol keep-alive (the old `Z21Watchdog` was removed as part of the transport decoupling).

The dispatcher must be instantiated for inbound handling to work — both DI extensions register it as
an **activated/auto-activated singleton** so it wires up `ITransport.OnBytesReceived` eagerly.
The dispatcher must be instantiated for inbound handling to work. It is registered as a plain
singleton and is a **constructor dependency of `Z21CommandStation`**, so resolving the station
instantiates the dispatcher, which wires up `ITransport.OnBytesReceived`. There is no eager
auto-activation — inbound handling comes up as soon as the station is resolved.

### DI registration

Expand Down
33 changes: 33 additions & 0 deletions src/Z21.Autofac.UnitTests/SpyTransport.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System;
using System.Threading.Tasks;
using CommandStation.Transport;

namespace Z21.Autofac.UnitTests
{
public class SpyTransport : ITransport
{
public bool IsConnected { get; private set; }

public event EventHandler<BytesReceivedEventArgs>? OnBytesReceived;

public event EventHandler<ConnectionChangedEventArgs>? OnConnectionChanged;

public Task ConnectAsync()
{
IsConnected = true;
OnConnectionChanged?.Invoke(this, new ConnectionChangedEventArgs(true));
return Task.CompletedTask;
}

public Task DisconnectAsync()
{
IsConnected = false;
OnConnectionChanged?.Invoke(this, new ConnectionChangedEventArgs(false));
return Task.CompletedTask;
}

public Task SendAsync(ReadOnlyMemory<byte> data) => Task.CompletedTask;

public void RaiseBytes(byte[] data) => OnBytesReceived?.Invoke(this, new BytesReceivedEventArgs(data));
}
}
37 changes: 37 additions & 0 deletions src/Z21.Autofac.UnitTests/Z21AutofacExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Autofac;
using CommandStation;
using CommandStation.Model;
using CommandStation.Transport;
using Z21.Core;
using Z21.Core.ResponseHandler.Settings;
Expand Down Expand Up @@ -92,6 +93,42 @@ public void AddZ21_Registers_CommandStation_As_Singleton()
Assert.That(s2, Is.SameAs(s1), "ICommandStation and IZ21CommandStation should resolve to the same singleton");
}

[Test]
public void AddZ21_ResolvingCommandStation_WiresInboundHandling()
{
SpyTransport transport = new();
using var container = BuildContainer(containerBuilder =>
{
containerBuilder.AddZ21();
containerBuilder.RegisterInstance(transport).As<ITransport>().SingleInstance();
});

ILocoControl station = container.Resolve<ICommandStation>() as ILocoControl
?? throw new InvalidOperationException("Station does not support loco control.");
LocoInfoData? received = null;
station.LocoInfoReceived += (_, data) => received = data;

transport.RaiseBytes([0x0F, 0x00, 0x40, 0x00, 0xEF, 0x00, 0x03, 0x02, 0x87, 0x00, 0x00, 0x00, 0x00, 0x00, 0x69]);

Assert.That(received, Is.Not.Null);
Assert.That(received!.LocoAddress, Is.EqualTo(3));
}

[Test]
public void AddZ21_CommandStation_ExposesProgrammingFeedbackAndFastClockCapabilities()
{
using var container = BuildContainer(containerBuilder => containerBuilder.AddZ21());

IZ21CommandStation station = container.Resolve<IZ21CommandStation>();

Assert.Multiple(() =>
{
Assert.That(station, Is.InstanceOf<IProgrammingControl>());
Assert.That(station, Is.InstanceOf<IFeedbackControl>());
Assert.That(station, Is.InstanceOf<IFastClockControl>());
});
}

[Test]
public void AddZ21_Registers_Options_Instance()
{
Expand Down
26 changes: 26 additions & 0 deletions src/Z21.Client.UnitTest/Core/IZ21CommandStationContractTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using System;
using CommandStation;
using Z21.Core;

namespace Z21.Client.UnitTest.Core
{
public class IZ21CommandStationContractTest
{
[Test]
public void IZ21CommandStation_ExposesEveryCapabilityTheStationImplements()
{
Type station = typeof(IZ21CommandStation);

Assert.Multiple(() =>
{
Assert.That(typeof(ILocoControl).IsAssignableFrom(station), Is.True, "ILocoControl");
Assert.That(typeof(IAccessoryControl).IsAssignableFrom(station), Is.True, "IAccessoryControl");
Assert.That(typeof(ITrackPowerControl).IsAssignableFrom(station), Is.True, "ITrackPowerControl");
Assert.That(typeof(ISystemInfoProvider).IsAssignableFrom(station), Is.True, "ISystemInfoProvider");
Assert.That(typeof(IProgrammingControl).IsAssignableFrom(station), Is.True, "IProgrammingControl");
Assert.That(typeof(IFeedbackControl).IsAssignableFrom(station), Is.True, "IFeedbackControl");
Assert.That(typeof(IFastClockControl).IsAssignableFrom(station), Is.True, "IFastClockControl");
});
}
}
}
2 changes: 1 addition & 1 deletion src/Z21.Client/Core/IZ21CommandStation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace Z21.Core
/// The Z21 command station: the protocol-agnostic capabilities plus a Z21-specific raw escape hatch
/// for sending hand-built commands.
/// </summary>
public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryControl, ITrackPowerControl, ISystemInfoProvider
public interface IZ21CommandStation : ICommandStation, ILocoControl, IAccessoryControl, ITrackPowerControl, ISystemInfoProvider, IProgrammingControl, IFeedbackControl, IFastClockControl
{
/// <summary>
/// Factory for building raw Z21 commands to pass to <see cref="SendCommandsAsync"/>.
Expand Down
5 changes: 5 additions & 0 deletions src/Z21.Client/Core/Z21CommandStation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public class Z21CommandStation : IZ21CommandStation, IProgrammingControl, IFeedb
private readonly Z21Options _options;
private readonly DelayedAction _delayedKeepAliveAction;
private readonly ILogger<Z21CommandStation>? _logger;
private bool _disposed;

/// <summary>
/// IPv4 safe MTU for payload according to specification.
Expand Down Expand Up @@ -207,6 +208,10 @@ private async Task KeepAliveAsync()

public void Dispose()
{
if (_disposed)
return;

_disposed = true;
_delayedKeepAliveAction.Dispose();
GC.SuppressFinalize(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using CommandStation.Model;
using CommandStation.Transport;
using Microsoft.Extensions.DependencyInjection;
using Z21.Core;
using Z21.Core.Model;
using Z21.Core.Model.EventArgs;
using Z21.Core.ResponseHandler;
Expand Down Expand Up @@ -58,6 +59,38 @@ public void AddZ21_WithoutHost_NewHandler_IsDiscoveredAndFlowsThroughCapability(
});
}

[Test]
public void AddZ21_CommandStation_ExposesProgrammingFeedbackAndFastClockCapabilities()
{
ServiceCollection services = new();
services.AddZ21();
ServiceProvider serviceProvider = services.BuildServiceProvider();

IZ21CommandStation station = serviceProvider.GetRequiredService<IZ21CommandStation>();

Assert.Multiple(() =>
{
Assert.That(station, Is.InstanceOf<IProgrammingControl>());
Assert.That(station, Is.InstanceOf<IFeedbackControl>());
Assert.That(station, Is.InstanceOf<IFastClockControl>());
});
}

[Test]
public void AddZ21_StationDisposedTwice_DoesNotThrow()
{
ServiceCollection services = new();
services.AddZ21();
ServiceProvider serviceProvider = services.BuildServiceProvider();

// Resolving ICommandStation makes MS.DI capture the IZ21CommandStation singleton for disposal
// a second time (factory forwarding), so Dispose() must be idempotent.
IDisposable station = (IDisposable)serviceProvider.GetRequiredService<ICommandStation>();

station.Dispose();
Assert.DoesNotThrow(() => station.Dispose());
}

[Test]
public void AddZ21_ProviderDisposedSynchronously_DoesNotThrow()
{
Expand Down
Loading