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
13 changes: 11 additions & 2 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Every packet — both directions — uses the same frame:
5+N 1 Checksum Sum of bytes [Source .. last payload byte], mod 256
```

Total wire size is `N + 6` bytes. The maximum payload is currently 250 bytes (limited by the TC's 512-byte receive buffer; in practice the largest PGN in use is 8 bytes).
Total wire size is `N + 6` bytes. The maximum payload is currently 250 bytes (limited by the TC's 512-byte receive buffer). Most PGNs are ≤10 bytes; the outlier is `0xD6` (GPS/IMU data, see §2.5), which needs at least 39 bytes today.

**Checksum**: the TC currently does **not** validate inbound checksums (the verification code is present but commented out in `udp_connections.cpp`). Clients **should still compute and include a correct checksum** so that future TC versions, or third-party listeners, can validate.

Expand All @@ -58,6 +58,7 @@ Source byte identifies the logical sender of a frame. The conventions used today

| Source | Logical sender |
|---|---|
| `0x7C` (124) | **AOG's GPS/IMU submodule** — sends PGN `0xD6` only (see §2.5). |
| `0x7F` (127) | **AgIO / AgValonia** (the GUI/host application) |
| `0x80` (128) | **AOG-TaskController** itself |

Expand Down Expand Up @@ -87,11 +88,12 @@ If no NIC matches, the TC falls back to loopback (`127.0.0.1`) — useful for lo

### 2.5 PGNs inbound (client → TC)

All PGNs sent **by AgIO/AgValonia to the TC** use source `0x7F`.
All PGNs sent **by AgIO/AgValonia to the TC** use source `0x7F`, except `0xD6` (GPS/IMU data), which comes from AOG's GPS submodule at source `0x7C`.

| PGN | Name | Length | Payload |
|---|---|---|---|
| `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 |
| `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` |
Expand All @@ -102,6 +104,12 @@ Tells the TC which `/24` subnet AgIO/AgValonia lives on. The first two payload b

On receipt the TC sets `settings.subnet = [IP0, IP1, IP2]`, closes the main socket, re-runs NIC enumeration, and rebinds. Useful for plug-and-play scenarios where the host may move between subnets.

#### `0xD6` — GPS/IMU data

Sent from AOG's GPS submodule, source `0x7C` (not `0x7F`). The TC only reads byte 38: AOG's fix-quality code (`0`=invalid, `1`=GPS, `2`=DGPS, `3`=PPS, `4`=RTK Fixed, `5`=RTK Float, `6`=Estimated, `7`=Manual, `8`=Simulated — the NMEA 2000 GNSS Method values DDI 514 uses). Values `0`–`8` are forwarded unchanged to implements as DDI 514 (GNSSQuality) — see §5.2.

