diff --git a/web/index.html b/web/index.html index a6fa64b..310025b 100644 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,7 @@ %VITE_SITE_TITLE% - diff --git a/web/src/components/Map.tsx b/web/src/components/Map.tsx index a4521bb..bdf247f 100644 --- a/web/src/components/Map.tsx +++ b/web/src/components/Map.tsx @@ -11,20 +11,65 @@ interface MapProps { className?: string; flush?: boolean; nightMode?: boolean; + precisionBits?: number; // Added for position precision } +// Helper function to calculate zoom level based on precision bits +const calculateZoomFromPrecisionBits = (precisionBits?: number): number => { + if (!precisionBits) return 14; // Default zoom + + // Each precision bit roughly halves the area, so we can map bits to zoom level + // Starting with Earth at zoom 0, each bit roughly adds 1 zoom level + // Typical values: 21 bits ~= zoom 13-14, 24 bits ~= zoom 16-17 + const baseZoom = 8; // Start with a basic zoom level + const additionalZoom = Math.max(0, precisionBits - 16); // Each 2 bits above 16 adds ~1 zoom level + + return Math.min(18, baseZoom + (additionalZoom / 2)); // Cap at zoom 18 +}; + +// Function to calculate accuracy in meters from precision bits +const calculateAccuracyFromPrecisionBits = (precisionBits?: number): number => { + if (!precisionBits) return 300; // Default accuracy of 300m + + // Each precision bit halves the accuracy radius + // Starting with Earth's circumference (~40075km), calculate the precision + const earthCircumference = 40075000; // in meters + const accuracy = earthCircumference / (2 ** precisionBits) / 2; + + // Limit to reasonable values + return Math.max(1, Math.min(accuracy, 10000)); +}; + export const Map: React.FC = ({ latitude, longitude, - zoom = 14, + zoom, width = 300, height = 200, caption, className = "", flush = false, - nightMode = true + nightMode = true, + precisionBits }) => { - const mapUrl = getStaticMapUrl(latitude, longitude, zoom, width, height, nightMode); + // Calculate zoom level based on precision bits if zoom is not provided + const effectiveZoom = zoom || calculateZoomFromPrecisionBits(precisionBits); + + // Calculate accuracy in meters if we have precision bits + const accuracyMeters = precisionBits !== undefined + ? calculateAccuracyFromPrecisionBits(precisionBits) + : undefined; + + const mapUrl = getStaticMapUrl( + latitude, + longitude, + effectiveZoom, + width, + height, + nightMode, + precisionBits, + accuracyMeters + ); const googleMapsUrl = getGoogleMapsUrl(latitude, longitude); // Check if Google Maps API key is available diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index 655a6b4..caa3060 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -2,29 +2,95 @@ import React, { useEffect, useRef } from "react"; import { useNavigate, Link } from "@tanstack/react-router"; import { useAppSelector, useAppDispatch } from "../../hooks"; import { selectNode } from "../../store/slices/aggregatorSlice"; -import { - ArrowLeft, Radio, Cpu, Thermometer, Gauge, Signal, - Droplets, Map, Calendar, Clock, Wifi, BarChart, - BatteryFull, BatteryMedium, BatteryLow, AlertTriangle, - Zap, Timer, ChevronRight, Users +import { RegionCode, ModemPreset } from "../../lib/types"; +import { KeyValuePair } from "../ui/KeyValuePair"; +import { Separator } from "../Separator"; +import { + ArrowLeft, + Radio, + Cpu, + Thermometer, + Gauge, + Signal, + Droplets, + Map, + Calendar, + Clock, + Wifi, + BarChart, + BatteryFull, + BatteryMedium, + BatteryLow, + AlertTriangle, + Zap, + Timer, + ChevronRight, + Users, + Earth, + TableConfig, + Save, } from "lucide-react"; interface NodeDetailProps { nodeId: number; } +// Function to calculate the position accuracy in meters using precision bits +const calculateAccuracyFromPrecisionBits = (precisionBits?: number): number => { + if (!precisionBits) return 300; // Default accuracy of 300m + + // Each precision bit halves the accuracy radius + // Starting with Earth's circumference (~40075km), calculate the precision + // For reference: 24 bits = ~2.4m accuracy, 21 bits = ~19m accuracy + const earthCircumference = 40075000; // in meters + const accuracy = earthCircumference / 2 ** precisionBits / 2; + + // Limit to reasonable values + return Math.max(1, Math.min(accuracy, 10000)); +}; + +// Calculate appropriate zoom level based on accuracy +const calculateZoomFromAccuracy = (accuracyMeters: number): number => { + // Roughly map accuracy to zoom level (higher accuracy = higher zoom) + // < 10m: zoom 18 + // < 50m: zoom 16 + // < 100m: zoom 15 + // < 500m: zoom 14 + // < 1km: zoom 13 + // < 5km: zoom 11 + // >= 5km: zoom 10 + if (accuracyMeters < 10) return 18; + if (accuracyMeters < 50) return 16; + if (accuracyMeters < 100) return 15; + if (accuracyMeters < 500) return 14; + if (accuracyMeters < 1000) return 13; + if (accuracyMeters < 5000) return 11; + return 10; +}; + // Google Maps component that uses the API loaded via script tag -const GoogleMap: React.FC<{lat: number, lng: number, zoom?: number}> = ({lat, lng, zoom = 14}) => { +const GoogleMap: React.FC<{ + lat: number; + lng: number; + zoom?: number; + precisionBits?: number; +}> = ({ lat, lng, zoom, precisionBits }) => { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const markerRef = useRef(null); + // Calculate accuracy in meters based on precision bits + const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits); + + // If zoom is not provided, calculate based on accuracy + const effectiveZoom = zoom || calculateZoomFromAccuracy(accuracyMeters); + useEffect(() => { if (mapRef.current && window.google && window.google.maps) { // Create map instance const mapOptions: google.maps.MapOptions = { center: { lat, lng }, - zoom, + zoom: effectiveZoom, mapTypeId: google.maps.MapTypeId.HYBRID, mapTypeControl: false, streetViewControl: false, @@ -32,95 +98,102 @@ const GoogleMap: React.FC<{lat: number, lng: number, zoom?: number}> = ({lat, ln zoomControl: true, styles: [ { - "featureType": "all", - "elementType": "labels.text.fill", - "stylers": [{"color": "#ffffff"}] + featureType: "all", + elementType: "labels.text.fill", + stylers: [{ color: "#ffffff" }], }, { - "featureType": "all", - "elementType": "labels.text.stroke", - "stylers": [{"visibility": "off"}] + featureType: "all", + elementType: "labels.text.stroke", + stylers: [{ visibility: "off" }], }, { - "featureType": "administrative", - "elementType": "geometry", - "stylers": [{"visibility": "on"}, {"color": "#2d2d2d"}] + featureType: "administrative", + elementType: "geometry", + stylers: [{ visibility: "on" }, { color: "#2d2d2d" }], }, { - "featureType": "landscape", - "elementType": "geometry", - "stylers": [{"color": "#1a1a1a"}] + featureType: "landscape", + elementType: "geometry", + stylers: [{ color: "#1a1a1a" }], }, { - "featureType": "poi", - "elementType": "geometry", - "stylers": [{"color": "#1a1a1a"}] + featureType: "poi", + elementType: "geometry", + stylers: [{ color: "#1a1a1a" }], }, { - "featureType": "road", - "elementType": "geometry.fill", - "stylers": [{"color": "#2d2d2d"}] + featureType: "road", + elementType: "geometry.fill", + stylers: [{ color: "#2d2d2d" }], }, { - "featureType": "road", - "elementType": "geometry.stroke", - "stylers": [{"color": "#333333"}] + featureType: "road", + elementType: "geometry.stroke", + stylers: [{ color: "#333333" }], }, { - "featureType": "water", - "elementType": "geometry", - "stylers": [{"color": "#0f252e"}] - } - ] + featureType: "water", + elementType: "geometry", + stylers: [{ color: "#0f252e" }], + }, + ], }; mapInstanceRef.current = new google.maps.Map(mapRef.current, mapOptions); - // Add marker - markerRef.current = new google.maps.Marker({ - position: { lat, lng }, - map: mapInstanceRef.current, - title: `Node Position`, - icon: { - path: google.maps.SymbolPath.CIRCLE, - scale: 8, - fillColor: "#4ade80", - fillOpacity: 1, - strokeColor: "#22c55e", - strokeWeight: 2, - }, - }); - - // Add accuracy circle if available - if (markerRef.current) { - new google.maps.Circle({ - strokeColor: "#22c55e", - strokeOpacity: 0.5, - strokeWeight: 1, - fillColor: "#4ade80", - fillOpacity: 0.15, + // Only add the center marker if we don't have precision information or + // it's very accurate. + if (precisionBits === undefined || accuracyMeters < 100) { + markerRef.current = new google.maps.Marker({ + position: { lat, lng }, map: mapInstanceRef.current, - center: { lat, lng }, - radius: 300 // 300m accuracy as default + title: `Node Position`, + icon: { + path: google.maps.SymbolPath.CIRCLE, + scale: 8, + fillColor: "#4ade80", + fillOpacity: 1, + strokeColor: "#22c55e", + strokeWeight: 2, + }, }); } - } - }, [lat, lng, zoom]); - return
; + // Circle will always be shown, using default 300m accuracy if no + // precision bits. + new google.maps.Circle({ + strokeColor: "#22c55e", + strokeOpacity: 0.8, + strokeWeight: 2.5, + fillColor: "#4ade80", + fillOpacity: 0.4, + map: mapInstanceRef.current, + center: { lat, lng }, + radius: accuracyMeters, + }); + } + }, [lat, lng, effectiveZoom, accuracyMeters]); + + return ( +
+ ); }; // Battery level component with visual indicator -const BatteryLevel: React.FC<{level: number}> = ({level}) => { - let color = 'bg-green-500'; - let icon = ; - +const BatteryLevel: React.FC<{ level: number }> = ({ level }) => { + let color = "bg-green-500"; + let icon = ; + if (level <= 20) { - color = 'bg-red-500'; - icon = ; + color = "bg-red-500"; + icon = ; } else if (level <= 50) { - color = 'bg-amber-500'; - icon = ; + color = "bg-amber-500"; + icon = ; } return ( @@ -130,87 +203,176 @@ const BatteryLevel: React.FC<{level: number}> = ({level}) => { {icon} Battery - 30 ? 'text-green-500' : 'text-amber-500'} font-medium`}> + 30 ? "text-green-500" : "text-amber-500"} font-mono text-sm`} + > {level}%
-
-
+
+
); }; // Signal strength component with visual indicator -const SignalStrength: React.FC<{snr: number}> = ({snr}) => { +const SignalStrength: React.FC<{ snr: number }> = ({ snr }) => { // SNR is typically in dB, with values from -20 to +20 // Higher is better: < 0 is poor, > 10 is excellent - let strengthClass = 'bg-red-500'; - let strengthText = 'Poor'; - + let strengthClass = "bg-red-500"; + let strengthText = "Poor"; + let textColor = "text-red-500"; + if (snr > 10) { - strengthClass = 'bg-green-500'; - strengthText = 'Excellent'; + strengthClass = "bg-green-500"; + strengthText = "Excellent"; + textColor = "text-green-500"; } else if (snr > 5) { - strengthClass = 'bg-green-400'; - strengthText = 'Good'; + strengthClass = "bg-green-400"; + strengthText = "Good"; + textColor = "text-green-400"; } else if (snr > 0) { - strengthClass = 'bg-amber-500'; - strengthText = 'Fair'; + strengthClass = "bg-amber-500"; + strengthText = "Fair"; + textColor = "text-amber-500"; } - + // Calculate width percentage (0-100%) // Map SNR from -20...+20 to 0...100% const percentage = Math.max(0, Math.min(100, ((snr + 20) / 40) * 100)); - + return (
- + Signal - - {snr} dB ({strengthText}) + + {snr} dB + + ({strengthText}) +
-
-
+
+
); }; +// Section component for consistent section styling +const Section: React.FC<{ + title: string; + icon?: React.ReactNode; + children: React.ReactNode; + className?: string; +}> = ({ title, icon, children, className = "" }) => { + return ( +
+

