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 — отправить команду, получить сырой ответ
|
ЛОКАЛЬНЫЙ РЕЖИМ (ELM327 подключён к серверу напрямую):
|
||||||
POST /api/v1/elm/raw/batch — отправить несколько команд
|
POST /api/v1/elm/raw — отправить команду, получить сырой ответ
|
||||||
GET /api/v1/elm/raw/mode — проверить/установить режим (normal/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:
|
УДАЛЁННЫЙ РЕЖИМ (Android-ретранслятор):
|
||||||
- Все остальные эндпоинты (/script, /session/upload) ОТКЛЮЧЕНЫ
|
POST /api/v1/elm/raw/hello — Android: «я готов»
|
||||||
- Только сырые команды через /elm/raw
|
POST /api/v1/elm/raw/cmd — Copilot: поставить команду в очередь
|
||||||
- Никакой пробинг, классификация, стейт-машина — только send/read
|
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 logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from flask import jsonify, request, Blueprint
|
from flask import jsonify, request, Blueprint
|
||||||
|
|
||||||
logger = logging.getLogger("elmer.raw_api")
|
logger = logging.getLogger("elmer.raw_api")
|
||||||
|
|
||||||
# Глобальное состояние — raw_mode
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# Глобальное состояние
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
_raw_mode = False
|
_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:
|
def is_raw_mode() -> bool:
|
||||||
"""Включён ли режим сырого взаимодействия."""
|
|
||||||
return _raw_mode
|
return _raw_mode
|
||||||
|
|
||||||
|
|
||||||
def set_raw_mode(on: bool):
|
def set_raw_mode(on: bool):
|
||||||
"""Включить/выключить режим сырого взаимодействия."""
|
|
||||||
global _raw_mode
|
global _raw_mode
|
||||||
_raw_mode = on
|
_raw_mode = on
|
||||||
logger.info(f"RawELM mode: {'ON' if on else 'OFF'}")
|
logger.info(f"RawELM mode: {'ON' if on else 'OFF'}")
|
||||||
|
|
||||||
|
|
||||||
def set_raw_elm(instance):
|
def set_raw_elm(instance):
|
||||||
"""Установить экземпляр RawELM для локального соединения."""
|
|
||||||
global _raw_elm
|
global _raw_elm
|
||||||
_raw_elm = instance
|
_raw_elm = instance
|
||||||
|
|
||||||
@@ -42,30 +58,129 @@ def set_raw_elm(instance):
|
|||||||
bp = Blueprint("raw_elm", __name__)
|
bp = Blueprint("raw_elm", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# ЛОКАЛЬНЫЙ РЕЖИМ — ELM327 подключён к серверу напрямую
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@bp.route("/api/v1/elm/raw", methods=["POST"])
|
@bp.route("/api/v1/elm/raw", methods=["POST"])
|
||||||
def raw_command():
|
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:
|
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)
|
data = request.get_json(silent=True)
|
||||||
if not data or "cmd" not in data:
|
if not data or "cmd" not in data:
|
||||||
return jsonify({"error": "missing 'cmd'"}), 400
|
return jsonify({"error": "missing 'cmd'"}), 400
|
||||||
@@ -74,96 +189,126 @@ def raw_command():
|
|||||||
if not cmd:
|
if not cmd:
|
||||||
return jsonify({"error": "empty cmd"}), 400
|
return jsonify({"error": "empty cmd"}), 400
|
||||||
|
|
||||||
timeout = data.get("timeout_ms")
|
with _lock:
|
||||||
drain_first = data.get("drain_first", False)
|
_pending_seq += 1
|
||||||
|
_pending_cmd = {
|
||||||
if drain_first:
|
"cmd": cmd,
|
||||||
_raw_elm.drain()
|
"timeout_ms": data.get("timeout_ms", 500),
|
||||||
|
"drain_first": data.get("drain_first", False),
|
||||||
result = _raw_elm.send(cmd, timeout=timeout)
|
"seq": _pending_seq,
|
||||||
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 // очищать буфер между командами
|
|
||||||
}
|
}
|
||||||
|
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:
|
Returns:
|
||||||
{"results": [...], "total_elapsed_ms": 1234}
|
200 {"cmd": "0105", "seq": 1, ...} — есть команда
|
||||||
|
204 — нет команды, полли дальше
|
||||||
"""
|
"""
|
||||||
if not _raw_elm:
|
global _pending_cmd
|
||||||
return jsonify({"error": "no local ELM connection"}), 503
|
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)
|
data = request.get_json(silent=True)
|
||||||
if not data or "cmds" not in data:
|
if not data:
|
||||||
return jsonify({"error": "missing 'cmds'"}), 400
|
return jsonify({"error": "empty body"}), 400
|
||||||
|
|
||||||
cmds = data["cmds"]
|
with _lock:
|
||||||
if len(cmds) > 100:
|
_last_response = {
|
||||||
return jsonify({"error": "too many commands (max 100)"}), 400
|
"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")
|
logger.info(f"RawELM: response #{_last_response['seq']} ← {_last_response['raw'][:80]}")
|
||||||
drain_between = data.get("drain_between", False)
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
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})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/v1/elm/raw/drain", methods=["POST"])
|
@bp.route("/api/v1/elm/raw/response", methods=["GET"])
|
||||||
def raw_drain():
|
def raw_get_response():
|
||||||
"""Очистить входной буфер ELM327. Возвращает что было выброшено."""
|
"""Copilot: прочитать последний ответ от ELM327.
|
||||||
if not _raw_elm:
|
|
||||||
return jsonify({"error": "no local ELM connection"}), 503
|
|
||||||
|
|
||||||
result = _raw_elm.drain()
|
Query: ?wait=30 — ждать до 30 сек пока появится новый ответ
|
||||||
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}
|
|
||||||
"""
|
"""
|
||||||
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":
|
if wait_s > 0:
|
||||||
data = request.get_json(silent=True) or {}
|
# Ждём пока появится ответ на команду с seq > указанного
|
||||||
on = data.get("raw_mode", False)
|
dl = time.time() + wait_s
|
||||||
set_raw_mode(on)
|
while time.time() < dl:
|
||||||
return jsonify({"raw_mode": _raw_mode, "has_local_elm": _raw_elm is not None})
|
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})
|
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
# План: тонкий Android-ретранслятор ELM327
|
||||||
|
|
||||||
|
Дата: 2026-06-14
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Отдельное Android-приложение — тупой ретранслятор команд между сервером и ELM327.
|
||||||
|
Пользователь устанавливает один раз. Вся логика (какие команды слать, как анализировать
|
||||||
|
ответы) — на сервере. Приложение только:
|
||||||
|
|
||||||
|
1. Коннектится к ELM327 по Bluetooth
|
||||||
|
2. Сообщает серверу «готов»
|
||||||
|
3. Поллит сервер на наличие команды
|
||||||
|
4. Отправляет команду в ELM327
|
||||||
|
5. Возвращает сырой ответ на сервер
|
||||||
|
6. Повторяет с пункта 3
|
||||||
|
|
||||||
|
## Почему отдельное приложение
|
||||||
|
|
||||||
|
- Ноль риска сломать существующий `ru.elmer.client`
|
||||||
|
- Независимый пакет `ru.elmer.raw`
|
||||||
|
- Свой APK, свой URL на сервере (`/elm-raw.apk`)
|
||||||
|
- Можно удалить/переустановить независимо от основного
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ Сервер (elmer/python) │
|
||||||
|
│ │
|
||||||
|
│ POST /api/v1/elm/raw/cmd ← я ставлю команду │
|
||||||
|
│ GET /api/v1/elm/raw/cmd ← приложение поллит │
|
||||||
|
│ POST /api/v1/elm/raw/response ← приложение шлёт │
|
||||||
|
│ GET /api/v1/elm/raw/response ← я читаю ответ │
|
||||||
|
│ /elm-raw.apk ← раздача APK │
|
||||||
|
└──────────────┬──────────────────────────────────┘
|
||||||
|
│ HTTP (OkHttp)
|
||||||
|
┌──────────────▼──────────────────────────────────┐
|
||||||
|
│ Android-приложение (ru.elmer.raw) │
|
||||||
|
│ │
|
||||||
|
│ RawRelayService (foreground) │
|
||||||
|
│ ├─ Bluetooth → ELM327 │
|
||||||
|
│ ├─ ElmProtocol (AndrOBD, проверенный) │
|
||||||
|
│ ├─ Polling: GET /cmd каждые 500ms │
|
||||||
|
│ └─ POST /response с сырым ответом │
|
||||||
|
│ │
|
||||||
|
│ MainActivity (минимальный UI) │
|
||||||
|
│ ├─ Статус: сервер / ELM / ECU │
|
||||||
|
│ ├─ Лог последних команд │
|
||||||
|
│ └─ Кнопка «Стоп» │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Компоненты Android-приложения
|
||||||
|
|
||||||
|
### 1. Пакет: `ru.elmer.raw`
|
||||||
|
|
||||||
|
Новый пакет, не пересекается с `ru.elmer.client`.
|
||||||
|
|
||||||
|
### 2. Файлы (5 штук)
|
||||||
|
|
||||||
|
| Файл | Размер | Назначение |
|
||||||
|
|------|--------|-----------|
|
||||||
|
| `MainActivity.kt` | ~100 строк | UI: статус, лог, кнопка стоп |
|
||||||
|
| `RawRelayService.kt` | ~150 строк | Foreground-сервис: BT+поллинг+команды |
|
||||||
|
| `ElmProtocol.kt` | копия | Точная копия из `ru.elmer.client.elm` |
|
||||||
|
| `ServerClient.kt` | ~80 строк | Урезанный HTTP-клиент (только cmd/response) |
|
||||||
|
| `AndroidManifest.xml` | ~40 строк | Свой манифест для `ru.elmer.raw` |
|
||||||
|
|
||||||
|
**Почему копия ElmProtocol.kt, а не общий модуль:**
|
||||||
|
- Не трогаем существующий код вообще
|
||||||
|
- AndrOBD-логика отлажена годами, меняться не будет
|
||||||
|
- Две копии живут независимо, никаких конфликтов
|
||||||
|
|
||||||
|
### 3. ElmProtocol.kt — как есть
|
||||||
|
|
||||||
|
Используем **без изменений** проверенную стейт-машину:
|
||||||
|
- `init()`: ATSP0 → ATAT1 → ATS0 → ATL0 → ATE0
|
||||||
|
- `sendCommand(cmd)`: отправить → прочитать до `>` → вернуть сырой ответ
|
||||||
|
- Обработка ошибок: BUS ERROR, CAN ERROR, BUFFER FULL, ретраи, восстановление
|
||||||
|
- Адаптивные тайминги
|
||||||
|
|
||||||
|
Единственное что добавим — вызов `sendCommand()` оборачиваем в `try/catch`,
|
||||||
|
результат всегда возвращается на сервер (даже если ошибка).
|
||||||
|
|
||||||
|
### 4. Протокол обмена с сервером
|
||||||
|
|
||||||
|
#### Приложение → Сервер: «я готов»
|
||||||
|
```
|
||||||
|
POST /api/v1/elm/raw/hello
|
||||||
|
{
|
||||||
|
"device_id": "android-xyz",
|
||||||
|
"elm_version": "ELM327 v1.5",
|
||||||
|
"protocol": "A4",
|
||||||
|
"voltage": "12.3V"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Сервер → Приложение: команда
|
||||||
|
```
|
||||||
|
GET /api/v1/elm/raw/cmd?device_id=android-xyz
|
||||||
|
Ответ 200:
|
||||||
|
{
|
||||||
|
"cmd": "0105",
|
||||||
|
"timeout_ms": 500,
|
||||||
|
"drain_first": false,
|
||||||
|
"seq": 1
|
||||||
|
}
|
||||||
|
Ответ 204: (нет команды — полли дальше)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Приложение → Сервер: ответ
|
||||||
|
```
|
||||||
|
POST /api/v1/elm/raw/response
|
||||||
|
{
|
||||||
|
"device_id": "android-xyz",
|
||||||
|
"seq": 1,
|
||||||
|
"cmd": "0105",
|
||||||
|
"raw": "41 05 5C",
|
||||||
|
"prompt": true,
|
||||||
|
"elapsed_ms": 48,
|
||||||
|
"bytes": 8,
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Сервер → Приложение: подтверждение
|
||||||
|
```
|
||||||
|
200 {"ok": true}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. RawRelayService — жизненный цикл
|
||||||
|
|
||||||
|
```
|
||||||
|
onStartCommand(Intent: serverUrl)
|
||||||
|
↓
|
||||||
|
1. Подключить Bluetooth к ELM327 (UUID SPP 00001101-0000-1000-8000-00805F9B34FB)
|
||||||
|
↓
|
||||||
|
2. ElmProtocol.init() — базовая инициализация
|
||||||
|
↓
|
||||||
|
3. POST /hello — сообщить серверу «готов»
|
||||||
|
↓
|
||||||
|
4. Цикл (в фоновом потоке):
|
||||||
|
GET /cmd — ждать команду (500ms поллинг)
|
||||||
|
если 204 → sleep 500ms → снова GET /cmd
|
||||||
|
если 200 →
|
||||||
|
drain? → ElmProtocol.sendCommand("ATPC") → read/discard
|
||||||
|
ElmProtocol.sendCommand(cmd)
|
||||||
|
POST /response — отправить сырой ответ
|
||||||
|
→ снова GET /cmd
|
||||||
|
↓
|
||||||
|
5. onDestroy(): закрыть BT, stopForeground, остановить поток
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. MainActivity — UI
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ ELM327 Raw Relay │
|
||||||
|
│ │
|
||||||
|
│ Сервер: ✅ obdai.ru │
|
||||||
|
│ ELM: 🔵 подключён │
|
||||||
|
│ ECU: ✅ отвечает │
|
||||||
|
│ │
|
||||||
|
│ Последняя команда: │
|
||||||
|
│ → 0105 │
|
||||||
|
│ ← 41 05 5C (48ms, 8 байт) │
|
||||||
|
│ │
|
||||||
|
│ Лог: 12 команд, 0 ошибок │
|
||||||
|
│ │
|
||||||
|
│ [ СТОП ] │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Минимальный UI:
|
||||||
|
- Три индикатора статуса (сервер, ELM, ECU)
|
||||||
|
- Последняя команда и ответ
|
||||||
|
- Счётчик команд/ошибок
|
||||||
|
- Кнопка «Стоп»
|
||||||
|
|
||||||
|
## Изменения на серверной стороне (elmer/python)
|
||||||
|
|
||||||
|
### 1. Очередь команд — `api/raw_elm.py`
|
||||||
|
|
||||||
|
Добавить эндпоинты (дополнить существующий `api/raw_elm.py`):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/elm/raw/cmd — я ставлю команду в очередь
|
||||||
|
GET /api/v1/elm/raw/cmd — приложение забирает команду
|
||||||
|
POST /api/v1/elm/raw/response — приложение шлёт ответ
|
||||||
|
GET /api/v1/elm/raw/response — я читаю последний ответ
|
||||||
|
POST /api/v1/elm/raw/hello — приложение регистрируется
|
||||||
|
GET /api/v1/elm/raw/status — статус: готово/ждёт/ошибка
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Хранение очереди
|
||||||
|
|
||||||
|
В памяти (глобальная переменная), не в БД:
|
||||||
|
- `_pending_cmd: dict | None` — команда, которую ждёт приложение
|
||||||
|
- `_last_response: dict | None` — последний ответ от ELM327
|
||||||
|
- `_device_ready: bool` — готово ли приложение
|
||||||
|
- `_device_info: dict` — информация об устройстве
|
||||||
|
|
||||||
|
Зачем в памяти: одна сессия отладки, один поток команд. Не нужна персистентность.
|
||||||
|
|
||||||
|
### 3. Раздача APK — `web/app.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.route("/elm-raw.apk")
|
||||||
|
def download_raw_apk():
|
||||||
|
return send_from_directory("static", "elm-raw.apk", ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
В `templates/index.html` — ссылка «Скачать ELM Raw Relay».
|
||||||
|
|
||||||
|
### 4. Интерактивная консоль — `tools/elm_relay.py`
|
||||||
|
|
||||||
|
Скрипт для меня (Copilot):
|
||||||
|
- Читает статус устройства
|
||||||
|
- Ставит команду в очередь
|
||||||
|
- Ждёт ответ
|
||||||
|
- Показывает сырой ответ
|
||||||
|
- Анализирует, ставит следующую команду
|
||||||
|
- История всех команд сохраняется
|
||||||
|
|
||||||
|
## Сборка и деплой
|
||||||
|
|
||||||
|
### Сборка APK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew :app:assembleDebug
|
||||||
|
# APK: android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
Но нам нужен **отдельный** APK для `ru.elmer.raw`. Два варианта:
|
||||||
|
|
||||||
|
**Вариант A: Product Flavor** (в одном проекте)
|
||||||
|
- В `app/build.gradle.kts` добавить `flavorDimensions` + два flavor: `client` и `raw`
|
||||||
|
- Разные `applicationId`, разные `AndroidManifest.xml`
|
||||||
|
- Общий код в `main/`, специфичный — в `client/` и `raw/`
|
||||||
|
- Минус: трогаем `build.gradle.kts` основного приложения
|
||||||
|
|
||||||
|
**Вариант B: Новый модуль** (рекомендую)
|
||||||
|
- Новый Gradle-модуль `android/raw/`
|
||||||
|
- Свой `build.gradle.kts`, свой манифест, свой пакет
|
||||||
|
- Не трогаем вообще ничего в `android/app/`
|
||||||
|
- `settings.gradle.kts` — добавить `include(":raw")`
|
||||||
|
- Минус: ElmProtocol.kt — физическая копия файла
|
||||||
|
|
||||||
|
### Я за Вариант B: новый модуль `:raw`
|
||||||
|
|
||||||
|
```
|
||||||
|
android/
|
||||||
|
├── app/ ← существующее, НЕ ТРОГАЕМ
|
||||||
|
├── raw/ ← НОВЫЙ модуль
|
||||||
|
│ ├── build.gradle.kts
|
||||||
|
│ └── src/main/
|
||||||
|
│ ├── AndroidManifest.xml
|
||||||
|
│ └── java/ru/elmer/raw/
|
||||||
|
│ ├── MainActivity.kt
|
||||||
|
│ ├── RawRelayService.kt
|
||||||
|
│ ├── ElmProtocol.kt ← копия из :app
|
||||||
|
│ └── ServerClient.kt
|
||||||
|
├── settings.gradle.kts ← + include(":raw")
|
||||||
|
└── build.gradle.kts ← не трогаем
|
||||||
|
```
|
||||||
|
|
||||||
|
### Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd android
|
||||||
|
./gradlew :raw:assembleDebug
|
||||||
|
cp raw/build/outputs/apk/debug/raw-debug.apk ../web/static/elm-raw.apk
|
||||||
|
# Задеплоить на сервер через deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Порядок работ
|
||||||
|
|
||||||
|
1. **Сервер**: дополнить `api/raw_elm.py` эндпоинтами очереди
|
||||||
|
2. **Сервер**: добавить `web/app.py` — раздача `/elm-raw.apk`
|
||||||
|
3. **Сервер**: `tools/elm_relay.py` — консоль для меня
|
||||||
|
4. **Android**: модуль `:raw` — 5 файлов (.kt + манифест + build.gradle)
|
||||||
|
5. **Сборка**: проверить что оба APK собираются
|
||||||
|
6. **Тест**: поставить APK на телефон, проверить связь с сервером
|
||||||
|
|
||||||
|
## Что НЕ делаем
|
||||||
|
|
||||||
|
- Не трогаем `ru.elmer.client` — ни строчки
|
||||||
|
- Не меняем `app/build.gradle.kts`
|
||||||
|
- Не меняем существующий `AndroidManifest.xml`
|
||||||
|
- Не изобретаем новый ELM327-протокол — используем AndrOBD как есть
|
||||||
|
- Не пишем сложный UI — только статус и лог
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Мнение по анализу динамического сбоя ELM327
|
||||||
|
|
||||||
|
Дата: 2026-06-14
|
||||||
|
|
||||||
|
## Общая оценка
|
||||||
|
|
||||||
|
Анализ написан правильно. Методология верная: исключение невозможного через уже проведённые эксперименты (паузы 4000 мс, автоподбор таймингов), затем ранжирование оставшихся гипотез. Главный вывод — проблема не в скорости, а в чтении потока — звучит убедительно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что поддерживаю
|
||||||
|
|
||||||
|
**Гипотезы 1–3 (вероятность: высокая)** — расставлены верно.
|
||||||
|
|
||||||
|
Из трёх наиболее вероятных причин **непрочитанный `>` в InputStream** — самая классическая ELM327-ловушка. Если ScriptEngine завершает чтение по таймауту или по числу строк вместо `>`, это объясняет всё: первый запрос проходит, потому что `>` ещё не накапливается, второй ломается из-за хвоста. Это надо проверять первым.
|
||||||
|
|
||||||
|
**Buffer drain перед send, а не только после receive** — часто игнорируемое место. Если drain делается только после чтения, но перед отправкой нового запроса остаток `>` или пустая строка ещё лежат в буфере — это незаметно даже в логах, если читать только "полезные" байты.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что добавил бы
|
||||||
|
|
||||||
|
### 1. NO DATA / UNABLE TO CONNECT в динамике
|
||||||
|
|
||||||
|
В анализе не рассмотрен сценарий, когда в ходе динамики ELM вернул `NO DATA` или `UNABLE TO CONNECT`. Это вполне реально при смене контекста CAN. Если ScriptEngine на такой ответ зависает в ожидании данных или некорректно парсит следующий ответ — результат идентичен описанному сбою. Стоит явно проверить, как ScriptEngine обрабатывает негативные ответы ELM, и логировать их.
|
||||||
|
|
||||||
|
### 2. AT ST (тайм-аут ELM) может различаться между режимами
|
||||||
|
|
||||||
|
Если ElmChecker и ScriptEngine отправляют разные значения `AT ST` (или один вообще не устанавливает его), ELM сам будет обрезать ответ или отвечать с разной задержкой. При высокой нагрузке ECU (динамика) тайм-аут ELM по умолчанию (200 мс) может быть недостаточен, и ELM уйдёт в `NO DATA` раньше, чем ECU ответил. Нужно убедиться, что `AT ST FF` (максимальный) или фиксированное значение установлены одинаково в обоих путях.
|
||||||
|
|
||||||
|
### 3. Клон ELM327 vs оригинал
|
||||||
|
|
||||||
|
Клоны (особенно v1.5 китайские) имеют известный баг: при высокой частоте запросов они перестают выдавать `>` — промпт появляется только после задержки или вообще пропадает. Если адаптер — клон, нужно явно учесть это при трактовке сырых логов: отсутствие `>` может быть аппаратным поведением, а не ошибкой кода.
|
||||||
|
|
||||||
|
### 4. Конкурентный доступ — недооценённый риск
|
||||||
|
|
||||||
|
Гипотезе 5 (два потока на сокет) поставлена средняя вероятность, но в Android-проектах это случается чаще, чем кажется. Достаточно одного фонового alive-check, который читает тот же InputStream в момент динамического цикла. Стоит выйти не только на проверку thread id, но и на `synchronized`-блоки или single-threaded executor для всех операций с сокетом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что менее убедительно
|
||||||
|
|
||||||
|
**Гипотеза 6 (порядок команд)** — оценка "средняя-низкая" верна, но её стоит проверять параллельно с гипотезами 1–3, не последовательно: это дёшево (достаточно дампа команд) и может мгновенно закрыть вопрос или исключить этот класс причин.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок расследования (скорректированный)
|
||||||
|
|
||||||
|
1. **Сырой RX/TX лог** с явным маркером `>` — сравнить статику и динамику. Первый приоритет.
|
||||||
|
2. **Проверить обработку негативных ответов** (`NO DATA`, `UNABLE TO CONNECT`) в ScriptEngine.
|
||||||
|
3. **Сравнить AT-последовательности** ElmChecker и ScriptEngine — весь init, включая `AT ST`.
|
||||||
|
4. **Убедиться в drain перед send**, а не только после receive.
|
||||||
|
5. **Thread id на каждый read/write** — исключить второй consumer.
|
||||||
|
6. **Дамп команд** обоих режимов — закрыть гипотезу 6 параллельно с остальными.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Анализ хороший. Главное не растягивать расследование на последовательное прохождение всех гипотез: сырой лог с маркером `>` и лог негативных ответов ELM — два дешёвых эксперимента, которые скорее всего сразу покажут, где рвётся синхронизация.
|
||||||
Executable
+270
@@ -0,0 +1,270 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
elm_relay.py — Интерактивная консоль для удалённого управления ELM327 через Android.
|
||||||
|
|
||||||
|
Работает через HTTP-очередь на сервере:
|
||||||
|
1. Ставит команду → POST /api/v1/elm/raw/cmd
|
||||||
|
2. Ждёт ответ → GET /api/v1/elm/raw/response?wait=N
|
||||||
|
3. Показывает сырой ответ
|
||||||
|
4. Анализирует → следующая команда
|
||||||
|
|
||||||
|
ЗАПУСК:
|
||||||
|
python3 tools/elm_relay.py # сервер по умолчанию http://localhost:5005
|
||||||
|
python3 tools/elm_relay.py --server https://obdai.ru
|
||||||
|
python3 tools/elm_relay.py --timeout 1000 # таймаут команд 1000мс
|
||||||
|
|
||||||
|
ИНТЕРАКТИВНЫЕ КОМАНДЫ:
|
||||||
|
ATZ — отправить "ATZ"
|
||||||
|
0105 — отправить PID
|
||||||
|
!status — статус устройства
|
||||||
|
!history [N] — последние N ответов
|
||||||
|
!drain — очистить буфер (ATPC)
|
||||||
|
!mode raw — включить raw-режим на сервере
|
||||||
|
!mode normal — выключить
|
||||||
|
!timeout N — таймаут команд (мс)
|
||||||
|
!help — справка
|
||||||
|
!quit — выход
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import cmd
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
DEFAULT_SERVER = "http://localhost:5005"
|
||||||
|
|
||||||
|
|
||||||
|
class ElmRelay(cmd.Cmd):
|
||||||
|
"""Интерактивная консоль для удалённого ELM327."""
|
||||||
|
|
||||||
|
intro = """
|
||||||
|
╔══════════════════════════════════════════════════════╗
|
||||||
|
║ ELM327 Remote Relay Console ║
|
||||||
|
║ Сервер → Android → ELM327 → ответ → анализ ║
|
||||||
|
║ !help для списка команд ║
|
||||||
|
╚══════════════════════════════════════════════════════╝
|
||||||
|
"""
|
||||||
|
prompt = "\nelm-relay> "
|
||||||
|
|
||||||
|
def __init__(self, server: str, timeout: int):
|
||||||
|
super().__init__()
|
||||||
|
self.server = server.rstrip("/")
|
||||||
|
self.timeout = timeout
|
||||||
|
self._last_seq = 0
|
||||||
|
|
||||||
|
# ── Отправка команд ──────────────────────────────
|
||||||
|
|
||||||
|
def default(self, line: str):
|
||||||
|
"""Любая не-! команда → отправить в ELM327."""
|
||||||
|
cmd_str = line.strip()
|
||||||
|
if not cmd_str:
|
||||||
|
return
|
||||||
|
if cmd_str.startswith("!"):
|
||||||
|
print(f"Неизвестная команда: {cmd_str}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._send_and_wait(cmd_str)
|
||||||
|
|
||||||
|
def _send_and_wait(self, cmd: str, drain_first: bool = False):
|
||||||
|
"""Поставить команду в очередь и дождаться ответа."""
|
||||||
|
# 1. Отправить команду
|
||||||
|
try:
|
||||||
|
enq = self._post("/api/v1/elm/raw/cmd", {
|
||||||
|
"cmd": cmd,
|
||||||
|
"timeout_ms": self.timeout,
|
||||||
|
"drain_first": drain_first,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка отправки: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
seq = enq.get("seq", 0)
|
||||||
|
print(f"→ {cmd} (seq={seq}, timeout={self.timeout}ms)")
|
||||||
|
|
||||||
|
# 2. Ждать ответ
|
||||||
|
try:
|
||||||
|
resp = self._get(f"/api/v1/elm/raw/response?wait=30&seq={self._last_seq}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Ошибка ожидания: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._last_seq = resp.get("seq", seq)
|
||||||
|
|
||||||
|
# 3. Показать
|
||||||
|
self._print_response(resp)
|
||||||
|
|
||||||
|
# ── Вывод ответа ──────────────────────────────────
|
||||||
|
|
||||||
|
def _print_response(self, r: dict):
|
||||||
|
raw = r.get("raw", "")
|
||||||
|
prompt = r.get("prompt", False)
|
||||||
|
elapsed = r.get("elapsed_ms", 0)
|
||||||
|
nbytes = r.get("bytes", 0)
|
||||||
|
error = r.get("error")
|
||||||
|
|
||||||
|
print(f"← {raw!r}" if raw else "← (пусто)")
|
||||||
|
status = []
|
||||||
|
if prompt:
|
||||||
|
status.append("✅ >")
|
||||||
|
else:
|
||||||
|
status.append("❌ нет >")
|
||||||
|
status.append(f"{elapsed}ms")
|
||||||
|
status.append(f"{nbytes}B")
|
||||||
|
if error:
|
||||||
|
status.append(f"⚠️ {error}")
|
||||||
|
print(f" {' | '.join(status)}")
|
||||||
|
|
||||||
|
# ── Специальные команды ───────────────────────────
|
||||||
|
|
||||||
|
def do_status(self, arg):
|
||||||
|
"""!status — статус устройства."""
|
||||||
|
try:
|
||||||
|
s = self._get("/api/v1/elm/raw/status")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {e}")
|
||||||
|
return
|
||||||
|
print(f"Устройство: {'✅ готово' if s.get('device_ready') else '❌ не подключено'}")
|
||||||
|
info = s.get("device_info", {})
|
||||||
|
if info:
|
||||||
|
print(f" ID: {info.get('device_id', '?')}")
|
||||||
|
print(f" ELM: {info.get('elm_version', '?')}")
|
||||||
|
print(f" Протокол: {info.get('protocol', '?')}")
|
||||||
|
print(f" Напряжение: {info.get('voltage', '?')}")
|
||||||
|
print(f"Очередь: {'есть команда' if s.get('pending_cmd') else 'пусто'}")
|
||||||
|
print(f"Последний seq: {s.get('last_response_seq', 0)}")
|
||||||
|
print(f"История: {s.get('history_count', 0)} команд")
|
||||||
|
|
||||||
|
def do_history(self, arg):
|
||||||
|
"""!history [N] — последние N ответов."""
|
||||||
|
try:
|
||||||
|
n = int(arg.strip()) if arg.strip() else 10
|
||||||
|
except ValueError:
|
||||||
|
n = 10
|
||||||
|
try:
|
||||||
|
h = self._get(f"/api/v1/elm/raw/history?n={n}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {e}")
|
||||||
|
return
|
||||||
|
items = h.get("history", [])
|
||||||
|
if not items:
|
||||||
|
print("📭 История пуста.")
|
||||||
|
return
|
||||||
|
print(f"Последние {len(items)} из {h.get('total', 0)}:\n")
|
||||||
|
for r in items:
|
||||||
|
seq = r.get("seq", "?")
|
||||||
|
cmd = r.get("cmd", "?")
|
||||||
|
raw = (r.get("raw") or "")[:60]
|
||||||
|
elapsed = r.get("elapsed_ms", 0)
|
||||||
|
prompt = "✅>" if r.get("prompt") else "❌"
|
||||||
|
error = f" ⚠️{r['error']}" if r.get("error") else ""
|
||||||
|
print(f" #{seq} → {cmd} ← {raw}{'…' if len(r.get('raw',''))>60 else ''} ({elapsed}ms, {prompt}){error}")
|
||||||
|
|
||||||
|
def do_drain(self, arg):
|
||||||
|
"""!drain — попросить Android очистить буфер ELM327."""
|
||||||
|
self._send_and_wait("ATPC", drain_first=False)
|
||||||
|
print("🗑 Буфер очищен.")
|
||||||
|
|
||||||
|
def do_mode(self, arg):
|
||||||
|
"""!mode raw|normal — переключить режим сервера."""
|
||||||
|
mode = arg.strip().lower()
|
||||||
|
if mode not in ("raw", "normal"):
|
||||||
|
print("❌ !mode raw или !mode normal")
|
||||||
|
return
|
||||||
|
on = mode == "raw"
|
||||||
|
try:
|
||||||
|
self._post("/api/v1/elm/raw/mode", {"raw_mode": on})
|
||||||
|
print(f"✅ Режим: {'RAW' if on else 'NORMAL'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ {e}")
|
||||||
|
|
||||||
|
def do_timeout(self, arg):
|
||||||
|
"""!timeout N — таймаут команд (мс)."""
|
||||||
|
try:
|
||||||
|
self.timeout = int(arg.strip())
|
||||||
|
print(f"⏱ Таймаут: {self.timeout}ms")
|
||||||
|
except ValueError:
|
||||||
|
print(f"❌ Нужно число: !timeout 1000")
|
||||||
|
|
||||||
|
def do_help(self, arg):
|
||||||
|
"""!help — справка."""
|
||||||
|
print("""
|
||||||
|
╔══════════════════════════════════════════════════════╗
|
||||||
|
║ ELM327 КОМАНДЫ (вводи как есть): ║
|
||||||
|
║ ATZ ATI ATE0 ATL0 ATS0 ║
|
||||||
|
║ ATH1 ATSP0 ATRV ATDPN ATSTxx ║
|
||||||
|
║ 0105 (ОЖ) 010C (RPM) 010D (Speed) ║
|
||||||
|
║ 03 (DTC) 07 (pending) 0902 (VIN) ║
|
||||||
|
║ ║
|
||||||
|
║ КОНСОЛЬ: ║
|
||||||
|
║ !status — статус устройства ║
|
||||||
|
║ !history [N] — последние ответы ║
|
||||||
|
║ !drain — очистить буфер ║
|
||||||
|
║ !mode raw — включить raw-режим ║
|
||||||
|
║ !timeout N — таймаут команд ║
|
||||||
|
║ !help — эта справка ║
|
||||||
|
║ !quit — выход ║
|
||||||
|
╚══════════════════════════════════════════════════════╝
|
||||||
|
""")
|
||||||
|
|
||||||
|
def do_quit(self, arg):
|
||||||
|
print("👋")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def do_exit(self, arg):
|
||||||
|
return self.do_quit(arg)
|
||||||
|
|
||||||
|
do_q = do_quit
|
||||||
|
do_h = do_help
|
||||||
|
do_s = do_status
|
||||||
|
|
||||||
|
# ── HTTP-хелперы ──────────────────────────────────
|
||||||
|
|
||||||
|
def _get(self, path: str) -> dict:
|
||||||
|
url = f"{self.server}{path}"
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
with urllib.request.urlopen(req, timeout=35) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
def _post(self, path: str, data: dict) -> dict:
|
||||||
|
url = f"{self.server}{path}"
|
||||||
|
body = json.dumps(data).encode()
|
||||||
|
req = urllib.request.Request(url, data=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST")
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
|
||||||
|
|
||||||
|
# ── main ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="ELM327 Remote Relay Console — удалённое управление через Android"
|
||||||
|
)
|
||||||
|
parser.add_argument("--server", default=DEFAULT_SERVER, help=f"URL сервера (default: {DEFAULT_SERVER})")
|
||||||
|
parser.add_argument("--timeout", type=int, default=500, help="Таймаут команд ms (default: 500)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
console = ElmRelay(server=args.server, timeout=args.timeout)
|
||||||
|
|
||||||
|
# Проверим связь с сервером
|
||||||
|
try:
|
||||||
|
s = console._get("/api/v1/elm/raw/status")
|
||||||
|
ready = s.get("device_ready", False)
|
||||||
|
print(f"Сервер: {args.server}")
|
||||||
|
print(f"Устройство: {'✅ готово' if ready else '❌ не подключено (запусти Android-приложение)'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Сервер недоступен: {e}")
|
||||||
|
print(f" Проверь: curl {args.server}/api/v1/ping")
|
||||||
|
|
||||||
|
try:
|
||||||
|
console.cmdloop()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n👋")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -62,6 +62,12 @@ def download_apk():
|
|||||||
return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk")
|
return send_from_directory("static", "app-debug.apk", as_attachment=True, download_name="elmer.apk")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/elm-raw.apk")
|
||||||
|
def download_raw_apk():
|
||||||
|
"""Прямая ссылка на APK Raw Relay."""
|
||||||
|
return send_from_directory("static", "elm-raw.apk", as_attachment=True, download_name="elm-raw.apk")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
print(f"🌐 elmAI Web: http://localhost:5005")
|
print(f"🌐 elmAI Web: http://localhost:5005")
|
||||||
app.run(host="0.0.0.0", port=5005, debug=False)
|
app.run(host="0.0.0.0", port=5005, debug=False)
|
||||||
|
|||||||
@@ -17,10 +17,18 @@
|
|||||||
|
|
||||||
<div class="card" style="text-align:center;margin-bottom:20px;">
|
<div class="card" style="text-align:center;margin-bottom:20px;">
|
||||||
<p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
|
<p style="margin:0 0 10px 0;">📱 Скачай приложение на телефон:</p>
|
||||||
<a href="/static/app-debug.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
|
<a href="/elmer.apk" style="color:#ff6b35;font-size:18px;font-weight:bold;text-decoration:none;">
|
||||||
⬇️ Скачать elmAI APK
|
⬇️ Скачать elmAI APK
|
||||||
</a>
|
</a>
|
||||||
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v1.18.0-dev • нажмите чтобы скачать</p>
|
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v1.18.0-dev • основное приложение</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="text-align:center;margin-bottom:20px;background:#1a1a2e;">
|
||||||
|
<p style="margin:0 0 10px 0;">🔧 Отладка ELM327:</p>
|
||||||
|
<a href="/elm-raw.apk" style="color:#00ff88;font-size:18px;font-weight:bold;text-decoration:none;">
|
||||||
|
⬇️ Скачать ELM Raw Relay
|
||||||
|
</a>
|
||||||
|
<p style="font-size:11px;opacity:0.6;margin:4px 0 0 0;">v0.1.0-dev • ретранслятор команд</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->
|
<!-- Кнопка десктоп-диагностики скрыта — только для разработчика с прямым ELM327 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user