OPi: store-and-forward outbox для /api/capture (переживает обрыв сети)
- outbox.py: SQLite-очередь + JPEG на диске, at-least-once доставка - scales.py: capture_and_send кладёт в очередь вместо прямого POST, heartbeat_loop каждые 30с дренирует очередь, статус публикует outbox_pending - fw_version -> 2026-07-02-outbox
This commit is contained in:
+79
-23
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Весовой сервис для Orange Pi — с управлением светофором
|
||||
Весовой сервис для Orange Pi — с управлением светофором + надёжной доставкой
|
||||
Читает данные с весового терминала A9 через USB-RS232 (WaveShare конвертер)
|
||||
и публикует вес в MQTT топик scale/weight
|
||||
и публикует вес в MQTT топик scale/weight. При обрыве сети события хранятся
|
||||
локально и досылаются при восстановлении (outbox store-and-forward).
|
||||
|
||||
Машина состояний:
|
||||
EMPTY → машины нет, оба реле выключены
|
||||
@@ -30,6 +31,13 @@ import base64
|
||||
import json
|
||||
import urllib.request
|
||||
|
||||
# ── outbox (store-and-forward, встроенный) ──────────────────────────────────
|
||||
try:
|
||||
from outbox import CaptureOutbox
|
||||
except ImportError:
|
||||
print("[WARNING] outbox модуль не найден, очередь будет отключена")
|
||||
CaptureOutbox = None
|
||||
|
||||
# ── Серийный порт ────────────────────────────────────────────────────────────
|
||||
SERIAL_PORT = '/dev/ttyUSB0'
|
||||
SERIAL_BAUD = 9600
|
||||
@@ -59,7 +67,7 @@ MQTT_STATUS_TOPIC = 'scale/device/status' # heartbeat: публикуем сю
|
||||
MQTT_CMD_TOPIC = 'scale/device/cmd' # команды от сервера: ping / reboot / test-shot
|
||||
|
||||
HEARTBEAT_INTERVAL = 30 # сек между heartbeat
|
||||
FIRMWARE_VERSION = '2026-06-30-heartbeat'
|
||||
FIRMWARE_VERSION = '2026-07-02-outbox'
|
||||
BOOT_TIME = time.time()
|
||||
|
||||
# ── Параметры взвешивания ─────────────────────────────────────────────────────
|
||||
@@ -80,6 +88,9 @@ BLINK_INTERVAL = 1.0 # сек между миганиями
|
||||
# ── Событие GO от MQTT ────────────────────────────────────────────────────────
|
||||
go_event = threading.Event()
|
||||
|
||||
# ── Store-and-forward outbox (глобальный) ────────────────────────────────────
|
||||
outbox = None
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════════
|
||||
# GPIO через sysfs
|
||||
@@ -192,12 +203,11 @@ def publish_weight(w):
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════════
|
||||
# Захват кадра с камеры и отправка на сервер распознавания
|
||||
# Захват кадра с камеры и отправка на сервер
|
||||
# ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def grab_frame():
|
||||
"""Снимает один кадр с RTSP-камеры через ffmpeg. Возвращает JPEG-байты или None."""
|
||||
# шлём НАТИВНЫЙ кадр (2560x1440) без ужатия — сервер сам вырежет зону номера и распознает
|
||||
cmd = ['ffmpeg', '-y', '-loglevel', 'error', '-rtsp_transport', 'tcp',
|
||||
'-i', RTSP_URL, '-frames:v', '1', '-update', '1',
|
||||
'-q:v', '2', '-f', 'image2', FRAME_PATH]
|
||||
@@ -210,27 +220,49 @@ def grab_frame():
|
||||
return None
|
||||
|
||||
def capture_and_send(weight, event_id, is_test=False):
|
||||
"""Кадр + вес → наш VPS. Запускать в отдельном потоке (не блокирует светофор)."""
|
||||
"""
|
||||
Кадр + вес → локальную очередь (outbox).
|
||||
Очередь досылает когда сеть доступна. Запускать в отдельном потоке.
|
||||
"""
|
||||
img = grab_frame()
|
||||
if not img:
|
||||
print(f"[CAPTURE] {event_id}: кадр не получен, пропуск")
|
||||
return
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"event_id": event_id,
|
||||
"device_id": CAPTURE_DEVICE_ID,
|
||||
"weight": round(weight, 1),
|
||||
"captured_at": datetime.datetime.now().isoformat(),
|
||||
"media_type": "image/jpeg",
|
||||
"photo_b64": base64.b64encode(img).decode(),
|
||||
"is_test": is_test,
|
||||
}).encode()
|
||||
req = urllib.request.Request(SERVER_CAPTURE_URL, data=payload,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
print(f"[CAPTURE] {event_id}: отправлено ({len(img)} б), ответ {r.status}")
|
||||
except Exception as e:
|
||||
print(f"[CAPTURE] {event_id}: ошибка отправки: {e}")
|
||||
|
||||
# Положить в outbox
|
||||
if outbox:
|
||||
outbox.enqueue(
|
||||
event_id=event_id,
|
||||
weight=round(weight, 1),
|
||||
weight_raw=str(round(weight, 1)),
|
||||
captured_at=datetime.datetime.now().isoformat(),
|
||||
jpeg_bytes=img
|
||||
)
|
||||
# Сразу попытаемся отправить (если сеть есть)
|
||||
sent, pending = outbox.flush()
|
||||
if sent > 0:
|
||||
print(f"[CAPTURE] {event_id}: отправлено из очереди сразу")
|
||||
elif pending > 0:
|
||||
print(f"[CAPTURE] {event_id}: в очереди ({pending} ожидающих)")
|
||||
else:
|
||||
# fallback: прямой POST если outbox не доступен (на случай, если модуль не импортировался)
|
||||
print("[CAPTURE] outbox недоступен, попытка прямой отправки...")
|
||||
try:
|
||||
payload = json.dumps({
|
||||
"event_id": event_id,
|
||||
"device_id": CAPTURE_DEVICE_ID,
|
||||
"weight": round(weight, 1),
|
||||
"captured_at": datetime.datetime.now().isoformat(),
|
||||
"media_type": "image/jpeg",
|
||||
"photo_b64": base64.b64encode(img).decode(),
|
||||
"is_test": is_test,
|
||||
}).encode()
|
||||
req = urllib.request.Request(SERVER_CAPTURE_URL, data=payload,
|
||||
headers={"Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
print(f"[CAPTURE] {event_id}: отправлено ({len(img)} б), ответ {r.status}")
|
||||
except Exception as e:
|
||||
print(f"[CAPTURE] {event_id}: ошибка отправки: {e}")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -279,7 +311,7 @@ def get_load_avg():
|
||||
|
||||
def build_status(current_state):
|
||||
total_mb, free_mb = get_mem_mb()
|
||||
return {
|
||||
status = {
|
||||
"device_id": CAPTURE_DEVICE_ID,
|
||||
"ts": datetime.datetime.now().isoformat(),
|
||||
"uptime_s": round(time.time() - BOOT_TIME),
|
||||
@@ -292,6 +324,10 @@ def build_status(current_state):
|
||||
"load_avg_1m": get_load_avg(),
|
||||
"gpio_ok": gpio_ok,
|
||||
}
|
||||
# Добавить статус очереди, если outbox доступен
|
||||
if outbox:
|
||||
status["outbox_pending"] = outbox.pending_count()
|
||||
return status
|
||||
|
||||
def publish_status(current_state):
|
||||
payload = json.dumps(build_status(current_state))
|
||||
@@ -306,6 +342,12 @@ _state_ref = {'value': 'EMPTY'} # шарится с heartbeat-потоком
|
||||
def heartbeat_loop():
|
||||
while True:
|
||||
try:
|
||||
# Дренаж очереди каждый heartbeat
|
||||
if outbox:
|
||||
sent, pending = outbox.flush()
|
||||
if sent > 0:
|
||||
print(f"[OUTBOX] Досланных: {sent}, осталось: {pending}")
|
||||
# Публикуем статус (включая outbox_pending)
|
||||
publish_status(_state_ref['value'])
|
||||
print(f"[HEARTBEAT] state={_state_ref['value']}")
|
||||
except Exception as e:
|
||||
@@ -347,10 +389,24 @@ def parse_weight(data):
|
||||
# ════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def main():
|
||||
global outbox
|
||||
|
||||
gpio_init()
|
||||
mqtt_connect()
|
||||
lights_off()
|
||||
|
||||
# Инициализация outbox
|
||||
if CaptureOutbox:
|
||||
try:
|
||||
outbox = CaptureOutbox(
|
||||
server_url=SERVER_CAPTURE_URL.rsplit('/api/capture', 1)[0],
|
||||
device_id=CAPTURE_DEVICE_ID
|
||||
)
|
||||
print("[OUTBOX] Инициализирована")
|
||||
except Exception as e:
|
||||
print(f"[OUTBOX] Ошибка инициализации: {e}")
|
||||
outbox = None
|
||||
|
||||
threading.Thread(target=heartbeat_loop, daemon=True).start()
|
||||
print(f"[HEARTBEAT] Поток запущен, интервал {HEARTBEAT_INTERVAL}с, топик {MQTT_STATUS_TOPIC}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user