From fce468cf76dbee59e35d123d2c04ebe536bae8b1 Mon Sep 17 00:00:00 2001 From: matthieu Date: Fri, 9 Jan 2026 15:32:10 +0000 Subject: [PATCH] feat(api): FastAPI bridge for pont bascule via serial + Tailscale access - Add FastAPI service exposing /health, /last, /send/esclave, /send/dsd, /send/custom - Implement SerialBridge aligned with legacy Tkinter behavior (open delay 2s, post-write 0.5s, read in_waiting once) - Enforce single in-flight serial request (non-blocking lock, returns 409 BUSY) - Add environment-based serial configuration (.env + systemd EnvironmentFile) - Document installation, systemd service, and Tailscale usage (direct IP and tailscale serve) --- README.md | 318 +++++++++++++++++- __init__.py => app/__init__.py | 0 app/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 147 bytes app/__pycache__/main.cpython-313.pyc | Bin 0 -> 4433 bytes app/__pycache__/mock_bridge.cpython-313.pyc | Bin 0 -> 1423 bytes app/__pycache__/serial_bridge.cpython-313.pyc | Bin 0 -> 5635 bytes app/main.py | 83 +++++ main.py => app/main.py.old | 0 mock_bridge.py => app/mock_bridge.py | 0 app/serial_bridge.py | 129 +++++++ serial_bridge.py => app/serial_bridge.py.old | 0 test_api.py => app/test_api.py | 0 12 files changed, 528 insertions(+), 2 deletions(-) rename __init__.py => app/__init__.py (100%) create mode 100644 app/__pycache__/__init__.cpython-313.pyc create mode 100644 app/__pycache__/main.cpython-313.pyc create mode 100644 app/__pycache__/mock_bridge.cpython-313.pyc create mode 100644 app/__pycache__/serial_bridge.cpython-313.pyc create mode 100644 app/main.py rename main.py => app/main.py.old (100%) rename mock_bridge.py => app/mock_bridge.py (100%) create mode 100644 app/serial_bridge.py rename serial_bridge.py => app/serial_bridge.py.old (100%) rename test_api.py => app/test_api.py (100%) diff --git a/README.md b/README.md index 281fd74..10738b0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,317 @@ -# Pont bascule API +# Pont Bascule Connector (Raspberry Pi) — FastAPI + Serial + Tailscale -Projet FastAPI pour Raspberry Pi. +API HTTP (FastAPI) qui pilote un pont bascule connecté en USB (port série) sur Raspberry Pi. +Objectif : permettre à une application/serveur distant d’interroger le pont bascule via réseau (Tailscale), +avec une contrainte stricte : **1 requête série à la fois**. + +--- + +## Fonctionnement global + +Client (PC / serveur / app) --HTTP--> Raspberry Pi (FastAPI) +| +| 1 appel à la fois (lock) +v +Port série (/dev/ttyUSB0) +| +v +Pont bascule + +yaml +Copier le code + +Accès distant : +- via IP Tailscale `100.x.x.x` (VPN mesh) +- optionnellement via `tailscale serve` pour exposer l’API sur le port 80 sans `:8000` + +--- + +## Prérequis + +### Raspberry Pi +- Raspberry Pi OS (Lite recommandé) +- Python 3 +- Accès SSH +- Tailscale installé et connecté + +### Matériel +- Pont bascule branché en USB (ou via adaptateur USB↔RS232/RS485 selon le matériel) + +--- + +## Installation (Raspberry Pi) + +### 1) Récupérer le projet +```bash +cd ~ +git clone pont-bascule-connector +cd pont-bascule-connector +2) Environnement Python +Deux options : + +Option A : venv global (recommandé si déjà en place) + +bash +Copier le code +python3 -m venv /home/malio/venv +source /home/malio/venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +Option B : venv dans le projet + +bash +Copier le code +python3 -m venv ./venv +source ./venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt +Configuration série (.env) +Créer un fichier .env à la racine du projet : + +bash +Copier le code +cd ~/pont-bascule-connector +nano .env +Exemple : + +env +Copier le code +SERIAL_PORT=/dev/ttyUSB0 +SERIAL_BAUDRATE=9600 +SERIAL_TIMEOUT_S=1.0 +SERIAL_OPEN_DELAY_S=2.0 +SERIAL_POST_WRITE_DELAY_S=0.5 +Notes importantes +SERIAL_OPEN_DELAY_S=2.0 et SERIAL_POST_WRITE_DELAY_S=0.5 reproduisent le comportement du script Tkinter historique : + +attente 2s après ouverture du port + +envoi trame + +attente 0.5s + +lecture une seule fois de in_waiting + +Si ton port est /dev/ttyACM0, adapte SERIAL_PORT. + +Droits port série (dialout) +Vérifier les devices : + +bash +Copier le code +ls /dev/ttyUSB* 2>/dev/null || true +ls /dev/ttyACM* 2>/dev/null || true +dmesg | tail -n 30 +Ajouter l’utilisateur au groupe dialout : + +bash +Copier le code +sudo usermod -aG dialout malio +sudo reboot +Lancer l’API (mode manuel) +bash +Copier le code +source /home/malio/venv/bin/activate # ou ./venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8000 +Test local : + +bash +Copier le code +curl http://127.0.0.1:8000/health +Lancer l’API au démarrage (systemd) +Créer le service : + +bash +Copier le code +sudo nano /etc/systemd/system/pont-bascule-api.service +Contenu (adapter les chemins si nécessaire) : + +ini +Copier le code +[Unit] +Description=Pont bascule API (FastAPI) +After=network-online.target tailscaled.service +Wants=network-online.target + +[Service] +User=malio +WorkingDirectory=/home/malio/pont-bascule-connector +EnvironmentFile=/home/malio/pont-bascule-connector/.env +ExecStart=/home/malio/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target +Activer et démarrer : + +bash +Copier le code +sudo systemctl daemon-reload +sudo systemctl enable --now pont-bascule-api +sudo systemctl status pont-bascule-api --no-pager +Logs : + +bash +Copier le code +journalctl -u pont-bascule-api -f +API — Endpoints +Santé +GET /health + +Exemple : + +bash +Copier le code +curl http://127.0.0.1:8000/health +Dernière réponse (debug) +GET /last + +Envoi trame “Esclave” +POST /send/esclave + +bash +Copier le code +curl -X POST http://127.0.0.1:8000/send/esclave +Envoi trame “DSD” +POST /send/dsd + +bash +Copier le code +curl -X POST http://127.0.0.1:8000/send/dsd +Envoi trame custom (hex) +POST /send/custom + +bash +Copier le code +curl -X POST http://127.0.0.1:8000/send/custom \ + -H "Content-Type: application/json" \ + -d '{"hex":"01 0D 0A"}' +Format de réponse +La réponse renvoie : + +response_ascii : texte décodé ASCII (souvent le poids + infos) + +response_hex : trame brute + +duration_ms : durée de l’opération + +error : message d’erreur si problème + +Exemple (indicatif) : + +json +Copier le code +{ + "ok": true, + "mode": "serial", + "port": "/dev/ttyUSB0", + "baudrate": 9600, + "request_hex": "01 0D 0A", + "response_hex": "30 30 31 32 2E 33 34 20 6B 67", + "response_ascii": "0012.34 kg", + "duration_ms": 2600, + "error": null +} +Contrainte “1 appel à la fois” (important) +Le port série ne doit pas être utilisé en concurrence. +Si une requête est déjà en cours, l’API renvoie : + +HTTP 409 + +message BUSY + +Accès à distance via Tailscale +1) Vérifier Tailscale +Sur le Raspberry : + +bash +Copier le code +tailscale status +tailscale ip -4 +Exemple : IP Tailscale du Pi 100.122.43.54. + +2) Appeler l’API via Tailscale (simple) +bash +Copier le code +curl http://100.122.43.54:8000/health +curl -X POST http://100.122.43.54:8000/send/esclave +3) Option recommandé : exposer sans port avec tailscale serve +Sur le Raspberry : + +bash +Copier le code +sudo tailscale serve --http=80 localhost:8000 +sudo tailscale serve status +Ensuite : + +bash +Copier le code +curl http://100.122.43.54/health +curl -X POST http://100.122.43.54/send/esclave +4) SSH via Tailscale +bash +Copier le code +tailscale ssh malio@raspberrypi +Dépannage rapide +API down +bash +Copier le code +sudo systemctl status pont-bascule-api --no-pager +journalctl -u pont-bascule-api -n 100 --no-pager +Port série introuvable +bash +Copier le code +ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null +dmesg | tail -n 50 +Permission refusée +bash +Copier le code +groups +# dialout doit apparaître +Pas de réponse +vérifier le baudrate + +vérifier le port /dev/ttyUSB0 vs /dev/ttyACM0 + +augmenter SERIAL_POST_WRITE_DELAY_S (ex: 1.0) si la réponse arrive lentement + +Sécurité recommandée (à faire) +Exposer l’API uniquement via Tailscale : + +faire écouter uvicorn en local seulement (--host 127.0.0.1) + +utiliser tailscale serve comme reverse proxy + +Ajouter un token API si besoin (header Authorization) + +Ajouter une route /weight qui parse la chaîne response_ascii et renvoie weight + unit + ticket proprement. + +yaml +Copier le code + +--- + +## 3) Autres choses que je te recommande (vraiment utiles) + +1) **Sécuriser l’API** +Aujourd’hui tu exposes `0.0.0.0:8000` → accessible depuis le LAN. +Si tu veux “Tailscale only” (recommandé) : +- Dans systemd : `--host 127.0.0.1` +- Et tu actives `tailscale serve --http=80 localhost:8000` + +2) **Ajouter une route `/weight`** +Tu m’envoies 1 exemple de `response_ascii` (exact) et je te code un parseur robuste qui sort : +```json +{"weight": 12.34, "unit": "kg", "ticket": "000123", "raw": "..."} +Port série stable (udev) +Si ton port passe parfois de /dev/ttyUSB0 à /dev/ttyUSB1, on peut créer une règle udev pour avoir un nom fixe, genre /dev/pontbascule. + +Healthcheck matériel +Ajouter une route GET /serial/info qui vérifie : + +port existe + +user a accès + +bridge non-busy diff --git a/__init__.py b/app/__init__.py similarity index 100% rename from __init__.py rename to app/__init__.py diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b676ceac937a6f7e280d584402c837dbc8877bf2 GIT binary patch literal 147 zcmey&%ge<81fjnYGWme?V-N=h7@>^MEI`IohI9r^M!%H|MNB~6XOPq_BmIp0+*JMC z#GK50{et|w65XW4;^fktRNdtKyu8%plKdk5#DW6-`1s7c%#!$cy@JYH95%W6DL^H5 WMXW$0Kvook7$2D#85xV1fh+(`i6Ytn literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5dba64cd1547605861e82573997c2a5edb48c0c GIT binary patch literal 4433 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 b&0)4W2RdWy5iC4{6HkqktZ@j4GPAz`->D?K literal 0 HcmV?d00001 diff --git a/app/__pycache__/serial_bridge.cpython-313.pyc b/app/__pycache__/serial_bridge.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9880266faa3c052b9d0f9ef9ebd55364f5a536de GIT binary patch literal 5635 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 D!=6Ja literal 0 HcmV?d00001 diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..5326b06 --- /dev/null +++ b/app/main.py @@ -0,0 +1,83 @@ +import os +import time +import socket +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +from .serial_bridge import SerialBridge, SerialConfig + +app = FastAPI(title="Pont bascule API", version="1.3") + +TRAME_ESCLAVE = b"\x01\x0D\x0A" +TRAME_DSD = b"\x01\x10\x39\x39\x4D\x0D\x0A" + + +def env(name: str, default: str) -> str: + return os.getenv(name, default) + + +def hex2b(s: str) -> bytes: + s = s.strip().replace("0x", "").replace(",", " ") + parts = [p for p in s.split() if p] + return bytes(int(p, 16) for p in parts) + + +cfg = SerialConfig( + port=env("SERIAL_PORT", "/dev/ttyUSB0"), + baudrate=int(env("SERIAL_BAUDRATE", "9600")), + timeout_s=float(env("SERIAL_TIMEOUT_S", "1.0")), + open_delay_s=float(env("SERIAL_OPEN_DELAY_S", "2.0")), # ✅ comme Tkinter + post_write_delay_s=float(env("SERIAL_POST_WRITE_DELAY_S", "0.5")), # ✅ +) +bridge = SerialBridge(cfg) + + +class CustomReq(BaseModel): + hex: str + + +@app.get("/health") +def health(): + return { + "ok": True, + "mode": "serial", + "busy": bridge.busy(), + "hostname": socket.gethostname(), + "timestamp": time.time(), + "port": cfg.port, + "baudrate": cfg.baudrate, + } + + +@app.get("/last") +def last(): + return bridge.last() + + +@app.post("/send/esclave") +def send_esclave(): + res = bridge.send_and_read_once(TRAME_ESCLAVE) + if res.get("busy"): + raise HTTPException(status_code=409, detail=res["error"]) + return res + + +@app.post("/send/dsd") +def send_dsd(): + res = bridge.send_and_read_once(TRAME_DSD) + if res.get("busy"): + raise HTTPException(status_code=409, detail=res["error"]) + return res + + +@app.post("/send/custom") +def send_custom(req: CustomReq): + try: + payload = hex2b(req.hex) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid hex: {e}") + + res = bridge.send_and_read_once(payload) + if res.get("busy"): + raise HTTPException(status_code=409, detail=res["error"]) + return res diff --git a/main.py b/app/main.py.old similarity index 100% rename from main.py rename to app/main.py.old diff --git a/mock_bridge.py b/app/mock_bridge.py similarity index 100% rename from mock_bridge.py rename to app/mock_bridge.py diff --git a/app/serial_bridge.py b/app/serial_bridge.py new file mode 100644 index 0000000..048efd4 --- /dev/null +++ b/app/serial_bridge.py @@ -0,0 +1,129 @@ +import time +import threading +from dataclasses import dataclass +from typing import Dict, Any + + +def b2hex(b: bytes) -> str: + return " ".join(f"{x:02X}" for x in b) + + +@dataclass +class SerialConfig: + port: str + baudrate: int = 9600 + timeout_s: float = 1.0 + open_delay_s: float = 2.0 # ✅ comme ton script + post_write_delay_s: float = 0.5 # ✅ comme ton script + + +class SerialBridge: + """ + Adapté au comportement du script Tkinter: + - open port + - wait 2s + - write + - wait 0.5s + - read once (in_waiting) + - ascii decode ignore + + lock non bloquant => 1 appel à la fois + """ + + def __init__(self, cfg: SerialConfig): + self.cfg = cfg + self._lock = threading.Lock() + self._last: Dict[str, Any] = { + "ts": None, + "request_hex": None, + "response_hex": None, + "response_ascii": None, + "error": None, + "duration_ms": None, + } + + def busy(self) -> bool: + return self._lock.locked() + + def last(self) -> Dict[str, Any]: + return { + **self._last, + "mode": "serial", + "port": self.cfg.port, + "baudrate": self.cfg.baudrate, + "busy": self.busy(), + } + + def send_and_read_once(self, payload: bytes) -> Dict[str, Any]: + if not self._lock.acquire(blocking=False): + return { + "ok": False, + "busy": True, + "mode": "serial", + "port": self.cfg.port, + "baudrate": self.cfg.baudrate, + "request_hex": b2hex(payload), + "response_hex": None, + "response_ascii": None, + "duration_ms": 0, + "error": "BUSY: une requête série est déjà en cours", + } + + t0 = time.time() + try: + import serial # pyserial + + with serial.Serial(self.cfg.port, self.cfg.baudrate, timeout=self.cfg.timeout_s) as ser: + time.sleep(self.cfg.open_delay_s) # ✅ 2s + + ser.write(payload) + time.sleep(self.cfg.post_write_delay_s) # ✅ 0.5s + + n = getattr(ser, "in_waiting", 0) + if n and n > 0: + data = ser.read(n) + else: + data = b"" + + texte = data.decode("ascii", errors="ignore").strip() if data else None + + result = { + "ok": bool(data), + "busy": False, + "mode": "serial", + "port": self.cfg.port, + "baudrate": self.cfg.baudrate, + "request_hex": b2hex(payload), + "response_hex": b2hex(data) if data else None, + "response_ascii": texte, + "duration_ms": int((time.time() - t0) * 1000), + "error": None if data else "Pas de réponse reçue.", + } + + self._last.update( + { + "ts": time.time(), + "request_hex": result["request_hex"], + "response_hex": result["response_hex"], + "response_ascii": result["response_ascii"], + "error": result["error"], + "duration_ms": result["duration_ms"], + } + ) + return result + + except Exception as e: + return { + "ok": False, + "busy": False, + "mode": "serial", + "port": self.cfg.port, + "baudrate": self.cfg.baudrate, + "request_hex": b2hex(payload), + "response_hex": None, + "response_ascii": None, + "duration_ms": int((time.time() - t0) * 1000), + "error": str(e), + } + + finally: + self._lock.release() diff --git a/serial_bridge.py b/app/serial_bridge.py.old similarity index 100% rename from serial_bridge.py rename to app/serial_bridge.py.old diff --git a/test_api.py b/app/test_api.py similarity index 100% rename from test_api.py rename to app/test_api.py