diff --git a/applications/nucleo-cyphal/include/CyphalApp.hpp b/applications/nucleo-cyphal/include/CyphalApp.hpp index 3afaa46..07f9940 100644 --- a/applications/nucleo-cyphal/include/CyphalApp.hpp +++ b/applications/nucleo-cyphal/include/CyphalApp.hpp @@ -24,6 +24,7 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D static constexpr uint16_t UdpPort = 9382U; static constexpr uint32_t SubjectId = 7509U; + static constexpr UdpardPortID DiagnosticSubjectId = 8184U; static constexpr UdpardPortID GetInfoServiceId = 430U; static constexpr std::size_t MaxUdpPayload = 1400U; // Cyphal/UDP binds the node-ID to the last octet of the node's source IP. @@ -53,6 +54,7 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D void InitUdpard(); void PublishHeartbeat(); + void PublishRecord(); void ProcessTransmitQueue(); void ServiceDispatcherInit(); void ServiceResponseHandler(struct UdpardRxRPCTransfer const& transfer); @@ -96,6 +98,9 @@ class CyphalApp final : public jarnax::Loopable, public jarnax::net::ethernet::D size_t stats_print_counter_; UdpardTransferID heartbeat_transfer_id_; jarnax::Ticks last_heartbeat_ticks_; + UdpardTransferID record_transfer_id_; + jarnax::Ticks last_record_ticks_; + uint32_t record_counter_; jarnax::Ticks last_getinfo_scan_ticks_; }; diff --git a/applications/nucleo-cyphal/source/CyphalApp.cpp b/applications/nucleo-cyphal/source/CyphalApp.cpp index d237cda..b0f8b8b 100644 --- a/applications/nucleo-cyphal/source/CyphalApp.cpp +++ b/applications/nucleo-cyphal/source/CyphalApp.cpp @@ -12,6 +12,7 @@ #include "stm32/h7xx/ethernet/Driver.hpp" #include "strings.hpp" +#include "uavcan/diagnostic/Record_1_0.h" #include "uavcan/node/GetInfo_1_0.h" #include "uavcan/node/Heartbeat_1_0.h" @@ -90,6 +91,14 @@ bool SameIPv4Address(HyphaIpIPv4Address_t const& a, HyphaIpIPv4Address_t const& return std::memcmp(&a, &b, sizeof(a)) == 0; } +unsigned long Snprint(char buffer[], size_t buffer_size, const char *format, ...) { + va_list args; + va_start(args, format); + unsigned long const length = core::vsnprint(buffer, buffer_size, format, args); + va_end(args); + return length; +} + } // namespace namespace nucleo { @@ -121,7 +130,9 @@ CyphalApp::CyphalApp(jarnax::Ticker& ticker, jarnax::BoardContext& board_context , initialized_{false} , filter_installed_{false} , service_initialized_{false} - , stats_print_counter_{0U} { + , stats_print_counter_{0U} + , record_transfer_id_{0U} + , record_counter_{0U} { for (auto& in_use : frame_in_use_) { in_use = false; } @@ -225,6 +236,12 @@ bool CyphalApp::Execute() { PublishHeartbeat(); } + // Publish a diagnostic Record at the same 1 s cadence so yactui shows traffic. + if ((current_ticks.value() - last_record_ticks_.value()) >= ticker_.GetTicksPerSecond().value()) { + last_record_ticks_ = current_ticks; + PublishRecord(); + } + // GetInfo client scan: query the next server node in the window each 5 seconds. if ((current_ticks.value() - last_getinfo_scan_ticks_.value()) >= 5U * ticker_.GetTicksPerSecond().value()) { last_getinfo_scan_ticks_ = current_ticks; @@ -716,6 +733,59 @@ void CyphalApp::PublishHeartbeat() { ); } +void CyphalApp::PublishRecord() { + uavcan_diagnostic_Record_1_0 record{}; + // memset-style init: the generated initialize_() inlines a deserialize_ fed a + // 1-byte buffer, which trips GCC -Warray-bounds under -Werror. All defaults are + // zero, so memset gives the identical result. + std::memset(&record, 0, sizeof(record)); + + record.timestamp.microsecond = 0U; // UNKNOWN (no time sync yet) + record.severity.value = (record_counter_ % 2U == 0U) // + ? uavcan_diagnostic_Severity_1_0_INFO + : uavcan_diagnostic_Severity_1_0_WARNING; + + char text[uavcan_diagnostic_Record_1_0_text_ARRAY_CAPACITY_]; + unsigned long const length = Snprint( + text, sizeof(text), "nucleo-cyphal diagnostic #%lu uptime=%lus", + static_cast(record_counter_), + static_cast(NowUs(ticker_) / 1000000U) + ); + std::size_t const count = length < sizeof(text) ? length : sizeof(text) - 1U; + for (std::size_t i = 0U; i < count; ++i) { + record.text.elements[i] = static_cast(text[i]); + } + record.text.count = count; + + ++record_counter_; + + uint8_t buffer[uavcan_diagnostic_Record_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_]{}; + size_t serialized_size = sizeof(buffer); + int8_t const err = uavcan_diagnostic_Record_1_0_serialize_(&record, buffer, &serialized_size); + if (err < 0) { + return; + } + + struct UdpardPayload const payload = { + .size = serialized_size, + .data = buffer, + }; + + UdpardMicrosecond const now = NowUs(ticker_); + static constexpr UdpardMicrosecond RecordPeriodUs = 1000000U; + + int32_t const result = udpardTxPublish( + &tx_, now + RecordPeriodUs, UdpardPriorityLow, DiagnosticSubjectId, + record_transfer_id_++, payload, this + ); + jarnax::print( + "CyphalApp: record published=%d subject=%lu sev=%u text=%.*s tid=%llu\r\n", + static_cast(result), static_cast(DiagnosticSubjectId), + static_cast(record.severity.value), static_cast(count), + text, static_cast(record_transfer_id_ - 1U) + ); +} + UdpardMicrosecond CyphalApp::NowUs(jarnax::Ticker const& ticker) { auto const ticks = ticker.GetTicksSinceBoot(); return static_cast(ticks.value()) * 1000LL; diff --git a/applications/nucleo-cyphal/tests/CMakeLists.txt b/applications/nucleo-cyphal/tests/CMakeLists.txt index 1b74203..83ec2e3 100644 --- a/applications/nucleo-cyphal/tests/CMakeLists.txt +++ b/applications/nucleo-cyphal/tests/CMakeLists.txt @@ -8,6 +8,16 @@ host_unit_test(NAME cyphal-heartbeat NO_BOARDS ) +host_unit_test(NAME cyphal-record + SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/catch2-cyphal-record.cpp + LIBRARIES + cyphal-dsdl + CATCH2 + NO_CONFIGURATIONS + NO_BOARDS +) + host_unit_test(NAME cyphal-getinfo-server SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/catch2-cyphal-getinfo-server.cpp diff --git a/applications/nucleo-cyphal/tests/catch2-cyphal-record.cpp b/applications/nucleo-cyphal/tests/catch2-cyphal-record.cpp new file mode 100644 index 0000000..474b28b --- /dev/null +++ b/applications/nucleo-cyphal/tests/catch2-cyphal-record.cpp @@ -0,0 +1,109 @@ +#define CATCH_CONFIG_MAIN +#include + +#include "uavcan/diagnostic/Record_1_0.h" + +#include +#include + +namespace { + +void Zero(uint8_t* const data, size_t const size) { + std::memset(data, 0, size); +} + +} // namespace + +TEST_CASE("Record serialize with empty text produces the expected wire layout", "[cyphal][record]") { + uavcan_diagnostic_Record_1_0 record{}; + uavcan_diagnostic_Record_1_0_initialize_(&record); + record.timestamp.microsecond = 0U; + record.severity.value = uavcan_diagnostic_Severity_1_0_INFO; + record.text.count = 0U; + + uint8_t buffer[uavcan_diagnostic_Record_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_]{}; + size_t size = sizeof(buffer); + REQUIRE(uavcan_diagnostic_Record_1_0_serialize_(&record, buffer, &size) == NUNAVUT_SUCCESS); + // Serialized size is trimmed to the actual payload: 7 (timestamp) + 1 (severity) + 1 (text len). + REQUIRE(size == 9U); + + // timestamp UNKNOWN=0 (56-bit LE zero), severity=INFO(2), text length 0 + uint8_t const expected[] = {0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x00U, 0x02U, 0x00U}; + REQUIRE(std::memcmp(buffer, expected, sizeof(expected)) == 0); +} + +TEST_CASE("Record serialize places severity and text after the 56-bit timestamp", "[cyphal][record]") { + uavcan_diagnostic_Record_1_0 record{}; + uavcan_diagnostic_Record_1_0_initialize_(&record); + record.timestamp.microsecond = 0x01020304050607ULL; + record.severity.value = uavcan_diagnostic_Severity_1_0_WARNING; + char const text[] = "hi"; + record.text.count = 2U; + record.text.elements[0] = static_cast(text[0]); + record.text.elements[1] = static_cast(text[1]); + + uint8_t buffer[uavcan_diagnostic_Record_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_]{}; + size_t size = sizeof(buffer); + REQUIRE(uavcan_diagnostic_Record_1_0_serialize_(&record, buffer, &size) == NUNAVUT_SUCCESS); + + // timestamp 0x01020304050607 -> LE 07 06 05 04 03 02 01, severity=WARNING(4), len=2, 'h','i' + uint8_t const expected[] = {0x07U, 0x06U, 0x05U, 0x04U, 0x03U, 0x02U, 0x01U, 0x04U, 0x02U, 'h', 'i'}; + REQUIRE(std::memcmp(buffer, expected, sizeof(expected)) == 0); +} + +TEST_CASE("Record deserialize round-trips timestamp, severity and text", "[cyphal][record]") { + uavcan_diagnostic_Record_1_0 record{}; + uavcan_diagnostic_Record_1_0_initialize_(&record); + record.timestamp.microsecond = 1234U; + record.severity.value = uavcan_diagnostic_Severity_1_0_ERROR; + char const text[] = "nucleo-cyphal"; + record.text.count = sizeof(text) - 1U; + for (size_t i = 0U; i < record.text.count; ++i) { + record.text.elements[i] = static_cast(text[i]); + } + + uint8_t buffer[uavcan_diagnostic_Record_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_]{}; + size_t size = sizeof(buffer); + REQUIRE(uavcan_diagnostic_Record_1_0_serialize_(&record, buffer, &size) == NUNAVUT_SUCCESS); + + uavcan_diagnostic_Record_1_0 deserialized{}; + Zero(reinterpret_cast(&deserialized), sizeof(deserialized)); + size_t deserialize_size = size; + REQUIRE(uavcan_diagnostic_Record_1_0_deserialize_(&deserialized, buffer, &deserialize_size) == NUNAVUT_SUCCESS); + + REQUIRE(deserialized.timestamp.microsecond == 1234U); + REQUIRE(deserialized.severity.value == uavcan_diagnostic_Severity_1_0_ERROR); + REQUIRE(deserialized.text.count == sizeof(text) - 1U); + for (size_t i = 0U; i < deserialized.text.count; ++i) { + REQUIRE(deserialized.text.elements[i] == static_cast(text[i])); + } +} + +TEST_CASE("Record text is capped at the 112-byte array capacity", "[cyphal][record]") { + uavcan_diagnostic_Record_1_0 record{}; + uavcan_diagnostic_Record_1_0_initialize_(&record); + // Saturating member of the struct; the array cannot exceed its capacity. + REQUIRE(uavcan_diagnostic_Record_1_0_text_ARRAY_CAPACITY_ == 112U); + + record.text.count = 112U; + for (size_t i = 0U; i < record.text.count; ++i) { + record.text.elements[i] = static_cast('A' + (i % 26U)); + } + + uint8_t buffer[uavcan_diagnostic_Record_1_0_SERIALIZATION_BUFFER_SIZE_BYTES_]{}; + size_t size = sizeof(buffer); + REQUIRE(uavcan_diagnostic_Record_1_0_serialize_(&record, buffer, &size) == NUNAVUT_SUCCESS); + + uavcan_diagnostic_Record_1_0 deserialized{}; + Zero(reinterpret_cast(&deserialized), sizeof(deserialized)); + size_t deserialize_size = size; + REQUIRE(uavcan_diagnostic_Record_1_0_deserialize_(&deserialized, buffer, &deserialize_size) == NUNAVUT_SUCCESS); + REQUIRE(deserialized.text.count == 112U); + REQUIRE(deserialized.text.elements[0] == static_cast('A')); + REQUIRE(deserialized.text.elements[111] == 'A' + (111U % 26U)); +} + +TEST_CASE("Record fixed subject ID matches the registry (8184)", "[cyphal][record]") { + REQUIRE(uavcan_diagnostic_Record_1_0_HAS_FIXED_PORT_ID_ == true); + REQUIRE(uavcan_diagnostic_Record_1_0_FIXED_PORT_ID_ == 8184U); +} \ No newline at end of file