Pedal WebSocket API

FastAPI WebSocket backend scaffold for UWB key exchange using GPS positions.

Requirements

Run Locally

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn src.main:app --reload

The API will be available at http://localhost:8000.

The deployed API is available at:

https://pedal.comtec.eecs.uni-kassel.de/

The deployed WebSocket endpoint is:

wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect

Run With Docker

docker compose up --build

Build Registry Image

For Portainer deployments on a typical x86 server, build and push an amd64 Linux image:

scripts/build-linux-image.sh registry.example.com/pedal-websocket-api:latest

The script runs docker buildx build with --platform linux/amd64 and --push. For a multi-architecture image:

PLATFORM=linux/amd64,linux/arm64 scripts/build-linux-image.sh registry.example.com/pedal-websocket-api:latest

Endpoints

WebSocket sessions close automatically after 3 hours without data from the client.

/dead-reckoning is separate from /pedal-connect so high-rate ARKit dead reckoning does not interfere with GPS/UWB registration. Each payload identifies the sending phone with uuid or phone_id and includes a dead_reckoning object. The backend validates and normalizes the update, then broadcasts it to all other phones connected to /dead-reckoning; the sender does not receive its own update back.

Sessions are stored only in memory under src/data; nothing is persisted. Each open WebSocket connection owns one cache entry keyed by the UUID sent by the iPhone. Registration and position-update payloads carry uuid, GPS position, and uwb_key. Measurement-ready payloads carry uuid and GPS position, but no UWB key. New position data overwrites the GPS position and replaces the cached UWB key for that UUID. A measurement-ready payload also marks the device as ready to start the measurement session. The entry is removed when the connection closes. If the latest accepted GPS position for a session is 10 seconds old or older, the device is treated as not currently sharing GPS until a new position update arrives. The WebSocket connection remains open.

After every saved GPS position, GeoLinkService checks all other cached sessions that are currently sharing GPS. If another active GPS-sharing session is within GEO_LINK_RANGE meters, the backend sends each client a direct geo_link_established message containing the peer UUID and UWB key. The peer UUID is sent as both id and uuid for compatibility. If the same pair already has an active UWB cache entry, the backend suppresses further geo_link_established messages for that pair while they remain active.

GEO_LINK_RANGE is currently 1000.0 meters.

/pedal-connect also handles session start readiness. A device must already be connected on that WebSocket. The readiness message sends current GPS data on the same WebSocket, so the backend can verify readiness with the latest position before checking range:

{
  "event": "messurement_ready",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "latitude": 51.3127,
  "longitude": 9.4797,
  "accuracy": 5
}

The first readiness payload starts a 2 minute ready window. Once the backend detects the first two ready devices within GEO_LINK_RANGE, it shortens that window to 5 seconds so more nearby devices can still join the same exchange. When the window closes, the existing /pedal-connect WebSockets receive a peer message:

{
  "event": "messurement_ready",
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "uuid": "550e8400-e29b-41d4-a716-446655440001",
  "peer_uuid": "550e8400-e29b-41d4-a716-446655440001",
  "peer_uuids": [
    "550e8400-e29b-41d4-a716-446655440001",
    "550e8400-e29b-41d4-a716-446655440002"
  ],
  "ready_uuids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "550e8400-e29b-41d4-a716-446655440001",
    "550e8400-e29b-41d4-a716-446655440002"
  ],
  "ready_count": 3,
  "ready": true
}

Ready exchanges are one-shot events. After the backend sends this message to a ready group, those devices are no longer treated as ready. If the same devices should start another measurement session later, they send a new readiness payload.

UWB pair state is also stored only in memory. POST /uwb-connections creates an inactive cache entry after the first device acknowledges a pair. When the second device acknowledges the same pair, the entry becomes active and both devices get a WebSocket confirmation. If one device ends UWB, all cached pair entries for that UUID are deleted and the paired devices are notified over WebSocket. The backend also deletes related UWB cache entries when a WebSocket session closes. Leaving GEO_LINK_RANGE does not end an active UWB cache entry. The internal UWB cache stores uuid1, uuid2, uwb1, uwb2, and status (active or inactive).

Overview Page

