Skip to content
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,19 @@ if(AOG_TC_VALIDATE_IOP)
add_dependencies(iop_validator test_element_hierarchy)
add_test(NAME element_hierarchy COMMAND test_element_hierarchy)

# section_layout (condensed-DDI owners per boom, overlapping-section grouping)
# unit tests, built with the validation workflow for the same reason.
add_executable(test_section_layout
${CMAKE_CURRENT_LIST_DIR}/tools/test_section_layout.cpp)
target_compile_features(test_section_layout PRIVATE cxx_std_20)
set_target_properties(test_section_layout PROPERTIES CXX_EXTENSIONS OFF)
target_include_directories(test_section_layout
PRIVATE ${CMAKE_CURRENT_LIST_DIR}/include)
target_link_libraries(test_section_layout PRIVATE isobus::Isobus
isobus::Utility)
add_dependencies(iop_validator test_section_layout)
add_test(NAME section_layout COMMAND test_section_layout)

# 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
Expand Down
193 changes: 193 additions & 0 deletions include/section_layout.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
/**
* @brief How an implement's sections map onto condensed-DDI owner elements and onto AOG's sections
*
* Pure functions, so they can be tested against sample DDOPs without a bus.
*/

#pragma once

#include "element_hierarchy.hpp"
#include "isobus/isobus/isobus_device_descriptor_object_pool.hpp"

#include <cstdint>
#include <cstdlib>
#include <map>
#include <optional>
#include <vector>

