Clean up UI windows and strengthen settings workflows

This commit is contained in:
2026-09-30 15:22:09 +02:00
parent 7c85397af0
commit 0093f3f4aa
18 changed files with 2533 additions and 118 deletions
+1
View File
@@ -3,4 +3,5 @@ __pycache__/
*.py[cod] *.py[cod]
logs/ logs/
model_settings.json model_settings.json
link_settings.json
runtime/ runtime/
+1
View File
@@ -16,6 +16,7 @@ The script creates a virtual environment and installs dependencies automatically
On first launch, it also adds a **SUB Control** desktop shortcut. On first launch, it also adds a **SUB Control** desktop shortcut.
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware. Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
Connection settings are under **•••**. Connection settings are under **•••**.
Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view.
## Controls ## Controls
+1
View File
@@ -3,4 +3,5 @@ __pycache__/
*.py[cod] *.py[cod]
logs/ logs/
model_settings.json model_settings.json
link_settings.json
runtime/ runtime/
+1
View File
@@ -16,6 +16,7 @@ The script creates a virtual environment and installs dependencies automatically
On first launch, it also adds a **SUB Control** desktop shortcut. On first launch, it also adds a **SUB Control** desktop shortcut.
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware. Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
Connection settings are under **•••**. Connection settings are under **•••**.
Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view.
## Controls ## Controls
+447 -32
View File
@@ -13,6 +13,9 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
# before importing pymavlink so received ESC packets can be decoded/logged.
os.environ["MAVLINK20"] = "1"
from pymavlink import mavutil from pymavlink import mavutil
from serial.tools import list_ports from serial.tools import list_ports
@@ -67,6 +70,10 @@ class MavlinkWorker:
self.connection = None self.connection = None
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.lock = threading.Lock() self.lock = threading.Lock()
# pymavlink writes are not thread-safe. The web/API thread can issue
# commands while this worker sends heartbeats and controls, so serialize
# every packet before it reaches a low-bandwidth serial link.
self.tx_lock = threading.Lock()
self.thread = None self.thread = None
self.axes = Axes() self.axes = Axes()
self.output_axes = Axes() self.output_axes = Axes()
@@ -87,6 +94,34 @@ class MavlinkWorker:
self.last_rx = 0.0 self.last_rx = 0.0
self.armed = False self.armed = False
self.config_paused = False self.config_paused = False
# Keep configuration traffic off the link during an arm/disarm
# transition, including the wait for the vehicle's confirmation.
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config = deque()
self.next_stream_config_at = 0.0
# Low-rate TIMESYNC echoes measure PC↔FC round trips with minimal traffic.
self.ping_rate_hz = 1
self.heavy_quality_test = None
self.heavy_pending_pings = {}
self.heavy_quality_latest = None
self.telemetry_rates_hz = {
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
}
self.ping_sequence = 0
self.next_ping_at = 0.0
self.pending_pings = {}
self.ping_results = deque(maxlen=300)
self.bad_frame_times = deque()
self.bad_frame_bytes = deque()
self.rx_sequence_by_source = {}
self.rx_delivery_results = deque(maxlen=1024)
self.next_quality_publish_at = 0.0
self.param_scan = deque() self.param_scan = deque()
self.param_waiting = None self.param_waiting = None
self.param_deadline = 0.0 self.param_deadline = 0.0
@@ -96,6 +131,9 @@ class MavlinkWorker:
self.log_lock = threading.Lock() self.log_lock = threading.Lock()
self.log_file = None self.log_file = None
self.log_path = None self.log_path = None
self.console_lines = deque(maxlen=500)
self.console_rx_lines = deque(maxlen=300)
self.console_tx_lines = deque(maxlen=300)
def connect(self, port: str, baud: int): def connect(self, port: str, baud: int):
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}") print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
@@ -109,8 +147,12 @@ class MavlinkWorker:
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1) self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
normalized = normalize_endpoint(port) normalized = normalize_endpoint(port)
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}") print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}") self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
self.events.put(("log_path", str(self.log_path))) self.events.put(("log_path", str(self.log_path)))
self.last_heartbeat = 0.0
self.last_rx = 0.0
self.next_quality_publish_at = 0.0
self.thread = threading.Thread( self.thread = threading.Thread(
target=self._run, args=(normalized, baud), daemon=True target=self._run, args=(normalized, baud), daemon=True
) )
@@ -137,7 +179,7 @@ class MavlinkWorker:
# Send initial GCS heartbeat immediately # Send initial GCS heartbeat immediately
self.log("TX_HEARTBEAT", "GCS initial") self.log("TX_HEARTBEAT", "GCS initial")
conn.mav.heartbeat_send( self._send_mav(conn, "heartbeat_send",
mavutil.mavlink.MAV_TYPE_GCS, mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID, mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE, 0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
@@ -149,17 +191,32 @@ class MavlinkWorker:
now = time.monotonic() now = time.monotonic()
if now >= next_gcs_hb: if now >= next_gcs_hb:
self.log("TX_HEARTBEAT", "GCS") self.log("TX_HEARTBEAT", "GCS")
conn.mav.heartbeat_send( self._send_mav(conn, "heartbeat_send",
mavutil.mavlink.MAV_TYPE_GCS, mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID, mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE, 0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
) )
next_gcs_hb = now + 1.0 next_gcs_hb = now + 1.0
if self.target_system and now >= next_control: # ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
# 20 Hz neutral stream alive here only competes with ARM and
# its acknowledgement on constrained links.
if self.target_system and self.armed and now >= next_control:
self._send_control() self._send_control()
next_control = now + (1.0 / max(1, self.rate_hz)) # The first disarm packet is preceded by a neutral command.
# Until disarm is confirmed, keep sending neutral at a
# lower rate instead of consuming 20 Hz on the weak link.
control_hz = min(self.rate_hz, 5) if now < self.disarm_requested_until else self.rate_hz
next_control = now + (1.0 / max(1, control_hz))
elif not self.armed:
next_control = now
if self.target_system: if self.target_system:
self._tick_arm_retry(now)
self._tick_link_probe(now)
self._tick_stream_config(now)
self._tick_param_scan(now) self._tick_param_scan(now)
if now >= self.next_quality_publish_at:
self._publish_link_quality()
self.next_quality_publish_at = now + 1.0
msg = conn.recv_match(blocking=False) msg = conn.recv_match(blocking=False)
if msg: if msg:
@@ -170,6 +227,8 @@ class MavlinkWorker:
print(f"[MavlinkWorker._run] EXCEPTION: {exc}") print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
self.events.put(("error", f"Connection error: {exc}")) self.events.put(("error", f"Connection error: {exc}"))
finally: finally:
if self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
self._safe_stop() self._safe_stop()
if self.connection: if self.connection:
try: try:
@@ -180,6 +239,19 @@ class MavlinkWorker:
self.target_system = 0 self.target_system = 0
self.armed = False self.armed = False
self.config_paused = False self.config_paused = False
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config.clear()
self.pending_pings.clear()
self.ping_results.clear()
self.bad_frame_times.clear()
self.bad_frame_bytes.clear()
self.rx_sequence_by_source.clear()
self.rx_delivery_results.clear()
self.log("SESSION", "disconnected") self.log("SESSION", "disconnected")
with self.log_lock: with self.log_lock:
if self.log_file: if self.log_file:
@@ -189,22 +261,71 @@ class MavlinkWorker:
def log(self, category: str, message: str): def log(self, category: str, message: str):
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds") timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
line = f"{timestamp} [{category}] {message}"
with self.log_lock: with self.log_lock:
self.console_lines.append(line[:2000])
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
self.console_rx_lines.append(line[:2000])
elif category.startswith("TX") or category == "OPERATOR":
self.console_tx_lines.append(line[:2000])
if self.log_file: if self.log_file:
self.log_file.write(f"{timestamp} [{category}] {message}\n") self.log_file.write(line + "\n")
def read_console(self, limit=150):
with self.log_lock:
limit = max(1, min(300, int(limit)))
return {
"rx": list(self.console_rx_lines)[-limit:],
"tx": list(self.console_tx_lines)[-limit:],
}
def _send_mav(self, connection, method: str, *args):
"""Serialize MAVLink 2 writes and protect the start of each burst."""
with self.tx_lock:
# This full-duplex module pair loses the opening bytes of some
# PC-to-FC bursts after the line has been idle. Put a valid,
# expendable GCS heartbeat first so the following packet remains
# intact and the FC also sees a healthy GCS heartbeat stream.
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
connection.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
getattr(connection.mav, method)(*args)
def _handle_message(self, msg): def _handle_message(self, msg):
msg_type = msg.get_type() msg_type = msg.get_type()
if msg_type == "BAD_DATA": if msg_type == "BAD_DATA":
now = time.monotonic()
self.bad_frame_times.append(now)
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
self._trim_bad_frames(now)
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b""))) self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
return return
self.last_rx = time.monotonic() self.last_rx = time.monotonic()
self._track_rx_delivery(msg)
try: try:
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":")) payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
except Exception: except Exception:
payload = str(msg) payload = str(msg)
self.log("RX", payload) self.log("RX", payload)
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS: if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
token = int(getattr(msg, "ts1", -1))
heavy_probe = self.heavy_pending_pings.pop(token, None)
if heavy_probe is not None:
stage_index, sent_at = heavy_probe
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
self._publish_heavy_quality()
sent_at = None
else:
sent_at = self.pending_pings.pop(token, None)
if sent_at is not None:
rtt_ms = (time.monotonic() - sent_at) * 1000.0
self.ping_results.append((time.monotonic(), rtt_ms))
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
self._publish_link_quality()
elif msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID: if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
return return
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component): if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
@@ -216,6 +337,10 @@ class MavlinkWorker:
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
self.armed = armed self.armed = armed
self.config_paused = armed self.config_paused = armed
if self.pending_arm_state is not None and armed == self.pending_arm_state:
self.pending_arm_state = None
if not armed:
self.disarm_requested_until = 0.0
try: try:
mode = mavutil.mode_string_v10(msg) mode = mavutil.mode_string_v10(msg)
except Exception: except Exception:
@@ -224,6 +349,7 @@ class MavlinkWorker:
ap_name = autopilot.name if autopilot else str(msg.autopilot) ap_name = autopilot.name if autopilot else str(msg.autopilot)
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat)) self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
if first: if first:
self.next_ping_at = time.monotonic()
self.events.put(("status", "Heartbeat received; controls are neutral.")) self.events.put(("status", "Heartbeat received; controls are neutral."))
self._configure_low_bandwidth_streams() self._configure_low_bandwidth_streams()
self._start_servo_scan() self._start_servo_scan()
@@ -232,12 +358,21 @@ class MavlinkWorker:
return return
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
result_name = result.name if result else str(msg.result) result_name = result.name if result else str(msg.result)
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
self.log("RX_STREAM_CONFIG_ACK", result_name)
return
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
self.pending_arm_state = None
self.events.put(("ack", int(msg.command), result_name, self.last_rx)) self.events.put(("ack", int(msg.command), result_name, self.last_rx))
elif msg_type == "STATUSTEXT": elif msg_type == "STATUSTEXT":
text = msg.text text = msg.text
if isinstance(text, bytes): if isinstance(text, bytes):
text = text.decode(errors="replace") text = text.decode(errors="replace")
self.events.put(("statustext", text.rstrip("\x00"))) text = text.rstrip("\x00")
if "/APM/LOGS" in text and "Failed" in text:
self.log("ONBOARD_LOG_ERROR", text)
self.events.put(("statustext", text))
elif msg_type == "SYS_STATUS": elif msg_type == "SYS_STATUS":
voltage_mv = int(msg.voltage_battery) voltage_mv = int(msg.voltage_battery)
if 0 < voltage_mv < 65535: if 0 < voltage_mv < 65535:
@@ -264,6 +399,238 @@ class MavlinkWorker:
self.events.put(("motor_direction", int(parts[1]), self.events.put(("motor_direction", int(parts[1]),
int(round(msg.param_value)))) int(round(msg.param_value))))
def _trim_bad_frames(self, now):
while self.bad_frame_times and now - self.bad_frame_times[0] > 20.0:
self.bad_frame_times.popleft()
def _track_rx_delivery(self, msg):
"""Estimate downlink loss from MAVLink's per-source packet sequence."""
try:
source = (int(msg.get_srcSystem()), int(msg.get_srcComponent()))
sequence = int(msg.get_seq())
except (AttributeError, TypeError, ValueError):
return
if self.target_system and source != (self.target_system, self.target_component):
return
previous = self.rx_sequence_by_source.get(source)
self.rx_sequence_by_source[source] = sequence
if previous is None:
return
missing = (sequence - previous - 1) & 0xFF
# A large backwards jump is a reset/reordered packet, not 200 losses.
if missing > 127:
return
timestamp = time.monotonic()
self.rx_delivery_results.extend((timestamp, False) for _ in range(missing))
self.rx_delivery_results.append((timestamp, True))
def _publish_link_quality(self):
now = time.monotonic()
self._trim_bad_frames(now)
while self.ping_results and now - self.ping_results[0][0] > 30.0:
self.ping_results.popleft()
while self.rx_delivery_results and now - self.rx_delivery_results[0][0] > 30.0:
self.rx_delivery_results.popleft()
while self.bad_frame_bytes and now - self.bad_frame_bytes[0][0] > 20.0:
self.bad_frame_bytes.popleft()
samples = [sample for _, sample in self.ping_results]
received = [sample for sample in samples if sample is not None]
total = len(samples)
loss_percent = 100.0 * (total - len(received)) / total if total else None
ordered_rtt = sorted(received)
latency_ms = (sum(ordered_rtt) / len(ordered_rtt)) if ordered_rtt else None
median_rtt_ms = ordered_rtt[len(ordered_rtt) // 2] if ordered_rtt else None
p95_rtt_ms = ordered_rtt[min(len(ordered_rtt) - 1, (len(ordered_rtt) * 95 + 99) // 100 - 1)] if ordered_rtt else None
rx_received = sum(1 for _, delivered in self.rx_delivery_results if delivered)
rx_missing = sum(1 for _, delivered in self.rx_delivery_results if not delivered)
rx_loss_percent = 100.0 * rx_missing / (rx_received + rx_missing) if rx_received else None
rx_corrupt_bytes = sum(byte_count for _, byte_count in self.bad_frame_bytes)
heartbeat_age = (now - self.last_heartbeat) if self.last_heartbeat else None
if total < 10 or rx_received < 10 or heartbeat_age is None:
grade = "testing"
elif (heartbeat_age > 3 or loss_percent > 15
or (rx_loss_percent is not None and rx_loss_percent > 5)
or len(self.bad_frame_bytes) >= 5):
grade = "poor"
elif (loss_percent > 3 or (rx_loss_percent is not None and rx_loss_percent > 1)
or (p95_rtt_ms is not None and p95_rtt_ms > 500) or self.bad_frame_bytes):
grade = "fair"
elif (loss_percent > 0.5 or (rx_loss_percent is not None and rx_loss_percent > 0.2)
or (p95_rtt_ms is not None and p95_rtt_ms > 200)):
grade = "good"
else:
grade = "excellent"
self.events.put(("link_quality", {
"grade": grade,
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
"rtt_median_ms": round(median_rtt_ms, 1) if median_rtt_ms is not None else None,
"rtt_p95_ms": round(p95_rtt_ms, 1) if p95_rtt_ms is not None else None,
"loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_samples": total,
"probe_replies": len(received),
"rx_packets_30s": rx_received,
"rx_sequence_loss_percent": round(rx_loss_percent, 1) if rx_loss_percent is not None else None,
"rx_corrupt_frames_20s": len(self.bad_frame_bytes),
"rx_corrupt_bytes_20s": rx_corrupt_bytes,
"samples": total,
"corrupt_frames_20s": len(self.bad_frame_times),
"heartbeat_age_s": round(heartbeat_age, 1) if heartbeat_age is not None else None,
"probe_supported": total > 0,
}))
def _tick_link_probe(self, now):
if self.heavy_quality_test is not None:
self._tick_heavy_quality(now)
return
expired = [sequence for sequence, sent_at in self.pending_pings.items()
if now - sent_at >= 2.5]
for sequence in expired:
self.pending_pings.pop(sequence, None)
self.ping_results.append((now, None))
self.log("LINK_PROBE_TIMEOUT", f"timesync={sequence}")
if expired:
self._publish_link_quality()
if (now < self.next_ping_at or now < self.command_quiet_until
or not self.connection or not self.target_system):
return
token = time.monotonic_ns()
self.pending_pings[token] = now
self.log("TX_LINK_PROBE", f"timesync={token}")
# ArduPilot implements TIMESYNC even on builds that omit PING. A
# request has tc1=0; the response mirrors our unique ts1 token.
self._send_mav(self.connection, "timesync_send", 0, token)
self.next_ping_at = now + (1.0 / max(1, self.ping_rate_hz))
def start_heavy_quality_test(self):
if not self.connection or not self.target_system:
raise RuntimeError("Connect to the flight controller before testing the link")
if self.armed:
raise RuntimeError("The heavy link test is available while the vehicle is disarmed")
if self.pending_arm_state is not None or time.monotonic() < self.command_quiet_until:
raise RuntimeError("Wait for the current vehicle command to finish")
if self.heavy_quality_test is not None:
raise RuntimeError("A heavy link test is already running")
self.pending_pings.clear()
self.heavy_pending_pings.clear()
self.heavy_quality_latest = {
"running": True, "stage": 1, "stages": 3, "rate_hz": 1,
"status": "Running", "results": [],
}
self.heavy_quality_test = {
"rates": (1, 5, 10), "duration_s": 10.0, "stage_index": 0,
"stage_started": time.monotonic(), "next_probe_at": time.monotonic(),
"stages": [{"rate_hz": rate, "sent": 0, "replies": 0,
"timeouts": 0, "rtts": []} for rate in (1, 5, 10)],
}
self.log("TX_HEAVY_TEST", "started rates=1,5,10Hz stage_seconds=10")
self._publish_heavy_quality()
return self.heavy_quality_latest
def cancel_heavy_quality_test(self):
if self.heavy_quality_test is None:
return self.heavy_quality_latest
self.heavy_pending_pings.clear()
test = self.heavy_quality_test
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 0, "stages": len(test["rates"]),
"rate_hz": None, "status": "Cancelled", "results": self._heavy_results(test),
}
self.next_ping_at = time.monotonic()
self.log("TX_HEAVY_TEST", "cancelled")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return self.heavy_quality_latest
@staticmethod
def _heavy_results(test):
results = []
for stage in test["stages"]:
rtts = sorted(stage["rtts"])
replies = stage["replies"]
resolved = replies + stage["timeouts"]
results.append({
"rate_hz": stage["rate_hz"], "sent": stage["sent"], "replies": replies,
"loss_percent": round(100.0 * stage["timeouts"] / resolved, 1) if resolved else None,
"median_rtt_ms": round(rtts[len(rtts) // 2], 1) if rtts else None,
"p95_rtt_ms": round(rtts[min(len(rtts) - 1, (len(rtts) * 95 + 99) // 100 - 1)], 1) if rtts else None,
})
return results
def _record_heavy_probe(self, stage_index, rtt_ms=None):
test = self.heavy_quality_test
if test is None or not 0 <= stage_index < len(test["stages"]):
return
stage = test["stages"][stage_index]
if rtt_ms is None:
stage["timeouts"] += 1
else:
stage["replies"] += 1
stage["rtts"].append(rtt_ms)
def _publish_heavy_quality(self):
test = self.heavy_quality_test
if test is None:
return
index = test["stage_index"]
stage = test["stages"][index]
self.heavy_quality_latest = {
"running": True, "stage": index + 1, "stages": len(test["rates"]),
"rate_hz": stage["rate_hz"], "sent": stage["sent"],
"replies": stage["replies"], "timeouts": stage["timeouts"],
"status": f"Stage {index + 1} of {len(test['rates'])}: {stage['rate_hz']} Hz",
"results": self._heavy_results(test),
}
self.events.put(("heavy_quality", self.heavy_quality_latest))
def _tick_heavy_quality(self, now):
test = self.heavy_quality_test
if test is None:
return
for token, (stage_index, sent_at) in list(self.heavy_pending_pings.items()):
if now - sent_at >= 2.5:
self.heavy_pending_pings.pop(token, None)
self._record_heavy_probe(stage_index)
self.log("HEAVY_PROBE_TIMEOUT", f"rate={test['rates'][stage_index]}Hz timesync={token}")
index = test["stage_index"]
stage_pending = any(stage_index == index for stage_index, _ in self.heavy_pending_pings.values())
if now - test["stage_started"] >= test["duration_s"] and not stage_pending:
index += 1
if index >= len(test["rates"]):
results = self._heavy_results(test)
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 3, "stages": 3, "rate_hz": None,
"status": "Complete", "results": results,
}
self.next_ping_at = now
self.log("TX_HEAVY_TEST", "complete")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return
test["stage_index"] = index
test["stage_started"] = now
test["next_probe_at"] = now
self._publish_heavy_quality()
index = test["stage_index"]
stage = test["stages"][index]
if now >= test["next_probe_at"] and now - test["stage_started"] < test["duration_s"]:
token = time.monotonic_ns()
self.heavy_pending_pings[token] = (index, now)
stage["sent"] += 1
self.log("TX_HEAVY_PROBE", f"rate={stage['rate_hz']}Hz timesync={token}")
self._send_mav(self.connection, "timesync_send", 0, token)
test["next_probe_at"] = now + 1.0 / stage["rate_hz"]
if now >= self.next_quality_publish_at:
self._publish_heavy_quality()
self.next_quality_publish_at = now + 1.0
def set_link_rates(self, ping_hz, telemetry_rates):
self.ping_rate_hz = max(1, min(10, int(ping_hz)))
for name in self.telemetry_rates_hz:
if name in telemetry_rates:
self.telemetry_rates_hz[name] = max(0, min(10, int(telemetry_rates[name])))
if self.connection and self.target_system:
self._configure_low_bandwidth_streams()
def _start_servo_scan(self): def _start_servo_scan(self):
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)] names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal, # ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
@@ -284,31 +651,52 @@ class MavlinkWorker:
self.param_retries = 0 self.param_retries = 0
def _configure_low_bandwidth_streams(self): def _configure_low_bandwidth_streams(self):
"""Stop inherited high-rate streams and request only diagnostic essentials.""" """Pace optional telemetry changes; never burst them at arm time."""
if not self.connection: if not self.connection:
return return
self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL") self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL")
self.connection.mav.request_data_stream_send( self._send_mav(self.connection, "request_data_stream_send",
self.target_system, self.target_component, self.target_system, self.target_component,
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0, mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
) )
# Heartbeat and STATUSTEXT do not need to be requested. Keep only # ATTITUDE and VFR_HUD interval commands were accepted by this FC.
# output/input evidence needed to diagnose controls, at 2 Hz each. # AHRS2 is also unneeded, but an acknowledgement has not been seen.
for message_id, interval_us, name in ( # NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"), # A single interval command is sometimes lost, so send two paced
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"), # passes. This does not change persistent parameters or onboard logs.
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"), unwanted = (
): "ATTITUDE", "AHRS2", "VFR_HUD",
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}") )
self.connection.mav.command_long_send( self.stream_config = deque(
self.target_system, self.target_component, (name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL, for _ in range(2) for name in unwanted
0, message_id, interval_us, 0, 0, 0, 0, 0, )
) for name, rate_hz in self.telemetry_rates_hz.items():
self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…")) interval_us = -1 if rate_hz == 0 else round(1_000_000 / rate_hz)
self.stream_config.append((
name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), interval_us,
))
self.next_stream_config_at = time.monotonic()
self.events.put(("status", "Applying low-bandwidth telemetry rates…"))
def _tick_stream_config(self, now):
if (not self.connection or not self.target_system or self.armed
or now < self.command_quiet_until or now < self.next_stream_config_at
or not self.stream_config):
return
name, message_id, interval_us = self.stream_config.popleft()
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
0, message_id, interval_us, 0, 0, 0, 0, 0,
)
self.next_stream_config_at = now + 0.5
def _tick_param_scan(self, now): def _tick_param_scan(self, now):
if not self.connection or self.config_paused or now < self.param_deadline: if (not self.connection or self.config_paused
or self.stream_config or now < self.next_stream_config_at
or now < self.command_quiet_until or now < self.param_deadline):
return return
if self.param_waiting is not None: if self.param_waiting is not None:
if self.param_retries < 2: if self.param_retries < 2:
@@ -329,7 +717,7 @@ class MavlinkWorker:
def _request_param(self, name: str): def _request_param(self, name: str):
self.log("TX_PARAM_REQUEST", name) self.log("TX_PARAM_REQUEST", name)
self.connection.mav.param_request_read_send( self._send_mav(self.connection, "param_request_read_send",
self.target_system, self.target_component, name.encode("ascii"), -1 self.target_system, self.target_component, name.encode("ascii"), -1
) )
@@ -343,7 +731,7 @@ class MavlinkWorker:
raise RuntimeError(f"{name} has not been read from the autopilot") raise RuntimeError(f"{name} has not been read from the autopilot")
self.pending_sets[name] = int(value) self.pending_sets[name] = int(value)
self.log("TX_PARAM_SET", f"{name}={value} type={param_type}") self.log("TX_PARAM_SET", f"{name}={value} type={param_type}")
self.connection.mav.param_set_send( self._send_mav(self.connection, "param_set_send",
self.target_system, self.target_component, name.encode("ascii"), self.target_system, self.target_component, name.encode("ascii"),
float(value), param_type, float(value), param_type,
) )
@@ -396,7 +784,7 @@ class MavlinkWorker:
f"x={x} y={y} z={z} r={r} active={active} " f"x={x} y={y} z={z} r={r} active={active} "
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})", f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
) )
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0) self._send_mav(conn, "manual_control_send", self.target_system, x, y, z, r, 0)
@staticmethod @staticmethod
def _approach(current: float, target: float, max_change: float) -> float: def _approach(current: float, target: float, max_change: float) -> float:
@@ -409,18 +797,45 @@ class MavlinkWorker:
conn = self.connection conn = self.connection
if not conn or not self.target_system: if not conn or not self.target_system:
raise RuntimeError("No autopilot heartbeat") raise RuntimeError("No autopilot heartbeat")
if arm and self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
now = time.monotonic()
self.command_quiet_until = now + 6.0
if not arm: if not arm:
self.disarm_requested_until = now + 6.0
self._send_control(force_neutral=True) self._send_control(force_neutral=True)
else: else:
# Give the ARM request priority over the low-rate parameter scan. # Give the ARM request priority over the low-rate parameter scan.
self.config_paused = True self.config_paused = True
self.log("TX_COMMAND", "ARM" if arm else "DISARM") self.log("TX_COMMAND", "ARM" if arm else "DISARM")
conn.mav.command_long_send( self.pending_arm_state = bool(arm)
self.next_arm_retry_at = now + 0.25
self.arm_retry_deadline = now + 5.0
self.arm_confirmation = 0
self._send_arm_packet(arm)
def _send_arm_packet(self, arm: bool):
conn = self.connection
if not conn or not self.target_system:
return
self._send_mav(conn, "command_long_send",
self.target_system, self.target_component, self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0, self.arm_confirmation, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
) )
def _tick_arm_retry(self, now):
if self.pending_arm_state is None or now < self.next_arm_retry_at:
return
if now >= self.arm_retry_deadline:
self.log("TX_COMMAND_TIMEOUT", "ARM" if self.pending_arm_state else "DISARM")
self.pending_arm_state = None
return
self.log("TX_COMMAND_RETRY", "ARM" if self.pending_arm_state else "DISARM")
self.arm_confirmation = min(255, self.arm_confirmation + 1)
self._send_arm_packet(self.pending_arm_state)
self.next_arm_retry_at = now + 0.25
def set_manual_mode(self): def set_manual_mode(self):
conn = self.connection conn = self.connection
if not conn or not self.target_system: if not conn or not self.target_system:
@@ -430,7 +845,7 @@ class MavlinkWorker:
if manual is None: if manual is None:
raise RuntimeError("MANUAL mode is not advertised by this firmware") raise RuntimeError("MANUAL mode is not advertised by this firmware")
self.log("TX_MODE", f"MANUAL custom_mode={manual}") self.log("TX_MODE", f"MANUAL custom_mode={manual}")
conn.mav.set_mode_send( self._send_mav(conn, "set_mode_send",
self.target_system, self.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
manual, manual,
@@ -467,7 +882,7 @@ class MavlinkWorker:
if self.connection and self.target_system: if self.connection and self.target_system:
try: try:
self._send_control(force_neutral=True) self._send_control(force_neutral=True)
self.connection.mav.command_long_send( self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component, self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+168 -2
View File
@@ -1,6 +1,9 @@
"""No serial port is opened by these tests.""" """No serial port is opened by these tests."""
import queue import queue
import threading
import time
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from app import Axes, MavlinkWorker, NEUTRAL_Z from app import Axes, MavlinkWorker, NEUTRAL_Z
@@ -100,9 +103,18 @@ class WorkerWatchdogTests(unittest.TestCase):
def assertNeutral(self): def assertNeutral(self):
self.worker._send_control() self.worker._send_control()
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args, self.assertEqual(self.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0)) (1, 0, 0, NEUTRAL_Z, 0, 0))
def control_values(self):
return self.worker.connection.mav.manual_control_send.call_args.args
def test_manual_control_uses_native_v2_without_forced_long_payload(self):
self.worker._send_mav(self.worker.connection, 'manual_control_send',
1, 50, 0, 525, -20, 0)
self.assertEqual(self.control_values(), (1, 50, 0, 525, -20, 0))
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.kwargs, {})
def test_ui_freeze_neutralizes_and_does_not_replay(self): def test_ui_freeze_neutralizes_and_does_not_replay(self):
with patch("app.time.monotonic", return_value=10): with patch("app.time.monotonic", return_value=10):
self.worker.set_axes(Axes(forward=1), 1) self.worker.set_axes(Axes(forward=1), 1)
@@ -118,7 +130,7 @@ class WorkerWatchdogTests(unittest.TestCase):
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1) self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
self.worker._send_control() self.worker._send_control()
# 25% input at the default 20% limit: 5% command, not 100%. # 25% input at the default 20% limit: 5% command, not 100%.
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args, self.assertEqual(self.control_values(),
(1, 50, 0, 525, 0, 0)) (1, 50, 0, 525, 0, 0))
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self): def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
@@ -131,6 +143,160 @@ class WorkerWatchdogTests(unittest.TestCase):
self.worker.set_axes(Axes(forward=1), 19) self.worker.set_axes(Axes(forward=1), 19)
self.assertEqual(self.worker.axes, Axes()) self.assertEqual(self.worker.axes, Axes())
def test_mavlink_transmissions_are_serialized(self):
first_entered = threading.Event()
release_first = threading.Event()
second_entered = threading.Event()
class Mav:
def first_send(self, **_):
first_entered.set()
release_first.wait(1)
def second_send(self, **_):
second_entered.set()
connection = SimpleNamespace(mav=Mav())
first = threading.Thread(target=self.worker._send_mav,
args=(connection, "first_send"))
second = threading.Thread(target=self.worker._send_mav,
args=(connection, "second_send"))
first.start()
self.assertTrue(first_entered.wait(.5))
second.start()
time.sleep(.05)
self.assertFalse(second_entered.is_set())
release_first.set()
first.join(1)
second.join(1)
self.assertTrue(second_entered.is_set())
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
for index in range(310):
self.worker.log("RX_TEST", f"vehicle {index}")
self.worker.log("TX_TEST", f"computer {index}")
sides = self.worker.read_console(2)
self.assertEqual(set(sides), {"rx", "tx"})
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
self.assertEqual(len(self.worker.console_rx_lines), 300)
self.assertEqual(len(self.worker.console_tx_lines), 300)
self.assertEqual(len(self.worker.console_lines), 500)
def test_arm_request_pauses_parameter_traffic(self):
self.worker.armed = False
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker.config_paused = False # A disarmed heartbeat arrived.
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.command_quiet_until, 16)
self.worker.connection.mav.param_request_read_send.assert_not_called()
self.worker.connection.mav.command_long_send.assert_called_once()
def test_arm_request_retries_until_state_confirmation(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker._tick_arm_retry(10.24)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_arm_retry(10.25)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
self.worker.pending_arm_state = None
self.worker._tick_arm_retry(10.50)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_outbound_command_has_valid_heartbeat_lead_in(self):
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
names = [call[0] for call in self.worker.connection.mav.method_calls]
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(False)
self.worker.config_paused = False
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.disarm_requested_until, 16)
self.assertEqual(self.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0))
self.worker.connection.mav.param_request_read_send.assert_not_called()
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker._configure_low_bandwidth_streams()
self.assertEqual(len(self.worker.stream_config), 10)
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
self.worker.connection.mav.command_long_send.assert_not_called()
self.worker._tick_stream_config(10)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(10.1)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker.command_quiet_until = 16
self.worker._tick_stream_config(11)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(16)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
self.worker.armed = False
self.worker.ping_rate_hz = 5
with patch("app.time.monotonic", return_value=10), \
patch("app.time.monotonic_ns", return_value=10_000_000_000):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_called_once_with(
0, 10_000_000_000)
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
self.assertEqual(self.worker.next_ping_at, 10.2)
message = SimpleNamespace(
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
with patch("app.time.monotonic", return_value=10.04):
self.worker._handle_message(message)
quality = self.worker.events.get_nowait()
self.assertEqual(quality[0], "link_quality")
self.assertEqual(quality[1]["latency_ms"], 40.0)
self.assertEqual(quality[1]["loss_percent"], 0.0)
def test_link_rates_rebuild_requested_telemetry_intervals(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.set_link_rates(10, {
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
})
self.assertEqual(self.worker.ping_rate_hz, 10)
configured = {name: interval for name, _, interval in self.worker.stream_config}
self.assertEqual(configured["SYS_STATUS"], 200_000)
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
self.assertEqual(configured["RC_CHANNELS"], -1)
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
self.worker.pending_pings[4] = 5
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
self.worker.next_ping_at = 0
self.worker.command_quiet_until = 20
with patch("app.time.monotonic", return_value=10):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_not_called()
self.assertEqual(list(self.worker.ping_results), [(10, None)])
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "testing")
def test_clean_downlink_cannot_hide_failed_round_trips(self):
self.worker.ping_results.extend([(10, None)] * 20)
self.worker.rx_delivery_results.extend([(10, True)] * 200)
with patch("app.time.monotonic", return_value=10):
self.worker._publish_link_quality()
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "poor")
class BridgeTests(unittest.TestCase): class BridgeTests(unittest.TestCase):
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self): def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
+140
View File
@@ -0,0 +1,140 @@
/* Browser regression check. Run: node tests/ui_windows.cjs [path-to-playwright]
Uses a simulated Python bridge; never opens a COM port or sends MAVLink. */
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const http=require('node:http');
const {chromium}=require(process.argv[2]||'playwright');
const root=path.resolve(__dirname,'..'),output=path.join(root,'runtime','ui-check');
fs.mkdirSync(output,{recursive:true});
const server=http.createServer((req,res)=>{
const url=decodeURIComponent(new URL(req.url,'http://localhost').pathname);
let file=path.resolve(root,'.'+url);
if(url==='/model.stl'||url==='/model')file=path.join(root,'runtime','vehicle_model.stl');
if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){
res.writeHead(404);res.end();return;
}
const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.stl':'model/stl'};
res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream');
fs.createReadStream(file).pipe(res);
});
async function main(){
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage({viewport:{width:1080,height:720}});
const errors=[];page.on('pageerror',error=>errors.push(error.message));
await page.addInitScript(()=>{
const clone=value=>JSON.parse(JSON.stringify(value));
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]};
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
window.pywebview={api:{
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true,
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
disconnect:async()=>{Object.assign(state,{connected:false,armed_known:false,app_state:'disconnected'});return true},
reverse_motor:async servo=>{calls.reverse++;if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>state.servos[servo].direction*=-1,650);return true},
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}},
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
}};
});
await page.goto('http://127.0.0.1:'+server.address().port+'/web/index.html');
await page.waitForFunction(()=>bridgeReady);
const openLink=()=>page.locator('[data-open="link-modal"]').click();
const close=async id=>{await page.locator('#'+id+' [data-close]').first().click();await page.waitForFunction(id=>!document.getElementById(id).open,id)};
await openLink();
await page.locator('#baud').selectOption('115200');
await page.locator('#link-modal').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await openLink();assert.equal(await page.locator('#baud').inputValue(),'57600');
await page.locator('#custom-port-input').fill('COM42');
await page.locator('#custom-port-btn').click();
assert(await page.locator('#link-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#baud').selectOption('115200');
assert(await page.locator('#link-form [type="submit"]').evaluate(el=>{el.click();return el.disabled}));
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await page.locator('#refresh').click();assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#connect').click();
await page.waitForFunction(()=>testBridge.calls.connect.length===1);
assert.equal(await page.evaluate(()=>testBridge.calls.connect[0].baud),115200);
await page.locator('[data-open="battery-modal"]').click();
await page.locator('#battery-form [name="cells"]').fill('5');
await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'4');
await page.evaluate(()=>testBridge.failBattery=true);
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.locator('#battery-form .form-feedback').waitFor({state:'visible'});
assert(await page.locator('#battery-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#battery-form .form-feedback').textContent(),'Battery save failed');
await page.evaluate(()=>testBridge.failBattery=false);
await page.locator('#battery-form [name="cells"]').fill('5');
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'5');
await page.screenshot({path:path.join(output,'battery.png')});await close('battery-modal');
await page.locator('[data-open="motors-modal"]').click();
assert.equal(await page.locator('#motor-list > .motor-row').count(),3);
await page.locator('#motor-list > .motor-row').first().getByRole('button').click();
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Confirming…');
assert(await page.locator('#motor-list > .motor-row').first().getByRole('button').isDisabled());
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Reversed');
await page.evaluate(()=>testBridge.failMotor=true);
await page.locator('#motor-list > .motor-row').first().locator('select').selectOption('2');
await page.locator('#motor-feedback').waitFor({state:'visible'});
assert.equal(await page.locator('#motor-list > .motor-row').first().locator('select').inputValue(),'1');
await page.screenshot({path:path.join(output,'motors.png')});await close('motors-modal');
await page.locator('#link-quality').click();
assert.equal(await page.locator('#quality-detail-rtt').textContent(),'24 ms');
await page.locator('#heavy-quality-run').click();
assert(await page.locator('#heavy-quality-run').isDisabled());
await page.locator('#heavy-quality-cancel').waitFor({state:'visible'});
assert.equal(await page.evaluate(()=>testBridge.calls.heavy),1);
await page.locator('#heavy-quality-cancel').click();
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
await page.locator('#console').click();
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample'));
await page.locator('#console-pause').click();
const paused=await page.locator('#console-rx-output').textContent();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
await page.locator('#console-pause').click();
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
await page.screenshot({path:path.join(output,'console.png')});await close('console-modal');
for(const viewport of [{width:900,height:560},{width:480,height:720}]){
await page.setViewportSize(viewport);
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){
await page.evaluate(id=>openModal(id),id);
const box=await page.locator('#'+id).boundingBox();
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
assert.equal(await page.evaluate(()=>canDrive()),false);
await close(id);
}
}
await page.setViewportSize({width:1080,height:720});await openLink();
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
assert.deepEqual(errors,[]);
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.');
}catch(error){
const page=browser.contexts()[0]?.pages()[0];
if(page){
await page.screenshot({path:path.join(output,'failure.png')});
console.error(await page.evaluate(()=>[...document.querySelectorAll('input')].filter(el=>!el.checkValidity()).map(el=>({name:el.name,value:el.value,error:el.validationMessage}))));
}
throw error;
}finally{await browser.close();await new Promise(resolve=>server.close(resolve))}
}
main().catch(error=>{console.error(error);server.close();process.exitCode=1});
+216 -16
View File
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
link_degraded: 'Signal lost — check tether', link_degraded: 'Signal lost — check tether',
}; };
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null; const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const api=(name,...args)=>window.pywebview.api[name](...args); const api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[]; const activity=[];
let lastReportedStatus=''; let lastReportedStatus='';
@@ -21,13 +21,14 @@ function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{cons
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true, let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750; const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive; window.canDrive=canDrive;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}} function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)} function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
window.showToast=toast; window.showToast=toast;
const manualPorts=new Map();
function populatePorts(ports){ function populatePorts(ports){
const p=$('#port'); const p=$('#port');
if(!p)return; if(!p)return;
@@ -50,6 +51,7 @@ function populatePorts(ports){
}; };
if(Array.isArray(ports)) ports.forEach(addPort); if(Array.isArray(ports)) ports.forEach(addPort);
manualPorts.forEach(addPort);
addPort({device:'demo', label:'Demo Mode (no hardware)'}); addPort({device:'demo', label:'Demo Mode (no hardware)'});
let matched=false; let matched=false;
@@ -94,7 +96,7 @@ function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.ve
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){ function resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer}); if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
@@ -111,28 +113,62 @@ addEventListener('focus',()=>focusChanged(!document.hidden));
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus())); document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
addEventListener('pagehide',()=>focusChanged(false)); addEventListener('pagehide',()=>focusChanged(false));
function setFeedback(el,message=''){
if(!el)return;
el.textContent=message;el.hidden=!message;
}
function openModal(id){ function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id; const el=typeof id==='string'?document.getElementById(id):id;
if(!el)return; if(!el||el.open)return;
resetInputs();
el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='link-modal'){
fillLinkForm();
$('#custom-port-status').textContent='';
$('#custom-port-input').value='';
}
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
el.classList.remove('closing'); el.classList.remove('closing');
if(!el.open)el.showModal(); el.showModal();
} }
function closeModal(dialog){ function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return; if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
dialog.classList.add('closing'); dialog.classList.add('closing');
let done=false; let done=false;
const finish=()=>{ const finish=()=>{
if(done)return; if(done)return;
done=true; done=true;
dialog.removeEventListener('animationend',finish); dialog.removeEventListener('animationend',onEnd);
dialog.classList.remove('closing'); dialog.classList.remove('closing');
dialog.close(); dialog.close();
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
}; };
dialog.addEventListener('animationend',finish,{once:true}); const onEnd=e=>{if(e.target===dialog)finish()};
setTimeout(finish,300); dialog.addEventListener('animationend',onEnd);
setTimeout(finish,180);
} }
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open)); $$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog'))); $$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$('#link-quality').onclick=()=>openModal('link-quality-modal');
let heavyActionBusy=false;
async function requestHeavyTest(action){
if(heavyActionBusy)return;
heavyActionBusy=true;
setFeedback($('#heavy-quality-error'));
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
try{
const test=await api(action);
vehicleSnapshot.heavy_quality=test;
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
finally{
heavyActionBusy=false;
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
}
}
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
$$('dialog').forEach(d=>{ $$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)}); d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{ d.addEventListener('click',e=>{
@@ -255,7 +291,7 @@ $('#connect').onclick=async()=>{
recordActivity(`Connecting to ${port}…`); recordActivity(`Connecting to ${port}…`);
toast(`Connecting to ${port}…`); toast(`Connecting to ${port}…`);
try{ try{
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value); const confirmedState=await api('connect',port,+linkConfig.baud||57600,+linkConfig.control_hz||20);
if(confirmedState)render(confirmedState); if(confirmedState)render(confirmedState);
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known); toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
}catch(e){ }catch(e){
@@ -270,7 +306,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
if(customPortBtn&&customPortInput){ if(customPortBtn&&customPortInput){
const applyCustomPort=()=>{ const applyCustomPort=()=>{
const val=customPortInput.value.trim(); const val=customPortInput.value.trim();
if(!val)return; if(!val){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;}
if(vehicleSnapshot.connected)return;
manualPorts.set(val,{device:val,label:`Custom (${val})`});
const p=$('#port'); const p=$('#port');
let exists=false; let exists=false;
for(const opt of p.options){ for(const opt of p.options){
@@ -280,8 +318,7 @@ if(customPortBtn&&customPortInput){
p.add(new Option(`Custom (${val})`, val), 0); p.add(new Option(`Custom (${val})`, val), 0);
p.value=val; p.value=val;
} }
closeModal($('#link-modal')); $('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
toast(`Selected port: ${val}`);
}; };
customPortBtn.onclick=applyCustomPort; customPortBtn.onclick=applyCustomPort;
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}}; customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
@@ -380,6 +417,34 @@ if(engageButton){
} }
$('#logs').onclick=()=>api('open_logs'); $('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false;
$('#console-pause').onclick=()=>{
consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages';
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true;
try{
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36;
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36;
const sides=await api('read_console',150);
if(consolePaused||!consoleDialog.open)return;
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…';
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…';
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight;
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight;
}catch(error){
$('#console-state').textContent=`Console unavailable: ${error.message}`;
}
finally{consoleRefreshBusy=false}
}
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
setInterval(refreshConsole,750);
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)} function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5); $('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
// Explicit pointer ownership makes the full range rail a reliable touch target // Explicit pointer ownership makes the full range rail a reliable touch target
@@ -391,9 +456,108 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)}); gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain)); ['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}}); gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}}; async function saveSettings(form,method,commit,message){
if(form.dataset.busy==='true'||!form.reportValidity())return;
const data=Object.fromEntries(new FormData(form));
const button=form.querySelector('[type="submit"]'),label=button.textContent;
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
setFeedback(form.querySelector('.form-feedback'));
let saved=false;
try{commit(await api(method,data));saved=true;toast(message)}
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
finally{
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
if(saved)closeModal(form.closest('dialog'));
}
}
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}} function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}} $('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
const motorEdits=new Map();
async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{};
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
setFeedback($('#motor-feedback'));
if(demoActive){
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
else s.direction=expected;
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
}
motorEdits.set(servo,{kind,expected,started:performance.now()});
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
try{
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
}catch(error){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
}
}
function renderMotors(servos){
for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
}
}
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length;
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
if(signature===servoSignature)return;
servoSignature=signature;
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
for(let i=1;i<=16;i++){
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
const select=row.querySelector('select');
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
select.value=String(s.motor||0);
select.disabled=!editable||!!pending||s.function==null;
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
reverse.onclick=()=>requestMotorEdit(i,'direction');
(s.motor?list:other).append(row);
}
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
if(assigned<16)list.append(other);
}
function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
if(!status||!results||!run||!stop)return;
const quality=demoActive?null:vehicleSnapshot.link_quality;
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
const running=!!test?.running;
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
stop.hidden=!running;
stop.disabled=heavyActionBusy;
results.replaceChildren();
for(const row of test?.results||[]){
const line=document.createElement('div');line.className='heavy-result-row';
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
const stats=document.createElement('span');
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
line.append(rate,stats);results.append(line);
}
}
function render(s,confirmed=false){ function render(s,confirmed=false){
vehicleSnapshot=s; vehicleSnapshot=s;
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error || if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
@@ -402,10 +566,13 @@ function render(s,confirmed=false){
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
document.body.dataset.appState=appState; document.body.dataset.appState=appState;
document.body.dataset.controlsEnabled=String(view.drive); document.body.dataset.controlsEnabled=String(view.drive);
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale); document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected; $('#port').disabled=!!s.connected;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
const isOnline=(appState==='connected_disarmed'||appState==='armed'); const isOnline=(appState==='connected_disarmed'||appState==='armed');
const connectBtn=$('#connect'); const connectBtn=$('#connect');
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null; const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
@@ -456,6 +623,37 @@ function render(s,confirmed=false){
$('#battery-percent').textContent='waiting'; $('#battery-percent').textContent='waiting';
$('#battery-fill').style.width='0'; $('#battery-fill').style.width='0';
} }
const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){
if(demoActive){
qualityCard.dataset.grade='offline';
$('#link-quality-grade').textContent='Demo';
$('#link-quality-roundtrip').textContent='no hardware link test';
$('#link-quality-downlink').textContent='';
renderMotors(s.servos||{});
return;
}
const grade=quality?.grade||(s.connected?'testing':'offline');
qualityCard.dataset.grade=grade;
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
if(quality&&quality.probe_samples){
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
const downLoss=percent(quality.rx_sequence_loss_percent);
const badFrames=quality.rx_corrupt_frames_20s??0;
const badBytes=quality.rx_corrupt_bytes_20s??0;
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
if(quality.probe_samples<10||quality.rx_packets_30s<10){
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
}
}else{
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
$('#link-quality-downlink').textContent='';
}
}
renderMotors(s.servos||{}); renderMotors(s.servos||{});
} }
@@ -564,8 +762,10 @@ async function boot(){
populatePorts(await withTimeout(api('refresh_ports'),4000)); populatePorts(await withTimeout(api('refresh_ports'),4000));
const init=await withTimeout(api('initial_state'),4000); const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{}; batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{};
populatePorts(init.ports); populatePorts(init.ports);
fillBatteryForm(); fillBatteryForm();
fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now(); bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state); if(!demoActive)render(init.state);
poll(); poll();
+62 -6
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20260827-model-layout-v14"> <link rel="stylesheet" href="styles.css?v=20260930-dialogs-v1">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
@@ -50,13 +50,19 @@
<b><em id="battery-fill"></em></b> <b><em id="battery-fill"></em></b>
</button> </button>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
</button>
<div class="deck-status-bar"> <div class="deck-status-bar">
<ol class="activity-history" aria-label="Last three events" aria-live="polite"> <ol class="activity-history" aria-label="Last three events" aria-live="polite">
<li id="activity-oldest" hidden></li> <li id="activity-oldest" hidden></li>
<li id="activity-previous" hidden></li> <li id="activity-previous" hidden></li>
<li id="status-text">Not connected</li> <li id="status-text">Not connected</li>
</ol> </ol>
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header> <header class="activity-heading"><span>Recent activity</span><span class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></span></header>
</div> </div>
</section> </section>
@@ -146,14 +152,64 @@
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<!-- Modals --> <!-- Modals -->
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog> <dialog id="link-modal" class="modal" aria-labelledby="link-title">
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog> <button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link settings</h2>
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog> <form id="link-form">
<section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<p class="note">Baud changes apply on the next connection.</p>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p>
</section>
<section class="settings-section"><h3>Live updates</h3>
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
</div>
<p class="note">Lower rates leave more bandwidth for controls. Vehicle telemetry rates reset after a reboot.</p>
</section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form>
</dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="quality-title">Link quality</h2>
<p class="note">The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.</p>
<div class="quality-summary"><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3>Extended test</h3><p class="note">Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.</p>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Power</small><h2 id="battery-title-modal">Battery settings</h2><p class="note">Charge is estimated from voltage. Capacity and C rating describe your battery.</p>
<form id="battery-form">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Configuration</small><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="console-modal" class="modal console-modal" aria-labelledby="console-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Local session</small><h2 id="console-title">Live console</h2>
<div class="console-toolbar"><p id="console-state" class="note" role="status">Live · follows new messages</p><button id="console-pause" type="button" class="button subtle" aria-pressed="false">Pause</button></div>
<div class="console-split"><section><header><i class="rx-dot"></i><span>RX</span><small>Vehicle → PC</small></header><pre id="console-rx-output" tabindex="0" aria-label="Received messages">Waiting for vehicle data…</pre></section><section><header><i class="tx-dot"></i><span>TX</span><small>PC → Vehicle</small></header><pre id="console-tx-output" tabindex="0" aria-label="Sent messages">Waiting for transmitted data…</pre></section></div>
</dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script> <script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script> <script src="joystick-3d.js?v=20260827-keyboard-v7"></script>
<script src="control-state.js?v=20260827-state-v1"></script> <script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260827-keyboard-v7"></script> <script src="app.js?v=20260930-dialogs-v1"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script> <script src="cursor-light.js?v=20260827-directional-v10"></script>
</body> </body>
</html> </html>
+167
View File
@@ -539,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
margin-left: auto; margin-left: auto;
} }
.logs-link:hover { color: #f1f4f2; } .logs-link:hover { color: #f1f4f2; }
.activity-actions { display:flex; align-items:center; gap:12px; }
/* ══════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════
PRIMARY CONTROL ZONE (Workspace) PRIMARY CONTROL ZONE (Workspace)
@@ -554,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
min-width: 0; min-width: 0;
position: relative; position: relative;
} }
/* ── Active MAVLink link test ── */
.link-quality {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
min-height: 48px;
padding: 3px 0;
color: #eef2f0;
width: 100%;
border: 0;
text-align: left;
font: inherit;
background: transparent;
cursor: pointer;
border-radius: 9px;
transition: background-color .18s ease;
}
.link-quality:hover,.link-quality:focus-visible { background:rgba(255,255,255,.055); outline:none; }
.link-quality-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #667074;
}
.link-quality span:not(.link-quality-dot) { display:flex; flex-direction:column; gap:1px; min-width:0; }
.link-quality small { color:#8d979a; font-size:10px; font-weight:400; }
.link-quality strong { font-size:14px; font-weight:500; line-height:1.15; }
.link-quality i {
grid-column: 2;
min-width: 0;
color: #aab2b4;
font-size: 11px;
line-height: 1.3;
font-style: normal;
white-space: normal;
overflow-wrap: anywhere;
}
.link-quality i span { display:block; }
.link-quality[data-grade="excellent"] .link-quality-dot { background:#bce96c; box-shadow:0 0 8px rgba(188,233,108,.35); }
.link-quality[data-grade="good"] .link-quality-dot { background:#85c7e8; }
.link-quality[data-grade="fair"] .link-quality-dot { background:#e7bf69; }
.link-quality[data-grade="poor"] .link-quality-dot { background:#f07f78; box-shadow:0 0 8px rgba(240,127,120,.3); }
.link-quality[data-grade="testing"] .link-quality-dot { background:#aab3b6; }
.quality-modal { width:min(520px,calc(100vw - 32px)); }
.heavy-test-panel { margin-top:18px; padding-top:14px; border-top:1px solid rgba(128,145,145,.2); }
.heavy-quality-status { font-size:14px; font-weight:500; }
.heavy-quality-results { display:grid; gap:6px; margin-top:12px; }
.heavy-result-row { display:flex; justify-content:space-between; gap:12px; align-items:baseline; padding:7px 10px; border-radius:10px; background:rgba(120,140,140,.08); font-size:12px; }
.heavy-result-row strong { flex:none; }
.heavy-result-row span { text-align:right; color:var(--muted); }
.primary-control-zone { .primary-control-zone {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
@@ -977,6 +1031,37 @@ body[data-telemetry-stale="true"] #battery-fill { opacity:.35; }
border-bottom: 1.5px solid #c8cec6; border-bottom: 1.5px solid #c8cec6;
} }
.console-modal {
width: min(1040px, calc(100vw - 64px));
max-width: 1040px;
}
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
.console-split section { min-width:0; }
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
.console-split header .rx-dot { background:#79c8eb; }
.console-split header .tx-dot { background:#bce96c; }
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
.console-split pre {
height: min(55vh, 480px);
margin: 0;
padding: 14px;
overflow: auto;
border: 1px solid #d9ddda;
border-radius: 12px;
background: #171b1d;
color: #dce4e1;
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
@media (max-width: 760px) {
.console-split { grid-template-columns:1fr; }
.console-split pre { height:220px; }
}
/* ── Toast ── */ /* ── Toast ── */
.toast { .toast {
position: fixed; position: fixed;
@@ -1131,3 +1216,85 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
.telemetry-zone { gap:12px; } .telemetry-zone { gap:12px; }
.activity-history { gap:4px; } .activity-history { gap:4px; }
} }
/* Settings windows: readable fields, predictable spacing, one action hierarchy. */
.modal {
color-scheme: light;
width: min(560px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 28px;
overflow: auto;
overscroll-behavior: contain;
border: 1px solid #dedfdf;
border-radius: 20px;
background: #f6f6f6;
box-shadow: 0 24px 80px rgba(0,0,0,.24);
backdrop-filter: none;
}
.modal[open] { animation: dialog-in .16s ease-out; }
.modal.closing { animation: dialog-out .12s ease-in forwards; }
.modal::backdrop { background: rgba(15,19,22,.4); backdrop-filter: blur(3px); }
.modal > small { display:block; color:#73787b; font-size:12px; font-weight:400; margin:0 42px 6px 0; }
.modal h2 { margin:0 40px 18px 0; font-size:28px; letter-spacing:-.025em; font-weight:400; }
.modal h3 { margin:0 0 10px; font-size:14px; font-weight:500; color:#454b4e; }
.modal .note { font-size:13px; line-height:1.5; margin:8px 0 0; color:#676e71; }
.modal-close { top:16px; right:16px; width:36px; height:36px; display:grid; place-items:center; border-radius:50%; background:#e9ebec; color:#555c60; font-size:25px; line-height:1; }
.modal-close:hover { background:#dfe3e5; }
.settings-section { margin-top:22px; }
.settings-section + .settings-section { border-top:1px solid #e0e3e4; padding-top:20px; }
.modal .form-grid { gap:16px; margin-top:10px; }
.modal .form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
.modal label { min-width:0; font-size:13px; line-height:1.35; font-weight:400; color:#50585c; }
.modal label > span { display:block; margin-bottom:7px; }
.modal input,.modal select { width:100%; min-width:0; height:42px; padding:0 13px; border:1px solid #d7dcde; border-radius:999px; background:#fff; color:#272c2f; font-size:14px; font-weight:400; }
.modal select { padding-right:28px; }
.modal :is(button,input,select,summary,pre):focus-visible { outline:2px solid #6598b5; outline-offset:3px; }
.modal .number-pill { height:42px; grid-template-columns:36px minmax(0,1fr) 36px; background:#fff; border:1px solid #d7dcde; }
.modal .number-pill button { width:36px; height:40px; font-size:24px; color:#5b666b; }
.modal .number-pill input { border:0; padding:0; height:40px; appearance:textfield; -moz-appearance:textfield; }
.modal .number-pill input::-webkit-inner-spin-button { appearance:none; }
.modal .number-pill:focus-within { border-color:#6598b5; }
.custom-port-label { display:block; margin-top:16px; }
.custom-port-row { display:flex; gap:8px; }
.custom-port-row input { flex:1; }
.modal .button { min-height:42px; height:auto; padding:10px 18px; font-size:14px; font-weight:400; border-radius:999px; white-space:normal; }
.modal .button.subtle { background:#e9ebec; border:0; color:#424b50; }
.modal .modal-primary { background:#333e44; color:#fff; border:0; }
.modal .modal-primary:hover { background:#455660; }
.modal .modal-actions { margin-top:24px; gap:10px; align-items:center; }
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
.modal [hidden] { display:none !important; }
.modal [data-busy="true"] { cursor:progress; }
.motor-modal { width:min(650px,calc(100vw - 32px)); }
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
.motor-row strong { font-size:12px; font-weight:500; }
.motor-row > span { font-size:12px; color:#727b80; }
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
.motor-other { margin-top:16px; }
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
.quality-summary div { border-radius:14px; background:#e9ebec; padding:14px 12px; min-width:0; }
.quality-summary span { display:block; font-size:12px; color:#697278; margin-bottom:8px; }
.quality-summary strong { font-size:20px; font-weight:400; font-variant-numeric:tabular-nums; }
.modal .heavy-quality-status { padding:12px; background:#e9ebec; border-radius:12px; font-size:14px; margin-top:14px; }
.modal .heavy-result-row { font-size:13px; padding:10px 12px; font-variant-numeric:tabular-nums; }
.console-modal { width:min(1020px,calc(100vw - 32px)); }
.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:12px; }
.console-toolbar .note { margin:0; }
.modal .console-split header small { font-size:12px; }
.modal .console-split pre { height:clamp(180px,48vh,430px); padding:14px; font-size:12px; line-height:1.55; border-radius:12px; }
@keyframes dialog-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
@keyframes dialog-out { to { opacity:0; transform:translateY(5px); } }
@media (max-width:520px) {
.modal { padding:22px 18px; }
.modal .form-grid.two { grid-template-columns:1fr; }
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
.modal .motor-row .button { padding:8px 6px; }
.quality-summary { gap:6px; }
.quality-summary div { padding:12px 8px; }
.quality-summary strong { font-size:17px; }
}
@media (prefers-reduced-motion:reduce) {
.modal[open],.modal.closing { animation:none; }
}
+64 -3
View File
@@ -40,12 +40,15 @@ class ControlApi:
"app_state": "disconnected", "app_state": "disconnected",
"status": "Ready. Select a USB port to begin.", "status": "Ready. Select a USB port to begin.",
"battery": None, "battery": None,
"link_quality": None,
"heavy_quality": None,
"log_path": None, "log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)}, "servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
} }
self.servo_functions: dict[int, int] = {} self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {} self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json" self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json" self.model_path = Path(__file__).resolve().parent / "model_settings.json"
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl" self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt" self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
@@ -62,11 +65,22 @@ class ControlApi:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8"))) self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
pass pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self._apply_link_config()
def initial_state(self): def initial_state(self):
return { return {
"ports": self.refresh_ports(), "ports": self.refresh_ports(),
"battery_config": self.battery_config, "battery_config": self.battery_config,
"link_config": self.link_config,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(), "model_info": self.get_saved_model_info(),
} }
@@ -233,6 +247,9 @@ class ControlApi:
self.state.update( self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED", connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.", armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
link_quality={"grade": "excellent", "latency_ms": 12.0,
"loss_percent": 0.0, "samples": 20,
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72, battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False}, "cells": 4, "low": False},
) )
@@ -268,15 +285,26 @@ class ControlApi:
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds") raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): def disconnect(self):
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode: if self.demo_mode:
self.demo_mode = False self.demo_mode = False
with self.lock: with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, status="Demo mode disconnected") armed=False, mode=None, battery=None, link_quality=None,
status="Demo mode disconnected")
return True return True
self.worker.disconnect() self.worker.disconnect()
return True return True
def start_heavy_quality_test(self):
if self.demo_mode:
raise RuntimeError("The heavy link test requires a real flight-controller connection")
return self.worker.start_heavy_quality_test()
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def set_axes(self, forward, turn, vertical, sequence=None): def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode: if self.demo_mode:
with self.lock: with self.lock:
@@ -389,8 +417,33 @@ class ControlApi:
raise ValueError("Full voltage per cell must be greater than empty voltage") raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100: if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 100") raise ValueError("Warning percentage must be between 0 and 100")
self.battery_config = clean
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8") self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.battery_config = clean
return clean
def _apply_link_config(self):
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
})
def save_link_settings(self, config):
allowed = {0, 1, 2, 5, 10}
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
raise ValueError("Choose a supported baud rate")
if clean["control_hz"] not in {10, 20}:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
self._apply_link_config()
return clean return clean
def open_logs(self): def open_logs(self):
@@ -399,6 +452,10 @@ class ControlApi:
os.startfile(path) os.startfile(path)
return True return True
def read_console(self, limit=150):
"""Return a bounded, read-only view of the local session console."""
return self.worker.read_console(limit)
def poll(self): def poll(self):
# Bound a poll even if telemetry arrives continuously. # Bound a poll even if telemetry arrives continuously.
with self._poll_lock: with self._poll_lock:
@@ -468,6 +525,10 @@ class ControlApi:
"voltage": voltage, "per_cell": per_cell, "percent": percent, "voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"], "cells": cells, "low": percent <= self.battery_config["warning_percent"],
} }
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
self.state["heavy_quality"] = event[1]
elif kind == "servo_param": elif kind == "servo_param":
_, param_id, value = event _, param_id, value = event
servo = int(param_id[5:param_id.index("_")]) servo = int(param_id[5:param_id.index("_")])
@@ -490,7 +551,7 @@ class ControlApi:
last_err = self.state.get("last_error") last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected" status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
mode=None, status=status_msg) mode=None, link_quality=None, status=status_msg)
self.state["last_error"] = None self.state["last_error"] = None
def _update_servo(self, servo): def _update_servo(self, servo):
+448 -32
View File
@@ -13,6 +13,9 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from tkinter import messagebox, ttk from tkinter import messagebox, ttk
# ESC_TELEMETRY_1_TO_4 uses a MAVLink 2 message ID. Select the v2 dialect
# before importing pymavlink so received ESC packets can be decoded/logged.
os.environ["MAVLINK20"] = "1"
from pymavlink import mavutil from pymavlink import mavutil
from serial.tools import list_ports from serial.tools import list_ports
@@ -67,6 +70,10 @@ class MavlinkWorker:
self.connection = None self.connection = None
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.lock = threading.Lock() self.lock = threading.Lock()
# pymavlink writes are not thread-safe. The web/API thread can issue
# commands while this worker sends heartbeats and controls, so serialize
# every packet before it reaches a low-bandwidth serial link.
self.tx_lock = threading.Lock()
self.thread = None self.thread = None
self.axes = Axes() self.axes = Axes()
self.output_axes = Axes() self.output_axes = Axes()
@@ -87,6 +94,34 @@ class MavlinkWorker:
self.last_rx = 0.0 self.last_rx = 0.0
self.armed = False self.armed = False
self.config_paused = False self.config_paused = False
# Keep configuration traffic off the link during an arm/disarm
# transition, including the wait for the vehicle's confirmation.
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config = deque()
self.next_stream_config_at = 0.0
# Low-rate TIMESYNC echoes measure PC↔FC round trips with minimal traffic.
self.ping_rate_hz = 1
self.heavy_quality_test = None
self.heavy_pending_pings = {}
self.heavy_quality_latest = None
self.telemetry_rates_hz = {
"SYS_STATUS": 1, "SERVO_OUTPUT_RAW": 2,
"RC_CHANNELS": 2, "ESC_TELEMETRY_1_TO_4": 1,
}
self.ping_sequence = 0
self.next_ping_at = 0.0
self.pending_pings = {}
self.ping_results = deque(maxlen=300)
self.bad_frame_times = deque()
self.bad_frame_bytes = deque()
self.rx_sequence_by_source = {}
self.rx_delivery_results = deque(maxlen=1024)
self.next_quality_publish_at = 0.0
self.param_scan = deque() self.param_scan = deque()
self.param_waiting = None self.param_waiting = None
self.param_deadline = 0.0 self.param_deadline = 0.0
@@ -96,6 +131,9 @@ class MavlinkWorker:
self.log_lock = threading.Lock() self.log_lock = threading.Lock()
self.log_file = None self.log_file = None
self.log_path = None self.log_path = None
self.console_lines = deque(maxlen=500)
self.console_rx_lines = deque(maxlen=300)
self.console_tx_lines = deque(maxlen=300)
def connect(self, port: str, baud: int): def connect(self, port: str, baud: int):
print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}") print(f"[MavlinkWorker] connect() called: port={port!r}, baud={baud}")
@@ -109,8 +147,12 @@ class MavlinkWorker:
self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1) self.log_file = self.log_path.open("a", encoding="utf-8", buffering=1)
normalized = normalize_endpoint(port) normalized = normalize_endpoint(port)
print(f"[MavlinkWorker] normalized endpoint: {normalized!r}") print(f"[MavlinkWorker] normalized endpoint: {normalized!r}")
self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} control_hz={self.rate_hz}") self.log("SESSION", f"start port={normalized} (input={port}) baud={baud} "
f"control_hz={self.rate_hz} rx=MAVLink2 tx=MAVLink2")
self.events.put(("log_path", str(self.log_path))) self.events.put(("log_path", str(self.log_path)))
self.last_heartbeat = 0.0
self.last_rx = 0.0
self.next_quality_publish_at = 0.0
self.thread = threading.Thread( self.thread = threading.Thread(
target=self._run, args=(normalized, baud), daemon=True target=self._run, args=(normalized, baud), daemon=True
) )
@@ -137,7 +179,7 @@ class MavlinkWorker:
# Send initial GCS heartbeat immediately # Send initial GCS heartbeat immediately
self.log("TX_HEARTBEAT", "GCS initial") self.log("TX_HEARTBEAT", "GCS initial")
conn.mav.heartbeat_send( self._send_mav(conn, "heartbeat_send",
mavutil.mavlink.MAV_TYPE_GCS, mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID, mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE, 0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
@@ -149,17 +191,32 @@ class MavlinkWorker:
now = time.monotonic() now = time.monotonic()
if now >= next_gcs_hb: if now >= next_gcs_hb:
self.log("TX_HEARTBEAT", "GCS") self.log("TX_HEARTBEAT", "GCS")
conn.mav.heartbeat_send( self._send_mav(conn, "heartbeat_send",
mavutil.mavlink.MAV_TYPE_GCS, mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID, mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE, 0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
) )
next_gcs_hb = now + 1.0 next_gcs_hb = now + 1.0
if self.target_system and now >= next_control: # ArduSub cannot use MANUAL_CONTROL while disarmed. Keeping a
# 20 Hz neutral stream alive here only competes with ARM and
# its acknowledgement on constrained links.
if self.target_system and self.armed and now >= next_control:
self._send_control() self._send_control()
next_control = now + (1.0 / max(1, self.rate_hz)) # The first disarm packet is preceded by a neutral command.
# Until disarm is confirmed, keep sending neutral at a
# lower rate instead of consuming 20 Hz on the weak link.
control_hz = min(self.rate_hz, 5) if now < self.disarm_requested_until else self.rate_hz
next_control = now + (1.0 / max(1, control_hz))
elif not self.armed:
next_control = now
if self.target_system: if self.target_system:
self._tick_arm_retry(now)
self._tick_link_probe(now)
self._tick_stream_config(now)
self._tick_param_scan(now) self._tick_param_scan(now)
if now >= self.next_quality_publish_at:
self._publish_link_quality()
self.next_quality_publish_at = now + 1.0
msg = conn.recv_match(blocking=False) msg = conn.recv_match(blocking=False)
if msg: if msg:
@@ -170,6 +227,8 @@ class MavlinkWorker:
print(f"[MavlinkWorker._run] EXCEPTION: {exc}") print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
self.events.put(("error", f"Connection error: {exc}")) self.events.put(("error", f"Connection error: {exc}"))
finally: finally:
if self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
self._safe_stop() self._safe_stop()
if self.connection: if self.connection:
try: try:
@@ -180,6 +239,19 @@ class MavlinkWorker:
self.target_system = 0 self.target_system = 0
self.armed = False self.armed = False
self.config_paused = False self.config_paused = False
self.command_quiet_until = 0.0
self.disarm_requested_until = 0.0
self.pending_arm_state = None
self.next_arm_retry_at = 0.0
self.arm_retry_deadline = 0.0
self.arm_confirmation = 0
self.stream_config.clear()
self.pending_pings.clear()
self.ping_results.clear()
self.bad_frame_times.clear()
self.bad_frame_bytes.clear()
self.rx_sequence_by_source.clear()
self.rx_delivery_results.clear()
self.log("SESSION", "disconnected") self.log("SESSION", "disconnected")
with self.log_lock: with self.log_lock:
if self.log_file: if self.log_file:
@@ -189,22 +261,71 @@ class MavlinkWorker:
def log(self, category: str, message: str): def log(self, category: str, message: str):
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds") timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
line = f"{timestamp} [{category}] {message}"
with self.log_lock: with self.log_lock:
self.console_lines.append(line[:2000])
if category.startswith("RX") or category == "ONBOARD_LOG_ERROR":
self.console_rx_lines.append(line[:2000])
elif category.startswith("TX") or category == "OPERATOR":
self.console_tx_lines.append(line[:2000])
if self.log_file: if self.log_file:
self.log_file.write(f"{timestamp} [{category}] {message}\n") self.log_file.write(line + "\n")
def read_console(self, limit=150):
with self.log_lock:
limit = max(1, min(300, int(limit)))
return {
"rx": list(self.console_rx_lines)[-limit:],
"tx": list(self.console_tx_lines)[-limit:],
}
def _send_mav(self, connection, method: str, *args):
"""Serialize MAVLink 2 writes and protect the start of each burst."""
with self.tx_lock:
# This full-duplex module pair loses the opening bytes of some
# PC-to-FC bursts after the line has been idle. Put a valid,
# expendable GCS heartbeat first so the following packet remains
# intact and the FC also sees a healthy GCS heartbeat stream.
if method != "heartbeat_send" and hasattr(connection.mav, "heartbeat_send"):
connection.mav.heartbeat_send(
mavutil.mavlink.MAV_TYPE_GCS,
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
)
getattr(connection.mav, method)(*args)
def _handle_message(self, msg): def _handle_message(self, msg):
msg_type = msg.get_type() msg_type = msg.get_type()
if msg_type == "BAD_DATA": if msg_type == "BAD_DATA":
now = time.monotonic()
self.bad_frame_times.append(now)
self.bad_frame_bytes.append((now, len(getattr(msg, "data", b""))))
self._trim_bad_frames(now)
self.log("RX_BAD_DATA", repr(getattr(msg, "data", b""))) self.log("RX_BAD_DATA", repr(getattr(msg, "data", b"")))
return return
self.last_rx = time.monotonic() self.last_rx = time.monotonic()
self._track_rx_delivery(msg)
try: try:
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":")) payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
except Exception: except Exception:
payload = str(msg) payload = str(msg)
self.log("RX", payload) self.log("RX", payload)
if msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS: if msg_type == "TIMESYNC" and int(getattr(msg, "tc1", 0)) != 0:
token = int(getattr(msg, "ts1", -1))
heavy_probe = self.heavy_pending_pings.pop(token, None)
if heavy_probe is not None:
stage_index, sent_at = heavy_probe
self._record_heavy_probe(stage_index, (time.monotonic() - sent_at) * 1000.0)
self._publish_heavy_quality()
sent_at = None
else:
sent_at = self.pending_pings.pop(token, None)
if sent_at is not None:
rtt_ms = (time.monotonic() - sent_at) * 1000.0
self.ping_results.append((time.monotonic(), rtt_ms))
self.log("RX_LINK_PROBE", f"timesync={token} rtt_ms={rtt_ms:.1f}")
self._publish_link_quality()
elif msg_type == "HEARTBEAT" and getattr(msg, "type", 0) != mavutil.mavlink.MAV_TYPE_GCS:
if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID: if getattr(msg, "autopilot", mavutil.mavlink.MAV_AUTOPILOT_INVALID) == mavutil.mavlink.MAV_AUTOPILOT_INVALID:
return return
if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component): if self.target_system and (msg.get_srcSystem(), msg.get_srcComponent()) != (self.target_system, self.target_component):
@@ -216,6 +337,10 @@ class MavlinkWorker:
armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) armed = bool(msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED)
self.armed = armed self.armed = armed
self.config_paused = armed self.config_paused = armed
if self.pending_arm_state is not None and armed == self.pending_arm_state:
self.pending_arm_state = None
if not armed:
self.disarm_requested_until = 0.0
try: try:
mode = mavutil.mode_string_v10(msg) mode = mavutil.mode_string_v10(msg)
except Exception: except Exception:
@@ -224,6 +349,7 @@ class MavlinkWorker:
ap_name = autopilot.name if autopilot else str(msg.autopilot) ap_name = autopilot.name if autopilot else str(msg.autopilot)
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat)) self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
if first: if first:
self.next_ping_at = time.monotonic()
self.events.put(("status", "Heartbeat received; controls are neutral.")) self.events.put(("status", "Heartbeat received; controls are neutral."))
self._configure_low_bandwidth_streams() self._configure_low_bandwidth_streams()
self._start_servo_scan() self._start_servo_scan()
@@ -232,12 +358,21 @@ class MavlinkWorker:
return return
result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result) result = mavutil.mavlink.enums["MAV_RESULT"].get(msg.result)
result_name = result.name if result else str(msg.result) result_name = result.name if result else str(msg.result)
if int(msg.command) == mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL:
self.log("RX_STREAM_CONFIG_ACK", result_name)
return
if (int(msg.command) == mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM
and result_name not in ("MAV_RESULT_ACCEPTED", "MAV_RESULT_IN_PROGRESS")):
self.pending_arm_state = None
self.events.put(("ack", int(msg.command), result_name, self.last_rx)) self.events.put(("ack", int(msg.command), result_name, self.last_rx))
elif msg_type == "STATUSTEXT": elif msg_type == "STATUSTEXT":
text = msg.text text = msg.text
if isinstance(text, bytes): if isinstance(text, bytes):
text = text.decode(errors="replace") text = text.decode(errors="replace")
self.events.put(("statustext", text.rstrip("\x00"))) text = text.rstrip("\x00")
if "/APM/LOGS" in text and "Failed" in text:
self.log("ONBOARD_LOG_ERROR", text)
self.events.put(("statustext", text))
elif msg_type == "SYS_STATUS": elif msg_type == "SYS_STATUS":
voltage_mv = int(msg.voltage_battery) voltage_mv = int(msg.voltage_battery)
if 0 < voltage_mv < 65535: if 0 < voltage_mv < 65535:
@@ -264,6 +399,239 @@ class MavlinkWorker:
self.events.put(("motor_direction", int(parts[1]), self.events.put(("motor_direction", int(parts[1]),
int(round(msg.param_value)))) int(round(msg.param_value))))
def _trim_bad_frames(self, now):
while self.bad_frame_times and now - self.bad_frame_times[0] > 20.0:
self.bad_frame_times.popleft()
def _track_rx_delivery(self, msg):
"""Estimate downlink loss from MAVLink's per-source packet sequence."""
try:
source = (int(msg.get_srcSystem()), int(msg.get_srcComponent()))
sequence = int(msg.get_seq())
except (AttributeError, TypeError, ValueError):
return
if self.target_system and source != (self.target_system, self.target_component):
return
previous = self.rx_sequence_by_source.get(source)
self.rx_sequence_by_source[source] = sequence
if previous is None:
return
missing = (sequence - previous - 1) & 0xFF
# A large backwards jump is a reset/reordered packet, not 200 losses.
if missing > 127:
return
timestamp = time.monotonic()
self.rx_delivery_results.extend((timestamp, False) for _ in range(missing))
self.rx_delivery_results.append((timestamp, True))
def _publish_link_quality(self):
now = time.monotonic()
self._trim_bad_frames(now)
while self.ping_results and now - self.ping_results[0][0] > 30.0:
self.ping_results.popleft()
while self.rx_delivery_results and now - self.rx_delivery_results[0][0] > 30.0:
self.rx_delivery_results.popleft()
while self.bad_frame_bytes and now - self.bad_frame_bytes[0][0] > 20.0:
self.bad_frame_bytes.popleft()
samples = [sample for _, sample in self.ping_results]
received = [sample for sample in samples if sample is not None]
total = len(samples)
loss_percent = 100.0 * (total - len(received)) / total if total else None
ordered_rtt = sorted(received)
latency_ms = (sum(ordered_rtt) / len(ordered_rtt)) if ordered_rtt else None
median_rtt_ms = ordered_rtt[len(ordered_rtt) // 2] if ordered_rtt else None
p95_rtt_ms = ordered_rtt[min(len(ordered_rtt) - 1, (len(ordered_rtt) * 95 + 99) // 100 - 1)] if ordered_rtt else None
rx_received = sum(1 for _, delivered in self.rx_delivery_results if delivered)
rx_missing = sum(1 for _, delivered in self.rx_delivery_results if not delivered)
rx_loss_percent = 100.0 * rx_missing / (rx_received + rx_missing) if rx_received else None
rx_corrupt_bytes = sum(byte_count for _, byte_count in self.bad_frame_bytes)
heartbeat_age = (now - self.last_heartbeat) if self.last_heartbeat else None
if total < 10 or rx_received < 10 or heartbeat_age is None:
grade = "testing"
elif (heartbeat_age > 3 or loss_percent > 15
or (rx_loss_percent is not None and rx_loss_percent > 5)
or len(self.bad_frame_bytes) >= 5):
grade = "poor"
elif (loss_percent > 3 or (rx_loss_percent is not None and rx_loss_percent > 1)
or (p95_rtt_ms is not None and p95_rtt_ms > 500) or self.bad_frame_bytes):
grade = "fair"
elif (loss_percent > 0.5 or (rx_loss_percent is not None and rx_loss_percent > 0.2)
or (p95_rtt_ms is not None and p95_rtt_ms > 200)):
grade = "good"
else:
grade = "excellent"
self.events.put(("link_quality", {
"grade": grade,
"latency_ms": round(latency_ms, 1) if latency_ms is not None else None,
"rtt_median_ms": round(median_rtt_ms, 1) if median_rtt_ms is not None else None,
"rtt_p95_ms": round(p95_rtt_ms, 1) if p95_rtt_ms is not None else None,
"loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_loss_percent": round(loss_percent, 1) if loss_percent is not None else None,
"probe_samples": total,
"probe_replies": len(received),
"rx_packets_30s": rx_received,
"rx_sequence_loss_percent": round(rx_loss_percent, 1) if rx_loss_percent is not None else None,
"rx_corrupt_frames_20s": len(self.bad_frame_bytes),
"rx_corrupt_bytes_20s": rx_corrupt_bytes,
"samples": total,
"corrupt_frames_20s": len(self.bad_frame_times),
"heartbeat_age_s": round(heartbeat_age, 1) if heartbeat_age is not None else None,
"probe_supported": total > 0,
}))
def _tick_link_probe(self, now):
if self.heavy_quality_test is not None:
self._tick_heavy_quality(now)
return
expired = [sequence for sequence, sent_at in self.pending_pings.items()
if now - sent_at >= 2.5]
for sequence in expired:
self.pending_pings.pop(sequence, None)
self.ping_results.append((now, None))
self.log("LINK_PROBE_TIMEOUT", f"timesync={sequence}")
if expired:
self._publish_link_quality()
if (now < self.next_ping_at or now < self.command_quiet_until
or not self.connection or not self.target_system):
return
token = time.monotonic_ns()
self.pending_pings[token] = now
self.log("TX_LINK_PROBE", f"timesync={token}")
# ArduPilot implements TIMESYNC even on builds that omit PING. A
# request has tc1=0; the response mirrors our unique ts1 token.
self._send_mav(self.connection, "timesync_send", 0, token)
self.next_ping_at = now + (1.0 / max(1, self.ping_rate_hz))
def start_heavy_quality_test(self):
if not self.connection or not self.target_system:
raise RuntimeError("Connect to the flight controller before testing the link")
if self.armed:
raise RuntimeError("The heavy link test is available while the vehicle is disarmed")
if self.pending_arm_state is not None or time.monotonic() < self.command_quiet_until:
raise RuntimeError("Wait for the current vehicle command to finish")
if self.heavy_quality_test is not None:
raise RuntimeError("A heavy link test is already running")
self.pending_pings.clear()
self.heavy_pending_pings.clear()
self.heavy_quality_latest = {
"running": True, "stage": 1, "stages": 3, "rate_hz": 1,
"status": "Running", "results": [],
}
self.heavy_quality_test = {
"rates": (1, 5, 10), "duration_s": 10.0, "stage_index": 0,
"stage_started": time.monotonic(), "next_probe_at": time.monotonic(),
"stages": [{"rate_hz": rate, "sent": 0, "replies": 0,
"timeouts": 0, "rtts": []} for rate in (1, 5, 10)],
}
self.log("TX_HEAVY_TEST", "started rates=1,5,10Hz stage_seconds=10")
self._publish_heavy_quality()
return self.heavy_quality_latest
def cancel_heavy_quality_test(self):
if self.heavy_quality_test is None:
return self.heavy_quality_latest
self.heavy_pending_pings.clear()
test = self.heavy_quality_test
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 0, "stages": len(test["rates"]),
"rate_hz": None, "status": "Cancelled", "results": self._heavy_results(test),
}
self.next_ping_at = time.monotonic()
self.log("TX_HEAVY_TEST", "cancelled")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return self.heavy_quality_latest
@staticmethod
def _heavy_results(test):
results = []
for stage in test["stages"]:
rtts = sorted(stage["rtts"])
sent = stage["sent"]
replies = stage["replies"]
resolved = replies + stage["timeouts"]
results.append({
"rate_hz": stage["rate_hz"], "sent": sent, "replies": replies,
"loss_percent": round(100.0 * stage["timeouts"] / resolved, 1) if resolved else None,
"median_rtt_ms": round(rtts[len(rtts) // 2], 1) if rtts else None,
"p95_rtt_ms": round(rtts[min(len(rtts) - 1, (len(rtts) * 95 + 99) // 100 - 1)], 1) if rtts else None,
})
return results
def _record_heavy_probe(self, stage_index, rtt_ms=None):
test = self.heavy_quality_test
if test is None or not 0 <= stage_index < len(test["stages"]):
return
stage = test["stages"][stage_index]
if rtt_ms is None:
stage["timeouts"] += 1
else:
stage["replies"] += 1
stage["rtts"].append(rtt_ms)
def _publish_heavy_quality(self):
test = self.heavy_quality_test
if test is None:
return
index = test["stage_index"]
stage = test["stages"][index]
self.heavy_quality_latest = {
"running": True, "stage": index + 1, "stages": len(test["rates"]),
"rate_hz": stage["rate_hz"], "sent": stage["sent"],
"replies": stage["replies"], "timeouts": stage["timeouts"],
"status": f"Stage {index + 1} of {len(test['rates'])}: {stage['rate_hz']} Hz",
"results": self._heavy_results(test),
}
self.events.put(("heavy_quality", self.heavy_quality_latest))
def _tick_heavy_quality(self, now):
test = self.heavy_quality_test
if test is None:
return
for token, (stage_index, sent_at) in list(self.heavy_pending_pings.items()):
if now - sent_at >= 2.5:
self.heavy_pending_pings.pop(token, None)
self._record_heavy_probe(stage_index)
self.log("HEAVY_PROBE_TIMEOUT", f"rate={test['rates'][stage_index]}Hz timesync={token}")
index = test["stage_index"]
stage_pending = any(stage_index == index for stage_index, _ in self.heavy_pending_pings.values())
if now - test["stage_started"] >= test["duration_s"] and not stage_pending:
index += 1
if index >= len(test["rates"]):
results = self._heavy_results(test)
self.heavy_quality_test = None
self.heavy_quality_latest = {
"running": False, "stage": 3, "stages": 3, "rate_hz": None,
"status": "Complete", "results": results,
}
self.next_ping_at = now
self.log("TX_HEAVY_TEST", "complete")
self.events.put(("heavy_quality", self.heavy_quality_latest))
return
test["stage_index"] = index
test["stage_started"] = now
test["next_probe_at"] = now
self._publish_heavy_quality()
index = test["stage_index"]
stage = test["stages"][index]
if now >= test["next_probe_at"] and now - test["stage_started"] < test["duration_s"]:
token = time.monotonic_ns()
self.heavy_pending_pings[token] = (index, now)
stage["sent"] += 1
self.log("TX_HEAVY_PROBE", f"rate={stage['rate_hz']}Hz timesync={token}")
self._send_mav(self.connection, "timesync_send", 0, token)
test["next_probe_at"] = now + 1.0 / stage["rate_hz"]
if now >= self.next_quality_publish_at:
self._publish_heavy_quality()
self.next_quality_publish_at = now + 1.0
def set_link_rates(self, ping_hz, telemetry_rates):
self.ping_rate_hz = max(1, min(10, int(ping_hz)))
for name in self.telemetry_rates_hz:
if name in telemetry_rates:
self.telemetry_rates_hz[name] = max(0, min(10, int(telemetry_rates[name])))
if self.connection and self.target_system:
self._configure_low_bandwidth_streams()
def _start_servo_scan(self): def _start_servo_scan(self):
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)] names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal, # ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
@@ -284,31 +652,52 @@ class MavlinkWorker:
self.param_retries = 0 self.param_retries = 0
def _configure_low_bandwidth_streams(self): def _configure_low_bandwidth_streams(self):
"""Stop inherited high-rate streams and request only diagnostic essentials.""" """Pace optional telemetry changes; never burst them at arm time."""
if not self.connection: if not self.connection:
return return
self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL") self.log("TX_STREAM_CONFIG", "stop MAV_DATA_STREAM_ALL")
self.connection.mav.request_data_stream_send( self._send_mav(self.connection, "request_data_stream_send",
self.target_system, self.target_component, self.target_system, self.target_component,
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0, mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
) )
# Heartbeat and STATUSTEXT do not need to be requested. Keep only # ATTITUDE and VFR_HUD interval commands were accepted by this FC.
# output/input evidence needed to diagnose controls, at 2 Hz each. # AHRS2 is also unneeded, but an acknowledgement has not been seen.
for message_id, interval_us, name in ( # NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"), # A single interval command is sometimes lost, so send two paced
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"), # passes. This does not change persistent parameters or onboard logs.
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"), unwanted = (
): "ATTITUDE", "AHRS2", "VFR_HUD",
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}") )
self.connection.mav.command_long_send( self.stream_config = deque(
self.target_system, self.target_component, (name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL, for _ in range(2) for name in unwanted
0, message_id, interval_us, 0, 0, 0, 0, 0, )
) for name, rate_hz in self.telemetry_rates_hz.items():
self.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…")) interval_us = -1 if rate_hz == 0 else round(1_000_000 / rate_hz)
self.stream_config.append((
name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), interval_us,
))
self.next_stream_config_at = time.monotonic()
self.events.put(("status", "Applying low-bandwidth telemetry rates…"))
def _tick_stream_config(self, now):
if (not self.connection or not self.target_system or self.armed
or now < self.command_quiet_until or now < self.next_stream_config_at
or not self.stream_config):
return
name, message_id, interval_us = self.stream_config.popleft()
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL,
0, message_id, interval_us, 0, 0, 0, 0, 0,
)
self.next_stream_config_at = now + 0.5
def _tick_param_scan(self, now): def _tick_param_scan(self, now):
if not self.connection or self.config_paused or now < self.param_deadline: if (not self.connection or self.config_paused
or self.stream_config or now < self.next_stream_config_at
or now < self.command_quiet_until or now < self.param_deadline):
return return
if self.param_waiting is not None: if self.param_waiting is not None:
if self.param_retries < 2: if self.param_retries < 2:
@@ -329,7 +718,7 @@ class MavlinkWorker:
def _request_param(self, name: str): def _request_param(self, name: str):
self.log("TX_PARAM_REQUEST", name) self.log("TX_PARAM_REQUEST", name)
self.connection.mav.param_request_read_send( self._send_mav(self.connection, "param_request_read_send",
self.target_system, self.target_component, name.encode("ascii"), -1 self.target_system, self.target_component, name.encode("ascii"), -1
) )
@@ -343,7 +732,7 @@ class MavlinkWorker:
raise RuntimeError(f"{name} has not been read from the autopilot") raise RuntimeError(f"{name} has not been read from the autopilot")
self.pending_sets[name] = int(value) self.pending_sets[name] = int(value)
self.log("TX_PARAM_SET", f"{name}={value} type={param_type}") self.log("TX_PARAM_SET", f"{name}={value} type={param_type}")
self.connection.mav.param_set_send( self._send_mav(self.connection, "param_set_send",
self.target_system, self.target_component, name.encode("ascii"), self.target_system, self.target_component, name.encode("ascii"),
float(value), param_type, float(value), param_type,
) )
@@ -396,7 +785,7 @@ class MavlinkWorker:
f"x={x} y={y} z={z} r={r} active={active} " f"x={x} y={y} z={z} r={r} active={active} "
f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})", f"target=({target.forward:.3f},{target.lateral:.3f},{target.vertical:.3f},{target.yaw:.3f})",
) )
conn.mav.manual_control_send(self.target_system, x, y, z, r, 0) self._send_mav(conn, "manual_control_send", self.target_system, x, y, z, r, 0)
@staticmethod @staticmethod
def _approach(current: float, target: float, max_change: float) -> float: def _approach(current: float, target: float, max_change: float) -> float:
@@ -409,18 +798,45 @@ class MavlinkWorker:
conn = self.connection conn = self.connection
if not conn or not self.target_system: if not conn or not self.target_system:
raise RuntimeError("No autopilot heartbeat") raise RuntimeError("No autopilot heartbeat")
if arm and self.heavy_quality_test is not None:
self.cancel_heavy_quality_test()
now = time.monotonic()
self.command_quiet_until = now + 6.0
if not arm: if not arm:
self.disarm_requested_until = now + 6.0
self._send_control(force_neutral=True) self._send_control(force_neutral=True)
else: else:
# Give the ARM request priority over the low-rate parameter scan. # Give the ARM request priority over the low-rate parameter scan.
self.config_paused = True self.config_paused = True
self.log("TX_COMMAND", "ARM" if arm else "DISARM") self.log("TX_COMMAND", "ARM" if arm else "DISARM")
conn.mav.command_long_send( self.pending_arm_state = bool(arm)
self.next_arm_retry_at = now + 0.25
self.arm_retry_deadline = now + 5.0
self.arm_confirmation = 0
self._send_arm_packet(arm)
def _send_arm_packet(self, arm: bool):
conn = self.connection
if not conn or not self.target_system:
return
self._send_mav(conn, "command_long_send",
self.target_system, self.target_component, self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0, self.arm_confirmation, 1.0 if arm else 0.0, 0, 0, 0, 0, 0, 0,
) )
def _tick_arm_retry(self, now):
if self.pending_arm_state is None or now < self.next_arm_retry_at:
return
if now >= self.arm_retry_deadline:
self.log("TX_COMMAND_TIMEOUT", "ARM" if self.pending_arm_state else "DISARM")
self.pending_arm_state = None
return
self.log("TX_COMMAND_RETRY", "ARM" if self.pending_arm_state else "DISARM")
self.arm_confirmation = min(255, self.arm_confirmation + 1)
self._send_arm_packet(self.pending_arm_state)
self.next_arm_retry_at = now + 0.25
def set_manual_mode(self): def set_manual_mode(self):
conn = self.connection conn = self.connection
if not conn or not self.target_system: if not conn or not self.target_system:
@@ -430,7 +846,7 @@ class MavlinkWorker:
if manual is None: if manual is None:
raise RuntimeError("MANUAL mode is not advertised by this firmware") raise RuntimeError("MANUAL mode is not advertised by this firmware")
self.log("TX_MODE", f"MANUAL custom_mode={manual}") self.log("TX_MODE", f"MANUAL custom_mode={manual}")
conn.mav.set_mode_send( self._send_mav(conn, "set_mode_send",
self.target_system, self.target_system,
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
manual, manual,
@@ -467,7 +883,7 @@ class MavlinkWorker:
if self.connection and self.target_system: if self.connection and self.target_system:
try: try:
self._send_control(force_neutral=True) self._send_control(force_neutral=True)
self.connection.mav.command_long_send( self._send_mav(self.connection, "command_long_send",
self.target_system, self.target_component, self.target_system, self.target_component,
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM, mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+168 -2
View File
@@ -1,6 +1,9 @@
"""No serial port is opened by these tests.""" """No serial port is opened by these tests."""
import queue import queue
import threading
import time
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from app import Axes, MavlinkWorker, NEUTRAL_Z from app import Axes, MavlinkWorker, NEUTRAL_Z
@@ -100,9 +103,18 @@ class WorkerWatchdogTests(unittest.TestCase):
def assertNeutral(self): def assertNeutral(self):
self.worker._send_control() self.worker._send_control()
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args, self.assertEqual(self.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0)) (1, 0, 0, NEUTRAL_Z, 0, 0))
def control_values(self):
return self.worker.connection.mav.manual_control_send.call_args.args
def test_manual_control_uses_native_v2_without_forced_long_payload(self):
self.worker._send_mav(self.worker.connection, 'manual_control_send',
1, 50, 0, 525, -20, 0)
self.assertEqual(self.control_values(), (1, 50, 0, 525, -20, 0))
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.kwargs, {})
def test_ui_freeze_neutralizes_and_does_not_replay(self): def test_ui_freeze_neutralizes_and_does_not_replay(self):
with patch("app.time.monotonic", return_value=10): with patch("app.time.monotonic", return_value=10):
self.worker.set_axes(Axes(forward=1), 1) self.worker.set_axes(Axes(forward=1), 1)
@@ -118,7 +130,7 @@ class WorkerWatchdogTests(unittest.TestCase):
self.worker.set_axes(Axes(forward=.25, vertical=.25), 1) self.worker.set_axes(Axes(forward=.25, vertical=.25), 1)
self.worker._send_control() self.worker._send_control()
# 25% input at the default 20% limit: 5% command, not 100%. # 25% input at the default 20% limit: 5% command, not 100%.
self.assertEqual(self.worker.connection.mav.manual_control_send.call_args.args, self.assertEqual(self.control_values(),
(1, 50, 0, 525, 0, 0)) (1, 50, 0, 525, 0, 0))
def test_heartbeat_freeze_neutralizes_even_with_live_ui(self): def test_heartbeat_freeze_neutralizes_even_with_live_ui(self):
@@ -131,6 +143,160 @@ class WorkerWatchdogTests(unittest.TestCase):
self.worker.set_axes(Axes(forward=1), 19) self.worker.set_axes(Axes(forward=1), 19)
self.assertEqual(self.worker.axes, Axes()) self.assertEqual(self.worker.axes, Axes())
def test_mavlink_transmissions_are_serialized(self):
first_entered = threading.Event()
release_first = threading.Event()
second_entered = threading.Event()
class Mav:
def first_send(self, **_):
first_entered.set()
release_first.wait(1)
def second_send(self, **_):
second_entered.set()
connection = SimpleNamespace(mav=Mav())
first = threading.Thread(target=self.worker._send_mav,
args=(connection, "first_send"))
second = threading.Thread(target=self.worker._send_mav,
args=(connection, "second_send"))
first.start()
self.assertTrue(first_entered.wait(.5))
second.start()
time.sleep(.05)
self.assertFalse(second_entered.is_set())
release_first.set()
first.join(1)
second.join(1)
self.assertTrue(second_entered.is_set())
def test_live_console_is_read_only_bounded_and_keeps_newest_lines(self):
for index in range(310):
self.worker.log("RX_TEST", f"vehicle {index}")
self.worker.log("TX_TEST", f"computer {index}")
sides = self.worker.read_console(2)
self.assertEqual(set(sides), {"rx", "tx"})
self.assertTrue(sides["rx"][0].endswith("[RX_TEST] vehicle 308"))
self.assertTrue(sides["rx"][1].endswith("[RX_TEST] vehicle 309"))
self.assertTrue(sides["tx"][1].endswith("[TX_TEST] computer 309"))
self.assertEqual(len(self.worker.console_rx_lines), 300)
self.assertEqual(len(self.worker.console_tx_lines), 300)
self.assertEqual(len(self.worker.console_lines), 500)
def test_arm_request_pauses_parameter_traffic(self):
self.worker.armed = False
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker.config_paused = False # A disarmed heartbeat arrived.
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.command_quiet_until, 16)
self.worker.connection.mav.param_request_read_send.assert_not_called()
self.worker.connection.mav.command_long_send.assert_called_once()
def test_arm_request_retries_until_state_confirmation(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(True)
self.worker._tick_arm_retry(10.24)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_arm_retry(10.25)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
self.worker.pending_arm_state = None
self.worker._tick_arm_retry(10.50)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_outbound_command_has_valid_heartbeat_lead_in(self):
self.worker._send_mav(self.worker.connection, "timesync_send", 0, 123)
names = [call[0] for call in self.worker.connection.mav.method_calls]
self.assertEqual(names[-2:], ["heartbeat_send", "timesync_send"])
def test_disarm_sends_neutral_and_pauses_parameter_traffic(self):
self.worker.param_scan.append("SERVO1_FUNCTION")
with patch("app.time.monotonic", return_value=10):
self.worker.command_arm(False)
self.worker.config_paused = False
self.worker._tick_param_scan(10.1)
self.assertEqual(self.worker.disarm_requested_until, 16)
self.assertEqual(self.control_values(),
(1, 0, 0, NEUTRAL_Z, 0, 0))
self.worker.connection.mav.param_request_read_send.assert_not_called()
def test_telemetry_config_is_paced_and_includes_low_rate_esc(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker._configure_low_bandwidth_streams()
self.assertEqual(len(self.worker.stream_config), 10)
self.assertEqual(self.worker.stream_config[-1][0], "ESC_TELEMETRY_1_TO_4")
self.assertEqual(self.worker.stream_config[-1][2], 1_000_000)
self.worker.connection.mav.command_long_send.assert_not_called()
self.worker._tick_stream_config(10)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(10.1)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker.command_quiet_until = 16
self.worker._tick_stream_config(11)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 1)
self.worker._tick_stream_config(16)
self.assertEqual(self.worker.connection.mav.command_long_send.call_count, 2)
def test_link_probe_uses_configured_rate_and_measures_exact_echo(self):
self.worker.armed = False
self.worker.ping_rate_hz = 5
with patch("app.time.monotonic", return_value=10), \
patch("app.time.monotonic_ns", return_value=10_000_000_000):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_called_once_with(
0, 10_000_000_000)
self.assertEqual(self.worker.pending_pings, {10_000_000_000: 10})
self.assertEqual(self.worker.next_ping_at, 10.2)
message = SimpleNamespace(
tc1=1, ts1=10_000_000_000, get_type=lambda: "TIMESYNC",
to_dict=lambda: {"tc1": 1, "ts1": 10_000_000_000})
with patch("app.time.monotonic", return_value=10.04):
self.worker._handle_message(message)
quality = self.worker.events.get_nowait()
self.assertEqual(quality[0], "link_quality")
self.assertEqual(quality[1]["latency_ms"], 40.0)
self.assertEqual(quality[1]["loss_percent"], 0.0)
def test_link_rates_rebuild_requested_telemetry_intervals(self):
self.worker.armed = False
with patch("app.time.monotonic", return_value=10):
self.worker.set_link_rates(10, {
"SYS_STATUS": 5, "SERVO_OUTPUT_RAW": 10,
"RC_CHANNELS": 0, "ESC_TELEMETRY_1_TO_4": 2,
})
self.assertEqual(self.worker.ping_rate_hz, 10)
configured = {name: interval for name, _, interval in self.worker.stream_config}
self.assertEqual(configured["SYS_STATUS"], 200_000)
self.assertEqual(configured["SERVO_OUTPUT_RAW"], 100_000)
self.assertEqual(configured["RC_CHANNELS"], -1)
self.assertEqual(configured["ESC_TELEMETRY_1_TO_4"], 500_000)
def test_link_probe_counts_timeout_and_pauses_for_commands(self):
self.worker.pending_pings[4] = 5
self.worker.rx_delivery_results.extend(((10, True), (10, False)))
self.worker.next_ping_at = 0
self.worker.command_quiet_until = 20
with patch("app.time.monotonic", return_value=10):
self.worker._tick_link_probe(10)
self.worker.connection.mav.timesync_send.assert_not_called()
self.assertEqual(list(self.worker.ping_results), [(10, None)])
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "testing")
def test_clean_downlink_cannot_hide_failed_round_trips(self):
self.worker.ping_results.extend([(10, None)] * 20)
self.worker.rx_delivery_results.extend([(10, True)] * 200)
with patch("app.time.monotonic", return_value=10):
self.worker._publish_link_quality()
quality = self.worker.events.get_nowait()[1]
self.assertEqual(quality["loss_percent"], 100.0)
self.assertEqual(quality["grade"], "poor")
class BridgeTests(unittest.TestCase): class BridgeTests(unittest.TestCase):
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self): def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
+140
View File
@@ -0,0 +1,140 @@
/* Browser regression check. Run: node tests/ui_windows.cjs [path-to-playwright]
Uses a simulated Python bridge; never opens a COM port or sends MAVLink. */
const assert=require('node:assert/strict');
const fs=require('node:fs');
const path=require('node:path');
const http=require('node:http');
const {chromium}=require(process.argv[2]||'playwright');
const root=path.resolve(__dirname,'..'),output=path.join(root,'runtime','ui-check');
fs.mkdirSync(output,{recursive:true});
const server=http.createServer((req,res)=>{
const url=decodeURIComponent(new URL(req.url,'http://localhost').pathname);
let file=path.resolve(root,'.'+url);
if(url==='/model.stl'||url==='/model')file=path.join(root,'runtime','vehicle_model.stl');
if(!file.startsWith(root+path.sep)||!fs.existsSync(file)||!fs.statSync(file).isFile()){
res.writeHead(404);res.end();return;
}
const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.stl':'model/stl'};
res.setHeader('Content-Type',types[path.extname(file)]||'application/octet-stream');
fs.createReadStream(file).pipe(res);
});
async function main(){
await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage({viewport:{width:1080,height:720}});
const errors=[];page.on('pageerror',error=>errors.push(error.message));
await page.addInitScript(()=>{
const clone=value=>JSON.parse(JSON.stringify(value));
const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,motor:i<=3?i:null,direction:i<=3?1:null};
const state={connected:false,armed:false,armed_known:false,mode:null,app_state:'disconnected',status:'Ready',servos,link_quality:null};
const battery={cells:4,capacity_mah:5500,c_rating:35,full_v_per_cell:4.2,empty_v_per_cell:3.3,warning_percent:20};
const link={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1};
const calls={battery:0,link:0,heavy:0,reverse:0,console:0,connect:[]};
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
window.testBridge={state,battery,link,calls,failBattery:false,failMotor:false};
window.pywebview={api:{
initial_state:async()=>({ports:[{device:'COM5',label:'COM5'}],state:clone(state),battery_config:clone(battery),link_config:clone(link)}),
refresh_ports:async()=>[{device:'COM5',label:'COM5'}],poll:async()=>clone(state),
get_saved_model_info:async()=>null,set_gain:async()=>true,stop_controls:async()=>true,set_axes:async()=>true,
save_battery:async data=>{calls.battery++;await sleep(200);if(window.testBridge.failBattery)throw Error('Battery save failed');Object.keys(battery).forEach(k=>battery[k]=+data[k]);return clone(battery)},
save_link_settings:async data=>{calls.link++;await sleep(250);Object.keys(link).forEach(k=>link[k]=+data[k]);return clone(link)},
connect:async(port,baud,rate)=>{calls.connect.push({port,baud,rate});Object.assign(state,{connected:true,armed_known:true,app_state:'connected_disarmed',mode:'MANUAL',link:'LINK OK',link_quality:{grade:'excellent',probe_samples:20,rx_packets_30s:100,rtt_median_ms:24,rtt_p95_ms:40,probe_loss_percent:0,rx_sequence_loss_percent:0,heartbeat_age_s:.2}});return clone(state)},
disconnect:async()=>{Object.assign(state,{connected:false,armed_known:false,app_state:'disconnected'});return true},
reverse_motor:async servo=>{calls.reverse++;if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>state.servos[servo].direction*=-1,650);return true},
set_servo_function:async(servo,motor)=>{if(window.testBridge.failMotor)throw Error('Motor write failed');setTimeout(()=>Object.assign(state.servos[servo],{motor:motor||null,function:motor===0?0:motor<=8?32+motor:73+motor,direction:motor?1:null}),650);return true},
start_heavy_quality_test:async()=>{calls.heavy++;await sleep(250);state.heavy_quality={running:true,status:'Stage 1 of 3: 1 Hz',sent:4,replies:4,results:[]};return clone(state.heavy_quality)},
cancel_heavy_quality_test:async()=>{state.heavy_quality={running:false,status:'Cancelled',results:[]};return clone(state.heavy_quality)},
read_console:async()=>{calls.console++;return {rx:['RX sample '+calls.console],tx:['TX sample '+calls.console]}},
minimize_window:async()=>true,toggle_maximize_window:async()=>false,close_window:async()=>true
}};
});
await page.goto('http://127.0.0.1:'+server.address().port+'/web/index.html');
await page.waitForFunction(()=>bridgeReady);
const openLink=()=>page.locator('[data-open="link-modal"]').click();
const close=async id=>{await page.locator('#'+id+' [data-close]').first().click();await page.waitForFunction(id=>!document.getElementById(id).open,id)};
await openLink();
await page.locator('#baud').selectOption('115200');
await page.locator('#link-modal').getByRole('button',{name:'Cancel',exact:true}).click();
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await openLink();assert.equal(await page.locator('#baud').inputValue(),'57600');
await page.locator('#custom-port-input').fill('COM42');
await page.locator('#custom-port-btn').click();
assert(await page.locator('#link-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#baud').selectOption('115200');
assert(await page.locator('#link-form [type="submit"]').evaluate(el=>{el.click();return el.disabled}));
await page.waitForFunction(()=>!document.getElementById('link-modal').open);
await page.locator('#refresh').click();assert.equal(await page.locator('#port').inputValue(),'COM42');
await page.locator('#connect').click();
await page.waitForFunction(()=>testBridge.calls.connect.length===1);
assert.equal(await page.evaluate(()=>testBridge.calls.connect[0].baud),115200);
await page.locator('[data-open="battery-modal"]').click();
await page.locator('#battery-form [name="cells"]').fill('5');
await page.keyboard.press('Escape');await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'4');
await page.evaluate(()=>testBridge.failBattery=true);
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.locator('#battery-form .form-feedback').waitFor({state:'visible'});
assert(await page.locator('#battery-modal').evaluate(d=>d.open));
assert.equal(await page.locator('#battery-form .form-feedback').textContent(),'Battery save failed');
await page.evaluate(()=>testBridge.failBattery=false);
await page.locator('#battery-form [name="cells"]').fill('5');
await page.locator('#battery-form').getByRole('button',{name:'Save battery'}).click();
await page.waitForFunction(()=>!document.getElementById('battery-modal').open);
await page.locator('[data-open="battery-modal"]').click();
assert.equal(await page.locator('#battery-form [name="cells"]').inputValue(),'5');
await page.screenshot({path:path.join(output,'battery.png')});await close('battery-modal');
await page.locator('[data-open="motors-modal"]').click();
assert.equal(await page.locator('#motor-list > .motor-row').count(),3);
await page.locator('#motor-list > .motor-row').first().getByRole('button').click();
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Confirming…');
assert(await page.locator('#motor-list > .motor-row').first().getByRole('button').isDisabled());
await page.waitForFunction(()=>document.querySelector('#motor-list > .motor-row > span').textContent==='Reversed');
await page.evaluate(()=>testBridge.failMotor=true);
await page.locator('#motor-list > .motor-row').first().locator('select').selectOption('2');
await page.locator('#motor-feedback').waitFor({state:'visible'});
assert.equal(await page.locator('#motor-list > .motor-row').first().locator('select').inputValue(),'1');
await page.screenshot({path:path.join(output,'motors.png')});await close('motors-modal');
await page.locator('#link-quality').click();
assert.equal(await page.locator('#quality-detail-rtt').textContent(),'24 ms');
await page.locator('#heavy-quality-run').click();
assert(await page.locator('#heavy-quality-run').isDisabled());
await page.locator('#heavy-quality-cancel').waitFor({state:'visible'});
assert.equal(await page.evaluate(()=>testBridge.calls.heavy),1);
await page.locator('#heavy-quality-cancel').click();
await page.waitForFunction(()=>!testBridge.state.heavy_quality.running);
await page.screenshot({path:path.join(output,'quality.png')});await close('link-quality-modal');
await page.locator('#console').click();
await page.waitForFunction(()=>document.getElementById('console-rx-output').textContent.includes('RX sample'));
await page.locator('#console-pause').click();
const paused=await page.locator('#console-rx-output').textContent();
await page.waitForTimeout(850);assert.equal(await page.locator('#console-rx-output').textContent(),paused);
await page.locator('#console-pause').click();
await page.waitForFunction(old=>document.getElementById('console-rx-output').textContent!==old,paused);
await page.screenshot({path:path.join(output,'console.png')});await close('console-modal');
for(const viewport of [{width:900,height:560},{width:480,height:720}]){
await page.setViewportSize(viewport);
for(const id of ['link-modal','battery-modal','link-quality-modal','motors-modal','console-modal']){
await page.evaluate(id=>openModal(id),id);
const box=await page.locator('#'+id).boundingBox();
assert(box.x>=0&&box.y>=0&&box.width<=viewport.width&&box.height<=viewport.height,id+' must fit viewport');
assert.equal(await page.evaluate(()=>canDrive()),false);
await close(id);
}
}
await page.setViewportSize({width:1080,height:720});await openLink();
await page.screenshot({path:path.join(output,'link.png')});await close('link-modal');
assert.deepEqual(errors,[]);
console.log('PASS: settings cancel/save/failure, saved baud, custom port rescan, motor confirmation/failure, heavy test, console pause/resume, keyboard gating, and five dialogs at two window sizes.');
}catch(error){
const page=browser.contexts()[0]?.pages()[0];
if(page){
await page.screenshot({path:path.join(output,'failure.png')});
console.error(await page.evaluate(()=>[...document.querySelectorAll('input')].filter(el=>!el.checkValidity()).map(el=>({name:el.name,value:el.value,error:el.validationMessage}))));
}
throw error;
}finally{await browser.close();await new Promise(resolve=>server.close(resolve))}
}
main().catch(error=>{console.error(error);server.close();process.exitCode=1});
+216 -16
View File
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
link_degraded: 'Signal lost — check tether', link_degraded: 'Signal lost — check tether',
}; };
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)]; const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null; const control={forward:0,turn:0,vertical:0}, pressedKeys=new Set(); let bridgeReady=false, appState='disconnected', batteryConfig={}, linkConfig={baud:57600,control_hz:20,ping_hz:1,system_hz:1,servo_hz:2,rc_hz:2,esc_hz:1}, servoSignature='', lastAxisSend=0, axisTimer=0, demoActive=false, demoState=null;
const api=(name,...args)=>window.pywebview.api[name](...args); const api=(name,...args)=>window.pywebview.api[name](...args);
const activity=[]; const activity=[];
let lastReportedStatus=''; let lastReportedStatus='';
@@ -21,13 +21,14 @@ function withTimeout(promise,ms=1500){return new Promise((resolve,reject)=>{cons
let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true, let vehicleSnapshot={connected:false,armed:null}, view=ControlState.present(vehicleSnapshot), inputFocused=true,
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(), bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.now(),
localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now(); localRequest=null,commandError='',requestSerial=0,demoCommandTimer=0,lastSafetyTick=performance.now();
const canDrive=()=>view.drive&&performance.now()-lastSafetyTick<750; const canDrive=()=>view.drive&&!document.querySelector('dialog[open]')&&performance.now()-lastSafetyTick<750;
window.canDrive=canDrive; window.canDrive=canDrive;
function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},servos}} function makeDemoState(){const servos={};for(let i=1;i<=16;i++)servos[String(i)]={function:i<=3?32+i:0,label:i<=3?`Motor ${i}`:'Unassigned',motor:i<=3?i:null,direction:i<=3?1:null};return {connected:true,link:'LINK OK',vehicle:'DEMO • DISARMED',armed:false,armed_known:true,mode:'MANUAL',app_state:'connected_disarmed',status:'Demo mode ready — no hardware or spoofer.',battery:{voltage:15.8,per_cell:3.95,percent:72,cells:4,low:false},link_quality:null,servos}}
function updateDemo(patch){Object.assign(demoState,patch);render(demoState)} function updateDemo(patch){Object.assign(demoState,patch);render(demoState)}
document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))}); document.addEventListener('click',e=>{const btn=e.target.closest('.num-step');if(!btn)return;const pill=btn.closest('.number-pill');if(!pill)return;const input=pill.querySelector('input');if(!input)return;const step=+input.step||1,min=input.min!==''?+input.min:-Infinity,max=input.max!==''?+input.max:Infinity;let val=+input.value||0;if(btn.classList.contains('down'))val=Math.max(min,+(val-step).toFixed(4));else val=Math.min(max,+(val+step).toFixed(4));input.value=val;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}))});
function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)} function toast(message,error=false){const el=$('#toast');el.textContent=message;el.className=`toast show${error?' error':''}`;clearTimeout(toast.t);toast.t=setTimeout(()=>el.className='toast',2600)}
window.showToast=toast; window.showToast=toast;
const manualPorts=new Map();
function populatePorts(ports){ function populatePorts(ports){
const p=$('#port'); const p=$('#port');
if(!p)return; if(!p)return;
@@ -50,6 +51,7 @@ function populatePorts(ports){
}; };
if(Array.isArray(ports)) ports.forEach(addPort); if(Array.isArray(ports)) ports.forEach(addPort);
manualPorts.forEach(addPort);
addPort({device:'demo', label:'Demo Mode (no hardware)'}); addPort({device:'demo', label:'Demo Mode (no hardware)'});
let matched=false; let matched=false;
@@ -94,7 +96,7 @@ function easeHeave(target=0){cancelAnimationFrame(heaveAnim);const sv=control.ve
window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})}; window.verticalControl={setPos:value=>{cancelAnimationFrame(heaveAnim);setHeave(value)},easeBack:()=>{if(heavePointer===null)easeHeave()},getState:()=>({value:control.vertical,active:heavePointer!==null})};
function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)} function applyKeys(){if(!canDrive()){pressedKeys.clear();return;}const turn=(pressedKeys.has('d')||pressedKeys.has('e')?1:0)-(pressedKeys.has('a')||pressedKeys.has('q')?1:0),forward=(pressedKeys.has('w')?1:0)-(pressedKeys.has('s')?1:0),vertical=(pressedKeys.has('r')?1:0)-(pressedKeys.has('f')?1:0);if(!window.joystick3D?.getState().active)window.joystick3D?.easeTo(turn,-forward);if(heavePointer===null)easeHeave(vertical)}
addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();}); addEventListener('keydown',e=>{if(e.key==='F3'){e.preventDefault();if(!e.repeat&&view.action==='disarm')requestControl('disarm');return;}if(document.querySelector('dialog[open]')||/^(INPUT|SELECT|TEXTAREA)$/.test(e.target.tagName))return;if(e.key==='F2'){e.preventDefault();if(!e.repeat)startEngageHold();return;}const k=e.key.toLowerCase();if('wasdqerf'.includes(k)&&k.length===1){if(!canDrive())return;e.preventDefault();if(!pressedKeys.has(k)){pressedKeys.add(k);applyKeys();}}});addEventListener('keyup',e=>{if(e.key==='F2'){e.preventDefault();cancelEngageHold(true);return;}const k=e.key.toLowerCase();if(pressedKeys.delete(k))applyKeys();});
function resetInputs(){ function resetInputs(){
if(gainPointer!==null)releaseGain({pointerId:gainPointer}); if(gainPointer!==null)releaseGain({pointerId:gainPointer});
clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false); clearTimeout(axisTimer);axisTimer=0;pressedKeys.clear();cancelEngageHold(false);
@@ -111,28 +113,62 @@ addEventListener('focus',()=>focusChanged(!document.hidden));
document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus())); document.addEventListener('visibilitychange',()=>focusChanged(!document.hidden&&document.hasFocus()));
addEventListener('pagehide',()=>focusChanged(false)); addEventListener('pagehide',()=>focusChanged(false));
function setFeedback(el,message=''){
if(!el)return;
el.textContent=message;el.hidden=!message;
}
function openModal(id){ function openModal(id){
const el=typeof id==='string'?document.getElementById(id):id; const el=typeof id==='string'?document.getElementById(id):id;
if(!el)return; if(!el||el.open)return;
resetInputs();
el._returnFocus=document.activeElement;
if(el.id==='battery-modal')fillBatteryForm();
if(el.id==='link-modal'){
fillLinkForm();
$('#custom-port-status').textContent='';
$('#custom-port-input').value='';
}
el.querySelectorAll('.form-feedback').forEach(feedback=>setFeedback(feedback));
el.classList.remove('closing'); el.classList.remove('closing');
if(!el.open)el.showModal(); el.showModal();
} }
function closeModal(dialog){ function closeModal(dialog){
if(!dialog||!dialog.open||dialog.classList.contains('closing'))return; if(!dialog||!dialog.open||dialog.classList.contains('closing')||dialog.querySelector('[data-busy="true"]'))return;
dialog.classList.add('closing'); dialog.classList.add('closing');
let done=false; let done=false;
const finish=()=>{ const finish=()=>{
if(done)return; if(done)return;
done=true; done=true;
dialog.removeEventListener('animationend',finish); dialog.removeEventListener('animationend',onEnd);
dialog.classList.remove('closing'); dialog.classList.remove('closing');
dialog.close(); dialog.close();
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
}; };
dialog.addEventListener('animationend',finish,{once:true}); const onEnd=e=>{if(e.target===dialog)finish()};
setTimeout(finish,300); dialog.addEventListener('animationend',onEnd);
setTimeout(finish,180);
} }
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open)); $$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
$$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog'))); $$('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.closest('dialog')));
$('#link-quality').onclick=()=>openModal('link-quality-modal');
let heavyActionBusy=false;
async function requestHeavyTest(action){
if(heavyActionBusy)return;
heavyActionBusy=true;
setFeedback($('#heavy-quality-error'));
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
try{
const test=await api(action);
vehicleSnapshot.heavy_quality=test;
renderHeavyQuality(test,vehicleSnapshot.connected,vehicleSnapshot.armed);
}catch(e){setFeedback($('#heavy-quality-error'),e.message||'Link test unavailable. Try again.')}
finally{
heavyActionBusy=false;
renderHeavyQuality(vehicleSnapshot.heavy_quality,vehicleSnapshot.connected,vehicleSnapshot.armed);
}
}
$('#heavy-quality-run').onclick=()=>requestHeavyTest('start_heavy_quality_test');
$('#heavy-quality-cancel').onclick=()=>requestHeavyTest('cancel_heavy_quality_test');
$$('dialog').forEach(d=>{ $$('dialog').forEach(d=>{
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)}); d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
d.addEventListener('click',e=>{ d.addEventListener('click',e=>{
@@ -255,7 +291,7 @@ $('#connect').onclick=async()=>{
recordActivity(`Connecting to ${port}…`); recordActivity(`Connecting to ${port}…`);
toast(`Connecting to ${port}…`); toast(`Connecting to ${port}…`);
try{ try{
const confirmedState=await api('connect',port,+$('#baud').value,+$('#rate').value); const confirmedState=await api('connect',port,+linkConfig.baud||57600,+linkConfig.control_hz||20);
if(confirmedState)render(confirmedState); if(confirmedState)render(confirmedState);
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known); toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
}catch(e){ }catch(e){
@@ -270,7 +306,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
if(customPortBtn&&customPortInput){ if(customPortBtn&&customPortInput){
const applyCustomPort=()=>{ const applyCustomPort=()=>{
const val=customPortInput.value.trim(); const val=customPortInput.value.trim();
if(!val)return; if(!val){$('#custom-port-status').textContent='Enter a port first.';customPortInput.focus();return;}
if(vehicleSnapshot.connected)return;
manualPorts.set(val,{device:val,label:`Custom (${val})`});
const p=$('#port'); const p=$('#port');
let exists=false; let exists=false;
for(const opt of p.options){ for(const opt of p.options){
@@ -280,8 +318,7 @@ if(customPortBtn&&customPortInput){
p.add(new Option(`Custom (${val})`, val), 0); p.add(new Option(`Custom (${val})`, val), 0);
p.value=val; p.value=val;
} }
closeModal($('#link-modal')); $('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
toast(`Selected port: ${val}`);
}; };
customPortBtn.onclick=applyCustomPort; customPortBtn.onclick=applyCustomPort;
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}}; customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
@@ -380,6 +417,34 @@ if(engageButton){
} }
$('#logs').onclick=()=>api('open_logs'); $('#logs').onclick=()=>api('open_logs');
const consoleDialog=$('#console-modal'),consoleRx=$('#console-rx-output'),consoleTx=$('#console-tx-output');
let consoleRefreshBusy=false,consolePaused=false;
$('#console-pause').onclick=()=>{
consolePaused=!consolePaused;
$('#console-pause').textContent=consolePaused?'Resume':'Pause';
$('#console-pause').setAttribute('aria-pressed',String(consolePaused));
$('#console-state').textContent=consolePaused?'Paused · session logging continues':'Live · follows new messages';
if(!consolePaused)refreshConsole();
};
async function refreshConsole(){
if(!consoleDialog?.open||!bridgeReady||consoleRefreshBusy||consolePaused)return;
consoleRefreshBusy=true;
try{
const rxAtBottom=consoleRx.scrollHeight-consoleRx.scrollTop-consoleRx.clientHeight<36;
const txAtBottom=consoleTx.scrollHeight-consoleTx.scrollTop-consoleTx.clientHeight<36;
const sides=await api('read_console',150);
if(consolePaused||!consoleDialog.open)return;
consoleRx.textContent=sides.rx?.length?sides.rx.join('\n'):'Waiting for vehicle data…';
consoleTx.textContent=sides.tx?.length?sides.tx.join('\n'):'Waiting for transmitted data…';
if(rxAtBottom)consoleRx.scrollTop=consoleRx.scrollHeight;
if(txAtBottom)consoleTx.scrollTop=consoleTx.scrollHeight;
}catch(error){
$('#console-state').textContent=`Console unavailable: ${error.message}`;
}
finally{consoleRefreshBusy=false}
}
$('#console').onclick=()=>{openModal(consoleDialog);refreshConsole()};
setInterval(refreshConsole,750);
function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)} function setGain(value){const gain=$('#gain');gain.value=Math.max(+gain.min,Math.min(+gain.max,value));$('#gain-value').textContent=`${gain.value}%`;if(bridgeReady)api('set_gain',+gain.value)}
$('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5); $('#gain').oninput=e=>setGain(+e.target.value);$('#gain-down').onclick=()=>setGain(+$('#gain').value-5);$('#gain-up').onclick=()=>setGain(+$('#gain').value+5);
// Explicit pointer ownership makes the full range rail a reliable touch target // Explicit pointer ownership makes the full range rail a reliable touch target
@@ -391,9 +456,108 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)}); gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain)); ['pointerup','pointercancel','lostpointercapture'].forEach(type=>gainSlider.addEventListener(type,releaseGain));
gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}}); gainSlider.addEventListener('keydown',e=>{const delta={ArrowRight:1,ArrowUp:1,ArrowLeft:-1,ArrowDown:-1}[e.key];if(delta){e.preventDefault();setGain(+gainSlider.value+delta)}else if(e.key==='Home'||e.key==='End'){e.preventDefault();setGain(+(e.key==='Home'?gainSlider.min:gainSlider.max))}});
$('#battery-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));try{batteryConfig=await api('save_battery',data);closeModal($('#battery-modal'));toast('Battery settings saved')}catch(err){toast(err.message,true)}}; async function saveSettings(form,method,commit,message){
if(form.dataset.busy==='true'||!form.reportValidity())return;
const data=Object.fromEntries(new FormData(form));
const button=form.querySelector('[type="submit"]'),label=button.textContent;
const disabled=new Map([...form.querySelectorAll('input,select,button')].map(el=>[el,el.disabled]));
form.dataset.busy='true';disabled.forEach((_,el)=>el.disabled=true);button.textContent='Saving…';
setFeedback(form.querySelector('.form-feedback'));
let saved=false;
try{commit(await api(method,data));saved=true;toast(message)}
catch(error){setFeedback(form.querySelector('.form-feedback'),error.message||'Could not save settings. Try again.')}
finally{
delete form.dataset.busy;disabled.forEach((value,el)=>el.disabled=value);button.textContent=label;
if(saved)closeModal(form.closest('dialog'));
}
}
$('#battery-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_battery',value=>batteryConfig=value,'Battery settings saved')};
function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}} function fillBatteryForm(){for(const [k,v] of Object.entries(batteryConfig)){const el=$(`#battery-form [name=${k}]`);if(el)el.value=v}}
function renderMotors(servos){const signature=JSON.stringify(servos);if(signature===servoSignature)return;servoSignature=signature;const list=$('#motor-list');list.innerHTML='';for(let i=1;i<=16;i++){const s=servos[String(i)]||{},row=document.createElement('div');row.className='motor-row';const opts=['<option value="0">Unassigned</option>',...Array.from({length:12},(_,n)=>`<option value="${n+1}" ${s.motor===n+1?'selected':''}>Motor ${n+1}</option>`)].join('');row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} function">${opts}</select><span>${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}</span><button class="button subtle">Reverse</button>`;row.querySelector('select').onchange=e=>{if(demoActive){const motor=+e.target.value;demoState.servos[String(i)]={function:motor?32+motor:0,label:motor?`Motor ${motor}`:'Unassigned',motor:motor||null,direction:motor?1:null};servoSignature='';renderMotors(demoState.servos);toast(`Demo SERVO${i} updated`)}else api('set_servo_function',i,+e.target.value).catch(x=>toast(x.message,true))};row.querySelector('button').onclick=()=>{if(demoActive){if(s.motor){s.direction=s.direction===-1?1:-1;servoSignature='';renderMotors(demoState.servos);toast(`Demo Motor ${s.motor} reversed`)}else toast(`SERVO${i} is unassigned`,true)}else api('reverse_motor',i).catch(x=>toast(x.message,true))};list.append(row)}} $('#link-form').onsubmit=e=>{e.preventDefault();saveSettings(e.target,'save_link_settings',value=>linkConfig=value,'Link settings applied')};
function fillLinkForm(){for(const [k,v] of Object.entries(linkConfig)){const el=$(`#link-form [name=${k}]`);if(el)el.value=v}}
const motorEdits=new Map();
async function requestMotorEdit(servo,kind,value){
if(motorEdits.has(servo))return;
const s=vehicleSnapshot.servos?.[String(servo)]||{};
const expected=kind==='function'?(value===0?0:value<=8?32+value:73+value):-s.direction;
setFeedback($('#motor-feedback'));
if(demoActive){
if(kind==='function')Object.assign(s,{function:expected,motor:value||null,direction:value?1:null});
else s.direction=expected;
servoSignature='';renderMotors(demoState.servos);toast('Demo motor updated');return;
}
motorEdits.set(servo,{kind,expected,started:performance.now()});
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
try{
await api(kind==='function'?'set_servo_function':'reverse_motor',servo,...(kind==='function'?[value]:[]));
}catch(error){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),error.message||'Motor change failed. Try again.');
servoSignature='';renderMotors(vehicleSnapshot.servos||{});
}
}
function renderMotors(servos){
for(const [servo,edit] of motorEdits){
const s=servos[String(servo)]||{};
if((edit.kind==='function'?s.function:s.direction)===edit.expected){
motorEdits.delete(servo);toast(`SERVO${servo} change confirmed`);
}else if(!vehicleSnapshot.connected||performance.now()-edit.started>8000){
motorEdits.delete(servo);
setFeedback($('#motor-feedback'),`SERVO${servo}: confirmation not received. Check the reported assignment before retrying.`);
}
}
const editable=!!vehicleSnapshot.connected&&!vehicleSnapshot.armed&&(demoActive||view.known);
const assigned=Object.values(servos).filter(s=>s.motor).length;
$('#motor-status').textContent=vehicleSnapshot.armed?'Disarm to change assignments or direction.':!vehicleSnapshot.connected?'Connect to read motor outputs.':assigned?`${assigned} motor${assigned===1?'':'s'} assigned · changes wait for vehicle confirmation.`:'Reading motor assignments…';
const signature=JSON.stringify([servos,editable,[...motorEdits]]);
if(signature===servoSignature)return;
servoSignature=signature;
const list=$('#motor-list'),expanded=list.querySelector('details')?.open;
list.replaceChildren();
const other=document.createElement('details');other.className='motor-other';other.open=!!expanded;
const summary=document.createElement('summary');summary.textContent=`Other outputs (${16-assigned})`;other.append(summary);
for(let i=1;i<=16;i++){
const s=servos[String(i)]||{},pending=motorEdits.get(i),row=document.createElement('div');row.className='motor-row';
row.innerHTML=`<strong>SERVO${i}</strong><select aria-label="SERVO${i} assignment"></select><span></span><button type="button" class="button subtle">Reverse</button>`;
const select=row.querySelector('select');
select.add(new Option(s.function==null?'Reading…':'Unassigned','0'));
for(let motor=1;motor<=12;motor++)select.add(new Option(`Motor ${motor}`,String(motor)));
select.value=String(s.motor||0);
select.disabled=!editable||!!pending||s.function==null;
select.onchange=e=>requestMotorEdit(i,'function',+e.target.value);
row.querySelector('span').textContent=pending?'Confirming…':s.direction==null?'—':s.direction===-1?'Reversed':'Normal';
const reverse=row.querySelector('button');reverse.disabled=!editable||!!pending||!s.motor||s.direction==null;
reverse.setAttribute('aria-label',`Reverse SERVO${i} motor direction`);
reverse.onclick=()=>requestMotorEdit(i,'direction');
(s.motor?list:other).append(row);
}
if(!assigned){const empty=document.createElement('p');empty.className='note';empty.textContent='Assigned motors will appear here.';list.append(empty)}
if(assigned<16)list.append(other);
}
function renderHeavyQuality(test,connected,armed){
const status=$('#heavy-quality-status'),results=$('#heavy-quality-results'),run=$('#heavy-quality-run'),stop=$('#heavy-quality-cancel');
if(!status||!results||!run||!stop)return;
const quality=demoActive?null:vehicleSnapshot.link_quality;
$('#quality-detail-rtt').textContent=quality?.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
$('#quality-detail-loss').textContent=quality?.probe_loss_percent==null?'—':`${quality.probe_loss_percent.toFixed(1)}%`;
$('#quality-detail-downlink').textContent=quality?.rx_sequence_loss_percent==null?'—':`${quality.rx_sequence_loss_percent.toFixed(1)}%`;
const running=!!test?.running;
status.textContent=running?`${test.status} · ${test.replies||0}/${test.sent||0} replies`:(test?.status==='Complete'?'Test complete. Results below.':test?.status==='Cancelled'?'Test stopped. Partial results below.':demoActive?'Demo has no hardware link to measure.':!connected?'Connect to run a test.':armed?'Disarm to run a test.':'Ready to test.');
run.disabled=heavyActionBusy||running||!connected||armed||demoActive;
run.textContent=!connected?'Connect to run test':armed?'Disarm to run test':demoActive?'Unavailable in demo':test?.status==='Complete'?'Run again':'Run heavy test';
stop.hidden=!running;
stop.disabled=heavyActionBusy;
results.replaceChildren();
for(const row of test?.results||[]){
const line=document.createElement('div');line.className='heavy-result-row';
const rate=document.createElement('strong');rate.textContent=`${row.rate_hz} Hz`;
const stats=document.createElement('span');
const loss=row.loss_percent==null?'—':`${row.loss_percent.toFixed(1)}% loss`;
const rtt=row.median_rtt_ms==null?'—':`${row.median_rtt_ms.toFixed(0)} ms median / ${row.p95_rtt_ms.toFixed(0)} ms p95`;
stats.textContent=`${row.replies}/${row.sent} replies · ${loss} · ${rtt}`;
line.append(rate,stats);results.append(line);
}
}
function render(s,confirmed=false){ function render(s,confirmed=false){
vehicleSnapshot=s; vehicleSnapshot=s;
if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error || if(confirmed&&localRequest?.sent && (s.pending_command===localRequest.name || s.command_error ||
@@ -402,10 +566,13 @@ function render(s,confirmed=false){
view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError}); view=ControlState.present(s,{stale:bridgeStale,focused:inputFocused,pending:localRequest?.name,error:commandError});
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs(); if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected'; appState=bridgeStale&&s.connected?'link_degraded':s.app_state||'disconnected';
renderHeavyQuality(s.heavy_quality,s.connected,s.armed);
document.body.dataset.appState=appState; document.body.dataset.appState=appState;
document.body.dataset.controlsEnabled=String(view.drive); document.body.dataset.controlsEnabled=String(view.drive);
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale); document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
$('#port').disabled=!!s.connected; $('#port').disabled=!!s.connected;
$('#custom-port-input').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
$('#custom-port-btn').disabled=!!s.connected||$('#link-form').dataset.busy==='true';
const isOnline=(appState==='connected_disarmed'||appState==='armed'); const isOnline=(appState==='connected_disarmed'||appState==='armed');
const connectBtn=$('#connect'); const connectBtn=$('#connect');
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null; const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
@@ -456,6 +623,37 @@ function render(s,confirmed=false){
$('#battery-percent').textContent='waiting'; $('#battery-percent').textContent='waiting';
$('#battery-fill').style.width='0'; $('#battery-fill').style.width='0';
} }
const quality=s.link_quality,qualityCard=$('#link-quality');
if(qualityCard){
if(demoActive){
qualityCard.dataset.grade='offline';
$('#link-quality-grade').textContent='Demo';
$('#link-quality-roundtrip').textContent='no hardware link test';
$('#link-quality-downlink').textContent='';
renderMotors(s.servos||{});
return;
}
const grade=quality?.grade||(s.connected?'testing':'offline');
qualityCard.dataset.grade=grade;
$('#link-quality-grade').textContent=grade.charAt(0).toUpperCase()+grade.slice(1);
if(quality&&quality.probe_samples){
const percent=value=>value==null?'—':`${value.toFixed(1)}%`;
const median=quality.rtt_median_ms==null?'—':`${Math.round(quality.rtt_median_ms)} ms`;
const p95=quality.rtt_p95_ms==null?'—':`${Math.round(quality.rtt_p95_ms)} ms`;
$('#link-quality-roundtrip').textContent=`round trip ${median} / ${p95} p95 • loss ${percent(quality.probe_loss_percent)}`;
const downLoss=percent(quality.rx_sequence_loss_percent);
const badFrames=quality.rx_corrupt_frames_20s??0;
const badBytes=quality.rx_corrupt_bytes_20s??0;
const age=quality.heartbeat_age_s==null?'—':`${quality.heartbeat_age_s.toFixed(1)} s`;
$('#link-quality-downlink').textContent=`FC → PC loss ${downLoss} • ${badFrames} bad frames (${badBytes} B) • heartbeat ${age}`;
if(quality.probe_samples<10||quality.rx_packets_30s<10){
$('#link-quality-grade').textContent=`Testing ${Math.min(quality.probe_samples,quality.rx_packets_30s)}/10`;
}
}else{
$('#link-quality-roundtrip').textContent=s.connected?'testing round trip…':'waiting for connection';
$('#link-quality-downlink').textContent='';
}
}
renderMotors(s.servos||{}); renderMotors(s.servos||{});
} }
@@ -564,8 +762,10 @@ async function boot(){
populatePorts(await withTimeout(api('refresh_ports'),4000)); populatePorts(await withTimeout(api('refresh_ports'),4000));
const init=await withTimeout(api('initial_state'),4000); const init=await withTimeout(api('initial_state'),4000);
batteryConfig=init.battery_config||{}; batteryConfig=init.battery_config||{};
linkConfig=init.link_config||{};
populatePorts(init.ports); populatePorts(init.ports);
fillBatteryForm(); fillBatteryForm();
fillLinkForm();
bridgeReady=true;booted=true;lastBridgeAt=performance.now(); bridgeReady=true;booted=true;lastBridgeAt=performance.now();
if(!demoActive)render(init.state); if(!demoActive)render(init.state);
poll(); poll();
+62 -6
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sub Control</title> <title>Sub Control</title>
<link rel="stylesheet" href="styles.css?v=20260827-model-layout-v14"> <link rel="stylesheet" href="styles.css?v=20260930-dialogs-v1">
</head> </head>
<body> <body>
<main class="app-shell"> <main class="app-shell">
@@ -50,13 +50,19 @@
<b><em id="battery-fill"></em></b> <b><em id="battery-fill"></em></b>
</button> </button>
<button id="link-quality" type="button" class="link-quality" data-grade="offline" title="Open link-quality details" aria-haspopup="dialog" aria-controls="link-quality-modal">
<span class="link-quality-dot" aria-hidden="true"></span>
<span><small>Data link</small><strong id="link-quality-grade">Offline</strong></span>
<i><span id="link-quality-roundtrip">waiting for connection</span><span id="link-quality-downlink"></span></i>
</button>
<div class="deck-status-bar"> <div class="deck-status-bar">
<ol class="activity-history" aria-label="Last three events" aria-live="polite"> <ol class="activity-history" aria-label="Last three events" aria-live="polite">
<li id="activity-oldest" hidden></li> <li id="activity-oldest" hidden></li>
<li id="activity-previous" hidden></li> <li id="activity-previous" hidden></li>
<li id="status-text">Not connected</li> <li id="status-text">Not connected</li>
</ol> </ol>
<header class="activity-heading"><span>Recent activity</span><a id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</a></header> <header class="activity-heading"><span>Recent activity</span><span class="activity-actions"><button type="button" id="console" class="logs-link" title="Read live console" aria-label="Read live console">Console</button><button type="button" id="logs" class="logs-link" title="Open session logs" aria-label="Open session logs">Logs</button></span></header>
</div> </div>
</section> </section>
@@ -146,14 +152,64 @@
<div id="toast" class="toast"></div> <div id="toast" class="toast"></div>
<!-- Modals --> <!-- Modals -->
<dialog id="link-modal" class="modal"><button class="modal-close" data-close>×</button><small>connection</small><h2>Link Settings</h2><div class="form-grid two"><label><span>Baud rate</span><select id="baud"><option>9600</option><option>19200</option><option>38400</option><option selected>57600</option><option>115200</option></select></label><label><span>Control rate</span><select id="rate"><option>10</option><option selected>20</option></select></label></div><div style="margin-top:14px;"><label style="display:flex;flex-direction:column;gap:6px;"><span style="font-size:11px;font-weight:500;color:var(--muted)">Custom Port / Endpoint</span><div style="display:flex;gap:8px;"><input id="custom-port-input" placeholder="e.g. COM3 or tcp:127.0.0.1:5760" style="flex:1;height:32px;padding:0 12px;font-size:11px;"><button type="button" id="custom-port-btn" class="button subtle" style="white-space:nowrap;">Use Port</button></div></label></div><p class="note" style="margin-top:14px;">20 Hz is recommended for smooth intermediate commands on this link.</p></dialog> <dialog id="link-modal" class="modal" aria-labelledby="link-title">
<dialog id="battery-modal" class="modal"><button class="modal-close" data-close>×</button><small>Power</small><h2>Battery Settings</h2><form id="battery-form" class="form-grid three"><label><span>Cells (S)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="cells" type="number" min="1" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Capacity (mAh)</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="capacity_mah" type="number" min="1" step="50"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="c_rating" type="number" min="1" step=".1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Full V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="full_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Empty V / cell</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="empty_v_per_cell" type="number" step=".01"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><label><span>Warning %</span><div class="number-pill"><button type="button" class="num-step down" tabindex="-1">‹</button><input name="warning_percent" type="number" min="0" max="100" step="1"><button type="button" class="num-step up" tabindex="-1">›</button></div></label><div class="modal-actions wide"><button type="button" class="button subtle" data-close>Cancel</button><button class="button connect-button">Save Settings</button></div></form></dialog> <button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link settings</h2>
<dialog id="motors-modal" class="modal motor-modal"><button class="modal-close" data-close>×</button><small>configuration</small><h2>Motor Outputs</h2><p class="note">Function and direction are read one parameter at a time to protect the low-bandwidth link.</p><div class="motor-head"><span>Output</span><span>Function</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div></dialog> <form id="link-form">
<section class="settings-section"><h3>Serial connection</h3>
<div class="form-grid two"><label><span>Baud rate</span><select id="baud" name="baud"><option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option><option value="57600" selected>57600</option><option value="115200">115200</option></select></label><label><span>Control rate · Hz</span><select id="rate" name="control_hz"><option value="10">10</option><option value="20" selected>20</option></select></label></div>
<p class="note">Baud changes apply on the next connection.</p>
<label class="custom-port-label"><span>Custom port or endpoint</span><div class="custom-port-row"><input id="custom-port-input" placeholder="COM3 or tcp:127.0.0.1:5760" autocomplete="off"><button type="button" id="custom-port-btn" class="button subtle">Use port</button></div></label>
<p id="custom-port-status" class="note" role="status"></p>
</section>
<section class="settings-section"><h3>Live updates</h3>
<div class="form-grid two">
<label><span>Quality check · Hz</span><select name="ping_hz"><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Battery / system · Hz</span><select name="system_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>Motor outputs · Hz</span><select name="servo_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>RC channels · Hz</span><select name="rc_hz"><option value="0">Off</option><option value="1">1</option><option value="2" selected>2</option><option value="5">5</option><option value="10">10</option></select></label>
<label><span>ESC telemetry · Hz</span><select name="esc_hz"><option value="0">Off</option><option value="1" selected>1</option><option value="2">2</option><option value="5">5</option><option value="10">10</option></select></label>
</div>
<p class="note">Lower rates leave more bandwidth for controls. Vehicle telemetry rates reset after a reboot.</p>
</section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Apply settings</button></div>
</form>
</dialog>
<dialog id="link-quality-modal" class="modal quality-modal" aria-labelledby="quality-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="quality-title">Link quality</h2>
<p class="note">The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.</p>
<div class="quality-summary"><div><span>Round trip</span><strong id="quality-detail-rtt">—</strong></div><div><span>Probe loss</span><strong id="quality-detail-loss">—</strong></div><div><span>FC → PC loss</span><strong id="quality-detail-downlink">—</strong></div></div>
<section class="heavy-test-panel"><h3>Extended test</h3><p class="note">Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.</p>
<div id="heavy-quality-status" class="heavy-quality-status" role="status">Connect to run an extended test.</div>
<div id="heavy-quality-results" class="heavy-quality-results"></div>
<p id="heavy-quality-error" class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button id="heavy-quality-run" type="button" class="button modal-primary">Run extended test</button><button id="heavy-quality-cancel" type="button" class="button subtle" hidden>Stop test</button></div>
</section>
</dialog>
<dialog id="battery-modal" class="modal" aria-labelledby="battery-title-modal">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Power</small><h2 id="battery-title-modal">Battery settings</h2><p class="note">Charge is estimated from voltage. Capacity and C rating describe your battery.</p>
<form id="battery-form">
<div class="form-grid two"><label><span>Cells · S</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Cells · S">‹</button><input name="cells" type="number" min="1" step="1" max="16" required><button type="button" class="num-step up" aria-label="Increase Cells · S">›</button></div></label><label><span>Capacity · mAh</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Capacity · mAh">‹</button><input name="capacity_mah" type="number" min="50" step="50" required><button type="button" class="num-step up" aria-label="Increase Capacity · mAh">›</button></div></label><label><span>C rating</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease C rating">‹</button><input name="c_rating" type="number" min="1" step="0.1" required><button type="button" class="num-step up" aria-label="Increase C rating">›</button></div></label><label><span>Low charge warning · %</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Low charge warning · %">‹</button><input name="warning_percent" type="number" min="0" step="1" max="100" required><button type="button" class="num-step up" aria-label="Increase Low charge warning · %">›</button></div></label></div>
<section class="settings-section"><h3>Voltage per cell</h3><div class="form-grid two"><label><span>Full · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Full · V">‹</button><input name="full_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Full · V">›</button></div></label><label><span>Empty · V</span><div class="number-pill"><button type="button" class="num-step down" aria-label="Decrease Empty · V">‹</button><input name="empty_v_per_cell" type="number" min="0.01" step="0.01" required><button type="button" class="num-step up" aria-label="Increase Empty · V">›</button></div></label></div></section>
<p class="form-feedback" role="alert" hidden></p>
<div class="modal-actions"><button type="button" class="button subtle" data-close>Cancel</button><button type="submit" class="button modal-primary">Save battery</button></div>
</form>
</dialog>
<dialog id="motors-modal" class="modal motor-modal" aria-labelledby="motors-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Configuration</small><h2 id="motors-title">Motor outputs</h2><p id="motor-status" class="note" role="status">Waiting for motor assignments.</p>
<p id="motor-feedback" class="form-feedback" role="alert" hidden></p>
<div class="motor-head"><span>Output</span><span>Assignment</span><span>Direction</span><span></span></div><div id="motor-list" class="motor-list"></div>
</dialog>
<dialog id="console-modal" class="modal console-modal" aria-labelledby="console-title">
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Local session</small><h2 id="console-title">Live console</h2>
<div class="console-toolbar"><p id="console-state" class="note" role="status">Live · follows new messages</p><button id="console-pause" type="button" class="button subtle" aria-pressed="false">Pause</button></div>
<div class="console-split"><section><header><i class="rx-dot"></i><span>RX</span><small>Vehicle → PC</small></header><pre id="console-rx-output" tabindex="0" aria-label="Received messages">Waiting for vehicle data…</pre></section><section><header><i class="tx-dot"></i><span>TX</span><small>PC → Vehicle</small></header><pre id="console-tx-output" tabindex="0" aria-label="Sent messages">Waiting for transmitted data…</pre></section></div>
</dialog>
<script src="model-viewer.js?v=20260826-controls-v2"></script> <script src="model-viewer.js?v=20260826-controls-v2"></script>
<script src="joystick-3d.js?v=20260827-keyboard-v7"></script> <script src="joystick-3d.js?v=20260827-keyboard-v7"></script>
<script src="control-state.js?v=20260827-state-v1"></script> <script src="control-state.js?v=20260827-state-v1"></script>
<script src="app.js?v=20260827-keyboard-v7"></script> <script src="app.js?v=20260930-dialogs-v1"></script>
<script src="cursor-light.js?v=20260827-directional-v10"></script> <script src="cursor-light.js?v=20260827-directional-v10"></script>
</body> </body>
</html> </html>
+167
View File
@@ -539,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
margin-left: auto; margin-left: auto;
} }
.logs-link:hover { color: #f1f4f2; } .logs-link:hover { color: #f1f4f2; }
.activity-actions { display:flex; align-items:center; gap:12px; }
/* ══════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════
PRIMARY CONTROL ZONE (Workspace) PRIMARY CONTROL ZONE (Workspace)
@@ -554,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
min-width: 0; min-width: 0;
position: relative; position: relative;
} }
/* ── Active MAVLink link test ── */
.link-quality {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-width: 0;
min-height: 48px;
padding: 3px 0;
color: #eef2f0;
width: 100%;
border: 0;
text-align: left;
font: inherit;
background: transparent;
cursor: pointer;
border-radius: 9px;
transition: background-color .18s ease;
}
.link-quality:hover,.link-quality:focus-visible { background:rgba(255,255,255,.055); outline:none; }
.link-quality-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #667074;
}
.link-quality span:not(.link-quality-dot) { display:flex; flex-direction:column; gap:1px; min-width:0; }
.link-quality small { color:#8d979a; font-size:10px; font-weight:400; }
.link-quality strong { font-size:14px; font-weight:500; line-height:1.15; }
.link-quality i {
grid-column: 2;
min-width: 0;
color: #aab2b4;
font-size: 11px;
line-height: 1.3;
font-style: normal;
white-space: normal;
overflow-wrap: anywhere;
}
.link-quality i span { display:block; }
.link-quality[data-grade="excellent"] .link-quality-dot { background:#bce96c; box-shadow:0 0 8px rgba(188,233,108,.35); }
.link-quality[data-grade="good"] .link-quality-dot { background:#85c7e8; }
.link-quality[data-grade="fair"] .link-quality-dot { background:#e7bf69; }
.link-quality[data-grade="poor"] .link-quality-dot { background:#f07f78; box-shadow:0 0 8px rgba(240,127,120,.3); }
.link-quality[data-grade="testing"] .link-quality-dot { background:#aab3b6; }
.quality-modal { width:min(520px,calc(100vw - 32px)); }
.heavy-test-panel { margin-top:18px; padding-top:14px; border-top:1px solid rgba(128,145,145,.2); }
.heavy-quality-status { font-size:14px; font-weight:500; }
.heavy-quality-results { display:grid; gap:6px; margin-top:12px; }
.heavy-result-row { display:flex; justify-content:space-between; gap:12px; align-items:baseline; padding:7px 10px; border-radius:10px; background:rgba(120,140,140,.08); font-size:12px; }
.heavy-result-row strong { flex:none; }
.heavy-result-row span { text-align:right; color:var(--muted); }
.primary-control-zone { .primary-control-zone {
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
@@ -1025,6 +1079,37 @@ body[data-app-state="link_degraded"] button[data-open="motors-modal"] {
cursor: not-allowed; cursor: not-allowed;
} }
.console-modal {
width: min(1040px, calc(100vw - 64px));
max-width: 1040px;
}
.console-split { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:14px; min-width:0; }
.console-split section { min-width:0; }
.console-split header { display:flex; align-items:center; gap:7px; margin:0 2px 7px; }
.console-split header i { width:7px; height:7px; border-radius:50%; flex:0 0 auto; }
.console-split header .rx-dot { background:#79c8eb; }
.console-split header .tx-dot { background:#bce96c; }
.console-split header span { color:#303836; font-size:12px; font-weight:600; }
.console-split header small { margin-left:auto; color:#7d8683; font-size:10px; }
.console-split pre {
height: min(55vh, 480px);
margin: 0;
padding: 14px;
overflow: auto;
border: 1px solid #d9ddda;
border-radius: 12px;
background: #171b1d;
color: #dce4e1;
font: 11px/1.45 Consolas, "SFMono-Regular", monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
user-select: text;
}
@media (max-width: 760px) {
.console-split { grid-template-columns:1fr; }
.console-split pre { height:220px; }
}
/* A pending or unknown armed state must stay legible, even without a link. */ /* A pending or unknown armed state must stay legible, even without a link. */
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; } #engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -1131,3 +1216,85 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
.telemetry-zone { gap:12px; } .telemetry-zone { gap:12px; }
.activity-history { gap:4px; } .activity-history { gap:4px; }
} }
/* Settings windows: readable fields, predictable spacing, one action hierarchy. */
.modal {
color-scheme: light;
width: min(560px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
padding: 28px;
overflow: auto;
overscroll-behavior: contain;
border: 1px solid #dedfdf;
border-radius: 20px;
background: #f6f6f6;
box-shadow: 0 24px 80px rgba(0,0,0,.24);
backdrop-filter: none;
}
.modal[open] { animation: dialog-in .16s ease-out; }
.modal.closing { animation: dialog-out .12s ease-in forwards; }
.modal::backdrop { background: rgba(15,19,22,.4); backdrop-filter: blur(3px); }
.modal > small { display:block; color:#73787b; font-size:12px; font-weight:400; margin:0 42px 6px 0; }
.modal h2 { margin:0 40px 18px 0; font-size:28px; letter-spacing:-.025em; font-weight:400; }
.modal h3 { margin:0 0 10px; font-size:14px; font-weight:500; color:#454b4e; }
.modal .note { font-size:13px; line-height:1.5; margin:8px 0 0; color:#676e71; }
.modal-close { top:16px; right:16px; width:36px; height:36px; display:grid; place-items:center; border-radius:50%; background:#e9ebec; color:#555c60; font-size:25px; line-height:1; }
.modal-close:hover { background:#dfe3e5; }
.settings-section { margin-top:22px; }
.settings-section + .settings-section { border-top:1px solid #e0e3e4; padding-top:20px; }
.modal .form-grid { gap:16px; margin-top:10px; }
.modal .form-grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
.modal label { min-width:0; font-size:13px; line-height:1.35; font-weight:400; color:#50585c; }
.modal label > span { display:block; margin-bottom:7px; }
.modal input,.modal select { width:100%; min-width:0; height:42px; padding:0 13px; border:1px solid #d7dcde; border-radius:999px; background:#fff; color:#272c2f; font-size:14px; font-weight:400; }
.modal select { padding-right:28px; }
.modal :is(button,input,select,summary,pre):focus-visible { outline:2px solid #6598b5; outline-offset:3px; }
.modal .number-pill { height:42px; grid-template-columns:36px minmax(0,1fr) 36px; background:#fff; border:1px solid #d7dcde; }
.modal .number-pill button { width:36px; height:40px; font-size:24px; color:#5b666b; }
.modal .number-pill input { border:0; padding:0; height:40px; appearance:textfield; -moz-appearance:textfield; }
.modal .number-pill input::-webkit-inner-spin-button { appearance:none; }
.modal .number-pill:focus-within { border-color:#6598b5; }
.custom-port-label { display:block; margin-top:16px; }
.custom-port-row { display:flex; gap:8px; }
.custom-port-row input { flex:1; }
.modal .button { min-height:42px; height:auto; padding:10px 18px; font-size:14px; font-weight:400; border-radius:999px; white-space:normal; }
.modal .button.subtle { background:#e9ebec; border:0; color:#424b50; }
.modal .modal-primary { background:#333e44; color:#fff; border:0; }
.modal .modal-primary:hover { background:#455660; }
.modal .modal-actions { margin-top:24px; gap:10px; align-items:center; }
.modal .form-feedback { margin:16px 0 0; padding:10px 12px; border-radius:12px; font-size:13px; line-height:1.5; color:#9b392e; background:#f5e6e3; }
.modal [hidden] { display:none !important; }
.modal [data-busy="true"] { cursor:progress; }
.motor-modal { width:min(650px,calc(100vw - 32px)); }
.modal .motor-head,.modal .motor-row { grid-template-columns:78px minmax(100px,1fr) 94px 86px; font-size:13px; gap:10px; padding:10px 0; }
.modal .motor-head { margin-top:18px; color:#727b80; font-size:12px; font-weight:400; }
.motor-row strong { font-size:12px; font-weight:500; }
.motor-row > span { font-size:12px; color:#727b80; }
.modal .motor-row .button { min-height:36px; font-size:12px; padding:8px 12px; }
.motor-other { margin-top:16px; }
.motor-other summary { cursor:pointer; padding:12px 0; color:#697278; font-size:13px; }
.quality-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:10px; margin-top:18px; }
.quality-summary div { border-radius:14px; background:#e9ebec; padding:14px 12px; min-width:0; }
.quality-summary span { display:block; font-size:12px; color:#697278; margin-bottom:8px; }
.quality-summary strong { font-size:20px; font-weight:400; font-variant-numeric:tabular-nums; }
.modal .heavy-quality-status { padding:12px; background:#e9ebec; border-radius:12px; font-size:14px; margin-top:14px; }
.modal .heavy-result-row { font-size:13px; padding:10px 12px; font-variant-numeric:tabular-nums; }
.console-modal { width:min(1020px,calc(100vw - 32px)); }
.console-toolbar { display:flex; justify-content:space-between; align-items:center; gap:12px; }
.console-toolbar .note { margin:0; }
.modal .console-split header small { font-size:12px; }
.modal .console-split pre { height:clamp(180px,48vh,430px); padding:14px; font-size:12px; line-height:1.55; border-radius:12px; }
@keyframes dialog-in { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
@keyframes dialog-out { to { opacity:0; transform:translateY(5px); } }
@media (max-width:520px) {
.modal { padding:22px 18px; }
.modal .form-grid.two { grid-template-columns:1fr; }
.modal .motor-head,.modal .motor-row { grid-template-columns:56px minmax(80px,1fr) 74px 66px; gap:5px; font-size:12px; }
.modal .motor-row .button { padding:8px 6px; }
.quality-summary { gap:6px; }
.quality-summary div { padding:12px 8px; }
.quality-summary strong { font-size:17px; }
}
@media (prefers-reduced-motion:reduce) {
.modal[open],.modal.closing { animation:none; }
}
+64 -3
View File
@@ -40,12 +40,15 @@ class ControlApi:
"app_state": "disconnected", "app_state": "disconnected",
"status": "Ready. Select a USB port to begin.", "status": "Ready. Select a USB port to begin.",
"battery": None, "battery": None,
"link_quality": None,
"heavy_quality": None,
"log_path": None, "log_path": None,
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)}, "servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
} }
self.servo_functions: dict[int, int] = {} self.servo_functions: dict[int, int] = {}
self.motor_directions: dict[int, int] = {} self.motor_directions: dict[int, int] = {}
self.battery_path = Path(__file__).resolve().parent / "battery_settings.json" self.battery_path = Path(__file__).resolve().parent / "battery_settings.json"
self.link_path = Path(__file__).resolve().parent / "link_settings.json"
self.model_path = Path(__file__).resolve().parent / "model_settings.json" self.model_path = Path(__file__).resolve().parent / "model_settings.json"
self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl" self.model_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt" self.model_cache_name = Path(__file__).resolve().parent / "runtime" / "vehicle_model_name.txt"
@@ -62,11 +65,22 @@ class ControlApi:
self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8"))) self.battery_config.update(json.loads(self.battery_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError): except (OSError, ValueError, TypeError):
pass pass
self.link_config = {
"baud": 57600, "control_hz": 20,
"ping_hz": 1, "system_hz": 1, "servo_hz": 2,
"rc_hz": 2, "esc_hz": 1,
}
try:
self.link_config.update(json.loads(self.link_path.read_text(encoding="utf-8")))
except (OSError, ValueError, TypeError):
pass
self._apply_link_config()
def initial_state(self): def initial_state(self):
return { return {
"ports": self.refresh_ports(), "ports": self.refresh_ports(),
"battery_config": self.battery_config, "battery_config": self.battery_config,
"link_config": self.link_config,
"state": self._snapshot(), "state": self._snapshot(),
"model_info": self.get_saved_model_info(), "model_info": self.get_saved_model_info(),
} }
@@ -233,6 +247,9 @@ class ControlApi:
self.state.update( self.state.update(
connected=True, link="LINK OK", vehicle="DEMO • DISARMED", connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.", armed=False, mode="MANUAL", status="Demo mode ready — no hardware connected.",
link_quality={"grade": "excellent", "latency_ms": 12.0,
"loss_percent": 0.0, "samples": 20,
"corrupt_frames_20s": 0, "heartbeat_age_s": 0.1},
battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72, battery={"voltage": 15.8, "per_cell": 3.95, "percent": 72,
"cells": 4, "low": False}, "cells": 4, "low": False},
) )
@@ -268,15 +285,26 @@ class ControlApi:
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds") raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
def disconnect(self): def disconnect(self):
if self.worker.heavy_quality_test is not None:
self.worker.cancel_heavy_quality_test()
if self.demo_mode: if self.demo_mode:
self.demo_mode = False self.demo_mode = False
with self.lock: with self.lock:
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
armed=False, mode=None, battery=None, status="Demo mode disconnected") armed=False, mode=None, battery=None, link_quality=None,
status="Demo mode disconnected")
return True return True
self.worker.disconnect() self.worker.disconnect()
return True return True
def start_heavy_quality_test(self):
if self.demo_mode:
raise RuntimeError("The heavy link test requires a real flight-controller connection")
return self.worker.start_heavy_quality_test()
def cancel_heavy_quality_test(self):
return self.worker.cancel_heavy_quality_test()
def set_axes(self, forward, turn, vertical, sequence=None): def set_axes(self, forward, turn, vertical, sequence=None):
if self.demo_mode: if self.demo_mode:
with self.lock: with self.lock:
@@ -389,8 +417,33 @@ class ControlApi:
raise ValueError("Full voltage per cell must be greater than empty voltage") raise ValueError("Full voltage per cell must be greater than empty voltage")
if not 0 <= clean["warning_percent"] <= 100: if not 0 <= clean["warning_percent"] <= 100:
raise ValueError("Warning percentage must be between 0 and 100") raise ValueError("Warning percentage must be between 0 and 100")
self.battery_config = clean
self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8") self.battery_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.battery_config = clean
return clean
def _apply_link_config(self):
self.worker.rate_hz = self.link_config["control_hz"]
self.worker.set_link_rates(self.link_config["ping_hz"], {
"SYS_STATUS": self.link_config["system_hz"],
"SERVO_OUTPUT_RAW": self.link_config["servo_hz"],
"RC_CHANNELS": self.link_config["rc_hz"],
"ESC_TELEMETRY_1_TO_4": self.link_config["esc_hz"],
})
def save_link_settings(self, config):
allowed = {0, 1, 2, 5, 10}
clean = {key: int(config.get(key, value)) for key, value in self.link_config.items()}
if clean["baud"] not in {9600, 19200, 38400, 57600, 115200}:
raise ValueError("Choose a supported baud rate")
if clean["control_hz"] not in {10, 20}:
raise ValueError("Control rate must be 10 or 20 Hz")
if clean["ping_hz"] not in {1, 2, 5, 10}:
raise ValueError("Link test rate must be 1, 2, 5 or 10 Hz")
if any(clean[key] not in allowed for key in ("system_hz", "servo_hz", "rc_hz", "esc_hz")):
raise ValueError("Telemetry rates must be Off, 1, 2, 5 or 10 Hz")
self.link_path.write_text(json.dumps(clean, indent=2) + "\n", encoding="utf-8")
self.link_config = clean
self._apply_link_config()
return clean return clean
def open_logs(self): def open_logs(self):
@@ -399,6 +452,10 @@ class ControlApi:
os.startfile(path) os.startfile(path)
return True return True
def read_console(self, limit=150):
"""Return a bounded, read-only view of the local session console."""
return self.worker.read_console(limit)
def poll(self): def poll(self):
# Bound a poll even if telemetry arrives continuously. # Bound a poll even if telemetry arrives continuously.
with self._poll_lock: with self._poll_lock:
@@ -468,6 +525,10 @@ class ControlApi:
"voltage": voltage, "per_cell": per_cell, "percent": percent, "voltage": voltage, "per_cell": per_cell, "percent": percent,
"cells": cells, "low": percent <= self.battery_config["warning_percent"], "cells": cells, "low": percent <= self.battery_config["warning_percent"],
} }
elif kind == "link_quality":
self.state["link_quality"] = event[1]
elif kind == "heavy_quality":
self.state["heavy_quality"] = event[1]
elif kind == "servo_param": elif kind == "servo_param":
_, param_id, value = event _, param_id, value = event
servo = int(param_id[5:param_id.index("_")]) servo = int(param_id[5:param_id.index("_")])
@@ -490,7 +551,7 @@ class ControlApi:
last_err = self.state.get("last_error") last_err = self.state.get("last_error")
status_msg = last_err if last_err else "Disconnected" status_msg = last_err if last_err else "Disconnected"
self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat", self.state.update(connected=False, link="DISCONNECTED", vehicle="No heartbeat",
mode=None, status=status_msg) mode=None, link_quality=None, status=status_msg)
self.state["last_error"] = None self.state["last_error"] = None
def _update_servo(self, servo): def _update_servo(self, servo):