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)
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# app package
|
||||
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/mock_bridge.cpython-313.pyc
Normal file
BIN
app/__pycache__/mock_bridge.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/serial_bridge.cpython-313.pyc
Normal file
BIN
app/__pycache__/serial_bridge.cpython-313.pyc
Normal file
Binary file not shown.
83
app/main.py
Normal file
83
app/main.py
Normal file
@@ -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
|
||||
59
app/main.py.old
Normal file
59
app/main.py.old
Normal 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
18
app/mock_bridge.py
Normal 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
129
app/serial_bridge.py
Normal 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
26
app/serial_bridge.py.old
Normal 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()
|
||||
20
app/test_api.py
Normal file
20
app/test_api.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from fastapi import FastAPI
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
|
||||
app = FastAPI(title="Test API (no serial)")
|
||||
|
||||
class Echo(BaseModel):
|
||||
msg: str
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"ok": True, "ts": time.time()}
|
||||
|
||||
@app.get("/ping")
|
||||
def ping():
|
||||
return {"pong": True}
|
||||
|
||||
@app.post("/echo")
|
||||
def echo(body: Echo):
|
||||
return {"echo": body.msg}
|
||||
Reference in New Issue
Block a user