namespace section_layout
{
/// @brief An element that carries condensed section DDIs (e.g. DDI 290 / 161) for a run of sections.
/// @details ISO 11783-10 numbers the sections of a condensed DDI within the element that carries it.
/// An implement with several booms (e.g. one Function per product) has one owner per boom, each with
/// its own section 1.
struct CondensedOwner
{
std::uint16_t elementNumber = 0; ///< Element carrying the condensed DDIs
std::uint8_t firstSection = 0; ///< Index of its first section in the TC's flat section list
std::uint8_t count = 0; ///< Number of consecutive sections it owns
};

/// @brief Whether an element links a process data object with a DDI in [firstDdi, lastDdi]
/// @param requireSettable Only count process data the TC may set (needed when the range is DDI 161+, whose
/// read-only form only reports and cannot be commanded)
inline bool element_links_ddi(isobus::DeviceDescriptorObjectPool &pool, const isobus::task_controller_object::DeviceElementObject &element, std::uint16_t firstDdi, std::uint16_t lastDdi, bool requireSettable = false)
{
using isobus::task_controller_object::DeviceProcessDataObject;
using isobus::task_controller_object::ObjectTypes;
for (std::uint16_t childId : element.get_child_object_ids())
{
auto child = pool.get_object_by_id(childId);
if (child && (child->get_object_type() == ObjectTypes::DeviceProcessData))
{
const auto processData = std::static_pointer_cast<DeviceProcessDataObject>(child);
const auto ddi = processData->get_ddi();
if ((ddi >= firstDdi) && (ddi <= lastDdi) &&
(!requireSettable || processData->has_property(DeviceProcessDataObject::PropertiesBit::Settable)))
{
return true;
}
}
}
return false;
}

/// @brief Finds, for each section, the nearest element (the section itself or an ancestor) that links a
/// DDI in [firstDdi, lastDdi], and groups consecutive sections with the same owner.
/// @param requireSettable Only settable process data makes an owner (see element_links_ddi)
/// @returns The owners in section order, or an empty vector when a section has no owner or one owner's
/// sections are not consecutive (callers then keep the single-element behaviour).
inline std::vector<CondensedOwner> resolve_condensed_owners(isobus::DeviceDescriptorObjectPool &pool,
const element_hierarchy::ParentMap &parents,
const std::vector<std::uint16_t> &sectionElements,
std::uint16_t firstDdi,
std::uint16_t lastDdi,
bool requireSettable = false)
{
using isobus::task_controller_object::DeviceElementObject;
using isobus::task_controller_object::ObjectTypes;

std::map<std::uint16_t, std::shared_ptr<DeviceElementObject>> elements;
for (std::uint16_t i = 0; i < pool.size(); i++)
{
auto object = pool.get_object_by_index(i);
if (object && (object->get_object_type() == ObjectTypes::DeviceElement))
{
auto element = std::static_pointer_cast<DeviceElementObject>(object);
elements[element->get_element_number()] = element;
}
}

std::vector<CondensedOwner> owners;
for (std::size_t i = 0; i < sectionElements.size(); i++)
{
std::optional<std::uint16_t> owner;
std::uint16_t current = sectionElements[i];
for (std::size_t depth = 0; depth < 32; depth++) // depth guard against a cyclic pool
{
auto found = elements.find(current);
if ((found != elements.end()) && element_links_ddi(pool, *found->second, firstDdi, lastDdi, requireSettable))
{
owner = current;
break;
}
auto parent = parents.find(current);
if (parent == parents.end())
{
break; // Top of the element chain (the device element)
}
current = parent->second;
}

if (!owner)
{
return {};
}
if (!owners.empty() && (owners.back().elementNumber == *owner))
{
owners.back().count++;
continue;
}
for (const auto &existing : owners)
{
if (existing.elementNumber == *owner)
{
return {}; // Sections of one owner are not consecutive
}
}
owners.push_back({ *owner, static_cast<std::uint8_t>(i), 1 });
}
return owners;
}

/// @brief Lateral position of one section, as far as it is known
struct SectionGeometry
{
std::optional<std::int32_t> yOffset_mm; ///< DDI 135, left-/right+
std::optional<std::int32_t> width_mm; ///< DDI 67 (or 70 / 68 when that is all there is)
};

/// @brief Result of grouping sections that cover the same strip of ground
struct AogSectionMap
{
std::vector<std::uint8_t> aogSectionForSection; ///< Implement section -> AOG section
std::uint8_t numberOfAogSections = 0;
bool grouped = false; ///< true when at least two sections share an AOG section
};

/// @brief Identity mapping: implement section i is AOG section i
inline AogSectionMap identity_map(std::uint8_t numberOfSections)
{
AogSectionMap map;
for (std::uint8_t i = 0; i < numberOfSections; i++)
{
map.aogSectionForSection.push_back(i);
}
map.numberOfAogSections = numberOfSections;
return map;
}

/// @brief Puts sections with the same lateral offset and width (within toleranceMm) into one AOG section.
/// @details AOG sections are numbered in the order their first section appears, so an implement without
/// overlapping sections keeps the identity mapping. Fore/aft (X) offset is ignored: several dispensers
/// one behind the other still cover the same strip. Returns the identity mapping unless every section
/// has a known Y offset and a width above zero.
inline AogSectionMap group_overlapping_sections(const std::vector<SectionGeometry> &sections, std::int32_t toleranceMm = 10)
{
const auto count = static_cast<std::uint8_t>(sections.size());
for (const auto &section : sections)
{
if (!section.yOffset_mm || !section.width_mm || (*section.width_mm <= 0))
{
return identity_map(count);
}
}

AogSectionMap map;
std::vector<std::size_t> representative; // AOG section -> first implement section in it
for (std::size_t i = 0; i < sections.size(); i++)
{
std::optional<std::uint8_t> aogSection;
for (std::size_t g = 0; g < representative.size(); g++)
{
const auto &first = sections[representative[g]];
// 64-bit differences: two int32 values can be further apart than an int32 holds
if ((std::llabs(static_cast<long long>(*first.yOffset_mm) - *sections[i].yOffset_mm) <= toleranceMm) &&
(std::llabs(static_cast<long long>(*first.width_mm) - *sections[i].width_mm) <= toleranceMm))
{
aogSection = static_cast<std::uint8_t>(g);
break;
}
}
if (!aogSection)
{
aogSection = static_cast<std::uint8_t>(representative.size());
representative.push_back(i);
}
map.aogSectionForSection.push_back(*aogSection);
}
map.numberOfAogSections = static_cast<std::uint8_t>(representative.size());
map.grouped = (map.numberOfAogSections < count);
return map;
}
} // namespace section_layout
8 changes: 8 additions & 0 deletions include/settings.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ class Settings
*/
bool set_vt_enabled(bool enabled, bool save = true);

/// @brief Check whether sections that cover the same strip (same Y offset and width) share one AOG section.
bool is_group_overlapping_sections() const;

/// @brief Set whether sections that cover the same strip share one AOG section.
bool set_group_overlapping_sections(bool enabled, bool save = true);

