302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""
|
|
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")
|
|
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)})
|
|
|