feat: add WiFi setup wizard and device detail dashboard (Phase 4)

- SetupRobotPage: 4-step wizard (AP connect → WiFi scan → connect → pairing code)
- DeviceDetailPage: health metrics dashboard + log viewer with filters
- Robot local API client for captive portal communication (192.168.4.1)
- API endpoints for health reports, alerts, and device logs
- Dashboard: clickable device cards linking to detail, "Nouveau robot" button

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ordinarthur 2026-04-13 21:23:02 +02:00
parent 096f772da8
commit 6760759cb6
6 changed files with 1212 additions and 25 deletions

View File

@ -5,6 +5,8 @@ import { DashboardPage } from './pages/DashboardPage';
import { LoginPage } from './pages/LoginPage';
import { PairRobotPage } from './pages/PairRobotPage';
import { RegisterPage } from './pages/RegisterPage';
import { SetupRobotPage } from './pages/SetupRobotPage';
import { DeviceDetailPage } from './pages/DeviceDetailPage';
/**
* Router.
@ -45,6 +47,22 @@ export function App() {
</ProtectedRoute>
}
/>
<Route
path="/setup"
element={
<ProtectedRoute>
<SetupRobotPage />
</ProtectedRoute>
}
/>
<Route
path="/devices/:deviceId"
element={
<ProtectedRoute>
<DeviceDetailPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View File

@ -62,6 +62,58 @@ export interface PairingConfirmResult {
deviceName: string;
}
export interface HealthReport {
cpuTempCelsius: number;
memoryUsedMb: number;
memoryTotalMb: number;
diskUsedPercent: number;
loadAvg1m: number;
heapUsedMb: number;
heapTotalMb: number;
uptimeSeconds: number;
wifiSsid: string | null;
wifiSignalDbm: number | null;
clientVersion: string;
nodeVersion: string;
reportedAt: string;
}
export interface HealthReportsResponse {
deviceId: string;
reports: HealthReport[];
}
export interface HealthAlertsResponse {
deviceId: string;
alerts: string[];
healthy: boolean;
}
export interface LogEntry {
level: number;
levelLabel: string;
time: number;
msg: string;
name?: string;
[key: string]: unknown;
}
export interface LogsResponse {
deviceId: string;
logs: LogEntry[];
total: number;
}
export interface LogQueryParams {
level?: number;
logger?: string;
since?: string;
until?: string;
search?: string;
limit?: number;
offset?: number;
}
// ─── Error ──────────────────────────────────────────────────────────
export class ApiError extends Error {
@ -230,4 +282,27 @@ export const api = {
body: { code },
});
},
// Health
async getHealthReports(deviceId: string, limit = 20): Promise<HealthReportsResponse> {
return request<HealthReportsResponse>(`/devices/${deviceId}/health/reports?limit=${limit}`);
},
async getHealthAlerts(deviceId: string): Promise<HealthAlertsResponse> {
return request<HealthAlertsResponse>(`/devices/${deviceId}/health/alerts`);
},
// Logs
async getDeviceLogs(deviceId: string, params: LogQueryParams = {}): Promise<LogsResponse> {
const searchParams = new URLSearchParams();
if (params.level !== undefined) searchParams.set('level', String(params.level));
if (params.logger) searchParams.set('logger', params.logger);
if (params.since) searchParams.set('since', params.since);
if (params.until) searchParams.set('until', params.until);
if (params.search) searchParams.set('search', params.search);
if (params.limit !== undefined) searchParams.set('limit', String(params.limit));
if (params.offset !== undefined) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString();
return request<LogsResponse>(`/devices/${deviceId}/logs${qs ? `?${qs}` : ''}`);
},
};

View File

@ -0,0 +1,112 @@
/**
* Client for the Ti-Pote robot's local HTTP API (captive portal).
*
* When the robot boots without WiFi, it creates an AP (Access Point)
* named "Ti-Pote" at 192.168.4.1:80. The desktop app connects to
* this AP and uses these endpoints to configure WiFi.
*/
const DEFAULT_ROBOT_URL = 'http://192.168.4.1';
export interface RobotStatus {
ready: boolean;
robotName: string;
}
export interface WifiStatus {
connected: boolean;
ssid: string | null;
}
export interface WifiNetwork {
ssid: string;
signal: number;
security: string;
}
export interface WifiConnectResult {
success: boolean;
error?: string;
}
/**
* Create a robot local API client.
* @param baseUrl Override the robot's AP address (useful for dev/testing)
*/
export function createRobotLocalApi(baseUrl = DEFAULT_ROBOT_URL) {
const url = baseUrl.replace(/\/$/, '');
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${url}${path}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!res.ok) throw new Error(`Robot API error: ${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
async function post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${url}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`Robot API error: ${res.status} ${res.statusText}`);
return res.json() as Promise<T>;
}
return {
/**
* Check if the robot is reachable and get its name.
*/
async getStatus(): Promise<RobotStatus> {
return get<RobotStatus>('/api/status');
},
/**
* Check if the robot is connected to WiFi.
*/
async getWifiStatus(): Promise<WifiStatus> {
return get<WifiStatus>('/api/wifi/status');
},
/**
* Scan for available WiFi networks.
*/
async scanWifi(): Promise<WifiNetwork[]> {
return get<WifiNetwork[]>('/api/wifi/scan');
},
/**
* Attempt to connect the robot to a WiFi network.
* WARNING: After calling this, the robot will disconnect from AP mode.
* The app will lose connection to the robot.
*/
async connectWifi(ssid: string, password: string): Promise<WifiConnectResult> {
return post<WifiConnectResult>('/api/wifi/connect', { ssid, password });
},
/**
* Try to detect if we're connected to the robot's AP.
* Returns true if the robot's local API is reachable.
*/
async isReachable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${url}/api/status`, {
signal: controller.signal,
});
clearTimeout(timeout);
return res.ok;
} catch {
return false;
}
},
};
}
export type RobotLocalApi = ReturnType<typeof createRobotLocalApi>;

