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 @@ + + + + + + + + + +