diff --git a/decoder/decoder.go b/decoder/decoder.go index bc4e31c..d2203f0 100644 --- a/decoder/decoder.go +++ b/decoder/decoder.go @@ -112,6 +112,8 @@ func DecodeMessage(payload []byte, topicInfo *meshtreampb.TopicInfo) *meshtreamp data.ViaMqtt = packet.GetViaMqtt() data.NextHop = packet.GetNextHop() data.RelayNode = packet.GetRelayNode() + data.RxSnr = packet.GetRxSnr() + data.RxRssi = packet.GetRxRssi() // Process the payload if packet.GetDecoded() != nil { diff --git a/generated/google/protobuf/descriptor.pb.go b/generated/google/protobuf/descriptor.pb.go index 8034976..fc84964 100644 --- a/generated/google/protobuf/descriptor.pb.go +++ b/generated/google/protobuf/descriptor.pb.go @@ -38,8 +38,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: google/protobuf/descriptor.proto package descriptorpb diff --git a/generated/meshstream/meshstream.pb.go b/generated/meshstream/meshstream.pb.go index 782845a..5e26fac 100644 --- a/generated/meshstream/meshstream.pb.go +++ b/generated/meshstream/meshstream.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshstream/meshstream.proto package meshtreampb @@ -222,7 +222,10 @@ type Data struct { // Error tracking DecodeError string `protobuf:"bytes,60,opt,name=decode_error,json=decodeError,proto3" json:"decode_error,omitempty"` // Reception timestamp (added by decoder) - RxTime uint64 `protobuf:"varint,61,opt,name=rx_time,json=rxTime,proto3" json:"rx_time,omitempty"` + RxTime uint64 `protobuf:"varint,61,opt,name=rx_time,json=rxTime,proto3" json:"rx_time,omitempty"` + // RF reception quality (measured at gateway) + RxSnr float32 `protobuf:"fixed32,62,opt,name=rx_snr,json=rxSnr,proto3" json:"rx_snr,omitempty"` // SNR at receiving gateway (dB) + RxRssi int32 `protobuf:"varint,63,opt,name=rx_rssi,json=rxRssi,proto3" json:"rx_rssi,omitempty"` // RSSI at receiving gateway (dBm) unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -663,6 +666,20 @@ func (x *Data) GetRxTime() uint64 { return 0 } +func (x *Data) GetRxSnr() float32 { + if x != nil { + return x.RxSnr + } + return 0 +} + +func (x *Data) GetRxRssi() int32 { + if x != nil { + return x.RxRssi + } + return 0 +} + type isData_Payload interface { isData_Payload() } @@ -852,7 +869,7 @@ const file_meshstream_meshstream_proto_rawDesc = "" + "\aversion\x18\x03 \x01(\tR\aversion\x12\x16\n" + "\x06format\x18\x04 \x01(\tR\x06format\x12\x18\n" + "\achannel\x18\x05 \x01(\tR\achannel\x12\x17\n" + - "\auser_id\x18\x06 \x01(\tR\x06userId\"\x94\x0e\n" + + "\auser_id\x18\x06 \x01(\tR\x06userId\"\xc4\x0e\n" + "\x04Data\x12\x1d\n" + "\n" + "channel_id\x18\x01 \x01(\tR\tchannelId\x12\x1d\n" + @@ -916,7 +933,9 @@ const file_meshstream_meshstream_proto_rawDesc = "" + "\x06source\x186 \x01(\rR\x06source\x12#\n" + "\rwant_response\x187 \x01(\bR\fwantResponse\x12!\n" + "\fdecode_error\x18< \x01(\tR\vdecodeError\x12\x17\n" + - "\arx_time\x18= \x01(\x04R\x06rxTimeB\t\n" + + "\arx_time\x18= \x01(\x04R\x06rxTime\x12\x15\n" + + "\x06rx_snr\x18> \x01(\x02R\x05rxSnr\x12\x17\n" + + "\arx_rssi\x18? \x01(\x05R\x06rxRssiB\t\n" + "\apayloadB(Z&proto/generated/meshstream;meshtreampbb\x06proto3" var ( diff --git a/generated/meshtastic/admin.pb.go b/generated/meshtastic/admin.pb.go index f322f00..bd1fb57 100644 --- a/generated/meshtastic/admin.pb.go +++ b/generated/meshtastic/admin.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/admin.proto package meshtastic diff --git a/generated/meshtastic/apponly.pb.go b/generated/meshtastic/apponly.pb.go index 27c8497..ff57be2 100644 --- a/generated/meshtastic/apponly.pb.go +++ b/generated/meshtastic/apponly.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/apponly.proto package meshtastic diff --git a/generated/meshtastic/atak.pb.go b/generated/meshtastic/atak.pb.go index 181f995..7594c46 100644 --- a/generated/meshtastic/atak.pb.go +++ b/generated/meshtastic/atak.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/atak.proto package meshtastic diff --git a/generated/meshtastic/cannedmessages.pb.go b/generated/meshtastic/cannedmessages.pb.go index d72a607..449ad0c 100644 --- a/generated/meshtastic/cannedmessages.pb.go +++ b/generated/meshtastic/cannedmessages.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/cannedmessages.proto package meshtastic diff --git a/generated/meshtastic/channel.pb.go b/generated/meshtastic/channel.pb.go index 8867bac..8cf3268 100644 --- a/generated/meshtastic/channel.pb.go +++ b/generated/meshtastic/channel.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/channel.proto package meshtastic diff --git a/generated/meshtastic/clientonly.pb.go b/generated/meshtastic/clientonly.pb.go index d19a6f5..f94f41e 100644 --- a/generated/meshtastic/clientonly.pb.go +++ b/generated/meshtastic/clientonly.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/clientonly.proto package meshtastic diff --git a/generated/meshtastic/config.pb.go b/generated/meshtastic/config.pb.go index 034d982..d5bae3a 100644 --- a/generated/meshtastic/config.pb.go +++ b/generated/meshtastic/config.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/config.proto package meshtastic diff --git a/generated/meshtastic/connection_status.pb.go b/generated/meshtastic/connection_status.pb.go index 7856595..359abfd 100644 --- a/generated/meshtastic/connection_status.pb.go +++ b/generated/meshtastic/connection_status.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/connection_status.proto package meshtastic diff --git a/generated/meshtastic/device_ui.pb.go b/generated/meshtastic/device_ui.pb.go index d40040f..24301d5 100644 --- a/generated/meshtastic/device_ui.pb.go +++ b/generated/meshtastic/device_ui.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/device_ui.proto package meshtastic diff --git a/generated/meshtastic/deviceonly.pb.go b/generated/meshtastic/deviceonly.pb.go index 17548c6..f9b420e 100644 --- a/generated/meshtastic/deviceonly.pb.go +++ b/generated/meshtastic/deviceonly.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/deviceonly.proto package meshtastic diff --git a/generated/meshtastic/interdevice.pb.go b/generated/meshtastic/interdevice.pb.go index db8f3c3..c83ab6f 100644 --- a/generated/meshtastic/interdevice.pb.go +++ b/generated/meshtastic/interdevice.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/interdevice.proto package meshtastic diff --git a/generated/meshtastic/localonly.pb.go b/generated/meshtastic/localonly.pb.go index 7a1335e..7ff62a3 100644 --- a/generated/meshtastic/localonly.pb.go +++ b/generated/meshtastic/localonly.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/localonly.proto package meshtastic diff --git a/generated/meshtastic/mesh.pb.go b/generated/meshtastic/mesh.pb.go index 0544259..c87eb87 100644 --- a/generated/meshtastic/mesh.pb.go +++ b/generated/meshtastic/mesh.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/mesh.proto package meshtastic diff --git a/generated/meshtastic/module_config.pb.go b/generated/meshtastic/module_config.pb.go index 470881c..91cc83b 100644 --- a/generated/meshtastic/module_config.pb.go +++ b/generated/meshtastic/module_config.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/module_config.proto package meshtastic diff --git a/generated/meshtastic/mqtt.pb.go b/generated/meshtastic/mqtt.pb.go index 747021f..63ac9b7 100644 --- a/generated/meshtastic/mqtt.pb.go +++ b/generated/meshtastic/mqtt.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/mqtt.proto package meshtastic diff --git a/generated/meshtastic/nanopb.pb.go b/generated/meshtastic/nanopb.pb.go index 8f921f0..8053e21 100644 --- a/generated/meshtastic/nanopb.pb.go +++ b/generated/meshtastic/nanopb.pb.go @@ -7,8 +7,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/nanopb.proto package meshtastic diff --git a/generated/meshtastic/paxcount.pb.go b/generated/meshtastic/paxcount.pb.go index aebcb6b..969e59c 100644 --- a/generated/meshtastic/paxcount.pb.go +++ b/generated/meshtastic/paxcount.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/paxcount.proto package meshtastic diff --git a/generated/meshtastic/portnums.pb.go b/generated/meshtastic/portnums.pb.go index 384e0f8..1a3ad0d 100644 --- a/generated/meshtastic/portnums.pb.go +++ b/generated/meshtastic/portnums.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/portnums.proto package meshtastic diff --git a/generated/meshtastic/powermon.pb.go b/generated/meshtastic/powermon.pb.go index a0111ce..b500dfa 100644 --- a/generated/meshtastic/powermon.pb.go +++ b/generated/meshtastic/powermon.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/powermon.proto package meshtastic diff --git a/generated/meshtastic/remote_hardware.pb.go b/generated/meshtastic/remote_hardware.pb.go index f2529da..b7650ed 100644 --- a/generated/meshtastic/remote_hardware.pb.go +++ b/generated/meshtastic/remote_hardware.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/remote_hardware.proto package meshtastic diff --git a/generated/meshtastic/rtttl.pb.go b/generated/meshtastic/rtttl.pb.go index fe46359..6d3e823 100644 --- a/generated/meshtastic/rtttl.pb.go +++ b/generated/meshtastic/rtttl.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/rtttl.proto package meshtastic diff --git a/generated/meshtastic/storeforward.pb.go b/generated/meshtastic/storeforward.pb.go index 45e355c..41d6702 100644 --- a/generated/meshtastic/storeforward.pb.go +++ b/generated/meshtastic/storeforward.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/storeforward.proto package meshtastic diff --git a/generated/meshtastic/telemetry.pb.go b/generated/meshtastic/telemetry.pb.go index 3e1c7d6..e5414a1 100644 --- a/generated/meshtastic/telemetry.pb.go +++ b/generated/meshtastic/telemetry.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/telemetry.proto package meshtastic diff --git a/generated/meshtastic/xmodem.pb.go b/generated/meshtastic/xmodem.pb.go index 2ca88c6..615fe40 100644 --- a/generated/meshtastic/xmodem.pb.go +++ b/generated/meshtastic/xmodem.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.6 -// protoc v5.29.3 +// protoc-gen-go v1.36.11 +// protoc v4.25.1 // source: meshtastic/xmodem.proto package meshtastic diff --git a/proto/meshstream/meshstream.proto b/proto/meshstream/meshstream.proto index 9259a95..ec05c0b 100644 --- a/proto/meshstream/meshstream.proto +++ b/proto/meshstream/meshstream.proto @@ -91,7 +91,11 @@ message Data { // Error tracking string decode_error = 60; - + // Reception timestamp (added by decoder) uint64 rx_time = 61; + + // RF reception quality (measured at gateway) + float rx_snr = 62; // SNR at receiving gateway (dB) + int32 rx_rssi = 63; // RSSI at receiving gateway (dBm) } \ No newline at end of file diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx index 92880c6..d7e9860 100644 --- a/web/src/components/dashboard/NetworkMap.tsx +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from "react"; import { useAppSelector } from "../../hooks"; import { useNavigate } from "@tanstack/react-router"; import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice"; +import { LinkObservation } from "../../store/slices/topologySlice"; import { Position } from "../../lib/types"; import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity"; import { GOOGLE_MAPS_ID } from "../../lib/config"; @@ -13,13 +14,15 @@ interface NetworkMapProps { onAutoZoomChange?: (enabled: boolean) => void; /** Whether the map should take all available space (default: false) */ fullHeight?: boolean; + /** Whether to show topology link polylines (default: true) */ + showLinks?: boolean; } /** * NetworkMap displays all nodes with position data on a Google Map */ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>( - ({ height, fullHeight = false, onAutoZoomChange }, ref) => { + ({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => { const navigate = useNavigate(); const mapRef = useRef(null); const mapInstanceRef = useRef(null); @@ -31,10 +34,12 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); // Using any for the event listener since TypeScript can't find the MapsEventListener interface const zoomListenerRef = useRef(null); + const polylinesRef = useRef>({}); const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false); // Get nodes data from the store const { nodes, gateways } = useAppSelector((state) => state.aggregator); + const topologyLinks = useAppSelector((state) => state.topology.links); // Expose the resetAutoZoom function via ref React.useImperativeHandle(ref, () => ({ @@ -268,6 +273,71 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ } }, [autoZoomEnabled, fitMapToBounds, createMarker, updateMarker]); + // Update topology polylines on the map + const updateLinks = useCallback(( + links: Record, + nodePositions: MapNode[], + visible: boolean + ): void => { + if (!mapInstanceRef.current || !window.google?.maps) return; + + // Build position lookup + const posMap = new Map(); + for (const node of nodePositions) { + posMap.set(node.id, { + lat: node.position.latitudeI / 10000000, + lng: node.position.longitudeI / 10000000, + }); + } + + const activeKeys = new Set(); + + for (const link of Object.values(links)) { + const posA = posMap.get(link.nodeA); + const posB = posMap.get(link.nodeB); + if (!posA || !posB) continue; + + activeKeys.add(link.key); + + // Determine color based on best available SNR + const snr = link.snrAtoB ?? link.snrBtoA; + let strokeColor: string; + if (snr === undefined) { + strokeColor = "#6b7280"; // gray — no SNR data + } else if (snr >= 5) { + strokeColor = "#22c55e"; // green — strong + } else if (snr >= 0) { + strokeColor = "#eab308"; // yellow — marginal + } else { + strokeColor = "#ef4444"; // red — weak + } + const strokeOpacity = link.viaMqtt ? 0.4 : 0.7; + + if (polylinesRef.current[link.key]) { + const pl = polylinesRef.current[link.key]; + pl.setPath([posA, posB]); + pl.setOptions({ strokeColor, strokeOpacity, visible }); + } else { + polylinesRef.current[link.key] = new google.maps.Polyline({ + path: [posA, posB], + geodesic: true, + strokeColor, + strokeOpacity, + strokeWeight: 2, + map: visible ? mapInstanceRef.current : null, + }); + } + } + + // Remove polylines for edges no longer in state + for (const key of Object.keys(polylinesRef.current)) { + if (!activeKeys.has(key)) { + polylinesRef.current[key].setMap(null); + delete polylinesRef.current[key]; + } + } + }, []); + // Check for Google Maps API and initialize const tryInitializeMap = useCallback(() => { if (mapRef.current && window.google && window.google.maps) { @@ -284,6 +354,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ // Update markers and fit the map updateNodeMarkers(nodesWithPosition); + updateLinks(topologyLinks, nodesWithPosition, showLinks); return true; } catch (error) { console.error("Error initializing map:", error); @@ -292,7 +363,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ } console.warn("Cannot initialize map - prerequisites not met"); return false; - }, [nodesWithPosition, updateNodeMarkers, initializeMap]); + }, [nodesWithPosition, topologyLinks, showLinks, updateNodeMarkers, updateLinks, initializeMap]); // Check for Google Maps API loading - make sure all required objects are available useEffect(() => { @@ -378,6 +449,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ const markers = markersRef; const animatingNodes = animatingNodesRef; const infoWindow = infoWindowRef; + const polylines = polylinesRef; return () => { if (zoomListener.current && window.google && window.google.maps) { window.google.maps.event.removeListener(zoomListener.current); @@ -387,6 +459,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ Object.values(animatingNodes.current).forEach(timeoutId => window.clearTimeout(timeoutId) ); + Object.values(polylines.current).forEach(pl => pl.setMap(null)); if (infoWindow.current) { infoWindow.current.close(); } diff --git a/web/src/components/dashboard/NodeDetail.tsx b/web/src/components/dashboard/NodeDetail.tsx index 90d89ab..9bbff27 100644 --- a/web/src/components/dashboard/NodeDetail.tsx +++ b/web/src/components/dashboard/NodeDetail.tsx @@ -2,6 +2,7 @@ import React, { useEffect } from "react"; import { useNavigate, Link } from "@tanstack/react-router"; import { useAppSelector, useAppDispatch } from "../../hooks"; import { selectNode } from "../../store/slices/aggregatorSlice"; +import { LinkSource } from "../../store/slices/topologySlice"; import { getActivityLevel, getNodeColors, @@ -91,6 +92,7 @@ export const NodeDetail: React.FC = ({ nodeId }) => { const dispatch = useAppDispatch(); const navigate = useNavigate(); const { nodes, gateways } = useAppSelector((state) => state.aggregator); + const topologyLinks = useAppSelector((state) => state.topology.links); // Construct the gateway ID format from the node ID const gatewayId = `!${nodeId.toString(16).toLowerCase()}`; @@ -601,6 +603,15 @@ export const NodeDetail: React.FC = ({ nodeId }) => { )} + {/* Connections - Full Width */} +
} + className="mt-6" + > + +
+ {/* Recent Packets - Full Width */}
= ({ nodeId }) => { ); }; + +// ── Source badge helpers ────────────────────────────────────────────────────── + +const SOURCE_LABELS: Record = { + traceroute: "Traceroute", + zero_hop: "Zero-hop", + neighbor_info: "Neighbor", + relay_inferred: "Relayed", + unknown: "Unknown", +}; + +const SOURCE_COLORS: Record = { + traceroute: "bg-blue-900 text-blue-300", + zero_hop: "bg-green-900 text-green-300", + neighbor_info: "bg-yellow-900 text-yellow-300", + relay_inferred: "bg-neutral-700 text-neutral-300", + unknown: "bg-neutral-800 text-neutral-500", +}; + +const SOURCE_CONFIDENCE: Record = { + traceroute: 4, + neighbor_info: 3, + zero_hop: 2, + relay_inferred: 1, + unknown: 0, +}; + +function bestSource(a: LinkSource, b: LinkSource): LinkSource { + return SOURCE_CONFIDENCE[a] >= SOURCE_CONFIDENCE[b] ? a : b; +} + +// ── NodeConnections component ──────────────────────────────────────────────── + +interface NodeConnectionsProps { + nodeId: number; + links: Record; + nodes: Record; +} + +const NodeConnections: React.FC = ({ nodeId, links, nodes }) => { + const edges = Object.values(links).filter( + (l) => l.nodeA === nodeId || l.nodeB === nodeId + ); + + if (edges.length === 0) { + return ( +

