diff --git a/AOGDissector.lua b/AOGDissector.lua index 931e8d9..e7f7839 100644 --- a/AOGDissector.lua +++ b/AOGDissector.lua @@ -7,7 +7,8 @@ ISOBUS_proto = Proto("ISOBUSVT", "AgISOStack") local MajorPGNs = { [0x7F] = "Steer module", [0xFE] = "From AutoSteer", - [0x70] = "ISOBUS" + [0x70] = "ISOBUS", + [0x80] = "AOG-TaskController" } local MinorPGNs = { [0x00] = "AOG -> ISOBUS", @@ -111,7 +112,11 @@ local AOGFields = { genericSubnet = ProtoField.ipv4("GenericIP.IPSubnet", "Subnet", base.DEC), genericShortIPRange = ProtoField.string("GenericIP.IPSubnet", "Subnet", base.STRING), - freeFormMessage = ProtoField.string("Freeform.message", "Message", base.STRING) + freeFormMessage = ProtoField.string("Freeform.message", "Message", base.STRING), + + hwMsgDuration = ProtoField.uint8("TaskController.HwMsgDuration", "Duration", base.DEC), + hwMsgColor = ProtoField.uint8("TaskController.HwMsgColor", "Background", base.DEC), + hwMsgText = ProtoField.string("TaskController.HwMsgText", "Message", base.STRING) } FixQuality = { @@ -563,6 +568,17 @@ function AOGProtocol_proto.dissector(buffer, pinfo, tree) end end + if MajorPGN == 0x80 then -- 128 AOG-TaskController + if MinorPGN == 0xdd and buffer(4, 1):uint() > 2 then -- 221 hardware message + local textLength = buffer(4, 1):uint() - 2 + subtree:add(AOGFields.hwMsgDuration, buffer(5, 1)):append_text(" (x10 render frames)") + subtree:add(AOGFields.hwMsgColor, buffer(6, 1)):append_text(buffer(6, 1):uint() == 0 and + " (Salmon, alert)" or " (Bisque, info)") + subtree:add(AOGFields.hwMsgText, buffer(7, textLength)) + pinfo.cols.info = "TC hardware message: " .. buffer(7, textLength):string() + end + end + if MajorPGN == 0x78 then -- From GPS module 120 end diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index a7192ff..c62faf2 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -128,8 +128,37 @@ All PGNs sent **by the TC to AgIO/AgValonia** use source `0x80`. | PGN | Name | Length | Frequency | Payload | |---|---|---|---|---| +| `0xDD` (221) | Hardware message | 2 + T | On event | `[duration, colour, text0, text1, ...]` — `T` = UTF-8 text bytes | | `0xF0` (240) | Section heartbeat / state | 2 + ⌈N/8⌉ | 100 ms | `[mode, num_sections, byte0, byte1, ...]` | +#### `0xDD` — Hardware message + +Displays a text banner across the top of the AgOpenGPS map so the operator can see TC state that would otherwise only reach the console log. Payload: + +- byte 0: `duration` — see the caveat below +- byte 1: `colour` — `0` renders a Salmon background (alert), any other value renders Bisque (info). The TC sends `1` for info. +- bytes 2..: the message text, UTF-8, **not** NUL-terminated + +The frame's `Length` field is therefore `text_bytes + 2`, and AOG reads exactly `Length - 2` bytes starting at wire offset 7. The TC caps text at 60 bytes and truncates on a UTF-8 character boundary — splitting a multi-byte sequence would render as U+FFFD, and the display label clips long text anyway. + +**`duration` is frames, not seconds.** AOG stores `duration × 10` into a counter that it decrements once per OpenGL render tick, so the banner lasts `duration × 10` redraws — roughly `duration` seconds at AOG's nominal 10 Hz, but it stretches or shrinks with frame rate. **A condition that persists must be re-sent**; do not send once with a large duration and expect the banner to hold. The operator can also dismiss a banner early by clicking it. + +**AgOpenGPS ignores the source byte for this PGN** — it dispatches on the PGN byte alone. The TC still sends `0x80` per §2.2. + +**Display is opt-in on the AOG side.** AgOpenGPS drops these frames unless *Config → Data → Hardware Messages* is enabled; the setting defaults to **off**. A correct implementation looks like a no-op until it is switched on, which is the first thing to check when testing. There is no corresponding TC-side setting — the TC always sends. + +Messages the TC currently emits: + +| Text | Colour | Duration | Trigger | +|---|---|---|---| +| `Implement: ( sections, m)` | Info | 5 | An implement with sections registers. The `, m` clause is omitted when the DDOP has no usable geometry. | +| `Implement lost: ` | Alert | 10 | A previously seen implement disappears from the client list. | +| `TC address conflict: preferred address in use` | Alert | 20 | Another control function claiming `NAME::Function::TaskController` holds address `0xF7`. Re-sent every 15 s while true. | +| `TC address conflict resolved` | Info | 5 | The above clears. | +| `TECU failed to claim address 240` | Alert | 10 | Once at startup, when the TECU is enabled but could not claim its fixed address. | + +The conflict messages surface on the operator's screen what the TC already writes to its console log; the console warning remains and keeps its own 30 s throttle. + #### `0xF0` — Section heartbeat / state Sent every 100 ms for each connected ISOBUS implement that has sections. The payload is: @@ -170,7 +199,7 @@ The TC reads `settings.json` from a per-user config directory: | Key | Type | Default | Description | |---|---|---|---| | `subnet` | `int[3]` | `[192, 168, 5]` | First three octets of the LAN AgIO/AgValonia lives on. Used for NIC selection and broadcast destination. | -| `tecuEnabled` | `bool` | `true` | If `true`, the TC also impersonates a Tractor ECU on the CAN bus (claims address 128, broadcasts Speed Messages/NMEA2000, announces Class 1 BasicTractorECUServer). Set `false` when the tractor already has a TECU. | +| `tecuEnabled` | `bool` | `true` | If `true`, the TC also impersonates a Tractor ECU on the CAN bus (claims address 240, broadcasts Speed Messages/NMEA2000, announces Class 1 BasicTractorECUServer). Set `false` when the tractor already has a TECU. | | `nmeaSendEnabled` | `bool` | `true` | Enable cyclic NMEA2000 COG/SOG transmission. Requires `tecuEnabled`; the VT disables this control when no TECU interface exists. | | `aogHeartbeatEnabled` | `bool` | `true` | Send `0xF0` heartbeat to AgIO/AgValonia every 100 ms even with no implement. Disable for AOG < v6.8.2 beta 5. | | `vtEnabled` | `bool` | `true` | Register the Virtual Terminal client and display the TC UI when a VT is present. | @@ -209,8 +238,8 @@ Two control functions, both claiming addresses via standard J1939-81 address cla | CF | NAME function | Address | Notes | |---|---|---|---| -| Task Controller | `TaskController` (function code 61) | Preferred `233` (ISO 11783-10 MappingComputer). Walks if claimed. | Always present. | -| Tractor ECU | `TractorECU` (function code 132) | Fixed `128` (non-arbitrary-address-capable per ISO 11783-9). | Only present if `tecuEnabled: true`. | +| Task Controller | `TaskController` (function code 61) | Preferred `247` (ISO 11783-10 MappingComputer). Walks if claimed. | Always present. | +| Tractor ECU | `TractorECU` (function code 132) | Fixed `240` (non-arbitrary-address-capable per ISO 11783-9). | Only present if `tecuEnabled: true`. | Common NAME fields: Industry Group `2` (Agricultural), Device Class `0`, Manufacturer Code `1407`, Identity `20`. Override these in `app.cpp` if you fork. diff --git a/include/app.hpp b/include/app.hpp index 59406be..d74de11 100644 --- a/include/app.hpp +++ b/include/app.hpp @@ -9,6 +9,7 @@ #pragma once #include +#include #include #include #include @@ -36,6 +37,21 @@ class Application void stop(); private: + struct ImplementDetails + { + std::string displayName = "No implement"; + std::uint8_t sections = 0; + std::string widthText; + std::string boomOffsetText; + }; + + struct ImplementSnapshot + { + std::string displayName; + std::uint8_t sections = 0; + std::string widthText; + }; + void send_task_controller_status_message(); bool setup_can_hardware(); @@ -54,6 +70,11 @@ class Application void update_vt_status_strings(bool aogConnected); void send_vt_string_if_changed(std::uint16_t objectID, const std::string &value); + void send_hardware_message(const std::string &text, std::uint8_t duration, std::uint8_t color); + ImplementDetails derive_implement_details(ClientState &state) const; + + static constexpr std::uint8_t HW_MSG_ALERT = 0; + static constexpr std::uint8_t HW_MSG_INFO = 1; std::shared_ptr settings = std::make_shared(); boost::asio::io_context ioContext = boost::asio::io_context(); @@ -85,5 +106,8 @@ class Application std::uint32_t vtDisconnectedSinceMs = 0; std::uint32_t lastVtStatusUpdateMs = 0; std::uint32_t lastVtSectionUpdateMs = 0; + bool tcAddressConflictActive = false; + bool tecuAddressClaimFailed = false; + std::map implementSnapshots; std::map lastVtStrings; }; diff --git a/src/app.cpp b/src/app.cpp index 7419050..d75ef52 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -91,72 +91,64 @@ static void enumerate_bus_control_functions(const std::string &context) } // Check for TC address conflicts and log warning if we couldn't claim preferred address -static void check_tc_address_conflict(const std::shared_ptr &ourTC) +static bool check_tc_address_conflict(const std::shared_ptr &ourTC) { - if (!ourTC || !ourTC->get_address_valid()) - return; - static constexpr std::uint8_t PREFERRED_TC_ADDRESS = isobus::preferred_addresses::IndustryGroup2::TaskController_MappingComputer; static std::uint32_t lastWarnTime = 0; static bool conflictDetected = false; + bool conflictActive = false; + const bool havePreferredAddress = ourTC && ourTC->get_address_valid() && (ourTC->get_address() == PREFERRED_TC_ADDRESS); - // Check if we have the preferred address - if (ourTC->get_address() == PREFERRED_TC_ADDRESS) + if (ourTC && !havePreferredAddress) { - // We have the preferred address, clear conflict state - if (conflictDetected) + // We don't have the preferred address - check if another TC has it. + auto allCFs = isobus::CANNetworkManager::CANNetwork.get_control_functions(false); + for (const auto &cf : allCFs) { - conflictDetected = false; - std::cout << "[" << get_timestamp() << "] [TC Address] Successfully claimed preferred address " << static_cast(PREFERRED_TC_ADDRESS) << std::endl; - } - return; - } - - // We don't have the preferred address - check if another TC has it - auto allCFs = isobus::CANNetworkManager::CANNetwork.get_control_functions(false); - - for (const auto &cf : allCFs) - { - if (!cf || !cf->get_address_valid()) - continue; - - if (cf->get_address() == PREFERRED_TC_ADDRESS && cf != ourTC) - { - isobus::NAME otherName = cf->get_NAME(); - - // Check if it's actually a TC - std::uint8_t funcCode = otherName.get_function_code(); - if (funcCode == static_cast(isobus::NAME::Function::TaskController)) + if (cf && cf->get_address_valid() && (cf->get_address() == PREFERRED_TC_ADDRESS) && (cf != ourTC)) { - // Periodic warning every 30 seconds - if (isobus::SystemTiming::time_expired_ms(lastWarnTime, 30000)) + const isobus::NAME otherName = cf->get_NAME(); + const std::uint8_t funcCode = otherName.get_function_code(); + if (funcCode == static_cast(isobus::NAME::Function::TaskController)) { - conflictDetected = true; - std::cout << "\n"; - std::cout << "[" << get_timestamp() << "] [WARN] ==================================================" << std::endl; - std::cout << "[" << get_timestamp() << "] [WARN] TC ADDRESS CONFLICT - Another TC at preferred address " << static_cast(PREFERRED_TC_ADDRESS) << std::endl; - std::cout << "[" << get_timestamp() << "] [WARN] Conflicting TC: Mfg=" << otherName.get_manufacturer_code() - << ", Func=" << static_cast(funcCode) - << ", Identity=" << otherName.get_identity_number() - << ", ECU Inst=" << static_cast(otherName.get_ecu_instance()) - << ", Func Inst=" << static_cast(otherName.get_function_instance()) << std::endl; - std::cout << "[" << get_timestamp() << "] [WARN] Our TC using address: " << static_cast(ourTC->get_address()) << std::endl; - std::cout << "[" << get_timestamp() << "] [WARN] ==================================================" << std::endl; - std::cout << "\n"; - lastWarnTime = isobus::SystemTiming::get_timestamp_ms(); + conflictActive = true; + + // Periodic warning every 30 seconds. Conflict detection itself is not throttled. + if (isobus::SystemTiming::time_expired_ms(lastWarnTime, 30000)) + { + std::cout << "\n"; + std::cout << "[" << get_timestamp() << "] [WARN] ==================================================" << std::endl; + std::cout << "[" << get_timestamp() << "] [WARN] TC ADDRESS CONFLICT - Another TC at preferred address " << static_cast(PREFERRED_TC_ADDRESS) << std::endl; + std::cout << "[" << get_timestamp() << "] [WARN] Conflicting TC: Mfg=" << otherName.get_manufacturer_code() + << ", Func=" << static_cast(funcCode) + << ", Identity=" << otherName.get_identity_number() + << ", ECU Inst=" << static_cast(otherName.get_ecu_instance()) + << ", Func Inst=" << static_cast(otherName.get_function_instance()) << std::endl; + std::cout << "[" << get_timestamp() << "] [WARN] Our TC using address: " << static_cast(ourTC->get_address()) << std::endl; + std::cout << "[" << get_timestamp() << "] [WARN] ==================================================" << std::endl; + std::cout << "\n"; + lastWarnTime = isobus::SystemTiming::get_timestamp_ms(); + } + break; } - return; // Only report first conflicting TC found } } } - // If we get here, we didn't find a conflicting TC at the preferred address - // This means we arbitrated to a different address for another reason - if (conflictDetected) + if (conflictDetected && !conflictActive) { - conflictDetected = false; - std::cout << "[" << get_timestamp() << "] [TC Address] TC address conflict resolved" << std::endl; + if (havePreferredAddress) + { + std::cout << "[" << get_timestamp() << "] [TC Address] Successfully claimed preferred address " << static_cast(PREFERRED_TC_ADDRESS) << std::endl; + } + else + { + std::cout << "[" << get_timestamp() << "] [TC Address] TC address conflict resolved" << std::endl; + } } + conflictDetected = conflictActive; + + return conflictActive; } Application::Application(std::shared_ptr canDriver) : @@ -314,8 +306,7 @@ bool Application::setup_control_functions() tecuClaimAttempts++; } - // Check if TECU successfully claimed its FIXED address (128) - // TECU is non-arbitrary-address-capable and MUST use address 128 + // TECU is non-arbitrary-address-capable: it must land on its preferred address or not at all if (tecuCF->get_address_valid() && tecuCF->get_address() == isobus::preferred_addresses::IndustryGroup2::TractorECU) { // Record when the address was actually claimed for the 250ms delay calculation @@ -339,13 +330,14 @@ bool Application::setup_control_functions() } else { + tecuAddressClaimFailed = true; if (tecuCF->get_address_valid()) { - std::cout << "[" << get_timestamp() << "] [Warning] TECU claimed unexpected address " << static_cast(tecuCF->get_address()) << " instead of 128!" << std::endl; + std::cout << "[" << get_timestamp() << "] [Warning] TECU claimed unexpected address " << static_cast(tecuCF->get_address()) << " instead of " << static_cast(isobus::preferred_addresses::IndustryGroup2::TractorECU) << "!" << std::endl; } else { - std::cout << "[" << get_timestamp() << "] [Warning] TECU failed to claim address 128! Another TECU may be on the bus." << std::endl; + std::cout << "[" << get_timestamp() << "] [Warning] TECU failed to claim address " << static_cast(isobus::preferred_addresses::IndustryGroup2::TractorECU) << "! Another TECU may be on the bus." << std::endl; } std::cout << "[" << get_timestamp() << "] [Warning] TECU functionality will be disabled." << std::endl; tecuCF.reset(); // Release the failed control function @@ -587,10 +579,68 @@ bool Application::update() static std::uint32_t lastConflictCheck = 0; if (isobus::SystemTiming::time_expired_ms(lastConflictCheck, 15000)) { - check_tc_address_conflict(tcCF); + const bool conflictActive = check_tc_address_conflict(tcCF); + if (conflictActive) + { + send_hardware_message("TC address conflict: preferred address in use", 20, HW_MSG_ALERT); + } + else if (tcAddressConflictActive) + { + send_hardware_message("TC address conflict resolved", 5, HW_MSG_INFO); + } + tcAddressConflictActive = conflictActive; lastConflictCheck = isobus::SystemTiming::get_timestamp_ms(); } + // Diff active implement clients once per second so disconnect messages retain prior metadata. + static std::uint32_t lastImplementScanMs = 0; + if (isobus::SystemTiming::time_expired_ms(lastImplementScanMs, 1000)) + { + std::map currentImplementSnapshots; + for (auto &client : tcServer->get_clients()) + { + auto &state = client.second; + if (state.get_number_of_sections() > 0) + { + const ImplementDetails details = derive_implement_details(state); + currentImplementSnapshots[client.first->get_NAME().get_full_name()] = { details.displayName, details.sections, details.widthText }; + } + } + + for (const auto &[name, implement] : currentImplementSnapshots) + { + if (implementSnapshots.find(name) == implementSnapshots.end()) + { + std::ostringstream message; + message << "Implement: " << implement.displayName << " (" << static_cast(implement.sections) << " sections"; + if (!implement.widthText.empty()) + { + message << ", " << implement.widthText << " m"; + } + message << ")"; + send_hardware_message(message.str(), 5, HW_MSG_INFO); + } + } + + for (const auto &[name, implement] : implementSnapshots) + { + if (currentImplementSnapshots.find(name) == currentImplementSnapshots.end()) + { + send_hardware_message("Implement lost: " + implement.displayName, 10, HW_MSG_ALERT); + } + } + + implementSnapshots = std::move(currentImplementSnapshots); + lastImplementScanMs = isobus::SystemTiming::get_timestamp_ms(); + } + + // This is deferred from setup_control_functions() until UDP is ready. + if (tecuAddressClaimFailed) + { + send_hardware_message("TECU failed to claim address " + std::to_string(static_cast(isobus::preferred_addresses::IndustryGroup2::TractorECU)), 10, HW_MSG_ALERT); + tecuAddressClaimFailed = false; + } + // Send section control heartbeat to AOG every 100ms (PGN 0xF0, source 0x80) // When no clients with sections, send 0 sections as heartbeat so AOG knows TC is alive if (isobus::SystemTiming::time_expired_ms(lastHeartbeatTransmit, 100)) @@ -671,6 +721,116 @@ bool Application::update() return true; } +void Application::send_hardware_message(const std::string &text, std::uint8_t duration, std::uint8_t color) +{ + if (!text.empty()) + { + static constexpr std::size_t MAX_TEXT_BYTES = 60; + std::size_t textLength = text.size(); + if (textLength > MAX_TEXT_BYTES) + { + textLength = MAX_TEXT_BYTES; + while ((textLength > 0) && ((static_cast(text[textLength]) & 0xC0) == 0x80)) + { + textLength--; + } + } + + const std::string message = text.substr(0, textLength); + if (!message.empty()) + { + std::vector data = { duration, color }; + data.reserve(2 + message.size()); + data.insert(data.end(), message.begin(), message.end()); + udpConnections->send(0x80, 0xDD, data); + + std::cout << "[" << get_timestamp() << "] [Hardware Message] " << message << std::endl; + } + } +} + +Application::ImplementDetails Application::derive_implement_details(ClientState &state) const +{ + ImplementDetails details; + details.sections = state.get_number_of_sections(); + auto &pool = state.get_pool(); + if (auto deviceObject = pool.get_object_by_index(0)) + { + const std::string designator = deviceObject->get_designator(); + if (!designator.empty()) + { + details.displayName = designator; + } + } + + std::int32_t totalWidthMillimetres = 0; + const auto geometry = isobus::DeviceDescriptorObjectPoolHelper::get_implement_geometry(pool); + if (!geometry.booms.empty()) + { + const auto &boom = geometry.booms.front(); + if (boom.xOffset_mm || boom.yOffset_mm) + { + std::ostringstream offsetText; + offsetText << std::fixed << std::setprecision(2); + if (boom.xOffset_mm) + { + offsetText << "X:" << std::showpos + << (static_cast(boom.xOffset_mm.get()) / 1000.0) + << std::noshowpos; + } + else + { + offsetText << "X:n/a"; + } + offsetText << " "; + if (boom.yOffset_mm) + { + offsetText << "Y:" << std::showpos + << (static_cast(boom.yOffset_mm.get()) / 1000.0) + << std::noshowpos; + } + else + { + offsetText << "Y:n/a"; + } + details.boomOffsetText = offsetText.str(); + } + } + + for (const auto &boom : geometry.booms) + { + for (const auto §ion : boom.sections) + { + if (section.width_mm) + { + totalWidthMillimetres += section.width_mm.get(); + } + } + for (const auto &subBoom : boom.subBooms) + { + if (subBoom.sections.empty() && subBoom.width_mm) + { + totalWidthMillimetres += subBoom.width_mm.get(); + } + for (const auto §ion : subBoom.sections) + { + if (section.width_mm) + { + totalWidthMillimetres += section.width_mm.get(); + } + } + } + } + if (totalWidthMillimetres > 0) + { + std::ostringstream widthText; + widthText << std::fixed << std::setprecision(2) << (static_cast(totalWidthMillimetres) / 1000.0); + details.widthText = widthText.str(); + } + + return details; +} + void Application::send_task_controller_status_message() { // ISO 11783-10 B.8.1 Task Controller Status message @@ -1039,77 +1199,12 @@ void Application::update_vt_status_strings(bool aogConnected) if (!clients.empty()) { auto &state = clients.begin()->second; - auto &pool = state.get_pool(); - if (auto deviceObject = pool.get_object_by_index(0)) - { - implementName = deviceObject->get_designator(); - } + const ImplementDetails details = derive_implement_details(state); + implementName = details.displayName; sectionControl = state.is_section_control_enabled() ? "ENABLED" : "DISABLED"; - implementSections = state.get_number_of_sections(); - - std::int32_t totalWidthMillimetres = 0; - const auto geometry = isobus::DeviceDescriptorObjectPoolHelper::get_implement_geometry(pool); - if (!geometry.booms.empty()) - { - const auto &boom = geometry.booms.front(); - if (boom.xOffset_mm || boom.yOffset_mm) - { - std::ostringstream offsetText; - offsetText << std::fixed << std::setprecision(2); - if (boom.xOffset_mm) - { - offsetText << "X:" << std::showpos - << (static_cast(boom.xOffset_mm.get()) / 1000.0) - << std::noshowpos; - } - else - { - offsetText << "X:n/a"; - } - offsetText << " "; - if (boom.yOffset_mm) - { - offsetText << "Y:" << std::showpos - << (static_cast(boom.yOffset_mm.get()) / 1000.0) - << std::noshowpos; - } - else - { - offsetText << "Y:n/a"; - } - boomOffset = offsetText.str(); - } - } - for (const auto &boom : geometry.booms) - { - for (const auto §ion : boom.sections) - { - if (section.width_mm) - { - totalWidthMillimetres += section.width_mm.get(); - } - } - for (const auto &subBoom : boom.subBooms) - { - if (subBoom.sections.empty() && subBoom.width_mm) - { - totalWidthMillimetres += subBoom.width_mm.get(); - } - for (const auto §ion : subBoom.sections) - { - if (section.width_mm) - { - totalWidthMillimetres += section.width_mm.get(); - } - } - } - } - if (totalWidthMillimetres > 0) - { - std::ostringstream widthText; - widthText << std::fixed << std::setprecision(2) << (static_cast(totalWidthMillimetres) / 1000.0); - workingWidth = widthText.str(); - } + implementSections = details.sections; + workingWidth = details.widthText.empty() ? "n/a" : details.widthText; + boomOffset = details.boomOffsetText.empty() ? "n/a" : details.boomOffsetText; } const std::string implementDisplayName = implementName.substr(0, 16); const std::string activeDDOP = clients.empty() ? "none" : implementDisplayName;