From 27d4f44d979aa00ba3ab8a9afbb50d4228d6bbcf Mon Sep 17 00:00:00 2001 From: Filippo Olimpieri Date: Tue, 9 Jun 2026 22:17:34 -0400 Subject: [PATCH 1/2] json_encoder: adopt the aerial camelCase wire dialect (port from spectranet-demo) libe3's JSON encoder spoke snake_case keys with hex-string payloads (dapp_identifier, protocol_data, ...), while the aerial dApp ecosystem (NVIDIA aerial sample-apps and the spectranet dApps) speaks camelCase with nested-JSON payloads (ranFunctionIdentifier, protocolData: {...}). A JSON RAN agent built from main therefore could not interoperate with those dApps. Port the JSON encoder end-state from the spectranet-demo branch (686ba11, 4ec3cb0, ea62ba9, af826b4, fc6284b, 5dbacba squashed; no runtime dual-encoding, no core changes): - camelCase keys + camelCase PDU type strings (PascalCase rejected) - flat envelope: payload fields at root next to type/id/timestamp; messages with a "data" wrapper are rejected - JSON-object payloads carried inline; binary payloads as hex with a sentinel; plain hex-string actionData accepted (aerial 19f9bd3 sends that shape) - string_to_pdu_type returns std::optional; unknown PDU types fail decode instead of defaulting types.hpp gains the optional wire fields the dialect carries: SetupResponse.message, SubscriptionResponse{ran_function_identifier, telemetry_granted_list, control_granted_list, periodicity_us, message}, IndicationMessage.subscription_id. All are optional/empty by default, not populated by the core, and skipped on the wire when absent (the aerial dApp reads them conditionally). Core construction sites all use member assignment, so the layout change is source-compatible. test_json_encoder.cpp ports the dialect's test suite (21 cases); test_types.cpp aligns the encoder-default assertion with types.hpp's ASN1-first precedence so the suite holds when both encoders are compiled in. Verified standalone: json_encoder TU compiles against these headers (-std=c++17) and the 21-test suite passes; E3Config_defaults passes with both LIBE3_ENABLE_ASN1 and LIBE3_ENABLE_JSON defined. --- include/libe3/types.hpp | 29 ++- src/encoder/json_encoder.cpp | 438 ++++++++++++++++++++++++----------- src/encoder/json_encoder.hpp | 9 +- tests/test_json_encoder.cpp | 179 ++++++++++++-- 4 files changed, 500 insertions(+), 155 deletions(-) diff --git a/include/libe3/types.hpp b/include/libe3/types.hpp index 4b81769..222eaeb 100644 --- a/include/libe3/types.hpp +++ b/include/libe3/types.hpp @@ -204,6 +204,7 @@ struct SetupResponse { std::optional dapp_identifier; ///< Assigned dApp identifier (optional) std::string ran_identifier; ///< RAN identifier (mandatory) std::vector ran_function_list; ///< List of available RAN functions (optional) + std::optional message; ///< Diagnostic message (typically on negative response) }; /** @@ -228,20 +229,38 @@ struct SubscriptionDelete { /** * @brief E3AP Subscription Response structure + * + * Mirrors the fields cuBB's E3 Agent emits: the granted telemetry/control + * IDs, the agent-committed periodicity (may cap what the dApp requested), + * and a diagnostic message on negatives. The aerial dApps read + * telemetryGrantedList and periodicity, when present, to confirm/update + * their subscription state; all of these are optional on the wire, so + * producers that don't populate them stay compatible. */ struct SubscriptionResponse { - uint32_t request_id{0}; ///< ID of the corresponding SubscriptionRequest - uint32_t dapp_identifier{0}; ///< dApp identifier - ResponseCode response_code{ResponseCode::NEGATIVE}; ///< Response code (positive/negative) - std::optional subscription_id; ///< Subscription ID (optional) + uint32_t request_id{0}; ///< ID of the corresponding SubscriptionRequest + uint32_t dapp_identifier{0}; ///< dApp identifier + ResponseCode response_code{ResponseCode::NEGATIVE}; ///< Response code (positive/negative) + std::optional subscription_id; ///< Subscription ID (optional) + std::optional ran_function_identifier; ///< RAN function the subscription targets + std::vector telemetry_granted_list; ///< Telemetry IDs the agent committed to + std::vector control_granted_list; ///< Control IDs the agent committed to + std::optional periodicity_us; ///< Agent-committed periodicity (microseconds) + std::optional message; ///< Diagnostic message (typically on negative) }; /** * @brief E3AP Indication Message structure + * + * `subscription_id` lets receivers correlate indications back to the + * subscription they originated from; cuBB sets it on every outbound + * indication. Optional for backward compat with producers that don't + * populate it. */ struct IndicationMessage { uint32_t dapp_identifier{0}; - uint32_t ran_function_identifier{0}; ///< RAN function identifier + uint32_t ran_function_identifier{0}; ///< RAN function identifier + std::optional subscription_id; ///< Subscription this indication belongs to std::vector protocol_data; }; diff --git a/src/encoder/json_encoder.cpp b/src/encoder/json_encoder.cpp index 96d9c17..ec482f0 100644 --- a/src/encoder/json_encoder.cpp +++ b/src/encoder/json_encoder.cpp @@ -7,6 +7,7 @@ #include "json_encoder.hpp" #include "libe3/logger.hpp" +#include #include #include @@ -16,6 +17,107 @@ namespace { constexpr const char* LOG_TAG = "JsonEnc"; +std::string to_camel_case(const char* pascal) { + std::string s(pascal); + if (!s.empty()) s[0] = static_cast(std::tolower(static_cast(s[0]))); + return s; +} + +// ---------------------------------------------------------------------------- +// Structured-JSON payload helpers — liberal-receive, conservative-emit. +// +// libe3 is the only library that both encodes AND decodes every PDU type, so +// it acts as the authoritative reference for the wire format. To stay +// compatible with every peer in the wild we follow Postel's law: +// +// ON EMIT (bytes_to_json_payload): pick ONE canonical form per case. +// - bytes that parse as a JSON object or array -> embed inline (cuBB's +// convention, e3_agent.cpp:256+). +// - everything else -> wrap in {"__hex__": ""} sentinel. +// +// ON DECODE (json_payload_to_bytes): accept FOUR shapes: +// 1. inline JSON object / array (cuBB, libe3 self-roundtrip, spear-dApp) +// 2. {"__hex__": ""} sentinel (libe3's opaque-bytes form) +// 3. plain hex string (NVIDIA aerial, commit 19f9bd3: +// SendDAppControl hex-encodes a JSON +// payload and assigns it as a string) +// 4. plain UTF-8 string (final fallback so an opaque text +// payload still survives roundtrip) +// +// New peers should converge on shapes (1) or (2). Shapes (3)/(4) exist +// strictly for inbound compatibility; libe3 never emits them. +// ---------------------------------------------------------------------------- + +inline std::string bytes_to_hex_string(const std::vector& bytes) { + static const char* d = "0123456789abcdef"; + std::string out; + out.reserve(bytes.size() * 2); + for (uint8_t b : bytes) { + out.push_back(d[(b >> 4) & 0xF]); + out.push_back(d[b & 0xF]); + } + return out; +} + +inline std::vector hex_string_to_bytes(const std::string& hex) { + std::vector out; + out.reserve(hex.size() / 2); + for (size_t i = 0; i + 1 < hex.size(); i += 2) { + out.push_back(static_cast(std::stoi(hex.substr(i, 2), nullptr, 16))); + } + return out; +} + +nlohmann::json bytes_to_json_payload(const std::vector& bytes) { + if (bytes.empty()) { + return nlohmann::json::object(); + } + try { + auto parsed = nlohmann::json::parse(bytes.begin(), bytes.end()); + // Only structured (object/array) payloads are accepted as inline JSON; + // scalar literals that happen to parse (e.g. "42", "true") would lose + // precision on re-dump, so we route them through the hex sentinel. + if (parsed.is_object() || parsed.is_array()) { + return parsed; + } + } catch (const nlohmann::json::parse_error&) { + // Fall through to hex sentinel. + } + return nlohmann::json{{"__hex__", bytes_to_hex_string(bytes)}}; +} + +// True iff `s` is a non-empty even-length sequence of ASCII hex digits. +// Used to detect aerial-style plain hex-string payloads (shape 3 above). +inline bool is_pure_hex_string(const std::string& s) { + if (s.empty() || (s.size() % 2) != 0) return false; + for (char c : s) { + if (!std::isxdigit(static_cast(c))) return false; + } + return true; +} + +std::vector json_payload_to_bytes(const nlohmann::json& j) { + // Shape 2: explicit hex-sentinel object. + if (j.is_object() && j.contains("__hex__") && j["__hex__"].is_string()) { + return hex_string_to_bytes(j["__hex__"].get_ref()); + } + // Shapes 3 & 4: bare string payload. Aerial's SendDAppControl + // (spear-aerial-sample-apps@19f9bd3, e3_manager.cpp:1022+) hex-encodes + // the inner JSON config and ships it as a JSON string. Detect that and + // hex-decode; otherwise treat the string as opaque UTF-8 bytes. + if (j.is_string()) { + const std::string& s = j.get_ref(); + if (is_pure_hex_string(s)) { + return hex_string_to_bytes(s); + } + return std::vector(s.begin(), s.end()); + } + // Shape 1: inline JSON object/array — round-trip the structured payload + // back to compact UTF-8 text for the consumer SM to parse. + std::string dumped = j.dump(); + return std::vector(dumped.begin(), dumped.end()); +} + } // anonymous namespace @@ -23,19 +125,19 @@ constexpr const char* LOG_TAG = "JsonEnc"; // Helper methods for type conversions // ============================================================================ -PduType JsonE3Encoder::string_to_pdu_type(const std::string& s) const { - if (s == "SetupRequest") return PduType::SETUP_REQUEST; - if (s == "SetupResponse") return PduType::SETUP_RESPONSE; - if (s == "SubscriptionRequest") return PduType::SUBSCRIPTION_REQUEST; - if (s == "SubscriptionDelete") return PduType::SUBSCRIPTION_DELETE; - if (s == "SubscriptionResponse") return PduType::SUBSCRIPTION_RESPONSE; - if (s == "IndicationMessage") return PduType::INDICATION_MESSAGE; - if (s == "DAppControlAction") return PduType::DAPP_CONTROL_ACTION; - if (s == "DAppReport") return PduType::DAPP_REPORT; - if (s == "XAppControlAction") return PduType::XAPP_CONTROL_ACTION; - if (s == "ReleaseMessage") return PduType::RELEASE_MESSAGE; - if (s == "MessageAck") return PduType::MESSAGE_ACK; - return PduType::SETUP_REQUEST; // Default +std::optional JsonE3Encoder::string_to_pdu_type(const std::string& s) const { + if (s == "setupRequest") return PduType::SETUP_REQUEST; + if (s == "setupResponse") return PduType::SETUP_RESPONSE; + if (s == "subscriptionRequest") return PduType::SUBSCRIPTION_REQUEST; + if (s == "subscriptionDelete") return PduType::SUBSCRIPTION_DELETE; + if (s == "subscriptionResponse") return PduType::SUBSCRIPTION_RESPONSE; + if (s == "indicationMessage") return PduType::INDICATION_MESSAGE; + if (s == "dAppControlAction") return PduType::DAPP_CONTROL_ACTION; + if (s == "dAppReport") return PduType::DAPP_REPORT; + if (s == "xAppControlAction") return PduType::XAPP_CONTROL_ACTION; + if (s == "releaseMessage") return PduType::RELEASE_MESSAGE; + if (s == "messageAck") return PduType::MESSAGE_ACK; + return std::nullopt; } ErrorCode JsonE3Encoder::string_to_error_code(const std::string& s) const { @@ -74,9 +176,9 @@ std::vector JsonE3Encoder::hex_to_binary(const std::string& hex) { nlohmann::json JsonE3Encoder::encode_setup_request(const SetupRequest& req) const { nlohmann::json j; - j["e3ap_protocol_version"] = req.e3ap_protocol_version; - j["dapp_name"] = req.dapp_name; - j["dapp_version"] = req.dapp_version; + j["e3apProtocolVersion"] = req.e3ap_protocol_version; + j["dAppName"] = req.dapp_name; + j["dAppVersion"] = req.dapp_version; j["vendor"] = req.vendor; return j; @@ -84,97 +186,127 @@ nlohmann::json JsonE3Encoder::encode_setup_request(const SetupRequest& req) cons nlohmann::json JsonE3Encoder::encode_setup_response(const SetupResponse& resp) const { nlohmann::json j; - j["request_id"] = resp.request_id; - j["response_code"] = (resp.response_code == ResponseCode::POSITIVE) ? "positive" : "negative"; + j["requestId"] = resp.request_id; + j["responseCode"] = (resp.response_code == ResponseCode::POSITIVE) ? "positive" : "negative"; if (resp.e3ap_protocol_version.has_value()) { - j["e3ap_protocol_version"] = resp.e3ap_protocol_version.value(); + j["e3apProtocolVersion"] = resp.e3ap_protocol_version.value(); } if (resp.dapp_identifier.has_value()) { - j["dapp_identifier"] = resp.dapp_identifier.value(); + j["dAppIdentifier"] = resp.dapp_identifier.value(); } - j["ran_identifier"] = resp.ran_identifier; + j["ranIdentifier"] = resp.ran_identifier; if (!resp.ran_function_list.empty()) { nlohmann::json ran_funcs = nlohmann::json::array(); for (const auto& func : resp.ran_function_list) { nlohmann::json func_obj; - func_obj["ran_function_identifier"] = func.ran_function_identifier; - func_obj["telemetry_identifier_list"] = func.telemetry_identifier_list; - func_obj["control_identifier_list"] = func.control_identifier_list; - func_obj["ran_function_data"] = binary_to_hex(func.ran_function_data); + func_obj["ranFunctionIdentifier"] = func.ran_function_identifier; + func_obj["telemetryIdentifierList"] = func.telemetry_identifier_list; + func_obj["controlIdentifierList"] = func.control_identifier_list; + // cuBB emits ranFunctionData as a structured array of stream + // descriptors (e3_agent.cpp:938-944), not as a hex blob. + func_obj["ranFunctionData"] = bytes_to_json_payload(func.ran_function_data); ran_funcs.push_back(func_obj); } - j["ran_function_list"] = ran_funcs; + j["ranFunctionList"] = ran_funcs; + } + if (resp.message.has_value()) { + j["message"] = resp.message.value(); } return j; } nlohmann::json JsonE3Encoder::encode_subscription_request(const SubscriptionRequest& req) const { nlohmann::json j; - j["dapp_identifier"] = req.dapp_identifier; - j["ran_function_identifier"] = req.ran_function_identifier; - j["telemetry_identifier_list"] = req.telemetry_identifier_list; - j["control_identifier_list"] = req.control_identifier_list; + j["dAppIdentifier"] = req.dapp_identifier; + j["ranFunctionIdentifier"] = req.ran_function_identifier; + j["telemetryIdentifierList"] = req.telemetry_identifier_list; + j["controlIdentifierList"] = req.control_identifier_list; if (req.subscription_time.has_value()) { - j["subscription_time"] = req.subscription_time.value(); + j["subscriptionTime"] = req.subscription_time.value(); + } + if (req.periodicity.has_value()) { + j["periodicity"] = req.periodicity.value(); } return j; } nlohmann::json JsonE3Encoder::encode_subscription_delete(const SubscriptionDelete& del) const { nlohmann::json j; - j["dapp_identifier"] = del.dapp_identifier; - j["subscription_id"] = del.subscription_id; + j["dAppIdentifier"] = del.dapp_identifier; + j["subscriptionId"] = del.subscription_id; return j; } nlohmann::json JsonE3Encoder::encode_subscription_response(const SubscriptionResponse& resp) const { nlohmann::json j; - j["request_id"] = resp.request_id; - j["dapp_identifier"] = resp.dapp_identifier; - j["response_code"] = (resp.response_code == ResponseCode::POSITIVE) ? "positive" : "negative"; + j["requestId"] = resp.request_id; + j["dAppIdentifier"] = resp.dapp_identifier; + j["responseCode"] = (resp.response_code == ResponseCode::POSITIVE) ? "positive" : "negative"; if (resp.subscription_id.has_value()) { - j["subscription_id"] = resp.subscription_id.value(); + j["subscriptionId"] = resp.subscription_id.value(); + } + if (resp.ran_function_identifier.has_value()) { + j["ranFunctionIdentifier"] = resp.ran_function_identifier.value(); + } + if (!resp.telemetry_granted_list.empty()) { + j["telemetryGrantedList"] = resp.telemetry_granted_list; + } + if (!resp.control_granted_list.empty()) { + j["controlGrantedList"] = resp.control_granted_list; + } + if (resp.periodicity_us.has_value()) { + j["periodicity"] = resp.periodicity_us.value(); + } + if (resp.message.has_value()) { + j["message"] = resp.message.value(); } return j; } nlohmann::json JsonE3Encoder::encode_indication_message(const IndicationMessage& msg) const { nlohmann::json j; - j["dapp_identifier"] = msg.dapp_identifier; - j["ran_function_identifier"] = msg.ran_function_identifier; - j["protocol_data"] = binary_to_hex(msg.protocol_data); + j["dAppIdentifier"] = msg.dapp_identifier; + j["ranFunctionIdentifier"] = msg.ran_function_identifier; + if (msg.subscription_id.has_value()) { + j["subscriptionId"] = msg.subscription_id.value(); + } + // protocolData on the wire is inline structured JSON (cuBB e3_agent.cpp:269+). + // The hex sentinel falls back gracefully if an SM hasn't migrated yet. + j["protocolData"] = bytes_to_json_payload(msg.protocol_data); return j; } nlohmann::json JsonE3Encoder::encode_dapp_control_action(const DAppControlAction& action) const { nlohmann::json j; - j["dapp_identifier"] = action.dapp_identifier; - j["ran_function_identifier"] = action.ran_function_identifier; - j["control_identifier"] = action.control_identifier; - j["action_data"] = binary_to_hex(action.action_data); + j["dAppIdentifier"] = action.dapp_identifier; + j["ranFunctionIdentifier"] = action.ran_function_identifier; + j["controlIdentifier"] = action.control_identifier; + // Aerial sends actionData as a structured JSON object via SendDAppControl + // (subcarrier_power_app.cpp:340-344). Match that on the wire. + j["actionData"] = bytes_to_json_payload(action.action_data); return j; } nlohmann::json JsonE3Encoder::encode_dapp_report(const DAppReport& report) const { nlohmann::json j; - j["dapp_identifier"] = report.dapp_identifier; - j["ran_function_identifier"] = report.ran_function_identifier; - j["report_data"] = binary_to_hex(report.report_data); + j["dAppIdentifier"] = report.dapp_identifier; + j["ranFunctionIdentifier"] = report.ran_function_identifier; + j["reportData"] = bytes_to_json_payload(report.report_data); return j; } nlohmann::json JsonE3Encoder::encode_xapp_control_action(const XAppControlAction& action) const { nlohmann::json j; - j["dapp_identifier"] = action.dapp_identifier; - j["ran_function_identifier"] = action.ran_function_identifier; - j["xapp_control_data"] = binary_to_hex(action.xapp_control_data); + j["dAppIdentifier"] = action.dapp_identifier; + j["ranFunctionIdentifier"] = action.ran_function_identifier; + j["xAppControlData"] = bytes_to_json_payload(action.xapp_control_data); return j; } nlohmann::json JsonE3Encoder::encode_message_ack(const MessageAck& ack) const { nlohmann::json j; - j["request_id"] = ack.request_id; - j["response_code"] = (ack.response_code == ResponseCode::POSITIVE) ? "positive" : "negative"; + j["requestId"] = ack.request_id; + j["responseCode"] = (ack.response_code == ResponseCode::POSITIVE) ? "positive" : "negative"; return j; } @@ -184,9 +316,9 @@ nlohmann::json JsonE3Encoder::encode_message_ack(const MessageAck& ack) const { SetupRequest JsonE3Encoder::decode_setup_request(const nlohmann::json& j) const { SetupRequest req; - req.e3ap_protocol_version = j.value("e3ap_protocol_version", ""); - req.dapp_name = j.value("dapp_name", ""); - req.dapp_version = j.value("dapp_version", ""); + req.e3ap_protocol_version = j.value("e3apProtocolVersion", ""); + req.dapp_name = j.value("dAppName", ""); + req.dapp_version = j.value("dAppVersion", ""); req.vendor = j.value("vendor", ""); return req; @@ -194,99 +326,133 @@ SetupRequest JsonE3Encoder::decode_setup_request(const nlohmann::json& j) const SetupResponse JsonE3Encoder::decode_setup_response(const nlohmann::json& j) const { SetupResponse resp; - resp.request_id = j.value("request_id", 0u); + resp.request_id = j.value("requestId", 0u); - std::string response_code_str = j.value("response_code", "negative"); + std::string response_code_str = j.value("responseCode", "negative"); resp.response_code = (response_code_str == "positive") ? ResponseCode::POSITIVE : ResponseCode::NEGATIVE; - if (j.contains("e3ap_protocol_version")) { - resp.e3ap_protocol_version = j["e3ap_protocol_version"].get(); + if (j.contains("e3apProtocolVersion")) { + resp.e3ap_protocol_version = j["e3apProtocolVersion"].get(); } - if (j.contains("dapp_identifier")) { - resp.dapp_identifier = j["dapp_identifier"].get(); + if (j.contains("dAppIdentifier")) { + resp.dapp_identifier = j["dAppIdentifier"].get(); } - resp.ran_identifier = j.value("ran_identifier", ""); - if (j.contains("ran_function_list")) { - for (const auto& func_obj : j["ran_function_list"]) { + resp.ran_identifier = j.value("ranIdentifier", ""); + if (j.contains("ranFunctionList")) { + for (const auto& func_obj : j["ranFunctionList"]) { RanFunctionDef func; - func.ran_function_identifier = func_obj.value("ran_function_identifier", 0u); - func.telemetry_identifier_list = func_obj.value("telemetry_identifier_list", std::vector{}); - func.control_identifier_list = func_obj.value("control_identifier_list", std::vector{}); - func.ran_function_data = hex_to_binary(func_obj.value("ran_function_data", "")); + func.ran_function_identifier = func_obj.value("ranFunctionIdentifier", 0u); + func.telemetry_identifier_list = func_obj.value("telemetryIdentifierList", std::vector{}); + func.control_identifier_list = func_obj.value("controlIdentifierList", std::vector{}); + if (func_obj.contains("ranFunctionData")) { + func.ran_function_data = json_payload_to_bytes(func_obj["ranFunctionData"]); + } resp.ran_function_list.push_back(func); } } + if (j.contains("message") && j["message"].is_string()) { + resp.message = j["message"].get(); + } return resp; } SubscriptionRequest JsonE3Encoder::decode_subscription_request(const nlohmann::json& j) const { SubscriptionRequest req; - req.dapp_identifier = j.value("dapp_identifier", 0u); - req.ran_function_identifier = j.value("ran_function_identifier", 0u); - req.telemetry_identifier_list = j.value("telemetry_identifier_list", std::vector{}); - req.control_identifier_list = j.value("control_identifier_list", std::vector{}); - if (j.contains("subscription_time")) { - req.subscription_time = j["subscription_time"].get(); + req.dapp_identifier = j.value("dAppIdentifier", 0u); + req.ran_function_identifier = j.value("ranFunctionIdentifier", 0u); + req.telemetry_identifier_list = j.value("telemetryIdentifierList", std::vector{}); + req.control_identifier_list = j.value("controlIdentifierList", std::vector{}); + if (j.contains("subscriptionTime")) { + req.subscription_time = j["subscriptionTime"].get(); + } + if (j.contains("periodicity")) { + req.periodicity = j["periodicity"].get(); } return req; } SubscriptionDelete JsonE3Encoder::decode_subscription_delete(const nlohmann::json& j) const { SubscriptionDelete del; - del.dapp_identifier = j.value("dapp_identifier", 0u); - del.subscription_id = j.value("subscription_id", 0u); + del.dapp_identifier = j.value("dAppIdentifier", 0u); + del.subscription_id = j.value("subscriptionId", 0u); return del; } SubscriptionResponse JsonE3Encoder::decode_subscription_response(const nlohmann::json& j) const { SubscriptionResponse resp; - resp.request_id = j.value("request_id", 0u); - resp.dapp_identifier = j.value("dapp_identifier", 0u); - std::string response_code_str = j.value("response_code", "negative"); + resp.request_id = j.value("requestId", 0u); + resp.dapp_identifier = j.value("dAppIdentifier", 0u); + std::string response_code_str = j.value("responseCode", "negative"); resp.response_code = (response_code_str == "positive") ? ResponseCode::POSITIVE : ResponseCode::NEGATIVE; - if (j.contains("subscription_id")) { - resp.subscription_id = j["subscription_id"].get(); + if (j.contains("subscriptionId")) { + resp.subscription_id = j["subscriptionId"].get(); + } + if (j.contains("ranFunctionIdentifier")) { + resp.ran_function_identifier = j["ranFunctionIdentifier"].get(); + } + if (j.contains("telemetryGrantedList") && j["telemetryGrantedList"].is_array()) { + resp.telemetry_granted_list = j["telemetryGrantedList"].get>(); + } + if (j.contains("controlGrantedList") && j["controlGrantedList"].is_array()) { + resp.control_granted_list = j["controlGrantedList"].get>(); + } + if (j.contains("periodicity")) { + resp.periodicity_us = j["periodicity"].get(); + } + if (j.contains("message") && j["message"].is_string()) { + resp.message = j["message"].get(); } return resp; } IndicationMessage JsonE3Encoder::decode_indication_message(const nlohmann::json& j) const { IndicationMessage msg; - msg.dapp_identifier = j.value("dapp_identifier", 0u); - msg.ran_function_identifier = j.value("ran_function_identifier", 0u); - msg.protocol_data = hex_to_binary(j.value("protocol_data", "")); + msg.dapp_identifier = j.value("dAppIdentifier", 0u); + msg.ran_function_identifier = j.value("ranFunctionIdentifier", 0u); + if (j.contains("subscriptionId")) { + msg.subscription_id = j["subscriptionId"].get(); + } + if (j.contains("protocolData")) { + msg.protocol_data = json_payload_to_bytes(j["protocolData"]); + } return msg; } DAppControlAction JsonE3Encoder::decode_dapp_control_action(const nlohmann::json& j) const { DAppControlAction action; - action.dapp_identifier = j.value("dapp_identifier", 0u); - action.ran_function_identifier = j.value("ran_function_identifier", 0u); - action.control_identifier = j.value("control_identifier", 0u); - action.action_data = hex_to_binary(j.value("action_data", "")); + action.dapp_identifier = j.value("dAppIdentifier", 0u); + action.ran_function_identifier = j.value("ranFunctionIdentifier", 0u); + action.control_identifier = j.value("controlIdentifier", 0u); + if (j.contains("actionData")) { + action.action_data = json_payload_to_bytes(j["actionData"]); + } return action; } DAppReport JsonE3Encoder::decode_dapp_report(const nlohmann::json& j) const { DAppReport report; - report.dapp_identifier = j.value("dapp_identifier", 0u); - report.ran_function_identifier = j.value("ran_function_identifier", 0u); - report.report_data = hex_to_binary(j.value("report_data", "")); + report.dapp_identifier = j.value("dAppIdentifier", 0u); + report.ran_function_identifier = j.value("ranFunctionIdentifier", 0u); + if (j.contains("reportData")) { + report.report_data = json_payload_to_bytes(j["reportData"]); + } return report; } XAppControlAction JsonE3Encoder::decode_xapp_control_action(const nlohmann::json& j) const { XAppControlAction action; - action.dapp_identifier = j.value("dapp_identifier", 0u); - action.ran_function_identifier = j.value("ran_function_identifier", 0u); - action.xapp_control_data = hex_to_binary(j.value("xapp_control_data", "")); + action.dapp_identifier = j.value("dAppIdentifier", 0u); + action.ran_function_identifier = j.value("ranFunctionIdentifier", 0u); + if (j.contains("xAppControlData")) { + action.xapp_control_data = json_payload_to_bytes(j["xAppControlData"]); + } return action; } MessageAck JsonE3Encoder::decode_message_ack(const nlohmann::json& j) const { MessageAck ack; - ack.request_id = j.value("request_id", 0u); - std::string response_code_str = j.value("response_code", "negative"); + ack.request_id = j.value("requestId", 0u); + std::string response_code_str = j.value("responseCode", "negative"); ack.response_code = (response_code_str == "positive") ? ResponseCode::POSITIVE : ResponseCode::NEGATIVE; return ack; } @@ -294,13 +460,13 @@ MessageAck JsonE3Encoder::decode_message_ack(const nlohmann::json& j) const { // ReleaseMessage encode/decode nlohmann::json JsonE3Encoder::encode_release_message(const ReleaseMessage& msg) const { nlohmann::json j; - j["dapp_identifier"] = msg.dapp_identifier; + j["dAppIdentifier"] = msg.dapp_identifier; return j; } ReleaseMessage JsonE3Encoder::decode_release_message(const nlohmann::json& j) const { ReleaseMessage msg; - msg.dapp_identifier = j.value("dapp_identifier", 0u); + msg.dapp_identifier = j.value("dAppIdentifier", 0u); return msg; } @@ -311,52 +477,51 @@ ReleaseMessage JsonE3Encoder::decode_release_message(const nlohmann::json& j) co EncodeResult JsonE3Encoder::encode(const Pdu& pdu) { try { nlohmann::json root; - root["pdu_type"] = pdu_type_to_string(pdu.type); - root["message_id"] = pdu.message_id; + root["type"] = to_camel_case(pdu_type_to_string(pdu.type)); + root["id"] = pdu.message_id; root["timestamp"] = pdu.timestamp; - // Encode the data based on PDU type - nlohmann::json data; - std::visit([this, &data](auto&& arg) { + // Encode payload fields directly into root (flat format) + std::visit([this, &root](auto&& arg) { using T = std::decay_t; + nlohmann::json fields; if constexpr (std::is_same_v) { - data = encode_setup_request(arg); + fields = encode_setup_request(arg); } else if constexpr (std::is_same_v) { - data = encode_setup_response(arg); + fields = encode_setup_response(arg); } else if constexpr (std::is_same_v) { - data = encode_subscription_request(arg); + fields = encode_subscription_request(arg); } else if constexpr (std::is_same_v) { - data = encode_subscription_delete(arg); + fields = encode_subscription_delete(arg); } else if constexpr (std::is_same_v) { - data = encode_subscription_response(arg); + fields = encode_subscription_response(arg); } else if constexpr (std::is_same_v) { - data = encode_indication_message(arg); + fields = encode_indication_message(arg); } else if constexpr (std::is_same_v) { - data = encode_dapp_control_action(arg); + fields = encode_dapp_control_action(arg); } else if constexpr (std::is_same_v) { - data = encode_dapp_report(arg); + fields = encode_dapp_report(arg); } else if constexpr (std::is_same_v) { - data = encode_xapp_control_action(arg); + fields = encode_xapp_control_action(arg); } else if constexpr (std::is_same_v) { - data = encode_release_message(arg); + fields = encode_release_message(arg); } else if constexpr (std::is_same_v) { - data = encode_message_ack(arg); + fields = encode_message_ack(arg); } + root.update(fields); }, pdu.choice); - root["data"] = data; - std::string json_str = root.dump(); EncodedMessage msg; msg.buffer.assign(json_str.begin(), json_str.end()); @@ -392,54 +557,55 @@ EncodeResult JsonE3Encoder::decode(const uint8_t* data, size_t size) { Pdu pdu; - // Get PDU type - std::string pdu_type_str = root.value("pdu_type", ""); - pdu.type = string_to_pdu_type(pdu_type_str); - pdu.message_id = root.value("message_id", 0u); + std::string pdu_type_str = root.value("type", ""); + auto pdu_type = string_to_pdu_type(pdu_type_str); + if (!pdu_type) { + E3_LOG_ERROR(LOG_TAG) << "Unrecognized PDU type: " << pdu_type_str; + return tl::unexpected(ErrorCode::DECODE_FAILED); + } + pdu.type = *pdu_type; + pdu.message_id = root.value("id", 0u); pdu.timestamp = root.value("timestamp", 0ull); - // Get data object - if (!root.contains("data")) { - E3_LOG_ERROR(LOG_TAG) << "Missing 'data' field in JSON"; + if (root.contains("data")) { + E3_LOG_ERROR(LOG_TAG) << "Nested \"data\" wrapper is not supported; use flat format"; return tl::unexpected(ErrorCode::DECODE_FAILED); } - const nlohmann::json& j = root["data"]; - // Decode based on PDU type switch (pdu.type) { case PduType::SETUP_REQUEST: - pdu.choice = decode_setup_request(j); + pdu.choice = decode_setup_request(root); break; case PduType::SETUP_RESPONSE: - pdu.choice = decode_setup_response(j); + pdu.choice = decode_setup_response(root); break; case PduType::SUBSCRIPTION_REQUEST: - pdu.choice = decode_subscription_request(j); + pdu.choice = decode_subscription_request(root); break; case PduType::SUBSCRIPTION_DELETE: - pdu.choice = decode_subscription_delete(j); + pdu.choice = decode_subscription_delete(root); break; case PduType::SUBSCRIPTION_RESPONSE: - pdu.choice = decode_subscription_response(j); + pdu.choice = decode_subscription_response(root); break; case PduType::INDICATION_MESSAGE: - pdu.choice = decode_indication_message(j); + pdu.choice = decode_indication_message(root); break; case PduType::DAPP_CONTROL_ACTION: - pdu.choice = decode_dapp_control_action(j); + pdu.choice = decode_dapp_control_action(root); break; case PduType::DAPP_REPORT: - pdu.choice = decode_dapp_report(j); + pdu.choice = decode_dapp_report(root); break; case PduType::XAPP_CONTROL_ACTION: - pdu.choice = decode_xapp_control_action(j); + pdu.choice = decode_xapp_control_action(root); break; case PduType::RELEASE_MESSAGE: - pdu.choice = decode_release_message(j); + pdu.choice = decode_release_message(root); break; case PduType::MESSAGE_ACK: - pdu.choice = decode_message_ack(j); + pdu.choice = decode_message_ack(root); break; default: E3_LOG_ERROR(LOG_TAG) << "Unknown PDU type: " << pdu_type_str; diff --git a/src/encoder/json_encoder.hpp b/src/encoder/json_encoder.hpp index 0b3233a..73a12a8 100644 --- a/src/encoder/json_encoder.hpp +++ b/src/encoder/json_encoder.hpp @@ -17,6 +17,13 @@ namespace libe3 { * @brief JSON encoder for E3AP PDUs * * Provides JSON encoding/decoding for development and debugging. + * + * JSON wire format uses camelCase keys (e.g. "dAppName", "ranFunctionIdentifier") + * and camelCase PDU type strings (e.g. "setupRequest", "indicationMessage"). + * PascalCase PDU types are rejected by the decoder. + * + * Envelope format is flat: payload fields sit alongside "type", "id", and + * "timestamp" at root level. Messages with a "data" wrapper are rejected. */ class JsonE3Encoder : public E3Encoder { public: @@ -60,7 +67,7 @@ class JsonE3Encoder : public E3Encoder { static std::vector hex_to_binary(const std::string& hex); // Helper methods for type conversions - PduType string_to_pdu_type(const std::string& s) const; + std::optional string_to_pdu_type(const std::string& s) const; ErrorCode string_to_error_code(const std::string& s) const; }; diff --git a/tests/test_json_encoder.cpp b/tests/test_json_encoder.cpp index c657480..a83cbc9 100644 --- a/tests/test_json_encoder.cpp +++ b/tests/test_json_encoder.cpp @@ -8,6 +8,7 @@ #include "test_framework.hpp" #include "libe3/e3_encoder.hpp" #include "libe3/types.hpp" +#include using namespace libe3; @@ -34,7 +35,7 @@ TEST(JsonEncoder_encode_setup_request) { // Verify JSON contains expected fields std::string json(encoded->buffer.begin(), encoded->buffer.end()); - ASSERT_TRUE(json.find("SetupRequest") != std::string::npos); + ASSERT_TRUE(json.find("setupRequest") != std::string::npos); ASSERT_TRUE(json.find("TestDApp") != std::string::npos); ASSERT_TRUE(json.find("TestVendor") != std::string::npos); } @@ -78,7 +79,7 @@ TEST(JsonEncoder_encode_setup_response) { ASSERT_TRUE(encoded.has_value()); std::string json(encoded->buffer.begin(), encoded->buffer.end()); - ASSERT_TRUE(json.find("SetupResponse") != std::string::npos); + ASSERT_TRUE(json.find("setupResponse") != std::string::npos); } TEST(JsonEncoder_encode_decode_subscription_request) { @@ -150,7 +151,8 @@ TEST(JsonEncoder_encode_decode_indication_message) { IndicationMessage msg; msg.dapp_identifier = 77; msg.ran_function_identifier = 55; - msg.protocol_data = {0x01, 0x02, 0x03, 0x04, 0xAB, 0xCD}; + std::string payload = R"({"rnti":42069,"mcs":9,"snr":12.5})"; + msg.protocol_data.assign(payload.begin(), payload.end()); original.choice = msg; auto encoded = encoder->encode(original); @@ -162,8 +164,9 @@ TEST(JsonEncoder_encode_decode_indication_message) { auto& restored = std::get((*decoded).choice); ASSERT_EQ(restored.dapp_identifier, 77u); ASSERT_EQ(restored.ran_function_identifier, 55u); - ASSERT_EQ(restored.protocol_data.size(), 6u); - ASSERT_EQ(restored.protocol_data[4], 0xAB); + auto restored_json = nlohmann::json::parse(restored.protocol_data); + ASSERT_EQ(restored_json["rnti"].get(), 42069); + ASSERT_EQ(restored_json["mcs"].get(), 9); } TEST(JsonEncoder_encode_decode_control_action) { @@ -260,11 +263,12 @@ TEST(JsonEncoder_roundtrip_large_data) { Pdu original(PduType::INDICATION_MESSAGE); IndicationMessage msg; msg.dapp_identifier = 1; - // 1KB of data - msg.protocol_data.resize(1024); - for (size_t i = 0; i < msg.protocol_data.size(); ++i) { - msg.protocol_data[i] = static_cast(i & 0xFF); + nlohmann::json large; + for (int i = 0; i < 256; ++i) { + large["k" + std::to_string(i)] = i; } + std::string payload = large.dump(); + msg.protocol_data.assign(payload.begin(), payload.end()); original.choice = msg; auto encoded = encoder->encode(original); @@ -274,10 +278,159 @@ TEST(JsonEncoder_roundtrip_large_data) { ASSERT_TRUE(decoded.has_value()); auto& restored = std::get((*decoded).choice); - ASSERT_EQ(restored.protocol_data.size(), 1024u); - for (size_t i = 0; i < restored.protocol_data.size(); ++i) { - ASSERT_EQ(restored.protocol_data[i], static_cast(i & 0xFF)); - } + auto restored_json = nlohmann::json::parse(restored.protocol_data); + ASSERT_EQ(restored_json.size(), 256u); + ASSERT_EQ(restored_json["k0"].get(), 0); + ASSERT_EQ(restored_json["k255"].get(), 255); +} + +TEST(JsonEncoder_reject_nested_data_wrapper) { + auto encoder = create_encoder(); + + std::string nested_json = R"({ + "type": "setupRequest", + "id": 99, + "timestamp": 0, + "data": { + "e3apProtocolVersion": "2.0.0", + "dAppName": "NestedDApp", + "dAppVersion": "3.0.0", + "vendor": "NestedVendor" + } + })"; + + std::vector buf(nested_json.begin(), nested_json.end()); + auto result = encoder->decode(buf.data(), buf.size()); + ASSERT_FALSE(result.has_value()); +} + +TEST(JsonEncoder_reject_pascal_case_pdu_type) { + auto encoder = create_encoder(); + + std::string pascal_json = R"({ + "type": "SetupRequest", + "id": 1, + "timestamp": 0, + "dAppName": "Test", + "dAppVersion": "1.0", + "vendor": "V" + })"; + + std::vector buf(pascal_json.begin(), pascal_json.end()); + auto result = encoder->decode(buf.data(), buf.size()); + ASSERT_FALSE(result.has_value()); +} + +// --------------------------------------------------------------------------- +// actionData payload-shape acceptance matrix +// +// libe3 must tolerate every binary-payload shape a peer might emit (Postel's +// law on the receive side). These tests pin the four accepted shapes: +// 1. inline JSON object (cuBB / libe3-self / spear-dApp) +// 2. {"__hex__": "..."} (libe3's opaque-bytes sentinel) +// 3. plain hex string (NVIDIA aerial, spear-aerial-sample-apps@19f9bd3) +// 4. plain UTF-8 string (final-fallback so any text payload survives) +// --------------------------------------------------------------------------- + +namespace { +std::vector decode_action_data(const std::string& wire) { + auto encoder = create_encoder(); + std::vector buf(wire.begin(), wire.end()); + auto decoded = encoder->decode(buf.data(), buf.size()); + if (!decoded.has_value()) return {}; + return std::get(decoded->choice).action_data; +} +} // namespace + +TEST(JsonEncoder_actionData_inline_object) { + // Shape 1 — inline JSON object. The SM-facing bytes are the compact + // re-dump of that object. + std::string wire = R"({ + "type": "dAppControlAction", + "id": 1, "timestamp": 0, + "dAppIdentifier": 7, + "ranFunctionIdentifier": 4, + "controlIdentifier": 1, + "actionData": {"enabled": false, "ul_bw": 40} + })"; + auto bytes = decode_action_data(wire); + ASSERT_FALSE(bytes.empty()); + auto reparsed = nlohmann::json::parse(bytes); + ASSERT_TRUE(reparsed.is_object()); + ASSERT_EQ(reparsed["ul_bw"].get(), 40); + ASSERT_EQ(reparsed["enabled"].get(), false); +} + +TEST(JsonEncoder_actionData_hex_sentinel) { + // Shape 2 — explicit {"__hex__": ""} wrapper. Bytes are the literal + // hex-decoded blob. + std::string wire = R"({ + "type": "dAppControlAction", + "id": 2, "timestamp": 0, + "dAppIdentifier": 7, + "ranFunctionIdentifier": 4, + "controlIdentifier": 1, + "actionData": {"__hex__": "deadbeef"} + })"; + auto bytes = decode_action_data(wire); + ASSERT_EQ(bytes.size(), 4u); + ASSERT_EQ(bytes[0], 0xDEu); + ASSERT_EQ(bytes[1], 0xADu); + ASSERT_EQ(bytes[2], 0xBEu); + ASSERT_EQ(bytes[3], 0xEFu); +} + +TEST(JsonEncoder_actionData_plain_hex_string) { + // Shape 3 — NVIDIA aerial convention (spear-aerial-sample-apps@19f9bd3, + // e3_manager.cpp:1022+). actionData is a bare JSON string whose chars + // are pairwise hex digits of the inner JSON payload. Decoder must + // hex-decode rather than treat the string as opaque UTF-8. + // "7b22656e61626c6564223a747275657d" == `{"enabled":true}`. + std::string wire = R"({ + "type": "dAppControlAction", + "id": 3, "timestamp": 0, + "dAppIdentifier": 7, + "ranFunctionIdentifier": 4, + "controlIdentifier": 1, + "actionData": "7b22656e61626c6564223a747275657d" + })"; + auto bytes = decode_action_data(wire); + ASSERT_FALSE(bytes.empty()); + auto reparsed = nlohmann::json::parse(bytes); + ASSERT_TRUE(reparsed.is_object()); + ASSERT_EQ(reparsed["enabled"].get(), true); +} + +TEST(JsonEncoder_actionData_plain_utf8_string) { + // Shape 4 — bare string that is NOT pure hex. Decoder must NOT attempt + // hex-decode; the SM receives the literal UTF-8 bytes of the string. + std::string wire = R"({ + "type": "dAppControlAction", + "id": 4, "timestamp": 0, + "dAppIdentifier": 7, + "ranFunctionIdentifier": 4, + "controlIdentifier": 1, + "actionData": "hello world" + })"; + auto bytes = decode_action_data(wire); + std::string recovered(bytes.begin(), bytes.end()); + ASSERT_EQ(recovered, std::string("hello world")); +} + +// Edge: hex-looking string of odd length must NOT be hex-decoded (length +// invariant violated) — fall through to plain UTF-8 shape. +TEST(JsonEncoder_actionData_odd_length_hex_is_utf8) { + std::string wire = R"({ + "type": "dAppControlAction", + "id": 5, "timestamp": 0, + "dAppIdentifier": 7, + "ranFunctionIdentifier": 4, + "controlIdentifier": 1, + "actionData": "abc" + })"; + auto bytes = decode_action_data(wire); + std::string recovered(bytes.begin(), bytes.end()); + ASSERT_EQ(recovered, std::string("abc")); } int main() { From 12d99bf6a9785e79b265a2adb2ee259e700aca27 Mon Sep 17 00:00:00 2001 From: Filippo Olimpieri Date: Wed, 10 Jun 2026 17:24:46 -0400 Subject: [PATCH 2/2] docs: record the spectranet-demo branch repoint and the replaced dual-encoding lineage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spectranet-demo now tracks main + the aerial JSON dialect (fa187a0), replacing the runtime dual-encoding lineage (old tip aa46f6b). Document what the old lineage contained — per-commit, with re-implementation notes — since its last six commits lose their ref with this rewrite (the rest stays reachable via aerial-oai-integration), and map the relation to the json-format branch. --- BRANCH_HISTORY.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 BRANCH_HISTORY.md diff --git a/BRANCH_HISTORY.md b/BRANCH_HISTORY.md new file mode 100644 index 0000000..1898b9e --- /dev/null +++ b/BRANCH_HISTORY.md @@ -0,0 +1,56 @@ +# Branch history — `spectranet-demo` + +**2026-06-10: this branch was repointed.** It now tracks the **aerial-compatible +JSON dialect** lineage (`main` @ `bc2fd15` + `fa187a0`) and replaced the original +2026-04/05 **runtime dual-encoding** lineage (old tip `aa46f6b`). This file +records what the old lineage contained so nothing is lost with the rewrite. + +## What this branch is now + +- libe3 `main` plus one commit, `fa187a0`: the JSON encoder/decoder speaks the + NVIDIA Aerial camelCase wire dialect — flat envelope with a `"type"` key + (PascalCase and `"data"`-wrapped forms rejected), payloads accepted as inline + JSON / `{"__hex__": "…"}` / plain hex / UTF-8, structured `ranFunctionData` + passed inline, plus additive optional fields (`SetupResponse.message`, + `SubscriptionResponse` granted-list/periodicity/message echoes, + `IndicationMessage.subscription_id`). +- Exactly one encoder per build: the spectranet deployment uses + `-DLIBE3_ENABLE_JSON=ON -DLIBE3_ENABLE_ASN1=OFF`. +- Pairs with the `spectranet-demo` branches of `spear-openairinterface5g` + (gNB built with `-DE3_ENCODING_FORMAT=JSON`) and of the dApp repo. + +## The replaced lineage (runtime dual-encoding, old tip `aa46f6b`) + +The old branch served ASN.1 (spear-dApp) and JSON (aerial dApps) +**simultaneously** over two channel sets inside one agent. It was superseded by +the build-time single-encoding model above. Its commits up to `8afc87f` remain +reachable through the branch `aerial-oai-integration`; the last six commits +existed only on the old `spectranet-demo` and are summarized here so they can +be re-implemented if ever needed: + +| Commit | What it did | Still relevant? | +|---|---|---| +| `aa46f6b` | Reliable gNB→dApp xApp-control relay: per-message ack + retransmit on the agent side | The current stack relays xApp controls fire-and-forget; the dApp side has its own ack/retransmit for dApp→gNB. Re-implement if xApp→dApp delivery must be guaranteed under load. | +| `088b03d` | Link `libe3_sanitizers` PUBLIC so consumers inherit the flags | Build hygiene; re-do if sanitizer builds return. | +| `454874d` | Rate-limit + clarify malformed-setup warnings in the setup loop | Cosmetic; logs can flood if a wrong-encoding dApp hammers setup. | +| `5449543` | **Send an empty reply on setup-decode failure so the ZMQ REP socket never wedges** | The known sharp edge of `main`: a malformed/wrong-encoding setupRequest leaves the REP socket stuck (reply expected before next recv). Re-implement this if mixed-encoding dApps ever share a deployment. | +| `b458ba4` | Reduce SubscriptionManager reader pressure under emit load | Possibly superseded by `main`'s multi-dApp fan-out rework; re-evaluate before porting. | +| `774a043` | Per-dApp encoding query API for SMs | Dual-encoding only; obsolete under the single-encoding model. | + +Also notable inside `aerial-oai-integration` (still reachable there): +`e3fa80c` — ASN.1 setupResponse encode fails when an SM registers with empty +`ranFunctionData`; substituted a 1-byte placeholder. Needed only on **ASN1** +builds; current mitigation is to enable only SMs that provide +`ranFunctionData`. `5dbacba` — accept plain hex-string `actionData` +(now part of the `fa187a0` payload handling). `383ae08` — SubscriptionDetails +use-after-free fix (superseded by the `json-format` branch's SubscriptionDetails +rework, `ea62ba9`). + +## Related branches + +- `json-format`: an independent camelCase/flat-envelope implementation plus a + SubscriptionDetails C API. Largely overlaps `fa187a0`; its gaps for Aerial + compatibility are `ranFunctionData` always hex-encoded, no + `IndicationMessage.subscription_id`, no subscriptionResponse echoes, and a + strict `json::parse` on indication payloads. Natural upstream target: merge + it to `main`, then layer the `fa187a0` deltas on top.