From c6d9d8cbf4582ba9de1a75d1501073169714644f Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Fri, 9 Jan 2026 12:52:51 +0000 Subject: [PATCH 1/6] init: Add pont-bascule-api project --- README.md | 3 +++ requirements.txt | 4 ++++ 2 files changed, 7 insertions(+) create mode 100644 README.md create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..281fd74 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Pont bascule API + +Projet FastAPI pour Raspberry Pi. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f05ceef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +pydantic +pyserial From ee2594b9cf5e609a3d18ca34c2c101390981ad61 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Fri, 9 Jan 2026 12:53:14 +0000 Subject: [PATCH 2/6] Add pont-bascule-api project --- __init__.py | 1 + main.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++ mock_bridge.py | 18 +++++++++++++++ serial_bridge.py | 26 +++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 __init__.py create mode 100644 main.py create mode 100644 mock_bridge.py create mode 100644 serial_bridge.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b9d56a4 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# app package diff --git a/main.py b/main.py new file mode 100644 index 0000000..9c05c5b --- /dev/null +++ b/main.py @@ -0,0 +1,59 @@ +import os +import time +import socket +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel + +from .serial_bridge import SerialBridge, SerialConfig, hex2b +from .mock_bridge import MockBridge + +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) + +APP_MODE = env("APP_MODE", "mock").strip().lower() + +app = FastAPI(title="Pont bascule API", version="1.2") + +if APP_MODE == "serial": + cfg = SerialConfig( + port=env("SERIAL_PORT", "/dev/ttyUSB0"), + baudrate=int(env("SERIAL_BAUDRATE", "9600")), + ) + bridge = SerialBridge(cfg) +else: + cfg = None + bridge = MockBridge() + +class CustomReq(BaseModel): + hex: str + +class EchoReq(BaseModel): + msg: str + +@app.get("/health") +def health(): + return { + "ok": True, + "mode": APP_MODE, + "busy": bridge.busy(), + "hostname": socket.gethostname(), + "timestamp": time.time(), + } + +@app.get("/test/ping") +def test_ping(): + return {"pong": True} + +@app.post("/test/echo") +def test_echo(req: EchoReq): + return {"echo": req.msg} + +@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 diff --git a/mock_bridge.py b/mock_bridge.py new file mode 100644 index 0000000..79ba3cc --- /dev/null +++ b/mock_bridge.py @@ -0,0 +1,18 @@ +import threading +import time + +class MockBridge: + def __init__(self): + self._lock = threading.Lock() + + def busy(self): + return self._lock.locked() + + def send_and_read_once(self, payload: bytes): + if not self._lock.acquire(blocking=False): + return {"busy": True, "error": "BUSY"} + try: + time.sleep(0.1) + return {"ok": True, "response": "OK"} + finally: + self._lock.release() diff --git a/serial_bridge.py b/serial_bridge.py new file mode 100644 index 0000000..7cc1715 --- /dev/null +++ b/serial_bridge.py @@ -0,0 +1,26 @@ +import threading +from dataclasses import dataclass + +def hex2b(s: str) -> bytes: + return bytes(int(x, 16) for x in s.split()) + +@dataclass +class SerialConfig: + port: str + baudrate: int = 9600 + +class SerialBridge: + def __init__(self, cfg: SerialConfig): + self.cfg = cfg + self._lock = threading.Lock() + + def busy(self): + return self._lock.locked() + + def send_and_read_once(self, payload: bytes): + if not self._lock.acquire(blocking=False): + return {"busy": True, "error": "BUSY"} + try: + return {"ok": True, "request": payload.hex()} + finally: + self._lock.release() From fce468cf76dbee59e35d123d2c04ebe536bae8b1 Mon Sep 17 00:00:00 2001 From: matthieu Date: Fri, 9 Jan 2026 15:32:10 +0000 Subject: [PATCH 3/6] 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 zcmcf_TWlN0agTS$w?sWCnb!04uoGI*!w<45F2Vp+1%=t3lc)G#OVMAM<}=)GfO z2S{P0Kr6QiEG024)gKnx0I~9u^v}PAP#|dnRD|4!xVWi+=A-^&BB4f`pU&LzD2cJ# zBxth)XJ>bIXJ=<;W@m4$tjtTG+^Lk3y#Yf0h7G&$1!PSJPXZbH;bkp#$3 z$W>k-XF#1kpeC68>;wa{vORmA8(tuQIl6ib=7NTM7X)K}_gQsP?w*uVNwqtzDthO* zq{T8Rxih9JiX78b+AXEi-I`33QYt!5lktSymA+_r4kctozL2IzjL`U=v+{+mLn$>T zrL-emMG6Q-v><@HRrpeT}#}qH6W1~0=uB9u&g}?;3^p- zXe-nqGL+px#<($FbUXwUk)_Tt7qmFD#Z&i~2UN8tjv*n}ygg;zn z_(fAU$5mw_nYil%mIjwrsh+&F15o$4l!;SGmlHsMl0RyA^yH+hX7s35=p&CCel;yC z(YTzFE*862j~Z2JRnwy{)1)rjMCu1F!?3P98dao8IT|&*(deWaheHG0AB~>RNT~uP z5RFbGsivorima$WW;LA}&?_m5s!MuORkUc7ZU>eIn^bgKhZsMRQYD?%15O*Tqb3^F zaC9sx={il0XLK2-p(GT~*E}s+ix>jbfz|`y7i862_OpRpFm&nERBV|G<+jyb8kou~ zb9K48#!ExfJC?b|oY1z!)z0#ta%~nfWU=KW(JZj6odvMqknn~5_Q@VIjs7lfH1j?H zZ+~1$>x&D5lo4X;B%ZZADJ!}V&j?zKCeym`%uAq5nI80Z3V5&rjsg5-DX9y4G{o>o z?Vg^lUhJc?6cp*PaU?Kx^E3?NfcKn5MS*GzZU|Ih@KMwRR+6S;Sk~m!ggLel+U->P1jy{^M&P_9aG%v-qotwTzzw{LC7_= z=4!SB{$-hKr+12b!)qC>3#M<0)Tl^P$&UhpQF;XG(lSz1;s(XO2O$14!(j*A2PDIZ zdz9k~3`KUEV1pi+0-ncwk||R7k84eZbYYb7lk>4 zgQzFd5R5e=7(E0Smimc-v3zQN8k!$L{{WLgGINqXb4B{uj~7b#Ou1GjAAG^ON6oW z64Tk?f)rg{-gYvImw;JDikRI)Al0%@P;oR7kr&CJOJO7INae;D8~cCA{{Ws<_L>{D z=sv(Yh{A2!4+i{_(u#I^Ki%P-RZ?ty2_(Xe;0;?+HZ&@1*5Y?r6>sa3va#xDqfLsy z)h6=2g;=BT5#F{Q37jXwi~Pa236Vf6AZfEbYom_vHdn>@ltc6?PAlNsNwPqmbrv}` z0T<^;%f@UQ6MdpzEIZ&BX|sDaYTHVlf)q`0jY8sNm59?`8EpIqv+&5qZovLG`9M(` zlr!QQ#XQg~o2=$PdO^g|37NTA-U-==y|YcLK~xn9@D8AFDQ>VeTdnp&P>*zMY823K zy}nzlz(gqG7Ap&NRiUmf)IqGpny%tr1sDyId~;DU()rC!`y>U@PFlz##NI$0()tO9 zK$?jFzbBdDy2ETUnP6JTXqU~!MWi^X_z8G(xgl+gL7JL)h8_j6Dc8PnQL4ae*df0dhAXzPNA>l1s>GV`_$KVXxs(UovlA`Z%)LxQ&q)#j~Y&pHkEf z#YX|G?JB&04DUvwYi1+{Z{>eZE6atUPBRnKl7ENjmBIJlk0cFJVSz3#nCT-3x%h5I z?xH2j;Y!w;=q};yRvP7!V&^kSDwn1Q_`)<0y(l@3917A=xn)#o+M6gTd?a1z-W68njGGHyByN&n7~#Seb5>CM$7x z;l-mUR}^0qM)Q{Z@S6krfRFYX*ubW2u_YU5x!JoEX!$g-d$qP6a-CdAC}cc0%I3-- z8@_RB?$k;{XSSj9PILSGv)6x`6WVjFJ9EuKZu_Y(Yka}-Jn@y4uh$Vsp`QQc^S>IM za^xKcJT0I3x35-iyVf|@aldEq+M)UpGADcO`THiE%;$97@`Z4p55P5<* z%}k%THaIgl@A|+!J-8G+uo66)4IaH6?Eg@oI=Sv7+v;ccy>%`ZYW(jy`Mi7YJN$h7 z9p9~BuJxfi&8_d8cz5781HT>opf%gR?-rMBJG9(<`16(>bHSyK6QH%)@~J2Cb)>BF z%IV9eSNx4xf8!T*O*g{74qxxcwS{kXX50F5?YnLs$+jQN?R;dZ^Kf?Okz8Zj{F8H0 zn-0&D%BpqNQ&yf2l3-oFwaBh1>u>tAU${5P;BG&y9Q%5Gm?0J0*NER!Zl3eE*pqe6 z58BI}?G2#eTGIg5(E#!drOSVvZ-(W*&Nr13{-q6pE&hIQf91eovdA3^4s@`82p#W( z)<+%QfrGA(dfkX0_6`NvKf3xK9tyA@cQerVxQ83sMm`DffPWHX5#QD@w3iLD){_7q zQl{e%hbt&1M-*}uLNVc?0szAmjmFg&eAvKjj_yYgL4fZHidmoOs1z?q+7AG}PhEsB zA@oJ;*nt2qf9pLMKn(K(6JS!OJq_UJd6St~w#cqXuH+;`*TdUzhVLv^C>+JymFK7Eh zCCCS%6JC(|#WX~FYaiwoVmKN@kNJVk+O#S0)?FCxL)O=&BXj}=Gw|020OVPQVLl_i z&q&2zNXuVIL*B_S9aE7!fofWV{V*MQH*xsxIe4Z$PXMg3(9C1=Ud1HCw0uPnnp^lc 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 From d9eda4746a8e2bf00e3d13f4ae8f0c7a862331d4 Mon Sep 17 00:00:00 2001 From: THOLOT DECHENE Matthieu Date: Fri, 9 Jan 2026 16:09:47 +0000 Subject: [PATCH 4/6] fix: Update du readme --- README.md | 413 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 233 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 10738b0..7c25c9d 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,27 @@ # Pont Bascule Connector (Raspberry Pi) — FastAPI + Serial + Tailscale 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**. + +**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 + | + | 1 appel à la fois (lock) + v + Port série (/dev/ttyUSB0) + | + v + Pont bascule +``` -yaml -Copier le code - -Accès distant : +**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` +- optionnellement via `tailscale serve` pour exposer l'API sur le port 80 sans `:8000` --- @@ -42,92 +41,108 @@ Accès distant : ## Installation (Raspberry Pi) ### 1) Récupérer le projet + ```bash cd ~ git clone pont-bascule-connector cd pont-bascule-connector -2) Environnement Python +``` + +### 2) Environnement Python + Deux options : -Option A : venv global (recommandé si déjà en place) +#### Option A : venv global (recommandé si déjà en place) -bash -Copier le code +```bash 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 +#### Option B : venv dans le projet + +```bash 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 +### 3) Configuration série (.env) + +Créer un fichier `.env` à la racine du projet : + +```bash cd ~/pont-bascule-connector nano .env +``` + Exemple : -env -Copier le code +```env 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 +**Notes importantes :** -envoi trame +- `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` -attente 0.5s +### 4) Droits port série (dialout) -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 +```bash 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 +Ajouter l'utilisateur au groupe `dialout` : + +```bash sudo usermod -aG dialout malio sudo reboot -Lancer l’API (mode manuel) -bash -Copier le code +``` + +--- + +## Lancer l'API (mode manuel) + +```bash 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 +```bash curl http://127.0.0.1:8000/health -Lancer l’API au démarrage (systemd) -Créer le service : +``` -bash -Copier le code +--- + +## Lancer l'API au démarrage (systemd) + +### 1) Créer le service + +```bash sudo nano /etc/systemd/system/pont-bascule-api.service +``` + Contenu (adapter les chemins si nécessaire) : -ini -Copier le code +```ini [Unit] Description=Pont bascule API (FastAPI) After=network-online.target tailscaled.service @@ -143,65 +158,78 @@ RestartSec=2 [Install] WantedBy=multi-user.target -Activer et démarrer : +``` -bash -Copier le code +### 2) Activer et démarrer + +```bash sudo systemctl daemon-reload sudo systemctl enable --now pont-bascule-api sudo systemctl status pont-bascule-api --no-pager -Logs : +``` -bash -Copier le code +### 3) Logs + +```bash journalctl -u pont-bascule-api -f -API — Endpoints -Santé -GET /health +``` + +--- + +## API — Endpoints + +### Santé + +**GET** `/health` Exemple : -bash -Copier le code +```bash curl http://127.0.0.1:8000/health -Dernière réponse (debug) -GET /last +``` -Envoi trame “Esclave” -POST /send/esclave +### Dernière réponse (debug) -bash -Copier le code +**GET** `/last` + +### Envoi trame "Esclave" + +**POST** `/send/esclave` + +```bash curl -X POST http://127.0.0.1:8000/send/esclave -Envoi trame “DSD” -POST /send/dsd +``` -bash -Copier le code +### Envoi trame "DSD" + +**POST** `/send/dsd` + +```bash curl -X POST http://127.0.0.1:8000/send/dsd -Envoi trame custom (hex) -POST /send/custom +``` -bash -Copier le code +### Envoi trame custom (hex) + +**POST** `/send/custom` + +```bash 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 +``` + +### 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 +- `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 +```json { "ok": true, "mode": "serial", @@ -213,105 +241,130 @@ Copier le code "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) +## Contrainte "1 appel à la fois" (important) -1) **Sécuriser l’API** -Aujourd’hui tu exposes `0.0.0.0:8000` → accessible depuis le LAN. -Si tu veux “Tailscale only” (recommandé) : +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 +tailscale status +tailscale ip -4 +``` + +Exemple : IP Tailscale du Pi `100.122.43.54`. + +### 2) Appeler l'API via Tailscale (simple) + +```bash +curl http://100.122.43.54:8000/health +curl -X POST http://100.122.43.54:8000/send/esclave +``` + +### 3) Option recommandée : exposer sans port avec `tailscale serve` + +Sur le Raspberry : + +```bash +sudo tailscale serve --http=80 localhost:8000 +sudo tailscale serve status +``` + +Ensuite : + +```bash +curl http://100.122.43.54/health +curl -X POST http://100.122.43.54/send/esclave +``` + +### 4) SSH via Tailscale + +```bash +tailscale ssh malio@raspberrypi +``` + +--- + +## Dépannage rapide + +### API down + +```bash +sudo systemctl status pont-bascule-api --no-pager +journalctl -u pont-bascule-api -n 100 --no-pager +``` + +### Port série introuvable + +```bash +ls /dev/ttyUSB* /dev/ttyACM* 2>/dev/null +dmesg | tail -n 50 +``` + +### Permission refusée + +```bash +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) + +1. **Exposer l'API uniquement via Tailscale :** + - faire écouter uvicorn en local seulement (`--host 127.0.0.1`) + - utiliser `tailscale serve` comme reverse proxy + +2. **Ajouter un token API** si besoin (header `Authorization`) + +3. **Ajouter une route `/weight`** qui parse la chaîne `response_ascii` et renvoie `weight` + `unit` + `ticket` proprement. + +--- + +## Autres recommandations (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 : +### 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. +{ + "weight": 12.34, + "unit": "kg", + "ticket": "000123", + "raw": "..." +} +``` -Healthcheck matériel -Ajouter une route GET /serial/info qui vérifie : +### 3) Port série stable (udev) -port existe - -user a accès - -bridge non-busy +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`. \ No newline at end of file From 3292dfbedec4d860fd1a4dd61ae0733852412240 Mon Sep 17 00:00:00 2001 From: matthieu Date: Sat, 10 Jan 2026 11:36:34 +0100 Subject: [PATCH 5/6] chore : update env defaults and make targets --- .env.example | 8 +++ .githooks/commit-msg | 31 +++++++++ Makefile | 93 +++++++++++++++++++++++++ README.md | 160 +++++++++++++++++++++++-------------------- app/main.py | 34 ++++++--- requirements.txt | 1 + 6 files changed, 241 insertions(+), 86 deletions(-) create mode 100644 .env.example create mode 100755 .githooks/commit-msg create mode 100644 Makefile diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8cf0236 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +APP_MODE=mock +APP_HOST=0.0.0.0 +APP_PORT=8000 +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 diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..9d3f5c0 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +MSG_FILE="${1}" +FIRST_LINE="$(head -n 1 "$MSG_FILE" | tr -d '\r')" + +# Autoriser commits auto-generees par git +if [[ "$FIRST_LINE" =~ ^Merge\ ]]; then + exit 0 +fi + +# Types autorises (minuscules uniquement) +# Optionnel: scope => feat(auth) : ... +REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9._-]+\))?\ :\ .+' + +if [[ ! "$FIRST_LINE" =~ $REGEX ]]; then + echo "❌ Message de commit invalide." + echo "" + echo "➡️ Format attendu : () : " + echo "➡️ Types autorises (minuscules uniquement) :" + echo " build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test" + echo "" + echo "✅ Exemples :" + echo " feat : add login page" + echo " fix(auth) : prevent null token crash" + echo " docs : update README" + echo "" + echo "❌ Exemple refuse :" + echo " Feat : add login page" + exit 1 +fi diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8e9b58e --- /dev/null +++ b/Makefile @@ -0,0 +1,93 @@ +SHELL := /usr/bin/env bash +PYTHON ?= python3 +VENV_DIR ?= .venv +PIP := $(VENV_DIR)/bin/pip +UVICORN := $(VENV_DIR)/bin/uvicorn + +.PHONY: help venv install env run start stop restart run-mock run-serial reset clean + +help: + @echo "Targets:" + @echo " venv - create virtual environment in $(VENV_DIR)" + @echo " install - install Python dependencies" + @echo " env - create .env from .env.example if missing" + @echo " run - run API (uses .env)" + @echo " start - start API in background (PID file: .uvicorn.pid)" + @echo " stop - stop API using PID file" + @echo " restart - stop then start API" + @echo " run-mock - run API with APP_MODE=mock" + @echo " run-serial - run API with APP_MODE=serial" + @echo " reset - remove virtual environment and .env" + +venv: + $(PYTHON) -m venv $(VENV_DIR) + +install: venv env + $(PIP) install --upgrade pip + $(PIP) install -r requirements.txt + +env: + @bash -c 'set -euo pipefail; \ + if [ ! -f .env ]; then \ + cp .env.example .env; \ + echo ".env created from .env.example"; \ + else \ + changed=0; \ + while IFS= read -r line; do \ + case "$$line" in \ + ""|\#*) continue ;; \ + esac; \ + key="$${line%%=*}"; \ + if ! grep -qE "^$${key}=" .env; then \ + echo "$$line" >> .env; \ + changed=1; \ + fi; \ + done < .env.example; \ + if [ "$$changed" -eq 1 ]; then \ + echo ".env updated with missing variables"; \ + else \ + echo ".env up to date"; \ + fi; \ + fi' + +run: + bash -c 'set -a; [ -f .env ] && . ./.env; set +a; $(UVICORN) app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8000}"' + +start: + @bash -c 'set -a; [ -f .env ] && . ./.env; set +a; \ + if [ -f .uvicorn.pid ] && kill -0 "$$(cat .uvicorn.pid)" 2>/dev/null; then \ + echo "API already running (PID $$(cat .uvicorn.pid))"; \ + exit 0; \ + fi; \ + nohup $(UVICORN) app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8000}" >/dev/null 2>&1 & \ + echo $$! > .uvicorn.pid; \ + echo "API started (PID $$(cat .uvicorn.pid))"' + +stop: + @bash -c 'set -euo pipefail; \ + if [ ! -f .uvicorn.pid ]; then \ + echo "No PID file (.uvicorn.pid)."; \ + exit 0; \ + fi; \ + pid="$$(cat .uvicorn.pid)"; \ + if kill -0 "$$pid" 2>/dev/null; then \ + kill "$$pid"; \ + echo "API stopped (PID $$pid)"; \ + else \ + echo "Process not running (PID $$pid)"; \ + fi; \ + rm -f .uvicorn.pid' + +restart: stop start + +run-mock: + bash -c 'set -a; [ -f .env ] && . ./.env; set +a; APP_MODE=mock $(UVICORN) app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8000}"' + +run-serial: + bash -c 'set -a; [ -f .env ] && . ./.env; set +a; APP_MODE=serial $(UVICORN) app.main:app --host "$${APP_HOST:-0.0.0.0}" --port "$${APP_PORT:-8000}"' + +reset: + rm -rf $(VENV_DIR) + rm -f .env .uvicorn.pid + +clean: reset diff --git a/README.md b/README.md index 7c25c9d..13ec7de 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@ -# Pont Bascule Connector (Raspberry Pi) — FastAPI + Serial + Tailscale +# Pont Bascule Connector — FastAPI + Serial + (optionnel) Tailscale -API HTTP (FastAPI) qui pilote un pont bascule connecté en USB (port série) sur Raspberry Pi. +API HTTP (FastAPI) qui pilote un pont bascule connecté en USB (port série) sur Raspberry Pi ou Linux. -**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**. +**Objectif :** permettre à une application/serveur distant d'interroger le pont bascule via réseau, avec une contrainte stricte : **1 requête série à la fois**. --- ## Fonctionnement global ``` -Client (PC / serveur / app) --HTTP--> Raspberry Pi (FastAPI) +Client (PC / serveur / app) --HTTP--> Machine (FastAPI) | | 1 appel à la fois (lock) v @@ -19,58 +19,78 @@ Client (PC / serveur / app) --HTTP--> Raspberry Pi (FastAPI) Pont bascule ``` -**Accès distant :** +**Accès distant (optionnel) :** - via IP Tailscale `100.x.x.x` (VPN mesh) -- optionnellement via `tailscale serve` pour exposer l'API sur le port 80 sans `:8000` +- ou autre VPN / reverse-proxy selon votre infra --- ## 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) +### Système +- Raspberry Pi OS / Debian / Ubuntu (autres Linux OK) +- Python 3.9+ recommandé +- Accès SSH +- (optionnel) Tailscale installé et connecté + --- -## Installation (Raspberry Pi) +## Installation ### 1) Récupérer le projet ```bash cd ~ -git clone pont-bascule-connector +git clone gitea@gitea.malio.fr:MALIO-DEV/pont-bascule-connector.git cd pont-bascule-connector ``` -### 2) Environnement Python - -Deux options : - -#### Option A : venv global (recommandé si déjà en place) +### 2) Installer les dépendances système (Debian/Ubuntu/Raspberry Pi OS) ```bash -python3 -m venv /home/malio/venv -source /home/malio/venv/bin/activate +sudo apt update +sudo apt install -y python3 python3-venv python3-pip git +``` + +Si vous êtes sur un autre OS, installez Python 3 + pip + venv via votre gestionnaire de paquets. + +### 3) Installer les dépendances Python + +```bash +python3 -m venv ./.venv +source ./.venv/bin/activate pip install --upgrade pip pip install -r requirements.txt ``` -#### Option B : venv dans le projet +--- + +## Hook git (commit-msg) + +Le repo contient un hook pour valider le format des messages de commit. + +Activation : ```bash -python3 -m venv ./venv -source ./venv/bin/activate -pip install --upgrade pip -pip install -r requirements.txt +git config core.hooksPath .githooks ``` -### 3) Configuration série (.env) +Format attendu : + +```text +() : +``` + +Types autorisés : `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. + +--- + +## Configuration série (.env) + +Le fichier `.env` est chargé automatiquement au démarrage (via `python-dotenv`). Créer un fichier `.env` à la racine du projet : @@ -79,9 +99,12 @@ cd ~/pont-bascule-connector nano .env ``` -Exemple : +Exemple (mode reel / port serie) : ```env +APP_MODE=serial +APP_HOST=0.0.0.0 +APP_PORT=8000 SERIAL_PORT=/dev/ttyUSB0 SERIAL_BAUDRATE=9600 SERIAL_TIMEOUT_S=1.0 @@ -89,16 +112,28 @@ SERIAL_OPEN_DELAY_S=2.0 SERIAL_POST_WRITE_DELAY_S=0.5 ``` +Exemple (mode mock pour dev, sans port serie) : + +```env +APP_MODE=mock +APP_HOST=0.0.0.0 +APP_PORT=8000 +``` + **Notes importantes :** +- `APP_MODE=serial` (defaut) utilise le port serie ; `APP_MODE=mock` simule les reponses pour le dev. +- `APP_HOST` et `APP_PORT` permettent de changer l'interface d'ecoute et le port HTTP. - `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` +- Si votre port est `/dev/ttyACM0`, adaptez `SERIAL_PORT` -### 4) Droits port série (dialout) +--- + +## Droits port série (dialout) Vérifier les devices : @@ -108,19 +143,21 @@ ls /dev/ttyACM* 2>/dev/null || true dmesg | tail -n 30 ``` -Ajouter l'utilisateur au groupe `dialout` : +Ajouter l'utilisateur courant au groupe `dialout` : ```bash -sudo usermod -aG dialout malio -sudo reboot +sudo usermod -aG dialout "$USER" +newgrp dialout ``` +Si besoin, reconnectez-vous (ou redémarrez) pour que les droits prennent effet. + --- ## Lancer l'API (mode manuel) ```bash -source /home/malio/venv/bin/activate # ou ./venv/bin/activate +source ./.venv/bin/activate uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` @@ -140,19 +177,19 @@ curl http://127.0.0.1:8000/health sudo nano /etc/systemd/system/pont-bascule-api.service ``` -Contenu (adapter les chemins si nécessaire) : +Contenu (adapter les chemins et l'utilisateur) : ```ini [Unit] Description=Pont bascule API (FastAPI) -After=network-online.target tailscaled.service +After=network-online.target 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 +User= +WorkingDirectory=/home//pont-bascule-connector +EnvironmentFile=/home//pont-bascule-connector/.env +ExecStart=/home//pont-bascule-connector/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 Restart=always RestartSec=2 @@ -255,18 +292,18 @@ Si une requête est déjà en cours, l'API renvoie : --- -## Accès à distance via Tailscale +## Accès à distance via Tailscale (optionnel) ### 1) Vérifier Tailscale -Sur le Raspberry : +Sur la machine : ```bash tailscale status tailscale ip -4 ``` -Exemple : IP Tailscale du Pi `100.122.43.54`. +Exemple : IP Tailscale `100.122.43.54`. ### 2) Appeler l'API via Tailscale (simple) @@ -277,7 +314,7 @@ curl -X POST http://100.122.43.54:8000/send/esclave ### 3) Option recommandée : exposer sans port avec `tailscale serve` -Sur le Raspberry : +Sur la machine : ```bash sudo tailscale serve --http=80 localhost:8000 @@ -291,12 +328,6 @@ curl http://100.122.43.54/health curl -X POST http://100.122.43.54/send/esclave ``` -### 4) SSH via Tailscale - -```bash -tailscale ssh malio@raspberrypi -``` - --- ## Dépannage rapide @@ -330,7 +361,7 @@ groups --- -## Sécurité recommandée (à faire) +## Sécurité recommandée (optionnel) 1. **Exposer l'API uniquement via Tailscale :** - faire écouter uvicorn en local seulement (`--host 127.0.0.1`) @@ -342,29 +373,8 @@ groups --- -## Autres recommandations (vraiment utiles) +## Recommandations utiles -### 1) Sécuriser l'API +### Port série stable (udev) -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": "..." -} -``` - -### 3) 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`. \ No newline at end of file +Si votre port passe parfois de `/dev/ttyUSB0` à `/dev/ttyUSB1`, on peut créer une règle udev pour avoir un nom fixe (ex: `/dev/pontbascule`). diff --git a/app/main.py b/app/main.py index 5326b06..13e8a59 100644 --- a/app/main.py +++ b/app/main.py @@ -3,8 +3,10 @@ import time import socket from fastapi import FastAPI, HTTPException from pydantic import BaseModel +from dotenv import load_dotenv from .serial_bridge import SerialBridge, SerialConfig +from .mock_bridge import MockBridge app = FastAPI(title="Pont bascule API", version="1.3") @@ -16,20 +18,30 @@ def env(name: str, default: str) -> str: return os.getenv(name, default) +load_dotenv() + 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) +APP_MODE = env("APP_MODE", "serial").strip().lower() +if APP_MODE not in ("serial", "mock"): + APP_MODE = "serial" + +if APP_MODE == "mock": + cfg = None + bridge = MockBridge() +else: + 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): @@ -40,12 +52,12 @@ class CustomReq(BaseModel): def health(): return { "ok": True, - "mode": "serial", + "mode": APP_MODE, "busy": bridge.busy(), "hostname": socket.gethostname(), "timestamp": time.time(), - "port": cfg.port, - "baudrate": cfg.baudrate, + "port": cfg.port if cfg else None, + "baudrate": cfg.baudrate if cfg else None, } diff --git a/requirements.txt b/requirements.txt index f05ceef..8630feb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ fastapi uvicorn pydantic pyserial +python-dotenv From f442748d95321f61d530cc42d44070f82e19b3e2 Mon Sep 17 00:00:00 2001 From: matthieu Date: Tue, 13 Jan 2026 17:43:05 +0100 Subject: [PATCH 6/6] feat : add serial port status to health --- app/main.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/app/main.py b/app/main.py index 13e8a59..e842e90 100644 --- a/app/main.py +++ b/app/main.py @@ -25,6 +25,14 @@ def hex2b(s: str) -> bytes: parts = [p for p in s.split() if p] return bytes(int(p, 16) for p in parts) +def serial_port_connected(port: str): + try: + from serial.tools import list_ports + ports = {p.device for p in list_ports.comports()} + return port in ports, None + except Exception as e: + return None, str(e) + APP_MODE = env("APP_MODE", "serial").strip().lower() if APP_MODE not in ("serial", "mock"): @@ -50,14 +58,25 @@ class CustomReq(BaseModel): @app.get("/health") def health(): + port_connected = None + port_error = None + port_status = None + if cfg and cfg.port: + port_connected, port_error = serial_port_connected(cfg.port) + if port_connected is False: + port_status = "Port COM pas connecte" + if cfg is None or not cfg.port: + port_status = "Port COM pas configure" return { - "ok": True, + "ok": False if port_connected is False or (cfg is None or not cfg.port) else True, "mode": APP_MODE, "busy": bridge.busy(), "hostname": socket.gethostname(), "timestamp": time.time(), - "port": cfg.port if cfg else None, + "port": port_status if port_status else (cfg.port if cfg else None), "baudrate": cfg.baudrate if cfg else None, + "port_connected": port_connected, + "port_error": port_error, }