Files
IQ.Pilot/iqpilot/tools/cabana/konn3kt_canproxy.py
2026-08-27 20:17:33 -05:00

111 lines
4.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""
konn3kt_canproxy — view a remote device's live CAN in Cabana, through konn3kt.
Run this on your laptop. It connects to the konn3kt CAN stream for a device you have
access to and re-publishes the raw capnp frames onto a LOCAL ZMQ "can" socket. Then you
just open Cabana → Live → Device, pick ZMQ, and enter 127.0.0.1 — exactly the local
workflow, but the frames are coming from a device across the internet.
Flow:
device canlived ──ws──> konn3kt relay ──ws──> THIS PROXY ──zmq──> Cabana (127.0.0.1)
The proxy never parses the CAN bytes; it forwards the exact capnp Event frames the device
produced, so Cabana decodes them identically to a local ZMQ bridge.
Auth: you authenticate as yourself (a konn3kt user JWT), not as the device. Get your JWT
from the konn3kt app/web session and pass it via --token or the KONN3KT_JWT env var.
Access is enforced server-side (owner-only, or a superuser with an explicit owner grant).
Usage:
export KONN3KT_JWT="<your konn3kt jwt>"
./konn3kt_canproxy.py <dongle_id>
# then in Cabana: Live → Device → ZMQ → 127.0.0.1
Requires the iqpilot/openpilot Python env (for cereal.messaging) and websocket-client.
"""
import argparse
import os
import sys
import time
from websocket import create_connection, ABNF, WebSocketException
# Force cereal messaging onto ZMQ so the local "can" publisher binds a TCP port that
# Cabana's ZMQ Device stream connects to. Must be set before importing messaging.
os.environ["ZMQ"] = "1"
import iqpilot.cereal.messaging as messaging # noqa: E402
DEFAULT_HOST = os.getenv("KONN3KT_HOST", "wss://api-iqlabs.konn3kt.com")
RECONNECT_MIN = 1.0
RECONNECT_MAX = 10.0
def _ws_host(host: str) -> str:
host = host.rstrip("/")
if host.startswith("https://"):
return "wss://" + host[len("https://"):]
if host.startswith("http://"):
return "ws://" + host[len("http://"):]
if host.startswith(("ws://", "wss://")):
return host
return "wss://" + host
def run(dongle_id: str, host: str, token: str) -> None:
ws_uri = f"{_ws_host(host)}/v1/devices/{dongle_id}/can-stream?sig={token}"
# Local ZMQ publisher for "can"; Cabana subscribes to this on 127.0.0.1.
pub = messaging.pub_sock("can")
backoff = RECONNECT_MIN
while True:
try:
print(f"[canproxy] connecting to konn3kt for {dongle_id} ...", file=sys.stderr)
ws = create_connection(ws_uri, enable_multithread=True, timeout=30.0)
print("[canproxy] connected. Open Cabana → Live → Device → ZMQ → 127.0.0.1", file=sys.stderr)
backoff = RECONNECT_MIN
n = 0
while True:
opcode, data = ws.recv_data(control_frame=True)
if opcode == ABNF.OPCODE_BINARY:
# Republish the exact capnp Event bytes locally.
pub.send(data)
n += 1
if n % 1000 == 0:
print(f"[canproxy] forwarded {n} frames", file=sys.stderr)
elif opcode == ABNF.OPCODE_CLOSE:
print("[canproxy] server closed the stream", file=sys.stderr)
break
elif opcode == ABNF.OPCODE_PING:
ws.pong(data)
except (WebSocketException, OSError) as e:
print(f"[canproxy] disconnected: {e}; reconnecting in {backoff:.0f}s", file=sys.stderr)
time.sleep(backoff)
backoff = min(backoff * 2, RECONNECT_MAX)
finally:
try:
ws.close()
except Exception:
pass
def main() -> None:
ap = argparse.ArgumentParser(description="Bridge a konn3kt remote CAN stream into local ZMQ for Cabana.")
ap.add_argument("dongle_id", help="dongle id of the device to stream")
ap.add_argument("--host", default=DEFAULT_HOST, help=f"konn3kt host (default: {DEFAULT_HOST})")
ap.add_argument("--token", default=os.getenv("KONN3KT_JWT"),
help="konn3kt user JWT (or set KONN3KT_JWT)")
args = ap.parse_args()
if not args.token:
ap.error("no token: pass --token or set KONN3KT_JWT")
try:
run(args.dongle_id, args.host, args.token)
except KeyboardInterrupt:
print("\n[canproxy] bye", file=sys.stderr)
if __name__ == "__main__":
main()