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. 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() {