Open the deployed overview page in a browser:

https://pedal.comtec.eecs.uni-kassel.de/overview

The page is a regular HTTP endpoint, not a WebSocket. It loads once and then polls /overview/data with JavaScript, so the map view and zoom are not reset by full-page reloads. It shows:

The overview does not expose UWB keys.

Debug Counts

An iPhone can request the current live counters without loading the HTML overview page:

GET https://pedal.comtec.eecs.uni-kassel.de/debug

Example response:

{
  "open_ws": 2,
  "sharing_gps": 2,
  "uwb_links": 1,
  "geo_links": 1
}

sharing_gps only includes open sessions whose latest position update is less than 10 seconds old.

iOS can fetch the counters with a normal HTTP GET request:

import Foundation

struct PedalDebugCounts: Decodable {
    let openWebSockets: Int
    let sharingGPS: Int
    let uwbLinks: Int
    let geoLinks: Int

    enum CodingKeys: String, CodingKey {
        case openWebSockets = "open_ws"
        case sharingGPS = "sharing_gps"
        case uwbLinks = "uwb_links"
        case geoLinks = "geo_links"
    }
}

func fetchPedalDebugCounts(
    completion: @escaping (Result<PedalDebugCounts, Error>) -> Void
) {
    let url = URL(string: "https://pedal.comtec.eecs.uni-kassel.de/debug")!

    URLSession.shared.dataTask(with: url) { data, response, error in
        if let error {
            completion(.failure(error))
            return
        }

        guard
            let httpResponse = response as? HTTPURLResponse,
            (200..<300).contains(httpResponse.statusCode),
            let data
        else {
            completion(.failure(URLError(.badServerResponse)))
            return
        }

        do {
            let counts = try JSONDecoder().decode(PedalDebugCounts.self, from: data)
            completion(.success(counts))
        } catch {
            completion(.failure(error))
        }
    }.resume()
}

fetchPedalDebugCounts { result in
    switch result {
    case .success(let counts):
        print("Open WebSockets:", counts.openWebSockets)
        print("Sharing GPS:", counts.sharingGPS)
        print("UWB links:", counts.uwbLinks)
        print("Geo links:", counts.geoLinks)
    case .failure(let error):
        print("Pedal debug request failed:", error)
    }
}

Subscribe And Unsubscribe

Subscribe by opening a WebSocket connection to /pedal-connect and sending the device status payload after the socket opens:

const socket = new WebSocket("wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect");

socket.onopen = () => {
  socket.send(JSON.stringify({
    uuid: "550e8400-e29b-41d4-a716-446655440000",
    uwb_key: "AQIDBA==",
    latitude: 51.3127,
    longitude: 9.4797,
    accuracy: 5
  }));
};

socket.onmessage = (event) => {
  const message = JSON.parse(event.data);

  if (message.event === "geo_link_established") {
    console.log("Peer UUID:", message.id);
    console.log("Peer UWB key:", message.uwbkey);
  }

  if (message.event === "uwb_connection_established") {
    console.log("UWB connected with:", message.id);
  }

  if (message.event === "uwb_connection_ended") {
    console.log("Disconnected peer UUID:", message.id);
    console.log("UWB ended by:", message.ended_by);
  }
};

Keep the same WebSocket open and send the same payload shape for later status updates. The cache entry is updated in place:

socket.send(JSON.stringify({
  uuid: "550e8400-e29b-41d4-a716-446655440000",
  uwb_key: "BQYHCA==",
  latitude: 51.313,
  longitude: 9.48,
  accuracy: 4
}));

Unsubscribe by closing the WebSocket. The server removes the cached session entry for that UUID when the connection closes:

socket.close();

UWB Connection Acknowledgements

After a device receives a geo_link_established WebSocket event and completes the phone-to-phone UWB work, it can acknowledge the UWB pair over HTTP:

POST https://pedal.comtec.eecs.uni-kassel.de/uwb-connections
Content-Type: application/json
{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "paired_uuid": "550e8400-e29b-41d4-a716-446655440001"
}

The first acknowledgement creates an inactive cache entry. When the paired device sends the reciprocal acknowledgement, the entry becomes active and both devices receive:

