Positioning Adapters¶
The positioning subsystem is split into two concerns: a thin engine that
fuses measurements into a unified position, and a set of adapters that
each speak to one positioning technology (Wi-Fi RSSI, vendor RTLS, UWB,
or any future source). Phase 10 (northbound), positioning_engine role,
deploys the engine plus a standalone
synthetic-adapter (a Tier-1 synthetic/UWB source that exercises the real
adapter HTTP contract out of the box) and, opt-in, the placement-editor
geometry UI. Real-source adapters (wifi-adapter, the generic vendor-adapter,
and bring-your-own images) are provisioned at runtime from the dashboard, not by
Ansible.
Layers¶
Solid arrows carry data at runtime; dashed arrows are control, crossed once or on a heartbeat.
flowchart LR
subgraph CONS["Consumers"]
APP["location-app"]
CLI["any CAMARA client"]
end
subgraph EXPO["Exposure, phase 10 backbone"]
FD["front door"]
GW["camara-gateway<br/>CAMARA Location API, asset map"]
ENG["positioning-engine<br/>registry, fusion, blueprint"]
end
subgraph ADAPT["Adapters, one per technology"]
WIFI["wifi-adapter"]
VEND["vendor-adapter<br/>bound to a vendor by a schema document"]
SYN["synthetic-adapter"]
end
subgraph SRC["Sources"]
PI["Wi-Fi scanner<br/>a UE on the private 5G"]
KIT["vendor devices<br/>UEs on the private 5G"]
CLOUD[("vendor cloud")]
end
subgraph OPS["Operator"]
DASH["KELT dashboard"]
EDIT["placement-editor"]
end
APP --> FD
CLI --> FD
FD --> GW --> ENG
ENG --> WIFI
ENG --> VEND
ENG --> SYN
PI -->|"scans, over the radio<br/>into the on-site data network"| WIFI
KIT -->|"over the radio,<br/>out through the internet breakout"| CLOUD
CLOUD -->|"polled per the schema"| VEND
WIFI -.->|"register, heartbeat,<br/>capabilities"| ENG
VEND -.-> ENG
SYN -.-> ENG
DASH -.->|"deploy, configure, upgrade"| ADAPT
EDIT -.->|"blueprint"| ENG
Read it left to right. A consumer only ever sees the gateway, through the
front door, and names things by assetId. The gateway resolves the asset
to the source that tracks it and asks the engine; the engine never talks to
a measurement source itself: it polls the adapter that declared that
source over GET /measurement/{id} and converts the answer to WGS84 with
its blueprint. Adapters register with the engine when they start and keep
a heartbeat, carrying their capabilities (source name, frame,
precision class, how the source reports); a silent adapter is evicted and the
rest keeps serving.
With no adapter registered for an asset's source the gateway answers
503 UNAVAILABLE; when the source has no position for the device it answers
422 UNABLE_TO_LOCATE. An asset's kind (the class of the object tracked)
belongs to the asset map, not to the adapter.
The two real sources reach their adapters differently, and both cross the private 5G: the Wi-Fi scanner is a UE whose scans land on the on-site data network, so the fix is computed on site; the vendor's devices are UEs too, but their gateway talks to the vendor's cloud through the internet breakout, and the vendor-adapter reads the fix back from there. The synthetic-adapter is the source the backbone ships with, so the whole path works with no hardware. The flows are drawn one at a time in Deployment view.
Public adapter contract¶
The HTTP contract that adapters must implement, the request/response
schemas, the health probe shape, and the reference implementation
(wifi-adapter) all live in the 5g-northbound monorepo, alongside
the engine code that consumes them. See:
5g-northbound/docs/adapter-contract.mdfor the protocol spec5g-northbound/wifi-adapter/for the reference implementation
This testbed pulls the published images and orchestrates them; the contract itself is owned by the upstream repository so it can evolve without a testbed release.
What the positioning_engine role provisions¶
| Resource | Purpose |
|---|---|
Namespace positioning |
Isolation boundary for engine and all adapters |
ConfigMap positioning-engine-wiring |
Deployment wiring, rewritten on every run from all.yml: DEVICE_IDS, BLUEPRINT_SEED_PATH. No static ADAPTER_URLS: adapters self-register (an optional seed is rendered only if engine_adapter_urls is set) |
ConfigMap positioning-engine-config |
Operator settings (DEVICE_MAP, FUSION_STRATEGY, FUSION_COMPARE, WEBSOCKET_INTERVAL_MS): seeded once from the role defaults, then owned by the dashboard Configure form; the role never rewrites it. Listed after the wiring in envFrom, so it wins |
Deployment positioning-engine |
Single replica, image from 5g-northbound, REST + WebSocket on 8080; the synthetic-adapter deployed by the same role is its baseline source |
Service positioning-engine |
ClusterIP |
Deployment/Service synthetic-adapter |
Standalone reference synthetic adapter (ClusterIP), source synthetic; the engine discovers it from its devices capability so the demo shows live movement out of the box |
PVC positioning-blueprint |
Engine-owned blueprint store, RWO at /app/data; only the engine mounts it. The engine serves GET/PUT /blueprint |
ConfigMap positioning-blueprint-seed |
Cold-start blueprint (version 3: one floor plan with its georef, one room), read once via BLUEPRINT_SEED_PATH when the blueprint PVC is empty |
Deployment/Service placement-editor |
Opt-in (placement_editor_enabled): geometry authoring UI, ClusterIP. A write-client that PUTs the authored blueprint to the engine (POSITIONING_ENGINE_URL); mounts no PVC |
oauth2-proxy-placement (Deployment/Service/Secret) |
Opt-in: Keycloak gate in front of placement-editor on NodePort 31950; admits only g-dashboard-admins (realm client placement-editor-proxy) |
Adapters self-register with the engine and heartbeat, so they appear and
disappear in the live registry without an engine restart; an asset nobody
tracks is answered 422 UNABLE_TO_LOCATE by the gateway, not with a guess.
Blueprint distribution¶
The room geometry (the blueprint) is network-distributed with a single
authority: the engine persists it on its own PVC and serves GET/PUT
/blueprint. The placement-editor PUTs the authored blueprint; the demo GETs
it through the CAMARA gateway (a MEC app talks only to the gateway); adapters
that need geometry GET it from the engine. There is no shared PVC and no
file-mount across services, which keeps every consumer network-driven (the
same model the edge Wi-Fi scanner already uses). The blueprint schema, the
endpoint contract, and the bindings-vs-blueprint split are owned upstream:
see 5g-northbound/docs/blueprint-vs-bindings.md.
Who is broadcast on the live WebSocket¶
Since engine 0.8.19 the live broadcast set, and each device's source, are
derived from the adapters' devices capability
(API reference):
the engine asks every registered adapter which devices it currently reports and
broadcasts those, routing each to the adapter that reported it. An onboarded
asset therefore goes live as soon as its adapter reports it, and there is no
fan-out fusion across adapters.
DEVICE_IDS on the engine is now only a cold-start seed, used when no adapter
advertises any device; DEVICE_MAP is a legacy manual id=source override.
Because the synthetic-adapter advertises the synthetic demo device through the
same capability, its deployment carries its own DEVICE_IDS
(engine_device_ids), otherwise the demo walk would never appear.
Adding an adapter¶
Adapters self-register with the engine (v0.6.0). On boot each adapter POSTs
its name + base URL + kind to the engine (POST /adapters, target
POSITIONING_ENGINE_URL) and heartbeats; the engine is the registry authority
and evicts a self-registration that stops heartbeating after ADAPTER_TTL_S.
There is no static ADAPTER_URLS list to maintain and no rollout to trigger:
deploy an adapter and it announces itself within a heartbeat. The registry,
the endpoint contract, and the heartbeat/TTL semantics are owned upstream:
see 5g-northbound/docs/adapter-registry.md.
ADAPTER_URLS survives only as an optional cold-start seed the engine reads
once when its registry is empty (for an off-cluster adapter that cannot
self-register); the baseline synthetic-adapter self-registers, so the testbed
leaves it unset. The adapter Deployment must expose GET /health (no auth) and
GET /measurement/{id} per the public contract.
Dashboard provisioning¶
The dashboard Northbound page (/northbound, backend /api/v1/northbound/*)
shows the live registry read from the engine (GET /adapters): per adapter
its kind, registeredVia (self/seed/manual), lastSeenSAgo, and a derived
state (live / unreachable / stale). It does not register adapters by hand;
it deploys adapter images that then self-register, and can force-remove a stale
entry (DELETE /adapters/{name}). Two ways to add a positioning source without
touching Ansible:
-
Bring your own adapter image. Deploy-from-image: give a name,
image:tag, port, env vars (secret-marked vars go into a Secret), and an optionalimagePullSecretfor private images. The backend creates the Deployment + ClusterIP Service in thepositioningnamespace (pinned to the worker node) and injects the self-registration env (POSITIONING_ENGINE_URL,ADAPTER_NAME,ADAPTER_BASE_URL) so the adapter announces itself. What the adapter is comes from the image itself (itsadapter:family) and fromADAPTER_CAPABILITIESset afterwards in Configure; the deploy form asks for nothing else. The catalog pre-fills the referencewifi-adapter. -
No new code: the generic
vendor-adapter. Deploy the stockvendor-adapterimage, then declare a schema that maps any REST API to theMeasurementshape (the adapter persists the schema; the dashboard can write it via the adapter'sPUT /schema). Credentials are mounted from a Secret.
Deploy-from-image is admin-only and additionally gated by the backend
allow_workload_create setting; every write is audited. See
docs/dashboard/modules.md and docs/dashboard/api-reference.md.
Who owns a service's env¶
Every backbone service (gateway, engine, location-app) reads its env from two
ConfigMaps, in this order: <deployment>-wiring, written by its phase 10 role
from all.yml on every run, and <deployment>-config, seeded once by the role
with the defaults and from then on owned by the dashboard Configure form. A later
envFrom source wins, so an operator setting overrides the seed and a re-run of
the phase never puts it back. Inline env on the Deployment (the gateway's
client secret) is wiring too, and beats both. Catalog adapters have no wiring
ConfigMap: the dashboard creates <name>-config at deploy time and writes the
registration env into it.
The backend reads the live Deployment to tell the two apart (which source actually provides each contract variable), so Configure offers only what the operator owns and refuses to write a key the deployment provides elsewhere. Wiring and storage paths are shown read-only in the service's info panel.
ADAPTER_CAPABILITIES is where a deployment states the traits of the source
it is bound to: source, frame (room, venue or wgs84), z,
accuracy_class, nominalAccuracy, nominalVerticalAccuracy, reporting
(on_request, periodic or on_motion) and reportingInterval. The generic
vendor-adapter image bakes none of them. The form renders the variable as a
guided editor pre-filled from what the adapter advertises in the engine registry
and from its mounted schema, and offers accuracy_class from the gateway's
published /contracts/accuracy-class-vocabulary.json. It refuses to apply a
declaration the engine would answer with 422: periodic or on_motion without
a reportingInterval, or nominalVerticalAccuracy without z. An adapter with no
source declared is dropped by the engine. The contract also reports
transports (what the image implements) and transport (what the active
schema chose).
The MappingStudio edits a vendor-adapter schema. Its frame (wgs84 or venue)
and whether the source measures height are chosen at the top: they decide which
mapping targets exist, and saving writes them into ADAPTER_CAPABILITIES too,
since the adapter refuses a schema that contradicts its declaration. A schema
written for an earlier release opens with the renamed fields listed. The
dashboard reads an adapter's contract, mapping grammar and vendor sample from
the newest running pod of its deployment, ready or not, so during an upgrade
they come from the new image.
What KELT declares per source¶
| Source | Frame | Height | Reporting | Set by |
|---|---|---|---|---|
| synthetic | room |
measured | on_request |
the image |
| wifi | room |
not measured | periodic, 2 s |
the image |
| wittra | wgs84 |
not declared as measured | not declared | wittra-config, through the dashboard |
maxAge=0 is served only for a position that is current by the source's
declaration, and the gateway reports altitude only from a blueprint georef
with a surveyed altitude_m (floor_plan_altitude_m for the seed); the rules
are in the upstream profile. With
reporting undeclared, a Wittra asset answers 422 UNABLE_TO_FULFILL_MAX_AGE to
maxAge=0.
A request can end at one of two caches: the gateway keeps the last position per
positioning id and source for LOCATION_CACHE_TTL_S (5 s), and the
vendor-adapter keeps the vendor's answer for the schema's cacheTtl (5 s).
maxAge=0 skips the first only, and nothing skips the second. Other consumers
keep the vendor-adapter's cache warm: the engine's broadcast polls every
advertised device about twice a second while a client is connected to the
position stream, and the location-app's detail panel asks for the open asset
every 4 s. From 0.19.2 the vendor-adapter's hop line on GET /measurement/{id}
says cache: hit or cache: miss.
Deployment view: the flows as they run in the testbed¶
The upstream architecture page describes the contracts and who defines them, one flow at a time. This does the same for the deployed shape: the same components placed on the testbed's network, with the devices that feed them, one flow per drawing.
One CAMARA retrieve, end to end¶
flowchart LR
C["consumer<br/>location-app or any client"] -->|"HTTPS, Bearer JWT"| FD["front door<br/>tunnel + nginx"]
FD --> GW["camara-gateway<br/>assets PVC"]
GW -.->|"JWKS, once"| KC[("Keycloak")]
GW -->|"GET /position/{id}?source="| ENG["positioning-engine"]
ENG -->|"GET /measurement/{id}"| AD["the adapter that<br/>declared that source"]
AD --> ENG --> GW --> FD --> C
The consumer reaches only the gateway, through the front door; the gateway
resolves assetId to the source and the identifier that source knows the
asset by, the engine asks that adapter, converts to WGS84 with its blueprint,
and the circle goes back. Measured on the testbed: about 22 ms inside the
pipeline (gateway in to gateway out), see experiments/.
A vendor fix, from the tag to the adapter¶
flowchart LR
TAG["UWB tag"] --> MESH["vendor mesh<br/>and gateway"] --> UE["5G router (UE)"] --> GNB["femtocell"] --> UPF["UPF"]
UPF -->|"data network internet"| NET(("internet")) --> CLOUD[("vendor cloud")]
CLOUD -->|"vendor REST, polled<br/>per the loaded schema"| VEND["wittra<br/>vendor-adapter"]
The vendor's devices ride the private 5G like any UE, but their gateway
talks to the vendor's cloud: the fix is computed there and leaves the site.
The vendor-adapter reads it back over the cluster's ordinary internet
egress, keeps it for a few seconds (cacheTtl in the schema) and serves it
to the engine. Nothing of this path enters the site's data network.
A Wi-Fi fix, from the scanner to the adapter¶
flowchart LR
PI["Pi scanner<br/>5G dongle (UE)"] --> GNB["femtocell"] --> UPF["UPF"]
UPF -->|"data network mec"| N6M["on-site apps network"] -->|"POST /ingest/wifi-scan"| WIFI["wifi-adapter<br/>n6m address, calibration PVC"]
The scan crosses the radio and lands on the on-site data network, where the wifi-adapter has an extra interface with a reserved address; the fix is computed in the adapter, on site. Same 5G as the vendor path, different exit: this is the shape the vendor path would take with a local feed.
Adapters announce themselves; operators configure them¶
flowchart LR
AD["any adapter"] -.->|"POST /adapters + heartbeat<br/>+ ADAPTER_CAPABILITIES"| ENG["positioning-engine<br/>registry, evicts on TTL"]
OP["operator browser"] --> FD["front door"] --> GATE["oauth2-proxy gate"] --> EDIT["placement-editor"] -.->|"PUT /blueprint"| ENG
OP --> DASH["KELT dashboard<br/>ansible VM, out of band"]
DASH -.->|"deploy from catalogue, config,<br/>schema document, bindings, upgrade"| AD
Adapters register with the engine and keep a heartbeat; the engine is the registry the gateway routes by. The operator never touches a pod: the dashboard deploys catalogue adapters, writes their config and secrets, loads the schema document and runtime choices, and rolls them; the placement editor, behind its gate, writes the blueprint the engine owns.
Where each piece runs¶
| Component | Runs | Reached by | Keeps |
|---|---|---|---|
| 5G core | worker VM, namespace 5g |
the femtocell on the RAN boundary | subscribers in MongoDB |
| wifi-adapter | catalogue deployment, one extra interface on the mec data network |
UE-side scanners over the radio, the engine in-cluster | calibration on a PVC |
vendor-adapter (wittra) |
catalogue deployment, cluster network only | the engine; itself reaches the vendor cloud via egress | schema document in a ConfigMap |
| synthetic-adapter | phase 10 backbone | the engine | nothing |
| positioning-engine | phase 10 backbone | adapters register to it, the gateway and the editor call it | blueprint on a PVC, adapter registry in memory |
| camara-gateway | phase 10 backbone | consumers through the front door | asset map on a PVC |
| placement-editor | phase 10 backbone, behind the oauth2-proxy gate | operators through the front door | nothing (writes the engine's blueprint) |
| Keycloak | namespace iam |
the gateway (JWKS, in-cluster), browsers through the front door | realm, users, clients |
| KELT dashboard | ansible VM, out of band | operators | nothing of the positioning state; it writes ConfigMaps, Secrets and deployments |
Addresses, ports and the data-network subnets are owned by
5g-interfaces.md; the front door by
../security/external-access.md; the
mec namespace and its reserved addresses by edge-apps.md.
Backbone versus catalog: why the split¶
The split keeps the public testbed reproducible and the operational state mutable. Ansible phases describe what the backbone looks like at the start of every deployment, identically across users. Adapters depend on who is using the testbed and on which hardware is connected; pinning them in Ansible would push specific vendor or topology choices into a generic artifact. Moving adapter provisioning to runtime keeps the published contract vendor-neutral, lets each operator wire only the sources they have, and supports private adapter images that must not appear in the public repository.
See also¶
- Phase 10 Northbound README implementation notes for the CAMARA gateway, positioning engine, and demo