diff --git a/Dev/index.html b/Dev/index.html index 618af763..8796cc07 100644 --- a/Dev/index.html +++ b/Dev/index.html @@ -108,6 +108,18 @@
diff --git a/SimpleGCS/app.js b/SimpleGCS/app.js new file mode 100644 index 00000000..f2e76734 --- /dev/null +++ b/SimpleGCS/app.js @@ -0,0 +1,928 @@ +/* + app.js — Simple, mobile-friendly GCS map for autonomous buoys + */ + +(() => { + // --- State --- + let MAVLink = new MAVLink20Processor(); // provided by ../modules/MAVLink/mavlink.js + // IDs we send as (GCS identity) + let gcsSystemId = 255, gcsComponentId = 190; + // Target vehicle (auto-discovered from incoming msgs) + let vehSysId = 1, vehCompId = 1; + + // Vehicle type cache (for icon) + const VehicleType = { mavType: null, cls: "plane", lastSeen: 0 }; + + // Map + marker + const map = L.map(document.getElementById("map"), { zoomControl: true }).setView([0,0], 2); + L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(map); + + // prevent iPhone popup menus + map.getContainer().addEventListener("contextmenu", (e) => e.preventDefault()); + + let vehicleMarker = null; + let appliedVehClass = null; + let lastHeadingDeg = null; + + // marker for vehicle target + let targetMarker = null; + + // Toolbar + const armBtn = document.getElementById("armBtn"); + const disarmBtn = document.getElementById("disarmBtn"); + const rtlBtn = document.getElementById("rtlBtn"); + const loiterBtn = document.getElementById("loiterBtn"); + const recenterBtn = document.getElementById("recenterBtn"); + const connectBtn = document.getElementById("connectBtn"); + + // Connection + let ws = null; + let hbInterval = null; + let setupSigning = false; + + const roverModes = { + MANUAL : 0, + ACRO : 1, + STEERING : 3, + HOLD : 4, + LOITER : 5, + FOLLOW : 6, + SIMPLE : 7, + DOCK : 8, + CIRCLE : 9, + AUTO : 10, + RTL : 11, + SMART_RTL : 12, + GUIDED : 15, + INITIALISING : 16, + }; + + // --- Helpers --- + function toast(msg, ms=1500) { + const el = document.createElement("div"); + el.className = "toast"; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => el.remove(), ms); + } + + // --- COMMAND_INT helper --- + function sendCommandInt(cmd, params = []) { + if (!ws) { toast("Not connected"); return; } + const payload = new mavlink20.messages.command_int( + vehSysId, // target_system + vehCompId, // target_component + mavlink20.MAV_FRAME_GLOBAL_RELATIVE_ALT_INT, + cmd, // command + 0, 0, + params[0] || 0, params[1] || 0, params[2] || 0, params[3] || 0, + params[4] || 0, params[5] || 0, params[6] || 0 + ); + const pkt = payload.pack(MAVLink); + ws.send(Uint8Array.from(pkt)); + } + + function sendSetMode(mode) { + if (!ws) { toast("Not connected"); return; } + sendCommandInt(mavlink20.MAV_CMD_DO_SET_MODE, [ mavlink20.MAV_MODE_FLAG_CUSTOM_MODE_ENABLED, mode ]); + } + + // Telemetry state + let telemetry = { + batteryPct: null, + currentA: null, + speed: 0, + lastUpdate: 0, + armed: false, + modeName: "—" + }; + + // make rover mode names map + const roverModeNames = Object.fromEntries(Object.entries(roverModes).map(([k,v]) => [v, k])); + + let lastTargetSeenMs = 0; + + // Create telemetry display elements + function initTelemetryDisplay() { + // Find the toolbar + const toolbar = document.getElementById("toolbar"); + + // Create telemetry container + const telemetryDiv = document.createElement("div"); + telemetryDiv.id = "telemetry"; + telemetryDiv.style.cssText = ` + background: rgba(255,255,255,0.1); + border-radius: 8px; + padding: 8px; + margin: 10px 0; + font-size: 11px; + text-align: center; + color: #fff; + width: calc(var(--barW) - 12px); + `; + + // Status (armed + mode) + const statusDiv = document.createElement("div"); + statusDiv.id = "status-display"; + statusDiv.style.cssText = "margin-bottom: 8px;"; + statusDiv.innerHTML = ` +
+
+`; + telemetryDiv.prepend(statusDiv); + + // Battery display + const batteryDiv = document.createElement("div"); + batteryDiv.id = "battery-display"; + batteryDiv.style.cssText = "margin-bottom: 6px;"; + batteryDiv.innerHTML = ` +
+
+
+`; + + // Speed display + const speedDiv = document.createElement("div"); + speedDiv.id = "speed-display"; + speedDiv.innerHTML = ` +
+
+ `;
+
+ telemetryDiv.appendChild(batteryDiv);
+ telemetryDiv.appendChild(speedDiv);
+
+ // Insert before the flex spacer
+ const spacer = toolbar.querySelector('div[style*="flex:1"]');
+ toolbar.insertBefore(telemetryDiv, spacer);
+ }
+
+ function updateTelemetryDisplay() {
+ const batteryEl = document.getElementById("battery-value");
+ const currentEl = document.getElementById("current-value");
+ const speedEl = document.getElementById("speed-value");
+ const armedEl = document.getElementById("armed-pill");
+ const modeEl = document.getElementById("mode-value");
+
+ // battery
+ if (batteryEl) {
+ if (telemetry.batteryPct !== null && telemetry.batteryPct >= 0) {
+ let color = "#4caf50";
+ if (telemetry.batteryPct < 20) color = "#f44336";
+ else if (telemetry.batteryPct < 40) color = "#ff9800";
+ batteryEl.textContent = `${telemetry.batteryPct}%`;
+ batteryEl.style.color = color;
+ } else {
+ batteryEl.textContent = "---";
+ batteryEl.style.color = "#fff";
+ }
+ }
+
+ if (currentEl) {
+ if (telemetry.currentA !== null && telemetry.currentA >= 0) {
+ currentEl.textContent = `${telemetry.currentA.toFixed(1)} A`;
+ } else {
+ currentEl.textContent = "--- A";
+ }
+ }
+
+ // speed
+ if (speedEl) {
+ if (telemetry.speed >= 0) {
+ var speed_knots = 1.94384449 * telemetry.speed;
+ speedEl.textContent = `${speed_knots.toFixed(1)} knots`;
+ } else {
+ speedEl.textContent = "--- knots";
+ }
+ }
+
+ // armed + mode
+ if (armedEl) {
+ if (telemetry.armed) {
+ armedEl.textContent = "ARMED";
+ armedEl.style.background = "#81c784";
+ } else {
+ armedEl.textContent = "DISARM";
+ armedEl.style.background = "#9e9e9e";
+ }
+ }
+ if (modeEl) modeEl.textContent = telemetry.modeName || "—";
+ }
+
+ // Call initTelemetryDisplay() after the DOM is ready
+ initTelemetryDisplay();
+
+ // --- StatusText log
+ const StatusLog = {
+ max: 500,
+ items: [], // { t: Date, sev: number, txt: string }
+
+ push(sev, txt) {
+ // normalise & strip trailing NULs
+ if (Array.isArray(txt)) txt = String.fromCharCode(...txt);
+ txt = String(txt || "").replace(/\0+$/, "");
+ this.items.push({ t: new Date(), sev: sev ?? -1, txt });
+ if (this.items.length > this.max) this.items.splice(0, this.items.length - this.max);
+ this.renderIfOpen();
+ },
+
+ // severity to short tag
+ tag(sev) {
+ const map = ["EMERG","ALERT","CRIT","ERR","WARN","NOTICE","INFO","DEBUG"];
+ // MAV_SEVERITY matches this order in ArduPilot (0..7)
+ return (sev >= 0 && sev < map.length) ? map[sev] : "INFO";
+ },
+
+ // UI
+ _tip: null,
+ _box: null,
+
+ open(anchorEl) {
+ if (this._tip) { this._tip.show(); return; }
+
+ const wrap = document.createElement("div");
+ wrap.style.cssText = "display:flex; flex-direction:column; gap:6px; width:520px;";
+
+ const header = document.createElement("div");
+ header.textContent = "Messages (STATUSTEXT)";
+ header.style.cssText = "font-weight:600;";
+
+ const box = document.createElement("pre");
+ // ~10 rows visible. line-height ~1.25 → height ≈ 10*1.25em + padding
+ box.style.cssText = `
+ margin:0; background:#111; color:#eee; border-radius:6px;
+ padding:8px; max-height:15.5em; overflow:auto;
+ font: 12px/1.25 ui-monospace, SFMono-Regular, Menlo, monospace;
+ white-space: pre-wrap; word-break: break-word;
+ `;
+ this._box = box;
+
+ // tiny footer
+ const help = document.createElement("div");
+ help.style.cssText = "opacity:.7; font-size:12px;";
+ help.textContent = "Newest at the bottom. Keeps last 500 messages.";
+
+ wrap.append(header, box, help);
+
+ this._tip = tippy(anchorEl, {
+ content: wrap,
+ interactive: true,
+ trigger: "click",
+ theme: "light-border",
+ placement: "right-start",
+ appendTo: () => document.body,
+ onShow: () => this.renderIfOpen()
+ });
+
+ this._tip.show();
+ },
+
+ renderIfOpen() {
+ if (!this._box) return;
+ const lines = this.items.map(it => {
+ // HH:MM:SS [SEV] message
+ const t = it.t.toTimeString().slice(0,8);
+ return `${t} [${this.tag(it.sev)}] ${it.txt}`;
+ });
+ this._box.textContent = lines.join("\n");
+ // autoscroll to bottom
+ this._box.scrollTop = this._box.scrollHeight;
+ }
+ };
+
+ // setup the menu
+ initMenuButton();
+
+ // Fence handling
+ let ftp = null;
+ let fenceParser = new FenceParser();
+ let fenceLayers = [];
+ let fenceFetched = false;
+ let fenceRetryInterval = null;
+
+ // Initialize FTP when connected
+ function initFTP() {
+ if (ws && ws.readyState === WebSocket.OPEN) {
+ ftp = new MAVFTP(MAVLink, ws);
+ ftp.targetSystem = vehSysId;
+ ftp.targetComponent = vehCompId;
+ console.log("FTP initialized");
+ }
+ }
+
+ // Start fence fetch retry loop
+ function startFenceFetchRetry() {
+ // Clear any existing interval
+ if (fenceRetryInterval) {
+ clearInterval(fenceRetryInterval);
+ }
+
+ // Reset fence fetched flag
+ fenceFetched = false;
+
+ // Try to fetch immediately
+ fetchFence(true); // true = silent mode
+
+ // Retry every 5 seconds until successful
+ fenceRetryInterval = setInterval(() => {
+ if (!fenceFetched && ftp) {
+ console.log("Retrying fence fetch...");
+ fetchFence(true); // silent retry
+ } else if (fenceFetched) {
+ // Stop retrying once we have the fence
+ clearInterval(fenceRetryInterval);
+ fenceRetryInterval = null;
+ }
+ }, 5000);
+ }
+
+ // Stop fence fetch retry loop
+ function stopFenceFetchRetry() {
+ if (fenceRetryInterval) {
+ clearInterval(fenceRetryInterval);
+ fenceRetryInterval = null;
+ }
+ }
+
+ // Fetch fence from vehicle
+ function fetchFence(silent = false) {
+ if (!ftp) {
+ if (!silent) toast("Not connected");
+ return;
+ }
+
+ if (!silent) toast("Fetching fence...");
+
+ ftp.getFile("@MISSION/fence.dat", (data) => {
+ if (!data) {
+ if (!silent) toast("Failed to fetch fence");
+ return;
+ }
+
+ const fences = fenceParser.parse(data);
+ if (fences) {
+ displayFences(fences);
+ console.log(`Loaded ${fences.length} fence items`);
+ if (!silent) toast(`Loaded ${fences.length} fence items`);
+ fenceFetched = true; // Mark as successfully fetched
+ stopFenceFetchRetry(); // Stop retrying
+ } else {
+ if (!silent) toast("Failed to parse fence");
+ }
+ });
+ }
+
+ function fenceDisable() {
+ sendCommandInt(mavlink20.MAV_CMD_DO_FENCE_ENABLE, [ 0 ]);
+ }
+ function fenceEnable() {
+ sendCommandInt(mavlink20.MAV_CMD_DO_FENCE_ENABLE, [ 1 ]);
+ }
+
+ // Initialize menu button
+ function initMenuButton() {
+ const toolbar = document.getElementById("toolbar");
+ const recenterBtn = document.getElementById("recenterBtn");
+
+ // Create menu button
+ const menuBtn = document.createElement("button");
+ menuBtn.id = "menuBtn";
+ menuBtn.className = "btn small";
+ menuBtn.innerHTML = "☰"; // Hamburger icon
+ menuBtn.style.fontSize = "18px";
+
+ // Insert before recenter button
+ toolbar.insertBefore(menuBtn, recenterBtn);
+
+ // Create menu using Tippy
+ const menuDiv = document.createElement("div");
+ menuDiv.style.cssText = `
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 4px;
+ min-width: 150px;
+ `;
+
+ // Menu items
+ const menuItems = [
+ { text: "Fetch Fence", action: () => { fetchFence(); menuTip.hide(); }},
+ { text: "Fence Disable", action: () => { fenceDisable(); menuTip.hide(); }},
+ { text: "Fence Enable", action: () => { fenceEnable(); menuTip.hide(); }},
+ { text: "Messages", action: () => { StatusLog.open(menuBtn); } },
+ ];
+
+ /*
+ menuItems.push(
+ { text: "Dev: Enable cache‑bust", action: () => { window.__dev__?.enable(true); } },
+ { text: "Dev: Hard reload (cache‑bust)", action: () => { window.__dev__?.hardReload(); } },
+ { text: "Dev: Disable cache‑bust", action: () => { window.__dev__?.enable(false); } },
+ );
+ */
+
+ menuItems.forEach(item => {
+ const btn = document.createElement("button");
+ btn.style.cssText = `
+ padding: 8px 12px;
+ background: #f0f0f0;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ text-align: left;
+ font-size: 14px;
+ transition: background 0.2s;
+ `;
+ btn.textContent = item.text;
+ btn.onmouseover = () => btn.style.background = "#e0e0e0";
+ btn.onmouseout = () => btn.style.background = "#f0f0f0";
+ btn.onclick = item.action;
+ menuDiv.appendChild(btn);
+ });
+
+ // Create Tippy tooltip as menu
+ const menuTip = tippy(menuBtn, {
+ content: menuDiv,
+ interactive: true,
+ trigger: "click",
+ theme: "light-border",
+ placement: "right-start",
+ appendTo: () => document.body
+ });
+
+ return menuBtn;
+ }
+
+ // Display fences on map
+ function displayFences(fences) {
+ // Clear existing fence layers
+ fenceLayers.forEach(layer => map.removeLayer(layer));
+ fenceLayers = [];
+
+ fences.forEach((fence, idx) => {
+ let layer = null;
+
+ if (fence.type === mavlink20.MAV_CMD_NAV_FENCE_CIRCLE_INCLUSION) {
+ // Circle inclusion (green)
+ layer = L.circle([fence.center.lat, fence.center.lng], {
+ radius: fence.radius,
+ color: '#4caf50',
+ fillColor: '#4caf50',
+ fillOpacity: 0,
+ weight: 2
+ }).addTo(map);
+ layer.bindPopup(`Circle Inclusion #${idx}
Radius: ${fence.radius}m`);
+
+ } else if (fence.type === mavlink20.MAV_CMD_NAV_FENCE_CIRCLE_EXCLUSION) {
+ // Circle exclusion (red)
+ layer = L.circle([fence.center.lat, fence.center.lng], {
+ radius: fence.radius,
+ color: '#f44336',
+ fillColor: '#f44336',
+ fillOpacity: 0,
+ weight: 2
+ }).addTo(map);
+ } else if (fence.type === mavlink20.MAV_CMD_NAV_FENCE_POLYGON_VERTEX_INCLUSION) {
+ // Polygon inclusion (green)
+ const latlngs = fence.vertices.map(v => [v.lat, v.lng]);
+ layer = L.polygon(latlngs, {
+ color: '#4caf50',
+ fillColor: '#4caf50',
+ fillOpacity: 0,
+ weight: 2
+ }).addTo(map);
+ } else if (fence.type === mavlink20.MAV_CMD_NAV_FENCE_POLYGON_VERTEX_EXCLUSION) {
+ // Polygon exclusion (red)
+ const latlngs = fence.vertices.map(v => [v.lat, v.lng]);
+ layer = L.polygon(latlngs, {
+ color: '#f44336',
+ fillColor: '#f44336',
+ fillOpacity: 0,
+ weight: 2
+ }).addTo(map);
+ }
+
+ if (layer) {
+ fenceLayers.push(layer);
+ }
+ });
+
+ // Zoom to show all fences
+ if (fenceLayers.length > 0) {
+ const group = L.featureGroup(fenceLayers);
+ map.fitBounds(group.getBounds().pad(0.1));
+ }
+ }
+
+ function classifyVehicle(mavType) {
+ if (mavType === mavlink20.MAV_TYPE_SURFACE_BOAT) return "boat"; // MAV_TYPE_SURFACE_BOAT
+ if (mavType === mavlink20.MAV_TYPE_GROUND_ROVER) return "rover"; // MAV_TYPE_GROUND_ROVER
+ if (mavType === mavlink20.MAV_TYPE_FIXED_WING) return "plane"; // MAV_TYPE_FIXED_WING
+ if (mavType === mavlink20.MAV_TYPE_QUADROTOR || mavType === mavlink20.MAV_TYPE_COAXIAL || mavType === mavlink20.MAV_TYPE_HELICOPTER) return "copter"; // quad/coax/heli
+ return "plane";
+ }
+
+ function makeSvgIcon(kind, rotateDeg = 0) {
+ const paths = {
+ plane: `
+
+
+ + +
+ + + + + + + + + + + + + + + + + + +
+