Commit 4ce5e9d5 authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

Merge branch 'feature/location-retrieval-oai' into 'main'

added oai location retrieval through monitoring events, ip to external id...

See merge request !196
parents aff7f212 4579f384
Loading
Loading
Loading
Loading
Loading
+6 −3
Original line number Diff line number Diff line
@@ -3,12 +3,17 @@ stages:
  - build
  - publish

default:
  image: python:3.12-slim
  tags:
    - docker
    - vim

variables:
  PACKAGE_NAME: sunrise6g-opensdk

validate-mr:
  stage: validate
  image: python:3.12-slim
  before_script:
    - echo "Running merge request validation..."
    - pip install -r requirements.txt
@@ -27,7 +32,6 @@ validate-mr:

build-package:
  stage: build
  image: python:3.12-slim
  before_script:
    - pip install build twine
    - pip install -r requirements.txt
@@ -48,7 +52,6 @@ build-package:

publish-gitlab:
  stage: publish
  image: python:3.12-slim
  dependencies:
    - build-package
  before_script:
+6 −1
Original line number Diff line number Diff line
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "sunrise6g-opensdk"
version = "1.0.22"
version = "1.1.0"
description = "Open source SDK to abstract CAMARA/GSMA Transformation Functions (TFs) for Edge Cloud platforms, 5G network cores and Open RAN solutions."
keywords = [
  "Federation",
@@ -62,5 +62,10 @@ package-dir = {"" = "src"}
where = ["src"]
include = ["sunrise6g_opensdk*"]

[tool.pytest.ini_options]
markers = [
  "integration: tests that require a live NEF stack (deselected by default)",
]

[tool.setuptools.package-data]
sunrise6g_opensdk = ["py.typed"]
+374 −1
Original line number Diff line number Diff line
@@ -6,7 +6,13 @@
# Contributors:
#   - Giulio Carota (giulio.carota@eurecom.fr)
##
from datetime import datetime, timedelta, timezone

import requests

from sunrise6g_opensdk import logger
from sunrise6g_opensdk.network.adapters.errors import NetworkPlatformError
from sunrise6g_opensdk.network.core import schemas
from sunrise6g_opensdk.network.core.base_network_client import BaseNetworkClient
from sunrise6g_opensdk.network.core.schemas import (
    AsSessionWithQoSSubscription,
@@ -27,16 +33,18 @@ class NetworkManager(BaseNetworkClient):
    CAMARA APIs into specific HTTP requests understandable by the OAI NEF API.
    """

    capabilities = {"qod", "traffic_influence"}
    capabilities = {"qod", "traffic_influence", "location_retrieval"}

    def __init__(self, base_url: str, scs_as_id: str = None):
        try:
            super().__init__()
            self.base_url = base_url
            self.scs_as_id = scs_as_id
            self.identity_service_url = f"{base_url.rstrip('/')}/3gpp-ueid/v1"
            log.info(
                f"Initialized OaiNefClient with base_url: {self.base_url} and scs_as_id: {self.scs_as_id}"
            )
            log.info(f"NEF UE-ID API URL: {self.identity_service_url}")

        except Exception as e:
            log.error(f"Failed to initialize OaiNefClient: {e}")
@@ -109,6 +117,371 @@ class NetworkManager(BaseNetworkClient):
        ):
            raise OaiValidationError("OAI requires UE IPv4 Address to activate Traffic Influence")

    # ---- Location Retrieval (Monitoring Event) ----

    def _resolve_ue_external_id(self, ip_address: str) -> str:
        """
        Resolve a UE's IP address to the NEF ExternalId via the 3GPP
        UE Identifier API (TS 29.522).

        Calls ``POST {identity_service_url}/retrieve`` on the NEF's
        ``/3gpp-ueid/v1`` northbound endpoint with the UE's IPv4 address
        and retrieves the ``externalId`` from the response.

        args:
            ip_address: IPv4 address of the UE (e.g. '12.1.0.1').

        returns:
            The NEF ExternalId string.

        raises:
            OaiValidationError: If the UE-ID API URL is not configured.
            NetworkPlatformError: If the lookup fails.
        """
        if not self.identity_service_url:
            raise OaiValidationError(
                "UE-ID API URL (identity_service_url) is required for OAI "
                "location retrieval. Set it to the NEF /3gpp-ueid/v1 base URL."
            )
        url = f"{self.identity_service_url.rstrip('/')}/retrieve"
        body = {
            "afId": self.scs_as_id,
            "ueIpAddr": {"ipv4Addr": ip_address},
            "snssai": {"sst": 1, "sd": "FFFFFF"},
        }
        log.debug(f"Resolving UE ExternalId via NEF UE-ID API: POST {url}")
        try:
            resp = requests.post(
                url,
                json=body,
                headers={"accept": "application/json"},
                timeout=10,
            )
            resp.raise_for_status()
            data = resp.json()
        except requests.exceptions.RequestException as e:
            raise NetworkPlatformError(
                f"Failed to resolve UE ExternalId for IP {ip_address}: {e}"
            ) from e

        external_id = data.get("externalId")
        if not external_id:
            raise NetworkPlatformError(
                f"NEF UE-ID API did not return externalId for IP {ip_address}: {data}"
            )
        log.info(f"Resolved UE IP {ip_address} → externalId {external_id}")
        return external_id

    def core_specific_monitoring_event_validation(
        self, retrieve_location_request: schemas.RetrievalLocationRequest
    ) -> None:
        """
        Validates OAI-specific parameters for location retrieval via NEF monitoring events.

        OAI NEF only supports networkAccessIdentifier (pre-resolved ExternalId) or
        ipv4Address (resolved via the NEF UE-ID API at /3gpp-ueid/v1/retrieve).
        phoneNumber and ipv6Address are not supported by the OAI southbound and will
        be rejected early here rather than failing later in the build step.

        args:
            retrieve_location_request: The CAMARA location retrieval request to validate.

        raises:
            OaiValidationError: If the request does not meet OAI-specific requirements.
        """
        if retrieve_location_request.device is None:
            raise OaiValidationError(
                "OAI requires a device to be specified for location retrieval."
            )
        device = retrieve_location_request.device
        has_supported_identifier = (
            device.networkAccessIdentifier is not None or device.ipv4Address is not None
        )
        if not has_supported_identifier:
            raise OaiValidationError(
                "OAI requires a supported device identifier for location retrieval. "
                "Provide ipv4Address (recommended) or a pre-resolved "
                "networkAccessIdentifier (ExternalId)."
            )

    def add_core_specific_location_parameters(
        self, retrieve_location_request: schemas.RetrievalLocationRequest
    ) -> schemas.MonitoringEventSubscriptionRequest:
        """
        Build OAI-specific monitoring event subscription request for location retrieval.

        OAI NEF does not accept the locationType field. The subscription is configured
        for a single immediate report.

        args:
            retrieve_location_request: The CAMARA location retrieval request.

        returns:
            MonitoringEventSubscriptionRequest populated with OAI-specific parameters.
        """
        expire_time = datetime.now(timezone.utc) + timedelta(hours=1)
        return schemas.MonitoringEventSubscriptionRequest(
            # notification destination is harded coded because it is not used currently but is needed as a placeholder, could later be added as a variable
            notificationDestination="http://localhost:8080/callback",
            monitoringType=schemas.MonitoringType.LOCATION_REPORTING,
            maximumNumberOfReports=1,
            monitorExpireTime=expire_time,
        )

    def _build_monitoring_event_subscription(
        self, retrieve_location_request: schemas.RetrievalLocationRequest
    ) -> schemas.MonitoringEventSubscriptionRequest:
        """
        Override base class to handle OAI-specific identity resolution.

        The OAI NEF monitoring-event service requires an opaque ExternalId.
        If the CAMARA request provides an IPv4 address, this method resolves
        it to the ExternalId via the NEF UE-ID API (/3gpp-ueid/v1/retrieve).
        Alternatively, a pre-resolved ExternalId can be passed directly via the
        networkAccessIdentifier field.

        args:
            retrieve_location_request: The CAMARA location retrieval request.

        returns:
            MonitoringEventSubscriptionRequest ready for the OAI NEF.
        """
        self.core_specific_monitoring_event_validation(retrieve_location_request)
        subscription = self.add_core_specific_location_parameters(retrieve_location_request)

        device = retrieve_location_request.device

        # Resolve ExternalId: prefer pre-resolved networkAccessIdentifier,
        # otherwise resolve from IPv4 via the NEF UE-ID API
        if device.networkAccessIdentifier is not None:
            subscription.externalId = device.networkAccessIdentifier.root
            log.debug(
                f"Using pre-resolved ExternalId from networkAccessIdentifier: "
                f"{subscription.externalId}"
            )
        elif device.ipv4Address is not None:
            ip_str = str(device.ipv4Address.root.publicAddress.root)
            subscription.externalId = self._resolve_ue_external_id(ip_str)
        else:
            raise OaiValidationError(
                "OAI location retrieval requires either a pre-resolved "
                "networkAccessIdentifier (ExternalId) or an ipv4Address "
                "that can be resolved via the NEF UE-ID API."
            )

        return subscription

    def _parse_location_response(self, response: dict) -> schemas.Location:
        """
        Parse OAI NEF monitoring event response into CAMARA Location format.

        The OAI NEF may return either:
        - A direct MonitoringEventReport (with locationInfo at top level)
        - A subscription response wrapping monitoringEventReports

        Location data may be:
        - Geographic area (Point with uncertainty → CAMARA Circle, or Polygon)
        - Cell-level only (cellId, nrLocation with NCGI/TAI → CAMARA Circle
          centred at 0,0 with cellId in metadata – best-effort)

        args:
            response: Raw JSON response dict from the OAI NEF.

        returns:
            CAMARA Location object.
        """
        # Extract the report from the response (may be wrapped or direct)
        reports = response.get("monitoringEventReports", [])
        if reports:
            report_data = reports[0]
        elif "locationInfo" in response or "locationInformation" in response:
            report_data = response
        else:
            log.error(f"No location data in OAI NEF response: {response}")
            raise NetworkPlatformError(
                "No location data found in OAI NEF monitoring event response"
            )

        # Extract location info (handle both key names)
        location_info = report_data.get("locationInfo") or report_data.get("locationInformation")
        if not location_info:
            raise NetworkPlatformError(
                "Location information not found in OAI NEF monitoring event report"
            )

        # Parse event time
        event_time_raw = report_data.get("eventTime") or report_data.get("timeStamp")
        if event_time_raw:
            if isinstance(event_time_raw, str):
                event_time = datetime.fromisoformat(event_time_raw.replace("Z", "+00:00"))
            else:
                event_time = event_time_raw
        else:
            event_time = datetime.now(timezone.utc)

        # Parse age of location info – the OAI NEF returns it as a plain integer
        # (minutes) inside userLocation.nrLocation.ageOfLocationInformation,
        # or as ageOfLocationInfo at the locationInfo level.
        age_minutes = None
        age_info = location_info.get("ageOfLocationInfo")
        if age_info is not None:
            if isinstance(age_info, dict):
                age_minutes = age_info.get("duration")
            elif isinstance(age_info, (int, float)):
                age_minutes = int(age_info)

        # Also check the nrLocation-level age
        user_loc = location_info.get("userLocation", {})
        nr_loc = user_loc.get("nrLocation", {})
        if age_minutes is None and nr_loc:
            nr_age = nr_loc.get("ageOfLocationInformation")
            if isinstance(nr_age, (int, float)):
                age_minutes = int(nr_age)

        last_location_time = self._compute_camara_last_location_time(event_time, age_minutes)
        log.debug(f"OAI Last Location time is {last_location_time}")

        # --- Build CAMARA area from available location data ---

        # Option 1: geographicArea is present (Point or Polygon)
        geo_area = location_info.get("geographicArea")
        if geo_area:
            area = self._parse_geographic_area(geo_area)
            return schemas.Location(area=area, lastLocationTime=last_location_time)

        # Option 2: Cell-level location only (cellId / nrLocation)
        # The core simulator (and some real deployments) may only return cell
        # identifiers without geographic coordinates. We return a CAMARA Circle
        # at (0, 0) with a large radius to signal cell-level accuracy. In a
        # production deployment with a real core the geographicArea path above
        # would be taken instead.
        cell_id = location_info.get("cellId")
        if nr_loc or cell_id:
            log.warning(
                "OAI NEF returned cell-level location only (no geographic coordinates). "
                f"cellId={cell_id}, ncgi={nr_loc.get('ncgi')}, tai={nr_loc.get('tai')}. "
                "Returning CAMARA Circle with cell-level accuracy."
            )
            area = schemas.Circle(
                areaType=schemas.AreaType.circle,
                center=schemas.Point(latitude=0.0, longitude=0.0),
                radius=50000.0,  # ~50 km – coarse cell-level accuracy
            )
            return schemas.Location(area=area, lastLocationTime=last_location_time)

        raise NetworkPlatformError(
            f"No usable location data in OAI NEF response. " f"locationInfo: {location_info}"
        )

    @staticmethod
    def _extract_lat_lon(point_data: dict) -> tuple[float, float]:
        """
        Extract latitude and longitude from a point dict.

        Supports both 3GPP standard keys (lat/lon) and verbose keys
        (latitude/longitude) for forward-compatibility.
        """
        lat = float(point_data.get("lat", point_data.get("latitude", 0)))
        lon = float(point_data.get("lon", point_data.get("longitude", 0)))
        return lat, lon

    @staticmethod
    def _parse_geographic_area(geo_area: dict):
        """
        Parse a NEF geographicArea dict into a CAMARA area (Circle or Polygon).

        Handles all standard 3GPP shape types from TS 29.572 (Nlmf_Location):
        - POINT → Circle (radius 1m)
        - POINT_UNCERTAINTY_CIRCLE → Circle
        - POINT_UNCERTAINTY_ELLIPSE → Circle (uses semi-major axis as radius)
        - POINT_ALTITUDE → Circle (radius 1m, altitude ignored)
        - POINT_ALTITUDE_UNCERTAINTY → Circle (uses uncertainty as radius)
        - POLYGON → Polygon
        - ELLIPSOID_ARC → Circle (centre of arc, arc radius as radius)

        Also supports the internal SDK polygon format (polygon.point_list.
        geographical_coords) for backward-compatibility with Open5GS responses.
        """
        shape = (geo_area.get("shape") or "").upper()

        # --- ELLIPSOID_ARC → CAMARA Circle (best-effort approximation) ---
        # Must be checked before the generic point check, because arcs also
        # contain a "point" key but need different radius calculation.
        if shape == "ELLIPSOID_ARC":
            point_data = geo_area.get("point", {})
            lat, lon = NetworkManager._extract_lat_lon(point_data)
            inner_radius = float(geo_area.get("innerRadius", 0))
            uncertainty_radius = float(geo_area.get("uncertaintyRadius", 0))
            radius = inner_radius + uncertainty_radius
            center = schemas.Point(latitude=lat, longitude=lon)
            return schemas.Circle(
                areaType=schemas.AreaType.circle,
                center=center,
                radius=max(radius, 1.0),
            )

        # --- Point-based shapes → CAMARA Circle ---
        point_shapes = {
            "POINT",
            "POINT_UNCERTAINTY_CIRCLE",
            "POINT_UNCERTAINTY_ELLIPSE",
            "POINT_ALTITUDE",
            "POINT_ALTITUDE_UNCERTAINTY",
        }

        if shape in point_shapes or "point" in geo_area:
            point_data = geo_area.get("point", {})
            lat, lon = NetworkManager._extract_lat_lon(point_data)

            # Determine radius from the best available uncertainty field
            uncertainty = 0.0
            if "uncertainty" in geo_area:
                # POINT_UNCERTAINTY_CIRCLE
                uncertainty = float(geo_area["uncertainty"])
            elif "uncertaintyEllipse" in geo_area:
                # POINT_UNCERTAINTY_ELLIPSE – use the semi-major axis
                ellipse = geo_area["uncertaintyEllipse"]
                uncertainty = float(ellipse.get("semiMajor", ellipse.get("uncertainty", 0)))
            elif "uncertaintyAltitude" in geo_area:
                # POINT_ALTITUDE_UNCERTAINTY – horizontal uncertainty
                uncertainty = float(geo_area.get("uncertainty", 0))

            center = schemas.Point(latitude=lat, longitude=lon)
            return schemas.Circle(
                areaType=schemas.AreaType.circle,
                center=center,
                radius=max(uncertainty, 1.0),
            )

        # --- Polygon shapes → CAMARA Polygon ---
        if shape == "POLYGON" or "polygon" in geo_area or "pointList" in geo_area:
            # 3GPP standard format: top-level pointList array of {lat, lon}
            coords = geo_area.get("pointList", [])

            if not coords:
                # Internal SDK / Open5GS format: polygon.point_list.geographical_coords
                polygon_data = geo_area.get("polygon", {})
                point_list_data = polygon_data.get("point_list", {})
                coords = point_list_data.get("geographical_coords", [])

            camara_points = [
                schemas.Point(
                    latitude=NetworkManager._extract_lat_lon(c)[0],
                    longitude=NetworkManager._extract_lat_lon(c)[1],
                )
                for c in coords
            ]
            if len(camara_points) < 3:
                raise NetworkPlatformError("Polygon geographic area requires at least 3 points")
            return schemas.Polygon(
                areaType=schemas.AreaType.polygon,
                boundary=schemas.PointList(camara_points),
            )

        raise NetworkPlatformError(
            f"Unsupported geographic area shape in OAI NEF response: {geo_area}"
        )


def _retrieve_ue_ipv4(session_info: CreateSession):
    return session_info.device.ipv4Address.root.privateAddress
+41 −16
Original line number Diff line number Diff line
@@ -237,11 +237,18 @@ class BaseNetworkClient:
        self.core_specific_monitoring_event_validation(retrieve_location_request)
        subscription_3gpp = self.add_core_specific_location_parameters(retrieve_location_request)
        device = retrieve_location_request.device
        subscription_3gpp.externalId = device.networkAccessIdentifier
        subscription_3gpp.ipv4Addr = device.ipv4Address
        subscription_3gpp.ipv6Addr = device.ipv6Address
        # subscription.msisdn = device.phoneNumber.root.lstrip('+')
        # subscription.notificationDestination = "http://127.0.0.1:8001"

        # Extract plain values from CAMARA RootModel types for 3GPP subscription fields
        subscription_3gpp.externalId = (
            device.networkAccessIdentifier.root if device.networkAccessIdentifier else None
        )
        subscription_3gpp.msisdn = (
            device.phoneNumber.root.lstrip("+") if device.phoneNumber else None
        )
        subscription_3gpp.ipv4Addr = (
            device.ipv4Address.root.publicAddress.root if device.ipv4Address else None
        )
        subscription_3gpp.ipv6Addr = device.ipv6Address.root if device.ipv6Address else None

        return subscription_3gpp

@@ -265,23 +272,21 @@ class BaseNetworkClient:
        else:
            return event_time.replace(tzinfo=timezone.utc)

    @requires_capability("location_retrieval")
    def create_monitoring_event_subscription(
        self, retrieve_location_request: schemas.RetrievalLocationRequest
    ) -> schemas.Location:
    def _parse_location_response(self, response: dict) -> schemas.Location:
        """
        Creates a Monitoring Event subscription based on CAMARA Location API input.
        Parse NEF monitoring event response into CAMARA Location format.

        Default implementation handles responses where the raw response is a direct
        MonitoringEventReport with polygon-based geographic areas (e.g. Open5GS).

        Override this method for NEF implementations with different response formats.

        args:
            retrieve_location_request: Dictionary containing location retrieval details conforming to
                                        the CAMARA Location API parameters.
            response: Raw JSON response dict from the NEF monitoring event POST.

        returns:
            dictionary containing the created subscription details, including its ID.
            CAMARA Location object.
        """
        subscription = self._build_monitoring_event_subscription(retrieve_location_request)
        response = common.monitoring_event_post(self.base_url, self.scs_as_id, subscription)

        monitoring_event_report = schemas.MonitoringEventReport(**response)
        if monitoring_event_report.locationInfo is None:
            log.error("Failed to retrieve location information from monitoring event report")
@@ -307,6 +312,26 @@ class BaseNetworkClient:

        return camara_location

    @requires_capability("location_retrieval")
    def create_monitoring_event_subscription(
        self, retrieve_location_request: schemas.RetrievalLocationRequest
    ) -> schemas.Location:
        """
        Creates a one-shot Monitoring Event subscription to retrieve the current
        location of a device, based on CAMARA Location API input.

        args:
            retrieve_location_request: RetrievalLocationRequest containing location
                                        retrieval details conforming to the CAMARA
                                        Location API parameters.

        returns:
            CAMARA Location object containing the device location.
        """
        subscription = self._build_monitoring_event_subscription(retrieve_location_request)
        response = common.monitoring_event_post(self.base_url, self.scs_as_id, subscription)
        return self._parse_location_response(response)

    @requires_capability("qod")
    def create_qod_session(self, session_info: Dict) -> Dict:
        """
+422 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading