diff --git a/CMakeLists.txt b/CMakeLists.txt index b49f5af..2093864 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -194,6 +194,24 @@ if(AOG_TC_VALIDATE_IOP) add_dependencies(iop_validator test_async_log) add_test(NAME async_log COMMAND test_async_log) + # Section control method detection against every DDOP in + # tools/testdata/implements, expected values from its manifest.csv. Made a + # dependency of iop_validator so the validation workflow also builds it. + add_executable( + test_section_control + ${CMAKE_CURRENT_LIST_DIR}/tools/test_section_control_method.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/section_control_method.cpp) + target_compile_features(test_section_control PRIVATE cxx_std_20) + set_target_properties(test_section_control PROPERTIES CXX_EXTENSIONS OFF) + target_include_directories(test_section_control + PRIVATE ${CMAKE_CURRENT_LIST_DIR}/include) + target_link_libraries(test_section_control PRIVATE isobus::Isobus + isobus::Utility) + add_dependencies(iop_validator test_section_control) + add_test(NAME section_control_method + COMMAND test_section_control + "${CMAKE_CURRENT_LIST_DIR}/tools/testdata/implements") + # PASS_REGULAR_EXPRESSION makes CTest ignore the exit code, which is the part # a CI gate actually reads. add_test(NAME malformed_pool_exit_status COMMAND iop_validator diff --git a/include/section_control_method.hpp b/include/section_control_method.hpp new file mode 100644 index 0000000..f14ad08 --- /dev/null +++ b/include/section_control_method.hpp @@ -0,0 +1,57 @@ +/** + * @brief Works out how to switch an implement's sections from its DDOP + * + * Kept free of the TC server and the CAN stack, so tests can run it against stored DDOPs. + */ + +#pragma once + +#include "isobus/isobus/isobus_device_descriptor_object_pool.hpp" +#include "isobus/isobus/isobus_device_descriptor_object_pool_helpers.hpp" + +#include +#include +#include + +/// The section control methods, in the order the TC prefers them +enum class SectionControlMethod : std::uint8_t +{ + None, ///< The TC cannot switch the sections + CondensedSetpoint, ///< DDI 290+ (Setpoint Condensed Work State), actual states on DDI 161+ + SettableCondensedActual, ///< DDI 161+ (Actual Condensed Work State) written as the setpoint + PerElementSetpointWorkState, ///< DDI 289 on each section element, actual state on DDI 141 of the same element + PerElementActualWorkState ///< DDI 141 on each section element written as the setpoint +}; + +/// @brief Short name of a method, as used in logs and in tools/testdata/implements/manifest.csv +const char *to_string(SectionControlMethod method); + +/// @brief What to write, and to which element, to switch an implement's sections +struct SectionControlPlan +{ + SectionControlMethod method = SectionControlMethod::None; + + /// Condensed methods: element number per condensed DDI (290+ or 161+), one entry per group of 16 sections + std::map condensedTargets; + + /// Per-element methods: the setpoint DDI (289 or 141) and the element to write it to for each + /// section index. isobus::NULL_OBJECT_ID where that section does not link a settable setpoint. + std::uint16_t perElementSetpointDDI = 0; + std::vector perElementTargets; + + /// Global Setpoint Work State (DDI 289) on an element that is not a section, if the implement has one + bool hasSetpointWorkState = false; + std::uint16_t setpointWorkStateElement = 0; + + bool uses_per_element_control() const; +}; + +/// @brief Flattens the implement geometry into section element numbers, in AOG's section order +std::vector get_section_element_numbers(const isobus::DeviceDescriptorObjectPoolHelper::Implement &implement); + +/// @brief Picks the section control method and its targets. +/// Only process data that a device element links counts: some implements send every object of their +/// template and leave the unused ones unreferenced. +/// @param[in] pool The client's deserialized DDOP +/// @param[in] sectionElementNumbers The section element numbers in AOG's section order +SectionControlPlan resolve_section_control(isobus::DeviceDescriptorObjectPool &pool, const std::vector §ionElementNumbers); diff --git a/include/task_controller.hpp b/include/task_controller.hpp index 839a4fc..456fdf9 100644 --- a/include/task_controller.hpp +++ b/include/task_controller.hpp @@ -13,6 +13,7 @@ #include "isobus/isobus/isobus_device_descriptor_object_pool.hpp" #include "isobus/isobus/isobus_standard_data_description_indices.hpp" #include "isobus/isobus/isobus_task_controller_server.hpp" +#include "section_control_method.hpp" #include #include @@ -49,9 +50,8 @@ class ClientState bool is_section_control_enabled() const; void set_section_control_enabled(bool state); bool uses_per_element_control() const; - void set_uses_per_element_control(bool state); - std::uint16_t get_per_element_setpoint_ddi() const; - void set_per_element_setpoint_ddi(std::uint16_t ddi); + const SectionControlPlan &get_section_control_plan() const; + void set_section_control_plan(const SectionControlPlan &plan); isobus::DeviceDescriptorObjectPool &get_pool(); bool are_measurement_commands_sent() const; void mark_measurement_commands_sent(); @@ -77,8 +77,7 @@ class ClientState bool actualWorkState = false; ///< The overall work state actual std::map elementWorkStates; ///< Work state per element (element number -> is working) 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 + SectionControlPlan sectionControlPlan; ///< How to switch the sections, resolved from the DDOP at activation }; // Create the task controller server object, this will handle all the ISOBUS communication for us @@ -123,7 +122,6 @@ class MyTCServer : public isobus::TaskControllerServer private: void send_section_setpoint_states(std::shared_ptr client, std::uint8_t ddiOffset); void send_section_control_state(std::shared_ptr client, bool enabled); - bool is_ddi_settable(std::shared_ptr client, std::uint16_t ddi); std::map, ClientState> clients; std::map, std::queue>> uploadedPools; diff --git a/src/section_control_method.cpp b/src/section_control_method.cpp new file mode 100644 index 0000000..a0bff32 --- /dev/null +++ b/src/section_control_method.cpp @@ -0,0 +1,198 @@ +/** + * @brief Works out how to switch an implement's sections from its DDOP + */ +#include "section_control_method.hpp" + +#include "isobus/isobus/isobus_standard_data_description_indices.hpp" + +#include + +using isobus::DataDescriptionIndex; +using isobus::task_controller_object::DeviceElementObject; +using isobus::task_controller_object::DeviceProcessDataObject; +using isobus::task_controller_object::ObjectTypes; + +namespace +{ + constexpr auto SETPOINT_WORK_STATE = static_cast(DataDescriptionIndex::SetpointWorkState); + constexpr auto ACTUAL_WORK_STATE = static_cast(DataDescriptionIndex::ActualWorkState); + constexpr auto SETPOINT_CONDENSED_FIRST = static_cast(DataDescriptionIndex::SetpointCondensedWorkState1_16); + constexpr auto SETPOINT_CONDENSED_LAST = static_cast(DataDescriptionIndex::SetpointCondensedWorkState241_256); + constexpr auto ACTUAL_CONDENSED_FIRST = static_cast(DataDescriptionIndex::ActualCondensedWorkState1_16); + constexpr auto ACTUAL_CONDENSED_LAST = static_cast(DataDescriptionIndex::ActualCondensedWorkState241_256); + + /// One process data object as linked from one device element. A process data object that several + /// elements link appears once per element; one that no element links does not appear at all. + struct LinkedProcessData + { + std::uint16_t ddi; + std::uint16_t elementNumber; + bool settable; + }; + + std::vector get_linked_process_data(isobus::DeviceDescriptorObjectPool &pool) + { + std::vector links; + for (std::uint32_t i = 0; i < pool.size(); i++) + { + auto object = pool.get_object_by_index(i); + if (!object || object->get_object_type() != ObjectTypes::DeviceElement) + { + continue; + } + auto element = std::static_pointer_cast(object); + + // Read the children by index. Iterating get_child_object_ids() reads past the end of the list: + // AgIsoStack's DataSpan scales end() by sizeof(T) twice (see #110). + for (std::uint16_t c = 0; c < element->get_number_child_objects(); c++) + { + auto child = pool.get_object_by_id(element->get_child_object_id(c)); + if (!child || child->get_object_type() != ObjectTypes::DeviceProcessData) + { + continue; + } + auto processData = std::static_pointer_cast(child); + links.push_back({ processData->get_ddi(), + element->get_element_number(), + processData->has_property(DeviceProcessDataObject::PropertiesBit::Settable) }); + } + } + return links; + } + + bool is_in(std::uint16_t value, std::uint16_t first, std::uint16_t last) + { + return value >= first && value <= last; + } + + // The element of each section index that links a settable setpointDDI, NULL_OBJECT_ID where none does. + // Returns an empty list when no section links one. + std::vector get_per_element_targets(const std::vector &links, + const std::vector §ionElementNumbers, + std::uint16_t setpointDDI) + { + std::vector targets; + bool anyTarget = false; + for (auto sectionElement : sectionElementNumbers) + { + bool linked = std::any_of(links.begin(), links.end(), [&](const LinkedProcessData &link) { + return link.ddi == setpointDDI && link.settable && link.elementNumber == sectionElement; + }); + targets.push_back(linked ? sectionElement : isobus::NULL_OBJECT_ID); + anyTarget = anyTarget || linked; + } + return anyTarget ? targets : std::vector(); + } +} + +const char *to_string(SectionControlMethod method) +{ + switch (method) + { + case SectionControlMethod::CondensedSetpoint: + return "DDI290"; + case SectionControlMethod::SettableCondensedActual: + return "DDI161"; + case SectionControlMethod::PerElementSetpointWorkState: + return "DDI289"; + case SectionControlMethod::PerElementActualWorkState: + return "DDI141"; + default: + return "none"; + } +} + +bool SectionControlPlan::uses_per_element_control() const +{ + return method == SectionControlMethod::PerElementSetpointWorkState || method == SectionControlMethod::PerElementActualWorkState; +} + +std::vector get_section_element_numbers(const isobus::DeviceDescriptorObjectPoolHelper::Implement &implement) +{ + // Same order as the geometry rows in MyTCServer::activate_object_pool(): the index is AOG's section index + std::vector elements; + for (const auto &boom : implement.booms) + { + for (const auto &subBoom : boom.subBooms) + { + for (const auto §ion : subBoom.sections) + { + elements.push_back(section.elementNumber); + } + } + for (const auto §ion : boom.sections) + { + elements.push_back(section.elementNumber); + } + } + return elements; +} + +SectionControlPlan resolve_section_control(isobus::DeviceDescriptorObjectPool &pool, const std::vector §ionElementNumbers) +{ + SectionControlPlan plan; + if (sectionElementNumbers.empty()) + { + return plan; // Nothing to switch, e.g. a tractor + } + + const auto links = get_linked_process_data(pool); + auto isSection = [§ionElementNumbers](std::uint16_t elementNumber) { + return std::find(sectionElementNumbers.begin(), sectionElementNumbers.end(), elementNumber) != sectionElementNumbers.end(); + }; + + // A global Setpoint Work State lives above the sections. On a section element it is a per-element setpoint. + for (const auto &link : links) + { + if (link.ddi == SETPOINT_WORK_STATE && link.settable && !isSection(link.elementNumber)) + { + plan.hasSetpointWorkState = true; + plan.setpointWorkStateElement = link.elementNumber; + break; + } + } + + // Setpoint condensed work state (DDI 290+). The first linking element wins if several link the same DDI. + for (const auto &link : links) + { + if (is_in(link.ddi, SETPOINT_CONDENSED_FIRST, SETPOINT_CONDENSED_LAST)) + { + plan.condensedTargets.emplace(link.ddi, link.elementNumber); + } + } + if (!plan.condensedTargets.empty()) + { + plan.method = SectionControlMethod::CondensedSetpoint; + return plan; + } + + // Actual condensed work state (DDI 161+) declared settable, the fallback for implements without setpoint DDIs + for (const auto &link : links) + { + if (is_in(link.ddi, ACTUAL_CONDENSED_FIRST, ACTUAL_CONDENSED_LAST) && link.settable) + { + plan.condensedTargets.emplace(link.ddi, link.elementNumber); + } + } + if (!plan.condensedTargets.empty()) + { + plan.method = SectionControlMethod::SettableCondensedActual; + return plan; + } + + // Per-element: Setpoint Work State on each section, or else a settable Actual Work State on each section + plan.perElementTargets = get_per_element_targets(links, sectionElementNumbers, SETPOINT_WORK_STATE); + if (!plan.perElementTargets.empty()) + { + plan.method = SectionControlMethod::PerElementSetpointWorkState; + plan.perElementSetpointDDI = SETPOINT_WORK_STATE; + return plan; + } + plan.perElementTargets = get_per_element_targets(links, sectionElementNumbers, ACTUAL_WORK_STATE); + if (!plan.perElementTargets.empty()) + { + plan.method = SectionControlMethod::PerElementActualWorkState; + plan.perElementSetpointDDI = ACTUAL_WORK_STATE; + } + return plan; +} diff --git a/src/task_controller.cpp b/src/task_controller.cpp index 614bb8a..ffd2d6c 100644 --- a/src/task_controller.cpp +++ b/src/task_controller.cpp @@ -183,7 +183,7 @@ std::uint8_t ClientState::get_section_actual_state(std::uint8_t section) const // from DDI 141 in on_value_command, so we can return it without the // expensive parent-traversal check (which has thread-safety concerns // when called from the heartbeat on the main thread). - if (usesPerElementControl) + if (uses_per_element_control()) { return sectionActualStates[section]; } @@ -243,22 +243,17 @@ void ClientState::set_section_control_enabled(bool state) bool ClientState::uses_per_element_control() const { - return usesPerElementControl; + return sectionControlPlan.uses_per_element_control(); } -void ClientState::set_uses_per_element_control(bool state) +const SectionControlPlan &ClientState::get_section_control_plan() const { - usesPerElementControl = state; + return sectionControlPlan; } -std::uint16_t ClientState::get_per_element_setpoint_ddi() const +void ClientState::set_section_control_plan(const SectionControlPlan &plan) { - return perElementSetpointDDI; -} - -void ClientState::set_per_element_setpoint_ddi(std::uint16_t ddi) -{ - perElementSetpointDDI = ddi; + sectionControlPlan = plan; } isobus::DeviceDescriptorObjectPool &ClientState::get_pool() @@ -571,67 +566,55 @@ bool MyTCServer::activate_object_pool(std::shared_ptr p state.set_element_number_for_section(i, sectionElementNumbers[i]); } - // Scan the DDOP to determine which section control method the device supports - bool hasCondensedSetpoint = false; // Modern: DDI 290+ (paired with DDI 289 for global work state) - bool hasSettableCondensedActual = false; // Old: DDI 161+ settable - bool hasSettableActualWorkState = false; // Oldest: DDI 141 per-element settable - - for (std::uint32_t i = 0; i < state.get_pool().size(); i++) + // Pick the section control method (Modern > Old > per-element) and where to write it + const auto plan = resolve_section_control(state.get_pool(), sectionElementNumbers); + state.set_section_control_plan(plan); + switch (plan.method) { - auto object = state.get_pool().get_object_by_index(i); - if (object && object->get_object_type() == isobus::task_controller_object::ObjectTypes::DeviceProcessData) + case SectionControlMethod::CondensedSetpoint: + case SectionControlMethod::SettableCondensedActual: { - auto processDataObject = std::dynamic_pointer_cast(object); - auto ddi = processDataObject->get_ddi(); - - if (ddi >= static_cast(isobus::DataDescriptionIndex::SetpointCondensedWorkState1_16) && - ddi <= static_cast(isobus::DataDescriptionIndex::SetpointCondensedWorkState241_256)) - { - hasCondensedSetpoint = true; - } - if (ddi >= static_cast(isobus::DataDescriptionIndex::ActualCondensedWorkState1_16) && - ddi <= static_cast(isobus::DataDescriptionIndex::ActualCondensedWorkState241_256) && - processDataObject->has_property(isobus::task_controller_object::DeviceProcessDataObject::PropertiesBit::Settable)) + log("TC Server") << "Attempting Section Control via: " + << (plan.method == SectionControlMethod::CondensedSetpoint ? "DDI 290 (SetpointCondensedWorkState)" : "DDI 161 (ActualCondensedWorkState, settable)") + << " for " << static_cast(numberOfSections) << " sections." << std::endl; + for (const auto &target : plan.condensedTargets) { - hasSettableCondensedActual = true; + log() << " DDI " << target.first << " -> element " << target.second << std::endl; } - if (ddi == static_cast(isobus::DataDescriptionIndex::ActualWorkState) && - processDataObject->has_property(isobus::task_controller_object::DeviceProcessDataObject::PropertiesBit::Settable)) + } + break; + + case SectionControlMethod::PerElementSetpointWorkState: + case SectionControlMethod::PerElementActualWorkState: + { + log("TC Server") << "Attempting Section Control via: " + << (plan.method == SectionControlMethod::PerElementSetpointWorkState ? "DDI 289 (SetpointWorkState per-element, actual on DDI 141)" : "DDI 141 (ActualWorkState, settable per-element)") + << " for " << static_cast(numberOfSections) << " sections." << std::endl; + for (std::size_t i = 0; i < plan.perElementTargets.size(); i++) { - hasSettableActualWorkState = true; + log() << " Section " << i << " -> element " << sectionElementNumbers[i] + << (plan.perElementTargets[i] == isobus::NULL_OBJECT_ID ? " (no settable setpoint, not switched)" : "") << std::endl; } } - } + break; - // Announce and configure the section control method (hierarchy: Modern > Old > Oldest) - if (hasCondensedSetpoint) - { - // Modern: condensed setpoint DDI 290+ (always paired with DDI 289 for global work state) - log("TC Server") << "Attempting Section Control via: DDI 290 (SetpointCondensedWorkState) + DDI 289 (SetpointWorkState)" - << " for " << static_cast(numberOfSections) << " sections." << std::endl; - } - else if (hasSettableCondensedActual) - { - // Old: settable condensed actual DDI 161+ - log("TC Server") << "Attempting Section Control via: DDI 161 (ActualCondensedWorkState, settable)" - << " for " << static_cast(numberOfSections) << " sections." << std::endl; - } - else if (hasSettableActualWorkState) - { - // Oldest: per-element settable DDI 141 - state.set_uses_per_element_control(true); - state.set_per_element_setpoint_ddi(static_cast(isobus::DataDescriptionIndex::ActualWorkState)); - log("TC Server") << "Attempting Section Control via: DDI 141 (ActualWorkState, settable per-element)" - << " for " << static_cast(numberOfSections) << " sections." << std::endl; - for (std::uint8_t i = 0; i < numberOfSections; i++) + default: { - log() << " Section " << static_cast(i) << " -> element " << sectionElementNumbers[i] << std::endl; + log("TC Server") << "WARNING: No supported section control method detected! " + << "Device has no linked DDI 290, settable DDI 161, or settable DDI 289/141 on its sections." << std::endl; } + break; } - else + if (plan.method != SectionControlMethod::None) { - log("TC Server") << "WARNING: No supported section control method detected! " - << "Device has no DDI 290, 161 (settable), or 141 (settable)." << std::endl; + if (plan.hasSetpointWorkState) + { + log() << " DDI 289 (SetpointWorkState) -> element " << plan.setpointWorkStateElement << std::endl; + } + else if (!plan.uses_per_element_control()) + { + log("TC Server") << "DDI 289 (SetpointWorkState) not available, sending section setpoints only." << std::endl; + } } } else @@ -885,8 +868,12 @@ void MyTCServer::request_measurement_commands() { if (elementObjectChild == processDataObject->get_object_id()) { - // TODO: This is a bit of a hack, but it works for now - client.second.set_element_number_for_ddi(static_cast(processDataObject->get_ddi()), elementObject->get_element_number()); + // Where to write DDI 289 and 290+ comes from the section control plan, which only + // counts settable process data and keeps DDI 289 on a section out of the global target + if (processDataObject->get_ddi() == static_cast(isobus::DataDescriptionIndex::SectionControlState)) + { + client.second.set_element_number_for_ddi(isobus::DataDescriptionIndex::SectionControlState, elementObject->get_element_number()); + } const auto &entryB = isobus::DataDictionary::get_entry(processDataObject->get_ddi()); if (processDataObject->has_trigger_method(isobus::task_controller_object::DeviceProcessDataObject::AvailableTriggerMethods::OnChange)) @@ -1034,77 +1021,62 @@ void MyTCServer::send_section_setpoint_states(std::shared_ptr(isobus::DataDescriptionIndex::SetpointCondensedWorkState1_16) + ddiOffset; - // Legacy ECU? (DDI 161 ActualCondensedWorkState1_16 exists and Settable) - std::uint16_t ddiTargetLegacy = static_cast(isobus::DataDescriptionIndex::ActualCondensedWorkState1_16) + ddiOffset; - if (clients[client].has_element_number_for_ddi(static_cast(ddiTarget))) - { - std::uint16_t elementNumber = clients[client].get_element_number_for_ddi(static_cast(ddiTarget)); - send_set_value(client, ddiTarget, elementNumber, value); - - bool setpointWorkState = clients[client].is_any_section_setpoint_on(); - if ((clients[client].get_setpoint_work_state() != setpointWorkState) && clients[client].has_element_number_for_ddi(isobus::DataDescriptionIndex::SetpointWorkState)) - { - send_set_value(client, static_cast(isobus::DataDescriptionIndex::SetpointWorkState), clients[client].get_element_number_for_ddi(isobus::DataDescriptionIndex::SetpointWorkState), setpointWorkState ? 1 : 0); - clients[client].set_setpoint_work_state(setpointWorkState); - } - else if (!clients[client].has_element_number_for_ddi(isobus::DataDescriptionIndex::SetpointWorkState)) - { - log("TC Server") << "DDI 289 (SetpointWorkState) not available!" << std::endl; - } - return; // Modern condensed path complete - } - else if (clients[client].has_element_number_for_ddi(static_cast(ddiTargetLegacy))) + const auto &plan = clients[client].get_section_control_plan(); + switch (plan.method) { - if (is_ddi_settable(client, ddiTargetLegacy)) + case SectionControlMethod::CondensedSetpoint: + case SectionControlMethod::SettableCondensedActual: { - send_set_value(client, ddiTargetLegacy, clients[client].get_element_number_for_ddi(static_cast(ddiTargetLegacy)), value); - } - else - { - log("TC Server") << "Legacy DDI " << ddiTargetLegacy << " (ActualCondensedWorkState) is not settable!" << std::endl; - } - return; // Legacy condensed path complete - } - else if (clients[client].uses_per_element_control()) - { - // Per-element control: send setpoints to each section element individually (DDI 141 settable) - std::uint16_t setpointDDI = clients[client].get_per_element_setpoint_ddi(); - for (std::uint8_t i = 0; i < NUMBER_SECTIONS_PER_CONDENSED_MESSAGE; i++) - { - std::uint8_t sectionIndex = sectionOffset + i; - if (sectionIndex >= clients[client].get_number_of_sections()) + // Modern: DDI 290+. Old: settable DDI 161+, which carries the setpoint and the actual state. + auto firstDDI = (plan.method == SectionControlMethod::CondensedSetpoint) ? isobus::DataDescriptionIndex::SetpointCondensedWorkState1_16 : isobus::DataDescriptionIndex::ActualCondensedWorkState1_16; + std::uint16_t ddiTarget = static_cast(firstDDI) + ddiOffset; + auto target = plan.condensedTargets.find(ddiTarget); + if (target != plan.condensedTargets.end()) { - break; + send_set_value(client, ddiTarget, target->second, value); } - std::uint16_t elementNumber = clients[client].get_element_number_for_section(sectionIndex); - if (elementNumber != 0) + else { - std::uint8_t state = clients[client].get_section_setpoint_state(sectionIndex); - send_set_value(client, setpointDDI, elementNumber, (state == SectionState::ON) ? 1 : 0); + log("TC Server") << "DDI " << ddiTarget << " not available for sections " << sectionOffset + 1 << "-" << sectionOffset + NUMBER_SECTIONS_PER_CONDENSED_MESSAGE << "!" << std::endl; } } + break; - // Also send global work state on the boom/device element if DDI 289 is available - bool setpointWorkState = clients[client].is_any_section_setpoint_on(); - if (clients[client].get_setpoint_work_state() != setpointWorkState) + case SectionControlMethod::PerElementSetpointWorkState: + case SectionControlMethod::PerElementActualWorkState: { - if (clients[client].has_element_number_for_ddi(isobus::DataDescriptionIndex::SetpointWorkState)) + // Per-element: write DDI 289 (or settable DDI 141) to each section element + for (std::uint8_t i = 0; i < NUMBER_SECTIONS_PER_CONDENSED_MESSAGE; i++) { - send_set_value(client, - static_cast(isobus::DataDescriptionIndex::SetpointWorkState), - clients[client].get_element_number_for_ddi(isobus::DataDescriptionIndex::SetpointWorkState), - setpointWorkState ? 1 : 0); - clients[client].set_setpoint_work_state(setpointWorkState); + std::uint8_t sectionIndex = sectionOffset + i; + if (sectionIndex >= clients[client].get_number_of_sections() || sectionIndex >= plan.perElementTargets.size()) + { + break; + } + std::uint16_t elementNumber = plan.perElementTargets[sectionIndex]; + if (elementNumber != isobus::NULL_OBJECT_ID) + { + std::uint8_t state = clients[client].get_section_setpoint_state(sectionIndex); + send_set_value(client, plan.perElementSetpointDDI, elementNumber, (state == SectionState::ON) ? 1 : 0); + } } } - return; // Per-element path complete + break; + + default: + { + log("TC Server") << "No supported method to send section setpoint states! " + << "Device has no linked DDI 290, settable DDI 161, or settable DDI 289/141 on its sections." << std::endl; + return; + } } - else + + // Global work state on the element above the sections, if the implement has one + bool setpointWorkState = clients[client].is_any_section_setpoint_on(); + if (plan.hasSetpointWorkState && (clients[client].get_setpoint_work_state() != setpointWorkState)) { - log("TC Server") << "No supported method to send section setpoint states! " - << "Device has no DDI 290, 161 (settable), or 141 (settable)." << std::endl; + send_set_value(client, static_cast(isobus::DataDescriptionIndex::SetpointWorkState), plan.setpointWorkStateElement, setpointWorkState ? 1 : 0); + clients[client].set_setpoint_work_state(setpointWorkState); } } @@ -1113,21 +1085,3 @@ void MyTCServer::send_section_control_state(std::shared_ptr lock(clientsMutex); send_set_value(client, static_cast(isobus::DataDescriptionIndex::SectionControlState), clients[client].get_element_number_for_ddi(isobus::DataDescriptionIndex::SectionControlState), enabled ? 1 : 0); } - -bool MyTCServer::is_ddi_settable(std::shared_ptr client, std::uint16_t ddi) -{ - std::lock_guard lock(clientsMutex); - for (std::uint32_t i = 0; i < clients[client].get_pool().size(); i++) - { - auto object = clients[client].get_pool().get_object_by_index(i); - if (object && object->get_object_type() == isobus::task_controller_object::ObjectTypes::DeviceProcessData) - { - auto processDataObject = std::dynamic_pointer_cast(object); - if (processDataObject->get_ddi() == ddi) - { - return processDataObject->has_property(isobus::task_controller_object::DeviceProcessDataObject::PropertiesBit::Settable); - } - } - } - return false; -} diff --git a/tools/test_section_control_method.cpp b/tools/test_section_control_method.cpp new file mode 100644 index 0000000..985fa82 --- /dev/null +++ b/tools/test_section_control_method.cpp @@ -0,0 +1,203 @@ +/** + * @brief Checks the section control method the TC picks for every DDOP in tools/testdata/implements. + * + * The expected method is the section_control column of manifest.csv. A method only counts when it + * also leaves something to send, and the MIDI 3 pools are checked down to the target elements. + * + * Usage: test_section_control + * Returns 0 when every check passes, 1 otherwise. + */ + +#include "section_control_method.hpp" + +#include "isobus/isobus/isobus_task_controller_server.hpp" + +#include +#include +#include +#include +#include +#include + +static int failures = 0; + +static void check(bool condition, const std::string &label) +{ + if (!condition) + { + std::fprintf(stderr, " FAIL: %s\n", label.c_str()); + ++failures; + } +} + +// One CSV record per line; fields may be quoted and contain commas. +static std::vector split_csv_line(const std::string &line) +{ + std::vector fields(1); + bool quoted = false; + for (std::size_t i = 0; i < line.size(); i++) + { + char c = line[i]; + if (quoted && c == '"' && i + 1 < line.size() && line[i + 1] == '"') + { + fields.back() += '"'; + i++; + } + else if (c == '"') + { + quoted = !quoted; + } + else if (c == ',' && !quoted) + { + fields.emplace_back(); + } + else if (c != '\r') + { + fields.back() += c; + } + } + return fields; +} + +static bool load_pool(const std::string &path, isobus::DeviceDescriptorObjectPool &pool) +{ + std::ifstream file(path, std::ios::binary); + std::vector bytes((std::istreambuf_iterator(file)), std::istreambuf_iterator()); + // Same compatibility level as MyTCServer::activate_object_pool() + pool.set_task_controller_compatibility_level(static_cast(isobus::TaskControllerServer::TaskControllerVersion::SecondEditionDraft)); + return !bytes.empty() && pool.deserialize_binary_object_pool(bytes.data(), static_cast(bytes.size())); +} + +static std::string describe(const SectionControlPlan &plan) +{ + std::ostringstream text; + for (const auto &target : plan.condensedTargets) + { + text << " " << target.first << "->" << target.second; + } + if (plan.uses_per_element_control()) + { + text << " " << plan.perElementSetpointDDI << "->"; + for (std::size_t i = 0; i < plan.perElementTargets.size(); i++) + { + text << (i ? "," : "") << plan.perElementTargets[i]; + } + } + if (plan.hasSetpointWorkState) + { + text << " 289(global)->" << plan.setpointWorkStateElement; + } + return text.str(); +} + +// True when the plan leaves the TC something to write for the method it picked +static bool has_targets(const SectionControlPlan &plan) +{ + switch (plan.method) + { + case SectionControlMethod::CondensedSetpoint: + case SectionControlMethod::SettableCondensedActual: + return !plan.condensedTargets.empty(); + case SectionControlMethod::PerElementSetpointWorkState: + case SectionControlMethod::PerElementActualWorkState: + for (auto element : plan.perElementTargets) + { + if (element != isobus::NULL_OBJECT_ID) + return true; + } + return false; + default: + return true; + } +} + +static void check_midi3_targets(const std::string &file, const SectionControlPlan &plan) +{ + const std::string base = "sprayer/ME_SPRITZE_SPR_MIDI_3_11sec"; + const std::vector sections = { 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 }; + const std::map ddi290OnBoom = { { 290, 2 } }; + const std::map ddi161OnBoom = { { 161, 2 } }; + + if (file == base + ".ddop") + { + check(plan.condensedTargets == ddi290OnBoom, file + ": DDI 290 on element 2"); + } + else if (file == base + "_oldSC_CWS_synthetic.ddop") + { + check(plan.condensedTargets == ddi161OnBoom, file + ": settable DDI 161 on element 2"); + } + else if (file == base + "_newSC_perElement_synthetic.ddop") + { + check(plan.perElementSetpointDDI == 289 && plan.perElementTargets == sections, file + ": DDI 289 on elements 10..20"); + } + else if (file == base + "_oldSC_perElement_synthetic.ddop") + { + check(plan.perElementSetpointDDI == 141 && plan.perElementTargets == sections, file + ": DDI 141 on elements 10..20"); + } + else + { + return; + } + // DDI 289 is unlinked (condensed modes) or only on the sections (per-element modes): never a global target + check(!plan.hasSetpointWorkState, file + ": no global DDI 289"); +} + +int main(int argc, char **argv) +{ + if (argc < 2) + { + std::fprintf(stderr, "Usage: %s \n", argv[0]); + return 1; + } + const std::string root = argv[1]; + std::ifstream manifest(root + "/manifest.csv"); + check(manifest.is_open(), "open " + root + "/manifest.csv"); + + std::string line; + std::getline(manifest, line); + auto header = split_csv_line(line); + std::size_t fileColumn = header.size(), methodColumn = header.size(); + for (std::size_t i = 0; i < header.size(); i++) + { + if (header[i] == "file") + fileColumn = i; + if (header[i] == "section_control") + methodColumn = i; + } + check(fileColumn < header.size() && methodColumn < header.size(), "manifest has file and section_control columns"); + + int pools = 0; + while (std::getline(manifest, line) && fileColumn < header.size() && methodColumn < header.size()) + { + auto fields = split_csv_line(line); + if (fields.size() < header.size()) + continue; + const auto &file = fields[fileColumn]; + const auto &expected = fields[methodColumn]; + pools++; + + isobus::DeviceDescriptorObjectPool pool; + if (!load_pool(root + "/" + file, pool)) + { + check(false, file + ": failed to load"); + continue; + } + auto sections = get_section_element_numbers(isobus::DeviceDescriptorObjectPoolHelper::get_implement_geometry(pool)); + auto plan = resolve_section_control(pool, sections); + + bool ok = (expected == to_string(plan.method)) && has_targets(plan); + std::printf("%-4s %-70s expected %-6s got %-6s%s\n", ok ? "ok" : "FAIL", file.c_str(), expected.c_str(), to_string(plan.method), describe(plan).c_str()); + check(expected == to_string(plan.method), file + ": expected " + expected + ", got " + to_string(plan.method)); + check(has_targets(plan), file + ": " + to_string(plan.method) + " picked but nothing to send"); + check_midi3_targets(file, plan); + } + check(pools > 0, "manifest lists at least one pool"); + + if (failures != 0) + { + std::fprintf(stderr, "%d check(s) failed\n", failures); + return 1; + } + std::printf("All %d pools resolve to their expected section control method\n", pools); + return 0; +} diff --git a/tools/testdata/implements/README.md b/tools/testdata/implements/README.md new file mode 100644 index 0000000..7746333 --- /dev/null +++ b/tools/testdata/implements/README.md @@ -0,0 +1,121 @@ +# Sample implements + +Real-world Device Descriptor Object Pools (DDOPs), captured from implements that were connected to the +AOG-TaskController and shared in issues. They are the input for tests that exercise DDOP parsing, section +geometry and section-control method detection without needing the machine. + +`manifest.csv` is the machine-readable index. The table below is generated from it. + +## Layout + +One folder per implement type, using the same categories as the "Submit a machine" issue template: + +| Folder | Contents | +|---|---| +| `sprayer/` | Sprayers | +| `spreader/` | Fertilizer spreaders | +| `seeder/` | Seed drills and single-product planters | +| `combined/` | Multi-product machines (e.g. planter + fertilizer + microgranulate) | +| `tractor/` | Tractor pools. **No sections**, so they must never register as an implement | +| `unsorted/` | Real pools whose machine could not be identified from the DDOP or the issue. Move them once known | + +File names are `_[_].ddop`. The variant carries whatever distinguishes two pools of +the same machine: the section count (the Sulky reports a different pool depending on its configuration), +the section-control method, or the language. Put a `.iop` next to its `.ddop` under the same base name if a +machine's terminal pool is ever available. + +## Columns + +- **geometry**: where section offsets and width come from. `static` means the DDOP carries the values + (DeviceProperty), so they are available at activation. `process-data` means the DDOP only declares the + DDI, and the value has to be requested from the implement over the bus after activation. `none` is a pool + without sections. +- **section_control**: the method the TC picks, in priority order: `DDI290` (condensed setpoint), `DDI161` + (settable condensed actual), `DDI289` (per-element setpoint work state, actual on DDI 141 of the same element), + `DDI141` (settable per-element work state). `none` means the TC cannot control it. Only process data that a + device element links counts, because some implements also send objects no element references. The + `section_control_method` test checks every file against this column. +- **connector_types**: DDI 157 values found on the connector elements (0 unknown, 1 drawbar, + 2 semi-mounted three-point, 3 mounted three-point, 4 hitch hook, ...). + +## Privacy + +The serial number string and the identity-number bits of the DVC NAME were zeroed in every file, with the +length and layout unchanged. Nothing else was altered. Do the same to anything you add: some pools carry a +VIN-style serial. + +## Adding a machine + +Attach the DDOP to a "Submit a machine" issue, then add it here with its issue number in the `source` +column. Only add pools from a machine you can name, and keep `unsorted/` for the ones you cannot. + +## Synthetic pools + +Files ending in `_synthetic.ddop` were not captured from a machine. They are derived from a real capture by the +implement firmware's own rules, to cover configurations nobody has captured yet. Replace one once a real capture of +that configuration exists. + +The three MIDI 3 variants come from `ME_SPRITZE_SPR_MIDI_3_11sec.ddop` (firmware SPR_MIDI_3 V07.10.05.33), one per +section-control setting: old or new SC DDIs, condensed work state on or off. The firmware sends every template +object and only filters the element child lists, so these pools hold process data that no element links. The same +rules rebuild the real capture byte for byte. + +## Files + + +### sprayer + +| File | Sections | Geometry | Section control | Connector | Source | Note | +|---|---|---|---|---|---|---| +| `AGL_SPRAYER_7sec.ddop` | 7 | static | DDI290 | 1 | ddops.zip (no issue) | Y + width as static properties, no X offset. | +| `JD_740_SPRAYER_9sec.ddop` | 9 | process-data | DDI161 | 1 | #34 | Settable DDI 161. Geometry only as process data (Y + width). | +| `ME_ECO_SPRITZE_SC_BOX_9sec.ddop` | 9 | static | DDI141 | - | #64 | Mueller SC-Box from 2006. Per-section DDI 141 elements, no condensed DDIs. Static geometry. | +| `ME_SPRITZE_SPR_MIDI_3_11sec.ddop` | 11 | static | DDI290 | 0 | bench capture 2026-09-20 (no issue) | Full static geometry (DPT). Best reference pool. | +| `ME_SPRITZE_SPR_MIDI_3_11sec_newSC_perElement_synthetic.ddop` | 11 | static | DDI289 | 0 | synthetic, derived from the bench capture by firmware rules | SYNTHETIC, never seen on the wire. ISO options: new SC DDIs, condensed work state off. Each section links settable DDI 289 + DDI 141. | +| `ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_CWS_synthetic.ddop` | 11 | static | DDI161 | 0 | synthetic, derived from the bench capture by firmware rules | SYNTHETIC, never seen on the wire. ISO options: old SC DDIs, condensed work state on. The boom links a settable DDI 161; the original DDI 161 objects stay in the pool, unlinked and not settable. | +| `ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_perElement_synthetic.ddop` | 11 | static | DDI141 | 0 | synthetic, derived from the bench capture by firmware rules | SYNTHETIC, never seen on the wire. ISO options: old SC DDIs, condensed work state off. Each section links a settable DDI 141 (translated from 289). | + +### spreader + +| File | Sections | Geometry | Section control | Connector | Source | Note | +|---|---|---|---|---|---|---| +| `KVERNELAND_EDW2_ISO_2015_2sec.ddop` | 2 | process-data | DDI290 | - | #60 | Kept as kverneland_en.ddop in the DDOP editor folder. Issue #60 pairs it with the Exacta (a fertilizer spreader); model not confirmed. | +| `SULKY_ISOLINK_SPREADER_12sec.ddop` | 12 | process-data | DDI290 | - | #31, #93 | Same client NAME as the 6 section variant: the DDOP changes with machine configuration. Geometry only as process data. | +| `SULKY_ISOLINK_SPREADER_6sec.ddop` | 6 | process-data | DDI290 | - | #51 | Geometry only as process data. Localization label carries a control character (see #61). | + +### seeder + +| File | Sections | Geometry | Section control | Connector | Source | Note | +|---|---|---|---|---|---|---| +| `AGISOSTACK_EXAMPLE_SEEDER_6sec.ddop` | 6 | static | DDI290 | 9 | synthetic (AgIsoStack example) | Not a real machine. Full static geometry. | +| `KUHN_ESPRO_2sec_DDI141_en.ddop` | 2 | process-data | DDI141 | 2 | #10 | Same machine as the hu file, English strings. | +| `KUHN_ESPRO_2sec_DDI141_hu.ddop` | 2 | process-data | DDI141 | 2 | #10 | Non-ASCII strings (hu). Geometry only as process data. | +| `KUHN_ESPRO_2sec_DDI290_hu.ddop` | 2 | process-data | DDI290 | 2 | ddops.zip 2026-09-04 (#10 machine) | Newer capture of the same machine, condensed setpoint DDI 290 instead of 141. | +| `SKY_EASYDRILL_E2_1sec.ddop` | 1 | process-data | DDI290 | - | ddops.zip (no issue) | Single section, geometry only as process data. | + +### combined + +| File | Sections | Geometry | Section control | Connector | Source | Note | +|---|---|---|---|---|---|---| +| `AMAZONE_PRECEA_4500_6sec_2booms.ddop` | 6 | process-data | DDI290 | - | #35 | Planter + fertilizer + microgranulate. Only element 4 allows section control. | + +### tractor + +| File | Sections | Geometry | Section control | Connector | Source | Note | +|---|---|---|---|---|---|---| +| `JD_6000M_TRACTOR.ddop` | 0 | none | DDI141 | 1 3 4 5 6 7 8 | DDOP editor collection (woifi_tractor_en.ddop) | No sections. | +| `JD_6R150_TRACTOR.ddop` | 0 | none | none | 1 3 | #31, #93 | No sections. Must never register as an implement. | +| `JD_6R150_TRACTOR_minimal.ddop` | 0 | none | none | - | #93 | Reduced pool (no connectors, no booms). | +| `JD_6R175_6000RW_TRACTOR.ddop` | 0 | none | DDI141 | 1 3 4 5 6 7 8 | #34 | No sections, 8 connectors of mixed types. | + +### unsorted + +| File | Sections | Geometry | Section control | Connector | Source | Note | +|---|---|---|---|---|---|---| +| `MFG89_EDW2_ISO_DEMO_64sec_5booms.ddop` | 64 | process-data | DDI290 | - | ddops.zip 2026-04-06 | Stress case: 64 sections over 5 booms. | +| `MFG89_EDW3_MAIN_DEMO_24sec_3booms.ddop` | 24 | process-data | DDI290 | - | ddops.zip 2026-04-06 | | +| `MFG89_ESA_FC_DEMO_2sec.ddop` | 2 | process-data | DDI141 | - | ddops.zip 2026-04-06 | | +| `MFG89_E_COM_UD_DEMO_1sec_2booms.ddop` | 1 | process-data | DDI290 | - | ddops.zip 2026-04-06 | | +| `MFG89_IXSPRAY_DEMO_7sec.ddop` | 7 | process-data | DDI290 | - | ddops.zip 2026-04-06 | Name suggests a sprayer. | +| `MFG89_PSD_MB_DEMO_12sec.ddop` | 12 | process-data | DDI290 | - | ddops.zip 2026-04-06 | | +| `MFG89_PWRW_II_ISO_SIM_16sec.ddop` | 16 | process-data | DDI290 | - | ddops.zip 2026-04-06 | 16 sections in one pool. | diff --git a/tools/testdata/implements/combined/AMAZONE_PRECEA_4500_6sec_2booms.ddop b/tools/testdata/implements/combined/AMAZONE_PRECEA_4500_6sec_2booms.ddop new file mode 100644 index 0000000..9d399f0 Binary files /dev/null and b/tools/testdata/implements/combined/AMAZONE_PRECEA_4500_6sec_2booms.ddop differ diff --git a/tools/testdata/implements/manifest.csv b/tools/testdata/implements/manifest.csv new file mode 100644 index 0000000..b018cf7 --- /dev/null +++ b/tools/testdata/implements/manifest.csv @@ -0,0 +1,28 @@ +file,type,sections,functions,geometry,section_control,connector_types,bytes,source,note +combined/AMAZONE_PRECEA_4500_6sec_2booms.ddop,combined,6,2,process-data,DDI290,-,2705,#35,Planter + fertilizer + microgranulate. Only element 4 allows section control. +seeder/AGISOSTACK_EXAMPLE_SEEDER_6sec.ddop,seeder,6,1,static,DDI290,9,1427,synthetic (AgIsoStack example),Not a real machine. Full static geometry. +seeder/KUHN_ESPRO_2sec_DDI141_en.ddop,seeder,2,1,process-data,DDI141,2,2285,#10,"Same machine as the hu file, English strings." +seeder/KUHN_ESPRO_2sec_DDI141_hu.ddop,seeder,2,1,process-data,DDI141,2,2455,#10,Non-ASCII strings (hu). Geometry only as process data. +seeder/KUHN_ESPRO_2sec_DDI290_hu.ddop,seeder,2,1,process-data,DDI290,2,2677,ddops.zip 2026-09-04 (#10 machine),"Newer capture of the same machine, condensed setpoint DDI 290 instead of 141." +seeder/SKY_EASYDRILL_E2_1sec.ddop,seeder,1,1,process-data,DDI290,-,1255,ddops.zip (no issue),"Single section, geometry only as process data." +sprayer/AGL_SPRAYER_7sec.ddop,sprayer,7,1,static,DDI290,1,1660,ddops.zip (no issue),"Y + width as static properties, no X offset." +sprayer/JD_740_SPRAYER_9sec.ddop,sprayer,9,0,process-data,DDI161,1,1797,#34,Settable DDI 161. Geometry only as process data (Y + width). +sprayer/ME_ECO_SPRITZE_SC_BOX_9sec.ddop,sprayer,9,1,static,DDI141,-,1231,#64,"Mueller SC-Box from 2006. Per-section DDI 141 elements, no condensed DDIs. Static geometry." +sprayer/ME_SPRITZE_SPR_MIDI_3_11sec.ddop,sprayer,11,1,static,DDI290,0,5246,bench capture 2026-09-20 (no issue),Full static geometry (DPT). Best reference pool. +sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_newSC_perElement_synthetic.ddop,sprayer,11,1,static,DDI289,0,5286,"synthetic, derived from the bench capture by firmware rules","SYNTHETIC, never seen on the wire. ISO options: new SC DDIs, condensed work state off. Each section links settable DDI 289 + DDI 141." +sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_CWS_synthetic.ddop,sprayer,11,1,static,DDI161,0,5244,"synthetic, derived from the bench capture by firmware rules","SYNTHETIC, never seen on the wire. ISO options: old SC DDIs, condensed work state on. The boom links a settable DDI 161; the original DDI 161 objects stay in the pool, unlinked and not settable." +sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_perElement_synthetic.ddop,sprayer,11,1,static,DDI141,0,5264,"synthetic, derived from the bench capture by firmware rules","SYNTHETIC, never seen on the wire. ISO options: old SC DDIs, condensed work state off. Each section links a settable DDI 141 (translated from 289)." +spreader/KVERNELAND_EDW2_ISO_2015_2sec.ddop,spreader,2,0,process-data,DDI290,-,576,#60,Kept as kverneland_en.ddop in the DDOP editor folder. Issue #60 pairs it with the Exacta (a fertilizer spreader); model not confirmed. +spreader/SULKY_ISOLINK_SPREADER_12sec.ddop,spreader,12,1,process-data,DDI290,-,1147,"#31, #93",Same client NAME as the 6 section variant: the DDOP changes with machine configuration. Geometry only as process data. +spreader/SULKY_ISOLINK_SPREADER_6sec.ddop,spreader,6,1,process-data,DDI290,-,973,#51,Geometry only as process data. Localization label carries a control character (see #61). +tractor/JD_6000M_TRACTOR.ddop,tractor,0,1,none,DDI141,1 3 4 5 6 7 8,1829,DDOP editor collection (woifi_tractor_en.ddop),No sections. +tractor/JD_6R150_TRACTOR.ddop,tractor,0,3,none,none,1 3,1207,"#31, #93",No sections. Must never register as an implement. +tractor/JD_6R150_TRACTOR_minimal.ddop,tractor,0,0,none,none,-,970,#93,"Reduced pool (no connectors, no booms)." +tractor/JD_6R175_6000RW_TRACTOR.ddop,tractor,0,1,none,DDI141,1 3 4 5 6 7 8,1829,#34,"No sections, 8 connectors of mixed types." +unsorted/MFG89_EDW2_ISO_DEMO_64sec_5booms.ddop,unsorted,64,5,process-data,DDI290,-,2140,ddops.zip 2026-04-06,Stress case: 64 sections over 5 booms. +unsorted/MFG89_EDW3_MAIN_DEMO_24sec_3booms.ddop,unsorted,24,3,process-data,DDI290,-,1238,ddops.zip 2026-04-06, +unsorted/MFG89_ESA_FC_DEMO_2sec.ddop,unsorted,2,0,process-data,DDI141,-,603,ddops.zip 2026-04-06, +unsorted/MFG89_E_COM_UD_DEMO_1sec_2booms.ddop,unsorted,1,2,process-data,DDI290,-,818,ddops.zip 2026-04-06, +unsorted/MFG89_IXSPRAY_DEMO_7sec.ddop,unsorted,7,1,process-data,DDI290,-,756,ddops.zip 2026-04-06,Name suggests a sprayer. +unsorted/MFG89_PSD_MB_DEMO_12sec.ddop,unsorted,12,0,process-data,DDI290,-,760,ddops.zip 2026-04-06, +unsorted/MFG89_PWRW_II_ISO_SIM_16sec.ddop,unsorted,16,0,process-data,DDI290,-,762,ddops.zip 2026-04-06,16 sections in one pool. diff --git a/tools/testdata/implements/seeder/AGISOSTACK_EXAMPLE_SEEDER_6sec.ddop b/tools/testdata/implements/seeder/AGISOSTACK_EXAMPLE_SEEDER_6sec.ddop new file mode 100644 index 0000000..21e7653 Binary files /dev/null and b/tools/testdata/implements/seeder/AGISOSTACK_EXAMPLE_SEEDER_6sec.ddop differ diff --git a/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI141_en.ddop b/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI141_en.ddop new file mode 100644 index 0000000..f9f3fee Binary files /dev/null and b/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI141_en.ddop differ diff --git a/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI141_hu.ddop b/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI141_hu.ddop new file mode 100644 index 0000000..3c2fac7 Binary files /dev/null and b/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI141_hu.ddop differ diff --git a/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI290_hu.ddop b/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI290_hu.ddop new file mode 100644 index 0000000..89ae5f5 Binary files /dev/null and b/tools/testdata/implements/seeder/KUHN_ESPRO_2sec_DDI290_hu.ddop differ diff --git a/tools/testdata/implements/seeder/SKY_EASYDRILL_E2_1sec.ddop b/tools/testdata/implements/seeder/SKY_EASYDRILL_E2_1sec.ddop new file mode 100644 index 0000000..b46a478 Binary files /dev/null and b/tools/testdata/implements/seeder/SKY_EASYDRILL_E2_1sec.ddop differ diff --git a/tools/testdata/implements/sprayer/AGL_SPRAYER_7sec.ddop b/tools/testdata/implements/sprayer/AGL_SPRAYER_7sec.ddop new file mode 100644 index 0000000..56b3736 Binary files /dev/null and b/tools/testdata/implements/sprayer/AGL_SPRAYER_7sec.ddop differ diff --git a/tools/testdata/implements/sprayer/JD_740_SPRAYER_9sec.ddop b/tools/testdata/implements/sprayer/JD_740_SPRAYER_9sec.ddop new file mode 100644 index 0000000..2798ce3 Binary files /dev/null and b/tools/testdata/implements/sprayer/JD_740_SPRAYER_9sec.ddop differ diff --git a/tools/testdata/implements/sprayer/ME_ECO_SPRITZE_SC_BOX_9sec.ddop b/tools/testdata/implements/sprayer/ME_ECO_SPRITZE_SC_BOX_9sec.ddop new file mode 100644 index 0000000..e0119f9 Binary files /dev/null and b/tools/testdata/implements/sprayer/ME_ECO_SPRITZE_SC_BOX_9sec.ddop differ diff --git a/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec.ddop b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec.ddop new file mode 100644 index 0000000..128f528 Binary files /dev/null and b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec.ddop differ diff --git a/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_newSC_perElement_synthetic.ddop b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_newSC_perElement_synthetic.ddop new file mode 100644 index 0000000..a00ebf4 Binary files /dev/null and b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_newSC_perElement_synthetic.ddop differ diff --git a/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_CWS_synthetic.ddop b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_CWS_synthetic.ddop new file mode 100644 index 0000000..0cbffa8 Binary files /dev/null and b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_CWS_synthetic.ddop differ diff --git a/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_perElement_synthetic.ddop b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_perElement_synthetic.ddop new file mode 100644 index 0000000..ce6b1cf Binary files /dev/null and b/tools/testdata/implements/sprayer/ME_SPRITZE_SPR_MIDI_3_11sec_oldSC_perElement_synthetic.ddop differ diff --git a/tools/testdata/implements/spreader/KVERNELAND_EDW2_ISO_2015_2sec.ddop b/tools/testdata/implements/spreader/KVERNELAND_EDW2_ISO_2015_2sec.ddop new file mode 100644 index 0000000..3a885b5 Binary files /dev/null and b/tools/testdata/implements/spreader/KVERNELAND_EDW2_ISO_2015_2sec.ddop differ diff --git a/tools/testdata/implements/spreader/SULKY_ISOLINK_SPREADER_12sec.ddop b/tools/testdata/implements/spreader/SULKY_ISOLINK_SPREADER_12sec.ddop new file mode 100644 index 0000000..001079c Binary files /dev/null and b/tools/testdata/implements/spreader/SULKY_ISOLINK_SPREADER_12sec.ddop differ diff --git a/tools/testdata/implements/spreader/SULKY_ISOLINK_SPREADER_6sec.ddop b/tools/testdata/implements/spreader/SULKY_ISOLINK_SPREADER_6sec.ddop new file mode 100644 index 0000000..9b25972 Binary files /dev/null and b/tools/testdata/implements/spreader/SULKY_ISOLINK_SPREADER_6sec.ddop differ diff --git a/tools/testdata/implements/tractor/JD_6000M_TRACTOR.ddop b/tools/testdata/implements/tractor/JD_6000M_TRACTOR.ddop new file mode 100644 index 0000000..f81cf7c Binary files /dev/null and b/tools/testdata/implements/tractor/JD_6000M_TRACTOR.ddop differ diff --git a/tools/testdata/implements/tractor/JD_6R150_TRACTOR.ddop b/tools/testdata/implements/tractor/JD_6R150_TRACTOR.ddop new file mode 100644 index 0000000..0575ff9 Binary files /dev/null and b/tools/testdata/implements/tractor/JD_6R150_TRACTOR.ddop differ diff --git a/tools/testdata/implements/tractor/JD_6R150_TRACTOR_minimal.ddop b/tools/testdata/implements/tractor/JD_6R150_TRACTOR_minimal.ddop new file mode 100644 index 0000000..a4020fb Binary files /dev/null and b/tools/testdata/implements/tractor/JD_6R150_TRACTOR_minimal.ddop differ diff --git a/tools/testdata/implements/tractor/JD_6R175_6000RW_TRACTOR.ddop b/tools/testdata/implements/tractor/JD_6R175_6000RW_TRACTOR.ddop new file mode 100644 index 0000000..3abef74 Binary files /dev/null and b/tools/testdata/implements/tractor/JD_6R175_6000RW_TRACTOR.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_EDW2_ISO_DEMO_64sec_5booms.ddop b/tools/testdata/implements/unsorted/MFG89_EDW2_ISO_DEMO_64sec_5booms.ddop new file mode 100644 index 0000000..9fe0950 Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_EDW2_ISO_DEMO_64sec_5booms.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_EDW3_MAIN_DEMO_24sec_3booms.ddop b/tools/testdata/implements/unsorted/MFG89_EDW3_MAIN_DEMO_24sec_3booms.ddop new file mode 100644 index 0000000..3883f7b Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_EDW3_MAIN_DEMO_24sec_3booms.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_ESA_FC_DEMO_2sec.ddop b/tools/testdata/implements/unsorted/MFG89_ESA_FC_DEMO_2sec.ddop new file mode 100644 index 0000000..27faa59 Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_ESA_FC_DEMO_2sec.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_E_COM_UD_DEMO_1sec_2booms.ddop b/tools/testdata/implements/unsorted/MFG89_E_COM_UD_DEMO_1sec_2booms.ddop new file mode 100644 index 0000000..c88fa5d Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_E_COM_UD_DEMO_1sec_2booms.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_IXSPRAY_DEMO_7sec.ddop b/tools/testdata/implements/unsorted/MFG89_IXSPRAY_DEMO_7sec.ddop new file mode 100644 index 0000000..9ba584c Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_IXSPRAY_DEMO_7sec.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_PSD_MB_DEMO_12sec.ddop b/tools/testdata/implements/unsorted/MFG89_PSD_MB_DEMO_12sec.ddop new file mode 100644 index 0000000..05e0ff8 Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_PSD_MB_DEMO_12sec.ddop differ diff --git a/tools/testdata/implements/unsorted/MFG89_PWRW_II_ISO_SIM_16sec.ddop b/tools/testdata/implements/unsorted/MFG89_PWRW_II_ISO_SIM_16sec.ddop new file mode 100644 index 0000000..0839659 Binary files /dev/null and b/tools/testdata/implements/unsorted/MFG89_PWRW_II_ISO_SIM_16sec.ddop differ