Skip to content

gestes_routes

gestes_routes

Le mode gestes : armer, envoyer des images, désarmer.

Spatial Mesh, gestes — 25 août 2026. La caméra est ouverte par l'APPLICATION, pas par le serveur : macOS ne pose la question qu'à un paquet, et l'interpréteur Python n'en est pas un (voir docs/spatial-mesh/GESTES.md). L'interface capture donc, et poste ici les images ; le serveur voit, décide, et rend un état.

Le §78 gouverne ce module : rien ne guette en permanence. Une session s'arme explicitement, se désarme d'elle-même après un temps mort, et le voyant vert de la caméra dit la vérité pendant tout ce temps. Une caméra qui tournerait « au cas où » ferait de Diapason autre chose qu'un assistant.

Aucune image n'est écrite sur le disque, jamais : elle vit le temps d'un appel à Vision, et seuls des points articulaires lui survivent.

Classes

FichierPourGeste

Bases: BaseModel

Le chemin rendu par le dialogue natif — jamais un contenu encodé.

ChoixDAppareil

Bases: BaseModel

La réponse à « vers lequel ? » : le jeton posé, et un appareil.

Functions:

session_active

session_active() -> bool

Le mode gestes est-il armé ? Constaté, pour l'interface et les tests.

Source code in src/diapason/server/gestes_routes.py
def session_active() -> bool:
    """Le mode gestes est-il armé ? Constaté, pour l'interface et les tests."""
    global _session
    if _session is not None and _session.expiree:
        logger.info("mode gestes désarmé : silence ou durée dépassée")
        desarmer()
    return _session is not None

claps_actifs

claps_actifs() -> bool

Le micro écoute-t-il vraiment ? Un objet gardé n'est pas une preuve.

Source code in src/diapason/server/gestes_routes.py
def claps_actifs() -> bool:
    """Le micro écoute-t-il vraiment ? Un objet gardé n'est pas une preuve."""
    global _ecouteur_claps
    ecouteur = _ecouteur_claps
    if ecouteur is None:
        return False
    if not bool(getattr(ecouteur, "ecoute", True)):
        # Le fil est mort — casque débranché, micro repris par une autre
        # application. Garder le cadavre fait répondre « déjà en écoute » à
        # toute tentative de relance, et le micro reste fermé pour de bon
        # sans que rien ne le dise.
        _ecouteur_claps = None
        return False
    return True

ecouter_les_claps

ecouter_les_claps() -> dict[str, Any]

Ouvrir le micro pour entendre un double-clap, et rien d'autre.

Le détecteur ne transcrit rien et ne garde rien : il suit le niveau sonore et cherche deux pics rapprochés. Aucune parole n'est analysée, aucun son n'est enregistré.

