More cleanup to the node details page

This commit is contained in:
Daniel Pupius
2025-04-25 14:42:30 -07:00
parent acaeeaf495
commit 4817d31d39
13 changed files with 1370 additions and 552 deletions

View File

@@ -8,7 +8,7 @@
<title>%VITE_SITE_TITLE%</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;500;600;700&display=swap"
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;300;400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Roboto+Mono:wght@400;500&family=Share+Tech+Mono&family=Space+Mono:wght@400;700&family=VT323&display=swap"
rel="stylesheet">
<!-- Google Maps API with environment variable API key -->
<script async defer src="https://maps.googleapis.com/maps/api/js?key=%VITE_GOOGLE_MAPS_API_KEY%"></script>

View File

@@ -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<MapProps> = ({
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

File diff suppressed because it is too large Load Diff

View File

@@ -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<GenericPacketProps> = ({ packet }) => {
<KeyValuePair
label="Port"
value={portName}
vertical={true}
/>
<KeyValuePair
label="Payload"
value={getPayloadDescription()}
vertical={true}
/>
<KeyValuePair
label="To"
value={data.to === 4294967295 ? "Broadcast" : data.to.toString()}
vertical={true}
monospace={true}
/>
<KeyValuePair
label="Hop Limit"
value={data.hopLimit}
vertical={true}
monospace={true}
/>
</KeyValueGrid>

View File

@@ -1,36 +0,0 @@
import React from "react";
interface KeyValuePairProps {
label: string;
value: React.ReactNode;
large?: boolean;
}
export const KeyValuePair: React.FC<KeyValuePairProps> = ({
label,
value,
large = false
}) => {
return (
<div>
<div className={`text-xs tracking-wide text-neutral-400 uppercase ${large ? 'mb-1' : ''}`}>
{label}
</div>
<div className={`${large ? "text-base font-medium tracking-wide" : "tracking-tight"}`}>
{value || "—"}
</div>
</div>
);
};
interface KeyValueGridProps {
children: React.ReactNode;
}
export const KeyValueGrid: React.FC<KeyValueGridProps> = ({ children }) => {
return (
<div className="grid grid-cols-2 gap-x-6 gap-y-3 max-w-xl">
{children}
</div>
);
};

View File

@@ -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<string, string> = {
"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<string, string> = {
"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<MapReportPacketProps> = ({ 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 (
<PacketCard
packet={packet}
icon={<MapIcon />}
iconBgColor="bg-cyan-500"
label="Map Report"
backgroundColor="bg-cyan-950/5"
>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-medium text-neutral-300 mb-3">
Network Map ({mapReport.nodes.length} Nodes)
{/* MapReport properties */}
{(mapReport.longName ||
mapReport.shortName ||
mapReport.hwModel ||
mapReport.region ||
mapReport.modemPreset) && (
<div className="p-3 bg-neutral-800/50 rounded border border-neutral-700">
<h3 className="text-sm font-medium text-neutral-300 mb-3 flex items-center">
<Signal className="w-4 h-4 mr-2 text-cyan-400" />
Report Source
</h3>
<div className="space-y-4 max-h-[450px] overflow-y-auto pr-2">
{mapReport.nodes.map((node, index) => (
<div
key={node.num || index}
className="p-3 bg-neutral-800/50 rounded border border-neutral-700"
>
<div className="flex justify-between items-start mb-2">
<div className="font-medium text-neutral-200">
{node.user?.longName || `Node ${node.num?.toString(16)}`}
</div>
{node.lastHeard && (
<div className="text-xs text-neutral-400">
{formatTimestamp(node.lastHeard)}
</div>
)}
</div>
<KeyValueGrid>
{node.user?.shortName && (
<KeyValuePair
label="Short Name"
value={node.user.shortName}
/>
)}
{node.num !== undefined && (
<KeyValuePair
label="Node ID"
value={`!${node.num.toString(16)}`}
/>
)}
{node.user?.hwModel && (
<KeyValuePair
label="Hardware"
value={node.user.hwModel}
/>
)}
{node.snr !== undefined && (
<KeyValuePair
label="SNR"
value={`${node.snr.toFixed(1)} dB`}
/>
)}
{node.position?.latitudeI && node.position?.longitudeI && (
<>
<KeyValuePair
label="Latitude"
value={(node.position.latitudeI * 1e-7).toFixed(6)}
/>
<KeyValuePair
label="Longitude"
value={(node.position.longitudeI * 1e-7).toFixed(6)}
/>
</>
)}
</KeyValueGrid>
</div>
))}
</div>
<KeyValueGrid>
{mapReport.longName && (
<KeyValuePair label="Name" value={mapReport.longName} vertical />
)}
{mapReport.shortName && (
<KeyValuePair label="Short Name" value={mapReport.shortName} vertical />
)}
{mapReport.hwModel !== undefined && (
<KeyValuePair
label="Hardware"
value={getHardwareModelName(mapReport.hwModel)}
vertical
/>
)}
{mapReport.role !== undefined && (
<KeyValuePair
label="Role"
value={getRoleName(mapReport.role)}
vertical
/>
)}
{mapReport.firmwareVersion && (
<KeyValuePair
label="Firmware"
value={mapReport.firmwareVersion}
vertical
monospace
/>
)}
{mapReport.region !== undefined && (
<KeyValuePair
label="Region"
value={getRegionName(mapReport.region)}
vertical
/>
)}
{mapReport.modemPreset !== undefined && (
<KeyValuePair
label="Modem Preset"
value={getModemPresetName(mapReport.modemPreset)}
vertical
/>
)}
{mapReport.numOnlineLocalNodes !== undefined && (
<KeyValuePair
label="Online Nodes"
value={mapReport.numOnlineLocalNodes.toString()}
vertical
monospace
/>
)}
{mapReport.hasDefaultChannel !== undefined && (
<KeyValuePair
label="Default Channel"
value={mapReport.hasDefaultChannel ? "Yes" : "No"}
vertical
/>
)}
</KeyValueGrid>
</div>
{center && (
<div className="h-[300px] w-full rounded-lg overflow-hidden">
<Map
latitude={center.latitude}
)}
{center && (
<div>
<h3 className="text-sm font-medium text-neutral-300 mb-3 flex items-center">
<MapPin className="w-4 h-4 mr-2 text-cyan-400" />
Gateway Location
</h3>
<div className="h-[300px] rounded-lg overflow-hidden relative">
<Map
latitude={center.latitude}
longitude={center.longitude}
zoom={12}
width={400}
height={300}
flush={true}
caption="Network Overview"
caption="Gateway Location"
precisionBits={precisionBits}
/>
{precisionBits !== undefined && (
<div className="bg-black/60 px-3 py-1 text-xs text-white absolute bottom-0 left-0 right-0">
{positionAccuracy < 1000
? `Location Accuracy: ±${positionAccuracy.toFixed(0)}m`
: `Location Accuracy: ±${(positionAccuracy / 1000).toFixed(1)}km`}
</div>
)}
</div>
)}
</div>
{/* Position information */}
<div className="grid grid-cols-2 gap-3 mt-3 text-sm">
<KeyValuePair
label="Coordinates"
value={`${center.latitude.toFixed(6)}, ${center.longitude.toFixed(6)}`}
monospace={true}
inset={true}
/>
{/* Position accuracy (always show, even with default value) */}
<KeyValuePair
label="Accuracy"
value={positionAccuracy < 1000
? `±${positionAccuracy.toFixed(0)} m`
: `±${(positionAccuracy / 1000).toFixed(1)} km`}
monospace={true}
inset={true}
/>
{mapReport.positionPrecision !== undefined && (
<KeyValuePair
label="Precision"
value={`${mapReport.positionPrecision} bits`}
monospace={true}
inset={true}
/>
)}
{mapReport.altitude !== undefined && (
<KeyValuePair
label="Altitude"
value={`${mapReport.altitude} m`}
monospace={true}
inset={true}
/>
)}
</div>
</div>
)}
</div>
</PacketCard>
);
};
};

View File

@@ -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<PositionPacketProps> = ({ packet }) => {
<KeyValuePair
label="Latitude"
value={latitude !== undefined ? latitude.toFixed(6) : 'N/A'}
vertical
monospace
/>
<KeyValuePair
label="Longitude"
value={longitude !== undefined ? longitude.toFixed(6) : 'N/A'}
vertical
monospace
/>
{position.altitude && (
<KeyValuePair
label="Altitude"
value={`${position.altitude.toFixed(1)}m`}
vertical
monospace
/>
)}
{position.time && (
<KeyValuePair
label="Time"
value={formattedTime}
vertical
/>
)}
{position.locationSource && (
<KeyValuePair
label="Source"
value={position.locationSource.replace('LOC_', '')}
vertical
/>
)}
{position.satsInView && (
<KeyValuePair
label="Satellites"
value={position.satsInView}
vertical
monospace
highlight={position.satsInView > 6}
/>
)}
</KeyValueGrid>

View File

@@ -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<WaypointPacketProps> = ({ packet }) => {
)}
<KeyValueGrid>
{latitude !== undefined && (
<KeyValuePair label="Latitude" value={latitude.toFixed(6)} />
<KeyValuePair label="Latitude" value={latitude.toFixed(6)} vertical monospace />
)}
{longitude !== undefined && (
<KeyValuePair label="Longitude" value={longitude.toFixed(6)} />
<KeyValuePair label="Longitude" value={longitude.toFixed(6)} vertical monospace />
)}
{waypoint.id !== undefined && (
<KeyValuePair label="Waypoint ID" value={waypoint.id} />
<KeyValuePair label="Waypoint ID" value={waypoint.id} vertical monospace />
)}
{expireTime && <KeyValuePair label="Expires" value={expireTime} />}
{expireTime && <KeyValuePair label="Expires" value={expireTime} vertical />}
{waypoint.lockedTo !== undefined && waypoint.lockedTo > 0 && (
<KeyValuePair
label="Locked To"
value={`!${waypoint.lockedTo.toString(16)}`}
vertical
monospace
/>
)}
</KeyValueGrid>

View File

@@ -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<KeyValuePairProps> = ({
label,
value,
icon,
monospace = true,
highlight = false,
large = false,
vertical = false,
inset = false,
}) => {
if (vertical) {
return (
<div>
<div
className={cn(
"text-xs tracking-wide text-neutral-400 uppercase",
{ "mb-1": large },
{ "flex items-center": icon }
)}
>
{icon && <span className="mr-1.5 text-neutral-300">{icon}</span>}
{label}
</div>
<div
className={cn(
{
"text-base font-medium tracking-wide": large,
"tracking-tight": !large,
},
{ "font-mono text-sm": monospace },
{ "text-blue-400": highlight, "text-neutral-200": !highlight }
)}
>
{value || "—"}
</div>
</div>
);
}
return (
<div
className={cn("flex items-center justify-between", {
"bg-neutral-700/50 p-2 rounded effect-inset": inset,
})}
>
<span className="text-neutral-400 flex items-center text-sm">
{icon && <span className="mr-2 text-neutral-300">{icon}</span>}
{label}
</span>
<span
className={cn(
{ "text-blue-400": highlight, "text-neutral-200": !highlight },
{ "font-mono text-sm": monospace },
{ "font-medium": large }
)}
>
{value || "—"}
</span>
</div>
);
};
/**
* 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 (
<div className={cn(`grid grid-cols-${columns} gap-x-6 gap-y-3`, className)}>
{children}
</div>
);
};

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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

View File

@@ -19,6 +19,15 @@ export default {
'Arial',
'sans-serif',
],
mono: [
'"JetBrains Mono"',
'"Share Tech Mono"',
'"Space Mono"',
'"Roboto Mono"',
'"VT323"',
'"Courier New"',
'monospace',
],
}
},
},