From 0093f3f4aacb3659b3cbbada1dd4064894310fd1 Mon Sep 17 00:00:00 2001 From: PitresAli Date: Wed, 30 Sep 2026 15:22:09 +0200 Subject: [PATCH] Clean up UI windows and strengthen settings workflows --- .gitignore | 1 + README.md | 1 + SUB Control/.gitignore | 1 + SUB Control/README.md | 1 + SUB Control/app.py | 479 +++++++++++++++++++++-- SUB Control/tests/test_vehicle_state.py | 170 ++++++++- SUB Control/tests/ui_windows.cjs | 140 +++++++ SUB Control/web/app.js | 232 +++++++++++- SUB Control/web/index.html | 68 +++- SUB Control/web/styles.css | 167 +++++++++ SUB Control/web_ui.py | 67 +++- app.py | 480 ++++++++++++++++++++++-- tests/test_vehicle_state.py | 170 ++++++++- tests/ui_windows.cjs | 140 +++++++ web/app.js | 232 +++++++++++- web/index.html | 68 +++- web/styles.css | 167 +++++++++ web_ui.py | 67 +++- 18 files changed, 2533 insertions(+), 118 deletions(-) create mode 100644 SUB Control/tests/ui_windows.cjs create mode 100644 tests/ui_windows.cjs diff --git a/.gitignore b/.gitignore index c7bf4b4..93d7cf0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ *.py[cod] logs/ model_settings.json +link_settings.json runtime/ diff --git a/README.md b/README.md index 3c53bf5..3ee6e41 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SUB Control/.gitignore b/SUB Control/.gitignore index c7bf4b4..93d7cf0 100644 --- a/SUB Control/.gitignore +++ b/SUB Control/.gitignore @@ -3,4 +3,5 @@ __pycache__/ *.py[cod] logs/ model_settings.json +link_settings.json runtime/ diff --git a/SUB Control/README.md b/SUB Control/README.md index 3c53bf5..3ee6e41 100644 --- a/SUB Control/README.md +++ b/SUB Control/README.md @@ -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 diff --git a/SUB Control/app.py b/SUB Control/app.py index 3c38f65..b231de0 100644 --- a/SUB Control/app.py +++ b/SUB Control/app.py @@ -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, diff --git a/SUB Control/tests/test_vehicle_state.py b/SUB Control/tests/test_vehicle_state.py index 152aa13..cac1f37 100644 --- a/SUB Control/tests/test_vehicle_state.py +++ b/SUB Control/tests/test_vehicle_state.py @@ -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): diff --git a/SUB Control/tests/ui_windows.cjs b/SUB Control/tests/ui_windows.cjs new file mode 100644 index 0000000..b28af0c --- /dev/null +++ b/SUB Control/tests/ui_windows.cjs @@ -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}); diff --git a/SUB Control/web/app.js b/SUB Control/web/app.js index 2d06b11..9772c33 100644 --- a/SUB Control/web/app.js +++ b/SUB Control/web/app.js @@ -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=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;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=`SERVO${i}`; + 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(); diff --git a/SUB Control/web/index.html b/SUB Control/web/index.html index 10dc543..77b6747 100644 --- a/SUB Control/web/index.html +++ b/SUB Control/web/index.html @@ -4,7 +4,7 @@ Sub Control - +
@@ -50,13 +50,19 @@ + +
  1. Not connected
-
Recent activityLogs
+
Recent activity
@@ -146,14 +152,64 @@
- connection

Link Settings

20 Hz is recommended for smooth intermediate commands on this link.

- Power

Battery Settings

- configuration

Motor Outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
+ + Connection + + + + Connection

Link quality

+

The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.

+
Round trip—
Probe loss—
FC → PC loss—
+

Extended test

Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.

+
Connect to run an extended test.
+
+ + +
+
+ + Power

Battery settings

Charge is estimated from voltage. Capacity and C rating describe your battery.

+
+
+

Voltage per cell

+ + +
+
+ + Configuration

Motor outputs

Waiting for motor assignments.

+ +
OutputAssignmentDirection
+
+ + Local session

Live console

+

Live · follows new messages

+
RXVehicle → PC
Waiting for vehicle data…
TXPC → Vehicle
Waiting for transmitted data…
+
- + diff --git a/SUB Control/web/styles.css b/SUB Control/web/styles.css index 7041740..217748c 100644 --- a/SUB Control/web/styles.css +++ b/SUB Control/web/styles.css @@ -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; } +} diff --git a/SUB Control/web_ui.py b/SUB Control/web_ui.py index e40a023..f60c810 100644 --- a/SUB Control/web_ui.py +++ b/SUB Control/web_ui.py @@ -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): diff --git a/app.py b/app.py index 3c38f65..1aa98a0 100644 --- a/app.py +++ b/app.py @@ -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, diff --git a/tests/test_vehicle_state.py b/tests/test_vehicle_state.py index 152aa13..cac1f37 100644 --- a/tests/test_vehicle_state.py +++ b/tests/test_vehicle_state.py @@ -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): diff --git a/tests/ui_windows.cjs b/tests/ui_windows.cjs new file mode 100644 index 0000000..b28af0c --- /dev/null +++ b/tests/ui_windows.cjs @@ -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}); diff --git a/web/app.js b/web/app.js index 2d06b11..9772c33 100644 --- a/web/app.js +++ b/web/app.js @@ -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=['',...Array.from({length:12},(_,n)=>``)].join('');row.innerHTML=`SERVO${i}${s.direction==null?'—':s.direction===-1?'Reversed':'Normal'}`;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=`SERVO${i}`; + 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(); diff --git a/web/index.html b/web/index.html index 10dc543..77b6747 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ Sub Control - +
@@ -50,13 +50,19 @@ + +
  1. Not connected
-
Recent activityLogs
+
Recent activity
@@ -146,14 +152,64 @@
- connection

Link Settings

20 Hz is recommended for smooth intermediate commands on this link.

- Power

Battery Settings

- configuration

Motor Outputs

Function and direction are read one parameter at a time to protect the low-bandwidth link.

OutputFunctionDirection
+ + Connection + + + + Connection

Link quality

+

The live check watches round-trip replies, incoming packet loss, and heartbeat freshness.

+
Round trip—
Probe loss—
FC → PC loss—
+

Extended test

Checks 1, 5, then 10 Hz for about 35 seconds. Run while disarmed; the live echo resumes afterward.

+
Connect to run an extended test.
+
+ + +
+
+ + Power

Battery settings

Charge is estimated from voltage. Capacity and C rating describe your battery.

+
+
+

Voltage per cell

+ + +
+
+ + Configuration

Motor outputs

Waiting for motor assignments.

+ +
OutputAssignmentDirection
+
+ + Local session

Live console

+

Live · follows new messages

+
RXVehicle → PC
Waiting for vehicle data…
TXPC → Vehicle
Waiting for transmitted data…
+
- + diff --git a/web/styles.css b/web/styles.css index 7041740..a8f6c34 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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; } +} diff --git a/web_ui.py b/web_ui.py index e40a023..f60c810 100644 --- a/web_ui.py +++ b/web_ui.py @@ -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):