android: fork AndrOBD (submodule), thin client (Kotlin), raw-obd endpoint
This commit is contained in:
Submodule
+1
Submodule android/AndrOBD added at e056bbcdc6
@@ -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")
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user