mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-03-28 17:43:06 +01:00
Add CompanionAPIEndpoints integration in APIEndpoints class
- Introduced CompanionAPIEndpoints to handle routes under /api/companion/*. - Enhanced the APIEndpoints class to create a nested companion object for improved modularity and organization of companion-related API functionality.
This commit is contained in:
@@ -11,6 +11,7 @@ from repeater.config import update_global_flood_policy
|
||||
from .cad_calibration_engine import CADCalibrationEngine
|
||||
from .auth.middleware import require_auth
|
||||
from .auth_endpoints import AuthAPIEndpoints
|
||||
from .companion_endpoints import CompanionAPIEndpoints
|
||||
from pymc_core.protocol import CryptoUtils
|
||||
|
||||
logger = logging.getLogger("HTTPServer")
|
||||
@@ -146,6 +147,9 @@ class APIEndpoints:
|
||||
# Create nested auth object for /api/auth/* routes
|
||||
self.auth = AuthAPIEndpoints()
|
||||
|
||||
# Create nested companion object for /api/companion/* routes
|
||||
self.companion = CompanionAPIEndpoints(daemon_instance, event_loop, self.config)
|
||||
|
||||
def _is_cors_enabled(self):
|
||||
return self.config.get("web", {}).get("cors_enabled", False)
|
||||
|
||||
|
||||
576
repeater/web/companion_endpoints.py
Normal file
576
repeater/web/companion_endpoints.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""
|
||||
Companion Bridge REST API and SSE event stream endpoints.
|
||||
|
||||
Mounted as a nested CherryPy object at /api/companion/ via APIEndpoints.
|
||||
Provides browser-accessible REST endpoints that proxy into the CompanionBridge
|
||||
async methods, plus a Server-Sent Events stream for real-time push callbacks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import cherrypy
|
||||
from .auth.middleware import require_auth
|
||||
|
||||
logger = logging.getLogger("CompanionAPI")
|
||||
|
||||
|
||||
class CompanionAPIEndpoints:
|
||||
"""REST + SSE endpoints for a companion bridge.
|
||||
|
||||
CherryPy auto-mounts this at ``/api/companion/`` when assigned as
|
||||
``APIEndpoints.companion``. All async bridge calls are dispatched
|
||||
to the daemon's event loop via ``asyncio.run_coroutine_threadsafe``.
|
||||
"""
|
||||
|
||||
def __init__(self, daemon_instance=None, event_loop=None, config=None):
|
||||
self.daemon_instance = daemon_instance
|
||||
self.event_loop = event_loop
|
||||
self.config = config or {}
|
||||
|
||||
# SSE clients: each gets a thread-safe queue
|
||||
self._sse_clients: list[queue.Queue] = []
|
||||
self._sse_lock = threading.Lock()
|
||||
|
||||
# Flag: have we registered push callbacks yet?
|
||||
self._callbacks_registered = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_bridge(self, name: Optional[str] = None, companion_hash: Optional[int] = None):
|
||||
"""Return the companion bridge, or raise 503/404 if unavailable.
|
||||
|
||||
Resolution order (mirrors room-server pattern):
|
||||
1. *name* — look up via identity_manager by registered name.
|
||||
2. *companion_hash* — direct lookup in ``companion_bridges`` dict.
|
||||
3. Neither — return the first (and typically only) bridge.
|
||||
"""
|
||||
if not self.daemon_instance:
|
||||
raise cherrypy.HTTPError(503, "Daemon not initialized")
|
||||
bridges = getattr(self.daemon_instance, "companion_bridges", {})
|
||||
if not bridges:
|
||||
raise cherrypy.HTTPError(503, "No companion bridges configured")
|
||||
|
||||
# --- resolve by name via identity_manager (same pattern as room servers) ---
|
||||
if name is not None:
|
||||
identity_manager = getattr(self.daemon_instance, "identity_manager", None)
|
||||
if identity_manager:
|
||||
for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"):
|
||||
if reg_name == name:
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
bridge = bridges.get(hash_byte)
|
||||
if bridge:
|
||||
return bridge
|
||||
raise cherrypy.HTTPError(404, f"Companion '{name}' not found")
|
||||
|
||||
# --- resolve by hash (fallback) ---
|
||||
if companion_hash is not None:
|
||||
bridge = bridges.get(companion_hash)
|
||||
if not bridge:
|
||||
raise cherrypy.HTTPError(404, f"Companion 0x{companion_hash:02X} not found")
|
||||
return bridge
|
||||
|
||||
# --- default: first bridge ---
|
||||
return next(iter(bridges.values()))
|
||||
|
||||
def _resolve_bridge_params(self, params) -> dict:
|
||||
"""Extract optional companion name/hash from request params.
|
||||
|
||||
Returns kwargs suitable for ``_get_bridge(**result)``.
|
||||
Follows the room-server convention: ``companion_name`` is the
|
||||
primary selector, ``companion_hash`` is the fallback.
|
||||
"""
|
||||
name = params.get("companion_name")
|
||||
raw_hash = params.get("companion_hash")
|
||||
result: dict = {}
|
||||
if name is not None:
|
||||
result["name"] = str(name)
|
||||
elif raw_hash is not None:
|
||||
try:
|
||||
result["companion_hash"] = int(str(raw_hash), 0)
|
||||
except (ValueError, TypeError):
|
||||
raise cherrypy.HTTPError(400, "Invalid companion_hash")
|
||||
return result
|
||||
|
||||
def _run_async(self, coro, timeout: float = 30.0):
|
||||
"""Run an async coroutine on the daemon event loop and return result."""
|
||||
if self.event_loop is None:
|
||||
raise cherrypy.HTTPError(503, "Event loop not available")
|
||||
future = asyncio.run_coroutine_threadsafe(coro, self.event_loop)
|
||||
return future.result(timeout=timeout)
|
||||
|
||||
@staticmethod
|
||||
def _success(data, **kwargs):
|
||||
result = {"success": True, "data": data}
|
||||
result.update(kwargs)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _error(msg):
|
||||
return {"success": False, "error": str(msg)}
|
||||
|
||||
def _require_post(self):
|
||||
if cherrypy.request.method != "POST":
|
||||
cherrypy.response.headers["Allow"] = "POST"
|
||||
raise cherrypy.HTTPError(405, "Method not allowed. Use POST.")
|
||||
|
||||
def _get_json_body(self) -> dict:
|
||||
"""Read and parse the JSON request body."""
|
||||
try:
|
||||
raw = cherrypy.request.body.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
except (json.JSONDecodeError, ValueError) as exc:
|
||||
raise cherrypy.HTTPError(400, f"Invalid JSON body: {exc}")
|
||||
|
||||
def _pub_key_from_hex(self, hex_str: str) -> bytes:
|
||||
"""Decode a hex public key, raising 400 on error."""
|
||||
try:
|
||||
key = bytes.fromhex(hex_str)
|
||||
if len(key) != 32:
|
||||
raise ValueError("Expected 32-byte key")
|
||||
return key
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise cherrypy.HTTPError(400, f"Invalid public key: {exc}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSE push-event plumbing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_callbacks(self):
|
||||
"""Register push callbacks on the bridge (once)."""
|
||||
if self._callbacks_registered:
|
||||
return
|
||||
try:
|
||||
bridge = self._get_bridge()
|
||||
except cherrypy.HTTPError:
|
||||
return # bridge not yet available
|
||||
|
||||
def _make_cb(event_name):
|
||||
"""Create a callback that serialises event data for SSE clients."""
|
||||
def _cb(*args, **kwargs):
|
||||
payload = self._serialise_event(event_name, args, kwargs)
|
||||
self._broadcast_sse(payload)
|
||||
return _cb
|
||||
|
||||
callback_names = [
|
||||
"message_received",
|
||||
"channel_message_received",
|
||||
"advert_received",
|
||||
"contact_path_updated",
|
||||
"send_confirmed",
|
||||
"login_result",
|
||||
]
|
||||
for name in callback_names:
|
||||
register_fn = getattr(bridge, f"on_{name}", None)
|
||||
if register_fn:
|
||||
register_fn(_make_cb(name))
|
||||
|
||||
self._callbacks_registered = True
|
||||
|
||||
@staticmethod
|
||||
def _serialise_event(event_name: str, args: tuple, kwargs: dict) -> dict:
|
||||
"""Convert callback arguments to a JSON-safe dict."""
|
||||
data: dict = {"event": event_name, "timestamp": int(time.time())}
|
||||
for i, arg in enumerate(args):
|
||||
data[f"arg{i}"] = _to_json_safe(arg)
|
||||
for k, v in kwargs.items():
|
||||
data[k] = _to_json_safe(v)
|
||||
return data
|
||||
|
||||
def _broadcast_sse(self, payload: dict):
|
||||
"""Put *payload* into every active SSE client queue."""
|
||||
with self._sse_lock:
|
||||
dead = []
|
||||
for q in self._sse_clients:
|
||||
try:
|
||||
q.put_nowait(payload)
|
||||
except queue.Full:
|
||||
dead.append(q)
|
||||
for q in dead:
|
||||
self._sse_clients.remove(q)
|
||||
|
||||
# ==================================================================
|
||||
# REST Endpoints
|
||||
# ==================================================================
|
||||
|
||||
# ----- Index / listing -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def index(self, **kwargs):
|
||||
"""GET /api/companion/ — list configured companions."""
|
||||
bridges = getattr(self.daemon_instance, "companion_bridges", {})
|
||||
identity_manager = getattr(self.daemon_instance, "identity_manager", None)
|
||||
|
||||
# Build name lookup from identity_manager (same pattern as room servers)
|
||||
name_by_hash: dict[int, str] = {}
|
||||
if identity_manager:
|
||||
for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"):
|
||||
name_by_hash[identity.get_public_key()[0]] = reg_name
|
||||
|
||||
items = []
|
||||
for h, b in bridges.items():
|
||||
items.append({
|
||||
"companion_name": name_by_hash.get(h, ""),
|
||||
"companion_hash": f"0x{h:02X}",
|
||||
"node_name": b.prefs.node_name,
|
||||
"public_key": b.get_public_key().hex(),
|
||||
"is_running": b.is_running,
|
||||
"contacts_count": b.contacts.get_count(),
|
||||
"channels_count": b.channels.get_count(),
|
||||
})
|
||||
return self._success(items)
|
||||
|
||||
# ----- Identity -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def self_info(self, **kwargs):
|
||||
"""GET /api/companion/self_info — node identity and preferences."""
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
|
||||
prefs = bridge.get_self_info()
|
||||
return self._success({
|
||||
"public_key": bridge.get_public_key().hex(),
|
||||
"node_name": prefs.node_name,
|
||||
"adv_type": prefs.adv_type,
|
||||
"tx_power_dbm": prefs.tx_power_dbm,
|
||||
"frequency_hz": prefs.frequency_hz,
|
||||
"bandwidth_hz": prefs.bandwidth_hz,
|
||||
"spreading_factor": prefs.spreading_factor,
|
||||
"coding_rate": prefs.coding_rate,
|
||||
"latitude": prefs.latitude,
|
||||
"longitude": prefs.longitude,
|
||||
})
|
||||
|
||||
# ----- Contacts -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def contacts(self, **kwargs):
|
||||
"""GET /api/companion/contacts — list all contacts."""
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
|
||||
since = int(kwargs.get("since", 0))
|
||||
contacts = bridge.get_contacts(since=since)
|
||||
items = []
|
||||
for c in contacts:
|
||||
items.append({
|
||||
"public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key,
|
||||
"name": c.name,
|
||||
"adv_type": c.adv_type,
|
||||
"flags": c.flags,
|
||||
"out_path_len": c.out_path_len,
|
||||
"last_advert_timestamp": c.last_advert_timestamp,
|
||||
"lastmod": c.lastmod,
|
||||
"gps_lat": c.gps_lat,
|
||||
"gps_lon": c.gps_lon,
|
||||
})
|
||||
return self._success(items)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def contact(self, **kwargs):
|
||||
"""GET /api/companion/contact?pub_key=<hex> — get single contact."""
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
|
||||
pk_hex = kwargs.get("pub_key")
|
||||
if not pk_hex:
|
||||
raise cherrypy.HTTPError(400, "pub_key required")
|
||||
pub_key = self._pub_key_from_hex(pk_hex)
|
||||
c = bridge.get_contact_by_key(pub_key)
|
||||
if not c:
|
||||
raise cherrypy.HTTPError(404, "Contact not found")
|
||||
return self._success({
|
||||
"public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key,
|
||||
"name": c.name,
|
||||
"adv_type": c.adv_type,
|
||||
"flags": c.flags,
|
||||
"out_path_len": c.out_path_len,
|
||||
"out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "",
|
||||
"last_advert_timestamp": c.last_advert_timestamp,
|
||||
"lastmod": c.lastmod,
|
||||
"gps_lat": c.gps_lat,
|
||||
"gps_lon": c.gps_lon,
|
||||
})
|
||||
|
||||
# ----- Channels -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def channels(self, **kwargs):
|
||||
"""GET /api/companion/channels — list configured channels."""
|
||||
try:
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
|
||||
items = []
|
||||
for idx in range(bridge.channels.max_channels):
|
||||
ch = bridge.channels.get(idx)
|
||||
if ch:
|
||||
items.append({
|
||||
"index": idx,
|
||||
"name": ch.name,
|
||||
# Don't expose the PSK secret over REST
|
||||
})
|
||||
return self._success(items)
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"channels endpoint error: {exc}", exc_info=True)
|
||||
return self._error(str(exc))
|
||||
|
||||
# ----- Statistics -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def stats(self, **kwargs):
|
||||
"""GET /api/companion/stats?type=packets — local companion stats."""
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(kwargs))
|
||||
stats_type_map = {"core": 0, "radio": 1, "packets": 2}
|
||||
stype = stats_type_map.get(kwargs.get("type", "packets"), 2)
|
||||
return self._success(bridge.get_stats(stype))
|
||||
|
||||
# ----- Messaging -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def send_text(self, **kwargs):
|
||||
"""POST /api/companion/send_text {pub_key, text, txt_type?, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
|
||||
text = body.get("text", "")
|
||||
if not text:
|
||||
raise cherrypy.HTTPError(400, "text required")
|
||||
txt_type = int(body.get("txt_type", 0))
|
||||
result = self._run_async(
|
||||
bridge.send_text_message(pub_key, text, txt_type=txt_type)
|
||||
)
|
||||
return self._success({
|
||||
"sent": result.success,
|
||||
"is_flood": result.is_flood,
|
||||
"expected_ack": result.expected_ack,
|
||||
})
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def send_channel_message(self, **kwargs):
|
||||
"""POST /api/companion/send_channel_message {channel_idx, text, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
channel_idx = int(body.get("channel_idx", 0))
|
||||
text = body.get("text", "")
|
||||
if not text:
|
||||
raise cherrypy.HTTPError(400, "text required")
|
||||
success = self._run_async(bridge.send_channel_message(channel_idx, text))
|
||||
return self._success({"sent": success})
|
||||
|
||||
# ----- Login -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def login(self, **kwargs):
|
||||
"""POST /api/companion/login {pub_key, password?, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
|
||||
password = body.get("password", "")
|
||||
result = self._run_async(
|
||||
bridge.send_login(pub_key, password), timeout=15.0
|
||||
)
|
||||
return self._success(_to_json_safe(result))
|
||||
|
||||
# ----- Status / Telemetry Requests -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def request_status(self, **kwargs):
|
||||
"""POST /api/companion/request_status {pub_key, timeout?, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
|
||||
timeout = float(body.get("timeout", 15.0))
|
||||
result = self._run_async(
|
||||
bridge.send_status_request(pub_key, timeout=timeout),
|
||||
timeout=timeout + 5.0,
|
||||
)
|
||||
return self._success(_to_json_safe(result))
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def request_telemetry(self, **kwargs):
|
||||
"""POST /api/companion/request_telemetry {pub_key, want_base?, want_location?, want_environment?, timeout?, companion_name?}"""
|
||||
self._require_post()
|
||||
try:
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
|
||||
timeout = float(body.get("timeout", 10.0))
|
||||
result = self._run_async(
|
||||
bridge.send_telemetry_request(
|
||||
pub_key,
|
||||
want_base=bool(body.get("want_base", True)),
|
||||
want_location=bool(body.get("want_location", True)),
|
||||
want_environment=bool(body.get("want_environment", True)),
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout + 5.0,
|
||||
)
|
||||
# Ensure all values are JSON-serialisable (telemetry may contain bytes)
|
||||
return self._success(_to_json_safe(result))
|
||||
except cherrypy.HTTPError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error(f"request_telemetry endpoint error: {exc}", exc_info=True)
|
||||
return self._error(str(exc))
|
||||
|
||||
# ----- Repeater Commands -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def send_command(self, **kwargs):
|
||||
"""POST /api/companion/send_command {pub_key, command, parameters?, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
|
||||
command = body.get("command", "")
|
||||
if not command:
|
||||
raise cherrypy.HTTPError(400, "command required")
|
||||
parameters = body.get("parameters")
|
||||
result = self._run_async(
|
||||
bridge.send_repeater_command(pub_key, command, parameters),
|
||||
timeout=20.0,
|
||||
)
|
||||
return self._success(_to_json_safe(result))
|
||||
|
||||
# ----- Path / Routing -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def reset_path(self, **kwargs):
|
||||
"""POST /api/companion/reset_path {pub_key, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
pub_key = self._pub_key_from_hex(body.get("pub_key", ""))
|
||||
ok = bridge.reset_path(pub_key)
|
||||
return self._success({"reset": ok})
|
||||
|
||||
# ----- Device Configuration -----
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def set_advert_name(self, **kwargs):
|
||||
"""POST /api/companion/set_advert_name {advert_name, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
name = body.get("advert_name", body.get("name", ""))
|
||||
if not name:
|
||||
raise cherrypy.HTTPError(400, "name required")
|
||||
bridge.set_advert_name(name)
|
||||
return self._success({"name": bridge.prefs.node_name})
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def set_advert_location(self, **kwargs):
|
||||
"""POST /api/companion/set_advert_location {latitude, longitude, companion_name?}"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
lat = float(body.get("latitude", 0.0))
|
||||
lon = float(body.get("longitude", 0.0))
|
||||
bridge.set_advert_latlon(lat, lon)
|
||||
return self._success({"latitude": lat, "longitude": lon})
|
||||
|
||||
# ==================================================================
|
||||
# SSE Event Stream
|
||||
# ==================================================================
|
||||
|
||||
@cherrypy.expose
|
||||
def events(self, **kwargs):
|
||||
"""GET /api/companion/events — Server-Sent Events stream for push callbacks.
|
||||
|
||||
Connect with ``EventSource('/api/companion/events?token=JWT')``.
|
||||
Auth is handled by the CherryPy tool-level require_auth (supports
|
||||
query-param JWT tokens needed by the browser EventSource API).
|
||||
"""
|
||||
self._ensure_callbacks()
|
||||
|
||||
cherrypy.response.headers["Content-Type"] = "text/event-stream"
|
||||
cherrypy.response.headers["Cache-Control"] = "no-cache"
|
||||
cherrypy.response.headers["Connection"] = "keep-alive"
|
||||
cherrypy.response.headers["X-Accel-Buffering"] = "no"
|
||||
|
||||
client_queue: queue.Queue = queue.Queue(maxsize=256)
|
||||
with self._sse_lock:
|
||||
self._sse_clients.append(client_queue)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
yield f"data: {json.dumps({'event': 'connected', 'timestamp': int(time.time())})}\n\n"
|
||||
|
||||
while True:
|
||||
try:
|
||||
item = client_queue.get(timeout=15.0)
|
||||
yield f"data: {json.dumps(item)}\n\n"
|
||||
except queue.Empty:
|
||||
# Keep-alive comment
|
||||
yield f"data: {json.dumps({'event': 'keepalive', 'timestamp': int(time.time())})}\n\n"
|
||||
except GeneratorExit:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.debug(f"SSE stream ended: {exc}")
|
||||
finally:
|
||||
with self._sse_lock:
|
||||
if client_queue in self._sse_clients:
|
||||
self._sse_clients.remove(client_queue)
|
||||
|
||||
return generate()
|
||||
|
||||
events._cp_config = {"response.stream": True}
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Utility: make arbitrary objects JSON-serialisable for SSE events
|
||||
# ======================================================================
|
||||
|
||||
def _to_json_safe(obj):
|
||||
"""Convert common companion objects to JSON-safe dicts/values."""
|
||||
if obj is None or isinstance(obj, (bool, int, float, str)):
|
||||
return obj
|
||||
if isinstance(obj, bytes):
|
||||
return obj.hex()
|
||||
if isinstance(obj, bytearray):
|
||||
return bytes(obj).hex()
|
||||
if isinstance(obj, dict):
|
||||
return {k: _to_json_safe(v) for k, v in obj.items()}
|
||||
if isinstance(obj, (list, tuple)):
|
||||
return [_to_json_safe(v) for v in obj]
|
||||
# Dataclass / namedtuple with __dict__
|
||||
if hasattr(obj, "__dict__"):
|
||||
return {k: _to_json_safe(v) for k, v in obj.__dict__.items() if not k.startswith("_")}
|
||||
return str(obj)
|
||||
373
scripts/test_companion_api.sh
Executable file
373
scripts/test_companion_api.sh
Executable file
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# test_companion_api.sh — Smoke-test the companion REST + SSE endpoints
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test_companion_api.sh # defaults
|
||||
# ./scripts/test_companion_api.sh -H 192.168.1.10 # custom host
|
||||
# ./scripts/test_companion_api.sh -p 9000 # custom port
|
||||
# ./scripts/test_companion_api.sh -k <api-key> # use API key instead of JWT
|
||||
# ./scripts/test_companion_api.sh -P <pub_key_hex> # target contact for send tests
|
||||
#
|
||||
# Requires: curl, jq
|
||||
# =============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ----- Defaults -----
|
||||
HOST="localhost"
|
||||
PORT="8000"
|
||||
USERNAME="admin"
|
||||
PASSWORD=""
|
||||
CLIENT_ID="test-companion-api"
|
||||
API_KEY=""
|
||||
TARGET_PUBKEY=""
|
||||
COMPANION_NAME=""
|
||||
|
||||
# ----- Parse args -----
|
||||
while getopts "H:p:u:w:k:P:c:h" opt; do
|
||||
case $opt in
|
||||
H) HOST="$OPTARG" ;;
|
||||
p) PORT="$OPTARG" ;;
|
||||
u) USERNAME="$OPTARG" ;;
|
||||
w) PASSWORD="$OPTARG" ;;
|
||||
k) API_KEY="$OPTARG" ;;
|
||||
P) TARGET_PUBKEY="$OPTARG" ;;
|
||||
c) COMPANION_NAME="$OPTARG" ;;
|
||||
h)
|
||||
echo "Usage: $0 [-H host] [-p port] [-u user] [-w password] [-k api_key] [-P target_pubkey] [-c companion_name]"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: -$opt" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
BASE="http://${HOST}:${PORT}"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
# ----- Colours -----
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[0;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ----- Helpers -----
|
||||
|
||||
auth_header() {
|
||||
if [[ -n "$API_KEY" ]]; then
|
||||
echo "X-API-Key: ${API_KEY}"
|
||||
elif [[ -n "$TOKEN" ]]; then
|
||||
echo "Authorization: Bearer ${TOKEN}"
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Run a test: name, expected_http_code, curl_args...
|
||||
run_test() {
|
||||
local name="$1"
|
||||
local expect_code="$2"
|
||||
shift 2
|
||||
|
||||
printf " %-50s " "$name"
|
||||
|
||||
local tmpfile
|
||||
tmpfile=$(mktemp)
|
||||
|
||||
local http_code
|
||||
http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \
|
||||
-H "$(auth_header)" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$@" 2>/dev/null) || true
|
||||
|
||||
local body
|
||||
body=$(cat "$tmpfile")
|
||||
rm -f "$tmpfile"
|
||||
|
||||
if [[ "$http_code" == "$expect_code" ]]; then
|
||||
local success
|
||||
success=$(echo "$body" | jq -r '.success // empty' 2>/dev/null) || true
|
||||
if [[ "$success" == "true" ]]; then
|
||||
printf "${GREEN}PASS${NC} (HTTP %s)\n" "$http_code"
|
||||
PASS=$((PASS + 1))
|
||||
elif [[ "$success" == "false" ]]; then
|
||||
local err
|
||||
err=$(echo "$body" | jq -r '.error // .data.reason // "unknown"' 2>/dev/null) || true
|
||||
printf "${YELLOW}PASS${NC} (HTTP %s, success=false: %s)\n" "$http_code" "$err"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf "${GREEN}PASS${NC} (HTTP %s)\n" "$http_code"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
else
|
||||
printf "${RED}FAIL${NC} (expected HTTP %s, got %s)\n" "$expect_code" "$http_code"
|
||||
if [[ -n "$body" ]]; then
|
||||
echo " $(echo "$body" | jq -c '.' 2>/dev/null || echo "$body" | head -c 200)"
|
||||
fi
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
skip_test() {
|
||||
local name="$1"
|
||||
local reason="$2"
|
||||
printf " %-50s ${YELLOW}SKIP${NC} (%s)\n" "$name" "$reason"
|
||||
SKIP=$((SKIP + 1))
|
||||
}
|
||||
|
||||
# Pretty-print a JSON response
|
||||
show_response() {
|
||||
local name="$1"
|
||||
shift
|
||||
printf "\n${CYAN}--- %s ---${NC}\n" "$name"
|
||||
curl -s -H "$(auth_header)" -H "Content-Type: application/json" "$@" 2>/dev/null | jq '.' 2>/dev/null || echo "(no JSON)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# 0. Connectivity check
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo " Companion API Test Suite"
|
||||
echo " Target: ${BASE}"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
printf "Checking connectivity... "
|
||||
if ! curl -sf -o /dev/null --connect-timeout 3 "${BASE}/api/needs_setup" 2>/dev/null; then
|
||||
printf "${RED}FAILED${NC}\n"
|
||||
echo "Cannot reach ${BASE}. Is the repeater running?"
|
||||
exit 1
|
||||
fi
|
||||
printf "${GREEN}OK${NC}\n"
|
||||
|
||||
# =============================================================================
|
||||
# 1. Authentication
|
||||
# =============================================================================
|
||||
|
||||
TOKEN=""
|
||||
|
||||
if [[ -n "$API_KEY" ]]; then
|
||||
echo ""
|
||||
echo "Using API key for authentication."
|
||||
TOKEN=""
|
||||
elif [[ -n "$PASSWORD" ]]; then
|
||||
echo ""
|
||||
printf "Authenticating as '${USERNAME}'... "
|
||||
LOGIN_RESP=$(curl -s -X POST "${BASE}/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\",\"client_id\":\"${CLIENT_ID}\"}" 2>/dev/null)
|
||||
|
||||
TOKEN=$(echo "$LOGIN_RESP" | jq -r '.token // empty' 2>/dev/null) || true
|
||||
if [[ -n "$TOKEN" ]]; then
|
||||
printf "${GREEN}OK${NC} (token received)\n"
|
||||
else
|
||||
printf "${RED}FAILED${NC}\n"
|
||||
echo "$LOGIN_RESP" | jq '.' 2>/dev/null || echo "$LOGIN_RESP"
|
||||
echo ""
|
||||
echo "Cannot authenticate. Provide -w <password> or -k <api_key>."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "No password (-w) or API key (-k) provided."
|
||||
echo "Attempting unauthenticated requests (will fail if auth is required)."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 2. Read-only GET endpoints
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "--- GET endpoints (read-only) ---"
|
||||
|
||||
# Build companion_name query string if provided
|
||||
QS=""
|
||||
if [[ -n "$COMPANION_NAME" ]]; then
|
||||
QS="?companion_name=${COMPANION_NAME}"
|
||||
fi
|
||||
|
||||
run_test "GET /api/companion/" 200 "${BASE}/api/companion/"
|
||||
run_test "GET /api/companion/self_info" 200 "${BASE}/api/companion/self_info${QS}"
|
||||
run_test "GET /api/companion/contacts" 200 "${BASE}/api/companion/contacts${QS}"
|
||||
run_test "GET /api/companion/channels" 200 "${BASE}/api/companion/channels${QS}"
|
||||
run_test "GET /api/companion/stats" 200 "${BASE}/api/companion/stats${QS}"
|
||||
run_test "GET /api/companion/stats?type=core" 200 "${BASE}/api/companion/stats${QS:+${QS}&}${QS:+type=core}${QS:-?type=core}"
|
||||
|
||||
# Single contact lookup (needs a pub_key — grab the first one from contacts list)
|
||||
FIRST_PUBKEY=$(curl -s -H "$(auth_header)" "${BASE}/api/companion/contacts${QS}" 2>/dev/null \
|
||||
| jq -r '.data[0].public_key // empty' 2>/dev/null) || true
|
||||
|
||||
if [[ -n "$FIRST_PUBKEY" ]]; then
|
||||
run_test "GET /api/companion/contact?pub_key=..." 200 \
|
||||
"${BASE}/api/companion/contact?pub_key=${FIRST_PUBKEY}${QS:+&companion_name=${COMPANION_NAME}}"
|
||||
else
|
||||
skip_test "GET /api/companion/contact?pub_key=..." "no contacts available"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 3. Validation / error handling
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "--- Validation & error handling ---"
|
||||
|
||||
run_test "GET /api/companion/contact (no pub_key)" 400 "${BASE}/api/companion/contact"
|
||||
run_test "GET /api/companion/contact (bad pub_key)" 400 "${BASE}/api/companion/contact?pub_key=zzzz"
|
||||
run_test "POST send_text empty body" 400 -X POST "${BASE}/api/companion/send_text" -d '{}'
|
||||
run_test "GET send_text (wrong method)" 405 "${BASE}/api/companion/send_text"
|
||||
|
||||
# =============================================================================
|
||||
# 4. POST endpoints (write / send operations)
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "--- POST endpoints ---"
|
||||
|
||||
# Use TARGET_PUBKEY if provided, else use FIRST_PUBKEY from contacts list
|
||||
PK="${TARGET_PUBKEY:-$FIRST_PUBKEY}"
|
||||
|
||||
if [[ -z "$PK" ]]; then
|
||||
skip_test "POST /api/companion/login" "no target pubkey (-P)"
|
||||
skip_test "POST /api/companion/request_status" "no target pubkey (-P)"
|
||||
skip_test "POST /api/companion/request_telemetry" "no target pubkey (-P)"
|
||||
skip_test "POST /api/companion/send_text" "no target pubkey (-P)"
|
||||
skip_test "POST /api/companion/send_command" "no target pubkey (-P)"
|
||||
skip_test "POST /api/companion/reset_path" "no target pubkey (-P)"
|
||||
else
|
||||
# Build optional companion_name field for POST body
|
||||
CN_FIELD=""
|
||||
if [[ -n "$COMPANION_NAME" ]]; then
|
||||
CN_FIELD="\"companion_name\":\"${COMPANION_NAME}\","
|
||||
fi
|
||||
|
||||
# Login (passwordless)
|
||||
run_test "POST /api/companion/login" 200 \
|
||||
-X POST "${BASE}/api/companion/login" \
|
||||
-d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"password\":\"\"}"
|
||||
|
||||
# Status request (may timeout — that's OK, we test the plumbing)
|
||||
run_test "POST /api/companion/request_status" 200 \
|
||||
-X POST "${BASE}/api/companion/request_status" \
|
||||
-d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"timeout\":5}"
|
||||
|
||||
# Telemetry request
|
||||
run_test "POST /api/companion/request_telemetry" 200 \
|
||||
-X POST "${BASE}/api/companion/request_telemetry" \
|
||||
-d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"timeout\":5}"
|
||||
|
||||
# Send text
|
||||
run_test "POST /api/companion/send_text" 200 \
|
||||
-X POST "${BASE}/api/companion/send_text" \
|
||||
-d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"text\":\"API test ping\"}"
|
||||
|
||||
# Send command
|
||||
run_test "POST /api/companion/send_command" 200 \
|
||||
-X POST "${BASE}/api/companion/send_command" \
|
||||
-d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"command\":\"status\"}"
|
||||
|
||||
# Reset path
|
||||
run_test "POST /api/companion/reset_path" 200 \
|
||||
-X POST "${BASE}/api/companion/reset_path" \
|
||||
-d "{${CN_FIELD}\"pub_key\":\"${PK}\"}"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 5. Device configuration endpoints
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "--- Device configuration ---"
|
||||
|
||||
CN_FIELD=""
|
||||
if [[ -n "$COMPANION_NAME" ]]; then
|
||||
CN_FIELD="\"companion_name\":\"${COMPANION_NAME}\","
|
||||
fi
|
||||
|
||||
# Set advert name (we'll read it back to verify)
|
||||
run_test "POST /api/companion/set_advert_name" 200 \
|
||||
-X POST "${BASE}/api/companion/set_advert_name" \
|
||||
-d "{${CN_FIELD}\"advert_name\":\"TestNode\"}"
|
||||
|
||||
run_test "POST /api/companion/set_advert_location" 200 \
|
||||
-X POST "${BASE}/api/companion/set_advert_location" \
|
||||
-d "{${CN_FIELD}\"latitude\":37.7749,\"longitude\":-122.4194}"
|
||||
|
||||
# =============================================================================
|
||||
# 6. SSE event stream (quick connect/disconnect test)
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "--- SSE event stream ---"
|
||||
|
||||
SSE_URL="${BASE}/api/companion/events"
|
||||
if [[ -n "$TOKEN" ]]; then
|
||||
SSE_URL="${SSE_URL}?token=${TOKEN}"
|
||||
elif [[ -n "$API_KEY" ]]; then
|
||||
# SSE via EventSource doesn't support custom headers; API key in query not supported
|
||||
# so we just test that the endpoint responds
|
||||
SSE_URL="${SSE_URL}"
|
||||
fi
|
||||
|
||||
printf " %-50s " "SSE /api/companion/events (3s sample)"
|
||||
|
||||
SSE_TMP=$(mktemp)
|
||||
# Connect for 3 seconds, capture whatever comes
|
||||
curl -s -N --max-time 3 \
|
||||
-H "$(auth_header)" \
|
||||
"$SSE_URL" > "$SSE_TMP" 2>/dev/null || true
|
||||
|
||||
SSE_LINES=$(wc -l < "$SSE_TMP" | tr -d ' ')
|
||||
SSE_FIRST=$(head -1 "$SSE_TMP")
|
||||
|
||||
if [[ "$SSE_LINES" -gt 0 ]] && echo "$SSE_FIRST" | grep -q "data:"; then
|
||||
# Check for connected event
|
||||
if grep -q '"connected"' "$SSE_TMP" 2>/dev/null; then
|
||||
printf "${GREEN}PASS${NC} (connected event received, %s lines)\n" "$SSE_LINES"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
printf "${YELLOW}PASS${NC} (got %s lines, no 'connected' event)\n" "$SSE_LINES"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
else
|
||||
printf "${RED}FAIL${NC} (no SSE data received)\n"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
rm -f "$SSE_TMP"
|
||||
|
||||
# =============================================================================
|
||||
# 7. Verbose output: show full response bodies
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "--- Sample responses ---"
|
||||
|
||||
show_response "Companion listing" "${BASE}/api/companion/"
|
||||
show_response "Self info" "${BASE}/api/companion/self_info${QS}"
|
||||
show_response "Contacts" "${BASE}/api/companion/contacts${QS}"
|
||||
show_response "Stats (packets)" "${BASE}/api/companion/stats${QS}"
|
||||
|
||||
# =============================================================================
|
||||
# Summary
|
||||
# =============================================================================
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
printf " Results: ${GREEN}%d passed${NC}" "$PASS"
|
||||
if [[ "$FAIL" -gt 0 ]]; then
|
||||
printf ", ${RED}%d failed${NC}" "$FAIL"
|
||||
fi
|
||||
if [[ "$SKIP" -gt 0 ]]; then
|
||||
printf ", ${YELLOW}%d skipped${NC}" "$SKIP"
|
||||
fi
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
exit $FAIL
|
||||
Reference in New Issue
Block a user