+ {icon && {icon}} + {title} +

+
{children}
+
+ ); +}; + +// Use the new centralized KeyValuePair component from ui folder + // Format uptime into a human-readable string const formatUptime = (seconds: number): string => { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); - + const parts = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); - - return parts.join(' ') || '< 1m'; + + return parts.join(" ") || "< 1m"; +}; + +// Helper function to get region name +const getRegionName = (region: RegionCode | string | undefined): string => { + if (region === undefined) return "Unknown"; + + // Map of region codes to display names + const regionNames: Record = { + UNSET: "Unset", + US: "US", + EU_433: "EU 433MHz", + EU_868: "EU 868MHz", + CN: "China", + JP: "Japan", + ANZ: "Australia/NZ", + KR: "Korea", + TW: "Taiwan", + RU: "Russia", + IN: "India", + NZ_865: "New Zealand 865MHz", + TH: "Thailand", + LORA_24: "LoRa 2.4GHz", + UA_433: "Ukraine 433MHz", + UA_868: "Ukraine 868MHz", + MY_433: "Malaysia 433MHz", + }; + + // Get the name from the map, or return unknown with the value + return regionNames[region] || `Unknown (${region})`; +}; + +// Helper function to get modem preset name +const getModemPresetName = ( + preset: ModemPreset | string | undefined +): string => { + if (preset === undefined) return "Unknown"; + + // Map of modem presets to display names + const presetNames: Record = { + UNSET: "Unset", + LONG_FAST: "Long Fast", + LONG_SLOW: "Long Slow", + VERY_LONG_SLOW: "Very Long Slow", + MEDIUM_SLOW: "Medium Slow", + MEDIUM_FAST: "Medium Fast", + SHORT_SLOW: "Short Slow", + SHORT_FAST: "Short Fast", + ULTRA_FAST: "Ultra Fast", + }; + + // Get the name from the map, or return unknown with the value + return presetNames[preset] || `Unknown (${preset})`; }; export const NodeDetail: React.FC = ({ nodeId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); const { nodes, gateways } = useAppSelector((state) => state.aggregator); - + // First try to get the node directly from nodes collection let node = nodes[nodeId]; - + // If node not found in nodes collection, check if it might be a gateway if (!node) { // Construct the gateway ID format from the node ID const gatewayId = `!${nodeId.toString(16).toLowerCase()}`; - + // Check if there's a gateway with this ID const gateway = gateways[gatewayId]; - + if (gateway) { // Create a synthetic node from the gateway data node = { @@ -222,15 +384,19 @@ export const NodeDetail: React.FC = ({ nodeId }) => { isGateway: true, gatewayId: gatewayId, // Add observed nodes info - observedNodeCount: gateway.observedNodes.length + observedNodeCount: gateway.observedNodes.length, }; + + // Look for packets from this node that might have MapReport data + // No direct access to packets store here, so we'll rely on + // the data being properly populated in the aggregatorSlice } } useEffect(() => { // Update selected node in the store dispatch(selectNode(nodeId)); - + // Clear selection when component unmounts return () => { dispatch(selectNode(undefined)); @@ -243,11 +409,11 @@ export const NodeDetail: React.FC = ({ nodeId }) => { if (!node) { return ( -
+
-
- {node.isGateway ? : } +
+ {node.isGateway ? ( + + ) : ( + + )}
-