/**
* @brief Get the configured TC version
* @return The configured version (0-4, default 3)
Expand Down Expand Up @@ -161,6 +167,7 @@ class Settings
constexpr static bool DEFAULT_NMEA_SEND_ENABLED = true;
constexpr static bool DEFAULT_AOG_HEARTBEAT_ENABLED = true;
constexpr static bool DEFAULT_VT_ENABLED = true;
constexpr static bool DEFAULT_GROUP_OVERLAPPING_SECTIONS = false;
constexpr static std::uint8_t DEFAULT_TC_VERSION = 3; ///< SecondEditionDraft (V3 default for maximum implement compatibility)
static const std::string DEFAULT_LANGUAGE_CODE;
static const std::string DEFAULT_COUNTRY_CODE;
Expand All @@ -169,6 +176,7 @@ class Settings
bool nmeaSendEnabled = DEFAULT_NMEA_SEND_ENABLED;
bool aogHeartbeatEnabled = DEFAULT_AOG_HEARTBEAT_ENABLED;
bool vtEnabled = DEFAULT_VT_ENABLED;
bool groupOverlappingSections = DEFAULT_GROUP_OVERLAPPING_SECTIONS;
std::uint8_t tcVersion = DEFAULT_TC_VERSION;
std::string languageCode = DEFAULT_LANGUAGE_CODE;
std::string countryCode = DEFAULT_COUNTRY_CODE;
Expand Down
49 changes: 49 additions & 0 deletions include/task_controller.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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_layout.hpp"

#include <cstdint>
#include <map>
Expand Down Expand Up @@ -66,6 +67,38 @@ 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 Elements carrying the condensed setpoint/actual DDIs, one per boom. Two or more means each boom
/// numbers its own sections and gets its own setpoint and DDI 160; zero or one keeps the single-element path.
void set_condensed_owners(std::vector<section_layout::CondensedOwner> owners, std::uint16_t setpointBaseDdi);
const std::vector<section_layout::CondensedOwner> &get_condensed_owners() const;
bool has_multiple_condensed_owners() const;
std::uint16_t get_condensed_setpoint_base_ddi() const; ///< DDI 290, or DDI 161 for the settable-actual method
/// @brief Maps an owner element's local condensed section to the flat section index. False if unknown.
bool try_get_owned_section(std::uint16_t ownerElement, std::uint8_t localSection, std::uint8_t &section) const;
/// @brief Records one element's DDI 160 report; with several owners, automatic while any element is automatic
void set_element_section_control_state(std::uint16_t elementNumber, bool enabled);
/// @brief Elements that declare DDI 160 (Section Control State)
void add_section_control_state_element(std::uint16_t elementNumber);
const std::vector<std::uint16_t> &get_section_control_state_elements() const;

/// @brief Section element numbers in flat section order, as found in the DDOP geometry
void set_section_elements(const std::vector<std::uint16_t> &elements);
const std::vector<std::uint16_t> &get_section_elements() const;
/// @brief Stores a Y offset or width that a section reported (DDI 135 / 67 / 70 / 68). True if it changed.
bool set_section_geometry_value(std::uint16_t elementNumber, std::uint16_t ddi, std::int32_t value);
const std::vector<section_layout::SectionGeometry> &get_section_geometry() const;
void set_section_geometry(std::vector<section_layout::SectionGeometry> geometry);
/// @brief Sets which AOG section drives each implement section (identity until geometry says otherwise)
void set_aog_section_map(const section_layout::AogSectionMap &map);
const section_layout::AogSectionMap &get_aog_section_map() const;
/// @brief The grouping the geometry gives, whether or not it is applied (for the log)
void set_proposed_aog_section_map(const section_layout::AogSectionMap &map);
const section_layout::AogSectionMap &get_proposed_aog_section_map() const;
std::uint8_t get_aog_section_for_section(std::uint8_t section) const;
std::uint8_t get_number_of_aog_sections() const;
/// @brief Whether any implement section in this AOG section is on
bool is_aog_section_on(std::uint8_t aogSection) const;

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 @@ -83,6 +116,14 @@ 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::vector<section_layout::CondensedOwner> condensedOwners; ///< One per boom with its own condensed DDIs
std::uint16_t condensedSetpointBaseDdi = 0; ///< First DDI of the condensed setpoint range the owners use
std::map<std::uint16_t, bool> elementSectionControlStates; ///< DDI 160 as reported per element
std::vector<std::uint16_t> sectionControlStateElements; ///< Elements declaring DDI 160
std::vector<std::uint16_t> sectionElements; ///< Flat section index -> section element number (from the geometry)
std::vector<section_layout::SectionGeometry> sectionGeometry; ///< Lateral position per section, filled from the DDOP and requests
section_layout::AogSectionMap aogSectionMap; ///< Implement section -> AOG section
section_layout::AogSectionMap proposedAogSectionMap; ///< What the geometry gives; logged when it changes
};

// Create the task controller server object, this will handle all the ISOBUS communication for us
Expand Down Expand Up @@ -120,17 +161,25 @@ class MyTCServer : public isobus::TaskControllerServer
void update_section_states(std::vector<bool> &sectionStates);
void update_section_control_enabled(bool enabled);

/// @brief Whether sections that cover the same strip (same Y offset and width) share one AOG section (settings: groupOverlappingSections)
void set_group_overlapping_sections(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);
/// @brief Sends each condensed owner its own setpoint, sections numbered within that owner
void send_owner_section_setpoint_states(std::shared_ptr<isobus::ControlFunction> client);
/// @brief Recomputes which AOG section drives each implement section from the known geometry, and logs a change
void update_aog_section_map(std::shared_ptr<isobus::ControlFunction> client, ClientState &state);
void send_section_control_state(std::shared_ptr<isobus::ControlFunction> client, bool enabled);
bool is_ddi_settable(std::shared_ptr<isobus::ControlFunction> client, std::uint16_t ddi);

std::map<std::shared_ptr<isobus::ControlFunction>, ClientState> clients;
std::map<std::shared_ptr<isobus::ControlFunction>, std::queue<std::vector<std::uint8_t>>> uploadedPools;
bool groupOverlappingSections = false; ///< Guarded by clientsMutex

/// @brief Guards clients and uploadedPools.
///
Expand Down
1 change: 1 addition & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ AOG-TaskController reads its configuration from a `settings.json` file located i
| `nmeaSendEnabled` | `bool` | `true` | Enables cyclic NMEA2000 COG/SOG transmission. Requires `tecuEnabled`. |
| `aogHeartbeatEnabled` | `bool` | `true` | Sends the 100 ms zero-section heartbeat when no implement is connected. Disable for AgOpenGPS versions before v6.8.2 beta 5. |
| `vtEnabled` | `bool` | `true` | Enables the ISOBUS Virtual Terminal UI. |
| `groupOverlappingSections` | `bool` | `false` | Sections with the same lateral offset and width (within 10 mm) share one AOG section, e.g. several dispensers on one working width, or a 3 + 3 m seeder with three products per half (two AOG sections). AOG's section count must match the grouped count. The TC requests the offsets and widths an implement only declares as process data. The resulting mapping is logged even while this is off. |
| `tcVersion` | `integer` | `3` | Task Controller version code, from `0` through `4`; `3` is Second Edition Draft. |
| `languageCode` | `string` | `"en"` | Two-character language code advertised on ISOBUS. |
| `countryCode` | `string` | `"US"` | Two-character country code advertised on ISOBUS. |
Expand Down
16 changes: 11 additions & 5 deletions src/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ void Application::setup_task_controller_server()
languageInterface.set_language_code(settings->get_language_code());
languageInterface.set_country_code(settings->get_country_code());
tcServer->initialize();
tcServer->set_group_overlapping_sections(settings->is_group_overlapping_sections());
tcServer->set_task_totals_active(true); // TODO: make this dynamic based on status in AOG
tcFunctionalities = std::make_unique<isobus::ControlFunctionFunctionalities>(tcCF);
tcFunctionalities->set_functionality_is_supported(
Expand Down Expand Up @@ -739,17 +740,20 @@ bool Application::update()
}

anyClientWithSections = true;
std::vector<uint8_t> data = { state.is_section_control_enabled(), state.get_number_of_sections() };

// AOG's sections: overlapping implement sections are reported as one (identity mapping otherwise)
const std::uint8_t aogSections = state.get_number_of_aog_sections();
std::vector<uint8_t> data = { state.is_section_control_enabled(), aogSections };

std::uint8_t sectionIndex = 0;
while (sectionIndex < state.get_number_of_sections())
while (sectionIndex < aogSections)
{
std::uint8_t byte = 0;
for (std::uint8_t i = 0; i < 8; i++)
{
if (sectionIndex < state.get_number_of_sections())
if (sectionIndex < aogSections)
{
byte |= (state.get_section_actual_state(sectionIndex) == SectionState::ON) << i;
byte |= state.is_aog_section_on(sectionIndex) << i;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
sectionIndex++;
}
}
Expand Down Expand Up @@ -1029,7 +1033,9 @@ void Application::check_section_demand()
for (std::uint8_t i = 0; i < state.get_number_of_sections(); i++)
{
actual.push_back(SectionState::ON == state.get_section_actual_state(i));
demanded = demanded || (arriving && (i < sectionDemand.size()) && sectionDemand[i]);
// Demand is AOG's bit for the AOG section this implement section belongs to (grouped sections share one)
const std::uint8_t demandIndex = state.get_aog_section_for_section(i);
demanded = demanded || (arriving && (demandIndex < sectionDemand.size()) && sectionDemand[demandIndex]);
}
const bool working = (actual.end() != std::find(actual.begin(), actual.end(), true));

Expand Down
Loading
Loading