""" api/raw_elm.py — Сырое взаимодействие с ELM327 (локальное + удалённое через Android). ЛОКАЛЬНЫЙ РЕЖИМ (ELM327 подключён к серверу напрямую): POST /api/v1/elm/raw — отправить команду, получить сырой ответ POST /api/v1/elm/raw/batch — несколько команд POST /api/v1/elm/raw/drain — очистить буфер GET /api/v1/elm/raw/available — байт в буфере GET /api/v1/elm/raw/log — история команд GET /api/v1/elm/raw/mode — режим (normal/raw) УДАЛЁННЫЙ РЕЖИМ (Android-ретранслятор): POST /api/v1/elm/raw/hello — Android: «я готов» POST /api/v1/elm/raw/cmd — Copilot: поставить команду в очередь GET /api/v1/elm/raw/cmd — Android: забрать команду POST /api/v1/elm/raw/response — Android: отправить ответ GET /api/v1/elm/raw/response — Copilot: прочитать ответ GET /api/v1/elm/raw/status — Copilot: статус устройства """ import logging import threading import time from flask import jsonify, request, Blueprint from api.db import Database logger = logging.getLogger("elmer.raw_api") # ══════════════════════════════════════════════════════════ # Локальное состояние (только для прямого подключения ELM) # ══════════════════════════════════════════════════════════ _raw_mode = False _raw_elm = None # локальный RawELM (прямое подключение к серверу) def is_raw_mode() -> bool: return _raw_mode def set_raw_mode(on: bool): global _raw_mode _raw_mode = on logger.info(f"RawELM mode: {'ON' if on else 'OFF'}") def set_raw_elm(instance): global _raw_elm _raw_elm = instance bp = Blueprint("raw_elm", __name__) # ══════════════════════════════════════════════════════════ # ЛОКАЛЬНЫЙ РЕЖИМ — ELM327 подключён к серверу напрямую # ══════════════════════════════════════════════════════════ @bp.route("/api/v1/elm/raw", methods=["POST"]) def raw_command(): if not _raw_elm: return jsonify({"error": "no local ELM connection"}), 503 data = request.get_json(silent=True) if not data or "cmd" not in data: return jsonify({"error": "missing 'cmd'"}), 400 cmd = data["cmd"].strip() if not cmd: return jsonify({"error": "empty cmd"}), 400 timeout = data.get("timeout_ms") drain_first = data.get("drain_first", False) if drain_first: _raw_elm.drain() result = _raw_elm.send(cmd, timeout=timeout) return jsonify(result) @bp.route("/api/v1/elm/raw/batch", methods=["POST"]) def raw_batch(): if not _raw_elm: return jsonify({"error": "no local ELM connection"}), 503 data = request.get_json(silent=True) if not data or "cmds" not in data: return jsonify({"error": "missing 'cmds'"}), 400 cmds = data["cmds"] if len(cmds) > 100: return jsonify({"error": "too many commands (max 100)"}), 400 timeout = data.get("timeout_ms") drain_between = data.get("drain_between", False) t0 = time.time() results = [] for cmd in cmds: if drain_between: _raw_elm.drain() results.append(_raw_elm.send(cmd, timeout=timeout)) total_elapsed = int((time.time() - t0) * 1000) return jsonify({"results": results, "total_elapsed_ms": total_elapsed}) @bp.route("/api/v1/elm/raw/drain", methods=["POST"]) def raw_drain(): if not _raw_elm: return jsonify({"error": "no local ELM connection"}), 503 return jsonify(_raw_elm.drain()) @bp.route("/api/v1/elm/raw/available", methods=["GET"]) def raw_available(): if not _raw_elm: return jsonify({"error": "no local ELM connection"}), 503 return jsonify({"available": _raw_elm.available()}) @bp.route("/api/v1/elm/raw/log", methods=["GET"]) def raw_log(): if not _raw_elm: with Database() as db: rows = db.conn.execute( "SELECT seq, cmd, raw_response, elapsed_ms, prompt, error FROM command_queue WHERE status='done' ORDER BY id DESC LIMIT 50" ).fetchall() history = [{"seq": r["seq"], "cmd": r["cmd"], "raw": r["raw_response"], "elapsed_ms": r["elapsed_ms"], "prompt": r["prompt"], "error": r["error"]} for r in rows] return jsonify({"log": history, "count": len(history)}) return jsonify({"log": _raw_elm.log, "count": len(_raw_elm.log)}) @bp.route("/api/v1/elm/raw/mode", methods=["GET", "POST"]) def raw_mode_control(): global _raw_mode if request.method == "POST": data = request.get_json(silent=True) or {} on = data.get("raw_mode", False) set_raw_mode(on) with Database() as db: device = db.conn.execute( "SELECT device_id FROM command_queue ORDER BY id DESC LIMIT 1" ).fetchone() return jsonify({ "raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None, "device_ready": device is not None, }) # ══════════════════════════════════════════════════════════ # УДАЛЁННЫЙ РЕЖИМ — Android-ретранслятор (SQLite-очередь) # ══════════════════════════════════════════════════════════ @bp.route("/api/v1/elm/raw/hello", methods=["POST"]) def raw_hello(): """Android: «я подключился, готов принимать команды».""" data = request.get_json(silent=True) or {} device_id = data.get("device_id", "unknown") with Database() as db: # Очистить старые команды для этого устройства (новая сессия) db.conn.execute("DELETE FROM command_queue WHERE device_id = ?", (device_id,)) db.conn.commit() logger.info(f"RawELM: device ready — {device_id} " f"({data.get('elm_version', '?')}, proto {data.get('protocol', '?')})") return jsonify({"ok": True, "seq": 0}) @bp.route("/api/v1/elm/raw/cmd", methods=["POST"]) def raw_enqueue_cmd(): """Copilot: поставить команду в очередь.""" data = request.get_json(silent=True) if not data or "cmd" not in data: return jsonify({"error": "missing 'cmd'"}), 400 cmd = data["cmd"].strip() if not cmd: return jsonify({"error": "empty cmd"}), 400 device_id = data.get("device_id", "unknown") tmo = data.get("timeout_ms", 500) drain = 1 if data.get("drain_first") else 0 with Database() as db: cur = db.conn.execute("SELECT COALESCE(MAX(seq), 0) + 1 FROM command_queue WHERE device_id = ?", (device_id,)) seq = cur.fetchone()[0] db.conn.execute( "INSERT INTO command_queue (device_id, seq, cmd, timeout_ms, drain_first, status) VALUES (?,?,?,?,?,'pending')", (device_id, seq, cmd, tmo, drain) ) db.conn.commit() logger.info(f"RawELM: enqueued #{seq} → {cmd}") return jsonify({"ok": True, "seq": seq, "cmd": cmd}) @bp.route("/api/v1/elm/raw/cmd", methods=["GET"]) def raw_dequeue_cmd(): """Android: забрать команду из очереди.""" device_id = request.args.get("device_id", "unknown") with Database() as db: row = db.conn.execute( "SELECT id, seq, cmd, timeout_ms, drain_first FROM command_queue WHERE device_id=? AND status='pending' ORDER BY seq LIMIT 1", (device_id,) ).fetchone() if not row: return "", 204 cur = db.conn.execute( "UPDATE command_queue SET status='sent', sent_at=datetime('now') WHERE id=? AND status='pending'", (row["id"],) ) db.conn.commit() if cur.rowcount == 0: # перехватил другой воркер return "", 204 result = {"seq": row["seq"], "cmd": row["cmd"], "timeout_ms": row["timeout_ms"], "drain_first": bool(row["drain_first"])} logger.info(f"RawELM: dequeued #{row['seq']} → {row['cmd']}") return jsonify(result) @bp.route("/api/v1/elm/raw/response", methods=["POST"]) def raw_post_response(): """Android: отправить ответ ELM327.""" data = request.get_json(silent=True) if not data: return jsonify({"error": "empty body"}), 400 device_id = data.get("device_id", "unknown") seq = data.get("seq", 0) raw_resp = data.get("raw", "") elapsed = data.get("elapsed_ms", 0) prompt = 1 if data.get("prompt") else 0 error = data.get("error") with Database() as db: db.conn.execute( "UPDATE command_queue SET status='done', raw_response=?, elapsed_ms=?, prompt=?, error=?, responded_at=datetime('now') WHERE device_id=? AND seq=?", (raw_resp, elapsed, prompt, error, device_id, seq) ) db.conn.commit() logger.info(f"RawELM: response #{seq} ← {raw_resp[:80]}") return jsonify({"ok": True}) @bp.route("/api/v1/elm/raw/response", methods=["GET"]) def raw_get_response(): """Copilot: прочитать последний ответ.""" seq = int(request.args.get("seq", 0)) wait_s = int(request.args.get("wait", 0)) if wait_s > 0: dl = time.time() + wait_s while time.time() < dl: with Database() as db: row = db.conn.execute( "SELECT seq, cmd, raw_response, elapsed_ms, prompt, error FROM command_queue WHERE seq > ? AND status='done' ORDER BY seq DESC LIMIT 1", (seq,) ).fetchone() if row: return jsonify({"seq": row["seq"], "cmd": row["cmd"], "raw": row["raw_response"], "elapsed_ms": row["elapsed_ms"], "prompt": bool(row["prompt"]), "error": row["error"]}) time.sleep(0.5) with Database() as db: row = db.conn.execute( "SELECT seq, cmd, raw_response, elapsed_ms, prompt, error FROM command_queue WHERE status='done' ORDER BY seq DESC LIMIT 1" ).fetchone() if not row: return jsonify({"error": "no response yet", "seq": 0}) return jsonify({"seq": row["seq"], "cmd": row["cmd"], "raw": row["raw_response"], "elapsed_ms": row["elapsed_ms"], "prompt": bool(row["prompt"]), "error": row["error"]}) @bp.route("/api/v1/elm/raw/status", methods=["GET"]) def raw_status(): """Copilot: статус устройства.""" device_id = request.args.get("device_id", "unknown") with Database() as db: # Чистка старых записей (старше 1 дня) db.conn.execute( "DELETE FROM command_queue WHERE responded_at < datetime('now', '-1 day')" ) db.conn.commit() last = db.conn.execute( "SELECT seq, status FROM command_queue WHERE device_id=? ORDER BY id DESC LIMIT 1", (device_id,) ).fetchone() pending = db.conn.execute( "SELECT COUNT(*) FROM command_queue WHERE device_id=? AND status='pending'", (device_id,) ).fetchone()[0] total = db.conn.execute( "SELECT COUNT(*) FROM command_queue WHERE device_id=? AND status='done'", (device_id,) ).fetchone()[0] return jsonify({ "device_ready": last is not None, "pending_cmd": pending > 0, "pending_seq": pending, "last_response_seq": last["seq"] if last else 0, "history_count": total, }) @bp.route("/api/v1/elm/raw/history", methods=["GET"]) def raw_history(): """Copilot: история команд.""" n = int(request.args.get("n", 50)) with Database() as db: rows = db.conn.execute( "SELECT seq, cmd, raw_response, elapsed_ms, prompt, error FROM command_queue WHERE status='done' ORDER BY id DESC LIMIT ?", (n,) ).fetchall() history = [{"seq": r["seq"], "cmd": r["cmd"], "raw": r["raw_response"], "elapsed_ms": r["elapsed_ms"], "prompt": bool(r["prompt"]), "error": r["error"]} for r in rows] return jsonify({"history": history, "total": len(history)})