Source code in src/diapason/server/gestes_routes.py
@router.post("/clap/on")
def ecouter_les_claps() -> dict[str, Any]:
    """Ouvrir le micro pour entendre un double-clap, et rien d'autre.

    Le détecteur ne transcrit rien et ne garde rien : il suit le niveau
    sonore et cherche deux pics rapprochés. Aucune parole n'est analysée,
    aucun son n'est enregistré.
    """
    global _ecouteur_claps, _intention_ecoute, _echec_de_clap
    _intention_ecoute += 1
    # CONSTATER, pas supposer : la présence d'un objet n'est pas une écoute.
    # Tester l'objet plutôt que son état interdisait toute relance après la
    # mort silencieuse du fil — le micro restait fermé à vie.
    if claps_actifs():
        return {"listening": True, "already": True}
    if _ecouteur_claps is not None:
        ne_plus_ecouter()
        _intention_ecoute += 1
    try:
        from diapason.speech.clap_listener import ClapListener
    except Exception as exc:  # noqa: BLE001
        raise HTTPException(
            status_code=503, detail=f"Écoute indisponible : {str(exc)[:120]}"
        ) from exc

    def _sur_double_clap() -> None:
        # Deux claps arment ; deux claps de plus désarment. Le même geste
        # dans les deux sens, parce qu'un mode qu'on ne sait pas couper
        # sans souris n'est pas vraiment mains libres.
        global _echec_de_clap
        try:
            if session_active():
                desarmer()
                logger.info("double-clap : mode gestes désarmé")
            else:
                armer()
                logger.info("double-clap : mode gestes armé")
            _echec_de_clap = None
        except Exception as exc:  # noqa: BLE001
            _echec_de_clap = str(getattr(exc, "detail", exc))[:160]
            logger.warning("double-clap non traité : %s", _echec_de_clap)

    try:
        ecouteur = ClapListener(_sur_double_clap, once=False)
        ecouteur.start()
    except Exception as exc:  # noqa: BLE001
        raise HTTPException(
            status_code=503,
            detail=f"Le micro n'a pas pu être ouvert : {str(exc)[:120]}",
        ) from exc
    # CONSTATER, pas supposer. Le fil d'écoute meurt en silence quand
    # sounddevice manque ou que le micro refuse : start() rendait la main
    # sans rien dire, et cette route répondait « écoute active » à un
    # utilisateur qui pouvait claper jusqu'au soir (25 août 2026).
    if not ecouteur.ecoute:
        raison = ecouteur.panne or "le micro n'a pas répondu"
        try:
            ecouteur.stop()
        except Exception:  # noqa: BLE001
            pass
        raise HTTPException(
            status_code=503, detail=f"L'écoute n'a pas démarré : {raison}"
        )
    _ecouteur_claps = ecouteur
    _echec_de_clap = None
    logger.info("écoute des claps démarrée")
    return {"listening": True}

ne_plus_ecouter

ne_plus_ecouter() -> dict[str, Any]

Refermer le micro. Il ne doit pas rester ouvert par oubli.

Source code in src/diapason/server/gestes_routes.py
@router.post("/clap/off")
def ne_plus_ecouter() -> dict[str, Any]:
    """Refermer le micro. Il ne doit pas rester ouvert par oubli."""
    global _ecouteur_claps, _intention_ecoute
    _intention_ecoute += 1
    ecouteur, _ecouteur_claps = _ecouteur_claps, None
    if ecouteur is not None:
        try:
            ecouteur.stop()
        except Exception:  # noqa: BLE001
            logger.debug("arrêt de l'écoute imparfait", exc_info=True)
    return {"listening": False}

mesurer_la_piece

mesurer_la_piece() -> dict[str, Any]

Premier temps : écouter la pièce se taire.

Source code in src/diapason/server/gestes_routes.py
@router.post("/clap/calibrate/room")
def mesurer_la_piece() -> dict[str, Any]:
    """Premier temps : écouter la pièce se taire."""
    global _piece_mesuree
    try:
        from diapason.speech.clap_listener import ecouter_la_piece
    except Exception as exc:  # noqa: BLE001
        raise HTTPException(
            status_code=503, detail=f"Écoute indisponible : {str(exc)[:120]}"
        ) from exc

    if not _verrou_calibration.acquire(blocking=False):
        raise HTTPException(status_code=409, detail="Une mesure est déjà en cours.")
    try:
        # L'intention se relève APRÈS la prise du micro : refermer l'écoute
        # l'incrémente elle-même, et la relever avant faisait échouer la
        # comparaison à tous les coups — l'écoute n'était alors jamais
        # reprise, ce que seul un test a révélé.
        ecoutait = _prendre_le_micro()
        intention = _intention_ecoute
        try:
            piece = ecouter_la_piece()
        except Exception as exc:  # noqa: BLE001
            # Un échec ne doit pas laisser sans écoute quelqu'un qui en avait.
            _rendre_le_micro(ecoutait, intention)
            raise HTTPException(
                status_code=503,
                detail=f"Le micro n'a pas pu être ouvert : {str(exc)[:120]}",
            ) from exc
        # Le micro reste fermé : le second temps le reprendra.
        _piece_mesuree = {
            "piece": piece,
            "ecoutait": ecoutait,
            "intention": intention,
            "a": time.monotonic(),
        }
    finally:
        _verrou_calibration.release()
    return {
        "roomLevel": round(piece.niveau, 4),
        "roomHigh": round(piece.haute, 4),
        "roomLoudest": round(piece.maximum, 4),
        "disturbed": piece.troublee,
    }

