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..e842e90 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,38 @@ 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) -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): @@ -38,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, - "mode": "serial", + "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, - "baudrate": cfg.baudrate, + "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, } 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