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 new file mode 100644 index 0000000..13ec7de --- /dev/null +++ b/README.md @@ -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 +() : +``` + +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= +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 + +[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`). diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..b9d56a4 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# app package diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..b676cea Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..c5dba64 Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/mock_bridge.cpython-313.pyc b/app/__pycache__/mock_bridge.cpython-313.pyc new file mode 100644 index 0000000..31d6d7c Binary files /dev/null and b/app/__pycache__/mock_bridge.cpython-313.pyc differ diff --git a/app/__pycache__/serial_bridge.cpython-313.pyc b/app/__pycache__/serial_bridge.cpython-313.pyc new file mode 100644 index 0000000..9880266 Binary files /dev/null and b/app/__pycache__/serial_bridge.cpython-313.pyc differ diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..e842e90 --- /dev/null +++ b/app/main.py @@ -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 diff --git a/app/main.py.old b/app/main.py.old new file mode 100644 index 0000000..9c05c5b --- /dev/null +++ b/app/main.py.old @@ -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/app/mock_bridge.py b/app/mock_bridge.py new file mode 100644 index 0000000..79ba3cc --- /dev/null +++ b/app/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/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/app/serial_bridge.py.old b/app/serial_bridge.py.old new file mode 100644 index 0000000..7cc1715 --- /dev/null +++ b/app/serial_bridge.py.old @@ -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() 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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8630feb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi +uvicorn +pydantic +pyserial +python-dotenv