E2SM-RC — RAN Control
E2SM-RC is the radio control axis: per-UE observation and decisions. It is the richest SM in airpuls — it spans four RIC services: REPORT (observe RRC events, cell info, UE state), INSERT (intercept a gNB decision and rule on it), CONTROL (issue a decision such as a handover), and QUERY (snapshot known UEs on demand).
Spec anchors
- O-RAN.WG3.E2SM-RC-v01.03 (QUERY on R004-v09.00).
- OID
1.3.6.1.4.1.53148.1.1.2.3, default RAN Function ID3.
What RC gives you
| Service | Implemented | Use |
|---|---|---|
| REPORT | Styles 1–4 | Observe RRC message copies, call-process outcomes, cell info, UE RRC-state transitions |
| INSERT | Style 3 / Action 1 (Handover) | Intercept a gNB-initiated handover before it fires; drop / accept / manipulate it |
| CONTROL | Style 3 / Action 1 (Handover) | Issue a handover — standalone, or as the INSERT response |
| QUERY | Style 1 (E2 Node Information), Style 2 (UE Information) | One-shot snapshot of served cells / known UEs, over the RIC Query procedure |
REPORT — observing the RAN
An RC REPORT subscription selects a style and the RAN parameters to report. Style 1 copies RRC messages (with the UE ID); Style 4 reports UE RRC-state transitions; Style 3 reports per-cell config + neighbour relations.
from airpuls_ric_sdk import (
RcSubscriptionBuilder, RcStyle,
RcNrRrcClass, RcNrUlDcchMsg, RcNrDlDcchMsg,
RC_RANPARAM_RRC_MESSAGE, RC_RANPARAM_UE_ID,
RC_RANPARAM_RRC_STATE_CHANGED_TO,
)
# Style 1 — Message Copy (cell-level RRC message + UE ID).
# Name the messages to copy — one event per message, by NR RRC
# class + zero-based Message ID; the i-th event gets Event
# Trigger Condition ID i+1, echoed in each indication header.
b = RcSubscriptionBuilder(RcStyle.STYLE_1)
b.add_ran_param(RC_RANPARAM_RRC_MESSAGE)
b.add_ran_param(RC_RANPARAM_UE_ID)
b.add_rrc_message_event(RcNrRrcClass.UL_DCCH,
RcNrUlDcchMsg.MEASUREMENT_REPORT)
b.add_rrc_message_event(RcNrRrcClass.DL_DCCH,
RcNrDlDcchMsg.RRC_RECONFIGURATION)
# Network-interface copies use the interface's own Elementary
# Procedure Code (E2SM §6.2.2.12) plus the request/outcome
# discriminator — a procedure's failure answer is UNSUCCESSFUL.
b.add_ni_message_event(RcNiType.F1, RcF1Procedure.UE_CONTEXT_SETUP,
RcNiMsgType.INITIATING)
b.add_ni_message_event(RcNiType.F1, RcF1Procedure.UE_CONTEXT_SETUP,
RcNiMsgType.SUCCESSFUL)
sub_id = client.subscribe_rc(node, b)
# Style 4 — UE Information (RRC state transitions)
b = RcSubscriptionBuilder(RcStyle.STYLE_4)
b.add_ran_param(RC_RANPARAM_RRC_STATE_CHANGED_TO)
sub_id = client.subscribe_rc(node, b)
// Style 1 — Message Copy: one event per message to copy, by NR
// RRC class + zero-based Message ID; the i-th event gets Event
// Trigger Condition ID i+1, echoed in each indication header.
b := ric.NewRcSubscriptionBuilder(ric.RcStyle1)
b.AddRanParam(ric.RcRanParamRRCMessage).
AddRanParam(ric.RcRanParamUeID).
AddRrcMessageEvent(ric.RcNrRrcClassUlDcch, ric.RcNrUlDcchMeasurementReport).
AddRrcMessageEvent(ric.RcNrRrcClassDlDcch, ric.RcNrDlDcchRrcReconfiguration).
AddNiMessageEvent(ric.RcNiTypeF1, ric.RcF1ProcUeContextSetup, ric.RcNiMsgInitiating)
subID, err := client.SubscribeRC(node, b)
// Style 4 — UE RRC state transitions
b = ric.NewRcSubscriptionBuilder(ric.RcStyle4)
b.AddRanParam(ric.RcRanParamRRCStateChangedTo)
subID, err = client.SubscribeRC(node, b)
#include "e2sm-rc.h"
rc_subscription_builder_t *b = rc_subscription_builder_new(RC_STYLE_1);
rc_sub_add_ran_param(b, /* RAN parameter ID */ …);
/* Name the messages to copy — one event per message, by NR RRC
* class + zero-based Message ID. The i-th event gets Event
* Trigger Condition ID i+1, echoed in each indication header. */
rc_sub_add_rrc_message_event(b, RC_NR_RRC_CLASS_UL_DCCH,
RC_NR_UL_DCCH_MEASUREMENT_REPORT);
uint32_t sub_id = 0;
ric_client_subscribe_rc(client, node, b, &sub_id); /* consumes b */
Indications arrive on on_indication, and RANParameter values surface as
the generic measurement blocks (see
Building xApps).
The values in a block
Unlike KPM, RC names are fixed — a RANParameter ID maps to a name the
decoder assigns, so these are the exact strings m.name carries:
m.name |
Requested with | Value type | Meaning |
|---|---|---|---|
NI_Message |
RC_RANPARAM_NI_MESSAGE (2) |
octet string | The copied network-interface PDU, verbatim (an F1AP PDU today) — decode with pycrate's F1AP module; NI copies never carry a UE_ID, so extract the gNB-CU/DU UE F1AP IDs from the decoded PDU |
RRC_Message |
RC_RANPARAM_RRC_MESSAGE (3) |
octet string | The copied RRC PDU, verbatim — decode with airpuls_ric_sdk.rrc.decode_message(octets, <RrcClass>) (needs the rrc extra: pip install airpuls-ric-sdk[rrc]); the class to pass is the one of the condition that fired, which the header's condition ID identifies |
UE_ID |
RC_RANPARAM_UE_ID (4) |
octet string | APER-encoded UEID of the UE the copied message belongs to (E2SM-RC R004 v09.00 Clause 9.3.10) — decode with airpuls_ric_sdk.decode_ue_id_octets(octets); absent when the node has not resolved the UE |
RRC_State_Changed_To |
RC_RANPARAM_RRC_STATE_CHANGED_TO (202) |
integer | The RRC state the UE entered — an RcRrcState wire value (Clause 9.3.37): 0 connected, 1 inactive, 2 idle |
cellDeleted |
Style 3, automatic | integer (boolean) | Set when the reported cell was removed |
cellContextInfo |
Style 3, automatic | integer | Byte length of the cell's context blob |
A RANParameter you request that the node does not advertise never
appears as a measurement. Instead the RIC raises a
MEASUREMENT_NOT_ADVERTISED notification naming it RANParameter-ID <n>
— handle it in on_notification rather than waiting for a value that
will not come.
RRC_Message is bytes, not a number
RC RANParameter values span every primitive branch of
RANParameter-Value — boolean, integer, real, bit string, octet
string, printable string. A KPM-shaped loop that calls m.integer
unconditionally reads 0 for RRC_Message and looks like a decode
bug. Dispatch on m.type, or use m.value, which does it for you:
Which block is which
RC uses three Indication Message Formats, and the block shape differs:
| Format | Used by | Blocks | block.ue_id.display |
|---|---|---|---|
| 1 | Style 1 (Message Copy) | one cell-level block holding the RANParameters | cell |
| 2 | Style 4 (UE Information) | one block per UE — ue_id.gnb_cu_ue_f1ap_id carries the F1AP handover key when present |
ue:amf_ue_ngap_id:<n> |
| 3 | Style 3 (E2 Node Information) | one block per served cell | nr:<plmn>/0x<nci> or eutra:<plmn>/0x<nci> |
So a Style 1 subscription that also asked for UE_ID reports the UE as a
measurement, not as the block's identity — the block itself is
cell-scoped.
Narrowing what you get — INSERT filters
RC has no REPORT-side test conditions: a REPORT subscription reports every event of the styles you asked for. Narrowing happens on the INSERT side, where five filters pin the subscription to the handovers you care about. All are optional; setting none intercepts every handover.
| Filter | Matches |
|---|---|
set_target_cell_filter(cell_id) |
only handovers into this cell |
set_source_cell_filter(cell_id) |
only handovers out of this cell |
set_ho_type_filter(ho_type) |
only this handover type |
set_neighbour_pci_filter(pci) |
only this neighbour PCI |
set_rrc_ue_id_filter(rrc_ue_id) |
only this UE |
b = RcSubscriptionBuilder.new_insert()
b.set_target_cell_filter(target_cell_id) # gate only inbound HOs to one cell
b.set_source_cell_filter(source_cell_id) # …from one specific source
client.subscribe_rc(node, b)
Filters combine as AND — every one you set must match. Go uses the
same names Go-cased (SetTargetCellFilter, …); C uses
rc_sub_set_*_filter.
To narrow a REPORT stream, filter in the xApp on the values above, or subscribe per-UE with QUERY to learn the UE set first.
CONTROL — issuing a handover
An RC CONTROL Style 3 / Action 1 hands a UE over to a target cell. Identify the UE (a gNB UE ID with its GUAMI) and the target NR cell.
The identity must be echoed from the node's own indication, never
constructed: an AMF UE NGAP ID is unique only within its AMF, so a node
refuses a GUAMI it does not serve. hdr below is a header decoded from
an earlier indication (RcInsertHeader, or REPORT format 2).
from airpuls_ric_sdk import RcControlBuilder
b = RcControlBuilder(style_type=3, action_id=1)
b.set_ue_id_gnb(
amf_ue_ngap_id=hdr.ue_id_amf_ue_ngap_id,
plmn=hdr.guami.plmn,
amf_region_id=hdr.guami.amf_region_id,
amf_set_id=hdr.guami.amf_set_id,
amf_pointer=hdr.guami.amf_pointer,
)
b.set_target_cell_nr(
plmn=b"\x00\xf1\x10",
nr_cell_id=b"\x00\x00\x00\x01\x00",
)
outcome = client.control_rc(node, b, timeout_ms=5000)
rc_control_builder_t *cb = rc_control_builder_new(/*style_type=*/3, /*action_id=*/1);
rc_ctrl_set_ue_id_gnb(cb, hdr->ue_id_amf_ue_ngap_id, hdr->ue_id_guami_plmn,
hdr->ue_id_guami_amf_region_id,
hdr->ue_id_guami_amf_set_id,
hdr->ue_id_guami_amf_pointer);
rc_ctrl_set_target_cell_nr(cb, plmn, /*nr_cell_id[5]=*/nci);
sm_indication_data_t *outcome = NULL;
ric_client_control_rc(client, node, cb, &outcome, /*timeout_ms=*/5000);
/* outcome may be NULL — that is success, see the note below */
The Control Outcome is optional in E2AP — a missing outcome is
success, not an error. control_rc returns None / nil / leaves
*out_outcome NULL when the node acknowledged without one; NULL-check
before reading it.
A handover CONTROL needs a target cell
Style 3 / Action 1 without set_target_cell_nr (or
set_target_cell_eutra) has nowhere to hand the UE to. Set the UE
identity and the target cell on every standalone handover
control.
INSERT — gating a gNB-initiated handover
INSERT is the powerful RC pattern: the gNB is about to hand a UE over, the agent pauses it and asks the RIC, and your xApp rules on it. All three verdicts work end-to-end:
- drop — reply
REJECT; the UE stays on the source cell, the gNB's handover never starts. - accept — reply
ACCEPT; the gNB fires the handover toward the originally-captured target. - manipulate — reply
ACCEPTwith a different target cell; the gNB fires the handover toward the cell you chose.
Subscribe with the INSERT builder (optionally filtered to a target cell), then answer each INSERT indication with a CONTROL that echoes the Call Process ID — that is what distinguishes an INSERT response from a standalone CONTROL.
from airpuls_ric_sdk import (
RcSubscriptionBuilder, RcControlBuilder,
decode_rc_insert_header_fmt2, decode_rc_insert_message_fmt5,
decode_rc_call_process_id_fmt1,
)
RC_CONTROL_STYLE_HO = 3
RC_DECISION_ACCEPT, RC_DECISION_REJECT = 0, 1
class HoGate(BaseXApp):
def plugins(self):
return [rc_plugin_get()]
def on_e2_node_available(self, client, node):
b = RcSubscriptionBuilder.new_insert()
b.set_target_cell_filter(self._target_cell_id) # only HOs into this cell
client.subscribe_rc(node, b)
# header/message/cpi arrive as raw bytes — decode, decide, answer.
def on_insert_indication(self, client, sub_id, node, ran_func_id,
header_bytes, message_bytes, cpi_bytes):
hdr = decode_rc_insert_header_fmt2(header_bytes)
evt = decode_rc_insert_message_fmt5(message_bytes)
cpi = decode_rc_call_process_id_fmt1(cpi_bytes)
# hdr names the UE the way its arm defines it. Read only the
# fields the reported kind carries: a gNB header has the AMF
# UE NGAP ID and a GUAMI, and — on a CU-DU split, where
# E2SM-COMMON requires it — hdr.gnb_cu_ue_f1ap_id as well; a
# gNB-DU header has the F1AP ID alone. hdr.guami must be
# echoed rather than invented if you name this UE back to the
# node in a standalone control: an AMF UE NGAP ID is unique
# only within its AMF, so a node refuses a GUAMI it does not
# serve. An INSERT answer is keyed on the CPI instead.
b = RcControlBuilder(RC_CONTROL_STYLE_HO, action_id=1)
b.set_call_process_id(cpi_bytes) # echo the CPI — mandatory
if self._redirect_to: # manipulate
b.set_target_cell_nr(self._plmn, self._redirect_to)
b.set_decision(RC_DECISION_ACCEPT)
else: # drop
b.set_decision(RC_DECISION_REJECT)
client.control_rc(node, b)
// Subscribe for INSERT, pinned to handovers into one target cell.
ib := ric.NewRcInsertSubscriptionBuilder()
ib.SetTargetCellFilter(targetCellID)
subID, err := client.SubscribeRC(node, ib)
// Answer each INSERT: echo the CPI, set the decision, optionally
// redirect, then issue the CONTROL.
func (h *handler) OnInsertIndication(c *ric.Client, subID uint32,
node *ric.E2Node, ranFuncID uint16,
headerBytes, messageBytes, cpiBytes []byte) {
evt, err := ric.DecodeRcInsertMessageFmt5(messageBytes)
if err != nil {
return
}
cb := ric.NewRcControlBuilder(3, 1)
cb.SetCallProcessID(cpiBytes) // echo — mandatory
if h.redirectTo != nil { // manipulate
cb.SetTargetCellNR(h.plmn, *h.redirectTo).SetDecision(0 /*accept*/)
} else { // drop
cb.SetDecision(1 /*reject*/)
}
_, _ = c.ControlRC(node, cb, 5000)
_ = evt
}
/* Subscribe for INSERT with a target-cell filter. */
rc_subscription_builder_t *ib = rc_subscription_builder_new_insert();
rc_sub_set_target_cell_filter(ib, target_cell_id);
ric_client_subscribe_rc(client, node, ib, &sub_id);
/* In the on_insert_indication callback: echo the CPI, set the decision,
* optionally set a redirect target, then answer with a CONTROL. */
rc_control_builder_t *cb = rc_control_builder_new(/*style_type=*/3, /*action_id=*/1);
rc_ctrl_set_call_process_id(cb, cpi_buf, cpi_len); /* echo — mandatory */
rc_ctrl_set_target_cell_nr(cb, plmn, redirect_nci); /* manipulate only */
rc_ctrl_set_decision(cb, /*0=accept, 1=reject*/ 0);
ric_client_control_rc(client, node, cb, &outcome, /*timeout_ms=*/5000);
All three bindings implement the round-trip; the ho-cell-block xApp
exercises it in Python and C.
The Call Process ID is what makes it an INSERT response
A CONTROL that does not echo cpi_bytes is a standalone control —
the paused gNB procedure is never released and times out. Echo the
buffer verbatim; do not re-encode it.
QUERY — one-shot reads
QUERY rides the RIC Query procedure, not a subscription: you ask, the node answers once. Two styles are implemented.
Style 2 — known UEs
A late-starting xApp can enumerate the UEs that registered before it connected — the spec-sanctioned bootstrap that replaced On-Demand REPORT in v03.00. Each entry carries the UE's UEID plus the RAN parameters you asked for.
from airpuls_ric_sdk import RcQueryBuilder, RC_RANPARAM_RRC_STATE_CHANGED_TO
qb = RcQueryBuilder() # style 2 is the default
qb.add_ran_param(RC_RANPARAM_RRC_STATE_CHANGED_TO)
ues = client.query_rc(node, qb) # one-shot; consumes qb
for ue in ues:
# {"kind": int, "ue_id": int, "params": {ran_param_id: value|None}}
print(ue["ue_id"], ue["params"].get(RC_RANPARAM_RRC_STATE_CHANGED_TO))
rc_query_builder_t *qb = rc_query_builder_new(/*style_type=*/2);
rc_query_add_ran_param(qb, RC_RANPARAM_RRC_STATE_CHANGED_TO);
rc_query_outcome_t *out = NULL;
ric_client_query_rc(client, node, qb, &out, /*timeout_ms=*/5000);
/* iterate with rc_query_outcome_ue_count / _ue_id_value_at / _ue_param_*;
* free with rc_query_outcome_free(out) */
Style 1 — served cells
Style 1 returns the node's served-cell set: each entry carries the cell's
CGI (kind + PLMN + NR Cell Identity), the requested RAN parameters, and
an optional ServingCellConfigCommon.
from airpuls_ric_sdk import RcQueryBuilder, RC_QUERY_STYLE_CELL_INFO
qb = RcQueryBuilder(RC_QUERY_STYLE_CELL_INFO)
qb.add_ran_param(1) # the wire requires ≥1 id; see the note below
cells = client.query_rc_cells(node, qb)
for cell in cells:
print(cell["plmn"].hex(), hex(cell["nci"]),
"sccc" if cell["serving_cell_config"] else "-")
Why the placeholder RAN parameter on Style 1
The Query Definition requires at least one RAN parameter id on the
wire, but the cell identity and ServingCellConfigCommon ride
dedicated IEs rather than the parameter list — so any id works and
the per-cell parameter list comes back empty. rc-monitor passes
1 for exactly this reason.
Run a query on a cadence, from on_tick
QUERY is synchronous, and blocking from the dispatch thread is safe —
the internal wait pumps the event loop, so indications keep flowing.
Set xapp.tick_interval_ms and issue the query from
on_tick (Python) to keep a UE/cell catalogue current. A node that
does not advertise the style answers Query Failure; log it once per
failure streak rather than every interval.
Reference
| Concept | Python | Go | C |
|---|---|---|---|
| Plugin | rc_plugin_get() |
ric.RcPluginGet() |
rc_plugin_get() |
| RAN Function ID | RC_RAN_FUNC_ID (3) |
ric.RcRanFuncID |
RC_RAN_FUNC_ID |
| OID | RC_OID |
ric.RcOID |
RC_OID |
| REPORT builder | RcSubscriptionBuilder(RcStyle.STYLE_4) |
ric.NewRcSubscriptionBuilder(ric.RcStyle4) |
rc_subscription_builder_new(RC_STYLE_4) |
| RAN parameter | add_ran_param(id) |
AddRanParam(id) |
rc_sub_add_ran_param(b, id) |
| INSERT builder | RcSubscriptionBuilder.new_insert() |
ric.NewRcInsertSubscriptionBuilder() |
rc_subscription_builder_new_insert() |
| INSERT filters | set_target_cell_filter, set_source_cell_filter, set_ho_type_filter, set_neighbour_pci_filter, set_rrc_ue_id_filter |
same names, Go-cased | rc_sub_set_*_filter |
| Subscribe | client.subscribe_rc(node, b) |
client.SubscribeRC(node, b) |
ric_client_subscribe_rc(...) |
| Unsubscribe | client.unsubscribe(sub_id) |
client.Unsubscribe(subID) |
ric_client_unsubscribe(...) |
| Control builder | RcControlBuilder(3, 1) |
ric.NewRcControlBuilder(3, 1) |
rc_control_builder_new(3, 1) |
| Control | client.control_rc(node, b, timeout_ms) |
client.ControlRC(node, b, ms) |
ric_client_control_rc(...) |
| INSERT hook | on_insert_indication(...) |
OnInsertIndication(...) |
ric_client_set_on_insert_indication(...) |
| INSERT decoders | decode_rc_insert_header_fmt2, decode_rc_insert_message_fmt5, decode_rc_call_process_id_fmt1 |
ric.DecodeRcInsertHeaderFmt2, ric.DecodeRcInsertMessageFmt5, ric.DecodeRcCallProcessIDFmt1 |
rc_decode_indication_header_fmt2, rc_decode_indication_message_fmt5, rc_decode_call_process_id_fmt1 |
| Query — UEs | client.query_rc(node, qb) |
client.QueryRC(node, qb, ms) |
ric_client_query_rc(...) |
| Query — cells | client.query_rc_cells(node, qb) |
client.QueryRCCells(node, qb, ms) |
ric_client_query_rc_cells(...) |
Bundled examples
rc-monitor (REPORT 1+4 + periodic QUERY), cell-tracker (REPORT 3
cell discovery), ho-trigger (KPM-driven CONTROL handover), and
ho-cell-block (INSERT gate).
See xApps → Control and
Monitoring.