mesurer_les_claps

mesurer_les_claps() -> dict[str, Any]

Second temps : écouter claper, dans la pièce qu'on vient de mesurer.

Source code in src/diapason/server/gestes_routes.py
@router.post("/clap/calibrate/claps")
def mesurer_les_claps() -> dict[str, Any]:
    """Second temps : écouter claper, dans la pièce qu'on vient de mesurer."""
    global _piece_mesuree
    try:
        from diapason.speech.clap_listener import (
            ecouter_les_claps as ecouter_des_claps,
        )
        from diapason.speech.clap_listener import (
            enregistrer_reglage_claps,
            reglage_calibre,
        )
    except Exception as exc:  # noqa: BLE001
        raise HTTPException(
            status_code=503, detail=f"Écoute indisponible : {str(exc)[:120]}"
        ) from exc

    if not _verrou_calibration.acquire(blocking=False):
        raise HTTPException(status_code=409, detail="Une mesure est déjà en cours.")
    try:
        attente, _piece_mesuree = _piece_mesuree, None
        if attente is None or (time.monotonic() - attente["a"]) > _PEREMPTION_PIECE_S:
            raise HTTPException(
                status_code=409,
                detail="La pièce n'a pas été mesurée juste avant. Recommence.",
            )
        ecoutait, intention = attente["ecoutait"], attente["intention"]
        try:
            ecoute = ecouter_des_claps(attente["piece"])
            cfg = reglage_calibre(ecoute)
            enregistrer_reglage_claps(cfg)
        except ValueError as exc:
            raise HTTPException(status_code=422, detail=str(exc)) from exc
        except HTTPException:
            raise
        except Exception as exc:  # noqa: BLE001
            raise HTTPException(
                status_code=503, detail=f"La mesure a échoué : {str(exc)[:120]}"
            ) from exc
        finally:
            # Quoi qu'il arrive, le micro revient dans l'état où l'utilisateur
            # l'a laissé. Hors du try, un échec d'écriture perdait l'écoute en
            # silence ; dans le chemin du raise, un 503 de reprise écrasait le
            # 422 qui disait quoi corriger.
            _rendre_le_micro(ecoutait, intention)
    finally:
        _verrou_calibration.release()

    piece = ecoute.piece
    # En WARNING : une calibration est rare et c'est le seul endroit où les
    # chiffres bruts existent. Journalisée en INFO, elle était invisible dans
    # les journaux du service, et il a fallu la redemander.
    logger.warning(
        "claps calibrés : pièce %.4f (haute %.4f, max %.4f), claps %s, "
        "écartés %s, seuil %.4f",
        piece.niveau,
        piece.haute,
        piece.maximum,
        [round(c, 3) for c in ecoute.claps],
        [round(c, 3) for c in ecoute.ecartes],
        cfg.min_rms,
    )
    return {
        "calibrated": True,
        "roomLevel": round(piece.niveau, 4),
        "roomHigh": round(piece.haute, 4),
        "roomLoudest": round(piece.maximum, 4),
        "clapPeaks": [round(c, 4) for c in ecoute.claps],
        "discarded": [round(c, 4) for c in ecoute.ecartes],
        "threshold": cfg.min_rms,
    }

oublier_les_claps

oublier_les_claps() -> dict[str, Any]

Revenir au réglage d'usine. Le fil garde son seuil tant qu'il tourne : il faut donc le relancer pour que l'oubli prenne effet.

Source code in src/diapason/server/gestes_routes.py
@router.post("/clap/calibrate/reset")
def oublier_les_claps() -> dict[str, Any]:
    """Revenir au réglage d'usine. Le fil garde son seuil tant qu'il tourne :
    il faut donc le relancer pour que l'oubli prenne effet."""
    from diapason.speech.clap_listener import oublier_le_reglage_claps

    oublier_le_reglage_claps()
    if claps_actifs():
        ne_plus_ecouter()
        _rendre_le_micro(True, _intention_ecoute)
    return {"calibrated": False}

armer

armer(body: Optional[Armement] = None) -> dict[str, Any]

Armer le mode gestes. La caméra ne s'ouvre qu'après, côté interface.

Source code in src/diapason/server/gestes_routes.py
@router.post("/arm")
def armer(body: Optional[Armement] = None) -> dict[str, Any]:
    """Armer le mode gestes. La caméra ne s'ouvre qu'après, côté interface."""
    global _session
    from diapason.desktop.arbitre_geste import ArbitreDeGeste
    from diapason.desktop.gestes_main import MoteurDeGestes, charger_seuils
    from diapason.desktop.pointeur_main import MoteurDePointeur
    from diapason.desktop.vision_mains import disponible

    if not disponible():
        raise HTTPException(
            status_code=503,
            detail=(
                "La reconnaissance de main n'est pas disponible : "
                "uv pip install 'pyobjc-framework-Vision>=10'"
            ),
        )
    # Les seuils de CETTE machine : calibrés s'ils l'ont été.
    verrou = body.mode if body is not None else "AUTO"
    # AUTO démarre au transfert jusqu'à ce que l'arbitre confirme un index
    # (trois images) : une silhouette d'index ne doit pas cliquer tout de suite.
    mode = "POINTER" if verrou == "POINTER" else "TRANSFER"
    _session = _Session(
        moteur=MoteurDeGestes(charger_seuils()),
        verrou=verrou,
        mode=mode,
        pointeur=MoteurDePointeur(),
        arbitre=ArbitreDeGeste(),
    )
    logger.info("mode gestes armé : verrou %s", verrou.lower())
    return {
        "armed": True,
        "mode": mode,
        "modeLock": verrou,
        "inactivityTimeoutS": _INACTIVITE_MAX_S,
        "maxDurationS": _DUREE_MAX_S,
    }

desarmer_route

desarmer_route() -> dict[str, Any]

Désarmer. L'interface éteint la caméra en recevant cette réponse.

Source code in src/diapason/server/gestes_routes.py
@router.post("/disarm")
def desarmer_route() -> dict[str, Any]:
    """Désarmer. L'interface éteint la caméra en recevant cette réponse."""
    etait = session_active()
    desarmer()
    logger.info("mode gestes désarmé")
    return {"armed": False, "was": etait}

preparer_un_fichier

preparer_un_fichier(
    body: FichierPourGeste,
) -> dict[str, Any]

Préparer le fichier que le prochain poing attrapera.

Cette porte reste derrière l'authentification locale de l'application. Elle ne téléverse rien et ne renvoie jamais le chemin : le navigateur ne reçoit que le nom, le type et la taille qu'il doit afficher.

Source code in src/diapason/server/gestes_routes.py
@router.post("/file")
def preparer_un_fichier(body: FichierPourGeste) -> dict[str, Any]:
    """Préparer le fichier que le prochain poing attrapera.

    Cette porte reste derrière l'authentification locale de l'application.
    Elle ne téléverse rien et ne renvoie jamais le chemin : le navigateur ne
    reçoit que le nom, le type et la taille qu'il doit afficher.
    """
    from diapason.desktop.presse_papiers_spatial import preparer_fichier, tenu

    if not session_active() or _session is None:
        raise HTTPException(
            status_code=409,
            detail="Active d'abord les gestes, puis choisis le fichier.",
        )
    if _session.verrou == "POINTER":
        raise HTTPException(
            status_code=409,
            detail="Le curseur est verrouillé : un poing n'attrapera rien ici.",
        )
    if tenu() is not None:
        raise HTTPException(
            status_code=409,
            detail="Ta main tient déjà quelque chose. Dépose-le ou laisse tomber.",
        )
    try:
        objet = preparer_fichier(body.path)
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc
    _noter("fichier prêt", objet.titre, reussi=True)
    return {"preparedFile": objet.to_dict()}

oublier_le_fichier_prepare

oublier_le_fichier_prepare() -> dict[str, Any]

Retirer le fichier préparé sans toucher à ce qui est déjà tenu.