View File

@ -50,8 +50,11 @@ export function DashboardPage() {
<Button variant="secondary" onClick={() => void fetchDevices()}>
Rafraîchir
</Button>
<Link to="/setup">
<Button>+ Nouveau robot</Button>
</Link>
<Link to="/pair">
<Button>+ Associer un robot</Button>
<Button variant="secondary">Code uniquement</Button>
</Link>
</div>
</div>
@ -78,8 +81,8 @@ export function DashboardPage() {
Allume ton Ti-Pote puis associe-le avec le code qui s'affichera.
</p>
</div>
<Link to="/pair">
<Button>Associer un robot</Button>
<Link to="/setup">
<Button>Configurer un robot</Button>
</Link>
</Card>
)}
@ -87,29 +90,33 @@ export function DashboardPage() {
{!loading && !error && devices && devices.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2">
{devices.map((d) => (
<Card key={d.id} className="p-5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-medium">{d.name}</h3>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500">
{d.id}
</p>
<Link key={d.id} to={`/devices/${d.id}`} className="group">
<Card className="p-5 transition-colors group-hover:border-slate-700">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<h3 className="truncate text-base font-medium group-hover:text-brand-400 transition-colors">
{d.name}
</h3>
<p className="mt-0.5 truncate font-mono text-xs text-slate-500">
{d.id}
</p>
</div>
<StatusBadge status={d.status} />
</div>
<StatusBadge status={d.status} />
</div>
<dl className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<div>
<dt className="inline text-slate-500">Firmware : </dt>
<dd className="inline">{d.firmwareVersion || '—'}</dd>
</div>
<div>
<dt className="inline text-slate-500">Vu pour la dernière fois : </dt>
<dd className="inline">
{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'jamais'}
</dd>
</div>
</dl>
</Card>
<dl className="mt-4 flex flex-wrap gap-x-4 gap-y-1 text-xs text-slate-400">
<div>
<dt className="inline text-slate-500">Firmware : </dt>
<dd className="inline">{d.firmwareVersion || '—'}</dd>
</div>
<div>
<dt className="inline text-slate-500">Vu pour la dernière fois : </dt>
<dd className="inline">
{d.lastSeenAt ? new Date(d.lastSeenAt).toLocaleString() : 'jamais'}
</dd>
</div>
</dl>
</Card>
</Link>
))}
</div>
)}

View File

