Skip to content

Fix: carry over CF liveness when adopting an already-active VT/TC partner - #16

Open
Arjan-Woltjer wants to merge 4 commits into
Open-Agriculture:mainfrom
Arjan-Woltjer:fix-partner-cf-liveness-carryover
Open

Arjan-Woltjer wants to merge 4 commits into
Open-Agriculture:mainfrom
Arjan-Woltjer:fix-partner-cf-liveness-carryover

Conversation

@Arjan-Woltjer

Copy link
Copy Markdown

What

CANNetworkManager::update_new_partners() copies address and
controlFunctionNAME onto a PartneredControlFunction when it's late-bound
to a control function that already claimed its address before the partner
filter existed. It never copies claimedAddressSinceLastAddressClaimRequest,
so the newly-adopted partner starts that flag at its default false even
though the control function it was just adopted from already has it true.

Why this matters

The very next PGN 60928 (Address Claim) request seen anywhere on the bus —
routine when another node notices a newly-joined implement — resets that
flag network-wide and starts prune_inactive_control_functions()'s 755ms
(MAX_ADDRESS_CLAIM_RESOLUTION_TIME) clock. Since the flag was never true
to begin with, the partner gets evicted 755ms later even though the real
device never stopped being valid.

Once evicted, its controlFunctionTable slot is nulled, and
process_can_message_for_global_and_partner_callbacks() silently drops
every subsequent broadcast from that source address, since
message.get_source_control_function() no longer resolves. This is not
limited to the VT client's own internal tracking — any global PGN callback
watching that traffic goes dark at the same moment, which can make an
otherwise fully-functional device look like it stopped transmitting.

This is the same root cause reported in #584 (still open, independently
reproduced against current main as of 2026-07-09). @sujandumaru's comment
there correctly identifies that the flag, not just address/NAME, needs to be
propagated — this PR implements exactly that, copying the flag from the
already-active currentActiveControlFunction rather than hardcoding true.

Verification

