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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@
from net10, so it works across the whole net8–net10 range the libs multi-target. -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />

<!-- Remote backend client (app's Server mode): Refit for the REST one-shot actions, the SignalR
.NET client for real-time speed/LED. The server side is the CLI's Microsoft.AspNetCore.App. -->
<PackageVersion Include="Refit" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />

<!-- High-performance logging (source-generated LoggerMessage) with a Serilog backend. -->
<PackageVersion Include="Serilog" Version="4.2.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="9.0.0" />
<!-- Read Serilog levels/sinks from appsettings.json (CLI + serve) instead of hardcoding them. -->
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />

<!-- Test project. -->
Expand Down
22 changes: 22 additions & 0 deletions Source/Trackify.Application/Remote/ApiRoutes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Trackify.Application.Remote;

/// <summary>
/// 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 <b>one-shot</b> actions (Refit); real-time speed/LED go over
/// the SignalR hub — see <see cref="TrainHubMethods"/>. Control routes are keyed by <c>hubId</c> so
/// they map 1:1 onto <c>ILegoService</c> (the app's existing control seam).
/// </summary>
public static class ApiRoutes
{
/// <summary>All saved trains (for the app to sync into its local store).</summary>
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";

/// <summary>Last speed applied to each hub, so a freshly-connected client can show current state.</summary>
public const string State = "/api/state";

/// <summary>The SignalR hub endpoint for real-time train control.</summary>
public const string TrainHub = "/hubs/trains";
}
17 changes: 17 additions & 0 deletions Source/Trackify.Application/Remote/TrainHubMethods.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
namespace Trackify.Application.Remote;

/// <summary>
/// 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 <c>hubId</c>, matching ILegoService.
/// </summary>
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);
}
4 changes: 4 additions & 0 deletions Source/Trackify.Application/Remote/TrainSpeedState.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Trackify.Application.Remote;

/// <summary>The last speed applied to a hub, so a freshly-connected client can show current state.</summary>
public sealed record TrainSpeedState(string HubId, int Speed);
6 changes: 6 additions & 0 deletions Source/Trackify.Application/Trains/ITrainService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,10 @@ public interface ITrainService

/// <summary>Finds a saved train by its id or (case-insensitive) name; null if none matches.</summary>
Task<TrainDto?> FindAsync(string nameOrId, CancellationToken cancellationToken = default);

/// <summary>
/// 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.
/// </summary>
Task<TrainDto> SaveDiscoveredAsync(DiscoveredHubDto hub, CancellationToken cancellationToken = default);
}
24 changes: 24 additions & 0 deletions Source/Trackify.Application/Trains/TrainService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,28 @@ public async Task<IReadOnlyList<TrainDto>> GetAllAsync(CancellationToken cancell
string.Equals(train.Name, nameOrId, StringComparison.OrdinalIgnoreCase));
return match?.ToDto();
}

/// <summary>
/// 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.
/// </summary>
public async Task<TrainDto> 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();
}
}
134 changes: 134 additions & 0 deletions Source/Trackify.Cli/Commands/AutoCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using Trackify.Cli.Commands.Settings;

namespace Trackify.Cli.Commands;

/// <summary>
/// Auto-pilot: a long-running loop for unattended operation (systemd / Docker, no typing). Every
/// <c>--interval</c> 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.
/// </summary>
public sealed class AutoCommand(ITrainControlService control, ITrainService query) : AsyncCommand<AutoSettings>
{
protected override async Task<int> 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<Guid, TrainDto>();

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<Guid, TrainDto> 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);
}

/// <summary>Connects (if needed), re-asserts colour + speed, and reports the outcome for one train.</summary>
private async Task<string> ApplyAsync(TrainDto train, Dictionary<Guid, TrainDto> 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))}[/]";
}
}

/// <summary>Stops every motor and disconnects on shutdown — deliberately without the cancelled token.</summary>
private async Task ShutdownAsync(Dictionary<Guid, TrainDto> 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] + "…";
}
3 changes: 2 additions & 1 deletion Source/Trackify.Cli/Commands/DashboardCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Cancella
"[springgreen2]drive[/] <train> run a train until Ctrl+C",
"[springgreen2]stop[/] <train> stop a train's motor",
"[springgreen2]color[/] <train> <c> set the hub LED colour",
"[springgreen2]connect[/] <train> reachability test")))
"[springgreen2]connect[/] <train> reachability test",
"[springgreen2]auto[/] auto-pilot every saved train (loop)")))
.Header("[springgreen2]Commands[/]")
.BorderColor(Color.Grey37));

Expand Down
51 changes: 45 additions & 6 deletions Source/Trackify.Cli/Commands/DiscoverCommand.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
using Trackify.Application.Lego;
using Trackify.Cli.Commands.Settings;

namespace Trackify.Cli.Commands;

/// <summary>Scans for nearby hubs over Bluetooth and prints what turns up.</summary>
public sealed class DiscoverCommand(ITrainControlService control) : AsyncCommand<DiscoverSettings>
/// <summary>Scans for nearby hubs over Bluetooth, prints what turns up, and optionally saves them.</summary>
public sealed class DiscoverCommand(ITrainControlService control, ITrainService trains) : AsyncCommand<DiscoverSettings>
{
protected override async Task<int> ExecuteAsync(CommandContext context, DiscoverSettings settings, CancellationToken cancellationToken)
{
Expand All @@ -17,10 +18,23 @@ protected override async Task<int> 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<DiscoveredHubDto> 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)
{
Expand All @@ -29,6 +43,31 @@ protected override async Task<int> ExecuteAsync(CommandContext context, Discover
}

AnsiConsole.Write(Ui.HubsTable(hubs));

if (settings.Save)
await SaveHubsAsync(hubs, cancellationToken);

return 0;
}

private async Task SaveHubsAsync(IReadOnlyList<DiscoveredHubDto> 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.[/]");
}
}
18 changes: 18 additions & 0 deletions Source/Trackify.Cli/Commands/ServerCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using Trackify.Cli.Commands.Settings;
using Trackify.Cli.Server;

namespace Trackify.Cli.Commands;

/// <summary>
/// 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.
/// </summary>
public sealed class ServerCommand : AsyncCommand<ServerSettings>
{
protected override Task<int> 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<string>();
return TrackifyServer.RunAsync(hostArgs, storePath, cancellationToken);
}
}
15 changes: 15 additions & 0 deletions Source/Trackify.Cli/Commands/Settings/AutoSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.ComponentModel;

namespace Trackify.Cli.Commands.Settings;

/// <summary>Options for the auto (auto-pilot) command.</summary>
public sealed class AutoSettings : CommandSettings
{
[CommandOption("-i|--interval <SECONDS>")]
[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; }
}
4 changes: 4 additions & 0 deletions Source/Trackify.Cli/Commands/Settings/DiscoverSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ public sealed class DiscoverSettings : CommandSettings
[CommandOption("-t|--timeout <SECONDS>")]
[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; }
}
11 changes: 11 additions & 0 deletions Source/Trackify.Cli/Commands/Settings/ServerSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.ComponentModel;

namespace Trackify.Cli.Commands.Settings;

/// <summary>Options for the server command.</summary>
public sealed class ServerSettings : CommandSettings
{
[CommandOption("--urls <URLS>")]
[Description("Bind address(es), e.g. http://0.0.0.0:5000. Overrides the appsettings 'Urls' value.")]
public string? Urls { get; init; }
}
6 changes: 6 additions & 0 deletions Source/Trackify.Cli/Log.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading
Loading