From 6babc4af2295eea6e3ea7e9a11b1a8d1ac2a93da Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Sun, 20 Sep 2026 18:39:04 +0530 Subject: [PATCH 1/5] feat(telemetry): add live web dashboard Signed-off-by: TheLostLeo --- .gitignore | 3 + telemetry/live-web-dashboard/Dockerfile | 12 + telemetry/live-web-dashboard/app.js | 697 ---- telemetry/live-web-dashboard/index.html | 485 +-- .../live-web-dashboard/package-lock.json | 3341 +++++++++++++++++ telemetry/live-web-dashboard/package.json | 30 + .../live-web-dashboard/postcss.config.js | 6 + telemetry/live-web-dashboard/src/App.tsx | 125 + .../src/components/AltitudeCard.tsx | 37 + .../src/components/AttitudeIndicator.tsx | 36 + .../src/components/AttitudeScene.tsx | 79 + .../src/components/BatteryBox.tsx | 55 + .../src/components/ChartPanel.tsx | 40 + .../src/components/FlightStatusCard.tsx | 40 + .../src/components/LinkCard.tsx | 36 + .../src/components/NavSignalBox.tsx | 68 + .../src/components/Panel.tsx | 84 + .../src/components/SbcStatusCard.tsx | 72 + .../src/components/TelemetryChart.tsx | 60 + .../src/components/TopBar.tsx | 62 + .../live-web-dashboard/src/data/telemetry.ts | 29 + .../src/hooks/useTelemetry.ts | 392 ++ telemetry/live-web-dashboard/src/main.tsx | 10 + telemetry/live-web-dashboard/src/styles.css | 30 + .../live-web-dashboard/src/types/telemetry.ts | 109 + telemetry/live-web-dashboard/style.css | 1209 ------ .../live-web-dashboard/tailwind.config.js | 36 + telemetry/live-web-dashboard/tsconfig.json | 21 + .../live-web-dashboard/tsconfig.node.json | 10 + telemetry/live-web-dashboard/vite.config.ts | 10 + 30 files changed, 4843 insertions(+), 2381 deletions(-) create mode 100644 .gitignore create mode 100644 telemetry/live-web-dashboard/Dockerfile delete mode 100644 telemetry/live-web-dashboard/app.js create mode 100644 telemetry/live-web-dashboard/package-lock.json create mode 100644 telemetry/live-web-dashboard/package.json create mode 100644 telemetry/live-web-dashboard/postcss.config.js create mode 100644 telemetry/live-web-dashboard/src/App.tsx create mode 100644 telemetry/live-web-dashboard/src/components/AltitudeCard.tsx create mode 100644 telemetry/live-web-dashboard/src/components/AttitudeIndicator.tsx create mode 100644 telemetry/live-web-dashboard/src/components/AttitudeScene.tsx create mode 100644 telemetry/live-web-dashboard/src/components/BatteryBox.tsx create mode 100644 telemetry/live-web-dashboard/src/components/ChartPanel.tsx create mode 100644 telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx create mode 100644 telemetry/live-web-dashboard/src/components/LinkCard.tsx create mode 100644 telemetry/live-web-dashboard/src/components/NavSignalBox.tsx create mode 100644 telemetry/live-web-dashboard/src/components/Panel.tsx create mode 100644 telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx create mode 100644 telemetry/live-web-dashboard/src/components/TelemetryChart.tsx create mode 100644 telemetry/live-web-dashboard/src/components/TopBar.tsx create mode 100644 telemetry/live-web-dashboard/src/data/telemetry.ts create mode 100644 telemetry/live-web-dashboard/src/hooks/useTelemetry.ts create mode 100644 telemetry/live-web-dashboard/src/main.tsx create mode 100644 telemetry/live-web-dashboard/src/styles.css create mode 100644 telemetry/live-web-dashboard/src/types/telemetry.ts delete mode 100644 telemetry/live-web-dashboard/style.css create mode 100644 telemetry/live-web-dashboard/tailwind.config.js create mode 100644 telemetry/live-web-dashboard/tsconfig.json create mode 100644 telemetry/live-web-dashboard/tsconfig.node.json create mode 100644 telemetry/live-web-dashboard/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4e2c6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/telemetry/live-web-dashboard/Dockerfile b/telemetry/live-web-dashboard/Dockerfile new file mode 100644 index 0000000..1f05a13 --- /dev/null +++ b/telemetry/live-web-dashboard/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/telemetry/live-web-dashboard/app.js b/telemetry/live-web-dashboard/app.js deleted file mode 100644 index dbf8b9e..0000000 --- a/telemetry/live-web-dashboard/app.js +++ /dev/null @@ -1,697 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - - const el = { - batV: document.getElementById('val-bat-v'), - batA: document.getElementById('val-bat-a'), - batW: document.getElementById('val-bat-w'), - batPct: document.getElementById('val-bat-pct'), - batBar: document.getElementById('bat-remaining-bar'), - altRel: document.getElementById('val-alt-rel'), - altMsl: document.getElementById('val-alt-msl'), - climb: document.getElementById('val-climb'), - heading: document.getElementById('val-heading'), - headingCard: document.getElementById('val-heading-cardinal'), - roll: document.getElementById('val-roll'), - pitch: document.getElementById('val-pitch'), - rssi: document.getElementById('val-rssi'), - rssiBar: document.getElementById('rssi-bar'), - sbcTemp: document.getElementById('val-sbc-temp'), - sbcLoad: document.getElementById('val-sbc-load'), - badgeArm: document.getElementById('badge-arm'), - badgeMode: document.getElementById('badge-mode'), - badgeMission: document.getElementById('badge-mission'), - connIndicator: document.getElementById('conn-indicator'), - connText: document.getElementById('conn-text'), - gpsFix: document.getElementById('badge-gps-fix'), - gpsSats: document.getElementById('badge-gps-sats'), - gpsHdop: document.getElementById('badge-gps-hdop'), - lat: document.getElementById('val-lat'), - lon: document.getElementById('val-lon'), - accX: document.getElementById('val-acc-x'), - accY: document.getElementById('val-acc-y'), - accZ: document.getElementById('val-acc-z'), - cellDelta: document.getElementById('val-cell-delta'), - errRoll: document.getElementById('val-err-roll'), - errPitch: document.getElementById('val-err-pitch'), - sbcTempTxt: document.getElementById('sbc-temp-txt'), - sbcCpuTxt: document.getElementById('sbc-cpu-txt'), - sbcRamTxt: document.getElementById('sbc-ram-txt'), - sbcDiskTxt: document.getElementById('sbc-disk-txt'), - sbcUptimeTxt: document.getElementById('sbc-uptime-txt'), - - hudDot: document.getElementById('hud-dot'), - hudMissionState: document.getElementById('hud-mission-state'), - hudWpTracker: document.getElementById('hud-wp-tracker'), - hudWpDist: document.getElementById('hud-wp-dist'), - hudProgressFill: document.getElementById('hud-progress-fill'), - btnHudStart: document.getElementById('btn-hud-start'), - btnHudPause: document.getElementById('btn-hud-pause'), - btnHudRtl: document.getElementById('btn-hud-rtl'), - - gridModal: document.getElementById('grid-modal'), - btnOpenModal: document.getElementById('btn-open-grid-modal'), - btnCloseModal: document.getElementById('btn-close-modal'), - btnCancelModal: document.getElementById('btn-cancel-modal'), - btnPickCenter: document.getElementById('btn-pick-center'), - radiusSlider: document.getElementById('input-radius-slider'), - radiusLabel: document.getElementById('val-radius-label'), - inputSpacing: document.getElementById('input-spacing'), - inputAngle: document.getElementById('input-angle'), - inputAltitude: document.getElementById('input-altitude'), - inputSpeed: document.getElementById('input-speed'), - inputEndAction: document.getElementById('input-end-action'), - inputCustomLat: document.getElementById('input-custom-lat'), - inputCustomLon: document.getElementById('input-custom-lon'), - statWpCount: document.getElementById('stat-wp-count'), - statTotalDist: document.getElementById('stat-total-dist'), - statEstTime: document.getElementById('stat-est-time'), - btnPreviewMission: document.getElementById('btn-preview-mission'), - btnSubmitGrid: document.getElementById('btn-submit-grid'), - btnModalStart: document.getElementById('btn-modal-start'), - btnModalPause: document.getElementById('btn-modal-pause'), - btnModalAbort: document.getElementById('btn-modal-abort'), - btnModalClear: document.getElementById('btn-modal-clear'), - alertBox: document.getElementById('mission-alert-box') - }; - - const motors = [1, 2, 3, 4].map(i => ({ - pct: document.getElementById(`val-m${i}-pct`), - bar: document.getElementById(`bar-m${i}`), - pwm: document.getElementById(`val-m${i}-pwm`) - })); - - const cells = [1, 2, 3, 4, 5, 6].map(i => ({ - val: document.getElementById(`val-cell-${i}`), - bar: document.getElementById(`cell-bar-${i}`) - })); - - let currentTelemetry = {}; - let isPickingPointOnMap = false; - let selectedCenter = [12.971598, 77.594562]; - let activeWpMarkers = []; - - let map, droneMarker, flightPathPolyline; - let searchCenterMarker, searchRadiusCircle, missionRoutePolyline, missionLayerGroup; - const flightHistory = []; - const initialCoords = [12.971598, 77.594562]; - - function initMap() { - map = L.map('leaflet-map', { - center: initialCoords, - zoom: 18, - zoomControl: true, - attributionControl: false - }); - - const googleHybrid = L.tileLayer('https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', { - maxZoom: 21, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] - }); - - const googleTerrain = L.tileLayer('https://{s}.google.com/vt/lyrs=p&x={x}&y={y}&z={z}', { - maxZoom: 21, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] - }); - - const googleRoadmap = L.tileLayer('https://{s}.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', { - maxZoom: 21, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] - }); - - const googleSatellite = L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { - maxZoom: 21, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] - }); - - const osm = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 19 - }); - - googleHybrid.addTo(map); - - const baseMaps = { - "🛰️ Google Hybrid (Satellite + Buildings & Roads)": googleHybrid, - "⛰️ Google Terrain (Elevation & Relief)": googleTerrain, - "🏙️ Google Roadmap (Streets & Buildings)": googleRoadmap, - "🌍 Google Satellite (Pure Imagery)": googleSatellite, - "🗺️ OpenStreetMap": osm - }; - - L.control.layers(baseMaps, null, { position: 'topleft', collapsed: true }).addTo(map); - - const droneIcon = L.divIcon({ - className: 'drone-leaflet-icon', - html: `
- - - -
`, - iconSize: [32, 32], - iconAnchor: [16, 16] - }); - - droneMarker = L.marker(initialCoords, { icon: droneIcon }).addTo(map); - - flightPathPolyline = L.polyline([], { - color: '#00f0ff', - weight: 3, - opacity: 0.8, - dashArray: '4, 6' - }).addTo(map); - - missionLayerGroup = L.layerGroup().addTo(map); - - searchRadiusCircle = L.circle(initialCoords, { - radius: 50, - color: '#ffd000', - weight: 2, - dashArray: '6, 6', - fillColor: '#ffd000', - fillOpacity: 0.08 - }).addTo(map); - - map.on('click', (e) => { - if (isPickingPointOnMap) { - setSearchCenter(e.latlng.lat, e.latlng.lng); - togglePickMode(false); - el.gridModal.classList.remove('hidden'); - previewMissionRoute(); - } - }); - } - - function setSearchCenter(lat, lon) { - selectedCenter = [lat, lon]; - el.inputCustomLat.value = lat.toFixed(6); - el.inputCustomLon.value = lon.toFixed(6); - - const r = parseFloat(el.radiusSlider.value) || 50; - searchRadiusCircle.setLatLng(selectedCenter); - searchRadiusCircle.setRadius(r); - - if (searchCenterMarker) { - searchCenterMarker.setLatLng(selectedCenter); - } else { - const centerIcon = L.divIcon({ - className: 'center-marker-icon', - html: '🎯', - iconSize: [24, 24], - iconAnchor: [12, 12] - }); - searchCenterMarker = L.marker(selectedCenter, { icon: centerIcon }).addTo(map); - } - } - - function togglePickMode(enable) { - isPickingPointOnMap = (enable !== undefined) ? enable : !isPickingPointOnMap; - if (isPickingPointOnMap) { - el.btnPickCenter.textContent = '❌ Cancel Picking (Click Map)'; - el.btnPickCenter.style.borderColor = 'var(--accent-red)'; - el.btnPickCenter.style.color = 'var(--accent-red)'; - map.getContainer().style.cursor = 'crosshair'; - el.gridModal.classList.add('hidden'); - } else { - el.btnPickCenter.textContent = '📍 Pick Center on Map'; - el.btnPickCenter.style.borderColor = ''; - el.btnPickCenter.style.color = ''; - map.getContainer().style.cursor = ''; - isPickingPointOnMap = false; - } - } - - initMap(); - - document.getElementById('btn-recenter').addEventListener('click', () => { - if (droneMarker) { - map.panTo(droneMarker.getLatLng(), { animate: true }); - } - }); - - el.btnPickCenter.addEventListener('click', () => togglePickMode()); - - const maxDataPoints = 40; - const timeLabels = Array(maxDataPoints).fill(''); - - const gyroCtx = document.getElementById('gyroChart').getContext('2d'); - const gyroChart = new Chart(gyroCtx, { - type: 'line', - data: { - labels: [...timeLabels], - datasets: [ - { label: 'Roll Rate (ωx)', data: Array(maxDataPoints).fill(0), borderColor: '#00f0ff', backgroundColor: 'transparent', borderWidth: 2, pointRadius: 0, tension: 0.2 }, - { label: 'Pitch Rate (ωy)', data: Array(maxDataPoints).fill(0), borderColor: '#ffd000', backgroundColor: 'transparent', borderWidth: 2, pointRadius: 0, tension: 0.2 }, - { label: 'Yaw Rate (ωz)', data: Array(maxDataPoints).fill(0), borderColor: '#ff0077', backgroundColor: 'transparent', borderWidth: 2, pointRadius: 0, tension: 0.2 } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: false, - plugins: { legend: { display: false } }, - scales: { - x: { display: false }, - y: { grid: { color: '#1e293f' }, ticks: { color: '#8b9bb4', font: { size: 10 } } } - } - } - }); - - const pidCtx = document.getElementById('pidChart').getContext('2d'); - const pidChart = new Chart(pidCtx, { - type: 'line', - data: { - labels: [...timeLabels], - datasets: [ - { label: 'Target Pitch', data: Array(maxDataPoints).fill(0), borderColor: '#ffd000', borderWidth: 1.5, borderDash: [4, 4], pointRadius: 0, tension: 0.2 }, - { label: 'Measured Pitch', data: Array(maxDataPoints).fill(0), borderColor: '#00ff88', borderWidth: 2, pointRadius: 0, tension: 0.2 }, - { label: 'Target Roll', data: Array(maxDataPoints).fill(0), borderColor: '#00f0ff', borderWidth: 1.5, borderDash: [4, 4], pointRadius: 0, tension: 0.2 }, - { label: 'Measured Roll', data: Array(maxDataPoints).fill(0), borderColor: '#ff0077', borderWidth: 2, pointRadius: 0, tension: 0.2 } - ] - }, - options: { - responsive: true, - maintainAspectRatio: false, - animation: false, - plugins: { legend: { display: false } }, - scales: { - x: { display: false }, - y: { grid: { color: '#1e293f' }, ticks: { color: '#8b9bb4', font: { size: 10 } } } - } - } - }); - - function updateTelemetry(data) { - currentTelemetry = data; - - const rawV = data.battery_voltage || 0; - const isUsb = rawV < 0.5; - const v = isUsb ? '5.00 (USB)' : rawV.toFixed(2); - const a = (data.battery_current || 0).toFixed(1); - const w = isUsb ? '0' : (data.battery_voltage * data.battery_current).toFixed(0); - const pct = isUsb ? 100 : (data.battery_remaining || 0); - - el.batV.textContent = v; - el.batA.textContent = a; - el.batW.textContent = w; - el.batPct.textContent = isUsb ? 'USB' : pct; - el.batBar.style.width = `${pct}%`; - if (isUsb) el.batBar.style.background = 'var(--accent-blue)'; - - const displayedAlt = (data.altitude_relative !== 0 ? data.altitude_relative : (data.altitude_msl || 0)); - el.altRel.textContent = (displayedAlt || 0).toFixed(1); - el.altMsl.textContent = (data.altitude_msl || displayedAlt || 0).toFixed(1); - el.climb.textContent = (data.climb_rate >= 0 ? '+' : '') + (data.climb_rate || 0).toFixed(1); - - const hdg = Math.round(data.heading || 0); - el.heading.textContent = hdg.toString().padStart(3, '0'); - el.headingCard.textContent = getCardinal(hdg); - el.roll.textContent = (data.attitude_roll || 0).toFixed(1); - el.pitch.textContent = (data.attitude_pitch || 0).toFixed(1); - - const rssi = data.rc_rssi || 0; - el.rssi.textContent = rssi; - el.rssiBar.style.width = `${rssi}%`; - el.sbcTemp.textContent = (data.sbc?.cpu_temp_c || 48.0).toFixed(1); - el.sbcLoad.textContent = Math.round(data.sbc?.cpu_load_percent || 0); - - const nowSec = Date.now() / 1000; - const isHeartbeatLost = !data.connected || (data.last_packet_timestamp > 0 && (nowSec - data.last_packet_timestamp > 10.0)); - - if (data.armed && !isHeartbeatLost) { - el.badgeArm.textContent = 'ARMED'; - el.badgeArm.className = 'status-badge armed'; - } else { - el.badgeArm.textContent = 'DISARMED'; - el.badgeArm.className = 'status-badge disarmed'; - } - - if (isHeartbeatLost) { - el.badgeMode.textContent = 'NO HEARTBEAT'; - el.badgeMode.className = 'status-badge lost'; - el.connText.textContent = 'NO HEARTBEAT (>10s)'; - el.connIndicator.className = 'conn-indicator lost'; - } else { - el.badgeMode.textContent = data.flight_mode || 'STABILIZE'; - el.badgeMode.className = 'status-badge mode-badge'; - el.connText.textContent = 'PIXHAWK LIVE'; - el.connIndicator.className = 'conn-indicator online'; - } - el.badgeMission.textContent = isHeartbeatLost ? 'LINK LOST' : (data.mission_state || 'STANDBY'); - - if (el.hudMissionState) { - el.hudMissionState.textContent = `MISSION: ${data.mission_state || 'STANDBY'}`; - const isAuto = (data.flight_mode === 'AUTO'); - const isLoiter = (data.flight_mode === 'LOITER'); - el.hudDot.className = 'hud-dot' + (isAuto ? ' active' : (isLoiter ? ' paused' : '')); - const currSeq = data.mission_current_seq || 0; - const totItems = data.mission_total_items || 0; - el.hudWpTracker.textContent = totItems > 0 ? `WP ${currSeq} / ${totItems}` : '-- / --'; - el.hudProgressFill.style.width = `${data.mission_progress_percent || 0}%`; - - activeWpMarkers.forEach((m, idx) => { - const markerDom = m.getElement(); - if (markerDom) { - const wpIdx = idx + 1; - if (wpIdx < currSeq) { - markerDom.className = 'leaflet-marker-icon wp-marker-icon completed'; - } else if (wpIdx === currSeq) { - markerDom.className = 'leaflet-marker-icon wp-marker-icon active'; - } else { - markerDom.className = 'leaflet-marker-icon wp-marker-icon'; - } - } - }); - } - - el.gpsFix.textContent = data.gps_fix_type || '3D FIX'; - el.gpsSats.textContent = `${data.satellites || 0} SATS`; - el.gpsHdop.textContent = `HDOP: ${(data.hdop || 1.0).toFixed(1)}`; - - if (data.latitude && data.longitude && data.latitude !== 0) { - el.lat.textContent = data.latitude.toFixed(6); - el.lon.textContent = data.longitude.toFixed(6); - - const newLatLng = [data.latitude, data.longitude]; - droneMarker.setLatLng(newLatLng); - - const svgEl = document.getElementById('drone-map-svg'); - if (svgEl) svgEl.style.transform = `rotate(${hdg}deg)`; - - flightHistory.push(newLatLng); - if (flightHistory.length > 300) flightHistory.shift(); - flightPathPolyline.setLatLngs(flightHistory); - } - - el.accX.textContent = (data.accel_x || 0).toFixed(2); - el.accY.textContent = (data.accel_y || 0).toFixed(2); - el.accZ.textContent = (data.accel_z || 1.0).toFixed(2); - - gyroChart.data.datasets[0].data.shift(); - gyroChart.data.datasets[0].data.push(data.gyro_x || 0); - gyroChart.data.datasets[1].data.shift(); - gyroChart.data.datasets[1].data.push(data.gyro_y || 0); - gyroChart.data.datasets[2].data.shift(); - gyroChart.data.datasets[2].data.push(data.gyro_z || 0); - gyroChart.update(); - - pidChart.data.datasets[0].data.shift(); - pidChart.data.datasets[0].data.push(data.target_pitch || 0); - pidChart.data.datasets[1].data.shift(); - pidChart.data.datasets[1].data.push(data.attitude_pitch || 0); - pidChart.data.datasets[2].data.shift(); - pidChart.data.datasets[2].data.push(data.target_roll || 0); - pidChart.data.datasets[3].data.shift(); - pidChart.data.datasets[3].data.push(data.attitude_roll || 0); - pidChart.update(); - - el.errRoll.textContent = `${(data.error_roll || 0).toFixed(1)}°`; - el.errPitch.textContent = `${(data.error_pitch || 0).toFixed(1)}°`; - - const cellVals = data.cell_voltages || []; - cells.forEach((c, idx) => { - const cv = cellVals[idx] || 0.0; - if (cv > 1.0) { - c.val.textContent = `${cv.toFixed(2)}V`; - const p = Math.max(0, Math.min(100, ((cv - 3.2) / 1.0) * 100)); - c.bar.style.width = `${p}%`; - c.bar.style.background = cv >= 3.7 ? 'var(--accent-green)' : (cv >= 3.5 ? 'var(--accent-yellow)' : 'var(--accent-red)'); - } else { - c.val.textContent = '--.--'; - c.bar.style.width = '0%'; - } - }); - - const delta = data.cell_delta_mv || 0; - el.cellDelta.textContent = `ΔV: ${delta} mV (${delta <= 30 ? 'BALANCED ✓' : 'UNBALANCED !'})`; - el.cellDelta.style.color = delta <= 30 ? 'var(--accent-green)' : 'var(--accent-red)'; - - const pwms = data.motor_pwm || [1000, 1000, 1000, 1000]; - const pcts = data.motor_percent || [0, 0, 0, 0]; - motors.forEach((m, idx) => { - const p = pcts[idx] || 0; - m.pct.textContent = `${p}%`; - m.bar.style.height = `${p}%`; - m.pwm.textContent = `${pwms[idx] || 1000}µs`; - }); - - if (data.sbc) { - el.sbcTempTxt.innerHTML = `${data.sbc.cpu_temp_c.toFixed(1)} °C ${data.sbc.cpu_temp_c > 75 ? 'HIGH' : 'NORMAL'}`; - el.sbcCpuTxt.textContent = `${data.sbc.cpu_load_percent.toFixed(1)} %`; - el.sbcRamTxt.textContent = `${data.sbc.ram_used_mb} MB / ${data.sbc.ram_total_mb} MB (${data.sbc.ram_percent.toFixed(1)}%)`; - el.sbcDiskTxt.textContent = `${data.sbc.disk_free_gb} GB (${data.sbc.disk_percent.toFixed(0)}% used)`; - const hrs = Math.floor(data.sbc.uptime_seconds / 3600); - const mins = Math.floor((data.sbc.uptime_seconds % 3600) / 60); - el.sbcUptimeTxt.textContent = `${hrs}h ${mins}m`; - } - } - - function getCardinal(deg) { - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; - return directions[Math.round(deg / 45) % 8]; - } - - el.btnOpenModal.addEventListener('click', () => { - el.gridModal.classList.remove('hidden'); - el.alertBox.classList.add('hidden'); - previewMissionRoute(); - }); - - const closeModal = () => { - el.gridModal.classList.add('hidden'); - togglePickMode(false); - }; - el.btnCloseModal.addEventListener('click', closeModal); - el.btnCancelModal.addEventListener('click', closeModal); - - el.radiusSlider.addEventListener('input', () => { - const r = parseFloat(el.radiusSlider.value) || 50; - el.radiusLabel.textContent = r; - searchRadiusCircle.setRadius(r); - }); - el.radiusSlider.addEventListener('change', () => previewMissionRoute()); - - [el.inputSpacing, el.inputAngle, el.inputAltitude, el.inputSpeed, el.inputEndAction].forEach(elem => { - elem.addEventListener('change', () => previewMissionRoute()); - }); - - document.querySelectorAll('input[name="mission-pattern"]').forEach(radio => { - radio.addEventListener('change', () => previewMissionRoute()); - }); - - document.querySelectorAll('input[name="center-mode"]').forEach(radio => { - radio.addEventListener('change', (e) => { - const mode = e.target.value; - if (mode === 'current') { - const droneLat = currentTelemetry.latitude || 12.971598; - const droneLon = currentTelemetry.longitude || 77.594562; - setSearchCenter(droneLat, droneLon); - previewMissionRoute(); - } else if (mode === 'map') { - togglePickMode(true); - } - }); - }); - - function getSelectedMissionParams() { - const pattern = document.querySelector('input[name="mission-pattern"]:checked')?.value || 'grid'; - const radius = parseFloat(el.radiusSlider.value) || 50; - const spacing = parseFloat(el.inputSpacing.value) || 10; - const angle = parseFloat(el.inputAngle.value) || 0; - const altitude = parseFloat(el.inputAltitude.value) || 15; - const speed = parseFloat(el.inputSpeed.value) || 5; - const end_action = el.inputEndAction.value || 'RTL'; - - let lat = selectedCenter[0]; - let lon = selectedCenter[1]; - - const centerMode = document.querySelector('input[name="center-mode"]:checked')?.value || 'current'; - if (centerMode === 'custom') { - lat = parseFloat(el.inputCustomLat.value) || lat; - lon = parseFloat(el.inputCustomLon.value) || lon; - } - - return { lat, lon, radius, spacing, angle, altitude, speed, pattern, end_action }; - } - - async function previewMissionRoute() { - const params = getSelectedMissionParams(); - try { - const res = await fetch('/api/mission/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params) - }); - const data = await res.json(); - if (data && data.waypoints) { - renderMissionOnMap(data); - el.statWpCount.textContent = `${data.waypoint_count} WPs`; - el.statTotalDist.textContent = `${data.total_distance_m} m`; - const mins = Math.floor(data.estimated_duration_s / 60); - const secs = Math.floor(data.estimated_duration_s % 60); - el.statEstTime.textContent = `${mins}m ${secs.toString().padStart(2, '0')}s`; - } - } catch (e) { - console.error('Preview failed:', e); - } - } - - function renderMissionOnMap(plan) { - missionLayerGroup.clearLayers(); - activeWpMarkers = []; - - const waypoints = plan.waypoints || []; - if (waypoints.length === 0) return; - - const latLngs = waypoints.map(wp => [wp[0], wp[1]]); - - missionRoutePolyline = L.polyline(latLngs, { - color: '#ffd000', - weight: 2.5, - dashArray: '6, 6', - opacity: 0.9 - }).addTo(missionLayerGroup); - - waypoints.forEach((wp, idx) => { - const wpIcon = L.divIcon({ - className: 'wp-marker-icon', - html: `${idx + 1}`, - iconSize: [22, 22], - iconAnchor: [11, 11] - }); - const marker = L.marker([wp[0], wp[1]], { icon: wpIcon }) - .bindPopup(`Waypoint ${idx + 1}
Alt: ${wp[2]}m AGL
Lat: ${wp[0].toFixed(6)}
Lon: ${wp[1].toFixed(6)}`) - .addTo(missionLayerGroup); - activeWpMarkers.push(marker); - }); - - const takeoffIcon = L.divIcon({ - className: 'takeoff-marker-icon', - html: '🛫', - iconSize: [26, 26], - iconAnchor: [13, 13] - }); - L.marker([plan.center[0], plan.center[1]], { icon: takeoffIcon }) - .bindPopup(`Vertical Takeoff Target Datum
Ascends to ${plan.altitude_m}m AGL before cruising to WP 1`) - .addTo(missionLayerGroup); - } - - el.btnPreviewMission.addEventListener('click', previewMissionRoute); - - el.btnSubmitGrid.addEventListener('click', async () => { - const params = getSelectedMissionParams(); - el.alertBox.className = 'alert-box'; - el.alertBox.textContent = 'Uploading mission to Pixhawk via MAVLink...'; - el.alertBox.classList.remove('hidden'); - - try { - const res = await fetch('/api/mission/upload', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params) - }); - const resData = await res.json(); - if (res.ok && resData.status === 'success') { - el.alertBox.className = 'alert-box success'; - el.alertBox.textContent = `✓ Mission Uploaded! Generated ${resData.plan?.waypoint_count || '--'} waypoints with dynamic vertical takeoff. Ready to arm!`; - if (resData.plan) renderMissionOnMap(resData.plan); - } else { - el.alertBox.className = 'alert-box error'; - el.alertBox.textContent = `Upload failed: ${resData.message || 'Error'}`; - } - } catch (e) { - el.alertBox.className = 'alert-box error'; - el.alertBox.textContent = `Network error: ${e.message}`; - } - }); - - const sendMissionCmd = async (endpoint, payload = {}) => { - try { - const res = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - return await res.json(); - } catch (e) { - console.error(e); - return { status: 'error', message: e.message }; - } - }; - - el.btnModalStart.addEventListener('click', async () => { - const r = await sendMissionCmd('/api/mission/start', { auto_arm: true }); - if (r.status === 'success') { - el.alertBox.className = 'alert-box success'; - el.alertBox.textContent = '🚀 Pixhawk commanded to AUTO mode! Autonomous mission started.'; - setTimeout(closeModal, 1500); - } - }); - el.btnHudStart.addEventListener('click', () => sendMissionCmd('/api/mission/start', { auto_arm: true })); - - el.btnModalPause.addEventListener('click', () => sendMissionCmd('/api/mission/pause')); - el.btnHudPause.addEventListener('click', () => sendMissionCmd('/api/mission/pause')); - - el.btnModalAbort.addEventListener('click', () => sendMissionCmd('/api/mission/abort')); - el.btnHudRtl.addEventListener('click', () => sendMissionCmd('/api/mission/abort')); - - el.btnModalClear.addEventListener('click', async () => { - await sendMissionCmd('/api/mission/clear'); - missionLayerGroup.clearLayers(); - activeWpMarkers = []; - el.alertBox.className = 'alert-box'; - el.alertBox.textContent = 'Mission cleared from Pixhawk memory.'; - }); - - let lastRxTimestamp = Date.now(); - - function connectWebSocket() { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/telemetry`; - - const ws = new WebSocket(wsUrl); - - ws.onopen = () => { - el.connText.textContent = 'PIXHAWK LIVE'; - el.connIndicator.className = 'conn-indicator online'; - }; - - ws.onmessage = (event) => { - try { - lastRxTimestamp = Date.now(); - const payload = JSON.parse(event.data); - updateTelemetry(payload); - } catch (err) { - console.error('JSON parse error:', err); - } - }; - - ws.onerror = () => ws.close(); - - ws.onclose = () => { - el.connText.textContent = 'NO HEARTBEAT (>10s)'; - el.connIndicator.className = 'conn-indicator lost'; - setTimeout(connectWebSocket, 2000); - }; - } - - setInterval(async () => { - try { - const res = await fetch('/api/telemetry'); - if (res.ok) { - lastRxTimestamp = Date.now(); - const data = await res.json(); - updateTelemetry(data); - } - } catch (e) {} - }, 500); - - setInterval(() => { - if (Date.now() - lastRxTimestamp > 10000) { - el.badgeMode.textContent = 'NO HEARTBEAT'; - el.badgeMode.className = 'status-badge lost'; - el.connText.textContent = 'NO HEARTBEAT (>10s)'; - el.connIndicator.className = 'conn-indicator lost'; - } - }, 1000); - - connectWebSocket(); -}); diff --git a/telemetry/live-web-dashboard/index.html b/telemetry/live-web-dashboard/index.html index 0d19a28..acc3e3d 100644 --- a/telemetry/live-web-dashboard/index.html +++ b/telemetry/live-web-dashboard/index.html @@ -1,477 +1,12 @@ - + - - - - Drone Mission Control & Telemetry Dashboard - - - - - - - - - -
- -
-
-
🛸
-
-

DRONE MISSION CONTROL

-
Raspberry Pi 4B • Pixhawk 2.4.8 • TELEM2 (115200)
-
-
- -
-
DISARMED
-
DISCONNECTED
-
STANDBY
-
- - LIVE 10Hz -
-
-
- -
- -
-
- 🔋 - TOTAL BATTERY -
-
- --.--V -
-
- Current: --.- A - Power: --- W -
-
-
-
-
--% Remaining
-
- -
-
- ✈️ - ALTITUDE (AGL) -
-
- --.-m -
-
- MSL: --.- m - Climb: --.- m/s -
- -
- -
-
- 🧭 - HEADING & ATTITUDE -
-
- ---° - N -
-
- Roll: --.-° - Pitch: --.-° -
- -
- -
-
- 📶 - SIGNAL & SBC HEALTH -
-
- --% -
-
- CPU: --.-°C - Load: --% -
-
-
-
-
RC Link Strength
-
-
- -
- -
-
-
- 🗺️ - LIVE GPS SATELLITE TRACKING & AUTONOMOUS MISSION PLANNER -
-
- 3D FIX - 16 SATS - HDOP: 0.8 -
-
- -
-
- -
-
- - MISSION: STANDBY -
-
-
- Waypoint: - -- / -- -
-
- Distance to WP: - --.- m -
-
-
-
-
- - - -
-
-
-
- -
-
- LAT: --.------ - LON: --.------ -
-
- - - -
-
-
- -
-
-
- 📊 - 3-AXES GYROSCOPE LIVE RATES (deg/s) -
-
- Roll Rate (ωx) - Pitch Rate (ωy) - Yaw Rate (ωz) -
-
-
- -
- -
- -
-
-
- 🔋 - INDIVIDUAL CELL VOLTAGES & BALANCE -
- ΔV: 0 mV (BALANCED) -
-
-
-
CELL 1
-
4.20V
-
-
-
-
CELL 2
-
4.20V
-
-
-
-
CELL 3
-
4.20V
-
-
-
-
CELL 4
-
--.--
-
-
-
-
CELL 5
-
--.--
-
-
-
-
CELL 6
-
--.--
-
-
-
-
- -
-
-
- 🎯 - PID ATTITUDE TRACKING (TARGET vs ACTUAL) -
-
- Roll Err: 0.0° - Pitch Err: 0.0° -
-
-
- -
-
- -
-
- ⚡ - MOTOR POWER EQUALIZER (PWM µs / %) -
-
-
-
58%
-
-
-
-
1580µs
-
M1 (FR)
-
-
-
58%
-
-
-
-
1580µs
-
M2 (RL)
-
-
-
58%
-
-
-
-
1580µs
-
M3 (FL)
-
-
-
58%
-
-
-
-
1580µs
-
M4 (RR)
-
-
-
- -
-
- 💻 - SBC DIAGNOSTICS (RASPBERRY PI 4B) -
-
-
- CPU Temperature: - 48.5 °C NORMAL -
-
- CPU Utilization: - 24.5 % -
-
- RAM Consumption: - 450 MB / 1940 MB (23.2%) -
-
- Storage Free: - 18.5 GB (32% used) -
-
- System Uptime: - 0h 45m -
-
-
-
- - -
- - - - - + + + + Drone Telemetry Dashboard + + +
+ + diff --git a/telemetry/live-web-dashboard/package-lock.json b/telemetry/live-web-dashboard/package-lock.json new file mode 100644 index 0000000..a1102bf --- /dev/null +++ b/telemetry/live-web-dashboard/package-lock.json @@ -0,0 +1,3341 @@ +{ + "name": "drone-telemetry-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drone-telemetry-dashboard", + "version": "0.1.0", + "dependencies": { + "@react-three/fiber": "^8.17.10", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.12.7", + "three": "^0.165.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.165.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.3.0.tgz", + "integrity": "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.165.0.tgz", + "integrity": "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.1", + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.25.tgz", + "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.431", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.431.tgz", + "integrity": "sha512-AAVihz2YwJeOdAynX8MUtqpvjY0gaiARp/7+r4kwqgzqXMrfG1qDN7vwT80uxAoOQRPcZH8t73EI+CA/1OJN8A==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/meshoptimizer": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", + "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.56", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.56.tgz", + "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/read-cache": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.2.tgz", + "integrity": "sha512-/peqiBB/n07gQGLsWaHho3WfvUyRscw0gYTsEFMhrIe/nWLkYaf5SbKYjGYqtRV3aPwykJgF2VEMo1ac4bnsGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz", + "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + } + } +} diff --git a/telemetry/live-web-dashboard/package.json b/telemetry/live-web-dashboard/package.json new file mode 100644 index 0000000..b7b8119 --- /dev/null +++ b/telemetry/live-web-dashboard/package.json @@ -0,0 +1,30 @@ +{ + "name": "drone-telemetry-dashboard", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc -b && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@react-three/fiber": "^8.17.10", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.12.7", + "three": "^0.165.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/three": "^0.165.0", + "@vitejs/plugin-react": "^4.3.1", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.17", + "typescript": "^5.5.3", + "vite": "^5.3.4" + } +} diff --git a/telemetry/live-web-dashboard/postcss.config.js b/telemetry/live-web-dashboard/postcss.config.js new file mode 100644 index 0000000..ba80730 --- /dev/null +++ b/telemetry/live-web-dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/telemetry/live-web-dashboard/src/App.tsx b/telemetry/live-web-dashboard/src/App.tsx new file mode 100644 index 0000000..a3f891f --- /dev/null +++ b/telemetry/live-web-dashboard/src/App.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { GaugeIcon, SlidersHorizontalIcon, WavesIcon } from "lucide-react"; +import { AltitudeCard } from "./components/AltitudeCard"; +import { AttitudeIndicator } from "./components/AttitudeIndicator"; +import { BatteryBox } from "./components/BatteryBox"; +import { ChartPanel } from "./components/ChartPanel"; +import { FlightStatusCard } from "./components/FlightStatusCard"; +import { LinkCard } from "./components/LinkCard"; +import { NavSignalBox } from "./components/NavSignalBox"; +import { SbcStatusCard } from "./components/SbcStatusCard"; +import { TopBar } from "./components/TopBar"; +import { gyroSeries, motorSeries, pidSeries, vehicle } from "./data/telemetry"; +import { useTelemetry } from "./hooks/useTelemetry"; +import type { Telemetry } from "./types/telemetry"; + +function activeWarning(t: Telemetry): string | null { + if (t.battery.percent < 25) return "battery low · rtl advised"; + if (t.link.loss > 5) return "downlink packet loss"; + if (t.sbc.temp > 72) return "sbc thermal limit"; + if (t.nav.hdop > 1.2) return "gps hdop degraded"; + return null; +} + +export function App({ streaming = true }: { streaming?: boolean }) { + const [live, setLive] = useState(streaming); + const t = useTelemetry(live); + + return ( +
+ setLive((value) => !value)} + armed={t.flight.armed} + mode={t.flight.mode} + clock={t.clock} + warning={activeWarning(t)} + /> + +
+
+ + + + +
+ +
+ + + +
+ +
+ } + series={pidSeries} + data={t.pid as unknown as Array>} + domain={[-10, 10]} + ticks={[-10, -5, 0, 5, 10]} + unit="°" + className="h-[196px]" + footer={ + <> + err · deg · 60 s window + rate 50 hz + + } + /> + } + series={motorSeries} + data={t.motors as unknown as Array>} + domain={[40, 80]} + ticks={[40, 50, 60, 70, 80]} + unit="%" + className="h-[196px]" + footer={ + <> + throttle out · quad-x + + avg{" "} + {( + ((t.motors[t.motors.length - 1]?.m1 ?? 0) + + (t.motors[t.motors.length - 1]?.m2 ?? 0) + + (t.motors[t.motors.length - 1]?.m3 ?? 0) + + (t.motors[t.motors.length - 1]?.m4 ?? 0)) / + 4 + ).toFixed(1)} + % + + + } + /> +
+ + } + series={gyroSeries} + data={t.gyro as unknown as Array>} + domain={[-40, 40]} + ticks={[-40, -20, 0, 20, 40]} + unit="" + className="h-[170px]" + footer={ + <> + deg/s · imu1 · filtered + {vehicle.firmware} + + } + /> + +
+ + + esp32 json with mock fallback · gcs build 0.9.5 +
+
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/AltitudeCard.tsx b/telemetry/live-web-dashboard/src/components/AltitudeCard.tsx new file mode 100644 index 0000000..9e82f68 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/AltitudeCard.tsx @@ -0,0 +1,37 @@ +import { MountainSnowIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; +import { Chip, Panel } from "./Panel"; +import type { AltitudeState } from "../types/telemetry"; + +export function AltitudeCard({ altitude, className }: { altitude: AltitudeState; className?: string }) { + const rising = altitude.vspeed >= 0; + return ( + } + meta={{rising ? "CLIMB" : "DESC"}} + className={className} + bodyClassName="flex flex-col justify-between" + > +
+
Relative
+
+ {altitude.relative.toFixed(1)} + m +
+
+
+
+
MSL
+
{altitude.msl.toFixed(1)} m
+
+
+
V/S
+
+ {rising ? : } + {altitude.vspeed.toFixed(1)} m/s +
+
+
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/AttitudeIndicator.tsx b/telemetry/live-web-dashboard/src/components/AttitudeIndicator.tsx new file mode 100644 index 0000000..7ef79e7 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/AttitudeIndicator.tsx @@ -0,0 +1,36 @@ +import { CompassIcon } from "lucide-react"; +import { Chip, Panel } from "./Panel"; +import type { AttitudeState } from "../types/telemetry"; +import { AttitudeScene } from "./AttitudeScene"; + +export function AttitudeIndicator({ attitude, className }: { attitude: AttitudeState; className?: string }) { + const { roll, pitch, yaw } = attitude; + + return ( + } + meta={AHRS LOCK} + className={className} + bodyClassName="flex items-center gap-3" + > + + +
+ {[ + { label: "Roll", value: roll, color: "#4c8dff" }, + { label: "Pitch", value: pitch, color: "#2dd4bf" }, + { label: "Yaw", value: yaw, color: "#f5a524" } + ].map((r) => ( +
+
+
+
{r.value.toFixed(1)}°
+
+ ))} +
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/AttitudeScene.tsx b/telemetry/live-web-dashboard/src/components/AttitudeScene.tsx new file mode 100644 index 0000000..20e2251 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/AttitudeScene.tsx @@ -0,0 +1,79 @@ +import { useEffect, useMemo } from "react"; +import { Canvas } from "@react-three/fiber"; +import { CanvasTexture, Euler, Quaternion, Shape, Vector3 } from "three"; +import type { AttitudeState } from "../types/telemetry"; + +function AxisLabel({ text, position, color }: { text: string; position: [number, number, number]; color: string }) { + const texture = useMemo(() => { + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 64; + const context = canvas.getContext("2d")!; + context.font = "500 38px monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillStyle = color; + context.fillText(text, 64, 32); + return new CanvasTexture(canvas); + }, [text, color]); + useEffect(() => () => texture.dispose(), [texture]); + return ( + + + + ); +} + +function Arrow({ roll, pitch, yaw }: AttitudeState) { + const shape = useMemo(() => { + const outline = new Shape(); + outline.moveTo(0.85, 0); + outline.lineTo(-0.55, 0.55); + outline.lineTo(-0.2, 0); + outline.lineTo(-0.55, -0.55); + outline.closePath(); + return outline; + }, []); + // Body X is forward. Convert aviation right/down axes into a Z-up scene. + const orientation = useMemo(() => new Quaternion().setFromEuler(new Euler( + roll * Math.PI / 180, -pitch * Math.PI / 180, -yaw * Math.PI / 180, "ZYX" + )), [roll, pitch, yaw]); + return ( + + + + + + + + + + + + ); +} + +export function AttitudeScene({ attitude }: { attitude: AttitudeState }) { + return ( +
+ 3D view requires WebGL} + > + + + + + + + + + + + +
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/BatteryBox.tsx b/telemetry/live-web-dashboard/src/components/BatteryBox.tsx new file mode 100644 index 0000000..0ea74c8 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/BatteryBox.tsx @@ -0,0 +1,55 @@ +import { BatteryMediumIcon } from "lucide-react"; +import { Bar, Chip, Panel } from "./Panel"; +import type { BatteryState } from "../types/telemetry"; + +function cellTone(v: number): string { + if (v < 3.6) return "#f2555a"; + if (v < 3.8) return "#f5a524"; + return "#3fcf8e"; +} + +export function BatteryBox({ battery, className }: { battery: BatteryState; className?: string }) { + const pack = battery.percent; + const packColor = pack < 25 ? "#f2555a" : pack < 45 ? "#f5a524" : "#3fcf8e"; + const min = Math.min(...battery.cells.map((c) => c.volts)); + const max = Math.max(...battery.cells.map((c) => c.volts)); + const delta = (max - min) * 1000; + + return ( + } + meta={{pack}%} + className={className} + bodyClassName="flex flex-col gap-2" + > +
+
+
+ {battery.total.toFixed(2)} + V +
+
+ {battery.current.toFixed(1)} A · {battery.consumed} mAh +
+
+
+ +
Δ {delta.toFixed(0)} mV
+
+
+ +
    + {battery.cells.map((cell) => ( +
  • + {cell.label} + + + {cell.volts.toFixed(2)} + +
  • + ))} +
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/ChartPanel.tsx b/telemetry/live-web-dashboard/src/components/ChartPanel.tsx new file mode 100644 index 0000000..fef0997 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/ChartPanel.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from "react"; +import { ChartLegend, TelemetryChart } from "./TelemetryChart"; +import { Panel } from "./Panel"; +import type { SeriesDef } from "../types/telemetry"; + +type ChartPanelProps = { + label: string; + icon?: ReactNode; + series: SeriesDef[]; + data: Array>; + domain?: [number, number] | ["auto", "auto"]; + ticks?: number[]; + unit?: string; + footer?: ReactNode; + className?: string; +}; + +export function ChartPanel({ label, icon, series, data, domain, ticks, unit, footer, className = "" }: ChartPanelProps) { + const latest = data[data.length - 1] ?? {}; + const values: Record = {}; + series.forEach((s) => { + const v = latest[s.key]; + values[s.key] = typeof v === "number" ? v.toFixed(1) : "--"; + }); + + return ( + } + className={className} + bodyClassName="flex flex-col gap-1 px-1.5 pb-1.5 pt-1.5" + > +
+ +
+ {footer ?
{footer}
: null} +
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx b/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx new file mode 100644 index 0000000..9212f55 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx @@ -0,0 +1,40 @@ +import { PlaneIcon } from "lucide-react"; +import { Chip, Panel } from "./Panel"; +import type { FlightState } from "../types/telemetry"; + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function FlightStatusCard({ flight, className }: { flight: FlightState; className?: string }) { + return ( + } + meta={{flight.armed ? "ARMED" : "DISARMED"}} + className={className} + bodyClassName="flex flex-col justify-center gap-1" + > + + {flight.mode} + + + {flight.failsafe ? "TRIGGERED" : "CLEAR"} + + + {flight.mission} + WP {flight.wp} + + + + MAVLINK {flight.link} + + + + ); +} diff --git a/telemetry/live-web-dashboard/src/components/LinkCard.tsx b/telemetry/live-web-dashboard/src/components/LinkCard.tsx new file mode 100644 index 0000000..8c1dab0 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/LinkCard.tsx @@ -0,0 +1,36 @@ +import { RadioIcon } from "lucide-react"; +import { Chip, Panel, Stat } from "./Panel"; +import type { LinkState } from "../types/telemetry"; + +export function LinkCard({ link, className }: { link: LinkState; className?: string }) { + const healthy = link.loss < 3 && link.rssi > -78; + return ( + } + meta={{healthy ? "STABLE" : "DEGRADED"}} + className={className} + bodyClassName="flex flex-col justify-between gap-2" + > +
+ + + + +
+ +
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/NavSignalBox.tsx b/telemetry/live-web-dashboard/src/components/NavSignalBox.tsx new file mode 100644 index 0000000..876b0e8 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/NavSignalBox.tsx @@ -0,0 +1,68 @@ +import { SatelliteDishIcon } from "lucide-react"; +import { Chip, Panel, Stat } from "./Panel"; +import type { NavState } from "../types/telemetry"; + +function SignalBars({ rssi }: { rssi: number }) { + const level = rssi > -60 ? 4 : rssi > -68 ? 3 : rssi > -75 ? 2 : 1; + return ( + + ); +} + +function Compass({ heading }: { heading: number }) { + return ( +
+ {["N", "E", "S", "W"].map((d, i) => ( + + {d} + + ))} +
+ + +
+ + {Math.round(heading)}° + +
+ ); +} + +export function NavSignalBox({ nav, className }: { nav: NavState; className?: string }) { + return ( + } + meta={ + <> + {nav.fix} + + + } + className={className} + bodyClassName="flex items-center gap-3" + > +
+ + + + = 12 ? "text-sig-green" : "text-sig-amber"} /> + + +
+ +
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/Panel.tsx b/telemetry/live-web-dashboard/src/components/Panel.tsx new file mode 100644 index 0000000..7416ac1 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/Panel.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; + +type PanelProps = { + label: string; + icon?: ReactNode; + meta?: ReactNode; + className?: string; + bodyClassName?: string; + children: ReactNode; +}; + +export function Panel({ label, icon, meta, className = "", bodyClassName = "", children }: PanelProps) { + return ( +
+
+ {icon ? {icon} : null} +

+ {label} +

+
{meta}
+
+
{children}
+
+ ); +} + +type StatProps = { + label: string; + value: ReactNode; + unit?: string; + tone?: string; +}; + +export function Stat({ label, value, unit, tone = "text-hud-text" }: StatProps) { + return ( +
+
{label}
+
+ {value} + {unit ? {unit} : null} +
+
+ ); +} + +type ChipProps = { + children: ReactNode; + tone?: "neutral" | "green" | "amber" | "red" | "cyan" | "blue"; + className?: string; +}; + +const chipTones: Record = { + neutral: "border-hud-edge bg-hud-raise text-hud-dim", + green: "border-sig-green/40 bg-sig-green/10 text-sig-green", + amber: "border-sig-amber/40 bg-sig-amber/10 text-sig-amber", + red: "border-sig-red/40 bg-sig-red/10 text-sig-red", + cyan: "border-sig-cyan/40 bg-sig-cyan/10 text-sig-cyan", + blue: "border-sig-blue/40 bg-sig-blue/10 text-sig-blue" +}; + +export function Chip({ children, tone = "neutral", className = "" }: ChipProps) { + return ( + + {children} + + ); +} + +type BarProps = { + value: number; + color?: string; + className?: string; +}; + +export function Bar({ value, color = "#2dd4bf", className = "" }: BarProps) { + return ( +
+
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx b/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx new file mode 100644 index 0000000..7cb82c4 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx @@ -0,0 +1,72 @@ +import { CpuIcon } from "lucide-react"; +import { Bar, Chip, Panel } from "./Panel"; +import type { SbcState } from "../types/telemetry"; + +function formatUptime(ticks: number): string { + const seconds = ticks * 4; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${String(m).padStart(2, "0")}m`; +} + +function MetricRow({ label, value, pct, color }: { label: string; value: string; pct: number; color: string }) { + return ( +
+
+ {label} + {value} +
+ +
+ ); +} + +export function SbcStatusCard({ sbc, className }: { sbc: SbcState; className?: string }) { + const tempTone = sbc.temp > 70 ? "#f2555a" : sbc.temp > 62 ? "#f5a524" : "#3fcf8e"; + const memPct = (sbc.memUsed / sbc.memTotal) * 100; + const diskPct = (sbc.diskUsed / sbc.diskTotal) * 100; + + return ( + } + meta={ + <> + 70 ? "red" : "green"}>{sbc.temp.toFixed(1)}°C + ONLINE + + } + className={className} + bodyClassName="flex flex-col justify-between gap-2" + > +
+ 80 ? "#f2555a" : "#4c8dff"} /> + + + +
+ +
+
+ {sbc.cores.map((core, i) => ( +
+
+ 85 ? "#f5a524" : "#4c8dff" }} + /> +
+ C{i} +
+ ))} +
+
+
+ load {sbc.load.map((l) => l.toFixed(2)).join(" ")} +
+
uptime {formatUptime(sbc.uptime)}
+
+
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/TelemetryChart.tsx b/telemetry/live-web-dashboard/src/components/TelemetryChart.tsx new file mode 100644 index 0000000..928ee87 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/TelemetryChart.tsx @@ -0,0 +1,60 @@ +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + YAxis +} from "recharts"; +import type { SeriesDef } from "../types/telemetry"; + +type TelemetryChartProps = { + data: Array>; + series: SeriesDef[]; + domain?: [number, number] | ["auto", "auto"]; + ticks?: number[]; + unit?: string; +}; + +export function TelemetryChart({ data, series, domain = ["auto", "auto"], ticks, unit }: TelemetryChartProps) { + return ( + + + + `${v}${unit ?? ""}`} + /> + {series.map((s) => ( + + ))} + + + ); +} + +export function ChartLegend({ series, values }: { series: SeriesDef[]; values?: Record }) { + return ( +
+ {series.map((s) => ( + + + ))} +
+ ); +} diff --git a/telemetry/live-web-dashboard/src/components/TopBar.tsx b/telemetry/live-web-dashboard/src/components/TopBar.tsx new file mode 100644 index 0000000..30bb236 --- /dev/null +++ b/telemetry/live-web-dashboard/src/components/TopBar.tsx @@ -0,0 +1,62 @@ +import { ActivityIcon, PauseIcon, PlayIcon, ShieldAlertIcon } from "lucide-react"; +import { vehicle } from "../data/telemetry"; +import { Chip } from "./Panel"; + +type TopBarProps = { + live: boolean; + onToggleLive: () => void; + armed: boolean; + mode: string; + clock: number; + warning: string | null; +}; + +function stamp(clock: number): string { + const total = clock * 4; + const h = String(Math.floor(total / 3600) % 24).padStart(2, "0"); + const m = String(Math.floor((total % 3600) / 60)).padStart(2, "0"); + const s = String(total % 60).padStart(2, "0"); + return `${h}:${m}:${s}Z`; +} + +export function TopBar({ live, onToggleLive, armed, mode, clock, warning }: TopBarProps) { + return ( +
+
+ + + +
+
{vehicle.name}
+
{vehicle.linkPath}
+
+
+ +
+ {mode} + {armed ? "ARMED" : "DISARMED"} + {vehicle.sysId} +
+ + {warning ? ( +
+ + {warning} +
+ ) : null} + +
+ {stamp(clock)} + +
+
+ ); +} diff --git a/telemetry/live-web-dashboard/src/data/telemetry.ts b/telemetry/live-web-dashboard/src/data/telemetry.ts new file mode 100644 index 0000000..9fa486c --- /dev/null +++ b/telemetry/live-web-dashboard/src/data/telemetry.ts @@ -0,0 +1,29 @@ +import type { SeriesDef } from "../types/telemetry"; + +export const vehicle = { + name: "HX-4 / QUAD-X", + sysId: "SYS 1 · COMP 1", + firmware: "ArduCopter 4.5.7", + linkPath: "MAVLINK · NRF24 · ESP32-WIFI" +}; + +export const cellLabels = ["1S", "2S", "3S", "4S"]; + +export const pidSeries: SeriesDef[] = [ + { key: "roll", label: "ROLL", color: "#4c8dff" }, + { key: "pitch", label: "PITCH", color: "#2dd4bf" }, + { key: "yaw", label: "YAW", color: "#f5a524" } +]; + +export const motorSeries: SeriesDef[] = [ + { key: "m1", label: "M1", color: "#2dd4bf" }, + { key: "m2", label: "M2", color: "#4c8dff" }, + { key: "m3", label: "M3", color: "#9b8cff" }, + { key: "m4", label: "M4", color: "#f472b6" } +]; + +export const gyroSeries: SeriesDef[] = [ + { key: "x", label: "GX", color: "#3fcf8e" }, + { key: "y", label: "GY", color: "#f5a524" }, + { key: "z", label: "GZ", color: "#f2555a" } +]; diff --git a/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts b/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts new file mode 100644 index 0000000..79e39f2 --- /dev/null +++ b/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts @@ -0,0 +1,392 @@ +import { useEffect, useState } from "react"; +import { cellLabels } from "../data/telemetry"; +import type { GyroPoint, MotorPoint, PidPoint, Telemetry } from "../types/telemetry"; + +const SAMPLES = 64; +const TICK_MS = 260; +const LIVE_ENDPOINT = "http://192.168.4.1/telemetry.json"; + +const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +const wobble = (amp: number) => (Math.random() - 0.5) * 2 * amp; +const num = (value: unknown, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback); + +type RawTelemetry = { + connected?: boolean; + battery_voltage?: number; + battery_current?: number; + battery_remaining?: number; + cell_voltages?: number[]; + altitude_relative?: number; + altitude_msl?: number; + climb_rate?: number; + latitude?: number; + longitude?: number; + satellites?: number; + gps_fix_type?: string; + hdop?: number; + rc_rssi?: number; + radio_link_quality?: number; + heading?: number; + ground_speed?: number; + armed?: boolean; + flight_mode?: string; + mission_state?: string; + mission_current_seq?: number; + mission_total_items?: number; + attitude_roll?: number; + attitude_pitch?: number; + attitude_yaw?: number; + error_roll?: number; + error_pitch?: number; + error_yaw?: number; + motor_percent?: number[]; + gyro_x?: number; + gyro_y?: number; + gyro_z?: number; + sbc?: { + cpu_load_percent?: number; + ram_used_mb?: number; + ram_total_mb?: number; + ram_percent?: number; + cpu_temp_c?: number; + disk_percent?: number; + uptime_seconds?: number; + }; +}; + +function getLiveEndpoint(): string { + const configured = new URLSearchParams(window.location.search).get("telemetryUrl"); + return configured && configured.trim().length > 0 ? configured : LIVE_ENDPOINT; +} + +function seedSeries(): { + pid: PidPoint[]; + motors: MotorPoint[]; + gyro: GyroPoint[]; +} { + const pid: PidPoint[] = []; + const motors: MotorPoint[] = []; + const gyro: GyroPoint[] = []; + + for (let i = 0; i < SAMPLES; i += 1) { + const p = i / 6; + pid.push({ + t: i, + roll: Math.sin(p) * 6 + wobble(1.4), + pitch: Math.sin(p * 0.8 + 1.2) * 4.5 + wobble(1.2), + yaw: Math.sin(p * 0.45 + 2.4) * 3 + wobble(0.8) + }); + motors.push({ + t: i, + m1: 58 + Math.sin(p * 0.9) * 5 + wobble(1.6), + m2: 61 + Math.sin(p * 0.9 + 1.6) * 5 + wobble(1.6), + m3: 56 + Math.sin(p * 0.9 + 3.1) * 5 + wobble(1.6), + m4: 63 + Math.sin(p * 0.9 + 4.7) * 5 + wobble(1.6) + }); + gyro.push({ + t: i, + x: Math.sin(p * 1.4) * 22 + wobble(6), + y: Math.sin(p * 1.1 + 0.9) * 17 + wobble(6), + z: Math.sin(p * 0.7 + 2.2) * 11 + wobble(4) + }); + } + + return { pid, motors, gyro }; +} + +function initial(): Telemetry { + const series = seedSeries(); + const cells = [4.02, 3.98, 4.01, 3.95]; + + return { + clock: SAMPLES, + battery: { + total: cells.reduce((a, b) => a + b, 0), + percent: 74, + current: 18.4, + consumed: 2140, + cells: cells.map((volts, i) => ({ label: cellLabels[i], volts })) + }, + nav: { + lat: 12.971834, + lon: 77.593122, + heading: 142, + sats: 14, + hdop: 0.82, + rssi: -63, + fix: "RTK FLOAT", + groundSpeed: 8.4 + }, + altitude: { + relative: 62.4, + msl: 982.7, + vspeed: 0.6, + home: 920.3 + }, + link: { + rssi: -68, + rate: 92, + loss: 1.4, + latency: 34, + history: Array.from({ length: 28 }, () => 78 + Math.random() * 20) + }, + flight: { + mode: "AUTO", + armed: true, + failsafe: false, + mission: "WAYPOINT NAV", + wp: "7 / 12", + link: "OK" + }, + sbc: { + cpu: 42, + cores: [38, 51, 33, 46], + memUsed: 2.9, + memTotal: 8, + temp: 58.2, + uptime: 9412, + diskUsed: 21.4, + diskTotal: 64, + load: [0.82, 0.71, 0.64] + }, + attitude: { roll: 6.2, pitch: -3.4, yaw: 142 }, + pid: series.pid, + motors: series.motors, + gyro: series.gyro + }; +} + +function step(prev: Telemetry): Telemetry { + const t = prev.clock + 1; + const p = t / 6; + const cells = prev.battery.cells.map((cell) => ({ + ...cell, + volts: clamp(cell.volts - 0.00035 + wobble(0.004), 3.2, 4.2) + })); + const total = cells.reduce((a, b) => a + b.volts, 0); + const heading = (prev.nav.heading + wobble(1.1) + 360.15) % 360; + const relativeAltitude = clamp(prev.altitude.relative + prev.altitude.vspeed * 0.26 + wobble(0.05), 0, 400); + const verticalSpeed = clamp(prev.altitude.vspeed + wobble(0.16), -2.4, 2.4); + const linkRate = clamp(prev.link.rate + wobble(3), 62, 100); + + return { + clock: t, + battery: { + total, + percent: clamp(Math.round(((total / 4 - 3.35) / (4.2 - 3.35)) * 100), 0, 100), + current: clamp(prev.battery.current + wobble(0.6), 11, 27), + consumed: prev.battery.consumed + 1, + cells + }, + nav: { + ...prev.nav, + lat: prev.nav.lat + 0.0000098, + lon: prev.nav.lon + 0.0000074, + heading, + sats: clamp(Math.round(prev.nav.sats + wobble(0.4)), 11, 17), + hdop: clamp(prev.nav.hdop + wobble(0.02), 0.6, 1.4), + rssi: clamp(Math.round(prev.nav.rssi + wobble(0.9)), -78, -52), + groundSpeed: clamp(prev.nav.groundSpeed + wobble(0.25), 3, 14) + }, + altitude: { + ...prev.altitude, + relative: relativeAltitude, + msl: prev.altitude.home + relativeAltitude, + vspeed: verticalSpeed + }, + link: { + rssi: clamp(Math.round(prev.link.rssi + wobble(1.2)), -84, -54), + rate: linkRate, + loss: clamp(prev.link.loss + wobble(0.25), 0, 7.5), + latency: clamp(Math.round(prev.link.latency + wobble(3)), 18, 96), + history: [...prev.link.history.slice(-27), linkRate] + }, + flight: prev.flight, + sbc: { + ...prev.sbc, + cpu: clamp(prev.sbc.cpu + wobble(4), 18, 88), + cores: prev.sbc.cores.map((core) => clamp(core + wobble(7), 8, 97)), + memUsed: clamp(prev.sbc.memUsed + wobble(0.06), 1.8, 6.4), + temp: clamp(prev.sbc.temp + wobble(0.5), 44, 76), + uptime: prev.sbc.uptime + 1, + diskUsed: prev.sbc.diskUsed, + load: [ + clamp(prev.sbc.load[0] + wobble(0.06), 0.1, 3.2), + prev.sbc.load[1], + prev.sbc.load[2] + ] + }, + attitude: { + roll: clamp(Math.sin(p * 0.7) * 14 + wobble(1.6), -32, 32), + pitch: clamp(Math.sin(p * 0.5 + 1.1) * 9 + wobble(1.2), -24, 24), + yaw: heading + }, + pid: [ + ...prev.pid.slice(-(SAMPLES - 1)), + { + t, + roll: Math.sin(p) * 6 + wobble(1.4), + pitch: Math.sin(p * 0.8 + 1.2) * 4.5 + wobble(1.2), + yaw: Math.sin(p * 0.45 + 2.4) * 3 + wobble(0.8) + } + ], + motors: [ + ...prev.motors.slice(-(SAMPLES - 1)), + { + t, + m1: 58 + Math.sin(p * 0.9) * 5 + wobble(1.8), + m2: 61 + Math.sin(p * 0.9 + 1.6) * 5 + wobble(1.8), + m3: 56 + Math.sin(p * 0.9 + 3.1) * 5 + wobble(1.8), + m4: 63 + Math.sin(p * 0.9 + 4.7) * 5 + wobble(1.8) + } + ], + gyro: [ + ...prev.gyro.slice(-(SAMPLES - 1)), + { + t, + x: Math.sin(p * 1.4) * 22 + wobble(7), + y: Math.sin(p * 1.1 + 0.9) * 17 + wobble(7), + z: Math.sin(p * 0.7 + 2.2) * 11 + wobble(5) + } + ] + }; +} + +function fromLive(prev: Telemetry, raw: RawTelemetry): Telemetry { + const t = prev.clock + 1; + const rawCells = Array.isArray(raw.cell_voltages) ? raw.cell_voltages.filter((cell) => cell > 0.5).slice(0, 6) : []; + const cells = (rawCells.length > 0 ? rawCells : prev.battery.cells.map((cell) => cell.volts)).map((volts, i) => ({ + label: cellLabels[i] ?? `${i + 1}S`, + volts + })); + const total = num(raw.battery_voltage, cells.reduce((sum, cell) => sum + cell.volts, 0)); + const percent = clamp(Math.round(num(raw.battery_remaining, prev.battery.percent)), 0, 100); + const heading = (num(raw.heading, prev.nav.heading) + 360) % 360; + const motors = Array.isArray(raw.motor_percent) ? raw.motor_percent : []; + const lastMotors = prev.motors[prev.motors.length - 1] ?? { m1: 0, m2: 0, m3: 0, m4: 0 }; + const sbc = raw.sbc ?? {}; + const ramPercent = num(sbc.ram_percent, prev.sbc.memTotal > 0 ? (prev.sbc.memUsed / prev.sbc.memTotal) * 100 : 0); + const memTotal = num(sbc.ram_total_mb, prev.sbc.memTotal * 1024) / 1024 || prev.sbc.memTotal; + const memUsed = num(sbc.ram_used_mb, (ramPercent / 100) * memTotal * 1024) / 1024; + + return { + clock: t, + battery: { + total, + percent, + current: num(raw.battery_current, prev.battery.current), + consumed: prev.battery.consumed, + cells + }, + nav: { + lat: num(raw.latitude, prev.nav.lat), + lon: num(raw.longitude, prev.nav.lon), + heading, + sats: clamp(Math.round(num(raw.satellites, prev.nav.sats)), 0, 32), + hdop: num(raw.hdop, prev.nav.hdop), + rssi: Math.round(num(raw.rc_rssi, prev.nav.rssi)), + fix: raw.gps_fix_type ?? prev.nav.fix, + groundSpeed: num(raw.ground_speed, prev.nav.groundSpeed) + }, + altitude: { + relative: num(raw.altitude_relative, prev.altitude.relative), + msl: num(raw.altitude_msl, prev.altitude.msl), + vspeed: num(raw.climb_rate, prev.altitude.vspeed), + home: prev.altitude.home + }, + link: { + rssi: Math.round(num(raw.rc_rssi, prev.link.rssi)), + rate: clamp(num(raw.radio_link_quality, raw.connected === false ? 0 : 95), 0, 100), + loss: raw.connected === false ? 100 : 0, + latency: prev.link.latency, + history: [...prev.link.history.slice(-27), clamp(num(raw.radio_link_quality, raw.connected === false ? 0 : 95), 0, 100)] + }, + flight: { + mode: raw.flight_mode ?? prev.flight.mode, + armed: raw.armed ?? prev.flight.armed, + failsafe: raw.connected === false, + mission: raw.mission_state ?? prev.flight.mission, + wp: `${Math.round(num(raw.mission_current_seq, 0))} / ${Math.round(num(raw.mission_total_items, 0))}`, + link: raw.connected === false ? "LOST" : "OK" + }, + sbc: { + cpu: clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100), + cores: prev.sbc.cores, + memUsed, + memTotal, + temp: num(sbc.cpu_temp_c, prev.sbc.temp), + uptime: num(sbc.uptime_seconds, prev.sbc.uptime), + diskUsed: clamp(num(sbc.disk_percent, prev.sbc.diskUsed), 0, 100), + diskTotal: prev.sbc.diskTotal, + load: prev.sbc.load + }, + attitude: { + roll: num(raw.attitude_roll, prev.attitude.roll), + pitch: num(raw.attitude_pitch, prev.attitude.pitch), + yaw: num(raw.attitude_yaw, heading) + }, + pid: [ + ...prev.pid.slice(-(SAMPLES - 1)), + { + t, + roll: num(raw.error_roll, 0), + pitch: num(raw.error_pitch, 0), + yaw: num(raw.error_yaw, 0) + } + ], + motors: [ + ...prev.motors.slice(-(SAMPLES - 1)), + { + t, + m1: num(motors[0], lastMotors.m1), + m2: num(motors[1], lastMotors.m2), + m3: num(motors[2], lastMotors.m3), + m4: num(motors[3], lastMotors.m4) + } + ], + gyro: [ + ...prev.gyro.slice(-(SAMPLES - 1)), + { + t, + x: num(raw.gyro_x, 0), + y: num(raw.gyro_y, 0), + z: num(raw.gyro_z, 0) + } + ] + }; +} + +export function useTelemetry(live: boolean): Telemetry { + const [state, setState] = useState(initial); + + useEffect(() => { + if (!live) return; + const endpoint = getLiveEndpoint(); + let failedPolls = 0; + + const poll = async () => { + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 180); + try { + const response = await fetch(endpoint, { cache: "no-store", signal: controller.signal }); + if (!response.ok) throw new Error(`telemetry http ${response.status}`); + const raw = (await response.json()) as RawTelemetry; + failedPolls = 0; + setState((prev) => fromLive(prev, raw)); + } catch { + failedPolls += 1; + if (failedPolls > 3) { + setState(step); + } + } finally { + window.clearTimeout(timeout); + } + }; + + void poll(); + const id = window.setInterval(() => void poll(), TICK_MS); + return () => window.clearInterval(id); + }, [live]); + + return state; +} diff --git a/telemetry/live-web-dashboard/src/main.tsx b/telemetry/live-web-dashboard/src/main.tsx new file mode 100644 index 0000000..31af7ac --- /dev/null +++ b/telemetry/live-web-dashboard/src/main.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( + + + +); diff --git a/telemetry/live-web-dashboard/src/styles.css b/telemetry/live-web-dashboard/src/styles.css new file mode 100644 index 0000000..a08b17f --- /dev/null +++ b/telemetry/live-web-dashboard/src/styles.css @@ -0,0 +1,30 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; + width: 100%; +} + +body { + margin: 0; + background-color: #080b10; + color: #e3eaf2; + -webkit-font-smoothing: antialiased; +} + +.tabular { + font-variant-numeric: tabular-nums; +} + +.hud-grid-bg { + background-image: + linear-gradient(to right, rgba(45, 212, 191, 0.04) 1px, transparent 1px), + linear-gradient(to bottom, rgba(45, 212, 191, 0.04) 1px, transparent 1px); + background-size: 48px 48px; +} diff --git a/telemetry/live-web-dashboard/src/types/telemetry.ts b/telemetry/live-web-dashboard/src/types/telemetry.ts new file mode 100644 index 0000000..bd8ee2e --- /dev/null +++ b/telemetry/live-web-dashboard/src/types/telemetry.ts @@ -0,0 +1,109 @@ +export interface CellReading { + label: string; + volts: number; +} + +export interface BatteryState { + total: number; + percent: number; + current: number; + consumed: number; + cells: CellReading[]; +} + +export interface NavState { + lat: number; + lon: number; + heading: number; + sats: number; + hdop: number; + rssi: number; + fix: string; + groundSpeed: number; +} + +export interface AltitudeState { + relative: number; + msl: number; + vspeed: number; + home: number; +} + +export interface LinkState { + rssi: number; + rate: number; + loss: number; + latency: number; + history: number[]; +} + +export type LinkQuality = "OK" | "DEGRADED" | "LOST"; + +export interface FlightState { + mode: string; + armed: boolean; + failsafe: boolean; + mission: string; + wp: string; + link: LinkQuality; +} + +export interface SbcState { + cpu: number; + cores: number[]; + memUsed: number; + memTotal: number; + temp: number; + uptime: number; + diskUsed: number; + diskTotal: number; + load: [number, number, number]; +} + +export interface AttitudeState { + roll: number; + pitch: number; + yaw: number; +} + +export interface PidPoint { + t: number; + roll: number; + pitch: number; + yaw: number; +} + +export interface MotorPoint { + t: number; + m1: number; + m2: number; + m3: number; + m4: number; +} + +export interface GyroPoint { + t: number; + x: number; + y: number; + z: number; +} + +export interface Telemetry { + clock: number; + battery: BatteryState; + nav: NavState; + altitude: AltitudeState; + link: LinkState; + flight: FlightState; + sbc: SbcState; + attitude: AttitudeState; + pid: PidPoint[]; + motors: MotorPoint[]; + gyro: GyroPoint[]; +} + +export interface SeriesDef { + key: string; + label: string; + color: string; +} diff --git a/telemetry/live-web-dashboard/style.css b/telemetry/live-web-dashboard/style.css deleted file mode 100644 index 5b0dbc5..0000000 --- a/telemetry/live-web-dashboard/style.css +++ /dev/null @@ -1,1209 +0,0 @@ -:root { - --bg-primary: #0a0d14; - --bg-secondary: #121824; - --bg-card: #172033; - --bg-card-hover: #1e293f; - --border-color: #26354f; - --border-glow: rgba(0, 240, 255, 0.25); - - --text-main: #f0f4fc; - --text-muted: #8b9bb4; - --text-accent: #00f0ff; - - --accent-cyan: #00f0ff; - --accent-yellow: #ffd000; - --accent-magenta: #ff0077; - --accent-green: #00ff88; - --accent-red: #ff3355; - --accent-orange: #ff9900; - - --font-main: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; - --font-display: 'Orbitron', monospace; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - background-color: var(--bg-primary); - color: var(--text-main); - font-family: var(--font-main); - line-height: 1.4; - padding: 16px; - min-height: 100vh; -} - -.dashboard-container { - max-width: 1600px; - margin: 0 auto; - display: flex; - flex-direction: column; - gap: 16px; -} - -.top-header { - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - padding: 14px 20px; - border-radius: 10px; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); -} - -.header-left { - display: flex; - align-items: center; - gap: 14px; -} - -.logo-icon { - font-size: 28px; - filter: drop-shadow(0 0 8px var(--accent-cyan)); -} - -.system-title { - font-family: var(--font-display); - font-size: 18px; - font-weight: 900; - letter-spacing: 1.5px; - color: var(--accent-cyan); -} - -.system-sub { - font-size: 11px; - color: var(--text-muted); - letter-spacing: 0.5px; -} - -.header-status-group { - display: flex; - align-items: center; - gap: 10px; -} - -.status-badge { - font-family: var(--font-display); - font-size: 11px; - font-weight: 700; - padding: 5px 12px; - border-radius: 6px; - background: #2a3447; - color: var(--text-muted); - border: 1px solid var(--border-color); - letter-spacing: 1px; -} - -.status-badge.armed { - background: rgba(255, 51, 85, 0.2); - color: var(--accent-red); - border-color: var(--accent-red); - box-shadow: 0 0 10px rgba(255, 51, 85, 0.4); -} - -.status-badge.disarmed { - background: rgba(0, 255, 136, 0.15); - color: var(--accent-green); - border-color: var(--accent-green); -} - -.mode-badge { - background: rgba(0, 240, 255, 0.15); - color: var(--accent-cyan); - border-color: var(--accent-cyan); -} - -.mission-badge { - background: rgba(255, 208, 0, 0.15); - color: var(--accent-yellow); - border-color: var(--accent-yellow); -} - -.status-badge.lost { - background: rgba(255, 51, 85, 0.25); - color: var(--accent-red); - border-color: var(--accent-red); - box-shadow: 0 0 10px rgba(255, 51, 85, 0.4); - animation: fast-pulse 1.0s infinite; -} - -.conn-indicator { - display: flex; - align-items: center; - gap: 6px; - font-size: 11px; - font-weight: 600; - padding: 5px 10px; - border-radius: 6px; - background: var(--bg-card); - border: 1px solid var(--border-color); - transition: all 0.3s ease; -} - -.conn-indicator .dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--accent-green); - box-shadow: 0 0 8px var(--accent-green); - animation: pulse 1.5s infinite; -} - -.conn-indicator.lost { - border-color: var(--accent-red); - color: var(--accent-red); - background: rgba(255, 51, 85, 0.12); -} - -.conn-indicator.lost .dot { - background: var(--accent-red); - box-shadow: 0 0 10px var(--accent-red); - animation: fast-pulse 0.6s infinite; -} - -@keyframes pulse { - 0% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.4; transform: scale(0.85); } - 100% { opacity: 1; transform: scale(1); } -} - -@keyframes fast-pulse { - 0% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.3; transform: scale(0.85); } - 100% { opacity: 1; transform: scale(1); } -} - -.card { - background: var(--bg-card); - border: 1px solid var(--border-color); - border-radius: 10px; - padding: 16px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.25); - position: relative; - overflow: hidden; -} - -.card:hover { - border-color: var(--border-glow); -} - -.card-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; -} - -.flex-between { - justify-content: space-between; -} - -.header-title-group { - display: flex; - align-items: center; - gap: 8px; -} - -.card-icon { - font-size: 15px; -} - -.card-label { - font-size: 11px; - font-weight: 700; - letter-spacing: 1px; - color: var(--text-muted); - text-transform: uppercase; -} - -.card-title { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.8px; - color: var(--text-main); - text-transform: uppercase; -} - -.kpi-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 14px; -} - -.kpi-card { - display: flex; - flex-direction: column; -} - -.kpi-main-val { - font-family: var(--font-display); - font-size: 32px; - font-weight: 900; - color: #ffffff; - margin-bottom: 4px; - display: flex; - align-items: baseline; - gap: 4px; -} - -.kpi-main-val .unit { - font-size: 16px; - color: var(--accent-cyan); - font-weight: 700; -} - -.card-accent-text { - font-size: 18px; - margin-left: 8px; - color: var(--accent-yellow); - font-weight: 700; -} - -.kpi-sub-row { - display: flex; - justify-content: space-between; - font-size: 12px; - color: var(--text-muted); - margin-bottom: 8px; -} - -.kpi-sub-row b { - color: var(--text-main); -} - -.progress-bar-bg { - width: 100%; - height: 6px; - background: #101622; - border-radius: 4px; - overflow: hidden; - margin-top: auto; -} - -.progress-bar-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); - transition: width 0.3s ease; -} - -.progress-bar-fill.rssi-fill { - background: linear-gradient(90deg, var(--accent-yellow), var(--accent-green)); -} - -.progress-text { - font-size: 10px; - color: var(--text-muted); - text-align: right; - margin-top: 4px; -} - -.card-footer-tag { - font-size: 10px; - font-weight: 700; - color: var(--accent-cyan); - letter-spacing: 0.5px; - margin-top: auto; -} - -.main-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 16px; -} - -@media (max-width: 1024px) { - .main-grid { - grid-template-columns: 1fr; - } -} - -.map-card { - display: flex; - flex-direction: column; - min-height: 420px; -} - -.map-view { - flex: 1; - min-height: 320px; - border-radius: 8px; - border: 1px solid var(--border-color); - background: #080c14; -} - -.gps-meta-badges { - display: flex; - gap: 6px; -} - -.gps-badge { - font-size: 10px; - font-weight: 700; - padding: 3px 8px; - border-radius: 4px; - background: #202b3f; - color: var(--accent-cyan); - border: 1px solid var(--border-color); -} - -.map-bottom-bar { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 10px; - font-size: 12px; -} - -.coord-display { - display: flex; - gap: 16px; - font-family: var(--font-display); - font-size: 11px; -} - -.coord-display b { - color: var(--accent-cyan); -} - -.map-controls { - display: flex; - gap: 8px; -} - -.btn { - font-family: var(--font-main); - font-size: 11px; - font-weight: 600; - padding: 6px 14px; - border-radius: 6px; - cursor: pointer; - border: 1px solid var(--border-color); - transition: all 0.2s ease; -} - -.btn-secondary { - background: #202b3f; - color: var(--text-main); -} - -.btn-secondary:hover { - background: #2a3a56; - border-color: var(--accent-cyan); -} - -.btn-primary { - background: rgba(0, 240, 255, 0.2); - color: var(--accent-cyan); - border-color: var(--accent-cyan); -} - -.btn-primary:hover { - background: rgba(0, 240, 255, 0.4); -} - -.chart-card { - display: flex; - flex-direction: column; -} - -.chart-legend { - display: flex; - gap: 12px; - font-size: 11px; -} - -.legend-item { - display: flex; - align-items: center; - gap: 5px; -} - -.legend-color { - width: 10px; - height: 10px; - border-radius: 2px; -} - -.legend-color.cyan { background: var(--accent-cyan); } -.legend-color.yellow { background: var(--accent-yellow); } -.legend-color.magenta { background: var(--accent-magenta); } - -.canvas-container { - flex: 1; - position: relative; - min-height: 250px; - width: 100%; -} - -.canvas-container.small-chart { - min-height: 160px; -} - -.accel-footer { - display: flex; - justify-content: space-around; - margin-top: 10px; - font-size: 12px; - padding: 8px; - background: #111827; - border-radius: 6px; -} - -.accel-footer b { - color: var(--accent-cyan); - font-family: var(--font-display); -} - -.cell-delta-tag { - font-size: 11px; - font-weight: 700; - padding: 3px 8px; - border-radius: 4px; - background: rgba(0, 255, 136, 0.15); - color: var(--accent-green); - border: 1px solid var(--accent-green); -} - -.cells-grid { - display: grid; - grid-template-columns: repeat(6, 1fr); - gap: 8px; -} - -@media (max-width: 768px) { - .cells-grid { - grid-template-columns: repeat(3, 1fr); - } -} - -.cell-box { - background: #111827; - border: 1px solid var(--border-color); - padding: 10px; - border-radius: 6px; - text-align: center; -} - -.cell-label { - font-size: 10px; - color: var(--text-muted); - font-weight: 700; -} - -.cell-voltage { - font-family: var(--font-display); - font-size: 15px; - font-weight: 700; - color: #fff; - margin: 4px 0; -} - -.cell-bar-bg { - width: 100%; - height: 4px; - background: #202b3f; - border-radius: 2px; - overflow: hidden; -} - -.cell-bar-fill { - height: 100%; - background: var(--accent-green); -} - -.pid-errors { - display: flex; - gap: 12px; - font-size: 12px; -} - -.pid-errors b { - color: var(--accent-yellow); - font-family: var(--font-display); -} - -.motors-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 12px; - height: 160px; - align-items: flex-end; -} - -.motor-col { - display: flex; - flex-direction: column; - align-items: center; - height: 100%; -} - -.motor-val { - font-family: var(--font-display); - font-size: 13px; - font-weight: 700; - color: var(--accent-cyan); - margin-bottom: 4px; -} - -.motor-bar-wrapper { - flex: 1; - width: 28px; - background: #101622; - border-radius: 4px; - display: flex; - align-items: flex-end; - border: 1px solid var(--border-color); - overflow: hidden; -} - -.motor-bar-fill { - width: 100%; - background: linear-gradient(0deg, var(--accent-cyan), var(--accent-magenta)); - transition: height 0.15s ease; -} - -.motor-pwm { - font-size: 10px; - color: var(--text-muted); - margin-top: 4px; -} - -.motor-name { - font-size: 11px; - font-weight: 700; - color: var(--text-main); - margin-top: 2px; -} - -.sbc-stats-list { - display: flex; - flex-direction: column; - gap: 8px; -} - -.sbc-item { - display: flex; - justify-content: space-between; - font-size: 12px; - padding: 6px 10px; - background: #111827; - border-radius: 6px; - border: 1px solid var(--border-color); -} - -.sbc-label { - color: var(--text-muted); -} - -.sbc-val { - font-weight: 600; - color: #ffffff; -} - -.badge-ok { - font-size: 9px; - font-weight: 700; - padding: 2px 6px; - border-radius: 3px; - background: rgba(0, 255, 136, 0.2); - color: var(--accent-green); - margin-left: 6px; -} - -.modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(4px); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; -} - -.modal-overlay.hidden { - display: none; -} - -.modal-card { - background: var(--bg-card); - border: 1px solid var(--accent-cyan); - box-shadow: 0 0 30px rgba(0, 240, 255, 0.2); - border-radius: 12px; - width: 480px; - max-width: 90vw; - padding: 20px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.modal-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.modal-header h2 { - font-size: 15px; - font-family: var(--font-display); - color: var(--accent-cyan); -} - -.btn-close { - background: none; - border: none; - color: var(--text-muted); - font-size: 22px; - cursor: pointer; -} - -.modal-body { - display: flex; - flex-direction: column; - gap: 12px; -} - -.form-group { - display: flex; - flex-direction: column; - gap: 4px; -} - -.form-group label { - font-size: 11px; - color: var(--text-muted); - font-weight: 600; -} - -.form-group input, .form-group select { - background: #101622; - border: 1px solid var(--border-color); - padding: 8px 12px; - border-radius: 6px; - color: #fff; - font-family: var(--font-main); - font-size: 13px; -} - -.form-group input:focus, .form-group select:focus { - border-color: var(--accent-cyan); - outline: none; -} - -.modal-footer { - display: flex; - justify-content: flex-end; - gap: 10px; -} - -.alert-box { - padding: 8px 12px; - border-radius: 6px; - font-size: 12px; - font-weight: 600; -} - -.alert-box.success { - background: rgba(0, 255, 136, 0.2); - color: var(--accent-green); - border: 1px solid var(--accent-green); -} - -.alert-box.error { - background: rgba(255, 51, 85, 0.2); - color: var(--accent-red); - border: 1px solid var(--accent-red); -} - -.hidden { - display: none !important; -} - -.leaflet-control-layers { - background: rgba(18, 24, 36, 0.92) !important; - backdrop-filter: blur(10px); - border: 1px solid var(--border-color) !important; - border-radius: 8px !important; - color: #fff !important; - font-family: var(--font-main) !important; - font-size: 11px !important; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6) !important; - padding: 8px 12px !important; -} - -.leaflet-control-layers-toggle { - filter: invert(1) brightness(1.5); -} - -.leaflet-control-layers label { - color: var(--text-main); - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - margin: 4px 0; - transition: color 0.2s; -} - -.leaflet-control-layers label:hover { - color: var(--accent-cyan); -} - -.leaflet-control-layers input[type="radio"] { - accent-color: var(--accent-cyan); - cursor: pointer; -} - -.map-view-wrapper { - position: relative; - width: 100%; - height: 400px; -} - -.map-view-wrapper .map-view { - width: 100%; - height: 100%; -} - -.mission-map-hud { - position: absolute; - top: 12px; - right: 12px; - z-index: 1000; - background: rgba(18, 24, 36, 0.88); - backdrop-filter: blur(10px); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 10px 14px; - min-width: 220px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); - font-size: 11px; -} - -.hud-header { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 8px; - padding-bottom: 6px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); -} - -.hud-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--text-muted); -} - -.hud-dot.active { - background: var(--accent-green); - box-shadow: 0 0 8px var(--accent-green); - animation: fast-pulse 1.2s infinite; -} - -.hud-dot.paused { - background: var(--accent-yellow); -} - -.hud-title { - font-family: var(--font-display); - font-weight: 700; - letter-spacing: 0.8px; - color: var(--accent-cyan); -} - -.hud-body { - display: flex; - flex-direction: column; - gap: 6px; -} - -.hud-row { - display: flex; - justify-content: space-between; - color: var(--text-muted); -} - -.hud-row b { - color: #fff; - font-family: var(--font-display); -} - -.hud-progress-bg { - width: 100%; - height: 6px; - background: #0a0d14; - border-radius: 3px; - overflow: hidden; - margin: 4px 0; -} - -.hud-progress-fill { - height: 100%; - background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green)); - transition: width 0.3s ease; -} - -.hud-quick-actions { - display: flex; - gap: 6px; - margin-top: 6px; -} - -.hud-btn { - flex: 1; - padding: 4px 6px; - border-radius: 4px; - border: 1px solid var(--border-color); - font-size: 10px; - font-weight: 700; - cursor: pointer; - background: var(--bg-card); - color: #fff; - transition: all 0.2s; -} - -.hud-btn:hover { - filter: brightness(1.2); -} - -.hud-btn-start { - border-color: var(--accent-green); - color: var(--accent-green); -} - -.hud-btn-pause { - border-color: var(--accent-yellow); - color: var(--accent-yellow); -} - -.hud-btn-rtl { - border-color: var(--accent-red); - color: var(--accent-red); -} - -.modal-card.modal-large { - max-width: 860px; - width: 95%; - max-height: 90vh; - overflow-y: auto; -} - -.modal-title-group { - display: flex; - align-items: center; - gap: 12px; -} - -.modal-icon { - font-size: 24px; -} - -.modal-sub { - font-size: 11px; - color: var(--text-muted); -} - -.modal-grid-layout { - display: grid; - grid-template-columns: 1.2fr 1fr; - gap: 20px; - padding: 16px 0; -} - -@media (max-width: 768px) { - .modal-grid-layout { - grid-template-columns: 1fr; - } -} - -.section-label { - font-family: var(--font-display); - font-size: 10px; - font-weight: 700; - letter-spacing: 1.2px; - color: var(--accent-cyan); - margin-top: 10px; - margin-bottom: 6px; - border-bottom: 1px solid rgba(0, 240, 255, 0.2); - padding-bottom: 3px; -} - -.pattern-selector { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; - margin-bottom: 10px; -} - -.pattern-option { - cursor: pointer; -} - -.pattern-option input[type="radio"] { - display: none; -} - -.pattern-card { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - padding: 8px 6px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 6px; - transition: all 0.2s ease; -} - -.pattern-icon { - font-size: 18px; - margin-bottom: 4px; -} - -.pattern-name { - font-size: 11px; - font-weight: 700; - color: #fff; -} - -.pattern-desc { - font-size: 9px; - color: var(--text-muted); -} - -.pattern-option input:checked + .pattern-card { - border-color: var(--accent-cyan); - background: rgba(0, 240, 255, 0.1); - box-shadow: 0 0 10px rgba(0, 240, 255, 0.3); -} - -.radio-pill-group { - display: flex; - gap: 8px; - background: var(--bg-primary); - padding: 4px; - border-radius: 6px; - border: 1px solid var(--border-color); -} - -.radio-pill-group label { - flex: 1; - text-align: center; - font-size: 11px; - padding: 6px 4px; - border-radius: 4px; - cursor: pointer; - color: var(--text-muted); - transition: all 0.2s; -} - -.radio-pill-group input[type="radio"] { - display: none; -} - -.radio-pill-group label:has(input:checked) { - background: var(--accent-cyan); - color: #000; - font-weight: 700; -} - -.form-row-2 { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; -} - -.form-row-3 { - display: grid; - grid-template-columns: 1fr 1fr 1.2fr; - gap: 8px; -} - -.slider-header { - display: flex; - justify-content: space-between; - align-items: center; -} - -.slider-val-badge { - font-family: var(--font-display); - font-size: 12px; - color: var(--accent-yellow); -} - -.styled-slider { - width: 100%; - accent-color: var(--accent-cyan); - cursor: pointer; -} - -.preview-metrics-card { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 8px; - padding: 12px; - margin-bottom: 12px; -} - -.preview-stat-item { - display: flex; - flex-direction: column; - gap: 2px; -} - -.stat-label { - font-size: 9px; - font-weight: 700; - color: var(--text-muted); - letter-spacing: 0.5px; -} - -.stat-val { - font-family: var(--font-display); - font-size: 14px; - font-weight: 700; - color: var(--accent-cyan); -} - -.text-green { - color: var(--accent-green) !important; -} - -.takeoff-info-alert { - display: flex; - gap: 10px; - background: rgba(0, 240, 255, 0.08); - border: 1px solid rgba(0, 240, 255, 0.3); - border-radius: 6px; - padding: 10px; - font-size: 11px; - line-height: 1.4; - margin-bottom: 14px; -} - -.takeoff-info-alert code { - background: rgba(0, 0, 0, 0.5); - padding: 1px 4px; - border-radius: 3px; - color: var(--accent-yellow); - font-family: monospace; -} - -.modal-actions-container { - display: flex; - flex-direction: column; - gap: 8px; -} - -.btn-full { - width: 100%; - justify-content: center; - padding: 10px; - font-weight: 700; -} - -.modal-sub-actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 6px; -} - -.btn-success { - background: rgba(0, 255, 136, 0.2); - color: var(--accent-green); - border: 1px solid var(--accent-green); -} - -.btn-success:hover { - background: var(--accent-green); - color: #000; -} - -.btn-warning { - background: rgba(255, 208, 0, 0.2); - color: var(--accent-yellow); - border: 1px solid var(--accent-yellow); -} - -.btn-warning:hover { - background: var(--accent-yellow); - color: #000; -} - -.btn-danger { - background: rgba(255, 51, 85, 0.2); - color: var(--accent-red); - border: 1px solid var(--accent-red); -} - -.btn-danger:hover { - background: var(--accent-red); - color: #fff; -} - -.wp-marker-icon { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - background: var(--accent-cyan); - color: #000; - font-family: var(--font-display); - font-size: 10px; - font-weight: 900; - border-radius: 50%; - border: 2px solid #fff; - box-shadow: 0 0 8px rgba(0, 240, 255, 0.8); -} - -.wp-marker-icon.active { - background: var(--accent-yellow); - color: #000; - transform: scale(1.3); - box-shadow: 0 0 14px var(--accent-yellow); - animation: fast-pulse 0.8s infinite; -} - -.wp-marker-icon.completed { - background: var(--accent-green); - opacity: 0.6; -} - -.takeoff-marker-icon { - display: flex; - align-items: center; - justify-content: center; - width: 26px; - height: 26px; - background: #00ff88; - color: #000; - font-size: 14px; - font-weight: 900; - border-radius: 4px; - transform: rotate(45deg); - border: 2px solid #fff; - box-shadow: 0 0 10px #00ff88; -} - -.takeoff-marker-icon span { - transform: rotate(-45deg); -} - -.center-marker-icon { - font-size: 24px; - filter: drop-shadow(0 0 6px #ff0077); -} diff --git a/telemetry/live-web-dashboard/tailwind.config.js b/telemetry/live-web-dashboard/tailwind.config.js new file mode 100644 index 0000000..7affacc --- /dev/null +++ b/telemetry/live-web-dashboard/tailwind.config.js @@ -0,0 +1,36 @@ +export default { + content: ["./index.html", "./src/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + hud: { + bg: "#080b10", + panel: "#0e131a", + raise: "#141b24", + line: "#1d2733", + edge: "#27333f", + text: "#e3eaf2", + dim: "#8b9aab", + faint: "#5c6a7a" + }, + sig: { + cyan: "#2dd4bf", + blue: "#4c8dff", + amber: "#f5a524", + red: "#f2555a", + green: "#3fcf8e", + violet: "#9b8cff", + pink: "#f472b6" + } + }, + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + mono: ["JetBrains Mono", "ui-monospace", "monospace"] + }, + fontSize: { + "2xs": ["10px", "12px"] + } + } + }, + plugins: [] +}; diff --git a/telemetry/live-web-dashboard/tsconfig.json b/telemetry/live-web-dashboard/tsconfig.json new file mode 100644 index 0000000..91c7f7e --- /dev/null +++ b/telemetry/live-web-dashboard/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "composite": true, + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/telemetry/live-web-dashboard/tsconfig.node.json b/telemetry/live-web-dashboard/tsconfig.node.json new file mode 100644 index 0000000..7065ca9 --- /dev/null +++ b/telemetry/live-web-dashboard/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/telemetry/live-web-dashboard/vite.config.ts b/telemetry/live-web-dashboard/vite.config.ts new file mode 100644 index 0000000..4f6a825 --- /dev/null +++ b/telemetry/live-web-dashboard/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { + outDir: "dist", + emptyOutDir: true + } +}); From 2b93a3abe7847a4722ce57f1223535b655065956 Mon Sep 17 00:00:00 2001 From: TheLostLeo Date: Mon, 21 Sep 2026 22:20:55 +0530 Subject: [PATCH 2/5] Update telemetry relay dashboard --- .gitignore | 2 + telemetry/README.md | 46 +- .../handheld-nrf-monitor/esp-code/esp.ino | 686 ++++++++++++++++-- .../handheld-nrf-monitor/pi-code/__init__.py | 3 + .../handheld-nrf-monitor/pi-code/main.py | 60 +- .../pi-code/{modules => }/mavlink_manager.py | 528 ++++++++------ .../pi-code/modules/__init__.py | 1 - .../pi-code/modules/grid_search_module.py | 446 ------------ .../pi-code/modules/radio_tx_module.py | 210 ------ .../pi-code/modules/web_dashboard_module.py | 269 ------- .../pi-code/radio_tx_module.py | 351 +++++++++ .../pi-code/{modules => }/sbc_monitor.py | 27 +- telemetry/live-web-dashboard/.dockerignore | 4 + telemetry/live-web-dashboard/src/App.tsx | 152 +++- .../src/components/LinkCard.tsx | 4 +- .../src/components/SbcStatusCard.tsx | 33 +- .../src/hooks/useTelemetry.ts | 396 +++++----- .../live-web-dashboard/src/types/telemetry.ts | 16 +- 18 files changed, 1765 insertions(+), 1469 deletions(-) create mode 100644 telemetry/handheld-nrf-monitor/pi-code/__init__.py rename telemetry/handheld-nrf-monitor/pi-code/{modules => }/mavlink_manager.py (58%) delete mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py delete mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py delete mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/radio_tx_module.py delete mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py rename telemetry/handheld-nrf-monitor/pi-code/{modules => }/sbc_monitor.py (80%) create mode 100644 telemetry/live-web-dashboard/.dockerignore diff --git a/.gitignore b/.gitignore index f4e2c6d..c440836 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ dist/ *.tsbuildinfo +__pycache__/ +*.py[cod] diff --git a/telemetry/README.md b/telemetry/README.md index 76050cc..6c2fbb5 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -5,39 +5,57 @@ Telemetry contains the drone companion-computer code, handheld ground receiver f ## Directory Layout - `handheld-nrf-monitor/` - Raspberry Pi and ESP32 code for collecting Pixhawk telemetry and sending the compact NRF24L01+ stream to the handheld receiver. -- `live-web-dashboard/` - Static web dashboard files served by the Raspberry Pi companion computer. +- `live-web-dashboard/` - Vite React dashboard source. Build it before serving from the Raspberry Pi, or run it with Docker/Vite on a laptop. ## File Guide ### `handheld-nrf-monitor/esp_code/esp.ino` ESP32 receiver firmware for the handheld unit. It listens for NRF24L01+ telemetry packets, validates the packet checksum, decodes battery/GPS/altitude/RSSI values, and renders them on the SH1106 OLED. -### `handheld-nrf-monitor/pi_code/main.py` +### `handheld-nrf-monitor/pi-code/main.py` Entry point for the Raspberry Pi companion server. It starts the MAVLink manager, SBC monitor, web dashboard server, NRF24L01+ transmitter, and autonomous grid-search module. -### `handheld-nrf-monitor/pi_code/modules/mavlink_manager.py` +### `handheld-nrf-monitor/pi-code/modules/mavlink_manager.py` Manages the Pixhawk MAVLink connection or simulator, keeps the shared telemetry state, sends vehicle commands, uploads missions, and tracks mission progress. -### `handheld-nrf-monitor/pi_code/modules/radio_tx_module.py` +### `handheld-nrf-monitor/pi-code/modules/radio_tx_module.py` Packs selected telemetry into the 20-byte NRF24L01+ payload and broadcasts it to the ESP32 handheld receiver. -### `handheld-nrf-monitor/pi_code/modules/web_dashboard_module.py` -Runs the HTTP/WebSocket server, serves the dashboard from `live-web-dashboard/`, exposes telemetry APIs, and forwards mission-control requests to the grid-search module. +### `handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py` +Runs the HTTP/WebSocket server, serves the built dashboard from `live-web-dashboard/dist/`, exposes telemetry APIs, and forwards mission-control requests to the grid-search module. If `dist/` is missing, it returns a build-required error instead of serving Vite source files. -### `handheld-nrf-monitor/pi_code/modules/grid_search_module.py` +### `handheld-nrf-monitor/pi-code/modules/grid_search_module.py` Builds autonomous search missions, including grid, spiral, and sector patterns, and converts planned routes into MAVLink mission items. -### `handheld-nrf-monitor/pi_code/modules/sbc_monitor.py` +### `handheld-nrf-monitor/pi-code/modules/sbc_monitor.py` Reads Raspberry Pi CPU, memory, disk, thermal, and uptime metrics for dashboard telemetry. -### `handheld-nrf-monitor/pi_code/modules/__init__.py` +### `handheld-nrf-monitor/pi-code/modules/__init__.py` Marks the `modules` directory as a Python package. ### `live-web-dashboard/index.html` -Dashboard markup for live telemetry, map tracking, mission planning, battery status, charts, motor outputs, and SBC diagnostics. +Vite entrypoint used during development and build. The Pi server should not serve this source file directly in production. -### `live-web-dashboard/style.css` -Dashboard styling for the mission-control layout, map panel, cards, modal controls, charts, and telemetry status states. +### `live-web-dashboard/src/` +React and TypeScript dashboard source for telemetry panels, charts, attitude view, and ESP JSON polling. -### `live-web-dashboard/app.js` -Dashboard client logic for WebSocket telemetry updates, Leaflet map rendering, chart updates, route previews, and mission-control API calls. +### `live-web-dashboard/package.json` +Defines the Vite development, build, and preview commands for the dashboard. + +### `live-web-dashboard/dist/` +Generated production dashboard output from `npm run build`. This directory is ignored by Git and must be created on the deployment target before the Pi web server can host the dashboard. + +## Building the Web Dashboard for Pi Hosting + +From `telemetry/live-web-dashboard`: + +```bash +npm ci +npm run build +``` + +Then start the Raspberry Pi companion server. It will serve `telemetry/live-web-dashboard/dist/index.html` and the generated `dist/assets/*` files. For local development, run Vite instead: + +```bash +npm run dev -- --host 0.0.0.0 +``` diff --git a/telemetry/handheld-nrf-monitor/esp-code/esp.ino b/telemetry/handheld-nrf-monitor/esp-code/esp.ino index 03620b7..a7e689c 100644 --- a/telemetry/handheld-nrf-monitor/esp-code/esp.ino +++ b/telemetry/handheld-nrf-monitor/esp-code/esp.ino @@ -1,9 +1,46 @@ +/* + * ====================================================================================== + * Project: ESP32 Drone Telemetry Receiver (Ground Unit) + * File: esp/esp.ino + * + * Hardware Modules: + * - ESP32 Development Board (ESP-WROOM-32) + * - 1.3-inch I2C OLED Display (JMD1.3A / SH1106 Driver, 128x64) + * - NRF24L01+ PA + LNA Transceiver Module (with external antenna) + * - HW-200 3.3V Base Adapter Board (powered via ESP32 VIN/5V) + * + * Hardware Pinout Connections: + * [NRF24L01+ via HW-200 Adapter -> ESP32 Hardware VSPI] + * - VCC -> ESP32 VIN (5V) [Regulated to stable 3.3V by HW-200 AMS1117] + * - GND -> ESP32 GND + * - CE -> GPIO 4 + * - CSN -> GPIO 5 + * - SCK -> GPIO 18 (VSPI SCK) + * - MOSI -> GPIO 23 (VSPI MOSI) + * - MISO -> GPIO 19 (VSPI MISO) + * + * [1.3" SH1106 OLED Display -> ESP32 Hardware I2C] + * - VCC -> ESP32 3.3V (or 5V) + * - GND -> ESP32 GND + * - SCL -> GPIO 22 (I2C Clock) + * - SDA -> GPIO 21 (I2C Data) + * + * Serial Monitor Baud Rate: 115200 + * ====================================================================================== + */ + #include #include #include +#include +#include +#include #include #include +// ====================================================================================== +// PIN DEFINITIONS +// ====================================================================================== #define PIN_NRF_CE 4 #define PIN_NRF_CSN 5 #define PIN_NRF_SCK 18 @@ -13,44 +50,194 @@ #define PIN_OLED_SDA 21 #define PIN_OLED_SCL 22 +// ====================================================================================== +// TELEMETRY PACKET STRUCTURE (20 Bytes Packed Binary Struct) +// ====================================================================================== #define PACKET_MAGIC 0xAA +#define FRAME_BASIC 0 +#define FRAME_POWER_SBC 1 +#define FRAME_ATTITUDE 2 +#define FRAME_MOTORS 3 + +const char* WIFI_STA_SSID = "gamma"; +const char* WIFI_STA_PASSWORD = "gammared"; +const char* WIFI_HOSTNAME = "drone-esp32"; +const char* WIFI_FALLBACK_AP_SSID = "DroneTelemetryESP32"; +const char* WIFI_FALLBACK_AP_PASSWORD = "drone12345"; +const uint16_t HTTP_PORT = 80; struct __attribute__((packed)) TelemetryPacket { - uint8_t magic; - uint8_t seq; + uint8_t magic; // Byte 0: 0xAA Sync Header + uint8_t seq; // Byte 1: Packet sequence counter (0-255) + uint16_t bat_mv; // Bytes 2-3: Battery Voltage in millivolts (e.g. 12580 = 12.58V) + int16_t rssi; // Bytes 4-5: FlySky Receiver RSSI (e.g. 85 % or -65 dBm) + int32_t alt_cm; // Bytes 6-9: Altitude in centimeters (e.g. 4520 = 45.20m) + int32_t lat_e7; // Bytes 10-13: Latitude * 10^7 (e.g. 123456780 = 12.3456780 deg) + int32_t lon_e7; // Bytes 14-17: Longitude * 10^7 (e.g. 771234560 = 77.1234560 deg) + uint8_t satellites; // Byte 18: GPS Satellite count (e.g. 14) + uint8_t checksum; // Byte 19: 8-bit XOR Checksum +}; + +struct __attribute__((packed)) BasicFrame { + uint8_t magic; + uint8_t seq; + uint8_t frameType; + uint8_t flags; uint16_t bat_mv; - int16_t rssi; - int32_t alt_cm; - int32_t lat_e7; - int32_t lon_e7; - uint8_t satellites; - uint8_t checksum; + int16_t rssi; + int32_t alt_cm; + int32_t lat_e7; + int32_t lon_e7; + uint8_t satellites; + uint16_t heading_cd; + uint8_t battery_remaining; + uint8_t gps_fix; + uint8_t flight_mode; + uint8_t system_status; + uint8_t link_quality; + uint8_t mission_progress; + uint8_t cell_count; + uint8_t pad; + uint8_t checksum; +}; + +struct __attribute__((packed)) PowerSbcFrame { + uint8_t magic; + uint8_t seq; + uint8_t frameType; + uint8_t flags; + int16_t battery_current_ca; + uint16_t cell_mv[6]; + uint16_t cell_delta_mv; + uint8_t cpu_load_percent; + int16_t cpu_temp_dc; + uint8_t ram_percent; + uint8_t disk_percent; + uint16_t uptime_minutes; + uint16_t reserved; + uint8_t checksum; }; -U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE); +struct __attribute__((packed)) AttitudeFrame { + uint8_t magic; + uint8_t seq; + uint8_t frameType; + uint8_t flags; + int16_t roll_cd; + int16_t pitch_cd; + uint16_t yaw_cd; + int16_t target_roll_cd; + int16_t target_pitch_cd; + uint16_t target_yaw_cd; + int16_t error_roll_cd; + int16_t error_pitch_cd; + int16_t gyro_x_cdps; + int16_t gyro_y_cdps; + int16_t gyro_z_cdps; + int16_t climb_cms; + int16_t altitude_msl_dm; + uint8_t pad; + uint8_t checksum; +}; + +struct __attribute__((packed)) MotorsFrame { + uint8_t magic; + uint8_t seq; + uint8_t frameType; + uint8_t flags; + uint8_t motor_percent[4]; + uint16_t motor_pwm[4]; + uint16_t mission_current_seq; + uint16_t mission_total_items; + uint16_t dist_to_target_wp_dm; + uint16_t ground_speed_cms; + int16_t accel_x_centi; + int16_t accel_y_centi; + int16_t accel_z_centi; + uint8_t mission_state; + uint8_t checksum; +}; +// ====================================================================================== +// DISPLAY & RADIO INSTANTIATION +// ====================================================================================== +// SH1106 128x64 Full Buffer Hardware I2C (Address 0x3C) +U8G2_SH1106_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ U8X8_PIN_NONE); + +// NRF24L01 Radio on VSPI (CE=4, CSN=5) RF24 radio(PIN_NRF_CE, PIN_NRF_CSN); +WebServer server(HTTP_PORT); +// Radio Configuration const byte rfAddresses[][6] = {"1Node", "2Node"}; -const uint8_t RF_CHANNEL = 90; - -float g_batVoltage = 0.0; -int16_t g_rssiVal = 0; -float g_altitude = 0.0; -double g_latitude = 0.0; -double g_longitude = 0.0; -uint8_t g_satellites = 0; -uint8_t g_lastSeq = 0; +const uint8_t RF_CHANNEL = 90; // 2.490 GHz + +// ====================================================================================== +// STATE VARIABLES +// ====================================================================================== +float g_batVoltage = 0.0; // Volts +float g_batteryCurrent = 0.0; +uint8_t g_batteryRemaining = 0; +float g_cellVoltages[6] = {0, 0, 0, 0, 0, 0}; +uint16_t g_cellDeltaMv = 0; +uint8_t g_cellCount = 0; +int16_t g_rssiVal = 0; // % +uint8_t g_linkQuality = 0; +float g_altitude = 0.0; // Meters +float g_altitudeMsl = 0.0; +float g_climbRate = 0.0; +double g_latitude = 0.0; // Degrees +double g_longitude = 0.0; // Degrees +uint8_t g_satellites = 0; // Count +uint8_t g_gpsFix = 0; +float g_heading = 0.0; +float g_groundSpeed = 0.0; +uint8_t g_lastSeq = 0; // Sequence +uint8_t g_flightMode = 0; +uint8_t g_systemStatus = 4; +uint8_t g_missionState = 0; +uint8_t g_missionProgress = 0; +uint16_t g_missionCurrentSeq = 0; +uint16_t g_missionTotalItems = 0; +bool g_armed = false; +bool g_piMavlinkConnected = false; +float g_roll = 0.0; +float g_pitch = 0.0; +float g_yaw = 0.0; +float g_targetRoll = 0.0; +float g_targetPitch = 0.0; +float g_targetYaw = 0.0; +float g_errorRoll = 0.0; +float g_errorPitch = 0.0; +float g_gyroX = 0.0; +float g_gyroY = 0.0; +float g_gyroZ = 0.0; +float g_accelX = 0.0; +float g_accelY = 0.0; +float g_accelZ = 0.0; +uint8_t g_motorPercent[4] = {0, 0, 0, 0}; +uint16_t g_motorPwm[4] = {1000, 1000, 1000, 1000}; +uint8_t g_cpuLoad = 0; +float g_cpuTemp = 0.0; +uint8_t g_ramPercent = 0; +uint8_t g_diskPercent = 0; +uint32_t g_sbcUptime = 0; bool g_hasReceivedData = false; bool g_heartbeatState = false; bool g_radioHardwareOk = false; +bool g_wifiApMode = false; +String g_wifiIpText = "0.0.0.0"; unsigned long g_lastPacketTime = 0; -const unsigned long DISCONNECT_TIMEOUT_MS = 10000; +const unsigned long DISCONNECT_TIMEOUT_MS = 10000; // 10 seconds timeout +// Display Refresh Timer unsigned long g_lastDisplayRefresh = 0; -const unsigned long DISPLAY_REFRESH_INTERVAL_MS = 60; +const unsigned long DISPLAY_REFRESH_INTERVAL_MS = 60; // ~16 FPS +// ====================================================================================== +// CHECKSUM HELPER +// ====================================================================================== uint8_t calculateChecksum(const uint8_t* buffer, size_t length) { uint8_t c = 0; for (size_t i = 0; i < length; i++) { @@ -59,18 +246,162 @@ uint8_t calculateChecksum(const uint8_t* buffer, size_t length) { return c; } +bool validateFrameChecksum(const uint8_t* buffer, size_t size) { + if (size != 32) return false; + return buffer[31] == calculateChecksum(buffer, 31); +} + +const char* gpsFixLabel(uint8_t value) { + switch (value) { + case 2: return "2D FIX"; + case 3: return "3D FIX"; + case 4: return "DGPS"; + case 5: return "RTK FLT"; + case 6: return "RTK FIX"; + default: return "NO FIX"; + } +} + +const char* flightModeLabel(uint8_t value) { + switch (value) { + case 1: return "STABILIZE"; + case 2: return "ALT_HOLD"; + case 3: return "AUTO"; + case 4: return "GUIDED"; + case 5: return "LOITER"; + case 6: return "RTL"; + case 7: return "LAND"; + case 8: return "POSHOLD"; + case 9: return "BRAKE"; + case 10: return "NO HEARTBEAT"; + case 255: return "CUSTOM"; + default: return "DISCONNECTED"; + } +} + +const char* systemStatusLabel(uint8_t value) { + switch (value) { + case 0: return "UNINIT"; + case 1: return "BOOT"; + case 2: return "CALIBRATING"; + case 3: return "STANDBY"; + case 5: return "CRITICAL"; + case 6: return "EMERGENCY"; + default: return "ACTIVE"; + } +} + +const char* missionStateLabel(uint8_t value) { + switch (value) { + case 1: return "READY"; + case 2: return "TAKEOFF"; + case 3: return "WAYPOINT NAV"; + case 4: return "RTL"; + case 5: return "LANDING"; + case 6: return "PAUSED"; + case 7: return "MISSION COMPLETE"; + default: return "STANDBY"; + } +} + +// ====================================================================================== +// PACKET PROCESSORS +// ====================================================================================== +bool processTelemetryFrame(const uint8_t* buffer, size_t size, bool* shouldPrint) { + if (!validateFrameChecksum(buffer, size)) return false; + + uint8_t frameType = buffer[2]; + *shouldPrint = false; + + if (frameType == FRAME_BASIC) { + const BasicFrame* frame = (const BasicFrame*)buffer; + g_batVoltage = frame->bat_mv / 1000.0f; + g_rssiVal = frame->rssi; + g_altitude = frame->alt_cm / 100.0f; + g_latitude = frame->lat_e7 / 10000000.0; + g_longitude = frame->lon_e7 / 10000000.0; + g_satellites = frame->satellites; + g_heading = frame->heading_cd / 100.0f; + g_batteryRemaining = frame->battery_remaining; + g_gpsFix = frame->gps_fix; + g_flightMode = frame->flight_mode; + g_systemStatus = frame->system_status; + g_linkQuality = frame->link_quality; + g_missionProgress = frame->mission_progress; + g_cellCount = frame->cell_count; + g_lastSeq = frame->seq; + g_armed = (frame->flags & 0x01) != 0; + g_piMavlinkConnected = (frame->flags & 0x02) != 0; + *shouldPrint = true; + return true; + } + + if (frameType == FRAME_POWER_SBC) { + const PowerSbcFrame* frame = (const PowerSbcFrame*)buffer; + g_batteryCurrent = frame->battery_current_ca / 100.0f; + for (uint8_t i = 0; i < 6; i++) { + g_cellVoltages[i] = frame->cell_mv[i] / 1000.0f; + } + g_cellDeltaMv = frame->cell_delta_mv; + g_cpuLoad = frame->cpu_load_percent; + g_cpuTemp = frame->cpu_temp_dc / 10.0f; + g_ramPercent = frame->ram_percent; + g_diskPercent = frame->disk_percent; + g_sbcUptime = (uint32_t)frame->uptime_minutes * 60UL; + return true; + } + + if (frameType == FRAME_ATTITUDE) { + const AttitudeFrame* frame = (const AttitudeFrame*)buffer; + g_roll = frame->roll_cd / 100.0f; + g_pitch = frame->pitch_cd / 100.0f; + g_yaw = frame->yaw_cd / 100.0f; + g_targetRoll = frame->target_roll_cd / 100.0f; + g_targetPitch = frame->target_pitch_cd / 100.0f; + g_targetYaw = frame->target_yaw_cd / 100.0f; + g_errorRoll = frame->error_roll_cd / 100.0f; + g_errorPitch = frame->error_pitch_cd / 100.0f; + g_gyroX = frame->gyro_x_cdps / 100.0f; + g_gyroY = frame->gyro_y_cdps / 100.0f; + g_gyroZ = frame->gyro_z_cdps / 100.0f; + g_climbRate = frame->climb_cms / 100.0f; + g_altitudeMsl = frame->altitude_msl_dm / 10.0f; + return true; + } + + if (frameType == FRAME_MOTORS) { + const MotorsFrame* frame = (const MotorsFrame*)buffer; + for (uint8_t i = 0; i < 4; i++) { + g_motorPercent[i] = frame->motor_percent[i]; + g_motorPwm[i] = frame->motor_pwm[i]; + } + g_missionCurrentSeq = frame->mission_current_seq; + g_missionTotalItems = frame->mission_total_items; + g_groundSpeed = frame->ground_speed_cms / 100.0f; + g_accelX = frame->accel_x_centi / 100.0f; + g_accelY = frame->accel_y_centi / 100.0f; + g_accelZ = frame->accel_z_centi / 100.0f; + g_missionState = frame->mission_state; + return true; + } + + return false; +} + bool processBinaryPacket(const uint8_t* buffer, size_t size) { if (size < sizeof(TelemetryPacket)) return false; const TelemetryPacket* pkt = (const TelemetryPacket*)buffer; if (pkt->magic != PACKET_MAGIC) return false; + // Validate XOR Checksum over bytes 0 to 18 uint8_t expectedChecksum = calculateChecksum(buffer, sizeof(TelemetryPacket) - 1); if (pkt->checksum != expectedChecksum) { Serial.println(F("[NRF24] Corrupted packet dropped (Checksum mismatch).")); return false; } + // Unpack fixed-point integers into engineering units g_batVoltage = pkt->bat_mv / 1000.0f; g_rssiVal = pkt->rssi; g_altitude = pkt->alt_cm / 100.0f; @@ -78,10 +409,12 @@ bool processBinaryPacket(const uint8_t* buffer, size_t size) { g_longitude = pkt->lon_e7 / 10000000.0; g_satellites = pkt->satellites; g_lastSeq = pkt->seq; + g_piMavlinkConnected = true; return true; } +// Fallback CSV Parser bool processCsvPacket(const char* text) { char temp[33]; strncpy(temp, text, sizeof(temp)); @@ -106,6 +439,9 @@ bool processCsvPacket(const char* text) { return true; } +// ====================================================================================== +// OLED DRAWING FUNCTIONS +// ====================================================================================== void drawHardwareErrorScreen() { u8g2.clearBuffer(); u8g2.drawRFrame(0, 0, 128, 64, 3); @@ -130,8 +466,10 @@ void drawHardwareErrorScreen() { void drawDisconnectScreen(unsigned long elapsedMs) { u8g2.clearBuffer(); + // Outer rounded border frame u8g2.drawRFrame(0, 0, 128, 64, 3); + // Warning Header Banner u8g2.setFont(u8g2_font_6x12_tf); u8g2.drawBox(2, 2, 124, 15); u8g2.setDrawColor(0); @@ -140,26 +478,48 @@ void drawDisconnectScreen(unsigned long elapsedMs) { u8g2.setDrawColor(1); u8g2.setFont(u8g2_font_ncenB08_tr); - u8g2.setCursor(16, 34); + u8g2.setCursor(16, 32); u8g2.print(F("Telemetry Lost")); u8g2.setFont(u8g2_font_6x12_tf); - u8g2.setCursor(14, 50); + u8g2.setCursor(10, 46); if (!g_hasReceivedData) { - u8g2.print(F("Waiting for link...")); + u8g2.print(F("Waiting NRF link")); } else { unsigned long elapsedSec = elapsedMs / 1000; - u8g2.print(F("Lost: ")); + u8g2.print(F("Lost ")); u8g2.print(elapsedSec); - u8g2.print(F("s ago")); + u8g2.print(F("s")); } + u8g2.setCursor(10, 59); + u8g2.print(F("IP:")); + u8g2.print(g_wifiIpText); + + u8g2.sendBuffer(); +} + +void drawWifiStatusScreen(const __FlashStringHelper* title, const char* ssid, const String& ipText) { + u8g2.clearBuffer(); + u8g2.drawRFrame(0, 0, 128, 64, 4); + u8g2.setFont(u8g2_font_6x12_tf); + u8g2.setCursor(10, 14); + u8g2.print(title); + u8g2.setCursor(10, 30); + u8g2.print(F("SSID:")); + u8g2.print(ssid); + u8g2.setCursor(10, 46); + u8g2.print(F("IP:")); + u8g2.print(ipText); + u8g2.setCursor(10, 59); + u8g2.print(F("/telemetry.json")); u8g2.sendBuffer(); } void drawTelemetryScreen() { u8g2.clearBuffer(); + // LEVEL 1: Battery Voltage & Satellites / Heartbeat Indicator u8g2.setFont(u8g2_font_7x14B_tr); u8g2.setCursor(2, 13); u8g2.print(F("BAT:")); @@ -171,6 +531,7 @@ void drawTelemetryScreen() { u8g2.print(F("SAT:")); u8g2.print(g_satellites); + // Heartbeat Dot if (g_heartbeatState) { u8g2.drawDisc(122, 9, 3); } else { @@ -179,6 +540,7 @@ void drawTelemetryScreen() { u8g2.drawHLine(0, 16, 128); + // LEVEL 2: FlySky RSSI & Altitude in Meters u8g2.setFont(u8g2_font_6x12_tf); u8g2.setCursor(2, 29); u8g2.print(F("RSSI:")); @@ -192,6 +554,7 @@ void drawTelemetryScreen() { u8g2.drawHLine(0, 33, 128); + // LEVEL 3: High-Precision GPS Coordinates u8g2.setFont(u8g2_font_5x8_tr); u8g2.setCursor(2, 45); @@ -213,6 +576,223 @@ void drawTelemetryScreen() { u8g2.sendBuffer(); } +void addCorsHeaders() { + server.sendHeader(F("Access-Control-Allow-Origin"), F("*")); + server.sendHeader(F("Access-Control-Allow-Methods"), F("GET, OPTIONS")); + server.sendHeader(F("Access-Control-Allow-Headers"), F("Content-Type")); +} + +String makeTelemetryJson() { + bool radioFresh = g_hasReceivedData && (millis() - g_lastPacketTime < DISCONNECT_TIMEOUT_MS); + String json; + json.reserve(1800); + + json += F("{\"source\":\"esp32-nrf\",\"updated_ms\":"); + json += millis(); + json += F(",\"connected\":"); + json += (radioFresh ? F("true") : F("false")); + json += F(",\"mavlink_connected\":"); + json += (g_piMavlinkConnected ? F("true") : F("false")); + json += F(",\"seq\":"); + json += String((int)g_lastSeq); + json += F(",\"battery_voltage\":"); + json += String(g_batVoltage, 2); + json += F(",\"battery_current\":"); + json += String(g_batteryCurrent, 2); + json += F(",\"battery_remaining\":"); + json += String((int)g_batteryRemaining); + json += F(",\"cell_voltages\":["); + for (uint8_t i = 0; i < 6; i++) { + if (i) json += ','; + json += String(g_cellVoltages[i], 3); + } + json += F("],\"cell_delta_mv\":"); + json += g_cellDeltaMv; + json += F(",\"altitude_relative\":"); + json += String(g_altitude, 2); + json += F(",\"altitude_msl\":"); + json += String(g_altitudeMsl, 2); + json += F(",\"climb_rate\":"); + json += String(g_climbRate, 2); + json += F(",\"latitude\":"); + json += String(g_latitude, 7); + json += F(",\"longitude\":"); + json += String(g_longitude, 7); + json += F(",\"satellites\":"); + json += String((int)g_satellites); + json += F(",\"gps_fix_type\":\""); + json += gpsFixLabel(g_gpsFix); + json += F("\",\"rc_rssi\":"); + json += g_rssiVal; + json += F(",\"radio_link_quality\":"); + json += String((int)g_linkQuality); + json += F(",\"heading\":"); + json += String(g_heading, 2); + json += F(",\"ground_speed\":"); + json += String(g_groundSpeed, 2); + json += F(",\"armed\":"); + json += (g_armed ? F("true") : F("false")); + json += F(",\"flight_mode\":\""); + json += flightModeLabel(g_flightMode); + json += F("\",\"system_status\":\""); + json += systemStatusLabel(g_systemStatus); + json += F("\",\"mission_state\":\""); + json += missionStateLabel(g_missionState); + json += F("\",\"mission_progress_percent\":"); + json += String((int)g_missionProgress); + json += F(",\"mission_current_seq\":"); + json += g_missionCurrentSeq; + json += F(",\"mission_total_items\":"); + json += g_missionTotalItems; + json += F(",\"attitude_roll\":"); + json += String(g_roll, 2); + json += F(",\"attitude_pitch\":"); + json += String(g_pitch, 2); + json += F(",\"attitude_yaw\":"); + json += String(g_yaw, 2); + json += F(",\"target_roll\":"); + json += String(g_targetRoll, 2); + json += F(",\"target_pitch\":"); + json += String(g_targetPitch, 2); + json += F(",\"target_yaw\":"); + json += String(g_targetYaw, 2); + json += F(",\"error_roll\":"); + json += String(g_errorRoll, 2); + json += F(",\"error_pitch\":"); + json += String(g_errorPitch, 2); + json += F(",\"gyro_x\":"); + json += String(g_gyroX, 2); + json += F(",\"gyro_y\":"); + json += String(g_gyroY, 2); + json += F(",\"gyro_z\":"); + json += String(g_gyroZ, 2); + json += F(",\"accel_x\":"); + json += String(g_accelX, 2); + json += F(",\"accel_y\":"); + json += String(g_accelY, 2); + json += F(",\"accel_z\":"); + json += String(g_accelZ, 2); + json += F(",\"motor_percent\":["); + for (uint8_t i = 0; i < 4; i++) { + if (i) json += ','; + json += String((int)g_motorPercent[i]); + } + json += F("],\"motor_pwm\":["); + for (uint8_t i = 0; i < 4; i++) { + if (i) json += ','; + json += g_motorPwm[i]; + } + json += F("],\"sbc\":{\"cpu_load_percent\":"); + json += String((int)g_cpuLoad); + json += F(",\"cpu_temp_c\":"); + json += String(g_cpuTemp, 1); + json += F(",\"ram_percent\":"); + json += String((int)g_ramPercent); + json += F(",\"disk_percent\":"); + json += String((int)g_diskPercent); + json += F(",\"uptime_seconds\":"); + json += g_sbcUptime; + json += F("}}"); + + return json; +} + +void handleTelemetryJson() { + addCorsHeaders(); + server.send(200, F("application/json"), makeTelemetryJson()); +} + +void handleRoot() { + addCorsHeaders(); + String message = F("ESP32 drone telemetry JSON: http://"); + message += g_wifiIpText; + message += F("/telemetry.json"); + server.send(200, F("text/plain"), message); +} + +void handleOptions() { + addCorsHeaders(); + server.send(204); +} + +bool hasStationCredentials() { + return strlen(WIFI_STA_SSID) > 0 && + strcmp(WIFI_STA_SSID, "YOUR_WIFI_SSID") != 0 && + strcmp(WIFI_STA_PASSWORD, "YOUR_WIFI_PASSWORD") != 0; +} + +void startFallbackAccessPoint() { + g_wifiApMode = true; + WiFi.mode(WIFI_AP); + WiFi.softAP(WIFI_FALLBACK_AP_SSID, WIFI_FALLBACK_AP_PASSWORD); + g_wifiIpText = WiFi.softAPIP().toString(); + + Serial.print(F("[WIFI] Fallback AP started: ")); + Serial.print(WIFI_FALLBACK_AP_SSID); + Serial.print(F(" / ")); + Serial.println(g_wifiIpText); + + drawWifiStatusScreen(F("WIFI AP MODE"), WIFI_FALLBACK_AP_SSID, g_wifiIpText); +} + +void setupWifiApi() { + g_wifiApMode = false; + WiFi.setHostname(WIFI_HOSTNAME); + + if (hasStationCredentials()) { + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_STA_SSID, WIFI_STA_PASSWORD); + drawWifiStatusScreen(F("WIFI CONNECTING"), WIFI_STA_SSID, F("...")); + + Serial.print(F("[WIFI] Connecting to ")); + Serial.println(WIFI_STA_SSID); + + unsigned long startMs = millis(); + while (WiFi.status() != WL_CONNECTED && millis() - startMs < 15000) { + delay(300); + Serial.print('.'); + } + Serial.println(); + + if (WiFi.status() == WL_CONNECTED) { + g_wifiIpText = WiFi.localIP().toString(); + Serial.print(F("[WIFI] Connected: ")); + Serial.print(WIFI_STA_SSID); + Serial.print(F(" / ")); + Serial.println(g_wifiIpText); + drawWifiStatusScreen(F("WIFI CONNECTED"), WIFI_STA_SSID, g_wifiIpText); + } else { + Serial.println(F("[WIFI] Station connection failed. Starting fallback AP.")); + startFallbackAccessPoint(); + } + } else { + Serial.println(F("[WIFI] Station credentials not set. Starting fallback AP.")); + startFallbackAccessPoint(); + } + + server.on("/", HTTP_GET, handleRoot); + server.on("/telemetry.json", HTTP_GET, handleTelemetryJson); + server.on("/api/telemetry", HTTP_GET, handleTelemetryJson); + server.on("/telemetry.json", HTTP_OPTIONS, handleOptions); + server.on("/api/telemetry", HTTP_OPTIONS, handleOptions); + server.onNotFound(handleRoot); + server.begin(); + + if (MDNS.begin(WIFI_HOSTNAME)) { + MDNS.addService("http", "tcp", HTTP_PORT); + Serial.print(F("[MDNS] http://")); + Serial.print(WIFI_HOSTNAME); + Serial.println(F(".local/telemetry.json")); + } + + Serial.print(F("[HTTP] Telemetry JSON: http://")); + Serial.print(g_wifiIpText); + Serial.println(F("/telemetry.json")); +} + +// ====================================================================================== +// ARDUINO SETUP +// ====================================================================================== void setup() { Serial.begin(115200); delay(1000); @@ -221,10 +801,12 @@ void setup() { Serial.println(F(" ESP32 Drone Telemetry Receiver Booting ")); Serial.println(F("==============================================")); + // 1. Initialize Hardware I2C and SH1106 OLED Wire.begin(PIN_OLED_SDA, PIN_OLED_SCL); u8g2.begin(); u8g2.setContrast(255); + // Splash Screen u8g2.clearBuffer(); u8g2.setFont(u8g2_font_ncenB08_tr); u8g2.drawRFrame(0, 0, 128, 64, 4); @@ -235,11 +817,15 @@ void setup() { u8g2.print(F("Initializing...")); u8g2.sendBuffer(); + setupWifiApi(); + + // 2. Initialize VSPI Bus SPI.begin(PIN_NRF_SCK, PIN_NRF_MISO, PIN_NRF_MOSI, -1); pinMode(PIN_NRF_CE, OUTPUT); pinMode(PIN_NRF_CSN, OUTPUT); digitalWrite(PIN_NRF_CSN, HIGH); + // 3. Initialize NRF24L01+ Radio if (!radio.begin()) { Serial.println(F("[ERROR] NRF24L01 hardware not detected! Check SPI wiring & HW-200 5V power.")); g_radioHardwareOk = false; @@ -247,13 +833,13 @@ void setup() { } else { g_radioHardwareOk = true; radio.setChannel(RF_CHANNEL); - radio.setDataRate(RF24_250KBPS); + radio.setDataRate(RF24_250KBPS); // Long range mode (250 kbps) radio.setPALevel(RF24_PA_HIGH); radio.setCRCLength(RF24_CRC_16); radio.setAutoAck(false); radio.enableDynamicPayloads(); - radio.openReadingPipe(1, rfAddresses[1]); + radio.openReadingPipe(1, rfAddresses[1]); // "2Node" radio.openWritingPipe(rfAddresses[0]); radio.startListening(); @@ -262,12 +848,18 @@ void setup() { } } +// ====================================================================================== +// MAIN ARDUINO LOOP +// ====================================================================================== void loop() { + server.handleClient(); + if (!g_radioHardwareOk) { delay(500); return; } + // 1. Non-blocking Radio Packet Polling while (radio.available()) { uint8_t rawPayload[32] = {0}; uint8_t payloadSize = radio.getDynamicPayloadSize(); @@ -278,11 +870,19 @@ void loop() { radio.read(&rawPayload, payloadSize); bool packetDecoded = false; - - if (rawPayload[0] == PACKET_MAGIC) { + bool shouldPrintTelemetry = false; + + if (rawPayload[0] == PACKET_MAGIC && payloadSize == 32) { + packetDecoded = processTelemetryFrame(rawPayload, payloadSize, &shouldPrintTelemetry); + if (!packetDecoded) { + Serial.println(F("[NRF24] Corrupted packet dropped (Frame checksum mismatch).")); + } + } else if (rawPayload[0] == PACKET_MAGIC) { packetDecoded = processBinaryPacket(rawPayload, payloadSize); + shouldPrintTelemetry = packetDecoded; } else { packetDecoded = processCsvPacket((const char*)rawPayload); + shouldPrintTelemetry = packetDecoded; } if (packetDecoded) { @@ -290,21 +890,25 @@ void loop() { g_lastPacketTime = millis(); g_heartbeatState = !g_heartbeatState; - Serial.print(F("[RX] BAT: ")); - Serial.print(g_batVoltage, 2); - Serial.print(F("V | RSSI: ")); - Serial.print(g_rssiVal); - Serial.print(F("% | ALT: ")); - Serial.print(g_altitude, 1); - Serial.print(F("m | LAT: ")); - Serial.print(g_latitude, 6); - Serial.print(F(" | LON: ")); - Serial.print(g_longitude, 6); - Serial.print(F(" | SATS: ")); - Serial.println(g_satellites); + // Safe serial printing + if (shouldPrintTelemetry) { + Serial.print(F("[RX] BAT: ")); + Serial.print(g_batVoltage, 2); + Serial.print(F("V | RSSI: ")); + Serial.print(g_rssiVal); + Serial.print(F("% | ALT: ")); + Serial.print(g_altitude, 1); + Serial.print(F("m | LAT: ")); + Serial.print(g_latitude, 6); + Serial.print(F(" | LON: ")); + Serial.print(g_longitude, 6); + Serial.print(F(" | SATS: ")); + Serial.println(g_satellites); + } } } + // 2. Non-blocking OLED Screen Refresh unsigned long currentMillis = millis(); if (currentMillis - g_lastDisplayRefresh >= DISPLAY_REFRESH_INTERVAL_MS) { g_lastDisplayRefresh = currentMillis; diff --git a/telemetry/handheld-nrf-monitor/pi-code/__init__.py b/telemetry/handheld-nrf-monitor/pi-code/__init__.py new file mode 100644 index 0000000..2d437cd --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/__init__.py @@ -0,0 +1,3 @@ +""" +Drone Telemetry Companion Server Modules +""" diff --git a/telemetry/handheld-nrf-monitor/pi-code/main.py b/telemetry/handheld-nrf-monitor/pi-code/main.py index 9a696c8..7440f23 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/main.py +++ b/telemetry/handheld-nrf-monitor/pi-code/main.py @@ -1,75 +1,74 @@ #!/usr/bin/env python3 +""" +====================================================================================== +Project: Drone Telemetry Master Companion Server +File: pi/tele/main.py + +Description: + Telemetry-only supervisor running directly on the Raspberry Pi 4B. + Reads Pixhawk MAVLink, packages telemetry, and broadcasts NRF24 frames to + the ESP32. The laptop dashboard reads the ESP32 Wi-Fi JSON endpoint. +====================================================================================== +""" -import os -import sys import time import signal import argparse -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, SCRIPT_DIR) - -from modules.sbc_monitor import SBCMonitor -from modules.mavlink_manager import MAVLinkManager -from modules.radio_tx_module import RadioTXModule -from modules.web_dashboard_module import WebDashboardModule -from modules.grid_search_module import GridSearchModule +from sbc_monitor import SBCMonitor +from mavlink_manager import MAVLinkManager +from radio_tx_module import RadioTXModule def main(): - parser = argparse.ArgumentParser(description="Drone Telemetry Master Companion Server") + parser = argparse.ArgumentParser(description="Drone Telemetry Pi Relay") parser.add_argument("--port", default="/dev/serial0", help="Pixhawk serial port (default: /dev/serial0 or /dev/ttyACM0)") parser.add_argument("--baud", type=int, default=115200, help="Serial baud rate (default: 115200)") - parser.add_argument("--web-port", type=int, default=8000, help="Web Dashboard HTTP Port (default: 8000)") parser.add_argument("--radio-rate", type=float, default=5.0, help="NRF24 broadcast rate in Hz (default: 5.0)") + parser.add_argument("--no-web", action="store_true", help="Compatibility flag; the Pi no longer hosts the dashboard") parser.add_argument("--no-radio", action="store_true", help="Disable NRF24 radio module") parser.add_argument("--simulate", action="store_true", help="Run simulated flight telemetry without hardware") args = parser.parse_args() print("=" * 76) - print(" DRONE TELEMETRY MASTER COMPANION SERVER (RASPBERRY PI 4B) ") + print(" DRONE TELEMETRY PI RELAY (RASPBERRY PI 4B) ") print("=" * 76) print(f"[*] Flight Controller Port: {args.port} @ {args.baud} baud") - print(f"[*] Web Dashboard Port: http://0.0.0.0:{args.web_port}") + print("[*] Web Dashboard: ESP32 Wi-Fi JSON -> laptop web/ dashboard") print(f"[*] Radio TX Broadcast Rate: {args.radio_rate} Hz ({'DISABLED' if args.no_radio else 'ENABLED'})") print(f"[*] Operation Mode: {'SIMULATION' if args.simulate else 'LIVE PIXHAWK'}") print("----------------------------------------------------------------------------") + # 1. SBC Monitor sbc_monitor = SBCMonitor() print("[+] Initialized SBC Hardware & Thermal Monitor.") + # 2. Shared MAVLink Manager mav_manager = MAVLinkManager(port=args.port, baud=args.baud, simulate=args.simulate) mav_manager.start() - grid_module = GridSearchModule(mav_manager) - print("[✓] Module 3 (Grid Search Autonomy): Ready.") - - dashboard_module = WebDashboardModule( - mav_manager=mav_manager, - sbc_monitor=sbc_monitor, - grid_module=grid_module, - port=args.web_port - ) - dashboard_module.start() + print("[*] Web Dashboard: Pi hosting removed; ESP32 Wi-Fi JSON feeds the laptop dashboard.") + # 3. NRF24 Radio Transmitter radio_module = RadioTXModule( mav_manager=mav_manager, + sbc_monitor=sbc_monitor, rate_hz=args.radio_rate, enabled=not args.no_radio ) radio_module.start() print("\n" + "=" * 76) - print(f" ALL 3 MODULES RUNNING CONCURRENTLY ON RASPBERRY PI!") - print(f" • Module 1 (Radio TX): NRF24L01+ broadcast -> ESP32 Handheld OLED") - print(f" • Module 2 (Web Dashboard): http://0.0.0.0:{args.web_port}") - print(f" • Module 3 (Grid Autonomy): Boustrophedon generator & MAVLink controller") + print(f" TELEMETRY RELAY RUNNING ON RASPBERRY PI") + print(f" • MAVLink Input: Pixhawk -> {args.port}") + print(f" • NRF24 TX: Raspberry Pi -> ESP32 Handheld OLED") + print(f" • ESP32 Wi-Fi JSON: http:///telemetry.json -> laptop web/") print("=" * 76 + "\n") print("[*] Press Ctrl+C to stop all services.\n") + # Graceful exit handler def shutdown(signum, frame): - print("\n[*] Stopping companion modules...") + print("\n[*] Stopping telemetry relay...") radio_module.stop() - dashboard_module.stop() mav_manager.stop() print("[✓] All modules stopped cleanly.") sys.exit(0) @@ -77,6 +76,7 @@ def shutdown(signum, frame): signal.signal(signal.SIGINT, shutdown) signal.signal(signal.SIGTERM, shutdown) + # Supervisor keep-alive & live console status ticker (every 2s) last_print = 0 while True: time.sleep(0.5) diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/mavlink_manager.py b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py similarity index 58% rename from telemetry/handheld-nrf-monitor/pi-code/modules/mavlink_manager.py rename to telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py index 57d2d02..a3c488e 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/modules/mavlink_manager.py +++ b/telemetry/handheld-nrf-monitor/pi-code/mavlink_manager.py @@ -1,9 +1,25 @@ #!/usr/bin/env python3 +""" +====================================================================================== +Module: MAVLink Telemetry Manager & Shared State Hub +File: pi/tele/mavlink_manager.py + +Description: + Singleton MAVLink reader that connects to Pixhawk 2.4.8 on TELEM2 (/dev/serial0) + or USB (/dev/ttyACM0 @ 115200 baud). + + Key Features: + - Broadcasts 1 Hz Companion Heartbeats to keep ArduPilot active telemetry streaming alive + - Requests all sensor & attitude streams at 10 Hz + - Decodes ATTITUDE, AHRS, SYS_STATUS, GLOBAL_POSITION_INT, VFR_HUD, RAW_IMU, SERVO_OUTPUT + - Thread-safe state store for WebSockets and Radio TX +====================================================================================== +""" import time import math import threading -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional class MAVLinkManager: def __init__(self, port: str = "/dev/serial0", baud: int = 115200, simulate: bool = False): @@ -16,37 +32,46 @@ def __init__(self, port: str = "/dev/serial0", baud: int = 115200, simulate: boo self._lock = threading.Lock() self._serial_cmd_lock = threading.Lock() + # Simulation mission playback storage self.sim_mission_items: List[Dict[str, Any]] = [] self.sim_mission_index: int = 0 self.sim_current_lat: float = 12.971598 self.sim_current_lon: float = 77.594562 self.sim_current_alt: float = 0.0 + # Telemetry State Store (All 11 Categories + Mission Autonomy) self.state: Dict[str, Any] = { - + # 1. Total Battery & Power "battery_voltage": 0.0, "battery_current": 0.0, "battery_remaining": 0, - + + # 2. Individual Cell Voltages (Cell 1 to 6) "cell_voltages": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0], "cell_delta_mv": 0, - + + # 3. Altitude & Vertical Dynamics "altitude_relative": 0.0, "altitude_msl": 0.0, "climb_rate": 0.0, - + + # 4. GPS & Positioning "latitude": 0.0, "longitude": 0.0, "satellites": 0, "gps_fix_type": "NO FIX", "hdop": 99.9, - + + # 5. Signal Strength & Telemetry Link "rc_rssi": 0, "radio_link_quality": 0, - + + # 6. Heading & Compass "heading": 0.0, "compass_status": "CALIBRATED", - + "ground_speed": 0.0, + + # 7. Flight Status & Mission Autonomy State "armed": False, "flight_mode": "DISCONNECTED", "system_status": "STANDBY", @@ -57,14 +82,16 @@ def __init__(self, port: str = "/dev/serial0", baud: int = 115200, simulate: boo "dist_to_target_wp_m": 0.0, "target_wp_lat": 0.0, "target_wp_lon": 0.0, - + + # 8. 3-Axis Gyroscope & Accelerometer Rates "gyro_x": 0.0, "gyro_y": 0.0, "gyro_z": 0.0, "accel_x": 0.0, "accel_y": 0.0, "accel_z": 9.81, - + + # 9. PID Attitude Tracking & Errors "attitude_roll": 0.0, "attitude_pitch": 0.0, "attitude_yaw": 0.0, @@ -73,16 +100,22 @@ def __init__(self, port: str = "/dev/serial0", baud: int = 115200, simulate: boo "target_yaw": 0.0, "error_roll": 0.0, "error_pitch": 0.0, - + "error_yaw": 0.0, + + # 10. Motor Power Outputs (Motors 1 to 4) "motor_pwm": [1000, 1000, 1000, 1000], "motor_percent": [0, 0, 0, 0], - + + # Timing & Status "last_packet_timestamp": 0.0, + "last_heartbeat_timestamp": 0.0, + "last_message_type": "", + "mavlink_status": "Waiting for serial port", "connected": False } def start(self): - + """Starts the MAVLink ingestion thread.""" if self.running: return self.running = True @@ -90,7 +123,7 @@ def start(self): self.thread.start() def stop(self): - + """Stops the ingestion loop.""" self.running = False if self.thread and self.thread.is_alive(): self.thread.join(timeout=1.0) @@ -101,12 +134,16 @@ def stop(self): pass def get_telemetry_snapshot(self) -> Dict[str, Any]: - + """Thread-safe copy of latest telemetry.""" with self._lock: return dict(self.state) - def set_flight_mode(self, mode: str) -> bool: + # ---------------------------------------------------------------------------------- + # MAVLINK COMMAND & MISSION METHODS + # ---------------------------------------------------------------------------------- + def set_flight_mode(self, mode: str) -> bool: + """Commands Pixhawk into requested flight mode (e.g. AUTO, LOITER, RTL, GUIDED).""" mode_upper = mode.upper() if self.simulate or not self.mav: print(f"[✓] Simulation: Flight mode switched to '{mode_upper}'") @@ -155,7 +192,7 @@ def set_flight_mode(self, mode: str) -> bool: return False def set_arm(self, arm: bool = True, force: bool = False) -> bool: - + """Arms or disarms the motors.""" if self.simulate or not self.mav: print(f"[✓] Simulation: Motors {'ARMED' if arm else 'DISARMED'}") with self._lock: @@ -182,7 +219,7 @@ def set_arm(self, arm: bool = True, force: bool = False) -> bool: return False def clear_all_missions(self) -> bool: - + """Clears all mission waypoints from flight controller.""" if self.simulate or not self.mav: with self._lock: self.sim_mission_items = [] @@ -209,7 +246,7 @@ def clear_all_missions(self) -> bool: return False def upload_mission_items(self, items: List[Dict[str, Any]]) -> bool: - + """Thread-safe mission upload to Pixhawk using MAVLink Mission Protocol.""" if self.simulate or not self.mav: print(f"[✓] Simulation: Loaded {len(items)} mission items into simulator memory.") with self._lock: @@ -227,12 +264,15 @@ def upload_mission_items(self, items: List[Dict[str, Any]]) -> bool: target_sys = getattr(self.mav, "target_system", 1) or 1 target_comp = getattr(self.mav, "target_component", 1) or 1 + # 1. Clear existing mission self.mav.mav.mission_clear_all_send(target_sys, target_comp) time.sleep(0.1) + # 2. Announce mission count total_count = len(items) self.mav.mav.mission_count_send(target_sys, target_comp, total_count) + # 3. Transmit mission items upon request start_time = time.time() seq = 0 while seq < total_count and (time.time() - start_time < 15.0): @@ -288,7 +328,7 @@ def _run_loop(self): self._run_live_mavlink() def _send_companion_heartbeat(self, mavutil): - + """Sends periodic 1 Hz GCS heartbeat from Pi to Pixhawk to keep streams active.""" if not self.mav: return try: @@ -302,23 +342,23 @@ def _send_companion_heartbeat(self, mavutil): pass def _request_all_streams(self, mavutil): - + """Requests individual telemetry streams, sets stream params, and modern message intervals.""" if not self.mav: return target_sys = getattr(self.mav, "target_system", 1) or 1 - target_comp = 1 + target_comp = 1 # ArduPilot Autopilot primary component is always 1 try: - + # 1. Set ArduPilot Stream Parameters (SR0=USB, SR1=TELEM1, SR2=TELEM2) stream_params = { - "SR0_EXTRA1": 10.0, - "SR0_EXTRA2": 10.0, - "SR0_EXTRA3": 10.0, - "SR0_RAW_SENS": 10.0, - "SR0_POSITION": 5.0, - "SR0_EXT_STAT": 2.0, - "SR0_RC_CHAN": 5.0, + "SR0_EXTRA1": 10.0, # ATTITUDE @ 10 Hz + "SR0_EXTRA2": 10.0, # VFR_HUD @ 10 Hz + "SR0_EXTRA3": 10.0, # AHRS / system status @ 10 Hz + "SR0_RAW_SENS": 10.0, # RAW_IMU / SCALED_IMU @ 10 Hz + "SR0_POSITION": 5.0, # GPS & position @ 5 Hz + "SR0_EXT_STAT": 2.0, # SYS_STATUS & battery @ 2 Hz + "SR0_RC_CHAN": 5.0, # RC_CHANNELS & servos @ 5 Hz "SR1_EXTRA1": 10.0, "SR1_EXTRA2": 10.0, @@ -347,6 +387,7 @@ def _request_all_streams(self, mavutil): except Exception: pass + # 2. Legacy MAV_DATA_STREAM requests (targeting both Component 1 and Component 0) target_comps = [1, 0] if getattr(self.mav, "target_component", 0) not in target_comps: target_comps.append(self.mav.target_component) @@ -366,19 +407,27 @@ def _request_all_streams(self, mavutil): target_sys, comp_id, s, 10, 1 ) - intervals = { - mavutil.mavlink.MAVLINK_MSG_ID_ATTITUDE: 100000, - mavutil.mavlink.MAVLINK_MSG_ID_RAW_IMU: 100000, - mavutil.mavlink.MAVLINK_MSG_ID_SCALED_IMU: 100000, - mavutil.mavlink.MAVLINK_MSG_ID_GLOBAL_POSITION_INT: 200000, - mavutil.mavlink.MAVLINK_MSG_ID_GPS_RAW_INT: 200000, - mavutil.mavlink.MAVLINK_MSG_ID_VFR_HUD: 200000, - mavutil.mavlink.MAVLINK_MSG_ID_SYS_STATUS: 500000, - mavutil.mavlink.MAVLINK_MSG_ID_BATTERY_STATUS: 500000, - mavutil.mavlink.MAVLINK_MSG_ID_SERVO_OUTPUT_RAW: 200000, - mavutil.mavlink.MAVLINK_MSG_ID_NAV_CONTROLLER_OUTPUT: 200000 - } - for msg_id, interval_us in intervals.items(): + intervals = [ + ("MAVLINK_MSG_ID_ATTITUDE", 100000), + ("MAVLINK_MSG_ID_ATTITUDE_TARGET", 100000), + ("MAVLINK_MSG_ID_RAW_IMU", 100000), + ("MAVLINK_MSG_ID_SCALED_IMU", 100000), + ("MAVLINK_MSG_ID_HIGHRES_IMU", 100000), + ("MAVLINK_MSG_ID_GLOBAL_POSITION_INT", 200000), + ("MAVLINK_MSG_ID_GPS_RAW_INT", 200000), + ("MAVLINK_MSG_ID_VFR_HUD", 200000), + ("MAVLINK_MSG_ID_SYS_STATUS", 500000), + ("MAVLINK_MSG_ID_BATTERY_STATUS", 500000), + ("MAVLINK_MSG_ID_SERVO_OUTPUT_RAW", 200000), + ("MAVLINK_MSG_ID_RC_CHANNELS", 200000), + ("MAVLINK_MSG_ID_RADIO_STATUS", 500000), + ("MAVLINK_MSG_ID_NAV_CONTROLLER_OUTPUT", 200000), + ("MAVLINK_MSG_ID_MISSION_CURRENT", 500000), + ] + for msg_name, interval_us in intervals: + msg_id = getattr(mavutil.mavlink, msg_name, None) + if msg_id is None: + continue self.mav.mav.command_long_send( target_sys, target_comp, mavutil.mavlink.MAV_CMD_SET_MESSAGE_INTERVAL, @@ -388,11 +437,202 @@ def _request_all_streams(self, mavutil): except Exception: pass + def _set_connection_state(self, connected: bool, status: str): + with self._lock: + self.state["connected"] = connected + self.state["mavlink_status"] = status + if not connected: + self.state["flight_mode"] = "NO HEARTBEAT" if "packet" in status.lower() else "DISCONNECTED" + + def _heading(self, value, scale: float = 1.0): + try: + return round((float(value) / scale) % 360.0, 2) + except Exception: + return None + + def _yaw_error(self, target: float, actual: float) -> float: + return round((target - actual + 180.0) % 360.0 - 180.0, 2) + + def _update_attitude_errors(self): + self.state["error_roll"] = round(self.state["target_roll"] - self.state["attitude_roll"], 2) + self.state["error_pitch"] = round(self.state["target_pitch"] - self.state["attitude_pitch"], 2) + self.state["error_yaw"] = self._yaw_error(self.state["target_yaw"], self.state["attitude_yaw"]) + + def _mode_name(self, custom_mode) -> str: + try: + mode_map = self.mav.mode_mapping() if self.mav else {} + inv_map = {v: k for k, v in mode_map.items()} + return inv_map.get(custom_mode, f"MODE_{custom_mode}") + except Exception: + return f"MODE_{custom_mode}" + + def _handle_mavlink_message(self, msg, mavutil, now: Optional[float] = None) -> bool: + msg_type = msg.get_type() + if msg_type in ("BAD_DATA", "UNKNOWN"): + return False + + now = now or time.time() + with self._lock: + self.state["last_packet_timestamp"] = now + self.state["last_message_type"] = msg_type + self.state["connected"] = True + self.state["mavlink_status"] = "Receiving MAVLink telemetry" + + if msg_type == 'HEARTBEAT': + self.state["last_heartbeat_timestamp"] = now + self.state["armed"] = (msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) != 0 + self.state["flight_mode"] = self._mode_name(msg.custom_mode) + sys_status_map = { + 0: "UNINIT", 1: "BOOT", 2: "CALIBRATING", 3: "STANDBY", + 4: "ACTIVE", 5: "CRITICAL", 6: "EMERGENCY", 7: "POWEROFF" + } + self.state["system_status"] = sys_status_map.get(msg.system_status, "ACTIVE") + + elif msg_type == 'SYS_STATUS': + if 0 < msg.voltage_battery < 65535: + self.state["battery_voltage"] = round(msg.voltage_battery / 1000.0, 2) + if msg.current_battery >= 0: + self.state["battery_current"] = round(msg.current_battery / 100.0, 1) + if 0 <= msg.battery_remaining <= 100: + self.state["battery_remaining"] = msg.battery_remaining + + elif msg_type == 'BATTERY_STATUS': + raw_cells = list(getattr(msg, "voltages", []) or [])[:6] + valid_cells = [] + for value in raw_cells: + valid_cells.append(round(value / 1000.0, 3) if 500 <= value < 65535 else 0.0) + valid_cells.extend([0.0] * (6 - len(valid_cells))) + self.state["cell_voltages"] = valid_cells + active_cells = [cell for cell in valid_cells if cell > 1.0] + if active_cells: + self.state["cell_delta_mv"] = int(round((max(active_cells) - min(active_cells)) * 1000)) + if self.state["battery_voltage"] <= 0.5: + self.state["battery_voltage"] = round(sum(active_cells), 2) + if msg.current_battery >= 0: + self.state["battery_current"] = round(msg.current_battery / 100.0, 1) + if 0 <= msg.battery_remaining <= 100: + self.state["battery_remaining"] = msg.battery_remaining + + elif msg_type == 'GLOBAL_POSITION_INT': + self.state["latitude"] = round(msg.lat / 1e7, 7) + self.state["longitude"] = round(msg.lon / 1e7, 7) + self.state["altitude_relative"] = round(msg.relative_alt / 1000.0, 2) + self.state["altitude_msl"] = round(msg.alt / 1000.0, 2) + heading = self._heading(msg.hdg, 100.0) if msg.hdg != 65535 else None + if heading is not None: + self.state["heading"] = heading + + elif msg_type == 'GPS_RAW_INT': + if msg.satellites_visible != 255: + self.state["satellites"] = msg.satellites_visible + fix_map = {0: "NO FIX", 1: "NO FIX", 2: "2D FIX", 3: "3D FIX", 4: "DGPS", 5: "RTK FLT", 6: "RTK FIX"} + self.state["gps_fix_type"] = fix_map.get(msg.fix_type, "NO FIX") + if 0 < msg.eph < 65535: + self.state["hdop"] = round(msg.eph / 100.0, 2) + else: + self.state["hdop"] = 99.9 + + elif msg_type == 'VFR_HUD': + self.state["climb_rate"] = round(msg.climb, 2) + self.state["ground_speed"] = round(msg.groundspeed, 2) + heading = self._heading(msg.heading) + if heading is not None: + self.state["heading"] = heading + if hasattr(msg, 'alt') and msg.alt is not None: + self.state["altitude_msl"] = round(msg.alt, 2) + if self.state["altitude_relative"] == 0.0: + self.state["altitude_relative"] = round(msg.alt, 2) + + elif msg_type in ('ATTITUDE', 'AHRS2', 'AHRS3'): + if hasattr(msg, 'roll'): + self.state["attitude_roll"] = round(math.degrees(msg.roll), 2) + self.state["attitude_pitch"] = round(math.degrees(msg.pitch), 2) + self.state["attitude_yaw"] = round(math.degrees(msg.yaw) % 360, 2) + if hasattr(msg, 'rollspeed'): + self.state["gyro_x"] = round(math.degrees(msg.rollspeed), 2) + self.state["gyro_y"] = round(math.degrees(msg.pitchspeed), 2) + self.state["gyro_z"] = round(math.degrees(msg.yawspeed), 2) + if self.state["heading"] == 0: + self.state["heading"] = self.state["attitude_yaw"] + self._update_attitude_errors() + + elif msg_type == 'ATTITUDE_TARGET': + try: + q = msg.q + sinr_cosp = 2 * (q[0] * q[1] + q[2] * q[3]) + cosr_cosp = 1 - 2 * (q[1] * q[1] + q[2] * q[2]) + self.state["target_roll"] = round(math.degrees(math.atan2(sinr_cosp, cosr_cosp)), 2) + sinp = 2 * (q[0] * q[2] - q[3] * q[1]) + self.state["target_pitch"] = round(math.degrees(math.asin(max(-1.0, min(1.0, sinp)))), 2) + siny_cosp = 2 * (q[0] * q[3] + q[1] * q[2]) + cosy_cosp = 1 - 2 * (q[2] * q[2] + q[3] * q[3]) + self.state["target_yaw"] = round(math.degrees(math.atan2(siny_cosp, cosy_cosp)) % 360, 2) + self._update_attitude_errors() + except Exception: + pass + + elif msg_type == 'NAV_CONTROLLER_OUTPUT': + self.state["target_roll"] = round(msg.nav_roll, 2) + self.state["target_pitch"] = round(msg.nav_pitch, 2) + bearing = self._heading(msg.target_bearing) + if bearing is not None: + self.state["target_yaw"] = bearing + if hasattr(msg, "wp_dist"): + self.state["dist_to_target_wp_m"] = max(0.0, round(float(msg.wp_dist), 1)) + self._update_attitude_errors() + + elif msg_type == 'HIGHRES_IMU': + self.state["accel_x"] = round(msg.xacc, 2) + self.state["accel_y"] = round(msg.yacc, 2) + self.state["accel_z"] = round(msg.zacc, 2) + self.state["gyro_x"] = round(math.degrees(msg.xgyro), 2) + self.state["gyro_y"] = round(math.degrees(msg.ygyro), 2) + self.state["gyro_z"] = round(math.degrees(msg.zgyro), 2) + + elif msg_type in ('RAW_IMU', 'SCALED_IMU', 'SCALED_IMU2', 'SCALED_IMU3'): + self.state["accel_x"] = round((msg.xacc / 1000.0) * 9.81, 2) + self.state["accel_y"] = round((msg.yacc / 1000.0) * 9.81, 2) + self.state["accel_z"] = round((msg.zacc / 1000.0) * 9.81, 2) + if hasattr(msg, 'xgyro'): + self.state["gyro_x"] = round(math.degrees(msg.xgyro / 1000.0), 2) + self.state["gyro_y"] = round(math.degrees(msg.ygyro / 1000.0), 2) + self.state["gyro_z"] = round(math.degrees(msg.zgyro / 1000.0), 2) + + elif msg_type == 'SERVO_OUTPUT_RAW': + pwms = [msg.servo1_raw, msg.servo2_raw, msg.servo3_raw, msg.servo4_raw] + self.state["motor_pwm"] = pwms + self.state["motor_percent"] = [max(0, min(100, int((p - 1000) / 10.0))) for p in pwms] + + elif msg_type == 'RC_CHANNELS': + if 0 <= msg.rssi < 255: + self.state["rc_rssi"] = int((msg.rssi / 254.0) * 100) + + elif msg_type == 'RADIO_STATUS': + if 0 <= msg.rssi < 255: + self.state["radio_link_quality"] = int((msg.rssi / 254.0) * 100) + if 0 <= msg.remrssi < 255 and self.state["rc_rssi"] <= 0: + self.state["rc_rssi"] = int((msg.remrssi / 254.0) * 100) + + elif msg_type == 'MISSION_CURRENT': + self.state["mission_current_seq"] = msg.seq + total = getattr(msg, "total", 0) or self.state.get("mission_total_items", 0) + if total > 0: + self.state["mission_total_items"] = total + self.state["mission_progress_percent"] = min(100, int((msg.seq / float(total)) * 100)) + self.state["mission_state"] = f"ACTIVE (WP {msg.seq}/{total})" + + elif msg_type == 'MISSION_ITEM_REACHED': + self.state["mission_current_seq"] = msg.seq + 1 + self.state["mission_state"] = f"REACHED WP {msg.seq}" + + return True + def _run_live_mavlink(self): try: from pymavlink import mavutil except ImportError: print("[!] ERROR: 'pymavlink' is not installed. Run: pip install pymavlink") + self._set_connection_state(False, "pymavlink is not installed") return ports_to_try = [self.port, "/dev/ttyACM0", "/dev/serial0", "/dev/ttyAMA0", "/dev/ttyUSB0"] @@ -409,10 +649,12 @@ def _run_live_mavlink(self): if not mav_conn: print(f"[!] Waiting for Pixhawk serial connection ({self.port}). Retrying in 2s...") + self._set_connection_state(False, f"Waiting for Pixhawk serial port {self.port}") time.sleep(2.0) continue self.mav = mav_conn + self._set_connection_state(False, "Serial port open, waiting for MAVLink heartbeat") print("[*] Waiting for MAVLink Heartbeat from Pixhawk...") try: @@ -421,34 +663,39 @@ def _run_live_mavlink(self): tgt_sys = getattr(self.mav, "target_system", 1) or 1 tgt_comp = getattr(self.mav, "target_component", 1) or 1 print(f"[✓] Heartbeat received from Pixhawk (System: {tgt_sys}, Component: {tgt_comp})") - with self._lock: - self.state["connected"] = True + self._handle_mavlink_message(hb, mavutil) + else: + self._set_connection_state(False, "Serial open but no MAVLink heartbeat") except Exception: - pass + self._set_connection_state(False, "Serial open but heartbeat wait failed") + # Activate streams and send companion heartbeat self._send_companion_heartbeat(mavutil) self._request_all_streams(mavutil) last_heartbeat_time = time.time() last_stream_request_time = time.time() + # Live Ingestion Loop while self.running: try: now = time.time() + # 1. Send Companion Heartbeat at 1 Hz (CRITICAL for ArduPilot to stream data!) if now - last_heartbeat_time >= 1.0: last_heartbeat_time = now self._send_companion_heartbeat(mavutil) + # 2. Re-request stream keep-alive every 3s if now - last_stream_request_time >= 3.0: last_stream_request_time = now self._request_all_streams(mavutil) - last_pkt = self.state.get("last_packet_timestamp", 0.0) - if self.state.get("connected", False) and last_pkt > 0 and (now - last_pkt > 10.0): - with self._lock: - self.state["connected"] = False - self.state["flight_mode"] = "NO HEARTBEAT" + with self._lock: + last_pkt = self.state.get("last_packet_timestamp", 0.0) + was_connected = self.state.get("connected", False) + if was_connected and last_pkt > 0 and (now - last_pkt > 10.0): + self._set_connection_state(False, "No MAVLink packets for 10 seconds") packets_read = 0 while packets_read < 50: @@ -456,157 +703,12 @@ def _run_live_mavlink(self): if not msg: break packets_read += 1 - - msg_type = msg.get_type() - try: - with self._lock: - self.state["last_packet_timestamp"] = now - self.state["connected"] = True - - if msg_type == 'HEARTBEAT': - self.state["armed"] = (msg.base_mode & mavutil.mavlink.MAV_MODE_FLAG_SAFETY_ARMED) != 0 - try: - mode_map = self.mav.mode_mapping() - inv_map = {v: k for k, v in mode_map.items()} - self.state["flight_mode"] = inv_map.get(msg.custom_mode, f"MODE_{msg.custom_mode}") - except Exception: - self.state["flight_mode"] = f"MODE_{msg.custom_mode}" - - sys_status_map = { - 0: "UNINIT", 1: "BOOT", 2: "CALIBRATING", 3: "STANDBY", - 4: "ACTIVE", 5: "CRITICAL", 6: "EMERGENCY" - } - self.state["system_status"] = sys_status_map.get(msg.system_status, "ACTIVE") - - elif msg_type == 'SYS_STATUS': - if msg.voltage_battery > 0: - self.state["battery_voltage"] = round(msg.voltage_battery / 1000.0, 2) - if msg.current_battery >= 0: - self.state["battery_current"] = round(msg.current_battery / 100.0, 1) - if 0 <= msg.battery_remaining <= 100: - self.state["battery_remaining"] = msg.battery_remaining - - elif msg_type == 'BATTERY_STATUS': - if len(msg.voltages) > 0: - valid_cells = [] - for v in msg.voltages[:6]: - if v not in (0, 65535): - cell_v = round(v / 1000.0, 3) - valid_cells.append(cell_v) - else: - valid_cells.append(0.0) - self.state["cell_voltages"] = valid_cells - active_cells = [c for c in valid_cells if c > 1.0] - if active_cells: - delta = int((max(active_cells) - min(active_cells)) * 1000) - self.state["cell_delta_mv"] = max(0, delta) - if msg.current_battery >= 0: - self.state["battery_current"] = round(msg.current_battery / 100.0, 1) - if 0 <= msg.battery_remaining <= 100: - self.state["battery_remaining"] = msg.battery_remaining - - elif msg_type == 'GLOBAL_POSITION_INT': - self.state["latitude"] = round(msg.lat / 1e7, 7) - self.state["longitude"] = round(msg.lon / 1e7, 7) - self.state["altitude_relative"] = round(msg.relative_alt / 1000.0, 1) - self.state["altitude_msl"] = round(msg.alt / 1000.0, 1) - if msg.hdg != 65535 and msg.hdg != 0: - self.state["heading"] = round(msg.hdg / 100.0, 1) - - elif msg_type == 'GPS_RAW_INT': - self.state["satellites"] = msg.satellites_visible - fix_map = {0: "NO FIX", 1: "NO FIX", 2: "2D FIX", 3: "3D FIX", 4: "DGPS", 5: "RTK FLT", 6: "RTK FIX"} - self.state["gps_fix_type"] = fix_map.get(msg.fix_type, "NO FIX" if msg.fix_type == 0 else "3D FIX") - self.state["hdop"] = round(msg.eph / 100.0, 2) - - elif msg_type == 'VFR_HUD': - self.state["climb_rate"] = round(msg.climb, 2) - if msg.heading != 0: - self.state["heading"] = round(msg.heading, 1) - if hasattr(msg, 'alt') and msg.alt is not None: - self.state["altitude_msl"] = round(msg.alt, 1) - if self.state["altitude_relative"] == 0.0: - self.state["altitude_relative"] = round(msg.alt, 1) - - elif msg_type in ('ATTITUDE', 'AHRS2', 'AHRS3'): - if hasattr(msg, 'roll'): - self.state["attitude_roll"] = round(math.degrees(msg.roll), 2) - self.state["attitude_pitch"] = round(math.degrees(msg.pitch), 2) - self.state["attitude_yaw"] = round(math.degrees(msg.yaw) % 360, 2) - if hasattr(msg, 'rollspeed'): - self.state["gyro_x"] = round(math.degrees(msg.rollspeed), 2) - self.state["gyro_y"] = round(math.degrees(msg.pitchspeed), 2) - self.state["gyro_z"] = round(math.degrees(msg.yawspeed), 2) - if self.state["heading"] == 0 and "attitude_yaw" in self.state: - self.state["heading"] = self.state["attitude_yaw"] - self.state["error_roll"] = round(self.state["target_roll"] - self.state["attitude_roll"], 2) - self.state["error_pitch"] = round(self.state["target_pitch"] - self.state["attitude_pitch"], 2) - - elif msg_type == 'ATTITUDE_TARGET': - try: - q = msg.q - sinr_cosp = 2 * (q[0] * q[1] + q[2] * q[3]) - cosr_cosp = 1 - 2 * (q[1] * q[1] + q[2] * q[2]) - self.state["target_roll"] = round(math.degrees(math.atan2(sinr_cosp, cosr_cosp)), 2) - sinp = 2 * (q[0] * q[2] - q[3] * q[1]) - if abs(sinp) >= 1: - self.state["target_pitch"] = round(math.copysign(90, sinp), 2) - else: - self.state["target_pitch"] = round(math.degrees(math.asin(sinp)), 2) - except Exception: - pass - elif msg_type == 'NAV_CONTROLLER_OUTPUT': - self.state["target_roll"] = round(msg.nav_roll, 2) - self.state["target_pitch"] = round(msg.nav_pitch, 2) - self.state["target_yaw"] = round(msg.target_bearing, 2) - self.state["error_roll"] = round(msg.nav_roll - self.state["attitude_roll"], 2) - self.state["error_pitch"] = round(msg.nav_pitch - self.state["attitude_pitch"], 2) - - elif msg_type == 'HIGHRES_IMU': - self.state["accel_x"] = round(msg.xacc, 2) - self.state["accel_y"] = round(msg.yacc, 2) - self.state["accel_z"] = round(msg.zacc, 2) - self.state["gyro_x"] = round(math.degrees(msg.xgyro), 2) - self.state["gyro_y"] = round(math.degrees(msg.ygyro), 2) - self.state["gyro_z"] = round(math.degrees(msg.zgyro), 2) - elif msg_type in ('RAW_IMU', 'SCALED_IMU', 'SCALED_IMU2', 'SCALED_IMU3'): - self.state["accel_x"] = round((msg.xacc / 1000.0) * 9.81, 2) - self.state["accel_y"] = round((msg.yacc / 1000.0) * 9.81, 2) - self.state["accel_z"] = round((msg.zacc / 1000.0) * 9.81, 2) - if hasattr(msg, 'xgyro') and self.state["gyro_x"] == 0: - self.state["gyro_x"] = round(math.degrees(msg.xgyro / 1000.0), 2) - self.state["gyro_y"] = round(math.degrees(msg.ygyro / 1000.0), 2) - self.state["gyro_z"] = round(math.degrees(msg.zgyro / 1000.0), 2) - - elif msg_type == 'SERVO_OUTPUT_RAW': - pwms = [msg.servo1_raw, msg.servo2_raw, msg.servo3_raw, msg.servo4_raw] - self.state["motor_pwm"] = pwms - self.state["motor_percent"] = [ - max(0, min(100, int((p - 1000) / 10.0))) for p in pwms - ] - - elif msg_type == 'RC_CHANNELS': - if msg.rssi > 0: - self.state["rc_rssi"] = int((msg.rssi / 255.0) * 100) - else: - self.state["rc_rssi"] = 90 - - elif msg_type == 'MISSION_CURRENT': - self.state["mission_current_seq"] = msg.seq - tot = self.state.get("mission_total_items", 0) - if tot > 0: - self.state["mission_progress_percent"] = min(100, int((msg.seq / float(tot)) * 100)) - self.state["mission_state"] = f"ACTIVE (WP {msg.seq}/{tot})" - - elif msg_type == 'MISSION_ITEM_REACHED': - self.state["mission_current_seq"] = msg.seq + 1 - self.state["mission_state"] = f"REACHED WP {msg.seq}" - - except Exception: - pass - - time.sleep(0.005) + self._handle_mavlink_message(msg, mavutil, now) + except Exception as e: + print(f"[!] MAVLink decode error ({msg.get_type()}): {e}") + + time.sleep(0.005) # Yield CPU except Exception as e: print(f"[!] MAVLink read error: {e}") @@ -614,7 +716,7 @@ def _run_live_mavlink(self): break def _run_simulation(self): - + """Dynamic simulation loop supporting both manual flight and autonomous mission execution.""" print("[+] Starting high-fidelity telemetry & mission autonomy simulator (10 Hz)...") t = 0.0 base_lat = 12.971598 @@ -635,6 +737,7 @@ def _run_simulation(self): is_auto = (self.state.get("flight_mode") == "AUTO") has_mission = len(self.sim_mission_items) > 0 + # Default parameters sim_lat = self.sim_current_lat sim_lon = self.sim_current_lon sim_alt = self.sim_current_alt @@ -645,16 +748,18 @@ def _run_simulation(self): mission_state = self.state.get("mission_state", "STANDBY") progress_pct = self.state.get("mission_progress_percent", 0) + # --- AUTONOMOUS MISSION FLIGHT SIMULATOR --- if is_auto and has_mission and self.sim_mission_index < len(self.sim_mission_items): item = self.sim_mission_items[self.sim_mission_index] cmd = item.get("command", 16) total_wps = len(self.sim_mission_items) progress_pct = min(100, int((self.sim_mission_index / float(total_wps)) * 100)) + # 1. Takeoff Command (MAV_CMD_NAV_TAKEOFF = 22) if cmd == 22: target_alt = item.get("z", 15.0) if sim_alt < target_alt - 0.2: - sim_alt += 0.4 + sim_alt += 0.4 # Climb at 4 m/s climb_rate = 4.0 pitch_actual = 1.5 mission_state = f"TAKEOFF ({sim_alt:.1f}m / {target_alt:.0f}m)" @@ -664,14 +769,17 @@ def _run_simulation(self): self.sim_mission_index += 1 mission_state = f"CRUISE -> WP 1" + # 2. Speed Command (MAV_CMD_DO_CHANGE_SPEED = 178) elif cmd == 178: self.sim_mission_index += 1 + # 3. Waypoint Command (MAV_CMD_NAV_WAYPOINT = 16) elif cmd == 16: target_lat = item.get("x", 0) / 1e7 target_lon = item.get("y", 0) / 1e7 target_alt = item.get("z", 15.0) + # Calculate distance and bearing to waypoint dlat = (target_lat - sim_lat) * (math.pi / 180.0) * EARTH_R dlon = (target_lon - sim_lon) * (math.pi / 180.0) * EARTH_R * math.cos(math.radians(sim_lat)) dist = math.sqrt(dlat**2 + dlon**2) @@ -681,8 +789,9 @@ def _run_simulation(self): sim_heading = (sim_heading + min(max(heading_err * 0.3, -12.0), 12.0)) % 360.0 roll_actual = round(min(max(heading_err * 0.5, -20.0), 20.0), 2) - pitch_actual = -3.5 + pitch_actual = -3.5 # Forward acceleration pitch + # Step towards target at 5.0 m/s (0.5m per 0.1s tick) step_m = min(0.5, dist) if dist > 0.01: sim_lat += (dlat / dist) * (step_m / EARTH_R) * (180.0 / math.pi) @@ -690,9 +799,11 @@ def _run_simulation(self): mission_state = f"NAV WP {self.sim_mission_index}/{total_wps - 1} ({dist:.1f}m)" + # Reached Waypoint Acceptance Radius (2.0m) if dist <= 2.0: self.sim_mission_index += 1 + # 4. Return to Launch / Land (MAV_CMD_NAV_RETURN_TO_LAUNCH = 20, MAV_CMD_NAV_LAND = 21) elif cmd in (20, 21): dlat = (base_lat - sim_lat) * (math.pi / 180.0) * EARTH_R dlon = (base_lon - sim_lon) * (math.pi / 180.0) * EARTH_R * math.cos(math.radians(sim_lat)) @@ -707,7 +818,7 @@ def _run_simulation(self): mission_state = f"RTL RETURN ({dist:.1f}m)" else: if sim_alt > 0.3: - sim_alt -= 0.3 + sim_alt -= 0.3 # Descend at 3 m/s climb_rate = -3.0 mission_state = f"RTL LANDING ({sim_alt:.1f}m)" else: @@ -727,7 +838,7 @@ def _run_simulation(self): progress_pct = 100 else: - + # Normal Loiter / Telemetry Simulation Mode sim_alt = round(max(0.0, 15.0 + 3.0 * math.sin(t * 0.2)), 1) sim_lat = round(base_lat + 0.0004 * math.sin(t * 0.1), 7) sim_lon = round(base_lon + 0.0004 * math.cos(t * 0.1), 7) @@ -773,6 +884,7 @@ def _run_simulation(self): "rc_rssi": int(92 + 5 * math.sin(t * 0.1)), "radio_link_quality": 98, "heading": round(sim_heading, 1), + "ground_speed": 5.0 if is_auto and has_mission else 2.0, "mission_state": mission_state, "mission_progress_percent": progress_pct, "mission_current_seq": self.sim_mission_index, @@ -791,8 +903,12 @@ def _run_simulation(self): "target_yaw": round(sim_heading, 1), "error_roll": round(-roll_actual, 2), "error_pitch": round(-pitch_actual, 2), + "error_yaw": 0.0, "motor_pwm": [m1, m2, m3, m4], "motor_percent": [int((m - 1000) / 10.0) for m in [m1, m2, m3, m4]], "last_packet_timestamp": time.time(), + "last_heartbeat_timestamp": time.time(), + "last_message_type": "SIMULATION", + "mavlink_status": "Simulation telemetry running", "connected": True }) diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py b/telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py b/telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py deleted file mode 100644 index aea24ea..0000000 --- a/telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py +++ /dev/null @@ -1,446 +0,0 @@ -#!/usr/bin/env python3 - -import math -import time -import threading -from typing import List, Dict, Tuple, Any - -EARTH_RADIUS = 6378137.0 - -def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - - phi1 = math.radians(lat1) - phi2 = math.radians(lat2) - delta_phi = math.radians(lat2 - lat1) - delta_lambda = math.radians(lon2 - lon1) - - a = math.sin(delta_phi / 2.0)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0)**2 - c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) - return EARTH_RADIUS * c - -def meter_offsets_to_latlon(center_lat: float, center_lon: float, dx: float, dy: float) -> Tuple[float, float]: - - dlat = (dy / EARTH_RADIUS) * (180.0 / math.pi) - dlon = (dx / (EARTH_RADIUS * math.cos(math.radians(center_lat)))) * (180.0 / math.pi) - return center_lat + dlat, center_lon + dlon - -def rotate_point(x: float, y: float, angle_deg: float) -> Tuple[float, float]: - - rad = math.radians(angle_deg) - cos_a = math.cos(rad) - sin_a = math.sin(rad) - return (x * cos_a + y * sin_a), (-x * sin_a + y * cos_a) - -class GridSearchModule: - - def __init__(self, mav_manager): - self.mav_manager = mav_manager - self.active_mission = False - self.current_mission_data: Dict[str, Any] = {} - self.mission_status = { - "state": "IDLE", - "pattern": "grid", - "center_lat": 0.0, - "center_lon": 0.0, - "radius_m": 50.0, - "altitude_m": 15.0, - "spacing_m": 10.0, - "speed_m_s": 5.0, - "angle_deg": 0.0, - "end_action": "RTL", - "waypoint_count": 0, - "current_wp_index": 0, - "total_distance_m": 0.0, - "estimated_duration_s": 0.0, - "progress_percent": 0 - } - self._lock = threading.Lock() - - def generate_grid_waypoints(self, center_lat: float, center_lon: float, - radius_m: float = 50.0, altitude_m: float = 15.0, - spacing_m: float = 10.0, angle_deg: float = 0.0) -> List[Tuple[float, float, float]]: - - spacing_m = max(3.0, spacing_m) - radius_m = max(5.0, radius_m) - waypoints = [] - - num_lanes = int((2.0 * radius_m) / spacing_m) + 1 - - for i in range(num_lanes): - - local_x = -radius_m + (i * spacing_m) - - if abs(local_x) >= radius_m: - half_chord = 0.0 - else: - half_chord = math.sqrt(radius_m**2 - local_x**2) - - if half_chord < 1.0: - continue - - if i % 2 == 0: - y_endpoints = [-half_chord, half_chord] - else: - y_endpoints = [half_chord, -half_chord] - - for local_y in y_endpoints: - - rot_x, rot_y = rotate_point(local_x, local_y, angle_deg) - wp_lat, wp_lon = meter_offsets_to_latlon(center_lat, center_lon, rot_x, rot_y) - waypoints.append((round(wp_lat, 7), round(wp_lon, 7), round(altitude_m, 1))) - - return waypoints - - def generate_spiral_waypoints(self, center_lat: float, center_lon: float, - radius_m: float = 50.0, altitude_m: float = 15.0, - spacing_m: float = 10.0) -> List[Tuple[float, float, float]]: - - spacing_m = max(4.0, spacing_m) - radius_m = max(5.0, radius_m) - waypoints = [] - - waypoints.append((round(center_lat, 7), round(center_lon, 7), round(altitude_m, 1))) - - current_r = spacing_m - step = 1 - x, y = 0.0, 0.0 - - dx, dy = spacing_m, 0.0 - while current_r <= radius_m + (spacing_m / 2.0): - for _ in range(2): - for _ in range(step): - x += dx - y += dy - dist = math.sqrt(x**2 + y**2) - if dist <= radius_m: - wp_lat, wp_lon = meter_offsets_to_latlon(center_lat, center_lon, x, y) - waypoints.append((round(wp_lat, 7), round(wp_lon, 7), round(altitude_m, 1))) - - dx, dy = -dy, dx - step += 1 - current_r = max(abs(x), abs(y)) - - return waypoints - - def generate_sector_waypoints(self, center_lat: float, center_lon: float, - radius_m: float = 50.0, altitude_m: float = 15.0, - angle_deg: float = 0.0) -> List[Tuple[float, float, float]]: - - waypoints = [] - - angles = [angle_deg, angle_deg + 120.0, angle_deg + 240.0] - - for a in angles: - - waypoints.append((round(center_lat, 7), round(center_lon, 7), round(altitude_m, 1))) - rx, ry = rotate_point(0.0, radius_m, a) - w1_lat, w1_lon = meter_offsets_to_latlon(center_lat, center_lon, rx, ry) - waypoints.append((round(w1_lat, 7), round(w1_lon, 7), round(altitude_m, 1))) - - rx2, ry2 = rotate_point(0.0, radius_m, a + 120.0) - w2_lat, w2_lon = meter_offsets_to_latlon(center_lat, center_lon, rx2, ry2) - waypoints.append((round(w2_lat, 7), round(w2_lon, 7), round(altitude_m, 1))) - - waypoints.append((round(center_lat, 7), round(center_lon, 7), round(altitude_m, 1))) - return waypoints - - def calculate_mission_metrics(self, waypoints: List[Tuple[float, float, float]], speed_m_s: float = 5.0) -> Tuple[float, float]: - - if not waypoints or len(waypoints) < 2: - return 0.0, 0.0 - - total_dist = 0.0 - for i in range(len(waypoints) - 1): - p1 = waypoints[i] - p2 = waypoints[i + 1] - dist = haversine_distance(p1[0], p1[1], p2[0], p2[1]) - - dz = abs(p2[2] - p1[2]) - total_dist += math.sqrt(dist**2 + dz**2) - - speed_m_s = max(1.0, speed_m_s) - estimated_time_s = total_dist / speed_m_s - - estimated_time_s += len(waypoints) * 1.5 - - return round(total_dist, 1), round(estimated_time_s, 0) - - def plan_mission(self, center_lat: float, center_lon: float, - radius_m: float = 50.0, altitude_m: float = 15.0, - spacing_m: float = 10.0, speed_m_s: float = 5.0, - pattern: str = "grid", angle_deg: float = 0.0, - end_action: str = "RTL") -> Dict[str, Any]: - - if pattern == "spiral": - waypoints = self.generate_spiral_waypoints(center_lat, center_lon, radius_m, altitude_m, spacing_m) - elif pattern == "sector": - waypoints = self.generate_sector_waypoints(center_lat, center_lon, radius_m, altitude_m, angle_deg) - else: - pattern = "grid" - waypoints = self.generate_grid_waypoints(center_lat, center_lon, radius_m, altitude_m, spacing_m, angle_deg) - - total_dist, est_duration = self.calculate_mission_metrics(waypoints, speed_m_s) - - preview_items = [] - - preview_items.append({ - "seq": 0, "type": "HOME", "label": "Dynamic Home (Armed Position)", - "lat": center_lat, "lon": center_lon, "alt": 0.0 - }) - - preview_items.append({ - "seq": 1, "type": "TAKEOFF", "label": f"Vertical Takeoff to {altitude_m}m AGL", - "lat": 0.0, "lon": 0.0, "alt": altitude_m, "dynamic": True - }) - - preview_items.append({ - "seq": 2, "type": "SPEED", "label": f"Set Cruise Speed ({speed_m_s} m/s)", - "speed_m_s": speed_m_s - }) - - for idx, wp in enumerate(waypoints, start=3): - preview_items.append({ - "seq": idx, "type": "WAYPOINT", "label": f"WP {idx - 2}", - "lat": wp[0], "lon": wp[1], "alt": wp[2] - }) - - preview_items.append({ - "seq": len(preview_items), "type": end_action.upper(), - "label": f"End Action: {end_action.upper()}", - "lat": center_lat, "lon": center_lon, "alt": 0.0 - }) - - mission_payload = { - "status": "ready", - "pattern": pattern, - "center": [center_lat, center_lon], - "radius_m": radius_m, - "altitude_m": altitude_m, - "spacing_m": spacing_m, - "speed_m_s": speed_m_s, - "angle_deg": angle_deg, - "end_action": end_action.upper(), - "waypoint_count": len(waypoints), - "total_items": len(preview_items), - "total_distance_m": total_dist, - "estimated_duration_s": est_duration, - "waypoints": [[wp[0], wp[1], wp[2]] for wp in waypoints], - "items": preview_items - } - - with self._lock: - self.current_mission_data = mission_payload - - return mission_payload - - def build_mavlink_mission_items(self, center_lat: float, center_lon: float, - waypoints: List[Tuple[float, float, float]], - takeoff_alt: float = 15.0, speed_m_s: float = 5.0, - end_action: str = "RTL") -> List[Dict[str, Any]]: - - items = [] - - items.append({ - "seq": 0, - "frame": 0, - "command": 16, - "current": 0, "autocontinue": 1, - "param1": 0, "param2": 0, "param3": 0, "param4": 0, - "x": int(center_lat * 1e7), "y": int(center_lon * 1e7), "z": 0.0 - }) - - items.append({ - "seq": 1, - "frame": 3, - "command": 22, - "current": 0, "autocontinue": 1, - "param1": 0, "param2": 0, "param3": 0, "param4": 0, - "x": 0, "y": 0, "z": float(takeoff_alt) - }) - - items.append({ - "seq": 2, - "frame": 3, - "command": 178, - "current": 0, "autocontinue": 1, - "param1": 0, - "param2": float(speed_m_s), - "param3": -1, "param4": 0, - "x": 0, "y": 0, "z": 0.0 - }) - - for idx, wp in enumerate(waypoints, start=3): - items.append({ - "seq": idx, - "frame": 3, - "command": 16, - "current": 0, "autocontinue": 1, - "param1": 0, - "param2": 2.0, - "param3": 0, "param4": 0, - "x": int(wp[0] * 1e7), "y": int(wp[1] * 1e7), "z": float(wp[2]) - }) - - end_cmd = 20 - if end_action.upper() == "LAND": - end_cmd = 21 - elif end_action.upper() == "LOITER": - end_cmd = 17 - - items.append({ - "seq": len(items), - "frame": 3, - "command": end_cmd, - "current": 0, "autocontinue": 1, - "param1": 0, "param2": 0, "param3": 0, "param4": 0, - "x": 0, "y": 0, "z": 0.0 - }) - - return items - - def upload_mission_to_pixhawk(self, center_lat: float, center_lon: float, - waypoints: List[Tuple[float, float, float]], - takeoff_alt: float = 15.0, speed_m_s: float = 5.0, - end_action: str = "RTL") -> bool: - - items = self.build_mavlink_mission_items(center_lat, center_lon, waypoints, takeoff_alt, speed_m_s, end_action) - - if not self.mav_manager: - print("[!] Mission Planner: MAVLinkManager not available.") - return False - - print(f"[*] Mission Planner: Uploading {len(items)} items to Pixhawk (Takeoff + {len(waypoints)} WPs + {end_action})...") - success = self.mav_manager.upload_mission_items(items) - - with self._lock: - if success: - self.mission_status.update({ - "state": "UPLOADED", - "waypoint_count": len(waypoints), - "total_items": len(items), - "current_wp_index": 1, - "progress_percent": 0 - }) - else: - self.mission_status["state"] = "UPLOAD_FAILED" - - return success - - def trigger_mission_upload(self, lat: float = None, lon: float = None, - radius_m: float = 50.0, altitude_m: float = 15.0, - spacing_m: float = 10.0, speed_m_s: float = 5.0, - pattern: str = "grid", angle_deg: float = 0.0, - end_action: str = "RTL") -> Dict[str, Any]: - - snap = self.mav_manager.get_telemetry_snapshot() if self.mav_manager else {} - center_lat = lat if (lat is not None and lat != 0.0) else snap.get("latitude", 12.971598) - center_lon = lon if (lon is not None and lon != 0.0) else snap.get("longitude", 77.594562) - - if center_lat == 0.0 or center_lon == 0.0: - center_lat, center_lon = 12.971598, 77.594562 - - plan = self.plan_mission(center_lat, center_lon, radius_m, altitude_m, spacing_m, speed_m_s, pattern, angle_deg, end_action) - - success = self.upload_mission_to_pixhawk( - center_lat, center_lon, plan["waypoints"], altitude_m, speed_m_s, end_action - ) - - with self._lock: - self.mission_status.update({ - "state": "READY" if success else "FAILED", - "center_lat": center_lat, - "center_lon": center_lon, - "radius_m": radius_m, - "altitude_m": altitude_m, - "spacing_m": spacing_m, - "speed_m_s": speed_m_s, - "pattern": pattern, - "angle_deg": angle_deg, - "end_action": end_action.upper(), - "waypoint_count": plan["waypoint_count"], - "total_distance_m": plan["total_distance_m"], - "estimated_duration_s": plan["estimated_duration_s"] - }) - - return { - "status": "success" if success else "error", - "message": "Mission uploaded successfully to Pixhawk" if success else "Mission upload failed", - "plan": plan - } - - def start_mission(self, auto_arm: bool = False) -> Dict[str, Any]: - - if not self.mav_manager: - return {"status": "error", "message": "No MAVLink connection"} - - if auto_arm: - self.mav_manager.set_arm(True) - time.sleep(0.5) - - success = self.mav_manager.set_flight_mode("AUTO") - with self._lock: - if success: - self.mission_status["state"] = "ACTIVE" - return {"status": "success" if success else "error", "mode": "AUTO"} - - def pause_mission(self) -> Dict[str, Any]: - - if not self.mav_manager: - return {"status": "error", "message": "No MAVLink connection"} - - success = self.mav_manager.set_flight_mode("LOITER") - with self._lock: - if success: - self.mission_status["state"] = "PAUSED" - return {"status": "success" if success else "error", "mode": "LOITER"} - - def resume_mission(self) -> Dict[str, Any]: - - if not self.mav_manager: - return {"status": "error", "message": "No MAVLink connection"} - - success = self.mav_manager.set_flight_mode("AUTO") - with self._lock: - if success: - self.mission_status["state"] = "ACTIVE" - return {"status": "success" if success else "error", "mode": "AUTO"} - - def abort_mission(self) -> Dict[str, Any]: - - if not self.mav_manager: - return {"status": "error", "message": "No MAVLink connection"} - - success = self.mav_manager.set_flight_mode("RTL") - with self._lock: - self.mission_status["state"] = "ABORTED" - return {"status": "success" if success else "error", "mode": "RTL"} - - def clear_mission(self) -> Dict[str, Any]: - - if not self.mav_manager: - return {"status": "error", "message": "No MAVLink connection"} - - success = self.mav_manager.clear_all_missions() - with self._lock: - self.mission_status["state"] = "IDLE" - self.mission_status["waypoint_count"] = 0 - self.current_mission_data = {} - return {"status": "success" if success else "error", "message": "Mission cleared"} - - def get_status(self) -> Dict[str, Any]: - - with self._lock: - st = dict(self.mission_status) - if self.current_mission_data: - st["preview"] = self.current_mission_data - return st - -if __name__ == "__main__": - from mavlink_manager import MAVLinkManager - mgr = MAVLinkManager(simulate=True) - mgr.start() - grid = GridSearchModule(mgr) - plan = grid.plan_mission(center_lat=12.971598, center_lon=77.594562, radius_m=40, spacing_m=10, angle_deg=30) - print("Generated plan summary:") - print(f"Waypoints: {plan['waypoint_count']} | Distance: {plan['total_distance_m']}m | Duration: {plan['estimated_duration_s']}s") - mgr.stop() diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/radio_tx_module.py b/telemetry/handheld-nrf-monitor/pi-code/modules/radio_tx_module.py deleted file mode 100644 index 0b29d50..0000000 --- a/telemetry/handheld-nrf-monitor/pi-code/modules/radio_tx_module.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 - -import time -import struct -import threading - -R_REGISTER = 0x00 -W_REGISTER = 0x20 -W_TX_PAYLOAD = 0xA0 -FLUSH_TX = 0xE1 -FLUSH_RX = 0xE2 - -REG_CONFIG = 0x00 -REG_EN_AA = 0x01 -REG_SETUP_AW = 0x03 -REG_SETUP_RETR = 0x04 -REG_RF_CH = 0x05 -REG_RF_SETUP = 0x06 -REG_STATUS = 0x07 -REG_TX_ADDR = 0x10 -REG_RX_ADDR_P0 = 0x0A -REG_FEATURE = 0x1D -REG_DYNPD = 0x1C - -PACKET_MAGIC = 0xAA -RF_CHANNEL = 90 -TX_ADDRESS = b"2Node" - -class RadioTXModule: - def __init__(self, mav_manager, rate_hz: float = 5.0, enabled: bool = True): - self.mav_manager = mav_manager - self.rate_hz = rate_hz - self.enabled = enabled - self.running = False - self.thread = None - self.radio_available = False - self.spi = None - self.ce_fn = None - self.seq = 0 - - def start(self): - if not self.enabled: - print("[*] Module 1 (Radio TX): Disabled by user configuration.") - return - - self._init_hardware() - self.running = True - self.thread = threading.Thread(target=self._tx_loop, daemon=True) - self.thread.start() - print(f"[✓] Module 1 (Radio TX): Running at {self.rate_hz} Hz ({'HARDWARE SPI' if self.radio_available else 'SIMULATED MOCK'}).") - - def stop(self): - self.running = False - if self.thread and self.thread.is_alive(): - self.thread.join(timeout=1.0) - if self.spi: - try: - self.spi.close() - except Exception: - pass - - def _init_hardware(self): - try: - import spidev - self.spi = spidev.SpiDev() - self.spi.open(0, 0) - self.spi.max_speed_hz = 2000000 - self.spi.mode = 0 - - try: - from gpiozero import DigitalOutputDevice - ce_dev = DigitalOutputDevice(22, active_high=True, initial_value=False) - self.ce_fn = lambda s: ce_dev.on() if s else ce_dev.off() - except Exception: - try: - import RPi.GPIO as GPIO - GPIO.setmode(GPIO.BCM) - GPIO.setup(22, GPIO.OUT, initial=GPIO.LOW) - self.ce_fn = lambda s: GPIO.output(22, GPIO.HIGH if s else GPIO.LOW) - except Exception: - self.ce_fn = lambda s: None - - self._set_ce(False) - time.sleep(0.02) - aw = self._read_reg(REG_SETUP_AW) - if aw in (0x01, 0x02, 0x03): - self._configure_nrf24() - self.radio_available = True - else: - print(f"[!] Module 1 (Radio TX): NRF24 SPI test returned 0x{aw:02X}. Radio hardware not present.") - self.radio_available = False - - except Exception as e: - print(f"[!] Module 1 (Radio TX): SPI/GPIO driver not available ({e}). Running in mock mode.") - self.radio_available = False - - def _set_ce(self, state: bool): - if self.ce_fn: - try: - self.ce_fn(state) - except Exception: - pass - - def _read_reg(self, reg: int) -> int: - if not self.spi: - return 0 - resp = self.spi.xfer2([R_REGISTER | (reg & 0x1F), 0x00]) - return resp[1] - - def _write_reg(self, reg: int, val): - if not self.spi: - return - if isinstance(val, (bytes, bytearray, list)): - self.spi.xfer2([W_REGISTER | (reg & 0x1F)] + list(val)) - else: - self.spi.xfer2([W_REGISTER | (reg & 0x1F), val]) - - def _configure_nrf24(self): - self.spi.xfer2([FLUSH_TX]) - self.spi.xfer2([FLUSH_RX]) - self._write_reg(REG_STATUS, 0x70) - self._write_reg(REG_SETUP_AW, 0x03) - self._write_reg(REG_EN_AA, 0x00) - self._write_reg(REG_SETUP_RETR, 0x00) - self._write_reg(REG_RF_CH, RF_CHANNEL) - self._write_reg(REG_RF_SETUP, 0x26) - self._write_reg(REG_FEATURE, 0x04) - self._write_reg(REG_DYNPD, 0x01) - self._write_reg(REG_TX_ADDR, TX_ADDRESS) - self._write_reg(REG_RX_ADDR_P0, TX_ADDRESS) - self._write_reg(REG_CONFIG, 0x0E) - time.sleep(0.01) - - def _pack_telemetry(self, t: dict) -> bytes: - - bat_v = t.get("battery_voltage", 0.0) - bat_mv = int(bat_v * 1000) if bat_v > 0.5 else 5000 - - rssi = int(t.get("rc_rssi", 0)) - if rssi <= 0 and t.get("connected", False): - rssi = 90 - - alt = t.get("altitude_relative", 0.0) - if alt == 0.0 and t.get("altitude_msl", 0.0) != 0.0: - alt = t.get("altitude_msl", 0.0) - alt_cm = int(alt * 100) - - lat_e7 = int((t.get("latitude", 0.0)) * 1e7) - lon_e7 = int((t.get("longitude", 0.0)) * 1e7) - sats = int(t.get("satellites", 0)) - - payload = struct.pack( - " bool: - if not self.radio_available or not self.spi: - return True - try: - self.spi.xfer2([FLUSH_TX]) - self._write_reg(REG_STATUS, 0x70) - full_payload = payload + bytes(32 - len(payload)) if len(payload) < 32 else payload[:32] - self.spi.xfer2([W_TX_PAYLOAD] + list(full_payload)) - self._set_ce(True) - time.sleep(0.00002) - self._set_ce(False) - time.sleep(0.002) - status = self._read_reg(REG_STATUS) - self._write_reg(REG_STATUS, 0x70) - return (status & 0x20) != 0 - except Exception: - return False - - def _tx_loop(self): - interval = 1.0 / self.rate_hz if self.rate_hz > 0 else 0.2 - while self.running: - start_time = time.time() - telemetry = self.mav_manager.get_telemetry_snapshot() - packet = self._pack_telemetry(telemetry) - self._transmit(packet) - self.seq = (self.seq + 1) % 256 - - elapsed = time.time() - start_time - sleep_time = max(0.01, interval - elapsed) - time.sleep(sleep_time) - -if __name__ == "__main__": - from mavlink_manager import MAVLinkManager - mgr = MAVLinkManager(simulate=True) - mgr.start() - tx = RadioTXModule(mgr, rate_hz=5.0) - tx.start() - time.sleep(2.0) - tx.stop() - mgr.stop() - print("[✓] RadioTXModule test passed.") diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py b/telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py deleted file mode 100644 index d31ed6c..0000000 --- a/telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env python3 - -import os -import time -import json -import base64 -import hashlib -import struct -import threading -from http.server import HTTPServer, BaseHTTPRequestHandler -from socketserver import ThreadingMixIn - -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -WEB_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", "..", "live-web-dashboard")) - -class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): - daemon_threads = True - -def encode_ws_frame(message: str) -> bytes: - - data = message.encode('utf-8') - length = len(data) - frame = bytearray([0x81]) - - if length <= 125: - frame.append(length) - elif length <= 65535: - frame.append(126) - frame.extend(struct.pack("!H", length)) - else: - frame.append(127) - frame.extend(struct.pack("!Q", length)) - - frame.extend(data) - return bytes(frame) - -class WebDashboardModule: - def __init__(self, mav_manager, sbc_monitor, grid_module=None, port: int = 8000): - self.mav_manager = mav_manager - self.sbc_monitor = sbc_monitor - self.grid_module = grid_module - self.port = port - self.httpd = None - self.server_thread = None - self.ws_thread = None - self.running = False - self.connected_websockets = [] - self.ws_lock = threading.Lock() - - def get_full_telemetry(self) -> dict: - t = self.mav_manager.get_telemetry_snapshot() if self.mav_manager else {} - t["sbc"] = self.sbc_monitor.get_metrics_snapshot() if self.sbc_monitor else {} - if self.grid_module: - t["mission_progress"] = self.grid_module.get_status() - return t - - def start(self): - handler_factory = self._create_handler() - server_address = ("0.0.0.0", self.port) - self.httpd = ThreadedHTTPServer(server_address, handler_factory) - self.running = True - - self.server_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) - self.server_thread.start() - - self.ws_thread = threading.Thread(target=self._ws_broadcaster, daemon=True) - self.ws_thread.start() - - print(f"[✓] Module 2 (Web Dashboard): Mission Control live at http://0.0.0.0:{self.port}") - - def stop(self): - self.running = False - if self.httpd: - self.httpd.shutdown() - - def _ws_broadcaster(self): - while self.running: - time.sleep(0.1) - with self.ws_lock: - if not self.connected_websockets: - continue - snapshot = self.get_full_telemetry() - frame = encode_ws_frame(json.dumps(snapshot)) - dead_sockets = [] - for sock in self.connected_websockets: - try: - sock.sendall(frame) - except Exception: - dead_sockets.append(sock) - for d in dead_sockets: - if d in self.connected_websockets: - self.connected_websockets.remove(d) - - def _create_handler(self): - parent = self - - class Handler(BaseHTTPRequestHandler): - def log_message(self, format, *args): - pass - - def do_GET(self): - - if self.headers.get("Upgrade", "").lower() == "websocket": - self.handle_websocket() - return - - clean_path = self.path.split("?")[0] - - if clean_path == "/api/telemetry": - payload = json.dumps(parent.get_full_telemetry()) - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(payload.encode("utf-8")) - return - - if clean_path in ("/api/mission/status", "/api/mission/grid_search") and parent.grid_module: - payload = json.dumps(parent.grid_module.get_status()) - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(payload.encode("utf-8")) - return - - if clean_path in ("/", "/index.html"): - target_file = os.path.join(WEB_DIR, "index.html") - content_type = "text/html; charset=utf-8" - elif clean_path == "/style.css": - target_file = os.path.join(WEB_DIR, "style.css") - content_type = "text/css" - elif clean_path == "/app.js": - target_file = os.path.join(WEB_DIR, "app.js") - content_type = "application/javascript" - else: - self.send_error(404, "File Not Found") - return - - if os.path.exists(target_file): - with open(target_file, "rb") as f: - content = f.read() - self.send_response(200) - self.send_header("Content-Type", content_type) - self.send_header("Content-Length", str(len(content))) - self.end_headers() - self.wfile.write(content) - else: - self.send_error(404, "Static file not found") - - def do_POST(self): - clean_path = self.path.split("?")[0] - content_len = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(content_len).decode("utf-8") if content_len > 0 else "{}" - params = json.loads(body) if body else {} - - if clean_path == "/api/mission/preview" and parent.grid_module: - snap = parent.mav_manager.get_telemetry_snapshot() if parent.mav_manager else {} - lat = params.get("lat") if (params.get("lat") is not None and params.get("lat") != 0) else snap.get("latitude", 12.971598) - lon = params.get("lon") if (params.get("lon") is not None and params.get("lon") != 0) else snap.get("longitude", 77.594562) - - plan = parent.grid_module.plan_mission( - center_lat=lat, - center_lon=lon, - radius_m=float(params.get("radius", 50.0)), - altitude_m=float(params.get("altitude", 15.0)), - spacing_m=float(params.get("spacing", 10.0)), - speed_m_s=float(params.get("speed", 5.0)), - pattern=params.get("pattern", "grid"), - angle_deg=float(params.get("angle", 0.0)), - end_action=params.get("end_action", "RTL") - ) - self._send_json(200, plan) - return - - elif clean_path in ("/api/mission/upload", "/api/mission/grid_search") and parent.grid_module: - res = parent.grid_module.trigger_mission_upload( - lat=params.get("lat"), - lon=params.get("lon"), - radius_m=float(params.get("radius", 50.0)), - altitude_m=float(params.get("altitude", 15.0)), - spacing_m=float(params.get("spacing", 10.0)), - speed_m_s=float(params.get("speed", 5.0)), - pattern=params.get("pattern", "grid"), - angle_deg=float(params.get("angle", 0.0)), - end_action=params.get("end_action", "RTL") - ) - self._send_json(200, res) - return - - elif clean_path == "/api/mission/start" and parent.grid_module: - res = parent.grid_module.start_mission(auto_arm=params.get("auto_arm", True)) - self._send_json(200, res) - return - - elif clean_path == "/api/mission/pause" and parent.grid_module: - res = parent.grid_module.pause_mission() - self._send_json(200, res) - return - - elif clean_path == "/api/mission/resume" and parent.grid_module: - res = parent.grid_module.resume_mission() - self._send_json(200, res) - return - - elif clean_path == "/api/mission/abort" and parent.grid_module: - res = parent.grid_module.abort_mission() - self._send_json(200, res) - return - - elif clean_path == "/api/mission/clear" and parent.grid_module: - res = parent.grid_module.clear_mission() - self._send_json(200, res) - return - - elif clean_path == "/api/mode" and parent.mav_manager: - mode = params.get("mode", "STABILIZE") - success = parent.mav_manager.set_flight_mode(mode) - self._send_json(200, {"status": "success" if success else "error", "mode": mode}) - return - - elif clean_path == "/api/arm" and parent.mav_manager: - arm = params.get("arm", True) - success = parent.mav_manager.set_arm(arm) - self._send_json(200, {"status": "success" if success else "error", "armed": arm}) - return - - self.send_error(404, "Endpoint not found.") - - def _send_json(self, status_code: int, data: dict): - payload = json.dumps(data) - self.send_response(status_code) - self.send_header("Content-Type", "application/json") - self.send_header("Access-Control-Allow-Origin", "*") - self.end_headers() - self.wfile.write(payload.encode("utf-8")) - - def handle_websocket(self): - key = self.headers.get("Sec-WebSocket-Key") - if not key: - self.send_error(400, "Missing Sec-WebSocket-Key") - return - - guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" - accept = base64.b64encode(hashlib.sha1((key + guid).encode("utf-8")).digest()).decode("utf-8") - - self.send_response(101, "Switching Protocols") - self.send_header("Upgrade", "websocket") - self.send_header("Connection", "Upgrade") - self.send_header("Sec-WebSocket-Accept", accept) - self.end_headers() - - sock = self.request - with parent.ws_lock: - parent.connected_websockets.append(sock) - - try: - while True: - data = sock.recv(1024) - if not data: - break - except Exception: - pass - finally: - with parent.ws_lock: - if sock in parent.connected_websockets: - parent.connected_websockets.remove(sock) - - return Handler diff --git a/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py b/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py new file mode 100644 index 0000000..e7d4687 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/radio_tx_module.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +""" +====================================================================================== +Module 1: NRF24L01+ Radio Telemetry Transmitter (Airborne) +File: pi/tele/radio_tx_module.py + +Description: + Takes live flight telemetry from MAVLinkManager, packs it into the compact + 20-byte binary struct (with 8-bit XOR checksum), and broadcasts it at 5 Hz + via hardware SPI0 to the handheld ESP32 ground receiver (SH1106 OLED). +====================================================================================== +""" + +import time +import struct +import threading + +# Register definitions +R_REGISTER = 0x00 +W_REGISTER = 0x20 +W_TX_PAYLOAD = 0xA0 +FLUSH_TX = 0xE1 +FLUSH_RX = 0xE2 + +REG_CONFIG = 0x00 +REG_EN_AA = 0x01 +REG_SETUP_AW = 0x03 +REG_SETUP_RETR = 0x04 +REG_RF_CH = 0x05 +REG_RF_SETUP = 0x06 +REG_STATUS = 0x07 +REG_TX_ADDR = 0x10 +REG_RX_ADDR_P0 = 0x0A +REG_FEATURE = 0x1D +REG_DYNPD = 0x1C + +PACKET_MAGIC = 0xAA +RF_CHANNEL = 90 +TX_ADDRESS = b"2Node" +FRAME_BASIC = 0 +FRAME_POWER_SBC = 1 +FRAME_ATTITUDE = 2 +FRAME_MOTORS = 3 + +class RadioTXModule: + def __init__(self, mav_manager, sbc_monitor=None, rate_hz: float = 5.0, enabled: bool = True): + self.mav_manager = mav_manager + self.sbc_monitor = sbc_monitor + self.rate_hz = rate_hz + self.enabled = enabled + self.running = False + self.thread = None + self.radio_available = False + self.spi = None + self.ce_fn = None + self.seq = 0 + + def start(self): + if not self.enabled: + print("[*] Module 1 (Radio TX): Disabled by user configuration.") + return + + self._init_hardware() + self.running = True + self.thread = threading.Thread(target=self._tx_loop, daemon=True) + self.thread.start() + print(f"[✓] Module 1 (Radio TX): Running at {self.rate_hz} Hz ({'HARDWARE SPI' if self.radio_available else 'SIMULATED MOCK'}).") + + def stop(self): + self.running = False + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=1.0) + if self.spi: + try: + self.spi.close() + except Exception: + pass + + def _init_hardware(self): + try: + import spidev + self.spi = spidev.SpiDev() + self.spi.open(0, 0) + self.spi.max_speed_hz = 2000000 + self.spi.mode = 0 + + # Setup CE pin + try: + from gpiozero import DigitalOutputDevice + ce_dev = DigitalOutputDevice(25, active_high=True, initial_value=False) + self.ce_fn = lambda s: ce_dev.on() if s else ce_dev.off() + except Exception: + try: + import RPi.GPIO as GPIO + GPIO.setmode(GPIO.BCM) + GPIO.setup(25, GPIO.OUT, initial=GPIO.LOW) + self.ce_fn = lambda s: GPIO.output(25, GPIO.HIGH if s else GPIO.LOW) + except Exception: + self.ce_fn = lambda s: None + + # Test SPI communication with radio + self._set_ce(False) + time.sleep(0.02) + aw = self._read_reg(REG_SETUP_AW) + if aw in (0x01, 0x02, 0x03): + self._configure_nrf24() + self.radio_available = True + else: + print(f"[!] Module 1 (Radio TX): NRF24 SPI test returned 0x{aw:02X}. Radio hardware not present.") + self.radio_available = False + + except Exception as e: + print(f"[!] Module 1 (Radio TX): SPI/GPIO driver not available ({e}). Running in mock mode.") + self.radio_available = False + + def _set_ce(self, state: bool): + if self.ce_fn: + try: + self.ce_fn(state) + except Exception: + pass + + def _read_reg(self, reg: int) -> int: + if not self.spi: + return 0 + resp = self.spi.xfer2([R_REGISTER | (reg & 0x1F), 0x00]) + return resp[1] + + def _write_reg(self, reg: int, val): + if not self.spi: + return + if isinstance(val, (bytes, bytearray, list)): + self.spi.xfer2([W_REGISTER | (reg & 0x1F)] + list(val)) + else: + self.spi.xfer2([W_REGISTER | (reg & 0x1F), val]) + + def _configure_nrf24(self): + self.spi.xfer2([FLUSH_TX]) + self.spi.xfer2([FLUSH_RX]) + self._write_reg(REG_STATUS, 0x70) + self._write_reg(REG_SETUP_AW, 0x03) # 5-byte address width + self._write_reg(REG_EN_AA, 0x00) # No Auto-ACK (broadcast mode) + self._write_reg(REG_SETUP_RETR, 0x00) # No retries + self._write_reg(REG_RF_CH, RF_CHANNEL) # Channel 90 (2.490 GHz) + self._write_reg(REG_RF_SETUP, 0x26) # 250 kbps, PA_MAX + self._write_reg(REG_FEATURE, 0x04) # Enable Dynamic Payload + self._write_reg(REG_DYNPD, 0x01) # Pipe 0 DPL + self._write_reg(REG_TX_ADDR, TX_ADDRESS) + self._write_reg(REG_RX_ADDR_P0, TX_ADDRESS) + self._write_reg(REG_CONFIG, 0x0E) # PWR_UP=1, CRC=16bit, TX mode + time.sleep(0.01) + + def _clamp_int(self, value, low: int, high: int) -> int: + try: + return max(low, min(high, int(round(value)))) + except Exception: + return low + + def _u8(self, value) -> bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + return struct.pack(" bytes: + if len(body) > 31: + raise ValueError(f"NRF24 frame body too large: {len(body)} bytes") + body.extend(b"\x00" * (31 - len(body))) + checksum = 0 + for byte in body: + checksum ^= byte + return bytes(body + bytes([checksum])) + + def _frame_body(self, frame_type: int, flags: int = 0) -> bytearray: + return bytearray([PACKET_MAGIC, self.seq % 256, frame_type & 0xFF, flags & 0xFF]) + + def _gps_fix_id(self, fix: str) -> int: + return { + "NO FIX": 0, + "2D FIX": 2, + "3D FIX": 3, + "DGPS": 4, + "RTK FLT": 5, + "RTK FIX": 6, + }.get(str(fix).upper(), 0) + + def _flight_mode_id(self, mode: str) -> int: + modes = { + "DISCONNECTED": 0, "STABILIZE": 1, "ALT_HOLD": 2, "AUTO": 3, + "GUIDED": 4, "LOITER": 5, "RTL": 6, "LAND": 7, "POSHOLD": 8, + "BRAKE": 9, "NO HEARTBEAT": 10, + } + return modes.get(str(mode).upper(), 255) + + def _system_status_id(self, status: str) -> int: + return { + "UNINIT": 0, "BOOT": 1, "CALIBRATING": 2, "STANDBY": 3, + "ACTIVE": 4, "CRITICAL": 5, "EMERGENCY": 6, + }.get(str(status).upper(), 4) + + def _mission_state_id(self, state: str) -> int: + text = str(state).upper() + if "TAKEOFF" in text: + return 2 + if "NAV" in text or "ACTIVE" in text or "CRUISE" in text: + return 3 + if "RTL" in text or "RETURN" in text: + return 4 + if "LAND" in text: + return 5 + if "PAUSED" in text or "LOITER" in text: + return 6 + if "COMPLETED" in text: + return 7 + if "READY" in text: + return 1 + return 0 + + def _pack_telemetry_frames(self, t: dict, sbc: dict) -> list: + bat_v = t.get("battery_voltage", 0.0) + bat_mv = int(bat_v * 1000) if bat_v > 0.5 else 5000 + rssi = int(t.get("rc_rssi", 0)) + if rssi <= 0 and t.get("connected", False): + rssi = 90 + link_quality = int(t.get("radio_link_quality", 0) or rssi) + + alt = t.get("altitude_relative", 0.0) + if alt == 0.0 and t.get("altitude_msl", 0.0) != 0.0: + alt = t.get("altitude_msl", 0.0) + + cells = list(t.get("cell_voltages", []))[:6] + cells.extend([0.0] * (6 - len(cells))) + motor_pwm = list(t.get("motor_pwm", []))[:4] + motor_pwm.extend([1000] * (4 - len(motor_pwm))) + motor_percent = list(t.get("motor_percent", []))[:4] + motor_percent.extend([0] * (4 - len(motor_percent))) + + flags = (1 if t.get("armed") else 0) | (2 if t.get("connected") else 0) + basic = self._frame_body(FRAME_BASIC, flags) + basic += self._u16(bat_mv) + basic += self._i16(rssi) + basic += self._i32(alt * 100) + basic += self._i32(t.get("latitude", 0.0) * 1e7) + basic += self._i32(t.get("longitude", 0.0) * 1e7) + basic += self._u8(t.get("satellites", 0)) + basic += self._u16(t.get("heading", 0.0) * 100) + basic += self._u8(t.get("battery_remaining", 0)) + basic += self._u8(self._gps_fix_id(t.get("gps_fix_type", "NO FIX"))) + basic += self._u8(self._flight_mode_id(t.get("flight_mode", "DISCONNECTED"))) + basic += self._u8(self._system_status_id(t.get("system_status", "ACTIVE"))) + basic += self._u8(link_quality) + basic += self._u8(t.get("mission_progress_percent", 0)) + basic += self._u8(len([c for c in cells if c > 1.0])) + + power = self._frame_body(FRAME_POWER_SBC, flags) + power += self._i16(t.get("battery_current", 0.0) * 100) + for cell in cells: + power += self._u16(cell * 1000) + power += self._u16(t.get("cell_delta_mv", 0)) + power += self._u8(sbc.get("cpu_load_percent", 0)) + power += self._i16(sbc.get("cpu_temp_c", 0.0) * 10) + power += self._u8(sbc.get("ram_percent", 0)) + power += self._u8(sbc.get("disk_percent", 0)) + power += self._u16(sbc.get("uptime_seconds", 0) / 60) + power += self._u16(0) + + attitude = self._frame_body(FRAME_ATTITUDE, flags) + attitude += self._i16(t.get("attitude_roll", 0.0) * 100) + attitude += self._i16(t.get("attitude_pitch", 0.0) * 100) + attitude += self._u16(t.get("attitude_yaw", 0.0) * 100) + attitude += self._i16(t.get("target_roll", 0.0) * 100) + attitude += self._i16(t.get("target_pitch", 0.0) * 100) + attitude += self._u16(t.get("target_yaw", 0.0) * 100) + attitude += self._i16(t.get("error_roll", 0.0) * 100) + attitude += self._i16(t.get("error_pitch", 0.0) * 100) + attitude += self._i16(t.get("gyro_x", 0.0) * 100) + attitude += self._i16(t.get("gyro_y", 0.0) * 100) + attitude += self._i16(t.get("gyro_z", 0.0) * 100) + attitude += self._i16(t.get("climb_rate", 0.0) * 100) + attitude += self._i16(t.get("altitude_msl", 0.0) * 10) + + motors = self._frame_body(FRAME_MOTORS, flags) + for value in motor_percent: + motors += self._u8(value) + for value in motor_pwm: + motors += self._u16(value) + motors += self._u16(t.get("mission_current_seq", 0)) + motors += self._u16(t.get("mission_total_items", 0)) + motors += self._u16(t.get("dist_to_target_wp_m", 0.0) * 10) + motors += self._u16(t.get("ground_speed", 0.0) * 100) + motors += self._i16(t.get("accel_x", 0.0) * 100) + motors += self._i16(t.get("accel_y", 0.0) * 100) + motors += self._i16(t.get("accel_z", 0.0) * 100) + motors += self._u8(self._mission_state_id(t.get("mission_state", "STANDBY"))) + + return [ + self._finish_frame(basic), + self._finish_frame(power), + self._finish_frame(attitude), + self._finish_frame(motors), + ] + + def _transmit(self, payload: bytes) -> bool: + if not self.radio_available or not self.spi: + return True + try: + self.spi.xfer2([FLUSH_TX]) + self._write_reg(REG_STATUS, 0x70) + full_payload = payload + bytes(32 - len(payload)) if len(payload) < 32 else payload[:32] + self.spi.xfer2([W_TX_PAYLOAD] + list(full_payload)) + self._set_ce(True) + time.sleep(0.00002) # 20 µs pulse + self._set_ce(False) + time.sleep(0.002) + status = self._read_reg(REG_STATUS) + self._write_reg(REG_STATUS, 0x70) + return (status & 0x20) != 0 + except Exception: + return False + + def _tx_loop(self): + interval = 1.0 / self.rate_hz if self.rate_hz > 0 else 0.2 + while self.running: + start_time = time.time() + telemetry = self.mav_manager.get_telemetry_snapshot() + sbc = self.sbc_monitor.get_metrics_snapshot() if self.sbc_monitor else {} + for packet in self._pack_telemetry_frames(telemetry, sbc): + self._transmit(packet) + time.sleep(0.003) + self.seq = (self.seq + 1) % 256 + + elapsed = time.time() - start_time + sleep_time = max(0.01, interval - elapsed) + time.sleep(sleep_time) + +if __name__ == "__main__": + from mavlink_manager import MAVLinkManager + mgr = MAVLinkManager(simulate=True) + mgr.start() + tx = RadioTXModule(mgr, rate_hz=5.0) + tx.start() + time.sleep(2.0) + tx.stop() + mgr.stop() + print("[✓] RadioTXModule test passed.") diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/sbc_monitor.py b/telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py similarity index 80% rename from telemetry/handheld-nrf-monitor/pi-code/modules/sbc_monitor.py rename to telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py index 0fb0d7b..51faaf5 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/modules/sbc_monitor.py +++ b/telemetry/handheld-nrf-monitor/pi-code/sbc_monitor.py @@ -1,4 +1,19 @@ #!/usr/bin/env python3 +""" +====================================================================================== +Module: Raspberry Pi SBC Health & Hardware Monitor +File: pi/tele/sbc_monitor.py + +Description: + Extracts real-time Single Board Computer (SBC) performance metrics directly from + Linux /proc and /sys filesystems (zero mandatory external dependencies): + - CPU Core Temperature (°C) + - CPU Utilization (%) + - RAM Total, Used, and Usage (%) + - Disk Total, Used, and Usage (%) + - System Uptime (seconds) +====================================================================================== +""" import os import time @@ -22,7 +37,7 @@ def _init_cpu_stats(self): pass def get_cpu_temp(self) -> float: - + """Reads Raspberry Pi CPU temperature in Celsius.""" for path in ["/sys/class/thermal/thermal_zone0/temp", "/sys/devices/virtual/thermal/thermal_zone0/temp"]: if os.path.exists(path): try: @@ -34,7 +49,7 @@ def get_cpu_temp(self) -> float: return 46.5 def get_cpu_load(self) -> float: - + """Calculates instantaneous CPU load percentage.""" try: with open("/proc/stat", "r") as f: fields = [float(x) for x in f.readline().strip().split()[1:]] @@ -55,7 +70,7 @@ def get_cpu_load(self) -> float: return 18.5 def get_ram_usage(self): - + """Returns RAM (used_mb, total_mb, percent).""" try: meminfo = {} with open("/proc/meminfo", "r") as f: @@ -80,7 +95,7 @@ def get_ram_usage(self): return 420, 1940, 21.6 def get_disk_usage(self): - + """Returns disk usage percentage and free GB.""" try: total, used, free = shutil.disk_usage("/") percent = round((used / total) * 100.0, 1) @@ -90,7 +105,7 @@ def get_disk_usage(self): return 32.0, 18.5 def get_uptime(self) -> int: - + """Returns system uptime in seconds.""" try: with open("/proc/uptime", "r") as f: return int(float(f.readline().split()[0])) @@ -98,7 +113,7 @@ def get_uptime(self) -> int: return 3600 def get_metrics_snapshot(self) -> dict: - + """Aggregates all SBC metrics into a dictionary.""" used_mb, total_mb, ram_percent = self.get_ram_usage() disk_percent, disk_free_gb = self.get_disk_usage() diff --git a/telemetry/live-web-dashboard/.dockerignore b/telemetry/live-web-dashboard/.dockerignore new file mode 100644 index 0000000..8c633ec --- /dev/null +++ b/telemetry/live-web-dashboard/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +*.tsbuildinfo +npm-debug.log* diff --git a/telemetry/live-web-dashboard/src/App.tsx b/telemetry/live-web-dashboard/src/App.tsx index a3f891f..ea1bf65 100644 --- a/telemetry/live-web-dashboard/src/App.tsx +++ b/telemetry/live-web-dashboard/src/App.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { GaugeIcon, SlidersHorizontalIcon, WavesIcon } from "lucide-react"; +import { GaugeIcon, PlugZapIcon, SlidersHorizontalIcon, WavesIcon, WifiIcon } from "lucide-react"; import { AltitudeCard } from "./components/AltitudeCard"; import { AttitudeIndicator } from "./components/AttitudeIndicator"; import { BatteryBox } from "./components/BatteryBox"; @@ -14,6 +14,8 @@ import { useTelemetry } from "./hooks/useTelemetry"; import type { Telemetry } from "./types/telemetry"; function activeWarning(t: Telemetry): string | null { + if (t.source.status === "offline") return "esp json offline"; + if (t.source.status === "stale") return "nrf packets stale"; if (t.battery.percent < 25) return "battery low · rtl advised"; if (t.link.loss > 5) return "downlink packet loss"; if (t.sbc.temp > 72) return "sbc thermal limit"; @@ -21,9 +23,127 @@ function activeWarning(t: Telemetry): string | null { return null; } +function sourceTone(status: Telemetry["source"]["status"]): string { + if (status === "online") return "bg-sig-green"; + if (status === "stale") return "bg-sig-amber"; + return "bg-sig-red"; +} + +const ESP_IP_STORAGE_KEY = "droneTelemetryEspIp"; + +function initialEspInput(): string { + const fromQuery = new URLSearchParams(window.location.search).get("telemetryUrl"); + if (fromQuery) { + try { + return new URL(fromQuery).host; + } catch { + return fromQuery.replace(/^https?:\/\//, "").replace(/\/telemetry\.json\/?$/, ""); + } + } + return window.localStorage.getItem(ESP_IP_STORAGE_KEY) ?? ""; +} + +function telemetryEndpointFromInput(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) return ""; + const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + const url = new URL(withProtocol); + if (url.pathname === "/" || url.pathname === "") { + url.pathname = "/telemetry.json"; + } + url.search = ""; + url.hash = ""; + return url.toString(); +} + +function ConnectionPanel({ + value, + error, + onChange, + onSubmit +}: { + value: string; + error: string; + onChange: (value: string) => void; + onSubmit: () => void; +}) { + return ( +
+
{ + event.preventDefault(); + onSubmit(); + }} + > +
+ + + +
+

Connect ESP32 Telemetry

+

Enter the IP shown on the ESP32 OLED.

+
+
+ + +
+ onChange(event.target.value)} + placeholder="10.160.142.17" + className="h-10 min-w-0 flex-1 rounded border border-hud-line bg-hud-bg px-3 font-mono text-sm tabular text-hud-text outline-none transition focus:border-teal-300 focus:ring-2 focus:ring-teal-300/20" + /> + +
+ + {error ?

{error}

: null} + +
+
Example: 10.160.142.17
+
Dashboard will read: http://<esp-ip>/telemetry.json
+
+
+
+ ); +} + export function App({ streaming = true }: { streaming?: boolean }) { const [live, setLive] = useState(streaming); - const t = useTelemetry(live); + const [espInput, setEspInput] = useState(initialEspInput); + const [endpoint, setEndpoint] = useState(""); + const [connectError, setConnectError] = useState(""); + const t = useTelemetry(live, endpoint); + + const connect = () => { + try { + const nextEndpoint = telemetryEndpointFromInput(espInput); + if (nextEndpoint.length === 0) { + setConnectError("Enter the ESP32 IP address from the OLED."); + return; + } + setEndpoint(nextEndpoint); + setLive(true); + setConnectError(""); + window.localStorage.setItem(ESP_IP_STORAGE_KEY, espInput.trim()); + } catch { + setConnectError("Use an IP or host like 10.160.142.17."); + } + }; + + if (endpoint.length === 0) { + return ; + } return (
@@ -37,6 +157,19 @@ export function App({ streaming = true }: { streaming?: boolean }) { />
+ {t.source.status !== "online" ? ( +
+ {t.source.message} + {t.source.endpoint} +
+ ) : null} +
@@ -114,10 +247,19 @@ export function App({ streaming = true }: { streaming?: boolean }) {
-
diff --git a/telemetry/live-web-dashboard/src/components/LinkCard.tsx b/telemetry/live-web-dashboard/src/components/LinkCard.tsx index 8c1dab0..bc933ec 100644 --- a/telemetry/live-web-dashboard/src/components/LinkCard.tsx +++ b/telemetry/live-web-dashboard/src/components/LinkCard.tsx @@ -13,8 +13,8 @@ export function LinkCard({ link, className }: { link: LinkState; className?: str bodyClassName="flex flex-col justify-between gap-2" >
- - + +
diff --git a/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx b/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx index 7cb82c4..e321012 100644 --- a/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx +++ b/telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx @@ -23,8 +23,7 @@ function MetricRow({ label, value, pct, color }: { label: string; value: string; export function SbcStatusCard({ sbc, className }: { sbc: SbcState; className?: string }) { const tempTone = sbc.temp > 70 ? "#f2555a" : sbc.temp > 62 ? "#f5a524" : "#3fcf8e"; - const memPct = (sbc.memUsed / sbc.memTotal) * 100; - const diskPct = (sbc.diskUsed / sbc.diskTotal) * 100; + const [cpuUsage, memoryUsage, storageUsage] = sbc.load; return (
80 ? "#f2555a" : "#4c8dff"} /> - + - +
- {sbc.cores.map((core, i) => ( -
-
- 85 ? "#f5a524" : "#4c8dff" }} - /> + {sbc.cores.map((core, i) => { + const height = core > 0 ? Math.max(8, core) : 0; + + return ( +
+
+ 85 ? "#f5a524" : "#4c8dff" }} + /> +
+ C{i}
- C{i} -
- ))} + ); + })}
- load {sbc.load.map((l) => l.toFixed(2)).join(" ")} + usage cpu {cpuUsage.toFixed(0)}% mem {memoryUsage.toFixed(0)}% disk {storageUsage.toFixed(0)}%
uptime {formatUptime(sbc.uptime)}
diff --git a/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts b/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts index 79e39f2..8a8dcad 100644 --- a/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts +++ b/telemetry/live-web-dashboard/src/hooks/useTelemetry.ts @@ -1,13 +1,12 @@ import { useEffect, useState } from "react"; import { cellLabels } from "../data/telemetry"; -import type { GyroPoint, MotorPoint, PidPoint, Telemetry } from "../types/telemetry"; +import type { Telemetry } from "../types/telemetry"; const SAMPLES = 64; -const TICK_MS = 260; -const LIVE_ENDPOINT = "http://192.168.4.1/telemetry.json"; - +const POLL_MS = 1000; +const REQUEST_TIMEOUT_MS = 2500; +const OFFLINE_AFTER_FAILURES = 3; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); -const wobble = (amp: number) => (Math.random() - 0.5) * 2 * amp; const num = (value: unknown, fallback = 0) => (typeof value === "number" && Number.isFinite(value) ? value : fallback); type RawTelemetry = { @@ -54,206 +53,135 @@ type RawTelemetry = { }; }; -function getLiveEndpoint(): string { - const configured = new URLSearchParams(window.location.search).get("telemetryUrl"); - return configured && configured.trim().length > 0 ? configured : LIVE_ENDPOINT; +function zeroPid() { + return Array.from({ length: SAMPLES }, (_, t) => ({ t, roll: 0, pitch: 0, yaw: 0 })); } -function seedSeries(): { - pid: PidPoint[]; - motors: MotorPoint[]; - gyro: GyroPoint[]; -} { - const pid: PidPoint[] = []; - const motors: MotorPoint[] = []; - const gyro: GyroPoint[] = []; - - for (let i = 0; i < SAMPLES; i += 1) { - const p = i / 6; - pid.push({ - t: i, - roll: Math.sin(p) * 6 + wobble(1.4), - pitch: Math.sin(p * 0.8 + 1.2) * 4.5 + wobble(1.2), - yaw: Math.sin(p * 0.45 + 2.4) * 3 + wobble(0.8) - }); - motors.push({ - t: i, - m1: 58 + Math.sin(p * 0.9) * 5 + wobble(1.6), - m2: 61 + Math.sin(p * 0.9 + 1.6) * 5 + wobble(1.6), - m3: 56 + Math.sin(p * 0.9 + 3.1) * 5 + wobble(1.6), - m4: 63 + Math.sin(p * 0.9 + 4.7) * 5 + wobble(1.6) - }); - gyro.push({ - t: i, - x: Math.sin(p * 1.4) * 22 + wobble(6), - y: Math.sin(p * 1.1 + 0.9) * 17 + wobble(6), - z: Math.sin(p * 0.7 + 2.2) * 11 + wobble(4) - }); - } - - return { pid, motors, gyro }; +function zeroMotors() { + return Array.from({ length: SAMPLES }, (_, t) => ({ t, m1: 0, m2: 0, m3: 0, m4: 0 })); } -function initial(): Telemetry { - const series = seedSeries(); - const cells = [4.02, 3.98, 4.01, 3.95]; +function zeroGyro() { + return Array.from({ length: SAMPLES }, (_, t) => ({ t, x: 0, y: 0, z: 0 })); +} +function offlineTelemetry(endpoint = "", message = "Enter ESP IP to start"): Telemetry { return { - clock: SAMPLES, + clock: 0, + source: { + status: "offline", + endpoint, + message, + lastUpdated: 0 + }, battery: { - total: cells.reduce((a, b) => a + b, 0), - percent: 74, - current: 18.4, - consumed: 2140, - cells: cells.map((volts, i) => ({ label: cellLabels[i], volts })) + total: 0, + percent: 0, + current: 0, + consumed: 0, + cells: cellLabels.slice(0, 4).map((label) => ({ label, volts: 0 })) }, nav: { - lat: 12.971834, - lon: 77.593122, - heading: 142, - sats: 14, - hdop: 0.82, - rssi: -63, - fix: "RTK FLOAT", - groundSpeed: 8.4 + lat: 0, + lon: 0, + heading: 0, + sats: 0, + hdop: 99.9, + rssi: 0, + fix: "NO JSON", + groundSpeed: 0 }, altitude: { - relative: 62.4, - msl: 982.7, - vspeed: 0.6, - home: 920.3 + relative: 0, + msl: 0, + vspeed: 0, + home: 0 }, link: { - rssi: -68, - rate: 92, - loss: 1.4, - latency: 34, - history: Array.from({ length: 28 }, () => 78 + Math.random() * 20) + rssi: 0, + rate: 0, + loss: 100, + latency: 0, + history: Array.from({ length: 28 }, () => 0) }, flight: { - mode: "AUTO", - armed: true, - failsafe: false, - mission: "WAYPOINT NAV", - wp: "7 / 12", - link: "OK" + mode: "NO ESP JSON", + armed: false, + failsafe: true, + mission: "ESP JSON OFFLINE", + wp: "0 / 0", + link: "LOST" }, sbc: { - cpu: 42, - cores: [38, 51, 33, 46], - memUsed: 2.9, - memTotal: 8, - temp: 58.2, - uptime: 9412, - diskUsed: 21.4, - diskTotal: 64, - load: [0.82, 0.71, 0.64] + cpu: 0, + cores: [0, 0, 0, 0], + memPercent: 0, + temp: 0, + uptime: 0, + diskPercent: 0, + load: [0, 0, 0] }, - attitude: { roll: 6.2, pitch: -3.4, yaw: 142 }, - pid: series.pid, - motors: series.motors, - gyro: series.gyro + attitude: { roll: 0, pitch: 0, yaw: 0 }, + pid: zeroPid(), + motors: zeroMotors(), + gyro: zeroGyro() }; } -function step(prev: Telemetry): Telemetry { +function markJsonOffline(prev: Telemetry, endpoint: string, detail: string): Telemetry { const t = prev.clock + 1; - const p = t / 6; - const cells = prev.battery.cells.map((cell) => ({ - ...cell, - volts: clamp(cell.volts - 0.00035 + wobble(0.004), 3.2, 4.2) - })); - const total = cells.reduce((a, b) => a + b.volts, 0); - const heading = (prev.nav.heading + wobble(1.1) + 360.15) % 360; - const relativeAltitude = clamp(prev.altitude.relative + prev.altitude.vspeed * 0.26 + wobble(0.05), 0, 400); - const verticalSpeed = clamp(prev.altitude.vspeed + wobble(0.16), -2.4, 2.4); - const linkRate = clamp(prev.link.rate + wobble(3), 62, 100); return { + ...prev, clock: t, - battery: { - total, - percent: clamp(Math.round(((total / 4 - 3.35) / (4.2 - 3.35)) * 100), 0, 100), - current: clamp(prev.battery.current + wobble(0.6), 11, 27), - consumed: prev.battery.consumed + 1, - cells + source: { + status: "offline", + endpoint, + message: `ESP JSON unavailable: ${detail}`, + lastUpdated: prev.source.lastUpdated }, nav: { ...prev.nav, - lat: prev.nav.lat + 0.0000098, - lon: prev.nav.lon + 0.0000074, - heading, - sats: clamp(Math.round(prev.nav.sats + wobble(0.4)), 11, 17), - hdop: clamp(prev.nav.hdop + wobble(0.02), 0.6, 1.4), - rssi: clamp(Math.round(prev.nav.rssi + wobble(0.9)), -78, -52), - groundSpeed: clamp(prev.nav.groundSpeed + wobble(0.25), 3, 14) - }, - altitude: { - ...prev.altitude, - relative: relativeAltitude, - msl: prev.altitude.home + relativeAltitude, - vspeed: verticalSpeed + fix: "NO JSON", + rssi: 0, + groundSpeed: 0 }, link: { - rssi: clamp(Math.round(prev.link.rssi + wobble(1.2)), -84, -54), - rate: linkRate, - loss: clamp(prev.link.loss + wobble(0.25), 0, 7.5), - latency: clamp(Math.round(prev.link.latency + wobble(3)), 18, 96), - history: [...prev.link.history.slice(-27), linkRate] - }, - flight: prev.flight, - sbc: { - ...prev.sbc, - cpu: clamp(prev.sbc.cpu + wobble(4), 18, 88), - cores: prev.sbc.cores.map((core) => clamp(core + wobble(7), 8, 97)), - memUsed: clamp(prev.sbc.memUsed + wobble(0.06), 1.8, 6.4), - temp: clamp(prev.sbc.temp + wobble(0.5), 44, 76), - uptime: prev.sbc.uptime + 1, - diskUsed: prev.sbc.diskUsed, - load: [ - clamp(prev.sbc.load[0] + wobble(0.06), 0.1, 3.2), - prev.sbc.load[1], - prev.sbc.load[2] - ] + rssi: 0, + rate: 0, + loss: 100, + latency: 0, + history: [...prev.link.history.slice(-27), 0] }, - attitude: { - roll: clamp(Math.sin(p * 0.7) * 14 + wobble(1.6), -32, 32), - pitch: clamp(Math.sin(p * 0.5 + 1.1) * 9 + wobble(1.2), -24, 24), - yaw: heading + flight: { + ...prev.flight, + mode: "NO ESP JSON", + armed: false, + failsafe: true, + mission: "CHECK ESP WIFI JSON", + link: "LOST" }, - pid: [ - ...prev.pid.slice(-(SAMPLES - 1)), - { - t, - roll: Math.sin(p) * 6 + wobble(1.4), - pitch: Math.sin(p * 0.8 + 1.2) * 4.5 + wobble(1.2), - yaw: Math.sin(p * 0.45 + 2.4) * 3 + wobble(0.8) - } - ], - motors: [ - ...prev.motors.slice(-(SAMPLES - 1)), - { - t, - m1: 58 + Math.sin(p * 0.9) * 5 + wobble(1.8), - m2: 61 + Math.sin(p * 0.9 + 1.6) * 5 + wobble(1.8), - m3: 56 + Math.sin(p * 0.9 + 3.1) * 5 + wobble(1.8), - m4: 63 + Math.sin(p * 0.9 + 4.7) * 5 + wobble(1.8) - } - ], - gyro: [ - ...prev.gyro.slice(-(SAMPLES - 1)), - { - t, - x: Math.sin(p * 1.4) * 22 + wobble(7), - y: Math.sin(p * 1.1 + 0.9) * 17 + wobble(7), - z: Math.sin(p * 0.7 + 2.2) * 11 + wobble(5) - } - ] + pid: [...prev.pid.slice(-(SAMPLES - 1)), { t, roll: 0, pitch: 0, yaw: 0 }], + motors: [...prev.motors.slice(-(SAMPLES - 1)), { t, m1: 0, m2: 0, m3: 0, m4: 0 }], + gyro: [...prev.gyro.slice(-(SAMPLES - 1)), { t, x: 0, y: 0, z: 0 }] }; } -function fromLive(prev: Telemetry, raw: RawTelemetry): Telemetry { +function markJsonDegraded(prev: Telemetry, endpoint: string, detail: string, failures: number): Telemetry { + return { + ...prev, + source: { + status: "stale", + endpoint, + message: `ESP JSON slow: ${detail} (${failures}/${OFFLINE_AFTER_FAILURES})`, + lastUpdated: prev.source.lastUpdated + } + }; +} + +function fromLive(prev: Telemetry, raw: RawTelemetry, endpoint: string, latencyMs: number): Telemetry { const t = prev.clock + 1; + const nrfFresh = raw.connected !== false; const rawCells = Array.isArray(raw.cell_voltages) ? raw.cell_voltages.filter((cell) => cell > 0.5).slice(0, 6) : []; const cells = (rawCells.length > 0 ? rawCells : prev.battery.cells.map((cell) => cell.volts)).map((volts, i) => ({ label: cellLabels[i] ?? `${i + 1}S`, @@ -265,12 +193,21 @@ function fromLive(prev: Telemetry, raw: RawTelemetry): Telemetry { const motors = Array.isArray(raw.motor_percent) ? raw.motor_percent : []; const lastMotors = prev.motors[prev.motors.length - 1] ?? { m1: 0, m2: 0, m3: 0, m4: 0 }; const sbc = raw.sbc ?? {}; - const ramPercent = num(sbc.ram_percent, prev.sbc.memTotal > 0 ? (prev.sbc.memUsed / prev.sbc.memTotal) * 100 : 0); - const memTotal = num(sbc.ram_total_mb, prev.sbc.memTotal * 1024) / 1024 || prev.sbc.memTotal; - const memUsed = num(sbc.ram_used_mb, (ramPercent / 100) * memTotal * 1024) / 1024; + const ramPercent = clamp(num(sbc.ram_percent, prev.sbc.memPercent), 0, 100); + const diskPercent = clamp(num(sbc.disk_percent, prev.sbc.diskPercent), 0, 100); + const rssiPercent = clamp(Math.round(num(raw.rc_rssi, 0)), 0, 100); + const reportedQuality = num(raw.radio_link_quality, 0); + const linkRate = nrfFresh ? clamp(reportedQuality > 0 ? reportedQuality : rssiPercent > 0 ? rssiPercent : 95, 0, 100) : 0; + const linkLoss = nrfFresh ? clamp(100 - linkRate, 0, 100) : 100; return { clock: t, + source: { + status: nrfFresh ? "online" : "stale", + endpoint, + message: nrfFresh ? "ESP JSON live" : "ESP JSON reachable, waiting for fresh NRF packets", + lastUpdated: Date.now() + }, battery: { total, percent, @@ -284,9 +221,9 @@ function fromLive(prev: Telemetry, raw: RawTelemetry): Telemetry { heading, sats: clamp(Math.round(num(raw.satellites, prev.nav.sats)), 0, 32), hdop: num(raw.hdop, prev.nav.hdop), - rssi: Math.round(num(raw.rc_rssi, prev.nav.rssi)), + rssi: rssiPercent, fix: raw.gps_fix_type ?? prev.nav.fix, - groundSpeed: num(raw.ground_speed, prev.nav.groundSpeed) + groundSpeed: nrfFresh ? num(raw.ground_speed, prev.nav.groundSpeed) : 0 }, altitude: { relative: num(raw.altitude_relative, prev.altitude.relative), @@ -295,98 +232,117 @@ function fromLive(prev: Telemetry, raw: RawTelemetry): Telemetry { home: prev.altitude.home }, link: { - rssi: Math.round(num(raw.rc_rssi, prev.link.rssi)), - rate: clamp(num(raw.radio_link_quality, raw.connected === false ? 0 : 95), 0, 100), - loss: raw.connected === false ? 100 : 0, - latency: prev.link.latency, - history: [...prev.link.history.slice(-27), clamp(num(raw.radio_link_quality, raw.connected === false ? 0 : 95), 0, 100)] + rssi: rssiPercent, + rate: linkRate, + loss: linkLoss, + latency: latencyMs, + history: [...prev.link.history.slice(-27), linkRate] }, flight: { - mode: raw.flight_mode ?? prev.flight.mode, - armed: raw.armed ?? prev.flight.armed, - failsafe: raw.connected === false, - mission: raw.mission_state ?? prev.flight.mission, + mode: nrfFresh ? raw.flight_mode ?? prev.flight.mode : "NO NRF", + armed: nrfFresh ? raw.armed ?? prev.flight.armed : false, + failsafe: !nrfFresh, + mission: nrfFresh ? raw.mission_state ?? prev.flight.mission : "NRF LINK STALE", wp: `${Math.round(num(raw.mission_current_seq, 0))} / ${Math.round(num(raw.mission_total_items, 0))}`, - link: raw.connected === false ? "LOST" : "OK" + link: nrfFresh ? "OK" : "LOST" }, sbc: { cpu: clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100), - cores: prev.sbc.cores, - memUsed, - memTotal, + cores: Array.from({ length: 4 }, () => clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100)), + memPercent: ramPercent, temp: num(sbc.cpu_temp_c, prev.sbc.temp), uptime: num(sbc.uptime_seconds, prev.sbc.uptime), - diskUsed: clamp(num(sbc.disk_percent, prev.sbc.diskUsed), 0, 100), - diskTotal: prev.sbc.diskTotal, - load: prev.sbc.load + diskPercent, + load: [clamp(num(sbc.cpu_load_percent, prev.sbc.cpu), 0, 100), ramPercent, diskPercent] }, attitude: { - roll: num(raw.attitude_roll, prev.attitude.roll), - pitch: num(raw.attitude_pitch, prev.attitude.pitch), - yaw: num(raw.attitude_yaw, heading) + roll: nrfFresh ? num(raw.attitude_roll, prev.attitude.roll) : 0, + pitch: nrfFresh ? num(raw.attitude_pitch, prev.attitude.pitch) : 0, + yaw: nrfFresh ? num(raw.attitude_yaw, heading) : heading }, pid: [ ...prev.pid.slice(-(SAMPLES - 1)), { t, - roll: num(raw.error_roll, 0), - pitch: num(raw.error_pitch, 0), - yaw: num(raw.error_yaw, 0) + roll: nrfFresh ? num(raw.error_roll, 0) : 0, + pitch: nrfFresh ? num(raw.error_pitch, 0) : 0, + yaw: nrfFresh ? num(raw.error_yaw, 0) : 0 } ], motors: [ ...prev.motors.slice(-(SAMPLES - 1)), { t, - m1: num(motors[0], lastMotors.m1), - m2: num(motors[1], lastMotors.m2), - m3: num(motors[2], lastMotors.m3), - m4: num(motors[3], lastMotors.m4) + m1: nrfFresh ? num(motors[0], lastMotors.m1) : 0, + m2: nrfFresh ? num(motors[1], lastMotors.m2) : 0, + m3: nrfFresh ? num(motors[2], lastMotors.m3) : 0, + m4: nrfFresh ? num(motors[3], lastMotors.m4) : 0 } ], gyro: [ ...prev.gyro.slice(-(SAMPLES - 1)), { t, - x: num(raw.gyro_x, 0), - y: num(raw.gyro_y, 0), - z: num(raw.gyro_z, 0) + x: nrfFresh ? num(raw.gyro_x, 0) : 0, + y: nrfFresh ? num(raw.gyro_y, 0) : 0, + z: nrfFresh ? num(raw.gyro_z, 0) : 0 } ] }; } -export function useTelemetry(live: boolean): Telemetry { - const [state, setState] = useState(initial); +function errorText(error: unknown): string { + if (error instanceof DOMException && error.name === "AbortError") return "request timeout"; + if (error instanceof Error) return error.message; + return "request failed"; +} + +export function useTelemetry(live: boolean, endpoint: string): Telemetry { + const [state, setState] = useState(() => offlineTelemetry(endpoint)); + + useEffect(() => { + setState(offlineTelemetry(endpoint, endpoint ? "Waiting for ESP JSON" : "Enter ESP IP to start")); + }, [endpoint]); useEffect(() => { - if (!live) return; - const endpoint = getLiveEndpoint(); - let failedPolls = 0; + if (!live || endpoint.length === 0) return; + let stopped = false; + let failures = 0; + let timer: number | undefined; const poll = async () => { const controller = new AbortController(); - const timeout = window.setTimeout(() => controller.abort(), 180); + const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const startedAt = performance.now(); try { const response = await fetch(endpoint, { cache: "no-store", signal: controller.signal }); - if (!response.ok) throw new Error(`telemetry http ${response.status}`); + if (!response.ok) throw new Error(`http ${response.status}`); const raw = (await response.json()) as RawTelemetry; - failedPolls = 0; - setState((prev) => fromLive(prev, raw)); - } catch { - failedPolls += 1; - if (failedPolls > 3) { - setState(step); - } + const latencyMs = Math.max(0, Math.round(performance.now() - startedAt)); + failures = 0; + setState((prev) => fromLive(prev, raw, endpoint, latencyMs)); + } catch (error) { + failures += 1; + const detail = errorText(error); + setState((prev) => + failures >= OFFLINE_AFTER_FAILURES + ? markJsonOffline(prev, endpoint, detail) + : markJsonDegraded(prev, endpoint, detail, failures) + ); } finally { window.clearTimeout(timeout); + if (!stopped) { + timer = window.setTimeout(() => void poll(), POLL_MS); + } } }; void poll(); - const id = window.setInterval(() => void poll(), TICK_MS); - return () => window.clearInterval(id); - }, [live]); + return () => { + stopped = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [live, endpoint]); return state; } diff --git a/telemetry/live-web-dashboard/src/types/telemetry.ts b/telemetry/live-web-dashboard/src/types/telemetry.ts index bd8ee2e..a123b1e 100644 --- a/telemetry/live-web-dashboard/src/types/telemetry.ts +++ b/telemetry/live-web-dashboard/src/types/telemetry.ts @@ -51,12 +51,10 @@ export interface FlightState { export interface SbcState { cpu: number; cores: number[]; - memUsed: number; - memTotal: number; + memPercent: number; temp: number; uptime: number; - diskUsed: number; - diskTotal: number; + diskPercent: number; load: [number, number, number]; } @@ -88,8 +86,18 @@ export interface GyroPoint { z: number; } +export type TelemetrySourceStatus = "online" | "stale" | "offline"; + +export interface TelemetrySource { + status: TelemetrySourceStatus; + endpoint: string; + message: string; + lastUpdated: number; +} + export interface Telemetry { clock: number; + source: TelemetrySource; battery: BatteryState; nav: NavState; altitude: AltitudeState; From ce38d9c35d69ac332ab2c29335a960ab209c7c90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 21 Sep 2026 16:58:37 +0000 Subject: [PATCH 3/5] fix telemetry dashboard build and unit labels Co-authored-by: TheLostLeo <166975150+TheLostLeo@users.noreply.github.com> --- telemetry/live-web-dashboard/src/App.tsx | 4 ++-- .../live-web-dashboard/src/components/FlightStatusCard.tsx | 3 ++- telemetry/live-web-dashboard/src/components/LinkCard.tsx | 2 +- telemetry/live-web-dashboard/src/components/SbcStatusCard.tsx | 3 +-- telemetry/live-web-dashboard/tsconfig.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/telemetry/live-web-dashboard/src/App.tsx b/telemetry/live-web-dashboard/src/App.tsx index ea1bf65..ca0924b 100644 --- a/telemetry/live-web-dashboard/src/App.tsx +++ b/telemetry/live-web-dashboard/src/App.tsx @@ -195,8 +195,8 @@ export function App({ streaming = true }: { streaming?: boolean }) { className="h-[196px]" footer={ <> - err · deg · 60 s window - rate 50 hz + err · deg · 64 s window + rate 1 hz } /> diff --git a/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx b/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx index 9212f55..3e89e4b 100644 --- a/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx +++ b/telemetry/live-web-dashboard/src/components/FlightStatusCard.tsx @@ -1,8 +1,9 @@ import { PlaneIcon } from "lucide-react"; import { Chip, Panel } from "./Panel"; import type { FlightState } from "../types/telemetry"; +import type { ReactNode } from "react"; -function Row({ label, children }: { label: string; children: React.ReactNode }) { +function Row({ label, children }: { label: string; children: ReactNode }) { return (
{label} diff --git a/telemetry/live-web-dashboard/src/components/LinkCard.tsx b/telemetry/live-web-dashboard/src/components/LinkCard.tsx index bc933ec..b6b1368 100644 --- a/telemetry/live-web-dashboard/src/components/LinkCard.tsx +++ b/telemetry/live-web-dashboard/src/components/LinkCard.tsx @@ -3,7 +3,7 @@ import { Chip, Panel, Stat } from "./Panel"; import type { LinkState } from "../types/telemetry"; export function LinkCard({ link, className }: { link: LinkState; className?: string }) { - const healthy = link.loss < 3 && link.rssi > -78; + const healthy = link.loss < 3 && link.rssi > 45; return ( Date: Tue, 22 Sep 2026 12:59:37 +0530 Subject: [PATCH 4/5] fix(telemetry): address PR review findings --- telemetry/README.md | 69 ++- .../handheld-nrf-monitor/esp-code/esp.ino | 54 ++- .../handheld-nrf-monitor/pi-code/main.py | 29 +- .../pi-code/modules/__init__.py | 1 + .../pi-code/modules/grid_search_module.py | 446 ++++++++++++++++++ .../pi-code/modules/web_dashboard_module.py | 270 +++++++++++ telemetry/live-web-dashboard/src/App.tsx | 6 +- .../src/components/LinkCard.tsx | 2 +- .../src/components/NavSignalBox.tsx | 4 +- .../src/components/TopBar.tsx | 2 +- .../src/hooks/useTelemetry.ts | 8 + 11 files changed, 854 insertions(+), 37 deletions(-) create mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py create mode 100644 telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py diff --git a/telemetry/README.md b/telemetry/README.md index 6c2fbb5..f4facef 100644 --- a/telemetry/README.md +++ b/telemetry/README.md @@ -4,37 +4,37 @@ Telemetry contains the drone companion-computer code, handheld ground receiver f ## Directory Layout -- `handheld-nrf-monitor/` - Raspberry Pi and ESP32 code for collecting Pixhawk telemetry and sending the compact NRF24L01+ stream to the handheld receiver. -- `live-web-dashboard/` - Vite React dashboard source. Build it before serving from the Raspberry Pi, or run it with Docker/Vite on a laptop. +- `handheld-nrf-monitor/` - Raspberry Pi and ESP32 code for collecting Pixhawk telemetry, sending the compact NRF24L01+ stream to the ESP32 receiver, and preserving the optional Pi mission-control API. +- `live-web-dashboard/` - Vite React dashboard source. Run it on a laptop/static host and connect it to the ESP32 `/telemetry.json` endpoint shown on the OLED. ## File Guide -### `handheld-nrf-monitor/esp_code/esp.ino` -ESP32 receiver firmware for the handheld unit. It listens for NRF24L01+ telemetry packets, validates the packet checksum, decodes battery/GPS/altitude/RSSI values, and renders them on the SH1106 OLED. +### `handheld-nrf-monitor/esp-code/esp.ino` +ESP32 receiver firmware for the handheld unit. It listens for NRF24L01+ telemetry packets, validates frame checksums, decodes battery/GPS/altitude/attitude/SBC values, renders them on the SH1106 OLED, connects to Wi-Fi, and exposes the latest packet as JSON at `/telemetry.json`. ### `handheld-nrf-monitor/pi-code/main.py` -Entry point for the Raspberry Pi companion server. It starts the MAVLink manager, SBC monitor, web dashboard server, NRF24L01+ transmitter, and autonomous grid-search module. +Entry point for the Raspberry Pi telemetry relay. It starts the MAVLink manager, SBC monitor, NRF24L01+ transmitter, and optional mission API/static server. The laptop dashboard reads the ESP32 Wi-Fi JSON endpoint; `--no-web` disables the optional Pi HTTP/API server. -### `handheld-nrf-monitor/pi-code/modules/mavlink_manager.py` +### `handheld-nrf-monitor/pi-code/mavlink_manager.py` Manages the Pixhawk MAVLink connection or simulator, keeps the shared telemetry state, sends vehicle commands, uploads missions, and tracks mission progress. -### `handheld-nrf-monitor/pi-code/modules/radio_tx_module.py` -Packs selected telemetry into the 20-byte NRF24L01+ payload and broadcasts it to the ESP32 handheld receiver. +### `handheld-nrf-monitor/pi-code/radio_tx_module.py` +Packs selected telemetry into typed NRF24L01+ frames and broadcasts them to the ESP32 handheld receiver. -### `handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py` -Runs the HTTP/WebSocket server, serves the built dashboard from `live-web-dashboard/dist/`, exposes telemetry APIs, and forwards mission-control requests to the grid-search module. If `dist/` is missing, it returns a build-required error instead of serving Vite source files. +### `handheld-nrf-monitor/pi-code/sbc_monitor.py` +Reads Raspberry Pi CPU, memory, disk, thermal, and uptime metrics for dashboard telemetry. ### `handheld-nrf-monitor/pi-code/modules/grid_search_module.py` Builds autonomous search missions, including grid, spiral, and sector patterns, and converts planned routes into MAVLink mission items. -### `handheld-nrf-monitor/pi-code/modules/sbc_monitor.py` -Reads Raspberry Pi CPU, memory, disk, thermal, and uptime metrics for dashboard telemetry. +### `handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py` +Preserves the optional Pi HTTP/WebSocket server and `/api/mission/*` endpoints for existing mission-control workflows. When enabled and `live-web-dashboard/dist/` exists, it can also serve the built Vite dashboard assets. ### `handheld-nrf-monitor/pi-code/modules/__init__.py` Marks the `modules` directory as a Python package. ### `live-web-dashboard/index.html` -Vite entrypoint used during development and build. The Pi server should not serve this source file directly in production. +Vite entrypoint used during development and build. Production/static deployments should serve the generated `dist/index.html`. ### `live-web-dashboard/src/` React and TypeScript dashboard source for telemetry panels, charts, attitude view, and ESP JSON polling. @@ -43,19 +43,52 @@ React and TypeScript dashboard source for telemetry panels, charts, attitude vie Defines the Vite development, build, and preview commands for the dashboard. ### `live-web-dashboard/dist/` -Generated production dashboard output from `npm run build`. This directory is ignored by Git and must be created on the deployment target before the Pi web server can host the dashboard. +Generated production dashboard output from `npm run build`. This directory is ignored by Git and should be served from a laptop/static host, or by the optional Pi HTTP server when that legacy deployment path is needed. -## Building the Web Dashboard for Pi Hosting +## Running the Telemetry Relay -From `telemetry/live-web-dashboard`: +On the Raspberry Pi: + +```bash +cd telemetry/handheld-nrf-monitor/pi-code +python3 main.py --port /dev/serial0 --baud 115200 --radio-rate 5 +``` + +Add `--no-web` if only the MAVLink-to-NRF relay is required and the optional mission API/static server is not needed. + +## Running the Web Dashboard + +From `telemetry/live-web-dashboard` on the laptop or static-host machine: ```bash npm ci +npm run dev -- --host 0.0.0.0 +``` + +Open the dashboard URL and enter the ESP32 IP shown on the OLED. The dashboard reads: + +```text +http:///telemetry.json +``` + +For a production/static build: + +```bash npm run build +npm run preview -- --host 0.0.0.0 ``` -Then start the Raspberry Pi companion server. It will serve `telemetry/live-web-dashboard/dist/index.html` and the generated `dist/assets/*` files. For local development, run Vite instead: +The optional Pi HTTP server can also serve `live-web-dashboard/dist/` after `npm run build`, but the telemetry data still comes from the ESP32 JSON endpoint entered in the dashboard. + +## ESP32 Wi-Fi Credentials + +Do not commit Wi-Fi secrets into `esp.ino`. Provide them as build-time defines or through your local Arduino/PlatformIO environment: ```bash -npm run dev -- --host 0.0.0.0 +-DDRONE_WIFI_STA_SSID=\"your-network\" +-DDRONE_WIFI_STA_PASSWORD=\"your-password\" +-DDRONE_WIFI_FALLBACK_AP_SSID=\"DroneTelemetryESP32-001\" +-DDRONE_WIFI_FALLBACK_AP_PASSWORD=\"per-device-strong-password\" ``` + +The fallback AP is disabled unless `DRONE_WIFI_FALLBACK_AP_PASSWORD` is set to a per-device password of at least 12 characters. diff --git a/telemetry/handheld-nrf-monitor/esp-code/esp.ino b/telemetry/handheld-nrf-monitor/esp-code/esp.ino index a7e689c..9ffbc29 100644 --- a/telemetry/handheld-nrf-monitor/esp-code/esp.ino +++ b/telemetry/handheld-nrf-monitor/esp-code/esp.ino @@ -59,11 +59,31 @@ #define FRAME_ATTITUDE 2 #define FRAME_MOTORS 3 -const char* WIFI_STA_SSID = "gamma"; -const char* WIFI_STA_PASSWORD = "gammared"; -const char* WIFI_HOSTNAME = "drone-esp32"; -const char* WIFI_FALLBACK_AP_SSID = "DroneTelemetryESP32"; -const char* WIFI_FALLBACK_AP_PASSWORD = "drone12345"; +#ifndef DRONE_WIFI_STA_SSID +#define DRONE_WIFI_STA_SSID "" +#endif + +#ifndef DRONE_WIFI_STA_PASSWORD +#define DRONE_WIFI_STA_PASSWORD "" +#endif + +#ifndef DRONE_WIFI_HOSTNAME +#define DRONE_WIFI_HOSTNAME "drone-esp32" +#endif + +#ifndef DRONE_WIFI_FALLBACK_AP_SSID +#define DRONE_WIFI_FALLBACK_AP_SSID "DroneTelemetryESP32" +#endif + +#ifndef DRONE_WIFI_FALLBACK_AP_PASSWORD +#define DRONE_WIFI_FALLBACK_AP_PASSWORD "" +#endif + +const char* WIFI_STA_SSID = DRONE_WIFI_STA_SSID; +const char* WIFI_STA_PASSWORD = DRONE_WIFI_STA_PASSWORD; +const char* WIFI_HOSTNAME = DRONE_WIFI_HOSTNAME; +const char* WIFI_FALLBACK_AP_SSID = DRONE_WIFI_FALLBACK_AP_SSID; +const char* WIFI_FALLBACK_AP_PASSWORD = DRONE_WIFI_FALLBACK_AP_PASSWORD; const uint16_t HTTP_PORT = 80; struct __attribute__((packed)) TelemetryPacket { @@ -716,12 +736,25 @@ void handleOptions() { } bool hasStationCredentials() { - return strlen(WIFI_STA_SSID) > 0 && - strcmp(WIFI_STA_SSID, "YOUR_WIFI_SSID") != 0 && - strcmp(WIFI_STA_PASSWORD, "YOUR_WIFI_PASSWORD") != 0; + return strlen(WIFI_STA_SSID) > 0 && strlen(WIFI_STA_PASSWORD) > 0; +} + +bool hasFallbackAccessPointCredentials() { + return strlen(WIFI_FALLBACK_AP_SSID) > 0 && + strlen(WIFI_FALLBACK_AP_PASSWORD) >= 12 && + strcmp(WIFI_FALLBACK_AP_PASSWORD, "CHANGE_ME") != 0; } void startFallbackAccessPoint() { + if (!hasFallbackAccessPointCredentials()) { + g_wifiApMode = false; + g_wifiIpText = "NO WIFI"; + WiFi.mode(WIFI_OFF); + Serial.println(F("[WIFI] Fallback AP disabled. Define DRONE_WIFI_FALLBACK_AP_PASSWORD with a per-device strong password.")); + drawWifiStatusScreen(F("WIFI NOT CONFIG"), "NO CREDENTIALS", g_wifiIpText); + return; + } + g_wifiApMode = true; WiFi.mode(WIFI_AP); WiFi.softAP(WIFI_FALLBACK_AP_SSID, WIFI_FALLBACK_AP_PASSWORD); @@ -770,6 +803,11 @@ void setupWifiApi() { startFallbackAccessPoint(); } + if (g_wifiIpText == "NO WIFI") { + Serial.println(F("[HTTP] Telemetry JSON disabled until Wi-Fi credentials are configured.")); + return; + } + server.on("/", HTTP_GET, handleRoot); server.on("/telemetry.json", HTTP_GET, handleTelemetryJson); server.on("/api/telemetry", HTTP_GET, handleTelemetryJson); diff --git a/telemetry/handheld-nrf-monitor/pi-code/main.py b/telemetry/handheld-nrf-monitor/pi-code/main.py index 7440f23..ec7752c 100644 --- a/telemetry/handheld-nrf-monitor/pi-code/main.py +++ b/telemetry/handheld-nrf-monitor/pi-code/main.py @@ -14,17 +14,21 @@ import time import signal import argparse +import sys from sbc_monitor import SBCMonitor from mavlink_manager import MAVLinkManager from radio_tx_module import RadioTXModule +from modules.grid_search_module import GridSearchModule +from modules.web_dashboard_module import WebDashboardModule def main(): parser = argparse.ArgumentParser(description="Drone Telemetry Pi Relay") parser.add_argument("--port", default="/dev/serial0", help="Pixhawk serial port (default: /dev/serial0 or /dev/ttyACM0)") parser.add_argument("--baud", type=int, default=115200, help="Serial baud rate (default: 115200)") + parser.add_argument("--web-port", type=int, default=8000, help="Optional Pi mission API/static dashboard port") parser.add_argument("--radio-rate", type=float, default=5.0, help="NRF24 broadcast rate in Hz (default: 5.0)") - parser.add_argument("--no-web", action="store_true", help="Compatibility flag; the Pi no longer hosts the dashboard") + parser.add_argument("--no-web", action="store_true", help="Disable the optional Pi mission API/static dashboard server") parser.add_argument("--no-radio", action="store_true", help="Disable NRF24 radio module") parser.add_argument("--simulate", action="store_true", help="Run simulated flight telemetry without hardware") args = parser.parse_args() @@ -33,7 +37,8 @@ def main(): print(" DRONE TELEMETRY PI RELAY (RASPBERRY PI 4B) ") print("=" * 76) print(f"[*] Flight Controller Port: {args.port} @ {args.baud} baud") - print("[*] Web Dashboard: ESP32 Wi-Fi JSON -> laptop web/ dashboard") + print("[*] Web Dashboard: laptop/static host reads ESP32 Wi-Fi JSON") + print(f"[*] Mission API: {'DISABLED' if args.no_web else f'ENABLED on :{args.web_port}'}") print(f"[*] Radio TX Broadcast Rate: {args.radio_rate} Hz ({'DISABLED' if args.no_radio else 'ENABLED'})") print(f"[*] Operation Mode: {'SIMULATION' if args.simulate else 'LIVE PIXHAWK'}") print("----------------------------------------------------------------------------") @@ -46,9 +51,21 @@ def main(): mav_manager = MAVLinkManager(port=args.port, baud=args.baud, simulate=args.simulate) mav_manager.start() - print("[*] Web Dashboard: Pi hosting removed; ESP32 Wi-Fi JSON feeds the laptop dashboard.") + # 3. Existing mission planner/API retained for operators that use /api/mission/*. + grid_module = GridSearchModule(mav_manager) + web_module = None + if args.no_web: + print("[*] Mission API: Disabled by --no-web.") + else: + web_module = WebDashboardModule( + mav_manager=mav_manager, + sbc_monitor=sbc_monitor, + grid_module=grid_module, + port=args.web_port + ) + web_module.start() - # 3. NRF24 Radio Transmitter + # 4. NRF24 Radio Transmitter radio_module = RadioTXModule( mav_manager=mav_manager, sbc_monitor=sbc_monitor, @@ -62,6 +79,8 @@ def main(): print(f" • MAVLink Input: Pixhawk -> {args.port}") print(f" • NRF24 TX: Raspberry Pi -> ESP32 Handheld OLED") print(f" • ESP32 Wi-Fi JSON: http:///telemetry.json -> laptop web/") + if web_module: + print(f" • Mission API: http://:{args.web_port}/api/mission/status") print("=" * 76 + "\n") print("[*] Press Ctrl+C to stop all services.\n") @@ -69,6 +88,8 @@ def main(): def shutdown(signum, frame): print("\n[*] Stopping telemetry relay...") radio_module.stop() + if web_module: + web_module.stop() mav_manager.stop() print("[✓] All modules stopped cleanly.") sys.exit(0) diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py b/telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/modules/__init__.py @@ -0,0 +1 @@ + diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py b/telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py new file mode 100644 index 0000000..aea24ea --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/modules/grid_search_module.py @@ -0,0 +1,446 @@ +#!/usr/bin/env python3 + +import math +import time +import threading +from typing import List, Dict, Tuple, Any + +EARTH_RADIUS = 6378137.0 + +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + delta_phi = math.radians(lat2 - lat1) + delta_lambda = math.radians(lon2 - lon1) + + a = math.sin(delta_phi / 2.0)**2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2.0)**2 + c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a)) + return EARTH_RADIUS * c + +def meter_offsets_to_latlon(center_lat: float, center_lon: float, dx: float, dy: float) -> Tuple[float, float]: + + dlat = (dy / EARTH_RADIUS) * (180.0 / math.pi) + dlon = (dx / (EARTH_RADIUS * math.cos(math.radians(center_lat)))) * (180.0 / math.pi) + return center_lat + dlat, center_lon + dlon + +def rotate_point(x: float, y: float, angle_deg: float) -> Tuple[float, float]: + + rad = math.radians(angle_deg) + cos_a = math.cos(rad) + sin_a = math.sin(rad) + return (x * cos_a + y * sin_a), (-x * sin_a + y * cos_a) + +class GridSearchModule: + + def __init__(self, mav_manager): + self.mav_manager = mav_manager + self.active_mission = False + self.current_mission_data: Dict[str, Any] = {} + self.mission_status = { + "state": "IDLE", + "pattern": "grid", + "center_lat": 0.0, + "center_lon": 0.0, + "radius_m": 50.0, + "altitude_m": 15.0, + "spacing_m": 10.0, + "speed_m_s": 5.0, + "angle_deg": 0.0, + "end_action": "RTL", + "waypoint_count": 0, + "current_wp_index": 0, + "total_distance_m": 0.0, + "estimated_duration_s": 0.0, + "progress_percent": 0 + } + self._lock = threading.Lock() + + def generate_grid_waypoints(self, center_lat: float, center_lon: float, + radius_m: float = 50.0, altitude_m: float = 15.0, + spacing_m: float = 10.0, angle_deg: float = 0.0) -> List[Tuple[float, float, float]]: + + spacing_m = max(3.0, spacing_m) + radius_m = max(5.0, radius_m) + waypoints = [] + + num_lanes = int((2.0 * radius_m) / spacing_m) + 1 + + for i in range(num_lanes): + + local_x = -radius_m + (i * spacing_m) + + if abs(local_x) >= radius_m: + half_chord = 0.0 + else: + half_chord = math.sqrt(radius_m**2 - local_x**2) + + if half_chord < 1.0: + continue + + if i % 2 == 0: + y_endpoints = [-half_chord, half_chord] + else: + y_endpoints = [half_chord, -half_chord] + + for local_y in y_endpoints: + + rot_x, rot_y = rotate_point(local_x, local_y, angle_deg) + wp_lat, wp_lon = meter_offsets_to_latlon(center_lat, center_lon, rot_x, rot_y) + waypoints.append((round(wp_lat, 7), round(wp_lon, 7), round(altitude_m, 1))) + + return waypoints + + def generate_spiral_waypoints(self, center_lat: float, center_lon: float, + radius_m: float = 50.0, altitude_m: float = 15.0, + spacing_m: float = 10.0) -> List[Tuple[float, float, float]]: + + spacing_m = max(4.0, spacing_m) + radius_m = max(5.0, radius_m) + waypoints = [] + + waypoints.append((round(center_lat, 7), round(center_lon, 7), round(altitude_m, 1))) + + current_r = spacing_m + step = 1 + x, y = 0.0, 0.0 + + dx, dy = spacing_m, 0.0 + while current_r <= radius_m + (spacing_m / 2.0): + for _ in range(2): + for _ in range(step): + x += dx + y += dy + dist = math.sqrt(x**2 + y**2) + if dist <= radius_m: + wp_lat, wp_lon = meter_offsets_to_latlon(center_lat, center_lon, x, y) + waypoints.append((round(wp_lat, 7), round(wp_lon, 7), round(altitude_m, 1))) + + dx, dy = -dy, dx + step += 1 + current_r = max(abs(x), abs(y)) + + return waypoints + + def generate_sector_waypoints(self, center_lat: float, center_lon: float, + radius_m: float = 50.0, altitude_m: float = 15.0, + angle_deg: float = 0.0) -> List[Tuple[float, float, float]]: + + waypoints = [] + + angles = [angle_deg, angle_deg + 120.0, angle_deg + 240.0] + + for a in angles: + + waypoints.append((round(center_lat, 7), round(center_lon, 7), round(altitude_m, 1))) + rx, ry = rotate_point(0.0, radius_m, a) + w1_lat, w1_lon = meter_offsets_to_latlon(center_lat, center_lon, rx, ry) + waypoints.append((round(w1_lat, 7), round(w1_lon, 7), round(altitude_m, 1))) + + rx2, ry2 = rotate_point(0.0, radius_m, a + 120.0) + w2_lat, w2_lon = meter_offsets_to_latlon(center_lat, center_lon, rx2, ry2) + waypoints.append((round(w2_lat, 7), round(w2_lon, 7), round(altitude_m, 1))) + + waypoints.append((round(center_lat, 7), round(center_lon, 7), round(altitude_m, 1))) + return waypoints + + def calculate_mission_metrics(self, waypoints: List[Tuple[float, float, float]], speed_m_s: float = 5.0) -> Tuple[float, float]: + + if not waypoints or len(waypoints) < 2: + return 0.0, 0.0 + + total_dist = 0.0 + for i in range(len(waypoints) - 1): + p1 = waypoints[i] + p2 = waypoints[i + 1] + dist = haversine_distance(p1[0], p1[1], p2[0], p2[1]) + + dz = abs(p2[2] - p1[2]) + total_dist += math.sqrt(dist**2 + dz**2) + + speed_m_s = max(1.0, speed_m_s) + estimated_time_s = total_dist / speed_m_s + + estimated_time_s += len(waypoints) * 1.5 + + return round(total_dist, 1), round(estimated_time_s, 0) + + def plan_mission(self, center_lat: float, center_lon: float, + radius_m: float = 50.0, altitude_m: float = 15.0, + spacing_m: float = 10.0, speed_m_s: float = 5.0, + pattern: str = "grid", angle_deg: float = 0.0, + end_action: str = "RTL") -> Dict[str, Any]: + + if pattern == "spiral": + waypoints = self.generate_spiral_waypoints(center_lat, center_lon, radius_m, altitude_m, spacing_m) + elif pattern == "sector": + waypoints = self.generate_sector_waypoints(center_lat, center_lon, radius_m, altitude_m, angle_deg) + else: + pattern = "grid" + waypoints = self.generate_grid_waypoints(center_lat, center_lon, radius_m, altitude_m, spacing_m, angle_deg) + + total_dist, est_duration = self.calculate_mission_metrics(waypoints, speed_m_s) + + preview_items = [] + + preview_items.append({ + "seq": 0, "type": "HOME", "label": "Dynamic Home (Armed Position)", + "lat": center_lat, "lon": center_lon, "alt": 0.0 + }) + + preview_items.append({ + "seq": 1, "type": "TAKEOFF", "label": f"Vertical Takeoff to {altitude_m}m AGL", + "lat": 0.0, "lon": 0.0, "alt": altitude_m, "dynamic": True + }) + + preview_items.append({ + "seq": 2, "type": "SPEED", "label": f"Set Cruise Speed ({speed_m_s} m/s)", + "speed_m_s": speed_m_s + }) + + for idx, wp in enumerate(waypoints, start=3): + preview_items.append({ + "seq": idx, "type": "WAYPOINT", "label": f"WP {idx - 2}", + "lat": wp[0], "lon": wp[1], "alt": wp[2] + }) + + preview_items.append({ + "seq": len(preview_items), "type": end_action.upper(), + "label": f"End Action: {end_action.upper()}", + "lat": center_lat, "lon": center_lon, "alt": 0.0 + }) + + mission_payload = { + "status": "ready", + "pattern": pattern, + "center": [center_lat, center_lon], + "radius_m": radius_m, + "altitude_m": altitude_m, + "spacing_m": spacing_m, + "speed_m_s": speed_m_s, + "angle_deg": angle_deg, + "end_action": end_action.upper(), + "waypoint_count": len(waypoints), + "total_items": len(preview_items), + "total_distance_m": total_dist, + "estimated_duration_s": est_duration, + "waypoints": [[wp[0], wp[1], wp[2]] for wp in waypoints], + "items": preview_items + } + + with self._lock: + self.current_mission_data = mission_payload + + return mission_payload + + def build_mavlink_mission_items(self, center_lat: float, center_lon: float, + waypoints: List[Tuple[float, float, float]], + takeoff_alt: float = 15.0, speed_m_s: float = 5.0, + end_action: str = "RTL") -> List[Dict[str, Any]]: + + items = [] + + items.append({ + "seq": 0, + "frame": 0, + "command": 16, + "current": 0, "autocontinue": 1, + "param1": 0, "param2": 0, "param3": 0, "param4": 0, + "x": int(center_lat * 1e7), "y": int(center_lon * 1e7), "z": 0.0 + }) + + items.append({ + "seq": 1, + "frame": 3, + "command": 22, + "current": 0, "autocontinue": 1, + "param1": 0, "param2": 0, "param3": 0, "param4": 0, + "x": 0, "y": 0, "z": float(takeoff_alt) + }) + + items.append({ + "seq": 2, + "frame": 3, + "command": 178, + "current": 0, "autocontinue": 1, + "param1": 0, + "param2": float(speed_m_s), + "param3": -1, "param4": 0, + "x": 0, "y": 0, "z": 0.0 + }) + + for idx, wp in enumerate(waypoints, start=3): + items.append({ + "seq": idx, + "frame": 3, + "command": 16, + "current": 0, "autocontinue": 1, + "param1": 0, + "param2": 2.0, + "param3": 0, "param4": 0, + "x": int(wp[0] * 1e7), "y": int(wp[1] * 1e7), "z": float(wp[2]) + }) + + end_cmd = 20 + if end_action.upper() == "LAND": + end_cmd = 21 + elif end_action.upper() == "LOITER": + end_cmd = 17 + + items.append({ + "seq": len(items), + "frame": 3, + "command": end_cmd, + "current": 0, "autocontinue": 1, + "param1": 0, "param2": 0, "param3": 0, "param4": 0, + "x": 0, "y": 0, "z": 0.0 + }) + + return items + + def upload_mission_to_pixhawk(self, center_lat: float, center_lon: float, + waypoints: List[Tuple[float, float, float]], + takeoff_alt: float = 15.0, speed_m_s: float = 5.0, + end_action: str = "RTL") -> bool: + + items = self.build_mavlink_mission_items(center_lat, center_lon, waypoints, takeoff_alt, speed_m_s, end_action) + + if not self.mav_manager: + print("[!] Mission Planner: MAVLinkManager not available.") + return False + + print(f"[*] Mission Planner: Uploading {len(items)} items to Pixhawk (Takeoff + {len(waypoints)} WPs + {end_action})...") + success = self.mav_manager.upload_mission_items(items) + + with self._lock: + if success: + self.mission_status.update({ + "state": "UPLOADED", + "waypoint_count": len(waypoints), + "total_items": len(items), + "current_wp_index": 1, + "progress_percent": 0 + }) + else: + self.mission_status["state"] = "UPLOAD_FAILED" + + return success + + def trigger_mission_upload(self, lat: float = None, lon: float = None, + radius_m: float = 50.0, altitude_m: float = 15.0, + spacing_m: float = 10.0, speed_m_s: float = 5.0, + pattern: str = "grid", angle_deg: float = 0.0, + end_action: str = "RTL") -> Dict[str, Any]: + + snap = self.mav_manager.get_telemetry_snapshot() if self.mav_manager else {} + center_lat = lat if (lat is not None and lat != 0.0) else snap.get("latitude", 12.971598) + center_lon = lon if (lon is not None and lon != 0.0) else snap.get("longitude", 77.594562) + + if center_lat == 0.0 or center_lon == 0.0: + center_lat, center_lon = 12.971598, 77.594562 + + plan = self.plan_mission(center_lat, center_lon, radius_m, altitude_m, spacing_m, speed_m_s, pattern, angle_deg, end_action) + + success = self.upload_mission_to_pixhawk( + center_lat, center_lon, plan["waypoints"], altitude_m, speed_m_s, end_action + ) + + with self._lock: + self.mission_status.update({ + "state": "READY" if success else "FAILED", + "center_lat": center_lat, + "center_lon": center_lon, + "radius_m": radius_m, + "altitude_m": altitude_m, + "spacing_m": spacing_m, + "speed_m_s": speed_m_s, + "pattern": pattern, + "angle_deg": angle_deg, + "end_action": end_action.upper(), + "waypoint_count": plan["waypoint_count"], + "total_distance_m": plan["total_distance_m"], + "estimated_duration_s": plan["estimated_duration_s"] + }) + + return { + "status": "success" if success else "error", + "message": "Mission uploaded successfully to Pixhawk" if success else "Mission upload failed", + "plan": plan + } + + def start_mission(self, auto_arm: bool = False) -> Dict[str, Any]: + + if not self.mav_manager: + return {"status": "error", "message": "No MAVLink connection"} + + if auto_arm: + self.mav_manager.set_arm(True) + time.sleep(0.5) + + success = self.mav_manager.set_flight_mode("AUTO") + with self._lock: + if success: + self.mission_status["state"] = "ACTIVE" + return {"status": "success" if success else "error", "mode": "AUTO"} + + def pause_mission(self) -> Dict[str, Any]: + + if not self.mav_manager: + return {"status": "error", "message": "No MAVLink connection"} + + success = self.mav_manager.set_flight_mode("LOITER") + with self._lock: + if success: + self.mission_status["state"] = "PAUSED" + return {"status": "success" if success else "error", "mode": "LOITER"} + + def resume_mission(self) -> Dict[str, Any]: + + if not self.mav_manager: + return {"status": "error", "message": "No MAVLink connection"} + + success = self.mav_manager.set_flight_mode("AUTO") + with self._lock: + if success: + self.mission_status["state"] = "ACTIVE" + return {"status": "success" if success else "error", "mode": "AUTO"} + + def abort_mission(self) -> Dict[str, Any]: + + if not self.mav_manager: + return {"status": "error", "message": "No MAVLink connection"} + + success = self.mav_manager.set_flight_mode("RTL") + with self._lock: + self.mission_status["state"] = "ABORTED" + return {"status": "success" if success else "error", "mode": "RTL"} + + def clear_mission(self) -> Dict[str, Any]: + + if not self.mav_manager: + return {"status": "error", "message": "No MAVLink connection"} + + success = self.mav_manager.clear_all_missions() + with self._lock: + self.mission_status["state"] = "IDLE" + self.mission_status["waypoint_count"] = 0 + self.current_mission_data = {} + return {"status": "success" if success else "error", "message": "Mission cleared"} + + def get_status(self) -> Dict[str, Any]: + + with self._lock: + st = dict(self.mission_status) + if self.current_mission_data: + st["preview"] = self.current_mission_data + return st + +if __name__ == "__main__": + from mavlink_manager import MAVLinkManager + mgr = MAVLinkManager(simulate=True) + mgr.start() + grid = GridSearchModule(mgr) + plan = grid.plan_mission(center_lat=12.971598, center_lon=77.594562, radius_m=40, spacing_m=10, angle_deg=30) + print("Generated plan summary:") + print(f"Waypoints: {plan['waypoint_count']} | Distance: {plan['total_distance_m']}m | Duration: {plan['estimated_duration_s']}s") + mgr.stop() diff --git a/telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py b/telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py new file mode 100644 index 0000000..a3f5aee --- /dev/null +++ b/telemetry/handheld-nrf-monitor/pi-code/modules/web_dashboard_module.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 + +import os +import time +import json +import base64 +import hashlib +import mimetypes +import struct +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from socketserver import ThreadingMixIn + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +WEB_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", "..", "live-web-dashboard")) +WEB_DIST_DIR = os.path.join(WEB_DIR, "dist") + +class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): + daemon_threads = True + +def encode_ws_frame(message: str) -> bytes: + + data = message.encode('utf-8') + length = len(data) + frame = bytearray([0x81]) + + if length <= 125: + frame.append(length) + elif length <= 65535: + frame.append(126) + frame.extend(struct.pack("!H", length)) + else: + frame.append(127) + frame.extend(struct.pack("!Q", length)) + + frame.extend(data) + return bytes(frame) + +class WebDashboardModule: + def __init__(self, mav_manager, sbc_monitor, grid_module=None, port: int = 8000): + self.mav_manager = mav_manager + self.sbc_monitor = sbc_monitor + self.grid_module = grid_module + self.port = port + self.httpd = None + self.server_thread = None + self.ws_thread = None + self.running = False + self.connected_websockets = [] + self.ws_lock = threading.Lock() + + def get_full_telemetry(self) -> dict: + t = self.mav_manager.get_telemetry_snapshot() if self.mav_manager else {} + t["sbc"] = self.sbc_monitor.get_metrics_snapshot() if self.sbc_monitor else {} + if self.grid_module: + t["mission_progress"] = self.grid_module.get_status() + return t + + def start(self): + handler_factory = self._create_handler() + server_address = ("0.0.0.0", self.port) + self.httpd = ThreadedHTTPServer(server_address, handler_factory) + self.running = True + + self.server_thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.server_thread.start() + + self.ws_thread = threading.Thread(target=self._ws_broadcaster, daemon=True) + self.ws_thread.start() + + print(f"[✓] Module 2 (Mission API): HTTP/API server live at http://0.0.0.0:{self.port}") + + def stop(self): + self.running = False + if self.httpd: + self.httpd.shutdown() + + def _ws_broadcaster(self): + while self.running: + time.sleep(0.1) + with self.ws_lock: + if not self.connected_websockets: + continue + snapshot = self.get_full_telemetry() + frame = encode_ws_frame(json.dumps(snapshot)) + dead_sockets = [] + for sock in self.connected_websockets: + try: + sock.sendall(frame) + except Exception: + dead_sockets.append(sock) + for d in dead_sockets: + if d in self.connected_websockets: + self.connected_websockets.remove(d) + + def _create_handler(self): + parent = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def do_GET(self): + + if self.headers.get("Upgrade", "").lower() == "websocket": + self.handle_websocket() + return + + clean_path = self.path.split("?")[0] + + if clean_path == "/api/telemetry": + payload = json.dumps(parent.get_full_telemetry()) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(payload.encode("utf-8")) + return + + if clean_path in ("/api/mission/status", "/api/mission/grid_search") and parent.grid_module: + payload = json.dumps(parent.grid_module.get_status()) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(payload.encode("utf-8")) + return + + if not os.path.exists(os.path.join(WEB_DIST_DIR, "index.html")): + self.send_error(503, "Build telemetry/live-web-dashboard with npm run build before serving static dashboard files.") + return + + relative_path = "index.html" if clean_path in ("/", "/index.html") else clean_path.lstrip("/") + target_file = os.path.abspath(os.path.join(WEB_DIST_DIR, relative_path)) + if not target_file.startswith(WEB_DIST_DIR + os.sep): + self.send_error(403, "Forbidden") + return + if not os.path.exists(target_file) or os.path.isdir(target_file): + target_file = os.path.join(WEB_DIST_DIR, "index.html") + + with open(target_file, "rb") as f: + content = f.read() + content_type = mimetypes.guess_type(target_file)[0] or "application/octet-stream" + if target_file.endswith(".html"): + content_type = "text/html; charset=utf-8" + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.end_headers() + self.wfile.write(content) + + def do_POST(self): + clean_path = self.path.split("?")[0] + content_len = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_len).decode("utf-8") if content_len > 0 else "{}" + params = json.loads(body) if body else {} + + if clean_path == "/api/mission/preview" and parent.grid_module: + snap = parent.mav_manager.get_telemetry_snapshot() if parent.mav_manager else {} + lat = params.get("lat") if (params.get("lat") is not None and params.get("lat") != 0) else snap.get("latitude", 12.971598) + lon = params.get("lon") if (params.get("lon") is not None and params.get("lon") != 0) else snap.get("longitude", 77.594562) + + plan = parent.grid_module.plan_mission( + center_lat=lat, + center_lon=lon, + radius_m=float(params.get("radius", 50.0)), + altitude_m=float(params.get("altitude", 15.0)), + spacing_m=float(params.get("spacing", 10.0)), + speed_m_s=float(params.get("speed", 5.0)), + pattern=params.get("pattern", "grid"), + angle_deg=float(params.get("angle", 0.0)), + end_action=params.get("end_action", "RTL") + ) + self._send_json(200, plan) + return + + elif clean_path in ("/api/mission/upload", "/api/mission/grid_search") and parent.grid_module: + res = parent.grid_module.trigger_mission_upload( + lat=params.get("lat"), + lon=params.get("lon"), + radius_m=float(params.get("radius", 50.0)), + altitude_m=float(params.get("altitude", 15.0)), + spacing_m=float(params.get("spacing", 10.0)), + speed_m_s=float(params.get("speed", 5.0)), + pattern=params.get("pattern", "grid"), + angle_deg=float(params.get("angle", 0.0)), + end_action=params.get("end_action", "RTL") + ) + self._send_json(200, res) + return + + elif clean_path == "/api/mission/start" and parent.grid_module: + res = parent.grid_module.start_mission(auto_arm=params.get("auto_arm", True)) + self._send_json(200, res) + return + + elif clean_path == "/api/mission/pause" and parent.grid_module: + res = parent.grid_module.pause_mission() + self._send_json(200, res) + return + + elif clean_path == "/api/mission/resume" and parent.grid_module: + res = parent.grid_module.resume_mission() + self._send_json(200, res) + return + + elif clean_path == "/api/mission/abort" and parent.grid_module: + res = parent.grid_module.abort_mission() + self._send_json(200, res) + return + + elif clean_path == "/api/mission/clear" and parent.grid_module: + res = parent.grid_module.clear_mission() + self._send_json(200, res) + return + + elif clean_path == "/api/mode" and parent.mav_manager: + mode = params.get("mode", "STABILIZE") + success = parent.mav_manager.set_flight_mode(mode) + self._send_json(200, {"status": "success" if success else "error", "mode": mode}) + return + + elif clean_path == "/api/arm" and parent.mav_manager: + arm = params.get("arm", True) + success = parent.mav_manager.set_arm(arm) + self._send_json(200, {"status": "success" if success else "error", "armed": arm}) + return + + self.send_error(404, "Endpoint not found.") + + def _send_json(self, status_code: int, data: dict): + payload = json.dumps(data) + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(payload.encode("utf-8")) + + def handle_websocket(self): + key = self.headers.get("Sec-WebSocket-Key") + if not key: + self.send_error(400, "Missing Sec-WebSocket-Key") + return + + guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + accept = base64.b64encode(hashlib.sha1((key + guid).encode("utf-8")).digest()).decode("utf-8") + + self.send_response(101, "Switching Protocols") + self.send_header("Upgrade", "websocket") + self.send_header("Connection", "Upgrade") + self.send_header("Sec-WebSocket-Accept", accept) + self.end_headers() + + sock = self.request + with parent.ws_lock: + parent.connected_websockets.append(sock) + + try: + while True: + data = sock.recv(1024) + if not data: + break + except Exception: + pass + finally: + with parent.ws_lock: + if sock in parent.connected_websockets: + parent.connected_websockets.remove(sock) + + return Handler diff --git a/telemetry/live-web-dashboard/src/App.tsx b/telemetry/live-web-dashboard/src/App.tsx index ca0924b..3b4c196 100644 --- a/telemetry/live-web-dashboard/src/App.tsx +++ b/telemetry/live-web-dashboard/src/App.tsx @@ -82,7 +82,7 @@ function ConnectionPanel({

Connect ESP32 Telemetry

-

Enter the IP shown on the ESP32 OLED.

+

Enter the IP shown on the ESP32 OLED.

@@ -109,7 +109,7 @@ function ConnectionPanel({ {error ?

{error}

: null} -
+
Example: 10.160.142.17
Dashboard will read: http://<esp-ip>/telemetry.json
@@ -252,7 +252,7 @@ export function App({ streaming = true }: { streaming?: boolean }) {