Runtime Commands
The gNB components of a Racora cell — CNS OCUDU — expose a runtime
remote-control interface: JSON commands over WebSocket, port 8001. The
CU-CP's instance is the operational one — it carries the cell lifecycle
(lock/bar), the runtime mobility configuration, and the handover
triggers. Every command below is in the gnb image Racora ships. The cell lifecycle
commands (lock, unlock, bar, unbar, status) and sib_update are upstream OCUDU's; the
mobility configuration and handover-trigger commands and pci_set are CNS OCUDU's
(what CNS OCUDU adds).
Normal operation does not need this surface. The NRCell spec is the
operator interface — see
Configure Mobility and Cell State — and
the controller projects spec changes into the boot overlay and applies
them to the running CU-CP over these same commands (no restarts). This
page documents the machinery itself: what the gNB image is capable of.
Reach for direct WS access for inspection (cell_status), for actions
with no declarative form (the handover triggers), and as an escape hatch.
Reaching the Surface
In-cluster, the CU-CP answers at
ws://cu-cp.centralized-unit.svc.cluster.local:8001 (Service port
metrics-ws). The convenient interactive client:
# needs Docker Hub and npm; on a mirrored cluster, `kubectl -n centralized-unit port-forward svc/cu-cp 8001` and a local wscat instead
kubectl run wsclient --rm -it --restart=Never --image=node:22-alpine \
-n centralized-unit -- sh -c 'npx --yes wscat -c ws://cu-cp:8001'
Requests are single-line JSON: {"cmd":"<name>", ...args}. Success
replies echo {"cmd","timestamp"} (plus "result" where applicable);
failures reply {"cmd","error","timestamp"}.
DUs run the same server (see DU commands), but no
Service exposes it — use kubectl exec into the DU pod. Racora applies
PCI changes through the NRCell spec, not this path.
Two Command Semantics
Config commands are synchronous. The reply carries the real outcome:
an ack means the change is live in the CU-CP registry now; an error
means nothing changed. Applies to: cell_lock/unlock/bar/unbar,
mobility_cell_set/remove, neighbor_add/remove,
report_config_set/remove, periodic_report_set.
Trigger commands are fire-and-forget. For trigger_handover and
trigger_conditional_handover the ack means only "arguments parsed" —
a trigger against a stale RNTI or unknown target acks successfully and
then logs a warning. The outcome exists only in the CU-CP log
(/tmp/cu_cp.log in the pod, mirrored to ClickHouse). Success chain,
in order:
ue=N: Trigger intra-CU (inter-DU) handover from source_du=A to target_du=B
ue=N: "Intra CU Handover Routine" finished successfully
ue=M: "Intra CU Handover Target Routine" finished successfully
Two more properties of every config command:
- No push to connected UEs. Runtime mobility changes reach a UE's measConfig only at its next RRC reconfiguration (What a Connected UE Sees).
- Not persisted by the gNB. Runtime state dies with the CU-CP process. The controller's boot overlay (from NRCell spec) is what restarts converge to — manual WS changes that should survive belong in the spec.
Command Reference
Every command is one JSON object: cmd plus its arguments. An optional
top-level traceparent (W3C) makes the command's span a child of that
trace. Success replies echo cmd and add timestamp (cell_status also
adds result); a failure replies error instead. NCIs are decimal
integers on the wire (kubectl get nrcell shows the hex form in its NCI
column; status.nciDecimal holds this one), PLMNs are 5- or 6-digit strings,
PCIs are 0–1007.
The envelope is validated before any command: an unknown cmd replies
Unknown command type: <cmd>, a missing cmd replies
'cmd' object is missing and it is mandatory, a non-string cmd
replies 'cmd' object value type should be a string, and malformed JSON
replies an error with no cmd key at all.
Cell Lifecycle (CU-CP, CGI-Addressed)
All five take the cell's global identity, cgi = {plmn, nci}:
{"cmd": "cell_lock", "cgi": {"plmn": "90170", "nci": 6733824}}
| Command | Effect | Rejection |
|---|---|---|
cell_lock | Graceful stop: bar → release UEs → deactivate → radio stop. The CU-CP keeps the intent across DU restarts until an explicit cell_unlock. Declarative form: spec.adminState: Locked. | CU-CP rejected cell_lock: no served DU matches the provided CGI, or scheduling failed |
cell_unlock | Reactivates the cell (spec.adminState: Unlocked). | CU-CP rejected cell_unlock: … (same wording) |
cell_bar / cell_unbar | Sets / clears MIB cellBarred: the cell stays on air, UEs may not camp. Tracked independently of the lock. Declarative: spec.cellBarred. | CU-CP rejected cell_bar: … / cell_unbar: … |
cell_status | Reads the cell back: "result": {"admin_state": "locked" | "unlocked", "operational_state": "enabled" | "disabled", "cell_barred": true | false}. Matches by NCI alone. | CU-CP has no cell matching the provided CGI, or the state read failed |
The cgi schema errors: 'cgi' object is missing and it is mandatory,
'cgi.plmn' object is missing and it is mandatory, 'cgi.nci' object value type should be an unsigned integer, Invalid PLMN identity value,
Invalid NR cell identity value. Note that cell_lock rejects a PLMN
that does not equal the cell's spec.plmn exactly, while cell_status
still answers — the most common "lock does nothing" cause.
Runtime Mobility Configuration (CU-CP, NCI-Addressed)
neighbor_add — a directional relation; declare both ways for
symmetric mobility. Declarative: spec.neighbors.
{"cmd": "neighbor_add", "nci": 6733824, "neighbor_nci": 6733825, "report_configs": [2]}
| Key | Required | Value |
|---|---|---|
nci, neighbor_nci | yes | unsigned integers, valid NCIs (Invalid NR cell identity value in '<key>') |
report_configs | yes | non-empty array of report config ids 1–63 ('report_configs' entries must be in range [1, 63]); each must exist and be event-triggered |
Rejection: CU-CP rejected neighbor_add: unknown cell, or invalid report config reference.
neighbor_remove — {"cmd": "neighbor_remove", "nci": 6733824, "neighbor_nci": 6733825}.
Rejection: CU-CP rejected neighbor_remove: no such neighbor relation.
report_config_set — upsert of a CU-wide measurement report
configuration; retunes a live A3 in place. Declarative:
spec.mobility.reportConfigs.
{"cmd": "report_config_set", "report_cfg_id": 2, "report_type": "event_triggered",
"event_triggered_report_type": "a3", "meas_trigger_quantity": "rsrp",
"meas_trigger_quantity_offset_db": 3, "hysteresis_db": 0,
"time_to_trigger_ms": 100, "report_interval_ms": 1024}
| Key | Required | Value |
|---|---|---|
report_cfg_id | yes | 1–63 |
report_type | yes | periodical | event_triggered | cond_trigger |
event_triggered_report_type | for events | a1 … a6. d1, d2, t1 are refused here (Distance and time based events (d1, d2, t1) are not supported through this command) — declare those through the NRCell |
meas_trigger_quantity | no | rsrp | rsrq | sinr |
meas_trigger_quantity_threshold_db, meas_trigger_quantity_threshold_2_db, meas_trigger_quantity_offset_db | no | integers (A1/A2/A4/A5 threshold, A5 second threshold, A3/A6 offset) |
hysteresis_db | no | 0–15 |
time_to_trigger_ms | no | 0, 40, 64, 80, 100, 128, 160, 256, 320, 480, 512, 640, 1024, 1280, 2560, 5120 |
report_interval_ms | for periodical and event_triggered | 120, 240, 480, 640, 1024, 2048, 5120, 10240, 20480, 40960, 60000, 360000, 720000, 1800000 |
t312 | no | 0, 50, 100, 200, 300, 400, 500, 1000 |
periodic_ho_rsrp_offset_db | no | −1 … 30 (−1 disables handover from periodical reports) |
Rejections: Invalid report configuration (see CU-CP log for the cause) and CU-CP rejected report_config_set: type conflicts with existing references (a config's type class cannot change while a
relation or a periodic report references it — use a new id).
report_config_remove — {"cmd": "report_config_remove", "report_cfg_id": 3}.
Rejection: CU-CP rejected report_config_remove: unknown id, or still referenced.
periodic_report_set — the serving cell's periodical report; omit
report_cfg_id to clear it. Declarative: spec.periodicReportCfgId.
{"cmd": "periodic_report_set", "nci": 6733824, "report_cfg_id": 1}
Rejection: CU-CP rejected periodic_report_set: unknown cell or non-periodical report config.
mobility_cell_set — declares or updates an external cell
(another gNB's) in the mobility registry. On a local cell the next F1
Setup overwrites it, and a partial set degrades the entry to incomplete
until then.
{"cmd": "mobility_cell_set", "nci": 6750208, "gnb_id_bit_length": 22, "plmn": "90170",
"pci": 7, "tac": 7, "band": 3, "ssb_arfcn": 368410, "ssb_scs": 15,
"ssb_period": 20, "ssb_offset": 0, "ssb_duration": 1}
| Key | Required | Value |
|---|---|---|
nci | yes | the external cell's NCI |
gnb_id_bit_length | yes | 22–32 |
plmn | no | string |
pci | no | 0–1007 |
tac | no | 1–16777213 (0xfffffd) |
band, ssb_arfcn | no | unsigned integers |
ssb_scs | no | 15, 30, 60, 120, 240 |
ssb_period, ssb_offset, ssb_duration | together or not at all | period 5, 10, 20, 40, 80, 160 ms; offset < period; duration 1–5 |
Rejection: CU-CP rejected mobility_cell_set.
mobility_cell_remove — {"cmd": "mobility_cell_remove", "nci": 6750208}.
Cascades: relations pointing at the cell are removed first. Rejection:
CU-CP rejected mobility_cell_remove: no cell with the provided NCI.
Handover Triggers (CU-CP, Fire-and-Forget)
{"cmd": "trigger_handover", "serving_pci": 1, "rnti": 17921, "target_pci": 0,
"plmn": "90170", "tac": 7}
All five keys are required (serving_pci, target_pci 0–1007; rnti
0–65535; tac 1–16777213; plmn a string). (serving_pci, rnti)
selects the UE; target_pci resolves against all cells the CU knows:
a local match runs an intra-CU handover, no local match takes the
inter-CU path (the only place plmn/tac are consumed). The ack means
"arguments parsed"; the outcome is in the CU-CP log and on the command's
span (mobility.trigger_outcome).
{"cmd": "trigger_conditional_handover", "serving_pci": 1, "rnti": 17921,
"target_pcis": [2, 3], "timeout_ms": 5000}
target_pcis is 1–8 PCIs; timeout_ms (optional) 1–600000; t1_thres
(optional, a timestamp string) overrides the T1 threshold. Arms Rel-16
conditional handover — requires UE CHO capability, aborts gracefully
without it.
The RNTI workflow: the CU log prints it in hex
(grep "Updated UE with" /tmp/cu_cp.log), the command takes decimal,
and it changes on every re-attach and every handover — always
re-read immediately before triggering.
DU Commands
Each DU serves the same protocol on its own port 8001 (no Service; use
kubectl exec into the DU pod). CNS OCUDU adds pci_set, a DU
command surface for changing a cell's PCI: in this release the DU accepts the request and
does not change the cell's PCI. Racora does not
use it: a PCI decision patches spec.pci, and the controller regenerates and rolls the DU
under a cell lock. Do not call it by hand on a Racora cell; the controller owns spec.pci:
{"cmd": "pci_set", "cells": [{"plmn": "90170", "nci": 6733824, "pci": 5}]}
The other DU commands — ssb_set (cells[].ssb_block_power_dbm),
sib_update (cells[].sib.{type, content}), rrm_policy_ratio_set
(policies[]) and ntn_config_update — are upstream OCUDU's and are
documented at docs.ocudu.org.
Reading Rejections — The Error Contract
Validation is two-layer, and the reply tells you which layer spoke:
- Precise message (
'tac' must be in range [1, 0xfffffd],'report_configs' entries must be in range [1, 63], …): the schema layer rejected it; the CU-CP never saw the command. - Coarse message (
CU-CP rejected <cmd>: …): the CU-CP's state validation refused it — the precise cause is in the CU-CP log.
Common coarse rejections and their log-side causes:
| WS error (coarse) | CU log causes |
|---|---|
neighbor_add: unknown cell, or invalid report config reference | No cell config for neighbor nci=… · A cell cannot neighbor itself · Report config id=N is periodical (serving cell only) |
report_config_set: type conflicts with existing references | Referenced by a neighbor relation of nci=… · Used as periodic report of nci=… |
report_config_remove: unknown id, or still referenced | No report config with this id or Referenced by a neighbor relation of nci=… — the WS string does not distinguish; check the log |
periodic_report_set: unknown cell or non-periodical report config | Report config id=N is not periodical · No cell config for nci=… |
cell_lock: no served DU matches the provided CGI, or scheduling failed | most often a PLMN mismatch — the CGI's plmn must equal the cell's spec.plmn exactly (note: cell_status matches by NCI alone and will "work" with a wrong PLMN; cell_lock will not) |
CU-CP has no cell matching the provided CGI, or the state read failed (cell_status) | unknown NCI |
mobility_cell_remove: no cell with the provided NCI | Cannot remove cell nci=… Cause: No cell config for this NCI |
The envelope errors are listed with the command reference above. The
controller depends on the first of them: an Unknown command type reply
is how it classifies a gnb image that predates a command as
unavailable (tolerated), not as a failure.
Known Failure Modes
- Handover triggered at a locked cell is rejected cleanly: the
trigger acks (arguments parsed), the CU-CP logs
Ignoring Handover Request. Cause: Target cell with pci=N is administratively deactivated, and the UE stays put. - Locked cells linger as neighbors. Locking does not touch other cells' measurement configuration (Administrative State).
- A forced handover against the RF gradient does not stick unless
the A3 policy allows it: with symmetric neighbor config and
trigger_handover_from_measurementson, the automation hands the UE back within about a second. Durable forced placement requires a measurement dead-band (offset/hysteresis), a removed relation, or the UE genuinely sitting between cells. - "My runtime change did nothing" — check the two semantics first: connected UEs need churn to receive measConfig updates, and a CU-CP restart since the change means it's gone (re-apply, or put it in the spec where it belongs).
- Ping-pong between adjacent cells is correct behavior at
hysteresis 0— each bounce is a successful handover tracking the strongest cell. Tune the trade live:report_config_setwith a highermeas_trigger_quantity_offset_dband/orhysteresis_dbwidens the dead-band.
Observing Outcomes
Every command logs its effect in the CU-CP's log file and in ClickHouse, the "RAN
Mobility" dashboard counts the handover markers, and every command executes inside a
cucp.<cmd>.execute span that a traceparent on the payload parents to your trace.
Where each of these is and how to query them is Read Logs and Traces;
the span names and attributes are the span schema.