{
  "event": "uwb_connection_established",
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "uuid": "550e8400-e29b-41d4-a716-446655440001",
  "paired_uuid": "550e8400-e29b-41d4-a716-446655440001",
  "connection": {
    "uuid1": "550e8400-e29b-41d4-a716-446655440000",
    "uuid2": "550e8400-e29b-41d4-a716-446655440001",
    "status": "active"
  }
}

To end all UWB connections for one device, post the device UUID to the same endpoint. The action field makes the intent explicit; a payload containing only uuid also ends all cached UWB connections for that device.

{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "action": "end"
}

Every paired device receives:

{
  "event": "uwb_connection_ended",
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "paired_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "ended_by": "550e8400-e29b-41d4-a716-446655440000",
  "connection": {
    "uuid1": "550e8400-e29b-41d4-a716-446655440000",
    "uuid2": "550e8400-e29b-41d4-a716-446655440001",
    "status": "active"
  }
}

iOS Client Integration

An iPhone subscribes by opening one WebSocket connection to /pedal-connect. Every location payload uses the same shape: the iPhone UUID, the current UWB key, and the current GPS position. The first payload registers the session. Later payloads on the same WebSocket update the GPS position and replace the cached UWB key. A device that is ready to start the measurement session sends a readiness payload with current GPS data on the same WebSocket. The connection is unsubscribed by closing the WebSocket.

Production URL

Use this endpoint for the deployed backend:

URL(string: "wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect")!

Use this endpoint for UWB connection acknowledgements:

URL(string: "https://pedal.comtec.eecs.uni-kassel.de/uwb-connections")!

Use this endpoint for continuous dead-reckoning updates:

URL(string: "wss://pedal.comtec.eecs.uni-kassel.de/dead-reckoning")!

Development URL

For the iOS Simulator, the backend on your Mac is reachable with:

URL(string: "ws://localhost:8000/pedal-connect")!
URL(string: "ws://localhost:8000/dead-reckoning")!

For a physical iPhone, localhost means the phone itself. Run the backend on all interfaces and use your Mac's local network IP address:

uvicorn src.main:app --host 0.0.0.0 --port 8000 --reload
URL(string: "ws://192.168.178.42:8000/pedal-connect")!
URL(string: "ws://192.168.178.42:8000/dead-reckoning")!

The iPhone and backend machine must be on the same network, and the firewall must allow inbound connections to port 8000. Use wss:// in production.

iOS Permissions

Add location permission text to Info.plist:

<key>NSLocationWhenInUseUsageDescription</key>
<string>Pedal uses your location to exchange UWB keys with nearby devices.</string>

For local development against a backend on your LAN, iOS may also require:

<key>NSLocalNetworkUsageDescription</key>
<string>Pedal connects to a local development backend.</string>

If ws:// is blocked during local development, add a temporary App Transport Security exception for development builds only. Production deployments should use TLS and wss://.

Payload Models

Location payloads must contain uuid, uwb_key, and the current GPS position. Readiness payloads contain uuid and the current GPS position. On iOS, model uwb_key as Data. Because the WebSocket payload is JSON, JSONEncoder serializes Data as Base64 on the wire and JSONDecoder converts the peer uwbkey back into Data. Later updates must use the same uuid; the server overwrites the previous UWB key for that UUID whenever the payload is accepted.

import CoreLocation
import Foundation

struct PedalDeviceStatusPayload: Encodable {
    let uuid: String
    let uwbKey: Data
    let latitude: Double
    let longitude: Double
    let accuracy: Double?

    enum CodingKeys: String, CodingKey {
        case uuid
        case uwbKey = "uwb_key"
        case latitude
        case longitude
        case accuracy
    }
}

struct PedalGeoLinkMessage: Decodable {
    let event: String
    let id: String
    let uuid: String?
    let uwbkey: Data
}

struct PedalUWBAcknowledgementPayload: Encodable {
    let uuid: String
    let pairedUuid: String

    enum CodingKeys: String, CodingKey {
        case uuid
        case pairedUuid = "paired_uuid"
    }
}

struct PedalUWBEndPayload: Encodable {
    let uuid: String
    let action = "end"
}

