From fdc82b15191afbc80ed8135390bb0b777e54146e Mon Sep 17 00:00:00 2001 From: Tomas Aas-Hansen Date: Thu, 11 Jun 2026 15:01:47 +0200 Subject: [PATCH 1/3] Removed unused tabs Resources and Camera 2 --- data/data.json | 34 +++++++++++++++++----------------- static/templates/base.html | 6 ------ 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/data/data.json b/data/data.json index 3368130..18eebf8 100644 --- a/data/data.json +++ b/data/data.json @@ -1,13 +1,13 @@ { "imu": { - "yaw": 0.0, - "pitch": 0.0, - "roll": 0.0, - "yr": 0.0, - "pr": 0.0, - "rr": 0.0, - "ax": 0.0, - "ay": 0.0, + "yaw": -2.52, + "pitch": 4.75, + "roll": -179.72, + "yr": 0.02, + "pr": 0.02, + "rr": 0.09, + "ax": -0.001, + "ay": -0.0, "az": 0.0 }, "9dof": { @@ -74,14 +74,14 @@ "dptSet": 0.0 }, "resources": { - "sequence": 0, - "uptime_ms": 0, - "cpu_percent": 0, - "heap_used_percent": 0, - "heap_free_kb": 0, - "heap_total_kb": 0, - "thread_count": 0, - "udp_rx_count": 0, + "sequence": 1644, + "uptime_ms": 1647821, + "cpu_percent": 5, + "heap_used_percent": 2, + "heap_free_kb": 502, + "heap_total_kb": 512, + "thread_count": 20, + "udp_rx_count": 30408, "udp_rx_errors": 0 }, "control_telemetry": { @@ -123,4 +123,4 @@ "Buttons": { "button_surface": 0 } -} +} \ No newline at end of file diff --git a/static/templates/base.html b/static/templates/base.html index 669c636..93255ee 100644 --- a/static/templates/base.html +++ b/static/templates/base.html @@ -37,12 +37,6 @@ - - From eb10d51176de805fd1fa21c8a77da69d9ad815ab Mon Sep 17 00:00:00 2001 From: Tomas Aas-Hansen Date: Thu, 11 Jun 2026 17:09:58 +0200 Subject: [PATCH 2/3] Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum. --- app.py | 16 +- data/config.json | 6 +- data/data.json | 39 +- routes.py | 155 ++++- static/css/pilot.css | 6 + static/css/styles.css | 79 +++ static/js/configuration.js | 278 ++++++--- static/js/connection.js | 95 +++ static/js/debug.js | 988 ++++--------------------------- static/js/ip_camera.js | 181 ++++++ static/js/pilot.js | 290 +++------ static/templates/base.html | 47 +- static/templates/camera2.html | 15 +- static/templates/config.html | 153 +++++ static/templates/connection.html | 74 +++ static/templates/debug.html | 613 +------------------ static/templates/ip_camera.html | 45 ++ static/templates/layout.html | 234 ++------ static/templates/pilot.html | 124 +--- static/templates/tooling.html | 39 ++ tests/test_ip_camera_routes.py | 75 +++ 21 files changed, 1396 insertions(+), 2156 deletions(-) create mode 100644 static/js/connection.js create mode 100644 static/js/ip_camera.js create mode 100644 static/templates/config.html create mode 100644 static/templates/connection.html create mode 100644 static/templates/ip_camera.html create mode 100644 static/templates/tooling.html create mode 100644 tests/test_ip_camera_routes.py diff --git a/app.py b/app.py index 660b256..208c59c 100644 --- a/app.py +++ b/app.py @@ -76,11 +76,25 @@ ) # Initialize IP camera (SMTSEC SIP-K327GS) via RTSP -ip_cam_url = os.getenv("IP_CAMERA_URL", "rtsp://10.77.0.3:554/stream1") +_ip_camera_config = _config.get_section("ip_camera") or {} +ip_cam_active_ip = _ip_camera_config.get("active_ip") or "10.77.0.4" +ip_cam_url = os.getenv("IP_CAMERA_URL") +if ip_cam_url: + ip_cam_active_ip = None +else: + ip_cam_url = f"rtsp://{ip_cam_active_ip}:554/stream1" ip_cam_out_width = int(os.getenv("IP_CAMERA_OUT_WIDTH", "960")) ip_cam_out_height = int(os.getenv("IP_CAMERA_OUT_HEIGHT", "540")) ip_cam_jpeg_quality = int(os.getenv("IP_CAMERA_JPEG_QUALITY", "70")) ip_cam_flip_180 = os.getenv("IP_CAMERA_FLIP_180", "false").strip().lower() in {"1", "true", "yes", "on"} +app.config["IP_CAMERA_ACTIVE_IP"] = ip_cam_active_ip +app.config["IP_CAMERA_ACTIVE_URL"] = ip_cam_url +app.config["IP_CAMERA_SETTINGS"] = { + "out_width": ip_cam_out_width, + "out_height": ip_cam_out_height, + "jpeg_quality": ip_cam_jpeg_quality, + "flip_180": ip_cam_flip_180, +} app.config["IP_CAMERA"] = init_ip_camera( url=ip_cam_url, out_width=ip_cam_out_width, diff --git a/data/config.json b/data/config.json index 7e13297..9651aa7 100644 --- a/data/config.json +++ b/data/config.json @@ -13,5 +13,9 @@ "x": "+x", "y": "+y", "z": "+z" + }, + "ip_camera": { + "active_ip": "10.77.0.4", + "presets": [] } -} \ No newline at end of file +} diff --git a/data/data.json b/data/data.json index 18eebf8..508b1d3 100644 --- a/data/data.json +++ b/data/data.json @@ -1,14 +1,26 @@ { "imu": { - "yaw": -2.52, +<<<<<<< HEAD + "yaw": -2.76, "pitch": 4.75, - "roll": -179.72, - "yr": 0.02, - "pr": 0.02, - "rr": 0.09, - "ax": -0.001, + "roll": -179.79, + "yr": -0.01, + "pr": 0.0, + "rr": 0.06, + "ax": 0.001, "ay": -0.0, + "az": -0.0 +======= + "yaw": -2.86, + "pitch": 4.76, + "roll": -179.79, + "yr": -0.0, + "pr": -0.01, + "rr": 0.11, + "ax": 0.001, + "ay": 0.0, "az": 0.0 +>>>>>>> 9a56d4d (Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum.) }, "9dof": { "acceleration": { @@ -74,14 +86,23 @@ "dptSet": 0.0 }, "resources": { - "sequence": 1644, - "uptime_ms": 1647821, +<<<<<<< HEAD + "sequence": 365, + "uptime_ms": 367294, +======= + "sequence": 474, + "uptime_ms": 476425, +>>>>>>> 9a56d4d (Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum.) "cpu_percent": 5, "heap_used_percent": 2, "heap_free_kb": 502, "heap_total_kb": 512, "thread_count": 20, - "udp_rx_count": 30408, +<<<<<<< HEAD + "udp_rx_count": 15240, +======= + "udp_rx_count": 19788, +>>>>>>> 9a56d4d (Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum.) "udp_rx_errors": 0 }, "control_telemetry": { diff --git a/routes.py b/routes.py index 126aa59..d9889aa 100644 --- a/routes.py +++ b/routes.py @@ -1,13 +1,15 @@ +import ipaddress import json import math import re import time from pathlib import Path +from urllib.parse import urlparse from flask import Response, current_app, jsonify, render_template, request, send_from_directory from lib.axis_config_sender import send_axis_config -from lib.camera import generate_frames, generate_ip_camera_frames, generate_rpi_frames +from lib.camera import generate_frames, generate_ip_camera_frames, generate_rpi_frames, init_ip_camera from lib.json_data_handler import JSONDataHandler from lib.pid_config_client import AXES as PID_AXES from lib.pid_config_client import request_pid_gains, send_pid_gains @@ -41,6 +43,7 @@ def _save_pid_configs(configs): ATTITUDE_LIMITS_DEG = {"roll": 180.0, "pitch": 90.0, "yaw": 180.0} CONTROL_AXES = ("surge", "sway", "heave", "roll", "pitch", "yaw") ATTITUDE_AXES = ("roll", "pitch", "yaw") +DEFAULT_IP_CAMERA_IP = "10.77.0.4" def _clamp(value, lower, upper): @@ -124,6 +127,57 @@ def _send_full_axis_config(): send_axis_config(imu_axes=imu_axes, accel_axes=accel_axes, offset=offset) +def _camera_url_for_ip(ip): + return f"rtsp://{ip}:554/stream1" + + +def _coerce_ipv4(value): + try: + addr = ipaddress.ip_address(str(value).strip()) + except ValueError: + return None + if addr.version != 4: + return None + return str(addr) + + +def _ip_from_url(url): + try: + return _coerce_ipv4(urlparse(url).hostname) + except Exception: + return None + + +def _camera_status_payload(): + ip_cam = current_app.config.get("IP_CAMERA") + status = ip_cam.get_status() if ip_cam else {"connected": False} + active_url = status.get("url") or current_app.config.get("IP_CAMERA_ACTIVE_URL") or _camera_url_for_ip(DEFAULT_IP_CAMERA_IP) + active_ip = current_app.config.get("IP_CAMERA_ACTIVE_IP") or _ip_from_url(active_url) or DEFAULT_IP_CAMERA_IP + return active_ip, active_url, status + + +def _get_ip_camera_config(): + section = config_handler.get_section("ip_camera") or {} + raw_presets = section.get("presets", []) + presets = [] + if isinstance(raw_presets, dict): + raw_presets = [{"name": name, "ip": ip} for name, ip in raw_presets.items()] + if isinstance(raw_presets, list): + for item in raw_presets: + if not isinstance(item, dict): + continue + name = str(item.get("name", "")).strip() + ip = _coerce_ipv4(item.get("ip")) + if name and ip: + presets.append({"name": name, "ip": ip}) + active_ip = _coerce_ipv4(section.get("active_ip")) or DEFAULT_IP_CAMERA_IP + return {"active_ip": active_ip, "presets": presets} + + +def _save_ip_camera_config(section): + config_handler.update_data({"ip_camera": section}) + + # Default resource data (used when no telemetry received) DEFAULT_RESOURCES = { "sequence": 0, @@ -153,7 +207,7 @@ def camera1(): @app.route("/Camera2") def camera2(): """Render the camera2.html template.""" - return render_template("camera2.html") + return render_template("ip_camera.html") @app.route("/pilot") def pilot(): @@ -165,6 +219,26 @@ def debug(): """Render the debug slider page.""" return render_template("debug.html", attitude_limits=ATTITUDE_LIMITS_DEG) + @app.route("/tooling") + def tooling(): + """Render the tooling controls page.""" + return render_template("tooling.html") + + @app.route("/config") + def config(): + """Render the configuration page.""" + return render_template("config.html") + + @app.route("/ip-camera") + def ip_camera(): + """Render the IP camera page.""" + return render_template("ip_camera.html") + + @app.route("/connection") + def connection(): + """Render the connection status page.""" + return render_template("connection.html") + @app.route("/pid-tuning") def pid_tuning(): """Render the PID tuning page.""" @@ -260,6 +334,83 @@ def ip_camera_status(): return jsonify(ip_cam.get_status()) return jsonify({"connected": False}) + @app.route("/api/ip_camera/configs", methods=["GET"]) + def get_ip_camera_configs(): + """Return active IP camera URL plus saved IP presets.""" + active_ip, active_url, status = _camera_status_payload() + section = _get_ip_camera_config() + return jsonify( + { + "ok": True, + "active_ip": active_ip, + "active_url": active_url, + "presets": section["presets"], + "status": status, + } + ) + + @app.route("/api/ip_camera/configs", methods=["POST"]) + def save_ip_camera_config(): + """Save or update a named IP camera IPv4 preset.""" + data = request.get_json(force=True, silent=True) or {} + name = str(data.get("name", "")).strip() + ip = _coerce_ipv4(data.get("ip")) + if not name or not re.match(r"^[\w\s\-\.]+$", name): + return jsonify({"ok": False, "error": "Invalid preset name"}), 400 + if not ip: + return jsonify({"ok": False, "error": "Invalid IPv4 address"}), 400 + + section = _get_ip_camera_config() + presets = [preset for preset in section["presets"] if preset["name"] != name] + presets.append({"name": name, "ip": ip}) + presets.sort(key=lambda preset: preset["name"].lower()) + section["presets"] = presets + _save_ip_camera_config(section) + return jsonify({"ok": True, "name": name, "ip": ip, "presets": presets}) + + @app.route("/api/ip_camera/configs/", methods=["DELETE"]) + def delete_ip_camera_config(name): + """Delete a named IP camera preset.""" + section = _get_ip_camera_config() + presets = [preset for preset in section["presets"] if preset["name"] != name] + if len(presets) == len(section["presets"]): + return jsonify({"ok": False, "error": "Preset not found"}), 404 + section["presets"] = presets + _save_ip_camera_config(section) + return jsonify({"ok": True, "presets": presets}) + + @app.route("/api/ip_camera/reassign", methods=["POST"]) + def reassign_ip_camera(): + """Restart only the IP camera receiver with a new RTSP URL.""" + data = request.get_json(force=True, silent=True) or {} + ip = _coerce_ipv4(data.get("ip")) + if not ip: + return jsonify({"ok": False, "error": "Invalid IPv4 address"}), 400 + + url = _camera_url_for_ip(ip) + old_camera = current_app.config.get("IP_CAMERA") + if old_camera: + old_camera.stop() + + settings = current_app.config.get("IP_CAMERA_SETTINGS") or {} + new_camera = init_ip_camera( + url=url, + out_width=settings.get("out_width", 960), + out_height=settings.get("out_height", 540), + jpeg_quality=settings.get("jpeg_quality", 70), + flip_180=settings.get("flip_180", False), + marker_logger=current_app.config.get("ARUCO_LOGGER"), + ) + current_app.config["IP_CAMERA"] = new_camera + current_app.config["IP_CAMERA_ACTIVE_IP"] = ip + current_app.config["IP_CAMERA_ACTIVE_URL"] = url + + section = _get_ip_camera_config() + section["active_ip"] = ip + _save_ip_camera_config(section) + active_ip, active_url, status = _camera_status_payload() + return jsonify({"ok": True, "active_ip": active_ip, "active_url": active_url, "status": status}) + @app.route("/api/aruco-log", methods=["GET"]) def aruco_log_status(): """Return ordered ARUCO marker sightings for the pipeline challenge.""" diff --git a/static/css/pilot.css b/static/css/pilot.css index e4b9097..c30596d 100644 --- a/static/css/pilot.css +++ b/static/css/pilot.css @@ -397,6 +397,12 @@ /* Thruster strip */ .hud-thruster-strip { flex: 1; } +.hud-lights-panel { + width: 170px; +} +.hud-light-slider { + margin: 7px 0 0; +} .hud-thruster-row { display: flex; gap: 8px; diff --git a/static/css/styles.css b/static/css/styles.css index 5f7d7b7..620f4e5 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -405,3 +405,82 @@ body { font-size: 0.9rem; } } + +.text-light-muted { + color: #adb5bd !important; +} + +.page-link-card { + text-decoration: none; +} + +.page-link-card:hover { + border-color: #0dcaf0; +} + +.debug-pre { + max-height: 360px; + overflow: auto; + font-size: 0.85rem; + background: rgba(0, 0, 0, 0.35); + border: 1px solid #495057; + border-radius: 6px; + padding: 12px; +} + +.log-stream { + background: rgba(0, 0, 0, 0.4); + min-height: 240px; + max-height: 320px; + overflow-y: auto; + font-family: var(--bs-font-monospace); + font-size: 0.8rem; + padding: 8px; +} + +.ip-camera-shell { + min-height: calc(100vh - 220px); +} + +.ip-camera-view { + position: relative; + min-height: calc(100vh - 240px); + overflow: hidden; + border: 1px solid #495057; + border-radius: 8px; + background: #05080b; +} + +.ip-camera-view img { + display: block; + width: 100%; + min-height: calc(100vh - 240px); + max-height: calc(100vh - 180px); + object-fit: contain; + background: #000; +} + +.ip-camera-panel { + position: absolute; + top: 16px; + right: 16px; + width: min(360px, calc(100% - 32px)); + padding: 14px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 8px; + background: rgba(20, 24, 28, 0.92); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); +} + +@media (max-width: 768px) { + .ip-camera-panel { + position: static; + width: 100%; + border-radius: 0; + box-shadow: none; + } + + .ip-camera-view img { + min-height: 360px; + } +} diff --git a/static/js/configuration.js b/static/js/configuration.js index c44cde6..d81f383 100644 --- a/static/js/configuration.js +++ b/static/js/configuration.js @@ -1,96 +1,206 @@ document.addEventListener("DOMContentLoaded", function () { - const slider = document.getElementById("update-interval"); - const intervalDisplay = document.getElementById("interval-value"); + const axes = ["surge", "sway", "heave", "roll", "pitch", "yaw"]; + const masterSlider = document.getElementById("gain-master"); + const masterDisplay = document.getElementById("gain-master-value"); - // Start with the default update interval of 500ms - let batteryInterval = setInterval(updateBattery, 500); - let depthInterval = setInterval(updateDepth, 500); - let lightsInterval = setInterval(updateLights, 500); - let sensorsInterval = setInterval(updateSensors, 500); - let thrustersInterval = setInterval(updateThrusterStatus, 500) + function setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; + } + function setFeedback(id, text, cls) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.className = "small mt-2 " + cls; + } + + function sendGains(payload) { + fetch("/api/controller/gains", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(() => {}); + } + + fetch("/api/controller/gains") + .then((r) => r.json()) + .then((data) => { + if (!data.ok) return; + const gains = data.gains; + if (masterSlider && gains.master !== undefined) { + masterSlider.value = gains.master; + if (masterDisplay) masterDisplay.textContent = Number(gains.master).toFixed(2); + } + axes.forEach((axis) => { + const slider = document.getElementById("gain-" + axis); + if (!slider || gains[axis] === undefined) return; + slider.value = gains[axis]; + setText("gain-" + axis + "-value", Number(gains[axis]).toFixed(2)); + }); + }) + .catch(() => {}); + + if (masterSlider) { + masterSlider.addEventListener("input", function () { + const value = parseFloat(this.value); + if (masterDisplay) masterDisplay.textContent = value.toFixed(2); + const payload = { master: value }; + axes.forEach((axis) => { + const slider = document.getElementById("gain-" + axis); + if (slider) slider.value = value; + setText("gain-" + axis + "-value", value.toFixed(2)); + payload[axis] = value; + }); + sendGains(payload); + }); + } + + axes.forEach((axis) => { + const slider = document.getElementById("gain-" + axis); + if (!slider) return; slider.addEventListener("input", function () { - const newInterval = parseInt(slider.value); - intervalDisplay.textContent = newInterval; - - // Clear previous intervals - clearInterval(batteryInterval); - clearInterval(depthInterval); - clearInterval(lightsInterval); - clearInterval(sensorsInterval); - clearInterval(thrustersInterval); - - // Set new intervals with the updated time - batteryInterval = setInterval(updateBattery, newInterval); - depthInterval = setInterval(updateDepth, newInterval); - lightsInterval = setInterval(updateLights, newInterval); - sensorsInterval = setInterval(updateSensors, newInterval); - thrustersInterval = setInterval(updateThrusterStatus, newInterval); + const value = parseFloat(this.value); + setText("gain-" + axis + "-value", value.toFixed(2)); + sendGains({ [axis]: value }); }); + }); - // ── Gain sliders ──────────────────────────────────────── - const axes = ["surge", "sway", "heave", "roll", "pitch", "yaw"]; - const masterSlider = document.getElementById("gain-master"); - const masterDisplay = document.getElementById("gain-master-value"); - - // Fetch current gains on load - fetch("/api/controller/gains") - .then(r => r.json()) - .then(data => { - if (!data.ok) return; - const g = data.gains; - if (masterSlider) { - masterSlider.value = g.master; - masterDisplay.textContent = Number(g.master).toFixed(2); - } - axes.forEach(axis => { - const sl = document.getElementById("gain-" + axis); - const disp = document.getElementById("gain-" + axis + "-value"); - if (sl && g[axis] !== undefined) { - sl.value = g[axis]; - disp.textContent = Number(g[axis]).toFixed(2); - } + const axesYaw = document.getElementById("axes-yaw"); + const axesPitch = document.getElementById("axes-pitch"); + const axesRoll = document.getElementById("axes-roll"); + + fetch("/api/imu/axes") + .then((r) => r.json()) + .then((data) => { + if (!data.ok || !data.axes) return; + if (axesYaw) axesYaw.value = data.axes.yaw; + if (axesPitch) axesPitch.value = data.axes.pitch; + if (axesRoll) axesRoll.value = data.axes.roll; + }) + .catch(() => {}); + + const saveAxes = document.getElementById("btn-save-axes"); + if (saveAxes) { + saveAxes.addEventListener("click", async function () { + try { + const res = await fetch("/api/imu/axes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + yaw: axesYaw.value, + pitch: axesPitch.value, + roll: axesRoll.value, + }), }); - }) - .catch(() => {}); - - function sendGains(payload) { - fetch("/api/controller/gains", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }).catch(() => {}); - } + const data = await res.json(); + setFeedback("axes-feedback", data.ok ? "Mapping saved" : "Failed to save mapping", data.ok ? "text-success" : "text-danger"); + } catch (error) { + setFeedback("axes-feedback", "Error: " + error.message, "text-danger"); + } + }); + } + + const accelX = document.getElementById("accel-x"); + const accelY = document.getElementById("accel-y"); + const accelZ = document.getElementById("accel-z"); + + fetch("/api/imu/accel_axes") + .then((r) => r.json()) + .then((data) => { + if (!data.ok || !data.accel_axes) return; + if (accelX) accelX.value = data.accel_axes.x; + if (accelY) accelY.value = data.accel_axes.y; + if (accelZ) accelZ.value = data.accel_axes.z; + }) + .catch(() => {}); - // Master gain: also updates all per-axis sliders - if (masterSlider) { - masterSlider.addEventListener("input", function () { - const val = parseFloat(this.value); - masterDisplay.textContent = val.toFixed(2); - // Set all per-axis sliders to the master value - axes.forEach(axis => { - const sl = document.getElementById("gain-" + axis); - const disp = document.getElementById("gain-" + axis + "-value"); - if (sl) { - sl.value = val; - disp.textContent = val.toFixed(2); - } + const saveAccel = document.getElementById("btn-save-accel"); + if (saveAccel) { + saveAccel.addEventListener("click", async function () { + try { + const res = await fetch("/api/imu/accel_axes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + x: accelX.value, + y: accelY.value, + z: accelZ.value, + }), }); - // Build full payload: master + all axes - const payload = { master: val }; - axes.forEach(a => payload[a] = val); - sendGains(payload); - }); - } + const data = await res.json(); + setFeedback("accel-feedback", data.ok ? "Accelerometer mapping saved" : "Failed to save mapping", data.ok ? "text-success" : "text-danger"); + } catch (error) { + setFeedback("accel-feedback", "Error: " + error.message, "text-danger"); + } + }); + } - // Per-axis gain sliders - axes.forEach(axis => { - const sl = document.getElementById("gain-" + axis); - if (!sl) return; - sl.addEventListener("input", function () { - const val = parseFloat(this.value); - document.getElementById("gain-" + axis + "-value").textContent = val.toFixed(2); - sendGains({ [axis]: val }); - }); + const offsetX = document.getElementById("offset-x"); + const offsetY = document.getElementById("offset-y"); + const offsetZ = document.getElementById("offset-z"); + + fetch("/api/imu/offset") + .then((r) => r.json()) + .then((data) => { + if (!data.ok || !data.offset) return; + if (offsetX) offsetX.value = data.offset.x; + if (offsetY) offsetY.value = data.offset.y; + if (offsetZ) offsetZ.value = data.offset.z; + }) + .catch(() => {}); + + const saveOffset = document.getElementById("btn-save-offset"); + if (saveOffset) { + saveOffset.addEventListener("click", async function () { + try { + const res = await fetch("/api/imu/offset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + x: parseFloat(offsetX.value) || 0, + y: parseFloat(offsetY.value) || 0, + z: parseFloat(offsetZ.value) || 0, + }), + }); + const data = await res.json(); + if (data.ok) { + setFeedback("offset-feedback", "Offset saved", "text-success"); + } else { + setFeedback("offset-feedback", "Failed to save offset", "text-danger"); + } + } catch (error) { + setFeedback("offset-feedback", "Error: " + error.message, "text-danger"); + } }); + } + + async function pollInputSource() { + try { + const res = await fetch("/api/command/status", { cache: "no-store" }); + const data = await res.json(); + if (!data.ok) return; + + const controller = data.controller || {}; + const override = data.override || {}; + const uplink = data.uplink || {}; + const connected = controller.connected === true || controller.active === true; + const activeOverride = override.active === true; + const ackAge = uplink.last_ack_age_ms; + + setText("input-controller", connected ? "Connected" : "Not active"); + setText("input-override", activeOverride ? "Active" : "Inactive"); + setText("input-last-ack", ackAge == null ? "--" : Math.round(ackAge) + " ms"); + + const badge = document.getElementById("input-source-status"); + if (!badge) return; + badge.textContent = activeOverride ? "OVERRIDE" : connected ? "CONTROLLER" : "IDLE"; + badge.className = "badge " + (activeOverride ? "bg-danger" : connected ? "bg-success" : "bg-secondary"); + } catch (_) { + setText("input-controller", "Unavailable"); + } + } + + pollInputSource(); + setInterval(pollInputSource, 1000); }); diff --git a/static/js/connection.js b/static/js/connection.js new file mode 100644 index 0000000..c29a390 --- /dev/null +++ b/static/js/connection.js @@ -0,0 +1,95 @@ +(function () { + const badge = document.getElementById("nucleo-contact-badge"); + const lastAck = document.getElementById("nucleo-last-ack"); + const udpRx = document.getElementById("nucleo-udp-rx"); + const udpErrors = document.getElementById("nucleo-udp-errors"); + const details = document.getElementById("nucleo-link-details"); + const resetBtn = document.getElementById("btn-system-reset"); + const resetStatus = document.getElementById("system-reset-status"); + + function fmt(value) { + return value == null ? "--" : String(Math.round(value)); + } + + function setBadge(ackAge) { + if (!badge) return; + if (ackAge == null) { + badge.textContent = "NO ACK"; + badge.className = "badge bg-secondary"; + } else if (ackAge < 1000) { + badge.textContent = "LIVE"; + badge.className = "badge bg-success"; + } else if (ackAge < 3000) { + badge.textContent = "STALE"; + badge.className = "badge bg-warning text-dark"; + } else { + badge.textContent = "DEGRADED"; + badge.className = "badge bg-danger"; + } + } + + async function pollConnection() { + try { + const res = await fetch("/api/command/status", { cache: "no-store" }); + const data = await res.json(); + if (!data.ok) return; + + const uplink = data.uplink || {}; + const ackAge = uplink.last_ack_age_ms; + setBadge(ackAge); + if (lastAck) lastAck.textContent = ackAge == null ? "--" : fmt(ackAge) + " ms"; + if (udpRx) udpRx.textContent = data.udp_rx_count == null ? "--" : String(data.udp_rx_count); + if (udpErrors) udpErrors.textContent = data.udp_rx_errors == null ? "--" : String(data.udp_rx_errors); + if (details) { + details.textContent = JSON.stringify( + { + uplink, + resource: { + udp_rx_count: data.udp_rx_count, + udp_rx_errors: data.udp_rx_errors, + }, + }, + null, + 2 + ); + } + } catch (_) { + setBadge(null); + if (details) details.textContent = "Connection status unavailable"; + } + } + + if (resetBtn) { + resetBtn.addEventListener("click", async function () { + if (!window.confirm("Restart the MCU now?")) return; + resetBtn.disabled = true; + if (resetStatus) { + resetStatus.textContent = "SENDING"; + resetStatus.className = "badge bg-warning text-dark"; + } + try { + const res = await fetch("/api/system/reset", { method: "POST" }); + const data = await res.json(); + if (resetStatus) { + resetStatus.textContent = data.ok ? "RESET SENT" : data.error || "FAILED"; + resetStatus.className = "badge " + (data.ok ? "bg-success" : "bg-danger"); + } + } catch (error) { + if (resetStatus) { + resetStatus.textContent = "ERROR"; + resetStatus.className = "badge bg-danger"; + } + } + setTimeout(() => { + if (resetStatus) { + resetStatus.textContent = "READY"; + resetStatus.className = "badge bg-secondary"; + } + resetBtn.disabled = false; + }, 3000); + }); + } + + pollConnection(); + setInterval(pollConnection, 1000); +})(); diff --git a/static/js/debug.js b/static/js/debug.js index a228a38..a6767b4 100644 --- a/static/js/debug.js +++ b/static/js/debug.js @@ -1,202 +1,70 @@ -// debug.js – Debug slider page logic + IMU readout + offset controls (function () { const AXES = ["surge", "sway", "heave", "roll", "pitch", "yaw"]; - const SEND_INTERVAL_MS = 50; // 20 Hz updates while override is active - const HISTORY_LIMIT = 120; // 12 seconds of telemetry samples + const SEND_INTERVAL_MS = 50; let overrideActive = false; let sendTimer = null; - let overrideState = null; - let chartFrameScheduled = false; - let lastTelemetry = null; - const axisHistory = AXES.reduce((acc, axis) => { - acc[axis] = { - setpoint: [], - output: [], - error: [], - }; - return acc; - }, {}); - - // DOM refs const btnEnable = document.getElementById("btn-enable"); const btnDisable = document.getElementById("btn-disable"); const btnResetAll = document.getElementById("btn-reset-all"); + const btnStopAll = document.getElementById("btn-stop-all"); const statusBadge = document.getElementById("debug-status"); const rovStatus = document.getElementById("rov-status"); const telemetryBody = document.getElementById("control-telemetry-body"); const telemetryAge = document.getElementById("control-telemetry-age"); - const uplinkStatusBadge = document.getElementById("uplink-status-badge"); - const uplinkSequence = document.getElementById("uplink-sequence"); - const uplinkSendAge = document.getElementById("uplink-send-age"); - const uplinkAckAge = document.getElementById("uplink-ack-age"); - const uplinkUdpRx = document.getElementById("uplink-udp-rx"); - const uplinkUdpErrors = document.getElementById("uplink-udp-errors"); - const uplinkResends = document.getElementById("uplink-resends"); - const overrideStatusChip = document.getElementById("override-status-chip"); - const overrideAxes = document.getElementById("override-axes"); const logStreamEl = document.getElementById("log-stream"); - const ATTITUDE_AXES = ["roll", "pitch", "yaw"]; - const attitudeLimits = window.debugAttitudeLimits || {}; - const attRollInput = document.getElementById("attitude-roll"); - const attPitchInput = document.getElementById("attitude-pitch"); - const attYawInput = document.getElementById("attitude-yaw"); - const btnAttitudeSend = document.getElementById("btn-attitude-send"); - const btnAttitudeClear = document.getElementById("btn-attitude-clear"); - const attitudeFeedback = document.getElementById("attitude-feedback"); - const attitudeStatus = document.getElementById("attitude-status"); - const btnSystemReset = document.getElementById("btn-system-reset"); - const systemResetStatus = document.getElementById("system-reset-status"); - // --- Slider helpers --- + function slider(axis) { + return document.getElementById("slider-" + axis); + } + function getSliderValue(axis) { - return parseInt(document.getElementById("slider-" + axis).value, 10) / 100; + const el = slider(axis); + return el ? parseInt(el.value, 10) / 100 : 0; } function getAllValues() { - const vals = {}; - AXES.forEach((a) => (vals[a] = getSliderValue(a))); - return vals; + const values = {}; + AXES.forEach((axis) => { + values[axis] = getSliderValue(axis); + }); + return values; } function updateValueDisplay(axis) { - const val = getSliderValue(axis); - const el = document.getElementById("val-" + axis); - el.textContent = val.toFixed(2); - - // colour class - const slider = document.getElementById("slider-" + axis); - slider.classList.remove("positive", "negative", "zero"); - if (val > 0.005) slider.classList.add("positive"); - else if (val < -0.005) slider.classList.add("negative"); - else slider.classList.add("zero"); + const value = getSliderValue(axis); + const valueEl = document.getElementById("val-" + axis); + const sliderEl = slider(axis); + if (valueEl) valueEl.textContent = value.toFixed(2); + if (!sliderEl) return; + sliderEl.classList.remove("positive", "negative", "zero"); + if (value > 0.005) sliderEl.classList.add("positive"); + else if (value < -0.005) sliderEl.classList.add("negative"); + else sliderEl.classList.add("zero"); } function resetSlider(axis) { - document.getElementById("slider-" + axis).value = 0; + const el = slider(axis); + if (!el) return; + el.value = 0; updateValueDisplay(axis); } - // --- Control telemetry helpers --- - function pushTelemetrySample(sample) { - if (!sample || !sample.setpoint) return; - lastTelemetry = sample; - AXES.forEach((axis) => { - const store = axisHistory[axis]; - ["setpoint", "output", "error"].forEach((key) => { - const source = sample[key] || {}; - const value = typeof source[axis] === "number" ? source[axis] : NaN; - const series = store[key]; - series.push(value); - if (series.length > HISTORY_LIMIT) { - series.shift(); - } - }); - }); - requestChartRender(); - } - - function requestChartRender() { - if (chartFrameScheduled) return; - chartFrameScheduled = true; - window.requestAnimationFrame(() => { - drawAllCharts(); - chartFrameScheduled = false; - }); - } - - function drawAllCharts() { - AXES.forEach((axis) => drawAxisChart(axis)); - } - - function drawAxisChart(axis) { - const canvas = document.getElementById("chart-" + axis); - if (!canvas) return; - const ctx = canvas.getContext("2d"); - const width = canvas.clientWidth || canvas.width; - const height = canvas.clientHeight || canvas.height; - if (canvas.width !== width || canvas.height !== height) { - canvas.width = width; - canvas.height = height; + function setOverrideUi(active) { + overrideActive = active; + if (btnEnable) btnEnable.disabled = active; + if (btnDisable) btnDisable.disabled = !active; + if (statusBadge) { + statusBadge.textContent = active ? "ACTIVE" : "INACTIVE"; + statusBadge.className = "badge " + (active ? "bg-danger" : "bg-secondary"); } - const store = axisHistory[axis]; - const values = [...store.setpoint, ...store.output, ...store.error].filter((v) => typeof v === "number" && !Number.isNaN(v)); - const min = values.length ? Math.min(...values) : -1; - const max = values.length ? Math.max(...values) : 1; - const span = max - min || 1; - ctx.clearRect(0, 0, canvas.width, canvas.height); - - function drawSeries(series, color, width = 1.5) { - if (!series.length) return; - ctx.beginPath(); - ctx.strokeStyle = color; - ctx.lineWidth = width; - let started = false; - series.forEach((value, idx) => { - const normalized = Number.isNaN(value) ? null : value; - if (normalized == null) return; - const x = (idx / Math.max(series.length - 1, 1)) * canvas.width; - const y = canvas.height - ((normalized - min) / span) * canvas.height; - if (!started) { - ctx.moveTo(x, y); - started = true; - } else { - ctx.lineTo(x, y); - } - }); - if (started) ctx.stroke(); - } - - drawSeries(store.setpoint, "#0dcaf0", 2); - drawSeries(store.output, "#20c997", 2); - drawSeries(store.error, "#ffc107", 1.5); - - // midline - ctx.beginPath(); - ctx.strokeStyle = "rgba(255,255,255,0.1)"; - ctx.lineWidth = 1; - const midY = canvas.height - ((0 - min) / span) * canvas.height; - ctx.moveTo(0, midY); - ctx.lineTo(canvas.width, midY); - ctx.stroke(); - } - - function axisOverrideActive(axis) { - if (!overrideState || !overrideState.active) return false; - const axes = overrideState.axes || {}; - const value = axes[axis]; - return typeof value === "number" && Math.abs(value) > 0.01; - } - - function updateAxisStatus(axis, errorValue) { - const badge = document.getElementById("axis-status-" + axis); - if (!badge) return; - const override = axisOverrideActive(axis); - if (override) { - badge.textContent = "OVR"; - badge.className = "badge bg-danger axis-status"; - return; - } - if (errorValue == null || Number.isNaN(errorValue)) { - badge.textContent = "NA"; - badge.className = "badge bg-secondary axis-status"; - return; - } - const absErr = Math.abs(errorValue); - if (absErr < 0.05) { - badge.textContent = "LOCK"; - badge.className = "badge bg-success axis-status"; - } else if (absErr < 0.2) { - badge.textContent = "TRACK"; - badge.className = "badge bg-warning text-dark axis-status"; - } else { - badge.textContent = "OFF"; - badge.className = "badge bg-danger axis-status"; + if (!active && sendTimer) { + clearInterval(sendTimer); + sendTimer = null; } } - // --- API calls --- async function sendOverride() { if (!overrideActive) return; try { @@ -205,8 +73,8 @@ headers: { "Content-Type": "application/json" }, body: JSON.stringify(getAllValues()), }); - } catch (e) { - console.error("Failed to send debug override:", e); + } catch (error) { + console.error("Failed to send debug override:", error); } } @@ -214,40 +82,52 @@ try { const res = await fetch("/api/debug/clear", { method: "POST" }); return res.ok; - } catch (e) { - console.error("Failed to clear debug override:", e); + } catch (error) { + console.error("Failed to clear debug override:", error); return false; } } + function enableOverride() { + setOverrideUi(true); + sendOverride(); + sendTimer = setInterval(sendOverride, SEND_INTERVAL_MS); + } + + async function stopAll() { + setOverrideUi(false); + AXES.forEach(resetSlider); + await clearOverride(); + pollStatus(); + } + async function pollStatus() { + if (!rovStatus) return; try { - const res = await fetch("/api/rov/status"); + const res = await fetch("/api/rov/status", { cache: "no-store" }); const data = await res.json(); - rovStatus.textContent = JSON.stringify({ - command: data.command, - uplink: data.uplink, - resource: data.resource, - }, null, 2); + rovStatus.textContent = JSON.stringify( + { + command: data.command, + uplink: data.uplink, + resource: data.resource, + }, + null, + 2 + ); } catch (_) { rovStatus.textContent = "Error fetching status"; } } function fmtMs(value) { - if (value == null) return "--"; + if (value == null || Number.isNaN(value)) return "--"; return value.toFixed(0); } - function fmtFloat(value, digits = 2) { + function fmtFloat(value) { if (value == null || Number.isNaN(value)) return "NaN"; - return value.toFixed(digits); - } - - function updateTelemetryAgeLabel(snapshot) { - if (!telemetryAge) return; - const ageMs = snapshot && snapshot.timestamp ? Math.max(0, Date.now() - snapshot.timestamp * 1000) : null; - telemetryAge.textContent = fmtMs(ageMs); + return Number(value).toFixed(2); } function renderTelemetryTable(snapshot) { @@ -258,19 +138,12 @@ const frag = document.createDocumentFragment(); AXES.forEach((axis) => { const tr = document.createElement("tr"); - tr.className = "telemetry-row"; - if (axisOverrideActive(axis)) tr.classList.add("override-active"); - const tdAxis = document.createElement("td"); - tdAxis.textContent = axis.toUpperCase(); - const tdSet = document.createElement("td"); - tdSet.textContent = fmtFloat(setpoint[axis]); - const tdOut = document.createElement("td"); - tdOut.textContent = fmtFloat(output[axis]); - const tdErr = document.createElement("td"); - tdErr.textContent = fmtFloat(error[axis]); - tr.append(tdAxis, tdSet, tdOut, tdErr); + [axis.toUpperCase(), fmtFloat(setpoint[axis]), fmtFloat(output[axis]), fmtFloat(error[axis])].forEach((text) => { + const td = document.createElement("td"); + td.textContent = text; + tr.appendChild(td); + }); frag.appendChild(tr); - updateAxisStatus(axis, error[axis]); }); telemetryBody.innerHTML = ""; telemetryBody.appendChild(frag); @@ -278,82 +151,17 @@ async function pollControlTelemetry() { try { - const res = await fetch("/api/control/telemetry"); - const data = await res.json(); - if (!data.ok) return; - pushTelemetrySample(data.telemetry); - renderTelemetryTable(data.telemetry); - updateTelemetryAgeLabel(data.telemetry); - } catch (err) { - console.error("control telemetry poll failed", err); - } - } - - async function bootstrapTelemetryHistory() { - try { - const res = await fetch(`/api/control/telemetry/history?limit=${HISTORY_LIMIT}`); + const res = await fetch("/api/control/telemetry", { cache: "no-store" }); const data = await res.json(); if (!data.ok) return; - (data.history || []).forEach((sample) => pushTelemetrySample(sample)); - if (data.history && data.history.length) { - const latest = data.history[data.history.length - 1]; - renderTelemetryTable(latest); - updateTelemetryAgeLabel(latest); + const snapshot = data.telemetry || {}; + renderTelemetryTable(snapshot); + if (telemetryAge) { + const ageMs = snapshot.timestamp ? Math.max(0, Date.now() - snapshot.timestamp * 1000) : null; + telemetryAge.textContent = fmtMs(ageMs); } - } catch (err) { - console.error("bootstrap telemetry history failed", err); - } - } - - function renderOverrideState(state) { - if (!overrideAxes || !overrideStatusChip) return; - overrideState = state || null; - const active = !!(state && state.active); - overrideStatusChip.textContent = active ? "ACTIVE" : "INACTIVE"; - overrideStatusChip.className = `badge ${active ? "bg-danger" : "bg-secondary"}`; - const axes = (state && state.axes) || {}; - const entries = AXES.filter((axis) => Math.abs(axes[axis] || 0) > 0.01) - .map((axis) => `${axis}: ${fmtFloat(axes[axis])}`); - let html = entries.length ? entries.join(" ") : 'No overrides'; - if (state && state.last_error) { - html += `
${state.last_error}
`; - } - overrideAxes.innerHTML = html; - } - - function updateUplinkStatus(payload, udp, override) { - if (!uplinkSequence) return; - uplinkSequence.textContent = payload && typeof payload.sequence !== "undefined" ? payload.sequence : "--"; - uplinkSendAge.textContent = fmtMs(payload ? payload.last_send_age_ms : null); - uplinkAckAge.textContent = fmtMs(payload ? payload.last_ack_age_ms : null); - uplinkUdpRx.textContent = udp && typeof udp.count !== "undefined" ? udp.count : "--"; - uplinkUdpErrors.textContent = udp && typeof udp.errors !== "undefined" ? udp.errors : "--"; - uplinkResends.textContent = payload && typeof payload.watchdog_resends !== "undefined" ? payload.watchdog_resends : 0; - const ackAge = payload && typeof payload.last_ack_age_ms !== "undefined" ? payload.last_ack_age_ms : null; - if (ackAge == null) { - uplinkStatusBadge.textContent = "IDLE"; - uplinkStatusBadge.className = "badge bg-secondary"; - } else if (ackAge < 1000) { - uplinkStatusBadge.textContent = "LIVE"; - uplinkStatusBadge.className = "badge bg-success"; - } else if (ackAge < 3000) { - uplinkStatusBadge.textContent = "STALE"; - uplinkStatusBadge.className = "badge bg-warning text-dark"; - } else { - uplinkStatusBadge.textContent = "DEGRADED"; - uplinkStatusBadge.className = "badge bg-danger"; - } - renderOverrideState(override); - } - - async function pollCommandStatus() { - try { - const res = await fetch("/api/command/status"); - const data = await res.json(); - if (!data.ok) return; - updateUplinkStatus(data.uplink, { count: data.udp_rx_count, errors: data.udp_rx_errors }, data.override); - } catch (err) { - console.error("command status poll failed", err); + } catch (error) { + console.error("control telemetry poll failed", error); } } @@ -363,20 +171,23 @@ entries.forEach((entry) => { const row = document.createElement("div"); row.className = "d-flex justify-content-between border-bottom border-secondary py-1"; + const body = document.createElement("div"); - const level = entry.level || "I"; const badge = document.createElement("span"); + const level = entry.level || "I"; const levelMap = { I: "bg-info text-dark", W: "bg-warning text-dark", R: "bg-danger", D: "bg-secondary" }; - badge.className = `badge me-2 ${levelMap[level] || "bg-secondary"}`; + badge.className = "badge me-2 " + (levelMap[level] || "bg-secondary"); badge.textContent = level; body.appendChild(badge); + const msg = document.createElement("span"); msg.textContent = entry.message || ""; body.appendChild(msg); + const ts = document.createElement("span"); ts.className = "text-light-muted small ms-3"; - const tsValue = entry.ts ? new Date(entry.ts * 1000) : new Date(); - ts.textContent = tsValue.toLocaleTimeString(); + ts.textContent = (entry.ts ? new Date(entry.ts * 1000) : new Date()).toLocaleTimeString(); + row.appendChild(body); row.appendChild(ts); frag.appendChild(row); @@ -388,630 +199,31 @@ async function pollLogs() { try { - const res = await fetch("/api/logs/live?limit=50"); + const res = await fetch("/api/logs/live?limit=50", { cache: "no-store" }); const data = await res.json(); - if (!data.ok) return; - renderLogs(data.logs || []); - } catch (err) { - console.error("log stream poll failed", err); - } - } - - // --- Enable / Disable --- - function enableOverride() { - overrideActive = true; - btnEnable.disabled = true; - btnDisable.disabled = false; - statusBadge.textContent = "ACTIVE"; - statusBadge.classList.remove("bg-secondary"); - statusBadge.classList.add("bg-danger", "active"); - - // Start sending slider values at 20 Hz - sendOverride(); // send immediately - sendTimer = setInterval(sendOverride, SEND_INTERVAL_MS); - } - - function resetOverrideUi() { - overrideActive = false; - btnEnable.disabled = false; - btnDisable.disabled = true; - statusBadge.textContent = "INACTIVE"; - statusBadge.classList.remove("bg-danger", "active"); - statusBadge.classList.add("bg-secondary"); - if (sendTimer) { - clearInterval(sendTimer); - sendTimer = null; + if (data.ok) renderLogs(data.logs || []); + } catch (error) { + console.error("log stream poll failed", error); } } - function disableOverride() { - resetOverrideUi(); - clearOverride(); - } - - // --- Event wiring --- - btnEnable.addEventListener("click", enableOverride); - btnDisable.addEventListener("click", disableOverride); - btnResetAll.addEventListener("click", () => AXES.forEach(resetSlider)); + if (btnEnable) btnEnable.addEventListener("click", enableOverride); + if (btnDisable) btnDisable.addEventListener("click", stopAll); + if (btnResetAll) btnResetAll.addEventListener("click", () => AXES.forEach(resetSlider)); + if (btnStopAll) btnStopAll.addEventListener("click", stopAll); AXES.forEach((axis) => { - const slider = document.getElementById("slider-" + axis); - slider.addEventListener("input", () => updateValueDisplay(axis)); - slider.addEventListener("dblclick", () => resetSlider(axis)); - updateValueDisplay(axis); // init + const el = slider(axis); + if (!el) return; + el.addEventListener("input", () => updateValueDisplay(axis)); + el.addEventListener("dblclick", () => resetSlider(axis)); + updateValueDisplay(axis); }); - // Poll ROV status every 500 ms pollStatus(); setInterval(pollStatus, 500); - pollCommandStatus(); - setInterval(pollCommandStatus, 1000); - bootstrapTelemetryHistory(); pollControlTelemetry(); - setInterval(pollControlTelemetry, 200); + setInterval(pollControlTelemetry, 500); pollLogs(); setInterval(pollLogs, 1500); - - // IMU Live Readout - const imuStatus = document.getElementById("imu-status"); - const imuYaw = document.getElementById("imu-yaw"); - const imuPitch = document.getElementById("imu-pitch"); - const imuRoll = document.getElementById("imu-roll"); - const imuPktCount = document.getElementById("imu-pkt-count"); - const imuAge = document.getElementById("imu-age"); - - function fmtDeg(v) { - const n = parseFloat(v); - if (isNaN(n)) return "--.-\u00B0"; - return (n >= 0 ? "+" : "") + n.toFixed(1) + "\u00B0"; - } - - async function pollIMU() { - try { - const res = await fetch("/api/imu/status"); - const data = await res.json(); - if (!data.ok) return; - - const s = data.stats; - const d = s.last_data; - - imuYaw.textContent = fmtDeg(d.yaw); - imuPitch.textContent = fmtDeg(d.pitch); - imuRoll.textContent = fmtDeg(d.roll); - imuPktCount.textContent = s.packet_count; - imuAge.textContent = s.age_ms != null ? s.age_ms : "--"; - - // Color based on age - if (s.age_ms != null && s.age_ms < 500) { - imuStatus.textContent = "LIVE"; - imuStatus.className = "badge bg-success me-2"; - } else if (s.age_ms != null && s.age_ms < 2000) { - imuStatus.textContent = "STALE"; - imuStatus.className = "badge bg-warning me-2"; - } else { - imuStatus.textContent = "NO DATA"; - imuStatus.className = "badge bg-secondary me-2"; - } - - } catch (_) { - /* silent */ - } - } - - pollIMU(); - setInterval(pollIMU, 200); - - if (btnSystemReset) { - btnSystemReset.addEventListener("click", async () => { - if (!window.confirm("Restart the MCU now?")) return; - systemResetStatus.textContent = "SENDING"; - systemResetStatus.className = "badge bg-warning text-dark"; - btnSystemReset.disabled = true; - try { - const res = await fetch("/api/system/reset", { method: "POST" }); - const data = await res.json(); - if (data.ok) { - systemResetStatus.textContent = "RESET SENT"; - systemResetStatus.className = "badge bg-success"; - } else { - systemResetStatus.textContent = data.error || "FAILED"; - systemResetStatus.className = "badge bg-danger"; - } - } catch (e) { - systemResetStatus.textContent = "ERROR"; - systemResetStatus.className = "badge bg-danger"; - console.error("MCU reset failed:", e); - } finally { - setTimeout(() => { - systemResetStatus.textContent = "READY"; - systemResetStatus.className = "badge bg-secondary"; - btnSystemReset.disabled = false; - }, 3000); - } - }); - } - - // IMU Offset from Mass Center - const offsetX = document.getElementById("offset-x"); - const offsetY = document.getElementById("offset-y"); - const offsetZ = document.getElementById("offset-z"); - const offsetFeedback = document.getElementById("offset-feedback"); - - // Load current offset on page load - async function loadOffset() { - try { - const res = await fetch("/api/imu/offset"); - const data = await res.json(); - if (data.ok && data.offset) { - offsetX.value = data.offset.x; - offsetY.value = data.offset.y; - offsetZ.value = data.offset.z; - } - } catch (_) { - /* silent */ - } - } - - document.getElementById("btn-save-offset").addEventListener("click", async () => { - try { - const res = await fetch("/api/imu/offset", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - x: parseFloat(offsetX.value) || 0, - y: parseFloat(offsetY.value) || 0, - z: parseFloat(offsetZ.value) || 0, - }), - }); - if (!res.ok) { - offsetFeedback.textContent = "Server error: " + res.status; - offsetFeedback.className = "small mt-2 text-danger"; - return; - } - const data = await res.json(); - if (data.ok) { - offsetFeedback.textContent = "Offset saved: X=" + data.offset.x + " Y=" + data.offset.y + " Z=" + data.offset.z; - offsetFeedback.className = "small mt-2 text-success"; - } else { - offsetFeedback.textContent = "Failed to save offset"; - offsetFeedback.className = "small mt-2 text-danger"; - } - } catch (e) { - offsetFeedback.textContent = "Error: " + e.message; - offsetFeedback.className = "small mt-2 text-danger"; - } - }); - - loadOffset(); - - // ============================ - // IMU Axis Mapping - // ============================ - const axesYaw = document.getElementById("axes-yaw"); - const axesPitch = document.getElementById("axes-pitch"); - const axesRoll = document.getElementById("axes-roll"); - const axesFeedback = document.getElementById("axes-feedback"); - - async function loadAxes() { - try { - const res = await fetch("/api/imu/axes"); - const data = await res.json(); - if (data.ok && data.axes) { - axesYaw.value = data.axes.yaw; - axesPitch.value = data.axes.pitch; - axesRoll.value = data.axes.roll; - } - } catch (_) { - /* silent */ - } - } - - document.getElementById("btn-save-axes").addEventListener("click", async () => { - try { - const res = await fetch("/api/imu/axes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - yaw: axesYaw.value, - pitch: axesPitch.value, - roll: axesRoll.value, - }), - }); - if (!res.ok) { - axesFeedback.textContent = "Server error: " + res.status; - axesFeedback.className = "small mt-2 text-danger"; - return; - } - const data = await res.json(); - if (data.ok) { - axesFeedback.textContent = "Orientation saved — takes effect immediately"; - axesFeedback.className = "small mt-2 text-success"; - } else { - axesFeedback.textContent = "Failed to save orientation"; - axesFeedback.className = "small mt-2 text-danger"; - } - } catch (e) { - axesFeedback.textContent = "Error: " + e.message; - axesFeedback.className = "small mt-2 text-danger"; - } - }); - - loadAxes(); - - // ============================ - // Accelerometer Axis Mapping - // ============================ - const accelX = document.getElementById("accel-x"); - const accelY = document.getElementById("accel-y"); - const accelZ = document.getElementById("accel-z"); - const accelFeedback = document.getElementById("accel-feedback"); - - async function loadAccelAxes() { - try { - const res = await fetch("/api/imu/accel_axes"); - const data = await res.json(); - if (data.ok && data.accel_axes) { - accelX.value = data.accel_axes.x; - accelY.value = data.accel_axes.y; - accelZ.value = data.accel_axes.z; - } - } catch (_) { - /* silent */ - } - } - - document.getElementById("btn-save-accel").addEventListener("click", async () => { - try { - const res = await fetch("/api/imu/accel_axes", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - x: accelX.value, - y: accelY.value, - z: accelZ.value, - }), - }); - if (!res.ok) { - accelFeedback.textContent = "Server error: " + res.status; - accelFeedback.className = "small mt-2 text-danger"; - return; - } - const data = await res.json(); - if (data.ok) { - accelFeedback.textContent = "Accel mapping saved — takes effect immediately"; - accelFeedback.className = "small mt-2 text-success"; - } else { - accelFeedback.textContent = "Failed to save accel mapping"; - accelFeedback.className = "small mt-2 text-danger"; - } - } catch (e) { - accelFeedback.textContent = "Error: " + e.message; - accelFeedback.className = "small mt-2 text-danger"; - } - }); - - loadAccelAxes(); - - // --- PID Configuration --- - const PID_AXES = ["surge", "sway", "heave", "roll", "pitch", "yaw"]; - const PID_GAINS = ["kp", "ki", "kd"]; - const pidStatus = document.getElementById("pid-status"); - const btnPidRequest = document.getElementById("btn-pid-request"); - const btnPidSend = document.getElementById("btn-pid-send"); - const btnPidReset = document.getElementById("btn-pid-reset"); - - function setPidStatus(text, cls) { - pidStatus.textContent = text; - pidStatus.className = "badge me-2 " + cls; - } - - function fillPidFields(gains) { - PID_AXES.forEach(function (axis) { - PID_GAINS.forEach(function (g) { - var el = document.getElementById("pid-" + axis + "-" + g); - if (el && gains[axis]) el.value = gains[axis][g]; - }); - }); - } - - function readPidFields() { - var gains = {}; - PID_AXES.forEach(function (axis) { - gains[axis] = {}; - PID_GAINS.forEach(function (g) { - var el = document.getElementById("pid-" + axis + "-" + g); - gains[axis][g] = el ? parseFloat(el.value) || 0 : 0; - }); - }); - return gains; - } - - btnPidRequest.addEventListener("click", async function () { - setPidStatus("REQUESTING...", "bg-warning text-dark"); - btnPidRequest.disabled = true; - try { - var res = await fetch("/api/pid/gains"); - var data = await res.json(); - if (data.ok) { - fillPidFields(data.gains); - setPidStatus("LOADED", "bg-success"); - } else { - setPidStatus("NO RESPONSE", "bg-danger"); - } - } catch (e) { - setPidStatus("ERROR", "bg-danger"); - console.error("PID request failed:", e); - } - btnPidRequest.disabled = false; - }); - - btnPidSend.addEventListener("click", async function () { - setPidStatus("SENDING...", "bg-warning text-dark"); - btnPidSend.disabled = true; - try { - var res = await fetch("/api/pid/gains", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(readPidFields()), - }); - var data = await res.json(); - if (data.ok) { - fillPidFields(data.gains); - var label = data.attempts > 1 - ? "CONFIRMED (retry " + data.attempts + "/3)" - : "CONFIRMED"; - setPidStatus(label, "bg-success"); - } else { - setPidStatus(data.error || "NO RESPONSE", "bg-danger"); - } - } catch (e) { - setPidStatus("ERROR", "bg-danger"); - console.error("PID send failed:", e); - } - btnPidSend.disabled = false; - }); - - btnPidReset.addEventListener("click", async function () { - var zeros = {}; - PID_AXES.forEach(function (axis) { - zeros[axis] = { kp: 0, ki: 0, kd: 0 }; - }); - fillPidFields(zeros); - setPidStatus("SENDING...", "bg-warning text-dark"); - btnPidReset.disabled = true; - try { - var res = await fetch("/api/pid/gains", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(zeros), - }); - var data = await res.json(); - if (data.ok) { - fillPidFields(data.gains); - setPidStatus("RESET OK", "bg-success"); - } else { - setPidStatus(data.error || "NO RESPONSE", "bg-danger"); - } - } catch (e) { - setPidStatus("ERROR", "bg-danger"); - console.error("PID reset failed:", e); - } - btnPidReset.disabled = false; - }); - - // --- PID Config Save / Load --- - var pidConfigName = document.getElementById("pid-config-name"); - var pidConfigSelect = document.getElementById("pid-config-select"); - var btnPidSave = document.getElementById("btn-pid-save"); - var btnPidLoad = document.getElementById("btn-pid-load"); - var btnPidDelete = document.getElementById("btn-pid-delete"); - var pidConfigStatus = document.getElementById("pid-config-status"); - - function setConfigStatus(text, cls) { - pidConfigStatus.textContent = text; - pidConfigStatus.className = "badge small " + cls; - } - - async function refreshConfigList() { - try { - var res = await fetch("/api/pid/configs"); - var data = await res.json(); - var names = data.configs || []; - pidConfigSelect.innerHTML = ''; - names.forEach(function (n) { - var opt = document.createElement("option"); - opt.value = n; - opt.textContent = n; - pidConfigSelect.appendChild(opt); - }); - } catch (e) { - console.error("Failed to list PID configs:", e); - } - } - - btnPidSave.addEventListener("click", async function () { - var name = pidConfigName.value.trim(); - if (!name) { - setConfigStatus("Enter a name", "bg-warning text-dark"); - return; - } - try { - var res = await fetch("/api/pid/configs", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: name, gains: readPidFields() }), - }); - var data = await res.json(); - if (data.ok) { - setConfigStatus("Saved", "bg-success"); - refreshConfigList(); - } else { - setConfigStatus(data.error || "Error", "bg-danger"); - } - } catch (e) { - setConfigStatus("Error", "bg-danger"); - } - }); - - btnPidLoad.addEventListener("click", async function () { - var name = pidConfigSelect.value; - if (!name) { - setConfigStatus("Select a config", "bg-warning text-dark"); - return; - } - try { - var res = await fetch("/api/pid/configs/" + encodeURIComponent(name)); - var data = await res.json(); - if (data.ok) { - fillPidFields(data.gains); - pidConfigName.value = name; - setConfigStatus("Loaded", "bg-success"); - } else { - setConfigStatus(data.error || "Not found", "bg-danger"); - } - } catch (e) { - setConfigStatus("Error", "bg-danger"); - } - }); - - btnPidDelete.addEventListener("click", async function () { - var name = pidConfigSelect.value; - if (!name) { - setConfigStatus("Select a config", "bg-warning text-dark"); - return; - } - if (!confirm('Delete config "' + name + '"?')) return; - try { - var res = await fetch("/api/pid/configs/" + encodeURIComponent(name), { - method: "DELETE", - }); - var data = await res.json(); - if (data.ok) { - setConfigStatus("Deleted", "bg-success"); - refreshConfigList(); - } else { - setConfigStatus(data.error || "Error", "bg-danger"); - } - } catch (e) { - setConfigStatus("Error", "bg-danger"); - } - }); - - // Load config list on page load - refreshConfigList(); - - // --- Attitude Setpoint Override --- - function setAttitudeStatus(text, cls) { - attitudeStatus.textContent = text; - attitudeStatus.className = "badge " + cls; - } - - btnAttitudeSend.addEventListener("click", async function () { - var payload = {}; - var roll = parseFloat(attRollInput.value); - var pitch = parseFloat(attPitchInput.value); - var yaw = parseFloat(attYawInput.value); - if (!isNaN(roll)) payload.roll = roll; - if (!isNaN(pitch)) payload.pitch = pitch; - if (!isNaN(yaw)) payload.yaw = yaw; - if (Object.keys(payload).length === 0) { - attitudeFeedback.textContent = "Enter at least one value."; - attitudeFeedback.className = "small text-warning ms-auto"; - return; - } - setAttitudeStatus("SENDING…", "bg-warning text-dark"); - btnAttitudeSend.disabled = true; - try { - var res = await fetch("/api/debug/attitude_setpoint", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - var data = await res.json(); - if (data.ok) { - var sent = data.sent || {}; - var parts = Object.entries(sent).map(function (kv) { - return kv[0] + "=" + kv[1].toFixed(1) + "°"; - }); - attitudeFeedback.textContent = "Sent: " + parts.join(", "); - attitudeFeedback.className = "small text-success ms-auto"; - setAttitudeStatus("ACTIVE", "bg-danger"); - } else { - attitudeFeedback.textContent = data.error || "Failed"; - attitudeFeedback.className = "small text-danger ms-auto"; - setAttitudeStatus("ERROR", "bg-danger"); - } - } catch (e) { - attitudeFeedback.textContent = "Error: " + e.message; - attitudeFeedback.className = "small text-danger ms-auto"; - setAttitudeStatus("ERROR", "bg-danger"); - } - btnAttitudeSend.disabled = false; - }); - - btnAttitudeClear.addEventListener("click", async function () { - setAttitudeStatus("CLEARING…", "bg-warning text-dark"); - btnAttitudeClear.disabled = true; - try { - var res = await fetch("/api/debug/clear", { method: "POST" }); - var data = await res.json(); - if (data.ok) { - attitudeFeedback.textContent = "Override cleared."; - attitudeFeedback.className = "small text-light-muted ms-auto"; - setAttitudeStatus("IDLE", "bg-secondary"); - } else { - attitudeFeedback.textContent = data.error || "Failed to clear"; - attitudeFeedback.className = "small text-danger ms-auto"; - setAttitudeStatus("ERROR", "bg-danger"); - } - } catch (e) { - attitudeFeedback.textContent = "Error: " + e.message; - attitudeFeedback.className = "small text-danger ms-auto"; - setAttitudeStatus("ERROR", "bg-danger"); - } - btnAttitudeClear.disabled = false; - }); - - // --- Lights --- - const lightSlider = document.getElementById("debug-light-slider"); - const lightValue = document.getElementById("debug-light-value"); - let lightPostTimer = null; - - async function postLight(level) { - try { - await fetch("/api/lights", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ level: level }), - }); - } catch (e) { - /* ignore transient send errors */ - } - } - - if (lightSlider) { - lightSlider.addEventListener("input", function () { - var level = parseInt(lightSlider.value, 10); - if (lightValue) lightValue.textContent = level; - if (!lightPostTimer) { - lightPostTimer = setTimeout(function () { - lightPostTimer = null; - }, 100); - postLight(level); - } - }); - lightSlider.addEventListener("change", function () { - postLight(parseInt(lightSlider.value, 10)); - }); - - // Initialize from current server value. - fetch("/api/lights") - .then(function (r) { return r.json(); }) - .then(function (d) { - var pct = d.level != null ? d.level : (d.light != null ? d.light : 0); - lightSlider.value = pct; - if (lightValue) lightValue.textContent = pct; - }) - .catch(function () {}); - } })(); diff --git a/static/js/ip_camera.js b/static/js/ip_camera.js new file mode 100644 index 0000000..6db935c --- /dev/null +++ b/static/js/ip_camera.js @@ -0,0 +1,181 @@ +(function () { + const feed = document.getElementById("ip-camera-feed"); + const state = document.getElementById("ip-camera-state"); + const activeIp = document.getElementById("ip-camera-active-ip"); + const activeUrl = document.getElementById("ip-camera-active-url"); + const ipInput = document.getElementById("ip-camera-ip"); + const nameInput = document.getElementById("ip-camera-name"); + const presetSelect = document.getElementById("ip-camera-presets"); + const feedback = document.getElementById("ip-camera-feedback"); + const btnApply = document.getElementById("ip-camera-apply"); + const btnSave = document.getElementById("ip-camera-save"); + const btnLoad = document.getElementById("ip-camera-load"); + const btnDelete = document.getElementById("ip-camera-delete"); + + let presets = []; + + function setFeedback(text, cls) { + if (!feedback) return; + feedback.textContent = text; + feedback.className = "small mt-2 " + cls; + } + + function setState(status) { + if (!state) return; + if (status && status.connected) { + state.textContent = "LIVE"; + state.className = "badge bg-success"; + } else { + state.textContent = "OFFLINE"; + state.className = "badge bg-danger"; + } + } + + function reloadFeed() { + if (feed) feed.src = "/ip_video_feed?ts=" + Date.now(); + } + + function selectedPreset() { + const name = presetSelect ? presetSelect.value : ""; + return presets.find((preset) => preset.name === name); + } + + function renderPresets(list) { + presets = Array.isArray(list) ? list : []; + if (!presetSelect) return; + presetSelect.innerHTML = ""; + if (presets.length === 0) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "No presets"; + presetSelect.appendChild(opt); + return; + } + presets.forEach((preset) => { + const opt = document.createElement("option"); + opt.value = preset.name; + opt.textContent = preset.name + " - " + preset.ip; + presetSelect.appendChild(opt); + }); + } + + async function loadConfigs() { + try { + const res = await fetch("/api/ip_camera/configs", { cache: "no-store" }); + const data = await res.json(); + if (!data.ok) return; + renderPresets(data.presets || []); + if (activeIp) activeIp.textContent = data.active_ip || "--"; + if (activeUrl) activeUrl.textContent = data.active_url || "--"; + if (ipInput && data.active_ip) ipInput.value = data.active_ip; + setState(data.status || {}); + } catch (error) { + setFeedback("Failed to load camera config", "text-danger"); + } + } + + async function savePreset() { + const name = nameInput ? nameInput.value.trim() : ""; + const ip = ipInput ? ipInput.value.trim() : ""; + if (!name || !ip) { + setFeedback("Enter preset name and IP.", "text-warning"); + return; + } + try { + const res = await fetch("/api/ip_camera/configs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, ip }), + }); + const data = await res.json(); + if (data.ok) { + setFeedback("Preset saved.", "text-success"); + await loadConfigs(); + if (presetSelect) presetSelect.value = name; + } else { + setFeedback(data.error || "Save failed.", "text-danger"); + } + } catch (error) { + setFeedback("Save failed.", "text-danger"); + } + } + + async function deletePreset() { + const preset = selectedPreset(); + if (!preset) { + setFeedback("Select a preset.", "text-warning"); + return; + } + try { + const res = await fetch("/api/ip_camera/configs/" + encodeURIComponent(preset.name), { method: "DELETE" }); + const data = await res.json(); + if (data.ok) { + setFeedback("Preset deleted.", "text-success"); + renderPresets(data.presets || []); + } else { + setFeedback(data.error || "Delete failed.", "text-danger"); + } + } catch (error) { + setFeedback("Delete failed.", "text-danger"); + } + } + + function loadPreset() { + const preset = selectedPreset(); + if (!preset) { + setFeedback("Select a preset.", "text-warning"); + return; + } + if (nameInput) nameInput.value = preset.name; + if (ipInput) ipInput.value = preset.ip; + setFeedback("Preset loaded.", "text-light-muted"); + } + + async function applyIp() { + const ip = ipInput ? ipInput.value.trim() : ""; + if (!ip) { + setFeedback("Enter an IP address.", "text-warning"); + return; + } + if (btnApply) btnApply.disabled = true; + setFeedback("Applying IP...", "text-warning"); + try { + const res = await fetch("/api/ip_camera/reassign", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ip }), + }); + const data = await res.json(); + if (data.ok) { + if (activeIp) activeIp.textContent = data.active_ip || ip; + if (activeUrl) activeUrl.textContent = data.active_url || "--"; + setState(data.status || {}); + reloadFeed(); + setFeedback("Camera reassigned.", "text-success"); + } else { + setFeedback(data.error || "Apply failed.", "text-danger"); + } + } catch (error) { + setFeedback("Apply failed.", "text-danger"); + } + if (btnApply) btnApply.disabled = false; + } + + async function pollStatus() { + try { + const res = await fetch("/api/ip_camera/status", { cache: "no-store" }); + const data = await res.json(); + setState(data); + } catch (_) { + setState({ connected: false }); + } + } + + if (btnSave) btnSave.addEventListener("click", savePreset); + if (btnDelete) btnDelete.addEventListener("click", deletePreset); + if (btnLoad) btnLoad.addEventListener("click", loadPreset); + if (btnApply) btnApply.addEventListener("click", applyIp); + + loadConfigs(); + setInterval(pollStatus, 3000); +})(); diff --git a/static/js/pilot.js b/static/js/pilot.js index fcba66f..826fe29 100644 --- a/static/js/pilot.js +++ b/static/js/pilot.js @@ -1,8 +1,3 @@ -/* =================================================================== - Pilot HUD – JavaScript - Fetches telemetry from existing APIs and updates the HUD elements - =================================================================== */ - (function () { "use strict"; @@ -19,6 +14,7 @@ loadedOnce: false, fitContain: false, }; + const aruco = { enabled: false, state: null, @@ -28,20 +24,24 @@ log: null, }; + let hudLightSlider = null; + let hudLightPostTimer = null; + const startTime = Date.now(); + function setCameraState(state, label) { if (!camera.status || !camera.stateText) return; camera.status.classList.remove("state-ok", "state-reconnecting", "state-waiting", "state-error"); - camera.status.classList.add(`state-${state}`); + camera.status.classList.add("state-" + state); camera.stateText.textContent = label; } function cameraUrl() { - return `/ip_video_feed?ts=${Date.now()}`; + return "/ip_video_feed?ts=" + Date.now(); } - function scheduleCameraReconnect(reason = "Reconnecting…") { + function scheduleCameraReconnect(label) { if (!camera.img || camera.reconnectTimer) return; - setCameraState("reconnecting", reason); + setCameraState("reconnecting", label || "Reconnecting..."); camera.reconnectTimer = setTimeout(() => { camera.reconnectTimer = null; camera.img.src = cameraUrl(); @@ -65,8 +65,6 @@ camera.fitBtn.textContent = contain ? "Fill" : "Fit"; } - // ── Mission timer ───────────────────────────────────────────── - const startTime = Date.now(); function updateMissionTime() { const elapsed = Math.floor((Date.now() - startTime) / 1000); const h = String(Math.floor(elapsed / 3600)).padStart(2, "0"); @@ -76,208 +74,84 @@ if (el) el.textContent = `${h}:${m}:${s}`; } - // ── Compass strip builder ───────────────────────────────────── - function buildCompassStrip() { - const strip = document.getElementById("hud-compass-strip"); - if (!strip) return; - strip.innerHTML = ""; - const cardinals = { 0: "N", 45: "NE", 90: "E", 135: "SE", 180: "S", 225: "SW", 270: "W", 315: "NW", 360: "N" }; - // Build from -90 to 450 for wrapping - for (let deg = -90; deg <= 450; deg += 5) { - const tick = document.createElement("div"); - tick.className = "compass-tick"; - const line = document.createElement("div"); - const normDeg = ((deg % 360) + 360) % 360; - if (normDeg % 45 === 0) { - line.className = "compass-tick-line major"; - const lbl = document.createElement("div"); - lbl.className = "compass-tick-label"; - lbl.textContent = cardinals[normDeg] || `${normDeg}`; - tick.appendChild(line); - tick.appendChild(lbl); - } else if (normDeg % 10 === 0) { - line.className = "compass-tick-line minor"; - tick.appendChild(line); - } else { - line.className = "compass-tick-line minor"; - line.style.height = "3px"; - tick.appendChild(line); - } - tick.dataset.deg = deg; - strip.appendChild(tick); - } - } - - function updateCompass(heading) { - const strip = document.getElementById("hud-compass-strip"); - if (!strip) return; - // Each tick is 30px wide, ticks every 5° → 6px per degree - const offset = heading * 6; - // Center the -90 start offset - const baseOffset = -90 * 6; - strip.style.transform = `translateX(${-(offset - baseOffset)}px)`; - } - - // ── Artificial horizon ──────────────────────────────────────── - function updateHorizon(roll, pitch) { - const svg = document.getElementById("hud-horizon"); - if (!svg) return; - - const sky = document.getElementById("horizon-sky"); - const ground = document.getElementById("horizon-ground"); - const line = document.getElementById("horizon-line"); - const pitchG = document.getElementById("horizon-pitch-group"); - - // Pitch: 1px per degree, clamped to ±45 - const pitchPx = Math.max(-45, Math.min(45, pitch)) * 1.0; - - // Apply rotation (roll) and translation (pitch) to sky/ground group - const transform = `rotate(${-roll}, 60, 60) translate(0, ${pitchPx})`; - sky.setAttribute("transform", transform); - ground.setAttribute("transform", transform); - line.setAttribute("transform", transform); - if (pitchG) pitchG.setAttribute("transform", transform); - } - - function fmtAngle(v) { - return (v >= 0 ? "+" : "") + v.toFixed(1) + "\u00B0"; - } - - // ── Data fetchers ───────────────────────────────────────────── - async function fetchDepth() { try { const res = await fetch("/api/depth"); - const d = await res.json(); - const el = document.getElementById("hud-depth"); - const tgt = document.getElementById("hud-depth-target"); - if (el) el.textContent = d.dpt != null ? parseFloat(d.dpt).toFixed(1) : "--.-"; - if (tgt) tgt.textContent = d.dptSet != null ? parseFloat(d.dptSet).toFixed(1) : "--.-"; - } catch (_) { /* silent */ } + const data = await res.json(); + const depth = document.getElementById("hud-depth"); + const target = document.getElementById("hud-depth-target"); + if (depth) depth.textContent = data.dpt != null ? parseFloat(data.dpt).toFixed(1) : "--.-"; + if (target) target.textContent = data.dptSet != null ? parseFloat(data.dptSet).toFixed(1) : "--.-"; + } catch (_) {} } - async function fetchBattery() { + async function fetchLights() { try { - const res = await fetch("/api/battery"); - const d = await res.json(); - const level = d.battery ?? 0; - const el = document.getElementById("hud-battery"); - const fill = document.getElementById("hud-battery-fill"); - if (el) el.textContent = level; - if (fill) { - fill.style.width = level + "%"; - fill.classList.remove("batt-low", "batt-mid"); - if (level < 20) fill.classList.add("batt-low"); - else if (level < 50) fill.classList.add("batt-mid"); + const res = await fetch("/api/lights"); + const data = await res.json(); + const level = data.level ?? data.light ?? 0; + const readout = document.getElementById("hud-lights"); + if (readout) readout.textContent = typeof level === "number" ? `${level}%` : String(level); + if (hudLightSlider && document.activeElement !== hudLightSlider) { + hudLightSlider.value = typeof level === "number" ? level : 0; } - } catch (_) { /* silent */ } + } catch (_) {} } - async function fetchSensors() { + async function postLights(level) { try { - const res = await fetch("/api/sensors"); - const d = await res.json(); - - // VN-100S provides fused yaw/pitch/roll directly - const angles = { - roll: d.roll || 0, - pitch: d.pitch || 0, - yaw: d.yaw || 0, - }; - - // Update readouts - const elRoll = document.getElementById("hud-roll"); - const elPitch = document.getElementById("hud-pitch"); - const elHdg = document.getElementById("hud-heading"); - if (elRoll) elRoll.textContent = fmtAngle(angles.roll); - if (elPitch) elPitch.textContent = fmtAngle(angles.pitch); - - // Heading (yaw mapped to 0-360) - const heading = ((angles.yaw % 360) + 360) % 360; - if (elHdg) elHdg.textContent = heading.toFixed(0).padStart(3, "0"); - updateCompass(heading); - updateHorizon(angles.roll, angles.pitch); - } catch (_) { /* silent */ } - } - - function setTelem(id, val) { - const el = document.getElementById(id); - if (el) el.textContent = val != null ? parseFloat(val).toFixed(2) : "—"; + await fetch("/api/lights", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ level }), + }); + } catch (_) {} } - async function fetchThrusters() { - try { - const res = await fetch("/api/thrusters"); - const data = await res.json(); - const container = document.getElementById("hud-thrusters"); - if (!container) return; - - // Build thruster indicators if empty - const thrusters = Array.isArray(data) ? data : Object.values(data); - if (container.children.length === 0 && thrusters.length > 0) { - container.innerHTML = ""; - thrusters.forEach((_, i) => { - const item = document.createElement("div"); - item.className = "hud-thr-item"; - item.innerHTML = ` -
- T${i + 1} - `; - container.appendChild(item); - }); - } - - thrusters.forEach((t, i) => { - const dot = document.getElementById(`thr-dot-${i}`); - const val = document.getElementById(`thr-val-${i}`); - const power = t.power ?? t.pwr ?? 0; - const temp = t.temperature ?? t.temp ?? 0; - - if (val) val.textContent = `${power}W`; - if (dot) { - dot.classList.remove("thr-ok", "thr-warn", "thr-error"); - if (temp > 60) dot.classList.add("thr-error"); - else if (temp > 40) dot.classList.add("thr-warn"); - else dot.classList.add("thr-ok"); - } - }); - } catch (_) { /* silent */ } + function queueLightPost(level) { + if (hudLightPostTimer) return; + hudLightPostTimer = setTimeout(() => { + hudLightPostTimer = null; + }, 100); + postLights(level); } - async function fetchLights() { - try { - const res = await fetch("/api/lights"); - const d = await res.json(); - const el = document.getElementById("hud-lights"); - if (el) { - const level = d.level ?? d.light ?? d.value ?? "—"; - el.textContent = typeof level === "number" ? `${level}%` : level; - } - } catch (_) { /* silent */ } + function initLightControls() { + hudLightSlider = document.getElementById("hud-light-slider"); + if (!hudLightSlider) return; + hudLightSlider.addEventListener("input", () => { + const level = parseInt(hudLightSlider.value, 10); + const readout = document.getElementById("hud-lights"); + if (readout) readout.textContent = `${level}%`; + queueLightPost(level); + }); + hudLightSlider.addEventListener("change", () => { + postLights(parseInt(hudLightSlider.value, 10)); + }); } async function fetchCameraStatus() { try { const res = await fetch("/api/ip_camera/status"); - const d = await res.json(); + const data = await res.json(); const dot = document.getElementById("hud-rpi-dot"); if (dot) { dot.classList.remove("status-ok", "status-err"); - dot.classList.add(d.connected ? "status-ok" : "status-err"); + dot.classList.add(data.connected ? "status-ok" : "status-err"); } - if (d.connected) { + if (data.connected) { if (camera.loadedOnce) { setCameraState("ok", "Live"); resetCameraReconnectBackoff(); } else { - setCameraState("waiting", "Waiting for first frame…"); + setCameraState("waiting", "Waiting for first frame..."); } } else { setCameraState("error", "Camera offline"); - scheduleCameraReconnect("Camera offline – reconnecting…"); + scheduleCameraReconnect("Camera offline - reconnecting..."); } - } catch (_) { /* silent */ } + } catch (_) {} } function renderArucoLog(log) { @@ -286,9 +160,7 @@ aruco.state.textContent = aruco.enabled ? "ON" : "OFF"; aruco.state.classList.toggle("is-on", aruco.enabled); } - if (aruco.toggleBtn) { - aruco.toggleBtn.textContent = aruco.enabled ? "Stop" : "Start"; - } + if (aruco.toggleBtn) aruco.toggleBtn.textContent = aruco.enabled ? "Stop" : "Start"; if (aruco.visible) { const visibleIds = log && Array.isArray(log.visible_ids) ? log.visible_ids : []; aruco.visible.textContent = visibleIds.length > 0 ? visibleIds.join(", ") : "--"; @@ -298,7 +170,7 @@ aruco.log.innerHTML = ""; entries.forEach((entry) => { const item = document.createElement("li"); - item.textContent = `ID ${entry.id}`; + item.textContent = "ID " + entry.id; aruco.log.appendChild(item); }); } @@ -309,15 +181,15 @@ const res = await fetch("/api/aruco-log"); const data = await res.json(); if (data.ok) renderArucoLog(data.log); - } catch (_) { /* silent */ } + } catch (_) {} } async function postArucoAction(action) { try { - const res = await fetch(`/api/aruco-log/${action}`, { method: "POST" }); + const res = await fetch("/api/aruco-log/" + action, { method: "POST" }); const data = await res.json(); if (data.ok) renderArucoLog(data.log); - } catch (_) { /* silent */ } + } catch (_) {} } function initArucoControls() { @@ -332,9 +204,7 @@ postArucoAction(aruco.enabled ? "stop" : "start"); }); } - if (aruco.clearBtn) { - aruco.clearBtn.addEventListener("click", () => postArucoAction("clear")); - } + if (aruco.clearBtn) aruco.clearBtn.addEventListener("click", () => postArucoAction("clear")); } function initCameraFeed() { @@ -346,65 +216,49 @@ camera.fitBtn = document.getElementById("pilot-fit"); if (!camera.img) return; - - setCameraState("waiting", "Connecting…"); + setCameraState("waiting", "Connecting..."); camera.img.addEventListener("load", () => { camera.loadedOnce = true; setCameraState("ok", "Live"); resetCameraReconnectBackoff(); }); - camera.img.addEventListener("error", () => { setCameraState("error", "Stream error"); - scheduleCameraReconnect("Stream error – reconnecting…"); + scheduleCameraReconnect("Stream error - reconnecting..."); }); - if (camera.reconnectBtn) { camera.reconnectBtn.addEventListener("click", () => { resetCameraReconnectBackoff(); - setCameraState("reconnecting", "Manual reconnect…"); + setCameraState("reconnecting", "Manual reconnect..."); camera.img.src = cameraUrl(); }); } - - if (camera.fitBtn) { - camera.fitBtn.addEventListener("click", () => setFitMode(!camera.fitContain)); - } - + if (camera.fitBtn) camera.fitBtn.addEventListener("click", () => setFitMode(!camera.fitContain)); setFitMode(false); } - // ── Initialization ──────────────────────────────────────────── function init() { - buildCompassStrip(); initCameraFeed(); initArucoControls(); + initLightControls(); - // Hide the page header/footer for immersive view const header = document.querySelector("header.header"); if (header) header.style.display = "none"; const footer = document.querySelector("footer.footer, .footer"); if (footer) footer.style.display = "none"; - // Start polling loops (keep rates low to avoid starving the MJPEG stream) + fetchDepth(); + fetchLights(); + fetchCameraStatus(); + fetchArucoLog(); + updateMissionTime(); + setInterval(fetchDepth, 1000); - setInterval(fetchBattery, 3000); - setInterval(fetchSensors, 500); // lower API pressure; keep camera smooth - setInterval(fetchThrusters, 2000); setInterval(fetchLights, 3000); setInterval(fetchCameraStatus, 5000); setInterval(fetchArucoLog, 1000); setInterval(updateMissionTime, 1000); - - // Initial fetches - fetchDepth(); - fetchBattery(); - fetchSensors(); - fetchThrusters(); - fetchLights(); - fetchCameraStatus(); - fetchArucoLog(); } if (document.readyState === "loading") { diff --git a/static/templates/base.html b/static/templates/base.html index 93255ee..5c916f4 100644 --- a/static/templates/base.html +++ b/static/templates/base.html @@ -3,18 +3,15 @@ - - {% block extra_head %}{% endblock %} - {% block title %}ROV Dashboard{% endblock %} + {% block title %}UiASub Topside{% endblock %} - - -
{% block content %}{% endblock %}
-

© 2025 UiASub.

- - - - {% block extra_scripts %}{% endblock %} diff --git a/static/templates/camera2.html b/static/templates/camera2.html index 8d2bbb8..a91e183 100644 --- a/static/templates/camera2.html +++ b/static/templates/camera2.html @@ -1,14 +1 @@ - - - - - - Title - - -

Nothing here yet.

- - - \ No newline at end of file +{% extends "ip_camera.html" %} diff --git a/static/templates/config.html b/static/templates/config.html new file mode 100644 index 0000000..92a196a --- /dev/null +++ b/static/templates/config.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% block title %}Config - UiASub Topside{% endblock %} + +{% block content %} +
+
+
+
+
+
Input Source
+ UNKNOWN +
+
+
+
+
Controller
+
--
+
+
+
+
+
Override
+
--
+
+
+
+
+
Last Ack
+
--
+
+
+
+
+
+
+
+ +
+
+
+
+
Controller Gains
+
+ + +
+
+ {% for axis in ["surge", "sway", "heave", "roll", "pitch", "yaw"] %} +
+ + +
+ {% endfor %} +
+
+
+
+
+ +
+
+
+
+
IMU Axis Mapping
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+
+ +
+
+
+
+
Accelerometer Axis Mapping
+
+ {% for axis in ["x", "y", "z"] %} +
+ + +
+ {% endfor %} +
+ +
+
+
+
+
+
+
+ +
+
+
+
+
IMU Offset from Mass Center
+
+ {% for axis, label in [("x", "X forward +"), ("y", "Y starboard +"), ("z", "Z down +")] %} +
+ +
+ + mm +
+
+ {% endfor %} +
+ +
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/static/templates/connection.html b/static/templates/connection.html new file mode 100644 index 0000000..a64d69f --- /dev/null +++ b/static/templates/connection.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}Connection - UiASub Topside{% endblock %} + +{% block content %} +
+
+
+
+
+
Nucleo Contact Proof
+ WAITING +
+
+
+
+
Last Ack
+
--
+
+
+
+
+
UDP RX
+
--
+
+
+
+
+
RX Errors
+
--
+
+
+
+ +
+
+
+ +
+
+
+
ESC Proof
+ UNAVAILABLE +

Real ESC telemetry is not available yet.

+
+
+
+
+ +
+
+
+
+
+
Restart MCU
+ READY +
+ +
+
+
+ +
+
+
+
test.py Activation
+ +
+
+
+
+ + +{% endblock %} diff --git a/static/templates/debug.html b/static/templates/debug.html index 26ccaee..e720d6b 100644 --- a/static/templates/debug.html +++ b/static/templates/debug.html @@ -1,485 +1,60 @@ {% extends "base.html" %} -{% block title %}Debug Controls{% endblock %} +{% block title %}Debug - UiASub Topside{% endblock %} {% block content %} - -
-
IMU – VN-100S
-
- NO DATA -
-
- -
-
-
- YAW -
--.-°
-
-
-
-
- PITCH -
--.-°
-
-
-
-
- ROLL -
--.-°
-
-
-
- -
-
- - Packets: 0 | - Age: -- ms - -
-
-
-
-
-
- - -
-
-
-
-
IMU Axis Remapping
-

- Remap the sensor output to match the ROV frame. For each ROV axis, pick which sensor output to use and whether to flip it. -

-
-
- - -
-
- - -
-
- - -
-
- -
-
-
-
-
-
-
- - -
-
-
-
-
-
System Controls
- READY -
-

- Request a cold restart of the MCU. Thrusters will stop while the controller reboots and reconnects. -

- -
-
-
-
- - -
-
-
-
-
Lights
-

- Dimmable light on D6/PA8. Drives the same value the controller D-pad sets. -

- - -
-
-
-
- - -
-
-
-
-
Accelerometer Axis Remapping
-

- Remap the accelerometer output to match the ROV frame. For each ROV axis, pick which sensor output to use and whether to flip it. -

-
-
- - -
-
- - -
-
- - -
-
- -
+
Debug Override
+ INACTIVE
-
-
-
-
-
- - -
-
-
-
-
IMU Offset from Mass Center
-

- Distance from the IMU sensor to the ROV center of mass (mm). Used for PID compensation. -

-
-
- -
- - mm -
-
-
- -
- - mm -
-
-
- -
- - mm -
-
-
- -
-
-
-
-
-
-
- - -
-
-
-
-
-
Debug Override Controls
-
- INACTIVE - - -
+
+ + + +
-

- When enabled, these sliders override the physical controller. All values range from -1.0 to 1.0. - Double-click any slider to reset it to 0. -

- -
-
-
- - 0.00 -
- Forward / Backward - -
-
- - -
-
-
- - 0.00 -
- Left / Right - -
-
- - -
-
-
- - 0.00 -
- Up / Down - -
-
- - -
-
-
- - 0.00 -
- Rotate around forward axis - -
-
- - + {% for axis, label in [("surge", "Surge"), ("sway", "Sway"), ("heave", "Heave"), ("roll", "Roll"), ("pitch", "Pitch"), ("yaw", "Yaw")] %}
- - 0.00 + + 0.00
- Nose up / down - -
-
- - -
-
-
- - 0.00 -
- Turn left / right - +
-
- - -
- + {% endfor %}
- -
-
-
-
-
-
Attitude Setpoint Override (°)
- IDLE -
-

- Push exact roll, pitch, and yaw setpoints down to the flight controller. Values are clamped to the configured VN-100 angle ranges. - Use this for heading-hold experiments without touching the raw thruster sliders. -

-
-
- -
- - deg -
-
-
- -
- - deg -
-
-
- -
- - deg -
-
-
-
- - -
Waiting for input…
-
-
-
-
-
- -
-
-
+
+
-
PID Configuration (MCU)
-
- READY - - - -
-
-

- Set exact P, I, D gains for each axis. Use "Request Current" to load values from the MCU, edit them, then "Send to MCU". -

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AxisP (Proportional)I (Integral)D (Derivative)
Surge
Sway
Heave
Roll
Pitch
Yaw
-
- - -
-
-
- - -
-
- -
-
- - -
-
- -
-
- -
-
- - -
-
+
Topside Sends
+ /api/rov/status
+
Loading...
-
- - -
-
Control Telemetry
+
Nucleo Control Telemetry
Updated -- ms ago
@@ -493,168 +68,24 @@
Control Telemetry
- Waiting for packets… + Waiting for packets...
-
-
-
-
-
Command Link Health
- IDLE -
-

Sequence -- · Last send -- ms · Last ack -- ms

-
-
-
UDP RX Count
- -
-
-
RX Errors
- -
-
-
Watchdog Resends
- -
-
Override State
- INACTIVE -
-
-
-
-
-
- - -
-
-
-
-
-
Control Loop Visualizer
- 10 Hz history of setpoint, output, and error -
-
-
-
-
- Surge - -- -
- -
- Setpoint - Output - Error -
-
-
-
-
-
- Sway - -- -
- -
- Setpoint - Output - Error -
-
-
-
-
-
- Heave - -- -
- -
- Setpoint - Output - Error -
-
-
-
-
-
- Roll - -- -
- -
- Setpoint - Output - Error -
-
-
-
-
-
- Pitch - -- -
- -
- Setpoint - Output - Error -
-
-
-
-
-
- Yaw - -- -
- -
- Setpoint - Output - Error -
-
-
-
-
-
-
-
- - -
-
-
-
-
-
Live Packages Sent from Topside
- Bitmask payload preview & raw command snapshot -
-
Loading...
-
-
-
-
Zephyr Log Stream
- Latest 50 entries + /api/logs/live
-
+
diff --git a/static/templates/ip_camera.html b/static/templates/ip_camera.html new file mode 100644 index 0000000..b7f444a --- /dev/null +++ b/static/templates/ip_camera.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} + +{% block title %}IP Camera - UiASub Topside{% endblock %} + +{% block content %} +
+
+ IP camera feed +
+
+
IP Camera
+ LOADING +
+
+ Active: --
+ URL: -- +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + +
+
Ready.
+
+
+
+ + +{% endblock %} diff --git a/static/templates/layout.html b/static/templates/layout.html index 56af67e..08e9dad 100644 --- a/static/templates/layout.html +++ b/static/templates/layout.html @@ -1,226 +1,62 @@ {% extends "base.html" %} +{% block title %}Home - UiASub Topside{% endblock %} + {% block content %}
- -
-
-
-
Battery
-
-
0%
-
-

Loading...

-
-
-
- - -
-
-
-
Depth
-

Loading...

- Target: - -
-
-
- - -
-
-
-
Lights
-
- - -
+
+
+
+

UiASub Topside

+

+ Start in the Connection tab and verify Nucleo contact before using camera, tooling, debug, or pilot. +

+ Open Connection
- - -
-
-
-
Manipulator
-
- - -
-
- Applied: --° / - -- us -
-
Source: --
+
+
+
+

Branch

+
gui-rework
- -
- - - - - - -
- - -
- - - -
- -
-
-
-
Controller Status
-
-
- {% include '_controller.html' %} -
-
-
-
-
-
- - -{% include '_config_modal.html' %} - - - - - - - - - {% endblock %} diff --git a/static/templates/pilot.html b/static/templates/pilot.html index d861a0b..9519248 100644 --- a/static/templates/pilot.html +++ b/static/templates/pilot.html @@ -1,28 +1,22 @@ {% extends "base.html" %} -{% block title %}Pilot View – ROV Dashboard{% endblock %} +{% block title %}Pilot - UiASub Topside{% endblock %} {% block content %}
- -
- ROV Camera Feed + ROV camera feed
- CONNECTING… + CONNECTING...
-
- -
-
DEPTH
@@ -32,76 +26,10 @@ TGT --.-m
- -
- -
- -
+
-
@@ -119,52 +47,25 @@
- - - -
- + - - - -
-
-
- THRUSTERS -
+
+ LIGHTS +
+ LEVEL + -- +
+
@@ -202,7 +103,6 @@ 00:00:00
-
diff --git a/static/templates/tooling.html b/static/templates/tooling.html new file mode 100644 index 0000000..91a8f5b --- /dev/null +++ b/static/templates/tooling.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}Tooling - UiASub Topside{% endblock %} + +{% block content %} +
+
+
+
+
Main Lights
+ + +
+
+
+ +
+
+
+
Manipulator
+ + +
+ Applied: -- deg / + -- us +
+
Source: --
+
+
+
+
+ + + +{% endblock %} diff --git a/tests/test_ip_camera_routes.py b/tests/test_ip_camera_routes.py new file mode 100644 index 0000000..92717cf --- /dev/null +++ b/tests/test_ip_camera_routes.py @@ -0,0 +1,75 @@ +from flask import Flask + +import routes +from lib.json_data_handler import JSONDataHandler + + +class FakeIPCamera: + def __init__(self, url): + self.url = url + self.stopped = False + + def stop(self): + self.stopped = True + + def get_status(self): + return {"connected": True, "url": self.url} + + +def make_client(monkeypatch, tmp_path): + config_handler = JSONDataHandler(file_path=tmp_path / "config.json") + monkeypatch.setattr(routes, "config_handler", config_handler) + + app = Flask(__name__) + camera = FakeIPCamera("rtsp://10.77.0.4:554/stream1") + app.config["IP_CAMERA"] = camera + app.config["IP_CAMERA_ACTIVE_IP"] = "10.77.0.4" + app.config["IP_CAMERA_ACTIVE_URL"] = camera.url + app.config["IP_CAMERA_SETTINGS"] = { + "out_width": 320, + "out_height": 240, + "jpeg_quality": 70, + "flip_180": False, + } + routes.register_routes(app) + return app, app.test_client(), camera + + +def test_ip_camera_preset_save_and_delete(monkeypatch, tmp_path): + _app, client, _camera = make_client(monkeypatch, tmp_path) + + res = client.post("/api/ip_camera/configs", json={"name": "Pool", "ip": "10.77.0.5"}) + assert res.status_code == 200 + assert res.get_json()["presets"] == [{"name": "Pool", "ip": "10.77.0.5"}] + + res = client.get("/api/ip_camera/configs") + assert res.status_code == 200 + assert res.get_json()["presets"] == [{"name": "Pool", "ip": "10.77.0.5"}] + + res = client.delete("/api/ip_camera/configs/Pool") + assert res.status_code == 200 + assert res.get_json()["presets"] == [] + + +def test_ip_camera_reassign_restarts_fake_receiver(monkeypatch, tmp_path): + app, client, old_camera = make_client(monkeypatch, tmp_path) + created = [] + + def fake_init_ip_camera(**kwargs): + camera = FakeIPCamera(kwargs["url"]) + created.append((camera, kwargs)) + return camera + + monkeypatch.setattr(routes, "init_ip_camera", fake_init_ip_camera) + + res = client.post("/api/ip_camera/reassign", json={"ip": "10.77.0.9"}) + + assert res.status_code == 200 + data = res.get_json() + assert data["active_ip"] == "10.77.0.9" + assert data["active_url"] == "rtsp://10.77.0.9:554/stream1" + assert old_camera.stopped is True + assert len(created) == 1 + assert created[0][1]["url"] == "rtsp://10.77.0.9:554/stream1" + assert created[0][1]["out_width"] == 320 + assert app.config["IP_CAMERA"] is created[0][0] From 41cef6f74e390874f77828a884e70f18b2587b5f Mon Sep 17 00:00:00 2001 From: Tomas Aas-Hansen Date: Thu, 11 Jun 2026 19:11:26 +0200 Subject: [PATCH 3/3] Remove controller gains and expose PID tuning --- data/data.json | 51 +++++++--------------- docs/swagger.yml | 55 +---------------------- lib/camera.py | 24 +++++------ lib/controller.py | 39 ----------------- routes.py | 21 --------- static/js/configuration.js | 67 +++++------------------------ static/templates/_config_modal.html | 48 --------------------- static/templates/base.html | 1 + static/templates/config.html | 22 ---------- tests/test_controller.py | 10 ----- 10 files changed, 39 insertions(+), 299 deletions(-) diff --git a/data/data.json b/data/data.json index 508b1d3..3368130 100644 --- a/data/data.json +++ b/data/data.json @@ -1,26 +1,14 @@ { "imu": { -<<<<<<< HEAD - "yaw": -2.76, - "pitch": 4.75, - "roll": -179.79, - "yr": -0.01, + "yaw": 0.0, + "pitch": 0.0, + "roll": 0.0, + "yr": 0.0, "pr": 0.0, - "rr": 0.06, - "ax": 0.001, - "ay": -0.0, - "az": -0.0 -======= - "yaw": -2.86, - "pitch": 4.76, - "roll": -179.79, - "yr": -0.0, - "pr": -0.01, - "rr": 0.11, - "ax": 0.001, + "rr": 0.0, + "ax": 0.0, "ay": 0.0, "az": 0.0 ->>>>>>> 9a56d4d (Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum.) }, "9dof": { "acceleration": { @@ -86,23 +74,14 @@ "dptSet": 0.0 }, "resources": { -<<<<<<< HEAD - "sequence": 365, - "uptime_ms": 367294, -======= - "sequence": 474, - "uptime_ms": 476425, ->>>>>>> 9a56d4d (Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum.) - "cpu_percent": 5, - "heap_used_percent": 2, - "heap_free_kb": 502, - "heap_total_kb": 512, - "thread_count": 20, -<<<<<<< HEAD - "udp_rx_count": 15240, -======= - "udp_rx_count": 19788, ->>>>>>> 9a56d4d (Extensive visual Topside changes, as well as workflow changes. removed e.g. placeholders, fixed nucleo uplink log. Kept Back-end changes to a minimum.) + "sequence": 0, + "uptime_ms": 0, + "cpu_percent": 0, + "heap_used_percent": 0, + "heap_free_kb": 0, + "heap_total_kb": 0, + "thread_count": 0, + "udp_rx_count": 0, "udp_rx_errors": 0 }, "control_telemetry": { @@ -144,4 +123,4 @@ "Buttons": { "button_surface": 0 } -} \ No newline at end of file +} diff --git a/docs/swagger.yml b/docs/swagger.yml index f59fe0c..4815564 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -5,7 +5,7 @@ info: description: > REST and streaming endpoints exposed by the Topside ROV Flask application. The API provides camera status and feeds, telemetry, command/uplink status, - IMU configuration, debug overrides, controller gains, and PID tuning. + IMU configuration, debug overrides, controller input, and PID tuning. version: "1.0.0" host: localhost:5000 @@ -28,8 +28,6 @@ tags: description: IMU receiver status, tare, offsets, and axis mappings - name: Debug description: Manual debug overrides and attitude setpoints - - name: Controller - description: Gamepad controller input and gain settings - name: PID description: PID gain tuning, setpoints, and saved presets - name: System @@ -695,46 +693,6 @@ paths: 504: description: Thruster axes were neutralized, but the MCU did not confirm zero PID gains - /api/controller/gains: - get: - tags: [Controller] - summary: Get controller gain settings - responses: - 200: - description: Current controller gains - schema: - type: object - properties: - ok: - type: boolean - example: true - gains: - $ref: "#/definitions/ControllerGains" - 503: - description: Controller not available - post: - tags: [Controller] - summary: Set controller gain settings - parameters: - - in: body - name: body - required: true - schema: - $ref: "#/definitions/ControllerGains" - responses: - 200: - description: Gains updated - schema: - type: object - properties: - ok: - type: boolean - example: true - gains: - $ref: "#/definitions/ControllerGains" - 503: - description: Controller not available - /api/pid/gains: get: tags: [PID] @@ -1020,17 +978,6 @@ definitions: type: number example: [0, 0, 1, 0, 0, 0, 0.4, 0] - ControllerGains: - type: object - properties: - master: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - surge: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - sway: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - heave: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - roll: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - pitch: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - yaw: {type: number, minimum: 0.0, maximum: 1.0, example: 1.0} - CameraStatusBase: type: object properties: diff --git a/lib/camera.py b/lib/camera.py index f04ee64..da06d5b 100644 --- a/lib/camera.py +++ b/lib/camera.py @@ -338,14 +338,14 @@ def _build_placeholder_jpeg(self): return buf.tobytes() if ok else b"" def _run(self): - print("[RPi Camera] Trying OpenCV+GStreamer …") + print("[RPi Camera] Trying OpenCV+GStreamer ...") if self._opencv_gstreamer_available(): if self._run_opencv_gstreamer(): return else: print("[RPi Camera] OpenCV has no GStreamer support, skipping.") - print("[RPi Camera] Trying gst-launch-1.0 …") + print("[RPi Camera] Trying gst-launch-1.0 ...") self._run_gst_subprocess() def _opencv_gstreamer_available(self): @@ -373,14 +373,14 @@ def _run_opencv_gstreamer(self): self.last_error = "OpenCV GStreamer pipeline failed to open" return False - print(f"[RPi Camera] Listening on UDP port {self.port} …") + print(f"[RPi Camera] Listening on UDP port {self.port} ...") self.is_listening = True had_frame = False while not self._stop_event.is_set(): ok, frame = cap.read() if ok and frame is not None and frame.size > 0: if not had_frame: - print("[RPi Camera] ✓ Receiving frames") + print("[RPi Camera] Receiving frames") had_frame = True self._set_frame(frame) else: @@ -454,7 +454,7 @@ def _run_gst_subprocess(self): "async=false", ] - print(f"[RPi Camera] Listening on UDP port {self.port} …") + print(f"[RPi Camera] Listening on UDP port {self.port} ...") self.is_listening = True proc = subprocess.Popen( cmd, @@ -504,7 +504,7 @@ def _run_gst_subprocess(self): del buffer[: end + 2] if not had_frame: - print("[RPi Camera] ✓ Receiving frames") + print("[RPi Camera] Receiving frames") had_frame = True # JPEG is already encoded by GStreamer; avoid re-decode/re-encode. self._set_jpeg_bytes(jpg) @@ -741,31 +741,31 @@ def _release_cap(self): self._cap = None def _run_loop(self): - """Main thread: connect → read frames → reconnect on failure.""" + """Main thread: connect, read frames, then reconnect on failure.""" while not self._stop_event.is_set(): - print(f"[IP Camera] Connecting to {self.url} …") + print(f"[IP Camera] Connecting to {self.url} ...") if not self._open_stream(): self.last_error = f"Failed to open: {self.url}" - print(f"[IP Camera] ✗ {self.last_error}") + print(f"[IP Camera] error: {self.last_error}") self.is_connected = False if self._stop_event.wait(self.RECONNECT_DELAY): break continue - print("[IP Camera] ✓ Stream connected") + print("[IP Camera] Stream connected") self.last_error = None had_frame = False while not self._stop_event.is_set(): ok, frame = self._cap.read() if not ok or frame is None: - print("[IP Camera] Lost connection, will reconnect …") + print("[IP Camera] Lost connection, will reconnect ...") self.is_connected = False self._release_cap() break if not had_frame: - print("[IP Camera] ✓ Receiving frames") + print("[IP Camera] Receiving frames") had_frame = True if frame.shape[1] != self.out_width or frame.shape[0] != self.out_height: diff --git a/lib/controller.py b/lib/controller.py index 1519f02..a12be9f 100644 --- a/lib/controller.py +++ b/lib/controller.py @@ -83,17 +83,6 @@ def __init__(self, bitmask_client: BitmaskClient = None, rate_hz: float = 60.0): self._debug_lock = threading.Lock() self._input_status_lock = threading.Lock() self._input_status = self._empty_input_status() - # Gain settings (per-axis and master) - self._gain_lock = threading.Lock() - self._master_gain = 1.0 - self._axis_gains = { - "surge": 1.0, - "sway": 1.0, - "heave": 1.0, - "roll": 1.0, - "pitch": 1.0, - "yaw": 1.0, - } self._try_connect() def _try_connect(self): @@ -287,26 +276,6 @@ def _update_input_status(self, buttons): } ) - # --- Gain API --- - def set_gains(self, master=None, **axis_gains): - """Set master and/or per-axis gains. Values should be 0.0 – 1.0.""" - with self._gain_lock: - if master is not None: - self._master_gain = max(0.0, min(1.0, float(master))) - for key in ("surge", "sway", "heave", "roll", "pitch", "yaw"): - if key in axis_gains: - self._axis_gains[key] = max(0.0, min(1.0, float(axis_gains[key]))) - - def get_gains(self): - """Return current gain settings.""" - with self._gain_lock: - return {"master": self._master_gain, **self._axis_gains} - - def _apply_gain(self, axis_name, value): - """Multiply a value by its per-axis gain and the master gain.""" - with self._gain_lock: - return value * self._axis_gains.get(axis_name, 1.0) * self._master_gain - # --- Light API --- def set_light(self, level): """Set light brightness from a normalized 0.0-1.0 level. @@ -499,14 +468,6 @@ def update(self): self._prev_dpad_down = dpad_down self._update_input_status(buttons) - # Apply gain to each axis - surge = self._apply_gain("surge", surge) - sway = self._apply_gain("sway", sway) - heave = self._apply_gain("heave", heave) - roll = self._apply_gain("roll", roll) - pitch = self._apply_gain("pitch", pitch) - yaw = self._apply_gain("yaw", yaw) - # Send to ROV! if self.bm: self.bm.set_from_axes( diff --git a/routes.py b/routes.py index d9889aa..3709ed5 100644 --- a/routes.py +++ b/routes.py @@ -912,27 +912,6 @@ def zero_all_pid(): } ) - # --- Gain endpoints --- - @app.route("/api/controller/gains", methods=["GET"]) - def get_gains(): - """Return current gain settings.""" - ctrl = current_app.config.get("CONTROLLER") - if not ctrl: - return jsonify({"ok": False, "error": "Controller not available"}), 503 - return jsonify({"ok": True, "gains": ctrl.get_gains()}) - - @app.route("/api/controller/gains", methods=["POST"]) - def set_gains(): - """Set gain values. JSON body: master (0-1), surge, sway, heave, roll, pitch, yaw (0-1).""" - data = request.get_json(force=True, silent=True) or {} - ctrl = current_app.config.get("CONTROLLER") - if not ctrl: - return jsonify({"ok": False, "error": "Controller not available"}), 503 - master = data.get("master") - axis_gains = {k: float(data[k]) for k in ("surge", "sway", "heave", "roll", "pitch", "yaw") if k in data} - ctrl.set_gains(master=master, **axis_gains) - return jsonify({"ok": True, "gains": ctrl.get_gains()}) - # --- PID config (MCU) endpoints --- @app.route("/api/pid/gains", methods=["GET"]) def get_pid_gains(): diff --git a/static/js/configuration.js b/static/js/configuration.js index d81f383..99cc446 100644 --- a/static/js/configuration.js +++ b/static/js/configuration.js @@ -1,8 +1,4 @@ document.addEventListener("DOMContentLoaded", function () { - const axes = ["surge", "sway", "heave", "roll", "pitch", "yaw"]; - const masterSlider = document.getElementById("gain-master"); - const masterDisplay = document.getElementById("gain-master-value"); - function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; @@ -15,57 +11,6 @@ document.addEventListener("DOMContentLoaded", function () { el.className = "small mt-2 " + cls; } - function sendGains(payload) { - fetch("/api/controller/gains", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }).catch(() => {}); - } - - fetch("/api/controller/gains") - .then((r) => r.json()) - .then((data) => { - if (!data.ok) return; - const gains = data.gains; - if (masterSlider && gains.master !== undefined) { - masterSlider.value = gains.master; - if (masterDisplay) masterDisplay.textContent = Number(gains.master).toFixed(2); - } - axes.forEach((axis) => { - const slider = document.getElementById("gain-" + axis); - if (!slider || gains[axis] === undefined) return; - slider.value = gains[axis]; - setText("gain-" + axis + "-value", Number(gains[axis]).toFixed(2)); - }); - }) - .catch(() => {}); - - if (masterSlider) { - masterSlider.addEventListener("input", function () { - const value = parseFloat(this.value); - if (masterDisplay) masterDisplay.textContent = value.toFixed(2); - const payload = { master: value }; - axes.forEach((axis) => { - const slider = document.getElementById("gain-" + axis); - if (slider) slider.value = value; - setText("gain-" + axis + "-value", value.toFixed(2)); - payload[axis] = value; - }); - sendGains(payload); - }); - } - - axes.forEach((axis) => { - const slider = document.getElementById("gain-" + axis); - if (!slider) return; - slider.addEventListener("input", function () { - const value = parseFloat(this.value); - setText("gain-" + axis + "-value", value.toFixed(2)); - sendGains({ [axis]: value }); - }); - }); - const axesYaw = document.getElementById("axes-yaw"); const axesPitch = document.getElementById("axes-pitch"); const axesRoll = document.getElementById("axes-roll"); @@ -94,7 +39,11 @@ document.addEventListener("DOMContentLoaded", function () { }), }); const data = await res.json(); - setFeedback("axes-feedback", data.ok ? "Mapping saved" : "Failed to save mapping", data.ok ? "text-success" : "text-danger"); + setFeedback( + "axes-feedback", + data.ok ? "Mapping saved" : "Failed to save mapping", + data.ok ? "text-success" : "text-danger" + ); } catch (error) { setFeedback("axes-feedback", "Error: " + error.message, "text-danger"); } @@ -129,7 +78,11 @@ document.addEventListener("DOMContentLoaded", function () { }), }); const data = await res.json(); - setFeedback("accel-feedback", data.ok ? "Accelerometer mapping saved" : "Failed to save mapping", data.ok ? "text-success" : "text-danger"); + setFeedback( + "accel-feedback", + data.ok ? "Accelerometer mapping saved" : "Failed to save mapping", + data.ok ? "text-success" : "text-danger" + ); } catch (error) { setFeedback("accel-feedback", "Error: " + error.message, "text-danger"); } diff --git a/static/templates/_config_modal.html b/static/templates/_config_modal.html index 1c4afb9..d5a942c 100644 --- a/static/templates/_config_modal.html +++ b/static/templates/_config_modal.html @@ -13,54 +13,6 @@
-
-
Controller Gain
- - -
- - -
- - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
Controller Gains
-
- - -
-
- {% for axis in ["surge", "sway", "heave", "roll", "pitch", "yaw"] %} -
- - -
- {% endfor %} -
-
-
-
-
-
diff --git a/tests/test_controller.py b/tests/test_controller.py index 721756c..f322f16 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -90,16 +90,6 @@ def build_controller(controller=None, joystick=None): ctrl._debug_lock = threading.Lock() ctrl._input_status_lock = threading.Lock() ctrl._input_status = ctrl._empty_input_status() - ctrl._gain_lock = threading.Lock() - ctrl._master_gain = 1.0 - ctrl._axis_gains = { - "surge": 1.0, - "sway": 1.0, - "heave": 1.0, - "roll": 1.0, - "pitch": 1.0, - "yaw": 1.0, - } return ctrl