Skip to content

envoi_fichier

envoi_fichier

Envoyer un fichier à un appareil de la flotte — le côté qui pousse.

Spatial Mesh, phase 3 — 25 août 2026. Le pendant de files_routes. Il annonce, négocie une session, chiffre morceau par morceau, et ne dit « arrivé » que quand le RÉCEPTEUR l'a dit.

Classes

EnvoiRefuse

Bases: RuntimeError

Le transfert n'a pas eu lieu, et le message dit pourquoi.

Functions:

envoyer_fichier

envoyer_fichier(
    chemin: Path | str,
    device: dict,
    *,
    poster: Any = None,
    progression: Any = None,
    attente: Any = None,
    dormir: Any = sleep,
    maintenant: Any = monotonic,
) -> Envoi

Pousser un fichier vers un appareil déjà appairé.

device est la ligne du registre : il porte l'adresse et le niveau de confiance. La vérification que l'on a le DROIT de le joindre reste celle du transport — un seul endroit décide de ce qui sort de la machine.

Source code in src/diapason/mesh/envoi_fichier.py
def envoyer_fichier(
    chemin: Path | str,
    device: dict,
    *,
    poster: Any = None,
    progression: Any = None,
    attente: Any = None,
    dormir: Any = time.sleep,
    maintenant: Any = time.monotonic,
) -> Envoi:
    """Pousser un fichier vers un appareil déjà appairé.

    ``device`` est la ligne du registre : il porte l'adresse et le niveau de
    confiance. La vérification que l'on a le DROIT de le joindre reste celle
    du transport — un seul endroit décide de ce qui sort de la machine.
    """

    from diapason.mesh.coffre import cle_de_session, nouvelle_demi_cle
    from diapason.mesh.identity import device_identity, owner_id
    from diapason.mesh.signed import sign_payload
    from diapason.mesh.transfert import decrire_fichier, lire_morceaux
    from diapason.mesh.transport import assert_may_reach_device

    chemin = Path(chemin).expanduser()
    if not chemin.is_file():
        raise EnvoiRefuse(f"{chemin} n'est pas un fichier.")
    adresse = str(device.get("address") or "").strip()
    if not adresse:
        raise EnvoiRefuse("L'adresse de cet appareil est inconnue.")
    # Le même garde que pour les commandes : ce qui sort de la machine passe
    # par une seule porte, jamais par une seconde écrite pour l'occasion.
    assert_may_reach_device(device, adresse)

    manifeste = decrire_fichier(chemin)
    demi = nouvelle_demi_cle()
    offre = sign_payload(
        {
            "version": 1,
            "ownerId": owner_id(),
            "deviceId": device_identity().device_id,
            "sentAtMs": int(time.time() * 1000),
            "sessionNonce": secrets.token_urlsafe(12),
            "manifest": manifeste.to_dict(),
            "ephemeralPublicKey": demi.publique_b64,
        },
        _champs(),
    )

    base = adresse.rstrip("/")
    envoyer = poster or _poster
    reponse = _verifier_reponse(
        envoyer(f"{base}/v1/mesh/files/offer", offre, None), device
    )

    if reponse.get("status") == "ALREADY_PRESENT":
        return Envoi(
            statut="ALREADY_PRESENT",
            message=str(reponse.get("userSafeMessage") or "Déjà présent."),
            chemin_distant=str(reponse.get("path") or ""),
        )

    # Rolling-upgrade compatibility for receivers older than 28 August 2026.
    # Current versions answer ACCEPTED immediately to a trusted peer, but
    # removing this path would require every device in a fleet to update at
    # exactly the same time.
    if reponse.get("status") == "PENDING":
        request_id = str(reponse.get("requestId") or "")
        request_token = str(reponse.get("requestToken") or "")
        if not request_id or not request_token:
            raise EnvoiRefuse("La demande d'accord du destinataire est incomplète.")
        message_attente = str(
            reponse.get("userSafeMessage")
            or "En attente de l'accord sur l'appareil destinataire."
        )
        if attente is not None:
            try:
                attente(message_attente)
            except Exception:  # noqa: BLE001 - l'affichage n'arrête pas l'envoi
                pass

        try:
            delai_accord = int(reponse.get("expiresInS") or 120)
        except (TypeError, ValueError, OverflowError):
            delai_accord = 120
        # La réponse est signée, mais le récepteur ne décide pas combien de
        # temps l'émetteur garde une commande bloquée. Deux minutes est aussi
        # le TTL côté réception : plus long ne pourrait que sonder un cadavre.
        limite = maintenant() + min(max(delai_accord, 1), 120)
        while reponse.get("status") == "PENDING":
            restant = limite - maintenant()
            if restant <= 0:
                return Envoi(
                    statut="EXPIRED",
                    message="Aucune réponse n'a été donnée — rien n'a été envoyé.",
                )
            try:
                pause = float(reponse.get("pollAfterMs") or 2000) / 1000
            except (TypeError, ValueError, OverflowError):
                pause = 2.0
            dormir(min(max(pause, 0.25), 5.0, restant))
            reponse = _verifier_reponse(
                envoyer(
                    f"{base}/v1/mesh/files/requests/{request_id}/state",
                    {},
                    request_token,
                ),
                device,
            )
            rendu_id = str(reponse.get("requestId") or "")
            if rendu_id and rendu_id != request_id:
                raise EnvoiRefuse("Le destinataire a répondu à une autre demande.")

    statut = str(reponse.get("status") or "")
    if statut in {"DENIED", "EXPIRED"}:
        return Envoi(
            statut=statut,
            message=str(
                reponse.get("userSafeMessage")
                or "Le destinataire n'a pas accepté ce fichier."
            ),
        )
    if statut != "ACCEPTED":
        raise EnvoiRefuse(
            f"Réponse inattendue du destinataire : {statut or 'sans statut'}."
        )

    session_id = str(reponse.get("sessionId") or "")
    jeton = str(reponse.get("uploadToken") or "")
    if not session_id or not jeton:
        raise EnvoiRefuse("L'appareil n'a pas ouvert de session de transfert.")
    cle = cle_de_session(demi, str(reponse.get("ephemeralPublicKey") or ""), session_id)

    from diapason.mesh.coffre import sceller

    envoyes = 0
    for index, bloc in lire_morceaux(chemin):
        envoyer(
            f"{base}/v1/mesh/files/{session_id}/chunk?index={index}",
            sceller(cle, index, bloc),
            jeton,
        )
        envoyes += 1
        if progression is not None:
            try:
                progression(envoyes, manifeste.morceaux)
            except Exception:  # noqa: BLE001 - l'affichage n'arrête pas l'envoi
                pass

    fin = _verifier_reponse(
        envoyer(f"{base}/v1/mesh/files/{session_id}/finish", {}, jeton), device
    )
    if str(fin.get("sessionId") or "") != session_id:
        raise EnvoiRefuse("Le destinataire a confirmé une autre session.")
    # La phrase vient du récepteur : lui seul a vérifié l'empreinte. Depuis le
    # 26 août 2026, sa confirmation est signée elle aussi ; sinon un voisin
    # pouvait forger COMPLETE après avoir jeté le dernier morceau.
    return Envoi(
        statut=str(fin.get("status") or "?"),
        message=str(fin.get("userSafeMessage") or ""),
        chemin_distant=str(fin.get("path") or ""),
        octets=int(fin.get("bytes") or 0),
        morceaux=envoyes,
    )