Skip to content

Sync main with develop (24 commits, 10 PRs) - #84

Merged
chriskinal merged 26 commits into
mainfrom
develop
Sep 15, 2026
Merged

chriskinal merged 26 commits into
mainfrom
develop

Conversation

@chriskinal

Copy link
Copy Markdown
Collaborator

Routine developmain sync. Brings 24 commits (10 merged PRs) to main.

main carries no content of its own — its only unique commit is the merge commit from the previous sync (#64), so this merges cleanly with no conflicts.

Fixes

Refactors

Docs

🤖 Generated with Claude Code

erox93 and others added 24 commits August 6, 2026 14:07
Added AF_NETLINK to the list of restricted address families in the agopenweb.service file.
…tions

Fix linux headless service can not connect to UDP Modules input
Evaluate GeoPackage (.gpkg) as a replacement for the current text/binary
field storage, and reject it: GPKG is SQLite plus OGC table conventions,
and neither half earns its cost here. Our vector data is small and fully
resident in RAM for guidance, so indexed/partial reads are wasted, and
GeoJSON already reaches every consumer GPKG reaches. The coverage raster
is the only heavy data, and standard GPKG has no good home for a 1-bit
semantic mask on a local metric plane. Adoption would also mean a native
SQLite dependency across all five platform heads and a hand-rolled
container, since NetTopologySuite.IO.GeoPackage is a 2019 geometry codec
rather than a container manager.

Plan the incremental fix the analysis recommended instead. Reading the
coverage code closely showed the dominant autosave cost is not the RLE:
SaveSectionDisplay rebuilds the palette-index array from scratch every
30s by scanning the whole detection grid (~5e8 iterations plus a 25MB
LOH allocation on a 520ha field). The plan moves both layers to a
world-anchored tile grid so CheckAndExpandBounds cannot renumber tiles
mid-job, tracks dirty tiles in a stream independent of the renderer and
web-projector drains, and commits via a manifest written last.

Also records two latent bugs found while reading: the save reads
_detectionBits/_displayPixels off the lock while the GPS thread may
reallocate them, and a bounds expansion racing the save makes
SaveSectionDisplay log a mismatch and return without saving.

Docs only; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTikoBr49XDQmZ5an8qWUf
AgIO ships a stale 0x47 in the trailing CRC slot of its module-network
packets and never recomputes it — UDP.designer.cs:82 rewrites
helloFromAgIO[5] and leaves the checksum untouched. Modules gate on the
header, PGN and magic bytes and ignore the CRC, so the placeholder has
always been accepted. Compute it properly instead: firmware that ignores
the byte is unaffected, firmware that verifies it now passes rather than
relying on luck. Covers PGN 202 (scan), 201 (set subnet) and 200 (hello),
the last of which moves out of UdpCommunicationService into PgnBuilder so
every outbound packet is built in one place.

Fix ValidateChecksum, which XOR'd bytes [0..len-2] and so disagreed with
every packet this class builds — it would have rejected all valid
traffic. It now sums [2..len-2] like CalculateCrc and
PgnMessage.CalculateCRC. It stays diagnostics-only and deliberately
unwired from the receive path: some modules transmit a placeholder CRC of
their own, and gating inbound handling on this would drop legitimate
traffic and take the link down.

Route every builder through a single WithCrc helper. The seven remaining
builders each hand-wrote `buf[N] = CalculateCrc(buf, 2, N-2)`; all seven
were correct, but that is seven places to get wrong when a packet grows a
data byte. WithCrc derives the offset and span from the buffer length, so
the checksum cannot drift from the payload. It stamps in place, leaving
the pooled thread-local buffers the hot-path builders reuse untouched.

Add PgnChecksumTests covering all ten builders: expected length, header,
CRC against an independently computed reference, and a ValidateChecksum
round-trip. Also assert that corrupting any covered byte invalidates the
checksum — without that, a builder regressing to a constant CRC would
still satisfy every other assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DTikoBr49XDQmZ5an8qWUf
PgnProtocol existed as two hand-maintained copies: one in the standalone
vehicle simulator, one in AgOpenWeb.IntegrationTests' virtual UDP modules.
They were byte-identical apart from the namespace and a Describe() helper
the simulator added, so they had not drifted yet — but nothing was stopping
them, and this is the module-side definition of the wire format the host
side must agree with.

Move the simulator's copy (the superset) to
Shared/AgOpenWeb.Models/Communication/PgnProtocol.cs and delete the other.
AgOpenWeb.Models is the right home: both consuming projects already
reference it, so no csproj changes are needed, and PgnMessage — the
host-side equivalent — already lives there.

Expand the class doc with two things that were previously unwritten. First,
the checksum rule here (sum of bytes [2 .. len-2]) has to stay in lockstep
with PgnBuilder and PgnMessage.CalculateCRC. Second, IsValidPacket is
genuinely enforced: the virtual modules drop packets that fail it without
logging, so a host-side checksum bug surfaces as silence from the simulator
rather than as an error. That is exactly how the AgIO placeholder CRC in
the hello packet stayed hidden.

Pure refactor — no behaviour change. Simulator builds; Services 1026
passed / 2 skipped, Models 146, ViewModels 222.

Leaves the other four duplicated virtual modules alone. VirtualGpsReceiver,
VirtualMachineModule, VirtualModuleHub and VirtualSteerModule have genuinely
diverged between the two trees (45-153 diff lines each), so reconciling them
involves behavioural decisions and is not a move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133r866nfGcmeM3vabzrYRj
…analysis

Docs: GeoPackage storage analysis and coverage persistence plan
Send computed PGN checksums instead of AgIO's placeholder (builds on #72)
…otocol

Consolidate the duplicated PgnProtocol into Shared/AgOpenWeb.Models
The virtual GPS receiver, steer, machine and IMU modules existed as two
hand-maintained copies: one under the vehicle simulator, one under
AgOpenWeb.IntegrationTests. Unlike PgnProtocol these had genuinely diverged
(45-153 diff lines each), so this reconciles them rather than just moving a
file.

Move them to a new Simulators/AgOpenWeb.VirtualModules library that both
consumers reference. Deliberately not under Shared/: that tree compiles into
every platform head, and 1000+ lines of UDP sockets and PID simulation have
no business in a shipped app. The new project is a plain net10.0 library —
no Avalonia, no NUnit — so the simulator app and the test-support library can
both take it without dragging each other's dependencies along. Verified that
no project under Platforms/ or Shared/ references it.

In every case the simulator's copy was the superset: multi-destination
UdpTargets alongside a convenience constructor with the tests' exact
signature, OnSent/OnReceived taps, and a GPS receiver that also speaks PAOGI
and the 65535 no-IMU sentinel. Those are adopted wholesale.

The one genuine conflict was the bind address. The test copy bound
IPAddress.Loopback, commented "so Windows Defender Firewall does not prompt
during test runs"; the simulator bound IPAddress.Any with broadcast, because
it has to be reachable from a host on another machine. Both are right for
their caller, so neither wins: ModuleBindMode makes it an explicit choice,
ModuleSocket applies it in one place, the UdpTargets constructors default to
AllInterfaces, the single-host convenience constructors pin LoopbackOnly, and
VirtualModuleHub defaults to LoopbackOnly so a test cannot accidentally raise
a firewall prompt. The simulator passes AllInterfaces explicitly at its one
call site.

Two smaller reconciliations. VirtualModuleHub.Start() adopts the simulator's
behaviour of not starting the GPS send loop — the host drives emission via
SendOnce(), and a parallel 10 Hz emitter produced duplicate packets and jumpy
motion. The two hub tests never call Start() and assert an exact SentCount, so
they would have broken had the loop ever run. CreateIsolated() is dropped: the
simulator had already removed it and it had no callers anywhere.

Also ignore the *.SdkResolver.*.proj.Backup.tmp scratch files MSBuild drops
next to the platform csproj files on restore.

Pure refactor, no behaviour change for either consumer. Simulator and library
build clean; Services 1051 passed / 2 skipped, Models 146, ViewModels 222 —
identical to develop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0133r866nfGcmeM3vabzrYRj
…l-modules

Consolidate the duplicated virtual hardware modules
Selecting Imperial flipped ConfigurationStore.IsMetric host-side, but the web
client only honoured it in four places (status-bar speed, headland HUD,
area/rate, U-turn distance). Every config panel had (m)/(cm)/(km/h) hard-coded
in index.html, and populateCfgControls/wireCfgControls moved raw metric values
straight in and out of the inputs.

Models keep storing metric; conversion now happens only at the display/input
boundary, the same contract as the native UnitConversion helper.

Markup contract (index.html, 37 sites):
  <span class="u" data-unit="m"></span>  unit label, text filled by applyUnits()
  <input data-unit="m" ...>              value converted on populate + on send
The HTML step/min stay metric and are stashed into data-mstep/data-mmin so a
unit flip is reversible.

app.js gains a UNIT_DEFS table (m<->ft, cm<->in, km/h<->mph, km<->mi, ha<->ac)
plus unitLabel / toDisplayUnit / fromDisplayUnit / fmtUnit / readUnitInput /
writeUnitInput / applyUnits. syncUnits() rides the Status frame and, on a
change, relabels and marks the read-frames dirty so open panels re-populate
immediately.

Fields whose metric step is a whole unit (nudge distance, section widths,
coverage margin) are int-backed host-side, so they round after converting --
8 in becomes 20 cm, not 20.32, which the host's int parse would have dropped
silently.

Covered: Vehicle geometry, GPS distances and switch speed, Tool lengths /
offsets / section + default widths / coverage margin / slow-speed cutoff /
total width, U-turn, AutoSteer speed limits and nudge, Steer Wizard fields and
live readouts, Field Builder headland + tram, boundary player offset and area,
Offset Fix, field and AgShare lists, lightbar and XTE readouts, XTE chart.

Verified against the running headless Desktop head over CDP: wheelbase 2.5 m
shows 8.2 ft, nudge 20 cm shows 7.9 in, section width 100 cm shows 39.4 in,
XTE 0.123 m reads "5 in R"; typing 10 ft sends 3.048 and 8 in sends 20; a live
metric -> imperial -> metric flip restores every label, value, step and min.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132tf3qziiQTJv8AiJmwvVR
…#65)

The display-boundary fix covered everything the client formats itself, but
three measurements were rendered into strings host-side and printed verbatim by
app.js, so they stayed wrong.

Boundary list area was the worst of the three: BoundaryListItem.AreaDisplay was
hardcoded "{AreaAcres:F2} Ac" and ignored IsMetric entirely, so the boundary
menu showed acres even in metric mode. Fixed by sending the number instead of a
string -- BoundaryListItem now stores AreaHectares, BoundaryItemDto carries
AreaHa as f64, and the client formats it with fmtUnit like the sibling
BoundaryDto.AreaHa it sits next to. Wire codec and fingerprint follow; client
and server ship as one artifact so there is no protocol-version concern.

Vehicle and tool profile previews (ConfigurationService.FormatVehicle /
FormatTool) hardcoded " m" with no imperial branch. These stay host-rendered --
they are labelled free text, not editable values, so a structured DTO would
only move the labels to JS -- but they now take the device IsMetric and render
via UnitConversion.MetersToFeet. Note the flag comes from Store, not from the
throwaway ConfigurationStore used to preview a non-active profile: IsMetric is a
device setting and the temp store holds only the profile's own values.

ProfilesFingerprint folds in IsMetric so a unit flip actually re-sends the
frame; without it the picker preview stayed stale until some other config
changed. renderBoundaryMenu joins onUnitsChanged for the same reason.

Verified against the running headless Desktop head over CDP with a generated
field, boundary and profile pair: boundary area 4.05 ha <-> 10.02 ac, vehicle
wheelbase 2.50 m <-> 8.20 ft, tool width 6.00 m <-> 19.69 ft, all updating live
on a metric -> imperial -> metric flip. Full suite green (1419 tests). The iOS
and Android heads need no changes -- they ProjectReference the RemoteServer and
pick up its embedded wwwroot on rebuild.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132tf3qziiQTJv8AiJmwvVR
Convert measurements at the web UI's display/input boundary (#65)
The Android head could open to a permanent "webpage not available" screen
instead of the web UI. Users reported this as "crashes on startup" — the app
never crashed, it just never loaded.

Two bugs compounded:

1. Premature navigate. StartNavigationAsync waited
   Task.WhenAny(HostReady, Task.Delay(8000)) and then navigated regardless of
   whether the host had bound :5174. On a cold start the guidance host needs
   longer than that — 20.8 s and 41.8 s in two emulator repros, and even a warm
   relaunch measured 17.3 s.

2. The error page counted as a successful load. Android's WebView reports its
   own ERR_CONNECTION_REFUSED page via NavigationCompleted with IsSuccess=true.
   That latched `loaded = true`, which permanently disabled the retry watchdog
   (`if (!loaded) TryNavigate()`).

So the app navigated too early, got connection-refused, and the error page's
"success" killed every future retry. The host came up seconds later and nothing
ever navigated again.

Fix: use the port itself as ground truth instead of a timer plus a WebView
signal that can lie.

- IsHostAcceptingAsync() opens a loopback TCP connection to :5174. This is
  immune both to a stale HostReady (that TCS is static and survives a host
  restart in the same process) and to the fake error-page success.
- Navigation only starts once the probe sees the host accepting, and an
  IsSuccess=true only latches when `hostUp` is true, so an error page can never
  end the retry loop.
- Each attempt awaits its own navigation outcome via a per-attempt
  TaskCompletionSource rather than re-navigating on a fixed 2.5 s tick — a short
  watchdog restarts a load that is merely slow, which thrashes on slow devices.
- If the host genuinely never starts, ShowSplashError surfaces the reason
  instead of leaving a frozen splash or an unexplained error page.

iOS and Desktop are unaffected; both fully await the backend before navigating.
Only Android decoupled the Activity from the foreground service, which is what
forced the bounded-wait design in the first place.

Verified on an Android 15 / API 35 emulator (cold boot, host binding at 20.8 s
and 41.8 s — both previously dead, now load on attempt 1) and on a physical
Galaxy Tab Active3 (arm64, Android 13, host at 2.7 s — fast path unchanged).
Tests: 1419 passed, 0 failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wion3j9RSizcV8SuAm4w92
…artup-race

Fix Android launcher parking on a dead error page at startup (#73)
Vehicle/tool picker previews formatted lengths from Store.IsMetric, which only re-syncs to AppSettings on profile load/save, so a bare metric/imperial toggle left previews in metres while the rest of the UI showed feet. Read the authoritative settingsService.Settings.IsMetric instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Dre1bS3Fvi3VmUJGvnUDj
Fix: KML/ISO-XML import list always empty on headless installs
Fix: profile previews honor AppSettings units (imperial)
Fix: route data paths through AppDataRoot + honor imperial in remaining host strings
Bump sys/version.h to 26.6.75 for the 14 commits on develop since
v26.6.74 (AppDataRoot path fixes, imperial units, Android startup race,
PGN CRC, shared-module refactors).

Also realign the platform heads, which had drifted from version.h and
from each other: Desktop was pinned at 26.6.55 and iOS at 26.6.56 (both
near the old 26.6.55 tag), and Android had never been versioned at all,
still shipping the 1.0 template default. All three now read 26.6.75.

Build numbers are deliberately untouched: iOS ApplicationVersion (2) and
Android ApplicationVersion (1) are TestFlight/Play upload counters, not
marketing versions, and bumping them is an upload-time concern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bump to 26.6.75 and align platform versions
@chriskinal
chriskinal merged commit cfbb441 into main Sep 15, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants