Tus datos. Tu casa. Una IA que ve el cuadro completo.
FtiMind corre en tu NAS, Raspberry Pi o computador. Lee tus relojes y básculas, lo guarda en una base local privada y te asesora con coach, nutricionista y médico que conocen tu historia completa.
}>Empezar
~5 minutos · podés saltar pasos
QUÉ VAMOS A HACER
{[
{ n: "01", l: "Confirmar tu cuenta", d: "La sesión que iniciaste está lista" },
{ n: "02", l: "Conectar tu primer dispositivo", d: "Garmin, Eufy, otros vienen pronto" },
{ n: "03", l: "Configurar LLM (opcional)", d: "Sin esto: solo dashboards. Con esto: coach completo." },
].map((s, i) => (
{s.n}
{s.l}
{s.d}
))}
Todos tus datos viven en tu máquina. Solo lo que envíes al LLM sale.
);
const StepToken = ({ onNext }) => {
// Tras el cambio a auth por email+password, este paso ya no pide token: el usuario
// ya está autenticado al llegar acá (pasó por Login.jsx). Mostramos su sesión.
const user = API.getUser() || {};
const inDemo = API.demoMode();
return (
PASO 1 · TU CUENTA
{inDemo ? "Estás en modo demo." : "Tu cuenta está lista."}
{inDemo
? "Vas a navegar la app con datos de muestra. Para conectar Garmin/Eufy de verdad, salí del modo demo desde el topbar."
: "Tus credenciales viven en la base local de FtiMind. El token de sesión expira automáticamente y se renueva al iniciar sesión."}
SESIÓN ACTUAL
{user.name || (inDemo ? "Usuario demo" : "—")}
{user.email || "—"}
}>Continuar
);
};
const StepProvider = ({ authState, setAuthState, bleState, setBleState, syncProgress, onNext }) => {
const [picked, setPicked] = React.useState(null);
const [providers, setProviders] = React.useState(null);
React.useEffect(() => {
if (API.demoMode()) return;
let alive = true;
(async () => {
try {
const data = await API.listProviders();
const list = Array.isArray(data) ? data : (data?.providers || []);
if (alive) setProviders(list);
} catch (_) { /* fallback al hardcoded */ }
})();
return () => { alive = false; };
}, []);
if (picked === "garmin") return setPicked(null)} onDone={onNext} progress={syncProgress} />;
if (picked === "eufy") return setPicked(null)} onDone={onNext} />;
// Si tenemos providers reales del backend los usamos para decidir disponibilidad,
// si no, hardcodeamos los 4 conocidos para que la UI no se rompa.
const known = providers
? providers.map(p => (typeof p === "string" ? p : p.name)).filter(Boolean).map(n => n.toLowerCase())
: null;
const has = (n) => known ? known.includes(n) : (n === "garmin" || n === "eufy");
return (
PASO 2 · CONECTÁ TU PRIMER DISPOSITIVO
¿Qué tenés a mano?
Podés conectar más después. Para el MVP soportamos Garmin + Eufy. Otros vienen pronto.
);
};
// Modelos soportados por eufylife-ble-client. La key es el `model_id` que
// el backend espera en `configure`; el value es el nombre comercial.
const EUFY_MODELS = [
{ id: "eufy T9120", name: "Smart Scale A1" },
{ id: "eufy T9130", name: "Smart Scale C20" },
{ id: "eufy T9140", name: "Smart Scale" },
{ id: "eufy T9146", name: "Smart Scale C1" },
{ id: "eufy T9147", name: "Smart Scale P1" },
{ id: "eufy T9148", name: "Smart Scale P2" },
{ id: "eufy T9149", name: "Smart Scale P2 Pro" },
{ id: "eufy T9150", name: "Smart Scale P3" },
];
const _eufyModelLabel = (modelId) => {
const hit = EUFY_MODELS.find((m) => m.id === modelId);
return hit ? `${hit.id.replace("eufy ", "")} · ${hit.name}` : (modelId || "modelo desconocido");
};
const EufyPair = ({ state, setState, onBack, onDone }) => {
const [discovered, setDiscovered] = React.useState([]);
const [selectedAddress, setSelectedAddress] = React.useState(null);
const [modelId, setModelId] = React.useState("eufy T9146"); // C1 = T9146; default sensato
const [mode, setMode] = React.useState("local_ble");
const [manualAddress, setManualAddress] = React.useState("");
const [manualMode, setManualMode] = React.useState(false);
const [sex, setSex] = React.useState("male");
const [age, setAge] = React.useState(32);
const [heightCm, setHeightCm] = React.useState(178);
const [pairError, setPairError] = React.useState(null);
const [submitting, setSubmitting] = React.useState(false);
const [includeAll, setIncludeAll] = React.useState(false);
const startScan = async (allFlag = includeAll) => {
setPairError(null);
setDiscovered([]);
setSelectedAddress(null);
setManualMode(false);
setState("scanning");
if (API.demoMode()) {
// Modo demo: simulamos un C1 detectado.
setTimeout(() => {
setDiscovered([{ address: "CF:35:1A:B8:42:7D", name: "eufy T9146 Scale", rssi: -52, model_guess: "eufy T9146" }]);
setSelectedAddress("CF:35:1A:B8:42:7D");
setModelId("eufy T9146");
setState("results");
}, 1800);
return;
}
try {
const res = await API.extras("eufy", "discover", { seconds: 10, include_all: allFlag });
const found = Array.isArray(res?.devices) ? res.devices : [];
setDiscovered(found);
if (found.length > 0) {
setSelectedAddress(found[0].address);
if (found[0].model_guess) setModelId(found[0].model_guess);
}
setState("results");
} catch (e) {
if (e.network) {
setPairError("No hay conexión con el backend. Probá levantar el server o usar modo demo.");
setState("idle");
return;
}
const msg = e.detail?.detail || e.message || "El escaneo BLE falló.";
// En macOS la causa más común es que el host no le dio permisos de
// Bluetooth al proceso uvicorn → bleak devuelve un error específico.
const hint = /permission|authoriz|bluetooth.*not.*on|adapter|hci/i.test(msg)
? " · Pista: probá con `address` manual o asegurate que el host (Linux: /dev/hci0; macOS: permisos BT) tiene BLE habilitado."
: "";
setPairError(`${msg}${hint}`);
setState("idle");
}
};
const confirmPair = async () => {
setPairError(null);
setSubmitting(true);
try {
const address = manualMode ? manualAddress.trim() : selectedAddress;
if (!address) {
setPairError("Falta la dirección BLE de la báscula.");
return;
}
// Validación mínima. Aceptamos dos formatos:
// - MAC clásica (Linux/Windows): AA:BB:CC:DD:EE:FF
// - UUID CoreBluetooth (macOS): 12345678-1234-1234-1234-123456789ABC
// En macOS la MAC real está oculta y bleak entrega un UUID per-app.
const isMac = /^([0-9a-f]{2}[:\-]){5}[0-9a-f]{2}$/i.test(address);
const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(address);
if (!isMac && !isUuid) {
setPairError("La dirección no parece válida. Acepto MAC (AA:BB:CC:DD:EE:FF) o UUID CoreBluetooth (8-4-4-4-12 hex con guiones).");
return;
}
if (API.demoMode()) { onDone(); return; }
const payload = {
address,
mode,
model_id: modelId,
sex,
age: Number(age),
height_cm: Number(heightCm),
};
await API.extras("eufy", "configure", payload);
try {
await API.startListener("eufy");
} catch (le) {
// configure ok pero el listener no arrancó (BLE no disponible en el
// host). Mostramos el error pero seguimos: la config quedó persistida
// y el usuario puede reintentar desde Configuración → "Reiniciar listener".
const msg = le.detail?.detail || le.message || "El listener BLE no pudo arrancar.";
setPairError(`Configuración guardada, pero el listener falló: ${msg}. Reiniciá desde Configuración cuando el BLE esté disponible.`);
return;
}
onDone();
} catch (e) {
if (e.network) { onDone(); return; }
setPairError(e.detail?.detail || e.message || "No se pudo guardar la configuración Eufy.");
} finally {
setSubmitting(false);
}
};
return (
EMPAREJAR EUFY · BLE LOCAL
{pairError && (
Error: {pairError}
)}
{state === "idle" && (
<>
Despertá la báscula y subite encima.
Vamos a escanear BLE durante ~10 segundos. La báscula se anuncia mientras alguien está sobre ella o justo después de pisarla. Si no aparece, podés ver todos los devices BLE o ingresar la dirección a mano.
startScan(false)} icon={}>Iniciar escaneo { setIncludeAll(true); startScan(true); }}>Ver todos los BLE { setManualMode(true); setState("results"); }}>Ingresar manualmente
macOS oculta la MAC real de los devices BLE y la reemplaza por un UUID aleatorio, por eso a veces el filtro Eufy/Anker no encuentra la báscula. En ese caso usá "Ver todos los BLE" y elegila por nombre o RSSI más fuerte mientras estás parado encima.
{includeAll ? "El scan no detectó ningún BLE." : "No encontré básculas Eufy con el filtro."}
{includeAll
? "Asegurate que el host tiene Bluetooth encendido y permisos BLE concedidos al proceso. En macOS: Configuración → Privacidad → Bluetooth."
: "En macOS la MAC real está oculta, así que el filtro Eufy/Anker puede fallar aunque la báscula esté presente. Probá \"Ver todos los BLE\" para listar todo lo que hay alrededor."}
{ setIncludeAll(false); setState("idle"); }}>Volver a escanear
{!includeAll && (
{ setIncludeAll(true); startScan(true); }}>
Ver todos los BLE
)}
setManualMode(true)}>Ingresar manualmente