""" 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 logger = logging.getLogger("elmer.raw_api") # ══════════════════════════════════════════════════════════ # Глобальное состояние # ══════════════════════════════════════════════════════════ _raw_mode = False _raw_elm = None # локальный RawELM # Очередь команд (удалённый режим) _lock = threading.Lock() _pending_cmd: dict | None = None # команда, которую ждёт Android _pending_seq: int = 0 # номер последней команды _last_response: dict | None = None # последний ответ от ELM327 _device_ready: bool = False # Android подключён и готов _device_info: dict = {} # информация об устройстве (из hello) _history: list[dict] = [] # история команд через Android 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: 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) return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None, "device_ready": _device_ready}) return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None, "device_ready": _device_ready}) # ══════════════════════════════════════════════════════════ # УДАЛЁННЫЙ РЕЖИМ — Android-ретранслятор # ══════════════════════════════════════════════════════════ @bp.route("/api/v1/elm/raw/hello", methods=["POST"]) def raw_hello(): """Android сообщает: «я подключился к ELM327, готов принимать команды». Body: { "device_id": "android-xyz", "elm_version": "ELM327 v1.5", "protocol": "A4", "voltage": "12.3V" } """ global _device_ready, _device_info, _pending_cmd, _pending_seq, _last_response data = request.get_json(silent=True) or {} with _lock: _device_ready = True _device_info = { "device_id": data.get("device_id", "unknown"), "elm_version": data.get("elm_version", "?"), "protocol": data.get("protocol", "?"), "voltage": data.get("voltage", "?"), "connected_at": time.time(), } _pending_cmd = None _pending_seq = 0 _last_response = None logger.info(f"RawELM: device ready — {_device_info['device_id']} " f"({_device_info['elm_version']}, proto {_device_info['protocol']})") return jsonify({"ok": True, "seq": 0}) @bp.route("/api/v1/elm/raw/cmd", methods=["POST"]) def raw_enqueue_cmd(): """Copilot: поставить команду в очередь для Android. Body: { "cmd": "0105", "timeout_ms": 500, "drain_first": false } """ global _pending_cmd, _pending_seq 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 with _lock: _pending_seq += 1 _pending_cmd = { "cmd": cmd, "timeout_ms": data.get("timeout_ms", 500), "drain_first": data.get("drain_first", False), "seq": _pending_seq, } logger.info(f"RawELM: enqueued #{_pending_seq} → {cmd}") return jsonify({"ok": True, "seq": _pending_seq, "cmd": cmd}) @bp.route("/api/v1/elm/raw/cmd", methods=["GET"]) def raw_dequeue_cmd(): """Android: забрать команду из очереди. Returns: 200 {"cmd": "0105", "seq": 1, ...} — есть команда 204 — нет команды, полли дальше """ global _pending_cmd device_id = request.args.get("device_id", "") with _lock: if not _device_ready: return jsonify({"error": "device not ready"}), 503 if _pending_cmd is None: return "", 204 # No Content — полли дальше cmd = _pending_cmd _pending_cmd = None # забрали logger.info(f"RawELM: dequeued #{cmd['seq']} → {cmd['cmd']} (device={device_id})") return jsonify(cmd) @bp.route("/api/v1/elm/raw/response", methods=["POST"]) def raw_post_response(): """Android: отправить ответ ELM327 на сервер. Body: { "device_id": "android-xyz", "seq": 1, "cmd": "0105", "raw": "41 05 5C", "prompt": true, "elapsed_ms": 48, "bytes": 8, "error": null } """ global _last_response, _history data = request.get_json(silent=True) if not data: return jsonify({"error": "empty body"}), 400 with _lock: _last_response = { "seq": data.get("seq", 0), "cmd": data.get("cmd", ""), "raw": data.get("raw", ""), "prompt": data.get("prompt", False), "elapsed_ms": data.get("elapsed_ms", 0), "bytes": data.get("bytes", 0), "error": data.get("error"), "received_at": time.time(), } _history.append(dict(_last_response)) if len(_history) > 1000: _history = _history[-500:] logger.info(f"RawELM: response #{_last_response['seq']} ← {_last_response['raw'][:80]}") return jsonify({"ok": True}) @bp.route("/api/v1/elm/raw/response", methods=["GET"]) def raw_get_response(): """Copilot: прочитать последний ответ от ELM327. Query: ?wait=30 — ждать до 30 сек пока появится новый ответ """ global _last_response, _pending_cmd wait_s = int(request.args.get("wait", 0)) seq = int(request.args.get("seq", 0)) if wait_s > 0: # Ждём пока появится ответ на команду с seq > указанного dl = time.time() + wait_s while time.time() < dl: with _lock: if _last_response and _last_response["seq"] > seq: return jsonify(_last_response) if _pending_cmd is None and _last_response: # команд в очереди нет, ответ уже есть return jsonify(_last_response) time.sleep(0.5) with _lock: if _last_response is None: return jsonify({"error": "no response yet", "seq": 0}) return jsonify(_last_response) @bp.route("/api/v1/elm/raw/status", methods=["GET"]) def raw_status(): """Copilot: статус Android-устройства.""" with _lock: return jsonify({ "device_ready": _device_ready, "device_info": _device_info, "pending_cmd": bool(_pending_cmd), "pending_seq": _pending_seq, "last_response_seq": _last_response["seq"] if _last_response else 0, "history_count": len(_history), }) @bp.route("/api/v1/elm/raw/history", methods=["GET"]) def raw_history(): """Copilot: история всех команд через Android.""" n = int(request.args.get("n", 50)) with _lock: return jsonify({"history": _history[-n:], "total": len(_history)})