diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..43b2fbb
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+# Shell scripts must always use LF line endings so they run on Linux (the
+# Raspberry Pi / Docker), even when checked out or edited on a Windows box.
+*.sh text eol=lf
diff --git a/CLAUDE.md b/CLAUDE.md
index a8c5aff..376c343 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -98,3 +98,5 @@ CLI deployment (Pi publish — no build flags; `trains.json`/store schema, syste
- **Connect uses a bounded retry** catching `NullReferenceException`/`ArgumentNullException` — works around an unfixed null-deref in SharpBrick's `BluetoothKernel.ConnectAsync` ([sharpbrick/powered-up#188](https://github.com/sharpbrick/powered-up/issues/188)).
- **`CS7064`** (wasm favicon) is kept as a warning (not error) via `WarningsNotAsErrors` — Uno.Resizetizer can generate `favicon.ico` after the compiler first references it on clean builds.
- Mobile safe-area (status bar / gesture nav / notch) is handled with the Uno `Toolkit` feature: `utu:SafeArea.Insets` on the page header (`Top`) and content (`Bottom`). Adding a mobile screen? Apply the same so system bars don't overlap.
+- **BlueZ (Pi) discovery/connect must mirror the mobile stack or hubs never appear** (`BlueZPoweredUpBluetoothAdapter`): (1) **power the radio on first** — a soft-`rfkill`/`Powered=false` adapter silently scans/connects nothing; `GetReadyAdapterAsync` calls `SetPoweredAsync(true)` and otherwise throws an actionable message. (2) **Scan on the LE transport** (`SetDiscoveryFilterAsync{Transport=le}`) — BlueZ's default "auto" (BR/EDR+LE) routinely misses BLE-only hubs and their manufacturer data. (3) **Also enumerate `GetDevicesAsync()`** at scan start — a fresh `StartDiscovery` never re-fires `DeviceFound` for devices BlueZ already cached (mobile sees them from live advertisements). (4) After connect, **wait for `ServicesResolved=true`, not just `Connected`** (`BlueZDevice`) — GATT lookups race and return null otherwise.
+- **`Discover()` is fire-and-forget** (`void`, `_ = DiscoverLoopAsync(...)` per SharpBrick's `IPoweredUpBluetoothAdapter`) so exceptions inside it are swallowed — a radio-off error can't surface through it. `BlueZLegoService.DiscoverAsync` therefore awaits `adapter.EnsureReadyAsync()` **before** starting the scan. That preflight is why the service depends on the concrete `BlueZPoweredUpBluetoothAdapter` (a method not on SharpBrick's interface), wired via a **forwarding singleton registration** (concrete singleton + `IPoweredUpBluetoothAdapter` factory → same instance) so the SharpBrick host and the service share one radio.
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a9dfa73..68a42eb 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -30,9 +30,16 @@
from net10, so it works across the whole net8–net10 range the libs multi-target. -->
+
+
+
+
+
+
diff --git a/Source/Trackify.Application/Remote/ApiRoutes.cs b/Source/Trackify.Application/Remote/ApiRoutes.cs
new file mode 100644
index 0000000..3f19093
--- /dev/null
+++ b/Source/Trackify.Application/Remote/ApiRoutes.cs
@@ -0,0 +1,22 @@
+namespace Trackify.Application.Remote;
+
+///
+/// Route templates for the Trackify REST API, shared by the server (CLI) and the Refit client (app)
+/// so the two never drift. These are the one-shot actions (Refit); real-time speed/LED go over
+/// the SignalR hub — see . Control routes are keyed by hubId so
+/// they map 1:1 onto ILegoService (the app's existing control seam).
+///
+public static class ApiRoutes
+{
+ /// All saved trains (for the app to sync into its local store).
+ public const string Trains = "/api/trains";
+ public const string Discover = "/api/discover";
+ public const string Connect = "/api/hubs/{hubId}/connect";
+ public const string Disconnect = "/api/hubs/{hubId}/disconnect";
+
+ /// Last speed applied to each hub, so a freshly-connected client can show current state.
+ public const string State = "/api/state";
+
+ /// The SignalR hub endpoint for real-time train control.
+ public const string TrainHub = "/hubs/trains";
+}
diff --git a/Source/Trackify.Application/Remote/TrainHubMethods.cs b/Source/Trackify.Application/Remote/TrainHubMethods.cs
new file mode 100644
index 0000000..a3fcfce
--- /dev/null
+++ b/Source/Trackify.Application/Remote/TrainHubMethods.cs
@@ -0,0 +1,17 @@
+namespace Trackify.Application.Remote;
+
+///
+/// SignalR method names for the train hub — shared by server and client so the two never drift.
+/// The first group is invoked by clients (real-time control), the second is broadcast by the server
+/// (so every connected client sees live speed). All are keyed by hubId, matching ILegoService.
+///
+public static class TrainHubMethods
+{
+ // client → server (real-time control)
+ public const string SetSpeed = nameof(SetSpeed);
+ public const string SetLed = nameof(SetLed);
+ public const string Stop = nameof(Stop);
+
+ // server → client (broadcasts)
+ public const string SpeedChanged = nameof(SpeedChanged);
+}
diff --git a/Source/Trackify.Application/Remote/TrainSpeedState.cs b/Source/Trackify.Application/Remote/TrainSpeedState.cs
new file mode 100644
index 0000000..a058a4b
--- /dev/null
+++ b/Source/Trackify.Application/Remote/TrainSpeedState.cs
@@ -0,0 +1,4 @@
+namespace Trackify.Application.Remote;
+
+/// The last speed applied to a hub, so a freshly-connected client can show current state.
+public sealed record TrainSpeedState(string HubId, int Speed);
diff --git a/Source/Trackify.Application/Trains/ITrainService.cs b/Source/Trackify.Application/Trains/ITrainService.cs
index b890eab..4c4e8bb 100644
--- a/Source/Trackify.Application/Trains/ITrainService.cs
+++ b/Source/Trackify.Application/Trains/ITrainService.cs
@@ -11,4 +11,10 @@ public interface ITrainService
/// Finds a saved train by its id or (case-insensitive) name; null if none matches.
Task FindAsync(string nameOrId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Saves a discovered hub as a train, de-duplicated by hub identity (HubId, then MAC): if a train
+ /// already refers to that hub it is returned unchanged. Returns the saved (or existing) train.
+ ///
+ Task SaveDiscoveredAsync(DiscoveredHubDto hub, CancellationToken cancellationToken = default);
}
diff --git a/Source/Trackify.Application/Trains/TrainService.cs b/Source/Trackify.Application/Trains/TrainService.cs
index dff5e64..a98c2ea 100644
--- a/Source/Trackify.Application/Trains/TrainService.cs
+++ b/Source/Trackify.Application/Trains/TrainService.cs
@@ -23,4 +23,28 @@ public async Task> GetAllAsync(CancellationToken cancell
string.Equals(train.Name, nameOrId, StringComparison.OrdinalIgnoreCase));
return match?.ToDto();
}
+
+ ///
+ /// Saves a discovered hub as a train, de-duplicated by hub identity (HubId, then MAC): if a train
+ /// already refers to that hub it is returned unchanged. Returns the saved (or existing) train.
+ ///
+ public async Task SaveDiscoveredAsync(DiscoveredHubDto hub, CancellationToken cancellationToken = default)
+ {
+ var trains = await repository.GetAllAsync(cancellationToken);
+ var existing = trains.FirstOrDefault(train =>
+ (!string.IsNullOrWhiteSpace(hub.Id) && string.Equals(train.HubId, hub.Id, StringComparison.OrdinalIgnoreCase))
+ || (!string.IsNullOrWhiteSpace(hub.MacAddress) && string.Equals(train.BleAddress, hub.MacAddress, StringComparison.OrdinalIgnoreCase)));
+ if (existing is not null)
+ return existing.ToDto();
+
+ var train = new Train
+ {
+ Name = string.IsNullOrWhiteSpace(hub.Name) ? (hub.MacAddress ?? hub.Id) : hub.Name,
+ HubId = hub.Id,
+ BleAddress = hub.MacAddress ?? string.Empty,
+ Hub = hub.HubType ?? HubType.PoweredUpHub,
+ };
+ await repository.AddAsync(train, cancellationToken);
+ return train.ToDto();
+ }
}
diff --git a/Source/Trackify.Cli/Commands/AutoCommand.cs b/Source/Trackify.Cli/Commands/AutoCommand.cs
new file mode 100644
index 0000000..170f13b
--- /dev/null
+++ b/Source/Trackify.Cli/Commands/AutoCommand.cs
@@ -0,0 +1,134 @@
+using Trackify.Cli.Commands.Settings;
+
+namespace Trackify.Cli.Commands;
+
+///
+/// Auto-pilot: a long-running loop for unattended operation (systemd / Docker, no typing). Every
+/// --interval seconds it re-reads the saved trains from the store and applies each one's saved
+/// configuration — connect, set the hub LED, drive at its saved speed — reconnecting any hub that has
+/// dropped. Runs until Ctrl+C / SIGINT, then stops every motor and disconnects cleanly.
+///
+public sealed class AutoCommand(ITrainControlService control, ITrainService query) : AsyncCommand
+{
+ protected override async Task ExecuteAsync(CommandContext context, AutoSettings settings, CancellationToken cancellationToken)
+ {
+ if (!control.IsSupported)
+ {
+ AnsiConsole.MarkupLine("[red]Bluetooth is not available on this machine.[/]");
+ return 1;
+ }
+
+ var interval = TimeSpan.FromSeconds(Math.Max(1, settings.IntervalSeconds));
+ // Trains connected during this run, kept so shutdown can stop + disconnect every one of them.
+ var live = new Dictionary();
+
+ AnsiConsole.Write(new Rule("[springgreen2]▶ Auto-pilot[/]").LeftJustified());
+ AnsiConsole.MarkupLineInterpolated(
+ $"[grey]Re-applying saved trains every {interval.TotalSeconds:0}s. Press[/] [springgreen2]Ctrl+C[/] [grey]to stop.[/]");
+
+ try
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ await SweepAsync(settings, live, cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ // A daemon must survive a transient failure (e.g. a store read) and retry next cycle.
+ AnsiConsole.MarkupLineInterpolated($"[red]Sweep failed:[/] {ex.Message}");
+ }
+
+ await Task.Delay(interval, cancellationToken);
+ }
+ }
+ catch (OperationCanceledException) { /* clean shutdown below */ }
+ finally
+ {
+ await ShutdownAsync(live);
+ }
+
+ return 0;
+ }
+
+ private async Task SweepAsync(AutoSettings settings, Dictionary live, CancellationToken ct)
+ {
+ var trains = await query.GetAllAsync(ct);
+ var targets = (settings.All ? trains : trains.Where(t => t.IsActive)).ToList();
+
+ if (targets.Count == 0)
+ {
+ AnsiConsole.MarkupLine("[yellow]No active trains to run.[/] Save/activate trains in the app (or use [springgreen2]--all[/]).");
+ return;
+ }
+
+ var table = new Table()
+ .Border(TableBorder.Rounded)
+ .BorderColor(Color.Grey37)
+ .Title($"[springgreen2]Auto-pilot[/] [grey]{DateTimeOffset.Now:HH:mm:ss}[/]");
+ table.AddColumn("[grey]Name[/]");
+ table.AddColumn("[grey]Speed[/]");
+ table.AddColumn("[grey]Status[/]");
+
+ foreach (var train in targets)
+ {
+ ct.ThrowIfCancellationRequested();
+ var status = await ApplyAsync(train, live, ct);
+ table.AddRow($"[white]{Markup.Escape(train.Name)}[/]", $"[grey]{train.Speed}%[/]", status);
+ }
+
+ AnsiConsole.Write(table);
+ }
+
+ /// Connects (if needed), re-asserts colour + speed, and reports the outcome for one train.
+ private async Task ApplyAsync(TrainDto train, Dictionary live, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(train.HubId) && string.IsNullOrWhiteSpace(train.BleAddress))
+ return "[grey]○ no address[/]";
+
+ try
+ {
+ // ConnectAsync is idempotent (a no-op if already connected); SetSpeed doubles as a health
+ // check — if the link dropped it throws, we disconnect, and the next sweep reconnects fresh.
+ await control.ConnectAsync(train, ct);
+ try { await control.SetLedAsync(train, ct); } catch { /* the hub may have no RGB LED */ }
+ await control.SetSpeedAsync(train, train.Speed, ct);
+ live[train.Id] = train;
+ return "[springgreen2]● running[/]";
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ live.Remove(train.Id);
+ try { await control.DisconnectAsync(train, CancellationToken.None); } catch { /* best effort */ }
+ return $"[red]✗ {Markup.Escape(Short(ex.Message))}[/]";
+ }
+ }
+
+ /// Stops every motor and disconnects on shutdown — deliberately without the cancelled token.
+ private async Task ShutdownAsync(Dictionary live)
+ {
+ if (live.Count == 0)
+ return;
+
+ AnsiConsole.MarkupLine("[grey]■ Stopping all trains…[/]");
+ foreach (var train in live.Values)
+ {
+ try { await control.SetSpeedAsync(train, 0); } catch { /* best effort */ }
+ try { await control.DisconnectAsync(train); } catch { /* best effort */ }
+ }
+
+ AnsiConsole.MarkupLine("[grey]■ Auto-pilot stopped.[/]");
+ }
+
+ private static string Short(string message)
+ => message.Length <= 50 ? message : message[..49] + "…";
+}
diff --git a/Source/Trackify.Cli/Commands/DashboardCommand.cs b/Source/Trackify.Cli/Commands/DashboardCommand.cs
index 71f7c78..cb7d5f6 100644
--- a/Source/Trackify.Cli/Commands/DashboardCommand.cs
+++ b/Source/Trackify.Cli/Commands/DashboardCommand.cs
@@ -26,7 +26,8 @@ protected override async Task ExecuteAsync(CommandContext context, Cancella
"[springgreen2]drive[/] run a train until Ctrl+C",
"[springgreen2]stop[/] stop a train's motor",
"[springgreen2]color[/] set the hub LED colour",
- "[springgreen2]connect[/] reachability test")))
+ "[springgreen2]connect[/] reachability test",
+ "[springgreen2]auto[/] auto-pilot every saved train (loop)")))
.Header("[springgreen2]Commands[/]")
.BorderColor(Color.Grey37));
diff --git a/Source/Trackify.Cli/Commands/DiscoverCommand.cs b/Source/Trackify.Cli/Commands/DiscoverCommand.cs
index 97fe6e2..03a6dda 100644
--- a/Source/Trackify.Cli/Commands/DiscoverCommand.cs
+++ b/Source/Trackify.Cli/Commands/DiscoverCommand.cs
@@ -1,9 +1,10 @@
+using Trackify.Application.Lego;
using Trackify.Cli.Commands.Settings;
namespace Trackify.Cli.Commands;
-/// Scans for nearby hubs over Bluetooth and prints what turns up.
-public sealed class DiscoverCommand(ITrainControlService control) : AsyncCommand
+/// Scans for nearby hubs over Bluetooth, prints what turns up, and optionally saves them.
+public sealed class DiscoverCommand(ITrainControlService control, ITrainService trains) : AsyncCommand
{
protected override async Task ExecuteAsync(CommandContext context, DiscoverSettings settings, CancellationToken cancellationToken)
{
@@ -17,10 +18,23 @@ protected override async Task ExecuteAsync(CommandContext context, Discover
using var scan = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
scan.CancelAfter(TimeSpan.FromSeconds(settings.TimeoutSeconds));
- var hubs = await AnsiConsole.Status()
- .Spinner(Spinner.Known.Dots)
- .SpinnerStyle(Style.Parse("springgreen2"))
- .StartAsync("Scanning for hubs… (Ctrl+C to stop)", async _ => await control.DiscoverAsync(scan.Token));
+ IReadOnlyList hubs;
+ try
+ {
+ hubs = await AnsiConsole.Status()
+ .Spinner(Spinner.Known.Dots)
+ .SpinnerStyle(Style.Parse("springgreen2"))
+ .StartAsync("Scanning for hubs… (Ctrl+C to stop)", async _ => await control.DiscoverAsync(scan.Token));
+ }
+ catch (OperationCanceledException)
+ {
+ return 0; // Ctrl+C / timeout during the scan — nothing to report.
+ }
+ catch (Exception ex)
+ {
+ AnsiConsole.MarkupLineInterpolated($"[red]✗ Scan failed:[/] {ex.Message}");
+ return 1;
+ }
if (hubs.Count == 0)
{
@@ -29,6 +43,31 @@ protected override async Task ExecuteAsync(CommandContext context, Discover
}
AnsiConsole.Write(Ui.HubsTable(hubs));
+
+ if (settings.Save)
+ await SaveHubsAsync(hubs, cancellationToken);
+
return 0;
}
+
+ private async Task SaveHubsAsync(IReadOnlyList hubs, CancellationToken cancellationToken)
+ {
+ var saved = 0;
+ foreach (var hub in hubs)
+ {
+ try
+ {
+ var train = await trains.SaveDiscoveredAsync(hub, cancellationToken);
+ saved++;
+ AnsiConsole.MarkupLineInterpolated($"[springgreen2]✓ Saved[/] {train.Name} [grey]({train.HubId})[/]");
+ }
+ catch (Exception ex)
+ {
+ // Report the specific hub that failed (never swallow silently) and keep going.
+ AnsiConsole.MarkupLineInterpolated($"[red]✗ Could not save {hub.Name ?? hub.Id}:[/] {ex.Message}");
+ }
+ }
+
+ AnsiConsole.MarkupLineInterpolated($"[grey]Saved {saved}/{hubs.Count} hub(s) to the train list.[/]");
+ }
}
diff --git a/Source/Trackify.Cli/Commands/ServerCommand.cs b/Source/Trackify.Cli/Commands/ServerCommand.cs
new file mode 100644
index 0000000..295316f
--- /dev/null
+++ b/Source/Trackify.Cli/Commands/ServerCommand.cs
@@ -0,0 +1,18 @@
+using Trackify.Cli.Commands.Settings;
+using Trackify.Cli.Server;
+
+namespace Trackify.Cli.Commands;
+
+///
+/// Runs the network backend (REST + SignalR) so the Uno app can drive this Pi remotely. The backend
+/// composes the same Domain/Application/Infrastructure layers over its own ASP.NET host.
+///
+public sealed class ServerCommand : AsyncCommand
+{
+ protected override Task ExecuteAsync(CommandContext context, ServerSettings settings, CancellationToken cancellationToken)
+ {
+ var storePath = Environment.GetEnvironmentVariable("TRACKIFY_STORE");
+ var hostArgs = settings.Urls is { Length: > 0 } urls ? new[] { "--urls", urls } : Array.Empty();
+ return TrackifyServer.RunAsync(hostArgs, storePath, cancellationToken);
+ }
+}
diff --git a/Source/Trackify.Cli/Commands/Settings/AutoSettings.cs b/Source/Trackify.Cli/Commands/Settings/AutoSettings.cs
new file mode 100644
index 0000000..841b90b
--- /dev/null
+++ b/Source/Trackify.Cli/Commands/Settings/AutoSettings.cs
@@ -0,0 +1,15 @@
+using System.ComponentModel;
+
+namespace Trackify.Cli.Commands.Settings;
+
+/// Options for the auto (auto-pilot) command.
+public sealed class AutoSettings : CommandSettings
+{
+ [CommandOption("-i|--interval ")]
+ [Description("Seconds between auto-sweeps (default 60).")]
+ public int IntervalSeconds { get; init; } = 60;
+
+ [CommandOption("-a|--all")]
+ [Description("Include inactive trains too (default: active only).")]
+ public bool All { get; init; }
+}
diff --git a/Source/Trackify.Cli/Commands/Settings/DiscoverSettings.cs b/Source/Trackify.Cli/Commands/Settings/DiscoverSettings.cs
index 435af47..9cab983 100644
--- a/Source/Trackify.Cli/Commands/Settings/DiscoverSettings.cs
+++ b/Source/Trackify.Cli/Commands/Settings/DiscoverSettings.cs
@@ -8,4 +8,8 @@ public sealed class DiscoverSettings : CommandSettings
[CommandOption("-t|--timeout ")]
[Description("Give up scanning after this many seconds (default 30).")]
public int TimeoutSeconds { get; init; } = 30;
+
+ [CommandOption("--save")]
+ [Description("Save the discovered hub(s) to the train list (de-duplicated by hub identity).")]
+ public bool Save { get; init; }
}
diff --git a/Source/Trackify.Cli/Commands/Settings/ServerSettings.cs b/Source/Trackify.Cli/Commands/Settings/ServerSettings.cs
new file mode 100644
index 0000000..fc64d7b
--- /dev/null
+++ b/Source/Trackify.Cli/Commands/Settings/ServerSettings.cs
@@ -0,0 +1,11 @@
+using System.ComponentModel;
+
+namespace Trackify.Cli.Commands.Settings;
+
+/// Options for the server command.
+public sealed class ServerSettings : CommandSettings
+{
+ [CommandOption("--urls ")]
+ [Description("Bind address(es), e.g. http://0.0.0.0:5000. Overrides the appsettings 'Urls' value.")]
+ public string? Urls { get; init; }
+}
diff --git a/Source/Trackify.Cli/Log.cs b/Source/Trackify.Cli/Log.cs
index 386dba8..66abce4 100644
--- a/Source/Trackify.Cli/Log.cs
+++ b/Source/Trackify.Cli/Log.cs
@@ -7,4 +7,10 @@ internal static partial class Log
{
[LoggerMessage(EventId = 3000, Level = LogLevel.Information, Message = "Trackify CLI started (store: {StorePath})")]
public static partial void Started(ILogger logger, string storePath);
+
+ [LoggerMessage(EventId = 3001, Level = LogLevel.Information, Message = "Trackify backend starting (store: {StorePath})")]
+ public static partial void ServerStarting(ILogger logger, string storePath);
+
+ [LoggerMessage(EventId = 3002, Level = LogLevel.Error, Message = "Unhandled exception running command")]
+ public static partial void Unhandled(ILogger logger, Exception exception);
}
diff --git a/Source/Trackify.Cli/Program.cs b/Source/Trackify.Cli/Program.cs
index 0308abc..a2989e2 100644
--- a/Source/Trackify.Cli/Program.cs
+++ b/Source/Trackify.Cli/Program.cs
@@ -1,3 +1,4 @@
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
@@ -11,12 +12,16 @@
using Trackify.Infrastructure.Persistence;
using Log = Trackify.Cli.Log;
-// High-performance logging with a Serilog backend (diagnostics go to the console at Information+).
+// Configuration comes from appsettings.json (next to the binary) + environment — nothing hardcoded.
+var configuration = new ConfigurationBuilder()
+ .SetBasePath(AppContext.BaseDirectory)
+ .AddJsonFile("appsettings.json", optional: true)
+ .AddEnvironmentVariables()
+ .Build();
+
+// High-performance logging with a Serilog backend; levels + sinks are read from the "Serilog" section.
var serilog = new LoggerConfiguration()
- .MinimumLevel.Information()
- // EF Core logs every SQL command at Information — far too noisy for a CLI. Only surface warnings+.
- .MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
- .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .ReadFrom.Configuration(configuration)
.CreateLogger();
var storePath = Environment.GetEnvironmentVariable("TRACKIFY_STORE");
@@ -28,9 +33,8 @@
services.AddTrackifyApplication();
services.AddTrackifyInfrastructure(storePath);
-Log.Started(
- new SerilogLoggerFactory(serilog).CreateLogger("trackify"),
- storePath ?? SqliteTrainRepository.DefaultDatabasePath());
+var hostLogger = new SerilogLoggerFactory(serilog).CreateLogger("trackify");
+Log.Started(hostLogger, storePath ?? SqliteTrainRepository.DefaultDatabasePath());
// No command → the dashboard (banner + saved trains + cheat-sheet).
// DependencyInjectionRegistrar (NuGet) bridges Spectre onto Microsoft.Extensions.DependencyInjection.
@@ -39,12 +43,24 @@
app.Configure(config =>
{
config.SetApplicationName("trackify");
+
+ // Global exception handling (ASP.NET-style): log every unhandled command error and surface a
+ // clean message with a non-zero exit — never let one escape unlogged.
+ config.SetExceptionHandler((ex, _) =>
+ {
+ Log.Unhandled(hostLogger, ex);
+ AnsiConsole.MarkupLineInterpolated($"[red]✗ Error:[/] {ex.Message}");
+ return 1;
+ });
+
config.AddCommand("discover").WithDescription("Scan for nearby hubs.").WithExample("discover", "--timeout", "15");
config.AddCommand("list").WithDescription("List saved trains.");
config.AddCommand("connect").WithDescription("Connect a train's hub (reachability test).").WithExample("connect", "\"Blauer Zug\"");
config.AddCommand("drive").WithDescription("Run a train until Ctrl+C.").WithExample("drive", "\"Blauer Zug\"", "--speed", "40", "--color", "Green");
config.AddCommand("stop").WithDescription("Stop a train's motor.");
config.AddCommand("color").WithDescription("Set a train's hub LED colour.").WithExample("color", "\"Blauer Zug\"", "Blue");
+ config.AddCommand("auto").WithDescription("Auto-pilot: keep all saved trains running, re-scanning on an interval.").WithExample("auto", "--interval", "60");
+ config.AddCommand("server").WithDescription("Run the REST + SignalR backend so the app can drive this Pi.").WithExample("server", "--urls", "http://0.0.0.0:5000");
});
// Ctrl+C (also systemd/docker SIGINT) cancels this token; commands react and shut down cleanly.
diff --git a/Source/Trackify.Cli/ReadMe.md b/Source/Trackify.Cli/ReadMe.md
index 36750d7..dd35b5a 100644
--- a/Source/Trackify.Cli/ReadMe.md
+++ b/Source/Trackify.Cli/ReadMe.md
@@ -19,11 +19,28 @@ trackify connect "Blauer Zug" # reachability test (connect + disconnect)
trackify drive "Blauer Zug" --speed 40 --color Green # run until Ctrl+C
trackify stop "Blauer Zug" # stop the motor
trackify color "Blauer Zug" Blue # set the hub LED
+trackify auto # auto-pilot: run every saved train, re-scan on an interval
trackify --help # full help
```
A train is addressed by **name or id** (see `trackify list`).
+## Auto mode (auto-pilot)
+
+`trackify auto` is a long-running loop meant for **unattended operation** on the Pi (systemd / Docker).
+Every `--interval` seconds (default **60**) it re-reads the saved trains from `trackify.db` and applies
+each one's saved configuration — connect, set the hub LED colour, and drive at its saved speed — and
+**reconnects any hub that has dropped** since the last sweep. On Ctrl+C / SIGINT it stops every motor
+and disconnects cleanly.
+
+```bash
+trackify auto # active saved trains, sweep every 60s
+trackify auto --interval 30 # sweep every 30s
+trackify auto --all # include inactive trains too
+```
+
+New or edited trains are picked up automatically on the next sweep (the store is re-read each cycle).
+
## The train store (`trackify.db`)
Trains live in a **SQLite** database managed by **EF Core** (`SqliteTrainRepository`). Default path:
@@ -44,9 +61,36 @@ ssh pi@raspberrypi 'chmod +x /opt/trackify/trackify'
No build flags needed even when cross-publishing from Windows: BlueZ is always compiled in, and
`AddLinuxLego` picks the real transport vs. the no-op fallback at **runtime** via
-`OperatingSystem.IsLinux()` — so the same artifact works on the Pi. Prerequisites on the Pi:
-`bluetoothd` running, user in the `bluetooth` group; run `trackify discover` once so BlueZ knows the
-device. The CI `cli-arm64.yml` workflow produces this artifact.
+`OperatingSystem.IsLinux()` — so the same artifact works on the Pi. The CI `cli-arm64.yml` workflow
+produces this artifact.
+
+## Prerequisites on the Pi — install BlueZ
+
+Trackify drives hubs by talking to **`bluetoothd` over D-Bus**, so the **BlueZ** stack must be
+installed and running on the Pi. **Raspberry Pi OS** usually ships it; **Ubuntu Server** images do
+**not** — you'll see `bluetoothctl: command not found` and `bluetooth.service could not be found`.
+
+Run the bundled setup script once (Debian/Ubuntu). It's idempotent — safe to re-run:
+
+```bash
+sudo Source/Trackify.Cli/scripts/setup-bluez.sh # or: sudo ./setup-bluez.sh from the scripts dir
+```
+
+It installs `bluez` (+ `rfkill`), enables & starts `bluetoothd`, unblocks and powers on the radio, and
+adds your user to the `bluetooth` group. Equivalent manual steps:
+
+```bash
+sudo apt update && sudo apt install -y bluez rfkill
+sudo systemctl enable --now bluetooth # start bluetoothd now and at boot
+sudo rfkill unblock bluetooth # clear any soft-block
+sudo usermod -aG bluetooth "$USER" # D-Bus access for org.bluez (log out/in after)
+bluetoothctl power on # power the adapter
+```
+
+**Log out & back in (or reboot)** after the group change so a non-root `trackify` isn't denied on
+D-Bus. Then power on a hub and run `trackify discover` once so BlueZ knows the device. A handy
+read-only checker, `Source/Trackify.Cli/scripts/pi-bt-info.sh`, dumps the full radio/adapter/scan
+state if discovery still misbehaves.
## Docker
@@ -65,7 +109,8 @@ container turns the LINUX flag on automatically, so real BlueZ is compiled in.
## Run permanently at boot (systemd)
-`trackify drive` already runs until stopped (Ctrl+C → motor stop + clean disconnect). For autostart:
+`trackify drive` runs a single train until stopped; `trackify auto` runs **every** saved train and
+re-scans on an interval. Both stop cleanly on SIGINT. For autostart, prefer `auto` for the whole fleet:
```ini
# /etc/systemd/system/trackify.service
@@ -75,7 +120,8 @@ After=bluetooth.target
Requires=bluetooth.service
[Service]
-ExecStart=/opt/trackify/trackify drive "Blauer Zug" --speed 40
+# One train: drive "Blauer Zug" --speed 40 · Whole fleet: auto --interval 60
+ExecStart=/opt/trackify/trackify auto --interval 60
Restart=on-failure
RestartSec=5
User=pi
@@ -116,11 +162,29 @@ trackify connect "Blauer Zug" # Erreichbarkeits-Test (verbinden + trennen)
trackify drive "Blauer Zug" --speed 40 --color Green # fahren bis Ctrl+C
trackify stop "Blauer Zug" # Motor stoppen
trackify color "Blauer Zug" Blue # Hub-LED setzen
+trackify auto # Auto-Pilot: alle gespeicherten Züge fahren, per Intervall neu scannen
trackify --help # vollständige Hilfe
```
Ein Zug wird per **Name oder Id** angesprochen (siehe `trackify list`).
+## Auto-Modus (Auto-Pilot)
+
+`trackify auto` ist eine Dauerschleife für den **unbeaufsichtigten Betrieb** auf dem Pi (systemd /
+Docker). Alle `--interval` Sekunden (Standard **60**) liest es die gespeicherten Züge aus `trackify.db`
+neu und wendet die gespeicherte Konfiguration jedes Zugs an — verbinden, Hub-LED setzen und mit der
+gespeicherten Geschwindigkeit fahren — und **verbindet abgebrochene Hubs automatisch neu**. Bei
+Ctrl+C / SIGINT werden alle Motoren gestoppt und sauber getrennt.
+
+```bash
+trackify auto # aktive gespeicherte Züge, Scan alle 60s
+trackify auto --interval 30 # Scan alle 30s
+trackify auto --all # auch inaktive Züge einschließen
+```
+
+Neue oder geänderte Züge werden beim nächsten Durchlauf automatisch übernommen (der Store wird jedes
+Mal neu gelesen).
+
## Der Train-Store (`trackify.db`)
Züge liegen in einer **SQLite**-Datenbank, verwaltet von **EF Core** (`SqliteTrainRepository`). Standardpfad:
@@ -141,9 +205,37 @@ ssh pi@raspberrypi 'chmod +x /opt/trackify/trackify'
Kein Build-Flag nötig, auch beim Cross-Publish von Windows: BlueZ ist immer einkompiliert, und
`AddLinuxLego` wählt zur **Laufzeit** per `OperatingSystem.IsLinux()` den echten Transport bzw. den
-No-op-Fallback — dasselbe Artefakt läuft also auf dem Pi. Voraussetzungen auf dem Pi: `bluetoothd`
-läuft, Benutzer in der `bluetooth`-Gruppe; einmal `trackify discover` ausführen, damit BlueZ das Gerät
-kennt. Der CI-Workflow `cli-arm64.yml` erzeugt dieses Artefakt.
+No-op-Fallback — dasselbe Artefakt läuft also auf dem Pi. Der CI-Workflow `cli-arm64.yml` erzeugt
+dieses Artefakt.
+
+## Voraussetzungen auf dem Pi — BlueZ installieren
+
+Trackify steuert Hubs über **`bluetoothd` per D-Bus**, also muss der **BlueZ**-Stack auf dem Pi
+installiert sein und laufen. **Raspberry Pi OS** bringt ihn meist mit; **Ubuntu Server**-Images
+**nicht** — dann erscheint `bluetoothctl: command not found` und `bluetooth.service could not be found`.
+
+Das mitgelieferte Setup-Skript einmal ausführen (Debian/Ubuntu). Es ist idempotent — beliebig oft
+wiederholbar:
+
+```bash
+sudo Source/Trackify.Cli/scripts/setup-bluez.sh
+```
+
+Es installiert `bluez` (+ `rfkill`), aktiviert & startet `bluetoothd`, entsperrt und schaltet das Radio
+ein und fügt den Benutzer der `bluetooth`-Gruppe hinzu. Manuell entspricht das:
+
+```bash
+sudo apt update && sudo apt install -y bluez rfkill
+sudo systemctl enable --now bluetooth
+sudo rfkill unblock bluetooth
+sudo usermod -aG bluetooth "$USER" # danach ab-/anmelden
+bluetoothctl power on
+```
+
+Nach der Gruppenänderung **ab- und wieder anmelden (oder neu starten)**, damit ein Nicht-Root-
+`trackify` auf D-Bus nicht abgewiesen wird. Dann einen Hub einschalten und einmal `trackify discover`
+ausführen. Das read-only-Prüfskript `Source/Trackify.Cli/scripts/pi-bt-info.sh` zeigt den kompletten
+Radio-/Adapter-/Scan-Status, falls die Erkennung weiter hakt.
## Docker
diff --git a/Source/Trackify.Cli/Server/ServerServiceCollectionExtensions.cs b/Source/Trackify.Cli/Server/ServerServiceCollectionExtensions.cs
new file mode 100644
index 0000000..078e2fb
--- /dev/null
+++ b/Source/Trackify.Cli/Server/ServerServiceCollectionExtensions.cs
@@ -0,0 +1,29 @@
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Trackify.Cli.Server;
+
+///
+/// DI composition for the Trackify backend, mirroring the AddTrackify… layer pattern: SignalR,
+/// the speed-state store, JSON (enums as names), and open CORS so the app (incl. the WASM head) can
+/// reach it. The REST/hub handlers themselves resolve their use-cases from DI.
+///
+public static class ServerServiceCollectionExtensions
+{
+ public static IServiceCollection AddTrackifyServer(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSignalR();
+
+ // Enums as readable names on the wire (matches the store).
+ services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
+
+ // Allow any LAN origin to call the API/hub so the app (incl. the WASM head, which sends an
+ // Origin) can reach it. Credentials are deliberately NOT enabled — the backend uses no cookies
+ // or auth, so we avoid the unsafe any-origin + AllowCredentials combination (CWE-942).
+ services.AddCors(o => o.AddDefaultPolicy(p =>
+ p.SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod()));
+
+ return services;
+ }
+}
diff --git a/Source/Trackify.Cli/Server/TrackifyServer.cs b/Source/Trackify.Cli/Server/TrackifyServer.cs
new file mode 100644
index 0000000..02fea6a
--- /dev/null
+++ b/Source/Trackify.Cli/Server/TrackifyServer.cs
@@ -0,0 +1,104 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using Trackify.Application;
+using Trackify.Application.Lego;
+using Trackify.Application.Remote;
+using Trackify.Application.Trains;
+using Trackify.Domain;
+using Trackify.Domain.Enums;
+using Trackify.Infrastructure;
+
+namespace Trackify.Cli.Server;
+
+///
+/// Hosts the Trackify backend — a REST API (one-shot actions) plus a SignalR hub (real-time speed/LED)
+/// over the very same Domain/Application/Infrastructure use-cases the CLI runs. Started by
+/// trackify serve; on a Pi this owns the BlueZ radio and the app connects to it remotely.
+///
+internal static class TrackifyServer
+{
+ public static async Task RunAsync(string[] args, string? storePath, CancellationToken cancellationToken)
+ {
+ // Content root = the binary's dir so appsettings.json (copied next to trackify) is found even
+ // when launched from another working directory (systemd/Docker).
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ Args = args,
+ ContentRootPath = AppContext.BaseDirectory,
+ });
+
+ // Everything comes from appsettings.json / env / args — nothing hardcoded. Kestrel binds the
+ // "Urls" key automatically; Serilog levels + sinks are read from the "Serilog" section.
+ builder.Logging.ClearProviders();
+ builder.Logging.AddSerilog(new LoggerConfiguration()
+ .ReadFrom.Configuration(builder.Configuration)
+ .CreateLogger(), dispose: true);
+
+ builder.Services.AddTrackifyDomain();
+ builder.Services.AddTrackifyApplication();
+ builder.Services.AddTrackifyInfrastructure(storePath);
+ builder.Services.AddTrackifyServer();
+
+ var app = builder.Build();
+
+ // Global exception handling (ASP.NET-style): log every unhandled request error and return a
+ // clean 500 JSON — no failure escapes unlogged.
+ app.UseExceptionHandler(errorApp => errorApp.Run(async context =>
+ {
+ var error = context.Features.Get()?.Error;
+ var logger = context.RequestServices.GetRequiredService().CreateLogger("trackify.serve");
+ // Sanitize user-controlled request data before logging (prevents log forging / CWE-117).
+ logger.LogError(error, "Unhandled request exception on {Method} {Path}",
+ Sanitize(context.Request.Method), Sanitize(context.Request.Path.Value));
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ // Return a generic message only — never leak internal exception detail to clients (CWE-209).
+ await context.Response.WriteAsJsonAsync(new { error = "Internal server error" });
+ }));
+
+ app.UseCors();
+ MapApi(app, app.Configuration.GetValue("Trackify:Server:DiscoverTimeoutSeconds", 20));
+ app.MapHub(ApiRoutes.TrainHub);
+
+ Log.ServerStarting(app.Logger, storePath ?? "(default)");
+ await app.RunAsync(cancellationToken);
+ return 0;
+ }
+
+ private static void MapApi(WebApplication app, int discoverTimeoutSeconds)
+ {
+ // Train list — the app syncs this into its local SQLite store.
+ app.MapGet(ApiRoutes.Trains, (ITrainService trains, CancellationToken ct) => trains.GetAllAsync(ct));
+
+ app.MapPost(ApiRoutes.Discover, async (ILegoService lego, CancellationToken ct) =>
+ {
+ // Discovery has no fixed window (stops at the first hub); cap it so a REST call can't hang.
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ timeout.CancelAfter(TimeSpan.FromSeconds(discoverTimeoutSeconds));
+ return await lego.DiscoverAsync(timeout.Token);
+ });
+
+ app.MapPost(ApiRoutes.Connect, async (string hubId, HubType hubType, ILegoService lego, CancellationToken ct) =>
+ {
+ await lego.ConnectAsync(hubId, hubType, ct);
+ return Results.Ok();
+ });
+
+ app.MapPost(ApiRoutes.Disconnect, async (string hubId, ILegoService lego, CancellationToken ct) =>
+ {
+ await lego.DisconnectAsync(hubId, ct);
+ return Results.Ok();
+ });
+
+ app.MapGet(ApiRoutes.State, (TrainStateStore state) => state.Snapshot());
+ }
+
+ // Removes CR/LF from user-controlled values so they can't inject forged lines into the log.
+ private static string Sanitize(string? value)
+ => string.IsNullOrEmpty(value) ? string.Empty : value.Replace("\r", string.Empty).Replace("\n", string.Empty);
+}
diff --git a/Source/Trackify.Cli/Server/TrainHub.cs b/Source/Trackify.Cli/Server/TrainHub.cs
new file mode 100644
index 0000000..36a8872
--- /dev/null
+++ b/Source/Trackify.Cli/Server/TrainHub.cs
@@ -0,0 +1,26 @@
+using Microsoft.AspNetCore.SignalR;
+using Trackify.Application.Lego;
+using Trackify.Application.Remote;
+
+namespace Trackify.Cli.Server;
+
+///
+/// Real-time train control over SignalR: clients set speed/LED by hubId and every client gets a
+/// live SpeedChanged broadcast. Forwards straight to — the same
+/// control seam the app uses locally — so nothing about the control logic differs over the network.
+///
+public sealed class TrainHub(ILegoService lego, TrainStateStore state) : Hub
+{
+ public async Task SetSpeed(string hubId, int port, int power)
+ {
+ var clamped = Math.Clamp(power, -100, 100);
+ await lego.SetSpeedAsync(hubId, (byte)port, (sbyte)clamped, Context.ConnectionAborted);
+ state.SetSpeed(hubId, clamped);
+ await Clients.All.SendAsync(TrainHubMethods.SpeedChanged, hubId, clamped);
+ }
+
+ public Task Stop(string hubId, int port) => SetSpeed(hubId, port, 0);
+
+ public Task SetLed(string hubId, int red, int green, int blue)
+ => lego.SetLedAsync(hubId, (byte)red, (byte)green, (byte)blue, Context.ConnectionAborted);
+}
diff --git a/Source/Trackify.Cli/Server/TrainStateStore.cs b/Source/Trackify.Cli/Server/TrainStateStore.cs
new file mode 100644
index 0000000..7b4abb9
--- /dev/null
+++ b/Source/Trackify.Cli/Server/TrainStateStore.cs
@@ -0,0 +1,18 @@
+using System.Collections.Concurrent;
+using Trackify.Application.Remote;
+
+namespace Trackify.Cli.Server;
+
+///
+/// Tracks the last speed applied to each train so a freshly-connected client (or GET /api/state)
+/// can show the current state without waiting for the next change. In-memory, per server run.
+///
+public sealed class TrainStateStore
+{
+ private readonly ConcurrentDictionary _speeds = new();
+
+ public void SetSpeed(string trainId, int speed) => _speeds[trainId] = speed;
+
+ public IReadOnlyList Snapshot()
+ => _speeds.Select(kv => new TrainSpeedState(kv.Key, kv.Value)).ToList();
+}
diff --git a/Source/Trackify.Cli/Trackify.Cli.csproj b/Source/Trackify.Cli/Trackify.Cli.csproj
index 05d00f6..15dbcc5 100644
--- a/Source/Trackify.Cli/Trackify.Cli.csproj
+++ b/Source/Trackify.Cli/Trackify.Cli.csproj
@@ -28,14 +28,26 @@
https://github.com/Ktechen/Trackify
+
+
+
+
+
-
-
+
+
+
+
+
+
+
diff --git a/Source/Trackify.Cli/appsettings.json b/Source/Trackify.Cli/appsettings.json
new file mode 100644
index 0000000..8bcaa22
--- /dev/null
+++ b/Source/Trackify.Cli/appsettings.json
@@ -0,0 +1,25 @@
+{
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ]
+ },
+ "Urls": "http://0.0.0.0:5000",
+ "Trackify": {
+ "Server": {
+ "DiscoverTimeoutSeconds": 20
+ }
+ }
+}
diff --git a/Source/Trackify.Cli/scripts/pi-bt-info.sh b/Source/Trackify.Cli/scripts/pi-bt-info.sh
new file mode 100755
index 0000000..93647a1
--- /dev/null
+++ b/Source/Trackify.Cli/scripts/pi-bt-info.sh
@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+# ---------------------------------------------------------------------------
+# Trackify — Raspberry Pi Bluetooth (BlueZ) diagnostics (read-only)
+# Gathers everything needed to debug LEGO Powered Up hub discovery/connection.
+# ./pi-bt-info.sh # basic (user-level)
+# sudo ./pi-bt-info.sh # includes hciconfig/btmgmt/dmesg
+# SCAN_SECONDS=15 ./pi-bt-info.sh # longer LE scan
+# sudo ./pi-bt-info.sh | tee bt-report.txt # capture for sharing
+#
+# Only writes: 'bluetoothctl power on' + a timed scan. Nothing is paired/removed.
+# ---------------------------------------------------------------------------
+set -uo pipefail
+
+SCAN_SECONDS="${SCAN_SECONDS:-10}"
+LEGO_COMPANY_ID="0397" # LEGO manufacturer id in BLE advertising data
+
+c_hdr='\033[1;36m'; c_ok='\033[1;32m'; c_warn='\033[1;33m'; c_err='\033[1;31m'; c_off='\033[0m'
+section() { echo; echo -e "${c_hdr}══════ $* ══════${c_off}"; }
+have() { command -v "$1" >/dev/null 2>&1; }
+run() { echo -e "\$ $*"; "$@" 2>&1 | sed 's/^/ /'; echo; }
+note() { echo -e " ${c_warn}» $*${c_off}"; }
+ok() { echo -e " ${c_ok}✓ $*${c_off}"; }
+bad() { echo -e " ${c_err}✗ $*${c_off}"; }
+
+SUDO=""
+if [ "$(id -u)" -ne 0 ]; then
+ if have sudo && sudo -n true 2>/dev/null; then SUDO="sudo"; else
+ note "Not root and no passwordless sudo — hciconfig/btmgmt/dmesg sections may be skipped."
+ fi
+fi
+
+section "SYSTEM"
+run uname -a
+[ -f /proc/device-tree/model ] && { echo " Model: $(tr -d '\0' /dev/null | grep -qi 'Soft blocked: yes'; then
+ bad "Bluetooth is SOFT-blocked → run: sudo rfkill unblock bluetooth"
+ elif rfkill list bluetooth 2>/dev/null | grep -qi 'Hard blocked: yes'; then
+ bad "Bluetooth is HARD-blocked (physical/firmware switch)."
+ else
+ ok "No rfkill block on Bluetooth."
+ fi
+else
+ note "rfkill not installed."
+fi
+
+section "KERNEL / DRIVER"
+run lsmod | grep -iE 'bluetooth|btbcm|hci_uart|btintel|btusb' || note "no BT modules listed"
+if [ -n "$SUDO" ] || [ "$(id -u)" -eq 0 ]; then
+ echo " dmesg (Bluetooth lines):"
+ $SUDO dmesg 2>/dev/null | grep -iE 'bluetooth|hci|bcm43|firmware' | tail -n 30 | sed 's/^/ /'
+else
+ note "Skipping dmesg (needs root)."
+fi
+
+section "bluetoothd SERVICE"
+if have systemctl; then
+ run systemctl is-active bluetooth
+ run systemctl status bluetooth --no-pager -l | head -n 15
+ systemctl is-active bluetooth >/dev/null 2>&1 && ok "bluetoothd is running." || bad "bluetoothd is NOT active → sudo systemctl enable --now bluetooth"
+else
+ note "systemctl not available."
+fi
+
+section "D-BUS (BlueZ talks over the system bus)"
+[ -S /var/run/dbus/system_bus_socket ] && ok "System D-Bus socket present." || bad "No /var/run/dbus/system_bus_socket (Docker: mount /var/run/dbus)."
+have busctl && run busctl --system list | grep -i bluez || true
+
+section "PERMISSIONS (current user)"
+echo " user: $(id -un) groups: $(id -Gn)"
+if id -Gn | grep -qw bluetooth; then ok "In 'bluetooth' group."; else
+ bad "NOT in 'bluetooth' group → sudo usermod -aG bluetooth $(id -un) (then re-login)"; fi
+
+section "HCI ADAPTERS"
+if have hciconfig; then
+ run ${SUDO:+$SUDO }hciconfig -a
+else
+ note "hciconfig not installed (package: bluez)."
+fi
+have btmgmt && { echo " btmgmt info:"; ${SUDO:+$SUDO }btmgmt info 2>&1 | sed 's/^/ /'; echo; }
+
+section "ADAPTER STATE (bluetoothctl show)"
+if have bluetoothctl; then
+ out="$(bluetoothctl show 2>&1)"; echo "$out" | sed 's/^/ /'; echo
+ echo "$out" | grep -qi 'Powered: yes' && ok "Adapter Powered = yes" || bad "Adapter Powered = no → bluetoothctl power on"
+ echo "$out" | grep -qi 'Discovering: yes' && note "Adapter already Discovering (a scan is running)."
+else
+ bad "bluetoothctl not installed → run setup-bluez.sh"
+fi
+
+section "KNOWN / PAIRED DEVICES"
+if have bluetoothctl; then
+ run bluetoothctl devices
+ run bluetoothctl paired-devices
+fi
+
+section "LIVE LE SCAN (${SCAN_SECONDS}s) — looking for LEGO hubs"
+if have bluetoothctl; then
+ note "Power the LEGO hub ON now (its light should blink)."
+ bluetoothctl power on >/dev/null 2>&1
+ ( echo "menu scan"; echo "transport le"; echo "back"; echo "scan on"; sleep "$SCAN_SECONDS"; echo "scan off"; echo "quit" ) \
+ | timeout $((SCAN_SECONDS + 5)) bluetoothctl >/tmp/bt_scan.log 2>&1
+ echo " --- devices seen after scan ---"
+ bluetoothctl devices 2>/dev/null | sed 's/^/ /'
+ echo
+ echo " --- scan log (advertisements) ---"
+ grep -iE 'Device|Name|ManufacturerData|RSSI|0397' /tmp/bt_scan.log 2>/dev/null | tail -n 40 | sed 's/^/ /'
+ echo
+ if grep -qi "$LEGO_COMPANY_ID" /tmp/bt_scan.log 2>/dev/null; then
+ ok "Saw LEGO manufacturer data (0x$LEGO_COMPANY_ID) — a hub is advertising."
+ elif bluetoothctl devices 2>/dev/null | grep -qiE 'HUB|LEGO|Move|Technic|Duplo'; then
+ ok "A likely LEGO hub is in the device list."
+ else
+ bad "No LEGO hub seen. Check: hub powered on, in range, radio powered, LE transport."
+ fi
+else
+ note "bluetoothctl missing — cannot scan (run setup-bluez.sh)."
+fi
+
+section "SUMMARY HINTS"
+cat <<'EOF'
+ If discovery/connection still fails, the usual culprits (in order):
+ 1. BlueZ missing: sudo ./setup-bluez.sh (installs + enables everything)
+ 2. Radio off: sudo rfkill unblock bluetooth ; bluetoothctl power on
+ 3. Service down: sudo systemctl enable --now bluetooth
+ 4. Permissions: sudo usermod -aG bluetooth $USER (then log out/in)
+ 5. Docker only: -v /var/run/dbus:/var/run/dbus --network host
+ 6. Hub not in LE: Trackify forces an LE-transport scan; confirm the hub
+ blinks (advertising) before connecting.
+EOF
+echo
+echo -e "${c_ok}Done. Copy this whole output when reporting an issue.${c_off}"
diff --git a/Source/Trackify.Cli/scripts/setup-bluez.sh b/Source/Trackify.Cli/scripts/setup-bluez.sh
new file mode 100755
index 0000000..7c720ce
--- /dev/null
+++ b/Source/Trackify.Cli/scripts/setup-bluez.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+# ---------------------------------------------------------------------------
+# Trackify — BlueZ setup for a Debian/Ubuntu Raspberry Pi.
+#
+# Installs and enables everything Trackify needs to talk to LEGO Powered Up
+# hubs over the Pi's onboard Bluetooth: the BlueZ daemon (bluetoothd), powers
+# the radio on, and grants the current user access. Safe to re-run (idempotent).
+#
+# sudo ./setup-bluez.sh
+#
+# Ubuntu Server images ship WITHOUT bluez; Raspberry Pi OS usually includes it.
+# Either way this brings the Pi to a known-good state.
+# ---------------------------------------------------------------------------
+set -euo pipefail
+
+c_ok='\033[1;32m'; c_warn='\033[1;33m'; c_err='\033[1;31m'; c_hdr='\033[1;36m'; c_off='\033[0m'
+step() { echo -e "\n${c_hdr}== $* ==${c_off}"; }
+ok() { echo -e " ${c_ok}✓ $*${c_off}"; }
+warn() { echo -e " ${c_warn}» $*${c_off}"; }
+die() { echo -e " ${c_err}✗ $*${c_off}"; exit 1; }
+
+[ "$(id -u)" -eq 0 ] || die "Please run with sudo: sudo ./setup-bluez.sh"
+command -v apt-get >/dev/null 2>&1 || die "This script targets Debian/Ubuntu (apt). Install 'bluez' with your distro's package manager instead."
+
+# The human user to grant access to (the one who invoked sudo, not root).
+TARGET_USER="${SUDO_USER:-${USER:-pi}}"
+
+step "Install BlueZ + helpers"
+if dpkg -s bluez >/dev/null 2>&1; then
+ ok "bluez already installed."
+else
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get update -qq
+ # bluez = bluetoothd + bluetoothctl (the stack Trackify drives over D-Bus).
+ # rfkill = un/block the radio from the CLI.
+ apt-get install -y bluez rfkill
+ ok "bluez + rfkill installed."
+fi
+
+step "Enable and start bluetoothd"
+systemctl enable --now bluetooth
+if systemctl is-active --quiet bluetooth; then
+ ok "bluetoothd is active."
+else
+ systemctl status bluetooth --no-pager -l | head -n 15 || true
+ die "bluetoothd failed to start (see status above)."
+fi
+
+step "Unblock and power on the radio"
+if command -v rfkill >/dev/null 2>&1; then
+ rfkill unblock bluetooth || true
+ ok "rfkill: Bluetooth unblocked."
+fi
+# Give bluetoothd a moment to expose the adapter on D-Bus, then power it on.
+sleep 1
+if bluetoothctl power on >/dev/null 2>&1; then
+ ok "Adapter powered on."
+else
+ warn "Could not power the adapter yet — it may need a few seconds after boot."
+fi
+
+step "Grant '$TARGET_USER' access to org.bluez"
+if id -nG "$TARGET_USER" 2>/dev/null | grep -qw bluetooth; then
+ ok "'$TARGET_USER' is already in the 'bluetooth' group."
+else
+ usermod -aG bluetooth "$TARGET_USER"
+ ok "Added '$TARGET_USER' to the 'bluetooth' group."
+ NEED_RELOGIN=1
+fi
+
+step "Adapter state"
+bluetoothctl show 2>/dev/null | grep -E 'Name|Powered|Discoverable|Address' | sed 's/^/ /' || warn "bluetoothctl show returned nothing."
+
+echo
+ok "BlueZ setup complete."
+if [ "${NEED_RELOGIN:-0}" = "1" ]; then
+ echo -e "${c_warn}IMPORTANT:${c_off} log out & back in (or reboot) so '$TARGET_USER' picks up the 'bluetooth' group,"
+ echo " then run: trackify discover"
+else
+ echo "Next: power on a hub (light blinking), then run: trackify discover"
+fi
diff --git a/Source/Trackify.Infrastructure/Ble/BlueZDevice.cs b/Source/Trackify.Infrastructure/Ble/BlueZDevice.cs
index b6ba619..d9e857f 100644
--- a/Source/Trackify.Infrastructure/Ble/BlueZDevice.cs
+++ b/Source/Trackify.Infrastructure/Ble/BlueZDevice.cs
@@ -18,6 +18,9 @@ public async Task GetServiceAsync(Guid serviceId)
{
await device.ConnectAsync();
await device.WaitForPropertyValueAsync("Connected", value: true, TimeSpan.FromSeconds(15));
+ // BlueZ reports Connected=true before GATT discovery finishes; the services aren't queryable
+ // until ServicesResolved flips. Without this wait GetServiceAsync races and returns null.
+ await device.WaitForPropertyValueAsync("ServicesResolved", value: true, TimeSpan.FromSeconds(15));
_connected = true;
}
diff --git a/Source/Trackify.Infrastructure/Ble/BlueZLegoService.cs b/Source/Trackify.Infrastructure/Ble/BlueZLegoService.cs
index b4b94b0..b45e231 100644
--- a/Source/Trackify.Infrastructure/Ble/BlueZLegoService.cs
+++ b/Source/Trackify.Infrastructure/Ble/BlueZLegoService.cs
@@ -10,7 +10,7 @@ namespace Trackify.Infrastructure.Ble;
/// the Windows service — discover, open a SharpBrick protocol, keep it keyed by MAC — but the transport
/// is . Command building lives in .
///
-public sealed class BlueZLegoService(PoweredUpHost host, IPoweredUpBluetoothAdapter adapter, ILogger logger) : ILegoService
+public sealed class BlueZLegoService(PoweredUpHost host, BlueZPoweredUpBluetoothAdapter adapter, ILogger logger) : ILegoService
{
private readonly Lock _gate = new();
private readonly Dictionary _connectedHubs = new();
@@ -20,6 +20,10 @@ public sealed class BlueZLegoService(PoweredUpHost host, IPoweredUpBluetoothAdap
public async Task> DiscoverAsync(CancellationToken ct = default)
{
+ // Fail fast with a clear message if the radio is off/missing — the fire-and-forget scan loop
+ // below can't surface that itself.
+ await adapter.EnsureReadyAsync(ct);
+
var found = new Dictionary();
// Scan runs until the first hub is seen or the caller cancels - no fixed time window.
@@ -57,6 +61,8 @@ public async Task ConnectAsync(string hubId, HubType hubType, CancellationToken
return;
}
+ await adapter.EnsureReadyAsync(ct);
+
var deviceInfo = await adapter.CreateDeviceInfoByKnownStateAsync(LwpAddressingMapping.ParseMacAddress(hubId))
?? throw new InvalidOperationException("Invalid hub address.");
var protocol = host.CreateProtocol(deviceInfo);
diff --git a/Source/Trackify.Infrastructure/Ble/BlueZPoweredUpBluetoothAdapter.cs b/Source/Trackify.Infrastructure/Ble/BlueZPoweredUpBluetoothAdapter.cs
index 2ef975b..0d73ea4 100644
--- a/Source/Trackify.Infrastructure/Ble/BlueZPoweredUpBluetoothAdapter.cs
+++ b/Source/Trackify.Infrastructure/Ble/BlueZPoweredUpBluetoothAdapter.cs
@@ -1,5 +1,6 @@
using Linux.Bluetooth;
using Linux.Bluetooth.Extensions;
+using Microsoft.Extensions.Logging;
using SharpBrick.PoweredUp.Bluetooth;
namespace Trackify.Infrastructure.Ble;
@@ -8,46 +9,64 @@ namespace Trackify.Infrastructure.Ble;
/// A SharpBrick backed by BlueZ (via D-Bus) so the Pi's
/// onboard radio can be used. SharpBrick keeps owning the LEGO Wireless Protocol; this only maps its
/// generic connect/discover/GATT calls onto Linux.Bluetooth. Runs on Linux only.
+///
+/// Mirrors the mobile app's transport as closely as BlueZ allows: it makes sure the radio is actually
+/// powered on before use, scans on the LE transport (so BLE-only hubs and their manufacturer
+/// data surface), and also enumerates devices BlueZ already knows about (a fresh scan never re-emits
+/// DeviceFound for cached devices, whereas the mobile stack sees them from advertisements).
+///
///
-public sealed class BlueZPoweredUpBluetoothAdapter : IPoweredUpBluetoothAdapter
+public sealed class BlueZPoweredUpBluetoothAdapter(ILogger logger) : IPoweredUpBluetoothAdapter
{
// LEGO company id in BLE manufacturer-specific advertising data.
private const ushort LegoCompanyId = 0x0397;
+ private readonly ILogger _log = logger;
+
+ ///
+ /// Verifies the radio is present and powered (like the mobile app's radio-state check). Powers it
+ /// on automatically when it's off; throws a clear, actionable message if that isn't possible.
+ ///
+ public async Task EnsureReadyAsync(CancellationToken ct = default) => await GetReadyAdapterAsync(ct);
+
public void Discover(Func discoveryHandler, CancellationToken cancellationToken = default)
=> _ = DiscoverLoopAsync(discoveryHandler, cancellationToken);
- private static async Task DiscoverLoopAsync(Func handler, CancellationToken ct)
+ private async Task DiscoverLoopAsync(Func handler, CancellationToken ct)
{
- var adapter = await GetAdapterAsync();
+ var adapter = await GetReadyAdapterAsync(ct);
- async Task OnDeviceFound(Adapter sender, DeviceFoundEventArgs eventArgs)
+ async Task Surface(Device device)
{
- try
- {
- var device = eventArgs.Device;
-
- // Only surface genuine LEGO Powered Up hubs: they advertise manufacturer data under the
- // LEGO company id (0x0397). Every other nearby BLE device (phones, headsets, …) is
- // ignored so discovery never invents a hub.
- var manufacturerData = await GetLegoManufacturerDataAsync(device);
- if (manufacturerData.Length == 0)
- return;
-
- var name = await device.GetNameAsync();
- var address = await device.GetAddressAsync();
- await handler(new BlueZDeviceInfo(LwpAddressingMapping.ParseMacAddress(address), name, manufacturerData));
- }
- catch
- {
- // Ignore a device that vanished or wouldn't answer; the scan keeps running.
- }
+ // Only surface genuine LEGO Powered Up hubs: they advertise manufacturer data under the
+ // LEGO company id (0x0397). Every other nearby BLE device (phones, headsets, …) is ignored.
+ var manufacturerData = await GetLegoManufacturerDataAsync(device);
+ if (manufacturerData.Length == 0)
+ return;
+
+ string address;
+ try { address = await device.GetAddressAsync(); }
+ catch { return; } // device vanished mid-scan.
+
+ string? name = null;
+ try { name = await device.GetNameAsync(); } catch { /* hubs may advertise without a Name */ }
+
+ try { await handler(new BlueZDeviceInfo(LwpAddressingMapping.ParseMacAddress(address), name, manufacturerData)); }
+ catch { /* the handler's own failure must not kill the scan */ }
}
+ async Task OnDeviceFound(Adapter sender, DeviceFoundEventArgs eventArgs) => await Surface(eventArgs.Device);
+
adapter.DeviceFound += OnDeviceFound;
- await adapter.StartDiscoveryAsync();
+ await StartLeScanAsync(adapter);
+ Log.DiscoveryStarted(_log);
+
try
{
+ // Surface hubs BlueZ already has cached — DeviceFound won't re-fire for those.
+ foreach (var device in await adapter.GetDevicesAsync())
+ await Surface(device);
+
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
@@ -66,9 +85,15 @@ public async Task GetDeviceAsync(IPoweredUpBluetoothD
if (bluetoothDeviceInfo is not BlueZDeviceInfo info)
throw new ArgumentException($"Expected a {nameof(BlueZDeviceInfo)}.", nameof(bluetoothDeviceInfo));
- var adapter = await GetAdapterAsync();
- var device = await adapter.GetDeviceAsync(LwpAddressingMapping.FormatMacAddress(info.MacAddressAsUInt64))
- ?? throw new InvalidOperationException($"BLE device {info.Name} is not known to BlueZ; discover it first.");
+ var adapter = await GetReadyAdapterAsync();
+ var mac = LwpAddressingMapping.FormatMacAddress(info.MacAddressAsUInt64);
+
+ // Connect by id like the mobile app: if BlueZ doesn't know the device yet (e.g. a cold connect
+ // from auto mode after boot, with no prior 'discover'), do a brief LE scan to find it first.
+ var device = await adapter.GetDeviceAsync(mac) ?? await ScanForDeviceAsync(adapter, mac);
+ if (device is null)
+ throw new InvalidOperationException(
+ $"Hub {mac} not found. Make sure it is powered on and in range (or run 'trackify discover' first).");
return new BlueZDevice(device);
}
@@ -78,9 +103,77 @@ public Task CreateDeviceInfoByKnownStateAsync(obj
? Task.FromResult(new BlueZDeviceInfo(macAddress, name: null, manufacturerData: []))
: throw new NotSupportedException($"Unsupported device-info state '{state}'.");
- private static async Task GetAdapterAsync()
- => (await BlueZManager.GetAdaptersAsync()).FirstOrDefault()
- ?? throw new InvalidOperationException("No BlueZ Bluetooth adapter found. Is bluetoothd running?");
+ private async Task ScanForDeviceAsync(Adapter adapter, string mac)
+ {
+ Log.ScanningForDevice(_log, mac);
+ await StartLeScanAsync(adapter);
+ try
+ {
+ // Poll for ~15s for the hub to appear in BlueZ's object tree.
+ for (var attempt = 0; attempt < 75; attempt++)
+ {
+ var device = await adapter.GetDeviceAsync(mac);
+ if (device is not null)
+ return device;
+
+ await Task.Delay(200);
+ }
+ }
+ finally
+ {
+ // Stop scanning before the caller connects — BlueZ connects far more reliably when idle.
+ try { await adapter.StopDiscoveryAsync(); } catch { /* best effort */ }
+ }
+
+ return null;
+ }
+
+ private async Task GetReadyAdapterAsync(CancellationToken ct = default)
+ {
+ var adapter = (await BlueZManager.GetAdaptersAsync()).FirstOrDefault()
+ ?? throw new InvalidOperationException(
+ "No BlueZ Bluetooth adapter found. Is the Pi's Bluetooth hardware present and 'bluetoothd' running?");
+
+ if (await adapter.GetPoweredAsync())
+ return adapter;
+
+ // Radio is off — power it on ourselves (like a phone enabling Bluetooth).
+ Log.RadioPoweringOn(_log);
+ try { await adapter.SetPoweredAsync(true); } catch { /* likely rfkill soft-blocked */ }
+
+ // BlueZ flips 'Powered' asynchronously; give it up to ~2s to come up, then re-check.
+ for (var attempt = 0; attempt < 10 && !await adapter.GetPoweredAsync(); attempt++)
+ await Task.Delay(200, ct);
+
+ if (!await adapter.GetPoweredAsync())
+ throw new InvalidOperationException(
+ "Bluetooth is off. Enable the radio with 'sudo rfkill unblock bluetooth' then 'bluetoothctl power on', " +
+ "and make sure the user is in the 'bluetooth' group.");
+
+ return adapter;
+ }
+
+ private static async Task StartLeScanAsync(Adapter adapter)
+ {
+ // Scan on the LE transport, matching the mobile app: BLE-only LEGO hubs (and their
+ // manufacturer data) reliably surface, whereas BlueZ's default "auto" (BR/EDR + LE) often
+ // misses them. DuplicateData keeps advertisements flowing so RSSI/data stay fresh.
+ try
+ {
+ await adapter.SetDiscoveryFilterAsync(new Dictionary
+ {
+ ["Transport"] = "le",
+ ["DuplicateData"] = true,
+ });
+ }
+ catch
+ {
+ // An older BlueZ may reject a filter key — fall back to default (auto) discovery.
+ }
+
+ if (!await adapter.GetDiscoveringAsync())
+ await adapter.StartDiscoveryAsync();
+ }
private static async Task GetLegoManufacturerDataAsync(Device device)
{
diff --git a/Source/Trackify.Infrastructure/Ble/LinuxLegoServiceExtensions.cs b/Source/Trackify.Infrastructure/Ble/LinuxLegoServiceExtensions.cs
index e78f03c..e678ac9 100644
--- a/Source/Trackify.Infrastructure/Ble/LinuxLegoServiceExtensions.cs
+++ b/Source/Trackify.Infrastructure/Ble/LinuxLegoServiceExtensions.cs
@@ -21,7 +21,10 @@ public static IServiceCollection AddLinuxLego(this IServiceCollection services)
if (OperatingSystem.IsLinux())
{
services.AddPoweredUp();
- services.TryAddSingleton();
+ // One BlueZ adapter instance behind both SharpBrick's abstraction (for the host) and the
+ // concrete type (so BlueZLegoService can run its radio-ready preflight before scanning).
+ services.TryAddSingleton();
+ services.TryAddSingleton(sp => sp.GetRequiredService());
services.TryAddSingleton();
}
diff --git a/Source/Trackify.Infrastructure/Log.cs b/Source/Trackify.Infrastructure/Log.cs
index e423545..5348fe5 100644
--- a/Source/Trackify.Infrastructure/Log.cs
+++ b/Source/Trackify.Infrastructure/Log.cs
@@ -7,4 +7,13 @@ internal static partial class Log
{
[LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Hub {HubId} connected over BlueZ")]
public static partial void HubConnected(ILogger logger, string hubId);
+
+ [LoggerMessage(EventId = 2003, Level = LogLevel.Warning, Message = "Bluetooth adapter was off; powering it on")]
+ public static partial void RadioPoweringOn(ILogger logger);
+
+ [LoggerMessage(EventId = 2004, Level = LogLevel.Information, Message = "BlueZ LE discovery started")]
+ public static partial void DiscoveryStarted(ILogger logger);
+
+ [LoggerMessage(EventId = 2005, Level = LogLevel.Information, Message = "Scanning (LE) for hub {Mac}…")]
+ public static partial void ScanningForDevice(ILogger logger, string mac);
}
diff --git a/Source/Trackify/App.xaml.cs b/Source/Trackify/App.xaml.cs
index 05bf613..232d2b8 100644
--- a/Source/Trackify/App.xaml.cs
+++ b/Source/Trackify/App.xaml.cs
@@ -2,6 +2,7 @@
using Serilog;
using Trackify.Application;
using Trackify.Infrastructure;
+using Trackify.Services.Remote;
namespace Trackify;
@@ -14,6 +15,22 @@ public partial class App : Microsoft.UI.Xaml.Application
public App()
{
this.InitializeComponent();
+
+ // Global exception handling (ASP.NET-style): log every unhandled failure so nothing is
+ // swallowed silently — UI, background threads, and unobserved tasks.
+ Serilog.Log.Logger = CreateSerilogLogger();
+ this.UnhandledException += (_, e) =>
+ {
+ Serilog.Log.Error(e.Exception, "Unhandled UI exception");
+ e.Handled = true; // keep the app alive; the error is logged, not silently ignored
+ };
+ AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+ Serilog.Log.Error(e.ExceptionObject as Exception, "Unhandled domain exception");
+ System.Threading.Tasks.TaskScheduler.UnobservedTaskException += (_, e) =>
+ {
+ Serilog.Log.Error(e.Exception, "Unobserved task exception");
+ e.SetObserved();
+ };
}
protected Window? MainWindow { get; private set; }
@@ -42,8 +59,20 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args)
.ConfigureServices((context, services) =>
{
services.AddTrackifyDomain();
- services.AddTrackifyApplication();
+ services.AddTrackifyApplication(); // registers the platform (local) ILegoService, if any
services.AddTrackifyInfrastructure();
+
+ // Live mode switch: ConnectionState (seeded from config) drives a SwitchingLegoService
+ // that routes each call to the local Bluetooth transport or the remote backend — so
+ // Direct/Server can toggle at runtime. RemoteTrainSync pulls trains into local SQLite.
+ var defaultUrl = context.Configuration["AppConfig:ServerUrl"];
+ services.AddSingleton(sp => new ConnectionState(sp.GetRequiredService>(), defaultUrl));
+ services.AddSingleton();
+
+ var localDescriptor = services.LastOrDefault(d => d.ServiceType == typeof(ILegoService));
+ services.AddSingleton(sp => new SwitchingLegoService(
+ sp.GetRequiredService(),
+ ResolveLocal(sp, localDescriptor)));
})
.UseNavigation(RegisterRoutes)
);
@@ -56,9 +85,23 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args)
private static Serilog.Core.Logger CreateSerilogLogger()
=> new LoggerConfiguration()
.MinimumLevel.Information()
+ // Keep the overview clean: framework INFO noise (e.g. Uno's optional appsettings probes,
+ // Microsoft host chatter) is downgraded to warnings; Trackify's own logs stay at Information.
+ .MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
+ .MinimumLevel.Override("Uno", Serilog.Events.LogEventLevel.Warning)
.WriteTo.Console()
.CreateLogger();
+ // Builds the platform (local Bluetooth) ILegoService from its original registration, so the
+ // SwitchingLegoService can wrap it without the two competing for the ILegoService resolution.
+ private static ILegoService? ResolveLocal(IServiceProvider services, ServiceDescriptor? descriptor) => descriptor switch
+ {
+ { ImplementationInstance: ILegoService instance } => instance,
+ { ImplementationFactory: { } factory } => (ILegoService)factory(services),
+ { ImplementationType: { } type } => (ILegoService)ActivatorUtilities.CreateInstance(services, type),
+ _ => null,
+ };
+
private static void RegisterRoutes(IViewRegistry views, IRouteRegistry routes)
{
views.Register(
diff --git a/Source/Trackify/Log.cs b/Source/Trackify/Log.cs
index 5b63099..d8de2b0 100644
--- a/Source/Trackify/Log.cs
+++ b/Source/Trackify/Log.cs
@@ -11,4 +11,19 @@ internal static partial class Log
[LoggerMessage(EventId = 4002, Level = LogLevel.Warning, Message = "Train {TrainId} connect failed")]
public static partial void HubConnectFailed(ILogger logger, string trainId, Exception exception);
+
+ [LoggerMessage(EventId = 4003, Level = LogLevel.Information, Message = "Connection mode changed (server: {UseServer}, url: {ServerUrl})")]
+ public static partial void ConnectionModeChanged(ILogger logger, bool useServer, string serverUrl);
+
+ [LoggerMessage(EventId = 4004, Level = LogLevel.Information, Message = "Synced {Count} trains from the backend")]
+ public static partial void SyncCompleted(ILogger logger, int count);
+
+ [LoggerMessage(EventId = 4005, Level = LogLevel.Warning, Message = "Backend train sync failed")]
+ public static partial void SyncFailed(ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = 4006, Level = LogLevel.Warning, Message = "Could not load connection settings")]
+ public static partial void ConnectionSettingsLoadFailed(ILogger logger, Exception exception);
+
+ [LoggerMessage(EventId = 4007, Level = LogLevel.Warning, Message = "Could not save connection settings")]
+ public static partial void ConnectionSettingsSaveFailed(ILogger logger, Exception exception);
}
diff --git a/Source/Trackify/Models/AppConfig.cs b/Source/Trackify/Models/AppConfig.cs
index 51f0f27..5fc8df4 100644
--- a/Source/Trackify/Models/AppConfig.cs
+++ b/Source/Trackify/Models/AppConfig.cs
@@ -3,4 +3,10 @@ namespace Trackify.Models;
public record AppConfig
{
public string? Environment { get; init; }
+
+ ///
+ /// Backend base URL for Server mode, e.g. http://192.168.1.50:5000. Empty/absent = Direct
+ /// mode (the device's own Bluetooth). The HMI's mode switch writes this.
+ ///
+ public string? ServerUrl { get; init; }
}
diff --git a/Source/Trackify/Presentation/Pages/MainPage.xaml b/Source/Trackify/Presentation/Pages/MainPage.xaml
index 16b255c..173c3ca 100644
--- a/Source/Trackify/Presentation/Pages/MainPage.xaml
+++ b/Source/Trackify/Presentation/Pages/MainPage.xaml
@@ -69,9 +69,14 @@
-
+
+
+
+
@@ -87,5 +92,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Source/Trackify/Presentation/ViewModels/MainViewModel.cs b/Source/Trackify/Presentation/ViewModels/MainViewModel.cs
index 80a5ab2..9b56a64 100644
--- a/Source/Trackify/Presentation/ViewModels/MainViewModel.cs
+++ b/Source/Trackify/Presentation/ViewModels/MainViewModel.cs
@@ -3,6 +3,7 @@
using System.ComponentModel;
using Microsoft.Extensions.Logging;
using Trackify.Models.Trains;
+using Trackify.Services.Remote;
using Train = Trackify.Models.Trains.Train;
namespace Trackify.Presentation.ViewModels;
@@ -14,6 +15,7 @@ namespace Trackify.Presentation.ViewModels;
public partial class MainViewModel : ObservableObject
{
private readonly INavigator _navigator;
+ private readonly RemoteTrainSync _sync;
private readonly ILogger _log;
private int _sequence = 1;
@@ -22,6 +24,8 @@ public partial class MainViewModel : ObservableObject
[ObservableProperty] private Train? selectedTrain;
[ObservableProperty] private int activeCount;
[ObservableProperty] private int totalCount;
+ [ObservableProperty] private bool isConnectionPanelOpen;
+ [ObservableProperty] private string? connectionStatus;
public ObservableCollection Trains { get; } = [];
@@ -63,11 +67,20 @@ public partial class MainViewModel : ObservableObject
public IRelayCommand BackToListCommand { get; }
- public MainViewModel(INavigator navigator, ILegoService lego, ILogger logger)
+ /// Live connection mode + backend URL (Direct vs Server), bound by the Connection panel.
+ public ConnectionState Connection { get; }
+
+ public IRelayCommand ToggleConnectionPanelCommand { get; }
+
+ public IAsyncRelayCommand ApplyConnectionCommand { get; }
+
+ public MainViewModel(INavigator navigator, ILegoService lego, ConnectionState connection, RemoteTrainSync sync, ILogger logger)
{
_navigator = navigator;
_lego = lego;
+ _sync = sync;
_log = logger;
+ Connection = connection;
AddTrainCommand = new RelayCommand(AddTrain);
DuplicateTrainCommand = new RelayCommand(DuplicateTrain, () => SelectedTrain is not null);
@@ -83,6 +96,8 @@ public MainViewModel(INavigator navigator, ILegoService lego, ILogger SelectedTrain = null);
+ ToggleConnectionPanelCommand = new RelayCommand(() => IsConnectionPanelOpen = !IsConnectionPanelOpen);
+ ApplyConnectionCommand = new AsyncRelayCommand(ApplyConnectionAsync);
ColorSwatches = [.. LegoinoCatalog.Colors.Select(c => new ColorSwatchItemViewModel { Value = c.Value, Name = c.Name, Hex = c.Hex })];
@@ -138,6 +153,39 @@ private void SelectAfresh(Train train)
private async Task GoToStreckenplaner() => await _navigator.NavigateViewModelAsync(this);
+ // Persists the chosen mode/URL (SwitchingLegoService picks it up live) and, in Server mode, pulls
+ // the backend's trains into the local store.
+ private async Task ApplyConnectionAsync()
+ {
+ Connection.Save();
+ Log.ConnectionModeChanged(_log, Connection.UseServer, Connection.ServerUrl);
+
+ if (!Connection.UseServer)
+ {
+ ConnectionStatus = "Direktmodus (Bluetooth dieses Geräts).";
+ return;
+ }
+
+ if (string.IsNullOrWhiteSpace(Connection.ServerUrl))
+ {
+ ConnectionStatus = "Bitte eine Server-Adresse eingeben.";
+ return;
+ }
+
+ ConnectionStatus = "Synchronisiere Züge…";
+ try
+ {
+ var count = await _sync.SyncAsync();
+ ConnectionStatus = $"Servermodus aktiv — {count} Züge synchronisiert.";
+ Log.SyncCompleted(_log, count);
+ }
+ catch (Exception ex)
+ {
+ ConnectionStatus = $"Fehler: {ex.Message}";
+ Log.SyncFailed(_log, ex);
+ }
+ }
+
partial void OnSearchChanged(string value) => ApplyFilter();
partial void OnFilterChanged(TrainFilterType value) => ApplyFilter();
diff --git a/Source/Trackify/Services/Remote/ConnectionState.cs b/Source/Trackify/Services/Remote/ConnectionState.cs
new file mode 100644
index 0000000..f308fa1
--- /dev/null
+++ b/Source/Trackify/Services/Remote/ConnectionState.cs
@@ -0,0 +1,52 @@
+using Windows.Storage;
+
+namespace Trackify.Services.Remote;
+
+///
+/// The app's live connection mode — Direct (this device's own Bluetooth) vs. Server (a Trackify
+/// backend on a Pi) — plus the backend URL. Persisted in local app settings and read by
+/// on every call, so toggling takes effect immediately (no restart).
+///
+public sealed partial class ConnectionState : ObservableObject
+{
+ private const string UseServerKey = "trackify.useServer";
+ private const string ServerUrlKey = "trackify.serverUrl";
+
+ private readonly ILogger _log;
+
+ [ObservableProperty] private bool useServer;
+ [ObservableProperty] private string serverUrl = "";
+
+ public ConnectionState(ILogger logger, string? defaultServerUrl = null)
+ {
+ _log = logger;
+
+ try
+ {
+ var values = ApplicationData.Current.LocalSettings.Values;
+ useServer = values[UseServerKey] is bool stored && stored;
+ serverUrl = values[ServerUrlKey] as string ?? defaultServerUrl ?? "";
+ }
+ catch (Exception ex)
+ {
+ // No persisted settings yet (or storage unavailable) — start from the configured default.
+ serverUrl = defaultServerUrl ?? "";
+ Log.ConnectionSettingsLoadFailed(_log, ex);
+ }
+ }
+
+ /// Persists the current mode + URL so it survives a restart.
+ public void Save()
+ {
+ try
+ {
+ var values = ApplicationData.Current.LocalSettings.Values;
+ values[UseServerKey] = UseServer;
+ values[ServerUrlKey] = ServerUrl;
+ }
+ catch (Exception ex)
+ {
+ Log.ConnectionSettingsSaveFailed(_log, ex);
+ }
+ }
+}
diff --git a/Source/Trackify/Services/Remote/ITrackifyApi.cs b/Source/Trackify/Services/Remote/ITrackifyApi.cs
new file mode 100644
index 0000000..f89656c
--- /dev/null
+++ b/Source/Trackify/Services/Remote/ITrackifyApi.cs
@@ -0,0 +1,27 @@
+using Refit;
+using Trackify.Application.Remote;
+
+namespace Trackify.Services.Remote;
+
+///
+/// Refit client for the backend's one-shot REST actions (list/discover/connect/disconnect + current
+/// state). Real-time speed/LED go over SignalR instead — see . Routes
+/// come from the shared so client and server never drift.
+///
+public interface ITrackifyApi
+{
+ [Get(ApiRoutes.Trains)]
+ Task> GetTrainsAsync(CancellationToken ct = default);
+
+ [Post(ApiRoutes.Discover)]
+ Task> DiscoverAsync(CancellationToken ct = default);
+
+ [Post(ApiRoutes.Connect)]
+ Task ConnectAsync(string hubId, HubType hubType, CancellationToken ct = default);
+
+ [Post(ApiRoutes.Disconnect)]
+ Task DisconnectAsync(string hubId, CancellationToken ct = default);
+
+ [Get(ApiRoutes.State)]
+ Task> GetStateAsync(CancellationToken ct = default);
+}
diff --git a/Source/Trackify/Services/Remote/RemoteLegoService.cs b/Source/Trackify/Services/Remote/RemoteLegoService.cs
new file mode 100644
index 0000000..9d4f151
--- /dev/null
+++ b/Source/Trackify/Services/Remote/RemoteLegoService.cs
@@ -0,0 +1,67 @@
+using Microsoft.AspNetCore.SignalR.Client;
+using Trackify.Application.Remote;
+
+namespace Trackify.Services.Remote;
+
+///
+/// backed by a remote Trackify backend: one-shot actions over REST (Refit)
+/// and real-time speed/LED over SignalR. Selected instead of the device's own Bluetooth when the app
+/// is in Server mode, so the whole UI works unchanged — only the transport moves to the Pi.
+///
+public sealed class RemoteLegoService : ILegoService, IAsyncDisposable
+{
+ private readonly ITrackifyApi _api;
+ private readonly HubConnection _hub;
+
+ /// Raised when any client changes a hub's speed — lets the UI show the live current speed.
+ public event Action? SpeedChanged;
+
+ public RemoteLegoService(ITrackifyApi api, RemoteServerOptions options)
+ {
+ _api = api;
+ _hub = new HubConnectionBuilder()
+ .WithUrl($"{options.BaseUrl.TrimEnd('/')}{ApiRoutes.TrainHub}")
+ .WithAutomaticReconnect()
+ .Build();
+
+ _hub.On(TrainHubMethods.SpeedChanged, (hubId, speed) => SpeedChanged?.Invoke(hubId, speed));
+ }
+
+ /// Builds a remote transport for a backend base URL (used by the live mode switch).
+ public static RemoteLegoService Create(string baseUrl)
+ => new(TrackifyApiFactory.Create(baseUrl), new RemoteServerOptions { BaseUrl = baseUrl });
+
+ public bool IsSupported => true;
+
+ public Task> DiscoverAsync(CancellationToken ct = default)
+ => _api.DiscoverAsync(ct);
+
+ public async Task ConnectAsync(string hubId, HubType hubType, CancellationToken ct = default)
+ {
+ await EnsureHubAsync(ct);
+ await _api.ConnectAsync(hubId, hubType, ct);
+ }
+
+ public Task DisconnectAsync(string hubId, CancellationToken ct = default)
+ => _api.DisconnectAsync(hubId, ct);
+
+ public async Task SetSpeedAsync(string hubId, byte port, sbyte power, CancellationToken ct = default)
+ {
+ await EnsureHubAsync(ct);
+ await _hub.InvokeAsync(TrainHubMethods.SetSpeed, hubId, (int)port, (int)power, ct);
+ }
+
+ public async Task SetLedAsync(string hubId, byte red, byte green, byte blue, CancellationToken ct = default)
+ {
+ await EnsureHubAsync(ct);
+ await _hub.InvokeAsync(TrainHubMethods.SetLed, hubId, (int)red, (int)green, (int)blue, ct);
+ }
+
+ private async Task EnsureHubAsync(CancellationToken ct)
+ {
+ if (_hub.State == HubConnectionState.Disconnected)
+ await _hub.StartAsync(ct);
+ }
+
+ public async ValueTask DisposeAsync() => await _hub.DisposeAsync();
+}
diff --git a/Source/Trackify/Services/Remote/RemoteServerOptions.cs b/Source/Trackify/Services/Remote/RemoteServerOptions.cs
new file mode 100644
index 0000000..f066e9f
--- /dev/null
+++ b/Source/Trackify/Services/Remote/RemoteServerOptions.cs
@@ -0,0 +1,14 @@
+namespace Trackify.Services.Remote;
+
+///
+/// Where the app finds a Trackify backend (the CLI's trackify serve on a Pi). When a base URL
+/// is set the app runs in Server mode (remote transport); empty means Direct mode (the
+/// device's own Bluetooth). Persisted in app settings and shown behind the HMI's mode switch.
+///
+public sealed class RemoteServerOptions
+{
+ /// Base URL of the backend, e.g. http://192.168.1.50:5000. Empty = Direct (local BLE).
+ public string BaseUrl { get; set; } = "";
+
+ public bool Enabled => !string.IsNullOrWhiteSpace(BaseUrl);
+}
diff --git a/Source/Trackify/Services/Remote/RemoteTrainSync.cs b/Source/Trackify/Services/Remote/RemoteTrainSync.cs
new file mode 100644
index 0000000..370c49f
--- /dev/null
+++ b/Source/Trackify/Services/Remote/RemoteTrainSync.cs
@@ -0,0 +1,48 @@
+using Trackify.Application.Trains;
+
+namespace Trackify.Services.Remote;
+
+///
+/// Syncs the backend's trains into the local SQLite store while in Server mode. Each remote train is
+/// upserted, de-duplicated by hub identity (HubId → BleAddress → Name) so repeated syncs never
+/// pile up duplicates; a matched local row is updated in place (its id is kept) rather than re-added.
+///
+public sealed class RemoteTrainSync(ConnectionState state, ITrainRepository repository)
+{
+ /// Pulls all trains from the configured backend and upserts them locally; returns how many were written.
+ public async Task SyncAsync(CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(state.ServerUrl))
+ return 0;
+
+ var api = TrackifyApiFactory.Create(state.ServerUrl);
+ var remote = await api.GetTrainsAsync(ct);
+ var local = await repository.GetAllAsync(ct);
+ var written = 0;
+
+ foreach (var dto in remote)
+ {
+ var existing = local.FirstOrDefault(train => IsSameHub(train, dto));
+ var entity = dto.ToEntity();
+
+ if (existing is null)
+ {
+ await repository.AddAsync(entity, ct);
+ }
+ else
+ {
+ entity.Id = existing.Id; // keep the local row so any references stay stable
+ await repository.UpdateAsync(entity, ct);
+ }
+
+ written++;
+ }
+
+ return written;
+ }
+
+ private static bool IsSameHub(Train train, TrainDto dto)
+ => (!string.IsNullOrWhiteSpace(dto.HubId) && string.Equals(train.HubId, dto.HubId, StringComparison.OrdinalIgnoreCase))
+ || (!string.IsNullOrWhiteSpace(dto.BleAddress) && string.Equals(train.BleAddress, dto.BleAddress, StringComparison.OrdinalIgnoreCase))
+ || string.Equals(train.Name, dto.Name, StringComparison.OrdinalIgnoreCase);
+}
diff --git a/Source/Trackify/Services/Remote/SwitchingLegoService.cs b/Source/Trackify/Services/Remote/SwitchingLegoService.cs
new file mode 100644
index 0000000..3ebf143
--- /dev/null
+++ b/Source/Trackify/Services/Remote/SwitchingLegoService.cs
@@ -0,0 +1,51 @@
+namespace Trackify.Services.Remote;
+
+///
+/// Routes calls to the local (on-device Bluetooth) or the remote (backend)
+/// transport based on the live — so the HMI's mode switch takes effect
+/// immediately, without restarting the app. The remote transport is (re)created when the URL changes.
+///
+public sealed class SwitchingLegoService(ConnectionState state, ILegoService? local) : ILegoService
+{
+ private RemoteLegoService? _remote;
+ private string? _remoteUrl;
+
+ public bool IsSupported => state.UseServer || (local?.IsSupported ?? false);
+
+ public Task> DiscoverAsync(CancellationToken ct = default)
+ => Active().DiscoverAsync(ct);
+
+ public Task ConnectAsync(string hubId, HubType hubType, CancellationToken ct = default)
+ => Active().ConnectAsync(hubId, hubType, ct);
+
+ public Task DisconnectAsync(string hubId, CancellationToken ct = default)
+ => Active().DisconnectAsync(hubId, ct);
+
+ public Task SetSpeedAsync(string hubId, byte port, sbyte power, CancellationToken ct = default)
+ => Active().SetSpeedAsync(hubId, port, power, ct);
+
+ public Task SetLedAsync(string hubId, byte red, byte green, byte blue, CancellationToken ct = default)
+ => Active().SetLedAsync(hubId, red, green, blue, ct);
+
+ private ILegoService Active()
+ {
+ if (!state.UseServer)
+ return local ?? throw new InvalidOperationException(
+ "Direktmodus (Bluetooth) wird auf dieser Plattform nicht unterstützt — bitte Servermodus verwenden.");
+
+ var url = state.ServerUrl;
+ if (string.IsNullOrWhiteSpace(url))
+ throw new InvalidOperationException("Bitte zuerst eine Server-Adresse eingeben.");
+
+ if (_remote is null || _remoteUrl != url)
+ {
+ var previous = _remote;
+ _remote = RemoteLegoService.Create(url);
+ _remoteUrl = url;
+ if (previous is not null)
+ _ = previous.DisposeAsync(); // best-effort: drop the old connection in the background
+ }
+
+ return _remote;
+ }
+}
diff --git a/Source/Trackify/Services/Remote/TrackifyApiFactory.cs b/Source/Trackify/Services/Remote/TrackifyApiFactory.cs
new file mode 100644
index 0000000..ca2f2a4
--- /dev/null
+++ b/Source/Trackify/Services/Remote/TrackifyApiFactory.cs
@@ -0,0 +1,20 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Refit;
+
+namespace Trackify.Services.Remote;
+
+/// Builds a Refit client for a backend base URL, configured to match the server's JSON
+/// (enums as names). Used for both the remote transport and the train sync.
+internal static class TrackifyApiFactory
+{
+ public static ITrackifyApi Create(string baseUrl)
+ {
+ var settings = new RefitSettings(new SystemTextJsonContentSerializer(new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true,
+ Converters = { new JsonStringEnumConverter() },
+ }));
+ return RestService.For(baseUrl, settings);
+ }
+}
diff --git a/Source/Trackify/Trackify.csproj b/Source/Trackify/Trackify.csproj
index 07a6203..60f6762 100644
--- a/Source/Trackify/Trackify.csproj
+++ b/Source/Trackify/Trackify.csproj
@@ -49,6 +49,12 @@
+
+
+
+
+
+
diff --git a/Test/Trackify.Tests/Application/TrainSaveDiscoveredTests.cs b/Test/Trackify.Tests/Application/TrainSaveDiscoveredTests.cs
new file mode 100644
index 0000000..8a1f9b8
--- /dev/null
+++ b/Test/Trackify.Tests/Application/TrainSaveDiscoveredTests.cs
@@ -0,0 +1,62 @@
+using Trackify.Application.Lego;
+using Trackify.Application.Trains;
+using Trackify.Tests.Fakes;
+
+namespace Trackify.Tests.Application;
+
+/// Covers — the `discover --save` use-case.
+public class TrainSaveDiscoveredTests
+{
+ [Fact]
+ public async Task Saves_a_new_train_from_a_discovered_hub()
+ {
+ var repository = new FakeTrainRepository();
+ var service = new TrainService(repository);
+ var hub = new DiscoveredHubDto("AA:BB:CC:DD:EE:FF", "Blauer Zug", "AA:BB:CC:DD:EE:FF", HubType.PoweredUpHub);
+
+ var saved = await service.SaveDiscoveredAsync(hub);
+
+ Assert.Equal("Blauer Zug", saved.Name);
+ Assert.Equal("AA:BB:CC:DD:EE:FF", saved.HubId);
+ Assert.Equal(HubType.PoweredUpHub, saved.Hub);
+ Assert.Single(await service.GetAllAsync());
+ }
+
+ [Fact]
+ public async Task Does_not_duplicate_a_hub_already_saved_by_HubId()
+ {
+ var repository = new FakeTrainRepository(new Train { Name = "Existing", HubId = "AA:BB" });
+ var service = new TrainService(repository);
+ var hub = new DiscoveredHubDto("AA:BB", "Fresh Name", "AA:BB", HubType.PoweredUpHub);
+
+ var saved = await service.SaveDiscoveredAsync(hub);
+
+ Assert.Equal("Existing", saved.Name); // the existing train is returned, not a new duplicate
+ Assert.Single(await service.GetAllAsync());
+ }
+
+ [Fact]
+ public async Task Does_not_duplicate_a_hub_already_saved_by_MAC()
+ {
+ var repository = new FakeTrainRepository(new Train { Name = "Existing", BleAddress = "AA:BB:CC" });
+ var service = new TrainService(repository);
+ var hub = new DiscoveredHubDto("some-id", "Fresh Name", "AA:BB:CC", HubType.PoweredUpHub);
+
+ var saved = await service.SaveDiscoveredAsync(hub);
+
+ Assert.Equal("Existing", saved.Name);
+ Assert.Single(await service.GetAllAsync());
+ }
+
+ [Fact]
+ public async Task Falls_back_to_address_for_the_name_when_the_hub_is_unnamed()
+ {
+ var repository = new FakeTrainRepository();
+ var service = new TrainService(repository);
+ var hub = new DiscoveredHubDto("device-id", null, "AA:BB:CC", HubType.PoweredUpHub);
+
+ var saved = await service.SaveDiscoveredAsync(hub);
+
+ Assert.Equal("AA:BB:CC", saved.Name);
+ }
+}
diff --git a/Test/Trackify.Tests/Cli/TrainStateStoreTests.cs b/Test/Trackify.Tests/Cli/TrainStateStoreTests.cs
new file mode 100644
index 0000000..c933e52
--- /dev/null
+++ b/Test/Trackify.Tests/Cli/TrainStateStoreTests.cs
@@ -0,0 +1,26 @@
+using Trackify.Cli.Server;
+
+namespace Trackify.Tests.Cli;
+
+/// Covers the backend's per-hub speed tracking used for live SignalR state.
+public class TrainStateStoreTests
+{
+ [Fact]
+ public void Records_and_snapshots_the_latest_speed_per_hub()
+ {
+ var store = new TrainStateStore();
+ store.SetSpeed("hub-a", 40);
+ store.SetSpeed("hub-b", -20);
+ store.SetSpeed("hub-a", 55); // overwrites the earlier value
+
+ var snapshot = store.Snapshot();
+
+ Assert.Equal(2, snapshot.Count);
+ Assert.Equal(55, snapshot.Single(state => state.HubId == "hub-a").Speed);
+ Assert.Equal(-20, snapshot.Single(state => state.HubId == "hub-b").Speed);
+ }
+
+ [Fact]
+ public void Snapshot_is_empty_before_any_speed_is_set()
+ => Assert.Empty(new TrainStateStore().Snapshot());
+}