diff --git a/src/app/tablet/layout.tsx b/src/app/tablet/layout.tsx new file mode 100644 index 0000000..0337442 --- /dev/null +++ b/src/app/tablet/layout.tsx @@ -0,0 +1,7 @@ +export default function TabletLayout({ + children, +}: { + children: React.ReactNode; +}) { + return
{children}
; +} diff --git a/src/app/tablet/page.tsx b/src/app/tablet/page.tsx new file mode 100644 index 0000000..61987ba --- /dev/null +++ b/src/app/tablet/page.tsx @@ -0,0 +1,5 @@ +import { TabletDashboard } from '@/components/tablet/TabletDashboard'; + +export default function TabletPage() { + return ; +} diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index c061f2b..d1f16cf 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Toaster, toast } from 'sonner'; import { Header } from '@/components/dashboard/header'; import { CameraFeed } from '@/components/dashboard/camera-feed'; @@ -19,10 +19,123 @@ const vitalsData = [ { time: '13:00', heartRate: 75, battery: 60 }, ]; +const TELEOP_SEND_INTERVAL_MS = 50; + export function Dashboard() { const [isAutonomous, setIsAutonomous] = useState(true); const [speed, setSpeed] = useState([50]); const [activeTask, setActiveTask] = useState(null); + const [linearX, setLinearX] = useState(0); + const [linearY, setLinearY] = useState(0); + const [angular, setAngular] = useState(0); + + const wsRef = useRef(null); + const currentCmdRef = useRef({ linearX: 0, linearY: 0, angular: 0 }); + const sendIntervalRef = useRef(null); + const speedRef = useRef(speed); + const isAutonomousRef = useRef(isAutonomous); + + // Garde les refs synchronisées avec le state pour que l'intervalle lise les valeurs à jour + useEffect(() => { + speedRef.current = speed; + }, [speed]); + + useEffect(() => { + isAutonomousRef.current = isAutonomous; + }, [isAutonomous]); + + // Connexion WebSocket + useEffect(() => { + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${wsProtocol}//${window.location.hostname}:8765`; + + wsRef.current = new WebSocket(wsUrl); + + wsRef.current.onopen = () => { + console.log('[WS] Connecté au backend'); + toast.success('Robot connecté'); + }; + + wsRef.current.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log('[WS] Message reçu:', message.type); + } catch (error) { + console.error('[WS] Erreur parsing:', error); + } + }; + + wsRef.current.onerror = () => { + toast.error('Erreur de connexion au robot'); + }; + + wsRef.current.onclose = () => { + console.log('[WS] Déconnecté'); + toast.error('Robot déconnecté'); + }; + + return () => { + if (wsRef.current) { + wsRef.current.close(); + } + }; + }, []); + + // Envoie la commande courante au robot + const sendCurrentCmd = () => { + if (isAutonomousRef.current) return; + if (wsRef.current?.readyState !== WebSocket.OPEN) return; + + const speedFactor = (speedRef.current[0] ?? 50) / 100; + const { linearX: x, linearY: y, angular: a } = currentCmdRef.current; + + const message = { + type: 'teleop.move', + data: { + linear_x: x * speedFactor, + linear_y: y * speedFactor, + angular_z: a * speedFactor, + }, + }; + wsRef.current.send(JSON.stringify(message)); + }; + + // Boucle d'envoi à 20 Hz quand en mode manuel + useEffect(() => { + if (isAutonomous) { + // Stop la boucle et reset la commande + if (sendIntervalRef.current) { + clearInterval(sendIntervalRef.current); + sendIntervalRef.current = null; + } + currentCmdRef.current = { linearX: 0, linearY: 0, angular: 0 }; + return; + } + + sendIntervalRef.current = setInterval( + sendCurrentCmd, + TELEOP_SEND_INTERVAL_MS + ); + + return () => { + if (sendIntervalRef.current) { + clearInterval(sendIntervalRef.current); + sendIntervalRef.current = null; + } + }; + }, [isAutonomous]); + + // Sécurité : stop si la fenêtre perd le focus + useEffect(() => { + const onBlur = () => { + currentCmdRef.current = { linearX: 0, linearY: 0, angular: 0 }; + setLinearX(0); + setLinearY(0); + setAngular(0); + }; + window.addEventListener('blur', onBlur); + return () => window.removeEventListener('blur', onBlur); + }, []); const { connectionState, health } = useRobotHealth(); const handleStartTask = (taskName: string) => { @@ -38,11 +151,28 @@ export function Dashboard() { const handleEmergencyStop = () => { setIsAutonomous(false); setActiveTask(null); + currentCmdRef.current = { linearX: 0, linearY: 0, angular: 0 }; + setLinearX(0); + setLinearY(0); + setAngular(0); toast.error("ARRÊT D'URGENCE ACTIVÉ ! Passage en mode manuel.", { style: { backgroundColor: '#ef4444', color: 'white', border: 'none' }, }); }; + // Met à jour la commande courante — l'intervalle s'occupe de l'envoi + const handleTeleopMove = ( + newLinearX: number, + newLinearY: number, + newAngular: number + ) => { + currentCmdRef.current = { + linearX: newLinearX, + linearY: newLinearY, + angular: newAngular, + }; + }; + return (
@@ -62,6 +192,13 @@ export function Dashboard() { speed={speed} setSpeed={setSpeed} onEmergencyStop={handleEmergencyStop} + linearX={linearX} + setLinearX={setLinearX} + linearY={linearY} + setLinearY={setLinearY} + angular={angular} + setAngular={setAngular} + onTeleopMove={handleTeleopMove} />
diff --git a/src/components/dashboard/manual-control.tsx b/src/components/dashboard/manual-control.tsx index 3b4c13d..f313daf 100644 --- a/src/components/dashboard/manual-control.tsx +++ b/src/components/dashboard/manual-control.tsx @@ -1,6 +1,5 @@ 'use client'; -import React from 'react'; import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Power } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -20,6 +19,13 @@ interface ManualControlProps { speed: number[]; setSpeed: (value: number[]) => void; onEmergencyStop: () => void; + linearX?: number; + setLinearX?: (value: number) => void; + linearY?: number; + setLinearY?: (value: number) => void; + angular?: number; + setAngular?: (value: number) => void; + onTeleopMove?: (linearX: number, linearY: number, angular: number) => void; } export function ManualControl({ @@ -28,7 +34,52 @@ export function ManualControl({ speed, setSpeed, onEmergencyStop, + setLinearX, + setLinearY, + setAngular, + onTeleopMove, }: Readonly) { + const applyDirection = ( + direction: 'up' | 'down' | 'left' | 'right' | 'stop' + ) => { + if (isAutonomous) return; + + let newLinearX = 0; + const newLinearY = 0; + let newAngular = 0; + + switch (direction) { + case 'up': + newLinearX = 1; + break; + case 'down': + newLinearX = -1; + break; + case 'left': + newAngular = 1; + break; + case 'right': + newAngular = -1; + break; + case 'stop': + // tout à zéro + break; + } + + setLinearX?.(newLinearX); + setLinearY?.(newLinearY); + setAngular?.(newAngular); + onTeleopMove?.(newLinearX, newLinearY, newAngular); + }; + + const release = () => { + if (isAutonomous) return; + setLinearX?.(0); + setLinearY?.(0); + setAngular?.(0); + onTeleopMove?.(0, 0, 0); + }; + return ( @@ -73,14 +124,25 @@ export function ManualControl({ variant="secondary" className="h-12 w-full" disabled={isAutonomous} + onMouseDown={() => applyDirection('up')} + onMouseUp={release} + onMouseLeave={release} + onTouchStart={() => applyDirection('up')} + onTouchEnd={release} >
+ @@ -88,6 +150,7 @@ export function ManualControl({ variant="secondary" className="h-12 w-full" disabled={isAutonomous} + onClick={() => applyDirection('stop')} > @@ -95,14 +158,25 @@ export function ManualControl({ variant="secondary" className="h-12 w-full" disabled={isAutonomous} + onMouseDown={() => applyDirection('right')} + onMouseUp={release} + onMouseLeave={release} + onTouchStart={() => applyDirection('right')} + onTouchEnd={release} > +
diff --git a/src/components/tablet/AlertBanner.tsx b/src/components/tablet/AlertBanner.tsx new file mode 100644 index 0000000..985a932 --- /dev/null +++ b/src/components/tablet/AlertBanner.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Info, AlertTriangle, AlertCircle } from 'lucide-react'; + +interface Alert { + timestamp: string; + message: string; + severity: 'info' | 'warning' | 'error'; +} + +interface AlertBannerProps { + lastAlert: Alert | null; +} + +export function AlertBanner({ lastAlert }: AlertBannerProps) { + if (!lastAlert) { + return ( + + + Information + Aucune alerte récente + + ); + } + + const severityIcon = { + info: , + warning: , + error: , + }; + + const severityVariant = { + info: 'default', + warning: 'default', + error: 'destructive', + } as const; + + return ( + + {severityIcon[lastAlert.severity]} + {lastAlert.timestamp} + {lastAlert.message} + + ); +} diff --git a/src/components/tablet/MissionCard.tsx b/src/components/tablet/MissionCard.tsx new file mode 100644 index 0000000..a315239 --- /dev/null +++ b/src/components/tablet/MissionCard.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { Target } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; +import { Badge } from '@/components/ui/badge'; + +interface MissionCardProps { + missionName: string | null; + progress: number; + step: number; + totalSteps: number; + errorMessage?: string; +} + +export function MissionCard({ + missionName, + progress, + step, + totalSteps, + errorMessage, +}: MissionCardProps) { + if (!missionName) { + return ( + + + Aucune mission en cours + + + ); + } + + return ( + + + + + + {missionName} + + + {errorMessage ? 'Erreur' : 'En cours'} + + + + +
+ Progression + + {progress}% (étape {step}/{totalSteps}) + +
+ + {errorMessage && ( +
+ {errorMessage} +
+ )} +
+
+ ); +} diff --git a/src/components/tablet/RobotStatus.tsx b/src/components/tablet/RobotStatus.tsx new file mode 100644 index 0000000..e16534b --- /dev/null +++ b/src/components/tablet/RobotStatus.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Battery, Thermometer, Wifi, AlertTriangle } from 'lucide-react'; + +interface RobotStatusProps { + battery: number; + mode: 'manual' | 'auto' | 'maintenance' | 'stop'; + watchdog: boolean; + temperature?: number; + emergencyStop: boolean; +} + +export function RobotStatus({ + battery, + mode, + watchdog, + temperature, + emergencyStop, +}: RobotStatusProps) { + const getModeVariant = () => { + switch (mode) { + case 'auto': + return 'success'; + case 'manual': + return 'secondary'; + case 'maintenance': + return 'warning'; + case 'stop': + return 'destructive'; + default: + return 'default'; + } + }; + + const batteryColor = + battery < 20 + ? 'text-red-500' + : battery < 50 + ? 'text-yellow-500' + : 'text-green-500'; + + return ( + + + État du robot + + +
+
+ + {battery}% +
+
+ Mode: + {mode.toUpperCase()} +
+
+ + + {watchdog ? 'Watchdog OK' : 'Watchdog FAULT'} + +
+ {temperature !== undefined && ( +
+ + {temperature}°C +
+ )} +
+ {emergencyStop && ( +
+ + Arrêt d'urgence actif +
+ )} +
+
+ ); +} diff --git a/src/components/tablet/TabletDashboard.tsx b/src/components/tablet/TabletDashboard.tsx new file mode 100644 index 0000000..a77a694 --- /dev/null +++ b/src/components/tablet/TabletDashboard.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { TabletSmartphone } from 'lucide-react'; +import { RobotStatus } from './RobotStatus'; +import { MissionCard } from './MissionCard'; +import { AlertBanner } from './AlertBanner'; + +type Mode = 'manual' | 'auto' | 'maintenance' | 'stop'; + +const INITIAL_DATA = { + battery: 85, + mode: 'auto' as Mode, + watchdog: true, + temperature: 50, + emergencyStop: false, + mission: { + active: false, + name: '', + progress: 0, + step: 1, + totalSteps: 1, + error: undefined as string | undefined, + }, + arm: { + connected: true, + joints: { shoulder: 0, elbow: 45, wrist: 0, gripper: 0 }, + currentAction: 'Repos', + errorMessage: undefined as string | undefined, + }, + lastAlert: null as null | { + timestamp: string; + message: string; + severity: 'info' | 'warning' | 'error'; + }, +}; + +const generateRandomData = (): typeof INITIAL_DATA => ({ + battery: Math.floor(Math.random() * 100), + mode: (['manual', 'auto', 'maintenance', 'stop'] as Mode[])[ + Math.floor(Math.random() * 4) + ], + watchdog: Math.random() > 0.1, + temperature: 45 + Math.floor(Math.random() * 20), + emergencyStop: Math.random() < 0.05, + mission: { + active: Math.random() > 0.3, + name: 'Exploration bâtiment A', + progress: Math.floor(Math.random() * 100), + step: Math.floor(Math.random() * 5) + 1, + totalSteps: 5, + error: + Math.random() < 0.1 ? 'Obstacle détecté, arrêt temporaire' : undefined, + }, + arm: { + connected: Math.random() > 0.1, + joints: { + shoulder: Math.floor(Math.random() * 180) - 90, + elbow: Math.floor(Math.random() * 150), + wrist: Math.floor(Math.random() * 180) - 90, + gripper: Math.floor(Math.random() * 100), + }, + currentAction: ['Repos', 'Préhension', 'Rangement', 'Pousse'][ + Math.floor(Math.random() * 4) + ], + errorMessage: Math.random() < 0.1 ? 'Préhenseur bloqué' : undefined, + }, + lastAlert: + Math.random() > 0.7 + ? { + timestamp: new Date().toLocaleTimeString(), + message: 'Batterie faible (15%)', + severity: 'warning' as const, + } + : null, +}); + +export function TabletDashboard() { + const [data, setData] = useState(INITIAL_DATA); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setMounted(true); + setData(generateRandomData()); + const interval = setInterval(() => { + setData(generateRandomData()); + }, 3000); + return () => clearInterval(interval); + }, []); + + if (!mounted) { + return ( +
+

+ + Interface Robot - ROSMaster M3 Pro +

+
+
+
+
+
+
+
+
+
+ ); + } + + return ( +
+

+ + Interface Robot - ROSMaster M3 Pro +

+
+ + + +
+
+ ); +} diff --git a/src/components/tablet/tablet.tsx b/src/components/tablet/tablet.tsx new file mode 100644 index 0000000..5c55cb6 --- /dev/null +++ b/src/components/tablet/tablet.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { useState } from 'react'; +import { Toaster, toast } from 'sonner'; +import { Header } from '@/components/dashboard/header'; +import { CameraFeed } from '@/components/dashboard/camera-feed'; +import { ManualControl } from '@/components/dashboard/manual-control'; +import { TaskManager } from '@/components/dashboard/task-manager'; +import { MetricsAndAlerts } from '@/components/dashboard/metrics-and-alerts'; + +const vitalsData = [ + { time: '10:00', heartRate: 72, battery: 95 }, + { time: '10:30', heartRate: 75, battery: 90 }, + { time: '11:00', heartRate: 80, battery: 85 }, + { time: '11:30', heartRate: 76, battery: 78 }, + { time: '12:00', heartRate: 74, battery: 72 }, + { time: '12:30', heartRate: 79, battery: 65 }, + { time: '13:00', heartRate: 75, battery: 60 }, +]; + +export function Tablet() { + const [isAutonomous, setIsAutonomous] = useState(true); + const [speed, setSpeed] = useState([50]); + const [activeTask, setActiveTask] = useState(null); + + const handleStartTask = (taskName: string) => { + setActiveTask(taskName); + toast.success(`Tâche démarrée : ${taskName}`); + }; + + const handleStopTask = () => { + setActiveTask(null); + toast.error('Tâche interrompue.'); + }; + + const handleEmergencyStop = () => { + setIsAutonomous(false); + setActiveTask(null); + toast.error("ARRÊT D'URGENCE ACTIVÉ ! Passage en mode manuel.", { + style: { backgroundColor: '#ef4444', color: 'white', border: 'none' }, + }); + }; + + return ( +
+ + +
+ +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+ ); +}