android: fork AndrOBD (submodule), thin client (Kotlin), raw-obd endpoint

This commit is contained in:
“Naeel”
2026-05-25 08:48:59 +04:00
parent 5131e41ae1
commit 25bd49a00e
16 changed files with 620 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "android/AndrOBD"]
path = android/AndrOBD
url = https://github.com/fr3ts0n/AndrOBD.git
+1
Submodule android/AndrOBD added at e056bbcdc6
+48
View File
@@ -0,0 +1,48 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "ru.elmer.client"
compileSdk = 34
defaultConfig {
applicationId = "ru.elmer.client"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// AndrOBD library (forked ELM327 protocol)
implementation(project(":library"))
// Minimal Android
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
// HTTP
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// JSON
implementation("org.json:json:20231013")
}
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Только два разрешения -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Elmer">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,218 @@
package ru.elmer.client
import android.app.Service
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothSocket
import android.content.Intent
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.util.Log
import com.fr3ts0n.ecu.prot.obd.ElmProt
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.*
/**
* Фоновый сервис: Bluetooth SPP → ELM327 → HTTP-forward.
* Забираем сырые OBD-ответы из ElmProt и шлём на сервер.
*/
class ElmForwardService : Service() {
private var btSocket: BluetoothSocket? = null
private var inputStream: InputStream? = null
private var outputStream: OutputStream? = null
private val elm = ElmProt()
private val okHttp = OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
private var serverUrl = "https://elmer.kube5s.ru/api/v1/raw-obd"
private val handler = Handler(Looper.getMainLooper())
private var running = false
private var readThread: Thread? = null
companion object {
const val TAG = "ElmForward"
const val ACTION_CONNECT = "ru.elmer.client.CONNECT"
const val ACTION_DISCONNECT = "ru.elmer.client.DISCONNECT"
const val EXTRA_DEVICE_MAC = "device_mac"
const val EXTRA_SERVER_URL = "server_url"
const val BROADCAST_STATUS = "ru.elmer.client.STATUS"
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
it.getStringExtra(EXTRA_SERVER_URL)?.let { url -> serverUrl = url }
}
when (intent?.action) {
ACTION_CONNECT -> {
val mac = intent.getStringExtra(EXTRA_DEVICE_MAC) ?: return START_NOT_STICKY
connect(mac)
}
ACTION_DISCONNECT -> disconnect()
}
return START_STICKY
}
// ── Bluetooth connection ──────────────────────────
private fun connect(mac: String) {
broadcast("Подключение к $mac...")
try {
val adapter = BluetoothAdapter.getDefaultAdapter()
val device: BluetoothDevice = adapter.getRemoteDevice(mac)
// UUID для SPP (Serial Port Profile)
val uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
btSocket = device.createRfcommSocketToServiceRecord(uuid)
adapter.cancelDiscovery()
btSocket?.connect()
inputStream = btSocket?.inputStream
outputStream = btSocket?.outputStream
// Настраиваем ELM протокол
elm.addTelegramWriter { buffer ->
outputStream?.write(String(buffer).toByteArray())
}
broadcast("Подключено к ELM327")
startReading()
initElm()
} catch (e: Exception) {
Log.e(TAG, "Bluetooth error", e)
broadcast("Ошибка: ${e.message}")
}
}
private fun initElm() {
// Отправляем AT-команды инициализации
sendRaw("ATZ\r") // сброс
Thread.sleep(1000)
sendRaw("ATE0\r") // эхо off
sendRaw("ATL0\r") // linefeed off
sendRaw("ATSP0\r") // авто-протокол
sendRaw("ATH1\r") // заголовки on
}
// ── Reading loop ──────────────────────────────────
private fun startReading() {
running = true
readThread = Thread {
val buffer = ByteArray(256)
val sb = StringBuilder()
while (running) {
try {
val bytesRead = inputStream?.read(buffer) ?: -1
if (bytesRead == -1) break
for (i in 0 until bytesRead) {
val c = buffer[i].toInt().toChar()
if (c == '>' || c == '\r') {
if (sb.isNotEmpty()) {
val raw = sb.toString().trim()
if (raw.isNotEmpty()) {
elm.handleTelegram(raw.toCharArray())
forwardToServer(raw)
}
sb.clear()
}
} else if (c != '\n') {
sb.append(c)
}
}
} catch (e: IOException) {
if (running) {
Log.e(TAG, "Read error", e)
broadcast("Соединение потеряно")
}
break
}
}
disconnect()
}.apply {
name = "ELM-Reader"
isDaemon = true
start()
}
broadcast("Чтение данных...")
}
// ── HTTP forward ──────────────────────────────────
private fun forwardToServer(rawLine: String) {
try {
val json = JSONObject().apply {
put("raw", rawLine)
put("timestamp", System.currentTimeMillis())
}
val body = json.toString()
.toRequestBody("application/json; charset=utf-8".toMediaType())
val request = Request.Builder()
.url(serverUrl)
.post(body)
.build()
okHttp.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
// Молча, сервер недоступен — не критично
}
override fun onResponse(call: Call, response: Response) {
response.close()
}
})
} catch (e: Exception) {
Log.w(TAG, "Forward error", e)
}
}
// ── Utils ─────────────────────────────────────────
fun sendRaw(cmd: String) {
try {
outputStream?.write(cmd.toByteArray())
outputStream?.flush()
} catch (e: Exception) {
Log.e(TAG, "Send error", e)
}
}
private fun broadcast(msg: String) {
val intent = Intent(BROADCAST_STATUS).apply {
putExtra("message", msg)
}
sendBroadcast(intent)
}
private fun disconnect() {
running = false
try {
btSocket?.close()
} catch (_: Exception) {}
broadcast("Отключено")
stopSelf()
}
override fun onDestroy() {
disconnect()
super.onDestroy()
}
}
@@ -0,0 +1,108 @@
package ru.elmer.client
import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.*
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.widget.Button
import android.widget.EditText
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
/**
* Тонкий клиент Elmer — одна кнопка.
* Подключается к ELM327 по Bluetooth, шлёт сырые OBD-ответы на сервер.
*/
class MainActivity : AppCompatActivity() {
private lateinit var btnConnect: Button
private lateinit var tvStatus: TextView
private lateinit var etUrl: EditText
private val btAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter()
private var elmDevice: BluetoothDevice? = null
companion object {
const val REQUEST_BT_PERMISSIONS = 1
const val REQUEST_ENABLE_BT = 2
}
private val statusReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val msg = intent?.getStringExtra("message") ?: return
runOnUiThread { tvStatus.text = msg }
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
btnConnect = findViewById(R.id.btn_connect)
tvStatus = findViewById(R.id.tv_status)
etUrl = findViewById(R.id.et_server_url)
registerReceiver(statusReceiver, IntentFilter(ElmForwardService.BROADCAST_STATUS),
ContextCompat.RECEIVER_NOT_EXPORTED)
btnConnect.setOnClickListener { startDiagnostics() }
}
private fun startDiagnostics() {
if (btAdapter == null) {
tvStatus.text = "❌ Bluetooth не поддерживается"
return
}
// Проверка разрешений (Android 12+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT)
!= PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN),
REQUEST_BT_PERMISSIONS
)
return
}
}
if (!btAdapter.isEnabled) {
startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BT)
return
}
// Ищем спаренный ELM327 (обычно название содержит "OBD" или "ELM")
val paired = btAdapter.bondedDevices
elmDevice = paired.find { d ->
d.name.uppercase().let { it.contains("OBD") || it.contains("ELM") }
}
if (elmDevice == null) {
tvStatus.text = "❌ ELM327 не найден. Сопряги устройство в настройках Bluetooth."
return
}
val intent = Intent(this, ElmForwardService::class.java).apply {
action = ElmForwardService.ACTION_CONNECT
putExtra(ElmForwardService.EXTRA_DEVICE_MAC, elmDevice!!.address)
putExtra(ElmForwardService.EXTRA_SERVER_URL,
etUrl.text.toString().ifBlank { "https://elmer.kube5s.ru/api/v1/raw-obd" })
}
ContextCompat.startForegroundService(this, intent)
tvStatus.text = "⏳ Подключение..."
}
override fun onDestroy() {
unregisterReceiver(statusReceiver)
super.onDestroy()
}
}
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#16213e"/>
<corners android:radius="8dp"/>
<stroke android:color="#0f3460" android:width="1dp"/>
</shape>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="24dp"
android:gravity="center_horizontal"
android:background="#1a1a2e">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="🔧 Elmer"
android:textSize="32sp"
android:textColor="#ff6b35"
android:layout_marginTop="40dp"
android:layout_marginBottom="8dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Диагностика авто через ИИ"
android:textSize="14sp"
android:textColor="#888888"
android:layout_marginBottom="40dp" />
<EditText
android:id="@+id/et_server_url"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="URL сервера"
android:text="https://elmer.kube5s.ru/api/v1/raw-obd"
android:textColor="#e0e0e0"
android:textColorHint="#666666"
android:textSize="14sp"
android:padding="12dp"
android:background="@drawable/edit_bg"
android:layout_marginBottom="16dp"
android:inputType="textUri" />
<Button
android:id="@+id/btn_connect"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="🚗 Диагностировать"
android:textSize="18sp"
android:textColor="#ffffff"
android:backgroundTint="#ff6b35"
android:padding="16dp"
android:layout_marginBottom="24dp" />
<TextView
android:id="@+id/tv_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Готов к подключению"
android:textSize="16sp"
android:textColor="#e0e0e0"
android:gravity="center"
android:padding="16dp" />
</LinearLayout>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Elmer</string>
<string name="btn_diagnose">🚗 Диагностировать</string>
<string name="connecting">Подключение к ELM327…</string>
<string name="reading">Чтение данных ЭБУ…</string>
<string name="sending">Отправка на сервер…</string>
<string name="done">✅ Готово</string>
<string name="error_bt">❌ Ошибка Bluetooth</string>
<string name="error_server">❌ Ошибка сервера</string>
<string name="server_url_hint">URL сервера</string>
<string name="server_url_default">https://elmer.kube5s.ru/api/diagnose</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Elmer" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="colorPrimary">#ff6b35</item>
<item name="colorPrimaryDark">#1a1a2e</item>
<item name="colorAccent">#4ecca3</item>
<item name="android:windowBackground">#1a1a2e</item>
<item name="android:textColor">#e0e0e0</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file
plugins {
id("com.android.application") version "8.2.0" apply false
id("org.jetbrains.kotlin.android") version "1.9.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+21
View File
@@ -0,0 +1,21 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "ElmerClient"
include(":app")
include(":library")
// AndrOBD library submodule
project(":library").projectDir = file("AndrOBD/library")
+36 -1
View File
@@ -12,7 +12,42 @@
DeepSeek дал полный подробный анализ по логам VCDS с тестового проезда.
Лучше любого гугла. Гипотеза подтверждена.
## 2026-05-25 — Платформенные ограничения
## 2026-05-25 — AndrOBD (fr3ts0n)
- **Разработчик:** Erwin Scheuch-Heilig (fr3ts0n), Австрия, `erwin.scheuch-heilig@gmx.at`
- **Репозиторий:** `github.com/fr3ts0n/AndrOBD`
- **Лицензия:** GPLv2
- **Год начала:** 2015
- **Архитектура:** библиотека (`library/`) + приложение (`androbd/`) + плагины
### Ключевые файлы для форка
| Файл | Что делает |
|---|---|
| `library/.../ElmProt.java` | Протокол ELM327: AT-команды, парсинг ответов, DTC, PID, VIN, адаптивные таймауты |
| `library/.../ObdProt.java` | OBD2-сервисы, декодирование PID, битовые маски, NRC-коды |
| `androbd/.../BtCommService.java` | Bluetooth SPP (классический, не BLE) |
| `androbd/.../BleCommService.java` | Bluetooth BLE (запасной вариант) |
| `androbd/.../NetworkCommService.java` | WiFi OBD адаптеры |
### Что НЕ берём
- `androbd/` — весь UI: графики, дашборды, CSV-экспорт, плагины (MQTT, GPS, сенсоры)
- `fastlane/` — метаданные для магазинов
### Стратегия форка
- **НЕ копипаст.** Используем Git submodule на AndrOBD
- Подключаем только `library/` модуль
- Наш код — отдельный `app/` модуль с тонким UI
- Лицензия GPLv2 на весь клиент (совместимо с открытостью)
- AndrOBD протестирован с 2015 года — экономим недели отладки краевых случаев
### Решение
Библиотека AndrOBD (GPLv2) покроет 100% ELM327-клоунов и их глюки.
Наш тонкий UI + HTTP-forward — пишем сами.
Перед RuStore — форкнуть обязательно. Для MVP (одна машина) — опционально, наш `elm.py` справится.
- Chrome Android: Web Bluetooth API — только BLE. ELM327 использует Bluetooth Classic SPP → НЕСОВМЕСТИМЫ
- Web Serial API — не поддерживается на Android вообще
+5
View File
@@ -6,6 +6,7 @@
"""
import sys
import logging
from pathlib import Path
# Добавляем корень проекта в PYTHONPATH
@@ -18,9 +19,13 @@ from elmer.db import Database
from elmer.diagnose import Diagnoser
from elmer.elm import ELM327
from elmer.prompts import SYSTEM_PROMPT, build_user_prompt
from web.raw_endpoint import register as register_raw_endpoint
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(message)s")
app = Flask(__name__)
config = load()
register_raw_endpoint(app)
@app.route("/")
+52
View File
@@ -0,0 +1,52 @@
"""Эндпоинт /api/v1/raw-obd — приём сырых OBD-ответов от тонкого клиента.
Формат входящих данных:
{"raw": "41 05 5A", "timestamp": 1716652800000}
Сервер логирует, складывает в очередь, и по готовности парсит + отправляет в LLM.
"""
import json
import logging
import threading
from collections import deque
from datetime import datetime, timezone
logger = logging.getLogger("elmer.raw")
# Буфер сырых сообщений на сессию (в production — Redis очередь)
raw_buffer: deque[dict] = deque()
buffer_lock = threading.Lock()
def register(app):
"""Регистрирует эндпоинт на Flask-приложении."""
@app.route("/api/v1/raw-obd", methods=["POST"])
def raw_obd():
from flask import request, jsonify
data = request.get_json(silent=True)
if not data or "raw" not in data:
return jsonify({"error": "missing 'raw' field"}), 400
raw = data["raw"].strip()
ts = data.get("timestamp", int(datetime.now(timezone.utc).timestamp() * 1000))
# Логируем в консоль
logger.info(f"[RAW] {raw}")
# Складываем в буфер
with buffer_lock:
raw_buffer.append({
"raw": raw,
"timestamp": ts,
})
return jsonify({
"status": "ok",
"received": raw,
"buffer_size": len(raw_buffer),
})
return app