diff --git a/ui/social-bot/src/App.jsx b/ui/social-bot/src/App.jsx index ed6bbc6..e6aafdb 100644 --- a/ui/social-bot/src/App.jsx +++ b/ui/social-bot/src/App.jsx @@ -65,6 +65,9 @@ import { WaypointEditor } from './components/WaypointEditor.jsx'; // Bandwidth monitor (issue #287) import { BandwidthMonitor } from './components/BandwidthMonitor.jsx'; +// Temperature gauge (issue #308) +import { TempGauge } from './components/TempGauge.jsx'; + const TAB_GROUPS = [ { label: 'SOCIAL', @@ -87,6 +90,7 @@ const TAB_GROUPS = [ { id: 'battery', label: 'Battery', }, { id: 'battery-chart', label: 'Battery History', }, { id: 'motors', label: 'Motors', }, + { id: 'thermal', label: 'Thermal', }, { id: 'map', label: 'Map', }, { id: 'control', label: 'Control', }, { id: 'health', label: 'Health', }, @@ -254,6 +258,7 @@ export default function App() { {activeTab === 'battery-chart' && } {activeTab === 'motors' && } {activeTab === 'motor-current-graph' && } + {activeTab === 'thermal' && } {activeTab === 'map' && } {activeTab === 'control' && (
diff --git a/ui/social-bot/src/components/TempGauge.jsx b/ui/social-bot/src/components/TempGauge.jsx new file mode 100644 index 0000000..8e78ee2 --- /dev/null +++ b/ui/social-bot/src/components/TempGauge.jsx @@ -0,0 +1,322 @@ +/** + * TempGauge.jsx — CPU and GPU temperature circular gauge + * + * Features: + * - Subscribes to /saltybot/thermal_status for CPU and GPU temperatures + * - Circular gauge visualization with color zones + * - Temperature zones: green <60°C, yellow 60-75°C, red >75°C + * - Real-time temperature display with needle pointer + * - Fan speed percentage indicator + * - Peak temperature tracking + * - Thermal alert indicators + */ + +import { useEffect, useRef, useState } from 'react'; + +const MIN_TEMP = 0; +const MAX_TEMP = 100; +const GAUGE_START_ANGLE = Math.PI * 0.7; // 126° +const GAUGE_END_ANGLE = Math.PI * 2.3; // 414° (180° + 234°) +const GAUGE_RANGE = GAUGE_END_ANGLE - GAUGE_START_ANGLE; + +const TEMP_ZONES = { + good: { max: 60, color: '#10b981', label: 'Good' }, // green + caution: { max: 75, color: '#f59e0b', label: 'Caution' }, // yellow + critical: { max: Infinity, color: '#ef4444', label: 'Critical' }, // red +}; + +function CircularGauge({ temp, maxTemp, label, color }) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const width = canvas.width; + const height = canvas.height; + const centerX = width / 2; + const centerY = height * 0.65; + const radius = width * 0.35; + + // Clear canvas + ctx.fillStyle = '#1f2937'; + ctx.fillRect(0, 0, width, height); + + // Draw gauge background arcs (color zones) + const zoneValues = [60, 75, 100]; + const zoneColors = ['#10b981', '#f59e0b', '#ef4444']; + + for (let i = 0; i < zoneValues.length; i++) { + const startVal = i === 0 ? 0 : zoneValues[i - 1]; + const endVal = zoneValues[i]; + const normalizedStart = startVal / MAX_TEMP; + const normalizedEnd = endVal / MAX_TEMP; + + const startAngle = GAUGE_START_ANGLE + GAUGE_RANGE * normalizedStart; + const endAngle = GAUGE_START_ANGLE + GAUGE_RANGE * normalizedEnd; + + ctx.strokeStyle = zoneColors[i]; + ctx.lineWidth = 12; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, startAngle, endAngle); + ctx.stroke(); + } + + // Draw outer ring + ctx.strokeStyle = '#374151'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(centerX, centerY, radius, GAUGE_START_ANGLE, GAUGE_END_ANGLE); + ctx.stroke(); + + // Draw tick marks and labels + ctx.fillStyle = '#9ca3af'; + ctx.strokeStyle = '#9ca3af'; + ctx.lineWidth = 1; + ctx.font = 'bold 10px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + for (let i = 0; i <= 10; i++) { + const value = (i / 10) * MAX_TEMP; + const angle = GAUGE_START_ANGLE + (i / 10) * GAUGE_RANGE; + const tickLen = i % 5 === 0 ? 8 : 4; + + const x1 = centerX + Math.cos(angle) * radius; + const y1 = centerY + Math.sin(angle) * radius; + const x2 = centerX + Math.cos(angle) * (radius + tickLen); + const y2 = centerY + Math.sin(angle) * (radius + tickLen); + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + ctx.stroke(); + + if (i % 2 === 0) { + const labelX = centerX + Math.cos(angle) * (radius + 18); + const labelY = centerY + Math.sin(angle) * (radius + 18); + ctx.fillText(`${Math.round(value)}°`, labelX, labelY); + } + } + + // Draw needle + const normalizedTemp = Math.max(0, Math.min(1, temp / MAX_TEMP)); + const needleAngle = GAUGE_START_ANGLE + normalizedTemp * GAUGE_RANGE; + + // Needle base circle + ctx.fillStyle = '#1f2937'; + ctx.strokeStyle = '#9ca3af'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(centerX, centerY, 8, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Needle line + ctx.strokeStyle = color || '#06b6d4'; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(centerX, centerY); + const needleLen = radius * 0.85; + ctx.lineTo( + centerX + Math.cos(needleAngle) * needleLen, + centerY + Math.sin(needleAngle) * needleLen + ); + ctx.stroke(); + + // Needle tip circle + ctx.fillStyle = color || '#06b6d4'; + ctx.beginPath(); + ctx.arc( + centerX + Math.cos(needleAngle) * needleLen, + centerY + Math.sin(needleAngle) * needleLen, + 4, + 0, + Math.PI * 2 + ); + ctx.fill(); + + // Draw temperature display + ctx.fillStyle = color || '#06b6d4'; + ctx.font = 'bold 32px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(`${Math.round(temp)}°`, centerX, centerY - 20); + + ctx.fillStyle = '#9ca3af'; + ctx.font = 'bold 12px monospace'; + ctx.fillText(label, centerX, centerY + 25); + }, [temp, label, color]); + + return ; +} + +function TemperatureRow({ label, temp, fanSpeed, maxTemp = MAX_TEMP }) { + // Determine color zone + let zoneColor = '#10b981'; // green + let zoneLabel = 'Good'; + if (temp >= 60 && temp < 75) { + zoneColor = '#f59e0b'; // yellow + zoneLabel = 'Caution'; + } else if (temp >= 75) { + zoneColor = '#ef4444'; // red + zoneLabel = 'Critical'; + } + + return ( +
+ + +
+
+ STATUS + + {zoneLabel} + +
+ + {fanSpeed !== undefined && ( +
+ FAN SPEED + {Math.round(fanSpeed)}% +
+ )} + +
+ MAX REACHED + {Math.round(maxTemp)}° +
+
+
+ ); +} + +export function TempGauge({ subscribe }) { + const [cpuTemp, setCpuTemp] = useState(0); + const [gpuTemp, setGpuTemp] = useState(0); + const [cpuFanSpeed, setCpuFanSpeed] = useState(0); + const [gpuFanSpeed, setGpuFanSpeed] = useState(0); + const [cpuMaxTemp, setCpuMaxTemp] = useState(0); + const [gpuMaxTemp, setGpuMaxTemp] = useState(0); + const maxTempRef = useRef({ cpu: 0, gpu: 0 }); + + // Subscribe to thermal status + useEffect(() => { + const unsubscribe = subscribe( + '/saltybot/thermal_status', + 'saltybot_msgs/ThermalStatus', + (msg) => { + try { + const cpu = msg.cpu_temp || 0; + const gpu = msg.gpu_temp || 0; + const cpuFan = msg.cpu_fan_speed || 0; + const gpuFan = msg.gpu_fan_speed || 0; + + setCpuTemp(cpu); + setGpuTemp(gpu); + setCpuFanSpeed(cpuFan); + setGpuFanSpeed(gpuFan); + + // Track max temperatures + if (cpu > maxTempRef.current.cpu) { + maxTempRef.current.cpu = cpu; + setCpuMaxTemp(cpu); + } + if (gpu > maxTempRef.current.gpu) { + maxTempRef.current.gpu = gpu; + setGpuMaxTemp(gpu); + } + } catch (e) { + console.error('Error parsing thermal status:', e); + } + } + ); + + return unsubscribe; + }, [subscribe]); + + const overallCritical = cpuTemp >= 75 || gpuTemp >= 75; + const overallCaution = cpuTemp >= 60 || gpuTemp >= 60; + + return ( +
+ {/* Summary Header */} +
+
+
+ THERMAL STATUS +
+
+ {overallCritical ? 'CRITICAL' : overallCaution ? 'CAUTION' : 'NORMAL'} +
+
+ + {/* Quick stats */} +
+
+
CPU
+
{Math.round(cpuTemp)}°C
+
+
+
GPU
+
{Math.round(gpuTemp)}°C
+
+
+
+ + {/* Gauge Cards */} +
+ + +
+ + {/* Temperature zones legend */} +
+
TEMPERATURE ZONES
+
+
+
+ <60°C +
+
+
+ 60-75°C +
+
+
+ >75°C +
+
+
+ + {/* Topic info */} +
+
+ Topic: + /saltybot/thermal_status +
+
+ Type: + saltybot_msgs/ThermalStatus +
+
+
+ ); +}