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