struct PedalUWBConnectionMessage: Decodable {
    let event: String
    let id: String
    let uuid: String
    let pairedUuid: String
    let endedBy: String?

    enum CodingKeys: String, CodingKey {
        case event
        case id
        case uuid
        case pairedUuid = "paired_uuid"
        case endedBy = "ended_by"
    }
}

struct PedalMeasurementReadyPayload: Encodable {
    let event = "messurement_ready"
    let uuid: String
    let latitude: Double
    let longitude: Double
    let accuracy: Double?

    enum CodingKeys: String, CodingKey {
        case event
        case uuid
        case latitude
        case longitude
        case accuracy
    }
}

struct PedalMeasurementReadyMessage: Decodable {
    let event: String
    let id: String
    let uuid: String
    let peerUuid: String
    let peerUuids: [String]?
    let readyUuids: [String]?
    let readyCount: Int?
    let ready: Bool

    enum CodingKeys: String, CodingKey {
        case event
        case id
        case uuid
        case peerUuid = "peer_uuid"
        case peerUuids = "peer_uuids"
        case readyUuids = "ready_uuids"
        case readyCount = "ready_count"
        case ready
    }
}

struct DeadReckoningPayload: Encodable {
    let event = "dead_reckoning_update"
    let uuid: String
    let phoneId: String
    let deadReckoning: DeadReckoningData

    enum CodingKeys: String, CodingKey {
        case event
        case uuid
        case phoneId = "phone_id"
        case deadReckoning = "dead_reckoning"
    }
}

struct DeadReckoningMessage: Decodable {
    let event: String
    let uuid: String
    let phoneId: String
    let deadReckoning: DeadReckoningData

    enum CodingKeys: String, CodingKey {
        case event
        case uuid
        case phoneId = "phone_id"
        case deadReckoning = "dead_reckoning"
    }
}

struct DeadReckoningData: Codable {
    let timestamp: String
    let position: Vector3Payload
    let eulerAngles: Vector3Payload
    let heading: Double
    let trackingState: String

    enum CodingKeys: String, CodingKey {
        case timestamp
        case position
        case eulerAngles = "euler_angles"
        case heading
        case trackingState = "tracking_state"
    }
}

struct Vector3Payload: Codable {
    let x: Double
    let y: Double
    let z: Double
}

WebSocket Client

Keep one URLSessionWebSocketTask open while the iPhone is available for matching. The receive loop stays active for server messages such as geo_link_established and messurement_ready.

import CoreLocation
import Foundation

final class PedalWebSocketClient {
    private let uuid: String
    private var uwbKey: Data
    private let encoder = JSONEncoder()
    private let decoder = JSONDecoder()
    private var task: URLSessionWebSocketTask?

    init(uuid: UUID = UUID(), uwbKey: Data) {
        self.uuid = uuid.uuidString
        self.uwbKey = uwbKey
    }

    func connect(to url: URL) {
        task = URLSession.shared.webSocketTask(with: url)
        task?.resume()
        receiveLoop()
    }

    func sendLocation(_ location: CLLocation) {
        send(PedalDeviceStatusPayload(
            uuid: uuid,
            uwbKey: uwbKey,
            latitude: location.coordinate.latitude,
            longitude: location.coordinate.longitude,
            accuracy: location.horizontalAccuracy
        ))
    }

    func sendReady(_ location: CLLocation) {
        send(PedalMeasurementReadyPayload(
            uuid: uuid,
            latitude: location.coordinate.latitude,
            longitude: location.coordinate.longitude,
            accuracy: location.horizontalAccuracy
        ))
    }

    func updateUWBKey(_ newUWBKey: Data) {
        uwbKey = newUWBKey
    }

    func disconnect() {
        task?.cancel(with: .normalClosure, reason: nil)
        task = nil
    }

    private func send<T: Encodable>(_ payload: T) {
        do {
            let data = try encoder.encode(payload)
            guard let text = String(data: data, encoding: .utf8) else {
                return
            }

            task?.send(.string(text)) { error in
                if let error {
                    print("Pedal WebSocket send failed:", error)
                }
            }
        } catch {
            print("Pedal payload encoding failed:", error)
        }
    }

