From 02caea941fecedbe6d0c8dc2f7096a9024c7bc54 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 14:14:59 +0200 Subject: [PATCH 01/10] Fix BlueZ hub discovery/connect on the Raspberry Pi The Pi (BlueZ) transport found and connected nothing because it did not do what the mobile transport does. Bring it to parity: - Power the radio on first: check the adapter's Powered property and call SetPoweredAsync(true); throw an actionable message (rfkill/bluetoothctl) when it cannot come up, instead of silently scanning nothing. - Scan on the LE transport (SetDiscoveryFilter Transport=le) so BLE-only hubs and their manufacturer data surface; BlueZ's default auto mode routinely misses them. - Also enumerate GetDevicesAsync() at scan start, since a fresh StartDiscovery never re-fires DeviceFound for already-cached devices. - Wait for ServicesResolved=true (not just Connected) before GATT lookups, which otherwise race and return null. - Cold connect: GetDeviceAsync does a brief LE scan when the hub is not in BlueZ's cache yet, so auto/connect work after a reboot with no discover. - Surface radio errors: DiscoverAsync awaits an EnsureReadyAsync preflight (Discover() is fire-and-forget and swallows exceptions) and DiscoverCommand prints the message cleanly. Add diagnostic log events. The preflight is why BlueZLegoService takes the concrete adapter, wired via a forwarding singleton so the SharpBrick host and the service share one radio instance. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 2 + .../Trackify.Cli/Commands/DiscoverCommand.cs | 22 ++- .../Ble/BlueZDevice.cs | 3 + .../Ble/BlueZLegoService.cs | 8 +- .../Ble/BlueZPoweredUpBluetoothAdapter.cs | 153 ++++++++++++++---- .../Ble/LinuxLegoServiceExtensions.cs | 5 +- Source/Trackify.Infrastructure/Log.cs | 9 ++ 7 files changed, 166 insertions(+), 36 deletions(-) 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/Source/Trackify.Cli/Commands/DiscoverCommand.cs b/Source/Trackify.Cli/Commands/DiscoverCommand.cs index 97fe6e2..11f2261 100644 --- a/Source/Trackify.Cli/Commands/DiscoverCommand.cs +++ b/Source/Trackify.Cli/Commands/DiscoverCommand.cs @@ -1,3 +1,4 @@ +using Trackify.Application.Lego; using Trackify.Cli.Commands.Settings; namespace Trackify.Cli.Commands; @@ -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) { 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); } From 4cc7e68e2568c3d4e9d90e1595797796d12a722d Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 14:15:12 +0200 Subject: [PATCH 02/10] Add headless auto-pilot mode to the CLI (trackify auto) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A long-running loop for unattended operation on the Pi (systemd/Docker). Every --interval seconds (default 60) it re-reads the saved trains from SQLite and applies each one's saved config — connect, set the hub LED, drive at its saved speed — reconnecting any hub that has dropped. On Ctrl+C / SIGINT it stops every motor and disconnects cleanly. - SetSpeed doubles as a health check: a dropped link throws, the train is disconnected, and the next sweep reconnects it fresh (self-healing). - The store is re-read each cycle, so new/edited trains are picked up live. - A failed sweep is caught and retried next interval so the daemon never dies; --all includes inactive trains. - Documented in ReadMe (EN + DE) incl. the systemd unit; added to the dashboard cheat-sheet and command help. Co-Authored-By: Claude Opus 4.8 --- Source/Trackify.Cli/Commands/AutoCommand.cs | 134 ++++++++++++++++++ .../Trackify.Cli/Commands/DashboardCommand.cs | 3 +- .../Commands/Settings/AutoSettings.cs | 15 ++ Source/Trackify.Cli/Program.cs | 1 + Source/Trackify.Cli/ReadMe.md | 41 +++++- 5 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 Source/Trackify.Cli/Commands/AutoCommand.cs create mode 100644 Source/Trackify.Cli/Commands/Settings/AutoSettings.cs 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/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/Program.cs b/Source/Trackify.Cli/Program.cs index 0308abc..a8c5231 100644 --- a/Source/Trackify.Cli/Program.cs +++ b/Source/Trackify.Cli/Program.cs @@ -45,6 +45,7 @@ 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"); }); // 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..5bba8d3 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: @@ -65,7 +82,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 +93,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 +135,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: From dba468360a2d6c719d7f4533c77a04ca38fe4ca7 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 14:33:49 +0200 Subject: [PATCH 03/10] Document BlueZ install on the Pi + add setup/diagnostics scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu Server images ship without BlueZ, so trackify could not reach bluetoothd over D-Bus (bluetoothctl/bluetooth.service missing) even though the kernel BT stack was fine. Add first-class setup + diagnostics: - scripts/setup-bluez.sh: idempotent installer — apt install bluez+rfkill, enable/start bluetoothd, rfkill unblock, power on the adapter, add the user to the bluetooth group. - scripts/pi-bt-info.sh: read-only diagnostics — radio/rfkill/service/ permissions/adapter state + a live LE scan that flags LEGO (0x0397) manufacturer data. - ReadMe (EN + DE): new "install BlueZ" prerequisites section referencing both scripts and the manual apt steps. Both scripts are marked executable. Co-Authored-By: Claude Opus 4.8 --- Source/Trackify.Cli/ReadMe.md | 67 +++++++++- Source/Trackify.Cli/scripts/pi-bt-info.sh | 141 +++++++++++++++++++++ Source/Trackify.Cli/scripts/setup-bluez.sh | 81 ++++++++++++ 3 files changed, 283 insertions(+), 6 deletions(-) create mode 100755 Source/Trackify.Cli/scripts/pi-bt-info.sh create mode 100755 Source/Trackify.Cli/scripts/setup-bluez.sh diff --git a/Source/Trackify.Cli/ReadMe.md b/Source/Trackify.Cli/ReadMe.md index 5bba8d3..dd35b5a 100644 --- a/Source/Trackify.Cli/ReadMe.md +++ b/Source/Trackify.Cli/ReadMe.md @@ -61,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 @@ -178,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/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 From 3160914001cc04fcc8f34e81a1e570dcc28f74ee Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 14:34:23 +0200 Subject: [PATCH 04/10] Force LF for *.sh via .gitattributes Guarantees the Pi setup/diagnostics scripts keep Unix line endings even when checked out or edited on Windows (CRLF breaks #!/usr/bin/env bash on Linux). Co-Authored-By: Claude Opus 4.8 --- .gitattributes | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitattributes 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 From a821b879c74fa6738543c457424ead879c1f2f80 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 15:19:10 +0200 Subject: [PATCH 05/10] Add `trackify server` backend (REST + SignalR) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hosts an ASP.NET Core backend over the same Domain/Application/Infrastructure use-cases the CLI runs, so the Uno app can drive a Pi remotely: - REST (one-shot): GET /api/trains, POST /api/discover, connect/disconnect by hubId, GET /api/state — routes shared via Application/Remote/ApiRoutes. - SignalR hub /hubs/trains: real-time SetSpeed/SetLed/Stop + a SpeedChanged broadcast; method names shared via TrainHubMethods. Keyed by hubId so it maps 1:1 onto ILegoService (the app's existing control seam). - Composed with an AddTrackifyServer() DI extension; handlers resolve their use-cases from DI. `server` is a normal Spectre command (--urls override). - Config is externalised to appsettings.json (Serilog levels/sinks, Urls, DiscoverTimeoutSeconds) via Serilog.Settings.Configuration — nothing hardcoded. - Global exception handling: Spectre SetExceptionHandler (CLI) + UseExceptionHandler middleware (serve), both logging; new Info/Error log events. - ASP.NET arrives via a Microsoft.AspNetCore.App FrameworkReference (dropping the now-redundant M.E.DI/Logging package refs that tripped NU1510). Co-Authored-By: Claude Opus 4.8 --- Directory.Packages.props | 7 ++ .../Trackify.Application/Remote/ApiRoutes.cs | 22 +++++ .../Remote/TrainHubMethods.cs | 17 ++++ .../Remote/TrainSpeedState.cs | 4 + Source/Trackify.Cli/Commands/ServerCommand.cs | 18 ++++ .../Commands/Settings/ServerSettings.cs | 11 +++ Source/Trackify.Cli/Log.cs | 6 ++ Source/Trackify.Cli/Program.cs | 31 ++++-- .../ServerServiceCollectionExtensions.cs | 27 ++++++ Source/Trackify.Cli/Server/TrackifyServer.cs | 97 +++++++++++++++++++ Source/Trackify.Cli/Server/TrainHub.cs | 26 +++++ Source/Trackify.Cli/Server/TrainStateStore.cs | 18 ++++ Source/Trackify.Cli/Trackify.Cli.csproj | 16 ++- Source/Trackify.Cli/appsettings.json | 25 +++++ 14 files changed, 315 insertions(+), 10 deletions(-) create mode 100644 Source/Trackify.Application/Remote/ApiRoutes.cs create mode 100644 Source/Trackify.Application/Remote/TrainHubMethods.cs create mode 100644 Source/Trackify.Application/Remote/TrainSpeedState.cs create mode 100644 Source/Trackify.Cli/Commands/ServerCommand.cs create mode 100644 Source/Trackify.Cli/Commands/Settings/ServerSettings.cs create mode 100644 Source/Trackify.Cli/Server/ServerServiceCollectionExtensions.cs create mode 100644 Source/Trackify.Cli/Server/TrackifyServer.cs create mode 100644 Source/Trackify.Cli/Server/TrainHub.cs create mode 100644 Source/Trackify.Cli/Server/TrainStateStore.cs create mode 100644 Source/Trackify.Cli/appsettings.json 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.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/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 a8c5231..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,6 +43,16 @@ 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\""); @@ -46,6 +60,7 @@ 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/Server/ServerServiceCollectionExtensions.cs b/Source/Trackify.Cli/Server/ServerServiceCollectionExtensions.cs new file mode 100644 index 0000000..c2c43ea --- /dev/null +++ b/Source/Trackify.Cli/Server/ServerServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +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())); + + // Open CORS so the app (incl. the WASM head, which sends an Origin) can call REST + the hub. + services.AddCors(o => o.AddDefaultPolicy(p => + p.SetIsOriginAllowed(_ => true).AllowAnyHeader().AllowAnyMethod().AllowCredentials())); + + return services; + } +} diff --git a/Source/Trackify.Cli/Server/TrackifyServer.cs b/Source/Trackify.Cli/Server/TrackifyServer.cs new file mode 100644 index 0000000..5fb2d30 --- /dev/null +++ b/Source/Trackify.Cli/Server/TrackifyServer.cs @@ -0,0 +1,97 @@ +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"); + logger.LogError(error, "Unhandled request exception on {Method} {Path}", context.Request.Method, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + await context.Response.WriteAsJsonAsync(new { error = error?.Message ?? "Unexpected 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()); + } +} 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 + } + } +} From dcd5863df7b674c9c08bb9c65a1e4f4d4aafbfd6 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 15:19:23 +0200 Subject: [PATCH 06/10] Add HMI Server mode: remote transport + train sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app gains an optional Server mode alongside its own Bluetooth (Direct mode); both are interchangeable ILegoService implementations, so the UI is unchanged: - RemoteLegoService : ILegoService — one-shot actions over REST (Refit ITrackifyApi) and real-time speed/LED over SignalR; raises SpeedChanged so the UI can show live speed. - RemoteTrainSync — pulls the backend's trains into the local SQLite store, de-duplicated by hub identity (HubId → BleAddress → Name), upserting in place. - AddTrackifyRemote(baseUrl) wires the Refit client + SignalR transport; when AppConfig.ServerUrl is set the app registers it (wins as ILegoService) and enables the sync — otherwise it stays in Direct mode (unchanged behaviour). - Global exception handling (ASP.NET-style): App hooks UnhandledException, AppDomain.UnhandledException and TaskScheduler.UnobservedTaskException, all logged via Serilog — no silent swallowing. The UI mode switch + IP entry is the remaining piece (wired via config for now). Co-Authored-By: Claude Opus 4.8 --- Source/Trackify/App.xaml.cs | 27 ++++++++ Source/Trackify/Models/AppConfig.cs | 6 ++ .../Trackify/Services/Remote/ITrackifyApi.cs | 27 ++++++++ .../Services/Remote/RemoteLegoService.cs | 63 +++++++++++++++++++ .../Remote/RemoteLegoServiceExtensions.cs | 29 +++++++++ .../Services/Remote/RemoteServerOptions.cs | 14 +++++ .../Services/Remote/RemoteTrainSync.cs | 44 +++++++++++++ Source/Trackify/Trackify.csproj | 6 ++ 8 files changed, 216 insertions(+) create mode 100644 Source/Trackify/Services/Remote/ITrackifyApi.cs create mode 100644 Source/Trackify/Services/Remote/RemoteLegoService.cs create mode 100644 Source/Trackify/Services/Remote/RemoteLegoServiceExtensions.cs create mode 100644 Source/Trackify/Services/Remote/RemoteServerOptions.cs create mode 100644 Source/Trackify/Services/Remote/RemoteTrainSync.cs diff --git a/Source/Trackify/App.xaml.cs b/Source/Trackify/App.xaml.cs index 05bf613..f9ae917 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; } @@ -44,6 +61,16 @@ protected async override void OnLaunched(LaunchActivatedEventArgs args) services.AddTrackifyDomain(); services.AddTrackifyApplication(); services.AddTrackifyInfrastructure(); + + // Server mode: with a backend URL configured, use the remote transport (REST + + // SignalR to a Pi) instead of the device's own Bluetooth — it wins as ILegoService + // because it's registered last — and enable syncing its trains into the local store. + var serverUrl = context.Configuration["AppConfig:ServerUrl"]; + if (!string.IsNullOrWhiteSpace(serverUrl)) + { + services.AddTrackifyRemote(serverUrl); + services.AddSingleton(); + } }) .UseNavigation(RegisterRoutes) ); 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/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..fd4d64a --- /dev/null +++ b/Source/Trackify/Services/Remote/RemoteLegoService.cs @@ -0,0 +1,63 @@ +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)); + } + + 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/RemoteLegoServiceExtensions.cs b/Source/Trackify/Services/Remote/RemoteLegoServiceExtensions.cs new file mode 100644 index 0000000..1a754d1 --- /dev/null +++ b/Source/Trackify/Services/Remote/RemoteLegoServiceExtensions.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Refit; + +namespace Trackify.Services.Remote; + +/// +/// Wires the remote (Server-mode) transport: a Refit REST client plus the SignalR-backed +/// , registered as the app's . Call this +/// instead of the local per-head transport when the user has entered a backend URL. +/// +public static class RemoteLegoServiceExtensions +{ + public static IServiceCollection AddTrackifyRemote(this IServiceCollection services, string baseUrl) + { + services.AddSingleton(new RemoteServerOptions { BaseUrl = baseUrl }); + + // The backend serialises enums as names (matching the store); mirror that here. + var settings = new RefitSettings(new SystemTextJsonContentSerializer(new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + })); + services.AddSingleton(RestService.For(baseUrl, settings)); + + services.AddSingleton(); + return services; + } +} 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..ee6d976 --- /dev/null +++ b/Source/Trackify/Services/Remote/RemoteTrainSync.cs @@ -0,0 +1,44 @@ +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(ITrackifyApi api, ITrainRepository repository) +{ + /// Pulls all trains from the backend and upserts them locally; returns how many were written. + public async Task SyncAsync(CancellationToken ct = default) + { + 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/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 @@ + + + + + + From 4b6f3a76d3e99011981af3629fa2e35644d02a81 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 15:19:36 +0200 Subject: [PATCH 07/10] Add `discover --save` to persist found hubs as trains `trackify discover --save` stores each discovered hub in the train list, de-duplicated by hub identity (HubId then MAC). Saving goes through a new Application use-case (ITrainService.SaveDiscoveredAsync) so the CLI stays DTO-only (the entity boundary stays behind Application). Per-hub failures are reported, never swallowed. Co-Authored-By: Claude Opus 4.8 --- .../Trains/ITrainService.cs | 6 ++++ .../Trains/TrainService.cs | 24 +++++++++++++++ .../Trackify.Cli/Commands/DiscoverCommand.cs | 29 +++++++++++++++++-- .../Commands/Settings/DiscoverSettings.cs | 4 +++ 4 files changed, 61 insertions(+), 2 deletions(-) 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/DiscoverCommand.cs b/Source/Trackify.Cli/Commands/DiscoverCommand.cs index 11f2261..03a6dda 100644 --- a/Source/Trackify.Cli/Commands/DiscoverCommand.cs +++ b/Source/Trackify.Cli/Commands/DiscoverCommand.cs @@ -3,8 +3,8 @@ 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) { @@ -43,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/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; } } From eaae1cffbef2099ddfcb7c8f47803b02baddc2a9 Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 15:19:37 +0200 Subject: [PATCH 08/10] Add tests for discover --save and the backend speed store - TrainSaveDiscoveredTests: new-train creation, dedup by HubId and by MAC, and the unnamed-hub name fallback (Application, via FakeTrainRepository). - TrainStateStoreTests: latest-speed-per-hub tracking + empty snapshot (CLI). Co-Authored-By: Claude Opus 4.8 --- .../Application/TrainSaveDiscoveredTests.cs | 62 +++++++++++++++++++ .../Cli/TrainStateStoreTests.cs | 26 ++++++++ 2 files changed, 88 insertions(+) create mode 100644 Test/Trackify.Tests/Application/TrainSaveDiscoveredTests.cs create mode 100644 Test/Trackify.Tests/Cli/TrainStateStoreTests.cs 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()); +} From 0d58833b2096aad86d0460de8db4986a9dc58cad Mon Sep 17 00:00:00 2001 From: kevin Date: Sat, 25 Jul 2026 15:32:57 +0200 Subject: [PATCH 09/10] Add HMI Connection panel with a live Direct/Server switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainPage gains a Connection panel (header toggle → modal overlay): a Direct/Server switch + backend URL field + Apply, bound to a persisted ConnectionState. Switching is live (no restart): - SwitchingLegoService routes each ILegoService call to the local Bluetooth transport or a remote one, chosen per-call from ConnectionState; the remote transport is (re)created when the URL changes. Registered by wrapping the platform transport (ResolveLocal) so the two don't compete for resolution. - ConnectionState persists mode + URL in local app settings (seeded from AppConfig.ServerUrl); TrackifyApiFactory builds the Refit client per URL. - Apply persists the choice and, in Server mode, runs RemoteTrainSync (which now reads the URL from ConnectionState) to pull trains into local SQLite. - Quieted framework INFO noise (Uno appsettings probes, Microsoft host) so the log overview stays clean; new Info/Warning log events for mode/sync/settings. - Replaces the earlier config-only remote wiring (AddTrackifyRemote removed). Co-Authored-By: Claude Opus 4.8 --- Source/Trackify/App.xaml.cs | 36 +++++++++---- Source/Trackify/Log.cs | 15 ++++++ .../Trackify/Presentation/Pages/MainPage.xaml | 38 ++++++++++++-- .../Presentation/ViewModels/MainViewModel.cs | 50 +++++++++++++++++- .../Services/Remote/ConnectionState.cs | 52 +++++++++++++++++++ .../Services/Remote/RemoteLegoService.cs | 4 ++ .../Remote/RemoteLegoServiceExtensions.cs | 29 ----------- .../Services/Remote/RemoteTrainSync.cs | 8 ++- .../Services/Remote/SwitchingLegoService.cs | 51 ++++++++++++++++++ .../Services/Remote/TrackifyApiFactory.cs | 20 +++++++ 10 files changed, 258 insertions(+), 45 deletions(-) create mode 100644 Source/Trackify/Services/Remote/ConnectionState.cs delete mode 100644 Source/Trackify/Services/Remote/RemoteLegoServiceExtensions.cs create mode 100644 Source/Trackify/Services/Remote/SwitchingLegoService.cs create mode 100644 Source/Trackify/Services/Remote/TrackifyApiFactory.cs diff --git a/Source/Trackify/App.xaml.cs b/Source/Trackify/App.xaml.cs index f9ae917..232d2b8 100644 --- a/Source/Trackify/App.xaml.cs +++ b/Source/Trackify/App.xaml.cs @@ -59,18 +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(); - // Server mode: with a backend URL configured, use the remote transport (REST + - // SignalR to a Pi) instead of the device's own Bluetooth — it wins as ILegoService - // because it's registered last — and enable syncing its trains into the local store. - var serverUrl = context.Configuration["AppConfig:ServerUrl"]; - if (!string.IsNullOrWhiteSpace(serverUrl)) - { - services.AddTrackifyRemote(serverUrl); - services.AddSingleton(); - } + // 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) ); @@ -83,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/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 @@ + + + + + + + + + + + +