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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}