Root-caused against real hardware in a downstream project
(Triton's Ploegbesturing
ISOBUS plough controller): an Ag Leader InCommand 1200 VT reproduced a VT
Status Timeout 100% of the time, ~2-3 seconds after every connect, because
the VT (the tractor's own screen) was already on the bus and already claimed
before the client powered up — exactly this late-binding path. An
independent, from-scratch global PGN 0xE600 listener added purely for
diagnosis saw the exact same cutoff as VirtualTerminalClient's own
internal status tracking, which is what led to tracing the drop to this
function rather than to the terminal itself. Not yet re-tested on that same
hardware with this fix applied — that's the next step on our end — but the
control-flow bug itself is confirmed by direct source reading, not
inference.

Closes #584.

ad3154 and others added 4 commits October 20, 2024 11:54
Updated AgIsoStack to 29dab887a48bb204aae983b06052d52b0f2314d5
Added a basic example showing how to receive machine speed messages.
update_new_partners() copies address and controlFunctionNAME onto a
PartneredControlFunction when it's late-bound to a control function that
already claimed its address before the partner filter existed, but it never
copied claimedAddressSinceLastAddressClaimRequest. That flag starts false on
a freshly constructed partner, so the very next PGN 60928 (Address Claim)
request seen anywhere on the bus -- routine when another node notices a
newly-joined implement -- resets it network-wide and starts the 755ms
prune_inactive_control_functions() clock. Since it was never true to begin
with, the partner gets evicted 755ms later even though the real device never
stopped being valid.

Root-caused against real hardware: an Ag Leader InCommand 1200 VT reproduced
this 100% of the time, ~2-3s after every connect, because the VT (the
tractor's own screen) was already on the bus and already claimed before our
client powered up -- exactly this late-binding path. Once the table slot is
nulled, CANNetworkManager::process_can_message_for_global_and_partner_callbacks()
silently drops every subsequent broadcast from that address (source control
function resolves to nullptr), so even a from-scratch independent PGN
listener alongside VirtualTerminalClient's own status tracking saw the exact
same cutoff -- it looked like the VT itself stopped broadcasting, but the
frames were being dropped downstream of a stale control-function table entry.

Closes #584.
Arjan-Woltjer added a commit to Arjan-Woltjer/NeptuneGPS_Triton that referenced this pull request Sep 5, 2026
…not the terminal

The InCommand 1200 almost certainly never stopped broadcasting its VT status
message. Our VT partner control function gets wrongly evicted from
AgIsoStack's own control-function table ~2-3s after connect (upstream bug
Open-Agriculture/AgIsoStack-plus-plus#584: update_new_partners() adopts an
already-active CF without carrying over claimedAddressSinceLastAddressClaim-
Request, so the next PGN 60928 request anywhere on the bus gets it pruned).
Once evicted, every subsequent status frame is silently dropped before
reaching any listener -- including the independent vtstat counter added
earlier this session, which is why it froze in lockstep with AgIsoStack's
own tracking rather than actually corroborating a terminal-side stop.

Vendor-patches the fix (documented as patch #3, applied to
.pio/libdeps/teensy41_isobus per the existing convention -- gitignored, not
in this diff) and bumps the VT client's log level Warning -> Info so a
future capture shows AgIsoStack's [NM] control-function lifecycle lines
directly. Also explains #18 (no auto-reconnect) as the same root cause
rather than a separate gap: a compliant VT only claims once, so nothing
naturally repopulates the evicted table slot afterward.

Filed upstream as Open-Agriculture/AgIsoStack-Arduino#16. Not yet
field-verified against the InCommand 1200 that surfaced this -- next
hardware session's job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Arjan-Woltjer added a commit to Arjan-Woltjer/NeptuneGPS_Triton that referenced this pull request Sep 8, 2026
…ction root cause (#24)

* Widen GuidanceSource calibration banner separator to match header width

* Fix CANNetworkManager::CANNetwork() calls -- it's a static member, not a method

AgIsoStack-Arduino 0.1.5 declares CANNetworkManager::CANNetwork as
static CANNetworkManager CANNetwork; (a singleton object), not a
factory method. All 11 call sites in IsobusGuidanceChannel.cpp and
IsobusDebugMenu.cpp were calling it as CANNetwork(), which fails to
compile against a genuinely fresh build (a stale cached object file
was masking this).

* Restore NanoLibcCompat.cpp swprintf shim -- confirmed load-bearing, not dead code

Deleted it on a hunch it was an unneeded shim and rebuilt
teensy41_isobus. Link failed exactly as its own header comment
predicted: undefined reference to swprintf/getwc/ungetwc out of
libstdc++_nano.a and libc_nano.a under TEENSY_OPT_SMALLEST_CODE's
nano.specs. Restored verbatim, with a note recording the
re-verification. See issue #13 for the still-open question of what
exactly pulls in the wide-locale facet in the first place.

* Fix legacy PGN source-address resolution, speed sentinel, and add retry + diagnostics

Live bus test on a New Holland T6 (Ag Leader Dual Trac / InCommand 1200
VT) on 2026-08-08 found the guidance source only ever broadcasts legacy
proprietary PGNs (65267/65256/65535), not the standard NMEA2000 set.

- OnLegacySpeed had no 0xFFFF ("not available") sentinel guard, unlike
  its NMEA2000 sibling -- an unavailable reading was being converted
  into an impossible 131.70 m/s (256.00 kn). Guarded.
- OnLegacyXteJohnDeere/OnLegacyXteTrimble filtered on
  msg.get_source_control_function(), which is permanently null for
  these senders: they never broadcast a real ISO Address Claim, so
  AgIsoStack's control-function table never resolves an entry for
  their address. Switched to reading the address straight off the CAN
  identifier instead, which doesn't require control-function
  resolution.
- That unblocked the real address: kSourceAddressJohnDeere was 0x2A
  (an assumption inherited from the old VehicleGps fixed-CAN-ID era);
  the actual unit on this rig claims 0x80. Updated.
- Added a bus-diagnostic snapshot to MessageCounters (last source
  address / raw values per PGN, captured ahead of any filter) so
  IsobusDebugMenu's status dump can show what's actually arriving even
  when a handler's own filtering drops it -- this is what surfaced the
  0x80 vs 0x2A mismatch and the raw 0xFFFF speed sentinel in the first
  place.

See Documentation/HardwareTestNotes.md for the full session log.

* Add hardware test notes for ISOBUS sessions 1-2

Log of the New Holland T6 / Ag Leader Dual Trac live-bus test
sessions: rig description, bugs found/fixed, still-open items, and
code-level confirmation (address-claim renewal via VT Working Set
Maintenance, PGN request behavior, VT object pool push) for the three
questions raised before session 2.

* minor changes to the layout added stubs in main to guide Claude in implementing the stuff

* Fix P1-P5 guidance-channel prerequisites from TC client design doc

Fixes flagged in Triton_TC_Client_Design.md sec.2 as blocking before
adding a Task Controller client on top of the existing PGN guidance path:

- P1: OnAllImplementStop now decodes the AISO 2-bit state (byte 7 bits
  0-1) instead of stopping on any received frame. Layout confirmed
  against AgIsoStack's own ShortcutButtonInterface::process_message().
  Previously every periodic Permit (01) broadcast halted the plough.
- P2: OnXteNmea2000 no longer hardcodes quality=RTK on every message.
  PGN 129029 is deliberately not being pursued as a quality source (not
  reliably present across hardware, e.g. Ag Leader), so single-arg
  SetXte() is used instead -- this leaves quality unset rather than
  lying that the fix is RTK-equivalent, which previously defeated
  InterfacePlough's IsRtkQuality() interlock unconditionally.
- P3: decode PGN 129283 byte 1's Navigation Terminated bit and skip the
  XTE update when set, instead of discarding the byte entirely.
- P4: guard the second N2K sentinel value (0x7FFFFFFE, error) alongside
  the existing 0x7FFFFFFF (not available) check.
- P5: renamed GuidanceSource::GetXteFixAge() to GetXteTimestamp() --
  it returns a millis() timestamp, not an elapsed age. Updated the
  native test mock and all real call sites; mock's backing field
  renamed to lastXteFix to match the real class's private member name.

Verified: teensy41_isobus builds clean; all 47 native AUnit tests pass
(via test/native/run_tests_msvc.ps1, no GCC on this machine).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add IsobusTcInterface -- ISOBUS Task Controller client

Implements the TC client design from Triton_TC_Client_Design.md: connects
to a Task Controller, uploads a DDOP, and receives DDI 513 (guidance line
deviation at the implement's Device Reference Point) and DDI 514 (GNSS
quality) via ValueCommand callbacks. Data source only -- no control
authority over the plough, and deliberately not wired into GuidanceSource
yet (design doc sec 6: DDI 513 is a different quantity than the tractor's
own PGN 129283 XTE; start at cross-check-only, decide fusion later).

Verified the AgIsoStack API against the vendored source actually used by
the teensy41_isobus build (.pio/libdeps/teensy41_isobus/AgIsoStack) rather
than trusting the design doc's sketch or the *other*, differently-versioned
AgIsoStack copy under .pio/libdeps/teensy41/ (a stale leftover, unused by
any current env). Two real divergences found this way:
- TaskControllerClient's value callbacks use int32_t, not uint32_t.
- There's no Object::NULL_OBJECT_ID member in this version; the "no
  object" sentinel is the free constant isobus::NULL_OBJECT_ID.
Also corrected DDI 116 in the design doc's element-tree sketch: it's
TotalArea, not ActualWorkingDepth (that's DDI 52) -- moot here since the
working-depth DPD isn't declared in this first cut anyway (see below).

Scope trimmed from the design doc's element tree: only Device/Connector/
Function elements and the two TC-writable process data variables (513,
514) plus the connector's X/Y offset properties (534/135) are declared.
DDI 67/70 (working width) and 141 (work state) from the doc's sketch are
NOT declared -- this implement controls plough *offset*, not width
(ImplementPlough has no working-width concept at all), and has no
engaged/disengaged state to report either. The doc's own test plan
(Phases 1-3: connection, read-only 513/514, geometry validation) doesn't
need them. The connector offsets are committed as named zero placeholders
pending a real measurement (design doc sec 4.3 / Phase 3).

Wires gTcInterface into main.cpp's setup()/loop(), replacing the stub
comments already left there, matching gVtInterface's construction order
and lifecycle exactly.

Verified: teensy41_isobus builds clean; teensy41_serial (no ISOBUS) still
builds clean with the new files compiling to empty translation units.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Wire IsobusTcInterface diagnostics into IsobusDebugMenu

There was no visibility into TC connection state at all before this --
IsobusTcInterface exposed IsConnected()/GetDrpDeviationMm()/etc. but
nothing called them. Needed for tomorrow's hardware test.

IsobusDebugMenu takes an optional IsobusTcInterface* (nullptr-safe, so the
TC section is skipped if unset) and now prints: connected state, task
active (advisory only, matching the same caveat already on the interface
itself), DRP deviation (DDI 513) with age, and TC GNSS quality (DDI 514)
with age -- both in the full status dump and the periodic one-liner.
main.cpp now passes gTcInterface through.

Verified: teensy41_isobus and teensy41_serial both build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Correct overstated VT claim in hardware test notes

Session 2's item 3 claimed the object-pool upload path "matches session
1's observation of the controller actually appearing/working in the
InCommand 1200's VT" -- session 1 only observed Ploegbesturing visible as
a connected ECU (address claim/device presence), never the actual working
set/data mask (the plough control screen) rendered. Confirmed directly:
the Ploughcontrol screen has still not been seen on a VT as of today. Code
running without error is not evidence it rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add VT connect/upload progress to IsobusDebugMenu

Same gap as the TC side: IsobusVtInterface had no debug output at all --
only get_is_connected() existed anywhere, a single boolean true only once
VT connect + object pool upload + activation (a ~22-step handshake)
fully completes. No way to tell where it's stuck.

AgIsoStack's VirtualTerminalClient tracks its internal state machine but
never exposed it publicly (unlike TaskControllerClient, which already has
an unpatched get_state()) -- patched the vendored source to add the
missing accessor. Documented in the new Documentation/
AgIsoStackVendorPatches.md, since .pio/libdeps/ is gitignored and this
patch will be silently lost on any pio pkg update / clean lib reinstall
if not reapplied from that doc. Also used the occasion to correct a
stale claim in that same area: the CANNetworkManager Meyer's-singleton
patch from the 2026-08-04 CAN bring-up session is no longer present in
the currently vendored copy (confirmed by reading the file directly) --
wiped by a lib reinstall between 2026-08-04 and 2026-08-08, never
reapplied, not needed since the real hang fix was the Teensy
optimization flag, not the singleton conversion.

IsobusVtInterface::GetStateStep()/GetStateTotalSteps()/GetStateName()
expose the state as "step N of ~22" plus a human-readable name (e.g.
"12/22 UploadObjectPool") -- not byte-accurate upload percentage, which
would need the transport-protocol session's own progress counter; there's
no public path to reach that session either (CANNetworkManager's
per-channel TransportProtocolManager is private with no accessor).
Judged disproportionate to patch for a first pass.

IsobusDebugMenu now takes an optional IsobusVtInterface*, printing VT
connect state + step in both the full dump and periodic line, same
nullptr-safe pattern as the TC section added earlier today.

Verified: teensy41_isobus and teensy41_serial both build clean (the
former recompiles the patched AgIsoStack VT client files without error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix VT/TC partner registration and TC DDOP tree; add TC/VT diagnostics

Both IsobusVtInterface and IsobusTcInterface constructed their
PartneredControlFunction directly instead of via
CANNetworkManager::create_partnered_control_function(), so neither could
ever be matched against an incoming Address Claim -- VT and TC sat at
"not connected" indefinitely regardless of what was on the bus. Fixed by
using the factory, matching AgIsoStack's own examples.

The TC DDOP was also missing its mandatory root DeviceElement of
Type::Device (ISO 11783-10 requires exactly one) and never called
add_reference_to_child_object() to attach the DPT/DPD children to their
owning element, leaving them unattached. Fixed; structure label bumped
TC01 -> TC02.

Confirmed on hardware (Fendt + Trimble rig, 2026-08-10): TC now connects,
reaches task-active, and correctly identifies both declared DDIs via
Value Requests. VT object pool is still rejected by the real terminal
despite the same fix getting its handshake to the upload step -- root
cause not yet found via three live bisection rounds or static analysis;
see HardwareTestNotes.md for the full session log and next steps (VT via
AgIsoVirtualTerminal simulator; real XTE PGN via CAN sniff).

Also adds: VT negotiated-version reporting, TC value-command/request
activity counters (any DDI) for IsobusDebugMenu, and disabled VT pool
bisection scaffolding left in place for future reuse.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add TC-GEO connected-server capability query to IsobusTcInterface

AgIsoStack's TaskControllerClient already decodes the connected TC's
reported ServerOptions bits (SupportsTCGEOWithPositionBasedControl /
...WithoutPositionBasedControl) from its ParameterVersion handshake
message, but nothing read them. Session 3's hardware notes leading
theory for "DDI 513/514 never arrived" was that the Fendt+Trimble
rig's TC simply doesn't implement TC-GEO -- this exposes that as a
direct, on-screen Y/N in IsobusDebugMenu instead of inferring it from
DDI silence, ahead of testing against a Trimble (expected N) and an
Ag Leader InCommand 1200 (expected Y) rig today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add TC-GEO manufacturer licensing research and field test log; note offline VT pool reference-parser verification

ISOBUS_TC_Manufacturer_Comparison.md: how John Deere, Trimble, Raven,
CNH, and Ag Leader each gate TC-GEO (licensing tier, activation
mechanism, per-model quirks) -- research backing session 3's "does
this TC implement TC-GEO at all" open question.

TCGEO_Field_Test_Log.md: tracks confirming the above against real
farm terminals, starting with Bos (Trimble+Fendt, TC-GEO expected
absent -- consistent with their separately-purchased plough control
system) and van Mastwijk (Ag Leader InCommand 1200+CNH, TC-GEO
expected present out of the box).

HardwareTestNotes.md: adds a follow-up section documenting an offline
verification that fed VTObjectPool.cpp's exact pool bytes into
AgIsoStack++'s own upstream IOP parser (the same parser
AgIsoVirtualTerminal uses) -- accepted cleanly, all 23 objects, no
errors, shifting the VT-rejection theory from "bug in our bytes"
toward "Fendt-UT-specific rejection reason."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fold TCGEO_Field_Test_Log.md into HardwareTestNotes.md

Bos and van Mastwijk are the same rigs already tracked as Session 3
and Session 1/4 in the hardware test log -- keeping TC-GEO field
results in a separate doc split the same real-world test event across
two files. Bos's TC-GEO findings move into Session 3 as a subsection
(explaining the DDI 513/514 silence via Trimble's licensing model);
van Mastwijk's pre-visit checklist becomes a new Session 4 placeholder
for this afternoon's second visit. ISOBUS_TC_Manufacturer_Comparison.md
(the underlying per-brand licensing research) stays a separate
reference doc -- only the field-test-log/session-log split is removed,
giving one clean, session-numbered file for the laptop session doing
today's testing to fill in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Decode the Session 3 VT rejection's error bitmask against ISO 11783-6

EndOfObjectPool_ErrorBitmask_Research.md reads the actual standard
text (End of Object Pool Response, PGN 1810) rather than relying on
AgIsoStack's own reference server, which hardcodes that byte to 0 on
send and so never decodes it. "Bitmask value 9" = bit 0 (method or
attribute not supported by the VT) + bit 3 (pool deleted from
volatile memory -- boilerplate on any rejection per the standard).

Corrects HardwareTestNotes.md's Session 3 follow-up, which had called
the byte "opaque" / "Fendt-proprietary" after only checking AgIsoStack
itself. The real complaint is specific: something in the WorkingSet
object's own attribute fields (not its children/masks, which is all
Session 3's bisection varied) isn't supported by this VT. Points
Session 4's checklist at a sharper bisection target if the same
rejection recurs there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add WorkingSet-attribute bisection scaffold; patch AgIsoStack to decode object pool error bits

VTObjectPool.cpp: appendWorkingSet() gains optional selectable/
numLanguages/languageCode params (defaulted to current production
values, so this is a no-op until used) plus a
VT_WORKINGSET_BISECT_VARIANT switch in BuildObjectPool() to try
alternate WorkingSet field values against the REAL 23-object pool,
not a stripped-down substitute. Session 3's own bisection only varied
DataMask/SoftKeyMask content; EndOfObjectPool_ErrorBitmask_Research.md
points at the WorkingSet's own attributes instead, which nothing has
tested yet -- this is a bisection tool, not a confirmed fix, since
there's no way to verify against a real terminal without hardware.

.pio/libdeps/teensy41_isobus/AgIsoStack (vendor patch, documented in
AgIsoStackVendorPatches.md #2, NOT tracked by git -- reapply on any
other machine/clean .pio/ per that doc's own caveat): decodes the End
of Object Pool response's error-codes byte into named ISO 11783-6
bits directly in the serial log ("method or attribute not supported
by the VT" etc.) instead of a raw integer, so a future rejection is
readable without a manual standards lookup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Fix VT object pool rejection: WorkingSet needs a child, and a field-order bug

The VT object pool has been rejected by every real terminal tested since
session 1 -- confirmed today, by elimination, that it was never about pool
content (WorkingSet selectable/language/colour, DataMask/SoftKeyMask
content, version labels, VT3-vs-VT4 negotiation, and a real but unrelated
manufacturer_code bug -- was 64, belonged to another manufacturer, now
1407 -- all ruled out one at a time against a real CNH terminal).

Decisive test: uploaded AgIsoStack's own reference pool (examples/
VirtualTerminal/ObjectPool.cpp, pulled in verbatim via #include, not
copied) against the same terminal. It connected and rendered -- the
first working VT pool this project has ever confirmed on real hardware,
proving the upload mechanism itself was sound and the bug was in our
pool's content specifically.

Field-by-field comparison found it: the reference WorkingSet has 1 child
object reference; ours always had 0. Adding one exposed a second, latent
bug in appendWorkingSet() -- it wrote the language-code bytes immediately
after the header, but the correct ISO 11783-6 order is children, then
macros, then language codes. With 0 children this was invisible (nothing
to reorder around); the moment a child existed, the terminal read our
language code "nl" (bytes 0x6E,0x6C) as the child's object ID (0x6C6E =
27758) and correctly rejected it as unknown -- confirmed by exact byte
arithmetic against the terminal's own error response.

Fixed both: appendWorkingSet() now only writes the header, callers append
children then the language code (new appendLanguageCode()) in the correct
order; production pool gets a real child. Confirmed working after the fix
-- pool connects and renders. VT_WORKINGSET_BISECT_VARIANT keeps 1-4
available (now all with the child-reference fix retained) for re-testing
against Fendt, which was never retried with a WorkingSet child at all
despite implicating the same object.

VT pool version label bumped MW01 -> MW02 since the structure genuinely
changed, matching the TC DDOP's TC01 -> TC02 discipline from session 3.

Full session log, including the still-open post-connect stability timeout
(VT_STATUS_TIMEOUT_MS = 3000, cause not yet found) and what to check next
for DDI 513/514 and Ag Leader, in HardwareTestNotes.md Session 4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Stop flooding the VT with unconditional numeric-value updates

updateVtVariables() sent 4 send_change_numeric_value() commands every
100ms (40 msg/s) the whole time connected, regardless of whether any
value changed. AgIsoStack's own reference example only sends on a
button press. Session 4's decisive VT-pool test swapped in that
reference pool and it stayed connected stably, while our own pool
drops intermittently with "[VT]: Status Timeout" -- the one thing
that differs behaviorally (not just structurally) between those two
tests is this unconditional flood: against our own pool the 4 object
IDs are real, bound OutputNumber widgets, so the VT does actual
redraw work on every message, unlike the reference pool's IDs, which
almost certainly don't resolve to a NumberVariable and get rejected
cheaply. Sustained redraw load intermittently starving the VT's own
periodic status broadcast past AgIsoStack's 3s VT_STATUS_TIMEOUT_MS
is a plausible mechanism, not yet confirmed on hardware.

Fix: send only on real value change, plus a 1s heartbeat resend so a
dropped CAN frame can't leave the VT stale forever. Cuts steady-state
traffic ~10x for slow-changing plough telemetry with no functional
loss. Needs re-testing on hardware to confirm/rule out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add an app-switcher icon for the plough working set

Closes #14. Decoded AgIsoStack's own reference pool's WorkingSet
object by hand (it stayed connected/rendered on real hardware during
Session 4's decisive test) and found its 1 declared child is
specifically a PictureGraphic "avatar" icon at x=0, y=-4 -- that's
the actual mechanism VTs read for an implement's app-switcher entry,
not just "any child object" as the earlier fix treated it.

Adds appendPictureGraphic() (byte layout confirmed against AgIsoStack's
own parser, not guessed) and a hand-authored 16x16 monochrome plough
pictogram (hitch narrowing to a share on a ground line). WorkingSet's
child swaps from the placeholder OutputString to this icon, matching
the reference pool's exact pattern. Version label bumped MW02 -> MW03
per this project's established discipline for pool structure changes.

Builds clean on teensy41_isobus/teensy41_serial. Not yet seen on a
real VT's app switcher -- needs field verification next session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Wire VT Wider/Narrower soft keys into the button arbitration ladder

Follows this project's existing (currently disabled) joystick-input
precedent: rather than a separate VT control path racing
InterfacePlough::Update()'s existing button-driven Adjust() call,
IsobusVtInterface::ConsumeWiderPress()/ConsumeNarrowerPress() are
consume-once signals that OR directly into CheckButtons()'s existing
LEFT_BUTTON_2/RIGHT_BUTTON_2 conditions -- same debounce/arbitration
logic, no new code path.

Direction mapping confirmed by tracing ImplementPlough::Adjust():
direction=-1 (LEFT_BUTTON_2) -> Wider(), direction=+1
(RIGHT_BUTTON_2) -> Narrower(), so Key_Wider feeds the LEFT slot and
Key_Narrower the RIGHT slot. Key_Auto stays log-only -- AUTO mode is
derived from GPS/hitch state, not user-settable, so there's no
existing target for it.

InterfacePlough::Update()/CheckButtons() gain two defaulted bool
params (vtWiderPressed/vtNarrowerPressed = false) so the class stays
framework-agnostic; main.cpp only passes real values under #ifdef
ISOBUS. All 47 native AUnit tests still pass (verified via a one-off
MSVC run excluding test_VehicleGps.cpp, whose missing include path in
run_tests_msvc.ps1 is a pre-existing, unrelated gap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add ISO 11783-6:2004 standard PDF as reference

Primary source for the End of Object Pool error-bitmask decode in
EndOfObjectPool_ErrorBitmask_Research.md (bitmask 9 = bit 0 + bit 3).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Add VT object pool import/export tooling for AgIsoTerminalDesigner

export_pool_msvc.ps1 builds VTObjectPool.cpp's BuildObjectPool() natively
(reusing the existing MSVC/Arduino-stub pattern from the AUnit tests, since
the pool code has no AgIsoStack/CAN dependency) and dumps the current
production pool to a raw .iop file for visual inspection/editing in
AgIsoTerminalDesigner (https://open-agriculture.github.io/AgIsoTerminalDesigner/).

iop_to_header.ps1 goes the other way, converting an edited .iop file into a
C++ header. VTObjectPool.cpp gains a VT_POOL_USE_DESIGNED_POOL toggle
(mirroring the existing VT_POOL_USE_AGISOSTACK_REFERENCE one) to swap in a
designer-authored pool for on-hardware testing without touching the
hand-authored append() pool. Verified round-trip byte-for-byte identical;
teensy41_isobus and teensy41_serial both still build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Link the two upstream AgIsoStack PRs from the vendor-patch TODO list

get_state() and the EOP error-bit decoding are now open PRs (#14, #15) on
Arjan-Woltjer/AgIsoStack-Arduino against upstream, not just a TODO -- notes
what to do (bump lib_deps, drop the local .pio patch) once either merges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Drop unwantedRecommendations from .vscode/extensions.json

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Recommend C++ tooling extensions for Ploegbesturing Isobus

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Reorganize Ploegbesturing Isobus into calibration/config/implement/isobus folders, extract testable PGN decode

- Split lib/PloegbesturingCore/src's flat layout into calibration/,
  config/, and implement/ subfolders; InterfacePlough stays at src root.
- Extract IsobusGuidanceChannel's byte-level PGN decode into a new,
  framework-free IsobusPgnDecode unit, native-testable like the serial/
  parsers -- 31 new tests, including regression coverage for two
  corrections: the JD/Ag-Leader-Raven legacy XTE PGN accepts either
  known-valid source address (0x2A and 0x80, not one replacing the
  other), and its quality check is a nibble range, not one exact byte.
- Remove the unused VehicleGps native test (never exercised anything
  in this project) and its now-dead build wiring.
- Convert every include inside PloegbesturingCore to a relative path
  (matching Salacia's SalaciaFirmwareCore convention) instead of a bare
  filename resolved via per-subfolder -I flags; drops every such -I
  entry from platformio.ini in favor of a single library-root -I for
  [env:native] (teensy envs get it for free via PlatformIO's LDF).
- Delete the native GuidanceSource fake this enabled removing: tests
  now construct and drive the real class directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Drop unused AUnit/test_framework from Ploegbesturing Isobus's real-board envs

teensy41_isobus/teensy41_serial carried bxparks/AUnit and test_framework =
custom since the very first commit that split them off the old Ploeg ISOBUS
prototype's single env, even though this project's tests have always run
under [env:native] only. Nothing in src/ or lib/PloegbesturingCore/src/
includes AUnit, so PlatformIO's LDF never actually compiled it in --
confirmed empirically, teensy_size's FLASH/RAM1 totals are byte-for-byte
identical with or without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Move Ploegbesturing Isobus's IMXRT1062 size-check fix to the shared teensy41 base

extra_script.py's corrected combined ITCM+DTCM FlexRAM check was only wired
up for teensy41_isobus; teensy41_serial extends the same [env:teensy41] base
but never inherited it, silently falling back to PlatformIO's stock
per-bank check. Moved to [env:teensy41] so both variants get it -- low risk
today given teensy41_serial's much smaller footprint, but the same shared
hardware pool applies to it too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Rename cryptic single-letter parser params, add GN* NMEA talker ID support

Renamed h/n/t parameters to header/termNumber/term across NmeaParser,
TrimbleParser, and CanSerialParser for readability, no behavior change.
NmeaParser also now claims GNGGA/GNVTG/GNXTE alongside GPGGA/GPVTG/GPXTE,
for multi-constellation GNSS receivers that use the GN talker ID instead
of GP. Fixed a missing closing paren in NmeaParser's VTG case introduced
by the rename (newSpeed = atof(term) -- would not have compiled).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Document Trimble's mixed-checksum handling in SerialGuidanceChannel, drop unreachable disjunct

Explain why dispatchTerm() doesn't commit at the Trimble outer-packet end
marker (isChecksumTerm is never set there) and why the explicit commitTo()
right after it is safe (the outer packet's sum-based CRC already passed).
Removes the earlier confused "why does this call again?" note now that
this is traced and understood -- see conversation.

Also comments out dispatchTerm()'s activeParse->useParityAsChecksum()
disjunct: unreachable today (TrimbleParser is the only parser reporting
true, and ROXTE sentences never terminate on '*', so they never reach this
branch at all -- their integrity is fully covered by the outer packet's
own checksum). Kept commented rather than deleted since a future
'*'-terminated, checksum-less parser would need it back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Log Session 5 field test (Ag Leader InCommand 1200): VT status timeout root-caused

HMI object pool upload/render confirmed working. Found and root-caused a
reproducible VT Status Timeout ~2-3s after connect: the InCommand 1200
stops sending its own VT status broadcast after exactly 4 messages,
independent of anything we send it (isolation-tested by disabling our
post-connect variable burst entirely -- identical failure). Ruled out as
downstream of no auto-reconnect and TC never connecting either, both filed
as separate issues (#17, #18, #19).

Adds IsobusVtInterface::GetVtStatusMessageCount()/GetVtStatusMessageAgeMs(),
an independent PGN 0xE600 listener used to make this diagnosis, surfaced in
IsobusDebugMenu as vtstat=<count>/<age>ms.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Root-cause #17's VT Status Timeout to an AgIsoStack CF-eviction bug, not the terminal

The InCommand 1200 almost certainly never stopped broadcasting its VT status
message. Our VT partner control function gets wrongly evicted from
AgIsoStack's own control-function table ~2-3s after connect (upstream bug
Open-Agriculture/AgIsoStack-plus-plus#584: update_new_partners() adopts an
already-active CF without carrying over claimedAddressSinceLastAddressClaim-
Request, so the next PGN 60928 request anywhere on the bus gets it pruned).
Once evicted, every subsequent status frame is silently dropped before
reaching any listener -- including the independent vtstat counter added
earlier this session, which is why it froze in lockstep with AgIsoStack's
own tracking rather than actually corroborating a terminal-side stop.

Vendor-patches the fix (documented as patch #3, applied to
.pio/libdeps/teensy41_isobus per the existing convention -- gitignored, not
in this diff) and bumps the VT client's log level Warning -> Info so a
future capture shows AgIsoStack's [NM] control-function lifecycle lines
directly. Also explains #18 (no auto-reconnect) as the same root cause
rather than a separate gap: a compliant VT only claims once, so nothing
naturally repopulates the evicted table slot afterward.

Filed upstream as Open-Agriculture/AgIsoStack-Arduino#16. Not yet
field-verified against the InCommand 1200 that surfaced this -- next
hardware session's job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Extend #17's root-cause analysis to cover #19 (TC never connects)

IsobusTcInterface::Begin() binds the TC partner via the identical
NAMEFilter + create_partnered_control_function() pattern as the VT, so it's
subject to the same AgIsoStack-plus-plus#584 eviction bug. TaskControllerClient's
WaitForServerStatusMessage state has no timeout at all -- it waits
indefinitely for the TC server's first status broadcast, gated through the
same process_can_message_for_global_and_partner_callbacks() dispatch that
dropped the VT's status frames. If the TC partner is evicted before that
first broadcast arrives, the client sits stuck forever, matching #19's
observed symptom independently of the VT's own connection state.

No source change needed -- the vendor patch already applied for #17 fixes
this generically for any late-bound partner. Documentation only, plus
posting the analysis to #19.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Log Session 6: #17/#19 fixed and verified, plus a second eviction bug found and fixed on the tractor

Field session against the Ag Leader InCommand 1200 and the New Holland
tractor's own VT. Verifies the off-tractor root-cause work in 590f2fa/935de81
and then goes considerably further.

Confirmed fixed (issues #17, #19, both closed):
- VT holds indefinitely against a single terminal; 160s unbroken with vtstat
  climbing at 1Hz, where it previously died in ~2-3s every time.
- Task Controller connects and activates its DDOP for the FIRST TIME in the
  project's history, settling #19. The "downstream of the VT" theory was
  wrong; the TC was a third, independently-evicted partner.

Second bug found, root-caused and fixed live (vendor patch #4):
Patch #3 fixed only the initial partner adoption. Every subsequent PGN 60928
address-claim roll-call clears the liveness flag on all tracked CFs and
prunes 755ms later, so partners kept being evicted out from under live
sessions -- and update_address_table() restores a pruned CF without ever
setting that flag, making it self-sustaining (observed as unbroken
offline/online churn for 300+ seconds). Patch #4 exempts Partnered CFs from
the prune, alongside the existing Internal exemption.

Verified in the configuration that had been dying in ~3s all afternoon:
235 prune cycles survived over 800s with zero partner losses, then the worst
case -- a second terminal enumerating onto the bus mid-session, previously
fatal within ~4s -- survived with 185 consecutive vt=Y + tc=Y samples and
~21 minutes of unbroken VT+TC uptime.

Also found, filed as new issues:
- #20: the legacy XTE decode reads the wrong vendor's payload on this rig.
  Ground truth swung 144 -> 8 -> 118 -> 14 cm while our value took two
  distinct values in ~400 samples. Session 1 added SA 0x80 (Ag Leader) to the
  John Deere decoder assuming a shared layout on proprietary PGN 0xFFFF; it
  is not shared. Same cause explains quality always reading 0.
- #21: the TC reports TC-GEO-with-position support with an active task and an
  AB line selected, yet sends zero Value Commands and zero Value Requests.
  Kills the Session 3 TC-GEO licensing theory.
- #22: upstream patches #3 and #4 plus the update_address_table restore fix.

Recorded honestly in the notes: an intermediate XTE analysis concluded a 100x
scale error from a single ground-truth point that matched almost perfectly.
Further readings disproved it. Raw captures are committed alongside the
annotated excerpts precisely because re-deriving that took counting distinct
values across ~400 samples.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Stop decoding Ag Leader payloads with John Deere's layout (#20)

Session 1 (2026-08-18) widened DecodeLegacyXteJohnDeere() to accept source
address 0x80 alongside 0x2A, assuming Ag Leader/Raven and John Deere share a
payload layout on the overloaded proprietary PGN 65535. Session 6
(2026-09-05) disproved that against live ground truth: while the terminal's
own XTE swung 144 -> 8 -> 118 -> 14 cm, data[3..4] produced only two distinct
values across ~400 consecutive samples, and data[1] reads 0x03, which the
John Deere quality nibble check can never accept.

So on an Ag Leader rig we were producing a confidently wrong XTE and a
permanently failing quality gate -- worse than no reading, since the number
looks plausible. 0x80 is now diagnostics-only until a raw capture
establishes its real layout.

0x2A remains decoded as John Deere, confirmed by the user this session. Note
that path is only John Deere's *proprietary* XTE; the same equipment family
also broadcasts standard NMEA2000 XTE (PGN 129283, legacy CAN ID 1DF9031C,
SA 0x1C), already handled unfiltered by DecodeXteNmea2000().

Raw diagnostics (rawWord/rawByte1) are deliberately still captured for 0x80
and now stored regardless of `valid` -- they are exactly what deriving the
Ag Leader layout needs off a live bus, so they must keep reaching
IsobusDebugMenu. This also brings the call site in line with
IsobusPgnDecode.hpp's already-documented convention that diagnostic fields
populate independent of `valid`.

Does not close #20: this stops us reporting wrong data, but the rig still
has no usable XTE source, which is what actually blocks plough steering.

79/79 native tests pass; teensy41_isobus builds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add a Tramline Control probe to the DDOP (#21)

DDI 513/514 are optional members of the AEF Tramline Control DDI set, not
standalone process data. Per "Tramline Control -- Basic Requirements v1.16"
(ISO 11783-11 DDE supplement, attached to the DDI 505 entity on isobus.net),
a Level 1 system also requires DDIs 505, 506, 515, 507, 508, 509, 510 and 511
in the DDOP. This DDOP declared only 513 and 514 -- precisely the two
optional ones -- leaving them as orphan objects a TC has no reason to write
to. That is the most likely reason no Value Command has ever been observed.

Rather than build out a tramline capability a plough does not have (Level 1
means "the implement calculates the tramline tracks"), this declares only the
handshake pair as a probe: DDI 505 as a DPT with NO levels claimed, and DDI
506 which the TC writes back. A tramline-capable TC is required to answer
with 506 even when there is no common level (value 0), so any arrival proves
the terminal implements the feature and that 513/514 are reachable; silence
suggests it does not, and no DDOP work would ever produce them from it.

DDI 506's arrival is latched separately from lastValueCommandDdi (which a
later DDI would overwrite) and surfaced in IsobusDebugMenu, since the result
is that it arrived at all rather than what it said.

Structure label bumped TC02 -> TC03: the tree changed, terminals cache pools
by that label, and AgIsoStack logs an explicit error on a reused one.

Also notable, and why the previous reasoning went wrong: the TC's
ServerOptions capability bits have no tramline flag at all, so the
"TC-GEO (with/without pos): Y/N" line says nothing about whether 513/514
will ever arrive.

79/79 native tests pass; teensy41_isobus builds clean. Not yet field-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Research which Task Controllers implement Tramline Control (#21)

Background for #21, where DDI 513/514 were traced to the AEF Tramline
Control feature. Establishes what can be expected from real terminals before
spending tractor time on it.

Headline: Tramline Control only became AEF-certifiable on 2026-08-31,
released as TRACK Generation 1 together with its conformance test. Before
that it was a DDI definition plus a requirements document with no
certification path, so any tramline interop in the field today was built
vendor-to-vendor. Certification data barely exists yet -- absence from the
AEF ISOBUS Database means nothing for at least a year. Gen 1 covers Levels 1
and 2 only; Level 3 (the TC calculates tramlines) is deferred to an
unreleased Generation 2, so no TC can be certified for it today.

For the InCommand 1200 specifically: no public evidence either way, and a
trap worth knowing about. It does have a feature called "Tramlines", but it
sits in the Guidance and Steering chapter next to AB lines and SmartPath --
it flashes the pass number under the on-screen lightbar and no implement is
in the loop. Searching "InCommand tramline" lands on that and misleads.

Confirmed TC-side implementers from their own current pages: Fendt, and
Amazone (the only vendor naming a level -- Level 1). CCI likely. John Deere
leads the AEF TRACK project team but publishes nothing about shipping
support; the report flags that as its most over-readable item.

Also finds that tramline control is mostly NOT negotiated over ISOBUS in
practice -- the drill usually does its own logic -- and that even where it is,
at Levels 1-2 the TC only publishes guidance-track info and honours the
505/506 handshake rather than computing anything.

Sec 7 flags a miscitation in ISOBUS_TC_Manufacturer_Comparison.md
(reference 2 cites the TRAM-becomes-TRACK article for a TC-GEO naming point;
that article is entirely about tramline control). Flagged, not edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix a miscited (and incorrect) TC-GEO naming claim

ISOBUS_TC_Manufacturer_Comparison.md sec. 1 stated that "Variable Rate
Control" is AEF's customer-facing marketing term for TC-GEO, citing the AEF
article "AEF Announces Update: TRAM becomes TRACK". That article is entirely
about the Tramline Control functionality being renamed TRACK and says
nothing about TC-GEO -- found while researching Tramline Control for #21.

Checking the claim rather than just re-citing it showed the claim itself was
wrong. Two AEF primary sources: the AEF Tour page (already reference 1) lists
"TC-GEO (Task-Controller geo-based)" and never uses the phrase "variable rate
control"; AEF's own "ISOBUS in Functionalities" leaflet expands it as "Task
Controller geo-based (variables)". AEF publishes no customer-facing marketing
name for TC-GEO. "Variable rate control" is industry and vendor description
of what TC-GEO enables, not an AEF functionality name -- a distinction that
matters when reading a certificate or the AEF database.

The pattern the original line reached for is real: AEF does separate
technical from customer-facing names, which is exactly what the TRAM/TRACK
article describes for tramline control. It just doesn't apply to TC-GEO.

Corrects the text, adds a dated correction note per the convention used
elsewhere in these docs, and repoints reference 2 at the leaflet. Also
updates TramlineControl_TC_Support_Research.md sec. 7, which said it was
flagging rather than editing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record that vendor patch #4 diverges from upstream design intent (#22)

Filed the roll-call eviction findings upstream as
AgIsoStack-plus-plus#717. While preparing it, upstream's own
CoreTest.InvalidatingControlFunctions turned out to explicitly assert that a
partnered control function which misses a roll-call goes address-invalid and
reports Offline. That is a named, intentional test, so pruning partners is
upstream design rather than an oversight, and patch #4 is a deliberate local
divergence rather than a bug fix awaiting acceptance.

Only the companion fix -- crediting the liveness flag when
update_address_table() restores a pruned CF -- was offered as a PR
(AgIsoStack-plus-plus#718), since it breaks no existing test and is
unambiguous. The pruning policy itself was put to the maintainers as a design
question, with the field evidence, rather than asserted as a defect.

Matters for whoever reapplies these after a clean .pio rebuild: unlike
patches #1-#3, patch #4 has no upstream release that will eventually retire
it. If #718 lands, re-test whether patch #4 is still needed at all -- a
durable restore may degrade the failure from permanent to transient on its
own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Apply the restored-CF liveness fix locally as vendor patch #5 (#22)

update_address_table()'s restore branch returns a pruned control function to
controlFunctionTable without setting claimedAddressSinceLastAddressClaim-
Request, so it re-enters already eligible for pruning and is evicted again on
the next roll-call. That is what made Session 6's failure self-sustaining
rather than transient.

Applied to the gitignored .pio/libdeps tree per the existing convention and
documented here as patch #5. Build-verified (teensy41_isobus builds clean);
not field-verified. With patch #4 also applied this should be a no-op for our
own VT/TC partners by construction -- it matters for every other CF on the
bus, and for anyone taking patch #4's reasoning without patch #4 -- which is
why it was safe to apply without a tractor, though that reasoning is itself
worth confirming in the field rather than assuming.

Offered upstream as AgIsoStack-plus-plus#718 and mirrored to the fork we
actually consume as AgIsoStack-Arduino#17; the latter is the one that would
retire this entry. Patch #4 was deliberately NOT offered as a PR, since
upstream's own CoreTest.InvalidatingControlFunctions asserts the behaviour it
changes -- that went to the maintainers as a design question instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Log the full 8-byte PGN 65535 payload for the Ag Leader capture (#20)

Deriving Ag Leader's XTE layout on this overloaded proprietary PGN needs
every byte of the payload logged against ground-truth XTE read off the
terminal. Until now only 3 of the 8 bytes were exposed (rawWord = d[3..4],
rawByte1 = d[1]) -- and those three are the John Deere layout's fields
specifically, which is exactly the assumption #20 exists to replace. A field
session without this would come back with the same data it went out with.

Captured on the same terms as the existing diagnostics: length guard passed
and the source address recognised, independent of `valid`, so an Ag Leader
sender still yields a full capture even though it is no longer decoded.

Printed in two places, for two different jobs:
- Full dump: the whole payload with an age, for reading on the spot.
- Periodic line: `xteraw=<SA>:<16 hex>`, so a serial capture is
  time-correlated. That is the one that matters -- matching bytes against
  values an operator calls out only works if each sample carries the same
  timestamp as everything else on the line. Sampled at the periodic 1 Hz
  rather than per message: the PGN arrives ~10 Hz but real XTE moves over
  seconds, so 1 Hz is ample and keeps the log readable. Source address is
  included because the whole problem is that this PGN is shared between
  vendors with different layouts.

Hex is zero-padded via a local helper -- Arduino's print(x, HEX) drops
leading zeros, which would shift byte boundaries in an offline analysis
(0x03 must read "03", not "3").

80/80 native tests pass; teensy41_isobus builds clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add VT/TC reconnect watchdogs and partner-address visibility (#18)

Both clients now force a clean re-attempt after a sustained outage that
follows a successful connection, so a transient bus event degrades to a
stutter instead of a dead session needing a Teensy power cycle.

Two things the issue's proposed fix would have got wrong, found by reading
AgIsoStack rather than assuming:

- VirtualTerminalClient::initialize() is guarded by `if (!initialized)`, so
  calling it again on a live client -- which is what #18 suggested -- is a
  no-op and would not have restarted anything. A real re-attempt needs
  terminate() first, which drops the PGN callbacks and resets the state
  machine so initialize() actually rebuilds them. terminate()'s
  delete-object-pool branch only runs when Connected, which we are not, so
  this is just teardown plus a reset.
- TaskControllerClient has a purpose-built restart(), and its initialize()
  registers PGN callbacks *unconditionally* -- so calling initialize() twice
  there would double-register them. restart() is used instead.

Neither watchdog arms until the first successful connection: the initial
handshake has its own timing (the TC's includes a six-second startup delay),
and firing during it would interrupt a connection that was progressing. That
deliberately leaves the never-connected case alone -- that was #19, whose
cause was the partner being evicted from AgIsoStack's control-function
table, whichE restarting the client would not have fixed.

Thresholds are 10 s (VT) and 15 s (TC), both comfortably longer than the
clients' own timeouts and retry paths, with equal retry spacing so a partner
that is genuinely gone produces one log line per interval rather than
churning callback registrations.

Also surfaces each partner's address and get_address_valid() in the debug
menu, with a reconnect-attempt count. That is the signal whose absence made
#17 take a source-diving session to find: a partner reading valid=N while
the terminal is plainly alive on screen is the control-function eviction,
and now says so directly.

80/80 native tests pass; teensy41_isobus builds clean. Not field-verified --
testing it means deliberately power-cycling a terminal mid-session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Clamp the VT's XTE variable, and give the TC client our VT for language data

Two small fixes from the #20/#21 follow-up lists.

Clamp (#20): updateVtVariables() biases XTE by +1000 so the VT's uint32
variable stays non-negative, which silently assumes +/-10 m. Out of that
range the value wrapped through the cast and rendered as a plausible-looking
huge number -- Session 6 saw the VT display 42949532.47, which decodes as
(uint32)(-14049). Enforce the assumption instead of implying it, so a bad
reading pegs visibly at the limit. Lower priority since 0x80 no longer
commits an XTE at all, but correct regardless of where the value comes from.

Language partner (#21): TaskControllerClient's third constructor argument is
the primary VT's *partnered control function*, not a VirtualTerminalClient --
we were passing nullptr. AgIsoStack's select_language_command_partner() uses
it to source ISO 11783-7 language/unit data whenever the TC server is older
than version 4, falling back to a global request with "no VT was provided ...
might not be ideal" when it is null. Every TC we have met reports version 3,
so that fallback is always what ran.

Checked before changing something that currently works: the VT is only ever
consulted for language data, the request is bounded by the same six-second
timeout as before, and the state machine proceeds to ProcessDDOP regardless.
Worst case is unchanged behaviour with a different addressee. The parameter
defaults to nullptr, so passing nothing restores exactly the old path.

main.cpp already constructed the TC interface after gVtInterface->Begin(), so
the VT partner exists by then; both call sites now say why the order matters.

80/80 native tests pass; teensy41_isobus and teensy41_serial both build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Decode lat/lon from both position PGNs (Session 1 bug #6)

The debug dump has read "Lat/Lon: 0.000000 / 0.000000" on live rigs since
Session 1, with a fresh fix age next to it. Both ISOBUS position handlers
only stamped the fix timer and never published coordinates -- and the
NMEA2000 one had already decoded them, then thrown them away. Only the serial
build's parsers ever called SetPosition(), so the ISOBUS build could never
show a position at all.

Session 1 deferred this pending a decision on whether anything needs live
coordinates. Answered by checking: nothing in the control path reads them.
The sole consumer of GuidanceSource::GetLatitude/GetLongitude is
IsobusDebugMenu's dump -- ImplementPlough and InterfacePlough use XTE, speed,
quality and fix ages. So this is a diagnostics fix, which is also why it is
low risk.

Kept the fix-age semantics identical, deliberately. NoteGgaFixReceived() is
still gated on fixPresent alone, exactly as before; coordinates are published
separately behind hasCoordinates. An implausible reading therefore costs a
coordinate but never a fix -- which matters because InterfacePlough drops to
HOLD on a stale GGA fix, so widening that gate would have been a safety
change disguised as a diagnostics one. SetPosition() stamps the same
timestamp, so the fix age cannot shift either way.

Legacy decode (PGN 65267) follows CanSerialParser's CAN_POS case, which reads
the same wire format off the serial transport: little-endian uint32 biased by
2100000000, 1e-7 degree units. Cross-validated in a test against that
decoder's own known-good fixture "$0CFEF31C,00072A9C80652680" (lat=52.0degN,
lon=5.0degE) -- the same cross-transport trick the John Deere XTE test uses,
so this rests on math verified against real hardware years ago rather than on
my reading of it.

Added a shared plausibility guard (lat +/-90, lon +/-180) since the legacy
encoding has no documented not-available sentinel; the NMEA2000 path keeps
its 0x7FFFFFFF check as the primary one.

83/83 native tests pass; teensy41_isobus builds clean. Coordinates themselves
are not field-verified -- worth a glance at the dump next session to confirm
they match the terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Field-test the DDI 505/506 Tramline Control handshake on an Ag Leader InCommand 1200 (#21) (#28)

* Probe the DDI 505/506 Tramline Control handshake on an InCommand 1200 (#21)

Field session 7. The InCommand 1200 sends no DDI 506 reply under either
declaration: 16 min with DDI 505 = 0 (no level claimed), and 6 min with
505 = 0x01 (Level 1), zero Value Commands and zero Value Requests in both.
Both runs logged "DDOP Activated without error", so the pool carrying
505/506 was accepted each time.

Run 2 exists to close the ambiguity the probe's own comment flagged: a TC
that short-circuits the handshake for a zero-capability implement is
indistinguishable from one with no Tramline Control at all. Claiming Level 1
removes that escape. Structure label bumped TC03 -> TC04 because the DDOP's
declared value changed and terminals cache pools by that label -- without it
the terminal serves run 1's pool and the re-run proves nothing.

The Level-1 value is NOT shippable: Level 1 means "the implement calculates
the tramline tracks", which a plough does not do. It is a diagnostic claim
made to force a reply, which is why this stays on a spike branch. Revert to
0, or build the feature out honestly, before this reaches isobus-tc-client.

The result is provisional on one unclosed confound, recorded in the notes:
zero Value *Requests* is equally consistent with our implement never having
been mapped into the running task on the terminal, in which case both runs
measured nothing. "Task active: Y" does not settle it -- AgIsoStack warns
that flag is unreliable per brand.

Also field-verified for the first time, independent of the TC work: all
three VT soft keys arrive from a real terminal -- BREDER x5, SMALLER x3,
AUTO x2 (AUTO's "not wired to control" is intended). Wired 2026-08-18,
never pressed on hardware until today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Revert the Level-1 probe claim, and record the CANedge capture for #20

Session 7's run 2 declared DDI 505 = 0x01 (Level 1) to force a Tramline
Control reply out of the InCommand 1200. It did not produce one, and the
claim is not honest firmware: Level 1 means "the implement calculates the
tramline tracks", which a plough does not do. Reverted to 0, and the
structure label with it (TC04 -> TC03), which the reverted pool matches
again. Firmware behaviour is therefore identical to where the session
started; only findings, logs and commentary change.

TC04 is recorded in the source as burned -- terminals that took part in run 2
still cache a Level-1 pool under that label, so the next real DDOP tree change
must go to TC05, not back to TC04.

Also records the CANedge full-bus capture taken during the session, with
ground truth called out live at 71 cm, then 1 cm, then 0 cm. That descending
sequence is what #20 has needed since session 3: a capture containing changes
of known size cannot be satisfied by a frozen field, unlike session 6's single
static reading that matched by coincidence.

Notes the better use of it too -- those figures are what the terminal
displayed, so the terminal was transmitting that quantity on the bus
throughout. Identifying the frames that pass through 71, 1 and 0 in order
yields continuous ground truth at full rate, and any zero crossing in it would
settle the sign convention that has been open since session 6 without another
tractor session.

83/83 native tests pass (MSVC runner); teensy41_isobus builds clean. Note
`pio test -e native` fails to discover the suite on this machine, identically
on an unmodified tree -- pre-existing, unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Record the full XTE ground-truth sequence, including both zero crossings (#20)

The CANedge session log is accompanied by five live-called ground-truth points,
not three: 71 cm, 1 cm, 0 cm, 99 cm on the other side of the line, then back to
0 cm.

The crossings are the valuable part. Session 6 left "sign convention remains
unverified" open because the operator's left/right calls were corrected
mid-sequence, leaving four magnitudes reliable but unsigned. Here the ordering
itself carries the sign, and it crosses zero twice -- out to the far side and
back. A wrong hypothesis can fake one sign flip via an unrelated bit that
toggled once; reproducing an out-and-back through zero on the correct field is
not something it does by accident.

So the derivation target for #20 is now stricter and better posed: find the
frames whose decode tracks all five magnitudes AND the flip. That should settle
both the layout and the sign convention from this log alone, with no further
tractor session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Add the sixth XTE ground-truth point and the capture's end marker (#20)

Sequence is now 71 / 1 / 0 / 99-far-side / 0 / 52-far-side: six points, three
zero crossings, and two distinct magnitudes on the far side rather than one.

The second far-side value matters beyond redundancy. A decode that saturates or
latches when the sign flips can still look correct against a single far-side
reading; it has to render 99 and 52 distinctly to survive this sequence. Three
ordered crossings likewise rule out a wrong field whose sign happens to toggle
once.

Also records that the capture ends with the plough control being disconnected,
which gives a clean end marker on the bus -- our control function drops off and
everything after is other traffic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
@Arjan-Woltjer
Arjan-Woltjer marked this pull request as ready for review September 11, 2026 21:32
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.

2 participants