H11 RTK BLE Console
+Disconnected
+diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..cb996f4 --- /dev/null +++ b/static/app.js @@ -0,0 +1,925 @@ +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(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..78e5548 --- /dev/null +++ b/static/index.html @@ -0,0 +1,156 @@ + + +
+ + +Disconnected
+