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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"Bash(Select-Object Name)",
"Bash(grep -E \"\\(MavNet\\\\.\\(Core|Protocol|Protocol\\\\.Generated|Transport|PX4\\)\\\\/[^/]+\\\\.cs$\\)\")",
"Bash(Get-ChildItem -Path \"C:\\\\Users\\\\Vinci\\\\source\\\\repos\\\\MavNet\\\\src\" -Directory)",
"Bash(Select-Object -ExpandProperty Name)"
"Bash(Select-Object -ExpandProperty Name)",
"Bash(Select-String \"rate-controlled state subscription\")"
]
}
}
72 changes: 72 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
root = true

# ---- Generated code: fully excluded from analysis ----
# Two shapes exist. The Protocol.Generated tree holds the emitted message /
# enum / command types; MessageRegistry.cs deliberately lives in the
# MavNet.Protocol assembly (NOT Protocol.Generated) so MavlinkFrame.TryDecode
# can reach it without Protocol referencing Protocol.Generated (circular).
# That is why the registry needs its own path section here.
# generated_code = true is honored inconsistently across the CA/IDE catalog
# under TreatWarningsAsErrors, so pair it with a bulk severity = none, and
# list IDE0005 explicitly (compiler-side, not always gated by the bulk rule).
# NOTE: the `**.cs` form (not `**/*.cs`) is deliberate — `dir/**/*.cs` does
# NOT match files directly in `dir`, only in subdirectories.
[src/MavNet.Protocol.Generated/**.cs]
generated_code = true
dotnet_analyzer_diagnostic.severity = none
dotnet_diagnostic.IDE0005.severity = none

[src/MavNet.Protocol/Generated/MessageRegistry.cs]
generated_code = true
dotnet_analyzer_diagnostic.severity = none
dotnet_diagnostic.IDE0005.severity = none

# ---- Hand-written C#: conservative severity baseline ----
# Intentionally tiny. Each line is justified against a real property of this
# codebase. New entries require a one-line codebase-specific justification;
# no blanket category disables.
[*.cs]

# CA1062: validate public-method args for null. The wire/decode public surface
# is small and CRTP-constrained and takes non-nullable Span<byte>/ref struct;
# defensive null guards on every method are noise here. Matches the existing
# tests/Directory.Build.props NoWarn for parity.
dotnet_diagnostic.CA1062.severity = none

# CA2007: ConfigureAwait(false) on every await. This is a non-UI library with
# no synchronization context; the hot await (ReceiveFromAsync) already uses
# ConfigureAwait(false) deliberately. Requiring it everywhere is ceremony.
dotnet_diagnostic.CA2007.severity = none

# IDE0058: expression value is never used. Fights the deliberate
# Socket.SendTo / fire-and-forget send path.
dotnet_diagnostic.IDE0058.severity = none

# CA1031: catch general Exception. INTENTIONAL and documented in CLAUDE.md —
# a buggy subscriber must never kill the receive loop. Demoted to suggestion
# so the design choice stays visible rather than silently overridden.
dotnet_diagnostic.CA1031.severity = suggestion

# CA1848 / CA1873: use LoggerMessage delegates / avoid expensive log args.
# These pay off for HIGH-FREQUENCY logging. By design this library logs only
# on connection lifecycle (Start/Dispose, once each) and rare error paths
# (socket exception, heartbeat-send failure); the per-frame decode/dispatch
# hot path logs nothing — the same no-per-frame-allocation discipline applied
# to logging. A generated LoggerMessage partial class for ~5 cold lines is
# pure boilerplate with no measurable benefit. Revisit if hot-path logging
# is ever added.
dotnet_diagnostic.CA1848.severity = none
dotnet_diagnostic.CA1873.severity = none

# ---- Codegen tool: CA1305 (locale-sensitive format/parse) ----
# A code generator must emit byte-identical output on any host locale (the CI
# codegen-drift gate depends on it). This is guaranteed by ONE mechanism, not
# per-call discipline: Program.cs pins CurrentCulture / DefaultThreadCurrentCulture
# to InvariantCulture at startup, so every format AND parse in the tool — the
# ~30 interpolated StringBuilder.AppendLine sites and every int.Parse, present
# and future — is deterministic. CA1305 does local analysis and cannot see that
# process-wide invariant, so it is suppressed tool-wide: a single robust pin is
# deliberately preferred over scattered IFormatProvider args that new code would
# forget. Do NOT re-add per-call providers here — that reintroduces the smell.
[tools/MavNet.CodeGen/**.cs]
dotnet_diagnostic.CA1305.severity = none
12 changes: 10 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ With no args it uses repo-relative defaults: spec `specs/common.xml`, allowlist
The allowlist controls which messages get emitted as record structs. Enums are emitted for everything. **When you add a message to `allowlist.txt`:**

1. Regenerate (above).
2. Add a new `event Action<MavId, NewMsg, DateTime>?` and a `case NewMsg.MsgId:` arm in `src/MavNet.Transport.Udp/MavlinkConnection.cs` — the dispatcher silently drops msgids it doesn't know. Also add the event to `IMavlinkConnection` so test fakes stay in sync.
2. Add a new `event Action<MavId, NewMsg, DateTime>?` and a `case NewMsg.MsgId:` arm in `src/MavNet.Transport.Udp/MavlinkConnection.cs` — the dispatcher silently drops msgids it doesn't know. Also add the event to `IMavlinkConnection` so test fakes stay in sync. `AllowlistWiringConsistencyTests` fails CI if the `IMavlinkConnection` event (or its impl) is missing for an inbound message; it cannot see the `case` arm, so the per-message dispatch `[Fact]` in step 4 covers that (a missing arm makes it time out). Genuinely send-only messages (e.g. `COMMAND_LONG`) go in that test's `SendOnly` set with justification.
3. If the message is something a `Drone`/vehicle should surface, wire it through `src/MavNet.PX4/Base/Vehicle.cs` and `Vehicles/Drone.cs`.
4. Add a roundtrip `[Fact]` for the new message in `tests/MavNet.Protocol.Generated.Tests/MessageRoundtripTests.cs` and a dispatch test in `tests/MavNet.Transport.Udp.Tests/MavlinkConnectionDispatchTests.cs`.

Expand Down Expand Up @@ -64,6 +64,14 @@ Drop the frame (return false) on: v1 magic, signed frames (`incompat & 0x01`), a
- **Ship docs and tests alongside any change.** Every new feature, behavior change, or bug fix lands with: (1) the code, (2) a test that exercises it under `tests/<matching project>.Tests/`, and (3) any doc updates it makes obsolete — XML doc comments on the changed API, the matching article under `docs/articles/`, and any section of this `CLAUDE.md` that became stale. No "I'll add tests later" — if it's worth merging, it's worth proving and documenting in the same PR. Bug fixes specifically must start with a failing test that reproduces the bug, then the fix.
- **Keep `README.md` and `ROADMAP.md` current too.** Whenever a change advances functionality, project scope, or status, update the project-status text in `README.md` (the **Done** / **Next** lists, milestone table, probe keybindings, articles list) and the matching row / milestone in `ROADMAP.md` (Current-state table, allowlist count, milestone definitions) in the same PR. Treat them as part of the deliverable, not optional polish. "Done = done on disk" — flip status as soon as the work lands, not when it ships to NuGet.

## Static analysis

`Directory.Build.props` enables the .NET analyzers at `AnalysisMode=Recommended` with `EnforceCodeStyleInBuild`. Since `TreatWarningsAsErrors=true`, every analyzer/style finding is a build error — CI enforces it with no `ci.yml` change.

Generated code is excluded via `.editorconfig`, in two sections: the `MavNet.Protocol.Generated` tree, and a **separate** one for `src/MavNet.Protocol/Generated/MessageRegistry.cs` (it lives in the `Protocol` assembly, not `Protocol.Generated`, to avoid the circular reference, so one glob can't cover both). Use the `dir/**.cs` glob form — `dir/**/*.cs` does **not** match files directly in `dir`, only subdirectories.

Suppressions are deliberately minimal and each carries a one-line, codebase-specific justification (CA1062/CA2007/IDE0058/CA1031 in the hand-written baseline; CA1848/CA1873 because logging is confined to cold lifecycle/error paths, never the per-frame path; CA1305 scoped to `tools/MavNet.CodeGen/**` because `Program.cs` pins the process to `InvariantCulture` for deterministic codegen). No blanket category disables — add a justified single-rule line or fix the code.

## Keeping this file current

Update this file when something it states becomes wrong or incomplete: a build/run command changes, a layer's responsibility shifts, the codegen workflow or allowlist→dispatcher wiring changes, frame-decode invariants are relaxed/tightened, the threading model changes, or a new top-level project is added. Don't append change logs — edit the affected section in place and delete anything no longer true.
Update this file when something it states becomes wrong or incomplete: a build/run command changes, a layer's responsibility shifts, the codegen workflow or allowlist→dispatcher wiring changes, frame-decode invariants are relaxed/tightened, the threading model changes, the analyzer mode or `.editorconfig` suppression policy changes, or a new top-level project is added. Don't append change logs — edit the affected section in place and delete anything no longer true.
11 changes: 11 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>

<!-- Static analysis. Recommended (not All) gives correctness/reliability
rules without the design-opinion noise that fights intentional
patterns (broad catch in the receive loop, no ConfigureAwait in a
non-UI lib). TreatWarningsAsErrors already makes findings gate CI —
no ci.yml change needed. Generated code is excluded via .editorconfig. -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
</PropertyGroup>

<PropertyGroup>
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ A condensed view. Full version in [ROADMAP.md](ROADMAP.md).
- Mission protocol: upload / download / clear / start state machines for waypoints, geofence and rally points; live `MISSION_CURRENT` / `MISSION_ITEM_REACHED` on `Vehicle`
- Rate-controlled state subscription
- Test harness, CI matrix, codegen-drift check, SourceLink, deterministic builds
- Static analysis: .NET analyzers (`Recommended` + code-style in build, generated code excluded), allowlist→`IMavlinkConnection` wiring consistency test

**Next, in order**

Expand Down Expand Up @@ -115,7 +116,7 @@ Do not hand-edit them. Regenerate with:
dotnet run --project tools/MavNet.CodeGen
```

When adding a message to `tools/MavNet.CodeGen/allowlist.txt`, regenerate, then add its typed event and dispatch case in `src/MavNet.Transport.Udp/MavlinkConnection.cs`. Surface it through `Vehicle` or `Drone` only when it belongs in the high-level API.
When adding a message to `tools/MavNet.CodeGen/allowlist.txt`, regenerate, then add its typed event and dispatch case in `src/MavNet.Transport.Udp/MavlinkConnection.cs`. Surface it through `Vehicle` or `Drone` only when it belongs in the high-level API. `AllowlistWiringConsistencyTests` fails the build if the `IMavlinkConnection` event for an inbound message is forgotten (send-only messages are listed explicitly).

## Docs

Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Path to a "full" C# MAVLink SDK. This document is the single source of truth for
| Rate-controlled state subscription | Done | `MavNet.Core/IStateObservable` |
| Test harness (xUnit + FluentAssertions), CI matrix, codegen-drift check | Done | `tests/`, `.github/workflows/ci.yml` |
| NuGet + SourceLink + deterministic builds | Done | `Directory.Build.props` |
| Static analysis: .NET analyzers (`Recommended`) + code-style in build, generated code excluded, allowlist-wiring consistency test | Done | `Directory.Build.props`, `.editorconfig`, `tests/MavNet.Transport.Udp.Tests/AllowlistWiringConsistencyTests.cs` |
| Docs site (DocFX) with architecture + getting-started | Thin | `docs/articles/` |

**Allowlisted messages today (17):** HEARTBEAT, COMMAND_LONG / ACK, GLOBAL_POSITION_INT, VFR_HUD, GPS_RAW_INT, SYS_STATUS, EXTENDED_SYS_STATE, and the 9 MISSION_* messages (REQUEST_LIST, COUNT, CLEAR_ALL, ITEM_REACHED, ACK, CURRENT, REQUEST, REQUEST_INT, ITEM_INT).
Expand Down Expand Up @@ -132,7 +133,7 @@ The long tail of "is it a full SDK." Each is independently shippable.

## Cross-cutting workstreams (every milestone)

- **Allowlist hygiene:** every new message follows the CLAUDE.md "Code generation" 4-step ritual (regen, dispatcher event + switch arm, `IMavlinkConnection` event, roundtrip test + dispatch test).
- **Allowlist hygiene:** every new message follows the CLAUDE.md "Code generation" 4-step ritual (regen, dispatcher event + switch arm, `IMavlinkConnection` event, roundtrip test + dispatch test). `AllowlistWiringConsistencyTests` auto-enforces the `IMavlinkConnection` event half (interface + impl); the `case` arm stays covered by the per-message dispatch `[Fact]`.
- **Threading-friendly API for GCS:** events fire on the receive thread (CLAUDE.md "Threading model"); every new event-producing layer ships with an `IAsyncEnumerable<T>` adapter or `Channel<T>` helper so Blazor/WPF consumers do not have to marshal manually. Lives in `MavNet.Core` as `EventStream<T>`.
- **Sample apps:** one new sample per ~2 milestones, in `examples/` — `MavNet.Probe` (have), `MavNet.MissionCli`, `MavNet.ParamDumper`, `MavNet.LogDownloader`, `MavNet.MiniGcs` (Blazor).
- **Docs ship in the same PR as code and tests.** Update `CLAUDE.md` "Architecture" when a new top-level project lands.
Expand Down
10 changes: 8 additions & 2 deletions examples/MavNet.Probe/Probe.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ namespace MavNet.Probe;
/// L Land R RTL M Mission demo (upload + start)
/// Q Quit
/// </summary>
internal sealed class Probe
internal sealed class Probe : IDisposable
{
private const string DefaultUri = "udp://0.0.0.0:14550?rhost=127.0.0.1&rport=18570";

Expand All @@ -34,6 +34,12 @@ private Probe(Drone drone)
_drone.MissionItemReached += seq => Log($"★ MISSION_ITEM_REACHED seq={seq} (total reached={_drone.MissionReachedCount})");
}

public void Dispose()
{
_cts.Dispose();
GC.SuppressFinalize(this);
}

public static async Task<int> Main(string[] args)
{
var uri = args.Length >= 1 ? args[0] : DefaultUri;
Expand All @@ -52,7 +58,7 @@ public static async Task<int> Main(string[] args)

await using (drone)
{
var probe = new Probe(drone);
using var probe = new Probe(drone);
Log($"connected to {drone.DeviceId} (type={drone.VehicleType})");
Console.WriteLine();
Console.WriteLine("Keys: A=Arm D=Disarm T=Takeoff(10m) L=Land R=RTL M=Mission demo Q=Quit");
Expand Down
2 changes: 2 additions & 0 deletions src/MavNet.PX4/Base/Vehicle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -682,5 +682,7 @@ public virtual async ValueTask DisposeAsync()

if (_ownsConnection)
await _connection.DisposeAsync().ConfigureAwait(false);

GC.SuppressFinalize(this);
}
}
7 changes: 2 additions & 5 deletions src/MavNet.PX4/Missions/MissionClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -478,11 +478,8 @@ private void SendFinalAck() =>
private bool IsMine(MavId sender) =>
sender.SystemId == _targetSystem && sender.ComponentId == _targetComponent;

private void ThrowIfDisposed()
{
if (Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(MissionClient));
}
private void ThrowIfDisposed() =>
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);

private void ThrowIfBusyLocked()
{
Expand Down
27 changes: 25 additions & 2 deletions src/MavNet.PX4/Missions/MissionItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,12 @@ public static MissionItem Land(
Y: ToInt1e7(lonDeg),
Z: 0f);

/// <summary>Build a NAV_TAKEOFF item at the given altitude.</summary>
/// <summary>Build a NAV_TAKEOFF item with <see cref="X"/>/<see cref="Y"/> emitted as
/// <c>0</c> and <see cref="Z"/> = <paramref name="altMeters"/>. Per the
/// <c>MISSION_ITEM_INT</c> encoding <c>0</c> is the literal coordinate <c>0.0000000°</c>,
/// not an "unset"/"current position" sentinel — the protocol has no unset. Use
/// <see cref="Takeoff(double, double, float, float, MavFrame)"/> to send a takeoff
/// coordinate.</summary>
public static MissionItem Takeoff(
float altMeters,
float pitch = 0f,
Expand All @@ -81,6 +86,23 @@ public static MissionItem Takeoff(
Y: 0,
Z: altMeters);

/// <summary>Build a NAV_TAKEOFF item at an explicit coordinate
/// (<paramref name="latDeg"/>, <paramref name="lonDeg"/>, <paramref name="altMeters"/>).</summary>
public static MissionItem Takeoff(
double latDeg, double lonDeg, float altMeters,
float pitch = 0f,
MavFrame frame = MavFrame.GlobalRelativeAltInt) =>
new(
Frame: frame,
Command: MavCmd.NavTakeoff,
Param1: pitch,
Param2: 0f,
Param3: 0f,
Param4: float.NaN,
X: ToInt1e7(latDeg),
Y: ToInt1e7(lonDeg),
Z: altMeters);

/// <summary>Build a NAV_RETURN_TO_LAUNCH item.</summary>
public static MissionItem ReturnToLaunch() =>
new(
Expand All @@ -98,7 +120,8 @@ public static int ToInt1e7(double degrees) =>

/// <summary>Wrap this item with the per-transaction stamping (sequence number, target,
/// mission type) into a wire-shaped <see cref="MissionItemInt"/>. The <c>Current</c>
/// field is always emitted as 0 — autopilots track the active item via MISSION_CURRENT.</summary>
/// field is always emitted as <c>0</c>; the mission protocol tracks the active item
/// via <c>MISSION_CURRENT</c>.</summary>
internal MissionItemInt ToWire(ushort seq, byte targetSystem, byte targetComponent, MavMissionType missionType) =>
new(
Param1: Param1, Param2: Param2, Param3: Param3, Param4: Param4,
Expand Down
6 changes: 3 additions & 3 deletions src/MavNet.PX4/Vehicles/Drone.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,16 +99,16 @@ private Drone(MavlinkConnection conn, byte sys, byte comp, string? cs,
/// <c>udp://localBind:localPort?rhost=remote&amp;rport=remotePort</c>.
/// See <see cref="ConnectionString"/>.</param>
/// <param name="timeout">How long to wait for the first heartbeat before throwing.</param>
/// <param name="ct">Cancellation token.</param>
/// <param name="connectionLogger">Optional logger for the underlying transport.</param>
/// <param name="vehicleLogger">Optional logger for the Vehicle layer.</param>
/// <param name="ct">Cancellation token.</param>
/// <exception cref="TimeoutException">No heartbeat arrived within <paramref name="timeout"/>.</exception>
public static async Task<Drone> ConnectAsync(
string connectionString,
TimeSpan timeout,
CancellationToken ct = default,
ILogger<MavlinkConnection>? connectionLogger = null,
ILogger<Vehicle>? vehicleLogger = null)
ILogger<Vehicle>? vehicleLogger = null,
CancellationToken ct = default)
{
var (local, remote) = Transport.Udp.ConnectionString.Parse(connectionString);
var conn = new MavlinkConnection(local, remote, logger: connectionLogger);
Expand Down
Loading
Loading