{nodeName}

+

+ {nodeName} +

- - {isActive ? 'Active' : 'Inactive'} - last seen {lastSeenText} + + {isActive ? "Active" : "Inactive"} - last seen {lastSeenText}
-
- ID: !{nodeId.toString(16)} +
+ !{nodeId.toString(16)}
-
-
-
+ + +
+
+
{/* Basic Info */} -
-

Device Information

-
- {/* Display gateway info if this is a gateway */} - {node.isGateway && ( -
+
} + > + {node.longName && ( + + )} + + {node.hwModel && ( + } + inset={true} + /> + )} + + {node.macAddr && ( + + )} + +
+ + + Channels + +
+ {node.channelId ? ( + + + + {node.channelId} + + + ) : ( + + None detected + + )} + {node.mapReport?.hasDefaultChannel !== undefined && ( + + Default channel:{" "} + {node.mapReport.hasDefaultChannel ? "Yes" : "No"} + + )} +
+
+ + {/* Show MapReport-specific information for gateways */} + {node.isGateway && ( +
+
Gateway Node @@ -337,227 +589,269 @@ export const NodeDetail: React.FC = ({ nodeId }) => { {node.observedNodeCount !== undefined && ( - {node.observedNodeCount} {node.observedNodeCount === 1 ? 'node' : 'nodes'} + {node.observedNodeCount}{" "} + {node.observedNodeCount === 1 ? "node" : "nodes"} + + )} + {node.mapReport?.numOnlineLocalNodes !== undefined && ( + + {node.mapReport.numOnlineLocalNodes} online local nodes )}
- )} - - {node.longName && ( -
- Name: - {node.longName} -
- )} + {node.mapReport?.region !== undefined && ( + } + inset={true} + /> + )} - {node.hwModel && ( -
- Hardware: - {node.hwModel} -
- )} + {node.mapReport?.modemPreset !== undefined && ( + } + inset={true} + /> + )} - {node.macAddr && ( -
- MAC Address: - {node.macAddr} -
- )} - -
- Channels: -
- {node.channelId ? ( - - - {node.channelId} - - ) : ( - None detected - )} -
+ {node.mapReport?.firmwareVersion && ( + } + inset={true} + /> + )}
- -
-
- Packets: -
-
- {node.messageCount} - Total -
-
- {node.textMessageCount} - Text -
-
-
-
-
-
+ )} + {/* Activity */} -
-

Last Activity

