Skip to content

Adapters

An adapter connects one positioning source to the engine. It answers GET /measurement/{positioningId} with the latest fix of a device, registers itself with the engine, and declares what its source can do. The engine knows nothing else about a source.

Adapter Source Computes the fix
wifi-adapter WiFi RSSI scans posted by an edge scanner this adapter, by multilateration
vendor-adapter a vendor's REST positioning cloud, described by a schema document the vendor
synthetic-adapter generated devices walking in a room this adapter

For a vendor with a REST API, adding a source is a schema document loaded into vendor-adapter, with no code. See integrating a vendor REST API. A separate adapter is written for what a schema cannot express: a proprietary SDK, a push transport, an on-site solver. Adapters that carry vendor SDKs or material under NDA live in private repositories and ship as private images that implement the same contract.

No adapter exists yet for 5G positioning or GNSS. The editor can place anchors of those technologies, and an asset whose capability names such a source has no position.

HTTP contract

Endpoint Required Purpose
GET /measurement/{positioningId} yes the latest fix of a device, or 404 when there is none
GET /health yes liveness: 200 while the process runs
GET /ready yes readiness: 200 once the configuration is loaded, otherwise 503 {"status": "not-ready", "error": …}
GET /devices with the devices capability the devices the source knows, for onboarding
PUT, DELETE /devices/{id}/placement with the placement capability the start point of a synthetic device

Other endpoints, such as the WiFi ingest path or the vendor schema, belong to one adapter. The engine never calls them.

GET /measurement/{positioningId}

The machine-readable contract is schema/adapter-measurement.schema.json, generated from the model the engine parses with. A body that does not satisfy it is dropped as malformed.

{
  "source":     "wifi",
  "frame":      "room",
  "room":       "room-01",
  "x":          11.5,
  "y":          10.3,
  "accuracy":   2.4,
  "confidence": 0.85,
  "timestamp":  1790500000.0
}
{
  "source":    "wittra",
  "frame":     "wgs84",
  "latitude":  59.404251,
  "longitude": 17.949247,
  "z":         1.2,
  "confidence": 0.95,
  "timestamp": 1790500000.0,
  "lastSeen":  1790507200.0
}
Field Type Meaning
source string Technology tag. Defaults to the adapter's registered name.
frame room, venue, wgs84 Reference of the horizontal position. Defaults to venue.
room string Blueprint room id. Required with frame: room. A room the blueprint does not hold leaves the fix unplaced, and the engine drops it.
x, y metres Required with room or venue: x along the width, y along the depth, from the lower-left corner.
latitude, longitude degrees Required with wgs84. The engine applies the venue georef.
z metres, optional Height above the venue floor. Sent only when measured for this fix, by a source that declares z: true.
verticalAccuracy metres, optional One-sigma error of z. Sent only with z, when the source reports it.
accuracy metres, optional One-sigma horizontal error radius. When absent the engine uses the declared nominal accuracy (below).
confidence 0 to 1, optional The source's own reliability score, a multiplier on the fusion weight. At 0 the measurement is left out.
timestamp epoch seconds Required. When the fix was taken.
lastSeen epoch seconds, optional When the device last communicated with its source. For an on_motion source it confirms the last fix. Published as lastCommunicationTime.

room suits a source that computes inside a room from anchors placed in it, as RSSI multilateration does. wgs84 suits a source whose backend is placed on a map, as vendor clouds usually are. The frames are defined in architecture.

A 404 means no fix for this device, and the engine skips the source for that cycle. Network errors, 5xx responses and malformed bodies count as failures: after three in a row the engine stops calling the adapter for 2 s, doubling up to 60 s while failures continue, and a success resets the count. Other 4xx responses, such as a rejected credential, skip the cycle without counting. An adapter returns its last fix with its real timestamp however old it is. The engine and the gateway judge its age.

GET /devices

{
  "origin": "inventory",
  "devices": [
    { "id": "wittra-tag-01", "role": "asset", "sourceClass": "uwb",
      "deviceType": "tag", "label": "Tag 01", "lastSeen": 1790507200.0 }
  ]
}

The contract is schema/adapter-devices.schema.json. origin is inventory when the source keeps a list of its devices, and observed when a device appears only once it is seen. id becomes a capability's positioningId when the device is onboarded. role separates tracked assets from fixed infrastructure such as anchors, which are never onboarded. The engine aggregates the lists of all adapters, and the gateway offers the devices not yet onboarded at GET /assets/discoverable. See asset registry.

Registration

An adapter registers with POST /adapters on the engine at start, repeats it as a heartbeat, and deregisters on shutdown. It reads four variables:

Variable Meaning
POSITIONING_ENGINE_URL the engine
ADAPTER_NAME the name it registers under, equal to the source of the capabilities it serves
ADAPTER_BASE_URL the URL the engine uses to reach it
ADAPTER_CAPABILITIES the declaration below, as JSON

The body is schema/adapter-announcement.schema.json. The engine refuses a malformed declaration with 422. Lifecycle, health and routing are in adapter registry. The in-repository adapters share one implementation, app/register.py.

What an adapter declares

Each adapter image carries adapter.contract.yaml. Its capabilities state what is true of the binary, and ADAPTER_CAPABILITIES adds what is true of the source it is bound to in one deployment. The adapter announces the merge. make positioning-check verifies that the compose file and the contract files agree.

