From 95109cfbdfeef3a05a100c8ec33981969d3011bf Mon Sep 17 00:00:00 2001 From: Vinod Patmanathan Date: Fri, 24 Apr 2026 11:43:30 +0200 Subject: [PATCH] feat: add NR NTN access detection Detect Non-Terrestrial Network access by checking for the NRNTN-TAI-Information protocol extension (ID 287 per 3GPP TS 38.413) in UserLocationInformationNR received via NGAP, and track the result in a new NtnAccessInfo struct on AmfUe and RanUe. This is detection-only groundwork. No protocol behavior changes: nothing reads the flag yet. Future patches can extend NtnAccessInfo with satellite backhaul category, GEO satellite ID, and NTN TAI list as those fields become available in omec-project/openapi and omec-project/ngap. Signed-off-by: Vinod Patmanathan --- context/amf_ue.go | 1 + context/ntn.go | 52 ++++++++++++++++++++++++++ context/ran_ue.go | 15 +++++++- context/ran_ue_test.go | 85 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 context/ntn.go create mode 100644 context/ran_ue_test.go diff --git a/context/amf_ue.go b/context/amf_ue.go index f62db9a4..ddcdcfa2 100644 --- a/context/amf_ue.go +++ b/context/amf_ue.go @@ -101,6 +101,7 @@ type AmfUe struct { EventSubscriptionsInfo map[string]*AmfUeEventSubscription `json:"eventSubscriptionInfo,omitempty"` /* User Location*/ RatType models.RatType `json:"ratType,omitempty"` + NtnAccess *NtnAccessInfo `json:"ntnAccess,omitempty"` Location models.UserLocation `json:"location,omitempty"` Tai models.Tai `json:"tai,omitempty"` LocationChanged bool `json:"locationChanged,omitempty"` diff --git a/context/ntn.go b/context/ntn.go new file mode 100644 index 00000000..fb63e22f --- /dev/null +++ b/context/ntn.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB +// +// SPDX-License-Identifier: Apache-2.0 + +package context + +// NtnAccessInfo captures Non-Terrestrial Network access properties for a +// UE, as derived from NGAP signaling. Only NTN-access presence is tracked +// today; future 3GPP-defined fields (satelliteBackhaulCategory, +// geoSatelliteId, NTN TAI list) can be added without reshaping AmfUe or +// RanUe. +type NtnAccessInfo struct { + // Detected is set true the first time the NRNTN-TAI-Information + // protocol extension is observed in a UserLocationInformationNR for + // this UE. It is sticky: once set, it is not cleared while the UE + // context lives. + // + // The extension is PRESENCE optional per 3GPP TS 38.413, and gNBs + // include it at their discretion — typically on context-establishing + // events (initial UE message, handover) but often not on routine + // uplink/location reports. Treating an absent extension as evidence + // of "not NTN" would cause IsNtn() to flap on routine NGAP traffic + // for a UE that genuinely is on NTN; sticky-on avoids that. + Detected bool `json:"detected"` +} + +// IsNtn reports whether NTN access has been observed for this UE during +// the current UE context. The flag is sticky for the session — see +// NtnAccessInfo.Detected for the rationale. +func (ue *AmfUe) IsNtn() bool { + return ue.NtnAccess != nil && ue.NtnAccess.Detected +} + +// updateNtnAccess records observed NTN access on this RanUe. +// +// detected==false is a no-op: the NRNTN-TAI-Information extension is +// PRESENCE optional per 3GPP TS 38.413, so an absent extension does not +// imply the UE has left NTN access. The flag is sticky for the lifetime +// of the UE context. +func (ranUe *RanUe) updateNtnAccess(detected bool) { + if !detected { + return + } + if ranUe.NtnAccess != nil && ranUe.NtnAccess.Detected { + return + } + if ranUe.NtnAccess == nil { + ranUe.NtnAccess = &NtnAccessInfo{} + } + ranUe.NtnAccess.Detected = true + ranUe.Log.Debugf("NR NTN access detected (NRNTN-TAI-Information extension present)") +} diff --git a/context/ran_ue.go b/context/ran_ue.go index de3bc2f1..85ff3df2 100644 --- a/context/ran_ue.go +++ b/context/ran_ue.go @@ -15,6 +15,7 @@ import ( "github.com/mohae/deepcopy" "github.com/omec-project/amf/logger" + "github.com/omec-project/ngap/v2" "github.com/omec-project/ngap/v2/ngapConvert" "github.com/omec-project/ngap/v2/ngapType" "github.com/omec-project/openapi/v2" @@ -47,8 +48,9 @@ type RanUe struct { TargetUe *RanUe `json:"-"` /* UserLocation*/ - Tai models.Tai - Location models.UserLocation + Tai models.Tai + Location models.UserLocation + NtnAccess *NtnAccessInfo `json:"-"` /* context about udm */ SupportVoPSn3gpp bool `json:"-"` SupportVoPS bool `json:"-"` @@ -237,12 +239,21 @@ func (ranUe *RanUe) UpdateLocation(userLocationInformation *ngapType.UserLocatio if locationInfoNR.TimeStamp != nil { ranUe.Location.NrLocation.AgeOfLocationInformation = openapi.PtrInt32(ngapConvert.TimeStampToInt32(locationInfoNR.TimeStamp.Value)) } + + // NTN detection runs only after the NR location has parsed cleanly, + // so a malformed message (early return above) does not desync NTN + // state from the stored location/Tai. + ranUe.updateNtnAccess(ngap.HasUserLocationInformationNRExtension(locationInfoNR, ngapType.ProtocolIEIDNRNTNTAIInformation)) + if ranUe.AmfUe != nil { if ranUe.AmfUe.Tai != ranUe.Tai { ranUe.AmfUe.LocationChanged = true } ranUe.AmfUe.Location = deepcopy.Copy(ranUe.Location).(models.UserLocation) ranUe.AmfUe.Tai = deepcopy.Copy(ranUe.AmfUe.Location.NrLocation.Tai).(models.Tai) + if ranUe.NtnAccess != nil { + ranUe.AmfUe.NtnAccess = deepcopy.Copy(ranUe.NtnAccess).(*NtnAccessInfo) + } } case ngapType.UserLocationInformationPresentUserLocationInformationN3IWF: locationInfoN3IWF := userLocationInformation.UserLocationInformationN3IWF diff --git a/context/ran_ue_test.go b/context/ran_ue_test.go new file mode 100644 index 00000000..77357b1e --- /dev/null +++ b/context/ran_ue_test.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Forsway Scandinavia AB +// +// SPDX-License-Identifier: Apache-2.0 + +package context + +import ( + "testing" + + "go.uber.org/zap" +) + +func TestRanUeUpdateNtnAccess(t *testing.T) { + tests := []struct { + name string + initial *NtnAccessInfo + detected bool + wantPresent bool + wantDetected bool + }{ + { + name: "nil + not-detected stays nil", + initial: nil, + detected: false, + wantPresent: false, + }, + { + name: "nil + detected allocates true", + initial: nil, + detected: true, + wantPresent: true, + wantDetected: true, + }, + { + name: "detected + detected stays true", + initial: &NtnAccessInfo{Detected: true}, + detected: true, + wantPresent: true, + wantDetected: true, + }, + { + name: "detected + not-detected stays true (sticky)", + initial: &NtnAccessInfo{Detected: true}, + detected: false, + wantPresent: true, + wantDetected: true, + }, + { + name: "not-detected + detected sets true", + initial: &NtnAccessInfo{Detected: false}, + detected: true, + wantPresent: true, + wantDetected: true, + }, + { + // Unreachable in production (we never allocate with Detected=false), + // but documents the function's contract: sticky means a !detected + // signal never modifies state. + name: "not-detected + not-detected stays false", + initial: &NtnAccessInfo{Detected: false}, + detected: false, + wantPresent: true, + wantDetected: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ranUe := &RanUe{ + NtnAccess: tc.initial, + Log: zap.NewNop().Sugar(), + } + ranUe.updateNtnAccess(tc.detected) + + if got := ranUe.NtnAccess != nil; got != tc.wantPresent { + t.Fatalf("NtnAccess presence: got %v, want %v", got, tc.wantPresent) + } + if !tc.wantPresent { + return + } + if ranUe.NtnAccess.Detected != tc.wantDetected { + t.Errorf("Detected = %v, want %v", ranUe.NtnAccess.Detected, tc.wantDetected) + } + }) + } +}