Source code in src/diapason/server/gestes_routes.py
@router.post("/file/cancel")
def oublier_le_fichier_prepare() -> dict[str, Any]:
    """Retirer le fichier préparé sans toucher à ce qui est déjà tenu."""
    from diapason.desktop.presse_papiers_spatial import oublier_fichier_prepare

    oublier_fichier_prepare()
    return {"cancelled": True}

repondre_au_choix

repondre_au_choix(
    attente: dict, cible: dict
) -> dict[str, Any]

Trancher une question en attente, et rendre ce que le récepteur a dit.

Le jeton sert de clé d'idempotence : deux réponses — un clic ET une phrase — n'envoient qu'une fois.

Source code in src/diapason/server/gestes_routes.py
def repondre_au_choix(attente: dict, cible: dict) -> dict[str, Any]:
    """Trancher une question en attente, et rendre ce que le récepteur a dit.

    Le jeton sert de clé d'idempotence : deux réponses — un clic ET une
    phrase — n'envoient qu'une fois.
    """
    session = _session
    pendant_saisie = bool(session is not None and session.moteur.etat.value == "SAISI")
    resultat = _envoyer_et_consommer(attente["objet"], cible, cle=attente["jeton"])
    if _session is not None:
        _session.dernier_depot = resultat
        _session.depot_effectue_pendant_saisie = pendant_saisie
    _noter(
        "déposé" if resultat.get("done") else "dépôt refusé",
        str(resultat.get("message") or ""),
        reussi=bool(resultat.get("done")),
    )
    return resultat

envoyer_ce_qui_est_tenu

envoyer_ce_qui_est_tenu(
    objet: Any, cible: dict
) -> dict[str, Any]

Envoyer la main vers un appareil, sans question préalable.

Le chemin de « envoie ça sur mon téléphone » quand aucune question n'est en suspens. L'appel est une issue terminale : succès, refus ou erreur viennent du destinataire et consomment l'intention ; recommencer sera un nouveau geste visible, jamais une reprise silencieuse.

Source code in src/diapason/server/gestes_routes.py
def envoyer_ce_qui_est_tenu(objet: Any, cible: dict) -> dict[str, Any]:
    """Envoyer la main vers un appareil, sans question préalable.

    Le chemin de « envoie ça sur mon téléphone » quand aucune question n'est
    en suspens. L'appel est une issue terminale : succès, refus ou erreur
    viennent du destinataire et consomment l'intention ; recommencer sera un
    nouveau geste visible, jamais une reprise silencieuse.
    """
    pendant_saisie = bool(
        _session is not None and _session.moteur.etat.value == "SAISI"
    )
    resultat = _envoyer_et_consommer(objet, cible)
    if _session is not None:
        _session.dernier_depot = resultat
        _session.depot_effectue_pendant_saisie = pendant_saisie
    _noter(
        "déposé" if resultat.get("done") else "dépôt refusé",
        str(resultat.get("message") or ""),
        reussi=bool(resultat.get("done")),
    )
    return resultat

choisir_lappareil

choisir_lappareil(body: ChoixDAppareil) -> dict[str, Any]

Trancher entre les candidats, et envoyer pour de bon.

Le deviceId reçu n'est jamais cru sur parole : il doit figurer dans la liste que le serveur a lui-même mesurée en posant la question. Un client ne choisit pas une destination, il choisit PARMI celles qu'on lui a proposées — c'est ce qui empêche cette route de devenir un « envoie n'importe quoi à n'importe qui » déguisé en réponse.

