Task 11: rewrite publisher as WebSocket server for Cockpit v1.18.0

This commit is contained in:
Grant 2026-05-13 05:24:55 +02:00
parent eb19cee7af
commit d16e451765

View File

@ -4,152 +4,112 @@ mock_named_value_publisher.py
SymbyTech ROV Autonomy Task 11: Mock NAMED_VALUE Publisher SymbyTech ROV Autonomy Task 11: Mock NAMED_VALUE Publisher
Injects fake NAMED_VALUE_INT / NAMED_VALUE_FLOAT messages into the BlueOS Runs a WebSocket server that streams ROV state variables directly into the
MAVLink bus so Cockpit widgets (W1 System Health, W2 Mission Status) show Cockpit data lake via the "Generic WebSocket Connections" feature introduced
live GREEN / AMBER / RED states without real ROS2 hardware. in Cockpit v1.18.0.
Full data path this script exercises: Data path:
This script (pymavlink udpout) This script (WebSocket server, port 8765)
BlueOS mavlink-router (VM port 14550) Cockpit connects as client
mavlink2rest (VM port 6040) Cockpit reads variable=value messages
Cockpit data lake (window.cockpit.getDataLakeValue) Cockpit data lake populated directly
W1 System Health Indicator W1 System Health Indicator (reads rov_failsa)
W2 Mission Status W2 Mission Status (reads rov_ms, rov_mp)
Message format one variable per line:
IMPORTANT MAVLink NAMED_VALUE 10-char name limit rov_failsa=0 (integer: 0=GREEN, 1=AMBER, 2=RED)
rov_ms=0 (integer: 0=IDLE, 1=RUNNING, 2=PAUSED, 3=COMPLETE, 4=ABORTED)
The NAMED_VALUE_INT / NAMED_VALUE_FLOAT "name" field is exactly rov_mp=0.0 (float: 0.0 to 1.0 mission progress)
10 bytes (null-terminated, per MAVLink spec). pymavlink silently
truncates any string longer than 10 chars when it encodes the packet.
Consequence: Cockpit configuration (one-time setup):
"rov_failsafe" (12 chars) truncated "rov_failsa" Menu > Settings > Generic WebSocket Connections
"rov_mission_state" (17 chars) truncated "rov_missio" Add: ws://{{ vehicle-address }}:8765
"rov_mission_progress"(20 chars) truncated "rov_missio" (CLASH!)
This script uses short, collision-free names defined in the NAME_* Usage (run from inside the BlueOS VM):
constants below. Before wiring the real bridge node, verify the python3 mock_named_value_publisher.py # auto-cycle mode
actual keys that appear in the Cockpit data lake by loading W0 python3 mock_named_value_publisher.py --manual # keyboard control
(Data Lake Inspector), then update each widget's VARIABLE / NAME python3 mock_named_value_publisher.py --port 8765
constant to match.
Install dependency (once):
Usage pip3 install websockets --break-system-packages
# Install dependency (once)
pip install pymavlink
# Auto-cycle through GREEN → AMBER → RED (default) Repo location:
python3 mock_named_value_publisher.py
# Manual keyboard control (0/g = GREEN, 1/a = AMBER, 2/r = RED)
python3 mock_named_value_publisher.py --mode manual
# Override the VM host (default: NAT IP 192.168.122.89)
python3 mock_named_value_publisher.py --host 100.84.141.120 # Tailscale
# Run on the SymbyTech server (SSH in first) — recommended
# The server can reach the VM via NAT without Tailscale.
Repo location
rov-autonomy / tools / mock_named_value_publisher.py rov-autonomy / tools / mock_named_value_publisher.py
""" """
import argparse import argparse
import asyncio
import sys import sys
import time
import threading import threading
import time
# ─── Early import check ─────────────────────────────────────────────────────── # --- Dependency check ---------------------------------------------------------
# Fail fast with a friendly message if pymavlink isn't installed.
try: try:
from pymavlink import mavutil import websockets
from pymavlink.dialects.v20 import ardupilotmega as mavlink2 from websockets.exceptions import ConnectionClosed
except ImportError: except ImportError:
print() print()
print(" ERROR: pymavlink is not installed.") print(" ERROR: websockets is not installed.")
print(" Fix: pip install pymavlink") print(" Fix: pip3 install websockets --break-system-packages")
print() print()
sys.exit(1) sys.exit(1)
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# CONFIGURATION — edit these to match your environment # CONFIGURATION
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# ─── MAVLink name constants ─────────────────────────────────────────────────── # --- Variable name constants --------------------------------------------------
# These MUST be ≤10 chars. Update each corresponding widget VARIABLE once you # These are the Cockpit data lake keys that W1 and W2 read from.
# have confirmed the actual data lake key via W0 (Data Lake Inspector). # MUST match the VARIABLE / NAME_STATE / NAME_PROG constants in each widget.
# NAME_FAILSAFE = "rov_failsa" # W1 polls this (0=GREEN, 1=AMBER, 2=RED)
# Current widget VARIABLE constants (may need updating after W0 inspection): NAME_MS_STATE = "rov_ms" # W2 polls this (0=IDLE … 4=ABORTED)
# W1: VARIABLE = 'rov_failsafe' → update to NAME_FAILSAFE below NAME_MS_PROG = "rov_mp" # W2 polls this (0.01.0 progress)
# W2: NAME_STATE = 'rov_mission_state' → update to NAME_MS_STATE below
# W2: NAME_PROG = 'rov_mission_progress' → update to NAME_MS_PROG below
NAME_FAILSAFE = "rov_failsa" # 10 chars — truncation of "rov_failsafe" # --- Failsafe state values ----------------------------------------------------
NAME_MS_STATE = "rov_ms" # 6 chars — short form of "rov_mission_state" STATE_GREEN = 0
NAME_MS_PROG = "rov_mp" # 6 chars — short form of "rov_mission_progress" STATE_AMBER = 1
STATE_RED = 2
# ─── Failsafe state values — MUST match W1 widget applyState() branches ────── # --- Mission state values -----------------------------------------------------
STATE_GREEN = 0 # Systems nominal
STATE_AMBER = 1 # Parameter degraded
STATE_RED = 2 # Critical failure
# ─── Mission state values — MUST match W2 widget state labels ─────────────────
MISSION_IDLE = 0 MISSION_IDLE = 0
MISSION_RUNNING = 1 MISSION_RUNNING = 1
MISSION_PAUSED = 2 MISSION_PAUSED = 2
MISSION_COMPLETE = 3 MISSION_COMPLETE = 3
MISSION_ABORTED = 4 MISSION_ABORTED = 4
# ─── Auto-cycle timing ──────────────────────────────────────────────────────── # --- Auto-cycle timing (seconds per state) ------------------------------------
# How long (seconds) to hold each failsafe state before advancing in cycle mode CYCLE_HOLD = {
CYCLE_HOLD_SECONDS = {
STATE_GREEN: 5.0, STATE_GREEN: 5.0,
STATE_AMBER: 4.0, STATE_AMBER: 4.0,
STATE_RED: 4.0, STATE_RED: 4.0,
} }
# How often to re-send all NAMED_VALUE messages (seconds). # --- Publish rate -------------------------------------------------------------
# Cockpit widgets poll at 500 ms; re-sending at 250 ms gives two updates per # How often to send updated values to all connected Cockpit clients.
# widget poll cycle — any dropped UDP packet is covered by the next one. PUBLISH_INTERVAL = 0.25 # 4 Hz — well above Cockpit's 500 ms poll rate
PUBLISH_INTERVAL = 0.25
# ─── MAVLink IDs for this script ───────────────────────────────────────────── # --- WebSocket server port ----------------------------------------------------
# We impersonate sysid 1 (autopilot) so mavlink-router forwards our packets DEFAULT_PORT = 8765
# to mavlink2rest. Component 195 is unused in ArduSub — no collision risk.
OUR_SYSID = 1
OUR_COMPID = 195
# ─── Heartbeat interval ───────────────────────────────────────────────────────
# mavlink-router discovers endpoints by seeing heartbeats from them.
# Without a heartbeat, the router may not route our NAMED_VALUE packets.
HEARTBEAT_INTERVAL = 1.0
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# CONSOLE COLOURS — ANSI escape codes, safe on Linux/Mac # CONSOLE COLOURS
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
class C: class C:
"""Thin namespace for ANSI colour codes."""
GREEN = "\033[92m" GREEN = "\033[92m"
AMBER = "\033[93m" AMBER = "\033[93m"
RED = "\033[91m" RED = "\033[91m"
DIM = "\033[2m"
BOLD = "\033[1m" BOLD = "\033[1m"
RESET = "\033[0m" RESET = "\033[0m"
# Per-state colour lookup — used for console output only
STATE_COLOUR = {STATE_GREEN: C.GREEN, STATE_AMBER: C.AMBER, STATE_RED: C.RED} STATE_COLOUR = {STATE_GREEN: C.GREEN, STATE_AMBER: C.AMBER, STATE_RED: C.RED}
# Human-readable labels for console output
FAILSAFE_LABEL = { FAILSAFE_LABEL = {
STATE_GREEN: "GREEN Systems nominal", STATE_GREEN: "GREEN - Systems nominal",
STATE_AMBER: "AMBER Parameter degraded", STATE_AMBER: "AMBER - Parameter degraded",
STATE_RED: "RED Critical failure", STATE_RED: "RED - Critical failure",
} }
MISSION_LABEL = { MISSION_LABEL = {
@ -161,254 +121,138 @@ MISSION_LABEL = {
} }
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# MockPublisher # SHARED STATE (thread-safe via lock)
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
class MockPublisher: _lock = threading.Lock()
_failsafe_state = STATE_GREEN
_mission_state = MISSION_IDLE
_mission_progress = 0.0
def set_failsafe(state: int):
"""Set the failsafe state from any thread."""
global _failsafe_state
with _lock:
_failsafe_state = state
def set_mission(state: int, progress: float = 0.0):
"""Set the mission state and progress from any thread."""
global _mission_state, _mission_progress
with _lock:
_mission_state = state
_mission_progress = max(0.0, min(1.0, progress))
def snapshot():
"""Read current state atomically."""
with _lock:
return _failsafe_state, _mission_state, _mission_progress
# ==============================================================================
# WEBSOCKET SERVER
# ==============================================================================
# Registry of all currently connected Cockpit clients
_clients: set = set()
_clients_lock = asyncio.Lock()
async def handler(websocket):
""" """
Manages a pymavlink UDP connection to the BlueOS VM and runs two Handle one Cockpit client connection.
background threads:
1. Heartbeat thread sends MAVLink HEARTBEAT at 1 Hz so that Cockpit connects to us as a client. On connect we send the current state
mavlink-router registers this script as a immediately so the widget shows something right away, then we rely on the
known endpoint and routes its messages. publish loop to keep values current.
2. Publish thread sends NAMED_VALUE_INT / NAMED_VALUE_FLOAT
messages at PUBLISH_INTERVAL Hz so the
Cockpit data lake stays current.
The main thread controls published values via the set_* methods, which
are protected by a threading.Lock so there are no race conditions.
""" """
addr = websocket.remote_address
print(f" [+] Cockpit connected from {addr}")
def __init__(self, host: str, port: int): async with _clients_lock:
self.host = host _clients.add(websocket)
self.port = port
self.connection = None # set in connect()
# ── Shared state (main thread writes, publish thread reads) ──
self._lock = threading.Lock()
self._failsafe_state = STATE_GREEN
self._mission_state = MISSION_IDLE
self._mission_progress = 0.0 # float, 0.01.0
# ── Thread control ──
self._running = False
self._heartbeat_thread = None
self._publish_thread = None
# ─── Connection ───────────────────────────────────────────────────────────
def connect(self):
"""
Open a udpout connection to BlueOS mavlink-router.
'udpout' means pymavlink sends UDP datagrams to host:port without
binding a local receive socket. That is exactly what we want
mavlink-router sees the datagrams arrive on its GCS port (14550)
and routes them to all connected endpoints (including mavlink2rest).
"""
url = f"udpout:{self.host}:{self.port}"
print(f" Connecting → {url} (sysid={OUR_SYSID} compid={OUR_COMPID})")
self.connection = mavutil.mavlink_connection(
url,
source_system=OUR_SYSID,
source_component=OUR_COMPID,
)
# Small pause to let the OS open the socket before we send
time.sleep(0.3)
print(" Socket open.")
# ─── Setters (thread-safe) ────────────────────────────────────────────────
def set_failsafe_state(self, state: int):
"""Set the failsafe state (STATE_GREEN / STATE_AMBER / STATE_RED)."""
with self._lock:
self._failsafe_state = state
def set_mission_state(self, state: int, progress: float = 0.0):
"""
Set the mission state and optional progress value (0.01.0).
Progress is clamped to [0.0, 1.0].
"""
with self._lock:
self._mission_state = state
self._mission_progress = max(0.0, min(1.0, progress))
# ─── Snapshot (thread-safe read) ──────────────────────────────────────────
def _snapshot(self):
"""Atomically read current state for publishing."""
with self._lock:
return (
self._failsafe_state,
self._mission_state,
self._mission_progress,
)
# ─── MAVLink send helpers ─────────────────────────────────────────────────
def _time_boot_ms(self) -> int:
"""
MAVLink time_boot_ms field. We use wall-clock monotonic time wrapped
to 32 bits good enough for a mock publisher; a real node would use
the autopilot's boot time.
"""
return int(time.monotonic() * 1000) & 0xFFFFFFFF
def _send_heartbeat(self):
"""
Send a MAVLink HEARTBEAT so mavlink-router keeps our endpoint live.
Type = GCS (6), autopilot = Generic (0).
mavlink-router uses heartbeats to maintain its endpoint routing table.
Without this the router may silently drop our NAMED_VALUE packets.
"""
self.connection.mav.heartbeat_send(
mavlink2.MAV_TYPE_GCS, # type: GCS
mavlink2.MAV_AUTOPILOT_GENERIC, # autopilot: generic
0, # base_mode
0, # custom_mode
mavlink2.MAV_STATE_ACTIVE, # system_status
)
def _send_named_values(self):
"""
Send the three NAMED_VALUE_FLOAT messages that feed W1 and W2.
All three use NAMED_VALUE_FLOAT Cockpit's data lake bridge maps
NAMED_VALUE_FLOAT to data lake variables but silently ignores
NAMED_VALUE_INT. States (0/1/2) are sent as floats (0.0/1.0/2.0);
the widgets cast them back to int with Math.round() when reading.
pymavlink packs the name field into exactly 10 bytes. Strings
longer than 10 chars are truncated; shorter strings are null-padded.
The constants NAME_FAILSAFE / NAME_MS_STATE / NAME_MS_PROG are
already 10 chars to avoid truncation.
"""
t = self._time_boot_ms()
fs, ms, mp = self._snapshot()
# W1 data: failsafe state sent as float (0.0=GREEN, 1.0=AMBER, 2.0=RED)
self.connection.mav.named_value_float_send(
t,
NAME_FAILSAFE.encode("utf-8"),
float(fs),
)
# W2 data (part 1): mission state as float (0.0=IDLE … 4.0=ABORTED)
self.connection.mav.named_value_float_send(
t,
NAME_MS_STATE.encode("utf-8"),
float(ms),
)
# W2 data (part 2): mission progress (0.01.0)
self.connection.mav.named_value_float_send(
t,
NAME_MS_PROG.encode("utf-8"),
mp,
)
# ─── Background threads ───────────────────────────────────────────────────
def _heartbeat_loop(self):
"""Send a heartbeat every HEARTBEAT_INTERVAL seconds."""
while self._running:
try: try:
self._send_heartbeat() # Send current state immediately on connect so widgets light up fast
except Exception as exc: fs, ms, mp = snapshot()
# Don't crash the thread on a transient socket error await websocket.send(f"{NAME_FAILSAFE}={fs}")
print(f"\n [heartbeat error] {exc}", file=sys.stderr) await websocket.send(f"{NAME_MS_STATE}={ms}")
time.sleep(HEARTBEAT_INTERVAL) await websocket.send(f"{NAME_MS_PROG}={mp:.3f}")
def _publish_loop(self): # Hold the connection open — the publish loop does the heavy lifting
"""Send NAMED_VALUE messages every PUBLISH_INTERVAL seconds.""" await websocket.wait_closed()
while self._running:
except ConnectionClosed:
pass # Normal disconnect — not an error
finally:
async with _clients_lock:
_clients.discard(websocket)
print(f" [-] Cockpit disconnected from {addr}")
async def publish_loop():
"""
Broadcast current state to all connected clients every PUBLISH_INTERVAL.
Each message is variableName=value on a separate send call.
Cockpit maps each message to a data lake variable by the key before the =.
"""
while True:
await asyncio.sleep(PUBLISH_INTERVAL)
fs, ms, mp = snapshot()
messages = [
f"{NAME_FAILSAFE}={fs}",
f"{NAME_MS_STATE}={ms}",
f"{NAME_MS_PROG}={mp:.3f}",
]
# Snapshot the client set to avoid holding the lock during sends
async with _clients_lock:
current_clients = set(_clients)
for ws in current_clients:
try: try:
self._send_named_values() for msg in messages:
except Exception as exc: await ws.send(msg)
print(f"\n [publish error] {exc}", file=sys.stderr) except ConnectionClosed:
time.sleep(PUBLISH_INTERVAL) pass # Will be cleaned up in the handler
# ─── Lifecycle ────────────────────────────────────────────────────────────
def start(self):
"""Start the heartbeat and publish background threads."""
self._running = True
self._heartbeat_thread = threading.Thread(
target=self._heartbeat_loop,
name="heartbeat",
daemon=True,
)
self._publish_thread = threading.Thread(
target=self._publish_loop,
name="publish",
daemon=True,
)
# Send the first heartbeat synchronously before launching the publish
# thread — this gives the router a chance to register our endpoint
# before NAMED_VALUE packets start arriving.
self._send_heartbeat()
time.sleep(0.1)
self._heartbeat_thread.start()
self._publish_thread.start()
def stop(self):
"""Signal threads to stop and wait for them to exit cleanly."""
self._running = False
if self._heartbeat_thread:
self._heartbeat_thread.join(timeout=2.0)
if self._publish_thread:
self._publish_thread.join(timeout=2.0)
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# CYCLE MODE — auto-advance through GREEN → AMBER → RED → GREEN # AUTO-CYCLE MODE
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
def run_cycle_mode(publisher: MockPublisher): def cycle_thread():
""" """
Automatically cycles through the three failsafe states in order. Background thread that cycles the failsafe state:
GREEN (5s) -> AMBER (4s) -> RED (4s) -> repeat.
A parallel mission state cycle runs alongside so W2 also shows activity: A matching mission state cycle runs in lock-step.
failsafe GREEN mission IDLE
failsafe AMBER mission RUNNING (50% progress)
failsafe RED mission PAUSED
Hold time for each state is defined in CYCLE_HOLD_SECONDS.
Press Ctrl+C to stop.
""" """
print("\n Mode: AUTO-CYCLE (Ctrl+C to stop)\n") failsafe_seq = [STATE_GREEN, STATE_AMBER, STATE_RED]
mission_seq = [
# Define the cycle sequences — indices advance in lock-step (MISSION_IDLE, 0.00), # GREEN -> IDLE
failsafe_cycle = [STATE_GREEN, STATE_AMBER, STATE_RED] (MISSION_RUNNING, 0.50), # AMBER -> RUNNING at 50%
mission_cycle = [ (MISSION_PAUSED, 0.50), # RED -> PAUSED at 50%
(MISSION_IDLE, 0.00), # matches GREEN
(MISSION_RUNNING, 0.50), # matches AMBER — 50% through mission
(MISSION_PAUSED, 0.50), # matches RED — paused mid-mission
] ]
step = 0 step = 0
while True: while True:
# Pick current state from cycle sequences fs = failsafe_seq[step % 3]
fs = failsafe_cycle[step % len(failsafe_cycle)] ms, mp = mission_seq[step % 3]
ms, mp = mission_cycle[step % len(mission_cycle)] hold = CYCLE_HOLD[fs]
set_failsafe(fs)
set_mission(ms, mp)
colour = STATE_COLOUR[fs] colour = STATE_COLOUR[fs]
hold = CYCLE_HOLD_SECONDS[fs]
# Push new state to publisher (publish thread picks it up within 250 ms)
publisher.set_failsafe_state(fs)
publisher.set_mission_state(ms, mp)
# Console output
print( print(
f" {colour}{C.BOLD}{FAILSAFE_LABEL[fs]:<30}{C.RESET}" f" {colour}{C.BOLD}{FAILSAFE_LABEL[fs]:<28}{C.RESET}"
f" mission={MISSION_LABEL[ms]:<10}" f" mission={MISSION_LABEL[ms]:<10}"
f" progress={mp:.2f}" f" progress={mp:.2f}"
f" (hold {hold:.0f}s)" f" (hold {hold:.0f}s)"
@ -418,200 +262,125 @@ def run_cycle_mode(publisher: MockPublisher):
step += 1 step += 1
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# MANUAL MODE — single-key control (Linux/Mac only) # MANUAL MODE (Linux/Mac — uses termios raw mode)
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
def _getch_unix(): def manual_thread():
""" """
Read a single character from stdin without requiring Enter. Keyboard-driven control.
Uses termios/tty raw mode Linux and macOS only.
On Windows, use msvcrt.getch() instead (not implemented here). Keys:
0 / g GREEN
1 / a AMBER
2 / r RED
m cycle mission state
q quit
""" """
import termios import termios
import tty import tty
def getch():
fd = sys.stdin.fileno() fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd) old = termios.tcgetattr(fd)
try: try:
tty.setraw(fd) tty.setraw(fd)
ch = sys.stdin.read(1) return sys.stdin.read(1)
finally: finally:
# Always restore terminal settings, even on exception termios.tcsetattr(fd, termios.TCSADRAIN, old)
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
return ch
print("\n Mode: MANUAL KEYBOARD")
print(" 0/g -> GREEN 1/a -> AMBER 2/r -> RED m -> mission q -> quit\n")
def run_manual_mode(publisher: MockPublisher): # Start in GREEN / RUNNING so widgets are live immediately
""" set_failsafe(STATE_GREEN)
Keyboard-driven control of the failsafe state. set_mission(MISSION_RUNNING, 0.30)
Keybindings:
0 / g / G GREEN (Systems nominal)
1 / a / A AMBER (Parameter degraded)
2 / r / R RED (Critical failure)
m / M cycle mission state (IDLE RUNNING PAUSED IDLE)
q / Q quit
NOTE: Uses termios raw mode requires Linux or macOS.
On Windows, run in WSL or use cycle mode instead.
"""
# Check platform
if sys.platform == "win32":
print()
print(" ERROR: manual mode uses termios (Linux/Mac only).")
print(" On Windows: run inside WSL, or use --mode cycle instead.")
print()
sys.exit(1)
print("\n Mode: MANUAL KEYBOARD (no Enter needed)")
print()
print(" 0 / g → GREEN — Systems nominal")
print(" 1 / a → AMBER — Parameter degraded")
print(" 2 / r → RED — Critical failure")
print(" m → cycle mission state")
print(" q → quit")
print()
# Start in GREEN / RUNNING at 30% so W2 immediately shows activity
publisher.set_failsafe_state(STATE_GREEN)
publisher.set_mission_state(MISSION_RUNNING, 0.30)
print(f" Initial: {C.GREEN}{C.BOLD}{FAILSAFE_LABEL[STATE_GREEN]}{C.RESET}") print(f" Initial: {C.GREEN}{C.BOLD}{FAILSAFE_LABEL[STATE_GREEN]}{C.RESET}")
print()
# Simple mission state toggle cycle for the 'm' key
mission_cycle = [MISSION_IDLE, MISSION_RUNNING, MISSION_PAUSED] mission_cycle = [MISSION_IDLE, MISSION_RUNNING, MISSION_PAUSED]
mission_idx = 1 # start at RUNNING mission_idx = 1
while True: while True:
ch = _getch_unix() ch = getch()
if ch in ("q", "Q", "\x03"): if ch in ("q", "Q", "\x03"):
# q or Ctrl+C
print("\n Quit.") print("\n Quit.")
# Signal the event loop to stop
asyncio.get_event_loop().call_soon_threadsafe(
asyncio.get_event_loop().stop
)
break break
elif ch in ("0", "g", "G"): elif ch in ("0", "g", "G"):
publisher.set_failsafe_state(STATE_GREEN) set_failsafe(STATE_GREEN)
print(f"{C.GREEN}{C.BOLD}{FAILSAFE_LABEL[STATE_GREEN]}{C.RESET}") print(f" -> {C.GREEN}{C.BOLD}{FAILSAFE_LABEL[STATE_GREEN]}{C.RESET}")
elif ch in ("1", "a", "A"): elif ch in ("1", "a", "A"):
publisher.set_failsafe_state(STATE_AMBER) set_failsafe(STATE_AMBER)
print(f"{C.AMBER}{C.BOLD}{FAILSAFE_LABEL[STATE_AMBER]}{C.RESET}") print(f" -> {C.AMBER}{C.BOLD}{FAILSAFE_LABEL[STATE_AMBER]}{C.RESET}")
elif ch in ("2", "r", "R"): elif ch in ("2", "r", "R"):
publisher.set_failsafe_state(STATE_RED) set_failsafe(STATE_RED)
print(f"{C.RED}{C.BOLD}{FAILSAFE_LABEL[STATE_RED]}{C.RESET}") print(f" -> {C.RED}{C.BOLD}{FAILSAFE_LABEL[STATE_RED]}{C.RESET}")
elif ch in ("m", "M"): elif ch in ("m", "M"):
# Advance mission state cycle mission_idx = (mission_idx + 1) % 3
mission_idx = (mission_idx + 1) % len(mission_cycle)
ms = mission_cycle[mission_idx] ms = mission_cycle[mission_idx]
# Give RUNNING a 50% progress, others 0% mp = 0.50 if ms == MISSION_RUNNING else 0.0
mp = 0.50 if ms == MISSION_RUNNING else 0.00 set_mission(ms, mp)
publisher.set_mission_state(ms, mp) print(f" -> mission={MISSION_LABEL[ms]} progress={mp:.2f}")
print(f" → mission={MISSION_LABEL[ms]} progress={mp:.2f}")
else:
# Unknown key — print without newline so the display stays clean
print(f" [unknown key {repr(ch)}]", end="\r", flush=True)
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# ARGUMENT PARSING # ARGUMENT PARSING
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
def parse_args() -> argparse.Namespace: def parse_args():
p = argparse.ArgumentParser( p = argparse.ArgumentParser(
description=( description="Task 11 — Mock NAMED_VALUE publisher via WebSocket."
"Task 11 — Mock NAMED_VALUE publisher.\n"
"Injects fake failsafe states into BlueOS MAVLink bus for end-to-end widget testing."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
) )
p.add_argument( p.add_argument(
"--host", "--port", type=int, default=DEFAULT_PORT,
default="192.168.122.89", help=f"WebSocket port to listen on (default: {DEFAULT_PORT})."
metavar="IP",
help=(
"BlueOS VM IP address. "
"Default: 192.168.122.89 (NAT, from SymbyTech server). "
"Use 100.84.141.120 for Tailscale access from laptop."
),
) )
p.add_argument( p.add_argument(
"--port", "--manual", action="store_true",
type=int, help="Keyboard control mode (Linux/Mac only). Default: auto-cycle."
default=14550,
metavar="PORT",
help="MAVLink GCS UDP port on BlueOS (default: 14550).",
)
p.add_argument(
"--mode",
choices=["cycle", "manual"],
default="cycle",
help=(
"cycle = automatically step through GREEN/AMBER/RED (default). "
"manual = keyboard control (Linux/Mac only)."
),
) )
return p.parse_args() return p.parse_args()
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
# MAIN # MAIN
# ══════════════════════════════════════════════════════════════════════════════ # ==============================================================================
def print_banner(args: argparse.Namespace): async def main(port: int, manual: bool):
"""Print startup information so the operator knows what is running.""" # Start the control thread (cycle or manual) as a daemon
print() if manual:
print(f" {C.BOLD}SymbyTech ROV — Mock NAMED_VALUE Publisher{C.RESET}") t = threading.Thread(target=manual_thread, daemon=True)
print(f" Task 11 — End-to-end data path test") else:
print() print("\n Mode: AUTO-CYCLE (Ctrl+C to stop)\n")
print(f" Target: {args.host}:{args.port} (BlueOS mavlink-router)") t = threading.Thread(target=cycle_thread, daemon=True)
print(f" Mode: {args.mode}") t.start()
print()
print(" MAVLink messages to be injected (all NAMED_VALUE_FLOAT):")
print(f" {C.DIM}{NAME_FAILSAFE:<12}{C.RESET} NAMED_VALUE_FLOAT → W1 System Health Indicator")
print(f" {C.DIM}{NAME_MS_STATE:<12}{C.RESET} NAMED_VALUE_FLOAT → W2 Mission Status")
print(f" {C.DIM}{NAME_MS_PROG:<12}{C.RESET} NAMED_VALUE_FLOAT → W2 Mission Progress")
print()
print(" ⚠ 10-char name limit applies. Use W0 (Data Lake Inspector)")
print(" to confirm actual data lake keys, then update each widget's")
print(" VARIABLE / NAME constant to match before committing to Gitea.")
print()
print(" Widget constants to check after W0 inspection:")
print(f" W1 VARIABLE (currently 'rov_failsafe') → should be '{NAME_FAILSAFE}'")
print(f" W2 NAME_STATE (currently 'rov_mission_state') → should be '{NAME_MS_STATE}'")
print(f" W2 NAME_PROG (currently 'rov_mission_progress') → should be '{NAME_MS_PROG}'")
print()
# Run the WebSocket server and publish loop concurrently
def main(): async with websockets.serve(handler, "0.0.0.0", port):
args = parse_args() print(f" WebSocket server listening on ws://0.0.0.0:{port}")
print_banner(args) print(f" In Cockpit: Settings > Generic WebSocket Connections")
print(f" Add: ws://{{{{ vehicle-address }}}}:{port}\n")
publisher = MockPublisher(args.host, args.port) await publish_loop()
try:
publisher.connect()
publisher.start()
print(" Threads running. First heartbeat sent.\n")
if args.mode == "cycle":
run_cycle_mode(publisher)
elif args.mode == "manual":
run_manual_mode(publisher)
except KeyboardInterrupt:
print("\n Ctrl+C — stopping.")
except Exception as exc:
print(f"\n FATAL: {exc}", file=sys.stderr)
sys.exit(1)
finally:
publisher.stop()
print(" Publisher stopped. MAVLink socket closed.")
print()
if __name__ == "__main__": if __name__ == "__main__":
main() args = parse_args()
print()
print(f" {C.BOLD}SymbyTech ROV - Mock NAMED_VALUE Publisher{C.RESET}")
print(f" Task 11 - End-to-end data path test (Cockpit v1.18.0 WebSocket)")
print()
print(f" Variables streamed:")
print(f" {NAME_FAILSAFE} -> W1 System Health Indicator")
print(f" {NAME_MS_STATE} -> W2 Mission Status")
print(f" {NAME_MS_PROG} -> W2 Mission Progress")
print()
try:
asyncio.run(main(args.port, args.manual))
except KeyboardInterrupt:
print("\n Ctrl+C - stopped.")
print()