feat: raw relay — очередь команд, эндпоинты, консоль, index.html
This commit is contained in:
+259
-114
@@ -1,40 +1,56 @@
|
||||
"""
|
||||
api/raw_elm.py — Эндпоинты сырого взаимодействия с ELM327.
|
||||
api/raw_elm.py — Сырое взаимодействие с ELM327 (локальное + удалённое через Android).
|
||||
|
||||
POST /api/v1/elm/raw — отправить команду, получить сырой ответ
|
||||
POST /api/v1/elm/raw/batch — отправить несколько команд
|
||||
GET /api/v1/elm/raw/mode — проверить/установить режим (normal/raw)
|
||||
ЛОКАЛЬНЫЙ РЕЖИМ (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)
|
||||
|
||||
Режим RAW:
|
||||
- Все остальные эндпоинты (/script, /session/upload) ОТКЛЮЧЕНЫ
|
||||
- Только сырые команды через /elm/raw
|
||||
- Никакой пробинг, классификация, стейт-машина — только send/read
|
||||
УДАЛЁННЫЙ РЕЖИМ (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
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Глобальное состояние
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
_raw_mode = False
|
||||
_raw_elm = None # экземпляр RawELM, если есть локальное соединение
|
||||
_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):
|
||||
"""Установить экземпляр RawELM для локального соединения."""
|
||||
global _raw_elm
|
||||
_raw_elm = instance
|
||||
|
||||
@@ -42,30 +58,129 @@ def set_raw_elm(instance):
|
||||
bp = Blueprint("raw_elm", __name__)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# ЛОКАЛЬНЫЙ РЕЖИМ — ELM327 подключён к серверу напрямую
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@bp.route("/api/v1/elm/raw", methods=["POST"])
|
||||
def raw_command():
|
||||
"""Отправить одну сырую команду в ELM327.
|
||||
|
||||
Body:
|
||||
{
|
||||
"cmd": "0105", // команда (без \\r)
|
||||
"timeout_ms": 500, // опционально, default 500
|
||||
"drain_first": false // опционально, очистить буфер перед командой
|
||||
}
|
||||
|
||||
Returns:
|
||||
{
|
||||
"cmd": "0105",
|
||||
"raw": "41 05 5C",
|
||||
"prompt": true,
|
||||
"elapsed_ms": 48,
|
||||
"bytes": 8,
|
||||
"error": null
|
||||
}
|
||||
"""
|
||||
if not _raw_elm:
|
||||
return jsonify({"error": "no local ELM connection", "hint": "ELM327 не подключён к серверу локально"}), 503
|
||||
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
|
||||
@@ -74,96 +189,126 @@ def raw_command():
|
||||
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():
|
||||
"""Отправить несколько команд подряд.
|
||||
|
||||
Body:
|
||||
{
|
||||
"cmds": ["0105", "010C", "010D"],
|
||||
"timeout_ms": 500,
|
||||
"drain_between": false // очищать буфер между командами
|
||||
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:
|
||||
{"results": [...], "total_elapsed_ms": 1234}
|
||||
200 {"cmd": "0105", "seq": 1, ...} — есть команда
|
||||
204 — нет команды, полли дальше
|
||||
"""
|
||||
if not _raw_elm:
|
||||
return jsonify({"error": "no local ELM connection"}), 503
|
||||
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 or "cmds" not in data:
|
||||
return jsonify({"error": "missing 'cmds'"}), 400
|
||||
if not data:
|
||||
return jsonify({"error": "empty body"}), 400
|
||||
|
||||
cmds = data["cmds"]
|
||||
if len(cmds) > 100:
|
||||
return jsonify({"error": "too many commands (max 100)"}), 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:]
|
||||
|
||||
timeout = data.get("timeout_ms")
|
||||
drain_between = data.get("drain_between", False)
|
||||
|
||||
import time
|
||||
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})
|
||||
logger.info(f"RawELM: response #{_last_response['seq']} ← {_last_response['raw'][:80]}")
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@bp.route("/api/v1/elm/raw/drain", methods=["POST"])
|
||||
def raw_drain():
|
||||
"""Очистить входной буфер ELM327. Возвращает что было выброшено."""
|
||||
if not _raw_elm:
|
||||
return jsonify({"error": "no local ELM connection"}), 503
|
||||
@bp.route("/api/v1/elm/raw/response", methods=["GET"])
|
||||
def raw_get_response():
|
||||
"""Copilot: прочитать последний ответ от ELM327.
|
||||
|
||||
result = _raw_elm.drain()
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@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": []})
|
||||
|
||||
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():
|
||||
"""GET: проверить режим. POST: включить/выключить raw mode.
|
||||
|
||||
POST body: {"raw_mode": true/false}
|
||||
Query: ?wait=30 — ждать до 30 сек пока появится новый ответ
|
||||
"""
|
||||
global _raw_mode
|
||||
global _last_response, _pending_cmd
|
||||
wait_s = int(request.args.get("wait", 0))
|
||||
seq = int(request.args.get("seq", 0))
|
||||
|
||||
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})
|
||||
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)})
|
||||
|
||||
return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None})
|
||||
|
||||
Reference in New Issue
Block a user