@ -0,0 +1,441 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Button, Card, StatusBadge } from '../components/ui';
import {
api,
ApiError,
type DeviceSummary,
type HealthReport,
type LogEntry,
} from '../lib/api';
// ─── Level helpers ────────────────────────────────────────────────
const LEVEL_COLORS: Record<string, string> = {
trace: 'text-slate-500',
debug: 'text-slate-400',
info: 'text-blue-400',
warn: 'text-amber-400',
error: 'text-red-400',
fatal: 'text-red-500 font-bold',
};
const LEVEL_OPTIONS = [
{ value: 0, label: 'Tous' },
{ value: 20, label: 'Debug+' },
{ value: 30, label: 'Info+' },
{ value: 40, label: 'Warn+' },
{ value: 50, label: 'Error+' },
];
// ─── Component ────────────────────────────────────────────────────
export function DeviceDetailPage() {
const { deviceId } = useParams<{ deviceId: string }>();
const [tab, setTab] = useState<'health' | 'logs'>('health');
// Device info
const [device, setDevice] = useState<DeviceSummary | null>(null);
// Health
const [reports, setReports] = useState<HealthReport[]>([]);
const [alerts, setAlerts] = useState<string[]>([]);
const [healthLoading, setHealthLoading] = useState(true);
// Logs
const [logs, setLogs] = useState<LogEntry[]>([]);
const [logsTotal, setLogsTotal] = useState(0);
const [logsLoading, setLogsLoading] = useState(false);
const [logLevel, setLogLevel] = useState(0);
const [logSearch, setLogSearch] = useState('');
const [error, setError] = useState<string | null>(null);
// ── Fetch device info ──
useEffect(() => {
if (!deviceId) return;
api
.listDevices()
.then((devices) => {
const d = devices.find((x) => x.id === deviceId);
if (d) setDevice(d);
})
.catch(() => {});
}, [deviceId]);
// ── Fetch health data ──
const fetchHealth = useCallback(async () => {
if (!deviceId) return;
setHealthLoading(true);
setError(null);
try {
const [reportsRes, alertsRes] = await Promise.all([
api.getHealthReports(deviceId, 20),
api.getHealthAlerts(deviceId),
]);
setReports(reportsRes.reports);
setAlerts(alertsRes.alerts);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
} finally {
setHealthLoading(false);
}
}, [deviceId]);
useEffect(() => {
if (tab === 'health') fetchHealth();
}, [tab, fetchHealth]);
// ── Fetch logs ──
const fetchLogs = useCallback(async () => {
if (!deviceId) return;
setLogsLoading(true);
try {
const res = await api.getDeviceLogs(deviceId, {
level: logLevel || undefined,
search: logSearch || undefined,
limit: 100,
});
setLogs(res.logs);
setLogsTotal(res.total);
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Erreur réseau');
} finally {
setLogsLoading(false);
}
}, [deviceId, logLevel, logSearch]);
useEffect(() => {
if (tab === 'logs') fetchLogs();
}, [tab, fetchLogs]);
// ── Helpers ──
function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 24) {
const d = Math.floor(h / 24);
return `${d}j ${h % 24}h`;
}
return `${h}h ${m}m`;
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleString('fr-FR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
const latest = reports[0] ?? null;
// ─── Render ─────────────────────────────────────────────────────
return (
<div className="mx-auto flex min-h-full w-full max-w-5xl flex-col gap-6 p-8">
{/* Header */}
<header className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/"
className="flex h-10 w-10 items-center justify-center rounded-xl border border-slate-800 text-slate-400 hover:bg-slate-800/60"
>
</Link>
<div>
<h1 className="text-xl font-semibold tracking-tight">
{device?.name ?? 'Robot'}
</h1>
<p className="font-mono text-xs text-slate-500">{deviceId}</p>
</div>
</div>
{device && <StatusBadge status={device.status} />}
</header>
{/* Tabs */}
<div className="flex gap-1 rounded-xl bg-slate-900/50 p-1">
<button
onClick={() => setTab('health')}
className={[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
tab === 'health'
? 'bg-slate-800 text-slate-100 shadow'
: 'text-slate-400 hover:text-slate-200',
].join(' ')}
>
Santé
</button>
<button
onClick={() => setTab('logs')}
className={[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
tab === 'logs'
? 'bg-slate-800 text-slate-100 shadow'
: 'text-slate-400 hover:text-slate-200',
].join(' ')}
>
Logs
</button>
</div>
{error && (
<Card className="border-red-500/30 bg-red-500/5 p-4 text-sm text-red-400">
{error}
</Card>
)}
{/* ── Health tab ── */}
{tab === 'health' && (
<>
{/* Alerts */}
{alerts.length > 0 && (
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
<h3 className="mb-2 text-sm font-medium text-amber-400"> Alertes</h3>
<ul className="flex flex-col gap-1">
{alerts.map((a, i) => (
<li key={i} className="text-sm text-amber-300/80">
{a}
</li>
))}
</ul>
</Card>
)}
{/* Live metrics */}
{healthLoading && !latest && (
<Card className="p-8 text-center text-sm text-slate-400">Chargement...</Card>
)}
{latest && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard
label="CPU"
value={`${latest.cpuTempCelsius}°C`}
warn={latest.cpuTempCelsius >= 70}
critical={latest.cpuTempCelsius >= 80}
/>
<MetricCard
label="RAM"
value={`${latest.memoryUsedMb.toFixed(0)} / ${latest.memoryTotalMb.toFixed(0)} MB`}
percent={(latest.memoryUsedMb / latest.memoryTotalMb) * 100}
warn={(latest.memoryUsedMb / latest.memoryTotalMb) >= 0.8}
critical={(latest.memoryUsedMb / latest.memoryTotalMb) >= 0.9}
/>
<MetricCard
label="Disque"
value={`${latest.diskUsedPercent.toFixed(0)}%`}
percent={latest.diskUsedPercent}
warn={latest.diskUsedPercent >= 80}
critical={latest.diskUsedPercent >= 90}
/>
<MetricCard
label="Heap Node"
value={`${latest.heapUsedMb.toFixed(0)} / ${latest.heapTotalMb.toFixed(0)} MB`}
percent={(latest.heapUsedMb / latest.heapTotalMb) * 100}
/>
<MetricCard
label="Load Avg (1m)"
value={latest.loadAvg1m.toFixed(2)}
warn={latest.loadAvg1m >= 2}
critical={latest.loadAvg1m >= 3}
/>
<MetricCard
label="WiFi"
value={latest.wifiSsid ?? 'Non connecté'}
sub={latest.wifiSignalDbm !== null ? `${latest.wifiSignalDbm} dBm` : undefined}
/>
<MetricCard
label="Uptime"
value={formatUptime(latest.uptimeSeconds)}
/>
<MetricCard
label="Version"
value={`v${latest.clientVersion}`}
sub={latest.nodeVersion}
/>
</div>
)}
{/* History */}
{reports.length > 1 && (
<Card className="p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-300">
Historique ({reports.length} rapports)
</h3>
<Button variant="ghost" className="text-xs" onClick={() => void fetchHealth()}>
Rafraîchir
</Button>
</div>
<div className="max-h-48 overflow-y-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-800 text-left text-slate-500">
<th className="pb-2 pr-3">Heure</th>
<th className="pb-2 pr-3">CPU</th>
<th className="pb-2 pr-3">RAM</th>
<th className="pb-2 pr-3">Disque</th>
<th className="pb-2 pr-3">Load</th>
<th className="pb-2">WiFi</th>
</tr>
</thead>
<tbody>
{reports.map((r, i) => (
<tr key={i} className="border-b border-slate-800/50 text-slate-400">
<td className="py-1.5 pr-3 font-mono">{formatTime(r.reportedAt)}</td>
<td className="py-1.5 pr-3">{r.cpuTempCelsius}°C</td>
<td className="py-1.5 pr-3">{r.memoryUsedMb.toFixed(0)} MB</td>
<td className="py-1.5 pr-3">{r.diskUsedPercent.toFixed(0)}%</td>
<td className="py-1.5 pr-3">{r.loadAvg1m.toFixed(2)}</td>
<td className="py-1.5">{r.wifiSignalDbm ?? '—'} dBm</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</>
)}
{/* ── Logs tab ── */}
{tab === 'logs' && (
<>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={logLevel}
onChange={(e) => setLogLevel(Number(e.target.value))}
className="rounded-xl border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm text-slate-200 focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
>
{LEVEL_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<input
type="text"
placeholder="Rechercher..."
value={logSearch}
onChange={(e) => setLogSearch(e.target.value)}
className="flex-1 rounded-xl border border-slate-700 bg-slate-900/60 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-500 focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
/>
<Button
variant="secondary"
onClick={() => void fetchLogs()}
loading={logsLoading}
className="text-xs"
>
Rafraîchir
</Button>
<span className="text-xs text-slate-500">
{logsTotal} log{logsTotal > 1 ? 's' : ''}
</span>
</div>
{/* Log entries */}
<Card className="overflow-hidden">
<div className="max-h-[600px] overflow-y-auto font-mono text-xs">
{logsLoading && logs.length === 0 && (
<div className="p-8 text-center text-slate-400">Chargement...</div>
)}
{!logsLoading && logs.length === 0 && (
<div className="p-8 text-center text-slate-400">Aucun log trouvé</div>
)}
{logs.map((log, i) => {
const levelColor = LEVEL_COLORS[log.levelLabel] ?? 'text-slate-400';
return (
<div
key={i}
className={[
'flex gap-3 border-b border-slate-800/50 px-4 py-1.5',
'hover:bg-slate-800/30',
log.levelLabel === 'error' || log.levelLabel === 'fatal'
? 'bg-red-500/5'
: '',
].join(' ')}
>
<span className="w-16 shrink-0 text-slate-500">
{new Date(log.time).toLocaleTimeString('fr-FR')}
</span>
<span className={`w-12 shrink-0 uppercase ${levelColor}`}>
{log.levelLabel}
</span>
<span className="w-24 shrink-0 truncate text-slate-500">
{log.name ?? '—'}
</span>
<span className="text-slate-300">{log.msg}</span>
</div>
);
})}
</div>
</Card>
</>
)}
</div>
);
}
// ─── MetricCard ───────────────────────────────────────────────────
function MetricCard({
label,
value,
sub,
percent,
warn = false,
critical = false,
}: {
label: string;
value: string;
sub?: string;
percent?: number;
warn?: boolean;
critical?: boolean;
}) {
const borderColor = critical
? 'border-red-500/30'
: warn
? 'border-amber-500/30'
: 'border-slate-800';
const valueColor = critical
? 'text-red-400'
: warn
? 'text-amber-400'
: 'text-slate-100';
return (
<Card className={`p-4 ${borderColor}`}>
<p className="text-xs text-slate-500">{label}</p>
<p className={`mt-1 text-lg font-semibold ${valueColor}`}>{value}</p>
{sub && <p className="mt-0.5 text-xs text-slate-500">{sub}</p>}
{percent !== undefined && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-800">
<div
className={[
'h-full rounded-full transition-all',
critical
? 'bg-red-500'
: warn
? 'bg-amber-500'
: 'bg-brand-500',
].join(' ')}
style={{ width: `${Math.min(percent, 100)}%` }}
/>
</div>
)}
</Card>
);
}

View File

@ -0,0 +1,534 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button, Card, Input } from '../components/ui';
import { api, ApiError } from '../lib/api';
import {
createRobotLocalApi,
type RobotLocalApi,
type WifiNetwork,
} from '../lib/robot-local-api';
// ─── Types ─────────────────────────────────────────────────────────
type Step = 'connect-ap' | 'wifi-select' | 'wifi-password' | 'wifi-connecting' | 'pairing';
// ─── Component ─────────────────────────────────────────────────────
export function SetupRobotPage() {
const navigate = useNavigate();
const [step, setStep] = useState<Step>('connect-ap');
const [robotApi] = useState<RobotLocalApi>(() => createRobotLocalApi());
const [robotName, setRobotName] = useState('Ti-Pote');
// WiFi state
const [networks, setNetworks] = useState<WifiNetwork[]>([]);
const [scanning, setScanning] = useState(false);
const [selectedSsid, setSelectedSsid] = useState('');
const [wifiPassword, setWifiPassword] = useState('');
const [wifiError, setWifiError] = useState<string | null>(null);
const [wifiConnecting, setWifiConnecting] = useState(false);
// Pairing state
const CODE_LENGTH = 6;
const [digits, setDigits] = useState<string[]>(() => Array.from({ length: CODE_LENGTH }, () => ''));
const [pairingSubmitting, setPairingSubmitting] = useState(false);
const [pairingError, setPairingError] = useState<string | null>(null);
const [pairingSuccess, setPairingSuccess] = useState<{ deviceId: string; deviceName: string } | null>(null);
const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
const code = useMemo(() => digits.join(''), [digits]);
const isCodeComplete = code.length === CODE_LENGTH && digits.every((d) => /\d/.test(d));
// ── Step 1: Detect robot AP ──────────────────────────────────────
const [detecting, setDetecting] = useState(false);
const [detected, setDetected] = useState(false);
const detectRobot = useCallback(async () => {
setDetecting(true);
try {
const reachable = await robotApi.isReachable();
if (reachable) {
const status = await robotApi.getStatus();
setRobotName(status.robotName);
setDetected(true);
} else {
setDetected(false);
}
} catch {
setDetected(false);
} finally {
setDetecting(false);
}
}, [robotApi]);
// Auto-detect every 3s on step 1
useEffect(() => {
if (step !== 'connect-ap') return;
detectRobot();
const interval = setInterval(detectRobot, 3000);
return () => clearInterval(interval);
}, [step, detectRobot]);
// ── Step 2: Scan WiFi ────────────────────────────────────────────
const scanNetworks = useCallback(async () => {
setScanning(true);
try {
const results = await robotApi.scanWifi();
// Sort by signal strength, deduplicate by SSID
const unique = new Map<string, WifiNetwork>();
for (const n of results) {
if (n.ssid && (!unique.has(n.ssid) || unique.get(n.ssid)!.signal < n.signal)) {
unique.set(n.ssid, n);
}
}
setNetworks(
Array.from(unique.values()).sort((a, b) => b.signal - a.signal),
);
} catch {
setNetworks([]);
} finally {
setScanning(false);
}
}, [robotApi]);
useEffect(() => {
if (step === 'wifi-select') {
scanNetworks();
}
}, [step, scanNetworks]);
// ── Step 3: Connect WiFi ─────────────────────────────────────────
async function handleWifiConnect() {
setWifiError(null);
setWifiConnecting(true);
setStep('wifi-connecting');
try {
const result = await robotApi.connectWifi(selectedSsid, wifiPassword);
if (result.success) {
// Robot is now on user's WiFi — we lost AP connection
// Wait a moment then move to pairing step
setTimeout(() => setStep('pairing'), 3000);
} else {
setWifiError(result.error || 'Connexion échouée');
setStep('wifi-password');
}
} catch {
// Likely lost connection because AP went down (expected on success)
// Move to pairing after a delay
setTimeout(() => setStep('pairing'), 3000);
} finally {
setWifiConnecting(false);
}
}
// ── Step 4: Pairing ──────────────────────────────────────────────
function setDigitAt(index: number, value: string) {
const cleaned = value.replace(/\D/g, '').slice(0, 1);
setDigits((prev) => {
const next = [...prev];
next[index] = cleaned;
return next;
});
if (cleaned && index < CODE_LENGTH - 1) {
inputsRef.current[index + 1]?.focus();
}
}
function onKeyDown(index: number, e: KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Backspace' && !digits[index] && index > 0) {
inputsRef.current[index - 1]?.focus();
} else if (e.key === 'ArrowLeft' && index > 0) {
inputsRef.current[index - 1]?.focus();
} else if (e.key === 'ArrowRight' && index < CODE_LENGTH - 1) {
inputsRef.current[index + 1]?.focus();
} else if (e.key === 'Enter' && isCodeComplete) {
void submitPairing();
}
}
function onPaste(e: ClipboardEvent<HTMLInputElement>) {
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, CODE_LENGTH);
if (!pasted) return;
e.preventDefault();
const next = Array.from({ length: CODE_LENGTH }, (_, i) => pasted[i] ?? '');
setDigits(next);
const lastFilled = Math.min(pasted.length, CODE_LENGTH) - 1;
inputsRef.current[lastFilled < 0 ? 0 : lastFilled]?.focus();
}
async function submitPairing() {
setPairingError(null);
setPairingSubmitting(true);
try {
const result = await api.confirmPairing(code);
setPairingSuccess(result);
} catch (err) {
if (err instanceof ApiError) {
if (err.status === 400) {
setPairingError('Code invalide ou expiré. Vérifie le code affiché sur le robot.');
} else {
setPairingError(err.message || "Erreur lors de l'association.");
}
} else {
setPairingError('Impossible de joindre le serveur. Reconnecte-toi à ton WiFi habituel.');
}
} finally {
setPairingSubmitting(false);
}
}
// ── Signal icon helper ───────────────────────────────────────────
function signalIcon(signal: number): string {
if (signal >= 70) return '▂▄▆█';
if (signal >= 50) return '▂▄▆░';
if (signal >= 30) return '▂▄░░';
return '▂░░░';
}
// ── Step progress indicator ──────────────────────────────────────
const steps = [
{ key: 'connect-ap', label: 'Connexion' },
{ key: 'wifi-select', label: 'WiFi' },
{ key: 'pairing', label: 'Association' },
] as const;
const currentStepIndex = (() => {
if (step === 'connect-ap') return 0;
if (step === 'wifi-select' || step === 'wifi-password' || step === 'wifi-connecting') return 1;
return 2;
})();
// ─── Render ──────────────────────────────────────────────────────
// Success screen
if (pairingSuccess) {
return (
<div className="flex min-h-full items-center justify-center p-8">
<Card className="w-full max-w-md p-8 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10 text-4xl">
🎉
</div>
<h1 className="text-2xl font-semibold tracking-tight">
{robotName} est prêt !
</h1>
<p className="mt-2 text-sm text-slate-400">
Le robot est connecté au WiFi et associé à ton foyer.
</p>
<p className="mt-1 font-mono text-xs text-slate-500">{pairingSuccess.deviceId}</p>
<div className="mt-6 flex gap-2">
<Button
variant="secondary"
className="flex-1"
onClick={() => navigate('/setup')}
>
Ajouter un autre
</Button>
<Button className="flex-1" onClick={() => navigate('/')}>
Tableau de bord
</Button>
</div>
</Card>
</div>
);
}
return (
<div className="flex min-h-full items-center justify-center p-8">
<Card className="w-full max-w-lg p-8">
{/* Step indicator */}
<div className="mb-8 flex items-center justify-center gap-2">
{steps.map((s, i) => (
<div key={s.key} className="flex items-center gap-2">
<div
className={[
'flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold transition-all',
i < currentStepIndex
? 'bg-emerald-500 text-white'
: i === currentStepIndex
? 'bg-brand-500 text-white ring-4 ring-brand-500/20'
: 'bg-slate-800 text-slate-500',
].join(' ')}
>
{i < currentStepIndex ? '✓' : i + 1}
</div>
<span
className={[
'text-xs font-medium',
i === currentStepIndex ? 'text-slate-200' : 'text-slate-500',
].join(' ')}
>
{s.label}
</span>
{i < steps.length - 1 && (
<div
className={[
'mx-1 h-px w-8',
i < currentStepIndex ? 'bg-emerald-500' : 'bg-slate-700',
].join(' ')}
/>
)}
</div>
))}
</div>
{/* ── Step 1: Connect to AP ── */}
{step === 'connect-ap' && (
<div className="flex flex-col items-center gap-6 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-brand-500/10 text-4xl">
📡
</div>
<div>
<h2 className="text-xl font-semibold">Connecte-toi au robot</h2>
<p className="mt-2 text-sm text-slate-400">
Ouvre les paramètres WiFi de ton ordinateur et connecte-toi au réseau :
</p>
<div className="mt-3 inline-block rounded-lg border border-brand-500/30 bg-brand-500/5 px-4 py-2">
<span className="font-mono text-lg font-bold text-brand-400">Ti-Pote</span>
</div>
<p className="mt-2 text-xs text-slate-500">
Pas de mot de passe requis
</p>
</div>
<div className="flex w-full flex-col items-center gap-3">
{detecting && !detected && (
<div className="flex items-center gap-2 text-sm text-slate-400">
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path fill="currentColor" className="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Recherche du robot...
</div>
)}
{detected && (
<div className="flex items-center gap-2 text-sm text-emerald-400">
<span></span>
<span>Robot détecté : <strong>{robotName}</strong></span>
</div>
)}
<Button
disabled={!detected}
onClick={() => setStep('wifi-select')}
className="w-full"
>
{detected ? 'Configurer le WiFi' : 'En attente de connexion...'}
</Button>
</div>
</div>
)}
{/* ── Step 2: WiFi selection ── */}
{step === 'wifi-select' && (
<div className="flex flex-col gap-5">
<div className="text-center">
<h2 className="text-xl font-semibold">Choisis ton réseau WiFi</h2>
<p className="mt-1 text-sm text-slate-400">
Le robot se connectera à ce réseau pour accéder à internet.
</p>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
{networks.length} réseau{networks.length > 1 ? 'x' : ''} trouvé{networks.length > 1 ? 's' : ''}
</span>
<Button
variant="ghost"
onClick={() => void scanNetworks()}
loading={scanning}
className="text-xs"
>
Rescanner
</Button>
</div>
{scanning && networks.length === 0 ? (
<div className="flex items-center justify-center py-8 text-sm text-slate-400">
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path fill="currentColor" className="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Scan en cours...
</div>
) : (
<div className="max-h-64 overflow-y-auto rounded-xl border border-slate-800">
{networks.map((n) => (
<button
key={n.ssid}
onClick={() => {
setSelectedSsid(n.ssid);
setStep('wifi-password');
}}
className={[
'flex w-full items-center justify-between px-4 py-3',
'border-b border-slate-800 last:border-b-0',
'text-left text-sm transition-colors',
'hover:bg-slate-800/60',
].join(' ')}
>
<div className="flex items-center gap-3">
<span className="text-slate-400">
{n.security !== '--' && n.security !== '' ? '🔒' : '🔓'}
</span>
<span className="font-medium text-slate-200">{n.ssid}</span>
</div>
<span className="font-mono text-xs text-slate-500" title={`${n.signal}%`}>
{signalIcon(n.signal)}
</span>
</button>
))}
</div>
)}
<Button variant="secondary" onClick={() => setStep('connect-ap')}>
Retour
</Button>
</div>
)}
{/* ── Step 2b: WiFi password ── */}
{step === 'wifi-password' && (
<div className="flex flex-col gap-5">
<div className="text-center">
<h2 className="text-xl font-semibold">Mot de passe WiFi</h2>
<p className="mt-1 text-sm text-slate-400">
Réseau : <strong className="text-slate-200">{selectedSsid}</strong>
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void handleWifiConnect();
}}
className="flex flex-col gap-4"
>
<Input
label="Mot de passe"
type="password"
value={wifiPassword}
onChange={(e) => setWifiPassword(e.target.value)}
error={wifiError}
autoFocus
placeholder="Mot de passe du réseau WiFi"
/>
<div className="flex gap-2">
<Button
variant="secondary"
className="flex-1"
type="button"
onClick={() => {
setWifiPassword('');
setWifiError(null);
setStep('wifi-select');
}}
>
Autre réseau
</Button>
<Button className="flex-1" type="submit" loading={wifiConnecting}>
Connecter
</Button>
</div>
</form>
</div>
)}
{/* ── Step 2c: Connecting... ── */}
{step === 'wifi-connecting' && (
<div className="flex flex-col items-center gap-6 py-8 text-center">
<svg className="h-12 w-12 animate-spin text-brand-400" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" className="opacity-25" />
<path fill="currentColor" className="opacity-75" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
<div>
<h2 className="text-xl font-semibold">Connexion en cours...</h2>
<p className="mt-2 text-sm text-slate-400">
Le robot se connecte au réseau <strong className="text-slate-200">{selectedSsid}</strong>.
</p>
<p className="mt-1 text-xs text-slate-500">
Le hotspot va se fermer. Reconnecte-toi à ton WiFi habituel.
</p>
</div>
</div>
)}
{/* ── Step 3: Pairing code ── */}
{step === 'pairing' && (
<div className="flex flex-col gap-5">
<div className="text-center">
<div className="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-500/10 text-3xl">
🔗
</div>
<h2 className="text-xl font-semibold">Associer le robot</h2>
<p className="mt-1 text-sm text-slate-400">
Saisis le code à 6 chiffres affiché sur {robotName}
</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-950/50 p-3 text-xs text-slate-500">
<p className="mb-1 font-medium text-slate-400">💡 Reconnecte-toi à ton WiFi</p>
<p>
Le robot est maintenant sur ton WiFi. Reconnecte ton ordinateur à ton réseau
habituel pour pouvoir communiquer avec le serveur.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void submitPairing();
}}
className="flex flex-col gap-4"
>
<div className="flex justify-center gap-2" onPaste={onPaste}>
{digits.map((d, i) => (
<input
key={i}
ref={(el) => {
inputsRef.current[i] = el;
}}
inputMode="numeric"
pattern="\d*"
maxLength={1}
value={d}
onChange={(e) => setDigitAt(i, e.target.value)}
onKeyDown={(e) => onKeyDown(i, e)}
onFocus={(e) => e.target.select()}
className="h-14 w-12 rounded-xl border border-slate-700 bg-slate-900/60 text-center text-2xl font-semibold tabular-nums text-slate-100 shadow-inner focus:border-brand-400/60 focus:outline-none focus:ring-2 focus:ring-brand-400/40"
aria-label={`Chiffre ${i + 1}`}
/>
))}
</div>
{pairingError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-center text-sm text-red-400">
{pairingError}
</div>
)}
<Button type="submit" loading={pairingSubmitting} disabled={!isCodeComplete} className="w-full">
Associer ce robot
</Button>
</form>
</div>
)}
{/* Bottom link */}
<div className="mt-6 flex items-center justify-center gap-1 text-sm text-slate-400">
<Link to="/" className="hover:text-slate-200">
Retour au tableau de bord
</Link>
</div>
</Card>
</div>
);
}