diff --git a/docs/CONCURRENCY.md b/docs/CONCURRENCY.md index dcbbb3e..3e53ed9 100644 --- a/docs/CONCURRENCY.md +++ b/docs/CONCURRENCY.md @@ -71,6 +71,8 @@ registration point itself defers to an `update()` call, or invokes the listener/ object. `TractorFacilities`' PGN 65032 callback may still run once after removal, which is harmless: it only logs and never touches the object. `TractorFacilities::providedFacilities` is under its own mutex (set on the main thread, read when answering a PGN 65033 request). +- **`TractorFacilities::timeDateActive`** — atomic: set on the main thread from the TECU plan, read on the + background thread when answering a request for PGN 65033 or PGN 65254. ## What's *not* protected yet — known gap diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 37bc229..787e5a9 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -271,6 +271,7 @@ Common NAME fields: Industry Group `2` (Agricultural), Device Class `0`, Manufac | `0xFC8E` (Control Function Functionalities) | At claim + periodic | TECU | Announces Class 1 BasicTractorECUServer (no options). | | `0xFE48`, `0xFE49`, `0xF022` (PGN 65096, 65097, 61474) | 100 ms | TECU | Wheel-based speed, ground-based speed, machine selected speed (with distance and direction). As a secondary, only those no other Tractor ECU provides (§5.7). | | `0xFE09` (PGN 65033 Tractor Facilities) | Power-up + on request, and when it changes | TECU | 8-byte facility bitmask advertising which PGNs the TECU actually broadcasts. See §5.6. | +| `0xFEE6` (PGN 65254 Time/Date) | On request only (ISO 11783-7 B.1) | TECU | UTC from the PC clock plus the local offset, including daylight saving time. A secondary TECU leaves requests to a Tractor ECU that provides it. | | NMEA2000 COG/SOG | Periodic | TECU | Optional course/speed over ground. Not while another Tractor ECU sends it (§5.7). | | GNSS Quality (DDI 514, via `0xCB00` Process Data) | 250 ms | TC | GPS fix quality from AgIO (PGN `0xD6`, see §2.5), sent to each client whose DDOP declares DDI 514 as settable. Falls back to `1` if `0xD6` has never arrived, and to `0` once it stops arriving (see §2.5). | @@ -313,6 +314,7 @@ When the TECU is enabled, the TC responds to PGN 65033 requests (ISO 11783-7 B.2 | 1 | 8,7 | TECU class | Always `00` (Class 1). | | 1 | 2 | Ground-based speed (PGN 65097) | The TECU sends it: always as the primary; as a secondary only if no other Tractor ECU provides it (§5.7). | | 1 | 3 | Wheel-based speed (PGN 65096) | Same rule. | +| 3 | 8 | Time/date (PGN 65254) | Our TECU answers requests for it: as the primary always, as a secondary only when no lower Tractor ECU advertises or sends it. | | 3 | 7,6 | Ground-based distance + direction | Same as ground-based speed. | | 3 | 5,4 | Wheel-based distance + direction | Same as wheel-based speed. | | 5 | 3 | Selected speed (machine selected speed, PGN 61474) | Same rule. | @@ -320,7 +322,6 @@ When the TECU is enabled, the TC responds to PGN 65033 requests (ISO 11783-7 B.2 **Facilities NOT advertised (bits always 0):** - Engine speed — no engine CAN access. -- Time/date (PGN 65254) — the TC does not broadcast it. - Power management — no key switch or power timer signals. - Hitch position / in-work / draft — the hydraulic lift output is a command we issue, not measured feedback; implements would trust it for work-state logic. - PTO shaft speed / engagement — no PTO sensor. @@ -331,7 +332,7 @@ When the TECU is enabled, the TC responds to PGN 65033 requests (ISO 11783-7 B.2 - Front hitch / PTO — no front hitch or PTO sensors. - All reserved bits (byte 2 bits 2–1, byte 4 bits 3–1, byte 5 bit 5, byte 7, byte 8 including the reserved-bit indicator at bit 1). -**Default payload** (TECU enabled as the primary): `[0x06, 0x00, 0x78, 0x00, 0x04, 0x00, 0x00, 0x00]`. +**Default payload** (TECU enabled as the primary): `[0x06, 0x00, 0xF8, 0x00, 0x04, 0x00, 0x00, 0x00]`. **PGN 65032 (Required Tractor Facilities):** When an implement broadcasts what it needs, the TC logs the request at debug level. The response is not modified based on the implement's requirements — a facility bit is set to 1 only when backed by a live broadcast. @@ -342,7 +343,7 @@ ISO 11783-9:2012 §4.4.2.1 allows several Tractor ECUs on the implement bus. The - **`tecuEnabled` picks the role.** `true`: AOG is the tractor's TECU. At startup the TC's own address claim makes every control function on the bus announce itself; if no other Tractor ECU (function code 134) is there, our TECU claims `240` as the primary (function instance 0) and sends everything in §5.2, otherwise it starts as a secondary. `false` ("the tractor has its own TECU"): always a secondary at an arbitrary address, with a function instance above every Tractor ECU on the bus. With no other Tractor ECU there, every message is a gap and it sends them all; once the tractor's appears, it cuts back to what that one lacks. - **Never taking `240` from a tractor:** our TECU NAME is arbitrary-address-capable in both roles. That is the most significant NAME bit, so in an address claim contest for `240` our primary loses to a fixed-address Tractor ECU and moves elsewhere, instead of knocking the tractor's ECU off the bus. - **Stepping down at runtime** (`tecuEnabled: true`): once a second the TC checks for other Tractor ECUs. If another one with function instance 0 appears while ours is the primary, or ours lost `240`, our TECU is rebuilt as a secondary above every Tractor ECU's function instance. Another secondary appearing is no reason to step down. The operator sees a hardware message ("Tractor ECU found: AOG adds only missing data"). -- **What a secondary sends:** nothing until the other Tractor ECUs have answered PGN 65033 (and at least 1 s has passed) or 2.5 s have passed. Then each of ground-based speed, wheel-based speed, machine selected speed, J1939 vehicle direction/speed and NMEA 2000 COG/SOG is sent only if no Tractor ECU with a lower function instance advertises it in PGN 65033 *and* none has sent it in the last 3 s. Only lower instances count, so two secondaries never wait on each other. Its PGN 65033 advertises exactly what it sends, and is re-sent when that changes. +- **What a secondary sends:** nothing until the other Tractor ECUs have answered PGN 65033 (and at least 1 s has passed) or 2.5 s have passed. Then each of ground-based speed, wheel-based speed, machine selected speed, J1939 vehicle direction/speed and NMEA 2000 COG/SOG is sent only if no Tractor ECU with a lower function instance advertises it in PGN 65033 *and* none has sent it in the last 3 s. Answering requests for time/date follows the same rule, except that a lower Tractor ECU that has sent time/date counts as providing it until it leaves the bus: it may only send it when asked, so no time window would do. Time/date is advertised only while it can be answered (request handler registered, PC clock between 1985 and 2235). Only lower instances count, so two secondaries never wait on each other. Its PGN 65033 advertises exactly what it sends, and is re-sent when that changes. - A secondary does not step back up to primary if the tractor's Tractor ECU leaves; it then sends everything, still as a secondary. --- diff --git a/include/tecu_coexistence.hpp b/include/tecu_coexistence.hpp index 99fcacb..79e7e92 100644 --- a/include/tecu_coexistence.hpp +++ b/include/tecu_coexistence.hpp @@ -99,6 +99,39 @@ struct OtherTecu std::uint16_t manufacturerCode = 0; }; +/// @brief The other Tractor ECUs that have sent Time/Date (PGN 65254) since they appeared on the bus. +/// +/// Time/Date is only sent on request (ISO 11783-7 B.1), so a time window cannot tell whether a lower Tractor +/// ECU provides it: with requests further apart than the window, both TECUs would answer every one. Instead a +/// TECU counts as providing Time/Date from the first time it sends it until it leaves the bus (forget()). +/// mark_sent() runs on the CAN stack's background thread, forget() and any() on the main thread. +class TimeDateProviders +{ +public: + void mark_sent(std::uint8_t address) + { + sent[address] = true; + } + void forget(std::uint8_t address) + { + sent[address] = false; + } + bool any() const + { + for (const auto &address : sent) + { + if (address) + { + return true; + } + } + return false; + } + +private: + std::array, 256> sent{}; +}; + /// @brief Tracks the other Tractor ECUs: who they are, the facilities they advertise and what they send. /// /// refresh() and the queries run on the main thread. on_frame() and the PGN 65033 callback run on the @@ -108,7 +141,7 @@ struct OtherTecu class OtherTecuMonitor { public: - /// How long a message seen from another TECU counts as "sent by it" + /// How long a message seen from another TECU counts as "sent by it" (not time/date, see TimeDateProviders) static constexpr std::uint32_t SENT_BY_OTHERS_WINDOW_MS = 3000; /// @brief Register the PGN 65033 listener. Call once. @@ -130,7 +163,7 @@ class OtherTecuMonitor /// @brief Whether every other TECU has answered with PGN 65033 bool all_facilities_received() const; - /// @brief Messages seen from another TECU within SENT_BY_OTHERS_WINDOW_MS + /// @brief Messages seen from another TECU within SENT_BY_OTHERS_WINDOW_MS. Time/date: sent by a TECU still on the bus std::array sent_by_others() const; /// @brief Whether we already asked this address for PGN 65033 since it appeared @@ -142,6 +175,7 @@ class OtherTecuMonitor std::array, 256> isOtherTecuAddress{}; std::array, TECU_MESSAGE_COUNT> lastSentByOthersMs{}; ///< 0 = never + TimeDateProviders timeDateProviders; mutable std::mutex mutex; ///< Guards the members below std::map facilitiesByAddress; diff --git a/include/tractor_facilities.hpp b/include/tractor_facilities.hpp index 28f556d..cd44d03 100644 --- a/include/tractor_facilities.hpp +++ b/include/tractor_facilities.hpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -94,6 +95,14 @@ std::array encode_facilities(const Facilities &f); /// @brief Decode an 8-byte PGN 65033 payload into a Facilities struct. Facilities decode_facilities(const std::array &payload); +/// @brief The minutes to add to UTC to get local time, positive east of Greenwich. +/// Compares the two broken-down clocks, so it includes daylight saving time and needs no time zone lookup. +int utc_offset_minutes(const std::tm &utc, const std::tm &local); + +/// @brief Encode the Time/Date payload (PGN 65254, ISO 11783-7 B.1) from the same instant as UTC and local time. +/// @param milliseconds Milliseconds into the current second; the message has a resolution of 250 ms. +std::array encode_time_date(const std::tm &utc, const std::tm &local, int milliseconds); + /// @brief Implements ISO 11783-7 Tractor Facilities response (PGN 65033) /// and Required Tractor Facilities diagnostic logging (PGN 65032). class TractorFacilities @@ -101,6 +110,7 @@ class TractorFacilities public: static constexpr std::uint32_t PGN_TRACTOR_FACILITIES = 65033; ///< 0xFE09 static constexpr std::uint32_t PGN_REQUIRED_TRACTOR_FACILITIES = 65032; ///< 0xFE08 + static constexpr std::uint32_t PGN_TIME_DATE = 65254; ///< 0xFEE6 TractorFacilities(std::shared_ptr tecuCF, std::shared_ptr settings); @@ -129,8 +139,9 @@ class TractorFacilities /// @brief Provide a pointer to the NMEA 2000 message interface. void set_nmea2000_message_interface(isobus::NMEA2000MessageInterface *iface); - /// @brief Set whether time/date (PGN 65254 / FEE6) is being broadcast. - /// When true, the time/date facility bit is advertised in PGN 65033. + /// @brief Set whether our TECU provides time/date (PGN 65254 / FEE6). + /// When true, it answers requests for that PGN and advertises the time/date facility bit in PGN 65033. + /// When false, it leaves requests to the other Tractor ECU. It is never sent unrequested (ISO 11783-7 B.1). void set_time_date_active(bool active); /// @brief Advertise exactly these facilities (the time/date bit still follows set_time_date_active()). @@ -149,6 +160,13 @@ class TractorFacilities isobus::AcknowledgementType &acknowledgeType, void *parentPointer); + /// @brief Static PGN-request callback registered for PGN 65254 (Time/Date). + static bool on_time_date_request(std::uint32_t parameterGroupNumber, + std::shared_ptr requestingControlFunction, + bool &acknowledge, + isobus::AcknowledgementType &acknowledgeType, + void *parentPointer); + /// @brief Static global-PGN callback for PGN 65032 (Required Tractor /// Facilities). Diagnostic only — logs the request unconditionally /// (not gated by log level) and never changes our response. @@ -158,11 +176,12 @@ class TractorFacilities std::shared_ptr settings; isobus::SpeedMessagesInterface *speedMessagesInterface = nullptr; isobus::NMEA2000MessageInterface *nmea2000MessageInterface = nullptr; - /// Whether we are broadcasting PGN 65254 (FEE6). Atomic because - /// set_time_date_active() and build_payload() are called from different - /// isobus callback contexts (see docs/CONCURRENCY.md) that can run - /// concurrently on different threads. + /// Whether we provide PGN 65254 (FEE6). Atomic because set_time_date_active() + /// runs on the main thread, while the PGN request callbacks that read it run + /// on the CAN stack's thread (see docs/CONCURRENCY.md). std::atomic timeDateActive{ false }; + std::atomic timeDateRequestLogged{ false }; ///< To log only the first request answered + std::atomic timeDateRequestHandlerRegistered{ false }; ///< Without it, requests for PGN 65254 go unanswered /// Set by set_provided_facilities() on the main thread, read by build_payload() from PGN request callbacks mutable std::mutex providedFacilitiesMutex; diff --git a/src/app.cpp b/src/app.cpp index f2d334f..9d3c023 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -485,7 +485,7 @@ void Application::setup_tecu_interfaces() // The primary sends everything. A secondary sends nothing until it knows what the other // Tractor ECUs provide (update_tecu()), so it never doubles one of their messages. - const TecuMessagePlan plan = plan_tecu_messages(TecuRole::Primary == tecuRole, Facilities{}, {}); + const TecuMessagePlan plan = (TecuRole::Primary == tecuRole) ? plan_tecu_messages(true, Facilities{}, {}) : TecuMessagePlan{}; log("Init") << "Creating Speed Messages Interface on TECU..." << std::endl; create_speed_messages_interface(plan); @@ -502,6 +502,7 @@ void Application::setup_tecu_interfaces() tractorFacilities->set_speed_messages_interface(speedMessagesInterface.get()); tractorFacilities->set_nmea2000_message_interface(nmea2000MessageInterface.get()); tractorFacilities->set_provided_facilities(facilities_for_plan(plan)); + tractorFacilities->set_time_date_active(plan.sends(TecuMessage::TimeDate)); tractorFacilities->initialize(); tecuPlan = plan; tecuInterfacesReady = true; @@ -636,6 +637,7 @@ void Application::apply_tecu_plan(const TecuMessagePlan &plan) if (tractorFacilities) { tractorFacilities->set_provided_facilities(facilities_for_plan(plan)); + tractorFacilities->set_time_date_active(plan.sends(TecuMessage::TimeDate)); if (tractorFacilitiesSentOnPowerUp) { // Implements that cached our earlier response learn what changed diff --git a/src/tecu_coexistence.cpp b/src/tecu_coexistence.cpp index 11811e9..28b8382 100644 --- a/src/tecu_coexistence.cpp +++ b/src/tecu_coexistence.cpp @@ -177,6 +177,17 @@ std::vector OtherTecuMonitor::refresh(std::uint8_t maxFunctionInstanc std::lock_guard lock(mutex); for (std::size_t address = 0; address < isOtherTecuAddress.size(); address++) + { + // A TECU that left, or a different one now at that address, no longer provides time/date. Done before + // isOtherTecuAddress is set, so a TECU that just appeared cannot have its first time/date cleared. + const auto name = names.find(static_cast(address)); + const auto previous = nameByAddress.find(static_cast(address)); + if ((names.end() == name) || (nameByAddress.end() == previous) || (name->second != previous->second)) + { + timeDateProviders.forget(static_cast(address)); + } + } + for (std::size_t address = 0; address < isOtherTecuAddress.size(); address++) { isOtherTecuAddress[address] = (addresses.count(static_cast(address)) > 0); } @@ -213,6 +224,10 @@ void OtherTecuMonitor::on_frame(const isobus::CANMessageFrame &frame) { const std::uint32_t now = isobus::SystemTiming::get_timestamp_ms(); lastSentByOthersMs[i] = (0 == now) ? 1 : now; // 0 means "never" + if (static_cast(TecuMessage::TimeDate) == i) + { + timeDateProviders.mark_sent(static_cast(frame.identifier & 0xFF)); + } } } } @@ -274,7 +289,14 @@ std::array OtherTecuMonitor::sent_by_others() const for (std::size_t i = 0; i < TECU_MESSAGE_COUNT; i++) { const std::uint32_t last = lastSentByOthersMs[i]; - sent[i] = (0 != last) && !isobus::SystemTiming::time_expired_ms(last, SENT_BY_OTHERS_WINDOW_MS); + if (static_cast(TecuMessage::TimeDate) == i) + { + sent[i] = timeDateProviders.any(); + } + else + { + sent[i] = (0 != last) && !isobus::SystemTiming::time_expired_ms(last, SENT_BY_OTHERS_WINDOW_MS); + } } return sent; } diff --git a/src/tractor_facilities.cpp b/src/tractor_facilities.cpp index ed80093..e95db3f 100644 --- a/src/tractor_facilities.cpp +++ b/src/tractor_facilities.cpp @@ -14,6 +14,8 @@ #include "logging_utils.hpp" #include "settings.hpp" +#include +#include #include #include #include @@ -242,6 +244,63 @@ Facilities decode_facilities(const std::array &p) return f; } +// --------------------------------------------------------------------------- +// Time/Date +// --------------------------------------------------------------------------- + +int utc_offset_minutes(const std::tm &utc, const std::tm &local) +{ + // The two clocks are at most a day apart. Across New Year tm_yday jumps, so compare the years first. + int dayDifference = local.tm_yday - utc.tm_yday; + if (local.tm_year != utc.tm_year) + { + dayDifference = (local.tm_year > utc.tm_year) ? 1 : -1; + } + return (dayDifference * 24 * 60) + ((local.tm_hour - utc.tm_hour) * 60) + (local.tm_min - utc.tm_min); +} + +std::array encode_time_date(const std::tm &utc, const std::tm &local, int milliseconds) +{ + const int offsetMinutes = utc_offset_minutes(utc, local); + const int seconds = std::min(utc.tm_sec, 59); // tm_sec is 60 during a leap second + const int quarterOfDay = utc.tm_hour / 6; + + std::array p{}; + p[0] = static_cast((seconds * 4) + (milliseconds / 250)); // 0.25 s/bit + p[1] = static_cast(utc.tm_min); + p[2] = static_cast(utc.tm_hour); + p[3] = static_cast(utc.tm_mon + 1); + p[4] = static_cast(((utc.tm_mday - 1) * 4) + quarterOfDay + 1); // 0.25 day/bit, 0 = not a day + p[5] = static_cast(utc.tm_year + 1900 - 1985); + // Both offsets carry the sign: -3:30 is -3 h and -30 min + p[6] = static_cast((offsetMinutes % 60) + 125); + p[7] = static_cast((offsetMinutes / 60) + 125); + return p; +} + +/// Read the PC clock. False if it is outside what the message can carry, e.g. a clock that was never set. +static bool read_system_time_date(std::array &payload) +{ + const auto now = std::chrono::system_clock::now(); + const std::time_t seconds = std::chrono::system_clock::to_time_t(now); + const auto milliseconds = std::chrono::duration_cast(now.time_since_epoch()).count() % 1000; + + std::tm utc{}; + std::tm local{}; +#if defined(_WIN32) + const bool converted = (0 == gmtime_s(&utc, &seconds)) && (0 == localtime_s(&local, &seconds)); +#else + const bool converted = (nullptr != gmtime_r(&seconds, &utc)) && (nullptr != localtime_r(&seconds, &local)); +#endif + const int year = utc.tm_year + 1900; + if (!converted || (year < 1985) || (year > 2235)) + { + return false; + } + payload = encode_time_date(utc, local, static_cast(milliseconds)); + return true; +} + // --------------------------------------------------------------------------- // TractorFacilities // --------------------------------------------------------------------------- @@ -269,6 +328,10 @@ TractorFacilities::~TractorFacilities() } isobus::CANNetworkManager::CANNetwork.remove_global_parameter_group_number_callback( PGN_REQUIRED_TRACTOR_FACILITIES, &TractorFacilities::on_required_facilities, this); + if (auto pgnRequestProtocol = tecuCF ? tecuCF->get_pgn_request_protocol().lock() : nullptr) + { + pgnRequestProtocol->remove_pgn_request_callback(PGN_TIME_DATE, &TractorFacilities::on_time_date_request, this); + } } void TractorFacilities::set_provided_facilities(const Facilities &facilities) @@ -322,10 +385,11 @@ std::array TractorFacilities::build_payload() const f.wheelBasedDistance = wheelSpeedActive; f.wheelBasedDirection = wheelSpeedActive; - // Time/date (PGN 65254 / FEE6) – set by the application layer when - // the TECU is actively broadcasting FEE6 and no duplicate provider - // exists on the bus. - f.timeDate = timeDateActive; + // Time/date (PGN 65254 / FEE6) – set by the application layer while + // the TECU answers requests for it. Not advertised when it could not + // answer: no request handler, or a PC clock the message cannot carry. + std::array unusedTimeDate{}; + f.timeDate = timeDateActive && timeDateRequestHandlerRegistered && read_system_time_date(unusedTimeDate); // Everything else stays 0: we have no engine data, hitch feedback, // PTO, auxiliary valves, lighting, language storage (PGN 65039), @@ -367,6 +431,12 @@ bool TractorFacilities::initialize() PGN_REQUIRED_TRACTOR_FACILITIES, &TractorFacilities::on_required_facilities, this); std::cout << "[" << get_timestamp() << "] [TractorFacilities] Registered PGN 65032 diagnostic listener." << std::endl; + timeDateRequestHandlerRegistered = pgnRequestProtocol->register_pgn_request_callback(PGN_TIME_DATE, &TractorFacilities::on_time_date_request, this); + if (!timeDateRequestHandlerRegistered) + { + std::cout << "[" << get_timestamp() << "] [TractorFacilities] Failed to register PGN 65254 request callback; Time/Date is not advertised." << std::endl; + } + initialized = true; return true; } @@ -406,6 +476,31 @@ bool TractorFacilities::send_facilities_response(bool isPowerUp) // Static callbacks // --------------------------------------------------------------------------- +bool TractorFacilities::on_time_date_request( + std::uint32_t parameterGroupNumber, + std::shared_ptr /*requestingControlFunction*/, + bool &acknowledge, + isobus::AcknowledgementType & /*acknowledgeType*/, + void *parentPointer) +{ + auto *self = static_cast(parentPointer); + std::array payload{}; + // Returning false leaves it to the Tractor ECU that does provide it: the stack ignores a global + // request and NACKs one addressed to us + if ((PGN_TIME_DATE != parameterGroupNumber) || (nullptr == self) || !self->timeDateActive || !read_system_time_date(payload)) + { + return false; + } + + acknowledge = false; // Sending the requested PGN is the response + const bool sent = isobus::CANNetworkManager::CANNetwork.send_can_message(PGN_TIME_DATE, payload.data(), payload.size(), self->tecuCF); + if (sent && !self->timeDateRequestLogged.exchange(true)) + { + std::cout << "[" << get_timestamp() << "] [TractorFacilities] Answering requests for PGN 65254 (Time/Date) from the PC clock." << std::endl; + } + return sent; +} + bool TractorFacilities::on_pgn_request( std::uint32_t parameterGroupNumber, std::shared_ptr requestingControlFunction, diff --git a/tools/test_tecu_coexistence.cpp b/tools/test_tecu_coexistence.cpp index 850cb6c..cdd6d81 100644 --- a/tools/test_tecu_coexistence.cpp +++ b/tools/test_tecu_coexistence.cpp @@ -137,6 +137,20 @@ static void test_pgn_from_can_identifier() check(tecu_message_pgn(TecuMessage::CogSogRapidUpdate) == 129026, "COG/SOG PGN"); } +// A lower TECU that answered a Time/Date request keeps providing it until it leaves, however long the gap +static void test_time_date_providers() +{ + std::printf("test_time_date_providers\n"); + TimeDateProviders providers; + check(!providers.any(), "nobody provides time/date at first"); + providers.mark_sent(0x80); + check(providers.any(), "a TECU that sent time/date provides it"); + providers.forget(0x81); + check(providers.any(), "another address leaving changes nothing"); + providers.forget(0x80); + check(!providers.any(), "the provider leaving the bus ends it"); +} + int main() { test_primary_sends_everything(); @@ -146,6 +160,7 @@ int main() test_primary_advertises_selected_speed(); test_merge_facilities(); test_pgn_from_can_identifier(); + test_time_date_providers(); if (failures > 0) { diff --git a/tools/test_tractor_facilities.cpp b/tools/test_tractor_facilities.cpp index e88644f..f46868f 100644 --- a/tools/test_tractor_facilities.cpp +++ b/tools/test_tractor_facilities.cpp @@ -323,6 +323,61 @@ static void test_rear_hitch_in_work_not_set() check((payload[1] & 0x40) == 0x00, "rear hitch in work (byte 2 bit 7) = 0 by default"); } +static std::tm make_tm(int year, int month, int day, int yday, int hour, int minute, int second) +{ + std::tm t{}; + t.tm_year = year - 1900; + t.tm_mon = month - 1; + t.tm_mday = day; + t.tm_yday = yday; + t.tm_hour = hour; + t.tm_min = minute; + t.tm_sec = second; + return t; +} + +static void test_utc_offset() +{ + std::printf("[test_utc_offset]\n"); + const auto summerUtc = make_tm(2026, 7, 15, 195, 10, 20, 30); + check(utc_offset_minutes(summerUtc, make_tm(2026, 7, 15, 195, 12, 20, 30)) == 120, "CEST (daylight saving) = +120"); + check(utc_offset_minutes(summerUtc, summerUtc) == 0, "UTC = 0"); + + const auto lateUtc = make_tm(2026, 3, 10, 68, 23, 30, 0); + check(utc_offset_minutes(lateUtc, make_tm(2026, 3, 11, 69, 0, 30, 0)) == 60, "local is already tomorrow"); + const auto earlyUtc = make_tm(2026, 3, 11, 69, 1, 0, 0); + check(utc_offset_minutes(earlyUtc, make_tm(2026, 3, 10, 68, 21, 0, 0)) == -240, "local is still yesterday"); + + const auto newYearEveUtc = make_tm(2026, 12, 31, 364, 23, 30, 0); + check(utc_offset_minutes(newYearEveUtc, make_tm(2027, 1, 1, 0, 0, 30, 0)) == 60, "local is already next year"); + const auto newYearUtc = make_tm(2027, 1, 1, 0, 2, 0, 0); + check(utc_offset_minutes(newYearUtc, make_tm(2026, 12, 31, 364, 22, 30, 0)) == -210, "local is still last year, half-hour zone"); +} + +static void test_time_date_payload() +{ + std::printf("[test_time_date_payload]\n"); + const auto utc = make_tm(2026, 7, 15, 195, 10, 20, 30); + const auto p = encode_time_date(utc, make_tm(2026, 7, 15, 195, 12, 20, 30), 750); + check(p[0] == (30 * 4) + 3, "seconds in 0.25 s steps"); + check(p[1] == 20, "minutes"); + check(p[2] == 10, "hours, UTC"); + check(p[3] == 7, "month"); + check(p[4] == (14 * 4) + 1 + 1, "day in quarter days, second quarter"); + check(p[5] == 41, "years since 1985"); + check(p[6] == 125, "local minute offset 0"); + check(p[7] == 127, "local hour offset +2"); + + const auto newYearUtc = make_tm(2027, 1, 1, 0, 2, 0, 0); + const auto west = encode_time_date(newYearUtc, make_tm(2026, 12, 31, 364, 22, 30, 0), 0); + check(west[6] == 125 - 30, "local minute offset -30"); + check(west[7] == 125 - 3, "local hour offset -3"); + check(west[4] == 1, "first quarter of the first day"); + + const auto leap = encode_time_date(make_tm(2026, 6, 30, 180, 23, 59, 60), make_tm(2026, 6, 30, 180, 23, 59, 60), 999); + check(leap[0] == (59 * 4) + 3, "a leap second stays in range"); +} + int main() { test_default_ground_speed_only(); @@ -333,6 +388,8 @@ int main() test_tecu_class_not_available(); test_zero_payload(); test_rear_hitch_in_work_not_set(); + test_utc_offset(); + test_time_date_payload(); if (failures > 0) {