diff --git a/.gitignore b/.gitignore index c41da16..76569b4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,13 @@ bin/ !tools/bin/pkg-config *.log .DS_Store +__pycache__/ +*.py[cod] +connectors/gtfs/.env.local +connectors/gtfs/.venv/ +connectors/gtfs/logs/ +connectors/opensky/.env.local +connectors/opensky/.venv/ +connectors/opensky/logs/ +connectors/local-hub/demo.env +connectors/local-hub/state/ diff --git a/README.md b/README.md index 9350ed5..bf84c1c 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Key capabilities: - Dockerized local runtime for Postgres, Mosquitto, Dex, and the hub - `just` workflows for bootstrap, code generation, validation, and compose operations - Unit tests and Testcontainers-based integration coverage +- Connector demonstrators under [`connectors/`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors) ## Omlox @@ -59,6 +60,12 @@ Notes: - [docs/auth.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/docs/auth.md) - [docs/rpc.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/docs/rpc.md) +## Connector Demonstrators +- [connectors/README.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/README.md) +- [connectors/local-hub/README.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/local-hub/README.md) +- [connectors/gtfs/README.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/gtfs/README.md) +- [connectors/opensky/README.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/opensky/README.md) + ## Engineering Docs - [engineering/index.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/engineering/index.md) - [engineering/testing.md](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/engineering/testing.md) diff --git a/cmd/hub/main.go b/cmd/hub/main.go index 8456ba0..8f85ff6 100644 --- a/cmd/hub/main.go +++ b/cmd/hub/main.go @@ -260,7 +260,19 @@ func runWithRuntime(ctx context.Context, rt runtimeDeps) error { RequestBodyLimitBytes: cfg.HTTPRequestBodyLimitBytes, }) gen.HandlerFromMux(h, r) - wsHub := ws.New(logger, service, eventBus, authenticator, registry, cfg.Auth, cfg.WebSocketWriteTimeout, cfg.WebSocketOutboundBuffer, cfg.CollisionsEnabled) + wsHub := ws.New( + logger, + service, + eventBus, + authenticator, + registry, + cfg.Auth, + cfg.WebSocketWriteTimeout, + cfg.WebSocketReadTimeout, + cfg.WebSocketPingInterval, + cfg.WebSocketOutboundBuffer, + cfg.CollisionsEnabled, + ) r.Get("/v2/ws/socket", wsHub.Handle) srv := rt.newHTTPServer(cfg.HTTPListenAddr, r) diff --git a/connectors/README.md b/connectors/README.md new file mode 100644 index 0000000..b1c941d --- /dev/null +++ b/connectors/README.md @@ -0,0 +1,19 @@ +# Connectors + +This directory contains demonstrator and integration projects that feed data +into a locally running Open RTLS Hub without changing the hub itself. + +Connector projects in this repository should: + +- stay environment-driven and runnable outside the hub process +- document their upstream data sources and any source-specific limitations +- prefer the hub's existing OMLOX interfaces over private integration paths +- keep bootstrap utilities and runtime connectors in the same project when they + depend on the same upstream metadata +- reuse the shared local hub runtime under [`connectors/local-hub`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/local-hub) when they need a local demo stack + +Available connector demos: + +- [`connectors/local-hub`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/local-hub): reusable local hub, Postgres, Dex, and Mosquitto stack +- [`connectors/gtfs`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/gtfs): GTFS-RT vehicle updates and station fence bootstrap +- [`connectors/opensky`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/opensky): OpenSky aircraft positions with airport-sector fences diff --git a/connectors/gtfs/.env.example b/connectors/gtfs/.env.example new file mode 100644 index 0000000..318762d --- /dev/null +++ b/connectors/gtfs/.env.example @@ -0,0 +1,24 @@ +# Hub endpoints +HUB_HTTP_URL=http://localhost:8080 +HUB_WS_URL=ws://localhost:8080/v2/ws/socket +HUB_TOKEN= + +# Upstream feeds +GTFS_STATIC_URL=https://transport.data.gouv.fr/resources/81254/download +GTFS_RT_URL=https://proxy.transport.data.gouv.fr/resource/tgd-dole-gtfs-rt-vehicle-position +GTFS_REFERENCE_DATASET_FAMILY= + +# Connector identity +GTFS_PROVIDER_ID=grand-dole-gtfs-demo +GTFS_PROVIDER_TYPE=gtfs-rt +GTFS_PROVIDER_NAME=Grand Dole GTFS Demonstrator + +# Runtime tuning +GTFS_POLL_INTERVAL_SECONDS=15 +GTFS_ROUTE_FILTER= +GTFS_STATION_FILTER= +GTFS_MAX_STATIONS= +GTFS_FALLBACK_RADIUS_METERS=250 +GTFS_STATION_POLYGON_MODE=circle +GTFS_STATION_RADIUS_METERS=250 +GTFS_STATION_HULL_BUFFER_METERS=0 diff --git a/connectors/gtfs/README.md b/connectors/gtfs/README.md new file mode 100644 index 0000000..9fb8d30 --- /dev/null +++ b/connectors/gtfs/README.md @@ -0,0 +1,206 @@ +# GTFS Connector Demonstrator + +This demonstrator forwards GTFS-RT vehicle positions to a locally running Open +RTLS Hub over the OMLOX WebSocket `location_updates` topic and bootstraps +station zones and polygon fences for arrival and departure tracking. + +The checked-in defaults target the live Grand Dole network: + +- static GTFS input defaults to the public `gtfs-dole.zip` feed +- realtime input defaults to the public Grand Dole GTFS-RT vehicle positions feed +- station polygons are generated from GTFS stop geometry by default +- optional external reference datasets can be layered on top when a deployment + has richer stop-area geometry available + +## Files + +- `connector.py`: polls GTFS-RT vehicle positions and publishes OMLOX + `location_updates` over WebSocket +- `station_polygons.py`: creates one station `Zone` and one station `Fence` per + station in the hub +- `scripts/log_locations.py`: subscribes to `location_updates` and writes NDJSON +- `scripts/log_fence_events.py`: subscribes to `fence_events` and writes NDJSON +- `scripts/log_collision_events.py`: subscribes to `collision_events` and writes NDJSON +- `scripts/check_geofence_alignment.py`: compares logged locations against the + current station fences +- `hub_client.py`: shared REST and WebSocket helper code +- `gtfs_support.py`: GTFS parsing, GTFS-RT decoding, and station polygon + generation helpers +- `.env.example`: environment template +- `pyproject.toml`: `uv`-managed Python project metadata +- `uv.lock`: locked Python dependency set for the demo + +## Shared Local Hub + +This demo uses the shared local runtime in +[`connectors/local-hub`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/local-hub). + +Start it with: + +```bash +connectors/local-hub/start_demo.sh +``` + +Fetch an admin token with: + +```bash +connectors/local-hub/fetch_demo_token.sh +``` + +## Required Inputs + +- `HUB_HTTP_URL`: base URL for REST bootstrap calls such as `http://localhost:8080` +- `HUB_WS_URL`: WebSocket endpoint such as `ws://localhost:8080/v2/ws/socket` +- `HUB_TOKEN`: optional JWT access token; required when hub auth is enabled +- `GTFS_STATIC_URL`: GTFS zip URL or local path +- `GTFS_RT_URL`: GTFS-RT protobuf URL +- `GTFS_REFERENCE_DATASET_FAMILY`: optional external geometry helper set; leave + unset for GTFS-only geometry, or set to `idfm` for Ile-de-France reference data + +Optional filters and tuning: + +- `GTFS_POLL_INTERVAL_SECONDS`: GTFS-RT poll interval +- `GTFS_ROUTE_FILTER`: route ID filter for the runtime connector +- `GTFS_STATION_FILTER`: station-name substring filter for polygon bootstrap +- `GTFS_MAX_STATIONS`: cap station bootstrap volume during local testing +- `GTFS_FALLBACK_RADIUS_METERS`: legacy/default station circle radius +- `GTFS_STATION_POLYGON_MODE`: `circle`, `auto`, or `hull` +- `GTFS_STATION_RADIUS_METERS`: radius used for circle-based station fences +- `GTFS_STATION_HULL_BUFFER_METERS`: outward expansion applied to hull polygons + +## Setup + +1. Start the self-contained local runtime: + +```bash +connectors/local-hub/start_demo.sh +``` + +2. Copy `.env.example` to `connectors/gtfs/.env.local` and fill in the required + variables: + +```bash +cp connectors/gtfs/.env.example connectors/gtfs/.env.local +``` + +3. If the hub runs with auth enabled, fetch a token with `connectors/local-hub/fetch_demo_token.sh` + and set `HUB_TOKEN`. +4. Sync the Python runtime with `uv`: + +```bash +uv sync --project connectors/gtfs +``` + +5. Bootstrap station zones and fences: + +```bash +uv run --project connectors/gtfs python connectors/gtfs/station_polygons.py --env-file connectors/gtfs/.env.local +``` + +6. Start the connector: + +```bash +uv run --project connectors/gtfs python connectors/gtfs/connector.py --env-file connectors/gtfs/.env.local +``` + +7. Optional: record live WebSocket topics to NDJSON: + +```bash +uv run --project connectors/gtfs python connectors/gtfs/scripts/log_locations.py --env-file connectors/gtfs/.env.local +uv run --project connectors/gtfs python connectors/gtfs/scripts/log_fence_events.py --env-file connectors/gtfs/.env.local +uv run --project connectors/gtfs python connectors/gtfs/scripts/log_collision_events.py --env-file connectors/gtfs/.env.local +``` + +For a single GTFS-RT fetch during local testing: + +```bash +uv run --project connectors/gtfs python connectors/gtfs/connector.py --env-file connectors/gtfs/.env.local --once +``` + +## Hub Mapping + +### Vehicle updates + +The connector publishes OMLOX WebSocket wrapper messages shaped like: + +```json +{ + "event": "message", + "topic": "location_updates", + "payload": [ + { + "position": { "type": "Point", "coordinates": [2.35, 48.85] }, + "crs": "EPSG:4326", + "provider_id": "grand-dole-gtfs-demo", + "provider_type": "gtfs-rt", + "source": "gtfs-stop:stop-area-12345" + } + ], + "params": { + "token": "..." + } +} +``` + +Runtime mapping details: + +- `provider_id` stays stable for the connector instance +- `provider_type` defaults to `gtfs-rt` +- `source` is derived from the GTFS stop when present and falls back to the + vehicle or entity ID otherwise +- vehicle IDs are mapped to deterministic trackable UUIDs and upserted through + `/v2/trackables` +- trip, route, stop, and vehicle metadata are copied into `Location.properties` + +### Station zones and fences + +The bootstrap script creates: + +- one `Zone` per station with deterministic UUID, `foreign_id` equal to the GTFS + station ID, and centroid `position` +- one `Fence` per station with deterministic UUID, `foreign_id` equal to the + GTFS station ID, and a WGS84 polygon ring + +The current hub API does not let `Zone` carry polygon geometry, so polygon-based +arrival and departure tracking is intentionally modeled through `Fence` objects. + +## Polygon Generation + +Station polygons are generated from the available station geometry in this order: + +1. GTFS station and child-stop coordinates +2. optional external reference points when `GTFS_REFERENCE_DATASET_FAMILY` is configured +3. centroid-plus-buffer fallback when there are too few points for a hull + +Generation behavior: + +- the default `circle` mode creates larger station catchment polygons around the centroid +- `auto` uses a hull when enough points exist and otherwise falls back to a circle +- `hull` uses a hull when possible and otherwise still falls back to a circle +- `GTFS_STATION_HULL_BUFFER_METERS` can expand tight stop-derived hulls outward +- `properties.generation_mode`, `properties.source_point_count`, and + `properties.point_sources` capture the provenance + +## Tracking Arrivals And Departures + +After the bootstrap script creates station fences, normal hub fence processing +can emit `fence_events` when vehicle trackables enter or leave those station +polygons. Subscribe to `fence_events` or `fence_events:geojson` to observe +arrival and departure behavior. + +The `scripts/` directory also includes a simple alignment checker: + +```bash +uv run --project connectors/gtfs python connectors/gtfs/scripts/check_geofence_alignment.py --env-file connectors/gtfs/.env.local +``` + +## Limitations + +- vehicle-to-trackable persistence is best-effort and keyed by deterministic + UUIDs derived from feed identifiers +- station polygons are heuristic when the available stop geometry does not + expose enough points for a hull +- the connector currently polls GTFS-RT over HTTP; it does not yet support + feed-specific authentication or incremental realtime cursors +- the demo stack persists Postgres data but keeps Dex state in memory because + the repository fixture uses static local users and clients diff --git a/connectors/gtfs/connector.py b/connectors/gtfs/connector.py new file mode 100644 index 0000000..01d4c53 --- /dev/null +++ b/connectors/gtfs/connector.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Forward GTFS-RT vehicle positions to a local Open RTLS Hub over WebSocket.""" + +from __future__ import annotations + +import argparse +import logging +import os +import time +from datetime import datetime, timezone +from typing import Any + +from google.transit import gtfs_realtime_pb2 + +from gtfs_support import fetch_gtfs_rt_feed, load_env_file, load_gtfs_index +from hub_client import HubConfig, HubRESTClient, HubWebSocketPublisher, deterministic_uuid, point + + +LOGGER = logging.getLogger("gtfs.connector") + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", default=os.getenv("GTFS_ENV_FILE")) + parser.add_argument("--once", action="store_true", help="Process one GTFS-RT fetch and exit") + return parser + + +def main() -> int: + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + gtfs_static_url = require_env("GTFS_STATIC_URL") + gtfs_rt_url = require_env("GTFS_RT_URL") + provider_id = os.getenv("GTFS_PROVIDER_ID", "gtfs-demo") + provider_type = os.getenv("GTFS_PROVIDER_TYPE", "gtfs-rt") + provider_name = os.getenv("GTFS_PROVIDER_NAME", "GTFS Demonstrator") + route_filter = os.getenv("GTFS_ROUTE_FILTER") or None + poll_interval = float(os.getenv("GTFS_POLL_INTERVAL_SECONDS", "15")) + + hub_config = HubConfig( + http_url=require_env("HUB_HTTP_URL"), + ws_url=require_env("HUB_WS_URL"), + token=os.getenv("HUB_TOKEN") or None, + ) + hub_rest = HubRESTClient(hub_config) + hub_ws = HubWebSocketPublisher(hub_config) + + gtfs = load_gtfs_index(gtfs_static_url) + LOGGER.info("loaded GTFS index with %d stations and %d trips", len(gtfs.stations), len(gtfs.trips)) + + hub_rest.ensure_provider( + provider_id=provider_id, + provider_type=provider_type, + name=provider_name, + properties={"connector": "gtfs", "transport": "websocket"}, + ) + + known_trackables: set[str] = set() + try: + while True: + feed = fetch_gtfs_rt_feed(gtfs_rt_url) + locations = build_locations( + feed=feed, + gtfs=gtfs, + provider_id=provider_id, + provider_type=provider_type, + route_filter=route_filter, + hub_rest=hub_rest, + known_trackables=known_trackables, + ) + if locations: + LOGGER.info("publishing %d location updates", len(locations)) + hub_ws.publish_locations(locations) + else: + LOGGER.info("no matching vehicle positions in current feed") + + if args.once: + return 0 + time.sleep(poll_interval) + finally: + hub_ws.close() + + +def build_locations( + feed: gtfs_realtime_pb2.FeedMessage, + gtfs: Any, + provider_id: str, + provider_type: str, + route_filter: str | None, + hub_rest: HubRESTClient, + known_trackables: set[str], +) -> list[dict[str, Any]]: + locations: list[dict[str, Any]] = [] + for entity in feed.entity: + if not entity.HasField("vehicle"): + continue + vehicle = entity.vehicle + if not vehicle.HasField("position"): + continue + + trip = gtfs.trips.get(vehicle.trip.trip_id, {}) + route_id = trip.get("route_id") or vehicle.trip.route_id or None + if route_filter and route_id != route_filter: + continue + + vehicle_id = vehicle_identifier(entity.id, vehicle) + trackable_id = None + if vehicle_id: + trackable_id = deterministic_uuid("trackable", vehicle_id) + if trackable_id not in known_trackables: + hub_rest.ensure_trackable( + trackable_id=trackable_id, + name=trackable_name(vehicle_id, trip, gtfs), + provider_id=provider_id, + properties=trackable_properties(vehicle_id, vehicle, trip), + ) + known_trackables.add(trackable_id) + + location = build_location_payload( + entity_id=entity.id, + vehicle=vehicle, + gtfs=gtfs, + provider_id=provider_id, + provider_type=provider_type, + route_id=route_id, + trip=trip, + trackable_id=trackable_id, + vehicle_id=vehicle_id, + ) + if location is not None: + locations.append(location) + return locations + + +def build_location_payload( + entity_id: str, + vehicle: gtfs_realtime_pb2.VehiclePosition, + gtfs: Any, + provider_id: str, + provider_type: str, + route_id: str | None, + trip: dict[str, str], + trackable_id: str | None, + vehicle_id: str | None, +) -> dict[str, Any] | None: + latitude = vehicle.position.latitude + longitude = vehicle.position.longitude + if latitude == 0 and longitude == 0: + return None + + station = gtfs.station_for_stop(vehicle.stop_id) + route = gtfs.routes.get(route_id or "", {}) + + payload: dict[str, Any] = { + "position": point(longitude, latitude), + "crs": "EPSG:4326", + "provider_id": provider_id, + "provider_type": provider_type, + "source": source_identifier(vehicle.stop_id, vehicle_id, entity_id), + "properties": { + "connector": "gtfs", + "entity_id": entity_id, + "trip_id": vehicle.trip.trip_id or None, + "route_id": route_id, + "route_short_name": route.get("route_short_name"), + "route_long_name": route.get("route_long_name"), + "direction_id": trip.get("direction_id"), + "vehicle_id": vehicle_id, + "vehicle_label": vehicle.vehicle.label or None, + "vehicle_license_plate": vehicle.vehicle.license_plate or None, + "current_stop_sequence": vehicle.current_stop_sequence or None, + "current_status": vehicle_status_name(vehicle.current_status), + "stop_id": vehicle.stop_id or None, + "station_id": station.station_id if station else None, + "station_name": station.name if station else None, + "bearing": vehicle.position.bearing or None, + "speed": vehicle.position.speed or None, + }, + } + if vehicle.timestamp: + payload["timestamp_generated"] = format_unix_timestamp(vehicle.timestamp) + if vehicle.position.speed: + payload["speed"] = vehicle.position.speed + if vehicle.position.bearing: + payload["course"] = vehicle.position.bearing + if trackable_id: + payload["trackables"] = [trackable_id] + return payload + + +def trackable_name(vehicle_id: str, trip: dict[str, str], gtfs: Any) -> str: + route = gtfs.routes.get(trip.get("route_id", ""), {}) + route_name = route.get("route_short_name") or route.get("route_long_name") + if route_name: + return f"{route_name} {vehicle_id}" + return f"Vehicle {vehicle_id}" + + +def trackable_properties( + vehicle_id: str, + vehicle: gtfs_realtime_pb2.VehiclePosition, + trip: dict[str, str], +) -> dict[str, Any]: + return { + "connector": "gtfs", + "external_vehicle_id": vehicle_id, + "trip_id": vehicle.trip.trip_id or None, + "route_id": trip.get("route_id") or vehicle.trip.route_id or None, + "vehicle_label": vehicle.vehicle.label or None, + } + + +def vehicle_identifier( + entity_id: str, + vehicle: gtfs_realtime_pb2.VehiclePosition, +) -> str | None: + for value in (vehicle.vehicle.id, vehicle.vehicle.label, entity_id): + if value: + return value + return None + + +def vehicle_status_name(status: int) -> str: + return gtfs_realtime_pb2.VehiclePosition.VehicleStopStatus.Name(status) + + +def source_identifier(stop_id: str, vehicle_id: str | None, entity_id: str) -> str: + if stop_id: + return f"gtfs-stop:{stop_id}" + if vehicle_id: + return f"gtfs-vehicle:{vehicle_id}" + return f"gtfs-entity:{entity_id}" + + +def format_unix_timestamp(timestamp: int) -> str: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() + + +def require_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise SystemExit(f"{name} is required") + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/gtfs_support.py b/connectors/gtfs/gtfs_support.py new file mode 100644 index 0000000..a929367 --- /dev/null +++ b/connectors/gtfs/gtfs_support.py @@ -0,0 +1,537 @@ +"""Shared GTFS and station geometry helpers for the demonstrator scripts.""" + +from __future__ import annotations + +import csv +import io +import logging +import math +import os +import tempfile +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Iterable + +import requests +from google.transit import gtfs_realtime_pb2 +from pyproj import Transformer + + +LOGGER = logging.getLogger(__name__) +IDFM_DATASET_BASE = "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets" +LAMBERT93_TO_WGS84 = Transformer.from_crs("EPSG:2154", "EPSG:4326", always_xy=True) + + +def load_env_file(path: str | None) -> None: + """Load KEY=VALUE pairs into os.environ when a local env file exists.""" + + if not path: + return + env_path = Path(path) + if not env_path.exists(): + return + for line in env_path.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +def truthy_env(name: str, default: bool = False) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass +class StopRecord: + stop_id: str + stop_name: str + stop_lat: float | None + stop_lon: float | None + parent_station: str | None + location_type: str | None + + @property + def coordinates(self) -> tuple[float, float] | None: + if self.stop_lon is None or self.stop_lat is None: + return None + return (self.stop_lon, self.stop_lat) + + +@dataclass +class StationRecord: + station_id: str + name: str + primary_stop: StopRecord | None + child_stops: list[StopRecord] = field(default_factory=list) + + @property + def numeric_id(self) -> str | None: + return extract_idfm_numeric(self.station_id) + + @property + def all_points(self) -> list[tuple[float, float]]: + points: list[tuple[float, float]] = [] + if self.primary_stop and self.primary_stop.coordinates is not None: + points.append(self.primary_stop.coordinates) + for stop in self.child_stops: + if stop.coordinates is not None: + points.append(stop.coordinates) + return unique_points(points) + + +@dataclass +class GTFSIndex: + stops: dict[str, StopRecord] + stations: dict[str, StationRecord] + routes: dict[str, dict[str, str]] + trips: dict[str, dict[str, str]] + + def station_for_stop(self, stop_id: str | None) -> StationRecord | None: + if not stop_id: + return None + if stop_id in self.stations: + return self.stations[stop_id] + stop = self.stops.get(stop_id) + if not stop: + return None + if stop.parent_station and stop.parent_station in self.stations: + return self.stations[stop.parent_station] + return None + + +@dataclass +class StationGeometry: + station: StationRecord + centroid: tuple[float, float] + polygon_ring: list[list[float]] + generation_mode: str + source_point_count: int + point_sources: list[str] + + +def ensure_local_file(source: str) -> Path: + """Return a local path for either an existing file or a downloaded URL.""" + + if source.startswith("http://") or source.startswith("https://"): + response = requests.get(source, timeout=120) + response.raise_for_status() + suffix = Path(source).suffix or ".zip" + handle = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + handle.write(response.content) + handle.flush() + handle.close() + LOGGER.info("downloaded %s to %s", source, handle.name) + return Path(handle.name) + return Path(source) + + +def load_gtfs_index(source: str) -> GTFSIndex: + """Load a GTFS zip and build quick lookup tables for stops, trips, and routes.""" + + zip_path = ensure_local_file(source) + with zipfile.ZipFile(zip_path) as archive: + stops = { + row["stop_id"]: StopRecord( + stop_id=row["stop_id"], + stop_name=row.get("stop_name") or row["stop_id"], + stop_lat=parse_float(row.get("stop_lat")), + stop_lon=parse_float(row.get("stop_lon")), + parent_station=row.get("parent_station") or None, + location_type=row.get("location_type") or None, + ) + for row in read_csv_from_zip(archive, "stops.txt") + } + routes = { + row["route_id"]: row + for row in read_csv_from_zip(archive, "routes.txt", required=False) + } + trips = { + row["trip_id"]: row + for row in read_csv_from_zip(archive, "trips.txt", required=False) + } + + stations: dict[str, StationRecord] = {} + for stop in stops.values(): + if stop.location_type == "1": + stations[stop.stop_id] = StationRecord( + station_id=stop.stop_id, + name=stop.stop_name, + primary_stop=stop, + ) + + for stop in stops.values(): + if stop.parent_station: + station = stations.get(stop.parent_station) + if station is None: + station = StationRecord( + station_id=stop.parent_station, + name=stop.stop_name, + primary_stop=None, + ) + stations[stop.parent_station] = station + station.child_stops.append(stop) + if station.primary_stop is None: + station.primary_stop = stop + + for stop in stops.values(): + if stop.location_type == "1" or stop.parent_station: + continue + stations.setdefault( + stop.stop_id, + StationRecord(station_id=stop.stop_id, name=stop.stop_name, primary_stop=stop), + ) + + return GTFSIndex(stops=stops, stations=stations, routes=routes, trips=trips) + + +def decode_gtfs_rt_feed(payload: bytes) -> gtfs_realtime_pb2.FeedMessage: + """Decode a GTFS-RT protobuf payload.""" + + feed = gtfs_realtime_pb2.FeedMessage() + feed.ParseFromString(payload) + return feed + + +def fetch_gtfs_rt_feed(url: str, timeout_seconds: float = 30.0) -> gtfs_realtime_pb2.FeedMessage: + """Download and decode the current GTFS-RT feed.""" + + response = requests.get(url, timeout=timeout_seconds) + response.raise_for_status() + return decode_gtfs_rt_feed(response.content) + + +def build_station_geometries( + gtfs: GTFSIndex, + fallback_radius_meters: float, + station_filter: str | None = None, + max_stations: int | None = None, +) -> list[StationGeometry]: + """Build station centroids and polygons from GTFS and optional references.""" + + references = load_reference_datasets() + results: list[StationGeometry] = [] + normalized_filter = station_filter.lower() if station_filter else None + polygon_mode = (os.getenv("GTFS_STATION_POLYGON_MODE") or "circle").strip().lower() + circle_radius = parse_float(os.getenv("GTFS_STATION_RADIUS_METERS")) or fallback_radius_meters + hull_buffer_meters = parse_float(os.getenv("GTFS_STATION_HULL_BUFFER_METERS")) or 0.0 + + for station in gtfs.stations.values(): + if normalized_filter and normalized_filter not in station.name.lower(): + continue + geometry = build_station_geometry( + station, + references, + fallback_radius_meters=circle_radius, + polygon_mode=polygon_mode, + hull_buffer_meters=hull_buffer_meters, + ) + if geometry is None: + continue + results.append(geometry) + if max_stations is not None and len(results) >= max_stations: + break + + return results + + +def build_station_geometry( + station: StationRecord, + references: dict[str, Any], + fallback_radius_meters: float, + polygon_mode: str, + hull_buffer_meters: float, +) -> StationGeometry | None: + """Build one station polygon, falling back to a circle when needed.""" + + points: list[tuple[float, float]] = [] + point_sources: list[str] = [] + numeric_id = station.numeric_id + + for point in station.all_points: + points.append(point) + point_sources.append("gtfs_stop") + + if numeric_id: + for point in references["access_points"].get(numeric_id, []): + points.append(point) + point_sources.append("idfm_access") + for point in references["stop_points"].get(numeric_id, []): + points.append(point) + point_sources.append("idfm_stop") + centroid = references["zone_centroids"].get(numeric_id) + if centroid is not None: + points.append(centroid) + point_sources.append("idfm_zone") + + points = unique_points(points) + if not points: + return None + + centroid = compute_centroid(points) + if polygon_mode in {"auto", "hull"} and len(points) >= 3: + hull = convex_hull(points) + if len(hull) >= 3: + ring = [[lon, lat] for lon, lat in hull] + if hull_buffer_meters > 0: + ring = expand_ring(ring, centroid, hull_buffer_meters) + ring = close_ring(ring) + return StationGeometry( + station=station, + centroid=centroid, + polygon_ring=ring, + generation_mode="convex_hull" if hull_buffer_meters <= 0 else "buffered_hull", + source_point_count=len(points), + point_sources=sorted(set(point_sources)), + ) + + ring = buffered_circle(centroid, fallback_radius_meters) + return StationGeometry( + station=station, + centroid=centroid, + polygon_ring=ring, + generation_mode="buffered_circle", + source_point_count=len(points), + point_sources=sorted(set(point_sources)), + ) + + +def load_reference_datasets() -> dict[str, Any]: + """Load optional external station reference datasets for polygon generation.""" + + dataset_family = (os.getenv("GTFS_REFERENCE_DATASET_FAMILY") or "").strip().lower() + if not dataset_family: + return empty_reference_datasets() + if dataset_family != "idfm": + LOGGER.warning("unknown GTFS_REFERENCE_DATASET_FAMILY=%s; ignoring", dataset_family) + return empty_reference_datasets() + + try: + return load_idfm_references() + except Exception: + LOGGER.warning("failed to load IDFM reference datasets; falling back to GTFS-only geometry", exc_info=True) + return empty_reference_datasets() + + +def empty_reference_datasets() -> dict[str, Any]: + return { + "access_points": {}, + "stop_points": {}, + "zone_centroids": {}, + } + + +def load_idfm_references() -> dict[str, Any]: + """Fetch the IDFM stop-area datasets needed for station polygon generation.""" + + access_records = fetch_dataset_records("acces") + relation_records = fetch_dataset_records("relations-acces") + stop_records = fetch_dataset_records("arrets") + zone_records = fetch_dataset_records("zones-d-arrets") + + access_by_id: dict[str, tuple[float, float]] = {} + for record in access_records: + geopoint = record.get("accgeopoint") or {} + lon = parse_float(geopoint.get("lon")) + lat = parse_float(geopoint.get("lat")) + if lon is None or lat is None: + continue + access_by_id[str(record["accid"])] = (lon, lat) + + access_points: dict[str, list[tuple[float, float]]] = {} + for relation in relation_records: + zdaid = str(relation.get("zdaid") or "") + accid = str(relation.get("accid") or "") + point = access_by_id.get(accid) + if not zdaid or point is None: + continue + access_points.setdefault(zdaid, []).append(point) + + stop_points: dict[str, list[tuple[float, float]]] = {} + for record in stop_records: + zdaid = str(record.get("zdaid") or "") + geopoint = record.get("arrgeopoint") or {} + lon = parse_float(geopoint.get("lon")) + lat = parse_float(geopoint.get("lat")) + if not zdaid or lon is None or lat is None: + continue + stop_points.setdefault(zdaid, []).append((lon, lat)) + + zone_centroids: dict[str, tuple[float, float]] = {} + for record in zone_records: + zdaid = str(record.get("zdaid") or "") + x = parse_float(record.get("zdaxepsg2154")) + y = parse_float(record.get("zdayepsg2154")) + if not zdaid or x is None or y is None: + continue + lon, lat = LAMBERT93_TO_WGS84.transform(x, y) + zone_centroids[zdaid] = (lon, lat) + + return { + "access_points": access_points, + "stop_points": stop_points, + "zone_centroids": zone_centroids, + } + + +def fetch_dataset_records(dataset: str, page_size: int = 100) -> list[dict[str, Any]]: + """Fetch all records for one IDFM Opendatasoft dataset.""" + + offset = 0 + results: list[dict[str, Any]] = [] + while True: + response = requests.get( + f"{IDFM_DATASET_BASE}/{dataset}/records", + params={"limit": page_size, "offset": offset}, + timeout=60, + ) + response.raise_for_status() + payload = response.json() + page = payload.get("results", []) + results.extend(page) + if len(page) < page_size: + return results + offset += page_size + + +def read_csv_from_zip( + archive: zipfile.ZipFile, + name: str, + required: bool = True, +) -> Iterable[dict[str, str]]: + """Yield CSV rows from one file inside a GTFS zip.""" + + if name not in archive.namelist(): + if required: + raise FileNotFoundError(f"{name} is required in the GTFS archive") + return [] + with archive.open(name, "r") as handle: + text = io.TextIOWrapper(handle, encoding="utf-8-sig", newline="") + reader = csv.DictReader(text) + return list(reader) + + +def extract_idfm_numeric(identifier: str | None) -> str | None: + """Extract the final numeric token from IDFM stop identifiers.""" + + if not identifier: + return None + token = identifier.rsplit(":", 1)[-1] + return token if token.isdigit() else None + + +def parse_float(value: Any) -> float | None: + if value in (None, ""): + return None + return float(value) + + +def unique_points(points: Iterable[tuple[float, float]]) -> list[tuple[float, float]]: + seen: set[tuple[float, float]] = set() + unique: list[tuple[float, float]] = [] + for lon, lat in points: + key = (round(lon, 7), round(lat, 7)) + if key in seen: + continue + seen.add(key) + unique.append((lon, lat)) + return unique + + +def compute_centroid(points: list[tuple[float, float]]) -> tuple[float, float]: + count = float(len(points)) + lon = sum(point[0] for point in points) / count + lat = sum(point[1] for point in points) / count + return (lon, lat) + + +def cross( + origin: tuple[float, float], + a: tuple[float, float], + b: tuple[float, float], +) -> float: + return (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * (b[0] - origin[0]) + + +def convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]: + """Compute a monotonic-chain convex hull.""" + + if len(points) <= 1: + return points + + sorted_points = sorted(points) + lower: list[tuple[float, float]] = [] + for point in sorted_points: + while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0: + lower.pop() + lower.append(point) + + upper: list[tuple[float, float]] = [] + for point in reversed(sorted_points): + while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0: + upper.pop() + upper.append(point) + + return lower[:-1] + upper[:-1] + + +def close_ring(ring: list[list[float]]) -> list[list[float]]: + if not ring: + return ring + if ring[0] != ring[-1]: + ring.append(list(ring[0])) + return ring + + +def buffered_circle( + center: tuple[float, float], + radius_meters: float, + steps: int = 24, +) -> list[list[float]]: + """Approximate a meter buffer around a point as a GeoJSON polygon ring.""" + + lon, lat = center + lat_factor = radius_meters / 111_320.0 + lon_factor = radius_meters / (111_320.0 * max(math.cos(math.radians(lat)), 0.2)) + ring: list[list[float]] = [] + for index in range(steps): + angle = (2.0 * math.pi * index) / steps + ring.append( + [ + lon + math.cos(angle) * lon_factor, + lat + math.sin(angle) * lat_factor, + ] + ) + return close_ring(ring) + + +def expand_ring( + ring: list[list[float]], + centroid: tuple[float, float], + buffer_meters: float, +) -> list[list[float]]: + """Expand polygon vertices away from the centroid by a fixed distance.""" + + expanded: list[list[float]] = [] + center_lon, center_lat = centroid + lon_scale = 111_320.0 * max(math.cos(math.radians(center_lat)), 0.2) + lat_scale = 111_320.0 + for lon, lat in ring: + dx = (lon - center_lon) * lon_scale + dy = (lat - center_lat) * lat_scale + distance = math.hypot(dx, dy) + if distance <= 1e-6: + expanded.append([lon, lat]) + continue + scale = (distance + buffer_meters) / distance + expanded.append( + [ + center_lon + (dx * scale) / lon_scale, + center_lat + (dy * scale) / lat_scale, + ] + ) + return expanded diff --git a/connectors/gtfs/hub_client.py b/connectors/gtfs/hub_client.py new file mode 100644 index 0000000..215e756 --- /dev/null +++ b/connectors/gtfs/hub_client.py @@ -0,0 +1,218 @@ +"""Helpers for talking to a local Open RTLS Hub from connector scripts.""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import uuid +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin + +import requests +import websocket + + +LOGGER = logging.getLogger(__name__) +NAMESPACE = uuid.UUID("bfc6b8ac-84f4-49e1-a2b4-26f8a9573fd4") + + +def deterministic_uuid(kind: str, external_id: str) -> str: + """Return a stable UUIDv5 for a connector-managed resource.""" + + return str(uuid.uuid5(NAMESPACE, f"{kind}:{external_id}")) + + +def point(longitude: float, latitude: float) -> dict[str, Any]: + """Return a GeoJSON point.""" + + return {"type": "Point", "coordinates": [longitude, latitude]} + + +@dataclass +class HubConfig: + http_url: str + ws_url: str + token: str | None = None + timeout_seconds: float = 30.0 + + +class HubRESTClient: + """HubRESTClient wraps idempotent CRUD helpers for connector resources.""" + + def __init__(self, config: HubConfig): + self.config = config + self.session = requests.Session() + self.session.headers.update({"Accept": "application/json"}) + if config.token: + self.session.headers.update({"Authorization": f"Bearer {config.token}"}) + + def ensure_provider( + self, + provider_id: str, + provider_type: str, + name: str, + properties: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "id": provider_id, + "type": provider_type, + "name": name, + "properties": properties or {}, + } + return self._ensure_resource( + collection_path="/v2/providers", + item_path=f"/v2/providers/{provider_id}", + payload=payload, + ) + + def ensure_trackable( + self, + trackable_id: str, + name: str, + provider_id: str, + properties: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "id": trackable_id, + "type": "virtual", + "name": name, + "location_providers": [provider_id], + "properties": properties or {}, + } + return self._ensure_resource( + collection_path="/v2/trackables", + item_path=f"/v2/trackables/{trackable_id}", + payload=payload, + ) + + def ensure_zone(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._ensure_resource( + collection_path="/v2/zones", + item_path=f"/v2/zones/{zone_id}", + payload=payload, + ) + + def ensure_fence(self, fence_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._ensure_resource( + collection_path="/v2/fences", + item_path=f"/v2/fences/{fence_id}", + payload=payload, + ) + + def _ensure_resource( + self, + collection_path: str, + item_path: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + existing = self._request("GET", item_path, expected={200, 404}) + if existing.status_code == 404: + response = self._request("POST", collection_path, json_body=payload, expected={201}) + return response.json() + + response = self._request("PUT", item_path, json_body=payload, expected={200}) + return response.json() + + def _request( + self, + method: str, + path: str, + json_body: dict[str, Any] | None = None, + expected: set[int] | None = None, + ) -> requests.Response: + response = self.session.request( + method=method, + url=urljoin(self.config.http_url.rstrip("/") + "/", path.lstrip("/")), + json=json_body, + timeout=self.config.timeout_seconds, + ) + if expected and response.status_code not in expected: + try: + details = response.json() + except ValueError: + details = response.text + raise RuntimeError( + f"{method} {path} returned {response.status_code}: {details}" + ) + return response + + +class HubWebSocketPublisher: + """HubWebSocketPublisher sends OMLOX wrapper messages to location_updates.""" + + def __init__(self, config: HubConfig): + self.config = config + self._connection: websocket.WebSocket | None = None + self._lock = threading.Lock() + self._keepalive_stop = threading.Event() + self._keepalive_thread = threading.Thread( + target=self._keepalive_loop, + name="hub-ws-keepalive", + daemon=True, + ) + self._keepalive_thread.start() + + def close(self) -> None: + self._keepalive_stop.set() + with self._lock: + self._close_locked() + self._keepalive_thread.join(timeout=1.0) + + def publish_locations(self, locations: list[dict[str, Any]]) -> None: + if not locations: + return + + message: dict[str, Any] = { + "event": "message", + "topic": "location_updates", + "payload": locations, + } + if self.config.token: + message["params"] = {"token": self.config.token} + + raw = json.dumps(message) + self._send(raw) + + def _connect(self) -> websocket.WebSocket: + LOGGER.info("connecting websocket publisher to %s", self.config.ws_url) + connection = websocket.create_connection( + self.config.ws_url, + timeout=self.config.timeout_seconds, + ) + connection.settimeout(self.config.timeout_seconds) + return connection + + def _send(self, raw: str) -> None: + with self._lock: + if self._connection is None: + self._connection = self._connect() + + try: + self._connection.send(raw) + except Exception: + LOGGER.info("websocket send failed; reconnecting") + self._close_locked() + self._connection = self._connect() + self._connection.send(raw) + + def _keepalive_loop(self) -> None: + while not self._keepalive_stop.wait(15.0): + with self._lock: + if self._connection is None: + continue + try: + self._connection.ping("keepalive") + except Exception: + LOGGER.info("websocket ping failed; reconnecting on next send") + self._close_locked() + + def _close_locked(self) -> None: + if self._connection is not None: + try: + self._connection.close() + except Exception: + LOGGER.debug("websocket close failed", exc_info=True) + self._connection = None diff --git a/connectors/gtfs/pyproject.toml b/connectors/gtfs/pyproject.toml new file mode 100644 index 0000000..549fa8b --- /dev/null +++ b/connectors/gtfs/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "open-rtls-gtfs-demo" +version = "0.1.0" +description = "GTFS demonstrator connector for Open RTLS Hub" +requires-python = ">=3.12" +dependencies = [ + "gtfs-realtime-bindings>=2.0.0", + "protobuf>=7.0.0", + "pyproj>=3.7.0", + "requests>=2.33.0", + "websocket-client>=1.9.0", +] + +[tool.uv] +package = false diff --git a/connectors/gtfs/scripts/check_geofence_alignment.py b/connectors/gtfs/scripts/check_geofence_alignment.py new file mode 100644 index 0000000..e62f172 --- /dev/null +++ b/connectors/gtfs/scripts/check_geofence_alignment.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +"""Compare logged locations against current hub fences and summarize proximity.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import requests + +SCRIPT_DIR = Path(__file__).resolve().parent +ROOT_DIR = SCRIPT_DIR.parent +sys.path.insert(0, str(ROOT_DIR)) + +from gtfs_support import load_env_file # noqa: E402 + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", default=os.getenv("GTFS_ENV_FILE")) + parser.add_argument("--locations-log", default="connectors/gtfs/logs/location_updates.ndjson") + parser.add_argument("--http-url", default=os.getenv("HUB_HTTP_URL")) + parser.add_argument("--token", default=os.getenv("HUB_TOKEN")) + return parser + + +def main() -> int: + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + + http_url = args.http_url or os.getenv("HUB_HTTP_URL") + if not http_url: + raise SystemExit("HUB_HTTP_URL or --http-url is required") + + fences = fetch_fences(http_url, args.token or os.getenv("HUB_TOKEN")) + fence_polygons = [normalize_fence(fence) for fence in fences if normalize_fence(fence) is not None] + locations = load_locations(Path(args.locations_log)) + + summary = summarize_proximity(locations, fence_polygons) + print(json.dumps(summary, indent=2)) + return 0 + + +def fetch_fences(http_url: str, token: str | None) -> list[dict[str, Any]]: + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + response = requests.get(f"{http_url.rstrip('/')}/v2/fences", headers=headers, timeout=30) + response.raise_for_status() + return response.json() + + +def normalize_fence(fence: dict[str, Any]) -> dict[str, Any] | None: + region = fence.get("region") or {} + if region.get("type") != "Polygon": + return None + coordinates = region.get("coordinates") or [] + if not coordinates or not coordinates[0]: + return None + return { + "id": fence.get("id"), + "foreign_id": fence.get("foreign_id"), + "name": fence.get("name"), + "ring": coordinates[0], + } + + +def load_locations(path: Path) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + if not path.exists(): + return results + with path.open("r", encoding="utf-8") as handle: + for line in handle: + if not line.strip(): + continue + record = json.loads(line) + message = record.get("message", {}) + if message.get("event") != "message": + continue + for payload in message.get("payload", []): + position = payload.get("position") or {} + coordinates = position.get("coordinates") or [] + if len(coordinates) < 2: + continue + results.append( + { + "received_at": record.get("received_at"), + "provider_id": payload.get("provider_id"), + "trackables": payload.get("trackables", []), + "longitude": coordinates[0], + "latitude": coordinates[1], + } + ) + return results + + +def summarize_proximity( + locations: list[dict[str, Any]], + fences: list[dict[str, Any]], +) -> dict[str, Any]: + inside_count = 0 + min_distance = None + closest_examples: list[dict[str, Any]] = [] + + for location in locations: + best = None + point = (location["longitude"], location["latitude"]) + for fence in fences: + if point_in_polygon(point, fence["ring"]): + inside_count += 1 + best = {"distance_meters": 0.0, "fence": fence} + break + distance = polygon_distance_meters(point, fence["ring"]) + if best is None or distance < best["distance_meters"]: + best = {"distance_meters": distance, "fence": fence} + + if best is None: + continue + distance = best["distance_meters"] + if min_distance is None or distance < min_distance: + min_distance = distance + closest_examples.append( + { + "received_at": location["received_at"], + "provider_id": location["provider_id"], + "trackables": location["trackables"], + "distance_meters": round(distance, 2), + "fence_id": best["fence"]["id"], + "fence_name": best["fence"]["name"], + "fence_foreign_id": best["fence"]["foreign_id"], + } + ) + + closest_examples.sort(key=lambda item: item["distance_meters"]) + return { + "location_count": len(locations), + "fence_count": len(fences), + "locations_inside_any_fence": inside_count, + "closest_distance_meters": None if min_distance is None else round(min_distance, 2), + "closest_examples": closest_examples[:10], + } + + +def point_in_polygon(point: tuple[float, float], ring: list[list[float]]) -> bool: + x, y = point + inside = False + for index in range(len(ring) - 1): + x1, y1 = ring[index] + x2, y2 = ring[index + 1] + intersects = ((y1 > y) != (y2 > y)) and ( + x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1 + ) + if intersects: + inside = not inside + return inside + + +def polygon_distance_meters(point: tuple[float, float], ring: list[list[float]]) -> float: + best = math.inf + for index in range(len(ring) - 1): + best = min(best, segment_distance_meters(point, tuple(ring[index]), tuple(ring[index + 1]))) + return best + + +def segment_distance_meters( + point: tuple[float, float], + start: tuple[float, float], + end: tuple[float, float], +) -> float: + lon_scale = 111_320.0 * math.cos(math.radians(point[1])) + lat_scale = 111_320.0 + + px, py = point[0] * lon_scale, point[1] * lat_scale + sx, sy = start[0] * lon_scale, start[1] * lat_scale + ex, ey = end[0] * lon_scale, end[1] * lat_scale + + dx = ex - sx + dy = ey - sy + if dx == 0 and dy == 0: + return math.hypot(px - sx, py - sy) + + t = ((px - sx) * dx + (py - sy) * dy) / (dx * dx + dy * dy) + t = max(0.0, min(1.0, t)) + cx = sx + t * dx + cy = sy + t * dy + return math.hypot(px - cx, py - cy) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/scripts/log_collision_events.py b/connectors/gtfs/scripts/log_collision_events.py new file mode 100644 index 0000000..9009c92 --- /dev/null +++ b/connectors/gtfs/scripts/log_collision_events.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Log collision_events messages to an NDJSON file.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="connectors/gtfs/logs/collision_events.ndjson") + parser.add_argument("--env-file", default="connectors/gtfs/.env.local") + args = parser.parse_args() + + logger = Path(__file__).with_name("ws_ndjson_logger.py") + command = [ + sys.executable, + str(logger), + "--topic", + "collision_events", + "--output", + args.output, + "--env-file", + args.env_file, + ] + try: + return subprocess.call(command) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/scripts/log_fence_events.py b/connectors/gtfs/scripts/log_fence_events.py new file mode 100644 index 0000000..61635c0 --- /dev/null +++ b/connectors/gtfs/scripts/log_fence_events.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Log fence_events messages to an NDJSON file.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="connectors/gtfs/logs/fence_events.ndjson") + parser.add_argument("--env-file", default="connectors/gtfs/.env.local") + args = parser.parse_args() + + logger = Path(__file__).with_name("ws_ndjson_logger.py") + command = [ + sys.executable, + str(logger), + "--topic", + "fence_events", + "--output", + args.output, + "--env-file", + args.env_file, + ] + try: + return subprocess.call(command) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/scripts/log_locations.py b/connectors/gtfs/scripts/log_locations.py new file mode 100644 index 0000000..bdf7f14 --- /dev/null +++ b/connectors/gtfs/scripts/log_locations.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Log location_updates messages to an NDJSON file.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="connectors/gtfs/logs/location_updates.ndjson") + parser.add_argument("--env-file", default="connectors/gtfs/.env.local") + args = parser.parse_args() + + logger = Path(__file__).with_name("ws_ndjson_logger.py") + command = [ + sys.executable, + str(logger), + "--topic", + "location_updates", + "--output", + args.output, + "--env-file", + args.env_file, + ] + try: + return subprocess.call(command) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/scripts/ws_ndjson_logger.py b/connectors/gtfs/scripts/ws_ndjson_logger.py new file mode 100644 index 0000000..bf099f3 --- /dev/null +++ b/connectors/gtfs/scripts/ws_ndjson_logger.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Subscribe to one hub WebSocket topic and append wrapper events as NDJSON.""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import websocket + +SCRIPT_DIR = Path(__file__).resolve().parent +ROOT_DIR = SCRIPT_DIR.parent +sys.path.insert(0, str(ROOT_DIR)) + +from gtfs_support import load_env_file # noqa: E402 + + +RUNNING = True + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--topic", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--env-file", default=os.getenv("GTFS_ENV_FILE")) + parser.add_argument("--ws-url", default=os.getenv("HUB_WS_URL")) + parser.add_argument("--token", default=os.getenv("HUB_TOKEN")) + return parser + + +def main() -> int: + global RUNNING + + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + + ws_url = args.ws_url or os.getenv("HUB_WS_URL") + if not ws_url: + raise SystemExit("HUB_WS_URL or --ws-url is required") + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + signal.signal(signal.SIGINT, handle_stop) + signal.signal(signal.SIGTERM, handle_stop) + + token = args.token or os.getenv("HUB_TOKEN") + with output_path.open("a", encoding="utf-8") as handle: + while RUNNING: + connection: websocket.WebSocket | None = None + try: + connection = connect_and_subscribe(ws_url, args.topic, token) + while RUNNING: + try: + raw = connection.recv() + except TimeoutError: + connection.ping("keepalive") + continue + except websocket.WebSocketTimeoutException: + connection.ping("keepalive") + continue + payload = { + "received_at": datetime.now(timezone.utc).isoformat(), + "topic": args.topic, + "message": json.loads(raw), + } + handle.write(json.dumps(payload, separators=(",", ":")) + "\n") + handle.flush() + time.sleep(0.01) + except KeyboardInterrupt: + break + except Exception: + time.sleep(2.0) + finally: + if connection is not None: + connection.close() + return 0 + + +def handle_stop(_signum: int, _frame: object) -> None: + global RUNNING + RUNNING = False + + +def connect_and_subscribe(ws_url: str, topic: str, token: str | None) -> websocket.WebSocket: + connection = websocket.create_connection(ws_url, timeout=30) + connection.settimeout(15) + subscribe = {"event": "subscribe", "topic": topic} + if token: + subscribe["params"] = {"token": token} + connection.send(json.dumps(subscribe)) + return connection + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/station_polygons.py b/connectors/gtfs/station_polygons.py new file mode 100644 index 0000000..3f43455 --- /dev/null +++ b/connectors/gtfs/station_polygons.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Create station zones and polygon fences for GTFS stations in the local hub.""" + +from __future__ import annotations + +import argparse +import json +import logging +import os + +from gtfs_support import build_station_geometries, load_env_file, load_gtfs_index +from hub_client import HubConfig, HubRESTClient, deterministic_uuid, point + + +LOGGER = logging.getLogger("gtfs.station_polygons") + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", default=os.getenv("GTFS_ENV_FILE")) + parser.add_argument( + "--preview-json", + help="Optional path for writing the generated station geometry preview", + ) + return parser + + +def main() -> int: + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + + logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + gtfs_static_url = require_env("GTFS_STATIC_URL") + fallback_radius = float(os.getenv("GTFS_FALLBACK_RADIUS_METERS", "75")) + station_filter = os.getenv("GTFS_STATION_FILTER") or None + max_stations = optional_int(os.getenv("GTFS_MAX_STATIONS")) + + hub = HubRESTClient( + HubConfig( + http_url=require_env("HUB_HTTP_URL"), + ws_url=os.getenv("HUB_WS_URL", "ws://localhost:8080/v2/ws/socket"), + token=os.getenv("HUB_TOKEN") or None, + ) + ) + gtfs = load_gtfs_index(gtfs_static_url) + geometries = build_station_geometries( + gtfs=gtfs, + fallback_radius_meters=fallback_radius, + station_filter=station_filter, + max_stations=max_stations, + ) + LOGGER.info("built %d station geometries", len(geometries)) + + if args.preview_json: + preview = [ + { + "station_id": item.station.station_id, + "station_name": item.station.name, + "centroid": item.centroid, + "generation_mode": item.generation_mode, + "source_point_count": item.source_point_count, + "point_sources": item.point_sources, + } + for item in geometries + ] + with open(args.preview_json, "w", encoding="utf-8") as handle: + json.dump(preview, handle, indent=2) + + created = 0 + for geometry in geometries: + station = geometry.station + zone_id = deterministic_uuid("zone", station.station_id) + fence_id = deterministic_uuid("fence", station.station_id) + + zone_payload = { + "id": zone_id, + "type": "rfid", + "foreign_id": station.station_id, + "name": station.name, + "description": "GTFS station zone used by the demonstrator connector", + "position": point(geometry.centroid[0], geometry.centroid[1]), + "radius": fallback_radius, + "properties": { + "connector": "gtfs", + "station_id": station.station_id, + "generation_mode": geometry.generation_mode, + "source_point_count": geometry.source_point_count, + "point_sources": geometry.point_sources, + }, + } + hub.ensure_zone(zone_id, zone_payload) + + fence_payload = { + "id": fence_id, + "crs": "EPSG:4326", + "foreign_id": station.station_id, + "name": station.name, + "region": {"type": "Polygon", "coordinates": [geometry.polygon_ring]}, + "properties": { + "connector": "gtfs", + "station_id": station.station_id, + "station_zone_id": zone_id, + "generation_mode": geometry.generation_mode, + "source_point_count": geometry.source_point_count, + "point_sources": geometry.point_sources, + }, + } + hub.ensure_fence(fence_id, fence_payload) + created += 1 + + LOGGER.info("upserted %d station zones and fences", created) + return 0 + + +def optional_int(value: str | None) -> int | None: + if not value: + return None + return int(value) + + +def require_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise SystemExit(f"{name} is required") + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/gtfs/uv.lock b/connectors/gtfs/uv.lock new file mode 100644 index 0000000..91dc268 --- /dev/null +++ b/connectors/gtfs/uv.lock @@ -0,0 +1,231 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "gtfs-realtime-bindings" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/9b/c33d7889ff8d24cc9e9ae82a83fb9defba3f6595312f056cb62937dd1b33/gtfs_realtime_bindings-2.0.0.tar.gz", hash = "sha256:861a9dcf4c40f9a59520044d870e336b00894ad5638bcf2c4a9b998923543b42", size = 6231, upload-time = "2025-12-03T19:19:40.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/d7/a5a71c655fd10c98fadb00d4db080195ced839445f410684fa6dfb901c28/gtfs_realtime_bindings-2.0.0-py3-none-any.whl", hash = "sha256:e66e581dfccad20e3b9eaed36aae106f945c2f5e506b03a63d37070096b8de39", size = 5303, upload-time = "2025-12-03T19:19:38.845Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "open-rtls-gtfs-demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "gtfs-realtime-bindings" }, + { name = "protobuf" }, + { name = "pyproj" }, + { name = "requests" }, + { name = "websocket-client" }, +] + +[package.metadata] +requires-dist = [ + { name = "gtfs-realtime-bindings", specifier = ">=2.0.0" }, + { name = "protobuf", specifier = ">=7.0.0" }, + { name = "pyproj", specifier = ">=3.7.0" }, + { name = "requests", specifier = ">=2.33.0" }, + { name = "websocket-client", specifier = ">=1.9.0" }, +] + +[[package]] +name = "protobuf" +version = "7.34.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, + { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, + { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, + { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, + { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, +] + +[[package]] +name = "pyproj" +version = "3.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz", hash = "sha256:39a0cf1ecc7e282d1d30f36594ebd55c9fae1fda8a2622cee5d100430628f88c", size = 226279, upload-time = "2025-08-14T12:05:42.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/ab/9893ea9fb066be70ed9074ae543914a618c131ed8dff2da1e08b3a4df4db/pyproj-3.7.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:0a9bb26a6356fb5b033433a6d1b4542158fb71e3c51de49b4c318a1dff3aeaab", size = 6219832, upload-time = "2025-08-14T12:04:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/53/78/4c64199146eed7184eb0e85bedec60a4aa8853b6ffe1ab1f3a8b962e70a0/pyproj-3.7.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:567caa03021178861fad27fabde87500ec6d2ee173dd32f3e2d9871e40eebd68", size = 4620650, upload-time = "2025-08-14T12:04:11.978Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ac/14a78d17943898a93ef4f8c6a9d4169911c994e3161e54a7cedeba9d8dde/pyproj-3.7.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c203101d1dc3c038a56cff0447acc515dd29d6e14811406ac539c21eed422b2a", size = 9667087, upload-time = "2025-08-14T12:04:13.964Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/212882c450bba74fc8d7d35cbd57e4af84792f0a56194819d98106b075af/pyproj-3.7.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:1edc34266c0c23ced85f95a1ee8b47c9035eae6aca5b6b340327250e8e281630", size = 9552797, upload-time = "2025-08-14T12:04:16.624Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c0/c0f25c87b5d2a8686341c53c1792a222a480d6c9caf60311fec12c99ec26/pyproj-3.7.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa9f26c21bc0e2dc3d224cb1eb4020cf23e76af179a7c66fea49b828611e4260", size = 10837036, upload-time = "2025-08-14T12:04:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/5d/37/5cbd6772addde2090c91113332623a86e8c7d583eccb2ad02ea634c4a89f/pyproj-3.7.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9428b318530625cb389b9ddc9c51251e172808a4af79b82809376daaeabe5e9", size = 10775952, upload-time = "2025-08-14T12:04:20.709Z" }, + { url = "https://files.pythonhosted.org/packages/69/a1/dc250e3cf83eb4b3b9a2cf86fdb5e25288bd40037ae449695550f9e96b2f/pyproj-3.7.2-cp312-cp312-win32.whl", hash = "sha256:b3d99ed57d319da042f175f4554fc7038aa4bcecc4ac89e217e350346b742c9d", size = 5898872, upload-time = "2025-08-14T12:04:22.485Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a6/6fe724b72b70f2b00152d77282e14964d60ab092ec225e67c196c9b463e5/pyproj-3.7.2-cp312-cp312-win_amd64.whl", hash = "sha256:11614a054cd86a2ed968a657d00987a86eeb91fdcbd9ad3310478685dc14a128", size = 6312176, upload-time = "2025-08-14T12:04:24.736Z" }, + { url = "https://files.pythonhosted.org/packages/5d/68/915cc32c02a91e76d02c8f55d5a138d6ef9e47a0d96d259df98f4842e558/pyproj-3.7.2-cp312-cp312-win_arm64.whl", hash = "sha256:509a146d1398bafe4f53273398c3bb0b4732535065fa995270e52a9d3676bca3", size = 6233452, upload-time = "2025-08-14T12:04:27.287Z" }, + { url = "https://files.pythonhosted.org/packages/be/14/faf1b90d267cea68d7e70662e7f88cefdb1bc890bd596c74b959e0517a72/pyproj-3.7.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:19466e529b1b15eeefdf8ff26b06fa745856c044f2f77bf0edbae94078c1dfa1", size = 6214580, upload-time = "2025-08-14T12:04:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/da9a45b184d375f62667f62eba0ca68569b0bd980a0bb7ffcc1d50440520/pyproj-3.7.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:c79b9b84c4a626c5dc324c0d666be0bfcebd99f7538d66e8898c2444221b3da7", size = 4615388, upload-time = "2025-08-14T12:04:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e7/d2b459a4a64bca328b712c1b544e109df88e5c800f7c143cfbc404d39bfb/pyproj-3.7.2-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:ceecf374cacca317bc09e165db38ac548ee3cad07c3609442bd70311c59c21aa", size = 9628455, upload-time = "2025-08-14T12:04:32.435Z" }, + { url = "https://files.pythonhosted.org/packages/f8/85/c2b1706e51942de19076eff082f8495e57d5151364e78b5bef4af4a1d94a/pyproj-3.7.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5141a538ffdbe4bfd157421828bb2e07123a90a7a2d6f30fa1462abcfb5ce681", size = 9514269, upload-time = "2025-08-14T12:04:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/07a9b89ae7467872f9a476883a5bad9e4f4d1219d31060f0f2b282276cbe/pyproj-3.7.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f000841e98ea99acbb7b8ca168d67773b0191de95187228a16110245c5d954d5", size = 10808437, upload-time = "2025-08-14T12:04:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/fda1daeabbd39dec5b07f67233d09f31facb762587b498e6fc4572be9837/pyproj-3.7.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8115faf2597f281a42ab608ceac346b4eb1383d3b45ab474fd37341c4bf82a67", size = 10745540, upload-time = "2025-08-14T12:04:38.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/c793182cbba65a39a11db2ac6b479fe76c59e6509ae75e5744c344a0da9d/pyproj-3.7.2-cp313-cp313-win32.whl", hash = "sha256:f18c0579dd6be00b970cb1a6719197fceecc407515bab37da0066f0184aafdf3", size = 5896506, upload-time = "2025-08-14T12:04:41.059Z" }, + { url = "https://files.pythonhosted.org/packages/be/0f/747974129cf0d800906f81cd25efd098c96509026e454d4b66868779ab04/pyproj-3.7.2-cp313-cp313-win_amd64.whl", hash = "sha256:bb41c29d5f60854b1075853fe80c58950b398d4ebb404eb532536ac8d2834ed7", size = 6310195, upload-time = "2025-08-14T12:04:42.974Z" }, + { url = "https://files.pythonhosted.org/packages/82/64/fc7598a53172c4931ec6edf5228280663063150625d3f6423b4c20f9daff/pyproj-3.7.2-cp313-cp313-win_arm64.whl", hash = "sha256:2b617d573be4118c11cd96b8891a0b7f65778fa7733ed8ecdb297a447d439100", size = 6230748, upload-time = "2025-08-14T12:04:44.491Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/611dd5cddb0d277f94b7af12981f56e1441bf8d22695065d4f0df5218498/pyproj-3.7.2-cp313-cp313t-macosx_13_0_x86_64.whl", hash = "sha256:d27b48f0e81beeaa2b4d60c516c3a1cfbb0c7ff6ef71256d8e9c07792f735279", size = 6241729, upload-time = "2025-08-14T12:04:46.274Z" }, + { url = "https://files.pythonhosted.org/packages/15/93/40bd4a6c523ff9965e480870611aed7eda5aa2c6128c6537345a2b77b542/pyproj-3.7.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:55a3610d75023c7b1c6e583e48ef8f62918e85a2ae81300569d9f104d6684bb6", size = 4652497, upload-time = "2025-08-14T12:04:48.203Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ae/7150ead53c117880b35e0d37960d3138fe640a235feb9605cb9386f50bb0/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:8d7349182fa622696787cc9e195508d2a41a64765da9b8a6bee846702b9e6220", size = 9942610, upload-time = "2025-08-14T12:04:49.652Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/7a4a7eafecf2b46ab64e5c08176c20ceb5844b503eaa551bf12ccac77322/pyproj-3.7.2-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:d230b186eb876ed4f29a7c5ee310144c3a0e44e89e55f65fb3607e13f6db337c", size = 9692390, upload-time = "2025-08-14T12:04:51.731Z" }, + { url = "https://files.pythonhosted.org/packages/c3/55/ae18f040f6410f0ea547a21ada7ef3e26e6c82befa125b303b02759c0e9d/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:237499c7862c578d0369e2b8ac56eec550e391a025ff70e2af8417139dabb41c", size = 11047596, upload-time = "2025-08-14T12:04:53.748Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2e/d3fff4d2909473f26ae799f9dda04caa322c417a51ff3b25763f7d03b233/pyproj-3.7.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8c225f5978abd506fd9a78eaaf794435e823c9156091cabaab5374efb29d7f69", size = 10896975, upload-time = "2025-08-14T12:04:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bc/8fc7d3963d87057b7b51ebe68c1e7c51c23129eee5072ba6b86558544a46/pyproj-3.7.2-cp313-cp313t-win32.whl", hash = "sha256:2da731876d27639ff9d2d81c151f6ab90a1546455fabd93368e753047be344a2", size = 5953057, upload-time = "2025-08-14T12:04:58.466Z" }, + { url = "https://files.pythonhosted.org/packages/cc/27/ea9809966cc47d2d51e6d5ae631ea895f7c7c7b9b3c29718f900a8f7d197/pyproj-3.7.2-cp313-cp313t-win_amd64.whl", hash = "sha256:f54d91ae18dd23b6c0ab48126d446820e725419da10617d86a1b69ada6d881d3", size = 6375414, upload-time = "2025-08-14T12:04:59.861Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f8/1ef0129fba9a555c658e22af68989f35e7ba7b9136f25758809efec0cd6e/pyproj-3.7.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fc52ba896cfc3214dc9f9ca3c0677a623e8fdd096b257c14a31e719d21ff3fdd", size = 6262501, upload-time = "2025-08-14T12:05:01.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/c2b050d3f5b71b6edd0d96ae16c990fdc42a5f1366464a5c2772146de33a/pyproj-3.7.2-cp314-cp314-macosx_13_0_x86_64.whl", hash = "sha256:2aaa328605ace41db050d06bac1adc11f01b71fe95c18661497763116c3a0f02", size = 6214541, upload-time = "2025-08-14T12:05:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/03/68/68ada9c8aea96ded09a66cfd9bf87aa6db8c2edebe93f5bf9b66b0143fbc/pyproj-3.7.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:35dccbce8201313c596a970fde90e33605248b66272595c061b511c8100ccc08", size = 4617456, upload-time = "2025-08-14T12:05:04.563Z" }, + { url = "https://files.pythonhosted.org/packages/81/e4/4c50ceca7d0e937977866b02cb64e6ccf4df979a5871e521f9e255df6073/pyproj-3.7.2-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:25b0b7cb0042444c29a164b993c45c1b8013d6c48baa61dc1160d834a277e83b", size = 9615590, upload-time = "2025-08-14T12:05:06.094Z" }, + { url = "https://files.pythonhosted.org/packages/05/1e/ada6fb15a1d75b5bd9b554355a69a798c55a7dcc93b8d41596265c1772e3/pyproj-3.7.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:85def3a6388e9ba51f964619aa002a9d2098e77c6454ff47773bb68871024281", size = 9474960, upload-time = "2025-08-14T12:05:07.973Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/9d48ad0a8db36e16f842f2c8a694c1d9d7dcf9137264846bef77585a71f3/pyproj-3.7.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b1bccefec3875ab81eabf49059e2b2ea77362c178b66fd3528c3e4df242f1516", size = 10799478, upload-time = "2025-08-14T12:05:14.102Z" }, + { url = "https://files.pythonhosted.org/packages/85/cf/2f812b529079f72f51ff2d6456b7fef06c01735e5cfd62d54ffb2b548028/pyproj-3.7.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d5371ca114d6990b675247355a801925814eca53e6c4b2f1b5c0a956336ee36e", size = 10710030, upload-time = "2025-08-14T12:05:16.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/9b/4626a19e1f03eba4c0e77b91a6cf0f73aa9cb5d51a22ee385c22812bcc2c/pyproj-3.7.2-cp314-cp314-win32.whl", hash = "sha256:77f066626030f41be543274f5ac79f2a511fe89860ecd0914f22131b40a0ec25", size = 5991181, upload-time = "2025-08-14T12:05:19.492Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/5a6610554306a83a563080c2cf2c57565563eadd280e15388efa00fb5b33/pyproj-3.7.2-cp314-cp314-win_amd64.whl", hash = "sha256:5a964da1696b8522806f4276ab04ccfff8f9eb95133a92a25900697609d40112", size = 6434721, upload-time = "2025-08-14T12:05:21.022Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/6c910ea2e1c74ef673c5d48c482564b8a7824a44c4e35cca2e765b68cfcc/pyproj-3.7.2-cp314-cp314-win_arm64.whl", hash = "sha256:e258ab4dbd3cf627809067c0ba8f9884ea76c8e5999d039fb37a1619c6c3e1f6", size = 6363821, upload-time = "2025-08-14T12:05:22.627Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e4/5532f6f7491812ba782a2177fe9de73fd8e2912b59f46a1d056b84b9b8f2/pyproj-3.7.2-cp314-cp314t-macosx_13_0_x86_64.whl", hash = "sha256:bbbac2f930c6d266f70ec75df35ef851d96fdb3701c674f42fd23a9314573b37", size = 6241773, upload-time = "2025-08-14T12:05:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/0938c3f2bbbef1789132d1726d9b0e662f10cfc22522743937f421ad664e/pyproj-3.7.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:b7544e0a3d6339dc9151e9c8f3ea62a936ab7cc446a806ec448bbe86aebb979b", size = 4652537, upload-time = "2025-08-14T12:05:26.391Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/488b1ed47d25972f33874f91f09ca8f2227902f05f63a2b80dc73e7b1c97/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f7f5133dca4c703e8acadf6f30bc567d39a42c6af321e7f81975c2518f3ed357", size = 9940864, upload-time = "2025-08-14T12:05:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/c7/cc/7f4c895d0cb98e47b6a85a6d79eaca03eb266129eed2f845125c09cf31ff/pyproj-3.7.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:5aff3343038d7426aa5076f07feb88065f50e0502d1b0d7c22ddfdd2c75a3f81", size = 9688868, upload-time = "2025-08-14T12:05:30.425Z" }, + { url = "https://files.pythonhosted.org/packages/b2/b7/c7e306b8bb0f071d9825b753ee4920f066c40fbfcce9372c4f3cfb2fc4ed/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b0552178c61f2ac1c820d087e8ba6e62b29442debddbb09d51c4bf8acc84d888", size = 11045910, upload-time = "2025-08-14T12:05:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/42/fb/538a4d2df695980e2dde5c04d965fbdd1fe8c20a3194dc4aaa3952a4d1be/pyproj-3.7.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:47d87db2d2c436c5fd0409b34d70bb6cdb875cca2ebe7a9d1c442367b0ab8d59", size = 10895724, upload-time = "2025-08-14T12:05:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/a3f0618b03957de9db5489a04558a8826f43906628bb0b766033aa3b5548/pyproj-3.7.2-cp314-cp314t-win32.whl", hash = "sha256:c9b6f1d8ad3e80a0ee0903a778b6ece7dca1d1d40f6d114ae01bc8ddbad971aa", size = 6056848, upload-time = "2025-08-14T12:05:37.553Z" }, + { url = "https://files.pythonhosted.org/packages/bc/56/413240dd5149dd3291eda55aa55a659da4431244a2fd1319d0ae89407cfb/pyproj-3.7.2-cp314-cp314t-win_amd64.whl", hash = "sha256:1914e29e27933ba6f9822663ee0600f169014a2859f851c054c88cf5ea8a333c", size = 6517676, upload-time = "2025-08-14T12:05:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/15/73/a7141a1a0559bf1a7aa42a11c879ceb19f02f5c6c371c6d57fd86cefd4d1/pyproj-3.7.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d9d25bae416a24397e0d85739f84d323b55f6511e45a522dd7d7eae70d10c7e4", size = 6391844, upload-time = "2025-08-14T12:05:40.745Z" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] diff --git a/connectors/local-hub/README.md b/connectors/local-hub/README.md new file mode 100644 index 0000000..c09d190 --- /dev/null +++ b/connectors/local-hub/README.md @@ -0,0 +1,54 @@ +# Local Hub Demo Runtime + +This directory contains the reusable local runtime used by connector demos in +this repository. + +The stack starts: + +- the hub +- Postgres with persistent bind-mounted data +- Dex for local OIDC tokens +- Mosquitto for parity with the normal hub runtime +- a migration container that applies the repository migrations before the hub starts + +## Files + +- `demo-compose.yml`: local stack definition +- `demo.env.example`: local environment template +- `start_demo.sh`: starts the stack and creates a local env file on first run +- `stop_demo.sh`: stops the stack without deleting persistent data +- `fetch_demo_token.sh`: fetches a Dex access token for manual calls or connectors + +## Usage + +Start the local hub: + +```bash +connectors/local-hub/start_demo.sh +``` + +Stop it: + +```bash +connectors/local-hub/stop_demo.sh +``` + +Fetch an admin token: + +```bash +connectors/local-hub/fetch_demo_token.sh +``` + +The first run creates `connectors/local-hub/demo.env` from `demo.env.example`. + +## Persistent State + +Postgres state is stored under: + +- `connectors/local-hub/state/postgres` + +## Default Dex Users + +- `admin@example.com` / `testpass123` +- `reader@example.com` / `testpass123` +- `owner@example.com` / `testpass123` diff --git a/connectors/local-hub/demo-compose.yml b/connectors/local-hub/demo-compose.yml new file mode 100644 index 0000000..38a6208 --- /dev/null +++ b/connectors/local-hub/demo-compose.yml @@ -0,0 +1,83 @@ +services: + dex: + image: ghcr.io/dexidp/dex:v2.43.1 + command: ["dex", "serve", "/etc/dex/config.yaml"] + volumes: + - ../../tools/dex/config.yaml:/etc/dex/config.yaml:ro + ports: + - "${DEMO_DEX_PORT:-5556}:5556" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5556/dex/.well-known/openid-configuration >/dev/null 2>&1"] + interval: 5s + timeout: 5s + retries: 20 + + postgres: + image: postgres:17 + environment: + POSTGRES_DB: ${POSTGRES_DB:-openrtls} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + ports: + - "${DEMO_POSTGRES_PORT:-5432}:5432" + volumes: + - ${DEMO_STATE_DIR:-./state}/postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 5s + timeout: 5s + retries: 20 + + mosquitto: + image: eclipse-mosquitto:2.0 + ports: + - "${DEMO_MQTT_PORT:-1883}:1883" + - "${DEMO_MQTT_WS_PORT:-9001}:9001" + volumes: + - ../../tools/mqtt/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + healthcheck: + test: ["CMD-SHELL", "mosquitto -h >/dev/null 2>&1 || exit 1"] + interval: 10s + timeout: 5s + retries: 10 + + migrate: + image: golang:1.26-alpine + working_dir: /workspace + entrypoint: ["/bin/sh", "-lc"] + command: + - 'apk add --no-cache build-base && /usr/local/go/bin/go install github.com/pressly/goose/v3/cmd/goose@v3.27.0 && /go/bin/goose -dir migrations postgres "$$POSTGRES_URL" up' + environment: + POSTGRES_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-openrtls}?sslmode=disable + volumes: + - ../../:/workspace + depends_on: + postgres: + condition: service_healthy + + hub: + build: + context: ../.. + environment: + HTTP_LISTEN_ADDR: ":8080" + POSTGRES_URL: postgres://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-openrtls}?sslmode=disable + MQTT_BROKER_URL: tcp://mosquitto:1883 + AUTH_MODE: oidc + AUTH_ISSUER: http://dex:5556/dex + AUTH_AUDIENCE: open-rtls-cli + AUTH_ROLES_CLAIM: email + AUTH_PERMISSIONS_FILE: /app/config/auth/permissions.yaml + COLLISIONS_ENABLED: "true" + ports: + - "${DEMO_APP_PORT:-8080}:8080" + volumes: + - ../../config/auth/permissions.yaml:/app/config/auth/permissions.yaml:ro + depends_on: + dex: + condition: service_healthy + postgres: + condition: service_healthy + mosquitto: + condition: service_healthy + migrate: + condition: service_completed_successfully diff --git a/connectors/local-hub/demo.env.example b/connectors/local-hub/demo.env.example new file mode 100644 index 0000000..6e222f8 --- /dev/null +++ b/connectors/local-hub/demo.env.example @@ -0,0 +1,10 @@ +POSTGRES_DB=openrtls +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +DEMO_APP_PORT=8080 +DEMO_POSTGRES_PORT=5432 +DEMO_DEX_PORT=5556 +DEMO_MQTT_PORT=1883 +DEMO_MQTT_WS_PORT=9001 +DEMO_STATE_DIR=./state diff --git a/connectors/local-hub/fetch_demo_token.sh b/connectors/local-hub/fetch_demo_token.sh new file mode 100755 index 0000000..6e9c99a --- /dev/null +++ b/connectors/local-hub/fetch_demo_token.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${1:-$SCRIPT_DIR/demo.env}" +USERNAME="${2:-admin@example.com}" +PASSWORD="${3:-testpass123}" + +DEX_PORT="$(awk -F= '/^DEMO_DEX_PORT=/{print $2}' "$ENV_FILE" | tail -n1)" +DEX_PORT="${DEX_PORT:-5556}" + +curl -sS -X POST "http://localhost:${DEX_PORT}/dex/token" \ + -u open-rtls-cli:cli-secret \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data "grant_type=password&scope=openid%20email%20profile&username=${USERNAME}&password=${PASSWORD}" diff --git a/connectors/local-hub/start_demo.sh b/connectors/local-hub/start_demo.sh new file mode 100755 index 0000000..f66450d --- /dev/null +++ b/connectors/local-hub/start_demo.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${1:-$SCRIPT_DIR/demo.env}" +COMPOSE_FILE="$SCRIPT_DIR/demo-compose.yml" +STATE_DIR_DEFAULT="$SCRIPT_DIR/state" + +if [[ ! -f "$ENV_FILE" ]]; then + cp "$SCRIPT_DIR/demo.env.example" "$ENV_FILE" + echo "created $ENV_FILE from demo.env.example" +fi + +STATE_DIR="$(awk -F= '/^DEMO_STATE_DIR=/{print $2}' "$ENV_FILE" | tail -n1)" +STATE_DIR="${STATE_DIR:-$STATE_DIR_DEFAULT}" +if [[ "$STATE_DIR" != /* ]]; then + STATE_DIR="$SCRIPT_DIR/${STATE_DIR#./}" +fi + +mkdir -p "$STATE_DIR/postgres" + +docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up --build -d + +cat <` +- callsign, country, on-ground state, altitude, and squawk go into `Location.properties` + +## Airport Fences + +The bootstrap script creates circular zones and fences for airport sectors. The +default `frankfurt` preset includes: + +- airport-wide catchment +- Terminal 1 sector +- Terminal 2 sector +- cargo/south apron sector + +These are sector-level catchments rather than gate-level geometry because the +public feed is suitable for airport/apron eventing but not reliable enough for +precise stand or gate assignment. + +## Limitations + +- anonymous OpenSky access is subject to public rate limits +- the public feed is good for airport and terminal-sector movement, not exact gate tracking +- on-ground traffic quality varies by airport and receiver coverage diff --git a/connectors/opensky/airport_fences.py b/connectors/opensky/airport_fences.py new file mode 100644 index 0000000..6d2868f --- /dev/null +++ b/connectors/opensky/airport_fences.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Create airport and apron sector zones/fences for the OpenSky demo.""" + +from __future__ import annotations + +import argparse +import logging +import os + +from hub_client import HubConfig, HubRESTClient, deterministic_uuid, point +from opensky_support import airport_areas, load_env_file + + +LOGGER = logging.getLogger("opensky.airport_fences") + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", default=os.getenv("OPENSKY_ENV_FILE")) + return parser + + +def main() -> int: + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + hub = HubRESTClient( + HubConfig( + http_url=require_env("HUB_HTTP_URL"), + ws_url=require_env("HUB_WS_URL"), + token=os.getenv("HUB_TOKEN") or None, + ) + ) + + upserted = 0 + for area in airport_areas(): + zone_id = deterministic_uuid("zone", area.foreign_id) + fence_id = deterministic_uuid("fence", area.foreign_id) + zone_payload = { + "id": zone_id, + "type": "rfid", + "foreign_id": area.foreign_id, + "name": area.name, + "description": area.description, + "position": point(area.longitude, area.latitude), + "radius": area.radius_meters, + "properties": {"connector": "opensky", "area_type": "airport_sector"}, + } + fence_payload = { + "id": fence_id, + "crs": "EPSG:4326", + "foreign_id": area.foreign_id, + "name": area.name, + "region": point(area.longitude, area.latitude), + "radius": area.radius_meters, + "properties": {"connector": "opensky", "area_type": "airport_sector", "zone_id": zone_id}, + } + hub.ensure_zone(zone_id, zone_payload) + hub.ensure_fence(fence_id, fence_payload) + upserted += 1 + + LOGGER.info("upserted %d airport sectors", upserted) + return 0 + + +def require_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise SystemExit(f"{name} is required") + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/connector.py b/connectors/opensky/connector.py new file mode 100644 index 0000000..0cc615c --- /dev/null +++ b/connectors/opensky/connector.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Forward OpenSky aircraft state vectors to a local Open RTLS Hub over WebSocket.""" + +from __future__ import annotations + +import argparse +import logging +import os +import time +from datetime import datetime, timezone + +from hub_client import HubConfig, HubRESTClient, HubWebSocketPublisher, deterministic_uuid, point +from opensky_support import fetch_states, load_env_file, resolve_bbox + + +LOGGER = logging.getLogger("opensky.connector") + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", default=os.getenv("OPENSKY_ENV_FILE")) + parser.add_argument("--once", action="store_true") + return parser + + +def main() -> int: + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + hub_config = HubConfig( + http_url=require_env("HUB_HTTP_URL"), + ws_url=require_env("HUB_WS_URL"), + token=os.getenv("HUB_TOKEN") or None, + ) + hub_rest = HubRESTClient(hub_config) + hub_ws = HubWebSocketPublisher(hub_config) + bbox = resolve_bbox() + + provider_id = os.getenv("OPENSKY_PROVIDER_ID", "opensky-demo") + provider_type = os.getenv("OPENSKY_PROVIDER_TYPE", "adsb") + provider_name = os.getenv("OPENSKY_PROVIDER_NAME", "OpenSky Aircraft Demonstrator") + poll_interval = float(os.getenv("OPENSKY_POLL_INTERVAL_SECONDS", "20")) + on_ground_only = (os.getenv("OPENSKY_ON_GROUND_ONLY") or "").strip().lower() in {"1", "true", "yes"} + + hub_rest.ensure_provider( + provider_id=provider_id, + provider_type=provider_type, + name=provider_name, + properties={"connector": "opensky", "bbox": bbox.__dict__}, + ) + + known_trackables: set[str] = set() + try: + while True: + try: + payload = fetch_states(require_env("OPENSKY_URL"), bbox, timeout_seconds=hub_config.timeout_seconds) + locations = build_locations( + payload.get("states") or [], + provider_id, + provider_type, + hub_rest, + known_trackables, + on_ground_only, + ) + if locations: + LOGGER.info("publishing %d aircraft locations", len(locations)) + hub_ws.publish_locations(locations) + else: + LOGGER.info("no matching aircraft positions in current payload") + if args.once: + return 0 + except KeyboardInterrupt: + LOGGER.info("stopping connector") + return 0 + except Exception: + LOGGER.exception("poll iteration failed; retrying after %.1fs", poll_interval) + time.sleep(poll_interval) + finally: + hub_ws.close() + + +def build_locations( + states: list[list[object]], + provider_id: str, + provider_type: str, + hub_rest: HubRESTClient, + known_trackables: set[str], + on_ground_only: bool, +) -> list[dict[str, object]]: + locations: list[dict[str, object]] = [] + for row in states: + if len(row) < 17: + continue + icao24 = (row[0] or "").strip() + callsign = (row[1] or "").strip() + origin_country = row[2] + time_position = row[3] + last_contact = row[4] + longitude = row[5] + latitude = row[6] + baro_altitude = row[7] + on_ground = bool(row[8]) + velocity = row[9] + true_track = row[10] + vertical_rate = row[11] + geo_altitude = row[13] if len(row) > 13 else None + squawk = row[14] if len(row) > 14 else None + + if not icao24 or latitude is None or longitude is None: + continue + if on_ground_only and not on_ground: + continue + + trackable_id = deterministic_uuid("aircraft", icao24) + if trackable_id not in known_trackables: + hub_rest.ensure_trackable( + trackable_id=trackable_id, + name=callsign or icao24, + provider_id=provider_id, + properties={"connector": "opensky", "icao24": icao24, "callsign": callsign or None}, + ) + known_trackables.add(trackable_id) + + location: dict[str, object] = { + "position": point(float(longitude), float(latitude)), + "crs": "EPSG:4326", + "provider_id": provider_id, + "provider_type": provider_type, + "source": f"opensky:{icao24}", + "trackables": [trackable_id], + "properties": { + "connector": "opensky", + "icao24": icao24, + "callsign": callsign or None, + "origin_country": origin_country, + "on_ground": on_ground, + "baro_altitude": baro_altitude, + "geo_altitude": geo_altitude, + "vertical_rate": vertical_rate, + "squawk": squawk, + }, + } + if velocity is not None: + location["speed"] = float(velocity) + if true_track is not None: + location["course"] = float(true_track) + if time_position: + location["timestamp_generated"] = format_unix_timestamp(int(time_position)) + elif last_contact: + location["timestamp_generated"] = format_unix_timestamp(int(last_contact)) + locations.append(location) + return locations + + +def format_unix_timestamp(timestamp: int) -> str: + return datetime.fromtimestamp(timestamp, tz=timezone.utc).isoformat() + + +def require_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise SystemExit(f"{name} is required") + return value + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/hub_client.py b/connectors/opensky/hub_client.py new file mode 100644 index 0000000..e48559c --- /dev/null +++ b/connectors/opensky/hub_client.py @@ -0,0 +1,172 @@ +"""Helpers for talking to a local Open RTLS Hub from connector scripts.""" + +from __future__ import annotations + +import json +import logging +import threading +import uuid +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin + +import requests +import websocket + + +LOGGER = logging.getLogger(__name__) +NAMESPACE = uuid.UUID("eeab8466-d6f2-4a65-9c38-9223f84549a0") + + +def deterministic_uuid(kind: str, external_id: str) -> str: + return str(uuid.uuid5(NAMESPACE, f"{kind}:{external_id}")) + + +def point(longitude: float, latitude: float) -> dict[str, Any]: + return {"type": "Point", "coordinates": [longitude, latitude]} + + +@dataclass +class HubConfig: + http_url: str + ws_url: str + token: str | None = None + timeout_seconds: float = 30.0 + + +class HubRESTClient: + def __init__(self, config: HubConfig): + self.config = config + self.session = requests.Session() + self.session.headers.update({"Accept": "application/json"}) + if config.token: + self.session.headers.update({"Authorization": f"Bearer {config.token}"}) + + def ensure_provider( + self, + provider_id: str, + provider_type: str, + name: str, + properties: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "id": provider_id, + "type": provider_type, + "name": name, + "properties": properties or {}, + } + return self._ensure_resource("/v2/providers", f"/v2/providers/{provider_id}", payload) + + def ensure_trackable( + self, + trackable_id: str, + name: str, + provider_id: str, + properties: dict[str, Any] | None = None, + ) -> dict[str, Any]: + payload = { + "id": trackable_id, + "type": "virtual", + "name": name, + "location_providers": [provider_id], + "properties": properties or {}, + } + return self._ensure_resource("/v2/trackables", f"/v2/trackables/{trackable_id}", payload) + + def ensure_zone(self, zone_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._ensure_resource("/v2/zones", f"/v2/zones/{zone_id}", payload) + + def ensure_fence(self, fence_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return self._ensure_resource("/v2/fences", f"/v2/fences/{fence_id}", payload) + + def _ensure_resource(self, collection_path: str, item_path: str, payload: dict[str, Any]) -> dict[str, Any]: + existing = self._request("GET", item_path, expected={200, 404}) + if existing.status_code == 404: + return self._request("POST", collection_path, json_body=payload, expected={201}).json() + return self._request("PUT", item_path, json_body=payload, expected={200}).json() + + def _request( + self, + method: str, + path: str, + json_body: dict[str, Any] | None = None, + expected: set[int] | None = None, + ) -> requests.Response: + response = self.session.request( + method=method, + url=urljoin(self.config.http_url.rstrip("/") + "/", path.lstrip("/")), + json=json_body, + timeout=self.config.timeout_seconds, + ) + if expected and response.status_code not in expected: + try: + details = response.json() + except ValueError: + details = response.text + raise RuntimeError(f"{method} {path} returned {response.status_code}: {details}") + return response + + +class HubWebSocketPublisher: + def __init__(self, config: HubConfig): + self.config = config + self._connection: websocket.WebSocket | None = None + self._lock = threading.Lock() + self._keepalive_stop = threading.Event() + self._keepalive_thread = threading.Thread(target=self._keepalive_loop, daemon=True) + self._keepalive_thread.start() + + def close(self) -> None: + self._keepalive_stop.set() + with self._lock: + self._close_locked() + self._keepalive_thread.join(timeout=1.0) + + def publish_locations(self, locations: list[dict[str, Any]]) -> None: + if not locations: + return + message: dict[str, Any] = { + "event": "message", + "topic": "location_updates", + "payload": locations, + } + if self.config.token: + message["params"] = {"token": self.config.token} + self._send(json.dumps(message)) + + def _connect(self) -> websocket.WebSocket: + LOGGER.info("connecting websocket publisher to %s", self.config.ws_url) + connection = websocket.create_connection(self.config.ws_url, timeout=self.config.timeout_seconds) + connection.settimeout(self.config.timeout_seconds) + return connection + + def _send(self, raw: str) -> None: + with self._lock: + if self._connection is None: + self._connection = self._connect() + try: + self._connection.send(raw) + except Exception: + LOGGER.info("websocket send failed; reconnecting") + self._close_locked() + self._connection = self._connect() + self._connection.send(raw) + + def _keepalive_loop(self) -> None: + while not self._keepalive_stop.wait(15.0): + with self._lock: + if self._connection is None: + continue + try: + self._connection.ping("keepalive") + except Exception: + LOGGER.info("websocket ping failed; reconnecting on next send") + self._close_locked() + + def _close_locked(self) -> None: + if self._connection is not None: + try: + self._connection.close() + except Exception: + LOGGER.debug("websocket close failed", exc_info=True) + self._connection = None diff --git a/connectors/opensky/opensky_support.py b/connectors/opensky/opensky_support.py new file mode 100644 index 0000000..364a622 --- /dev/null +++ b/connectors/opensky/opensky_support.py @@ -0,0 +1,118 @@ +"""OpenSky polling and airport preset helpers.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +import requests + + +def load_env_file(path: str | None) -> None: + if not path: + return + if not os.path.exists(path): + return + with open(path, "r", encoding="utf-8") as handle: + for line in handle: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + os.environ.setdefault(key.strip(), value.strip()) + + +@dataclass(frozen=True) +class AirportArea: + foreign_id: str + name: str + latitude: float + longitude: float + radius_meters: float + description: str + + +@dataclass(frozen=True) +class BoundingBox: + lamin: float + lomin: float + lamax: float + lomax: float + + +AIRPORT_PRESETS: dict[str, list[AirportArea]] = { + "frankfurt": [ + AirportArea("fra-airport", "Frankfurt Airport", 50.0379, 8.5622, 2200, "Airport-wide catchment"), + AirportArea("fra-terminal-1", "Frankfurt Terminal 1", 50.0370, 8.5625, 500, "Terminal 1 sector"), + AirportArea("fra-terminal-2", "Frankfurt Terminal 2", 50.0518, 8.5880, 500, "Terminal 2 sector"), + AirportArea("fra-cargo-south", "Frankfurt Cargo South", 50.0260, 8.5705, 700, "Cargo and south apron sector"), + ], + "munich": [ + AirportArea("muc-airport", "Munich Airport", 48.3538, 11.7861, 2200, "Airport-wide catchment"), + AirportArea("muc-terminal-1", "Munich Terminal 1", 48.3541, 11.7837, 450, "Terminal 1 sector"), + AirportArea("muc-terminal-2", "Munich Terminal 2", 48.3535, 11.7755, 450, "Terminal 2 sector"), + AirportArea("muc-satellite", "Munich Satellite Apron", 48.3530, 11.7710, 450, "Satellite and apron sector"), + ], + "newyork": [ + AirportArea("jfk-airport", "John F. Kennedy International Airport", 40.6413, -73.7781, 2400, "JFK airport catchment"), + AirportArea("lga-airport", "LaGuardia Airport", 40.7769, -73.8740, 1800, "LaGuardia airport catchment"), + AirportArea("ewr-airport", "Newark Liberty International Airport", 40.6895, -74.1745, 2200, "Newark airport catchment"), + AirportArea("jfk-terminals", "JFK Terminal Core", 40.6447, -73.7827, 850, "JFK terminal and apron sector"), + AirportArea("ewr-terminals", "Newark Terminal Core", 40.6899, -74.1770, 850, "Newark terminal and apron sector"), + AirportArea("lga-terminals", "LaGuardia Terminal Core", 40.7729, -73.8705, 700, "LaGuardia terminal and apron sector"), + ], +} + + +REGION_PRESETS: dict[str, BoundingBox] = { + "frankfurt": BoundingBox(49.8, 8.1, 50.4, 8.95), + "munich": BoundingBox(48.0, 11.2, 48.7, 12.1), + "germany": BoundingBox(47.0, 5.5, 55.2, 15.8), + "newyork": BoundingBox(40.15, -74.7, 41.1, -73.3), +} + + +def resolve_bbox() -> BoundingBox: + preset = (os.getenv("OPENSKY_REGION_PRESET") or "frankfurt").strip().lower() + explicit = [os.getenv("OPENSKY_LAMIN"), os.getenv("OPENSKY_LOMIN"), os.getenv("OPENSKY_LAMAX"), os.getenv("OPENSKY_LOMAX")] + if all(explicit): + return BoundingBox(*(float(value) for value in explicit)) + if preset not in REGION_PRESETS: + raise SystemExit(f"unknown OPENSKY_REGION_PRESET={preset}") + return REGION_PRESETS[preset] + + +def airport_areas() -> list[AirportArea]: + preset = (os.getenv("OPENSKY_AIRPORT_PRESET") or "frankfurt").strip().lower() + if preset not in AIRPORT_PRESETS: + raise SystemExit(f"unknown OPENSKY_AIRPORT_PRESET={preset}") + scale = float(os.getenv("OPENSKY_FENCE_RADIUS_SCALE", "1.0")) + areas = [] + for area in AIRPORT_PRESETS[preset]: + areas.append( + AirportArea( + foreign_id=area.foreign_id, + name=area.name, + latitude=area.latitude, + longitude=area.longitude, + radius_meters=area.radius_meters * scale, + description=area.description, + ) + ) + return areas + + +def fetch_states(url: str, bbox: BoundingBox, timeout_seconds: float = 30.0) -> dict[str, Any]: + response = requests.get( + url, + params={ + "lamin": bbox.lamin, + "lomin": bbox.lomin, + "lamax": bbox.lamax, + "lomax": bbox.lomax, + }, + timeout=timeout_seconds, + ) + response.raise_for_status() + return response.json() diff --git a/connectors/opensky/pyproject.toml b/connectors/opensky/pyproject.toml new file mode 100644 index 0000000..f1d3aed --- /dev/null +++ b/connectors/opensky/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "open-rtls-opensky-demo" +version = "0.1.0" +description = "OpenSky demonstrator connector for Open RTLS Hub" +requires-python = ">=3.12" +dependencies = [ + "requests>=2.33.0", + "websocket-client>=1.9.0", +] + +[tool.uv] +package = false diff --git a/connectors/opensky/scripts/check_fence_alignment.py b/connectors/opensky/scripts/check_fence_alignment.py new file mode 100644 index 0000000..9e8a43e --- /dev/null +++ b/connectors/opensky/scripts/check_fence_alignment.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""Compare logged locations against current hub fences and summarize proximity.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +from pathlib import Path +from typing import Any + +import requests + +SCRIPT_DIR = Path(__file__).resolve().parent +ROOT_DIR = SCRIPT_DIR.parent +sys.path.insert(0, str(ROOT_DIR)) + +from opensky_support import load_env_file # noqa: E402 + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--env-file", default=os.getenv("OPENSKY_ENV_FILE")) + parser.add_argument("--locations-log", default="connectors/opensky/logs/location_updates.ndjson") + parser.add_argument("--http-url", default=os.getenv("HUB_HTTP_URL")) + parser.add_argument("--token", default=os.getenv("HUB_TOKEN")) + return parser + + +def main() -> int: + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + http_url = args.http_url or os.getenv("HUB_HTTP_URL") + if not http_url: + raise SystemExit("HUB_HTTP_URL or --http-url is required") + fences = fetch_fences(http_url, args.token or os.getenv("HUB_TOKEN")) + normalized = [item for item in (normalize_fence(fence) for fence in fences) if item is not None] + locations = load_locations(Path(args.locations_log)) + print(json.dumps(summarize_proximity(locations, normalized), indent=2)) + return 0 + + +def fetch_fences(http_url: str, token: str | None) -> list[dict[str, Any]]: + headers = {"Accept": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + response = requests.get(f"{http_url.rstrip('/')}/v2/fences", headers=headers, timeout=30) + response.raise_for_status() + return response.json() + + +def normalize_fence(fence: dict[str, Any]) -> dict[str, Any] | None: + region = fence.get("region") or {} + if region.get("type") == "Point": + coordinates = region.get("coordinates") or [] + if len(coordinates) < 2: + return None + return { + "id": fence.get("id"), + "foreign_id": fence.get("foreign_id"), + "name": fence.get("name"), + "kind": "circle", + "center": (coordinates[0], coordinates[1]), + "radius": float(fence.get("radius") or 0.0), + } + if region.get("type") == "Polygon": + coordinates = region.get("coordinates") or [] + if not coordinates or not coordinates[0]: + return None + return { + "id": fence.get("id"), + "foreign_id": fence.get("foreign_id"), + "name": fence.get("name"), + "kind": "polygon", + "ring": coordinates[0], + } + return None + + +def load_locations(path: Path) -> list[dict[str, Any]]: + results: list[dict[str, Any]] = [] + if not path.exists(): + return results + with path.open("r", encoding="utf-8") as handle: + for line in handle: + if not line.strip(): + continue + record = json.loads(line) + message = record.get("message", {}) + if message.get("event") != "message": + continue + for payload in message.get("payload", []): + position = payload.get("position") or {} + coordinates = position.get("coordinates") or [] + if len(coordinates) < 2: + continue + results.append( + { + "received_at": record.get("received_at"), + "provider_id": payload.get("provider_id"), + "trackables": payload.get("trackables", []), + "longitude": coordinates[0], + "latitude": coordinates[1], + } + ) + return results + + +def summarize_proximity(locations: list[dict[str, Any]], fences: list[dict[str, Any]]) -> dict[str, Any]: + inside_count = 0 + min_distance = None + closest_examples: list[dict[str, Any]] = [] + for location in locations: + point = (location["longitude"], location["latitude"]) + best = None + for fence in fences: + if fence["kind"] == "circle": + distance = max(haversine_meters(point, fence["center"]) - fence["radius"], 0.0) + inside = distance == 0.0 + else: + inside = point_in_polygon(point, fence["ring"]) + distance = 0.0 if inside else polygon_distance_meters(point, fence["ring"]) + if inside: + inside_count += 1 + best = {"distance_meters": 0.0, "fence": fence} + break + if best is None or distance < best["distance_meters"]: + best = {"distance_meters": distance, "fence": fence} + if best is None: + continue + distance = best["distance_meters"] + if min_distance is None or distance < min_distance: + min_distance = distance + closest_examples.append( + { + "received_at": location["received_at"], + "provider_id": location["provider_id"], + "trackables": location["trackables"], + "distance_meters": round(distance, 2), + "fence_id": best["fence"]["id"], + "fence_name": best["fence"]["name"], + "fence_foreign_id": best["fence"]["foreign_id"], + } + ) + closest_examples.sort(key=lambda item: item["distance_meters"]) + return { + "location_count": len(locations), + "fence_count": len(fences), + "locations_inside_any_fence": inside_count, + "closest_distance_meters": None if min_distance is None else round(min_distance, 2), + "closest_examples": closest_examples[:10], + } + + +def haversine_meters(a: tuple[float, float], b: tuple[float, float]) -> float: + lon1, lat1 = map(math.radians, a) + lon2, lat2 = map(math.radians, b) + dlon = lon2 - lon1 + dlat = lat2 - lat1 + value = math.sin(dlat / 2.0) ** 2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2.0) ** 2 + return 6371000.0 * 2.0 * math.asin(math.sqrt(value)) + + +def point_in_polygon(point: tuple[float, float], ring: list[list[float]]) -> bool: + x, y = point + inside = False + for index in range(len(ring) - 1): + x1, y1 = ring[index] + x2, y2 = ring[index + 1] + intersects = ((y1 > y) != (y2 > y)) and (x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1) + if intersects: + inside = not inside + return inside + + +def polygon_distance_meters(point: tuple[float, float], ring: list[list[float]]) -> float: + best = math.inf + for index in range(len(ring) - 1): + best = min(best, segment_distance_meters(point, tuple(ring[index]), tuple(ring[index + 1]))) + return best + + +def segment_distance_meters(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float: + lon_scale = 111_320.0 * math.cos(math.radians(point[1])) + lat_scale = 111_320.0 + px, py = point[0] * lon_scale, point[1] * lat_scale + sx, sy = start[0] * lon_scale, start[1] * lat_scale + ex, ey = end[0] * lon_scale, end[1] * lat_scale + dx = ex - sx + dy = ey - sy + if dx == 0 and dy == 0: + return math.hypot(px - sx, py - sy) + t = ((px - sx) * dx + (py - sy) * dy) / (dx * dx + dy * dy) + t = max(0.0, min(1.0, t)) + cx = sx + t * dx + cy = sy + t * dy + return math.hypot(px - cx, py - cy) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/scripts/log_collision_events.py b/connectors/opensky/scripts/log_collision_events.py new file mode 100644 index 0000000..7abda90 --- /dev/null +++ b/connectors/opensky/scripts/log_collision_events.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Log collision_events messages to an NDJSON file.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="connectors/opensky/logs/collision_events.ndjson") + parser.add_argument("--env-file", default="connectors/opensky/.env.local") + args = parser.parse_args() + logger = Path(__file__).with_name("ws_ndjson_logger.py") + command = [sys.executable, str(logger), "--topic", "collision_events", "--output", args.output, "--env-file", args.env_file] + try: + return subprocess.call(command) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/scripts/log_fence_events.py b/connectors/opensky/scripts/log_fence_events.py new file mode 100644 index 0000000..da54acf --- /dev/null +++ b/connectors/opensky/scripts/log_fence_events.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Log fence_events messages to an NDJSON file.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="connectors/opensky/logs/fence_events.ndjson") + parser.add_argument("--env-file", default="connectors/opensky/.env.local") + args = parser.parse_args() + logger = Path(__file__).with_name("ws_ndjson_logger.py") + command = [sys.executable, str(logger), "--topic", "fence_events", "--output", args.output, "--env-file", args.env_file] + try: + return subprocess.call(command) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/scripts/log_locations.py b/connectors/opensky/scripts/log_locations.py new file mode 100644 index 0000000..753be1b --- /dev/null +++ b/connectors/opensky/scripts/log_locations.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Log location_updates messages to an NDJSON file.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", default="connectors/opensky/logs/location_updates.ndjson") + parser.add_argument("--env-file", default="connectors/opensky/.env.local") + args = parser.parse_args() + logger = Path(__file__).with_name("ws_ndjson_logger.py") + command = [sys.executable, str(logger), "--topic", "location_updates", "--output", args.output, "--env-file", args.env_file] + try: + return subprocess.call(command) + except KeyboardInterrupt: + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/scripts/ws_ndjson_logger.py b/connectors/opensky/scripts/ws_ndjson_logger.py new file mode 100644 index 0000000..be27b15 --- /dev/null +++ b/connectors/opensky/scripts/ws_ndjson_logger.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Subscribe to one hub WebSocket topic and append wrapper events as NDJSON.""" + +from __future__ import annotations + +import argparse +import json +import os +import signal +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +import websocket + +SCRIPT_DIR = Path(__file__).resolve().parent +ROOT_DIR = SCRIPT_DIR.parent +sys.path.insert(0, str(ROOT_DIR)) + +from opensky_support import load_env_file # noqa: E402 + + +RUNNING = True + + +def build_argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--topic", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--env-file", default=os.getenv("OPENSKY_ENV_FILE")) + parser.add_argument("--ws-url", default=os.getenv("HUB_WS_URL")) + parser.add_argument("--token", default=os.getenv("HUB_TOKEN")) + return parser + + +def main() -> int: + global RUNNING + args = build_argument_parser().parse_args() + load_env_file(args.env_file) + ws_url = args.ws_url or os.getenv("HUB_WS_URL") + if not ws_url: + raise SystemExit("HUB_WS_URL or --ws-url is required") + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + signal.signal(signal.SIGINT, handle_stop) + signal.signal(signal.SIGTERM, handle_stop) + + token = args.token or os.getenv("HUB_TOKEN") + with output_path.open("a", encoding="utf-8") as handle: + while RUNNING: + connection: websocket.WebSocket | None = None + try: + connection = connect_and_subscribe(ws_url, args.topic, token) + while RUNNING: + try: + raw = connection.recv() + except TimeoutError: + connection.ping("keepalive") + continue + except websocket.WebSocketTimeoutException: + connection.ping("keepalive") + continue + payload = { + "received_at": datetime.now(timezone.utc).isoformat(), + "topic": args.topic, + "message": json.loads(raw), + } + handle.write(json.dumps(payload, separators=(",", ":")) + "\n") + handle.flush() + time.sleep(0.01) + except KeyboardInterrupt: + break + except Exception: + time.sleep(2.0) + finally: + if connection is not None: + connection.close() + return 0 + + +def handle_stop(_signum: int, _frame: object) -> None: + global RUNNING + RUNNING = False + + +def connect_and_subscribe(ws_url: str, topic: str, token: str | None) -> websocket.WebSocket: + connection = websocket.create_connection(ws_url, timeout=30) + connection.settimeout(15) + subscribe = {"event": "subscribe", "topic": topic} + if token: + subscribe["params"] = {"token": token} + connection.send(json.dumps(subscribe)) + return connection + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/connectors/opensky/uv.lock b/connectors/opensky/uv.lock new file mode 100644 index 0000000..81fdda8 --- /dev/null +++ b/connectors/opensky/uv.lock @@ -0,0 +1,142 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "open-rtls-opensky-demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, + { name = "websocket-client" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = ">=2.33.0" }, + { name = "websocket-client", specifier = ">=1.9.0" }, +] + +[[package]] +name = "requests" +version = "2.33.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] diff --git a/docs/configuration.md b/docs/configuration.md index cc31b6f..166feda 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,8 @@ Runtime lifecycle behavior: - `POSTGRES_URL` (default `postgres://postgres:postgres@localhost:5432/openrtls?sslmode=disable`) - `MQTT_BROKER_URL` (default `tcp://localhost:1883`) - `WEBSOCKET_WRITE_TIMEOUT` (duration, default `5s`) +- `WEBSOCKET_READ_TIMEOUT` (duration, default `1m`) +- `WEBSOCKET_PING_INTERVAL` (duration, default `30s`) - `WEBSOCKET_OUTBOUND_BUFFER` (default `32`) Hub metadata bootstrap behavior: @@ -50,6 +52,7 @@ Stateful ingest behavior: - metadata is loaded from Postgres at startup, updated immediately after successful CRUD writes, and reconciled in the background every `METADATA_RECONCILE_INTERVAL` - durable hub metadata is also loaded from Postgres at startup before the service begins accepting traffic - WebSocket delivery uses a per-connection outbound queue capped by `WEBSOCKET_OUTBOUND_BUFFER`; slow subscribers are disconnected instead of backpressuring the ingest path +- WebSocket liveness uses server ping frames every `WEBSOCKET_PING_INTERVAL` and considers the connection stale when no inbound message or pong arrives before `WEBSOCKET_READ_TIMEOUT` - the `metadata_changes` WebSocket topic emits lightweight `{id,type,operation,timestamp}` notifications for zone, fence, trackable, and location-provider CRUD or reconcile drift - when `COLLISIONS_ENABLED=true`, the hub evaluates trackable-versus-trackable collisions from the latest WGS84 motion state and keeps short-lived collision pair state in memory for `COLLISION_STATE_TTL` - `COLLISION_COLLIDING_DEBOUNCE` limits repeated `colliding` emissions for already-active pairs diff --git a/docs/index.md b/docs/index.md index e766978..ee3051b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,12 @@ # Software Documentation Reference documentation for hub architecture, configuration, authentication, and RPC behavior. + +Connector demonstrators live outside the hub runtime under +[`connectors/`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors). +The shared local runtime is documented in +[`connectors/local-hub/README.md`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/local-hub/README.md). +Connector examples currently include +[`connectors/gtfs/README.md`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/gtfs/README.md) +and +[`connectors/opensky/README.md`](/Users/jillesvangurp/git/open-rtls/open-rtls-hub/connectors/opensky/README.md). diff --git a/internal/config/config.go b/internal/config/config.go index 4409aca..9e70114 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,6 +24,8 @@ type Config struct { PostgresURL string MQTTBrokerURL string WebSocketWriteTimeout time.Duration + WebSocketReadTimeout time.Duration + WebSocketPingInterval time.Duration WebSocketOutboundBuffer int StateLocationTTL time.Duration StateProximityTTL time.Duration @@ -77,6 +79,8 @@ func fromLookupEnv(lookup lookupEnvFunc) (Config, error) { PostgresURL: envWithLookup(lookup, "POSTGRES_URL", "postgres://postgres:postgres@localhost:5432/openrtls?sslmode=disable"), MQTTBrokerURL: envWithLookup(lookup, "MQTT_BROKER_URL", "tcp://localhost:1883"), WebSocketWriteTimeout: durationEnvWithLookup(lookup, "WEBSOCKET_WRITE_TIMEOUT", 5*time.Second), + WebSocketReadTimeout: durationEnvWithLookup(lookup, "WEBSOCKET_READ_TIMEOUT", time.Minute), + WebSocketPingInterval: durationEnvWithLookup(lookup, "WEBSOCKET_PING_INTERVAL", 30*time.Second), WebSocketOutboundBuffer: intEnvWithLookup(lookup, "WEBSOCKET_OUTBOUND_BUFFER", 32), StateLocationTTL: durationEnvWithLookup(lookup, "STATE_LOCATION_TTL", 10*time.Minute), StateProximityTTL: durationEnvWithLookup(lookup, "STATE_PROXIMITY_TTL", 5*time.Minute), @@ -120,6 +124,15 @@ func fromLookupEnv(lookup lookupEnvFunc) (Config, error) { if cfg.WebSocketWriteTimeout <= 0 { return Config{}, fmt.Errorf("WEBSOCKET_WRITE_TIMEOUT must be > 0") } + if cfg.WebSocketReadTimeout <= 0 { + return Config{}, fmt.Errorf("WEBSOCKET_READ_TIMEOUT must be > 0") + } + if cfg.WebSocketPingInterval <= 0 { + return Config{}, fmt.Errorf("WEBSOCKET_PING_INTERVAL must be > 0") + } + if cfg.WebSocketReadTimeout <= cfg.WebSocketPingInterval { + return Config{}, fmt.Errorf("WEBSOCKET_READ_TIMEOUT must be greater than WEBSOCKET_PING_INTERVAL") + } if cfg.HTTPRequestBodyLimitBytes <= 0 { return Config{}, fmt.Errorf("HTTP_REQUEST_BODY_LIMIT_BYTES must be > 0") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 898f5c4..f0bc2de 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -39,9 +39,12 @@ func TestDefaults(t *testing.T) { if cfg.RPCTimeout <= 0 { t.Fatal("expected positive rpc timeout") } - if cfg.WebSocketWriteTimeout <= 0 || cfg.WebSocketOutboundBuffer <= 0 { + if cfg.WebSocketWriteTimeout <= 0 || cfg.WebSocketReadTimeout <= 0 || cfg.WebSocketPingInterval <= 0 || cfg.WebSocketOutboundBuffer <= 0 { t.Fatal("expected positive websocket settings") } + if cfg.WebSocketReadTimeout <= cfg.WebSocketPingInterval { + t.Fatal("expected websocket read timeout to exceed ping interval") + } if cfg.CollisionsEnabled { t.Fatal("expected collisions to default to disabled") } @@ -96,6 +99,19 @@ func TestRequestBodyLimitMustBePositive(t *testing.T) { } } +func TestWebSocketReadTimeoutMustExceedPingInterval(t *testing.T) { + t.Parallel() + + _, err := configFromMap(map[string]string{ + "AUTH_MODE": "none", + "WEBSOCKET_READ_TIMEOUT": "30s", + "WEBSOCKET_PING_INTERVAL": "30s", + }) + if err == nil { + t.Fatal("expected validation error") + } +} + func TestValidHubIDLoadsSuccessfully(t *testing.T) { t.Parallel() diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 0b17979..1a2df12 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -122,10 +122,19 @@ type metadataFilter struct { } // New constructs a WebSocket hub and starts bus fan-out. -func New(logger *zap.Logger, service *hub.Service, bus *hub.EventBus, authenticator auth.Authenticator, registry *auth.Registry, authCfg config.AuthConfig, writeTimeout time.Duration, outboundBuffer int, collisionsEnabled bool) *Hub { +func New(logger *zap.Logger, service *hub.Service, bus *hub.EventBus, authenticator auth.Authenticator, registry *auth.Registry, authCfg config.AuthConfig, writeTimeout, readTimeout, pingInterval time.Duration, outboundBuffer int, collisionsEnabled bool) *Hub { if writeTimeout <= 0 { writeTimeout = 5 * time.Second } + if readTimeout <= 0 { + readTimeout = time.Minute + } + if pingInterval <= 0 { + pingInterval = 30 * time.Second + } + if readTimeout <= pingInterval { + readTimeout = pingInterval + pingInterval/2 + } h := &Hub{ logger: logger, service: service, @@ -136,8 +145,8 @@ func New(logger *zap.Logger, service *hub.Service, bus *hub.EventBus, authentica writeTimeout: writeTimeout, outboundBuffer: outboundBuffer, collisionsEnabled: collisionsEnabled, - readTimeout: 2 * writeTimeout, - pingInterval: writeTimeout, + readTimeout: readTimeout, + pingInterval: pingInterval, upgrader: websocket.Upgrader{ CheckOrigin: func(*http.Request) bool { return true }, }, @@ -202,6 +211,7 @@ func (c *connection) readLoop() { } return } + _ = c.conn.SetReadDeadline(time.Now().Add(c.hub.readTimeout)) var msg wrapper if err := json.Unmarshal(payload, &msg); err != nil { c.sendError(errInvalidPayload, "invalid websocket wrapper") diff --git a/internal/ws/hub_test.go b/internal/ws/hub_test.go index aca9f46..527f5de 100644 --- a/internal/ws/hub_test.go +++ b/internal/ws/hub_test.go @@ -146,7 +146,7 @@ func TestSendWrapperSafeDuringConcurrentClose(t *testing.T) { t.Parallel() bus := hub.NewEventBus() - h := New(zap.NewNop(), nil, bus, nil, nil, config.AuthConfig{Enabled: false, Mode: "none"}, time.Second, 1, true) + h := New(zap.NewNop(), nil, bus, nil, nil, config.AuthConfig{Enabled: false, Mode: "none"}, time.Second, 3*time.Second, time.Second, 1, true) server2 := httptest.NewServer(http.HandlerFunc(h.Handle)) defer server2.Close() wsURL2 := "ws" + strings.TrimPrefix(server2.URL, "http") + "/v2/ws/socket" @@ -196,7 +196,41 @@ func startTestHub(t *testing.T, bus *hub.EventBus, collisionsEnabled bool) (*web func httpHandler(t *testing.T, bus *hub.EventBus, collisionsEnabled bool) http.Handler { t.Helper() - return http.HandlerFunc(New(zap.NewNop(), nil, bus, nil, nil, config.AuthConfig{Enabled: false, Mode: "none"}, time.Second, 8, collisionsEnabled).Handle) + return http.HandlerFunc(New(zap.NewNop(), nil, bus, nil, nil, config.AuthConfig{Enabled: false, Mode: "none"}, time.Second, 3*time.Second, time.Second, 8, collisionsEnabled).Handle) +} + +func TestReadDeadlineExtendsOnIncomingMessages(t *testing.T) { + t.Parallel() + + bus := hub.NewEventBus() + h := New( + zap.NewNop(), + nil, + bus, + nil, + nil, + config.AuthConfig{Enabled: false, Mode: "none"}, + time.Second, + 80*time.Millisecond, + time.Hour, + 8, + true, + ) + server := httptest.NewServer(http.HandlerFunc(h.Handle)) + defer server.Close() + + wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/v2/ws/socket" + client, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("websocket dial failed: %v", err) + } + defer client.Close() + + writeWS(t, client, map[string]any{"event": "wat"}) + time.Sleep(60 * time.Millisecond) + writeWS(t, client, map[string]any{"event": "wat"}) + time.Sleep(60 * time.Millisecond) + writeWS(t, client, map[string]any{"event": "wat"}) } func writeWS(t *testing.T, conn *websocket.Conn, value any) {