E2SM-LLC — Lower Layers Control
This page is a self-contained guide to building xApps on top of the E2SM-LLC service model in airpuls. By the end you will understand what LLC is for, how the O-RAN specification structures it, and how to write a monitoring xApp — for both the periodic RLC-buffer-status report and the triggered raw SRS/CSI copy — in Python, Go, or C. A closing section shows how the same xApp consumes live lower-layer data tapped straight from a real gNB.
Specification anchors
- O-RAN.WG3.TS.E2SM-LLC-R004-v01.00 — the E2 Service Model for
Lower Layers Control (the contract implemented here). RAN Function
ID
5, OID1.3.6.1.4.1.53148.1.1.2.5. - O-RAN.WG3.TS.E2AP — the E2 Application Protocol that carries the Subscription / Indication procedures LLC rides on.
- 3GPP TS 38.211 — NR physical channels and signals; the source of the Sounding Reference Signal (SRS) that Style 1 copies raw.
- 3GPP TS 38.212 — NR multiplexing and channel coding; the source of the Channel State Information (CSI) Part 1 / Part 2 fields.
- 3GPP TS 38.322 — NR RLC protocol; the buffer the Style 2
dl-rlc-buffer-statussnapshot measures. - IETF RFC 5905 — the 64-bit NTP timestamp format used for the
per-slot
slotStartTime.
The 3GPP specs above are not cited verbatim by the implementation: R004 wraps them, and airpuls treats the E2SM-LLC ASN.1 as the contract boundary for what it can emit and decode.
1. What LLC is for
A Near-RT RIC observes and acts on the RAN along three axes, one per service model:
| Service model | What it gives an xApp | Example |
|---|---|---|
| E2SM-KPM | Measurements — aggregated performance counters | DL throughput, PRB usage |
| E2SM-RC | Radio control — per-UE decisions | Handover, QoS flow mapping |
| E2SM-LLC | Lower-layers information & control — observe the RAN's lower layers directly | DL RLC buffer status, raw SRS / CSI |
LLC is the lower-layers axis. Where KPM reports counters aggregated over a window and RC steers individual UEs, LLC exposes the RAN's lower layers with almost no aggregation: the downlink RLC queue depth per logical channel, and the raw PHY bytes of the SRS and CSI a UE just sent up. This is the data a latency-aware scheduler-assist or a beam-management xApp needs — information that lives below the measurement-counter layer and is normally invisible outside the gNB.
Scope of the airpuls implementation
REPORT Style 2 (Periodic, dl-rlc-buffer-status) is implemented
end-to-end. REPORT Style 1 (LLI Copy, SRS + CSI) has operational
ASN.1 codecs and a full xApp subscription/decode path; its emission is
gated at the agent-side constructor and, on the emulator, falls back to
synthesised CSI so the xApp path can be exercised everywhere (see
§6). CONTROL and INSERT services are not yet implemented — LLC
is REPORT-only today.
2. How the specification models LLC
Three ideas, in order.
2.1 Two things LLC observes
E2SM-LLC distinguishes lower-layer information from lower-layer measurements, and airpuls surfaces each as one enum:
- Lower-Layer Information (
llc_lli_type_t) — a copy of raw PHY data as it arrived at the RAN. Two arms:LLC_LLI_SRS(Sounding Reference Signal) andLLC_LLI_CSI(Channel State Information). This is the payload of Style 1. - Lower-Layer Measurement (
llc_meas_type_t) — a numeric snapshot of a lower-layer state:LLC_MEAS_DL_RLC_BUFFER_STATUS,LLC_MEAS_DL_PDCP_BUFFER_STATUS,LLC_MEAS_DL_HARQ_STATISTICS,LLC_MEAS_SLOT_TIME_STAMP. These are the payload of Style 2;dl-rlc-buffer-statusis the one implemented today.
The split matters because the two carry fundamentally different data: measurements are integers grouped per UE and per logical channel; information is opaque PHY byte strings the xApp interprets itself.
Only dl-rlc-buffer-status is wired end-to-end
All four llc_meas_type_t values are accepted by the builder, but the
RIC's Indication Message Format 2 decoder only handles the
dlRlcBufferStatus arm — LLC_MEAS_DL_PDCP_BUFFER_STATUS,
LLC_MEAS_DL_HARQ_STATISTICS, and LLC_MEAS_SLOT_TIME_STAMP are not
emitted by the agent nor decoded today, so subscribing for them yields
no measurement blocks. Use LLC_MEAS_DL_RLC_BUFFER_STATUS for Style 2.
2.2 The two REPORT styles
Style 2 — Periodic |
Style 1 — LLI Copy |
|
|---|---|---|
| Trigger | every N ms (periodic timer) | event-driven: an SRS or CSI arrives at the RAN |
| Carries | DL RLC buffer status (per UE, per LCID) | raw SRS and/or CSI, copied verbatim |
| Use case | latency / congestion visibility | beam management, channel-quality analytics |
Style 2 arms a reporting timer and emits a downlink RLC buffer-status
snapshot every period. Style 1 arms one or more event-trigger
conditions; when the matching lower-layer information appears (an SRS
occasion, a CSI report), the node copies it into an indication and emits
it immediately — the indication's header echoes the condition ID that
fired. The header carries the single condition ID that matched, not
a bitmask: with OR-glued conditions (e.g. SRS on ID 1, CSI on ID 2) each
indication echoes the one ID that fired, and because every condition is
bound to one llc_lli_type_t, that ID also tells you whether the copy is
SRS or CSI before you inspect the LLI.
2.3 Services, styles, and formats
LLC exposes its data through RIC service styles. Each style pins an event-trigger style, an action-definition format, and the indication header/message formats it uses:
| REPORT style | Event Trigger style | Action Def | Ind. Header | Ind. Message |
|---|---|---|---|---|
Style 1 — LLI Copy (triggered, SRS/CSI) |
1 | 1 | 1 | 1 |
Style 2 — Periodic (dl-rlc-buffer-status) |
2 | 2 | 1 | 2 |
Both styles share Indication Header Format 1. The two styles differ
in their Indication Message Format: Format 1 carries the typed LLI
copy (SRS/CSI); Format 2 carries the per-UE / per-LCID measurement
blocks. The SDK decodes Format 1 into a typed llc_lli_t and lowers
Format 2 into the same generic measurement-block tree every SM uses, so
the xApp branches on which one it received.
No RAN Function Definition decode step
Unlike KPM — whose RAN Function Definition enumerates named counters
an xApp must decode before subscribing — LLC Style 2 advertises a
single fixed lower-layer measurement set. There is therefore no
RAN-function-definition decode step: you check that a node advertises
RAN Function 5 (OID 1.3.6.1.4.1.53148.1.1.2.5), then build a
subscription by selecting the llc_meas_type_t (Style 2) or
llc_lli_type_t (Style 1) directly.
2.4 Every sample is anchored in time — the slot timestamp
A raw SRS or CSI copy is meaningless without knowing which slot it came from, and a slot index is meaningless without its numerology. Every Style 1 indication therefore carries a slot timestamp:
| Field | Meaning | Range / format |
|---|---|---|
systemFramNumber (SFN) |
System Frame Number | 0..1023 |
slotIndex |
slot within the frame, as a numerology-tagged CHOICE |
scs-15 0..9, scs-30 0..19, scs-60 0..39, scs-120 0..79 |
slotStartTime |
wall-clock instant the slot started | NTP-64, 8 bytes, big-endian (RFC 5905) |
The CHOICE arm of slotIndex is the numerology — airpuls surfaces it
as llc_scs_t (LLC_SCS_15/30/60/120 = 0/1/2/3). The
slotStartTime is a 64-bit NTP timestamp stamped at the moment the data
was captured (see §6); it decodes to NTP seconds and fraction per RFC
5905.
Note
The SDK accessors expose all four fields of each Style 1 indication:
the SFN, the slot index, the SCS numerology, and the raw 8-byte NTP-64
slotStartTime (llc_lli_slot_start_time in C, slot_start_time_ntp
/ slot_start_time_s in Python, SlotStartTime /
SlotStartTimeSeconds in Go) — see §5.4.
3. The subscription mechanic — what you actually ask for
An LLC subscription is built with one small builder object, sized for the style you pick:
- Style 2 (
Periodic) — set a report period (ms) and add one or more measurement types. The builder emits an Action Definition (Format 2) and a periodic Event Trigger Definition. - Style 1 (
LLI Copy) — set the LLI type to report and add one or more event-trigger conditions. Each condition pairs a RIC Event Trigger Condition ID (1..65535, echoed back in the indication header) with thellc_lli_type_twhose arrival fires it. Conditions can be OR-glued, so a single subscription can fire on SRS or CSI.
You submit the builder with one call; it is consumed, a subscription ID
comes back, and indications then stream to your on_indication handler
until you unsubscribe.
sequenceDiagram
autonumber
participant X as xApp (Py/Go/C)
participant S as C SDK (libric-client)
participant R as Near-RT RIC
participant N as E2 Node (agent)
X->>S: subscribe_llc(node, builder)
Note over X,S: builder = Style 2 (period + meas types)<br/>or Style 1 (LLI type + trigger conditions)
S->>R: RIC Subscription Request (ranFuncId=5)
Note over R: routes by ranFuncId — never decodes LLC payload
R->>N: E2AP RIC Subscription Request
N->>N: parse Action Def + Event Trigger,<br/>arm timer (Style 2) or trigger (Style 1)
N-->>R: SubscribeAck
R-->>S: ack
S-->>X: sub_id
alt Style 2 — periodic
loop every reporting period
N-->>R: RIC Indication (Format 2: RLC blocks)
R-->>S: forward
S-->>X: on_indication(... data.blocks)
end
else Style 1 — triggered
loop on each SRS / CSI arrival
N-->>R: RIC Indication (Format 1: typed LLI)
R-->>S: forward
S-->>X: on_indication(... data.llc_lli())
end
end
4. Tutorial — a monitoring xApp
Goal: subscribe to every LLC-capable node for both styles, then log each periodic RLC snapshot and each triggered SRS/CSI copy as it arrives.
Step 1 — register the LLC plugin so the inbound indication path can
dispatch LLC indications (keyed by RAN Function ID 5).
Step 2 — on each available node, capability-check for the LLC RAN function, then subscribe twice: a Style 2 periodic RLC subscription, and a Style 1 triggered subscription that fires on SRS or CSI.
Step 3 — in the indication handler, branch on whether a typed LLI is present: if it is, this is a Style 1 (triggered) copy; otherwise it is a Style 2 (periodic) measurement snapshot.
from airpuls_ric_sdk import (
LLC_RAN_FUNC_ID, llc_plugin_get,
LlcSubscriptionBuilder, LlcStyle, LlcMeasType, LlcLliType,
SmMeasurementType,
)
from airpuls_ric_sdk.xapp import BaseXApp, setup_logging
class LlcMonitor(BaseXApp):
XAPP_TYPE = "llc-monitor"
# Step 1: advertise the LLC plugin to the SDK.
def plugins(self):
return [llc_plugin_get()]
# Step 2: subscribe to LLC-capable nodes (both styles).
def on_e2_node_available(self, client, node):
if node.find_ran_function(LLC_RAN_FUNC_ID) is None:
return # node does not advertise LLC
# Style 2 — periodic DL RLC buffer status, every 1 s.
periodic = LlcSubscriptionBuilder(LlcStyle.STYLE_2)
periodic.set_report_period(1000)
periodic.add_measurement(LlcMeasType.DL_RLC_BUFFER_STATUS)
client.subscribe_llc(node, periodic)
# Style 1 — triggered LLI copy, fire on SRS OR CSI.
trig = LlcSubscriptionBuilder(LlcStyle.STYLE_1)
trig.set_lli_type(LlcLliType.SRS)
trig.add_trigger_condition(1, LlcLliType.SRS)
trig.add_trigger_condition(2, LlcLliType.CSI, logical_or=True)
client.subscribe_llc(node, trig)
# Step 3: branch on the indication kind.
def on_indication(self, client, sub_id, node, ran_func_id, header, data):
if ran_func_id != LLC_RAN_FUNC_ID:
return
lli = data.llc_lli() # typed LLI for Style 1, else None
if lli is not None:
self._on_lli(header, lli) # triggered SRS/CSI copy
else:
self._on_rlc(data) # periodic RLC blocks
# --- Style 2: per-UE / per-LCID RLC buffer status ---
def _on_rlc(self, data):
for block in data.blocks:
ue = block.ue_id.display if block.ue_id else "cell"
for m in block.measurements:
if m.type == SmMeasurementType.INTEGER:
# m.name e.g. "DlRlcBufferOccupancy.LCID0",
# "DlRlcHolTimeToLive.LCID0"
print(ue, m.name, m.integer)
# --- Style 1: typed SRS / CSI copy ---
def _on_lli(self, header, lli):
cond = header.event_trigger_condition_id # which condition fired
print("LLI", "sfn", lli.slot_sfn, "slot", lli.slot_index,
"scs", lli.slot_scs, "cond", cond)
if lli.kind == LlcLliType.SRS:
for a in range(lli.srs_antenna_count):
for s in range(lli.srs_symbol_count(a)):
raw = lli.srs_raw(a, s)
hdr = lli.srs_compression_header(a, s)
print(" SRS ant", a, "sym", s,
len(raw), "bytes comp_hdr", hdr)
else: # LlcLliType.CSI
for u in range(lli.csi_ue_count):
for r in range(lli.csi_report_count(u)):
cfg = lli.csi_report_config_id(u, r)
p1 = lli.csi_part1(u, r)
p1_unused = lli.csi_part1_bits_unused(u, r)
p2 = lli.csi_part2(u, r) # bytes or None
print(" CSI ue", u, "rep", r, "cfg", cfg,
"part1", p1.hex(), "unused", p1_unused,
"part2", p2.hex() if p2 else None)
if __name__ == "__main__":
setup_logging()
LlcMonitor(config_path="/etc/airpuls/xapp.yml").run_sync()
package main
import (
"encoding/hex"
"log"
"github.com/airpuls/ric-client-go/ric"
)
type handler struct{ ric.DefaultEventHandler }
// Step 2: subscribe to LLC-capable nodes (both styles). Resolve by
// OID — spec-stable, unlike the node-local RAN Function ID.
func (h *handler) OnE2NodeAvailable(c *ric.Client, node *ric.E2Node) {
if node.FindRanFunctionByOID(ric.LlcOID) == nil {
return
}
// Style 2 — periodic DL RLC buffer status, every 1 s.
periodic := ric.NewLlcSubscriptionBuilder(ric.LlcStyle2)
periodic.SetReportPeriod(1000).
AddMeasurement(ric.LlcMeasDlRlcBufferStatus)
c.SubscribeLLC(node, periodic)
// Style 1 — triggered LLI copy, fire on SRS OR CSI.
trig := ric.NewLlcSubscriptionBuilder(ric.LlcStyle1)
trig.SetLliType(ric.LlcLliSrs).
AddTriggerCondition(1, ric.LlcLliSrs, false).
AddTriggerCondition(2, ric.LlcLliCsi, true)
c.SubscribeLLC(node, trig)
}
// Step 3: branch on the indication kind.
func (h *handler) OnIndication(c *ric.Client, subID uint32, node *ric.E2Node,
ranFuncID uint16, hdr *ric.IndicationHeader, data *ric.IndicationData) {
if ranFuncID != ric.LlcRanFuncID {
return
}
lli := data.Lli() // typed LLI for Style 1, nil for Style 2
if lli == nil {
// Style 2: per-UE / per-LCID RLC buffer status.
for _, block := range data.Blocks() {
for _, m := range block.Measurements() {
// m.Name() e.g. "DlRlcBufferOccupancy.LCID0"
log.Printf("%s = %d", m.Name(), m.Integer())
}
}
return
}
// Style 1: typed SRS / CSI copy.
log.Printf("LLI sfn=%d slot=%d scs=%d", lli.SlotSfn(),
lli.SlotIndex(), lli.SlotScs())
if lli.Kind() == ric.LlcLliSrs {
for a := 0; a < lli.SrsAntennaCount(); a++ {
for s := 0; s < lli.SrsSymbolCount(a); s++ {
raw := lli.SrsRaw(a, s)
log.Printf(" SRS ant=%d sym=%d %d bytes comp_hdr=%d",
a, s, len(raw), lli.SrsCompressionHeader(a, s))
}
}
} else { // ric.LlcLliCsi
for u := 0; u < lli.CsiUeCount(); u++ {
for r := 0; r < lli.CsiReportCount(u); r++ {
p1 := lli.CsiPart1(u, r)
log.Printf(" CSI ue=%d rep=%d cfg=%d part1=%s unused=%d",
u, r, lli.CsiReportConfigID(u, r),
hex.EncodeToString(p1), lli.CsiPart1BitsUnused(u, r))
}
}
}
}
func main() {
cfg := ric.NewXappConfig("/etc/airpuls/xapp.yml")
defer cfg.Close()
if err := cfg.Load(); err != nil {
log.Fatal(err)
}
client, err := ric.Connect(cfg)
if err != nil {
log.Fatal(err)
}
defer client.Close()
client.RegisterSM(ric.LlcPluginGet()) // Step 1
client.SetEventHandler(&handler{})
client.Run()
}
#include <stdio.h>
#include "ric-client.h"
#include "xapp-config.h"
#include "e2sm-llc.h" /* LLC_RAN_FUNC_ID, llc_plugin_get, builder + LLI accessors */
/* Step 2: subscribe when an LLC-capable node appears. */
static void on_node(ric_client_t *client, const ric_e2_node_t *node, void *ud) {
(void)ud;
/* Style 2 — periodic DL RLC buffer status, every 1 s. */
llc_subscription_builder_t *p = llc_subscription_builder_new(LLC_STYLE_2);
llc_sub_set_report_period(p, 1000);
llc_sub_add_measurement(p, LLC_MEAS_DL_RLC_BUFFER_STATUS);
uint32_t sub_p = 0;
ric_client_subscribe_llc(client, node, p, &sub_p); /* builder consumed */
/* Style 1 — triggered LLI copy, fire on SRS OR CSI. */
llc_subscription_builder_t *t = llc_subscription_builder_new(LLC_STYLE_1);
llc_sub_set_lli_type(t, LLC_LLI_SRS);
llc_sub_add_trigger_condition(t, 1, LLC_LLI_SRS, false);
llc_sub_add_trigger_condition(t, 2, LLC_LLI_CSI, true); /* OR-glued */
uint32_t sub_t = 0;
ric_client_subscribe_llc(client, node, t, &sub_t);
}
/* Step 3: branch on the indication kind. */
static void on_ind(ric_client_t *client, uint32_t sub_id,
const ric_e2_node_t *node, uint16_t ran_func_id,
const sm_indication_header_t *hdr,
const sm_indication_data_t *data, void *ud) {
(void)client; (void)sub_id; (void)node; (void)hdr; (void)ud;
if (ran_func_id != LLC_RAN_FUNC_ID) return;
const llc_lli_t *lli = llc_indication_lli(data); /* borrowed; NULL = Style 2 */
if (lli == NULL) {
/* Style 2: generic measurement blocks — one per UE, each holding
* DlRlcBufferOccupancy.LCID<n> / DlRlcHolTimeToLive.LCID<n> plus
* the per-block SlotTimeStamp.SFN/SlotIndex/SCS. sm_measurement_t
* is opaque; read it through the accessors. */
for (size_t bi = 0; bi < sm_indication_data_block_count(data); bi++) {
const sm_measurement_block_t *block =
sm_indication_data_block_at(data, bi);
const char *ue = sm_ue_id_display(sm_measurement_block_ue_id(block));
for (size_t j = 0; j < sm_measurement_block_count(block); j++) {
const sm_measurement_t *m = sm_measurement_block_at(block, j);
if (sm_measurement_type(m) == SM_MEAS_TYPE_INTEGER)
printf(" %s %s = %lld\n", ue, sm_measurement_name(m),
(long long)sm_measurement_integer(m));
}
}
return;
}
/* Style 1: typed SRS / CSI copy. */
printf("LLI sfn=%u slot=%u scs=%d\n",
llc_lli_slot_sfn(lli), llc_lli_slot_index(lli), llc_lli_slot_scs(lli));
if (llc_lli_kind(lli) == LLC_LLI_SRS) {
for (size_t a = 0; a < llc_lli_srs_antenna_count(lli); a++)
for (size_t s = 0; s < llc_lli_srs_symbol_count(lli, a); s++) {
size_t n = 0;
const uint8_t *raw = llc_lli_srs_raw(lli, a, s, &n);
uint8_t comp = llc_lli_srs_compression_header(lli, a, s);
printf(" SRS ant=%zu sym=%zu %zu bytes comp_hdr=%u\n", a, s, n, comp);
(void)raw;
}
} else { /* LLC_LLI_CSI */
for (size_t u = 0; u < llc_lli_csi_ue_count(lli); u++)
for (size_t r = 0; r < llc_lli_csi_report_count(lli, u); r++) {
size_t p1n = 0, p2n = 0;
const uint8_t *p1 = llc_lli_csi_part1(lli, u, r, &p1n);
const uint8_t *p2 = llc_lli_csi_part2(lli, u, r, &p2n); /* NULL if absent */
printf(" CSI ue=%zu rep=%zu cfg=%ld part1=%zuB part2=%s\n",
u, r, llc_lli_csi_report_config_id(lli, u, r), p1n,
p2 ? "present" : "absent");
(void)p1;
}
}
/* lli is borrowed from data — do NOT llc_lli_free() it here. */
}
int main(void) {
ric_xapp_config_t *cfg = ric_xapp_config_new("/etc/airpuls/xapp.yml");
ric_xapp_config_load(cfg);
ric_client_t *client = ric_client_connect(cfg);
ric_client_register_sm(client, llc_plugin_get()); /* Step 1 */
ric_client_set_on_e2_node_available(client, on_node, NULL);
ric_client_set_on_indication(client, on_ind, NULL);
ric_client_run(client);
ric_client_destroy(client);
ric_xapp_config_free(cfg);
return 0;
}
What you will see
The periodic subscription logs one block per active UE, each with two
measurements per logical channel — DlRlcBufferOccupancy.LCID<n> and
DlRlcHolTimeToLive.LCID<n> — plus three per-block SlotTimeStamp.*
values that locate the snapshot in time. The triggered subscription logs one line
per SRS occasion or CSI report, tagged with the SFN/slot it came from
and the condition ID that fired. Against the emulator you will still
see CSI indications even with no live radio — the agent synthesises
them (see §6).
5. The decoded data model
The branch in Step 3 splits into three concrete shapes. Each binding exposes the same accessors; only the spelling differs.
5.1 Style 2 — DL RLC buffer status
Format 2 lowers into the generic measurement-block tree: one block per UE, each block holding integer measurements named by logical channel. Per LCID there are two values:
| Measurement name | Meaning | Units / range |
|---|---|---|
DlRlcBufferOccupancy.LCID<n> |
downlink RLC buffer occupancy | bytes |
DlRlcHolTimeToLive.LCID<n> |
head-of-line time-to-live | 0..1032 units of 0.5 ms (0..516 ms) |
SlotTimeStamp.SFN |
System Frame Number of the snapshot | 0..1023 |
SlotTimeStamp.SlotIndex |
slot within the frame | per numerology |
SlotTimeStamp.SCS |
numerology of the slot index | kHz (15/30/60/120) |
The two DlRlc* values repeat once per logical channel (.LCID<n>),
and each block additionally carries the three SlotTimeStamp.*
measurements once per block — they locate the whole snapshot in time
(this is how the slot timestamp surfaces for Style 2, since the typed
slot accessors of §5.4 only apply to the Style 1 LLI). The logical
channel ID <n> is the spec lcID (1..32); it identifies the bearer's
logical channel (signalling SRBs and data DRBs alike). A high occupancy
with a small HOL-TTL is a congested bearer about to miss its delay
budget — the signal a scheduler-assist xApp watches for.
RLC has no dedicated accessor
No binding exposes a typed rlc_buffer_status() method. In every
language you iterate the generic measurement blocks
(data.blocks / data.Blocks() / sm_indication_data_*) and read the
measurement name and integer value. The name encodes the field and
the LCID; parse <n> off the .LCID<n> suffix.
5.2 Style 1 — SRS
An SRS copy is a two-level array — receive antennas, and per antenna a list of symbols (size 1, 2, or 4). Each symbol carries the raw SRS bytes plus the one-byte compression header needed to interpret them:
Accessor (per antenna, symbol) |
Meaning |
|---|---|
srs_antenna_count |
number of receive antennas in this copy |
srs_symbol_count(antenna) |
symbols captured for that antenna |
srs_raw(antenna, symbol) |
raw SRS PHY bytes (rawSRS OCTET STRING) |
srs_compression_header(antenna, symbol) |
srsCompressionHeader byte — the raw SRS is uninterpretable without it |
The compression header is a 1-byte OCTET STRING that describes how the
gNB packed the rawSRS payload; airpuls passes both through verbatim
and never interprets them. An analytics xApp must decode the raw samples
against this header before using them — and because the header can differ
between occasions, two raw-SRS copies are only comparable once each has
been de-compressed against its own header.
5.3 Style 1 — CSI
A CSI copy is keyed per UE, each UE holding a list of reports.
Each report has a config ID and a mandatory Part 1 with an optional
Part 2. The wire also carries a per-UE channelCarryingUCI flag (the
uplink channel — PUCCH or PUSCH — that carried the UCI), but it is not
surfaced through a typed accessor in any binding today; use the presence
of Part 2 as the practical discriminator (PUCCH-carried CSI has no
Part 2 — see below).
Accessor (per ue, report) |
Meaning |
|---|---|
csi_ue_count |
UEs in this copy |
csi_report_count(ue) |
reports for that UE |
csi_report_config_id(ue, report) |
csiReportConfigID |
csi_part1(ue, report) |
Part 1 BIT STRING bytes (mandatory) |
csi_part1_bits_unused(ue, report) |
trailing unused bits 0..7; valid bits = len(part1)*8 - unused |
csi_part2(ue, report) |
Part 2 BIT STRING bytes, or None/NULL if absent |
csi_part2_bits_unused(ue, report) |
trailing unused bits of Part 2 (0 if absent) |
Because Part 1 and Part 2 are BIT STRINGs, the bits-unused count is
load-bearing: the last byte is only partially significant. Always
compute the valid bit length before interpreting the codeword. Part 2 is
genuinely optional — for CSI carried on PUCCH the agent does not decode
it, and csi_part2() returns absent.
5.4 The slot timestamp accessors
For any Style 1 indication, four accessors locate the sample in time:
| Accessor | Meaning |
|---|---|
slot_sfn |
System Frame Number (0..1023) |
slot_index |
slot index within the numerology |
slot_scs |
numerology arm (LLC_SCS_15/30/60/120) — required to interpret slot_index |
slot_start_time |
slotStartTime as the raw 64-bit NTP value (RFC 5905: high 32 bits = seconds since 1900-01-01, low 32 bits = fraction in 2⁻³² s) |
The NTP-64 slotStartTime gives absolute wall-clock anchoring across
E2 nodes; Python and Go additionally offer a convenience float-seconds
form (slot_start_time_s / SlotStartTimeSeconds) — use the raw value
where the full 2⁻³² s fraction matters. A slot index is only comparable
across samples of the same slot_scs: the numerology fixes the
slots-per-frame (10 at 15 kHz, 20 at 30 kHz, 40 at 60 kHz, 80 at
120 kHz), so never compare or subtract slot_index values that came
from different slot_scs arms without normalising first.
uint32_t sfn = llc_lli_slot_sfn(lli);
uint32_t slot = llc_lli_slot_index(lli);
llc_scs_t scs = llc_lli_slot_scs(lli);
uint64_t t_ntp = llc_lli_slot_start_time(lli); /* raw NTP-64 */
size_t p1n = 0;
(void)llc_lli_csi_part1(lli, u, r, &p1n);
size_t bits = p1n * 8 - llc_lli_csi_part1_bits_unused(lli, u, r);
6. Advanced — a live RAN tap
Everything above works identically against the emulator and against a real gNB. This section explains how real CSI reaches your Style 1 handler, and what to tune when it does.
6.1 How live CSI flows out of the gNB
The aircell gNB image carries the airpuls E2 agent compiled into OAI's L1
PHY. When the L1 PUCCH receive thread finishes decoding a PUCCH2 CSI
report, an #ifdef E2_AGENT-guarded hook copies the CSI at the
hookpoint — Part 1 payload, the report config ID, the SFN/slot/SCS of
the live slot, and an RFC 5905 NTP-64 timestamp captured then and there
(the L1 RX thread is the single producer, so it always sees the true
capture instant; the consumer never re-stamps).
The hook does no allocation and posts no ITTI message: it writes into a
lock-free SPSC ring — a timestamp plus a memcpy of the payload. At
each triggered-report tick the agent's LLC builder drains the ring as the
single consumer, encodes the latest CSI into a Style 1 indication, and
carries the live SFN/slot/SCS and NTP timestamp verbatim to the xApp.
No synthetic data is injected on the live path.
sequenceDiagram
autonumber
participant L as gNB L1 (PUCCH RX thread)
participant Q as lock-free SPSC ring
participant A as E2 agent (LLC drain)
participant R as Near-RT RIC
participant X as xApp
L->>L: nr_decode_pucch2() decodes CSI Part 1
L->>Q: copy CSI + NTP-64 timestamp (stamped at the hook)
Note over L,Q: no allocation, no ITTI — memcpy + timestamp only
loop every triggered period
A->>Q: fetch latest CSI (single consumer)
alt live CSI present
A->>R: RIC Indication (Style 1 — live SFN/slot/SCS/NTP)
else no live CSI this period
A->>R: RIC Indication (Style 1 — synthesised CSI)
end
R->>X: on_indication(... data.llc_lli())
end
6.2 The same xApp, emulator or real gNB
When the drain finds no live CSI for a period — on the emulator, in CI,
or before any UE has reported — the builder synthesises a CSI report
(random 4-byte Part 1, a monotonically incrementing SFN from a per-node
counter, slot index 0 at 30 kHz) and emits on schedule anyway. The
live agent's synthesised fallback carries a zero slotStartTime;
the standalone E2 emulator's mock stamps the emitting host's wall clock
instead, so emulator samples carry a plausible, monotonic NTP-64. This
is why the identical monitoring xApp from §4 runs unchanged against
the emulator and against a live gNB: the indication cadence and the
typed accessors are the same; only the contents differ. Because the
slot fields are a counter rather than a real slot (and the emulator's
timestamp is an emit-time stamp, not a true slot start), synthesised
samples cannot be used for slot-accurate correlation — treat them as
path-exercising filler. For PUCCH-carried CSI, Part 2 is not decoded —
csi_part2() is absent on both paths.
6.3 Sizing the southbound buffer — transport.southbound.recv_bufsize
A raw SRS occasion is large. A 4×4 SRS copy can reach roughly 209 KB,
which exceeds the RIC's default southbound receive slot. The
transport.southbound.recv_bufsize knob (under ric: in nrtric.yml) sizes each
southbound SCTP recvmmsg slot and the socket SO_RCVBUF:
- Default:
1114112bytes (1 MB + envelope) — comfortably above any raw-SRS copy, which isantennas × symbols × fft × 4bytes and tops out at 512 KB for 8 antennas × 4 symbols at FFT 4096. - Clamp:
[65536, 16 MiB], enforced at config load. - This is an airpuls userspace slot size, not an SCTP protocol limit — SCTP reassembles larger messages transparently; the knob sizes the buffer the RIC reads them into. The RIC keeps no reassembly buffer, so an indication larger than the slot is partial-delivered and dropped.
Raise it for wider bandwidths or higher antenna counts:
Each southbound worker pre-allocates 16 slots of this size.
6.4 Watching the live data flow
The RIC exports per-SM byte-volume counters at GET /metrics,
labelled by service model:
| Metric | Interface · direction | Meaning |
|---|---|---|
airpuls_sm_indications_bytes_in_total{sm="LLC"} |
southbound ingress (gNB → RIC) | indication payload bytes (header + message) received from agents |
airpuls_sm_indications_bytes_out_total{sm="LLC"} |
northbound egress (RIC → xApp) | indication payload bytes dispatched to xApps |
A rate() over either gives bytes/sec. The bundled Grafana dashboard
plots them on the "Southbound Data Volume per SM (RIC ↔ E2 Node)" and
"Northbound Data Volume per SM (RIC ↔ xApp)" mirror panels (per-SM,
direction encoded by sign). LLC is REPORT-only, so its reverse-path
Control counters (airpuls_sm_control_bytes_in_total{sm="LLC"} /
airpuls_sm_control_bytes_out_total{sm="LLC"}) stay near zero — LLC
shows on the indication (▲) side of both panels. When you flip from
emulator to a live gNB and raise transport.southbound.recv_bufsize, these panels are
where you confirm SRS-sized LLC traffic is actually flowing — a flat
LLC line means no indications are reaching the RIC.
7. Reference
SDK surface
| Concept | Python | Go | C |
|---|---|---|---|
| Plugin | llc_plugin_get() |
ric.LlcPluginGet() |
llc_plugin_get() |
| RAN Function ID | LLC_RAN_FUNC_ID (5) |
ric.LlcRanFuncID |
LLC_RAN_FUNC_ID |
| OID | LLC_OID |
ric.LlcOID |
LLC_OID |
| Builder (style) | LlcSubscriptionBuilder(LlcStyle.STYLE_2) |
ric.NewLlcSubscriptionBuilder(ric.LlcStyle2) |
llc_subscription_builder_new(LLC_STYLE_2) |
| Period (Style 2) | set_report_period(ms) |
SetReportPeriod(ms) |
llc_sub_set_report_period(b, ms) |
| Measurement (Style 2) | add_measurement(LlcMeasType.…) |
AddMeasurement(ric.LlcMeas…) |
llc_sub_add_measurement(b, LLC_MEAS_…) |
| LLI type (Style 1) | set_lli_type(LlcLliType.…) |
SetLliType(ric.LlcLli…) |
llc_sub_set_lli_type(b, LLC_LLI_…) |
| Trigger cond (Style 1) | add_trigger_condition(id, type, logical_or=…) |
AddTriggerCondition(id, type, or) |
llc_sub_add_trigger_condition(b, id, type, or) |
| Subscribe | client.subscribe_llc(node, b) |
client.SubscribeLLC(node, b) |
ric_client_subscribe_llc(client, node, b, &id) |
| Unsubscribe | client.unsubscribe(sub_id) |
client.Unsubscribe(subID) |
ric_client_unsubscribe(client, id) |
| Typed LLI (Style 1) | data.llc_lli() → LlcLli/None |
data.Lli() → *LlcLli/nil |
llc_indication_lli(data) → borrowed/NULL |
| RLC blocks (Style 2) | data.blocks |
data.Blocks() |
sm_indication_data_block_at + sm_measurement_block_at |
Enum values are stable across bindings: LLC_MEAS_DL_RLC_BUFFER_STATUS=0,
DL_PDCP_BUFFER_STATUS=1, DL_HARQ_STATISTICS=2, SLOT_TIME_STAMP=3;
LLC_LLI_SRS=0, LLC_LLI_CSI=1; LLC_SCS_15/30/60/120 = 0/1/2/3.
Running the bundled example
A complete reference xApp ships at xapps/llc-monitor/ — it subscribes to
both REPORT styles, decodes SRS and CSI, and streams the lower-layer
samples to the telemetry sinks. Run it natively or in Docker exactly like
the other xApps:
./build_docker.sh llc-monitor
docker run -v $(pwd)/xapps/llc-monitor/xapp.yml:/etc/airpuls/xapp.yml \
llc-monitor-python:latest
Configuration
LLC xApps use the same xapp.yml schema as every other xApp; nothing
LLC-specific lives in YAML — the what/when of a subscription is expressed
in code via the builder. The one RIC-side knob that matters for LLC is
transport.southbound.recv_bufsize (§6), set in the RIC's nrtric.yml when you expect
large raw-SRS occasions.
8. Troubleshooting — what to check
- No LLC at all.
find_ran_function(LLC_RAN_FUNC_ID)returns nothing → the node does not advertise RAN Function5. Confirm the agent exports LLC (OID1.3.6.1.4.1.53148.1.1.2.5, name "Lower Layers Control (airpuls)") in its E2 Setup RAN Function list. data.llc_lli()isNoneon a Style 1 subscription. That indication was Format 2 (periodic) — a Style 1 copy always yields a typed LLI. Confirm you branched on the presence of the LLI, not on the subscription ID.- No Style 1 indications on a real gNB. Style 1 emission is gated at the agent-side constructor; verify the aircell build enables it and that a UE is actually sending SRS/CSI. On the emulator you should always see synthesised CSI — if even that is missing, the subscription did not arm.
- CSI
part2is always absent. Expected for PUCCH-carried CSI — the L1 PUCCH path does not decode Part 2. Read Part 1 with itscsi_part1_bits_unusedcount. - Truncated or dropped large indications. The RIC logs
Dropping oversized SCTP message ... exceeds our N-byte receive slotonce per oversized message. Raw 4×4 SRS occasions (~209 KB) fit the 1 MB default; higher symbol counts may not. Raisetransport.southbound.recv_bufsize(§6) — the slot size is an airpuls buffer choice, not an SCTP limit. - Is data actually flowing? Watch
rate(airpuls_sm_indications_bytes_in_total{sm="LLC"}[1m])and the "Southbound Data Volume per SM (RIC ↔ E2 Node)" Grafana panel. A flatLLCseries means no LLC indications are reaching the RIC. slot_indexlooks wrong. It is only meaningful together withslot_scs— the numerology arm fixes the slot count per frame (15 kHz →0..9, 120 kHz →0..79).- Looking for CONTROL. LLC is REPORT-only today; CONTROL and INSERT are not implemented.