    private func receiveLoop() {
        task?.receive { [weak self] result in
            guard let self else { return }

            switch result {
            case .success(let message):
                self.handle(message)
                self.receiveLoop()
            case .failure(let error):
                print("Pedal WebSocket receive failed:", error)
            }
        }
    }

    private func handle(_ message: URLSessionWebSocketTask.Message) {
        let data: Data?

        switch message {
        case .string(let text):
            data = text.data(using: .utf8)
        case .data(let value):
            data = value
        @unknown default:
            data = nil
        }

        guard let data else { return }

        if let geoLink = try? decoder.decode(PedalGeoLinkMessage.self, from: data),
           geoLink.event == "geo_link_established" {
            print("Peer UUID:", geoLink.id)
            print("Peer UWB key data:", geoLink.uwbkey)
        }

        if let readyMessage = try? decoder.decode(PedalMeasurementReadyMessage.self, from: data),
           readyMessage.event == "messurement_ready",
           readyMessage.ready {
            print("Peers ready to start measurement:", readyMessage.peerUuids ?? [readyMessage.peerUuid])
        }

        if let uwbConnection = try? decoder.decode(PedalUWBConnectionMessage.self, from: data),
           uwbConnection.event == "uwb_connection_established" {
            print("UWB connected with:", uwbConnection.id)
        }

        if let uwbConnection = try? decoder.decode(PedalUWBConnectionMessage.self, from: data),
           uwbConnection.event == "uwb_connection_ended" {
            print("UWB disconnected peer:", uwbConnection.id)
            print("UWB ended by:", uwbConnection.endedBy ?? "unknown")
        }
    }
}

Measurement Ready From iOS

Call sendReady(_:) on the existing /pedal-connect WebSocket with the latest CLLocation. The backend stores that GPS position first, then uses it to verify which ready devices are currently within GEO_LINK_RANGE. Readiness is batched for up to 2 minutes, then shortened to 5 seconds once the first nearby ready pair is found so three or more devices can join the same ready exchange. A successful ready exchange is one-shot, so call sendReady(_:) again for the next measurement session.

let ownUuid = UUID()
let uwbKeyData = Data([1, 2, 3, 4])

let pedalClient = PedalWebSocketClient(uuid: ownUuid, uwbKey: uwbKeyData)
pedalClient.connect(to: URL(string: "wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect")!)

// currentLocation is the latest CLLocation from CLLocationManager.
pedalClient.sendReady(currentLocation)

Acknowledging UWB From iOS

Use the HTTP endpoint after the iPhone has completed its local UWB pairing work with the UUID received in geo_link_established.id.

func postJSON<T: Encodable>(_ payload: T, to url: URL) {
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")

    do {
        request.httpBody = try JSONEncoder().encode(payload)
    } catch {
        print("UWB payload encoding failed:", error)
        return
    }

    URLSession.shared.dataTask(with: request) { _, _, error in
        if let error {
            print("UWB endpoint request failed:", error)
        }
    }.resume()
}

let uwbEndpoint = URL(string: "https://pedal.comtec.eecs.uni-kassel.de/uwb-connections")!
let ownUuid = UUID(uuidString: "550e8400-e29b-41d4-a716-446655440000")!
let peerUuidString = "550e8400-e29b-41d4-a716-446655440001"

postJSON(
    PedalUWBAcknowledgementPayload(
        uuid: ownUuid.uuidString,
        pairedUuid: peerUuidString
    ),
    to: uwbEndpoint
)

postJSON(
    PedalUWBEndPayload(uuid: ownUuid.uuidString),
    to: uwbEndpoint
)

Sending Regular Position Updates

Use CLLocationManager to send the first available location and every later location update through the same WebSocket connection.

import CoreLocation
import Foundation

final class PedalLocationPublisher: NSObject, CLLocationManagerDelegate {
    private let locationManager = CLLocationManager()
    private let pedalClient: PedalWebSocketClient

    init(pedalClient: PedalWebSocketClient) {
        self.pedalClient = pedalClient
        super.init()

        locationManager.delegate = self
        locationManager.desiredAccuracy = kCLLocationAccuracyBest
        locationManager.distanceFilter = 1
    }