Key Meaning
source technology tag of the fixes
frame frame of its measurements
z the source measures height. A height from a source without z: true is discarded, with its verticalAccuracy
accuracy_class sub-metre, metre or coarse
nominalAccuracy metres, for a fix that reports no accuracy
nominalVerticalAccuracy metres, for a fix that carries z without verticalAccuracy. Requires z: true
reporting, reportingInterval how the source produces fixes
responseTimeout seconds the adapter may take to answer a measurement request, its own call to its source included. The engine waits this long and answers 504 past it. The vendor-adapter derives it from its schema's requestTimeout
streaming the adapter pushes instead of being polled (none does today)
devices, discover, diagnostics, calibration, placement the optional endpoints it serves

The vendor-adapter image is generic, so its contract file declares only its endpoints. Every trait of the bound vendor arrives in ADAPTER_CAPABILITIES.

reporting and reportingInterval

reporting states how the source produces fixes, and so how old a position really is.

reporting The source A position holds as of
on_request computes a fix at each poll the fix time
periodic produces a fix at least every reportingInterval the fix time
on_motion produces a fix whenever the device moves, and communicates at least every reportingInterval while it is still the later of the fix time and lastSeen

reportingInterval is the longest time between two reports the source guarantees, in seconds, transport included. It is required with periodic and on_motion. A position is current when it is as recent as its source can provide: always for on_request, within one reportingInterval for the other two. A source that declares no model counts as periodic without an interval: its position holds as of the fix time and is never current.

Under on_motion, a communication confirms the last fix because the declaration states that any movement produces a new one. Whoever binds the adapter to a source answers for the declaration. vendor-adapter checks it against the schema and the payloads (see integrating a vendor REST API).

For each fused position the engine reports establishedAt, the earliest established time among its contributions, and current, true when every contribution is current. The gateway judges maxAge on them.

Adapter reporting reportingInterval
synthetic-adapter on_request
wifi-adapter periodic 2 s, for the edge scanner's default of one scan a second
vendor-adapter per deployment per deployment

accuracy_class and nominal accuracies

accuracy_class is the band the source's technology usually delivers. The bands are defined in spec/private-profile/accuracy-class-vocabulary.json. An application reads them from GET /capabilities to weigh a position by its provenance as well as its radius. The class describes the technology, not a bound on each fix: a degraded fix reports its own larger accuracy.

The engine also uses them for a source that reports no accuracy. It takes the adapter's nominalAccuracy if declared, otherwise the upper bound of the declared class. coarse has no upper bound, so a coarse source without per-fix accuracy must declare nominalAccuracy. A measurement with no way to obtain an accuracy is dropped with a warning.

nominalVerticalAccuracy does the same for the height: a fix that carries z without verticalAccuracy takes it. There are no vertical classes. Without the declaration such a fix has no vertical error, and a fused height that includes it has none either. The fusion needs no vertical error, so the fix is kept.

Both nominal values describe the deployed hardware, so they belong in the deployment's ADAPTER_CAPABILITIES. confidence is a different quantity: the source's score for one fix, which multiplies its weight in the fusion.

placement

A synthetic source can be told where a device starts. PUT /devices/{id}/placement takes {x, y} in the frame of the room the source walks and clamps the point into it. DELETE removes the device, which then has no position. With SPAWN_REQUIRED set, a device reports nothing until placed. Placing an id that DEVICE_IDS does not declare creates that device: it walks, reports and is listed on /devices with placed: true until it is removed. An id neither declared nor placed answers 404, so the source never answers for another source's device. Anchor ids are not placeable. The placements live in the adapter's memory: a restarted adapter has none, so the synthetic adapter runs as one replica. The gateway offers this per asset at /assets/{assetId}/placement and refuses a source that does not declare placement with 422 NOT_PLACEABLE.

Engine options per adapter

The engine reads optional variables per adapter name, uppercased with other characters replaced by _ (wittra becomes WITTRA):

Variable Default Meaning
ADAPTER_<NAME>_API_KEY unset a token the engine sends on every request to this adapter
ADAPTER_<NAME>_API_KEY_HEADER X-API-Key the header carrying it
ADAPTER_<NAME>_TIMEOUT the adapter's declared responseTimeout, else 1.0 seconds the engine waits for the adapter's answer

They are declared in the engine's environment contract with the placeholder {NAME}, and the key is sensitive. Adapters inside the cluster need none of them. An adapter reached over an untrusted network needs TLS and authentication, and one that accepts pushes from devices, as wifi-adapter does, authenticates or rate-limits them itself.

Writing an adapter

from typing import Optional

from fastapi import FastAPI, HTTPException, Response
from pydantic import BaseModel


class Measurement(BaseModel):
    frame: str = "room"
    room: str = "room-01"
    x: float
    y: float
    z: Optional[float] = None
    accuracy: Optional[float] = None
    confidence: Optional[float] = None
    timestamp: float


app = FastAPI()
latest: dict[str, Measurement] = {}   # filled by the adapter's own ingestion
ready = True


@app.get("/health")
async def health():
    return {"status": "ok"}


@app.get("/ready")
async def ready_probe(response: Response):
    if not ready:
        response.status_code = 503
        return {"status": "not-ready", "error": "configuration not loaded"}
    return {"status": "ready"}


@app.get("/measurement/{positioning_id}", response_model=Measurement, response_model_exclude_none=True)
async def measurement(positioning_id: str):
    if positioning_id not in latest:
        raise HTTPException(404)
    return latest[positioning_id]

Add registration (copy app/register.py from an in-repository adapter), an adapter.contract.yaml, and an env.contract.yaml served at GET /contract. Package it as an image listening on port 8080 with a non-root user, and deploy it with a ClusterIP Service. Once it registers, the engine routes to it every capability whose source equals its ADAPTER_NAME.

wifi-adapter is the reference for a source that computes on site: an ingest endpoint receives raw scans, a solver turns them into positions, and /measurement serves the latest one. Its configuration and calibration are in blueprint vs bindings.