From bf36bdf8a607b83bbe9c2bdc3e41cfaa12492d88 Mon Sep 17 00:00:00 2001 From: gx1400 Date: Tue, 20 Oct 2026 23:59:45 -0500 Subject: [PATCH 1/3] feat(gps): a GPS source over serial, TCP or gpsd, and the station where it says MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GPS is a source kind: NMEA sentences on a serial port or over TCP, read by the app and put together by APRS.NET's NMEA tracker, or gpsd through AprsNet.Gps. Its pill says the fix (3D fix · 9 satellites, or no fix), its NMEA lines go to the Source console, and the status carries the fix. While the fix is under a minute old the station is where the GPS says: beacons and Send position now carry its position with course, speed and altitude, the APRS-IS range follows it after moving a quarter of the range, and the map's house moves with it; without a fresh fix the profile's location is used, and it's never overwritten. A profile uses one GPS. Closes #205 --- Directory.Packages.props | 1 + docs/features/F3.6-gps.md | 109 +++++ docs/scope.md | 2 +- src/AprsMap.Contracts/EngineErrors.cs | 9 + src/AprsMap.Contracts/EngineStatus.cs | 47 +- src/AprsMap.Contracts/SourceSettings.cs | 66 ++- src/AprsMap.Contracts/Traffic.cs | 3 + src/AprsMap.Core/AprsIsPacketSource.cs | 71 +++ src/AprsMap.Core/AprsMap.Core.csproj | 1 + src/AprsMap.Core/Beaconer.cs | 28 +- src/AprsMap.Core/Engine.Gps.cs | 137 ++++++ src/AprsMap.Core/Engine.cs | 35 +- src/AprsMap.Core/GpsSources.cs | 297 ++++++++++++ src/AprsMap.Core/SettingsValidator.cs | 25 + src/AprsMap.UI/Localization/Strings.resx | 53 +++ .../Settings/SourcesSectionViewModel.cs | 133 +++++- src/AprsMap.UI/ViewModels/MainViewModel.cs | 31 +- src/AprsMap.UI/ViewModels/SourcePill.cs | 43 +- src/AprsMap.UI/Views/SettingsView.axaml | 41 +- tests/AprsMap.Core.Tests/EngineTests.cs | 29 +- tests/AprsMap.Core.Tests/GpsTests.cs | 437 ++++++++++++++++++ tests/AprsMap.UI.Tests/GpsUiTests.cs | 136 ++++++ tests/AprsMap.UI.Tests/SourcesSectionTests.cs | 4 +- 23 files changed, 1698 insertions(+), 40 deletions(-) create mode 100644 docs/features/F3.6-gps.md create mode 100644 src/AprsMap.Core/Engine.Gps.cs create mode 100644 src/AprsMap.Core/GpsSources.cs create mode 100644 tests/AprsMap.Core.Tests/GpsTests.cs create mode 100644 tests/AprsMap.UI.Tests/GpsUiTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 0c110ec2..9b79b8f9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/docs/features/F3.6-gps.md b/docs/features/F3.6-gps.md new file mode 100644 index 00000000..379dcc24 --- /dev/null +++ b/docs/features/F3.6-gps.md @@ -0,0 +1,109 @@ +# F3.6 GPS + +Status: built for 0.6.0 ([#205](https://github.com/APRS-NET/AprsMap/issues/205)) except the OS location API, browser +geolocation and a tracked position (decision 6); decisions proposed 2026-10-21 for the maintainer's review. In the UI: +Settings › Sources › GPS; its pill in the status bar. + +## User story + +As a mobile operator, I plug in a GPS (or point the app at gpsd, or at a GPS shared over the network), and my station +is where the GPS says: my beacons carry my position, course and speed, my APRS-IS range follows me, and the map's +house is where I am. When the GPS loses its fix, the app says so and falls back to my profile's fixed location. + +## Background: what already exists + +- Sources (F1.25): a master list of sources, picked per profile; each runs, reconnects with backoff and has a pill in + the status bar. F1.25 behavior 12 sketched a GPS source with **Position for**. +- APRS.NET 0.5.0-alpha.1: `NmeaSentence` and `NmeaFixTracker` (G17) turn NMEA lines into a `GpsFix` (position, time, + speed, course, altitude, satellites, HDOP, 2D or 3D fix); `AprsNet.Gps`'s `GpsdConnection` (G30) reads gpsd and gives + the same `GpsFix`, reconnecting by itself. Reading a serial port or a socket stays in the app (G17). +- The Source console (F1.28) shows each source's traffic. + +## Behavior + +### Adding a GPS + +1. **GPS** is a source kind in Settings › Sources, with three ways to reach it (proposed): + 1. **NMEA over serial**: a port and its speed, **4800** baud by default (the NMEA 0183 rate; many USB GPSs use + 9600, offered beside it); + 2. **NMEA over TCP**: a host and port, **10110** by default (the port commonly used for NMEA over a network); + 3. **gpsd**: a host and port, **2947** by default, on this machine or another. +2. A profile uses at most **one GPS** (proposed); a second is refused when saving, saying why. +3. A GPS never sends; it's RX only by nature (F2.1), so no Transmit choices show for it. + +### Its state + +4. Its pill in the status bar says how it's doing: connecting or reconnecting as other sources do, then the fix: + **No fix**, **2D fix** or **3D fix**, with the satellites used where known ("GPS · 3D fix · 9 satellites"). +5. A fix is **fresh** for **60 seconds** (proposed). Without a fresh fix the pill says **GPS: no fix, using the + profile's location** when the profile has one. +6. NMEA lines read from serial or TCP go to the Source console (F1.28), each line once, so they can be watched and + saved as text. gpsd's reports aren't shown there (proposed: later, with F3.12). + +### Where the station is + +7. **Position for my station** (F1.25 behavior 12): while the profile's GPS has a fresh fix, the station is where the + GPS says, in everything that uses the station's location (F1.13): + 1. **Beacons and Send position now** (F2.2, F2.1) send the GPS position, with **course and speed**, and the + altitude with a 3D fix; + 2. **The APRS-IS filter's range** (F1.2) is around the GPS position, re-sent when the station has moved more than + a quarter of the range since the last one (proposed), at most once per filter interval as now; + 3. **The map's house and Home** (F1.13, F1.4) are at the GPS position; distances and bearings from me follow. +8. Without a fresh fix, all of these use the profile's fixed location, as without a GPS. The fixed location isn't + changed by the GPS (proposed): it's the profile's setting, kept for when there's no fix. +9. The position confirmation (F2.2 behavior 12) is unchanged: beacons wait for a position sent by hand once. + +### Later + +10. **The OS location API** (desktop) and **browser geolocation** sent to the gateway (proposed: a follow-up issue), + and **a tracked position** (F1.25 behavior 12's second option: a GPS somewhere else shown as a named marker, + beaconed as an object). The **Moving** beacon schedule, from the GPS's speed and course, is F2.2's revision + ([#335](https://github.com/APRS-NET/AprsMap/issues/335)); the sky view and accuracy are F3.12 + ([#331](https://github.com/APRS-NET/AprsMap/issues/331)). + +## Service surface + +- `GpsLink` (`Serial`, `Tcp`, `Gpsd`) and `SourceSettings.GpsLink`; a serial GPS uses `SerialPort` and `Baud`, a TCP + GPS or gpsd `Host` and `Port`. `SourceSettings.GpsFor…` makes each. +- `EngineStatus.Gps` (`GpsStatus`): the GPS source, its fix kind, position, altitude, speed, course, satellites, HDOP, + when the fix was taken, and whether it's fresh. +- Error codes: `settings.source.gps-invalid` (no port or host), `settings.profile.two-gps`. + +## APRS.NET dependencies + +G17 (NMEA parsing, delivered in 0.4.0-alpha.12) and G30 (gpsd client, `AprsNet.Gps`, delivered in 0.4.0-alpha.12). + +## Desktop vs web differences + +| | Desktop | Web (gateway) | +| --- | --- | --- | +| Where a serial GPS plugs in | This computer | The gateway's host (a container needs the device passed through) | +| Seeing the fix | The user | Everyone; changing the source is the operator's | + +## Acceptance tests + +1. NMEA lines (RMC, GGA, GSA) over TCP give a 3D fix with position, speed, course, altitude and satellites in the + status; the pill says "3D fix · 9 satellites"; the lines are in the console. +2. A fix older than 60 s isn't fresh, and the station falls back to the profile's location. +3. With a fresh fix, a beacon goes out at the GPS position with course and speed; without one, at the profile's. +4. Moving the GPS more than a quarter of the range re-sends the filter around the new position; less doesn't. +5. gpsd's fixes do the same. +6. A profile with two GPS sources, or a GPS without its port or host, is refused. +7. Settings › Sources offers GPS with its three links, and shows the right fields for each. + +## Decisions for review + +All proposed (2026-10-21), built as described so they can be tried: + +1. **Three links**: NMEA over serial (4800 baud), NMEA over TCP (port 10110), gpsd (port 2947). +2. **One GPS per profile.** +3. **A fix is fresh for 60 s**, then the profile's location is used. +4. **The GPS doesn't overwrite the profile's location**; it's used while fresh. +5. **The filter follows the GPS** after moving a quarter of the range. +6. **Later**: the OS location API, browser geolocation and a tracked position; gpsd's raw reports in the console. + +## Sources + +- NMEA 0183 (4800 baud), gpsd's documentation (port 2947, JSON reports), the conventional NMEA-over-TCP port 10110. +- [F1.25](F1.25-sources.md) behavior 12, [F1.13](F1.13-my-location.md), [F1.2](F1.2-automatic-filter.md), + [F2.2](F2.2-beacons.md), [F1.28](F1.28-source-console.md). APRS.NET's `AprsNet.Gps` README. The design is original. diff --git a/docs/scope.md b/docs/scope.md index 3de7ce59..723c10f5 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -137,7 +137,7 @@ milestone ([0.3.0-alpha.1](https://github.com/APRS-NET/AprsMap/milestone/1), don | F3.3 | Per-port RF beacon paths, RF versus IS source attribution on the map, heard-direct indication ([spec](features/F3.3-rf-paths.md)) | — | App | G20 | built (0.5.0, [#210](https://github.com/APRS-NET/AprsMap/issues/210)) | | F3.4 | IGate: RF→IS, IS→RF for messages to local stations, objects; configurable rules | (core feature list), Receive-Only IGates Break Messaging | App | G16 | scoped (receive only in 0.6.0, [#206](https://github.com/APRS-NET/AprsMap/issues/206); two-way in 0.7.0, [#208](https://github.com/APRS-NET/AprsMap/issues/208)) | | F3.5 | Digipeater: WIDEn-N, fill-in, own-call digipeat | Digipeating | App | G15 | scoped (0.7.0, [#209](https://github.com/APRS-NET/AprsMap/issues/209)) | -| F3.6 | GPS: NMEA from serial or TCP, gpsd, the OS location API on desktop, browser geolocation sent to the gateway; as [F1.25](features/F1.25-sources.md) sources with **Position for** my station or a tracked position. Planned with beaconing (F2.2) | Android GPS | App | G17, G30 | scoped (0.6.0, [#205](https://github.com/APRS-NET/AprsMap/issues/205)) | +| F3.6 | GPS ([spec](features/F3.6-gps.md)): NMEA over serial or TCP, and gpsd, as [F1.25](features/F1.25-sources.md) sources; while the fix is fresh the station is where the GPS says (beacons with course and speed, the filter's range, the map's house); the OS location API, browser geolocation and a tracked position later | Android GPS | App | G17, G30 | built (0.6.0, [#205](https://github.com/APRS-NET/AprsMap/issues/205)) except the OS and browser location; decisions proposed | | F3.7 | Frequency objects and radio tune format in comments | Frequencies, Radio Tune Format | App | G01 | scoped | | F3.8 | AGWPE over TCP as an RF port | Port Configuration | App | G22 | scoped | | F3.9 | CWOP weather feed port (unfiltered; filtered locally) | CWOP | App | G21 | scoped | diff --git a/src/AprsMap.Contracts/EngineErrors.cs b/src/AprsMap.Contracts/EngineErrors.cs index 287502ca..ab3e11b2 100644 --- a/src/AprsMap.Contracts/EngineErrors.cs +++ b/src/AprsMap.Contracts/EngineErrors.cs @@ -39,6 +39,12 @@ public static class EngineErrorCodes /// A profile uses more than one APRS-IS source (F1.25 behavior 3). public const string ProfileTwoAprsIs = "settings.profile.two-aprs-is"; + /// A profile uses more than one GPS (docs/features/F3.6-gps.md, behavior 2). + public const string ProfileTwoGps = "settings.profile.two-gps"; + + /// A GPS without its link, serial port or host (F3.6 behavior 1). + public const string GpsSourceInvalid = "settings.source.gps-invalid"; + /// Settings were sent without profiles or units. public const string SettingsIncomplete = "settings.incomplete"; @@ -200,6 +206,9 @@ public static class EngineErrorCodes /// The link to a KISS TNC failed, or it went silent and was given up as gone. public const string KissLinkFailed = "kiss.link-failed"; + /// A GPS couldn't be opened or reached, or went away (docs/features/F3.6-gps.md); reason says why. + public const string GpsLinkFailed = "gps.link-failed"; + /// /// A KISS TNC is connected but sends text lines, not KISS frames: it isn't in KISS mode. A warning, shown while the /// source stays connected. diff --git a/src/AprsMap.Contracts/EngineStatus.cs b/src/AprsMap.Contracts/EngineStatus.cs index ac188ac9..9af2ef16 100644 --- a/src/AprsMap.Contracts/EngineStatus.cs +++ b/src/AprsMap.Contracts/EngineStatus.cs @@ -64,6 +64,7 @@ public enum ConnectionState /// How many stations, objects and items the store holds, for the View filter's chip (F1.9). /// /// The gateway's page title (Gateway:Title, F1.26 behavior 10); null on the desktop. +/// The active profile's GPS and its fix (docs/features/F3.6-gps.md); null without one. /// Counts up each time the held packets are cleared (F1.19 behavior 3), so every viewer empties its packet list. /// public sealed record EngineStatus( @@ -88,9 +89,53 @@ public sealed record EngineStatus( TransmitStatus? Transmit = null, int UnreadMessages = 0, int? CapturedToday = null, - string? Title = null + string? Title = null, + GpsStatus? Gps = null ); +/// How much a GPS knows (docs/features/F3.6-gps.md, behavior 4). +public enum GpsFixKind +{ + /// No fix yet, or none now. + NoFix, + + /// Position without altitude. + Fix2D, + + /// Position with altitude. + Fix3D, +} + +/// The active profile's GPS and its latest fix (docs/features/F3.6-gps.md, behaviors 4, 5, 7). +/// The GPS source. +/// How much it knows. +/// When the fix came; null before the first. +/// Whether the fix is recent enough for the station to be where it says (60 s). +/// Where it is, with a fix. +/// Where it is, with a fix. +/// Height above mean sea level, with a 3D fix. +/// Speed over the ground. +/// Course over the ground, in degrees true. +/// Satellites used in the fix, where the GPS says. +/// Horizontal dilution of precision, where the GPS says. +public sealed record GpsStatus( + string SourceId, + GpsFixKind Fix, + DateTimeOffset? At = null, + bool IsFresh = false, + double? Latitude = null, + double? Longitude = null, + double? AltitudeMeters = null, + double? SpeedKnots = null, + double? Course = null, + int? Satellites = null, + double? Hdop = null +) +{ + /// Whether the station is where the GPS says: a fresh fix with a position. + public bool HasPosition => IsFresh && Fix != GpsFixKind.NoFix && Latitude is not null && Longitude is not null; +} + /// Transmitting, for the status bar (docs/features/F2.1-own-station.md, behaviors 4–13). /// Whether the Transmit switch is on. /// diff --git a/src/AprsMap.Contracts/SourceSettings.cs b/src/AprsMap.Contracts/SourceSettings.cs index b86c448e..a36b6eb6 100644 --- a/src/AprsMap.Contracts/SourceSettings.cs +++ b/src/AprsMap.Contracts/SourceSettings.cs @@ -35,6 +35,10 @@ namespace AprsMap.Contracts; /// /// For a : the script that switches the TNC into KISS. /// For a : the script that switches it out again, or null. +/// +/// For a GPS (docs/features/F3.6-gps.md, behavior 1): how it's reached. Serial uses and +/// ; TCP and gpsd use and . +/// public sealed record SourceSettings( string Id, string Name, @@ -52,9 +56,54 @@ public sealed record SourceSettings( bool? RtsEnable = null, string? TncType = null, string? EnterScript = null, - string? LeaveScript = null + string? LeaveScript = null, + GpsLink? GpsLink = null ) { + /// NMEA 0183's speed, the default for a GPS on a serial port (F3.6 decision 1). + public const int DefaultGpsBaud = 4800; + + /// The speeds offered for a serial GPS; another can be typed. + public static IReadOnlyList CommonGpsBauds { get; } = [4800, 9600, 19200, 38400, 115200]; + + /// The port commonly used for NMEA over TCP (F3.6 decision 1). + public const int DefaultNmeaPort = 10110; + + /// gpsd's port. + public const int DefaultGpsdPort = 2947; + + /// A new GPS on , reading NMEA, named after it. + public static SourceSettings GpsSerialFor(string serialPort, int? baud = null) => + new( + Guid.NewGuid().ToString("N"), + serialPort.Trim(), + SourceKind.Gps, + SerialPort: serialPort.Trim(), + Baud: baud, + GpsLink: Contracts.GpsLink.Serial + ); + + /// A new GPS reached over TCP () or through gpsd, named after its host. + public static SourceSettings GpsNetworkFor(GpsLink link, string host, int? port = null) => + new(Guid.NewGuid().ToString("N"), host.Trim(), SourceKind.Gps, Host: host.Trim(), Port: port, GpsLink: link); + + /// "/dev/ttyUSB0@4800", "pi.local:10110" or "pi.local:2947": where a GPS is read; null for other kinds. + public string? GpsEndpoint => + Kind != SourceKind.Gps ? null + : GpsLink == Contracts.GpsLink.Serial + ? SerialPort is { } serial + ? string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{serial}@{Baud ?? DefaultGpsBaud}" + ) + : null + : Host is { } host + ? string.Create( + System.Globalization.CultureInfo.InvariantCulture, + $"{host}:{Port ?? (GpsLink == Contracts.GpsLink.Gpsd ? DefaultGpsdPort : DefaultNmeaPort)}" + ) + : null; + /// The of a TNC switched with the source's own scripts (F3.2 behavior 4.3). public const string CustomTncType = "custom"; @@ -158,6 +207,19 @@ public enum SourceKind /// AprsTextSerial, - /// A GPS, over serial, TCP or gpsd (with beaconing). + /// A GPS, over serial, TCP or gpsd (docs/features/F3.6-gps.md). Gps, } + +/// How a GPS is reached (docs/features/F3.6-gps.md, behavior 1). +public enum GpsLink +{ + /// NMEA sentences on a serial port. + Serial, + + /// NMEA sentences over a TCP connection. + Tcp, + + /// gpsd, on this machine or another. + Gpsd, +} diff --git a/src/AprsMap.Contracts/Traffic.cs b/src/AprsMap.Contracts/Traffic.cs index 8598f01b..e7d5bc0f 100644 --- a/src/AprsMap.Contracts/Traffic.cs +++ b/src/AprsMap.Contracts/Traffic.cs @@ -61,6 +61,9 @@ public enum TrafficKind /// Another APRS-IS server command. CommandSent, + + /// A line a GPS sent: an NMEA sentence (docs/features/F3.6-gps.md, behavior 6). + GpsSentence, } /// diff --git a/src/AprsMap.Core/AprsIsPacketSource.cs b/src/AprsMap.Core/AprsIsPacketSource.cs index 0a894d65..e1cb673d 100644 --- a/src/AprsMap.Core/AprsIsPacketSource.cs +++ b/src/AprsMap.Core/AprsIsPacketSource.cs @@ -148,6 +148,10 @@ public IPacketSource Create(StationProfile profile, SourceSettings source, strin source.KissEndpoint ?? source.Name ); } + if (source.Kind == SourceKind.Gps) + { + return GpsSource(source); + } if (source.Kind != SourceKind.AprsIs) { throw new NotSupportedException($"{source.Kind} sources aren't built yet."); @@ -166,6 +170,73 @@ public IPacketSource Create(StationProfile profile, SourceSettings source, strin ); } + /// + /// A GPS by its link (docs/features/F3.6-gps.md, behavior 1): NMEA from a serial port or a TCP connection, read by + /// the app and parsed by APRS.NET, or gpsd through APRS.NET's client. + /// + private IPacketSource GpsSource(SourceSettings source) + { + var endpoint = source.GpsEndpoint ?? source.Name; + return source.GpsLink switch + { + GpsLink.Gpsd => new GpsdGpsSource( + new AprsNet.Gps.GpsdOptions + { + Host = source.Host ?? string.Empty, + Port = source.Port ?? SourceSettings.DefaultGpsdPort, + ReconnectDelay = options.Value.InitialRetryDelay, + MaxReconnectDelay = options.Value.MaxRetryDelay, + TimeProvider = timeProvider, + } + ), + GpsLink.Tcp => new NmeaGpsSource( + async token => + { + var client = new System.Net.Sockets.TcpClient(); + try + { + await client + .ConnectAsync( + source.Host ?? string.Empty, + source.Port ?? SourceSettings.DefaultNmeaPort, + token + ) + .ConfigureAwait(false); + return new OwningStream(client.GetStream(), client); + } + catch + { + client.Dispose(); + throw; + } + }, + endpoint, + timeProvider + ), + _ => new NmeaGpsSource( + _ => + { + var port = new System.IO.Ports.SerialPort( + source.SerialPort ?? string.Empty, + source.Baud ?? SourceSettings.DefaultGpsBaud + ); + try + { + port.Open(); + return Task.FromResult(new OwningStream(port.BaseStream, port)); + } + catch + { + port.Dispose(); + throw; + } + }, + endpoint, + timeProvider + ), + }; + } + /// /// The APRS.NET options for a KISS over serial source (docs/features/F3.1-kiss-sources.md, behaviors 1.2, 4, 13): /// its port, speed, TNC port and control lines, the engine's reconnect waits, and a warning after 30 minutes with diff --git a/src/AprsMap.Core/AprsMap.Core.csproj b/src/AprsMap.Core/AprsMap.Core.csproj index c6b3b6f9..62a657a2 100644 --- a/src/AprsMap.Core/AprsMap.Core.csproj +++ b/src/AprsMap.Core/AprsMap.Core.csproj @@ -18,6 +18,7 @@ + diff --git a/src/AprsMap.Core/Beaconer.cs b/src/AprsMap.Core/Beaconer.cs index cec089c6..072556bd 100644 --- a/src/AprsMap.Core/Beaconer.cs +++ b/src/AprsMap.Core/Beaconer.cs @@ -17,6 +17,9 @@ internal interface IBeaconHost /// Whether a packet can go out on now. bool CanSendOn(string sourceId); + /// The fresh fix from 's GPS, for course and speed (F3.6 behavior 7.1); or null. + GpsFix? Motion(StationProfile profile); + /// Sends on , listed as Sent; false when it failed. Task SendAsync(string sourceId, AprsPayload payload, CancellationToken cancellationToken); @@ -344,7 +347,7 @@ private async Task SendAsync(StationProfile profile, Entry entry) : null; AprsPayload? payload = entry.Kind switch { - BeaconKind.Position => PositionReport(profile), + BeaconKind.Position => PositionReport(profile, _host.Motion(profile)), BeaconKind.Status => StatusReport(profile, _time.GetUtcNow()), _ => placed is null ? null : ObjectReport(placed, _time.GetUtcNow()), }; @@ -408,10 +411,22 @@ first is null ? Timeout.InfiniteTimeSpan /// The position a profile sends, by hand or as a beacon (F2.1 behavior 10, F2.2 behavior 2): a position report, or /// a Mic-E report with its status (docs/features/F2.12-mic-e-status.md, behaviors 1–3). /// - public static PositionedPayload PositionReport(StationProfile profile) + /// The profile, where it is now (its GPS position while the fix is fresh). + /// + /// The GPS's fresh fix, whose course, speed and altitude go with the position (docs/features/F3.6-gps.md, behavior + /// 7.1); null for a station without one. + /// + public static PositionedPayload PositionReport(StationProfile profile, GpsFix? motion = null) { var beacon = profile.EffectivePositionBeacon; var symbol = profile.EffectiveSymbol; + // APRS courses run 1 to 360; north is 360, since 0 means unknown. + var course = motion?.Course is { } c + ? ((int)Math.Round(c) % 360 is 0 ? 360 : (int)Math.Round(c) % 360) + : (int?)null; + var speed = motion?.SpeedKnots; + var altitudeFeet = motion?.AltitudeMeters is { } metres ? metres / 0.3048 : (double?)null; + var moving = course is not null && speed is not null; if (beacon.MicE is { } status) { return new MicEReport @@ -421,6 +436,9 @@ public static PositionedPayload PositionReport(StationProfile profile) Comment = profile.Comment ?? string.Empty, Message = MicEMessageFor(status), IsMessagingCapable = true, + Course = moving ? course : null, + SpeedKnots = moving ? speed : null, + AltitudeFeet = altitudeFeet, }; } return new PositionReport @@ -430,7 +448,11 @@ public static PositionedPayload PositionReport(StationProfile profile) Comment = profile.Comment ?? string.Empty, IsMessagingCapable = true, // it answers messages (F2.4 behavior 15) IsCompressed = beacon.Compressed, - PowerHeightGain = beacon.Phg is { } phg && !beacon.Compressed ? PowerHeightGain(phg) : null, + // One data extension: the GPS's course and speed while it has them, otherwise PHG (APRS101 chapter 7). + Course = moving ? course : null, + SpeedKnots = moving ? speed : null, + PowerHeightGain = beacon.Phg is { } phg && !beacon.Compressed && !moving ? PowerHeightGain(phg) : null, + AltitudeFeet = altitudeFeet, }; } diff --git a/src/AprsMap.Core/Engine.Gps.cs b/src/AprsMap.Core/Engine.Gps.cs new file mode 100644 index 00000000..03631755 --- /dev/null +++ b/src/AprsMap.Core/Engine.Gps.cs @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: MIT +using AprsMap.Contracts; +using AprsNet.Filters; +using AprsNet.Packets; + +namespace AprsMap.Core; + +/// +/// The active profile's GPS (docs/features/F3.6-gps.md): its latest fix, and the station's position while that fix is +/// fresh, for beacons, Send position now, the filter's range and the status (behavior 7). +/// +public sealed partial class Engine +{ + /// How long a fix stands for the station's position (F3.6 decision 3). + public static readonly TimeSpan GpsFreshFor = TimeSpan.FromSeconds(60); + + private (string SourceId, GpsFix Fix, DateTimeOffset At)? _gps; // the latest, under _gate + + // The latest with a position: a GPS builds each second's fix sentence by sentence, so the latest can be one that + // hasn't said where yet. Under _gate. + private (string SourceId, GpsFix Fix, DateTimeOffset At)? _gpsPositioned; + private GpsFixMode _gpsMode; // the last 2D or 3D the GPS said, so the status doesn't flicker mid-second, under _gate + private AprsPosition? _filterCentre; // where my range was centred in the filter last sent, under _gate + + /// A GPS source read a fix: kept, and the filter re-sent once the station has moved far enough. + private void OnGpsFix(string sourceId, GpsFix fix) + { + var now = _time.GetUtcNow(); + AprsPosition? centre; + lock (_gate) + { + _gps = (sourceId, fix, now); + if (fix.Mode is GpsFixMode.Fix2D or GpsFixMode.Fix3D) + { + _gpsMode = fix.Mode; + } + if (HasPosition(fix)) + { + _gpsPositioned = (sourceId, fix, now); + } + centre = _filterCentre; + } + // My range follows the GPS once it has moved more than a quarter of the range (behavior 7.2). + if ( + fix.Position is { } here + && Station.Of(_settings) is { Profile.RangeKm: > 0 and var range } + && (centre is null || Geodesy.DistanceKm(centre.Value, here) > range / 4.0) + ) + { + RefreshFilter(); + } + } + + /// Forgets a GPS's fix when its source stops. + private void ForgetGps(string sourceId) + { + lock (_gate) + { + if (_gps?.SourceId == sourceId) + { + _gps = null; + _gpsPositioned = null; + } + } + } + + /// The GPS uses (one at most, behavior 2), or null. + private static SourceSettings? GpsOf(AppSettings settings, StationProfile? profile) => + profile is null ? null : settings.SourceList.FirstOrDefault(s => s.Kind == SourceKind.Gps && profile.Uses(s)); + + /// The fresh fix with a position from 's GPS, or null. Call under . + private GpsFix? FreshFixLocked(StationProfile profile) => + _gpsPositioned is var (sourceId, fix, at) + && _time.GetUtcNow() - at <= GpsFreshFor + && GpsOf(_settings ?? AppSettings.Empty, profile)?.Id == sourceId + ? fix + : null; + + /// The fresh fix from 's GPS, for course, speed and altitude; null without one. + internal GpsFix? FreshFix(StationProfile profile) + { + lock (_gate) + { + return FreshFixLocked(profile); + } + } + + /// + /// where its GPS says while the fix is fresh (behavior 7), otherwise as it is: its fixed + /// location is never changed (decision 4). + /// + internal StationProfile WithGps(StationProfile profile) => + FreshFix(profile)?.Position is { } here + ? profile with + { + Latitude = here.Latitude, + Longitude = here.Longitude, + } + : profile; + + /// Whether says where the GPS is: a position, and not a GPS saying it has no fix. + private static bool HasPosition(GpsFix fix) => fix.Position is not null && fix.Mode != GpsFixMode.NoFix; + + /// The active profile's GPS and its fix, for the status (behaviors 4, 5). Call under . + private GpsStatus? GpsStatusLocked() + { + var settings = _settings ?? AppSettings.Empty; + if (GpsOf(settings, settings.GetActiveProfile()) is not { } gps) + { + return null; + } + // The latest fix that said where the GPS is, unless the GPS has said since that it has none. + var latest = _gps is { Fix.Mode: GpsFixMode.NoFix } ? _gps : _gpsPositioned ?? _gps; + if (latest is not var (sourceId, fix, at) || sourceId != gps.Id) + { + return new GpsStatus(gps.Id, GpsFixKind.NoFix); + } + var kind = + !HasPosition(fix) ? GpsFixKind.NoFix + : fix.Mode == GpsFixMode.Fix3D || (fix.Mode != GpsFixMode.Fix2D && _gpsMode == GpsFixMode.Fix3D) + ? GpsFixKind.Fix3D + : GpsFixKind.Fix2D; + return new GpsStatus( + gps.Id, + kind, + at, + _time.GetUtcNow() - at <= GpsFreshFor, + fix.Position?.Latitude, + fix.Position?.Longitude, + kind == GpsFixKind.Fix3D ? fix.AltitudeMeters : null, + fix.SpeedKnots, + fix.Course, + fix.Satellites, + fix.Hdop + ); + } +} diff --git a/src/AprsMap.Core/Engine.cs b/src/AprsMap.Core/Engine.cs index 8f278dd0..cc940421 100644 --- a/src/AprsMap.Core/Engine.cs +++ b/src/AprsMap.Core/Engine.cs @@ -190,7 +190,8 @@ public Task GetStatusAsync(CancellationToken cancellationToken = d beaconHeld, LastPositionHeard() ), - UnreadMessages: unread + UnreadMessages: unread, + Gps: GpsStatusLocked() ) ); } @@ -770,6 +771,7 @@ private async Task StopAsync(string sourceId, CancellationTokenSource stop, Task _sourceLinks.Remove(sourceId); _sourceAttempts.Remove(sourceId); } + ForgetGps(sourceId); } /// @@ -810,6 +812,10 @@ CancellationToken cancellationToken ); return; } + if (connection is IGpsSource gps) + { + gps.Fixes = fix => OnGpsFix(source.Id, fix); // F3.6 + } await using (connection.ConfigureAwait(false)) { AttachTraffic(source.Id, connection, always: true); // a TNC's traffic is kept from the start (F1.28) @@ -1328,13 +1334,15 @@ public async Task> SendPositionAsync( ) { var (_, profile, targets) = await TargetsAsync(sourceId, cancellationToken).ConfigureAwait(false); - if (profile is not { Latitude: not null, Longitude: not null }) + var here = WithGps(profile); // where the GPS says while its fix is fresh (F3.6 behavior 7.1) + if (here is not { Latitude: not null, Longitude: not null }) { throw EngineErrors.ToException( new EngineError(EngineErrorCodes.TransmitNoPosition, "Set the station's location first.") ); } - var report = Beaconer.PositionReport(profile); // what a beacon sends, PHG and format included (F2.2 behavior 2) + // What a beacon sends, PHG and format included (F2.2 behavior 2), with the GPS's course and speed. + var report = Beaconer.PositionReport(here, FreshFix(profile)); var sent = new List(); Exception? failure = null; foreach (var (source, sender) in targets) @@ -1736,7 +1744,15 @@ Task IMessengerHost.SendAsync(string sourceId, MessagePayload payload, Can lock (_gate) { var settings = _settings ?? AppSettings.Empty; - var profile = settings.GetActiveProfile(); + var profile = settings.GetActiveProfile() is { } active + ? FreshFixLocked(active)?.Position is { } here + ? active with + { + Latitude = here.Latitude, + Longitude = here.Longitude, + } // F3.6 behavior 7.1 + : active + : null; var held = !settings.EffectiveToggles.Transmit ? new EngineError(EngineErrorCodes.TransmitOff, "Transmit is off.") : ProfileBlock(settings) @@ -1756,6 +1772,8 @@ Task IMessengerHost.SendAsync(string sourceId, MessagePayload payload, Can bool IBeaconHost.CanSendOn(string sourceId) => ((IMessengerHost)this).CanSendOn(sourceId); + GpsFix? IBeaconHost.Motion(StationProfile profile) => FreshFix(profile); + Task IBeaconHost.SendAsync(string sourceId, AprsPayload payload, CancellationToken cancellationToken) => SendPayloadAsync(sourceId, payload, cancellationToken); @@ -2346,11 +2364,16 @@ public void SetMapView(string viewer, MapViewReport? view) { views = [.. _views.Select(v => v.View).Reverse()]; } + var here = WithGps(profile.Profile); // my range follows the GPS (F3.6 behavior 7.2) + lock (_gate) + { + _filterCentre = here is { Latitude: { } lat, Longitude: { } lon } ? new AprsPosition(lat, lon) : null; + } return FilterComposer.ComposeParts( - profile.Profile, + here, profile.Filter, views, - name => _stations.Get(name) is { Latitude: { } lat, Longitude: { } lon } ? (lat, lon) : null + name => _stations.Get(name) is { Latitude: { } slat, Longitude: { } slon } ? (slat, slon) : null ); } diff --git a/src/AprsMap.Core/GpsSources.cs b/src/AprsMap.Core/GpsSources.cs new file mode 100644 index 00000000..413510c4 --- /dev/null +++ b/src/AprsMap.Core/GpsSources.cs @@ -0,0 +1,297 @@ +// SPDX-License-Identifier: MIT +using System.Runtime.CompilerServices; +using System.Text; +using AprsMap.Contracts; +using AprsNet.Gps; +using AprsNet.Packets; + +namespace AprsMap.Core; + +/// +/// A GPS source (docs/features/F3.6-gps.md): it reads fixes rather than packets, and hands each one to +/// . It runs as any other source: started, read until it drops, and started again by the engine. +/// +internal interface IGpsSource +{ + /// Where each fix goes; set before the source starts. Called on the source's own task. + Action? Fixes { get; set; } +} + +/// +/// A GPS that sends NMEA sentences over a stream: a serial port, or a TCP connection (F3.6 behaviors 1.1, 1.2). APRS.NET +/// reads the sentences into fixes (); opening the port or socket is the app's. Each line goes +/// to the Source console (behavior 6). +/// +/// Opens the port or connects; a failure is the engine's to retry. +/// Where it reads, for the pill: /dev/ttyUSB0@4800 or host:10110. +/// The clock the console's entries are stamped with. +internal sealed class NmeaGpsSource(Func> open, string endpoint, TimeProvider time) + : IPacketSource, + IGpsSource, + ITrafficSource +{ + /// The longest line taken as a sentence; NMEA 0183's are at most 82 characters. + private const int MaxLine = 256; + + private Stream? _stream; + private Action? _traffic; + + /// + public Action? Fixes { get; set; } + + /// + public async Task StartAsync( + Action stateChanged, + CancellationToken cancellationToken + ) + { + try + { + _stream = await open(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + when (ex + is IOException + or UnauthorizedAccessException + or System.Net.Sockets.SocketException + or ArgumentException + ) + { + throw LinkFailed(ex.Message, ex); + } + return new PacketSourceLogin(endpoint, Verified: false); + } + + /// Reads sentences until the GPS goes away; a GPS has no packets, so none come. + public IAsyncEnumerable ReadPacketsAsync(CancellationToken cancellationToken) => + ReadAsync(cancellationToken); + + private async IAsyncEnumerable ReadAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await PumpAsync(cancellationToken).ConfigureAwait(false); + yield break; + } + + private async Task PumpAsync(CancellationToken cancellationToken) + { + var stream = _stream ?? throw new InvalidOperationException("The GPS isn't open."); + var tracker = new NmeaFixTracker(); + using var reader = new StreamReader(stream, Encoding.ASCII, false, 1024, leaveOpen: true); + while (true) + { + string? line; + try + { + line = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or System.Net.Sockets.SocketException) + { + throw LinkFailed(ex.Message, ex); + } + if (line is null) + { + throw LinkFailed("The GPS closed the connection.", null); + } + line = line.Trim(); + if (line.Length is 0 or > MaxLine) + { + continue; + } + _traffic?.Invoke( + new TrafficEntry(time.GetUtcNow(), string.Empty, TrafficDirection.In, TrafficKind.GpsSentence, line) + ); + if (NmeaSentence.TryParse(line, out var sentence) && tracker.Update(sentence)) + { + Fixes?.Invoke(tracker.Fix); + } + } + } + + /// A GPS has no server filter. + public ValueTask SetFilterAsync(string filter, CancellationToken cancellationToken) => ValueTask.CompletedTask; + + /// The error a GPS that can't be read gives, for its pill. + internal static EngineOperationException LinkFailed(string reason, Exception? inner) => + new( + new EngineError( + EngineErrorCodes.GpsLinkFailed, + reason, + new Dictionary { [EngineErrorArgs.Reason] = reason } + ), + inner + ); + + /// + public void WatchTraffic(Action? sink) => _traffic = sink; + + /// + public async ValueTask DisposeAsync() + { + _traffic = null; + if (_stream is { } stream) + { + await stream.DisposeAsync().ConfigureAwait(false); + } + } +} + +/// +/// A GPS shared by gpsd, on this machine or another (F3.6 behavior 1.3), read with APRS.NET's +/// , which reconnects by itself; its state goes to the source's pill. +/// +internal sealed class GpsdGpsSource(GpsdOptions options) : IPacketSource, IGpsSource +{ + private readonly GpsdConnection _gpsd = new(options); + private readonly TaskCompletionSource _first = new( + TaskCreationOptions.RunContinuationsAsynchronously + ); + private readonly CancellationTokenSource _stop = new(); + private readonly System.Threading.Channels.Channel _fixes = + System.Threading.Channels.Channel.CreateBounded( + new System.Threading.Channels.BoundedChannelOptions(16) + { + FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest, + } + ); + private Action? _changed; + private Task? _pump; + + /// + public Action? Fixes { get; set; } + + /// Connects to gpsd; fails as the first attempt does, after which gpsd's own reconnects carry on. + public async Task StartAsync( + Action stateChanged, + CancellationToken cancellationToken + ) + { + _changed = stateChanged; + _gpsd.StateChanged += OnStateChanged; + _pump = PumpAsync(_stop.Token); + return await _first.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task PumpAsync(CancellationToken cancellationToken) + { + try + { + await foreach (var fix in _gpsd.ReadFixesAsync(cancellationToken).ConfigureAwait(false)) + { + _fixes.Writer.TryWrite(fix); + } + _fixes.Writer.TryComplete(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _fixes.Writer.TryComplete(); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + var failed = NmeaGpsSource.LinkFailed(ex.Message, ex); + _first.TrySetException(failed); + _fixes.Writer.TryComplete(failed); + } + } + + private void OnStateChanged(object? sender, GpsdConnectionStateChangedEventArgs e) + { + switch (e.State) + { + case GpsdConnectionState.Connected: + if (!_first.TrySetResult(new PacketSourceLogin(_gpsd.Server, Verified: false))) + { + _changed?.Invoke(new PacketSourceEvent(ConnectionState.Connected)); + } + break; + case GpsdConnectionState.Reconnecting: + var failed = NmeaGpsSource.LinkFailed(e.Error?.Message ?? "gpsd went away.", e.Error); + var error = failed.EngineError; + if (!_first.TrySetException(failed)) + { + _changed?.Invoke( + new PacketSourceEvent(ConnectionState.Reconnecting, RetryIn: e.RetryIn, Error: error) + ); + } + break; + case GpsdConnectionState.Connecting: + if (_first.Task.IsCompleted) + { + _changed?.Invoke(new PacketSourceEvent(ConnectionState.Connecting)); + } + break; + } + } + + /// Hands gpsd's fixes on until it's stopped; a GPS has no packets, so none come. + public IAsyncEnumerable ReadPacketsAsync(CancellationToken cancellationToken) => + ReadAsync(cancellationToken); + + private async IAsyncEnumerable ReadAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var fix in _fixes.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + Fixes?.Invoke(fix); + } + yield break; + } + + /// A GPS has no server filter. + public ValueTask SetFilterAsync(string filter, CancellationToken cancellationToken) => ValueTask.CompletedTask; + + /// + public async ValueTask DisposeAsync() + { + _gpsd.StateChanged -= OnStateChanged; + await _stop.CancelAsync().ConfigureAwait(false); + if (_pump is { } pump) + { + await pump.ConfigureAwait(false); + } + await _gpsd.DisposeAsync().ConfigureAwait(false); + _stop.Dispose(); + } +} + +/// A stream that closes what it came from (a socket or a serial port) when it's disposed. +internal sealed class OwningStream(Stream inner, IDisposable owner) : Stream +{ + public override bool CanRead => inner.CanRead; + + public override bool CanSeek => false; + + public override bool CanWrite => inner.CanWrite; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => inner.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => inner.Read(buffer, offset, count); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) => + inner.ReadAsync(buffer, cancellationToken); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + inner.ReadAsync(buffer, offset, count, cancellationToken); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => inner.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + inner.Dispose(); + owner.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/AprsMap.Core/SettingsValidator.cs b/src/AprsMap.Core/SettingsValidator.cs index 6d5be8ab..64250484 100644 --- a/src/AprsMap.Core/SettingsValidator.cs +++ b/src/AprsMap.Core/SettingsValidator.cs @@ -57,6 +57,23 @@ public static void Validate(AppSettings settings, TimeProvider time) "A KISS over serial source needs a port, a speed above 0 and a TNC port from 0 to 15." ); } + if ( + source.Kind == SourceKind.Gps + && ( + source.GpsLink is not { } link + || ( + link == GpsLink.Serial + ? string.IsNullOrWhiteSpace(source.SerialPort) || source.Baud is <= 0 + : string.IsNullOrWhiteSpace(source.Host) || source.Port is < 1 or > 65535 + ) + ) + ) + { + throw Fail( + EngineErrorCodes.GpsSourceInvalid, + "A GPS needs its serial port and a speed above 0, or a host and a port from 1 to 65535." + ); + } } foreach (var source in settings.SourceList) @@ -158,6 +175,14 @@ public static void Validate(AppSettings settings, TimeProvider time) profile.Name ); } + if (profile.SourceIds.Count(id => sources[id].Kind == SourceKind.Gps) > 1) + { + throw Fail( + EngineErrorCodes.ProfileTwoGps, + $"Profile '{profile.Name}' uses more than one GPS; the station is in one place.", + profile.Name + ); + } var aprsIs = profile.SourceIds.Select(id => sources[id]).Where(s => s.Kind == SourceKind.AprsIs).ToList(); if (aprsIs.Count > 1) { diff --git a/src/AprsMap.UI/Localization/Strings.resx b/src/AprsMap.UI/Localization/Strings.resx index c210a086..7ed05a4d 100644 --- a/src/AprsMap.UI/Localization/Strings.resx +++ b/src/AprsMap.UI/Localization/Strings.resx @@ -118,6 +118,21 @@ GPS + + Connected by + + + NMEA on a serial port + + + NMEA over the network (TCP) + + + gpsd + + + While the GPS has a fix, the station is where it says: beacons carry its position, course and speed, and the APRS-IS range follows it. Without a fix for a minute, the profile's own location is used. A profile uses one GPS. + {kind} · coming A kind of source that isn't built yet, shown so the feature's shape is visible. @@ -206,6 +221,30 @@ {source} connected + + {source} · 3D fix · {satellites, plural, one {# satellite} other {# satellites}} + source: the GPS's name. satellites: how many the fix uses. + + + {source} · 3D fix + source: the GPS's name. + + + {source} · 2D fix · {satellites, plural, one {# satellite} other {# satellites}} + source: the GPS's name. satellites: how many the fix uses. + + + {source} · 2D fix + source: the GPS's name. + + + {source} · no fix + source: the GPS's name. + + + {source} · no fix, using the profile's location + source: the GPS's name. + {source} connecting… @@ -1287,6 +1326,10 @@ Start the gateway (for example with docker compose up), then open the app from t Text Bytes outside any KISS frame: text from a TNC not in KISS mode, or noise. + + GPS + A line from a GPS: an NMEA sentence. + Sent @@ -4119,6 +4162,16 @@ pause 1s Link to the TNC failed + + Couldn't read the GPS: {reason} + reason: what the system said. + + + A profile uses one GPS: the station is in one place. + + + A GPS needs its serial port and a speed above 0, or a host and a port from 1 to 65535. + TNC isn't in KISS mode A warning while connected: the TNC sends text lines instead of KISS frames, so nothing it hears can be read. diff --git a/src/AprsMap.UI/Settings/SourcesSectionViewModel.cs b/src/AprsMap.UI/Settings/SourcesSectionViewModel.cs index daf27360..0496445c 100644 --- a/src/AprsMap.UI/Settings/SourcesSectionViewModel.cs +++ b/src/AprsMap.UI/Settings/SourcesSectionViewModel.cs @@ -26,10 +26,7 @@ internal SourcesSectionViewModel(IEngineService engine, Localizer text, Action().Select(k => new SourceKindChoice(k, k is not SourceKind.Gps, text, this)), - ]; + Kinds = [.. Enum.GetValues().Select(k => new SourceKindChoice(k, true, text, this))]; } /// The kind in the form: picked for a new source, fixed for one being edited. @@ -41,7 +38,12 @@ internal SourcesSectionViewModel(IEngineService engine, Localizer text, ActionWhether the form shows a serial KISS TNC's type (docs/features/F3.2-built-in-tncs.md, behavior 4). public bool IsKissSerial => EditKind == SourceKind.KissSerial; - /// Whether the form shows a serial port, speed and control lines (F3.1 behavior 1.2, F3.2 behavior 1). - public bool IsSerial => EditKind is SourceKind.KissSerial or SourceKind.AprsTextSerial; + /// + /// Whether the form shows a serial port and speed (F3.1 behavior 1.2, F3.2 behavior 1): a serial TNC, or a GPS on a + /// serial port (docs/features/F3.6-gps.md, behavior 1.1). + /// + public bool IsSerial => + EditKind is SourceKind.KissSerial or SourceKind.AprsTextSerial || (IsGps && EditGpsLink == GpsLink.Serial); + + /// Whether the form shows a serial TNC's help and control lines, which a GPS doesn't need. + public bool ShowsTncSerialParts => IsSerial && !IsGps; + + /// Whether the form is for a GPS (docs/features/F3.6-gps.md, behavior 1). + public bool IsGps => EditKind == SourceKind.Gps; + + /// How the GPS in the form is reached. + [ObservableProperty] + [NotifyPropertyChangedFor( + nameof(IsSerial), + nameof(ShowsTncSerialParts), + nameof(ShowsHost), + nameof(PortPlaceholder), + nameof(BaudPlaceholder), + nameof(GpsSerial), + nameof(GpsTcp), + nameof(GpsGpsd) + )] + public partial GpsLink EditGpsLink { get; set; } = GpsLink.Serial; + + /// NMEA on a serial port (behavior 1.1). + public bool GpsSerial + { + get => EditGpsLink == GpsLink.Serial; + set => SetLink(value, GpsLink.Serial); + } + + /// NMEA over TCP (behavior 1.2). + public bool GpsTcp + { + get => EditGpsLink == GpsLink.Tcp; + set => SetLink(value, GpsLink.Tcp); + } + + /// gpsd (behavior 1.3). + public bool GpsGpsd + { + get => EditGpsLink == GpsLink.Gpsd; + set => SetLink(value, GpsLink.Gpsd); + } + + private void SetLink(bool chosen, GpsLink link) + { + if (chosen) + { + EditGpsLink = link; + } + } + + partial void OnEditGpsLinkChanged(GpsLink value) + { + if (value == GpsLink.Serial && IsEditing && IsGps) + { + _ = RefreshSerialPortsAsync(); + } + } + + /// Whether the form shows a host and port: a TCP TNC, or a GPS over TCP or gpsd. + public bool ShowsHost => IsKissTcp || (IsGps && EditGpsLink != GpsLink.Serial); + + /// The port used when none is typed, shown in the box. + public string PortPlaceholder => + ( + !IsGps ? SourceSettings.DefaultKissPort + : EditGpsLink == GpsLink.Gpsd ? SourceSettings.DefaultGpsdPort + : SourceSettings.DefaultNmeaPort + ).ToString(CultureInfo.InvariantCulture); + + /// The speed used when none is typed, shown in the box. + public string BaudPlaceholder => + (IsGps ? SourceSettings.DefaultGpsBaud : SourceSettings.DefaultBaud).ToString(CultureInfo.InvariantCulture); /// Whether it's this station's own radio, so the form asks which profiles use it. public bool IsTnc => EditKind is not SourceKind.AprsIs; @@ -341,7 +419,12 @@ partial void OnEditKindChanged(SourceKind value) { kind.Refresh(); } - if (value is SourceKind.KissSerial or SourceKind.AprsTextSerial && IsEditing) + if ( + ( + value is SourceKind.KissSerial or SourceKind.AprsTextSerial + || (value == SourceKind.Gps && EditGpsLink == GpsLink.Serial) + ) && IsEditing + ) { _ = RefreshSerialPortsAsync(); } @@ -526,6 +609,7 @@ private void Add() TncTypes.Clear(); SelectedTncType = null; EditKind = SourceKind.AprsIs; + EditGpsLink = GpsLink.Serial; CanPickKind = true; EditRxOnly = false; EditTxPath = string.Empty; @@ -562,6 +646,7 @@ internal void Edit(SourceRow row) TncTypes.Clear(); SelectedTncType = null; EditKind = row.Source.Kind; + EditGpsLink = row.Source.GpsLink ?? GpsLink.Serial; CanPickKind = false; EditServer = row.Source.Server ?? string.Empty; EditFilter = row.Source.Filter ?? string.Empty; @@ -678,7 +763,11 @@ private async Task SaveKissAsync() int? tncPort = EditTncPort.Trim().Length == 0 ? null : Number(EditTncPort); var txPath = EditTxPath.Trim().Length > 0 ? EditTxPath.Trim().ToUpperInvariant() : null; SourceSettings source; - if (IsSerial) + if (IsGps) + { + source = GpsSourceFromForm(); + } + else if (IsSerial) { var serialPort = EditSerialPort.Trim(); int? baud = EditBaud.Trim().Length == 0 ? null : Number(EditBaud); @@ -754,6 +843,32 @@ static int Number(string text) => int.TryParse(text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : -1; } + /// The GPS the form describes (docs/features/F3.6-gps.md, behavior 1), new or the one being edited. + private SourceSettings GpsSourceFromForm() + { + var serial = EditGpsLink == GpsLink.Serial; + var where = serial ? EditSerialPort.Trim() : EditHost.Trim(); + int? number = (serial ? EditBaud : EditPort).Trim() is { Length: > 0 } typed + ? int.TryParse(typed, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) + ? n + : -1 + : null; + var start = + _editingId is { } id ? _settings.SourceList.Single(s => s.Id == id) + : serial ? SourceSettings.GpsSerialFor(where) + : SourceSettings.GpsNetworkFor(EditGpsLink, where); + return start with + { + Name = EditName.Trim().Length > 0 ? EditName.Trim() : where, + GpsLink = EditGpsLink, + SerialPort = serial ? where : null, + Baud = serial ? number : null, + Host = serial ? null : where, + Port = serial ? null : number, + RxOnly = true, // a GPS never sends (behavior 3) + }; + } + private void FillProfiles(Func ticked) { EditProfiles.Clear(); diff --git a/src/AprsMap.UI/ViewModels/MainViewModel.cs b/src/AprsMap.UI/ViewModels/MainViewModel.cs index 4f250b41..2ed88c4e 100644 --- a/src/AprsMap.UI/ViewModels/MainViewModel.cs +++ b/src/AprsMap.UI/ViewModels/MainViewModel.cs @@ -1036,7 +1036,13 @@ private void ShowSourcePills(EngineStatus status) SourcePills.Insert(Math.Min(i, SourcePills.Count), pill); pill.Switch.IsShown = !IsViewerOnly; } - pill.Show(source, CanTransmit, TransmitOn && CanTransmit); + pill.Show( + source, + CanTransmit, + TransmitOn && CanTransmit, + status.Gps is { } gps && gps.SourceId == source.Id ? gps : null, + _settings?.GetActiveProfile() is { Latitude: not null } + ); } } @@ -1312,8 +1318,9 @@ internal void Show(AppSettings settings) } if (Map is not null) { - // Home is the active profile's location (behavior 3), drawn as a house with its callsign (F1.13). - Map.Home = active is { Latitude: { } lat, Longitude: { } lon } ? (lat, lon) : null; + // Home is the active profile's location (behavior 3), drawn as a house with its callsign (F1.13), or where + // its GPS says while the fix is fresh (F3.6 behavior 7.3). + ShowHomePosition(); Map.HomeName = active?.Callsign; Map.HomeRangeKm = active?.RangeKm; Map.DistanceUnit = settings.Units.Distance; @@ -1420,6 +1427,22 @@ internal async Task SwitchProfileAsync(AppSettings settings, StationProfile prof } } + private GpsStatus? _gps; + + /// The house: where the GPS says while its fix is fresh (F3.6 behavior 7.3), otherwise the profile's location. + private void ShowHomePosition() + { + if (Map is null) + { + return; + } + var active = _settings?.GetActiveProfile(); + Map.Home = + _gps is { HasPosition: true, Latitude: { } glat, Longitude: { } glon } && active is not null ? (glat, glon) + : active is { Latitude: { } lat, Longitude: { } lon } ? (lat, lon) + : null; + } + internal void Show(EngineStatus status) { if (status.Title != StationTitle) @@ -1428,6 +1451,8 @@ internal void Show(EngineStatus status) SetPageTitle?.Invoke(status.Title is { } title ? $"{title} · {Brand.DisplayName}" : Brand.DisplayName); } FilterAreas = status.FilterAreas; + _gps = status.Gps; + ShowHomePosition(); Details.Refresh(); // "heard 4 minutes ago" moves on with the status poll StationList.Rewrite(); PacketCount = status.PacketsReceived; diff --git a/src/AprsMap.UI/ViewModels/SourcePill.cs b/src/AprsMap.UI/ViewModels/SourcePill.cs index 7ef1283c..b0da3ce2 100644 --- a/src/AprsMap.UI/ViewModels/SourcePill.cs +++ b/src/AprsMap.UI/ViewModels/SourcePill.cs @@ -102,7 +102,18 @@ internal SourcePill( /// Shows ; says whether this viewer may switch it, and /// whether the station transmits and this app may send (F2.1). /// - internal void Show(SourceStatus status, bool canChange, bool transmitting = false) + /// The source's state. + /// Whether this viewer may switch it. + /// Whether the station transmits and this app may send. + /// A GPS's fix (docs/features/F3.6-gps.md, behaviors 4, 5), for its words while it's connected. + /// Whether the profile has a location of its own to fall back on. + internal void Show( + SourceStatus status, + bool canChange, + bool transmitting = false, + GpsStatus? gps = null, + bool hasFixedLocation = false + ) { IsTransmitEnabled = status.MaySend && canChange; CanType = status.Kind == SourceKind.KissSerial && canChange; @@ -126,6 +137,13 @@ internal void Show(SourceStatus status, bool canChange, bool transmitting = fals }, name ); + // A connected GPS says what it knows (F3.6 behaviors 4, 5). + var noFix = false; + if (status is { Kind: SourceKind.Gps, Enabled: true, State: ConnectionState.Connected, Warning: null }) + { + noFix = gps is not { HasPosition: true }; + Text = GpsText(status.Name, gps, hasFixedLocation); + } // A source that never sends says so on its pill (F2.1 behavior 5; maintainer, 2026-10-18). if (status.Enabled && !status.MaySend && status.Kind is not SourceKind.Gps) { @@ -134,10 +152,29 @@ internal void Show(SourceStatus status, bool canChange, bool transmitting = fals SwitchName = _text.Format("Toggles.Switch.Source", name); // Connected with a warning, such as a TNC not in KISS mode, takes the warning colour (F3.1 behavior 13). var warned = status.State == ConnectionState.Connected && status.Warning is not null; - IsConnected = status.Enabled && status.State == ConnectionState.Connected && !warned; + IsConnected = status.Enabled && status.State == ConnectionState.Connected && !warned && !noFix; IsWarning = - status.Enabled && (warned || status.State == ConnectionState.Reconnecting && status.Error is not null); + status.Enabled + && (warned || noFix || status.State == ConnectionState.Reconnecting && status.Error is not null); CanReconnect = status.Enabled && canChange; Switch.Show(status.Enabled, canChange); } + + /// "GPS · 3D fix · 9 satellites", or "GPS · no fix, using the profile's location". + private string GpsText(string name, GpsStatus? gps, bool hasFixedLocation) + { + var args = new Dictionary { ["source"] = name, ["satellites"] = gps?.Satellites ?? 0 }; + return gps is not { HasPosition: true } + ? _text.Format(hasFixedLocation ? "Status.Gps.NoFixFixed" : "Status.Gps.NoFix", args) + : _text.Format( + (gps.Fix, gps.Satellites) switch + { + (GpsFixKind.Fix3D, not null) => "Status.Gps.Fix3DSatellites", + (GpsFixKind.Fix3D, null) => "Status.Gps.Fix3D", + (_, not null) => "Status.Gps.Fix2DSatellites", + _ => "Status.Gps.Fix2D", + }, + args + ); + } } diff --git a/src/AprsMap.UI/Views/SettingsView.axaml b/src/AprsMap.UI/Views/SettingsView.axaml index 8577031c..20204e50 100644 --- a/src/AprsMap.UI/Views/SettingsView.axaml +++ b/src/AprsMap.UI/Views/SettingsView.axaml @@ -422,11 +422,38 @@ - + + + + + + + + + - + - + diff --git a/tests/AprsMap.Core.Tests/EngineTests.cs b/tests/AprsMap.Core.Tests/EngineTests.cs index cc862924..d6fdca4e 100644 --- a/tests/AprsMap.Core.Tests/EngineTests.cs +++ b/tests/AprsMap.Core.Tests/EngineTests.cs @@ -857,10 +857,12 @@ public async Task AKindNotBuiltYetSaysSoAndIsNotRetried() }; var engine = new Engine( Options.Create(new EngineOptions()), - new AprsIsPacketSourceFactory( - Options.Create(new EngineOptions()), - NullLoggerFactory.Instance, - TimeProvider.System + new NotBuiltFactory( // every kind is built now: one that says GPS isn't + new AprsIsPacketSourceFactory( + Options.Create(new EngineOptions()), + NullLoggerFactory.Instance, + TimeProvider.System + ) ), new InMemorySettingsStore( new AppSettings( @@ -1276,6 +1278,15 @@ settings with Assert.True(london.Line.Max(p => p.Latitude) > 51.5); } + /// A factory that says GPS isn't built, as a kind still to come would. + private sealed class NotBuiltFactory(IPacketSourceFactory inner) : IPacketSourceFactory + { + public IPacketSource Create(StationProfile profile, SourceSettings source, string filter) => + source.Kind == SourceKind.Gps + ? throw new NotSupportedException("Not built.") + : inner.Create(profile, source, filter); + } + internal sealed class FakeSourceFactory : IPacketSourceFactory { private readonly Queue _script = new(); @@ -1309,9 +1320,19 @@ public FakeSource Enqueue(string server, bool verified = false) /// The connection a TNC-like source with opens. public FakeSource EnqueueFor(string sourceId, string server) => _others[sourceId] = new FakeSource(server); + /// Any other source, such as a GPS, that opens. + private readonly System.Collections.Concurrent.ConcurrentDictionary _given = new(); + + /// Opens for the source with . + public void Give(string sourceId, IPacketSource connection) => _given[sourceId] = connection; + public IPacketSource Create(StationProfile profile, SourceSettings source, string filter) { Opens.AddOrUpdate(source.Id, 1, (_, n) => n + 1); + if (_given.TryRemove(source.Id, out var given)) + { + return given; + } if (source.Kind != SourceKind.AprsIs) { return _others.TryGetValue(source.Id, out var other) ? other : new FakeSource(server: null); diff --git a/tests/AprsMap.Core.Tests/GpsTests.cs b/tests/AprsMap.Core.Tests/GpsTests.cs new file mode 100644 index 00000000..fac21323 --- /dev/null +++ b/tests/AprsMap.Core.Tests/GpsTests.cs @@ -0,0 +1,437 @@ +// SPDX-License-Identifier: MIT +using System.Globalization; +using System.IO.Pipelines; +using System.Net; +using System.Net.Sockets; +using System.Text; +using AprsMap.Contracts; +using AprsNet.Packets; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Time.Testing; + +namespace AprsMap.Core.Tests; + +/// GPS sources (docs/features/F3.6-gps.md, acceptance tests 1–6). +public sealed class GpsTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly SourceSettings AprsIs = InMemorySettingsStore.Rotate; + + private static readonly SourceSettings Gps = SourceSettings.GpsNetworkFor(GpsLink.Tcp, "127.0.0.1") with + { + Id = "gps", + }; + + /// An NMEA sentence with its checksum. + internal static string Nmea(string body) + { + var sum = 0; + foreach (var c in body) + { + sum ^= c; + } + return string.Create(CultureInfo.InvariantCulture, $"${body}*{sum:X2}\r\n"); + } + + /// + /// One second of a GPS at (N) and (W, in degrees and minutes as + /// NMEA writes them), moving at 22.4 knots on course 84.4, 545.4 m up, 9 satellites, a 3D fix. + /// + internal static string Second(string time, string lat = "4903.500", string lon = "07201.750") => + Nmea($"GPRMC,{time},A,{lat},N,{lon},W,022.4,084.4,211026,,,A") + + Nmea($"GPGGA,{time},{lat},N,{lon},W,1,09,0.9,545.4,M,46.9,M,,") + + Nmea("GPGSA,A,3,04,05,09,12,24,25,29,31,32,,,,2.5,0.9,2.1"); + + private sealed record Station(Engine Engine, EngineTests.FakeSourceFactory Sources, FakeTimeProvider Time, Pipe Gps) + : IDisposable + { + public void Dispose() => Engine.Dispose(); + + public async Task SendAsync(string text) + { + await Gps.Writer.WriteAsync(Encoding.ASCII.GetBytes(text), Ct); + } + } + + private static Station Create(PositionBeaconSettings? beacon = null, int range = 50) + { + var home = InMemorySettingsStore.Profile("N0TEST") with + { + Passcode = 12345, + Latitude = 40, + Longitude = -105, + RangeKm = range, + Licensed = true, + PositionConfirmed = true, + SourceIds = [AprsIs.Id, Gps.Id], + PositionBeacon = beacon, + }; + var store = new InMemorySettingsStore( + new AppSettings( + [home], + home.Id, + UnitSettings.Metric, + Sources: [AprsIs, Gps], + Toggles: StationToggles.AllOn with + { + Transmit = true, + } + ) + ); + var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-21T12:00:00Z", CultureInfo.InvariantCulture)); + var factory = new EngineTests.FakeSourceFactory(); + var pipe = new Pipe(); + factory.Give(Gps.Id, new NmeaGpsSource(_ => Task.FromResult(pipe.Reader.AsStream()), "test", time)); + var engine = new Engine(Options.Create(new EngineOptions()), factory, store, time, NullLogger.Instance); + return new Station(engine, factory, time, pipe); + } + + private static async Task<(Task Run, CancellationTokenSource Cts, EngineTests.FakeSource AprsIs)> Run( + Station station + ) + { + var aprsIs = station.Sources.Enqueue("T2TEST", verified: true); + var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct); + var run = station.Engine.RunAsync(cts.Token); + await WaitUntil(async () => + (await station.Engine.GetStatusAsync(Ct)).Sources is { Count: 2 } sources + && sources.All(s => s.State == ConnectionState.Connected) + ); + return (run, cts, aprsIs); + } + + [Fact] + public async Task NmeaSentencesGiveAThreeDFixInTheStatusAndTheConsole() + { + using var station = Create(); + var (run, cts, _) = await Run(station); + using (cts) + { + Assert.Equal(GpsFixKind.NoFix, (await station.Engine.GetStatusAsync(Ct)).Gps?.Fix); + + await station.SendAsync(Second("120000")); + await WaitUntil(async () => (await station.Engine.GetStatusAsync(Ct)).Gps is { Satellites: 9 }); + + var gps = (await station.Engine.GetStatusAsync(Ct)).Gps!; + Assert.Equal((GpsFixKind.Fix3D, true), (gps.Fix, gps.IsFresh)); + Assert.Equal(49.0583, gps.Latitude!.Value, 4); + Assert.Equal(-72.0292, gps.Longitude!.Value, 4); + Assert.Equal((22.4, 84.4, 545.4, 0.9), (gps.SpeedKnots, gps.Course, gps.AltitudeMeters, gps.Hdop)); + using var watch = CancellationTokenSource.CreateLinkedTokenSource(Ct); + watch.CancelAfter(TimeSpan.FromSeconds(1)); + var lines = new List(); + try + { + await foreach (var entry in station.Engine.WatchSourceTrafficAsync(Gps.Id, watch.Token)) + { + lines.Add(entry); + if (lines.Count >= 3) + { + break; + } + } + } + catch (OperationCanceledException) when (!Ct.IsCancellationRequested) { } + Assert.Contains( + lines, + e => e.Kind == TrafficKind.GpsSentence && e.Text!.StartsWith("$GPRMC", StringComparison.Ordinal) + ); + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task ANewSecondsFirstSentenceKeepsTheStationWhereItIsAndTheFixAsItWas() + { + using var station = Create(); + var (run, cts, _) = await Run(station); + using (cts) + { + await station.SendAsync(Second("120000")); + await WaitUntil(async () => (await station.Engine.GetStatusAsync(Ct)).Gps is { Satellites: 9 }); + + // The next second's RMC, before its GGA and GSA say the rest. + await station.SendAsync(Nmea("GPRMC,120001,A,4903.510,N,07201.750,W,022.4,084.4,211026,,,A")); + await WaitUntil(async () => (await station.Engine.GetStatusAsync(Ct)).Gps is { Latitude: > 49.0584 }); + + var gps = (await station.Engine.GetStatusAsync(Ct)).Gps!; + Assert.True(gps.HasPosition); + Assert.Equal(GpsFixKind.Fix3D, gps.Fix); + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task AFreshFixIsWhereTheStationIsAndAStaleOneFallsBackToTheProfile() + { + using var station = Create(); + var (run, cts, aprsIs) = await Run(station); + using (cts) + { + await station.SendAsync(Second("120000")); + await WaitUntil(async () => (await station.Engine.GetStatusAsync(Ct)).Gps is { HasPosition: true }); + + await station.Engine.SendPositionAsync(AprsIs.Id, Ct); + var moving = Assert.IsType(AprsPacket.Parse(aprsIs.Sent.Last()).Payload); + Assert.Equal(49.0583, Math.Round(moving.Position.Latitude, 4)); + Assert.Equal((84, 22), (moving.Course, (int)Math.Round(moving.SpeedKnots!.Value))); + Assert.Equal(1789, (int)Math.Round(moving.AltitudeFeet!.Value)); // 545.4 m + + station.Time.Advance(Engine.GpsFreshFor + TimeSpan.FromSeconds(1)); + Assert.False((await station.Engine.GetStatusAsync(Ct)).Gps!.IsFresh); + await station.Engine.SendPositionAsync(AprsIs.Id, Ct); + var fixedSpot = Assert.IsType(AprsPacket.Parse(aprsIs.Sent.Last()).Payload); + Assert.Equal( + (40, -105), + ((int)Math.Round(fixedSpot.Position.Latitude), (int)Math.Round(fixedSpot.Position.Longitude)) + ); + Assert.Null(fixedSpot.Course); + Assert.Equal(40, (await station.Engine.GetSettingsAsync(Ct)).GetActiveProfile()!.Latitude); // never overwritten + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task TheFiltersRangeFollowsTheGpsOnceItMovesAQuarterOfTheRange() + { + using var station = Create(range: 50); + var (run, cts, aprsIs) = await Run(station); + using (cts) + { + await station.SendAsync(Second("120000")); + await WaitUntil(async () => (await station.Engine.GetStatusAsync(Ct)).Gps is { HasPosition: true }); + station.Time.Advance(TimeSpan.FromSeconds(10)); // the filter interval since the login's + await WaitUntil(() => + Task.FromResult( + aprsIs.Filters.Any(f => f.StartsWith("r/49.0583/-72.0292/50", StringComparison.Ordinal)) + ) + ); + var sent = aprsIs.Filters.Count; + + station.Time.Advance(TimeSpan.FromSeconds(10)); // past the filter interval + await station.SendAsync(Second("120010", lat: "4904.000")); // about 1 km: not far enough + await Task.Delay(TimeSpan.FromMilliseconds(100), TimeProvider.System, Ct); + Assert.Equal(sent, aprsIs.Filters.Count); + + station.Time.Advance(TimeSpan.FromSeconds(10)); + await station.SendAsync(Second("120020", lat: "4910.000")); // about 12 km: a quarter of 50 is 12.5 + await Task.Delay(TimeSpan.FromMilliseconds(100), TimeProvider.System, Ct); + Assert.Equal(sent, aprsIs.Filters.Count); + + station.Time.Advance(TimeSpan.FromSeconds(10)); + await station.SendAsync(Second("120030", lat: "4915.000")); // about 21 km + await WaitUntil(() => Task.FromResult(aprsIs.Filters.Count == sent + 1)); + Assert.StartsWith("r/49.25/-72.0292/50", aprsIs.Filters.Last(), StringComparison.Ordinal); + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task ABeaconGoesOutWhereTheGpsSays() + { + using var station = Create( + new PositionBeaconSettings(On: true, Schedule: PositionBeaconSchedule.Fixed, IntervalMinutes: 30) + ); + var (run, cts, aprsIs) = await Run(station); + using (cts) + { + // A fix every 20 s until the first beacon, due within its first three minutes, goes out. + for (var second = 0; aprsIs.Sent.IsEmpty && second < 300; second += 20) + { + await station.SendAsync( + Second(string.Create(CultureInfo.InvariantCulture, $"12{second / 60:00}{second % 60:00}")) + ); + await WaitUntil(async () => (await station.Engine.GetStatusAsync(Ct)).Gps is { HasPosition: true }); + station.Time.Advance(TimeSpan.FromSeconds(20)); + await Task.Delay(TimeSpan.FromMilliseconds(50), TimeProvider.System, Ct); + } + await WaitUntil(() => Task.FromResult(!aprsIs.Sent.IsEmpty)); + + var beacon = Assert.IsType(AprsPacket.Parse(aprsIs.Sent.First()).Payload); + Assert.Equal(49.0583, Math.Round(beacon.Position.Latitude, 4)); + Assert.Equal(84, beacon.Course); + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task NmeaOverTcpIsReadFromTheSocket() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var factory = new AprsIsPacketSourceFactory( + Options.Create(new EngineOptions()), + NullLoggerFactory.Instance, + TimeProvider.System + ); + var source = factory.Create( + InMemorySettingsStore.Profile("N0TEST"), + SourceSettings.GpsNetworkFor(GpsLink.Tcp, "127.0.0.1", port), + string.Empty + ); + await using (source) + { + var fixes = new System.Collections.Concurrent.ConcurrentQueue(); + ((IGpsSource)source).Fixes = fixes.Enqueue; + var accepting = listener.AcceptTcpClientAsync(Ct); + var login = await source.StartAsync(_ => { }, Ct); + using var client = await accepting; + await client.GetStream().WriteAsync(Encoding.ASCII.GetBytes(Second("120000")), Ct); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(Ct); + var reading = Task.Run( + async () => + { + await foreach (var _ in source.ReadPacketsAsync(stop.Token)) { } + }, + Ct + ); + + await WaitUntil(() => Task.FromResult(fixes.Any(f => f.Satellites == 9))); + + Assert.Equal($"127.0.0.1:{port}", login.Server); + await stop.CancelAsync(); + await Assert.ThrowsAnyAsync(() => reading); + } + } + + [Fact] + public async Task GpsdsFixesComeThroughAPRSNETsClient() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var factory = new AprsIsPacketSourceFactory( + Options.Create(new EngineOptions()), + NullLoggerFactory.Instance, + TimeProvider.System + ); + var source = factory.Create( + InMemorySettingsStore.Profile("N0TEST"), + SourceSettings.GpsNetworkFor(GpsLink.Gpsd, "127.0.0.1", port), + string.Empty + ); + await using (source) + { + var fixes = new System.Collections.Concurrent.ConcurrentQueue(); + ((IGpsSource)source).Fixes = fixes.Enqueue; + var gpsd = Task.Run( + async () => + { + using var client = await listener.AcceptTcpClientAsync(Ct); + var stream = client.GetStream(); + await stream.WriteAsync( + Encoding.ASCII.GetBytes( + "{\"class\":\"VERSION\",\"release\":\"3.25\",\"proto_major\":3,\"proto_minor\":15}\n" + + "{\"class\":\"TPV\",\"mode\":3,\"time\":\"2026-10-21T12:00:00.000Z\",\"lat\":49.0583," + + "\"lon\":-72.0292,\"altMSL\":545.4,\"speed\":11.52,\"track\":84.4}\n" + ), + Ct + ); + await Task.Delay(TimeSpan.FromSeconds(5), TimeProvider.System, Ct); + }, + Ct + ); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(Ct); + var login = await source.StartAsync(_ => { }, Ct); + var reading = Task.Run( + async () => + { + await foreach (var _ in source.ReadPacketsAsync(stop.Token)) { } + }, + Ct + ); + + await WaitUntil(() => Task.FromResult(!fixes.IsEmpty)); + + var fix = fixes.First(); + Assert.Equal((49.0583, -72.0292), (fix.Position!.Value.Latitude, fix.Position.Value.Longitude)); + Assert.Equal(GpsFixMode.Fix3D, fix.Mode); + await stop.CancelAsync(); + await Assert.ThrowsAnyAsync(() => reading); + } + } + + [Theory] + [InlineData(GpsLink.Serial, null, null)] + [InlineData(GpsLink.Tcp, null, null)] + [InlineData(GpsLink.Gpsd, "pi.local", 70000)] + public async Task AGpsWithoutWhereToReadItIsRefused(GpsLink link, string? host, int? port) + { + var store = InMemorySettingsStore.WithProfile(); + using var engine = new Engine( + Options.Create(new EngineOptions()), + new EngineTests.FakeSourceFactory(), + store, + TimeProvider.System, + NullLogger.Instance + ); + var settings = await engine.GetSettingsAsync(Ct); + + var error = await Assert.ThrowsAsync(() => + engine.SaveSettingsAsync( + settings with + { + Sources = + [ + .. settings.SourceList, + new("gps", "GPS", SourceKind.Gps, Host: host, Port: port, GpsLink: link), + ], + }, + Ct + ) + ); + + Assert.Equal(EngineErrorCodes.GpsSourceInvalid, error.EngineError.Code); + } + + [Fact] + public async Task AProfileUsesOneGps() + { + var store = InMemorySettingsStore.WithProfile(); + using var engine = new Engine( + Options.Create(new EngineOptions()), + new EngineTests.FakeSourceFactory(), + store, + TimeProvider.System, + NullLogger.Instance + ); + var settings = await engine.GetSettingsAsync(Ct); + var one = SourceSettings.GpsNetworkFor(GpsLink.Gpsd, "localhost"); + var two = SourceSettings.GpsSerialFor("/dev/ttyUSB0"); + var profile = settings.GetActiveProfile()!; + + var error = await Assert.ThrowsAsync(() => + engine.SaveSettingsAsync( + settings with + { + Sources = [.. settings.SourceList, one, two], + Profiles = [profile with { SourceIds = [.. profile.SourceIds, one.Id, two.Id] }], + }, + Ct + ) + ); + + Assert.Equal(EngineErrorCodes.ProfileTwoGps, error.EngineError.Code); + } + + private static async Task WaitUntil(Func> condition) + { + for (var i = 0; i < 400; i++) + { + if (await condition()) + { + return; + } + await Task.Delay(TimeSpan.FromMilliseconds(10), TimeProvider.System, Ct); + } + Assert.Fail("Condition was not met in time."); + } +} diff --git a/tests/AprsMap.UI.Tests/GpsUiTests.cs b/tests/AprsMap.UI.Tests/GpsUiTests.cs new file mode 100644 index 00000000..0da8e718 --- /dev/null +++ b/tests/AprsMap.UI.Tests/GpsUiTests.cs @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using AprsMap.Contracts; +using AprsMap.UI.Localization; +using AprsMap.UI.Map; +using AprsMap.UI.Settings; +using AprsMap.UI.ViewModels; +using Microsoft.Extensions.Time.Testing; + +namespace AprsMap.UI.Tests; + +/// A GPS in Settings › Sources, its pill and the map (docs/features/F3.6-gps.md). +public sealed class GpsUiTests +{ + private static (SourcesSectionViewModel Section, FakeEngine Engine) Create() + { + var engine = new FakeEngine(); + var section = new SourcesSectionViewModel(engine, Localizer.Instance, _ => { }); + section.Show(engine.Settings); + return (section, engine); + } + + [Fact] + public async Task AGpsOnASerialPortIsSavedWithItsSpeedAndNeverSends() + { + var (section, engine) = Create(); + section.AddCommand.Execute(null); + section.EditKind = SourceKind.Gps; + + Assert.True(section.IsGps && section.GpsSerial && section.IsSerial); + Assert.False(section.ShowsTncSerialParts); // no control lines for a GPS + Assert.Equal("4800", section.BaudPlaceholder); + section.EditSerialPort = "/dev/ttyACM0"; + await section.SaveCommand.ExecuteAsync(null); + + var gps = engine.Settings.SourceList.Single(s => s.Kind == SourceKind.Gps); + Assert.Equal( + (GpsLink.Serial, "/dev/ttyACM0", (int?)null, true), + (gps.GpsLink, gps.SerialPort, gps.Baud, gps.RxOnly) + ); + Assert.Contains(gps.Id, engine.Settings.GetActiveProfile()!.SourceIds); // the active profile uses it + } + + [Theory] + [InlineData(GpsLink.Tcp, "10110")] + [InlineData(GpsLink.Gpsd, "2947")] + public async Task AGpsOverTheNetworkHasAHostAndItsLinksPort(GpsLink link, string port) + { + var (section, engine) = Create(); + section.AddCommand.Execute(null); + section.EditKind = SourceKind.Gps; + if (link == GpsLink.Tcp) + { + section.GpsTcp = true; + } + else + { + section.GpsGpsd = true; + } + + Assert.True(section.ShowsHost); + Assert.False(section.IsSerial); + Assert.Equal(port, section.PortPlaceholder); + section.EditHost = "pi.local"; + await section.SaveCommand.ExecuteAsync(null); + + var gps = engine.Settings.SourceList.Single(s => s.Kind == SourceKind.Gps); + Assert.Equal((link, "pi.local", (int?)null, (string?)null), (gps.GpsLink, gps.Host, gps.Port, gps.SerialPort)); + } + + [Theory] + [InlineData(GpsFixKind.Fix3D, 9, true, "GPS · 3D fix · 9 satellites", false)] + [InlineData(GpsFixKind.Fix2D, null, true, "GPS · 2D fix", false)] + [InlineData(GpsFixKind.Fix3D, 9, false, "GPS · no fix, using the profile's location", true)] + public void ItsPillSaysWhatTheGpsKnows(GpsFixKind fix, int? satellites, bool fresh, string said, bool warned) + { + var pill = new SourcePill( + "gps", + Localizer.Instance, + _ => Task.CompletedTask, + () => Task.CompletedTask, + () => Task.CompletedTask, + _ => Task.CompletedTask + ); + var gps = new GpsStatus("gps", fix, DateTimeOffset.UnixEpoch, fresh, 49, -72, Satellites: satellites); + + pill.Show( + new SourceStatus("gps", "GPS", SourceKind.Gps, ConnectionState.Connected), + true, + gps: gps, + hasFixedLocation: true + ); + + Assert.Equal(said, pill.Text); + Assert.Equal(warned, pill.IsWarning); + } + + [Fact] + public void TheHouseIsWhereAFreshFixSaysAndBackHomeWithoutOne() + { + var engine = new FakeEngine(); + var vm = new MainViewModel(engine, new FakeTimeProvider()) + { + Map = new MapViewModel(null, null, new FakeTimeProvider()), + }; + var active = engine.Settings.GetActiveProfile()!; + vm.Show( + engine.Settings with + { + Profiles = + [ + .. engine.Settings.Profiles.Select(p => + p.Id == active.Id ? p with { Latitude = 40, Longitude = -105 } : p + ), + ], + } + ); + Assert.Equal((40.0, -105.0), vm.Map!.Home); + + var status = new EngineStatus(ConnectionState.Connected, "T2TEST", 1, "1.0"); + vm.Show( + status with + { + Gps = new GpsStatus("gps", GpsFixKind.Fix3D, DateTimeOffset.UnixEpoch, true, 49.06, -72.03), + } + ); + Assert.Equal((49.06, -72.03), vm.Map.Home); + + vm.Show( + status with + { + Gps = new GpsStatus("gps", GpsFixKind.Fix3D, DateTimeOffset.UnixEpoch, false, 49.06, -72.03), + } + ); + Assert.Equal((40.0, -105.0), vm.Map.Home); // stale: the profile's location + } +} diff --git a/tests/AprsMap.UI.Tests/SourcesSectionTests.cs b/tests/AprsMap.UI.Tests/SourcesSectionTests.cs index 066ce6e4..098ba35c 100644 --- a/tests/AprsMap.UI.Tests/SourcesSectionTests.cs +++ b/tests/AprsMap.UI.Tests/SourcesSectionTests.cs @@ -28,10 +28,10 @@ public void EachSourceSaysWhoUsesItAndAprsIsOrAKissTncCanBeAdded() Assert.Equal("Used by: Home, Portable", section.Rows[0].UsedByText); Assert.Equal("Not used by any profile", section.Rows[1].UsedByText); Assert.Equal( - ["APRS-IS", "KISS over TCP", "KISS over serial", "TNC text output (TNC2) over serial", "GPS · coming"], + ["APRS-IS", "KISS over TCP", "KISS over serial", "TNC text output (TNC2) over serial", "GPS"], section.Kinds.Select(k => k.Name) ); - Assert.Equal([true, true, true, true, false], section.Kinds.Select(k => k.IsAvailable)); + Assert.All(section.Kinds, k => Assert.True(k.IsAvailable)); // GPS too (docs/features/F3.6-gps.md) } /// First run's result: one licensed profile and no sources yet. From 2ef4226acbbc24b0a81c9a26609ddb1cab568fc8 Mon Sep 17 00:00:00 2001 From: gx1400 Date: Wed, 21 Oct 2026 00:13:59 -0500 Subject: [PATCH 2/3] feat(beacons): the moving schedule, SmartBeaconing from the GPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving is offered in Settings › Beacons: while the profile's GPS has a fresh fix the position goes by speed and turns with APRS.NET's SmartBeaconSchedule (90 s at speed, 30 minutes stopped, at once on a turn), fed each fix; without one it beacons every interval. Its speeds, rates and turns are APRS.NET's defaults and can be changed, speeds in the viewer's unit. On a TNC the 10-minute floor doesn't hold it back. Closes #335 --- docs/features/F2.2-beacons.md | 23 +- docs/scope.md | 2 +- src/AprsMap.Contracts/Beacons.cs | 49 ++++- src/AprsMap.Contracts/EngineErrors.cs | 3 + src/AprsMap.Core/Beaconer.cs | 46 +++- src/AprsMap.Core/Engine.Gps.cs | 1 + src/AprsMap.Core/SettingsValidator.cs | 16 ++ src/AprsMap.UI/Localization/Strings.resx | 31 ++- .../Settings/BeaconsSectionViewModel.cs | 117 +++++++++- src/AprsMap.UI/Views/SettingsView.axaml | 112 ++++++++++ tests/AprsMap.Core.Tests/MovingBeaconTests.cs | 207 ++++++++++++++++++ tests/AprsMap.UI.Tests/BeaconsUiTests.cs | 47 +++- 12 files changed, 634 insertions(+), 20 deletions(-) create mode 100644 tests/AprsMap.Core.Tests/MovingBeaconTests.cs diff --git a/docs/features/F2.2-beacons.md b/docs/features/F2.2-beacons.md index 049a7534..f6155e5f 100644 --- a/docs/features/F2.2-beacons.md +++ b/docs/features/F2.2-beacons.md @@ -9,7 +9,8 @@ on top. Two changes from the draft: - On a TNC, a decay sequence starts at **10 minutes**, not 1: the 10-minute floor (behavior 6) would hold back every shorter step anyway, so the steps are 10, 20, then the interval. APRS-IS keeps 30 seconds, 1, 2, 4, 8, 16 minutes. -Moving (speed and turns) isn't offered until GPS (F3.6). +Moving (speed and turns) is built with GPS (F3.6) for 0.6.0 ([#335](https://github.com/APRS-NET/AprsMap/issues/335)): +behavior 1.1's third schedule, decisions 3 and 5 revised, proposed 2026-10-21 for the maintainer's review. ## User story @@ -36,8 +37,14 @@ checked what it says. - **Decay after a change** (the default when turned on): a beacon soon after the position, symbol or comment changes, then at doubling intervals up to the fixed interval (for example 1, 2, 4, 8, 16, then every 30 minutes), so a new position spreads quickly and then settles. - - **Moving (speed and turns)**: SmartBeaconing-style corner pegging (APRS.NET's names and defaults), offered - once the profile has a GPS (F3.6, 0.6.0); until then shown as coming. + - **Moving (speed and turns, with a GPS)**: SmartBeaconing with APRS.NET's `SmartBeaconSchedule`, fed each + fix from the profile's GPS (F3.6). While the fix is fresh: every **fast rate** (90 s) at or above the **fast + speed** (60 mph), every **slow rate** (30 minutes) at or below the **slow speed** (5 mph), in between + inversely to the speed, and at once on a turn sharper than the **turn angle** (30°) plus the **turn slope** + (255 degrees × mph) over the speed, no sooner than the **turn time** (10 s) after the last beacon. The defaults + are APRS.NET's (Dire Wolf's for a car); each can be changed in Settings › Beacons, speeds in the viewer's unit + (km/h, mph or knots after the distance unit). Without a fresh fix it beacons every interval, as Fixed; when a + fix comes back, the first moving beacon goes at once. 2. **Send via**: a checkmark for each of the profile's sources that isn't **RX only** (F2.1), all ticked by default. RX-only sources aren't listed. Deleting a source takes it out of every beacon's Send via. 3. **Format**: uncompressed (the default) or compressed, which is shorter on air. @@ -120,10 +127,14 @@ checked what it says. settles at the fixed interval, while a change spreads quickly. 2. **30 minutes for positions and 60 for status by default, 10 minutes the shortest** (proposed), the usual fixed-station etiquette. -3. **A 10-minute floor per RF source** (proposed), whatever the schedule, as a courtesy to the shared channel; APRS-IS has - no floor beyond the interval. +3. **A 10-minute floor per RF source** (proposed), as a courtesy to the shared channel; APRS-IS has no floor beyond the + interval. **Except Moving** (proposed 2026-10-21): a moving station is held to its own fast rate and turn time, + which SmartBeaconing exists for; a 10-minute floor would make it useless on RF. 4. **One schedule per beacon, Send via picks the sources** (proposed), rather than a schedule per source. -5. **The moving schedule waits for GPS** (proposed): it's shown as coming until F3.6, since a fixed location never moves. +5. **The moving schedule comes with GPS** (built for 0.6.0): it's always offered, says it needs a GPS, and beacons as + Fixed without a fresh fix (proposed 2026-10-21), rather than being refused for a profile without one. +6. **Moving's settings are APRS.NET's defaults, all seven adjustable** (proposed 2026-10-21), rather than presets + (car, bike, walking). ## Sources diff --git a/docs/scope.md b/docs/scope.md index 723c10f5..07256a2c 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -115,7 +115,7 @@ milestone ([0.3.0-alpha.1](https://github.com/APRS-NET/AprsMap/milestone/1), don | ID | Feature | Reference topic | Owner | Needs | Status | | --- | --- | --- | --- | --- | --- | | F2.1 | Own station ([spec](features/F2.1-own-station.md)): callsign, SSID guidance, symbol, comment, passcode, transmit enable and safety interlocks, Send position now; one Transmit switch in the status bar, RX only and Test send per source, RF through KISS TNCs | SSID, Passcode, Why Won't APRSIS Transmit | App | G01, G02, G13 | built (0.5.0, [#190](https://github.com/APRS-NET/AprsMap/issues/190)) | -| F2.2 | Beacons ([spec](features/F2.2-beacons.md)): fixed and SmartBeaconing-style position beacons, status beacons, PHG; **Send via** checkmarks per beacon over the sources that may transmit (F2.1), all ticked by default | PHG | App | G01, G11 | built (0.5.0, [#196](https://github.com/APRS-NET/AprsMap/issues/196)) | +| F2.2 | Beacons ([spec](features/F2.2-beacons.md)): fixed and SmartBeaconing-style position beacons, status beacons, PHG; **Send via** checkmarks per beacon over the sources that may transmit (F2.1), all ticked by default | PHG | App | G01, G11 | built (0.5.0, [#196](https://github.com/APRS-NET/AprsMap/issues/196)); the moving schedule (SmartBeaconing from the GPS) built for 0.6.0 ([#335](https://github.com/APRS-NET/AprsMap/issues/335)), decisions proposed | | F2.3 | Predictive beaconing indicator: show where others last saw you versus where you are (our own name for the feature) | Red Dot | App | G11 | scoped (0.6.0, [#197](https://github.com/APRS-NET/AprsMap/issues/197)) | | F2.4 | Messaging ([spec](features/F2.4-messaging.md)): chat per correspondent, ack/retry status, reply-ack, rejects, offline queue, selectable retry profiles (default, APRS-IS, none, slow HF modem), optional double-ack on RF; **Send via** per thread: Automatic (RF to a correspondent heard on a TNC recently, APRS-IS otherwise, falling back to APRS-IS when RF retries go unacked), or chosen sources; acks go back on the source the message came in on | APRS Messaging Explained | App | G01, G10 | built (0.5.0, [#192](https://github.com/APRS-NET/AprsMap/issues/192)) | | F2.5 | Bulletins and announcements, group bulletins; ANSRVR group join and leave; Send via checkmarks as for beacons ([spec](features/F2.5-bulletins.md)) | ANSRVR | App | G10, G34 | built; a conversation per group deferred ([#285](https://github.com/APRS-NET/AprsMap/issues/285), waits on APRS.NET#281, paused) (0.5.0, [#193](https://github.com/APRS-NET/AprsMap/issues/193)) | diff --git a/src/AprsMap.Contracts/Beacons.cs b/src/AprsMap.Contracts/Beacons.cs index 635862a8..f2475753 100644 --- a/src/AprsMap.Contracts/Beacons.cs +++ b/src/AprsMap.Contracts/Beacons.cs @@ -10,7 +10,10 @@ public enum PositionBeaconSchedule /// Soon after a send, then at doubling waits up to the interval. Decay, - /// By speed and turns; needs a GPS (F3.6), so until then it beacons as . + /// + /// By speed and turns (SmartBeaconing) from the GPS's fixes (docs/features/F2.2-beacons.md, revised for #335); as + /// while there's no fresh fix. + /// Moving, } @@ -43,6 +46,37 @@ public enum BeaconDueReason Spacing, } +/// +/// The moving schedule (SmartBeaconing, revised F2.2 for #335): every at or below +/// , every at or above +/// , in between inversely to the speed, and at once on a turn sharper than +/// + ÷ speed, no sooner than +/// after the last beacon. The defaults are APRS.NET's (Dire Wolf's for a car). +/// +/// At or below it, the slow rate: 5 mph. +/// The interval when slow or stopped: 30 minutes. +/// At or above it, the fast rate: 60 mph. +/// The interval at speed: 90 seconds. +/// The smallest turn at speed, in degrees: 30. +/// How much more a slow station must turn, degrees × knots: 221.6. +/// The shortest time from the last beacon to a turn beacon: 10 seconds. +public sealed record MovingBeaconSettings( + double SlowSpeedKnots = 5 / MovingBeaconSettings.MphPerKnot, + int SlowRateSeconds = 30 * 60, + double FastSpeedKnots = 60 / MovingBeaconSettings.MphPerKnot, + int FastRateSeconds = 90, + double TurnAngle = 30, + double TurnSlope = 255 / MovingBeaconSettings.MphPerKnot, + int TurnTimeSeconds = 10 +) +{ + /// Miles per hour in a knot. + public const double MphPerKnot = 1.150779; + + /// The defaults. + public static MovingBeaconSettings Default { get; } = new(); +} + /// /// The Mic-E status a position goes with (docs/features/F2.12-mic-e-status.md, APRS101 chapter 10). Emergency is /// deliberately absent: the app never sends it (decision 3). @@ -88,6 +122,7 @@ public sealed record PhgSettings(int PowerWatts, int HeightFeet, int GainDbi, in /// Send via: the TX-enabled sources it goes out on; null for all of them. /// Whether the position is sent compressed, which is shorter on air. /// PHG, or null for none. A compressed position can't carry it. +/// The speeds, rates and turns of the schedule; null for the defaults. /// /// Mic-E with this status (docs/features/F2.12-mic-e-status.md), or null for an uncompressed or compressed position. /// Mic-E is never compressed and carries no PHG. @@ -99,7 +134,8 @@ public sealed record PositionBeaconSettings( IReadOnlyList? SourceIds = null, bool Compressed = false, PhgSettings? Phg = null, - MicEStatus? MicE = null + MicEStatus? MicE = null, + MovingBeaconSettings? Moving = null ) { /// The interval a new position beacon has: 30 minutes. @@ -120,7 +156,8 @@ other is not null && BeaconSettings.SameSources(SourceIds, other.SourceIds) && Compressed == other.Compressed && Phg == other.Phg - && MicE == other.MicE; + && MicE == other.MicE + && EffectiveMoving == other.EffectiveMoving; /// public override int GetHashCode() => @@ -131,8 +168,12 @@ public override int GetHashCode() => SourceIds is null ? null : string.Join(',', SourceIds), Compressed, Phg, - MicE + MicE, + EffectiveMoving ); + + /// The moving schedule's settings in effect: , or the defaults. + public MovingBeaconSettings EffectiveMoving => Moving ?? MovingBeaconSettings.Default; } /// What a status carries besides its text (docs/features/F2.11-status-reports.md, behavior 3). diff --git a/src/AprsMap.Contracts/EngineErrors.cs b/src/AprsMap.Contracts/EngineErrors.cs index ab3e11b2..78fec2b0 100644 --- a/src/AprsMap.Contracts/EngineErrors.cs +++ b/src/AprsMap.Contracts/EngineErrors.cs @@ -42,6 +42,9 @@ public static class EngineErrorCodes /// A profile uses more than one GPS (docs/features/F3.6-gps.md, behavior 2). public const string ProfileTwoGps = "settings.profile.two-gps"; + /// The moving schedule's speeds, rates or turns can't be used (F2.2, #335). + public const string ProfileMovingInvalid = "settings.profile.moving-invalid"; + /// A GPS without its link, serial port or host (F3.6 behavior 1). public const string GpsSourceInvalid = "settings.source.gps-invalid"; diff --git a/src/AprsMap.Core/Beaconer.cs b/src/AprsMap.Core/Beaconer.cs index 072556bd..0d1af375 100644 --- a/src/AprsMap.Core/Beaconer.cs +++ b/src/AprsMap.Core/Beaconer.cs @@ -121,6 +121,38 @@ public Beaconer(IBeaconHost host, TimeProvider time, Random? random = null) /// Looks again now: the settings, the Transmit switch or a source changed. public void Poke() => _ = TickAsync(); + /// + /// The GPS has a new fix (docs/features/F3.6-gps.md): moving schedules take its speed and course, which can make a + /// beacon due at once (a turn, or speeding up), and a schedule starts or stops moving as the fix comes and goes. + /// + public void Moved(GpsFix fix) + { + lock (_gate) + { + foreach (var entry in _entries.Values) + { + if (entry.Schedule is SmartBeaconSchedule moving) + { + moving.Update(fix); + } + } + } + Poke(); + } + + /// APRS.NET's SmartBeaconing settings for . + internal static SmartBeaconSettings SmartSettings(MovingBeaconSettings moving) => + new() + { + SlowSpeedKnots = moving.SlowSpeedKnots, + SlowRate = TimeSpan.FromSeconds(moving.SlowRateSeconds), + FastSpeedKnots = moving.FastSpeedKnots, + FastRate = TimeSpan.FromSeconds(moving.FastRateSeconds), + TurnAngle = moving.TurnAngle, + TurnSlope = moving.TurnSlope, + TurnTime = TimeSpan.FromSeconds(moving.TurnTimeSeconds), + }; + /// /// A position was sent by hand on (behavior 11): it counts as that source's beacon, so the /// schedule carries on from it and the TNC spacing counts from it. @@ -227,11 +259,22 @@ private void Plan(StationProfile? profile, IReadOnlyList<(string Id, bool IsRf)> if (position.On && position.SendsVia(id)) { var interval = TimeSpan.FromMinutes(position.IntervalMinutes); - var signature = on + position.GetHashCode(); + // Moving follows speed and turns while the GPS has a fresh fix, and is Fixed without one (#335). + var smart = + position.Schedule == PositionBeaconSchedule.Moving && profile is not null + ? _host.Motion(profile) + : null; + var signature = on + position.GetHashCode() + (smart is null ? string.Empty : "|moving"); wanted[(BeaconKind.Position, id, null)] = ( signature, () => { + if (smart is not null) + { + var schedule = new SmartBeaconSchedule(SmartSettings(position.EffectiveMoving), _time); + schedule.Update(smart); + return new Entry(BeaconKind.Position, id, isRf, schedule, now, signature); + } if (position.Schedule == PositionBeaconSchedule.Decay) { var first = isRf ? RfFloor : IsFirstStep; @@ -330,6 +373,7 @@ private DateTimeOffset EarliestAt(Entry entry) if ( entry.Kind == BeaconKind.Position && entry.IsRf + && entry.Schedule is not SmartBeaconSchedule // moving keeps its own pace: its fast rate and turn time && _lastPosition.TryGetValue(entry.SourceId, out var lastPosition) && lastPosition + RfFloor > at ) diff --git a/src/AprsMap.Core/Engine.Gps.cs b/src/AprsMap.Core/Engine.Gps.cs index 03631755..e16f2db6 100644 --- a/src/AprsMap.Core/Engine.Gps.cs +++ b/src/AprsMap.Core/Engine.Gps.cs @@ -40,6 +40,7 @@ private void OnGpsFix(string sourceId, GpsFix fix) } centre = _filterCentre; } + _beaconer.Moved(fix); // the moving schedule's speed and turns (#335) // My range follows the GPS once it has moved more than a quarter of the range (behavior 7.2). if ( fix.Position is { } here diff --git a/src/AprsMap.Core/SettingsValidator.cs b/src/AprsMap.Core/SettingsValidator.cs index 64250484..61d3a5b2 100644 --- a/src/AprsMap.Core/SettingsValidator.cs +++ b/src/AprsMap.Core/SettingsValidator.cs @@ -518,6 +518,22 @@ private static void ValidateBeacons(StationProfile profile) profile.Name ); } + if (position.Moving is { } moving) + { + try + { + Beaconer.SmartSettings(moving).Validate(); + } + catch (ArgumentException ex) + { + throw Fail( + EngineErrorCodes.ProfileMovingInvalid, + $"Profile '{profile.Name}': the moving schedule can't be used: {ex.Message}", + profile.Name, + ex + ); + } + } if (position.MicE is not null && (position.Compressed || position.Phg is not null)) { throw Fail( diff --git a/src/AprsMap.UI/Localization/Strings.resx b/src/AprsMap.UI/Localization/Strings.resx index 7ed05a4d..ee98a781 100644 --- a/src/AprsMap.UI/Localization/Strings.resx +++ b/src/AprsMap.UI/Localization/Strings.resx @@ -4364,10 +4364,37 @@ pause 1s Every interval - By speed and turns + By speed and turns (with a GPS) + + + Needs a GPS among the profile's sources. While it has a fix, the position goes at the fast rate at speed and the slow rate when stopped, more often as the speed rises, and at once on a turn; on a TNC too. Without a fix for a minute, every interval. + + + Slow at or below + + + Slow rate (minutes) + + + Fast at or above + + + Fast rate (seconds) + + + Turn angle (degrees) + + + Turn slope (degrees × speed) + + + Turn time (seconds) + + + The speeds and turns can't be used: speeds above 0 with the slow one below the fast one, the fast rate shorter than the slow rate, and the turn settings 0 or more. - After a change, the next beacons follow at 30 seconds, 1, 2, 4, 8 and 16 minutes on APRS-IS, then settle at the interval. A TNC never gets positions less than 10 minutes apart. Beaconing by speed and turns comes with GPS. + After a change, the next beacons follow at 30 seconds, 1, 2, 4, 8 and 16 minutes on APRS-IS, then settle at the interval. A TNC never gets positions less than 10 minutes apart, except by speed and turns. Interval diff --git a/src/AprsMap.UI/Settings/BeaconsSectionViewModel.cs b/src/AprsMap.UI/Settings/BeaconsSectionViewModel.cs index 552de0fd..4cfeab6a 100644 --- a/src/AprsMap.UI/Settings/BeaconsSectionViewModel.cs +++ b/src/AprsMap.UI/Settings/BeaconsSectionViewModel.cs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only using System.Collections.ObjectModel; +using System.Globalization; using AprsMap.Contracts; using AprsMap.UI.Localization; using CommunityToolkit.Mvvm.ComponentModel; @@ -78,8 +79,109 @@ internal BeaconsSectionViewModel(IEngineService engine, Localizer text, ActionThe schedule chosen. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(IsMoving))] public partial BeaconChoice? SelectedSchedule { get; set; } + // ── The moving schedule (SmartBeaconing, revised F2.2 for #335) ────────────────────────────────────────────── + + /// Whether the moving schedule's speeds, rates and turns show. + public bool IsMoving => SelectedSchedule?.Value == PositionBeaconSchedule.Moving; + + /// "km/h", "mph" or "kn": the speeds' unit, after the viewer's distance unit. + [ObservableProperty] + public partial string SpeedUnitText { get; private set; } = "km/h"; + + /// At or below this speed, the slow rate. + [ObservableProperty] + public partial string SlowSpeed { get; set; } = string.Empty; + + /// The slow rate, in minutes. + [ObservableProperty] + public partial string SlowRateMinutes { get; set; } = string.Empty; + + /// At or above this speed, the fast rate. + [ObservableProperty] + public partial string FastSpeed { get; set; } = string.Empty; + + /// The fast rate, in seconds. + [ObservableProperty] + public partial string FastRateSeconds { get; set; } = string.Empty; + + /// The smallest turn at speed, in degrees. + [ObservableProperty] + public partial string TurnAngle { get; set; } = string.Empty; + + /// How much more a slow station must turn, in degrees × the speed unit. + [ObservableProperty] + public partial string TurnSlope { get; set; } = string.Empty; + + /// The shortest time from the last beacon to a turn beacon, in seconds. + [ObservableProperty] + public partial string TurnTimeSeconds { get; set; } = string.Empty; + + /// Knots in one of the speed unit. + private double _knotsPerUnit = 1 / 1.852; + + private void ShowMoving(MovingBeaconSettings moving, DistanceUnit distance) + { + (_knotsPerUnit, SpeedUnitText) = distance switch + { + DistanceUnit.Miles => (1 / MovingBeaconSettings.MphPerKnot, "mph"), + DistanceUnit.NauticalMiles => (1.0, "kn"), + _ => (1 / 1.852, "km/h"), + }; + SlowSpeed = Plain(moving.SlowSpeedKnots / _knotsPerUnit); + SlowRateMinutes = Plain(moving.SlowRateSeconds / 60.0); + FastSpeed = Plain(moving.FastSpeedKnots / _knotsPerUnit); + FastRateSeconds = Plain(moving.FastRateSeconds); + TurnAngle = Plain(moving.TurnAngle); + TurnSlope = Plain(moving.TurnSlope / _knotsPerUnit); + TurnTimeSeconds = Plain(moving.TurnTimeSeconds); + + static string Plain(double value) => Math.Round(value, 1).ToString("0.#", CultureInfo.InvariantCulture); + } + + /// The moving settings as typed, in knots and seconds; null when one isn't a number. + private MovingBeaconSettings? MovingFromForm() + { + if ( + !Number(SlowSpeed, out var slow) + || !Number(SlowRateMinutes, out var slowRate) + || !Number(FastSpeed, out var fast) + || !Number(FastRateSeconds, out var fastRate) + || !Number(TurnAngle, out var angle) + || !Number(TurnSlope, out var slope) + || !Number(TurnTimeSeconds, out var turnTime) + ) + { + return null; + } + var typed = new MovingBeaconSettings( + slow * _knotsPerUnit, + (int)Math.Round(slowRate * 60), + fast * _knotsPerUnit, + (int)Math.Round(fastRate), + angle, + slope * _knotsPerUnit, + (int)Math.Round(turnTime) + ); + // Unchanged from the defaults, as shown rounded: none of its own. + var shown = MovingBeaconSettings.Default; + return + Math.Abs(typed.SlowSpeedKnots - shown.SlowSpeedKnots) < 0.1 * _knotsPerUnit + && typed.SlowRateSeconds == shown.SlowRateSeconds + && Math.Abs(typed.FastSpeedKnots - shown.FastSpeedKnots) < 0.1 * _knotsPerUnit + && typed.FastRateSeconds == shown.FastRateSeconds + && Math.Abs(typed.TurnAngle - shown.TurnAngle) < 0.1 + && Math.Abs(typed.TurnSlope - shown.TurnSlope) < 0.1 * _knotsPerUnit + && typed.TurnTimeSeconds == shown.TurnTimeSeconds + ? MovingBeaconSettings.Default + : typed; + + static bool Number(string text, out double value) => + double.TryParse(text.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + /// The intervals, named. public ObservableCollection> PositionIntervals { get; } = []; @@ -266,6 +368,7 @@ internal void Show(AppSettings settings) PositionOn = position.On; SelectedSchedule = Schedules.FirstOrDefault(s => s.Value == position.Schedule); SelectedPositionInterval = PositionIntervals.FirstOrDefault(i => i.Value == position.IntervalMinutes); + ShowMoving(position.EffectiveMoving, settings.Units.Distance); MicE = position.MicE is not null; Compressed = !MicE && position.Compressed; FormatStandard = !MicE && !Compressed; @@ -325,10 +428,7 @@ private void Fill(int? positionMinutes, int? statusMinutes) Schedules.Clear(); Schedules.Add(new(PositionBeaconSchedule.Decay, _text["Beacons.Schedule.Decay"])); Schedules.Add(new(PositionBeaconSchedule.Fixed, _text["Beacons.Schedule.Fixed"])); - if (schedule == PositionBeaconSchedule.Moving) - { - Schedules.Add(new(PositionBeaconSchedule.Moving, _text["Beacons.Schedule.Moving"])); // saved elsewhere - } + Schedules.Add(new(PositionBeaconSchedule.Moving, _text["Beacons.Schedule.Moving"])); // with a GPS (#335) SelectedSchedule = Schedules.FirstOrDefault(s => s.Value == schedule); var status = SelectedMicEStatus?.Value ?? _savedMicE ?? MicEStatus.OffDuty; MicEStatuses.Clear(); @@ -422,6 +522,12 @@ private async Task SaveBeaconsAsync() { return false; } + var moving = MovingFromForm(); + if (moving is null) + { + Error = _text["Error.settings.profile.moving-invalid"]; + return false; + } var position = new PositionBeaconSettings( PositionOn, SelectedSchedule?.Value ?? PositionBeaconSchedule.Decay, @@ -436,7 +542,8 @@ private async Task SaveBeaconsAsync() SelectedDirection is { Value: > 0 } d ? d.Value : null ) : null, - MicE ? SelectedMicEStatus?.Value ?? MicEStatus.OffDuty : null + MicE ? SelectedMicEStatus?.Value ?? MicEStatus.OffDuty : null, + moving == MovingBeaconSettings.Default ? null : moving ); var status = new StatusBeaconSettings( StatusOn, diff --git a/src/AprsMap.UI/Views/SettingsView.axaml b/src/AprsMap.UI/Views/SettingsView.axaml index 20204e50..dcda8a84 100644 --- a/src/AprsMap.UI/Views/SettingsView.axaml +++ b/src/AprsMap.UI/Views/SettingsView.axaml @@ -737,6 +737,118 @@ Foreground="{DynamicResource Brush.TextMuted}" TextWrapping="Wrap" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The moving schedule: SmartBeaconing from the GPS (docs/features/F2.2-beacons.md, revised for #335). +public sealed class MovingBeaconTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static readonly SourceSettings AprsIs = InMemorySettingsStore.Rotate; + + private static readonly SourceSettings Gps = SourceSettings.GpsNetworkFor(GpsLink.Tcp, "127.0.0.1") with + { + Id = "gps", + }; + + private static readonly PositionBeaconSettings Moving = new( + On: true, + Schedule: PositionBeaconSchedule.Moving, + IntervalMinutes: 30 + ); + + private sealed class Station(Engine engine, FakeTimeProvider time, Pipe gps, EngineTests.FakeSourceFactory sources) + : IDisposable + { + private int _seconds; + + public Engine Engine { get; } = engine; + + public FakeTimeProvider Time { get; } = time; + + public EngineTests.FakeSourceFactory Sources { get; } = sources; + + public void Dispose() => Engine.Dispose(); + + /// + /// Drives for at on : a fix every + /// seconds (fresh for 60), the clock moving with it. + /// + public async Task DriveAsync(int seconds, double knots, double course, int step = 10) + { + for (var t = 0; t < seconds; t += step) + { + var hms = TimeSpan.FromSeconds(_seconds).ToString(@"hhmmss", CultureInfo.InvariantCulture); + var speed = knots.ToString("000.0", CultureInfo.InvariantCulture); + var heading = course.ToString("000.0", CultureInfo.InvariantCulture); + var text = + GpsTests.Nmea($"GPRMC,{hms},A,4903.500,N,07201.750,W,{speed},{heading},211026,,,A") + + GpsTests.Nmea($"GPGGA,{hms},4903.500,N,07201.750,W,1,09,0.9,545.4,M,46.9,M,,") + + GpsTests.Nmea("GPGSA,A,3,04,05,09,12,24,25,29,31,32,,,,2.5,0.9,2.1"); + await gps.Writer.WriteAsync(Encoding.ASCII.GetBytes(text), Ct); + await Task.Delay(TimeSpan.FromMilliseconds(30), TimeProvider.System, Ct); + Time.Advance(TimeSpan.FromSeconds(step)); + _seconds += step; + await Task.Delay(TimeSpan.FromMilliseconds(30), TimeProvider.System, Ct); + } + } + } + + private static Station Create(PositionBeaconSettings beacon) + { + var home = InMemorySettingsStore.Profile("N0TEST") with + { + Passcode = 12345, + Latitude = 40, + Longitude = -105, + Licensed = true, + PositionConfirmed = true, + SourceIds = [AprsIs.Id, Gps.Id], + PositionBeacon = beacon, + }; + var store = new InMemorySettingsStore( + new AppSettings( + [home], + home.Id, + UnitSettings.Metric, + Sources: [AprsIs, Gps], + Toggles: StationToggles.AllOn with + { + Transmit = true, + } + ) + ); + var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-21T00:00:00Z", CultureInfo.InvariantCulture)); + var factory = new EngineTests.FakeSourceFactory(); + var pipe = new Pipe(); + factory.Give(Gps.Id, new NmeaGpsSource(_ => Task.FromResult(pipe.Reader.AsStream()), "test", time)); + var engine = new Engine(Options.Create(new EngineOptions()), factory, store, time, NullLogger.Instance); + return new Station(engine, time, pipe, factory); + } + + private static int Positions(EngineTests.FakeSource source) => + source.Sent.Count(l => AprsPacket.Parse(l).Payload is PositionReport); + + private static async Task<(Task Run, CancellationTokenSource Cts, EngineTests.FakeSource AprsIs)> Run( + Station station + ) + { + var aprsIs = station.Sources.Enqueue("T2TEST", verified: true); + var cts = CancellationTokenSource.CreateLinkedTokenSource(Ct); + var run = station.Engine.RunAsync(cts.Token); + for (var i = 0; i < 400; i++) + { + if ( + (await station.Engine.GetStatusAsync(Ct)).Sources is { Count: 2 } sources + && sources.All(s => s.State == ConnectionState.Connected) + ) + { + return (run, cts, aprsIs); + } + await Task.Delay(TimeSpan.FromMilliseconds(10), TimeProvider.System, Ct); + } + Assert.Fail("The sources didn't connect."); + return default; + } + + [Fact] + public async Task AtSpeedItBeaconsEveryNinetySecondsAndOnATurn() + { + using var station = Create(Moving); + var (run, cts, aprsIs) = await Run(station); + using (cts) + { + await station.DriveAsync(10, 60, 90); // 69 mph: the first beacon at once + Assert.Equal(1, Positions(aprsIs)); + + await station.DriveAsync(70, 60, 90); + Assert.Equal(1, Positions(aprsIs)); // 80 s since the first: not yet + await station.DriveAsync(20, 60, 90); + Assert.Equal(2, Positions(aprsIs)); + + await station.DriveAsync(20, 60, 180); // a right turn of 90°: at once, after the 10 s turn time + Assert.Equal(3, Positions(aprsIs)); + var turned = Assert.IsType(AprsPacket.Parse(aprsIs.Sent.Last()).Payload); + Assert.Equal(180, turned.Course); + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task StoppedItBeaconsAtTheSlowRate() + { + using var station = Create(Moving); + var (run, cts, aprsIs) = await Run(station); + using (cts) + { + await station.DriveAsync(10, 0, 0); + Assert.Equal(1, Positions(aprsIs)); + + await station.DriveAsync(25 * 60, 0, 0, step: 50); + Assert.Equal(1, Positions(aprsIs)); + await station.DriveAsync(6 * 60, 0, 0, step: 50); // 30 minutes stopped + Assert.Equal(2, Positions(aprsIs)); + await cts.CancelAsync(); + await run; + } + } + + [Fact] + public async Task WithoutAGpsFixItBeaconsAtTheFixedInterval() + { + using var station = Create(Moving with { IntervalMinutes = 10 }); + var (run, cts, aprsIs) = await Run(station); + using (cts) + { + station.Time.Advance(Beaconer.MaxStartWait); + await Task.Delay(TimeSpan.FromMilliseconds(100), TimeProvider.System, Ct); + Assert.Equal(1, Positions(aprsIs)); + var fixedSpot = Assert.IsType(AprsPacket.Parse(aprsIs.Sent.Last()).Payload); + Assert.Equal(40, (int)Math.Round(fixedSpot.Position.Latitude)); // the profile's location + + station.Time.Advance(TimeSpan.FromMinutes(10)); + await Task.Delay(TimeSpan.FromMilliseconds(100), TimeProvider.System, Ct); + Assert.Equal(2, Positions(aprsIs)); + await cts.CancelAsync(); + await run; + } + } + + [Theory] + [InlineData(4.3, 1800, 52.1, 1900)] // the fast rate slower than the slow one + [InlineData(60, 1800, 10, 90)] // the slow speed above the fast one + public async Task UnusableMovingSettingsAreRefused(double slow, int slowRate, double fast, int fastRate) + { + using var station = Create(Moving); + var settings = await station.Engine.GetSettingsAsync(Ct); + var profile = settings.GetActiveProfile()! with + { + PositionBeacon = Moving with { Moving = new MovingBeaconSettings(slow, slowRate, fast, fastRate) }, + }; + + var error = await Assert.ThrowsAsync(() => + station.Engine.SaveSettingsAsync(settings with { Profiles = [profile] }, Ct) + ); + + Assert.Equal(EngineErrorCodes.ProfileMovingInvalid, error.EngineError.Code); + } +} diff --git a/tests/AprsMap.UI.Tests/BeaconsUiTests.cs b/tests/AprsMap.UI.Tests/BeaconsUiTests.cs index 3f6c6f83..9f3ebc28 100644 --- a/tests/AprsMap.UI.Tests/BeaconsUiTests.cs +++ b/tests/AprsMap.UI.Tests/BeaconsUiTests.cs @@ -46,7 +46,10 @@ public async Task BeaconsAreOffAndSavingThemKeepsSendViaOnEverySourceUnlessOneIs Assert.False(section.PositionOn); Assert.False(section.StatusOn); Assert.Equal(PositionBeaconSchedule.Decay, section.SelectedSchedule!.Value); - Assert.Equal(["Soon after a change, then less often", "Every interval"], section.Schedules.Select(s => s.Name)); + Assert.Equal( + ["Soon after a change, then less often", "Every interval", "By speed and turns (with a GPS)"], + section.Schedules.Select(s => s.Name) + ); Assert.Equal("30 minutes", section.SelectedPositionInterval!.Name); Assert.Equal("1 hour", section.SelectedStatusInterval!.Name); Assert.Equal(["rotate.aprs2.net", "Direwolf"], section.PositionVia.Select(v => v.Name)); @@ -152,4 +155,46 @@ EngineStatus Status(TransmitStatus transmit) => Assert.Equal("Beacons are waiting: Send your position once by hand to start beaconing.", vm.TransmitTip); Assert.False(vm.IsTransmitHeld); // a hand send is still possible: that's what starts them } + + [Fact] + public async Task MovingShowsItsSettingsInTheViewersUnitsAndSavesThemInKnots() + { + var (section, engine) = Create( + Licensed() with + { + Units = UnitSettings.Metric with { Distance = DistanceUnit.Miles }, + } + ); + Assert.False(section.IsMoving); + + section.SelectedSchedule = section.Schedules.Single(s => s.Value == PositionBeaconSchedule.Moving); + Assert.True(section.IsMoving); + Assert.Equal( + ("mph", "5", "30", "60", "90"), + ( + section.SpeedUnitText, + section.SlowSpeed, + section.SlowRateMinutes, + section.FastSpeed, + section.FastRateSeconds + ) + ); + Assert.Equal(("30", "255", "10"), (section.TurnAngle, section.TurnSlope, section.TurnTimeSeconds)); + + await section.SaveCommand.ExecuteAsync(null); + var unchanged = engine.Settings.GetActiveProfile()!.EffectivePositionBeacon; + Assert.Equal(PositionBeaconSchedule.Moving, unchanged.Schedule); + Assert.Null(unchanged.Moving); // the defaults: none of its own + + section.FastSpeed = "45"; + section.FastRateSeconds = "120"; + await section.SaveCommand.ExecuteAsync(null); + var moving = engine.Settings.GetActiveProfile()!.EffectivePositionBeacon.EffectiveMoving; + Assert.Equal(45 / MovingBeaconSettings.MphPerKnot, moving.FastSpeedKnots, 3); + Assert.Equal(120, moving.FastRateSeconds); + + section.TurnAngle = "sharp"; + await section.SaveCommand.ExecuteAsync(null); + Assert.StartsWith("The speeds and turns can't be used", section.Error, StringComparison.Ordinal); + } } From 3fa2c3759b431bf66e0b7c1bede0c246a552dd21 Mon Sep 17 00:00:00 2001 From: gx1400 Date: Wed, 21 Oct 2026 00:23:15 -0500 Subject: [PATCH 3/3] feat(map): dead reckoning, where moving stations probably are now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Settings › Map › Show where moving stations probably are now (off by default), a station whose latest position gave a course and a speed of at least 5 km/h is drawn for up to 10 minutes where it has probably got to, with a dashed line back to where it was heard and a ring that shrinks as the estimate ages; Details add where it probably is now. The station stream carries the latest report's own course and speed. Distances and sorting stay on the reported position. Closes #198 --- docs/features/F1.17-dead-reckoning.md | 25 +-- docs/scope.md | 2 +- src/AprsMap.Contracts/StationBatch.cs | 9 +- src/AprsMap.Core/StationStore.cs | 11 +- .../Appearance/AppearancePreferences.cs | 8 + .../Appearance/AppearanceService.cs | 1 + .../Appearance/AppearanceSettings.cs | 6 + src/AprsMap.UI/Localization/Strings.resx | 16 +- src/AprsMap.UI/Map/MapView.cs | 150 +++++++++++++++++- src/AprsMap.UI/Map/StationLayer.cs | 8 +- src/AprsMap.UI/Settings/SettingsRegistry.cs | 10 ++ .../Stations/StationDetailsViewModel.cs | 26 +++ src/AprsMap.UI/ViewModels/MainViewModel.cs | 6 + src/AprsMap.UI/Views/DetailsPanel.axaml | 18 +++ tests/AprsMap.Core.Tests/StationStoreTests.cs | 21 +++ tests/AprsMap.UI.Tests/DeadReckoningTests.cs | 94 +++++++++++ tests/AprsMap.UI.Tests/MapTests.cs | 1 + 17 files changed, 395 insertions(+), 17 deletions(-) create mode 100644 tests/AprsMap.UI.Tests/DeadReckoningTests.cs diff --git a/docs/features/F1.17-dead-reckoning.md b/docs/features/F1.17-dead-reckoning.md index 4d750877..9fa06829 100644 --- a/docs/features/F1.17-dead-reckoning.md +++ b/docs/features/F1.17-dead-reckoning.md @@ -1,6 +1,7 @@ # F1.17 Dead reckoning -Status: draft +Status: built for 0.6.0 ([#198](https://github.com/APRS-NET/AprsMap/issues/198)); decisions proposed, revised +2026-10-21 for the maintainer's review. In the UI: Settings › Map › Show where moving stations probably are now. ## User story @@ -11,22 +12,26 @@ estimate, with an indication of how old the real fix is. ## Behavior 1. A station whose last position report carried **course and speed**, with a speed of at least 5 km/h, gets an - **estimated position**: from the last reported position along the course at that speed, for the time since. + **estimated position**: from the last reported position along the course at that speed, for the time since. Only + the latest report's own course and speed count: a later report without them (a parked station) ends the estimate, + and a weather station's course and speed are its wind. 2. Estimates stop after **10 minutes** or when the station goes stale (F1.3), whichever is first; after that the station is shown only at its last reported position. -3. **On the map**: the station's symbol moves to the estimated position, drawn with a dashed outline, and a thin dashed - line joins it to the last reported position, which keeps a small dot. A **freshness ring** around the symbol shows - the share of the 10 minutes left, shrinking as the fix ages. -4. The station's details (F1.6) show both: "Reported 3 min ago at …" and "Estimated now at …, 4.5 km on". +3. **On the map**: the station's symbol and label move to the estimated position, and a thin dashed line in the track + colour joins it to the last reported position, which keeps a small dot. A **freshness ring** around the symbol shows + the share of the 10 minutes left, an arc shrinking as the fix ages (proposed: the ring marks the estimate, rather + than a dashed outline on the symbol). Clicking the symbol where it's drawn selects the station. +4. The station's details (F1.6) keep the reported position and add a **Now** line: "Probably 2.2 km on along its + course, if it kept going". 5. Distance and bearing (F1.13) and list sorting (F1.7) use the reported position, never the estimate. 6. **Dead reckoning** (Settings › Map, and a layer toggle): On · Off; default Off. Per viewer. -7. Estimates move smoothly: they're recomputed each frame from the time (no engine traffic), within F1.4's frame - budget. +7. Estimates are worked out in the UI from the time (no engine traffic), each frame and at least **once a second** + while there's one (proposed: a second's step is smooth enough at map scales, and costs nothing between). ## Service surface -- `StationState` (the map stream) gains `Course` and `Speed` (km/h) and `PositionTime` (when the reported position was - received), for stations that report them; the UI computes the estimate per frame. +- `StationState` (the map stream) gains `Course` and `SpeedKmh`, from the latest position report only; its + `LastHeard` is when that position came. The UI computes the estimate. - The estimate needs a destination-point calculation (G08) in the UI, which references only `AprsMap.Contracts`. Until that is settled (decision 3), it is a small pure function in the UI, marked `// SHIM: G08`. diff --git a/docs/scope.md b/docs/scope.md index 07256a2c..bc7e00c5 100644 --- a/docs/scope.md +++ b/docs/scope.md @@ -93,7 +93,7 @@ milestone ([0.3.0-alpha.1](https://github.com/APRS-NET/AprsMap/milestone/1), don | F1.14 | Gateway ([spec](features/F1.14-gateway.md)): hosts the engine, browser bundle, anonymous read-only viewing | — | App | — | done | | F1.15 | Quick toggles ([spec](features/F1.15-quick-toggles.md)): APRS-IS, internet-access master switch (gates tiles too), GPS, beaconing, sound, tile fetching | (Enables menu) | App | — | spec'd (0.4.0) | | F1.16 | Extra map views following one station each (split view; desktop tear-off windows), auto-added to the server filter ([spec](features/F1.16-extra-map-views.md)) | Station Info, Automatic Filters | App | G04 | built (0.6.0, [#207](https://github.com/APRS-NET/AprsMap/issues/207)); decisions proposed | -| F1.17 | Dead-reckoned positions for moving stations, with a freshness indicator ([spec](features/F1.17-dead-reckoning.md)) | (Screen menu) | App | G08, G11 | spec'd (0.6.0, [#198](https://github.com/APRS-NET/AprsMap/issues/198)) | +| F1.17 | Dead-reckoned positions for moving stations, with a freshness indicator ([spec](features/F1.17-dead-reckoning.md)) | (Screen menu) | App | G08, G11 | built (0.6.0, [#198](https://github.com/APRS-NET/AprsMap/issues/198)); decisions proposed | | F1.18 | Set destination: distance and bearing to a chosen point ([spec](features/F1.18-destination.md)) | Station Info | App | G08 | built (0.5.0, [#194](https://github.com/APRS-NET/AprsMap/issues/194)) | | F1.19 | Data housekeeping: clear stations, tracks, messages, bulletins, telemetry, saved texts ([spec](features/F1.19-housekeeping.md)) | (Clear menu) | App | — | spec'd (0.4.0) | | F1.20 | Packet and station anomaly flags (duplicate positions, impossible speed, sub-second repeats, invalid) ([spec](features/F1.20-anomalies.md)) | Scroller | App | G24 | spec'd (0.4.0) | diff --git a/src/AprsMap.Contracts/StationBatch.cs b/src/AprsMap.Contracts/StationBatch.cs index 3c6de38c..72666152 100644 --- a/src/AprsMap.Contracts/StationBatch.cs +++ b/src/AprsMap.Contracts/StationBatch.cs @@ -17,6 +17,11 @@ namespace AprsMap.Contracts; /// For a station that reports weather: what its map label and wind barb show (F1.10). /// For an area object: the shape it draws (F1.12). /// For a signpost: the 1–3 characters it shows (F1.12). +/// +/// The course its latest position report gave, 1–360 degrees, for dead reckoning (docs/features/F1.17-dead-reckoning.md); +/// null when that report had none. +/// +/// The speed its latest position report gave, with ; null when none. public sealed record StationState( string Name, DateTimeOffset LastHeard, @@ -29,7 +34,9 @@ public sealed record StationState( int? BearingDegrees = null, WeatherSummary? Weather = null, AreaInfo? Area = null, - string? Signpost = null + string? Signpost = null, + int? Course = null, + double? SpeedKmh = null ); /// diff --git a/src/AprsMap.Core/StationStore.cs b/src/AprsMap.Core/StationStore.cs index 7b7f4bc7..3a708228 100644 --- a/src/AprsMap.Core/StationStore.cs +++ b/src/AprsMap.Core/StationStore.cs @@ -860,6 +860,7 @@ private void SetPosition(Entry entry, PositionedPayload positioned, DateTimeOffs { SetWeather(entry, weather, receivedAt); // A weather station's course and speed are the wind (APRS101 ch. 12), not how it moves. + entry.Motion = null; entry.Symbol = positioned.Symbol.ToString(); entry.Comment = string.IsNullOrWhiteSpace(positioned.Comment) ? entry.Comment : positioned.Comment; entry.AltitudeMetres = positioned.AltitudeFeet is { } wxFeet @@ -871,6 +872,9 @@ private void SetPosition(Entry entry, PositionedPayload positioned, DateTimeOffs entry.Comment = string.IsNullOrWhiteSpace(positioned.Comment) ? entry.Comment : positioned.Comment; entry.Course = positioned.Course ?? entry.Course; entry.SpeedKmh = positioned.SpeedKnots is { } knots ? knots * KmhPerKnot : entry.SpeedKmh; + entry.Motion = positioned is { Course: { } course, SpeedKnots: { } speed } + ? (course, speed * KmhPerKnot) + : null; entry.AltitudeMetres = positioned.AltitudeFeet is { } feet ? feet * MetresPerFoot : entry.AltitudeMetres; } @@ -1343,6 +1347,9 @@ internal sealed class Entry(string name, StationKind kind) public string? Status { get; set; } public int? Course { get; set; } public double? SpeedKmh { get; set; } + + /// The latest position report's own course and speed, not carried over, for dead reckoning (F1.17). + public (int Course, double SpeedKmh)? Motion { get; set; } public double? AltitudeMetres { get; set; } public string? Path { get; set; } public string? Owner { get; set; } @@ -1492,7 +1499,9 @@ Weather is { } w ) : null, Area, - Signpost + Signpost, + Motion?.Course, + Motion?.SpeedKmh ); } diff --git a/src/AprsMap.UI/Appearance/AppearancePreferences.cs b/src/AprsMap.UI/Appearance/AppearancePreferences.cs index c6efcb3c..1e13f647 100644 --- a/src/AprsMap.UI/Appearance/AppearancePreferences.cs +++ b/src/AprsMap.UI/Appearance/AppearancePreferences.cs @@ -128,6 +128,7 @@ internal static string Write(AppearanceSettings s) json.WriteString("mapWeatherLabels", s.MapWeatherLabels.ToString()); json.WriteString("mapWind", s.MapWind.ToString()); json.WriteBoolean("showFilterCircles", s.ShowFilterCircles); + json.WriteBoolean("deadReckoning", s.DeadReckoning); if (s.MapProvider is { } provider) { json.WriteString("mapProvider", provider); @@ -253,6 +254,13 @@ internal static string Write(AppearanceSettings s) { s = s with { ShowFilterCircles = circles.GetBoolean() }; } + if ( + root.TryGetProperty("deadReckoning", out var reckoning) + && reckoning.ValueKind is JsonValueKind.True or JsonValueKind.False + ) + { + s = s with { DeadReckoning = reckoning.GetBoolean() }; + } if (root.TryGetProperty("mapProvider", out var provider) && provider.ValueKind == JsonValueKind.String) { s = s with { MapProvider = provider.GetString() }; diff --git a/src/AprsMap.UI/Appearance/AppearanceService.cs b/src/AprsMap.UI/Appearance/AppearanceService.cs index f674618d..3f2de8f9 100644 --- a/src/AprsMap.UI/Appearance/AppearanceService.cs +++ b/src/AprsMap.UI/Appearance/AppearanceService.cs @@ -185,6 +185,7 @@ private void ApplyPalette(Application app, Palette palette) ); yield return ("Map.ShowLabels", s.MapLabels == MapLabels.AllThatFit); yield return ("Map.ShowFilterCircles", s.ShowFilterCircles); + yield return ("Map.DeadReckoning", s.DeadReckoning); yield return ("Map.Tracks", s.MapTracks); yield return ("Map.Paths", s.MapPaths); yield return ("Map.ShowAreas", s.ShowAreas); diff --git a/src/AprsMap.UI/Appearance/AppearanceSettings.cs b/src/AprsMap.UI/Appearance/AppearanceSettings.cs index c0e10de3..2090392a 100644 --- a/src/AprsMap.UI/Appearance/AppearanceSettings.cs +++ b/src/AprsMap.UI/Appearance/AppearanceSettings.cs @@ -201,6 +201,12 @@ public sealed record AppearanceSettings /// public bool ShowFilterCircles { get; init; } = true; + /// + /// Whether moving stations are also drawn where they probably are now (docs/features/F1.17-dead-reckoning.md, + /// behavior 6); off by default. + /// + public bool DeadReckoning { get; init; } + /// The map symbol sizes Settings › Map offers. public static IReadOnlyList MapSymbolScaleSteps { get; } = [0.75, 1.0, 1.25, 1.5, 1.75, 2.0]; diff --git a/src/AprsMap.UI/Localization/Strings.resx b/src/AprsMap.UI/Localization/Strings.resx index ee98a781..d9dd7cfe 100644 --- a/src/AprsMap.UI/Localization/Strings.resx +++ b/src/AprsMap.UI/Localization/Strings.resx @@ -266,7 +266,13 @@ Show the filter's ranges on the map - Dashed circles show where APRS-IS sends stations from: around your location, the map and any station you follow. + Dashed circles show where APRS-IS sends stations from: around your location, and around any station you follow or watch. + + + Show where moving stations probably are now + + + For up to 10 minutes after a moving station's last position, its symbol moves on along its course at its speed, with a dashed line back to where it was heard and a ring that shrinks as the estimate ages. Distances and sorting still use where it was heard. APRS-IS source @@ -3601,6 +3607,14 @@ Start the gateway (for example with docker compose up), then open the app from t From me + + Now + Label of where dead reckoning puts a moving station now. + + + Probably {distance} on along its course, if it kept going + distance: how far it has probably gone since its last position, in the viewer's unit. + Motion diff --git a/src/AprsMap.UI/Map/MapView.cs b/src/AprsMap.UI/Map/MapView.cs index 63a50d74..52103828 100644 --- a/src/AprsMap.UI/Map/MapView.cs +++ b/src/AprsMap.UI/Map/MapView.cs @@ -72,6 +72,13 @@ public sealed class MapView : Control, IDisposable private IBrush? _labelBrush; private IBrush? _haloBrush; private IPen? _ringPen; + private IPen? _estimatePen; + private IPen? _freshnessPen; + private IBrush? _estimateDot; + + // Where each estimated station is drawn this frame, in Web Mercator metres, for hit-testing (F1.17). + private Dictionary _estimated = new(StringComparer.Ordinal); + private readonly DispatcherTimer _reckoningTimer = new() { Interval = TimeSpan.FromSeconds(1) }; private IPen? _filterPen; private ImmutablePen? _destinationPen; private ImmutablePen? _destinationPolePen; @@ -137,6 +144,14 @@ public MapView() } ); Watch("Map.ShowFilterCircles", v => ShowFilterCircles = v is true); + Watch("Map.DeadReckoning", v => DeadReckoning = v is true); + _reckoningTimer.Tick += (_, _) => + { + if (_estimated.Count > 0) + { + InvalidateVisual(); // estimates move on with the clock + } + }; Watch("Map.Tracks", v => Tracks = v is MapTracks tracks ? tracks : MapTracks.All); Watch("Map.Paths", v => Paths = v is MapPaths paths ? paths : MapPaths.SelectedStation); Watch( @@ -171,6 +186,12 @@ public MapView() v => { _trackColour = v is Color c ? new SKColor(c.R, c.G, c.B, c.A) : SKColors.Transparent; + // Dead reckoning's line, dot and freshness ring, in the track colour (F1.17 behavior 3). + _estimatePen = v is Color e + ? new ImmutablePen(new ImmutableSolidColorBrush(e), 1.5, new ImmutableDashStyle([2, 3], 0)) + : null; + _freshnessPen = v is Color r ? new ImmutablePen(new ImmutableSolidColorBrush(r), 2) : null; + _estimateDot = v is Color d ? new ImmutableSolidColorBrush(d) : null; InvalidateVisual(); } ); @@ -213,6 +234,77 @@ public void Dispose() /// The unit the hover text gives distances from my location in (F1.13 behavior 4). public DistanceUnit DistanceUnit { get; set; } + /// + /// Whether moving stations are drawn where they probably are now (docs/features/F1.17-dead-reckoning.md): their + /// estimate is redrawn every second while there is one. + /// + public bool DeadReckoning + { + get; + set + { + field = value; + if (value) + { + _reckoningTimer.Start(); + } + else + { + _reckoningTimer.Stop(); + _estimated = new(StringComparer.Ordinal); + } + InvalidateVisual(); + } + } + + /// How long after a station's position its estimate is drawn (F1.17 behavior 2). + public static readonly TimeSpan ReckonFor = TimeSpan.FromMinutes(10); + + /// The slowest speed estimated, in km/h; slower, a GPS's course means little (F1.17 behavior 1). + public const double ReckonFromKmh = 5; + + /// + /// Where probably is at , in Web Mercator metres, and the share of + /// left; null when it isn't estimated (F1.17 behaviors 1, 2). + /// + internal static (double X, double Y, double Left)? Estimate(StationMark mark, DateTimeOffset now) + { + var age = now - mark.LastHeard; + if ( + mark is not { Course: { } course, SpeedKmh: >= ReckonFromKmh and var kmh, IsStale: false } + || age <= TimeSpan.Zero + || age > ReckonFor + ) + { + return null; + } + var lonLat = SphericalMercator.ToLonLat(mark.X, mark.Y); + var (lat, lon) = PointAlong(lonLat.lat, lonLat.lon, course, kmh * age.TotalHours); + var (x, y) = SphericalMercator.FromLonLat(lon, lat); + return (x, y, 1 - age / ReckonFor); + } + + /// The point from a place along , on a sphere. + // SHIM: G08 — APRS.NET's destination point, until the UI can use its geodesy (F1.17 decision 3). + internal static (double Latitude, double Longitude) PointAlong( + double latitude, + double longitude, + double bearing, + double km + ) + { + const double EarthKm = 6371.0088; + var d = km / EarthKm; + var b = bearing * Math.PI / 180; + var lat1 = latitude * Math.PI / 180; + var lon1 = longitude * Math.PI / 180; + var lat2 = Math.Asin(Math.Sin(lat1) * Math.Cos(d) + Math.Cos(lat1) * Math.Sin(d) * Math.Cos(b)); + var lon2 = + lon1 + + Math.Atan2(Math.Sin(b) * Math.Sin(d) * Math.Cos(lat1), Math.Cos(d) - Math.Sin(lat1) * Math.Sin(lat2)); + return (lat2 * 180 / Math.PI, ((lon2 * 180 / Math.PI) + 540) % 360 - 180); + } + /// Whether the filter's range parts are drawn as dashed circles (F1.2 behavior 6). public bool ShowFilterCircles { @@ -807,10 +899,22 @@ public override void Render(DrawingContext context) var viewport = _map.Navigator.Viewport; var marks = _drawn; var screen = new SKPoint[marks.Count]; + // Dead reckoning (F1.17): a moving station is drawn where it probably is now; where it was heard keeps a dot. + var now = Time.GetUtcNow(); + var estimates = new List<(SKPoint Heard, SKPoint Now, double Left)>(); + var estimated = new Dictionary(StringComparer.Ordinal); for (var i = 0; i < marks.Count; i++) { screen[i] = ToScreen(viewport, marks[i]); + if (DeadReckoning && Estimate(marks[i], now) is var (ex, ey, left)) + { + var probably = ToScreen(viewport, marks[i] with { X = ex, Y = ey }); + estimates.Add((screen[i], probably, left)); + estimated[marks[i].Name] = (ex, ey); + screen[i] = probably; + } } + _estimated = estimated; // The map and symbols first: the tiles would cover anything drawn before them. // Everything the render thread needs is read here, on the UI thread: styled properties such as the theme // variant can't be read from the render thread (on desktop that throws every frame and blanks the window). @@ -846,6 +950,7 @@ public override void Render(DrawingContext context) } DrawFilterAreas(context, viewport); DrawDestination(context, viewport); + DrawEstimates(context, estimates); DrawLabels(context, marks, screen); if (Selected is { } name && _ringPen is not null && FindIndex(marks, name) is { } selected) { @@ -858,6 +963,45 @@ public override void Render(DrawingContext context) } } + /// + /// For each estimate, a dashed line back to where the station was heard, a dot there, and a ring around the symbol + /// whose arc is the share of the 10 minutes left (F1.17 behavior 3). + /// + private void DrawEstimates(DrawingContext context, List<(SKPoint Heard, SKPoint Now, double Left)> estimates) + { + if (_estimatePen is null || _freshnessPen is null || _estimateDot is null) + { + return; + } + var radius = _symbolDip * 0.7; + foreach (var (heard, now, left) in estimates) + { + var from = new Point(heard.X, heard.Y); + var to = new Point(now.X, now.Y); + context.DrawLine(_estimatePen, from, to); + context.DrawEllipse(_estimateDot, null, from, 3, 3); + var sweep = 360 * Math.Clamp(left, 0, 1); + if (sweep >= 359.5) + { + context.DrawEllipse(null, _freshnessPen, to, radius, radius); + continue; + } + var geometry = new StreamGeometry(); + using (var arc = geometry.Open()) + { + var start = new Point(to.X, to.Y - radius); // from twelve o'clock, clockwise + var end = new Point( + to.X + radius * Math.Sin(sweep * Math.PI / 180), + to.Y - radius * Math.Cos(sweep * Math.PI / 180) + ); + arc.BeginFigure(start, false); + arc.ArcTo(end, new Size(radius, radius), 0, sweep > 180, SweepDirection.Clockwise); + arc.EndFigure(false); + } + context.DrawGeometry(null, _freshnessPen, geometry); + } + } + /// The distance of a direct route, on the dotted line, in a box like a label's halo (F1.21 behavior 2). private void DrawRouteLabel(DrawingContext context, Point at, double km) { @@ -1051,7 +1195,11 @@ internal Point ScreenPoint(double latitude, double longitude) var bestDistance = double.MaxValue; foreach (var mark in _marks) { - var at = ToScreen(viewport, mark); + // An estimated station is where it's drawn (F1.17). + var at = ToScreen( + viewport, + _estimated.TryGetValue(mark.Name, out var now) ? mark with { X = now.X, Y = now.Y } : mark + ); var distance = Math.Sqrt(Math.Pow(at.X - point.X, 2) + Math.Pow(at.Y - point.Y, 2)); if (distance <= reach && distance < bestDistance) { diff --git a/src/AprsMap.UI/Map/StationLayer.cs b/src/AprsMap.UI/Map/StationLayer.cs index f48dc175..5a1134b4 100644 --- a/src/AprsMap.UI/Map/StationLayer.cs +++ b/src/AprsMap.UI/Map/StationLayer.cs @@ -18,7 +18,9 @@ internal sealed record StationMark( int? BearingDegrees = null, WeatherSummary? Weather = null, AreaInfo? Area = null, - string? Signpost = null + string? Signpost = null, + int? Course = null, + double? SpeedKmh = null ) { /// The mark for a station from the station stream. @@ -40,7 +42,9 @@ public static StationMark From(StationState station) station.BearingDegrees, station.Weather, station.Area, - station.Signpost + station.Signpost, + station.Course, + station.SpeedKmh ); } } diff --git a/src/AprsMap.UI/Settings/SettingsRegistry.cs b/src/AprsMap.UI/Settings/SettingsRegistry.cs index 099b9eab..469f720f 100644 --- a/src/AprsMap.UI/Settings/SettingsRegistry.cs +++ b/src/AprsMap.UI/Settings/SettingsRegistry.cs @@ -353,6 +353,16 @@ .. AppearanceSettings.MapSymbolScaleSteps.Select(step => value => Update(s => s with { ShowFilterCircles = value }) ) ); + items.Add( + new ToggleSetting( + "map.dead-reckoning", + "Settings.Map.DeadReckoning", + "Settings.Map.DeadReckoning.Help", + text, + () => appearance.Current.DeadReckoning, + value => Update(s => s with { DeadReckoning = value }) + ) + ); if (aging is var (get, save)) { // Removal always after staleness, so each list offers only what fits the other's current value. The value is diff --git a/src/AprsMap.UI/Stations/StationDetailsViewModel.cs b/src/AprsMap.UI/Stations/StationDetailsViewModel.cs index ecaed5d3..cc446094 100644 --- a/src/AprsMap.UI/Stations/StationDetailsViewModel.cs +++ b/src/AprsMap.UI/Stations/StationDetailsViewModel.cs @@ -165,6 +165,16 @@ HeardRow Row(HeardStation h) => [ObservableProperty] public partial string? MotionText { get; private set; } + /// + /// "Probably 3.4 km on, along its course": where dead reckoning puts it now (docs/features/F1.17-dead-reckoning.md, + /// behavior 4); null while dead reckoning is off or the station isn't estimated. + /// + [ObservableProperty] + public partial string? EstimateText { get; private set; } + + /// Whether this viewer shows dead reckoning (Settings › Map), so the estimate is said here too. + internal bool DeadReckoning { get; set; } + /// The latest comment, control characters shown as symbols (behavior 2.5). [ObservableProperty] public partial string? Comment { get; private set; } @@ -601,6 +611,7 @@ private void Write() if (d is null || Name is null) { Symbol = KindText = HeardText = HeardTime = PositionText = LocatorText = RangeText = MotionText = null; + EstimateText = null; Comment = Status = ArrivalText = Path = DeviceText = LastPacket = HeardViaText = null; WriteWeather(null, units: UnitSettings.Metric, lastHeard: default); WriteProblems(null); @@ -658,6 +669,21 @@ d.Locator is null ? null : d.Locator; RangeText = Localization.RangeText.Format(d.DistanceKm, d.BearingDegrees, units.Distance, _text); MotionText = Motion(d, units, culture); + var age = _time.GetUtcNow() - d.LastHeard; + EstimateText = + DeadReckoning + && d is { CourseDegrees: not null, SpeedKmh: >= Map.MapView.ReckonFromKmh and var kmh } + && d.Weather is null + && age > TimeSpan.Zero + && age <= Map.MapView.ReckonFor + ? _text.Format( + "Details.Estimate", + new Dictionary + { + ["distance"] = Localization.RangeText.Distance(kmh * age.TotalHours, units.Distance, _text), + } + ) + : null; WriteWeather(d.Weather, units, d.LastHeard); WriteProblems(d.Problems); Comment = Visible(d.Comment); diff --git a/src/AprsMap.UI/ViewModels/MainViewModel.cs b/src/AprsMap.UI/ViewModels/MainViewModel.cs index 2ed88c4e..249a793b 100644 --- a/src/AprsMap.UI/ViewModels/MainViewModel.cs +++ b/src/AprsMap.UI/ViewModels/MainViewModel.cs @@ -68,6 +68,12 @@ public MainViewModel( SourceNames, name => _settings is { ReadOnly: false } && _tracks.TryGetValue(name, out var track) && track.Count > 1 ); + Details.DeadReckoning = _appearance.Current.DeadReckoning; + _appearance.Changed += (_, settings) => + { + Details.DeadReckoning = settings.DeadReckoning; // F1.17 behavior 4 + Details.Refresh(); + }; Details.CloseRequested += (_, _) => SelectedStation = null; Details.MessageRequested += (_, name) => OpenConversation(name); Details.DestinationRequested += (_, d) => _ = Destination.SetAsync(d.Latitude, d.Longitude, d.Name); diff --git a/src/AprsMap.UI/Views/DetailsPanel.axaml b/src/AprsMap.UI/Views/DetailsPanel.axaml index d7abeadf..529c5375 100644 --- a/src/AprsMap.UI/Views/DetailsPanel.axaml +++ b/src/AprsMap.UI/Views/DetailsPanel.axaml @@ -166,6 +166,24 @@ /> + + + + + + + + + diff --git a/tests/AprsMap.Core.Tests/StationStoreTests.cs b/tests/AprsMap.Core.Tests/StationStoreTests.cs index 378bead0..992cee50 100644 --- a/tests/AprsMap.Core.Tests/StationStoreTests.cs +++ b/tests/AprsMap.Core.Tests/StationStoreTests.cs @@ -246,4 +246,25 @@ public void AtTheCapTheLeastRecentlyHeardGoesFirst() Assert.Null(store.Get("N0BBB")); Assert.NotNull(store.Get("N0AAA")); } + + [Fact] + public void TheMapStreamCarriesOnlyTheLatestPositionsCourseAndSpeed() + { + var store = new StationStore(); + Apply(store, "N0CALL-9>APRS,TCPIP*:!4903.50N/07201.75W>090/048", T0); // 48 knots on course 90 + + store.Subscribe(out var moving); + var state = Assert.Single(moving); + Assert.Equal(90, state.Course); + Assert.Equal(88.9, Math.Round(state.SpeedKmh!.Value, 1)); + + Apply(store, "N0CALL-9>APRS,TCPIP*:!4903.60N/07201.75W>", T0.AddMinutes(1)); // parked: no course or speed + store.Subscribe(out var parked); + Assert.Null(Assert.Single(parked).Course); // not the one before (F1.17) + Assert.Equal(90, store.Get("N0CALL-9")!.CourseDegrees); // the details keep what was last said + + Apply(store, "WX0STN>APRS,TCPIP*:!4903.50N/07201.75W_270/010g015t068", T0); // a weather station's wind + store.Subscribe(out var all); + Assert.Null(all.Single(s => s.Name == "WX0STN").Course); + } } diff --git a/tests/AprsMap.UI.Tests/DeadReckoningTests.cs b/tests/AprsMap.UI.Tests/DeadReckoningTests.cs new file mode 100644 index 00000000..80ba1217 --- /dev/null +++ b/tests/AprsMap.UI.Tests/DeadReckoningTests.cs @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-only +using AprsMap.Contracts; +using AprsMap.UI.Map; +using AprsMap.UI.ViewModels; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.XUnit; +using Avalonia.Threading; +using Mapsui.Projections; +using Microsoft.Extensions.Time.Testing; + +namespace AprsMap.UI.Tests; + +/// Dead reckoning (docs/features/F1.17-dead-reckoning.md, acceptance tests 1, 2, 4). +public sealed class DeadReckoningTests +{ + private static readonly DateTimeOffset T0 = new(2026, 10, 21, 12, 0, 0, TimeSpan.Zero); + + private static StationMark Moving(double kmh, int course = 90, bool stale = false) => + StationMark.From( + new StationState("N0CALL-9", T0, 40, -105, "/>", IsStale: stale, Course: course, SpeedKmh: kmh) + ); + + [Fact] + public void NinetyKmhEastForTwoMinutesIsThreeKilometresEast() + { + var (x, y, left) = MapView.Estimate(Moving(90), T0.AddMinutes(2))!.Value; + + var (lon, lat) = SphericalMercator.ToLonLat(x, y); + var (lat2, lon2) = MapView.PointAlong(40, -105, 90, 3); // 3 km east + Assert.Equal(lat2, lat, 6); + Assert.Equal(lon2, lon, 6); + var km = (lon - -105) * Math.PI / 180 * 6371.0088 * Math.Cos(40 * Math.PI / 180); + Assert.InRange(km, 2.97, 3.03); // within 1 % + Assert.Equal(0.8, left, 3); // 8 of the 10 minutes left + } + + [Theory] + [InlineData(4.9, 2, false)] // too slow: the course means little + [InlineData(90, 11, false)] // more than 10 minutes since + [InlineData(90, 2, true)] // stale + public void NoEstimateWhenSlowOldOrStale(double kmh, int minutes, bool stale) + { + Assert.Null(MapView.Estimate(Moving(kmh, stale: stale), T0.AddMinutes(minutes))); + } + + [AvaloniaFact] + public void TheMapDrawsAndPicksTheStationWhereItProbablyIsWhenOn() + { + var time = new FakeTimeProvider(T0.AddMinutes(2)); + using var view = new MapView { Time = time }; + var window = new Window + { + Content = view, + Width = 800, + Height = 600, + }; + window.Show(); + view.SetStations([new StationState("N0CALL-9", T0, 40, -105, "/>", Course: 90, SpeedKmh: 90)]); + view.ShowAround(40, -105, 20); + Dispatcher.UIThread.RunJobs(); + var heard = view.ScreenPoint(40, -105); + var (lat, lon) = MapView.PointAlong(40, -105, 90, 3); + var probably = view.ScreenPoint(lat, lon); + + Assert.Equal("N0CALL-9", view.HitTest(heard)?.Name); // off by default: where it was heard + + view.DeadReckoning = true; + Dispatcher.UIThread.RunJobs(); + window.CaptureRenderedFrame()?.Dispose(); // a frame works the estimate out + Assert.Equal("N0CALL-9", view.HitTest(probably)?.Name); + Assert.Null(view.HitTest(heard)); + window.Close(); + } + + [Fact] + public async Task DetailsSayWhereItProbablyIsWhileDeadReckoningIsOn() + { + var time = new FakeTimeProvider(T0.AddMinutes(2)); + var engine = new FakeEngine(); + engine.Details["N0CALL-9"] = FakeEngine.Detail("N0CALL-9", T0); // 66.7 km/h on course 88 + var appearance = new Appearance.AppearanceService(); + var vm = new MainViewModel(engine, time, appearance: appearance); + await vm.Details.ShowAsync("N0CALL-9"); + Assert.Null(vm.Details.EstimateText); // off by default (F1.17 decision 1) + + appearance.Apply(appearance.Current with { DeadReckoning = true }); + + Assert.Equal("Probably 2.2 km on along its course, if it kept going", vm.Details.EstimateText); + time.Advance(TimeSpan.FromMinutes(9)); + vm.Details.Refresh(); + Assert.Null(vm.Details.EstimateText); // past 10 minutes + } +} diff --git a/tests/AprsMap.UI.Tests/MapTests.cs b/tests/AprsMap.UI.Tests/MapTests.cs index e6ca1058..a87f5411 100644 --- a/tests/AprsMap.UI.Tests/MapTests.cs +++ b/tests/AprsMap.UI.Tests/MapTests.cs @@ -277,6 +277,7 @@ public async Task SettingsMapOffersTheViewersChoicesAndTheStationsAgingWhereAllo "map.symbol-size", "map.areas", "map.filter-circles", + "map.dead-reckoning", "map.stale-after", "map.remove-after", "map.track-points",