diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..f20285d
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "android/AndrOBD"]
+ path = android/AndrOBD
+ url = https://github.com/fr3ts0n/AndrOBD.git
diff --git a/android/AndrOBD b/android/AndrOBD
new file mode 160000
index 0000000..e056bbc
--- /dev/null
+++ b/android/AndrOBD
@@ -0,0 +1 @@
+Subproject commit e056bbcdc6342ff0c00e57c2460f40b3d84c9812
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..79e538c
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -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")
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..13fef69
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/ru/elmer/client/ElmForwardService.kt b/android/app/src/main/java/ru/elmer/client/ElmForwardService.kt
new file mode 100644
index 0000000..50b81ec
--- /dev/null
+++ b/android/app/src/main/java/ru/elmer/client/ElmForwardService.kt
@@ -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()
+ }
+}
diff --git a/android/app/src/main/java/ru/elmer/client/MainActivity.kt b/android/app/src/main/java/ru/elmer/client/MainActivity.kt
new file mode 100644
index 0000000..e91d217
--- /dev/null
+++ b/android/app/src/main/java/ru/elmer/client/MainActivity.kt
@@ -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()
+ }
+}
diff --git a/android/app/src/main/res/drawable/edit_bg.xml b/android/app/src/main/res/drawable/edit_bg.xml
new file mode 100644
index 0000000..e5ddf37
--- /dev/null
+++ b/android/app/src/main/res/drawable/edit_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..ad0c5a4
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1a43bd7
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,13 @@
+
+
+ Elmer
+ 🚗 Диагностировать
+ Подключение к ELM327…
+ Чтение данных ЭБУ…
+ Отправка на сервер…
+ ✅ Готово
+ ❌ Ошибка Bluetooth
+ ❌ Ошибка сервера
+ URL сервера
+ https://elmer.kube5s.ru/api/diagnose
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..225fa45
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..aaf4b29
--- /dev/null
+++ b/android/build.gradle.kts
@@ -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
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..a9c6248
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -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")
diff --git a/doc/research.md b/doc/research.md
index ae72867..d6ca32e 100644
--- a/doc/research.md
+++ b/doc/research.md
@@ -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 вообще
diff --git a/web/app.py b/web/app.py
index 1f6abfd..207c499 100644
--- a/web/app.py
+++ b/web/app.py
@@ -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("/")
diff --git a/web/raw_endpoint.py b/web/raw_endpoint.py
new file mode 100644
index 0000000..3c05e55
--- /dev/null
+++ b/web/raw_endpoint.py
@@ -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