Clean up UI windows and strengthen settings workflows
This commit is contained in:
@@ -3,4 +3,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
|
||||
@@ -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.
|
||||
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
|
||||
Connection settings are under **•••**.
|
||||
Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
@@ -3,4 +3,5 @@ __pycache__/
|
||||
*.py[cod]
|
||||
logs/
|
||||
model_settings.json
|
||||
link_settings.json
|
||||
runtime/
|
||||
|
||||
@@ -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.
|
||||
Select a COM port and **Connect**, or choose **Demo Mode** to try it without hardware.
|
||||
Connection settings are under **•••**.
|
||||
Click **Data link** for an extended connection test, or **Console** to inspect RX/TX messages and pause the view.
|
||||
|
||||
## Controls
|
||||
|
||||
|
||||
+447
-32
@@ -13,6 +13,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
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 serial.tools import list_ports
|
||||
|
||||
@@ -67,6 +70,10 @@ class MavlinkWorker:
|
||||
self.connection = None
|
||||
self.stop_event = threading.Event()
|
||||
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.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
@@ -87,6 +94,34 @@ class MavlinkWorker:
|
||||
self.last_rx = 0.0
|
||||
self.armed = 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_waiting = None
|
||||
self.param_deadline = 0.0
|
||||
@@ -96,6 +131,9 @@ class MavlinkWorker:
|
||||
self.log_lock = threading.Lock()
|
||||
self.log_file = 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):
|
||||
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)
|
||||
normalized = normalize_endpoint(port)
|
||||
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.last_heartbeat = 0.0
|
||||
self.last_rx = 0.0
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._run, args=(normalized, baud), daemon=True
|
||||
)
|
||||
@@ -137,7 +179,7 @@ class MavlinkWorker:
|
||||
|
||||
# Send initial GCS heartbeat immediately
|
||||
self.log("TX_HEARTBEAT", "GCS initial")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
@@ -149,17 +191,32 @@ class MavlinkWorker:
|
||||
now = time.monotonic()
|
||||
if now >= next_gcs_hb:
|
||||
self.log("TX_HEARTBEAT", "GCS")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
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()
|
||||
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:
|
||||
self._tick_arm_retry(now)
|
||||
self._tick_link_probe(now)
|
||||
self._tick_stream_config(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)
|
||||
if msg:
|
||||
@@ -170,6 +227,8 @@ class MavlinkWorker:
|
||||
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||
self.events.put(("error", f"Connection error: {exc}"))
|
||||
finally:
|
||||
if self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
self._safe_stop()
|
||||
if self.connection:
|
||||
try:
|
||||
@@ -180,6 +239,19 @@ class MavlinkWorker:
|
||||
self.target_system = 0
|
||||
self.armed = 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")
|
||||
with self.log_lock:
|
||||
if self.log_file:
|
||||
@@ -189,22 +261,71 @@ class MavlinkWorker:
|
||||
|
||||
def log(self, category: str, message: str):
|
||||
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
line = f"{timestamp} [{category}] {message}"
|
||||
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:
|
||||
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):
|
||||
msg_type = msg.get_type()
|
||||
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"")))
|
||||
return
|
||||
self.last_rx = time.monotonic()
|
||||
self._track_rx_delivery(msg)
|
||||
try:
|
||||
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
|
||||
except Exception:
|
||||
payload = str(msg)
|
||||
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:
|
||||
return
|
||||
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)
|
||||
self.armed = 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:
|
||||
mode = mavutil.mode_string_v10(msg)
|
||||
except Exception:
|
||||
@@ -224,6 +349,7 @@ class MavlinkWorker:
|
||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||
if first:
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||
self._configure_low_bandwidth_streams()
|
||||
self._start_servo_scan()
|
||||
@@ -232,12 +358,21 @@ class MavlinkWorker:
|
||||
return
|
||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(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))
|
||||
elif msg_type == "STATUSTEXT":
|
||||
text = msg.text
|
||||
if isinstance(text, bytes):
|
||||
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":
|
||||
voltage_mv = int(msg.voltage_battery)
|
||||
if 0 < voltage_mv < 65535:
|
||||
@@ -264,6 +399,238 @@ class MavlinkWorker:
|
||||
self.events.put(("motor_direction", int(parts[1]),
|
||||
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):
|
||||
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
|
||||
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
|
||||
@@ -284,31 +651,52 @@ class MavlinkWorker:
|
||||
self.param_retries = 0
|
||||
|
||||
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:
|
||||
return
|
||||
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,
|
||||
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
|
||||
)
|
||||
# Heartbeat and STATUSTEXT do not need to be requested. Keep only
|
||||
# output/input evidence needed to diagnose controls, at 2 Hz each.
|
||||
for message_id, interval_us, name in (
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"),
|
||||
):
|
||||
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
|
||||
self.connection.mav.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.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…"))
|
||||
# ATTITUDE and VFR_HUD interval commands were accepted by this FC.
|
||||
# AHRS2 is also unneeded, but an acknowledgement has not been seen.
|
||||
# NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
|
||||
# A single interval command is sometimes lost, so send two paced
|
||||
# passes. This does not change persistent parameters or onboard logs.
|
||||
unwanted = (
|
||||
"ATTITUDE", "AHRS2", "VFR_HUD",
|
||||
)
|
||||
self.stream_config = deque(
|
||||
(name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
|
||||
for _ in range(2) for name in unwanted
|
||||
)
|
||||
for name, rate_hz in self.telemetry_rates_hz.items():
|
||||
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):
|
||||
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
|
||||
if self.param_waiting is not None:
|
||||
if self.param_retries < 2:
|
||||
@@ -329,7 +717,7 @@ class MavlinkWorker:
|
||||
|
||||
def _request_param(self, name: str):
|
||||
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
|
||||
)
|
||||
|
||||
@@ -343,7 +731,7 @@ class MavlinkWorker:
|
||||
raise RuntimeError(f"{name} has not been read from the autopilot")
|
||||
self.pending_sets[name] = int(value)
|
||||
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"),
|
||||
float(value), param_type,
|
||||
)
|
||||
@@ -396,7 +784,7 @@ class MavlinkWorker:
|
||||
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})",
|
||||
)
|
||||
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
|
||||
def _approach(current: float, target: float, max_change: float) -> float:
|
||||
@@ -409,18 +797,45 @@ class MavlinkWorker:
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
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:
|
||||
self.disarm_requested_until = now + 6.0
|
||||
self._send_control(force_neutral=True)
|
||||
else:
|
||||
# Give the ARM request priority over the low-rate parameter scan.
|
||||
self.config_paused = True
|
||||
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,
|
||||
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):
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
@@ -430,7 +845,7 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
@@ -467,7 +882,7 @@ class MavlinkWorker:
|
||||
if self.connection and self.target_system:
|
||||
try:
|
||||
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,
|
||||
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""No serial port is opened by these tests."""
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app import Axes, MavlinkWorker, NEUTRAL_Z
|
||||
@@ -100,9 +103,18 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
|
||||
def assertNeutral(self):
|
||||
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))
|
||||
|
||||
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):
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
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._send_control()
|
||||
# 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))
|
||||
|
||||
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.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):
|
||||
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
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 activity=[];
|
||||
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,
|
||||
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.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;
|
||||
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)}
|
||||
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)}
|
||||
window.showToast=toast;
|
||||
const manualPorts=new Map();
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
@@ -50,6 +51,7 @@ function populatePorts(ports){
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
manualPorts.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
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})};
|
||||
|
||||
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(){
|
||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||
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()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function setFeedback(el,message=''){
|
||||
if(!el)return;
|
||||
el.textContent=message;el.hidden=!message;
|
||||
}
|
||||
function openModal(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');
|
||||
if(!el.open)el.showModal();
|
||||
el.showModal();
|
||||
}
|
||||
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');
|
||||
let done=false;
|
||||
const finish=()=>{
|
||||
if(done)return;
|
||||
done=true;
|
||||
dialog.removeEventListener('animationend',finish);
|
||||
dialog.removeEventListener('animationend',onEnd);
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
|
||||
};
|
||||
dialog.addEventListener('animationend',finish,{once:true});
|
||||
setTimeout(finish,300);
|
||||
const onEnd=e=>{if(e.target===dialog)finish()};
|
||||
dialog.addEventListener('animationend',onEnd);
|
||||
setTimeout(finish,180);
|
||||
}
|
||||
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||
$$('[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=>{
|
||||
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||
d.addEventListener('click',e=>{
|
||||
@@ -255,7 +291,7 @@ $('#connect').onclick=async()=>{
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
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);
|
||||
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||
}catch(e){
|
||||
@@ -270,7 +306,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
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');
|
||||
let exists=false;
|
||||
for(const opt of p.options){
|
||||
@@ -280,8 +318,7 @@ if(customPortBtn&&customPortInput){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
closeModal($('#link-modal'));
|
||||
toast(`Selected port: ${val}`);
|
||||
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
@@ -380,6 +417,34 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#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)}
|
||||
$('#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
|
||||
@@ -391,9 +456,108 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['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))}});
|
||||
$('#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 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){
|
||||
vehicleSnapshot=s;
|
||||
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});
|
||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||
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.controlsEnabled=String(view.drive);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#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 connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -456,6 +623,37 @@ function render(s,confirmed=false){
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#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||{});
|
||||
}
|
||||
|
||||
@@ -564,8 +762,10 @@ async function boot(){
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
linkConfig=init.link_config||{};
|
||||
populatePorts(init.ports);
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -50,13 +50,19 @@
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</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">
|
||||
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||
<li id="activity-oldest" hidden></li>
|
||||
<li id="activity-previous" hidden></li>
|
||||
<li id="status-text">Not connected</li>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -146,14 +152,64 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- 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="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>
|
||||
<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>
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link settings</h2>
|
||||
<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="joystick-3d.js?v=20260827-keyboard-v7"></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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -539,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
margin-left: auto;
|
||||
}
|
||||
.logs-link:hover { color: #f1f4f2; }
|
||||
.activity-actions { display:flex; align-items:center; gap:12px; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
PRIMARY CONTROL ZONE (Workspace)
|
||||
@@ -554,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
min-width: 0;
|
||||
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 {
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
@@ -1131,3 +1216,85 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
.telemetry-zone { gap:12px; }
|
||||
.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
@@ -40,12 +40,15 @@ class ControlApi:
|
||||
"app_state": "disconnected",
|
||||
"status": "Ready. Select a USB port to begin.",
|
||||
"battery": None,
|
||||
"link_quality": None,
|
||||
"heavy_quality": None,
|
||||
"log_path": None,
|
||||
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||
}
|
||||
self.servo_functions: dict[int, int] = {}
|
||||
self.motor_directions: dict[int, int] = {}
|
||||
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_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
||||
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")))
|
||||
except (OSError, ValueError, TypeError):
|
||||
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):
|
||||
return {
|
||||
"ports": self.refresh_ports(),
|
||||
"battery_config": self.battery_config,
|
||||
"link_config": self.link_config,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
@@ -233,6 +247,9 @@ class ControlApi:
|
||||
self.state.update(
|
||||
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
|
||||
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,
|
||||
"cells": 4, "low": False},
|
||||
)
|
||||
@@ -268,15 +285,26 @@ class ControlApi:
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
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
|
||||
self.worker.disconnect()
|
||||
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):
|
||||
if self.demo_mode:
|
||||
with self.lock:
|
||||
@@ -389,8 +417,33 @@ class ControlApi:
|
||||
raise ValueError("Full voltage per cell must be greater than empty voltage")
|
||||
if not 0 <= clean["warning_percent"] <= 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_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
|
||||
|
||||
def open_logs(self):
|
||||
@@ -399,6 +452,10 @@ class ControlApi:
|
||||
os.startfile(path)
|
||||
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):
|
||||
# Bound a poll even if telemetry arrives continuously.
|
||||
with self._poll_lock:
|
||||
@@ -468,6 +525,10 @@ class ControlApi:
|
||||
"voltage": voltage, "per_cell": per_cell, "percent": 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":
|
||||
_, param_id, value = event
|
||||
servo = int(param_id[5:param_id.index("_")])
|
||||
@@ -490,7 +551,7 @@ class ControlApi:
|
||||
last_err = self.state.get("last_error")
|
||||
status_msg = last_err if last_err else "Disconnected"
|
||||
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
|
||||
|
||||
def _update_servo(self, servo):
|
||||
|
||||
@@ -13,6 +13,9 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
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 serial.tools import list_ports
|
||||
|
||||
@@ -67,6 +70,10 @@ class MavlinkWorker:
|
||||
self.connection = None
|
||||
self.stop_event = threading.Event()
|
||||
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.axes = Axes()
|
||||
self.output_axes = Axes()
|
||||
@@ -87,6 +94,34 @@ class MavlinkWorker:
|
||||
self.last_rx = 0.0
|
||||
self.armed = 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_waiting = None
|
||||
self.param_deadline = 0.0
|
||||
@@ -96,6 +131,9 @@ class MavlinkWorker:
|
||||
self.log_lock = threading.Lock()
|
||||
self.log_file = 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):
|
||||
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)
|
||||
normalized = normalize_endpoint(port)
|
||||
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.last_heartbeat = 0.0
|
||||
self.last_rx = 0.0
|
||||
self.next_quality_publish_at = 0.0
|
||||
self.thread = threading.Thread(
|
||||
target=self._run, args=(normalized, baud), daemon=True
|
||||
)
|
||||
@@ -137,7 +179,7 @@ class MavlinkWorker:
|
||||
|
||||
# Send initial GCS heartbeat immediately
|
||||
self.log("TX_HEARTBEAT", "GCS initial")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
@@ -149,17 +191,32 @@ class MavlinkWorker:
|
||||
now = time.monotonic()
|
||||
if now >= next_gcs_hb:
|
||||
self.log("TX_HEARTBEAT", "GCS")
|
||||
conn.mav.heartbeat_send(
|
||||
self._send_mav(conn, "heartbeat_send",
|
||||
mavutil.mavlink.MAV_TYPE_GCS,
|
||||
mavutil.mavlink.MAV_AUTOPILOT_INVALID,
|
||||
0, 0, mavutil.mavlink.MAV_STATE_ACTIVE,
|
||||
)
|
||||
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()
|
||||
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:
|
||||
self._tick_arm_retry(now)
|
||||
self._tick_link_probe(now)
|
||||
self._tick_stream_config(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)
|
||||
if msg:
|
||||
@@ -170,6 +227,8 @@ class MavlinkWorker:
|
||||
print(f"[MavlinkWorker._run] EXCEPTION: {exc}")
|
||||
self.events.put(("error", f"Connection error: {exc}"))
|
||||
finally:
|
||||
if self.heavy_quality_test is not None:
|
||||
self.cancel_heavy_quality_test()
|
||||
self._safe_stop()
|
||||
if self.connection:
|
||||
try:
|
||||
@@ -180,6 +239,19 @@ class MavlinkWorker:
|
||||
self.target_system = 0
|
||||
self.armed = 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")
|
||||
with self.log_lock:
|
||||
if self.log_file:
|
||||
@@ -189,22 +261,71 @@ class MavlinkWorker:
|
||||
|
||||
def log(self, category: str, message: str):
|
||||
timestamp = datetime.now().astimezone().isoformat(timespec="milliseconds")
|
||||
line = f"{timestamp} [{category}] {message}"
|
||||
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:
|
||||
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):
|
||||
msg_type = msg.get_type()
|
||||
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"")))
|
||||
return
|
||||
self.last_rx = time.monotonic()
|
||||
self._track_rx_delivery(msg)
|
||||
try:
|
||||
payload = json.dumps(msg.to_dict(), default=str, separators=(",", ":"))
|
||||
except Exception:
|
||||
payload = str(msg)
|
||||
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:
|
||||
return
|
||||
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)
|
||||
self.armed = 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:
|
||||
mode = mavutil.mode_string_v10(msg)
|
||||
except Exception:
|
||||
@@ -224,6 +349,7 @@ class MavlinkWorker:
|
||||
ap_name = autopilot.name if autopilot else str(msg.autopilot)
|
||||
self.events.put(("heartbeat", self.target_system, ap_name, mode, armed, self.last_heartbeat))
|
||||
if first:
|
||||
self.next_ping_at = time.monotonic()
|
||||
self.events.put(("status", "Heartbeat received; controls are neutral."))
|
||||
self._configure_low_bandwidth_streams()
|
||||
self._start_servo_scan()
|
||||
@@ -232,12 +358,21 @@ class MavlinkWorker:
|
||||
return
|
||||
result = mavutil.mavlink.enums["MAV_RESULT"].get(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))
|
||||
elif msg_type == "STATUSTEXT":
|
||||
text = msg.text
|
||||
if isinstance(text, bytes):
|
||||
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":
|
||||
voltage_mv = int(msg.voltage_battery)
|
||||
if 0 < voltage_mv < 65535:
|
||||
@@ -264,6 +399,239 @@ class MavlinkWorker:
|
||||
self.events.put(("motor_direction", int(parts[1]),
|
||||
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):
|
||||
names = [f"SERVO{number}_FUNCTION" for number in range(1, 17)]
|
||||
# ArduSub reverses mixer motors with MOT_n_DIRECTION (1 normal,
|
||||
@@ -284,31 +652,52 @@ class MavlinkWorker:
|
||||
self.param_retries = 0
|
||||
|
||||
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:
|
||||
return
|
||||
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,
|
||||
mavutil.mavlink.MAV_DATA_STREAM_ALL, 0, 0,
|
||||
)
|
||||
# Heartbeat and STATUSTEXT do not need to be requested. Keep only
|
||||
# output/input evidence needed to diagnose controls, at 2 Hz each.
|
||||
for message_id, interval_us, name in (
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS, 1_000_000, "SYS_STATUS"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW, 500_000, "SERVO_OUTPUT_RAW"),
|
||||
(mavutil.mavlink.MAVLINK_MSG_ID_RC_CHANNELS, 500_000, "RC_CHANNELS"),
|
||||
):
|
||||
self.log("TX_STREAM_CONFIG", f"{name} interval_us={interval_us}")
|
||||
self.connection.mav.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.events.put(("status", "Low-bandwidth telemetry configured; reading servo setup…"))
|
||||
# ATTITUDE and VFR_HUD interval commands were accepted by this FC.
|
||||
# AHRS2 is also unneeded, but an acknowledgement has not been seen.
|
||||
# NAMED_VALUE_FLOAT explicitly returned DENIED, so do not retry it.
|
||||
# A single interval command is sometimes lost, so send two paced
|
||||
# passes. This does not change persistent parameters or onboard logs.
|
||||
unwanted = (
|
||||
"ATTITUDE", "AHRS2", "VFR_HUD",
|
||||
)
|
||||
self.stream_config = deque(
|
||||
(name, getattr(mavutil.mavlink, f"MAVLINK_MSG_ID_{name}"), -1)
|
||||
for _ in range(2) for name in unwanted
|
||||
)
|
||||
for name, rate_hz in self.telemetry_rates_hz.items():
|
||||
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):
|
||||
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
|
||||
if self.param_waiting is not None:
|
||||
if self.param_retries < 2:
|
||||
@@ -329,7 +718,7 @@ class MavlinkWorker:
|
||||
|
||||
def _request_param(self, name: str):
|
||||
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
|
||||
)
|
||||
|
||||
@@ -343,7 +732,7 @@ class MavlinkWorker:
|
||||
raise RuntimeError(f"{name} has not been read from the autopilot")
|
||||
self.pending_sets[name] = int(value)
|
||||
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"),
|
||||
float(value), param_type,
|
||||
)
|
||||
@@ -396,7 +785,7 @@ class MavlinkWorker:
|
||||
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})",
|
||||
)
|
||||
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
|
||||
def _approach(current: float, target: float, max_change: float) -> float:
|
||||
@@ -409,18 +798,45 @@ class MavlinkWorker:
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
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:
|
||||
self.disarm_requested_until = now + 6.0
|
||||
self._send_control(force_neutral=True)
|
||||
else:
|
||||
# Give the ARM request priority over the low-rate parameter scan.
|
||||
self.config_paused = True
|
||||
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,
|
||||
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):
|
||||
conn = self.connection
|
||||
if not conn or not self.target_system:
|
||||
@@ -430,7 +846,7 @@ class MavlinkWorker:
|
||||
if manual is None:
|
||||
raise RuntimeError("MANUAL mode is not advertised by this firmware")
|
||||
self.log("TX_MODE", f"MANUAL custom_mode={manual}")
|
||||
conn.mav.set_mode_send(
|
||||
self._send_mav(conn, "set_mode_send",
|
||||
self.target_system,
|
||||
mavutil.mavlink.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED,
|
||||
manual,
|
||||
@@ -467,7 +883,7 @@ class MavlinkWorker:
|
||||
if self.connection and self.target_system:
|
||||
try:
|
||||
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,
|
||||
mavutil.mavlink.MAV_CMD_COMPONENT_ARM_DISARM,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
|
||||
+168
-2
@@ -1,6 +1,9 @@
|
||||
"""No serial port is opened by these tests."""
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app import Axes, MavlinkWorker, NEUTRAL_Z
|
||||
@@ -100,9 +103,18 @@ class WorkerWatchdogTests(unittest.TestCase):
|
||||
|
||||
def assertNeutral(self):
|
||||
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))
|
||||
|
||||
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):
|
||||
with patch("app.time.monotonic", return_value=10):
|
||||
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._send_control()
|
||||
# 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))
|
||||
|
||||
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.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):
|
||||
def test_delayed_arm_after_focus_reset_or_expiry_is_not_sent(self):
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@ const STATUS_LABELS = {
|
||||
link_degraded: 'Signal lost — check tether',
|
||||
};
|
||||
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 activity=[];
|
||||
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,
|
||||
bridgeStale=false,lastBridgeAt=performance.now(),axisBusy=false,inputSequence=Date.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;
|
||||
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)}
|
||||
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)}
|
||||
window.showToast=toast;
|
||||
const manualPorts=new Map();
|
||||
function populatePorts(ports){
|
||||
const p=$('#port');
|
||||
if(!p)return;
|
||||
@@ -50,6 +51,7 @@ function populatePorts(ports){
|
||||
};
|
||||
|
||||
if(Array.isArray(ports)) ports.forEach(addPort);
|
||||
manualPorts.forEach(addPort);
|
||||
addPort({device:'demo', label:'Demo Mode (no hardware)'});
|
||||
|
||||
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})};
|
||||
|
||||
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(){
|
||||
if(gainPointer!==null)releaseGain({pointerId:gainPointer});
|
||||
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()));
|
||||
addEventListener('pagehide',()=>focusChanged(false));
|
||||
|
||||
function setFeedback(el,message=''){
|
||||
if(!el)return;
|
||||
el.textContent=message;el.hidden=!message;
|
||||
}
|
||||
function openModal(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');
|
||||
if(!el.open)el.showModal();
|
||||
el.showModal();
|
||||
}
|
||||
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');
|
||||
let done=false;
|
||||
const finish=()=>{
|
||||
if(done)return;
|
||||
done=true;
|
||||
dialog.removeEventListener('animationend',finish);
|
||||
dialog.removeEventListener('animationend',onEnd);
|
||||
dialog.classList.remove('closing');
|
||||
dialog.close();
|
||||
if(dialog._returnFocus?.isConnected)dialog._returnFocus.focus({preventScroll:true});
|
||||
};
|
||||
dialog.addEventListener('animationend',finish,{once:true});
|
||||
setTimeout(finish,300);
|
||||
const onEnd=e=>{if(e.target===dialog)finish()};
|
||||
dialog.addEventListener('animationend',onEnd);
|
||||
setTimeout(finish,180);
|
||||
}
|
||||
$$('[data-open]').forEach(b=>b.onclick=()=>openModal(b.dataset.open));
|
||||
$$('[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=>{
|
||||
d.addEventListener('cancel',e=>{e.preventDefault();closeModal(d)});
|
||||
d.addEventListener('click',e=>{
|
||||
@@ -255,7 +291,7 @@ $('#connect').onclick=async()=>{
|
||||
recordActivity(`Connecting to ${port}…`);
|
||||
toast(`Connecting to ${port}…`);
|
||||
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);
|
||||
toast(confirmedState?.armed_known?'MAVLink connected':'Waiting for vehicle telemetry',!confirmedState?.armed_known);
|
||||
}catch(e){
|
||||
@@ -270,7 +306,9 @@ const customPortBtn=$('#custom-port-btn'), customPortInput=$('#custom-port-input
|
||||
if(customPortBtn&&customPortInput){
|
||||
const applyCustomPort=()=>{
|
||||
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');
|
||||
let exists=false;
|
||||
for(const opt of p.options){
|
||||
@@ -280,8 +318,7 @@ if(customPortBtn&&customPortInput){
|
||||
p.add(new Option(`Custom (${val})`, val), 0);
|
||||
p.value=val;
|
||||
}
|
||||
closeModal($('#link-modal'));
|
||||
toast(`Selected port: ${val}`);
|
||||
$('#custom-port-status').textContent=`Selected ${val}. Apply settings, then connect.`;
|
||||
};
|
||||
customPortBtn.onclick=applyCustomPort;
|
||||
customPortInput.onkeydown=e=>{if(e.key==='Enter'){e.preventDefault();applyCustomPort();}};
|
||||
@@ -380,6 +417,34 @@ if(engageButton){
|
||||
}
|
||||
|
||||
$('#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)}
|
||||
$('#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
|
||||
@@ -391,9 +456,108 @@ gainSlider.addEventListener('pointerdown',e=>{if(gainPointer!==null||e.button>0)
|
||||
gainSlider.addEventListener('pointermove',e=>{if(e.pointerId===gainPointer)gainFromPointer(e)});
|
||||
['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))}});
|
||||
$('#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 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){
|
||||
vehicleSnapshot=s;
|
||||
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});
|
||||
if((previous.drive&&!view.drive)||(previous.action==='arm'&&view.action!=='arm'))resetInputs();
|
||||
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.controlsEnabled=String(view.drive);
|
||||
document.body.dataset.telemetryStale=String(!view.known||!!s.battery_stale);
|
||||
$('#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 connectBtn=$('#connect');
|
||||
const connectLabel=connectBtn?connectBtn.querySelector('.btn-label'):null;
|
||||
@@ -456,6 +623,37 @@ function render(s,confirmed=false){
|
||||
$('#battery-percent').textContent='waiting';
|
||||
$('#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||{});
|
||||
}
|
||||
|
||||
@@ -564,8 +762,10 @@ async function boot(){
|
||||
populatePorts(await withTimeout(api('refresh_ports'),4000));
|
||||
const init=await withTimeout(api('initial_state'),4000);
|
||||
batteryConfig=init.battery_config||{};
|
||||
linkConfig=init.link_config||{};
|
||||
populatePorts(init.ports);
|
||||
fillBatteryForm();
|
||||
fillLinkForm();
|
||||
bridgeReady=true;booted=true;lastBridgeAt=performance.now();
|
||||
if(!demoActive)render(init.state);
|
||||
poll();
|
||||
|
||||
+62
-6
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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>
|
||||
<body>
|
||||
<main class="app-shell">
|
||||
@@ -50,13 +50,19 @@
|
||||
<b><em id="battery-fill"></em></b>
|
||||
</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">
|
||||
<ol class="activity-history" aria-label="Last three events" aria-live="polite">
|
||||
<li id="activity-oldest" hidden></li>
|
||||
<li id="activity-previous" hidden></li>
|
||||
<li id="status-text">Not connected</li>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
@@ -146,14 +152,64 @@
|
||||
<div id="toast" class="toast"></div>
|
||||
|
||||
<!-- 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="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>
|
||||
<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>
|
||||
<dialog id="link-modal" class="modal" aria-labelledby="link-title">
|
||||
<button class="modal-close" type="button" data-close aria-label="Close window">×</button><small>Connection</small><h2 id="link-title">Link settings</h2>
|
||||
<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="joystick-3d.js?v=20260827-keyboard-v7"></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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+167
@@ -539,6 +539,7 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
margin-left: auto;
|
||||
}
|
||||
.logs-link:hover { color: #f1f4f2; }
|
||||
.activity-actions { display:flex; align-items:center; gap:12px; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════════
|
||||
PRIMARY CONTROL ZONE (Workspace)
|
||||
@@ -554,6 +555,59 @@ body[data-app-state="link_degraded"] .status-line .status-dot {
|
||||
min-width: 0;
|
||||
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 {
|
||||
display: grid;
|
||||
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;
|
||||
}
|
||||
|
||||
.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. */
|
||||
#engage.danger:disabled, #engage.pending:disabled { opacity:1 !important; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -1131,3 +1216,85 @@ body:not([data-controls-enabled="true"]) .controls-grid { opacity:.65; }
|
||||
.telemetry-zone { gap:12px; }
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -40,12 +40,15 @@ class ControlApi:
|
||||
"app_state": "disconnected",
|
||||
"status": "Ready. Select a USB port to begin.",
|
||||
"battery": None,
|
||||
"link_quality": None,
|
||||
"heavy_quality": None,
|
||||
"log_path": None,
|
||||
"servos": {str(i): {"function": None, "direction": None} for i in range(1, 17)},
|
||||
}
|
||||
self.servo_functions: dict[int, int] = {}
|
||||
self.motor_directions: dict[int, int] = {}
|
||||
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_cache = Path(__file__).resolve().parent / "runtime" / "vehicle_model.stl"
|
||||
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")))
|
||||
except (OSError, ValueError, TypeError):
|
||||
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):
|
||||
return {
|
||||
"ports": self.refresh_ports(),
|
||||
"battery_config": self.battery_config,
|
||||
"link_config": self.link_config,
|
||||
"state": self._snapshot(),
|
||||
"model_info": self.get_saved_model_info(),
|
||||
}
|
||||
@@ -233,6 +247,9 @@ class ControlApi:
|
||||
self.state.update(
|
||||
connected=True, link="LINK OK", vehicle="DEMO • DISARMED",
|
||||
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,
|
||||
"cells": 4, "low": False},
|
||||
)
|
||||
@@ -268,15 +285,26 @@ class ControlApi:
|
||||
raise TimeoutError(f"No MAVLink heartbeat received from {port} within 8 seconds")
|
||||
|
||||
def disconnect(self):
|
||||
if self.worker.heavy_quality_test is not None:
|
||||
self.worker.cancel_heavy_quality_test()
|
||||
if self.demo_mode:
|
||||
self.demo_mode = False
|
||||
with self.lock:
|
||||
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
|
||||
self.worker.disconnect()
|
||||
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):
|
||||
if self.demo_mode:
|
||||
with self.lock:
|
||||
@@ -389,8 +417,33 @@ class ControlApi:
|
||||
raise ValueError("Full voltage per cell must be greater than empty voltage")
|
||||
if not 0 <= clean["warning_percent"] <= 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_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
|
||||
|
||||
def open_logs(self):
|
||||
@@ -399,6 +452,10 @@ class ControlApi:
|
||||
os.startfile(path)
|
||||
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):
|
||||
# Bound a poll even if telemetry arrives continuously.
|
||||
with self._poll_lock:
|
||||
@@ -468,6 +525,10 @@ class ControlApi:
|
||||
"voltage": voltage, "per_cell": per_cell, "percent": 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":
|
||||
_, param_id, value = event
|
||||
servo = int(param_id[5:param_id.index("_")])
|
||||
@@ -490,7 +551,7 @@ class ControlApi:
|
||||
last_err = self.state.get("last_error")
|
||||
status_msg = last_err if last_err else "Disconnected"
|
||||
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
|
||||
|
||||
def _update_servo(self, servo):
|
||||
|
||||
Reference in New Issue
Block a user