Two fallback cases both resolve to `1` (weakest real GNSS fix), not `0` (No GNSS): AOG reporting a value above `8` (not a defined GNSS Method), and no fresh `0xD6` (AOG doesn't always send this PGN at all — e.g. Simulator mode — and if none has arrived within 2 s the last value is treated as stale). `0` is deliberately avoided as a fallback because some implements gate TRACK/section control on GNSS quality being non-zero.

#### `0xE5` — Section states

Reports the *actual* state of up to 64 sections. 8 bytes = 64 bits, one bit per section. The TC forwards these to the connected ISOBUS implement via the appropriate condensed work-state DDIs (DDI 160/161/290).
Expand Down Expand Up @@ -252,6 +260,7 @@ Common NAME fields: Industry Group `2` (Agricultural), Device Class `0`, Manufac
| `0xFEE8` (PGN 65256 Speed/Direction) | 100 ms | TECU | Ground/Wheel/Machine-selected speed + machine direction, J1939 format. Only when TECU enabled. |
| `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. |

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

Expand Down
3 changes: 3 additions & 0 deletions include/app.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ class Application
std::uint32_t lastTCStatusTransmit = 0;
std::int32_t lastSpeedValue = 0;
std::int32_t lastXteValue = 0;
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
std::uint32_t lastDistanceMm = 0;
std::uint32_t lastAogPacketMs = 0;
std::uint32_t vtDisconnectedSinceMs = 0;
Expand Down
4 changes: 4 additions & 0 deletions include/task_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ class MyTCServer : public isobus::TaskControllerServer
void update_section_states(std::vector<bool> &sectionStates);
void update_section_control_enabled(bool enabled);

/// @brief Sends GNSS quality (DDI 514) to every client that declares that DDI.
/// @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);

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
6 changes: 6 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ The application logs the detected VT version, screen size, softkey dimensions, a
- **Maximum Sections:** 64 (supports both individual sections and zone-based control)
- **Section Control:** Generation 1 (TC-SC) with support for DDI 160/161/290

### Guidance data sent to implements

Sent to any implement whose device description declares the DDI as settable.

- **GNSS quality (DDI 514):** AgOpenGPS's GPS fix quality, every 250 ms.

## Contributing

Please keep pull requests small and focused on one change. A PR that fixes one bug or adds one
Expand Down
48 changes: 48 additions & 0 deletions src/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,39 @@ void Application::setup_udp_connections()
static std::uint32_t lastXteTransmit = 0;

auto packetHandler = [this](std::uint8_t src, std::uint8_t pgn, std::span<std::uint8_t> data) {
// PGN 0xD6 (214) — GPS/IMU data from AOG (src=0x7C, frame [0x80,0x81,0x7C,0xD6,...]).
// Only the fix-quality byte is consumed today; the rest of the frame (position,
// heading, speed, etc.) is not yet parsed by this TC.
static constexpr std::size_t GNSS_FIX_QUALITY_OFFSET = 38; // frame byte 43
static constexpr std::size_t MIN_0xD6_PAYLOAD_SIZE = GNSS_FIX_QUALITY_OFFSET + 1;
static constexpr std::uint8_t GNSS_QUALITY_MAX = 8; // SimulateMode
if (src == 0x7C && pgn == 0xD6)
{
static std::uint8_t lastLoggedQuality = 0xFF;
if (data.size() < MIN_0xD6_PAYLOAD_SIZE)
{
log("AOG") << "PGN 0xD6 received but too short for fix quality (len="
<< data.size() << ")" << std::endl;
return;
}

std::uint8_t quality = data[GNSS_FIX_QUALITY_OFFSET];
// AOG fix values follow the NMEA 2000 GNSS Method enumeration that DDI 514 uses: 0=invalid, 1=GPS,
// 2=DGPS, 3=PPS, 4=RTK Fix, 5=Float, 6=Estimated, 7=Manual, 8=Simulated. Forward 0-8 unchanged so
// the implement sees the real source; anything above 8 is not a defined value, so map it to the
// weakest real fix (1=GNSS) rather than 0=No GPS, which would falsely claim there is no position.
gnssFixQuality = (quality <= GNSS_QUALITY_MAX) ? quality : 1;
lastGnssQualityMs = isobus::SystemTiming::get_timestamp_ms();

if (quality != lastLoggedQuality)
{
log("GNSS") << "fix quality byte=" << static_cast<int>(quality)
<< " -> DDI514=" << static_cast<int>(gnssFixQuality) << std::endl;
lastLoggedQuality = quality;
}
return;
}

if (src != 0x7F)
{
return;
Expand Down Expand Up @@ -767,6 +800,21 @@ bool Application::update()
}
}

// Send GNSS quality (DDI 514) to implements every 250 ms
static std::uint32_t lastGnssSendMs = 0;
if (tcServer && isobus::SystemTiming::time_expired_ms(lastGnssSendMs, 250))
{
// PGN 0xD6 is an independently-timed stream — treat the fix quality as unknown once it
// goes stale. Fall back to 1 (weakest real GNSS fix), not 0 (No GNSS): some implements gate
// TRACK/section control on GNSS quality being non-zero, and AOG doesn't always send 0xD6 at
// all (e.g. in Simulator mode) — 0 would falsely claim there is no position fix at all and
// can get commands rejected.
const bool gnssQualityFresh = (lastGnssQualityMs != 0) &&
!isobus::SystemTiming::time_expired_ms(lastGnssQualityMs, GNSS_QUALITY_TIMEOUT_MS);
tcServer->send_gnss_quality(gnssQualityFresh ? gnssFixQuality : 1);
lastGnssSendMs = isobus::SystemTiming::get_timestamp_ms();
}

return true;
}

Expand Down
75 changes: 75 additions & 0 deletions src/task_controller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,37 @@ static std::string sanitize_filename(const std::string &input)
return result;
}

// Finds the number of the first device element that lists the process data object as a child.
static bool find_owning_element_number(isobus::DeviceDescriptorObjectPool &pool,
const isobus::task_controller_object::DeviceProcessDataObject &processData,
std::uint16_t &elementNumber)
{
for (std::uint32_t i = 0; i < pool.size(); i++)
{
auto object = pool.get_object_by_index(i);
if (!object || object->get_object_type() != isobus::task_controller_object::ObjectTypes::DeviceElement)
{
continue;
}

auto elementObject = std::dynamic_pointer_cast<isobus::task_controller_object::DeviceElementObject>(object);
if (!elementObject)
{
continue;
}

for (std::uint16_t childId : elementObject->get_child_object_ids())
{
if (childId == processData.get_object_id())
{
elementNumber = elementObject->get_element_number();
return true;
}
}
}
return false;
}

// Logs and keeps the DDOP bytes exactly as received, so pools from different sessions can be compared byte for byte.
// Files are named by hash, so each distinct pool is written once. Never affects activation.
static void save_received_ddop(std::uint64_t clientName, const std::vector<std::uint8_t> &rawPool, const std::vector<std::size_t> &chunkSizes)
Expand Down Expand Up @@ -877,6 +908,37 @@ void MyTCServer::request_measurement_commands()
}
}

// Map GNSS quality (DDI 514) so send_gnss_quality() knows which element to address.
// Mapping only: this is a value the TC pushes to the implement, not one to subscribe to.
for (std::uint32_t i = 0; i < client.second.get_pool().size(); i++)
{
auto object = client.second.get_pool().get_object_by_index(i);
if (!object || object->get_object_type() != isobus::task_controller_object::ObjectTypes::DeviceProcessData)
{
continue;
}

auto processDataObject = std::dynamic_pointer_cast<isobus::task_controller_object::DeviceProcessDataObject>(object);
if (!processDataObject || processDataObject->get_ddi() != static_cast<std::uint16_t>(isobus::DataDescriptionIndex::GNSSQuality))
{
continue;
}
Comment thread
gunicsba marked this conversation as resolved.

if (!processDataObject->has_property(isobus::task_controller_object::DeviceProcessDataObject::PropertiesBit::Settable))
{
log() << "DDI " << processDataObject->get_ddi() << " (GNSS Quality) is declared but not settable, so it is not sent" << std::endl;
continue;
}

std::uint16_t elementNumber = 0;
if (find_owning_element_number(client.second.get_pool(), *processDataObject, elementNumber))
{
client.second.set_element_number_for_ddi(isobus::DataDescriptionIndex::GNSSQuality, elementNumber);
log() << "Mapped DDI " << processDataObject->get_ddi() << " (GNSS Quality) to element "
<< elementNumber << std::endl;
}
}

log() << "Measurement commands sent." << std::endl;
client.second.mark_measurement_commands_sent();
}
Expand Down Expand Up @@ -949,6 +1011,19 @@ void MyTCServer::update_section_control_enabled(bool enabled)
}
}

void MyTCServer::send_gnss_quality(std::uint8_t quality)
{
std::lock_guard<std::recursive_mutex> lock(clientsMutex);
constexpr auto DDI = isobus::DataDescriptionIndex::GNSSQuality;
for (auto &client : clients)
{
if (client.second.has_element_number_for_ddi(DDI))
{
send_set_value(client.first, static_cast<std::uint16_t>(DDI), client.second.get_element_number_for_ddi(DDI), static_cast<std::int32_t>(quality));
}
}
}

void MyTCServer::send_section_setpoint_states(std::shared_ptr<isobus::ControlFunction> client, std::uint8_t ddiOffset)
{
std::lock_guard<std::recursive_mutex> lock(clientsMutex);
Expand Down
Loading