-
-
- - Date: - {lastHeardDay} -
+
}> + } + monospace={true} + inset={true} + /> -
- - Time: - {lastHeardTime} -
+ } + monospace={true} + inset={true} + /> - {node.deviceMetrics?.uptimeSeconds !== undefined && ( -
- - Uptime: - - {formatUptime(node.deviceMetrics.uptimeSeconds)} + {node.deviceMetrics?.uptimeSeconds !== undefined && ( + } + monospace={true} + highlight={node.deviceMetrics.uptimeSeconds > 86400} + inset={true} + /> + )} + +
+ + + Gateways + +
+ {node.gatewayId ? ( + // Check if gateway ID matches the current node ID (self-reporting) + node.gatewayId === + `!${nodeId.toString(16).toLowerCase()}` ? ( + + Self reported + + ) : ( + + {node.gatewayId} + + + ) + ) : ( + + None detected + )} +
+
+
+ + + Packets + +
+
+ + {node.messageCount} + + Total
- )} - -
-
- Gateways: -
- {node.gatewayId ? ( - // Check if gateway ID matches the current node ID (self-reporting) - node.gatewayId === `!${nodeId.toString(16).toLowerCase()}` ? ( - - Self reported - - ) : ( - - {node.gatewayId} - - - ) - ) : ( - None detected - )} -
+
+ + {node.textMessageCount} + + Text
-
+
{/* Position Map */} {hasPosition && ( -
-

- - Node Location -

-
- +
} + className="mt-4" + > +
+
-
-
- Coordinates: - - {latitude.toFixed(6)}, {longitude.toFixed(6)} - -
- +
+ + {node.position?.altitude !== undefined && ( -
- Altitude: - {node.position.altitude} m -
+ )} - + + {/* Position Accuracy */} + + + {precisionBits !== undefined && ( + + )} + {node.position?.satsInView !== undefined && ( -
- Satellites: - {node.position.satsInView} -
+ 6} + inset={true} + /> )} - + {node.position?.groundSpeed !== undefined && ( -
- Speed: - {node.position.groundSpeed} m/s -
+ )}
-
+
)}
{/* Telemetry Info - Device Metrics */} - {(node.deviceMetrics || node.batteryLevel !== undefined || node.snr !== undefined) && ( -
-

- - Device Status -

+ {(node.deviceMetrics || + node.batteryLevel !== undefined || + node.snr !== undefined) && ( +
}>
{node.batteryLevel !== undefined && ( )} - + {node.deviceMetrics?.voltage !== undefined && ( -
- - - Voltage: - - {node.deviceMetrics.voltage.toFixed(2)} V -
+ } + monospace={true} + highlight={node.deviceMetrics.voltage > 3.7} + inset={true} + /> )} - - {node.snr !== undefined && ( - - )} - - {(node.deviceMetrics?.channelUtilization !== undefined || node.deviceMetrics?.airUtilTx !== undefined) && ( + + {node.snr !== undefined && } + + {(node.deviceMetrics?.channelUtilization !== undefined || + node.deviceMetrics?.airUtilTx !== undefined) && (
- + Channel Utilization:
- + {node.deviceMetrics?.channelUtilization !== undefined && (
Total - {node.deviceMetrics.channelUtilization}% + + {node.deviceMetrics.channelUtilization}% +
-
-
+
)} - + {node.deviceMetrics?.airUtilTx !== undefined && (
Transmit - {node.deviceMetrics.airUtilTx}% + + {node.deviceMetrics.airUtilTx}% +
-
-
+
@@ -565,107 +859,131 @@ export const NodeDetail: React.FC = ({ nodeId }) => {
)}
-
+
)} {/* Telemetry Info - Environment Metrics */} - {node.environmentMetrics && Object.keys(node.environmentMetrics).length > 0 && ( -
-

- - Environment Data -

-
- {node.environmentMetrics.temperature !== undefined && ( -
-
- Temperature: - 30 ? 'text-red-500' : - node.environmentMetrics.temperature < 10 ? 'text-blue-500' : - 'text-green-500'} font-medium - `}> - {node.environmentMetrics.temperature}°C - + {node.environmentMetrics && + Object.keys(node.environmentMetrics).length > 0 && ( +
} + > +
+ {node.environmentMetrics.temperature !== undefined && ( +
+
+ + + Temperature + + 30 + ? "text-red-500" + : node.environmentMetrics.temperature < 10 + ? "text-blue-500" + : "text-green-500" + } font-mono text-sm + `} + > + {node.environmentMetrics.temperature}°C + +
+
+ {/* Temp scale: -10°C to 40°C mapped to 0-100% */} +
30 + ? "bg-red-500" + : node.environmentMetrics.temperature < 10 + ? "bg-blue-500" + : "bg-green-500" + } h-2 rounded-full + `} + style={{ + width: `${Math.max(0, Math.min(100, ((node.environmentMetrics.temperature + 10) / 50) * 100))}%`, + }} + >
+
-
- {/* Temp scale: -10°C to 40°C mapped to 0-100% */} -
30 ? 'bg-red-500' : - node.environmentMetrics.temperature < 10 ? 'bg-blue-500' : - 'bg-green-500'} h-2 rounded-full - `} - style={{ - width: `${Math.max(0, Math.min(100, ((node.environmentMetrics.temperature + 10) / 50) * 100))}%` - }} - >
-
-
- )} - - {node.environmentMetrics.relativeHumidity !== undefined && ( -
-
- - - Humidity: - - - {node.environmentMetrics.relativeHumidity}% - -
-
-
-
-
- )} - - {node.environmentMetrics.barometricPressure !== undefined && ( -
- - - Pressure: - - - {node.environmentMetrics.barometricPressure} hPa - -
- )} + )} - {node.environmentMetrics.soilMoisture !== undefined && ( -
-
- Soil Moisture: - - {node.environmentMetrics.soilMoisture}% - + {node.environmentMetrics.relativeHumidity !== undefined && ( +
+
+ + + Humidity + + + {node.environmentMetrics.relativeHumidity}% + +
+
+
+
-
-
+ )} + + {node.environmentMetrics.barometricPressure !== undefined && ( + } + monospace={true} + inset={true} + /> + )} + + {node.environmentMetrics.soilMoisture !== undefined && ( +
+
+ + + Soil Moisture + + + {node.environmentMetrics.soilMoisture}% + +
+
+
+
-
- )} -
-
- )} + )} +
+ + )} {/* Warning for low battery */} {node.batteryLevel !== undefined && node.batteryLevel < 20 && ( -
+
- -

Low Battery Warning

+ +

+ LOW BATTERY WARNING +

-

- This node's battery level is critically low at {node.batteryLevel}%. - The device may stop reporting soon. +

+ + Battery level critically low at{" "} + + {node.batteryLevel}% + + Device may stop reporting soon.

)} @@ -673,4 +991,4 @@ export const NodeDetail: React.FC = ({ nodeId }) => {
); -}; \ No newline at end of file +}; diff --git a/web/src/components/packets/GenericPacket.tsx b/web/src/components/packets/GenericPacket.tsx index 10b7a5a..deb3607 100644 --- a/web/src/components/packets/GenericPacket.tsx +++ b/web/src/components/packets/GenericPacket.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Packet, PortNum } from "../../lib/types"; import { Package } from "lucide-react"; import { PacketCard } from "./PacketCard"; -import { KeyValueGrid, KeyValuePair } from "./KeyValuePair"; +import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair"; interface GenericPacketProps { packet: Packet; @@ -53,18 +53,24 @@ export const GenericPacket: React.FC = ({ packet }) => { diff --git a/web/src/components/packets/KeyValuePair.tsx b/web/src/components/packets/KeyValuePair.tsx deleted file mode 100644 index 15eec85..0000000 --- a/web/src/components/packets/KeyValuePair.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from "react"; - -interface KeyValuePairProps { - label: string; - value: React.ReactNode; - large?: boolean; -} - -export const KeyValuePair: React.FC = ({ - label, - value, - large = false -}) => { - return ( -
-
- {label} -
-
- {value || "—"} -
-
- ); -}; - -interface KeyValueGridProps { - children: React.ReactNode; -} - -export const KeyValueGrid: React.FC = ({ children }) => { - return ( -
- {children} -
- ); -}; \ No newline at end of file diff --git a/web/src/components/packets/MapReportPacket.tsx b/web/src/components/packets/MapReportPacket.tsx index 879ebd5..d402d0e 100644 --- a/web/src/components/packets/MapReportPacket.tsx +++ b/web/src/components/packets/MapReportPacket.tsx @@ -1,145 +1,337 @@ import React from "react"; -import { Packet } from "../../lib/types"; -import { Map as MapIcon } from "lucide-react"; +import { + Packet, + HardwareModel, + DeviceRole, + RegionCode, + ModemPreset, +} from "../../lib/types"; +import { Map as MapIcon, Signal, MapPin } from "lucide-react"; import { PacketCard } from "./PacketCard"; -import { KeyValueGrid, KeyValuePair } from "./KeyValuePair"; +import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair"; import { Map } from "../Map"; interface MapReportPacketProps { packet: Packet; } +// Helper function to get hardware model name +const getHardwareModelName = ( + model: string | HardwareModel | undefined +): string => { + if (model === undefined) return "Unknown"; + + // If it's a string, return it directly + if (typeof model === "string") return model; + + // If it's an enum value, return the name + switch (model) { + case HardwareModel.TBEAM: + return "T-BEAM"; + case HardwareModel.TBEAM_V0P7: + return "T-BEAM v0.7"; + case HardwareModel.LILYGO_TBEAM_S3_CORE: + return "T-BEAM S3"; + case HardwareModel.TLORA_V1: + return "T-LORA v1"; + case HardwareModel.TLORA_V1_1P3: + return "T-LORA v1.1.3"; + case HardwareModel.TLORA_V2: + return "T-LORA v2"; + case HardwareModel.TLORA_V2_1_1P6: + return "T-LORA v2.1.6"; + case HardwareModel.TLORA_V2_1_1P8: + return "T-LORA v2.1.8"; + case HardwareModel.TLORA_T3_S3: + return "T-LORA T3-S3"; + case HardwareModel.T_ECHO: + return "T-ECHO"; + case HardwareModel.HELTEC_V1: + return "Heltec v1"; + case HardwareModel.HELTEC_V2_0: + return "Heltec v2.0"; + case HardwareModel.HELTEC_V2_1: + return "Heltec v2.1"; + case HardwareModel.RAK4631: + return "RAK4631"; + case HardwareModel.RAK11200: + return "RAK11200"; + case HardwareModel.NANO_G1: + return "Nano G1"; + case HardwareModel.NANO_G1_EXPLORER: + return "Nano G1 Explorer"; + case HardwareModel.NANO_G2_ULTRA: + return "Nano G2 Ultra"; + default: + return `Unknown (${model})`; + } +}; + +// Helper function to get role name +const getRoleName = (role: string | DeviceRole | undefined): string => { + if (role === undefined) return "Unknown"; + + // If it's a string, return it directly + if (typeof role === "string") return role; + + // If it's an enum value, return the name + switch (role) { + case DeviceRole.CLIENT: + return "Client"; + case DeviceRole.ROUTER: + return "Router"; + case DeviceRole.ROUTER_CLIENT: + return "Router+Client"; + case DeviceRole.TRACKER: + return "Tracker"; + case DeviceRole.TAK_TRACKER: + return "TAK Tracker"; + case DeviceRole.SENSOR: + return "Sensor"; + case DeviceRole.REPEATER: + return "Repeater"; + default: + return `Unknown (${role})`; + } +}; + +// Helper function to get region name +const getRegionName = (region: RegionCode | string | undefined): string => { + if (region === undefined) return "Unknown"; + + // Map of region codes to display names + const regionNames: Record = { + "UNSET": "Unset", + "US": "US", + "EU_433": "EU 433MHz", + "EU_868": "EU 868MHz", + "CN": "China", + "JP": "Japan", + "ANZ": "Australia/NZ", + "KR": "Korea", + "TW": "Taiwan", + "RU": "Russia", + "IN": "India", + "NZ_865": "New Zealand 865MHz", + "TH": "Thailand", + "LORA_24": "LoRa 2.4GHz", + "UA_433": "Ukraine 433MHz", + "UA_868": "Ukraine 868MHz", + "MY_433": "Malaysia 433MHz" + }; + + // Get the name from the map, or return unknown with the value + return regionNames[region] || `Unknown (${region})`; +}; + +// Helper function to get modem preset name +const getModemPresetName = (preset: ModemPreset | string | undefined): string => { + if (preset === undefined) return "Unknown"; + + // Map of modem presets to display names + const presetNames: Record = { + "UNSET": "Unset", + "LONG_FAST": "Long Fast", + "LONG_SLOW": "Long Slow", + "VERY_LONG_SLOW": "Very Long Slow", + "MEDIUM_SLOW": "Medium Slow", + "MEDIUM_FAST": "Medium Fast", + "SHORT_SLOW": "Short Slow", + "SHORT_FAST": "Short Fast", + "ULTRA_FAST": "Ultra Fast" + }; + + // Get the name from the map, or return unknown with the value + return presetNames[preset] || `Unknown (${preset})`; +}; + +// Helper function to calculate position accuracy in meters from precision bits +const calculateAccuracyFromPrecisionBits = (precisionBits?: number): number => { + if (!precisionBits) return 300; // Default accuracy of 300m + + // Each precision bit halves the accuracy radius + // Starting with Earth's circumference (~40075km), calculate the precision + const earthCircumference = 40075000; // in meters + const accuracy = earthCircumference / (2 ** precisionBits) / 2; + + // Limit to reasonable values + return Math.max(1, Math.min(accuracy, 10000)); +}; + export const MapReportPacket: React.FC = ({ packet }) => { const { data } = packet; const mapReport = data.mapReport; - if (!mapReport || !mapReport.nodes || mapReport.nodes.length === 0) { + if (!mapReport) { return null; } - - // Get the center point for the map (average of all node positions) + + // Get the center point for the map based on the MapReport position const getMapCenter = () => { - let validPositions = 0; - let sumLat = 0; - let sumLng = 0; - - mapReport.nodes.forEach(node => { - if (node.position && node.position.latitudeI && node.position.longitudeI) { - sumLat += node.position.latitudeI * 1e-7; - sumLng += node.position.longitudeI * 1e-7; - validPositions++; - } - }); - - if (validPositions > 0) { + // Check if the report has a position + if (mapReport.latitudeI && mapReport.longitudeI) { return { - latitude: sumLat / validPositions, - longitude: sumLng / validPositions, + latitude: mapReport.latitudeI * 1e-7, + longitude: mapReport.longitudeI * 1e-7, }; } - return null; }; - + const center = getMapCenter(); - const formatTimestamp = (timestamp: number) => { - return new Date(timestamp * 1000).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit' - }); - }; + // Get the position precision bits if available + const precisionBits = mapReport.positionPrecision; + // Calculate position accuracy in meters + const positionAccuracy = calculateAccuracyFromPrecisionBits(precisionBits); + return ( } iconBgColor="bg-cyan-500" label="Map Report" - backgroundColor="bg-cyan-950/5" >
-
-
-