    func start() {
        locationManager.requestWhenInUseAuthorization()
        locationManager.startUpdatingLocation()
    }

    func stop() {
        locationManager.stopUpdatingLocation()
        pedalClient.disconnect()
    }

    func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        guard let location = locations.last else {
            return
        }

        pedalClient.sendLocation(location)
    }

    func locationManager(
        _ manager: CLLocationManager,
        didFailWithError error: Error
    ) {
        print("Location updates failed:", error)
    }
}

Typical usage:

let uwbKeyData = Data([1, 2, 3, 4])
let ownUuid = UUID()
let client = PedalWebSocketClient(uuid: ownUuid, uwbKey: uwbKeyData)
client.connect(to: URL(string: "wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect")!)

let publisher = PedalLocationPublisher(pedalClient: client)
publisher.start()

// Later, when the user leaves the matching flow:
publisher.stop()

The server also closes idle WebSocket sessions after 3 hours without client data. Keep sending position updates while the iPhone should remain registered. If the app must keep sending location in the background, enable the iOS background location capability and request the corresponding location permission.

Quick WebSocket Test

Open the browser console while the API is running:

const socket = new WebSocket("wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect");
socket.onmessage = (event) => console.log(event.data);
socket.onopen = () => socket.send(JSON.stringify({
  uuid: "550e8400-e29b-41d4-a716-446655440000",
  uwb_key: "AQIDBA==",
  latitude: 51.3127,
  longitude: 9.4797,
  accuracy: 5
}));

Or use a WebSocket CLI client:

websocat wss://pedal.comtec.eecs.uni-kassel.de/pedal-connect

Device status payload:

{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "uwb_key": "AQIDBA==",
  "latitude": 51.3127,
  "longitude": 9.4797,
  "accuracy": 5
}

Subsequent position payloads on the same WebSocket must use the same shape and the same uuid. The cached UWB key for that UUID is overwritten on every accepted position payload. Manual JSON tests must send the binary key as Base64. The iOS app can keep using Data.

Measurement-ready payload on the same WebSocket:

{
  "event": "messurement_ready",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "latitude": 51.3127,
  "longitude": 9.4797,
  "accuracy": 5
}

Dead-reckoning payload on /dead-reckoning:

{
  "event": "dead_reckoning_update",
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "phone_id": "550e8400-e29b-41d4-a716-446655440000",
  "dead_reckoning": {
    "timestamp": "2026-06-16T12:00:00.000Z",
    "position": {
      "x": 0.12,
      "y": 0.01,
      "z": -1.48
    },
    "euler_angles": {
      "x": 0.01,
      "y": -0.42,
      "z": 0.03
    },
    "heading": 24.5,
    "tracking_state": "normal"
  }
}

Dead-reckoning messages are broadcast to the other /dead-reckoning clients in the same normalized shape, with an additional received_at timestamp inside dead_reckoning.

Geo link response:

{
  "event": "geo_link_established",
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "uuid": "550e8400-e29b-41d4-a716-446655440001",
  "uwbkey": "BQYHCA=="
}

Measurement-ready response when ready devices are currently within GEO_LINK_RANGE:

{
  "event": "messurement_ready",
  "id": "550e8400-e29b-41d4-a716-446655440001",
  "uuid": "550e8400-e29b-41d4-a716-446655440001",
  "peer_uuid": "550e8400-e29b-41d4-a716-446655440001",
  "peer_uuids": [
    "550e8400-e29b-41d4-a716-446655440001"
  ],
  "ready_uuids": [
    "550e8400-e29b-41d4-a716-446655440000",
    "550e8400-e29b-41d4-a716-446655440001"
  ],
  "ready_count": 2,
  "ready": true
}

Project Layout

.
├── docker-compose.yml
├── Dockerfile
├── requirements.txt
└── src
    ├── data
    │   ├── __init__.py
    │   ├── cache.py
    │   └── uwb_connections.py
    ├── __init__.py
    ├── main.py
    ├── repo
    │   ├── __init__.py
    │   ├── overview.py
    │   ├── uwb_connections.py
    │   └── websockets.py
    └── service
        ├── __init__.py
        └── geo_link.py