No connections observed yet.

+ ); + } + + return ( +
+ {edges.map((link) => { + const neighborId = link.nodeA === nodeId ? link.nodeB : link.nodeA; + const neighborNode = nodes[neighborId]; + const neighborName = + neighborNode?.shortName ?? + neighborNode?.longName ?? + `!${neighborId.toString(16)}`; + + // Determine SNR direction relative to nodeId + // snrAtoB = SNR at nodeB receiving from nodeA + // snrBtoA = SNR at nodeA receiving from nodeB + // "outgoing SNR" = SNR that the neighbor measured (i.e., neighbor receiving from nodeId) + // "incoming SNR" = SNR that nodeId measured (i.e., nodeId receiving from neighbor) + const outgoingSnr = + nodeId === link.nodeA ? link.snrAtoB : link.snrBtoA; + const incomingSnr = + nodeId === link.nodeA ? link.snrBtoA : link.snrAtoB; + + const source = bestSource(link.sourceAtoB, link.sourceBtoA); + const secondsAgo = Math.floor(Date.now() / 1000) - link.lastSeen; + + return ( +
+
+
+ + {neighborName} + + + {SOURCE_LABELS[source]} + + {link.viaMqtt && ( + + MQTT + + )} +
+
+ {outgoingSnr !== undefined && ( + → {outgoingSnr.toFixed(1)} dB + )} + {incomingSnr !== undefined && ( + ← {incomingSnr.toFixed(1)} dB + )} + {link.rssiAtoB !== undefined && nodeId === link.nodeB && ( + + RSSI {link.rssiAtoB} dBm + + )} +
+
+
+ {formatLastSeen(secondsAgo)} +
+
+ ); + })} +
+ ); +}; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e3e0b44..df3f77e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -394,6 +394,10 @@ export interface Data { // Reception timestamp (added by decoder) rxTime?: number; + + // RF reception quality (measured at gateway) + rxSnr?: number; // SNR at receiving gateway (dB) + rxRssi?: number; // RSSI at receiving gateway (dBm) } // Packet represents a complete decoded MQTT message diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 780e181..8a2d733 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -6,6 +6,8 @@ import { streamPackets, StreamEvent, ConnectionInfoEvent } from "../lib/api"; import { processNewPacket } from "../store/slices/aggregatorSlice"; import { addPacket } from "../store/slices/packetSlice"; import { updateConnectionInfo, updateConnectionStatus } from "../store/slices/connectionSlice"; +import { processTopologyPacket } from "../store/slices/topologySlice"; +import { store } from "../store"; import { createRootRoute } from "@tanstack/react-router"; export const Route = createRootRoute({ @@ -56,6 +58,10 @@ function RootLayout() { // Process message for both the aggregator and packet display dispatch(processNewPacket(event.data)); dispatch(addPacket(event.data)); + // Dispatch topology processing (receives all copies including multi-gateway) + const timestamp = event.data.data.rxTime || Math.floor(Date.now() / 1000); + const nodeIds = Object.keys(store.getState().aggregator.nodes).map(Number); + dispatch(processTopologyPacket({ packet: event.data, timestamp, nodeIds })); } else if (event.type === "bad_data") { console.warn("[SSE] Received bad data:", event.data); } diff --git a/web/src/routes/map.tsx b/web/src/routes/map.tsx index 7b9cf71..ee00c3e 100644 --- a/web/src/routes/map.tsx +++ b/web/src/routes/map.tsx @@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router"; import { PageWrapper } from "../components"; import { NetworkMap } from "../components/dashboard"; import { Button } from "../components/ui"; -import { Locate } from "lucide-react"; +import { Locate, GitBranch } from "lucide-react"; import { getNodeColors, ActivityLevel } from "../lib/activity"; export const Route = createFileRoute("/map")({ @@ -13,6 +13,7 @@ export const Route = createFileRoute("/map")({ function MapPage() { // State to track if auto-zoom is enabled (forwarded from the NetworkMap component) const [autoZoomEnabled, setAutoZoomEnabled] = React.useState(true); + const [showLinks, setShowLinks] = React.useState(true); const mapRef = React.useRef<{ resetAutoZoom?: () => void }>({}); // Function to reset auto-zoom, will be called by the button @@ -26,10 +27,11 @@ function MapPage() {
-
@@ -48,17 +50,28 @@ function MapPage() {
- {/* Always show the button, but disable it when auto-zoom is enabled */} - +
+ + {/* Always show the button, but disable it when auto-zoom is enabled */} + +
diff --git a/web/src/store/index.ts b/web/src/store/index.ts index 950857e..6c0e4c3 100644 --- a/web/src/store/index.ts +++ b/web/src/store/index.ts @@ -2,12 +2,14 @@ import { configureStore } from '@reduxjs/toolkit'; import packetReducer from './slices/packetSlice'; import aggregatorReducer from './slices/aggregatorSlice'; import connectionReducer from './slices/connectionSlice'; +import topologyReducer from './slices/topologySlice'; export const store = configureStore({ reducer: { packets: packetReducer, aggregator: aggregatorReducer, connection: connectionReducer, + topology: topologyReducer, }, }); diff --git a/web/src/store/slices/aggregatorSlice.ts b/web/src/store/slices/aggregatorSlice.ts index 601c89e..a92a50b 100644 --- a/web/src/store/slices/aggregatorSlice.ts +++ b/web/src/store/slices/aggregatorSlice.ts @@ -29,6 +29,8 @@ export interface NodeData { // Fields for gateway nodes isGateway?: boolean; observedNodeCount?: number; + // Number of hops from the gateway (0 = direct RF link) + hopsFromGateway?: number; // MapReport payload for this node mapReport?: MapReport; // User-specific fields @@ -260,6 +262,10 @@ const processPacket = (state: AggregatorState, packet: Packet) => { node.textMessageCount++; } + // Track hop distance from gateway + const hops = (data.hopStart ?? 0) - (data.hopLimit ?? 0); + if (hops >= 0) node.hopsFromGateway = hops; + // Set channelId and gatewayId if available if (channelId) { node.channelId = channelId; diff --git a/web/src/store/slices/topologySlice.ts b/web/src/store/slices/topologySlice.ts new file mode 100644 index 0000000..4a68218 --- /dev/null +++ b/web/src/store/slices/topologySlice.ts @@ -0,0 +1,304 @@ +import { createSlice, PayloadAction } from "@reduxjs/toolkit"; +import { Packet, PortNum } from "../../lib/types"; + +export type LinkSource = + | "traceroute" + | "neighbor_info" + | "zero_hop" + | "relay_inferred" + | "unknown"; + +export interface LinkObservation { + key: string; + nodeA: number; // lower numeric node ID + nodeB: number; // higher numeric node ID + snrAtoB?: number; // SNR in dB at nodeB receiving from nodeA + snrBtoA?: number; // SNR in dB at nodeA receiving from nodeB + rssiAtoB?: number; // RSSI in dBm at nodeB receiving from nodeA (gateway-measured) + sourceAtoB: LinkSource; // confidence for snrAtoB direction + sourceBtoA: LinkSource; // confidence for snrBtoA direction + viaMqtt?: boolean; // true if any observation crossed an MQTT bridge + lastSeen: number; // unix timestamp + hopCount?: number; // total traceroute path length +} + +interface TopologyState { + links: Record; + // packetKey → timestamp for dedup (traceroute + neighborInfo) + processedTraceroutes: Record; +} + +const initialState: TopologyState = { + links: {}, + processedTraceroutes: {}, +}; + +const SOURCE_CONFIDENCE: Record = { + traceroute: 4, + neighbor_info: 3, + zero_hop: 2, + relay_inferred: 1, + unknown: 0, +}; + +const MAX_LINKS = 2000; + +// Upsert a directed observation: sender transmitted, receiver measured snr/rssi. +// Updates the canonical link entry, applying per-direction confidence rules. +function upsertObservation( + state: TopologyState, + sender: number, + receiver: number, + snr: number | undefined, + rssi: number | undefined, + source: LinkSource, + viaMqtt: boolean, + timestamp: number, + hopCount?: number +): void { + const nodeA = Math.min(sender, receiver); + const nodeB = Math.max(sender, receiver); + const key = `${nodeA}-${nodeB}`; + + if (!state.links[key]) { + state.links[key] = { + key, + nodeA, + nodeB, + sourceAtoB: "unknown", + sourceBtoA: "unknown", + lastSeen: timestamp, + }; + } + + const link = state.links[key]; + link.lastSeen = Math.max(link.lastSeen, timestamp); + if (viaMqtt) link.viaMqtt = true; + if (hopCount !== undefined) link.hopCount = hopCount; + + // "sender→receiver": if sender===nodeA, this is AtoB (nodeB received from nodeA) + const isAtoB = sender === nodeA; + const currentSource: LinkSource = isAtoB + ? link.sourceAtoB + : link.sourceBtoA; + + if ( + currentSource === "unknown" || + SOURCE_CONFIDENCE[source] > SOURCE_CONFIDENCE[currentSource] + ) { + if (isAtoB) { + link.sourceAtoB = source; + if (snr !== undefined) link.snrAtoB = snr; + if (rssi !== undefined) link.rssiAtoB = rssi; + } else { + link.sourceBtoA = source; + if (snr !== undefined) link.snrBtoA = snr; + // No rssi for BtoA — only gateway-measured (AtoB) rssi is tracked + } + } +} + +function resolvePortNum(portNum: PortNum | string): PortNum | undefined { + if (typeof portNum === "number") return portNum; + const n = PortNum[portNum as keyof typeof PortNum]; + return typeof n === "number" ? n : undefined; +} + +function processTopology( + state: TopologyState, + packet: Packet, + timestamp: number, + nodeIds: number[] +): void { + const { data } = packet; + + // ── Zero-hop observations (no dedup; each gateway copy is independent) ── + if ( + data.hopStart !== undefined && + data.hopLimit !== undefined && + data.hopStart === data.hopLimit && + data.gatewayId?.startsWith("!") + ) { + const gatewayNum = parseInt(data.gatewayId.substring(1), 16); + if (!isNaN(gatewayNum) && data.from !== gatewayNum) { + upsertObservation( + state, + data.from, + gatewayNum, + data.rxSnr, + data.rxRssi, + "zero_hop", + !!data.viaMqtt, + timestamp + ); + } + } + + const portNum = resolvePortNum(data.portNum); + + // ── Traceroute replies ── + if ( + portNum === PortNum.TRACEROUTE_APP && + !data.wantResponse && + data.routeDiscovery + ) { + const packetKey = `!${data.from.toString(16).toLowerCase()}_${data.id}`; + if (state.processedTraceroutes[packetKey]) return; + state.processedTraceroutes[packetKey] = timestamp; + + const rd = data.routeDiscovery; + + // Forward path: data.to → ...route → data.from + const forwardPath = [data.to, ...(rd.route ?? []), data.from]; + const snrTowards = rd.snrTowards ?? []; + const hopCount = forwardPath.length; + + for (let i = 0; i < forwardPath.length - 1; i++) { + const cap = Math.min(snrTowards.length, forwardPath.length - 1); + const snr = i < cap ? snrTowards[i] / 4 : undefined; + upsertObservation( + state, + forwardPath[i], // sender + forwardPath[i + 1], // receiver + snr, + undefined, + "traceroute", + !!data.viaMqtt, + timestamp, + hopCount + ); + } + + // Return path: data.from → ...routeBack → data.to + const returnPath = [data.from, ...(rd.routeBack ?? []), data.to]; + const snrBack = rd.snrBack ?? []; + + for (let i = 0; i < returnPath.length - 1; i++) { + const cap = Math.min(snrBack.length, returnPath.length - 1); + const snr = i < cap ? snrBack[i] / 4 : undefined; + upsertObservation( + state, + returnPath[i], + returnPath[i + 1], + snr, + undefined, + "traceroute", + !!data.viaMqtt, + timestamp, + hopCount + ); + } + return; + } + + // ── NeighborInfo packets ── + if (portNum === PortNum.NEIGHBORINFO_APP && data.neighborInfo) { + const ni = data.neighborInfo; + const broadcaster = ni.nodeId; + const packetKey = `!${broadcaster.toString(16).toLowerCase()}_${data.id}`; + if (state.processedTraceroutes[packetKey]) return; + state.processedTraceroutes[packetKey] = timestamp; + + for (const neighbor of ni.neighbors ?? []) { + // broadcaster received from neighbor → sender=neighbor, receiver=broadcaster + upsertObservation( + state, + neighbor.nodeId, + broadcaster, + neighbor.snr, + undefined, + "neighbor_info", + !!data.viaMqtt, + timestamp + ); + } + return; + } + + // ── relay_node inferred links (1-hop packets with known relay) ── + if ( + data.hopStart !== undefined && + data.hopLimit !== undefined && + data.hopStart - data.hopLimit === 1 && + data.relayNode !== undefined && + data.relayNode !== 0 && + data.gatewayId?.startsWith("!") + ) { + const relayByte = data.relayNode & 0xff; + const candidates = nodeIds.filter((id) => (id & 0xff) === relayByte); + if (candidates.length === 1) { + const relay = candidates[0]; + const gatewayNum = parseInt(data.gatewayId.substring(1), 16); + if (!isNaN(gatewayNum)) { + // from → relay + upsertObservation( + state, + data.from, + relay, + undefined, + undefined, + "relay_inferred", + !!data.viaMqtt, + timestamp + ); + // relay → gateway + upsertObservation( + state, + relay, + gatewayNum, + undefined, + undefined, + "relay_inferred", + !!data.viaMqtt, + timestamp + ); + } + } + } +} + +const topologySlice = createSlice({ + name: "topology", + initialState, + reducers: { + processTopologyPacket: ( + state, + action: PayloadAction<{ + packet: Packet; + timestamp: number; + nodeIds: number[]; + }> + ) => { + const { packet, timestamp, nodeIds } = action.payload; + + // Prune edges and dedup entries older than 24 hours + const cutoff = timestamp - 86400; + for (const key of Object.keys(state.links)) { + if (state.links[key].lastSeen < cutoff) delete state.links[key]; + } + for (const key of Object.keys(state.processedTraceroutes)) { + if (state.processedTraceroutes[key] < cutoff) + delete state.processedTraceroutes[key]; + } + + processTopology(state, packet, timestamp, nodeIds); + + // Enforce 2000-edge cap: remove oldest when over limit + const linkCount = Object.keys(state.links).length; + if (linkCount > MAX_LINKS) { + const sorted = Object.entries(state.links).sort( + ([, a], [, b]) => a.lastSeen - b.lastSeen + ); + const toRemove = sorted.slice(0, linkCount - MAX_LINKS); + for (const [key] of toRemove) delete state.links[key]; + } + }, + clearTopology: (state) => { + state.links = {}; + state.processedTraceroutes = {}; + }, + }, +}); + +export const { processTopologyPacket, clearTopology } = topologySlice.actions; +export default topologySlice.reducer; diff --git a/web/src/types/google-maps.d.ts b/web/src/types/google-maps.d.ts index 16fbca3..a86fc28 100644 --- a/web/src/types/google-maps.d.ts +++ b/web/src/types/google-maps.d.ts @@ -47,11 +47,19 @@ declare namespace google { class InfoWindow { constructor(opts?: InfoWindowOptions); - setContent(content: string): void; + setContent(content: string | HTMLElement): void; open(map?: Map, anchor?: any): void; close(): void; } + class Polyline { + constructor(opts?: PolylineOptions); + setMap(map: Map | null): void; + setPath(path: LatLngLiteral[]): void; + setOptions(opts: PolylineOptions): void; + setVisible(visible: boolean): void; + } + class LatLngBounds { constructor(); extend(point: LatLngLiteral): void; @@ -99,6 +107,16 @@ declare namespace google { position?: LatLngLiteral; } + interface PolylineOptions { + path?: LatLngLiteral[]; + geodesic?: boolean; + strokeColor?: string; + strokeOpacity?: number; + strokeWeight?: number; + map?: Map | null; + visible?: boolean; + } + // Event-related functionality const event: { addListener(instance: object, event: string, listener: (Event) => void): MapsEventListener;