diff --git a/web/src/components/PacketList.tsx b/web/src/components/PacketList.tsx index 1992bff..a05a8fa 100644 --- a/web/src/components/PacketList.tsx +++ b/web/src/components/PacketList.tsx @@ -1,36 +1,221 @@ -import React from "react"; -import { useAppSelector } from "../hooks"; +import React, { useState, useEffect, useCallback } from "react"; +import { useAppSelector, useAppDispatch } from "../hooks"; +import { PacketRenderer } from "./packets/PacketRenderer"; +import { StreamControl } from "./StreamControl"; +import { Trash2, RefreshCw, Archive } from "lucide-react"; +import { clearPackets, toggleStreamPause } from "../store/slices/packetSlice"; +import { Packet } from "../lib/types"; + +// Number of packets to show per page +const PACKETS_PER_PAGE = 10; export const PacketList: React.FC = () => { - const { packets, loading, error } = useAppSelector((state) => state.packets); + const { packets, bufferedPackets, loading, error, streamPaused } = useAppSelector( + (state) => state.packets + ); + const dispatch = useAppDispatch(); + const [currentPage, setCurrentPage] = useState(1); + + // A unique ID is attached to each rendered packet element + const [packetKeys, setPacketKeys] = useState>({}); + + // Generate a reproducible hash code for a string + const hashString = useCallback((str: string): string => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + // Make sure hash is always positive and convert to string + return Math.abs(hash).toString(36); + }, []); + + // Create a consistent, unique fingerprint for a packet + const createPacketFingerprint = useCallback((packet: Packet): string => { + // Combine multiple fields to maximize uniqueness + const parts = [ + packet.data.from?.toString() ?? 'unknown', + packet.data.id?.toString() ?? 'noid', + packet.data.portNum?.toString() ?? 'noport', + packet.info.channel ?? 'nochannel', + packet.info.userId ?? 'nouser', + // Include a hash of raw JSON data for extra uniqueness + hashString(JSON.stringify(packet)), + ]; + return parts.join('_'); + }, [hashString]); + + // Update the packet keys whenever the packets change + useEffect(() => { + // Store new packet keys + const newKeys: Record = {...packetKeys}; + + // Assign keys to any packets that don't already have them + packets.forEach((packet) => { + const fingerprint = createPacketFingerprint(packet); + + // Only create new keys for packets we haven't seen before + if (!newKeys[fingerprint]) { + const randomPart = Math.random().toString(36).substring(2, 10); + newKeys[fingerprint] = `${fingerprint}_${randomPart}`; + } + }); + + // Only update state if we actually added new keys + if (Object.keys(newKeys).length !== Object.keys(packetKeys).length) { + setPacketKeys(newKeys); + } + }, [packets, createPacketFingerprint]); if (loading) { - return
Loading...
; + return ( +
+ + Loading... +
+ ); } if (error) { - return
Error: {error}
; + return ( +
+ Error: {error} +
+ ); } - if (packets.length === 0) { - return
No packets received yet
; + if (packets.length === 0 && bufferedPackets.length === 0) { + return ( +
+ No packets received yet +
+ ); } + // Calculate pagination + const totalPages = Math.ceil(packets.length / PACKETS_PER_PAGE); + const startIndex = (currentPage - 1) * PACKETS_PER_PAGE; + const endIndex = startIndex + PACKETS_PER_PAGE; + const currentPackets = packets.slice(startIndex, endIndex); + + const handleClearPackets = () => { + if (window.confirm("Are you sure you want to clear all packets?")) { + dispatch(clearPackets()); + setCurrentPage(1); + // Clear the packet keys too + setPacketKeys({}); + } + }; + + const handleToggleStream = () => { + dispatch(toggleStreamPause()); + // When unpausing with buffered packets, reset to first page to see new content + if (streamPaused && bufferedPackets.length > 0) { + setCurrentPage(1); + } + }; + + // Get the key for a packet + const getPacketKey = (packet: Packet, index: number): string => { + const fingerprint = createPacketFingerprint(packet); + // Fallback to index-based key if no fingerprint key exists + return packetKeys[fingerprint] || `packet_${index}_${Date.now()}`; + }; + return ( -
-

- Received Packets -

-
    - {packets.map((packet) => ( -
  • +
    +

    + Packets{" "} + + ({packets.length} total) + +

    + +
    + {/* Show buffered count when paused */} + {streamPaused && bufferedPackets.length > 0 && ( +
    + + {bufferedPackets.length} new +
    + )} + + {/* Stream control toggle */} + + + {/* Clear button */} + +
    +
    + +
      + {currentPackets.map((packet, index) => ( +
    • +
    • ))}
    + + {/* Empty state when no packets are visible but stream is paused */} + {packets.length === 0 && streamPaused && bufferedPackets.length > 0 && ( +
    + Stream is paused with {bufferedPackets.length} buffered messages. + +
    + )} + + {/* Pagination */} + {totalPages > 1 && ( +
    + + +
    + Page {currentPage} of {totalPages} +
    + + +
    + )}
); -}; +}; \ No newline at end of file diff --git a/web/src/components/StreamControl.tsx b/web/src/components/StreamControl.tsx new file mode 100644 index 0000000..76531a5 --- /dev/null +++ b/web/src/components/StreamControl.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { Play, Pause } from "lucide-react"; + +interface StreamControlProps { + isPaused: boolean; + onToggle: () => void; +} + +export const StreamControl: React.FC = ({ + isPaused, + onToggle, +}) => { + return ( + + ); +}; \ No newline at end of file diff --git a/web/src/components/index.ts b/web/src/components/index.ts index 37b25f5..3d4bc43 100644 --- a/web/src/components/index.ts +++ b/web/src/components/index.ts @@ -5,4 +5,5 @@ export * from './Filter'; export * from './InfoMessage'; export * from './ConnectionStatus'; export * from './Nav'; -export * from './Separator'; \ No newline at end of file +export * from './Separator'; +export * from './StreamControl'; \ No newline at end of file diff --git a/web/src/components/packets/ErrorPacket.tsx b/web/src/components/packets/ErrorPacket.tsx new file mode 100644 index 0000000..ee0fd95 --- /dev/null +++ b/web/src/components/packets/ErrorPacket.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Packet } from "../../lib/types"; +import { AlertTriangle } from "lucide-react"; + +interface ErrorPacketProps { + packet: Packet; +} + +export const ErrorPacket: React.FC = ({ packet }) => { + const { data } = packet; + + if (!data.decodeError) { + return null; + } + + return ( +
+
+
+
+ +
+ + Decode Error + +
+ + ID: {data.id || "No ID"} + +
+ +
+ {data.decodeError} +
+ +
+ Channel: {packet.info.channel} + Error +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/packets/GenericPacket.tsx b/web/src/components/packets/GenericPacket.tsx new file mode 100644 index 0000000..cd4c0ab --- /dev/null +++ b/web/src/components/packets/GenericPacket.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import { Packet, PortNum, PortNumByName } from "../../lib/types"; +import { Package } from "lucide-react"; + +interface GenericPacketProps { + packet: Packet; +} + +export const GenericPacket: React.FC = ({ packet }) => { + const { data } = packet; + + // Helper to get port name from enum + const getPortName = (portNum: PortNum | string): string => { + if (typeof portNum === 'string') { + return portNum; + } + return PortNum[portNum] || "Unknown"; + }; + + // Helper to get simple payload description + const getPayloadDescription = () => { + if (data.decodeError) { + return `Error: ${data.decodeError}`; + } + + // Determine what type of payload is present + if (data.binaryData) return "Binary data"; + if (data.waypoint) return "Waypoint"; + if (data.compressedText) return "Compressed text"; + if (data.mapReport) return "Map report"; + if (data.remoteHardware) return "Remote hardware"; + if (data.routing) return "Routing"; + if (data.admin) return "Admin"; + if (data.audioData) return "Audio"; + if (data.alert) return `Alert: ${data.alert}`; + if (data.reply) return `Reply: ${data.reply}`; + + return "Unknown data"; + }; + + return ( +
+
+
+
+ +
+ + From: {data.from || "Unknown"} + +
+ + ID: {data.id || "No ID"} + +
+ +
+
+
+
Port
+
{getPortName(data.portNum)}
+
+
+
Payload
+
{getPayloadDescription()}
+
+
+
To
+
{data.to || "Broadcast"}
+
+
+
Hop Limit
+
{data.hopLimit}
+
+
+
+ +
+ Channel: {packet.info.channel} + {getPortName(data.portNum)} +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/packets/NodeInfoPacket.tsx b/web/src/components/packets/NodeInfoPacket.tsx new file mode 100644 index 0000000..9ff45c7 --- /dev/null +++ b/web/src/components/packets/NodeInfoPacket.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Packet } from "../../lib/types"; +import { User } from "lucide-react"; + +interface NodeInfoPacketProps { + packet: Packet; +} + +export const NodeInfoPacket: React.FC = ({ packet }) => { + const { data } = packet; + const nodeInfo = data.nodeInfo; + + if (!nodeInfo) { + return null; + } + + return ( +
+
+
+
+ +
+ + From: {data.from || "Unknown"} + +
+ + ID: {data.id || "No ID"} + +
+ +
+
+
+
Long Name
+
{nodeInfo.longName || "—"}
+
+
+
Short Name
+
{nodeInfo.shortName || "—"}
+
+
+
ID
+
{nodeInfo.id || "—"}
+
+
+
+ +
+ Channel: {packet.info.channel} + Node Info +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/packets/PacketRenderer.tsx b/web/src/components/packets/PacketRenderer.tsx new file mode 100644 index 0000000..59e5c26 --- /dev/null +++ b/web/src/components/packets/PacketRenderer.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Packet, PortNum, PortNumByName } from "../../lib/types"; +import { TextMessagePacket } from "./TextMessagePacket"; +import { PositionPacket } from "./PositionPacket"; +import { NodeInfoPacket } from "./NodeInfoPacket"; +import { TelemetryPacket } from "./TelemetryPacket"; +import { ErrorPacket } from "./ErrorPacket"; +import { GenericPacket } from "./GenericPacket"; + +interface PacketRendererProps { + packet: Packet; +} + +export const PacketRenderer: React.FC = ({ packet }) => { + const { data } = packet; + + // If there's a decode error, show the error packet + if (data.decodeError) { + return ; + } + + // Get the PortNum enum value + const portNumValue = typeof data.portNum === 'string' + ? PortNumByName[data.portNum] + : data.portNum; + + // Determine which component to use based on portNum + switch (portNumValue) { + case PortNum.TEXT_MESSAGE_APP: + return ; + + case PortNum.POSITION_APP: + return ; + + case PortNum.NODEINFO_APP: + return ; + + case PortNum.TELEMETRY_APP: + return ; + + default: + return ; + } +}; diff --git a/web/src/components/packets/PositionPacket.tsx b/web/src/components/packets/PositionPacket.tsx new file mode 100644 index 0000000..db95276 --- /dev/null +++ b/web/src/components/packets/PositionPacket.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { Packet } from "../../lib/types"; +import { MapPin } from "lucide-react"; + +interface PositionPacketProps { + packet: Packet; +} + +export const PositionPacket: React.FC = ({ packet }) => { + const { data } = packet; + const position = data.position; + + if (!position) { + return null; + } + + // Convert latitudeI and longitudeI to degrees (multiply by 1e-7) + const latitude = position.latitudeI ? position.latitudeI * 1e-7 : undefined; + const longitude = position.longitudeI ? position.longitudeI * 1e-7 : undefined; + + return ( +
+
+
+
+ +
+ + From: {data.from || "Unknown"} + +
+ + ID: {data.id || "No ID"} + +
+ +
+
+
+
Latitude
+
{latitude !== undefined ? latitude.toFixed(6) : 'N/A'}
+
+
+
Longitude
+
{longitude !== undefined ? longitude.toFixed(6) : 'N/A'}
+
+ {position.altitude && ( +
+
Altitude
+
{position.altitude.toFixed(1)}m
+
+ )} + {position.time && ( +
+
Time
+
{new Date(position.time * 1000).toLocaleTimeString()}
+
+ )} + {position.locationSource && ( +
+
Source
+
{position.locationSource.replace('LOC_', '')}
+
+ )} + {position.satsInView && ( +
+
Satellites
+
{position.satsInView}
+
+ )} +
+
+ +
+ Channel: {packet.info.channel} + Position +
+
+ ); +}; diff --git a/web/src/components/packets/TelemetryPacket.tsx b/web/src/components/packets/TelemetryPacket.tsx new file mode 100644 index 0000000..b9662cb --- /dev/null +++ b/web/src/components/packets/TelemetryPacket.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { Packet } from "../../lib/types"; +import { BarChart } from "lucide-react"; + +interface TelemetryPacketProps { + packet: Packet; +} + +export const TelemetryPacket: React.FC = ({ packet }) => { + const { data } = packet; + const telemetry = data.telemetry; + + if (!telemetry) { + return null; + } + + // Helper function to display telemetry fields + const renderTelemetryFields = () => { + const entries = Object.entries(telemetry).filter(([key]) => key !== 'time'); + + if (entries.length === 0) { + return
No telemetry data available
; + } + + return ( +
+ {entries.map(([key, value]) => ( +
+
{key.charAt(0).toUpperCase() + key.slice(1)}
+
{typeof value === 'number' ? value.toFixed(2) : String(value)}
+
+ ))} + + {telemetry.time && ( +
+
Time
+
{new Date(telemetry.time * 1000).toLocaleString()}
+
+ )} +
+ ); + }; + + return ( +
+
+
+
+ +
+ + From: {data.from || "Unknown"} + +
+ + ID: {data.id || "No ID"} + +
+ +
+ {renderTelemetryFields()} +
+ +
+ Channel: {packet.info.channel} + Telemetry +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/packets/TextMessagePacket.tsx b/web/src/components/packets/TextMessagePacket.tsx new file mode 100644 index 0000000..818d8fe --- /dev/null +++ b/web/src/components/packets/TextMessagePacket.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { Packet } from "../../lib/types"; +import { MessageSquareText } from "lucide-react"; + +interface TextMessagePacketProps { + packet: Packet; +} + +export const TextMessagePacket: React.FC = ({ packet }) => { + const { data } = packet; + + return ( +
+
+
+
+ +
+ + From: {data.from || "Unknown"} + +
+ + ID: {data.id || "No ID"} + +
+ +
+ {data.textMessage || "Empty message"} +
+ +
+ Channel: {packet.info.channel} + Text Message +
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/packets/index.ts b/web/src/components/packets/index.ts new file mode 100644 index 0000000..f87652d --- /dev/null +++ b/web/src/components/packets/index.ts @@ -0,0 +1,7 @@ +export * from './TextMessagePacket'; +export * from './PositionPacket'; +export * from './NodeInfoPacket'; +export * from './TelemetryPacket'; +export * from './GenericPacket'; +export * from './ErrorPacket'; +export * from './PacketRenderer'; \ No newline at end of file diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index e3c7522..1b4c51b 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -35,135 +35,277 @@ export enum PortNum { RETICULUM_TUNNEL_APP = 102, } +// Map of PortNum string names to numeric enum values +export const PortNumByName: Record = { + "UNKNOWN_APP": PortNum.UNKNOWN_APP, + "TEXT_MESSAGE_APP": PortNum.TEXT_MESSAGE_APP, + "REMOTE_HARDWARE_APP": PortNum.REMOTE_HARDWARE_APP, + "POSITION_APP": PortNum.POSITION_APP, + "NODEINFO_APP": PortNum.NODEINFO_APP, + "ROUTING_APP": PortNum.ROUTING_APP, + "ADMIN_APP": PortNum.ADMIN_APP, + "TEXT_MESSAGE_COMPRESSED_APP": PortNum.TEXT_MESSAGE_COMPRESSED_APP, + "WAYPOINT_APP": PortNum.WAYPOINT_APP, + "AUDIO_APP": PortNum.AUDIO_APP, + "REPLY_APP": PortNum.REPLY_APP, + "IP_TUNNEL_APP": PortNum.IP_TUNNEL_APP, + "SERIAL_APP": PortNum.SERIAL_APP, + "STORE_FORWARD_APP": PortNum.STORE_FORWARD_APP, + "RANGE_TEST_APP": PortNum.RANGE_TEST_APP, + "TELEMETRY_APP": PortNum.TELEMETRY_APP, + "PRIVATE_APP": PortNum.PRIVATE_APP, + "DETECTION_SENSOR_APP": PortNum.DETECTION_SENSOR_APP, + "ZPS_APP": PortNum.ZPS_APP, + "SIMULATOR_APP": PortNum.SIMULATOR_APP, + "TRACEROUTE_APP": PortNum.TRACEROUTE_APP, + "NEIGHBORINFO_APP": PortNum.NEIGHBORINFO_APP, + "PAXCOUNTER_APP": PortNum.PAXCOUNTER_APP, + "MAP_REPORT_APP": PortNum.MAP_REPORT_APP, + "ALERT_APP": PortNum.ALERT_APP, + "ATAK_PLUGIN": PortNum.ATAK_PLUGIN, + "POWERSTRESS_APP": PortNum.POWERSTRESS_APP, + "RETICULUM_TUNNEL_APP": PortNum.RETICULUM_TUNNEL_APP, +} + // Different payload types based on the Meshtastic protocol export interface Position { - latitude: number; - longitude: number; - altitude: number; - time: number; - [key: string]: any; // For additional properties + latitudeI?: number; // multiply by 1e-7 to get degrees + longitudeI?: number; // multiply by 1e-7 to get degrees + altitude?: number; // in meters above MSL + time: number; // seconds since 1970 + locationSource?: string; // "LOC_UNSET", "LOC_MANUAL", "LOC_INTERNAL", "LOC_EXTERNAL" + altitudeSource?: string; // "ALT_UNSET", "ALT_MANUAL", "ALT_INTERNAL", "ALT_EXTERNAL", "ALT_BAROMETRIC" + timestamp?: number; // positional timestamp in integer epoch seconds + timestampMillisAdjust?: number; + altitudeHae?: number; // HAE altitude in meters + altitudeGeoSeparation?: number; + pdop?: number; // Position Dilution of Precision, in 1/100 units + hdop?: number; // Horizontal Dilution of Precision + vdop?: number; // Vertical Dilution of Precision + gpsAccuracy?: number; // GPS accuracy in mm + groundSpeed?: number; // in m/s + groundTrack?: number; // True North track in 1/100 degrees + fixQuality?: number; // GPS fix quality + fixType?: number; // GPS fix type 2D/3D + satsInView?: number; // Satellites in view + sensorId?: number; // For multiple positioning sensors + nextUpdate?: number; // Estimated time until next update in seconds + seqNumber?: number; // Sequence number for this packet + precisionBits?: number; // Bits of precision set by the sending node } export interface User { - id: string; - longName: string; - shortName: string; - [key: string]: any; // For additional properties + id: string; // Globally unique ID for this user + longName?: string; // Full name for this user + shortName?: string; // Very short name, ideally two characters + macaddr?: string; // MAC address of the device + hwModel?: string; // Hardware model name + hasGps?: boolean; // Whether the node has GPS capability + role?: string; // User's role in the mesh (e.g., "ROUTER") + snr?: number; // Signal-to-noise ratio + batteryLevel?: number; // Battery level 0-100 + voltage?: number; // Battery voltage + channelUtilization?: number; // Channel utilization percentage + airUtilTx?: number; // Air utilization for transmission + lastHeard?: number; // Last time the node was heard from } +// Device metrics for telemetry +export interface DeviceMetrics { + batteryLevel?: number; // 0-100 (>100 means powered) + voltage?: number; // Voltage measured + channelUtilization?: number; + airUtilTx?: number; // Percent of airtime for transmission used within the last hour + uptimeSeconds?: number; // How long the device has been running since last reboot (in seconds) +} + +// Environment metrics for telemetry +export interface EnvironmentMetrics { + temperature?: number; // Temperature measured + relativeHumidity?: number; // Relative humidity percent measured + barometricPressure?: number; // Barometric pressure in hPA + gasResistance?: number; // Gas resistance in MOhm + voltage?: number; // Voltage measured (To be deprecated) + current?: number; // Current measured (To be deprecated) + iaq?: number; // IAQ value (0-500) + distance?: number; // Distance in mm (water level detection) + lux?: number; // Ambient light (Lux) + whiteLux?: number; // White light (irradiance) + irLux?: number; // Infrared lux + uvLux?: number; // Ultraviolet lux + windDirection?: number; // Wind direction in degrees (0 = North, 90 = East) + windSpeed?: number; // Wind speed in m/s + weight?: number; // Weight in KG + windGust?: number; // Wind gust in m/s + windLull?: number; // Wind lull in m/s + radiation?: number; // Radiation in µR/h + rainfall1h?: number; // Rainfall in the last hour in mm + rainfall24h?: number; // Rainfall in the last 24 hours in mm + soilMoisture?: number; // Soil moisture measured (% 1-100) + soilTemperature?: number; // Soil temperature measured (°C) +} + +// Main telemetry interface export interface Telemetry { - time: number; - [key: string]: any; // For other telemetry fields + time: number; // seconds since 1970 + deviceMetrics?: DeviceMetrics; + environmentMetrics?: EnvironmentMetrics; + // Other telemetry types could be added as needed (PowerMetrics, AirQualityMetrics, etc.) } export interface Waypoint { - id: number; - name: string; - description: string; - latitude: number; - longitude: number; - [key: string]: any; // For additional properties + id: number; // ID of the waypoint + latitudeI?: number; // multiply by 1e-7 to get degrees + longitudeI?: number; // multiply by 1e-7 to get degrees + expire?: number; // Time the waypoint is to expire (epoch) + lockedTo?: number; // If greater than zero, nodenum allowed to update the waypoint + name?: string; // Name of the waypoint - max 30 chars + description?: string; // Description of the waypoint - max 100 chars + icon?: number; // Designator icon for the waypoint (unicode emoji) } export interface RouteDiscovery { - [key: string]: any; + route?: number[]; // The list of nodenums this packet has visited + snrTowards?: number[]; // The list of SNRs (in dB, scaled by 4) in the route towards destination + routeBack?: number[]; // The list of nodenums the packet has visited on the way back + snrBack?: number[]; // The list of SNRs (in dB, scaled by 4) in the route back +} + +// A neighbor in the mesh +export interface Neighbor { + nodeId: number; // Node ID of neighbor + snr: number; // SNR of last heard message + lastRxTime?: number; // Reception time of last message + nodeBroadcastIntervalSecs?: number; // Broadcast interval of this neighbor } export interface NeighborInfo { - [key: string]: any; + nodeId: number; // The node ID of the node sending info on its neighbors + lastSentById?: number; // Field to pass neighbor info for the next sending cycle + nodeBroadcastIntervalSecs?: number; // Broadcast interval of the represented node + neighbors?: Neighbor[]; // The list of out edges from this node } export interface MapReport { - [key: string]: any; + // This would need to be defined based on the actual data structure + // Currently not defined in the proto files } export interface HardwareMessage { - [key: string]: any; + // Based on remote_hardware.proto but would need to be defined + // Currently not detailed enough in the proto files to model completely + type?: number; + gpioMask?: number; + gpioValue?: number; +} + +// Routing error enum +export enum RoutingError { + NONE = 0, + NO_ROUTE = 1, + GOT_NAK = 2, + TIMEOUT = 3, + NO_INTERFACE = 4, + MAX_RETRANSMIT = 5, + NO_CHANNEL = 6, + TOO_LARGE = 7, + NO_RESPONSE = 8, + DUTY_CYCLE_LIMIT = 9, + BAD_REQUEST = 32, + NOT_AUTHORIZED = 33, + PKI_FAILED = 34, + PKI_UNKNOWN_PUBKEY = 35, + ADMIN_BAD_SESSION_KEY = 36, + ADMIN_PUBLIC_KEY_UNAUTHORIZED = 37 } export interface Routing { - [key: string]: any; + routeRequest?: RouteDiscovery; // A route request going from the requester + routeReply?: RouteDiscovery; // A route reply + errorReason?: RoutingError; // A failure in delivering a message } export interface AdminMessage { - [key: string]: any; + // This would need to be defined based on the admin.proto + // Only include what's actually used in your application } export interface Paxcount { - [key: string]: any; + // This would need to be defined based on the paxcount.proto + // Only include what's actually used in your application } // TopicInfo contains parsed information about a Meshtastic MQTT topic export interface TopicInfo { - full_topic: string; - region_path: string; + fullTopic: string; + regionPath: string; version: string; format: string; channel: string; - user_id: string; + userId: string; } // Data provides a flattened structure for decoded Meshtastic packets export interface Data { // From Service Envelope - channel_id: string; - gateway_id: string; + channelId: string; + gatewayId: string; // From Mesh Packet id: number; from: number; to: number; - hop_limit: number; - hop_start: number; - want_ack: boolean; + hopLimit: number; + hopStart: number; + wantAck: boolean; priority: string; - via_mqtt: boolean; - next_hop: number; - relay_node: number; + viaMqtt: boolean; + nextHop: number; + relayNode: number; // From Data - port_num: PortNum; + portNum: PortNum | string; // Can be either enum value or string name - // Payload is one of these types, depending on port_num - text_message?: string; - binary_data?: string; // Base64 encoded binary data + // Payload is one of these types, depending on portNum + textMessage?: string; + binaryData?: string; // Base64 encoded binary data position?: Position; - node_info?: User; + nodeInfo?: User; telemetry?: Telemetry; waypoint?: Waypoint; - route_discovery?: RouteDiscovery; - neighbor_info?: NeighborInfo; - compressed_text?: string; // Base64 encoded - map_report?: MapReport; - remote_hardware?: HardwareMessage; + routeDiscovery?: RouteDiscovery; + neighborInfo?: NeighborInfo; + compressedText?: string; // Base64 encoded + mapReport?: MapReport; + remoteHardware?: HardwareMessage; routing?: Routing; admin?: AdminMessage; - audio_data?: string; // Base64 encoded + audioData?: string; // Base64 encoded alert?: string; reply?: string; - ip_tunnel?: string; // Base64 encoded + ipTunnel?: string; // Base64 encoded paxcounter?: Paxcount; - serial_app?: string; // Base64 encoded - store_forward?: string; // Base64 encoded - range_test?: string; - zps_app?: string; // Base64 encoded + serialApp?: string; // Base64 encoded + storeForward?: string; // Base64 encoded + rangeTest?: string; + zpsApp?: string; // Base64 encoded simulator?: string; // Base64 encoded - atak_plugin?: string; // Base64 encoded + atakPlugin?: string; // Base64 encoded powerstress?: string; // Base64 encoded - reticulum_tunnel?: string; // Base64 encoded - private_app?: string; // Base64 encoded - detection_sensor?: string; + reticulumTunnel?: string; // Base64 encoded + privateApp?: string; // Base64 encoded + detectionSensor?: string; // Additional Data fields - request_id?: number; - reply_id?: number; + requestId?: number; + replyId?: number; emoji?: number; dest?: number; source?: number; - want_response?: boolean; + wantResponse?: boolean; // Error tracking - decode_error?: string; + decodeError?: string; } // Packet represents a complete decoded MQTT message diff --git a/web/src/store/slices/packetSlice.ts b/web/src/store/slices/packetSlice.ts index 5c628fc..b859978 100644 --- a/web/src/store/slices/packetSlice.ts +++ b/web/src/store/slices/packetSlice.ts @@ -1,20 +1,23 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { Packet } from '../../lib/types'; -interface Packet { - id: string; - // We'll define the full packet structure later based on the protobuf definitions -} +// Maximum number of packets to keep in memory +const MAX_PACKETS = 100; interface PacketState { packets: Packet[]; loading: boolean; error: string | null; + streamPaused: boolean; + bufferedPackets: Packet[]; // Holds packets received while paused } const initialState: PacketState = { packets: [], loading: false, error: null, + streamPaused: false, + bufferedPackets: [], }; const packetSlice = createSlice({ @@ -26,7 +29,8 @@ const packetSlice = createSlice({ state.error = null; }, fetchPacketsSuccess(state, action: PayloadAction) { - state.packets = action.payload; + // Limit initial load to MAX_PACKETS + state.packets = action.payload.slice(-MAX_PACKETS); state.loading = false; }, fetchPacketsFailure(state, action: PayloadAction) { @@ -34,8 +38,54 @@ const packetSlice = createSlice({ state.loading = false; }, addPacket(state, action: PayloadAction) { - state.packets.push(action.payload); + if (state.streamPaused) { + // When paused, add to buffer instead of main list + state.bufferedPackets.unshift(action.payload); + + // Ensure buffer doesn't grow too large + if (state.bufferedPackets.length > MAX_PACKETS) { + state.bufferedPackets = state.bufferedPackets.slice(0, MAX_PACKETS); + } + } else { + // Normal flow - add to main list + state.packets.unshift(action.payload); + + // Remove oldest packets if we exceed the limit + if (state.packets.length > MAX_PACKETS) { + state.packets = state.packets.slice(0, MAX_PACKETS); + } + } }, + clearPackets(state) { + state.packets = []; + state.bufferedPackets = []; + }, + toggleStreamPause(state) { + state.streamPaused = !state.streamPaused; + + // If unpausing, prepend buffered packets to the main list + if (!state.streamPaused && state.bufferedPackets.length > 0) { + state.packets = [...state.bufferedPackets, ...state.packets] + .slice(0, MAX_PACKETS); + state.bufferedPackets = []; + } + }, + // Explicitly set the pause state + setPauseState(state, action: PayloadAction) { + const newPausedState = action.payload; + + // Only process if state is actually changing + if (state.streamPaused !== newPausedState) { + state.streamPaused = newPausedState; + + // If unpausing, prepend buffered packets to the main list + if (!newPausedState && state.bufferedPackets.length > 0) { + state.packets = [...state.bufferedPackets, ...state.packets] + .slice(0, MAX_PACKETS); + state.bufferedPackets = []; + } + } + } }, }); @@ -44,6 +94,9 @@ export const { fetchPacketsSuccess, fetchPacketsFailure, addPacket, + clearPackets, + toggleStreamPause, + setPauseState, } = packetSlice.actions; -export default packetSlice.reducer; +export default packetSlice.reducer; \ No newline at end of file