diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e48de..52c8841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,3 +83,8 @@ - Реле: GPIO32 (красный), GPIO33 (зелёный) через BC547 транзисторы - Питание: 5V 2A PSU для всей системы - Весы DB25 пин 2 (TXD) → MAX3232 RX; DB25 пин 7 (GND) → общий GND + +## 2026-06-26 — авто-захват фото + распознавание номера (Claude API) +- orange_pi/scales.py: при стабилизации веса снимает кадр с RTSP-камеры (ffmpeg) и шлёт на scales.zeroday.su/api/capture в фоновом потоке +- grab_test.py: разовый тест камеры → сервер +- сервер распознаёт номер через aiprimetech.io (claude-haiku-4-5 + fallback sonnet-4-6) и кладёт фото+номер рядом с весом diff --git a/__pycache__/grab_test.cpython-312.pyc b/__pycache__/grab_test.cpython-312.pyc new file mode 100644 index 0000000..f2662a9 Binary files /dev/null and b/__pycache__/grab_test.cpython-312.pyc differ diff --git a/grab_test.py b/grab_test.py new file mode 100644 index 0000000..602c8c7 --- /dev/null +++ b/grab_test.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# Разовый тест: снять кадр с RTSP-камеры и отправить на наш сервер распознавания. +# Запуск (когда на весах стоит машина и виден номер): python3 grab_test.py +import subprocess, base64, urllib.request, json, datetime, time + +RTSP_URL = 'rtsp://iivideo:Vk9mT2xLp7nQ4wBj@192.168.20.3:9784/cameras/124/streaming/main' +SERVER = 'https://scales.zeroday.su/api/capture' +FRAME = '/tmp/scale_frame_test.jpg' + +print("1) Снимаю кадр с камеры...") +cmd = ['ffmpeg', '-y', '-loglevel', 'error', '-rtsp_transport', 'tcp', + '-i', RTSP_URL, '-frames:v', '1', '-update', '1', + '-vf', 'scale=1280:-2', '-q:v', '4', '-f', 'image2', FRAME] +try: + subprocess.run(cmd, timeout=15, check=True) +except Exception as e: + print(" ОШИБКА захвата кадра:", e); raise SystemExit(1) + +img = open(FRAME, 'rb').read() +print(f" OK, кадр {len(img)} байт") + +eid = f"opi-camtest-{int(time.time())}" +print(f"2) Отправляю на сервер (event_id={eid}, вес-заглушка 12345)...") +payload = json.dumps({ + 'event_id': eid, 'device_id': 'scales_opi', 'weight': 12345.0, + 'captured_at': datetime.datetime.now().isoformat(), 'media_type': 'image/jpeg', + 'photo_b64': base64.b64encode(img).decode() +}).encode() +req = urllib.request.Request(SERVER, data=payload, headers={'Content-Type': 'application/json'}) +print(" ответ сервера:", urllib.request.urlopen(req, timeout=20).read().decode()) +print(f"3) Готово! Скажи Claude event_id={eid} — проверю кадр и распознанный номер.") diff --git a/orange_pi/__pycache__/scales.cpython-312.pyc b/orange_pi/__pycache__/scales.cpython-312.pyc new file mode 100644 index 0000000..c264bc0 Binary files /dev/null and b/orange_pi/__pycache__/scales.cpython-312.pyc differ diff --git a/orange_pi/scales.py b/orange_pi/scales.py index eddb538..2e88c26 100644 --- a/orange_pi/scales.py +++ b/orange_pi/scales.py @@ -26,6 +26,9 @@ import datetime import threading import select import os +import base64 +import json +import urllib.request # ── Серийный порт ──────────────────────────────────────────────────────────── SERIAL_PORT = '/dev/ttyUSB0' @@ -43,6 +46,13 @@ MQTT2_PORT = 1884 MQTT2_USER = 'esp32' MQTT2_PASS = 'Esp32Scales#2026' +# ── Камера + распознавание (наш VPS) ───────────────────────────────────────── +CAPTURE_ENABLED = True +RTSP_URL = 'rtsp://iivideo:Vk9mT2xLp7nQ4wBj@192.168.20.3:9784/cameras/124/streaming/main' +SERVER_CAPTURE_URL = 'https://scales.zeroday.su/api/capture' +CAPTURE_DEVICE_ID = 'scales_opi' +FRAME_PATH = '/tmp/scale_frame.jpg' + MQTT_WEIGHT_TOPIC = 'scale/weight' MQTT_GO_TOPIC = 'scale/traffic/go' @@ -171,6 +181,46 @@ def publish_weight(w): print(f"[{name}] Ошибка публикации: {e}") +# ════════════════════════════════════════════════════════════════════════════════ +# Захват кадра с камеры и отправка на сервер распознавания +# ════════════════════════════════════════════════════════════════════════════════ + +def grab_frame(): + """Снимает один кадр с RTSP-камеры через ffmpeg. Возвращает JPEG-байты или None.""" + cmd = ['ffmpeg', '-y', '-loglevel', 'error', '-rtsp_transport', 'tcp', + '-i', RTSP_URL, '-frames:v', '1', '-update', '1', + '-vf', 'scale=1280:-2', '-q:v', '4', '-f', 'image2', FRAME_PATH] + try: + subprocess.run(cmd, timeout=15, check=True) + with open(FRAME_PATH, 'rb') as f: + return f.read() + except Exception as e: + print(f"[CAPTURE] Ошибка захвата кадра: {e}") + return None + +def capture_and_send(weight, event_id): + """Кадр + вес → наш VPS. Запускать в отдельном потоке (не блокирует светофор).""" + 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(), + }).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}") + + # ════════════════════════════════════════════════════════════════════════════════ # Парсер протокола A9 # ════════════════════════════════════════════════════════════════════════════════ @@ -274,7 +324,11 @@ def main(): if max(vals) - min(vals) <= STABLE_DELTA: avg = sum(vals) / len(vals) print(f"[STATE] → WAIT_GO: вес стабилен {avg:.1f} кг, отправляем") + event_id = f"opi-{int(time.time()*1000)}" publish_weight(avg) + if CAPTURE_ENABLED: + threading.Thread(target=capture_and_send, + args=(avg, event_id), daemon=True).start() state = 'WAIT_GO' go_event.clear() go_sent_time = time.time()