- Network Map ({mapReport.nodes.length} Nodes) + {/* MapReport properties */} + {(mapReport.longName || + mapReport.shortName || + mapReport.hwModel || + mapReport.region || + mapReport.modemPreset) && ( +
+

+ + Report Source

- -
- {mapReport.nodes.map((node, index) => ( -
-
-
- {node.user?.longName || `Node ${node.num?.toString(16)}`} -
- {node.lastHeard && ( -
- {formatTimestamp(node.lastHeard)} -
- )} -
- - - {node.user?.shortName && ( - - )} - {node.num !== undefined && ( - - )} - {node.user?.hwModel && ( - - )} - {node.snr !== undefined && ( - - )} - {node.position?.latitudeI && node.position?.longitudeI && ( - <> - - - - )} - -
- ))} -
+ + + {mapReport.longName && ( + + )} + {mapReport.shortName && ( + + )} + {mapReport.hwModel !== undefined && ( + + )} + {mapReport.role !== undefined && ( + + )} + {mapReport.firmwareVersion && ( + + )} + {mapReport.region !== undefined && ( + + )} + {mapReport.modemPreset !== undefined && ( + + )} + {mapReport.numOnlineLocalNodes !== undefined && ( + + )} + {mapReport.hasDefaultChannel !== undefined && ( + + )} +
- - {center && ( -
- +

+ + Gateway Location +

+
+ + {precisionBits !== undefined && ( +
+ {positionAccuracy < 1000 + ? `Location Accuracy: ±${positionAccuracy.toFixed(0)}m` + : `Location Accuracy: ±${(positionAccuracy / 1000).toFixed(1)}km`} +
+ )}
- )} -
+ {/* Position information */} +
+ + + {/* Position accuracy (always show, even with default value) */} + + + {mapReport.positionPrecision !== undefined && ( + + )} + + {mapReport.altitude !== undefined && ( + + )} +
+

+ )}
); -}; \ No newline at end of file +}; diff --git a/web/src/components/packets/PositionPacket.tsx b/web/src/components/packets/PositionPacket.tsx index be9be07..0ddbb1b 100644 --- a/web/src/components/packets/PositionPacket.tsx +++ b/web/src/components/packets/PositionPacket.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Packet } from "../../lib/types"; import { MapPin } from "lucide-react"; import { PacketCard } from "./PacketCard"; -import { KeyValueGrid, KeyValuePair } from "./KeyValuePair"; +import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair"; import { Map } from "../Map"; interface PositionPacketProps { @@ -40,33 +40,44 @@ export const PositionPacket: React.FC = ({ packet }) => { {position.altitude && ( )} {position.time && ( )} {position.locationSource && ( )} {position.satsInView && ( 6} /> )} diff --git a/web/src/components/packets/WaypointPacket.tsx b/web/src/components/packets/WaypointPacket.tsx index 42cc326..36911be 100644 --- a/web/src/components/packets/WaypointPacket.tsx +++ b/web/src/components/packets/WaypointPacket.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Packet } from "../../lib/types"; import { MapPin } from "lucide-react"; import { PacketCard } from "./PacketCard"; -import { KeyValueGrid, KeyValuePair } from "./KeyValuePair"; +import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair"; import { Map } from "../Map"; interface WaypointPacketProps { @@ -52,19 +52,21 @@ export const WaypointPacket: React.FC = ({ packet }) => { )} {latitude !== undefined && ( - + )} {longitude !== undefined && ( - + )} {waypoint.id !== undefined && ( - + )} - {expireTime && } + {expireTime && } {waypoint.lockedTo !== undefined && waypoint.lockedTo > 0 && ( )} diff --git a/web/src/components/ui/KeyValuePair.tsx b/web/src/components/ui/KeyValuePair.tsx new file mode 100644 index 0000000..1ba6163 --- /dev/null +++ b/web/src/components/ui/KeyValuePair.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { cn } from "../../lib/cn"; + +interface KeyValuePairProps { + label: string; + value: React.ReactNode; + icon?: React.ReactNode; + monospace?: boolean; + highlight?: boolean; + large?: boolean; + vertical?: boolean; + inset?: boolean; +} + +/** + * KeyValuePair component displays a label-value pair with consistent styling + * + * @param label - The label or key + * @param value - The value (can be any React node) + * @param icon - Optional icon to display next to the label + * @param monospace - Whether to use monospace font for the value + * @param highlight - Whether to highlight the value with a distinctive color + * @param large - Whether to use larger text sizing + * @param vertical - Whether to stack label and value vertically instead of side by side + * @param inset - Whether to use inset effect styling for the container + */ +export const KeyValuePair: React.FC = ({ + label, + value, + icon, + monospace = true, + highlight = false, + large = false, + vertical = false, + inset = false, +}) => { + if (vertical) { + return ( +
+
+ {icon && {icon}} + {label} +
+
+ {value || "—"} +
+
+ ); + } + + return ( +
+ + {icon && {icon}} + {label} + + + {value || "—"} + +
+ ); +}; + +/** + * KeyValueGrid provides a consistent grid layout for KeyValuePair components + */ +export const KeyValueGrid: React.FC<{ + children: React.ReactNode; + columns?: number; + className?: string; +}> = ({ children, columns = 2, className }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/web/src/lib/mapUtils.ts b/web/src/lib/mapUtils.ts index 8fe7c2e..d0688b9 100644 --- a/web/src/lib/mapUtils.ts +++ b/web/src/lib/mapUtils.ts @@ -10,6 +10,8 @@ * @param width The image width in pixels * @param height The image height in pixels * @param nightMode Whether to use dark styling for the map + * @param precisionBits Optional precision bits to determine how to display the marker + * @param accuracyMeters Optional accuracy in meters (used when precisionBits is provided) * @returns A URL string for the Google Maps Static API */ export const getStaticMapUrl = ( @@ -18,7 +20,9 @@ export const getStaticMapUrl = ( zoom: number = 15, width: number = 300, height: number = 200, - nightMode: boolean = true + nightMode: boolean = true, + precisionBits?: number, + accuracyMeters?: number ): string => { // Get API key from environment variable const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ""; @@ -34,8 +38,15 @@ export const getStaticMapUrl = ( mapUrl.searchParams.append("format", "png"); mapUrl.searchParams.append("scale", "2"); // Retina display support - // Add marker - mapUrl.searchParams.append("markers", `color:red|${latitude},${longitude}`); + // Only add marker if we don't have precision information + if (precisionBits === undefined) { + mapUrl.searchParams.append("markers", `color:green|${latitude},${longitude}`); + } + // With static maps we can't draw circles directly, so we use a marker with different color + // even when we have precision information, but we'll show it differently in the interactive map + else { + mapUrl.searchParams.append("markers", `color:green|${latitude},${longitude}`); + } // Apply night mode styling using the simpler approach if (nightMode) { diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 18ae4d5..d70851e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -186,10 +186,90 @@ export interface NeighborInfo { neighbors?: Neighbor[]; // The list of out edges from this node } +// HardwareModel enum based on mesh.proto +export enum HardwareModel { + UNSET = 0, + TLORA_V2 = 1, + TLORA_V1 = 2, + TLORA_V2_1_1P6 = 3, + TBEAM = 4, + HELTEC_V2_0 = 5, + TBEAM_V0P7 = 6, + T_ECHO = 7, + TLORA_V1_1P3 = 8, + RAK4631 = 9, + HELTEC_V2_1 = 10, + HELTEC_V1 = 11, + LILYGO_TBEAM_S3_CORE = 12, + RAK11200 = 13, + NANO_G1 = 14, + TLORA_V2_1_1P8 = 15, + TLORA_T3_S3 = 16, + NANO_G1_EXPLORER = 17, + NANO_G2_ULTRA = 18 +} + +// Roles from config.proto +export enum DeviceRole { + CLIENT = 0, + ROUTER = 1, + ROUTER_CLIENT = 2, + TRACKER = 3, + TAK_TRACKER = 4, + SENSOR = 5, + REPEATER = 6 +} + +// Region codes as string literals for the wire format +export enum RegionCode { + UNSET = "UNSET", + US = "US", + EU_433 = "EU_433", + EU_868 = "EU_868", + CN = "CN", + JP = "JP", + ANZ = "ANZ", + KR = "KR", + TW = "TW", + RU = "RU", + IN = "IN", + NZ_865 = "NZ_865", + TH = "TH", + LORA_24 = "LORA_24", + UA_433 = "UA_433", + UA_868 = "UA_868", + MY_433 = "MY_433" +} + +// Modem presets as string literals for the wire format +export enum ModemPreset { + UNSET = "UNSET", + LONG_FAST = "LONG_FAST", + LONG_SLOW = "LONG_SLOW", + VERY_LONG_SLOW = "VERY_LONG_SLOW", + MEDIUM_SLOW = "MEDIUM_SLOW", + MEDIUM_FAST = "MEDIUM_FAST", + SHORT_SLOW = "SHORT_SLOW", + SHORT_FAST = "SHORT_FAST", + ULTRA_FAST = "ULTRA_FAST" +} + +// MapReport interface based on mqtt.proto definition export interface MapReport { - // This would need to be defined based on the actual data structure - // Currently not defined in the proto files - [key: string]: unknown; + // Fields from the mqtt.proto MapReport message + longName?: string; + shortName?: string; + role?: DeviceRole; + hwModel?: HardwareModel; + firmwareVersion?: string; + region?: RegionCode; + modemPreset?: ModemPreset; + hasDefaultChannel?: boolean; + latitudeI?: number; + longitudeI?: number; + altitude?: number; + positionPrecision?: number; + numOnlineLocalNodes?: number; } export interface HardwareMessage { diff --git a/web/src/store/slices/aggregatorSlice.ts b/web/src/store/slices/aggregatorSlice.ts index ffd43c6..4836c6e 100644 --- a/web/src/store/slices/aggregatorSlice.ts +++ b/web/src/store/slices/aggregatorSlice.ts @@ -6,6 +6,7 @@ import { Position, User, Telemetry, + MapReport, } from "../../lib/types"; // Types for aggregated data @@ -28,6 +29,8 @@ export interface NodeData { // Fields for gateway nodes isGateway?: boolean; observedNodeCount?: number; + // MapReport payload for this node + mapReport?: MapReport; } export interface TextMessage { @@ -107,13 +110,14 @@ const processPacket = (state: AggregatorState, packet: Packet) => { // Always mark this packet as processed state.processedPackets[packetKey] = true; - // Update gateway data, but only if it's reporting packets from a different nodeId - // (a true gateway is relaying data from other nodes, not just its own data) - if ( - gatewayId && - nodeId !== undefined && - gatewayId !== `!${nodeId.toString(16)}` - ) { + // Update gateway data + // Handle both cases: + // 1. Gateway relaying data from other nodes (gatewayId != nodeId) + // 2. Gateway reporting its own data (gatewayId = nodeId) + if (gatewayId && nodeId !== undefined) { + const gatewayNodeHex = `!${nodeId.toString(16).toLowerCase()}`; + const isSelfReport = gatewayId === gatewayNodeHex; + if (!state.gateways[gatewayId]) { state.gateways[gatewayId] = { gatewayId, @@ -137,10 +141,55 @@ const processPacket = (state: AggregatorState, packet: Packet) => { gateway.channelIds.push(channelId); } - // Record node in observed nodes - if (!gateway.observedNodes.includes(nodeId)) { + // Only record as observed if it's not the gateway itself reporting + if (!isSelfReport && !gateway.observedNodes.includes(nodeId)) { gateway.observedNodes.push(nodeId); } + + // If this is a gateway reporting its own data via MAP_REPORT, create a node entry for it + if (isSelfReport && data.mapReport) { + // Extract gateway's node ID from the gateway ID + const gatewayNodeId = parseInt(gatewayId.substring(1), 16); + + // Make sure we have a node entry for this gateway + if (!state.nodes[gatewayNodeId]) { + state.nodes[gatewayNodeId] = { + nodeId: gatewayNodeId, + lastHeard: timestamp, + messageCount: 1, + textMessageCount: 0, + isGateway: true, + gatewayId: gatewayId + }; + } + + // Update the node with map report data + const gatewayNode = state.nodes[gatewayNodeId]; + gatewayNode.mapReport = { ...data.mapReport }; + gatewayNode.lastHeard = Math.max(gatewayNode.lastHeard, timestamp); + + // Copy relevant fields from MapReport to the node object + if (data.mapReport.longName) { + gatewayNode.longName = data.mapReport.longName; + } + if (data.mapReport.shortName) { + gatewayNode.shortName = data.mapReport.shortName; + } + if (data.mapReport.hwModel !== undefined) { + gatewayNode.hwModel = data.mapReport.hwModel.toString(); + } + + // Create position info from MapReport + if (data.mapReport.latitudeI !== undefined && data.mapReport.longitudeI !== undefined) { + gatewayNode.position = { + latitudeI: data.mapReport.latitudeI, + longitudeI: data.mapReport.longitudeI, + altitude: data.mapReport.altitude, + time: timestamp, + precisionBits: data.mapReport.positionPrecision + }; + } + } } if (!isNewPacket) { @@ -220,6 +269,35 @@ const processPacket = (state: AggregatorState, packet: Packet) => { if (data.telemetry) { updateTelemetry(node, data.telemetry); } + + // Update MapReport if available + if (data.mapReport) { + node.mapReport = { ...data.mapReport }; + + // Also update node fields from MapReport if they're missing + if (!node.longName && data.mapReport.longName) { + node.longName = data.mapReport.longName; + } + if (!node.shortName && data.mapReport.shortName) { + node.shortName = data.mapReport.shortName; + } + if (!node.hwModel && data.mapReport.hwModel !== undefined) { + node.hwModel = + data.mapReport.hwModel.toString(); // Store as string to match User.hwModel format + } + + // Update position from MapReport if node doesn't have position + if (!node.position && data.mapReport.latitudeI !== undefined && data.mapReport.longitudeI !== undefined) { + node.position = { + latitudeI: data.mapReport.latitudeI, + longitudeI: data.mapReport.longitudeI, + altitude: data.mapReport.altitude, + time: timestamp, // Use packet time + // Set precisionBits from positionPrecision if available + precisionBits: data.mapReport.positionPrecision + }; + } + } } // Process text messages - only for new packets to avoid duplicates diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 84d79ad..d084d02 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -19,6 +19,15 @@ export default { 'Arial', 'sans-serif', ], + mono: [ + '"JetBrains Mono"', + '"Share Tech Mono"', + '"Space Mono"', + '"Roboto Mono"', + '"VT323"', + '"Courier New"', + 'monospace', + ], } }, },