Update visualization of Telemetry Packets

This commit is contained in:
Daniel Pupius
2025-04-23 13:07:26 -07:00
parent 8ca47fba44
commit 4f33866ce3
8 changed files with 372 additions and 118 deletions
@@ -24,23 +24,15 @@
"telemetry": {
"time": 1745300043,
"deviceMetrics": {
"batteryLevel": 100,
"batteryLevel": 88,
"voltage": 4.19,
"channelUtilization": 13.37,
"airUtilTx": 2.58,
"uptimeSeconds": 771327
},
"environmentMetrics": {
"temperature": 22.5,
"relativeHumidity": 58.2,
"barometricPressure": 101325,
"gasResistance": 250000,
"voltage": 3.3,
"current": 0.01
"uptimeSeconds": 189327
}
},
"requestId": 0,
"replyId": 0,
"wantResponse": false
}
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"info": {
"fullTopic": "msh/US/bayarea/2/e/LongFast/!defg4567",
"regionPath": "US/bayarea",
"version": "2",
"format": "e",
"channel": "LongFast",
"userId": "!defg4567"
},
"data": {
"channelId": "LongFast",
"gatewayId": "!defg4567",
"id": 5567890123,
"from": 3949439480,
"to": 4294967295,
"hopLimit": 3,
"hopStart": 3,
"wantAck": false,
"priority": "UNSET",
"viaMqtt": true,
"nextHop": 0,
"relayNode": 0,
"portNum": "TELEMETRY_APP",
"telemetry": {
"time": 1745300047,
"environmentMetrics": {
"temperature": 22.5,
"relativeHumidity": 58.2,
"barometricPressure": 101325,
"lux": 450,
"windSpeed": 3.2,
"rainfall1h": 0.5,
"soilMoisture": 65,
"soilTemperature": 18.5
}
},
"requestId": 0,
"replyId": 0,
"wantResponse": false
}
}
+1 -1
View File
@@ -131,7 +131,7 @@ export const PacketList: React.FC = () => {
</div>
<Separator className="mx-0" />
<ul className="space-y-3">
<ul className="space-y-12">
{currentPackets.map((packet, index) => (
<li key={getPacketKey(packet, index)}>
<PacketRenderer packet={packet} />
@@ -0,0 +1,133 @@
import React from "react";
import { Packet, DeviceMetrics } from "../../lib/types";
import { Gauge, Wifi, Clock } from "lucide-react";
import { PacketCard } from "./PacketCard";
interface DeviceMetricsPacketProps {
packet: Packet;
metrics: DeviceMetrics;
timestamp?: number;
}
export const DeviceMetricsPacket: React.FC<DeviceMetricsPacketProps> = ({
packet,
metrics,
timestamp,
}) => {
// Format uptime in a readable way
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
};
// Format time without seconds
const formattedTime = timestamp
? new Date(timestamp * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: null;
return (
<PacketCard
packet={packet}
icon={<Gauge />}
iconBgColor="bg-amber-500"
label="Device Telemetry"
backgroundColor="bg-amber-950/5"
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
{metrics.batteryLevel !== undefined && (
<div className="col-span-2">
<div className="text-xs text-neutral-400 mb-1">Battery</div>
<div className="w-full h-3 bg-neutral-700/50 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
metrics.batteryLevel > 75
? "bg-green-500"
: metrics.batteryLevel > 40
? "bg-amber-500"
: "bg-red-500"
}`}
style={{ width: `${Math.min(100, metrics.batteryLevel)}%` }}
></div>
</div>
<div className="flex justify-between text-xs mt-1">
<span
className={
metrics.batteryLevel > 75
? "text-green-600"
: metrics.batteryLevel > 40
? "text-amber-400"
: "text-red-400"
}
>
{metrics.batteryLevel}%
</span>
{metrics.voltage !== undefined && (
<span className="text-neutral-500">
{metrics.voltage.toFixed(2)}V
</span>
)}
</div>
</div>
)}
{metrics.channelUtilization !== undefined && (
<div>
<div className="flex items-center gap-1.5">
<Wifi className="h-3 w-3 text-neutral-500" />
<span className="text-xs text-neutral-400">Channel</span>
</div>
<div className="text-sm">
{metrics.channelUtilization.toFixed(1)}%
</div>
</div>
)}
{metrics.airUtilTx !== undefined && (
<div>
<div className="flex items-center gap-1.5">
<Wifi className="h-3 w-3 text-neutral-500" />
<span className="text-xs text-neutral-400">Air TX</span>
</div>
<div className="text-sm">{metrics.airUtilTx.toFixed(1)}%</div>
</div>
)}
{metrics.uptimeSeconds !== undefined && (
<div className="">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-neutral-500" />
<span className="text-xs text-neutral-400">Uptime</span>
</div>
<div className="text-sm">
{formatUptime(metrics.uptimeSeconds)}
</div>
</div>
)}
{formattedTime && (
<div className="">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-neutral-500" />
<span className="text-xs text-neutral-400">Reported</span>
</div>
<div className="text-sm">{formattedTime}</div>
</div>
)}
</div>
</div>
</PacketCard>
);
};
@@ -0,0 +1,143 @@
import React from "react";
import { Packet, EnvironmentMetrics } from "../../lib/types";
import { Thermometer, Droplets, Wind, Sun, Ruler, Clock } from "lucide-react";
import { PacketCard } from "./PacketCard";
interface EnvironmentMetricsPacketProps {
packet: Packet;
metrics: EnvironmentMetrics;
timestamp?: number;
}
export const EnvironmentMetricsPacket: React.FC<
EnvironmentMetricsPacketProps
> = ({ packet, metrics, timestamp }) => {
// Format time without seconds
const formattedTime = timestamp
? new Date(timestamp * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
: null;
// Get appropriate icon for a metric
const getMetricIcon = (metricName: string) => {
switch (metricName) {
case "temperature":
case "soilTemperature":
return <Thermometer className="h-3 w-3 text-neutral-500" />;
case "relativeHumidity":
case "soilMoisture":
case "rainfall1h":
case "rainfall24h":
return <Droplets className="h-3 w-3 text-neutral-500" />;
case "windSpeed":
case "windDirection":
case "windGust":
case "windLull":
return <Wind className="h-3 w-3 text-neutral-500" />;
case "lux":
case "whiteLux":
case "irLux":
case "uvLux":
return <Sun className="h-3 w-3 text-neutral-500" />;
case "distance":
return <Ruler className="h-3 w-3 text-neutral-500" />;
default:
return null;
}
};
// Format metric value with appropriate unit
const formatMetricValue = (name: string, value: number): string => {
switch (name) {
case "temperature":
case "soilTemperature":
return `${value.toFixed(1)}°C`;
case "relativeHumidity":
case "soilMoisture":
return `${value.toFixed(1)}%`;
case "barometricPressure":
return `${(value / 100).toFixed(1)} hPa`;
case "lux":
case "whiteLux":
case "irLux":
case "uvLux":
return `${value.toFixed(0)} lux`;
case "windSpeed":
case "windGust":
case "windLull":
return `${value.toFixed(1)} m/s`;
case "windDirection":
return `${value.toFixed(0)}°`;
case "rainfall1h":
case "rainfall24h":
return `${value.toFixed(1)} mm`;
case "distance":
return `${value.toFixed(0)} mm`;
case "radiation":
return `${value.toFixed(1)} µR/h`;
case "weight":
return `${value.toFixed(2)} kg`;
default:
return `${value}`;
}
};
// Create metrics grid items from available metrics
const renderMetrics = () => {
const entries = Object.entries(metrics).filter(
([key]) => key !== "time" && key !== "voltage" && key !== "current"
);
return entries.map(([key, value]) => {
if (value === undefined) return null;
// Format the name for display
const displayName = key
.replace(/([A-Z])/g, " $1")
.toLowerCase()
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
return (
<div key={key}>
<div className="flex items-center gap-1.5">
{getMetricIcon(key)}
<span className="text-xs text-neutral-400">{displayName}</span>
</div>
<div className="text-sm">
{formatMetricValue(key, value as number)}
</div>
</div>
);
});
};
return (
<PacketCard
packet={packet}
icon={<Thermometer />}
iconBgColor="bg-emerald-700"
label="Environment Telemetry"
backgroundColor="bg-green-950/5"
>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
{renderMetrics()}
{formattedTime && (
<div className="">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-neutral-500" />
<span className="text-xs text-neutral-400">Reported</span>
</div>
<div className="text-sm">{formattedTime}</div>
</div>
)}
</div>
</div>
</PacketCard>
);
};
@@ -4,6 +4,8 @@ import { TextMessagePacket } from "./TextMessagePacket";
import { PositionPacket } from "./PositionPacket";
import { NodeInfoPacket } from "./NodeInfoPacket";
import { TelemetryPacket } from "./TelemetryPacket";
import { DeviceMetricsPacket } from "./DeviceMetricsPacket";
import { EnvironmentMetricsPacket } from "./EnvironmentMetricsPacket";
import { ErrorPacket } from "./ErrorPacket";
import { WaypointPacket } from "./WaypointPacket";
import { MapReportPacket } from "./MapReportPacket";
+36 -101
View File
@@ -2,7 +2,8 @@ import React from "react";
import { Packet } from "../../lib/types";
import { BarChart } from "lucide-react";
import { PacketCard } from "./PacketCard";
import { KeyValueGrid, KeyValuePair } from "./KeyValuePair";
import { DeviceMetricsPacket } from "./DeviceMetricsPacket";
import { EnvironmentMetricsPacket } from "./EnvironmentMetricsPacket";
interface TelemetryPacketProps {
packet: Packet;
@@ -16,117 +17,51 @@ export const TelemetryPacket: React.FC<TelemetryPacketProps> = ({ packet }) => {
return null;
}
// Helper function to render device metrics
const renderDeviceMetrics = () => {
if (!telemetry.deviceMetrics) return null;
const metrics = telemetry.deviceMetrics;
// Determine which type of telemetry we're dealing with
const hasDeviceMetrics = telemetry.deviceMetrics && Object.keys(telemetry.deviceMetrics).some(
key => telemetry.deviceMetrics![key as keyof typeof telemetry.deviceMetrics] !== undefined
);
const hasEnvironmentMetrics = telemetry.environmentMetrics && Object.keys(telemetry.environmentMetrics).some(
key => telemetry.environmentMetrics![key as keyof typeof telemetry.environmentMetrics] !== undefined
);
// Return the appropriate component based on telemetry type
if (hasDeviceMetrics && telemetry.deviceMetrics) {
return (
<div className="mb-4">
<h4 className="text-sm font-medium text-neutral-300 mb-2">Device</h4>
<KeyValueGrid>
{metrics.batteryLevel !== undefined && (
<KeyValuePair
label="Battery"
value={`${metrics.batteryLevel}%`}
/>
)}
{metrics.voltage !== undefined && (
<KeyValuePair
label="Voltage"
value={`${metrics.voltage.toFixed(2)}V`}
/>
)}
{metrics.channelUtilization !== undefined && (
<KeyValuePair
label="Channel Util."
value={`${metrics.channelUtilization.toFixed(1)}%`}
/>
)}
{metrics.uptimeSeconds !== undefined && (
<KeyValuePair
label="Uptime"
value={formatUptime(metrics.uptimeSeconds)}
/>
)}
</KeyValueGrid>
</div>
<DeviceMetricsPacket
packet={packet}
metrics={telemetry.deviceMetrics}
timestamp={telemetry.time}
/>
);
};
}
// Helper function to render environment metrics
const renderEnvironmentMetrics = () => {
if (!telemetry.environmentMetrics) return null;
const metrics = telemetry.environmentMetrics;
if (hasEnvironmentMetrics && telemetry.environmentMetrics) {
return (
<div>
<h4 className="text-sm font-medium text-neutral-300 mb-2">Environment</h4>
<KeyValueGrid>
{metrics.temperature !== undefined && (
<KeyValuePair
label="Temperature"
value={`${metrics.temperature.toFixed(1)}°C`}
/>
)}
{metrics.relativeHumidity !== undefined && (
<KeyValuePair
label="Humidity"
value={`${metrics.relativeHumidity.toFixed(1)}%`}
/>
)}
{metrics.barometricPressure !== undefined && (
<KeyValuePair
label="Pressure"
value={`${(metrics.barometricPressure/100).toFixed(1)} hPa`}
/>
)}
{metrics.lux !== undefined && (
<KeyValuePair
label="Light"
value={`${metrics.lux.toFixed(0)} lux`}
/>
)}
</KeyValueGrid>
</div>
<EnvironmentMetricsPacket
packet={packet}
metrics={telemetry.environmentMetrics}
timestamp={telemetry.time}
/>
);
};
// Format uptime in a readable way
const formatUptime = (seconds: number): string => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h`;
}
if (hours > 0) {
return `${hours}h ${mins}m`;
}
return `${mins}m`;
};
}
// Fallback for unknown telemetry type
return (
<PacketCard
packet={packet}
icon={<BarChart />}
iconBgColor="bg-amber-500"
label="Telemetry"
backgroundColor="bg-amber-950/5"
iconBgColor="bg-neutral-500"
label="Unknown Telemetry"
backgroundColor="bg-neutral-950/5"
>
<div className="max-w-md">
{telemetry.time && (
<div className="mb-3">
<KeyValuePair
label="Time"
value={new Date(telemetry.time * 1000).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
/>
</div>
)}
{renderDeviceMetrics()}
{renderEnvironmentMetrics()}
<div className="text-neutral-400 text-sm">
Unknown telemetry data received at{' '}
{telemetry.time
? new Date(telemetry.time * 1000).toLocaleTimeString()
: 'unknown time'
}
</div>
</PacketCard>
);
+13 -5
View File
@@ -14,7 +14,8 @@ import { GenericPacket } from "../components/packets/GenericPacket";
import textMessageData from "../../fixtures/text_message.json";
import positionData from "../../fixtures/position.json";
import nodeInfoData from "../../fixtures/nodeinfo.json";
import telemetryData from "../../fixtures/telemetry.json";
import telemetryData1 from "../../fixtures/telemetry1.json";
import telemetryData2 from "../../fixtures/telemetry2.json";
import decodeErrorData from "../../fixtures/decode_error.json";
import waypointData from "../../fixtures/waypoint.json";
import mapReportData from "../../fixtures/map_report.json";
@@ -60,18 +61,25 @@ export function DemoPage() {
<section>
<h3 className="text-md font-medium mb-4 text-neutral-300">
Telemetry Packet
Telemetry Packet (Device Metrics)
</h3>
<TelemetryPacket packet={telemetryData} />
<TelemetryPacket packet={telemetryData1} />
</section>
<section>
<h3 className="text-md font-medium mb-4 text-neutral-300">
Telemetry Packet (Environmental Data)
</h3>
<TelemetryPacket packet={telemetryData2} />
</section>
<section>
<h3 className="text-md font-medium mb-4 text-neutral-300">
Waypoint Packet
</h3>
<WaypointPacket packet={waypointData} />
</section>
<section>
<h3 className="text-md font-medium mb-4 text-neutral-300">
Map Report Packet