Source code in src/diapason/server/gestes_routes.py
@router.post("/drop/target")
def choisir_lappareil(body: ChoixDAppareil) -> dict[str, Any]:
    """Trancher entre les candidats, et envoyer pour de bon.

    Le `deviceId` reçu n'est jamais cru sur parole : il doit figurer dans la
    liste que le serveur a lui-même mesurée en posant la question. Un client
    ne choisit pas une destination, il choisit PARMI celles qu'on lui a
    proposées — c'est ce qui empêche cette route de devenir un « envoie
    n'importe quoi à n'importe qui » déguisé en réponse.
    """
    if not session_active() or _session is None:
        raise HTTPException(status_code=409, detail="Le mode gestes n'est pas armé.")
    attente = _choix_en_attente()
    if attente is None:
        raise HTTPException(
            status_code=409,
            detail=(
                "Aucun dépôt n'attend de réponse : la question a expiré, ou "
                "la main s'est vidée entre-temps."
            ),
        )
    if body.token != attente["jeton"]:
        raise HTTPException(
            status_code=409,
            detail="Ce jeton ne correspond pas à la question posée.",
        )
    cible = next(
        (c for c in attente["candidats"] if c["deviceId"] == body.deviceId), None
    )
    if cible is None:
        raise HTTPException(
            status_code=409,
            detail="Cet appareil ne faisait pas partie des candidats proposés.",
        )
    return repondre_au_choix(attente, cible)

renoncer_au_depot

renoncer_au_depot() -> dict[str, Any]

« Laisse tomber. » Sans cette route, la seule sortie serait le silence.

Une question qui ne peut qu'expirer laisse l'utilisateur attendre quarante-cinq secondes pour apprendre qu'il ne se passera rien. Renoncer est une réponse ; elle mérite d'exister.

Source code in src/diapason/server/gestes_routes.py
@router.post("/drop/cancel")
def renoncer_au_depot() -> dict[str, Any]:
    """« Laisse tomber. » Sans cette route, la seule sortie serait le silence.

    Une question qui ne peut qu'expirer laisse l'utilisateur attendre
    quarante-cinq secondes pour apprendre qu'il ne se passera rien. Renoncer
    est une réponse ; elle mérite d'exister.
    """
    attente = _choix_en_attente()
    if attente is None:
        return {
            "cancelled": False,
            "message": "Aucun dépôt n'attendait de réponse.",
        }
    titre = attente["objet"].titre
    _oublier_la_main()
    resultat = {
        "done": False,
        "reason": "CANCELLED",
        "message": f{titre} » n'a été envoyé nulle part.",
    }
    if _session is not None:
        _session.dernier_depot = resultat
    _noter("abandonné", titre, reussi=False)
    return {"cancelled": True, **resultat}

image async

image(request: Request) -> dict[str, Any]

Une image de plus. Rend l'état APRÈS cette image.

Le corps est l'image brute (JPEG ou PNG) envoyée par l'interface. Elle n'est jamais écrite : Vision la lit en mémoire, et seuls des points en sortent.

