Merge pull request 'develop' (!2) from develop into main

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-02-16 14:27:28 +00:00
16 changed files with 864 additions and 0 deletions

8
.env.example Normal file
View File

@@ -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

31
.githooks/commit-msg Executable file
View File

@@ -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 : <type>(<scope optionnel>) : <message>"
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

93
Makefile Normal file
View File

@@ -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

380
README.md Normal file
View File

@@ -0,0 +1,380 @@
# Pont Bascule Connector — FastAPI + Serial + (optionnel) Tailscale
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, avec une contrainte stricte : **1 requête série à la fois**.
---
## Fonctionnement global
```
Client (PC / serveur / app) --HTTP--> Machine (FastAPI)
|
| 1 appel à la fois (lock)
v
Port série (/dev/ttyUSB0)
|
v
Pont bascule
```
**Accès distant (optionnel) :**
- via IP Tailscale `100.x.x.x` (VPN mesh)
- ou autre VPN / reverse-proxy selon votre infra
---
## Prérequis
### 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
### 1) Récupérer le projet
```bash
cd ~
git clone gitea@gitea.malio.fr:MALIO-DEV/pont-bascule-connector.git
cd pont-bascule-connector
```
### 2) Installer les dépendances système (Debian/Ubuntu/Raspberry Pi OS)
```bash
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
```
---
## Hook git (commit-msg)
Le repo contient un hook pour valider le format des messages de commit.
Activation :
```bash
git config core.hooksPath .githooks
```
Format attendu :
```text
<type>(<scope optionnel>) : <message>
```
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 :
```bash
cd ~/pont-bascule-connector
nano .env
```
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
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 votre port est `/dev/ttyACM0`, adaptez `SERIAL_PORT`
---
## Droits port série (dialout)
Vérifier les devices :
```bash
ls /dev/ttyUSB* 2>/dev/null || true
ls /dev/ttyACM* 2>/dev/null || true
dmesg | tail -n 30
```
Ajouter l'utilisateur courant au groupe `dialout` :
```bash
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 ./.venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Test local :
```bash
curl http://127.0.0.1:8000/health
```
---
## 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 et l'utilisateur) :
```ini
[Unit]
Description=Pont bascule API (FastAPI)
After=network-online.target
Wants=network-online.target
[Service]
User=<votre_user>
WorkingDirectory=/home/<votre_user>/pont-bascule-connector
EnvironmentFile=/home/<votre_user>/pont-bascule-connector/.env
ExecStart=/home/<votre_user>/pont-bascule-connector/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target
```
### 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
```
### 3) Logs
```bash
journalctl -u pont-bascule-api -f
```
---
## API — Endpoints
### Santé
**GET** `/health`
Exemple :
```bash
curl http://127.0.0.1:8000/health
```
### Dernière réponse (debug)
**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
curl -X POST http://127.0.0.1:8000/send/dsd
```
### 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
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
{
"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 (optionnel)
### 1) Vérifier Tailscale
Sur la machine :
```bash
tailscale status
tailscale ip -4
```
Exemple : IP Tailscale `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 la machine :
```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
```
---
## 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 (optionnel)
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.
---
## Recommandations utiles
### Port série stable (udev)
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`).

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# app package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

114
app/main.py Normal file
View File

@@ -0,0 +1,114 @@
import os
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")
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)
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)
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"):
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):
hex: str
@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": 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": 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,
}
@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

59
app/main.py.old Normal file
View File

@@ -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

18
app/mock_bridge.py Normal file
View File

@@ -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()

129
app/serial_bridge.py Normal file
View File

@@ -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()

26
app/serial_bridge.py.old Normal file
View File

@@ -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()

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi
uvicorn
pydantic
pyserial
python-dotenv