Skip to content
Open
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
16 changes: 16 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,22 @@ if(AOG_TC_VALIDATE_IOP)
add_test(NAME object_pool_attributes_in_range COMMAND iop_validator
"${AOG_TC_IOP_SOURCE}")

# GuidanceTrackProvider (AOG PGN 0xF4 parsing) unit tests. Made a dependency
# of iop_validator so the validation workflow, which builds only that target,
# also builds this test.
add_executable(
test_guidance_track
${CMAKE_CURRENT_LIST_DIR}/tools/test_guidance_track.cpp
${CMAKE_CURRENT_LIST_DIR}/src/guidance_track_context.cpp
${CMAKE_CURRENT_LIST_DIR}/src/async_log.cpp)
target_compile_features(test_guidance_track PRIVATE cxx_std_20)
set_target_properties(test_guidance_track PROPERTIES CXX_EXTENSIONS OFF)
target_include_directories(test_guidance_track
PRIVATE ${CMAKE_CURRENT_LIST_DIR}/include)
target_link_libraries(test_guidance_track PRIVATE Threads::Threads)
add_dependencies(iop_validator test_guidance_track)
add_test(NAME guidance_track_parsing COMMAND test_guidance_track)

# A deliberately broken pool, so the suite asserts more than "today's pool
# passes". One case per message, since a shared exit code stays green as long
# as any one check still fires.
Expand Down
46 changes: 45 additions & 1 deletion docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ All PGNs sent **by AgIO/AgValonia to the TC** use source `0x7F`, except `0xD6` (
| `0xC9` (201) | Subnet detection | 5 | `[0xC9, 0xC9, IP0, IP1, IP2]` |
| `0xD6` (214) | GPS/IMU data | variable (≥39 used) | Only byte 38 (fix quality) is parsed today; rest of frame is currently unused. Source `0x7C`. |
| `0xE5` (229) | Section states (64 sections) | 8 | Bitfield: bit `8·j + i` of byte `j` is section `(8j + i)` ON/OFF |
| `0xEF` (239) | Machine data | variable | Only used as an AOG-liveness signal; payload not parsed. |
| `0xF1` (241) | Section control mode | 1 | `[mode]` where `1` = enabled, `0` = disabled |
| `0xF2` (242) | Process data | 6 | `[DDI_lo, DDI_hi, val0, val1, val2, val3]` — DDI is little-endian `uint16`; value is little-endian `int32` |
| `0xF3` (243) | Field name | variable | The open field's name as raw UTF-8; empty = field closed. |
| `0xF4` (244) | Guidance track context | 10 or 12 | Real-time AB-line/track guidance state, announced to implements as DDI 507-513 — see §5.4.1. |

#### `0xC9` — Subnet detection

Expand Down Expand Up @@ -126,10 +129,34 @@ Wraps a single ISO 11783 DDI/value pair. The TC currently dispatches on these DD
|---|---|---|
| `156` | Actual speed (mm/s) | Stored. If TECU enabled, broadcast as Ground/Wheel/Machine-selected speed (PGN 65256) + NMEA2000 SOG. Drives forward/reverse direction. Also produces J1939 PGN 65256 every 100 ms. |
| `597` | Total distance (mm) | Stored and displayed on the VT Status page. If TECU is enabled, also populated into Speed Messages distance fields. |
| Guidance line deviation | XTE (mm) | Converted to metres. Broadcast as NMEA2000 XTE (PGN 0x1F903) at 1 Hz. |
| Guidance line deviation | XTE (mm) | Converted to metres. Broadcast as NMEA2000 XTE (PGN 0x1F903) at 1 Hz. Also announced to implements as DDI 513 (GuidanceLineDeviation) — see §5.4.1. |

Unknown DDIs are silently ignored (PGN 0xF2 is the generic process-data channel — the TC will gain more DDIs over time).

#### `0xF3` — Field name

The whole payload is the open field's name as raw UTF-8 — no length prefix, no offset — up to 248 bytes (longer names are truncated). An empty payload means the field was closed, which also invalidates the current track context.

The TC maps each field name to a persistent 16-bit index, stored one `index,name` line per field in `field_registry.csv` next to `settings.json`. The index is folded into the upper 16 bits of DDI 508 (see `0xF4`), so a track's reference line ID stays unique across fields.

#### `0xF4` — Guidance track context

AOG's real-time AB-line/track guidance state. 12-byte payload (a 10-byte payload without the last field is also accepted):

```
Byte 0 Sequence counter (0–255, wraps)
Byte 1 Flags: bit0=valid, bit1=heading same way, bit2=curve mode
Bytes 2-3 Guidance Reference Line ID (uint16 LE) — 0 = no active track
Bytes 4-5 Actual Track Number (int16 LE, signed — can be negative and jump by more than 1)
Bytes 6-7 Track Number Left (int16 LE, signed)
Bytes 8-9 Track Number Right (int16 LE, signed)
Bytes 10-11 Swath Width in mm (uint16 LE) — distance between adjacent tracks; 0 = not reported
```

AOG sends this **only when the guidance state actually changes** — there is no heartbeat. The TC rejects any packet whose sequence number isn't strictly ahead of the last accepted one (catches duplicates, freezes, and reordered/stale UDP delivery).

**Track-number offset:** the TC adds `+1` to all three track numbers (current/left/right) before announcing them — confirmed by field testing, not documented anywhere on AOG's side. As sent raw by AOG, a tramline implement's own on-board phase (which pass of N is "on") was consistently one pass out of sync with AOG's own intended on/off state, for both left and right passes; a uniform `+1` (independent of sign) brought them into agreement. See `GuidanceTrackProvider::parse()` (`AOG_TRACK_NUMBER_OFFSET`).

### 2.6 PGNs outbound (TC → client)

All PGNs sent **by the TC to AgIO/AgValonia** use source `0x80`.
Expand Down Expand Up @@ -261,6 +288,7 @@ Common NAME fields: Industry Group `2` (Agricultural), Device Class `0`, Manufac
| `0xFC8E` (Control Function Functionalities) | At claim + periodic | TECU | Announces Class 1 BasicTractorECUServer (no options). |
| NMEA2000 COG/SOG | Periodic | TECU | Optional course/speed over ground. |
| GNSS Quality (DDI 514, via `0xCB00` Process Data) | 250 ms | TC | AOG's GPS fix quality (PGN `0xD6`, see §2.5), sent to each client whose DDOP declares DDI 514 as settable. Falls back to `1` when no fresh fix quality is available. |
| Guidance track data (DDI 507-513, via `0xCB00` Process Data) | 250 ms, while AOG has a valid track | TC | Track number, adjacent tracks, reference line, swath width and line deviation from AOG's PGN `0xF4`/`0xF2`, sent to each client whose DDOP declares them as settable. See §5.4.1. |

The TC also receives all ISOBUS Process Data (PGN 0xCB00) and Section Control commands from connected implements.

Expand All @@ -281,6 +309,22 @@ The TC also receives all ISOBUS Process Data (PGN 0xCB00) and Section Control co
| Max sections | 64 |
| Supported DDIs | 160 / 161 / 290 (condensed section setpoint and actual states), plus speed/distance/guidance DDIs from the tractor side |

#### 5.4.1 Guidance data sent to implements

The TC pushes guidance data to each client whose DDOP declares the DDI as settable. Clients can't request values from the TC, so a DDI a client doesn't declare, or declares as not settable, is never sent to it. Only clients with sections (implements, not tractors) are considered, as with the other DDI mappings.

| DDI | Name | Sent when |
|---|---|---|
| 507 | GuidanceTrackSequenceNumber | Valid track. Increments whenever the actual track number or the reference line ID changes — not on section-control toggles. |
| 508 | UniqueGuidanceReferenceLineID | Valid track. AOG's 16-bit reference line ID in the low 16 bits, the persistent field index (see `0xF3`) in the high 16 bits. |
| 509 | ActualGuidanceTrackNumber | Valid track. Signed; can be negative and can jump by more than 1 in a single update (e.g. skipping several tracks on a headland turn). |
| 510 / 511 | GuidanceTrackNumberToTheRight / ...ToTheLeft | Valid track. |
| 512 | GuidanceLineSwathWidth | Valid track, and AOG reported a non-zero swath width in `0xF4`. AOG's track spacing (tool width minus overlap), the same spacing the track numbers are derived from. |
| 513 | GuidanceLineDeviation | Valid track. AOG's XTE in mm (PGN `0xF2`, see §2.5). |
| 514 | GNSSQuality | Always — see `0xD6`. |

"Valid track" means the TC holds an accepted PGN `0xF4` payload with the valid flag set, a non-zero reference line ID, and an open field (PGN `0xF3`) to scope the ID to. Since AOG only sends `0xF4` on change, validity is *not* cleared just because no new `0xF4` has arrived; it is cleared only by an explicit "guidance off" packet, by the field closing, or by AOG disconnecting entirely (no packets of any kind for 3 s).

### 5.5 Virtual Terminal UI

The roughly 12 KB VT object pool is embedded in the executable, so deployment does not require a separate `AOG_TC.iop` file. The committed `src/AOG_TC.iop` remains its build-time source of truth, while `src/AOG_TC.iop.h` contains the ISO-Designer-generated object IDs and authored geometry.
Expand Down
19 changes: 19 additions & 0 deletions include/app.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
#include "isobus/isobus/isobus_virtual_terminal_client_update_helper.hpp"
#include "isobus/isobus/nmea2000_message_interface.hpp"

#include "field_registry.hpp"
#include "guidance_track_context.hpp"
#include "logging_utils.hpp"
#include "settings.hpp"
#include "task_controller.hpp"
Expand Down Expand Up @@ -76,6 +78,8 @@ class Application
static constexpr std::uint8_t HW_MSG_ALERT = 0;
static constexpr std::uint8_t HW_MSG_INFO = 1;

bool is_aog_connected() const;

std::shared_ptr<Settings> settings = std::make_shared<Settings>();
boost::asio::io_context ioContext = boost::asio::io_context();
std::shared_ptr<UdpConnections> udpConnections = std::make_shared<UdpConnections>(settings, ioContext);
Expand Down Expand Up @@ -105,6 +109,21 @@ class Application
std::uint8_t gnssFixQuality = 0; ///< AOG fix quality (NMEA 2000 GNSS Method): 0=invalid, 1=GPS, 2=DGPS, 3=PPS, 4=RTK Fix, 5=Float, 6=Estimated, 7=Manual, 8=Simulated
std::uint32_t lastGnssQualityMs = 0; ///< Timestamp of last PGN 0xD6 fix-quality update (0 = never received)
static constexpr std::uint32_t GNSS_QUALITY_TIMEOUT_MS = 2000; ///< No PGN 0xD6 for this long = fix quality unknown
static constexpr std::uint32_t AOG_CONNECTION_TIMEOUT_MS = 3000; ///< No AOG packet for this long = disconnected

// Guidance track context — real data from AOG PGN 0xF4.
GuidanceTrackProvider trackProvider;
GuidanceTrackContext currentTrackContext;
bool aogWasConnectedForTrack = false; ///< Edge-detection for AOG connect/disconnect transitions

// Field identity — from AOG PGN 0xF3. Folded into the upper 16 bits of DDI 508
// (see the PGN 0xF4 handling in setup_udp_connections()) so a track's guidance reference
// line ID is unique across fields, not just within whichever field AOG currently has open.
FieldRegistry fieldRegistry;
std::string currentFieldName; ///< Empty when no field is open
std::uint16_t currentFieldIndex = 0;
bool hasActiveField = false;

std::uint32_t lastDistanceMm = 0;
std::uint32_t lastAogPacketMs = 0;
std::uint32_t vtDisconnectedSinceMs = 0;
Expand Down
50 changes: 50 additions & 0 deletions include/field_registry.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* @file field_registry.hpp
* @brief Persistent field-name -> field-index mapping, used to make ISOBUS TRACK's
* DDI 508 (Unique Guidance Reference Line ID) actually unique across fields.
*
* AOG's own PGN 0xF4 guidance reference ID is only a 16-bit value scoped to whatever
* field is currently open in AOG - it is not guaranteed unique across different fields.
* An implement that caches per-track state (e.g. an offset) keyed on DDI 508 alone can
* therefore collide across a field switch. This registry assigns each field name a
* stable index, which the caller folds into the upper 16 bits of the 32-bit DDI 508
* value (see Application's PGN 0xF3/0xF4 handling), leaving AOG's own 16-bit ID in the
* lower 16 bits untouched.
*/

#pragma once

#include <cstdint>
#include <string>
#include <unordered_map>

/// @brief Loads/persists a field-name -> field-index mapping from a plain text file.
///
/// Deliberately its own file rather than part of settings.json: the mapping only grows
/// over time (one line per field ever opened), so a user may want to wipe it on its own
/// (e.g. to reclaim indices) without touching the rest of their configuration.
class FieldRegistry
{
public:
/// @brief Loads the registry from disk, if present. A missing or unreadable file
/// just starts empty - fields get freshly (re-)indexed and persisted as they're seen.
FieldRegistry();

/// @brief Returns the persistent index for a field name, assigning and persisting a
/// new one the first time this name is seen.
/// @param fieldName UTF-8 field folder name, as received from AOG PGN 0xF3.
/// @returns A stable index. Once the 16-bit space is exhausted (65536 distinct
/// field names - far beyond realistic use), the most recently assigned index is
/// reused and a warning is logged, rather than silently colliding with an existing
/// field.
std::uint16_t get_or_assign_index(const std::string &fieldName);

private:
void load();
void append_entry(const std::string &fieldName, std::uint16_t index);

std::string filePath;
std::unordered_map<std::string, std::uint16_t> nameToIndex;
std::uint16_t nextIndex = 0;
bool nextIndexExhausted = false;
};
85 changes: 85 additions & 0 deletions include/guidance_track_context.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @file guidance_track_context.hpp
* @brief Abstraction layer between AOG input and ISOBUS TRACK (Generation 1) protocol.
*
* This header defines the GuidanceTrackContext struct and the GuidanceTrackProvider
* that consumes AOG PGN 0xF4 guidance-track data.
*
* The ISOBUS TRACK sender consumes a GuidanceTrackContext without caring how it
* was produced. The GuidanceTrackContext maps directly to ISOBUS DDIs 508–511.
*/

#pragma once

#include <cstdint>
#include <optional>
#include <span>

/**
* @brief Immutable snapshot of guidance-track state consumed by the ISOBUS TRACK sender.
*
* Corresponds to ISOBUS DDIs:
* - guidanceReferenceLineId -> DDI 508 (Unique Guidance Reference Line ID)
* - actualTrackNumber -> DDI 509 (Actual Guidance Track Number)
* - trackNumberRight -> DDI 510 (Guidance Track Number to the Right)
* - trackNumberLeft -> DDI 511 (Guidance Track Number to the Left)
* - swathWidthMm -> DDI 512 (Guidance Line Swath Width)
*/
struct GuidanceTrackContext
{
std::uint32_t guidanceReferenceLineId = 1; ///< DDI 508
std::int32_t actualTrackNumber = 0; ///< DDI 509 — signed; track 0 is valid
std::int32_t trackNumberRight = -1; ///< DDI 510
std::int32_t trackNumberLeft = 1; ///< DDI 511
std::uint32_t swathWidthMm = 0; ///< DDI 512 — distance between adjacent tracks; 0 = not reported by AOG
bool valid = false; ///< Context has been initialized with at least one update
};

/**
* @brief Real guidance-track provider that consumes AOG PGN 0xF4 (244) data.
*
* PGN 0xF4 payload layout (10 data bytes, optionally 12):
* Byte 0: Sequence counter (0–255, wrapping)
* Byte 1: Flags (bit 0 = valid, bit 1 = heading same way, bit 2 = curve mode)
* Bytes 2-3: Guidance Reference ID (uint16 LE)
* Bytes 4-5: Current Track Number (int16 LE, signed)
* Bytes 6-7: Track Number Left (int16 LE, signed)
* Bytes 8-9: Track Number Right (int16 LE, signed)
* Bytes 10-11: Swath Width in mm (uint16 LE), optional — only present from newer AOG builds
*
* Track numbers (current/left/right) are each shifted by +1 from the raw wire value
* (see AOG_TRACK_NUMBER_OFFSET in parse()) to match the tram-pattern phase implements
* expect — confirmed by field testing, not part of AOG's own documented wire format.
*
* Sequence tracking:
* - Rejects any packet whose sequence number is not strictly ahead of the last
* accepted one (signed delta over the 0-255 wrap), catching both frozen/duplicate
* data and reordered/stale UDP packets arriving out of order.
* - Call reset() after a disconnect to treat the next packet as a fresh start.
*
* Returns valid=true when data is valid, refId != 0, and sequence is fresh.
*/
class GuidanceTrackProvider
{
public:
/// Minimum payload size (10 data bytes)
static constexpr std::size_t MIN_PAYLOAD_SIZE = 10;

/// Payload size that includes the swath width
static constexpr std::size_t PAYLOAD_SIZE_WITH_SWATH_WIDTH = 12;

/**
* @brief Parse AOG PGN 0xF4 payload and produce a GuidanceTrackContext.
*
* @param data Payload bytes (after UDP header stripping)
* @return GuidanceTrackContext with valid=true if parse succeeded and flags indicate valid data
*/
GuidanceTrackContext parse(std::span<const std::uint8_t> data);

/// @brief Reset sequence tracking (e.g., after AOG disconnect timeout).
/// The next parse() call is treated as a fresh start — no delta comparison.
void reset();

private:
std::optional<std::uint8_t> lastSequence_; ///< Unset until the first packet is accepted
};
15 changes: 15 additions & 0 deletions include/task_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

#pragma once

#include "guidance_track_context.hpp"
#include "isobus/isobus/isobus_data_dictionary.hpp"
#include "isobus/isobus/isobus_device_descriptor_object_pool.hpp"
#include "isobus/isobus/isobus_standard_data_description_indices.hpp"
Expand Down Expand Up @@ -63,6 +64,11 @@ class ClientState
void set_element_work_state(std::uint16_t elementNumber, bool isWorking);
bool try_get_element_work_state(std::uint16_t elementNumber, bool &isWorking) const;

/// @brief Advances the DDI 507 sequence number if the track or reference line differs from the
/// last one announced to this client, and remembers the new values.
/// @returns The sequence number to announce.
std::uint32_t update_guidance_track_sequence(std::int32_t trackNumber, std::uint32_t referenceLineId);

private:
isobus::DeviceDescriptorObjectPool pool; ///< The device descriptor object pool (DDOP) for the TC
bool areMeasurementCommandsSent = false; ///< Whether or not the measurement commands have been sent
Expand All @@ -79,6 +85,9 @@ class ClientState
bool isSectionControlEnabled = false; ///< Stores auto vs manual mode setting
bool usesPerElementControl = false; ///< Legacy mode: use per-element setpoint instead of condensed
std::uint16_t perElementSetpointDDI = 0; ///< The DDI to use for per-element setpoints (289 or 141), 0 if not applicable
std::int32_t lastSentTrackNumber = 0; ///< Last track number announced, for DDI 507 change detection
std::uint32_t lastSentReferenceLineId = 0; ///< Last reference line ID announced, for DDI 507 change detection
std::uint32_t guidanceTrackSequenceNumber = 0; ///< Per-client DDI 507 sequence number
};

// Create the task controller server object, this will handle all the ISOBUS communication for us
Expand Down Expand Up @@ -120,6 +129,12 @@ class MyTCServer : public isobus::TaskControllerServer
/// @param quality NMEA 2000 GNSS Method: 0=No GNSS, 1=GNSS, 2=DGNSS, 3=Precise, 4=RTK Fixed, 5=RTK Float, 6=Estimated, 7=Manual, 8=Simulated
void send_gnss_quality(std::uint8_t quality);

/// @brief Announces the current guidance track (DDI 507-511) and line deviation (DDI 513) to every
/// client that declares those DDIs. Does nothing while the context is not valid.
/// @param ctx Current guidance track state from AOG
/// @param lineDeviationMm Deviation from the guidance line in mm
void send_guidance_track_data(const GuidanceTrackContext &ctx, std::int32_t lineDeviationMm);

private:
void send_section_setpoint_states(std::shared_ptr<isobus::ControlFunction> client, std::uint8_t ddiOffset);
void send_section_control_state(std::shared_ptr<isobus::ControlFunction> client, bool enabled);
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ The application logs the detected VT version, screen size, softkey dimensions, a
Sent to any implement whose device description declares the DDI as settable.

- **GNSS quality (DDI 514):** AgOpenGPS's GPS fix quality, every 250 ms.
- **Guidance track (DDI 507-513):** the current track number, the tracks to its left and right, the track spacing, a reference line ID that is unique across fields, and the deviation from the guidance line, every 250 ms while AgOpenGPS has an active track.

## Contributing

Expand Down
Loading
Loading