Source code in src/diapason/server/gestes_routes.py
@router.post("/frame")
async def image(request: Request) -> dict[str, Any]:
    """Une image de plus. Rend l'état APRÈS cette image.

    Le corps est l'image brute (JPEG ou PNG) envoyée par l'interface. Elle
    n'est jamais écrite : Vision la lit en mémoire, et seuls des points en
    sortent.
    """
    from diapason.desktop.presse_papiers_spatial import attraper
    from diapason.desktop.vision_mains import (
        lateralite_de_main,
        mains_dans_les_octets,
        points_de_main,
    )

    if not session_active() or _session is None:
        raise HTTPException(
            status_code=409,
            detail="Le mode gestes n'est pas armé.",
        )
    octets = await _lire_image(request)
    if not octets:
        raise HTTPException(status_code=400, detail="Image vide.")
    if len(octets) > 4 * 1024 * 1024:
        raise HTTPException(status_code=413, detail="Image trop grande.")

    _session.vue_a = time.monotonic()
    _session.images += 1
    try:
        # UNE main, alors que Vision en lit deux — un choix, pas une limite.
        # Le moteur n'a aucune notion d'identité de main : `observer()` reçoit
        # une liste de points et rien d'autre. À deux mains il faudrait deux
        # moteurs et une règle disant laquelle agit, sans quoi la seconde main
        # de l'utilisateur — ou celle de quelqu'un qui passe — deviendrait un
        # geste. Vision rend la plus SÛRE, ce qui est le bon défaut.
        #
        # La limite que ce choix ne corrige pas, et qu'il faut connaître :
        # avec deux mains dans le champ, celle qui gagne peut CHANGER d'une
        # image à l'autre. Le moteur verrait alors la main se téléporter, et
        # comme une main perdue annule le geste (§12), le geste échoue —
        # bruyamment, ce qui vaut mieux que de déposer au hasard.
        mains = mains_dans_les_octets(octets, mains_max=1)
    except Exception as exc:  # noqa: BLE001 - une image illisible n'arrête rien
        logger.debug("image de geste illisible", exc_info=True)
        raise HTTPException(status_code=400, detail=f"Image illisible : {exc}") from exc

    main = mains[0] if mains else None
    points = points_de_main(main) if main is not None else None
    lateralite = lateralite_de_main(main) if main is not None else "unknown"
    if points:
        _session.mains_vues += 1
        _session.main_vue_a = _session.vue_a
    if points:
        from diapason.desktop.gestes_main import mesurer

        mesures = mesurer(points)
        if mesures is not None:
            _session.confiance_totale += mesures.confiance
            _session.confiance_mesures += 1
            _session.dernier_repliement = mesures.repliement
            if _session.calibration_en_cours:
                _session.echantillons.append(mesures.repliement)

    _appliquer_vocabulaire(points)

    if _session.mode == "POINTER":
        # Cette bifurcation est la frontière de sécurité : aucune ligne du
        # transfert située dessous n'est atteinte. Le serveur rend seulement
        # une intention ; l'application Tauri, titulaire de l'autorisation
        # Accessibilité, décidera si elle peut réellement l'appliquer.
        lecture = _session.pointeur.observer(points, lateralite=lateralite)
        _session.derniere_lecture_pointeur = lecture
        return {
            "state": _session.moteur.etat.value,
            "changed": False,
            "hand": bool(points),
            "frames": _session.images,
            "mode": _session.mode,
            "modeLock": _session.verrou,
            "pointer": lecture.to_dict(),
            "pendingDrop": None,
            "lastDrop": None,
            **_energie(),
        }
    position = _position_de_la_main(points)
    avant = _session.moteur.etat
    apres = _session.moteur.observer(points)
    # Une fois le poing saisi, chaque image déplace le surlignage. Cette
    # lecture reste séparée de la reconnaissance de pose : bouger ne peut ni
    # fabriquer un poing ni ouvrir une main.
    if apres.value == "SAISI" and apres is avant:
        if _session.depot_en_attente is None and _session.dernier_attrape is not None:
            _preparer_selecteur(_session.dernier_attrape, position)
        else:
            _deplacer_selecteur(position)
    if apres is not avant:
        _session.derniers_etats.append(apres.value)
        del _session.derniers_etats[:-10]
        if apres.value == "SAISI":
            _session.saisies += 1
            _session.depot_effectue_pendant_saisie = False
            # Refermer le poing, c'est RECOMMENCER : une question restée sans
            # réponse tombe avec l'objet qu'elle désignait. Sans cela, le
            # jeton survivrait à son objet et désignerait la saisie suivante.
            _session.depot_en_attente = None
            # Le geste exprime une INTENTION ; il ne transporte rien. Fermer
            # le poing désigne ce que l'écran affiche et le retient.
            _session.dernier_attrape = attraper()
            _noter(
                "attrapé",
                _session.dernier_attrape.titre
                if _session.dernier_attrape is not None
                else "rien — aucun écran de Diapason n'était ouvert",
                reussi=_session.dernier_attrape is not None,
            )
            _preparer_selecteur(_session.dernier_attrape, position)
        elif apres.value == "RELACHE":
            _session.relachements += 1
            if _session.depot_effectue_pendant_saisie:
                # Le clic ou la voix a déjà envoyé pendant que le poing était
                # fermé. L'ouverture termine seulement le geste physique ; le
                # vrai résultat reste visible et n'est pas écrasé par « rien ».
                _session.depot_effectue_pendant_saisie = False
            else:
                # DANS UN FIL, et ce n'est pas une précaution de style. Cette
                # route est `async def`, donc elle s'exécute SUR la boucle
                # d'événements — et un fichier peut attendre deux minutes le
                # consentement de l'autre appareil.
                session = _session
                resultat = await asyncio.to_thread(_deposer)
                if _session is session:
                    session.dernier_depot = resultat
                    _noter(
                        "déposé" if resultat.get("done") else "dépôt refusé",
                        str(resultat.get("message") or ""),
                        reussi=bool(resultat.get("done")),
                    )
        elif apres.value in ("PERDU", "ANNULE"):
            _session.pertes += 1
            # Une main perdue au milieu d'un geste ne laisse pas un objet
            # « tenu » que personne ne tient (§12) — ni une question en
            # suspens sur cet objet.
            _oublier_la_main()
    return {
        "state": apres.value,
        "changed": apres is not avant,
        "hand": bool(points),
        "frames": _session.images if _session is not None else 0,
        "mode": _session.mode if _session is not None else "TRANSFER",
        "modeLock": _session.verrou if _session is not None else "AUTO",
        # Le sondage reste le filet lent ; la réponse d'image transporte le
        # surlignage à 12 im/s pour qu'il colle réellement au poing.
        "pendingDrop": _choix_public(),
        "lastDrop": _session.dernier_depot if _session is not None else None,
        **_energie(),
    }

