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:
318
README.md
318
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 <URL_DE_TON_REPO> 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
|
||||||
|
|||||||
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
|
||||||
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()
|
||||||
Reference in New Issue
Block a user