const state = { commands: [], action: "GET", services: [], connected: false, measurement: { logging: false, mode: "live", file: null, notes: "", weather: null, fixedOnly: false, totalCount: 0, allPoints: [], points: [], metrics: { count: 0 }, }, }; const EARTH_RADIUS_M = 6371008.8; const RTK_FIXED_STREAK_LENGTH = 5; const TREND_METRICS = [ { key: "rms_m", label: "Measured RMS", digits: 3, suffix: " m" }, { key: "cep50_m", label: "CEP50", digits: 3, suffix: " m" }, { key: "r95_m", label: "R95 / CEP95", digits: 3, suffix: " m" }, { key: "two_drms_m", label: "2DRMS", digits: 3, suffix: " m" }, { key: "mean_error_m", label: "Mean Error", digits: 3, suffix: " m" }, { key: "max_error_m", label: "Max Error", digits: 3, suffix: " m" }, { key: "span_2d_m", label: "Span 2D", digits: 3, suffix: " m" }, { key: "receiver_hrms_rms_m", label: "Receiver HRMS", digits: 3, suffix: " m" }, { key: "receiver_vrms_rms_m", label: "Receiver VRMS", digits: 3, suffix: " m" }, { key: "rms_to_receiver_hrms_ratio", label: "RMS / HRMS", digits: 2, suffix: "x" }, ]; const $ = (id) => document.getElementById(id); function setStatus(connected, detail = "") { state.connected = connected; $("statusPill").textContent = connected ? "Online" : "Offline"; $("statusPill").classList.toggle("online", connected); $("statusText").textContent = detail || (connected ? "Connected" : "Disconnected"); } async function api(path, options = {}) { const response = await fetch(path, { headers: { "Content-Type": "application/json" }, ...options, }); if (!response.ok) { let message = `${response.status} ${response.statusText}`; try { const body = await response.json(); message = body.detail || message; } catch { // Keep the status text when the response is not JSON. } throw new Error(message); } return response.json(); } function log(text, cls = "") { const terminal = $("terminal"); const span = document.createElement("span"); if (cls) span.className = cls; span.textContent = text.endsWith("\n") ? text : `${text}\n`; terminal.appendChild(span); terminal.scrollTop = terminal.scrollHeight; } function option(label, value) { const item = document.createElement("option"); item.textContent = label; item.value = value; return item; } function fmt(value, digits = 3, suffix = "") { if (value == null || Number.isNaN(Number(value))) return "--"; return `${Number(value).toFixed(digits)}${suffix}`; } function haversineMeters(lat1, lon1, lat2, lon2) { const toRad = (value) => (value * Math.PI) / 180; const lat1Rad = toRad(lat1); const lat2Rad = toRad(lat2); const deltaLat = toRad(lat2 - lat1); const deltaLon = toRad(lon2 - lon1); const a = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1Rad) * Math.cos(lat2Rad) * Math.sin(deltaLon / 2) ** 2; return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a))); } function pointOffset(point, originLat, originLon) { let east = haversineMeters(originLat, originLon, originLat, point.longitude); let north = haversineMeters(originLat, originLon, point.latitude, originLon); if (point.longitude < originLon) east *= -1; if (point.latitude < originLat) north *= -1; return { east, north }; } function rootMeanSquare(values) { return values.length ? Math.sqrt(values.reduce((sum, value) => sum + value * value, 0) / values.length) : null; } function percentile(values, pct) { if (!values.length) return null; const ordered = [...values].sort((a, b) => a - b); if (ordered.length === 1) return ordered[0]; const rank = (ordered.length - 1) * pct; const lower = Math.floor(rank); const upper = Math.ceil(rank); if (lower === upper) return ordered[lower]; return ordered[lower] + (ordered[upper] - ordered[lower]) * (rank - lower); } function numericValues(points, key) { return points.map((point) => Number(point[key])).filter((value) => Number.isFinite(value)); } function isRtkFixed(point) { return Number(point.status) === 4; } function rtkFixedStreakPoints(points, streakLength = RTK_FIXED_STREAK_LENGTH) { const selected = []; let streak = 0; for (const point of points) { if (isRtkFixed(point)) { streak += 1; if (streak >= streakLength) selected.push(point); } else { streak = 0; } } return selected; } function filteredMeasurementPoints(points) { return state.measurement.fixedOnly ? rtkFixedStreakPoints(points) : points; } function allMeasurementPoints() { return state.measurement.allPoints.length ? state.measurement.allPoints : state.measurement.points; } function setMeasurementPoints(points) { state.measurement.allPoints = Array.isArray(points) ? points : []; state.measurement.points = filteredMeasurementPoints(state.measurement.allPoints); state.measurement.totalCount = state.measurement.allPoints.length; } function refreshMeasurementPointFilter() { state.measurement.points = filteredMeasurementPoints(allMeasurementPoints()); } function measurementPoints() { return state.measurement.points; } function measurementMetrics(points) { if (state.measurement.fixedOnly) return calculatePointMetrics(points); return enrichReceiverMetrics(state.measurement.metrics || { count: 0 }, points); } function calculatePointMetrics(points) { const validPoints = points.filter((point) => point.latitude != null && point.longitude != null); const count = validPoints.length; if (!count) return { count: 0 }; const meanLat = validPoints.reduce((sum, point) => sum + Number(point.latitude), 0) / count; const meanLon = validPoints.reduce((sum, point) => sum + Number(point.longitude), 0) / count; const offsets = validPoints.map((point) => pointOffset(point, meanLat, meanLon)); const radialErrors = validPoints.map((point) => haversineMeters(meanLat, meanLon, Number(point.latitude), Number(point.longitude)) ); const eastErrors = offsets.map((offset) => offset.east); const northErrors = offsets.map((offset) => offset.north); const stdEast = rootMeanSquare(eastErrors); const stdNorth = rootMeanSquare(northErrors); const spanEast = Math.max(...eastErrors) - Math.min(...eastErrors); const spanNorth = Math.max(...northErrors) - Math.min(...northErrors); const hrmsValues = numericValues(validPoints, "hrms_m"); const vrmsValues = numericValues(validPoints, "vrms_m"); const receiverHrms = rootMeanSquare(hrmsValues); const rms = rootMeanSquare(radialErrors); const withinReceiverHrms = validPoints .map((point, index) => (point.hrms_m == null ? null : radialErrors[index] <= Number(point.hrms_m))) .filter((value) => value != null); const statusCounts = {}; for (const point of validPoints) { const label = String(point.status_text ?? point.status ?? "Unknown"); statusCounts[label] = (statusCounts[label] || 0) + 1; } return { count, gnpos_count: validPoints.filter((point) => point.source === "gnpos").length, mean_latitude: meanLat, mean_longitude: meanLon, rms_m: rms, cep50_m: percentile(radialErrors, 0.5), cep95_m: percentile(radialErrors, 0.95), r95_m: percentile(radialErrors, 0.95), drms_m: stdEast != null && stdNorth != null ? Math.sqrt(stdEast * stdEast + stdNorth * stdNorth) : null, two_drms_m: stdEast != null && stdNorth != null ? 2 * Math.sqrt(stdEast * stdEast + stdNorth * stdNorth) : null, std_east_m: stdEast, std_north_m: stdNorth, mean_error_m: radialErrors.reduce((sum, value) => sum + value, 0) / count, max_error_m: Math.max(...radialErrors), span_east_m: spanEast, span_north_m: spanNorth, span_2d_m: Math.sqrt(spanEast * spanEast + spanNorth * spanNorth), receiver_estimate_count: hrmsValues.length, receiver_hrms_mean_m: hrmsValues.length ? hrmsValues.reduce((sum, value) => sum + value, 0) / hrmsValues.length : null, receiver_hrms_rms_m: receiverHrms, receiver_hrms_min_m: hrmsValues.length ? Math.min(...hrmsValues) : null, receiver_hrms_max_m: hrmsValues.length ? Math.max(...hrmsValues) : null, receiver_hrms_latest_m: hrmsValues.length ? hrmsValues[hrmsValues.length - 1] : null, receiver_vrms_mean_m: vrmsValues.length ? vrmsValues.reduce((sum, value) => sum + value, 0) / vrmsValues.length : null, receiver_vrms_rms_m: rootMeanSquare(vrmsValues), receiver_vrms_latest_m: vrmsValues.length ? vrmsValues[vrmsValues.length - 1] : null, rms_minus_receiver_hrms_m: rms != null && receiverHrms != null ? rms - receiverHrms : null, rms_to_receiver_hrms_ratio: rms != null && receiverHrms ? rms / receiverHrms : null, within_receiver_hrms_percent: withinReceiverHrms.length ? (withinReceiverHrms.filter(Boolean).length / withinReceiverHrms.length) * 100 : null, status_counts: statusCounts, }; } function enrichReceiverMetrics(metrics, points) { const enriched = { ...(metrics || {}) }; const validPoints = points.filter((point) => point.latitude != null && point.longitude != null); if (!validPoints.length) return enriched; if (enriched.gnpos_count == null) { enriched.gnpos_count = validPoints.filter((point) => point.source === "gnpos").length; } if (!enriched.status_counts) { enriched.status_counts = {}; for (const point of validPoints) { const label = String(point.status_text ?? point.status ?? "Unknown"); enriched.status_counts[label] = (enriched.status_counts[label] || 0) + 1; } } const hrmsValues = validPoints.map((point) => Number(point.hrms_m)).filter((value) => Number.isFinite(value)); const vrmsValues = validPoints.map((point) => Number(point.vrms_m)).filter((value) => Number.isFinite(value)); if (hrmsValues.length) { enriched.receiver_estimate_count = hrmsValues.length; enriched.receiver_hrms_mean_m ??= hrmsValues.reduce((sum, value) => sum + value, 0) / hrmsValues.length; enriched.receiver_hrms_rms_m ??= rootMeanSquare(hrmsValues); enriched.receiver_hrms_min_m ??= Math.min(...hrmsValues); enriched.receiver_hrms_max_m ??= Math.max(...hrmsValues); enriched.receiver_hrms_latest_m ??= hrmsValues[hrmsValues.length - 1]; } if (vrmsValues.length) { enriched.receiver_vrms_mean_m ??= vrmsValues.reduce((sum, value) => sum + value, 0) / vrmsValues.length; enriched.receiver_vrms_rms_m ??= rootMeanSquare(vrmsValues); enriched.receiver_vrms_latest_m ??= vrmsValues[vrmsValues.length - 1]; } if (enriched.rms_m != null && enriched.receiver_hrms_rms_m != null) { enriched.rms_minus_receiver_hrms_m ??= enriched.rms_m - enriched.receiver_hrms_rms_m; enriched.rms_to_receiver_hrms_ratio ??= enriched.receiver_hrms_rms_m > 0 ? enriched.rms_m / enriched.receiver_hrms_rms_m : null; } return enriched; } function metricCell(label, value) { const cell = document.createElement("div"); cell.className = "stat"; const labelNode = document.createElement("span"); labelNode.textContent = label; const valueNode = document.createElement("strong"); valueNode.textContent = value; cell.append(labelNode, valueNode); return cell; } function renderMetrics() { const points = measurementPoints(); const metrics = measurementMetrics(points); const sourceCount = metrics.gnpos_count != null ? `${metrics.gnpos_count} GNPOS` : "--"; const allPoints = allMeasurementPoints(); const allValidCount = allPoints.filter((point) => point.latitude != null && point.longitude != null).length; const stableFixedCount = rtkFixedStreakPoints(allPoints).filter((point) => point.latitude != null && point.longitude != null).length; const fixedPercent = allValidCount ? `${stableFixedCount} / ${allValidCount} (${((stableFixedCount / allValidCount) * 100).toFixed(1)}%)` : "--"; const weather = state.measurement.weather; const weatherLabel = weather?.record_type === "weather" ? `${weather.station?.id || "NWS"} ${weather.observation?.text_description || ""}`.trim() : weather?.record_type === "weather_error" ? "NWS unavailable" : "--"; const stats = $("precisionStats"); stats.replaceChildren( metricCell("Samples", String(metrics.count ?? points.length)), metricCell("Weather", weatherLabel), metricCell("GNPOS Samples", sourceCount), metricCell("RTK Fixed 5x", fixedPercent), metricCell("Measured RMS", fmt(metrics.rms_m, 3, " m")), metricCell("Receiver HRMS", fmt(metrics.receiver_hrms_rms_m, 3, " m")), metricCell("RMS - HRMS", fmt(metrics.rms_minus_receiver_hrms_m, 3, " m")), metricCell("RMS / HRMS", metrics.rms_to_receiver_hrms_ratio == null ? "--" : `${metrics.rms_to_receiver_hrms_ratio.toFixed(2)}x`), metricCell("Within HRMS", fmt(metrics.within_receiver_hrms_percent, 1, "%")), metricCell("Avg HRMS", fmt(metrics.receiver_hrms_mean_m, 3, " m")), metricCell("Latest H/V RMS", `${fmt(metrics.receiver_hrms_latest_m, 3, " m")} / ${fmt(metrics.receiver_vrms_latest_m, 3, " m")}`), metricCell("Avg VRMS", fmt(metrics.receiver_vrms_mean_m, 3, " m")), metricCell("CEP50", fmt(metrics.cep50_m, 3, " m")), metricCell("R95 / CEP95", fmt(metrics.r95_m ?? metrics.cep95_m, 3, " m")), metricCell("2DRMS", fmt(metrics.two_drms_m, 3, " m")), metricCell("Mean Error", fmt(metrics.mean_error_m, 3, " m")), metricCell("Max Error", fmt(metrics.max_error_m, 3, " m")), metricCell("Std E / N", `${fmt(metrics.std_east_m, 3, " m")} / ${fmt(metrics.std_north_m, 3, " m")}`), metricCell("Span 2D", fmt(metrics.span_2d_m, 3, " m")), metricCell("Mean Lat", fmt(metrics.mean_latitude, 9)), metricCell("Mean Lon", fmt(metrics.mean_longitude, 9)), metricCell("File", state.measurement.file || "--") ); } function renderLogStatus() { const { logging, mode, file, points } = state.measurement; const prefix = logging ? "Logging" : mode === "log" ? "Loaded" : "Idle"; const totalPoints = state.measurement.totalCount || allMeasurementPoints().length; const filteredSuffix = state.measurement.fixedOnly ? `, using ${points.length} RTK Fixed 5x` : ""; $("logStatus").textContent = `${prefix}: ${file || "no file"} (${totalPoints} points${filteredSuffix})`; $("startLogBtn").disabled = logging; $("stopLogBtn").disabled = !logging; $("sessionNotes").disabled = logging; } function drawPlot() { const canvas = $("precisionPlot"); if (!canvas) return; const wrap = canvas.parentElement; const size = Math.max(260, Math.floor(wrap.clientWidth)); const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(size * dpr); canvas.height = Math.floor(size * dpr); canvas.style.height = `${size}px`; const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, size, size); ctx.fillStyle = "#0a0d0e"; ctx.fillRect(0, 0, size, size); const pad = 34; const plotSize = size - pad * 2; ctx.strokeStyle = "#222b2e"; ctx.lineWidth = 1; ctx.strokeRect(pad, pad, plotSize, plotSize); for (let i = 1; i < 4; i += 1) { const pos = pad + (plotSize * i) / 4; ctx.beginPath(); ctx.moveTo(pos, pad); ctx.lineTo(pos, size - pad); ctx.moveTo(pad, pos); ctx.lineTo(size - pad, pos); ctx.stroke(); } const points = measurementPoints().filter((point) => point.latitude != null && point.longitude != null); if (!points.length) { ctx.fillStyle = "#9fb0aa"; ctx.font = "14px system-ui"; ctx.textAlign = "center"; ctx.fillText(state.measurement.fixedOnly ? "No RTK Fixed 5x points" : "No points", size / 2, size / 2); return; } const originLat = points.reduce((sum, point) => sum + Number(point.latitude), 0) / points.length; const originLon = points.reduce((sum, point) => sum + Number(point.longitude), 0) / points.length; const offsets = points.map((point) => pointOffset(point, originLat, originLon)); const maxExtent = Math.max(0.05, ...offsets.map((offset) => Math.max(Math.abs(offset.east), Math.abs(offset.north)))) * 1.15; const scale = plotSize / (maxExtent * 2); ctx.strokeStyle = "#344044"; ctx.beginPath(); ctx.moveTo(size / 2, pad); ctx.lineTo(size / 2, size - pad); ctx.moveTo(pad, size / 2); ctx.lineTo(size - pad, size / 2); ctx.stroke(); ctx.fillStyle = "#9fb0aa"; ctx.font = "12px system-ui"; ctx.textAlign = "left"; ctx.fillText(`+/- ${fmt(maxExtent, 2, " m")}`, pad, size - 12); offsets.forEach((offset, index) => { const x = size / 2 + offset.east * scale; const y = size / 2 - offset.north * scale; ctx.beginPath(); ctx.arc(x, y, index === offsets.length - 1 ? 4 : 2.8, 0, Math.PI * 2); ctx.fillStyle = index === offsets.length - 1 ? "#f4c45f" : "#58ddbb"; ctx.fill(); }); } function trendMetricConfig() { return TREND_METRICS.find((metric) => metric.key === $("trendMetric").value) || TREND_METRICS[0]; } function timedPoints() { return measurementPoints() .map((point) => ({ ...point, tsMs: Date.parse(point.received_at || "") })) .filter((point) => Number.isFinite(point.tsMs) && point.latitude != null && point.longitude != null) .sort((a, b) => a.tsMs - b.tsMs); } function drawLine(ctx, series, xFor, yFor, color, width = 2) { const drawable = series.filter((item) => item.value != null && Number.isFinite(item.value)); if (!drawable.length) return; ctx.strokeStyle = color; ctx.lineWidth = width; ctx.beginPath(); drawable.forEach((item, index) => { const x = xFor(item); const y = yFor(item.value); if (index === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); } function drawMetricTrend() { const canvas = $("metricTrendPlot"); if (!canvas) return; const wrap = canvas.parentElement; const width = Math.max(320, Math.floor(wrap.clientWidth)); const height = Math.max(240, Math.floor(wrap.clientHeight)); const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(width * dpr); canvas.height = Math.floor(height * dpr); const ctx = canvas.getContext("2d"); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, width, height); ctx.fillStyle = "#0a0d0e"; ctx.fillRect(0, 0, width, height); const pad = { left: 54, right: 18, top: 24, bottom: 36 }; const plotW = width - pad.left - pad.right; const plotH = height - pad.top - pad.bottom; ctx.strokeStyle = "#222b2e"; ctx.lineWidth = 1; ctx.strokeRect(pad.left, pad.top, plotW, plotH); const points = timedPoints(); const metric = trendMetricConfig(); const windowMinutes = Math.max(0.1, Number($("trendWindow").value) || 5); const windowMs = windowMinutes * 60 * 1000; if (points.length < 2) { ctx.fillStyle = "#9fb0aa"; ctx.font = "14px system-ui"; ctx.textAlign = "center"; ctx.fillText( state.measurement.fixedOnly ? "Need at least two timestamped RTK Fixed 5x points" : "Need at least two timestamped points", width / 2, height / 2 ); return; } const startMs = points[0].tsMs; const endMs = points[points.length - 1].tsMs; const fullMetrics = calculatePointMetrics(points); const fullValue = fullMetrics[metric.key]; const windowSeries = points.map((point, index) => { const windowStart = point.tsMs - windowMs; const subset = points.slice(0, index + 1).filter((candidate) => candidate.tsMs >= windowStart); return { tsMs: point.tsMs, value: calculatePointMetrics(subset)[metric.key] }; }); const fullSeries = points.map((point) => ({ tsMs: point.tsMs, value: fullValue })); const values = [...windowSeries.map((item) => item.value), fullValue].filter((value) => value != null && Number.isFinite(value)); if (!values.length) { ctx.fillStyle = "#9fb0aa"; ctx.font = "14px system-ui"; ctx.textAlign = "center"; ctx.fillText("Metric is not available for these points", width / 2, height / 2); return; } let yMin = Math.min(...values); let yMax = Math.max(...values); const yPad = Math.max((yMax - yMin) * 0.12, yMax === 0 ? 0.001 : Math.abs(yMax) * 0.08); yMin -= yPad; yMax += yPad; if (yMin === yMax) { yMin -= 1; yMax += 1; } const xFor = (item) => pad.left + ((item.tsMs - startMs) / Math.max(1, endMs - startMs)) * plotW; const yFor = (value) => pad.top + plotH - ((value - yMin) / (yMax - yMin)) * plotH; ctx.strokeStyle = "#344044"; ctx.fillStyle = "#9fb0aa"; ctx.font = "12px system-ui"; ctx.textAlign = "right"; for (let i = 0; i <= 4; i += 1) { const y = pad.top + (plotH * i) / 4; const value = yMax - ((yMax - yMin) * i) / 4; ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(width - pad.right, y); ctx.stroke(); ctx.fillText(fmt(value, metric.digits, metric.suffix), pad.left - 8, y + 4); } drawLine(ctx, windowSeries, xFor, yFor, "#58ddbb", 2); drawLine(ctx, fullSeries, xFor, yFor, "#f4c45f", 2); ctx.fillStyle = "#eff4f2"; ctx.textAlign = "left"; ctx.font = "12px system-ui"; ctx.fillText(`${metric.label}: ${windowMinutes} min window`, pad.left, 16); ctx.fillStyle = "#58ddbb"; ctx.fillText("Window", pad.left, height - 12); ctx.fillStyle = "#f4c45f"; ctx.fillText(`Full period ${fmt(fullValue, metric.digits, metric.suffix)}`, pad.left + 72, height - 12); ctx.fillStyle = "#9fb0aa"; ctx.textAlign = "right"; const elapsedMin = (endMs - startMs) / 60000; ctx.fillText(`${elapsedMin.toFixed(1)} min`, width - pad.right, height - 12); } function renderMeasurement() { renderLogStatus(); renderMetrics(); drawPlot(); drawMetricTrend(); } async function loadCommands() { const data = await api("/api/commands"); state.commands = data.commands; $("commandSelect").replaceChildren(...state.commands.map((cmd) => option(`${cmd.name} - ${cmd.title}`, cmd.name))); renderCommandFields(); } function selectedCommand() { return state.commands.find((cmd) => cmd.name === $("commandSelect").value); } function renderCommandFields() { const cmd = selectedCommand(); const fields = $("commandFields"); fields.replaceChildren(); if (!cmd) return; const supportsSet = Boolean((cmd.set_params || []).length); $("setBtn").disabled = !supportsSet; if (!supportsSet && state.action === "SET") { state.action = "GET"; $("getBtn").classList.add("active"); $("setBtn").classList.remove("active"); } $("commandHint").textContent = cmd.description; if (state.action === "GET") { $("commandInput").value = `${cmd.name}=GET`; return; } for (const param of cmd.set_params || []) { const wrap = document.createElement("div"); const label = document.createElement("label"); label.textContent = param.label; wrap.appendChild(label); let input; if (param.type === "select") { input = document.createElement("select"); for (const [value, text] of param.options) input.appendChild(option(text, value)); } else if (param.type === "bool") { input = document.createElement("select"); input.appendChild(option("Keep", "")); input.appendChild(option("Off", "0")); input.appendChild(option("On", "1")); } else { input = document.createElement("input"); input.type = param.type || "text"; input.placeholder = param.optional ? "optional" : ""; } input.dataset.param = param.name; input.dataset.prefix = param.prefix || ""; input.addEventListener("input", updateCommandInput); input.addEventListener("change", updateCommandInput); wrap.appendChild(input); fields.appendChild(wrap); } updateCommandInput(); } function updateCommandInput() { const cmd = selectedCommand(); if (!cmd) return; if (state.action === "GET") { $("commandInput").value = `${cmd.name}=GET`; return; } const params = []; for (const input of $("commandFields").querySelectorAll("[data-param]")) { const value = input.value; const prefix = input.dataset.prefix; if (prefix && value !== "") params.push(prefix); params.push(value); } while (params.length && params[params.length - 1] === "") params.pop(); $("commandInput").value = `${cmd.name}=SET${params.length ? "," + params.join(",") : ""}`; } async function scan() { $("scanBtn").disabled = true; log("[scan] Searching for BLE devices..."); try { const data = await api("/api/scan", { method: "POST", body: JSON.stringify({ timeout: 5 }) }); const options = data.devices.map((dev) => option(`${dev.name} (${dev.address})${dev.rssi ? ` ${dev.rssi} dBm` : ""}`, dev.address)); $("deviceSelect").replaceChildren(...options); log(`[scan] Found ${data.devices.length} device(s).`); } catch (err) { log(`[scan] ${err.message}`, "bad"); } finally { $("scanBtn").disabled = false; } } async function connect() { const address = $("deviceSelect").value.trim(); if (!address) { log("[connect] Pick a device first.", "bad"); return; } $("connectBtn").disabled = true; try { const data = await api("/api/connect", { method: "POST", body: JSON.stringify({ address }) }); applyStatus(data); log(`[connect] Connected to ${address}`, "ok"); } catch (err) { log(`[connect] ${err.message}`, "bad"); } finally { $("connectBtn").disabled = false; } } async function disconnect() { try { await api("/api/disconnect", { method: "POST", body: "{}" }); applyStatus({ connected: false, services: [] }); log("[connect] Disconnected."); } catch (err) { log(`[disconnect] ${err.message}`, "bad"); } } function applyStatus(data) { state.services = data.services || state.services || []; setStatus(Boolean(data.connected), data.connected ? `${data.address || ""}` : "Disconnected"); populateCharacteristics(data.tx_char, data.rx_char); } function populateCharacteristics(txChar, rxChar) { const chars = []; for (const service of state.services) { for (const char of service.characteristics) { const props = char.properties.join(","); chars.push({ uuid: char.uuid, label: `${char.uuid} (${props})` }); } } const empty = [option("No characteristic", "")]; $("txChar").replaceChildren(...empty, ...chars.map((char) => option(char.label, char.uuid))); $("rxChar").replaceChildren(option("No characteristic", ""), ...chars.map((char) => option(char.label, char.uuid))); if (txChar) $("txChar").value = txChar; if (rxChar) $("rxChar").value = rxChar; } async function applyCharacteristics() { try { const data = await api("/api/characteristics", { method: "POST", body: JSON.stringify({ tx_char: $("txChar").value || null, rx_char: $("rxChar").value || null }), }); applyStatus(data); log("[ble] Characteristics applied.", "ok"); } catch (err) { log(`[ble] ${err.message}`, "bad"); } } async function sendCommand(command) { const clean = command.trim(); if (!clean) return; try { await api("/api/send", { method: "POST", body: JSON.stringify({ command: clean, append_crlf: true, response: false }), }); } catch (err) { log(`[send] ${err.message}`, "bad"); } } function applyMeasurementStatus(data, mode = state.measurement.mode) { state.measurement.logging = Boolean(data.active); state.measurement.mode = mode; state.measurement.file = data.file || null; if (data.notes != null) { state.measurement.notes = data.notes; $("sessionNotes").value = data.notes; } state.measurement.weather = data.weather || state.measurement.weather || null; if (Array.isArray(data.points)) { setMeasurementPoints(data.points); } state.measurement.totalCount = data.total_count ?? state.measurement.totalCount ?? state.measurement.points.length; state.measurement.metrics = data.metrics || { count: state.measurement.points.length }; renderMeasurement(); } async function startLogging() { try { const data = await api("/api/measure/start", { method: "POST", body: JSON.stringify({ notes: $("sessionNotes").value }), }); applyMeasurementStatus(data, "live"); log(`[measure] Started ${data.file}`, "ok"); await loadLogList(); } catch (err) { log(`[measure] ${err.message}`, "bad"); } } async function stopLogging() { try { const data = await api("/api/measure/stop", { method: "POST", body: "{}" }); applyMeasurementStatus(data, "live"); log(`[measure] Stopped ${data.file || "log"} with ${data.count || 0} point(s).`, "ok"); await loadLogList(); } catch (err) { log(`[measure] ${err.message}`, "bad"); } } async function loadLogList() { try { const data = await api("/api/measure/logs"); const options = data.logs.map((item) => option(`${item.filename} (${Math.round(item.size_bytes / 1024)} KB)`, item.filename)); $("logSelect").replaceChildren(...options); } catch (err) { log(`[measure] ${err.message}`, "bad"); } } async function openSelectedLog() { const filename = $("logSelect").value; if (!filename) { log("[measure] No saved log selected.", "bad"); return; } try { state.measurement.fixedOnly = $("fixedOnlyToggle").checked; const data = await api(`/api/measure/logs/${encodeURIComponent(filename)}`); state.measurement.mode = "log"; state.measurement.file = data.file; state.measurement.notes = data.notes || ""; state.measurement.weather = data.weather || null; $("sessionNotes").value = state.measurement.notes; state.measurement.allPoints = data.points || []; state.measurement.points = filteredMeasurementPoints(state.measurement.allPoints); state.measurement.totalCount = data.total_count ?? state.measurement.allPoints.length; state.measurement.metrics = state.measurement.fixedOnly ? calculatePointMetrics(state.measurement.points) : data.metrics || { count: state.measurement.points.length }; renderMeasurement(); const total = state.measurement.fixedOnly ? ` of ${state.measurement.totalCount}` : ""; log(`[measure] Opened ${data.file} with ${state.measurement.points.length}${total} point(s).`, "ok"); } catch (err) { log(`[measure] ${err.message}`, "bad"); } } function updateDashboard(data) { $("fixStatus").textContent = data.status_text || "--"; $("accuracy").textContent = data.hrms_m != null && data.vrms_m != null ? `H ${data.hrms_m}m / V ${data.vrms_m}m` : "--"; $("satellites").textContent = data.satellites_used != null && data.satellites_visible != null ? `${data.satellites_used}/${data.satellites_visible}` : "--"; $("battery").textContent = data.battery_percent != null && data.battery_voltage != null ? `${data.battery_percent}% (${data.battery_voltage}V)` : "--"; $("position").textContent = data.latitude != null && data.longitude != null ? `${data.latitude.toFixed(9)}, ${data.longitude.toFixed(9)}` : "--"; $("ntrip").textContent = data.ntrip_connected ? `Connected ${data.correction_age_s ?? ""}s` : "Disconnected"; } function openSocket() { const proto = location.protocol === "https:" ? "wss" : "ws"; const ws = new WebSocket(`${proto}://${location.host}/ws`); ws.onopen = () => log("[ws] Connected.", "ok"); ws.onclose = () => { log("[ws] Closed. Reconnecting..."); setTimeout(openSocket, 1500); }; ws.onmessage = (message) => { let event; try { event = JSON.parse(message.data); } catch { return; } if (event.type === "tx") log(`> ${event.text}`, "tx"); if (event.type === "rx") log(event.text, "rx"); if (event.type === "line" && ["gnpos", "gga", "rmc"].includes(event.kind) && event.data) updateDashboard(event.data); if (event.type === "line" && event.kind === "gndev" && event.data) { log(`[device] SN ${event.data.serial_number} FW ${event.data.firmware_version}`, "ok"); } if (event.type === "line" && event.checksum_ok === false) log(`[checksum] Failed: ${event.line}`, "bad"); if (event.type === "measurement_status") { applyMeasurementStatus(event, state.measurement.mode === "log" && !event.active ? "log" : "live"); } if (event.type === "measurement_point" && state.measurement.mode === "live") { state.measurement.logging = true; state.measurement.file = event.file || state.measurement.file; if (!state.measurement.allPoints.some((point) => point.index === event.point.index)) { state.measurement.allPoints.push(event.point); } state.measurement.totalCount = state.measurement.allPoints.length; refreshMeasurementPointFilter(); state.measurement.metrics = event.metrics || state.measurement.metrics; renderMeasurement(); } if (event.type === "measurement_metrics" && state.measurement.mode === "live") { state.measurement.metrics = event.metrics || state.measurement.metrics; renderMeasurement(); } if (event.type === "measurement_weather" && state.measurement.mode === "live") { state.measurement.weather = event.weather || null; const obs = event.weather?.observation; const station = event.weather?.station?.id || "NWS"; if (event.weather?.record_type === "weather") { log(`[measure] Weather ${station}: ${obs?.text_description || "conditions captured"}`, "ok"); } else { log(`[measure] Weather capture failed: ${event.weather?.error || "unknown error"}`, "bad"); } renderMeasurement(); } if (event.type === "connection") { setStatus(Boolean(event.connected), event.message || (event.connected ? "Connected" : "Disconnected")); } if (event.type === "status") log(`[status] ${event.message}`); }; } function bindEvents() { state.measurement.fixedOnly = $("fixedOnlyToggle").checked; $("scanBtn").addEventListener("click", scan); $("connectBtn").addEventListener("click", connect); $("disconnectBtn").addEventListener("click", disconnect); $("applyCharsBtn").addEventListener("click", applyCharacteristics); $("sendBtn").addEventListener("click", () => sendCommand($("commandInput").value)); $("startLogBtn").addEventListener("click", startLogging); $("stopLogBtn").addEventListener("click", stopLogging); $("openLogBtn").addEventListener("click", openSelectedLog); $("refreshLogsBtn").addEventListener("click", loadLogList); $("fixedOnlyToggle").addEventListener("change", (event) => { state.measurement.fixedOnly = event.target.checked; if (state.measurement.mode === "log" && state.measurement.file) { openSelectedLog(); return; } refreshMeasurementPointFilter(); renderMeasurement(); }); $("trendMetric").addEventListener("change", drawMetricTrend); $("trendWindow").addEventListener("input", drawMetricTrend); $("commandInput").addEventListener("keydown", (event) => { if (event.key === "Enter") sendCommand($("commandInput").value); }); $("commandSelect").addEventListener("change", renderCommandFields); $("getBtn").addEventListener("click", () => { state.action = "GET"; $("getBtn").classList.add("active"); $("setBtn").classList.remove("active"); renderCommandFields(); }); $("setBtn").addEventListener("click", () => { state.action = "SET"; $("setBtn").classList.add("active"); $("getBtn").classList.remove("active"); renderCommandFields(); }); $("clearBtn").addEventListener("click", () => $("terminal").replaceChildren()); document.querySelectorAll(".quick").forEach((button) => button.addEventListener("click", () => sendCommand(button.dataset.command))); window.addEventListener("resize", () => { drawPlot(); drawMetricTrend(); }); } function populateTrendMetrics() { $("trendMetric").replaceChildren(...TREND_METRICS.map((metric) => option(metric.label, metric.key))); $("trendMetric").value = "rms_m"; } async function boot() { populateTrendMetrics(); bindEvents(); renderMeasurement(); openSocket(); await loadCommands(); await loadLogList(); try { applyStatus(await api("/api/status")); } catch (err) { log(`[status] ${err.message}`, "bad"); } try { applyMeasurementStatus(await api("/api/measure/status"), "live"); } catch (err) { log(`[measure] ${err.message}`, "bad"); } } boot();