appliquer

appliquer(body: Calibration) -> dict[str, Any]

Poser les seuils dérivés des deux mesures, et les garder.

Source code in src/diapason/server/gestes_routes.py
@router.post("/calibrate/apply")
def appliquer(body: Calibration) -> dict[str, Any]:
    """Poser les seuils dérivés des deux mesures, et les garder."""
    from dataclasses import asdict

    from diapason.desktop.gestes_main import enregistrer_seuils, seuils_calibres

    try:
        seuils = seuils_calibres(body.ouverte, body.fermee)
    except ValueError as exc:
        raise HTTPException(status_code=422, detail=str(exc)) from exc
    enregistrer_seuils(seuils)
    if _session is not None:
        _session.moteur.seuils = seuils
        _session.moteur.reinitialiser()
    return {"calibrated": True, "thresholds": asdict(seuils)}

calibrer

calibrer(pose: str) -> dict[str, Any]

Mesurer une pose — « ouverte » puis « fermee ».

La calibration ne devine pas : elle enregistre ce que la main de CETTE personne produit réellement, et place les seuils entre les deux poses.

Source code in src/diapason/server/gestes_routes.py
@router.post("/calibrate/{pose}")
def calibrer(pose: str) -> dict[str, Any]:
    """Mesurer une pose — « ouverte » puis « fermee ».

    La calibration ne devine pas : elle enregistre ce que la main de CETTE
    personne produit réellement, et place les seuils entre les deux poses.
    """
    if pose not in ("ouverte", "fermee"):
        raise HTTPException(status_code=400, detail="Pose inconnue.")
    if not session_active() or _session is None:
        raise HTTPException(status_code=409, detail="Le mode gestes n'est pas armé.")
    _session.calibration_en_cours = pose
    _session.echantillons = []
    return {"measuring": pose}

finir_la_mesure

finir_la_mesure(pose: str) -> dict[str, Any]

Clore une mesure et rendre sa moyenne — ou avouer qu'il n'y a rien.

Source code in src/diapason/server/gestes_routes.py
@router.post("/calibrate/{pose}/stop")
def finir_la_mesure(pose: str) -> dict[str, Any]:
    """Clore une mesure et rendre sa moyenne — ou avouer qu'il n'y a rien."""
    if not session_active() or _session is None:
        raise HTTPException(status_code=409, detail="Le mode gestes n'est pas armé.")
    echantillons = list(_session.echantillons)
    _session.calibration_en_cours = ""
    _session.echantillons = []
    if len(echantillons) < 5:
        raise HTTPException(
            status_code=422,
            detail=(
                "Je n'ai pas assez vu ta main. Rapproche-la de la caméra, "
                "éclaire-la, et recommence."
            ),
        )
    # La médiane, pas la moyenne : une image aberrante ne doit pas
    # déplacer un seuil que l'on gardera.
    tries = sorted(echantillons)
    mediane = tries[len(tries) // 2]
    return {"pose": pose, "value": round(mediane, 3), "samples": len(echantillons)}