diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 0000000..b3b933e
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,7 @@
+version: 2
+build:
+ os: ubuntu-22.04
+ tools:
+ python: "3.12"
+sphinx:
+ configuration: docs/source/conf.py
diff --git a/README.md b/README.md
index 481653c..5034a49 100644
--- a/README.md
+++ b/README.md
@@ -1,113 +1,203 @@
-# ABComm - Advanced Bluetooth Relay Control
+# ABComm - Advanced Bluetooth & Wi-Fi Relay Control
-**ABComm** is a futuristic Android application designed for high-performance control of relay devices via Bluetooth Low Energy (BLE).
+**ABComm** is a futuristic Android client application designed for high-performance, real-time control of 8-channel relay boards powered by **Raspberry Pi Pico** running **microHIL** firmware.
-Developed with **[Kotlin](https://kotlinlang.org/)** and **Jetpack Compose**.
+Developed with **[Kotlin](https://kotlinlang.org/)**, **Android Jetpack**, and **Kotlin Coroutines**.
-This application provides a "Cyberpunk" styled interface to manage up to 8 independent channels (relays) with real-time status monitoring and secure communication protocols.
+The application features a Cyberpunk-styled interface supporting dual-mode connectivity (**Bluetooth Low Energy / RFCOMM** and **Wi-Fi TCP Socket**), automated hardware telemetry synchronization, and robust error handling.
[](https://github.com/electux/abcomm/actions)
[](https://opensource.org/licenses/MIT)
[](https://github.com/electux/abcomm/issues)
[](https://github.com/electux/abcomm/graphs/contributors)
-
-
-**Table of Contents**
+---
+
+## Table of Contents
-- [🚀 Installation](#-installation)
- - [Build from Source](#build-from-source)
- - [Download APK](#download-apk)
-- [📦 Dependencies](#-dependencies)
-- [📁 Project Structure](#-project-structure)
- [✨ Features](#-features)
-- [🛠 Usage](#-usage)
+- [📡 microHIL Communication Protocol](#-microhil-communication-protocol)
+- [🚀 Installation & Building](#-installation--building)
+ - [Build from Source](#build-from-source)
+ - [Run Unit Tests](#run-unit-tests)
+- [📦 Dependencies & Permissions](#-dependencies--permissions)
+- [📁 Project Architecture](#-project-architecture)
+- [🛠 Usage Guide](#-usage-guide)
+ - [Bluetooth (BLE / RFCOMM) Mode](#bluetooth-ble--rfcomm-mode)
+ - [Wi-Fi (TCP Socket) Mode](#wi-fi-tcp-socket-mode)
+ - [Testing with Python Mock Server](#testing-with-python-mock-server)
- [👥 Contributing](#-contributing)
-- [📄 Copyright and licence](#-copyright-and-licence)
+- [📄 License](#-license)
+
+---
+
+## ✨ Features
+
+* **Dual Connectivity**: Seamlessly switch between **Bluetooth (BLE / RFCOMM)** and **Wi-Fi (TCP Socket)**.
+* **Settings Persistence**: User-configured Wi-Fi IP address and Port are securely persisted via SharedPreferences.
+* **8-Channel Independent Control**: Instant toggle for individual channels (1 to 8) with dynamic active/inactive states.
+* **Master Controls**: Quick-action **ALL ON** and **ALL OFF** buttons for simultaneous relay switching.
+* **Automated Telemetry Sync**: Automatically queries and displays hardware Board ID (`mh:333:2023:0`), Firmware Version (`microHIL v1.0.0`), and live relay states on connect.
+* **Manual Sync & Device Reboot**: Dedicated **SYNC** button for manual state refreshing and **RESET** button with a confirmation dialog.
+* **Robust Disconnection Handling**: Immediate socket cleanup and automatic UI state reset to `OFF` when the device disconnects or powers down.
+* **Clean Architecture**: 100% Type-Safe (`ConnectionStatus`, `DeviceResponse`), Dependency Inversion (DIP), Open/Closed (OCP) response matchers, and Coroutine-based background I/O (`Dispatchers.IO`).
+
+---
+
+## 📡 microHIL Communication Protocol
-
+All messages exchanged between the ABComm Android client and the Raspberry Pi Pico server are framed with `<` at the start and `>` at the end:
-### 🚀 Installation
+| Action | Command Frame | Response Format |
+| :--- | :--- | :--- |
+| **Toggle Channel ON** | `` | `` |
+| **Toggle Channel OFF** | `` | `` |
+| **All Channels ON** | `` | `` |
+| **All Channels OFF** | `` | `` |
+| **Query All Channels** | `` | `` |
+| **Query Board ID** | `` | `` |
+| **Query Firmware Version** | `` | `` |
+| **System Reboot** | `` | `` |
+| **Set Channel Mask** | `` | `` |
-Developed and tested on **Android 14 (API 34)** and newer.
+---
-##### Build from Source
+## 🚀 Installation & Building
-You can build **ABComm** using Android Studio or Gradle.
+Developed and tested on **Android 14 (API 34)** and backwards compatible down to **Android 7.0 (API 24)**.
+
+### Build from Source
```bash
-# Clone the repository
+# 1. Clone repository
git clone https://github.com/electux/abcomm.git
cd abcomm
-# Build Debug APK
+# 2. Build Debug APK
./gradlew assembleDebug
+
+# Output APK path:
+# app/build/outputs/apk/debug/app-debug.apk
```
-##### Download APK
+### Run Unit Tests
+
+Execute the complete test suite (Protocol formatters, Stream parsers, OCP Matchers, ViewModel state, and Repositories):
-Navigate to the **[Releases](https://github.com/electux/abcomm/releases/)** page to download the latest signed APK or App Bundle.
+```bash
+./gradlew testDebugUnitTest
+```
-### 📦 Dependencies
+---
-**ABComm** requires the following permissions and hardware:
+## 📦 Dependencies & Permissions
-* **Bluetooth Low Energy (BLE)** capable device.
-* **Android 7.0 (API 24)** or higher.
-* Permissions: `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `ACCESS_FINE_LOCATION`.
+The app declares and dynamically requests appropriate permissions:
-### 📁 Project Structure
+* **Bluetooth**: `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT` (Android 12+ / API 31+), `ACCESS_FINE_LOCATION` (Android 11 and earlier).
+* **Wi-Fi / Network**: `INTERNET`, `ACCESS_NETWORK_STATE`.
-**ABComm** follows the MVVM (Model-View-ViewModel) architecture.
+---
-Project structure
+## 📁 Project Architecture
-
-Click to expand app structure
+The codebase strictly follows the **Single Type per File** and **SOLID** principles, organized into domain packages:
```bash
- app/
- ├── src/
- │ ├── main/
- │ │ ├── java/com/abcomm/
- │ │ │ ├── MainActivity.kt # Main UI Entry Point
- │ │ │ ├── MainViewModel.kt # UI State & Logic
- │ │ │ └── BluetoothService.kt # BLE Communication Provider
- │ │ └── res/
- │ │ ├── drawable/ # Cyber-style Icons
- │ │ └── values/ # Futuristic Color Palette
- │ └── test/ # Unit Tests (MockK)
- └── build.gradle.kts # Build Configuration
+app/src/main/java/com/abcomm/
+├── protocol/
+│ ├── MicrohilProtocolConstants.kt # Protocol frame delimiters and command keywords
+│ ├── CommandFormatter.kt # Contract for outbound command formatting
+│ ├── MicrohilCommandFormatter.kt # Implementation of CommandFormatter
+│ ├── FrameParser.kt # Stream framing contract (<...>)
+│ ├── MicrohilFrameParser.kt # Chunked stream frame extractor
+│ ├── DeviceResponse.kt # Sealed interface for typed device responses
+│ ├── ResponseParser.kt # Response parsing contract
+│ ├── ResponseMatcher.kt # Extensible response matcher interface (OCP)
+│ ├── MicrohilResponseParser.kt # ResponseParser delegating to matcher list
+│ └── matchers/ # Individual pattern matchers for each response
+│ ├── ChannelStateMatcher.kt
+│ ├── AllChannelsStateMatcher.kt
+│ ├── AllChannelsSnapshotMatcher.kt
+│ ├── MaskAppliedMatcher.kt
+│ ├── BoardIdMatcher.kt
+│ ├── FirmwareVersionMatcher.kt
+│ └── SystemResettingMatcher.kt
+│
+├── communication/
+│ ├── ConnectionMode.kt # Enum: BLE, WIFI
+│ ├── ConnectionTarget.kt # Sealed interface: Bluetooth(device), Wifi(host, port)
+│ ├── ConnectionStatus.kt # Sealed interface: Disconnected, Connecting, Connected, Error
+│ ├── ConnectionController.kt # Lifecycle management contract
+│ ├── CommandSender.kt # Command dispatch contract
+│ ├── ConnectionObservable.kt # Status and response observer contract
+│ ├── CommunicationProvider.kt # Composite provider interface
+│ ├── CommunicationProviderRegistry.kt # Dynamic provider resolution contract
+│ ├── DefaultCommunicationProviderRegistry.kt
+│ ├── BluetoothService.kt # RFCOMM Bluetooth provider (Coroutines / Dispatchers.IO)
+│ └── WifiService.kt # TCP Socket Wi-Fi provider (Coroutines / Dispatchers.IO)
+│
+├── settings/
+│ ├── AppSettings.kt # Configuration data model and port boundaries
+│ ├── AppSettingsRepository.kt # Storage abstraction contract
+│ └── SharedPreferencesSettingsRepository.kt
+│
+├── ui/
+│ ├── MainUiState.kt # Immutable UI State data model
+│ ├── MainViewModel.kt # State machine orchestrating UI & hardware
+│ ├── MainViewModelFactory.kt # Dependency injection factory
+│ ├── BluetoothPermissionChecker.kt # Permission checker interface
+│ ├── BluetoothPermissionHelper.kt # Android SDK version-aware permission helper
+│ ├── BluetoothDeviceProvider.kt # Bluetooth adapter abstraction interface
+│ └── BluetoothDeviceManager.kt # Paired device manager
+│
+└── MainActivity.kt # Primary Android Activity view layer
```
-
-#### ✨ Features
+---
-* **Futuristic UI**: High-contrast "Cyberpunk" design with custom vector graphics.
-* **8-Channel Control**: Independent toggle for each relay with individual status indicators.
-* **Real-time Monitoring**: Instant feedback on connection status and relay states.
-* **Secure BLE Link**: Efficient communication protocol using unique UUIDs.
-* **Master Control**: Single-tap "All Channels ON" and "Force Shutdown" functions.
-* **Unit Tested**: Robust logic verified with 100% core coverage.
+## 🛠 Usage Guide
-### 🛠 Usage
+### Bluetooth (BLE / RFCOMM) Mode
-1. **Enable Bluetooth**: Ensure Bluetooth is active on your smartphone.
-2. **Scan for Device**: Launch ABComm and tap **CONNECT** to scan for available relay boards.
-3. **Control**: Use the channel grid to toggle specific relays or use the Master Control for group actions.
+1. Select the **BLE** mode toggle at the top of the screen.
+2. Tap **CONNECT**.
+3. Grant Bluetooth permissions if prompted.
+4. Select your Raspberry Pi Pico device from the paired devices list.
+5. Once connected, device info and current relay states will load automatically.
-### 👥 Contributing
+### Wi-Fi (TCP Socket) Mode
-[Contributing to abcomm](CONTRIBUTING.md)
+1. Select the **WIFI** mode toggle at the top.
+2. Enter the **IP Address** and **Port** of your microHIL device (e.g. `192.168.1.100`, Port `5000`). Values are automatically saved for subsequent app launches.
+3. Tap **CONNECT**.
+4. Telemetry and relay buttons will update automatically upon connection.
-### 📄 Copyright and licence
+### Testing with Python Mock Server
+
+You can test Wi-Fi communication without physical hardware using the included mock server:
+
+```bash
+# Run the mock server from the repository root
+python3 wifi/wifi_server.py
+```
+
+The mock server binds to `0.0.0.0:5000` and emulates real microHIL firmware behavior (board ID, version, channel toggling, and snapshots).
+
+---
+
+## 👥 Contributing
+
+Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
+
+---
+
+## 📄 License
[](https://opensource.org/licenses/MIT)
Copyright (C) 2026 by [electux.github.io/abcomm](https://github.com/electux)
**ABComm** is open-source software licensed under the **MIT License**.
-
-Feel free to fork, modify, and improve the project!
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index caaba80..20dcfd1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,8 @@
+
+
-
+
Unit)
-}
-
-class BluetoothService : CommunicationProvider {
-
- private var socket: BluetoothSocket? = null
- private val uuid: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB")
- private var onStatusChange: ((String) -> Unit)? = null
-
- override fun setStatusListener(listener: (String) -> Unit) {
- this.onStatusChange = listener
- }
-
- @SuppressLint("MissingPermission")
- override fun connect(device: Any) {
- if (device !is BluetoothDevice) return
-
- Thread {
- try {
- onStatusChange?.invoke("Connecting")
- // Reflection hack for better Linux compatibility
- socket = device.javaClass.getMethod("createRfcommSocket", Int::class.javaPrimitiveType)
- .invoke(device, 4) as BluetoothSocket
- socket?.connect()
- onStatusChange?.invoke("Connected to ${device.name}")
- } catch (e: Exception) {
- Log.e("BluetoothService", "Connection failed", e)
- onStatusChange?.invoke("Connection failed")
- disconnect()
- }
- }.start()
- }
-
- override fun sendCommand(command: String) {
- socket?.let {
- if (it.isConnected) {
- try {
- it.outputStream.write(command.toByteArray())
- Log.d("BluetoothService", "Sent: $command")
- } catch (e: IOException) {
- Log.e("BluetoothService", "Error sending data", e)
- onStatusChange?.invoke("Send failed")
- }
- } else {
- onStatusChange?.invoke("Disconnected")
- }
- } ?: onStatusChange?.invoke("Disconnected")
- }
-
- override fun disconnect() {
- try {
- socket?.close()
- socket = null
- onStatusChange?.invoke("Disconnected")
- } catch (e: IOException) {
- Log.e("BluetoothService", "Error closing socket", e)
- }
- }
-
- override fun isConnected(): Boolean = socket?.isConnected ?: false
-}
diff --git a/app/src/main/java/com/abcomm/MainActivity.kt b/app/src/main/java/com/abcomm/MainActivity.kt
index 2113521..2a9f53c 100644
--- a/app/src/main/java/com/abcomm/MainActivity.kt
+++ b/app/src/main/java/com/abcomm/MainActivity.kt
@@ -1,15 +1,9 @@
package com.abcomm
-import android.Manifest
import android.annotation.SuppressLint
-import android.bluetooth.BluetoothAdapter
-import android.bluetooth.BluetoothDevice
-import android.bluetooth.BluetoothManager
-import android.content.Context
-import android.content.pm.PackageManager
import android.content.res.ColorStateList
-import android.os.Build
import android.os.Bundle
+import android.view.View
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
@@ -20,30 +14,39 @@ import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.Lifecycle
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
+import com.abcomm.communication.ConnectionMode
+import com.abcomm.communication.ConnectionStatus
+import com.abcomm.communication.ConnectionTarget
import com.abcomm.databinding.ActivityMainBinding
+import com.abcomm.settings.AppSettings
+import com.abcomm.settings.AppSettingsRepository
+import com.abcomm.settings.SharedPreferencesSettingsRepository
+import com.abcomm.ui.BluetoothDeviceManager
+import com.abcomm.ui.BluetoothDeviceProvider
+import com.abcomm.ui.BluetoothPermissionChecker
+import com.abcomm.ui.BluetoothPermissionHelper
+import com.abcomm.ui.MainViewModel
+import com.abcomm.ui.MainViewModelFactory
import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.launch
+/**
+ * Primary activity managing presentation, user interactions, and visual telemetry feedback.
+ */
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
- private var bluetoothAdapter: BluetoothAdapter? = null
+ private val permissionHelper: BluetoothPermissionChecker = BluetoothPermissionHelper()
+ private val deviceManager: BluetoothDeviceProvider by lazy { BluetoothDeviceManager(applicationContext) }
+
+ private val settingsRepository: AppSettingsRepository by lazy {
+ SharedPreferencesSettingsRepository(applicationContext)
+ }
- // Simple Dependency Injection without Hilt for now
private val viewModel: MainViewModel by viewModels {
- object : ViewModelProvider.Factory {
- override fun create(modelClass: Class): T {
- if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
- @Suppress("UNCHECKED_CAST")
- return MainViewModel(BluetoothService()) as T
- }
- throw IllegalArgumentException("Unknown ViewModel class")
- }
- }
+ MainViewModelFactory(settingsRepository)
}
private val requestPermissionLauncher = registerForActivityResult(
@@ -68,22 +71,53 @@ class MainActivity : AppCompatActivity() {
insets
}
- val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
- bluetoothAdapter = bluetoothManager.adapter
-
+ loadSavedPreferences()
setupClickListeners()
observeUiState()
}
+ private fun loadSavedPreferences() {
+ val settings = settingsRepository.getSettings()
+ binding.etWifiIp.setText(settings.wifiIp)
+ binding.etWifiPort.setText(settings.wifiPort.toString())
+ }
+
private fun setupClickListeners() {
+ binding.toggleModeGroup.addOnButtonCheckedListener { _, checkedId, isChecked ->
+ if (isChecked) {
+ val selectedMode = when (checkedId) {
+ R.id.btn_mode_wifi -> ConnectionMode.WIFI
+ else -> ConnectionMode.BLE
+ }
+ viewModel.setConnectionMode(selectedMode)
+ }
+ }
+
binding.btnConnect.setOnClickListener {
if (viewModel.isConnected()) {
viewModel.disconnect()
} else {
- checkPermissionsAndConnect()
+ handleConnectAction()
}
}
+ binding.btnSync.setOnClickListener {
+ viewModel.queryAllStatus()
+ viewModel.queryDeviceInfo()
+ Toast.makeText(this, getString(R.string.toast_syncing), Toast.LENGTH_SHORT).show()
+ }
+
+ binding.btnReset.setOnClickListener {
+ AlertDialog.Builder(this, R.style.CyberDialogTheme)
+ .setTitle(getString(R.string.dialog_reboot_title))
+ .setMessage(getString(R.string.dialog_reboot_message))
+ .setPositiveButton(getString(R.string.dialog_reboot_confirm)) { _, _ ->
+ viewModel.resetDevice()
+ }
+ .setNegativeButton(getString(R.string.dialog_abort), null)
+ .show()
+ }
+
val buttons = listOf(
binding.btn1, binding.btn2, binding.btn3, binding.btn4,
binding.btn5, binding.btn6, binding.btn7, binding.btn8
@@ -98,43 +132,102 @@ class MainActivity : AppCompatActivity() {
binding.idAllOff.setOnClickListener { viewModel.setAllChannels(false) }
}
+ private fun handleConnectAction() {
+ when (viewModel.uiState.value.connectionMode) {
+ ConnectionMode.BLE -> checkPermissionsAndConnectBle()
+ ConnectionMode.WIFI -> connectWifi()
+ }
+ }
+
+ private fun connectWifi() {
+ val ip = binding.etWifiIp.text?.toString()?.trim().orEmpty()
+ val portStr = binding.etWifiPort.text?.toString()?.trim().orEmpty()
+ val port = portStr.toIntOrNull()
+
+ if (ip.isEmpty() || port == null || port !in AppSettings.MIN_PORT..AppSettings.MAX_PORT) {
+ Toast.makeText(this, getString(R.string.error_invalid_wifi), Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ viewModel.setWifiTarget(ip, port)
+ viewModel.connect(ConnectionTarget.Wifi(host = ip, port = port))
+ }
+
private fun observeUiState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
- updateStatusUI(state.status, state.isConnected, state.isConnecting)
+ updateModeUI(state.connectionMode)
+ updateStatusUI(state.connectionStatus)
+ updateDeviceInfoUI(state.boardId, state.firmwareVersion, state.isConnected)
updateChannelButtons(state.channelStates, state.isConnected)
+ updateResponseUI(state.lastResponse)
}
}
}
}
- private fun updateStatusUI(status: String, isConnected: Boolean, isConnecting: Boolean) {
- val formattedStatus = if (status.startsWith("Connected to", ignoreCase = true)) {
- val deviceName = status.substringAfter("Connected to ").uppercase()
- getString(R.string.status_label, "\n" + getString(R.string.status_connected, deviceName))
- } else if (isConnecting) {
- getString(R.string.status_label, getString(R.string.status_initializing))
+ private fun updateModeUI(mode: ConnectionMode) {
+ val isWifi = mode == ConnectionMode.WIFI
+ binding.layoutWifiSettings.visibility = if (isWifi) View.VISIBLE else View.GONE
+ binding.toggleModeGroup.check(if (isWifi) R.id.btn_mode_wifi else R.id.btn_mode_ble)
+ }
+
+ private fun updateDeviceInfoUI(boardId: String, firmwareVersion: String, isConnected: Boolean) {
+ if (isConnected && (boardId.isNotEmpty() || firmwareVersion.isNotEmpty())) {
+ binding.tvDeviceInfo.visibility = View.VISIBLE
+ val idText = if (boardId.isNotEmpty()) boardId else getString(R.string.label_not_available)
+ val fwText = if (firmwareVersion.isNotEmpty()) firmwareVersion else getString(R.string.label_not_available)
+ binding.tvDeviceInfo.text = getString(R.string.device_info_label, idText, fwText)
+ } else {
+ binding.tvDeviceInfo.visibility = View.GONE
+ }
+ binding.layoutQuickActions.visibility = if (isConnected) View.VISIBLE else View.GONE
+ }
+
+ private fun updateResponseUI(lastResponse: String) {
+ if (lastResponse.isNotEmpty()) {
+ binding.tvLastResponse.visibility = View.VISIBLE
+ binding.tvLastResponse.text = getString(R.string.last_response_label, lastResponse)
} else {
- getString(R.string.status_label, getString(R.string.status_disconnected))
+ binding.tvLastResponse.visibility = View.GONE
+ }
+ }
+
+ private fun updateStatusUI(status: ConnectionStatus) {
+ val formattedStatus = when (status) {
+ is ConnectionStatus.Connected -> {
+ val target = status.targetName.uppercase()
+ getString(R.string.status_label, "\n" + getString(R.string.status_connected, target))
+ }
+ is ConnectionStatus.Connecting -> {
+ val label = if (status.target.isNotEmpty()) "CONNECTING TO ${status.target}" else "CONNECTING"
+ getString(R.string.status_label, label)
+ }
+ is ConnectionStatus.Error -> {
+ getString(R.string.status_label, status.message.uppercase())
+ }
+ is ConnectionStatus.Disconnected -> {
+ getString(R.string.status_label, getString(R.string.status_disconnected))
+ }
}
-
+
binding.tvStatus.text = formattedStatus
-
- when {
- isConnected -> {
+
+ when (status) {
+ is ConnectionStatus.Connected -> {
binding.btnConnect.text = getString(R.string.btn_disconnect)
binding.btnConnect.setTextColor(ContextCompat.getColor(this, R.color.cyber_red))
binding.btnConnect.setStrokeColorResource(R.color.cyber_red)
binding.btnConnect.isEnabled = true
}
- isConnecting -> {
+ is ConnectionStatus.Connecting -> {
binding.btnConnect.text = getString(R.string.status_initializing)
binding.btnConnect.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray))
binding.btnConnect.setStrokeColorResource(android.R.color.darker_gray)
binding.btnConnect.isEnabled = false
}
- else -> {
+ is ConnectionStatus.Disconnected, is ConnectionStatus.Error -> {
binding.btnConnect.text = getString(R.string.btn_connect)
binding.btnConnect.setTextColor(ContextCompat.getColor(this, R.color.cyber_cyan))
binding.btnConnect.setStrokeColorResource(R.color.cyber_cyan)
@@ -163,7 +256,7 @@ class MainActivity : AppCompatActivity() {
ContextCompat.getColor(this, android.R.color.transparent)
}
button.backgroundTintList = ColorStateList.valueOf(color)
-
+
if (isChecked) {
button.setTextColor(ContextCompat.getColor(this, R.color.black))
button.setStrokeColorResource(R.color.cyber_green)
@@ -175,19 +268,8 @@ class MainActivity : AppCompatActivity() {
}
}
- private fun checkPermissionsAndConnect() {
- val permissions = mutableListOf()
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- permissions.add(Manifest.permission.BLUETOOTH_SCAN)
- permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
- } else {
- permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
- }
-
- val missingPermissions = permissions.filter {
- ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
- }
-
+ private fun checkPermissionsAndConnectBle() {
+ val missingPermissions = permissionHelper.getMissingPermissions(this)
if (missingPermissions.isEmpty()) {
showDeviceSelectionDialog()
} else {
@@ -197,18 +279,16 @@ class MainActivity : AppCompatActivity() {
@SuppressLint("MissingPermission")
private fun showDeviceSelectionDialog() {
- if (bluetoothAdapter == null) {
+ if (!deviceManager.isBluetoothSupported()) {
Toast.makeText(this, getString(R.string.error_no_bluetooth), Toast.LENGTH_SHORT).show()
return
}
- if (!bluetoothAdapter!!.isEnabled) {
+ if (!deviceManager.isBluetoothEnabled()) {
Toast.makeText(this, getString(R.string.error_bt_disabled), Toast.LENGTH_SHORT).show()
return
}
- val pairedDevices: Set? = bluetoothAdapter?.bondedDevices
- val deviceList = pairedDevices?.toList() ?: emptyList()
-
+ val deviceList = deviceManager.getBondedDevices()
if (deviceList.isEmpty()) {
Toast.makeText(this, getString(R.string.error_no_targets), Toast.LENGTH_SHORT).show()
return
@@ -219,7 +299,7 @@ class MainActivity : AppCompatActivity() {
AlertDialog.Builder(this, R.style.CyberDialogTheme)
.setTitle(getString(R.string.dialog_select_target))
.setItems(deviceNames) { _, which ->
- viewModel.connect(deviceList[which])
+ viewModel.connect(ConnectionTarget.Bluetooth(deviceList[which]))
}
.setNegativeButton(getString(R.string.dialog_abort), null)
.show()
diff --git a/app/src/main/java/com/abcomm/MainViewModel.kt b/app/src/main/java/com/abcomm/MainViewModel.kt
deleted file mode 100644
index e927799..0000000
--- a/app/src/main/java/com/abcomm/MainViewModel.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package com.abcomm
-
-import androidx.lifecycle.ViewModel
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
-
-/**
- * UI State for the Main Screen.
- * Respects SRP (Single Responsibility Principle) by separating State from Logic.
- */
-data class MainUiState(
- val status: String = "Disconnected",
- val isConnected: Boolean = false,
- val isConnecting: Boolean = false,
- val channelStates: List = List(8) { false }
-)
-
-class MainViewModel(private val communicator: CommunicationProvider) : ViewModel() {
-
- private val _uiState = MutableStateFlow(MainUiState())
- val uiState: StateFlow = _uiState.asStateFlow()
-
- init {
- communicator.setStatusListener { status ->
- updateStatus(status)
- }
- }
-
- private fun updateStatus(status: String) {
- val isConnected = status.startsWith("Connected")
- val isConnecting = status.startsWith("Connecting")
- _uiState.value = _uiState.value.copy(
- status = status,
- isConnected = isConnected,
- isConnecting = isConnecting
- )
- }
-
- fun connect(device: Any) {
- communicator.connect(device)
- }
-
- fun disconnect() {
- communicator.disconnect()
- }
-
- fun toggleChannel(index: Int) {
- if (!_uiState.value.isConnected) return
-
- val currentState = _uiState.value.channelStates[index]
- val newState = !currentState
-
- val newStates = _uiState.value.channelStates.toMutableList()
- newStates[index] = newState
- _uiState.value = _uiState.value.copy(channelStates = newStates)
-
- val channelNum = index + 1
- val cmdState = if (newState) "on" else "off"
- communicator.sendCommand("mh#ch#${channelNum}#${cmdState}#end")
- }
-
- fun setAllChannels(on: Boolean) {
- if (!_uiState.value.isConnected) return
-
- val stateStr = if (on) "on" else "off"
- communicator.sendCommand("mh#ch#all#$stateStr#end")
-
- _uiState.value = _uiState.value.copy(
- channelStates = List(8) { on }
- )
- }
-
- fun isConnected() = communicator.isConnected()
-}
diff --git a/app/src/main/java/com/abcomm/communication/BluetoothService.kt b/app/src/main/java/com/abcomm/communication/BluetoothService.kt
new file mode 100644
index 0000000..00d3df0
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/BluetoothService.kt
@@ -0,0 +1,154 @@
+package com.abcomm.communication
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothSocket
+import android.util.Log
+import com.abcomm.protocol.FrameParser
+import com.abcomm.protocol.MicrohilFrameParser
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.util.UUID
+
+/**
+ * Bluetooth RFCOMM implementation of CommunicationProvider managing device pairing,
+ * socket lifecycle, frame streaming, and coroutine-based background I/O.
+ */
+class BluetoothService(
+ private val frameParserFactory: () -> FrameParser = { MicrohilFrameParser() },
+ private val uuid: UUID = UUID.fromString(DEFAULT_SPP_UUID_STRING)
+) : CommunicationProvider {
+
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var connectJob: Job? = null
+ private var readerJob: Job? = null
+
+ private var socket: BluetoothSocket? = null
+ private var onStatusChange: ((ConnectionStatus) -> Unit)? = null
+ private var onResponseReceived: ((String) -> Unit)? = null
+
+ override fun setStatusListener(listener: (ConnectionStatus) -> Unit) {
+ this.onStatusChange = listener
+ }
+
+ override fun setResponseListener(listener: (String) -> Unit) {
+ this.onResponseReceived = listener
+ }
+
+ @SuppressLint("MissingPermission")
+ override fun connect(target: ConnectionTarget) {
+ if (target !is ConnectionTarget.Bluetooth) {
+ Log.w(TAG, "Invalid target type passed to BluetoothService: $target")
+ return
+ }
+
+ connectJob?.cancel()
+ connectJob = serviceScope.launch {
+ try {
+ val targetName = target.device.name ?: target.device.address
+ onStatusChange?.invoke(ConnectionStatus.Connecting(targetName))
+ val device = target.device
+
+ // Reflection fallback for Linux / Pico Bluetooth stack compatibility
+ val newSocket = device.javaClass.getMethod(METHOD_CREATE_RFCOMM_SOCKET, Int::class.javaPrimitiveType)
+ .invoke(device, DEFAULT_RFCOMM_CHANNEL) as BluetoothSocket
+ newSocket.connect()
+
+ socket = newSocket
+ onStatusChange?.invoke(ConnectionStatus.Connected(targetName))
+ startReaderJob()
+ } catch (e: Exception) {
+ Log.e(TAG, "Connection failed", e)
+ onStatusChange?.invoke(ConnectionStatus.Error(ERROR_CONNECTION_FAILED))
+ disconnect()
+ }
+ }
+ }
+
+ private fun startReaderJob() {
+ readerJob?.cancel()
+ val frameParser = frameParserFactory()
+ readerJob = serviceScope.launch {
+ val buffer = ByteArray(BUFFER_SIZE)
+ while (isActive && isConnected()) {
+ try {
+ val bytesRead = socket?.inputStream?.read(buffer) ?: -1
+ if (bytesRead > 0) {
+ val rawChunk = String(buffer, 0, bytesRead, Charsets.UTF_8)
+ Log.d(TAG, "Raw chunk: $rawChunk")
+ val frames = frameParser.process(rawChunk)
+ for (frame in frames) {
+ Log.d(TAG, "Frame received: $frame")
+ onResponseReceived?.invoke(frame)
+ }
+ } else if (bytesRead == -1) {
+ Log.d(TAG, "End of Bluetooth stream reached (remote disconnected)")
+ break
+ }
+ } catch (e: IOException) {
+ Log.d(TAG, "Reader loop stopped: ${e.message}")
+ break
+ }
+ }
+ if (isActive) {
+ disconnect()
+ }
+ }
+ }
+
+ override fun sendCommand(command: String) {
+ serviceScope.launch {
+ socket?.let {
+ if (it.isConnected) {
+ try {
+ withContext(Dispatchers.IO) {
+ it.outputStream.write(command.toByteArray(Charsets.UTF_8))
+ it.outputStream.flush()
+ }
+ Log.d(TAG, "Sent: $command")
+ } catch (e: IOException) {
+ Log.e(TAG, "Error sending data", e)
+ onStatusChange?.invoke(ConnectionStatus.Error(ERROR_SEND_FAILED))
+ }
+ } else {
+ onStatusChange?.invoke(ConnectionStatus.Disconnected)
+ }
+ } ?: onStatusChange?.invoke(ConnectionStatus.Disconnected)
+ }
+ }
+
+ override fun disconnect() {
+ serviceScope.launch {
+ readerJob?.cancel()
+ connectJob?.cancel()
+ try {
+ withContext(Dispatchers.IO) {
+ socket?.close()
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Error closing socket", e)
+ } finally {
+ socket = null
+ onStatusChange?.invoke(ConnectionStatus.Disconnected)
+ }
+ }
+ }
+
+ override fun isConnected(): Boolean = socket?.isConnected ?: false
+
+ companion object {
+ private const val TAG = "BluetoothService"
+ const val DEFAULT_SPP_UUID_STRING = "00001101-0000-1000-8000-00805F9B34FB"
+ const val DEFAULT_RFCOMM_CHANNEL = 4
+ const val BUFFER_SIZE = 1024
+ private const val METHOD_CREATE_RFCOMM_SOCKET = "createRfcommSocket"
+
+ const val ERROR_CONNECTION_FAILED = "Bluetooth connection failed"
+ const val ERROR_SEND_FAILED = "Bluetooth send failed"
+ }
+}
diff --git a/app/src/main/java/com/abcomm/communication/CommandSender.kt b/app/src/main/java/com/abcomm/communication/CommandSender.kt
new file mode 100644
index 0000000..c2a46af
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/CommandSender.kt
@@ -0,0 +1,8 @@
+package com.abcomm.communication
+
+/**
+ * Dispatches raw command frames across an active communication channel.
+ */
+interface CommandSender {
+ fun sendCommand(command: String)
+}
diff --git a/app/src/main/java/com/abcomm/communication/CommunicationProvider.kt b/app/src/main/java/com/abcomm/communication/CommunicationProvider.kt
new file mode 100644
index 0000000..5c22dea
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/CommunicationProvider.kt
@@ -0,0 +1,7 @@
+package com.abcomm.communication
+
+/**
+ * Unified communication contract combining lifecycle control, command dispatch,
+ * and asynchronous event observation for microHIL connections.
+ */
+interface CommunicationProvider : ConnectionController, CommandSender, ConnectionObservable
diff --git a/app/src/main/java/com/abcomm/communication/CommunicationProviderRegistry.kt b/app/src/main/java/com/abcomm/communication/CommunicationProviderRegistry.kt
new file mode 100644
index 0000000..a157292
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/CommunicationProviderRegistry.kt
@@ -0,0 +1,8 @@
+package com.abcomm.communication
+
+/**
+ * Registry resolving the appropriate CommunicationProvider instance based on the active ConnectionMode.
+ */
+interface CommunicationProviderRegistry {
+ fun getProvider(mode: ConnectionMode): CommunicationProvider
+}
diff --git a/app/src/main/java/com/abcomm/communication/ConnectionController.kt b/app/src/main/java/com/abcomm/communication/ConnectionController.kt
new file mode 100644
index 0000000..f667ebe
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/ConnectionController.kt
@@ -0,0 +1,10 @@
+package com.abcomm.communication
+
+/**
+ * Controls connection lifecycle (initiating, terminating, and checking connection state).
+ */
+interface ConnectionController {
+ fun connect(target: ConnectionTarget)
+ fun disconnect()
+ fun isConnected(): Boolean
+}
diff --git a/app/src/main/java/com/abcomm/communication/ConnectionMode.kt b/app/src/main/java/com/abcomm/communication/ConnectionMode.kt
new file mode 100644
index 0000000..844690a
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/ConnectionMode.kt
@@ -0,0 +1,5 @@
+package com.abcomm.communication
+
+enum class ConnectionMode {
+ BLE, WIFI
+}
diff --git a/app/src/main/java/com/abcomm/communication/ConnectionObservable.kt b/app/src/main/java/com/abcomm/communication/ConnectionObservable.kt
new file mode 100644
index 0000000..e0a64dd
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/ConnectionObservable.kt
@@ -0,0 +1,9 @@
+package com.abcomm.communication
+
+/**
+ * Provides subscription mechanisms for connection status changes and incoming frame responses.
+ */
+interface ConnectionObservable {
+ fun setStatusListener(listener: (ConnectionStatus) -> Unit)
+ fun setResponseListener(listener: (String) -> Unit)
+}
diff --git a/app/src/main/java/com/abcomm/communication/ConnectionStatus.kt b/app/src/main/java/com/abcomm/communication/ConnectionStatus.kt
new file mode 100644
index 0000000..d47e837
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/ConnectionStatus.kt
@@ -0,0 +1,11 @@
+package com.abcomm.communication
+
+/**
+ * Type-safe model representing all possible states of a device communication connection.
+ */
+sealed interface ConnectionStatus {
+ object Disconnected : ConnectionStatus
+ data class Connecting(val target: String = "") : ConnectionStatus
+ data class Connected(val targetName: String) : ConnectionStatus
+ data class Error(val message: String) : ConnectionStatus
+}
diff --git a/app/src/main/java/com/abcomm/communication/ConnectionTarget.kt b/app/src/main/java/com/abcomm/communication/ConnectionTarget.kt
new file mode 100644
index 0000000..1418511
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/ConnectionTarget.kt
@@ -0,0 +1,11 @@
+package com.abcomm.communication
+
+import android.bluetooth.BluetoothDevice
+
+/**
+ * Defines destination endpoints for device connectivity (Bluetooth RFCOMM or TCP Wi-Fi).
+ */
+sealed interface ConnectionTarget {
+ data class Bluetooth(val device: BluetoothDevice) : ConnectionTarget
+ data class Wifi(val host: String, val port: Int) : ConnectionTarget
+}
diff --git a/app/src/main/java/com/abcomm/communication/DefaultCommunicationProviderRegistry.kt b/app/src/main/java/com/abcomm/communication/DefaultCommunicationProviderRegistry.kt
new file mode 100644
index 0000000..c1fdee5
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/DefaultCommunicationProviderRegistry.kt
@@ -0,0 +1,13 @@
+package com.abcomm.communication
+
+/**
+ * Default implementation of CommunicationProviderRegistry.
+ */
+class DefaultCommunicationProviderRegistry(
+ private val providers: Map
+) : CommunicationProviderRegistry {
+
+ override fun getProvider(mode: ConnectionMode): CommunicationProvider {
+ return providers[mode] ?: throw IllegalArgumentException("Unsupported connection mode: $mode")
+ }
+}
diff --git a/app/src/main/java/com/abcomm/communication/WifiService.kt b/app/src/main/java/com/abcomm/communication/WifiService.kt
new file mode 100644
index 0000000..f059a40
--- /dev/null
+++ b/app/src/main/java/com/abcomm/communication/WifiService.kt
@@ -0,0 +1,163 @@
+package com.abcomm.communication
+
+import android.util.Log
+import com.abcomm.protocol.FrameParser
+import com.abcomm.protocol.MicrohilFrameParser
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.IOException
+import java.io.InputStream
+import java.io.OutputStream
+import java.net.InetSocketAddress
+import java.net.Socket
+
+/**
+ * TCP Socket implementation of CommunicationProvider managing network socket lifecycle,
+ * streaming frame parsing, and coroutine-based background I/O.
+ */
+class WifiService(
+ private val frameParserFactory: () -> FrameParser = { MicrohilFrameParser() }
+) : CommunicationProvider {
+
+ private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
+ private var connectJob: Job? = null
+ private var readerJob: Job? = null
+
+ private var socket: Socket? = null
+ private var inputStream: InputStream? = null
+ private var outputStream: OutputStream? = null
+
+ private var onStatusChange: ((ConnectionStatus) -> Unit)? = null
+ private var onResponseReceived: ((String) -> Unit)? = null
+
+ override fun setStatusListener(listener: (ConnectionStatus) -> Unit) {
+ this.onStatusChange = listener
+ }
+
+ override fun setResponseListener(listener: (String) -> Unit) {
+ this.onResponseReceived = listener
+ }
+
+ override fun connect(target: ConnectionTarget) {
+ if (target !is ConnectionTarget.Wifi) {
+ Log.w(TAG, "Invalid target type passed to WifiService: $target")
+ return
+ }
+
+ val targetAddress = "${target.host}:${target.port}"
+ connectJob?.cancel()
+ connectJob = serviceScope.launch {
+ try {
+ onStatusChange?.invoke(ConnectionStatus.Connecting(targetAddress))
+
+ val newSocket = Socket()
+ withContext(Dispatchers.IO) {
+ newSocket.connect(InetSocketAddress(target.host, target.port), CONNECT_TIMEOUT_MS)
+ }
+
+ socket = newSocket
+ inputStream = newSocket.getInputStream()
+ outputStream = newSocket.getOutputStream()
+
+ onStatusChange?.invoke(ConnectionStatus.Connected(targetAddress))
+ startReaderJob()
+
+ } catch (e: Exception) {
+ Log.e(TAG, "TCP Connection failed", e)
+ onStatusChange?.invoke(ConnectionStatus.Error(ERROR_CONNECTION_FAILED))
+ disconnect()
+ }
+ }
+ }
+
+ private fun startReaderJob() {
+ readerJob?.cancel()
+ val frameParser = frameParserFactory()
+ readerJob = serviceScope.launch {
+ val buffer = ByteArray(BUFFER_SIZE)
+ while (isActive && isConnected()) {
+ try {
+ val bytesRead = inputStream?.read(buffer) ?: -1
+ if (bytesRead > 0) {
+ val rawChunk = String(buffer, 0, bytesRead, Charsets.UTF_8)
+ Log.d(TAG, "Raw chunk: $rawChunk")
+ val frames = frameParser.process(rawChunk)
+ for (frame in frames) {
+ Log.d(TAG, "Frame received: $frame")
+ onResponseReceived?.invoke(frame)
+ }
+ } else if (bytesRead == -1) {
+ Log.d(TAG, "End of TCP stream reached (remote disconnected)")
+ break
+ }
+ } catch (e: IOException) {
+ Log.d(TAG, "Reader loop interrupted: ${e.message}")
+ break
+ }
+ }
+ if (isActive) {
+ disconnect()
+ }
+ }
+ }
+
+ override fun sendCommand(command: String) {
+ serviceScope.launch {
+ val out = outputStream
+ if (isConnected() && out != null) {
+ try {
+ withContext(Dispatchers.IO) {
+ out.write(command.toByteArray(Charsets.UTF_8))
+ out.flush()
+ }
+ Log.d(TAG, "Sent: $command")
+ } catch (e: IOException) {
+ Log.e(TAG, "Error sending data", e)
+ onStatusChange?.invoke(ConnectionStatus.Error(ERROR_SEND_FAILED))
+ }
+ } else {
+ onStatusChange?.invoke(ConnectionStatus.Disconnected)
+ }
+ }
+ }
+
+ override fun disconnect() {
+ serviceScope.launch {
+ readerJob?.cancel()
+ connectJob?.cancel()
+ try {
+ withContext(Dispatchers.IO) {
+ inputStream?.close()
+ outputStream?.close()
+ socket?.close()
+ }
+ } catch (e: IOException) {
+ Log.e(TAG, "Error closing socket", e)
+ } finally {
+ inputStream = null
+ outputStream = null
+ socket = null
+ onStatusChange?.invoke(ConnectionStatus.Disconnected)
+ }
+ }
+ }
+
+ override fun isConnected(): Boolean {
+ val s = socket
+ return s != null && s.isConnected && !s.isClosed
+ }
+
+ companion object {
+ private const val TAG = "WifiService"
+ const val CONNECT_TIMEOUT_MS = 5000
+ const val BUFFER_SIZE = 1024
+
+ const val ERROR_CONNECTION_FAILED = "TCP connection failed"
+ const val ERROR_SEND_FAILED = "TCP send failed"
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/CommandFormatter.kt b/app/src/main/java/com/abcomm/protocol/CommandFormatter.kt
new file mode 100644
index 0000000..80fdc79
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/CommandFormatter.kt
@@ -0,0 +1,18 @@
+package com.abcomm.protocol
+
+/**
+ * Formatter constructing framed outbound ASCII command strings formatted for microHIL firmware.
+ */
+interface CommandFormatter {
+ fun formatChannel(channel: Int, on: Boolean): String
+ fun formatAllChannels(on: Boolean): String
+ fun formatQueryAllStatus(): String
+ fun formatQueryChannelStatus(channel: Int): String
+ fun formatQueryBoardId(): String
+ fun formatQueryVersion(): String
+ fun formatReset(): String
+ fun formatMask(mask: String): String
+ fun formatTimer(channel: Int, seconds: Int): String
+ fun formatPulse(channel: Int, durationMs: Int): String
+ fun formatBlink(channel: Int, onMs: Int, offMs: Int, count: Int): String
+}
diff --git a/app/src/main/java/com/abcomm/protocol/DeviceResponse.kt b/app/src/main/java/com/abcomm/protocol/DeviceResponse.kt
new file mode 100644
index 0000000..0784a02
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/DeviceResponse.kt
@@ -0,0 +1,14 @@
+package com.abcomm.protocol
+
+/**
+ * Domain model representing typed responses received from microHIL firmware.
+ */
+sealed interface DeviceResponse {
+ data class ChannelState(val channel: Int, val isOn: Boolean) : DeviceResponse
+ data class AllChannelsState(val isOn: Boolean) : DeviceResponse
+ data class AllChannelsSnapshot(val states: List) : DeviceResponse
+ data class BoardId(val id: String) : DeviceResponse
+ data class FirmwareVersion(val version: String) : DeviceResponse
+ object SystemResetting : DeviceResponse
+ data class Unknown(val raw: String) : DeviceResponse
+}
diff --git a/app/src/main/java/com/abcomm/protocol/FrameParser.kt b/app/src/main/java/com/abcomm/protocol/FrameParser.kt
new file mode 100644
index 0000000..7c09021
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/FrameParser.kt
@@ -0,0 +1,9 @@
+package com.abcomm.protocol
+
+/**
+ * Extracts complete '<...>' protocol frames from continuous incoming byte streams.
+ */
+interface FrameParser {
+ fun process(chunk: String): List
+ fun reset()
+}
diff --git a/app/src/main/java/com/abcomm/protocol/MicrohilCommandFormatter.kt b/app/src/main/java/com/abcomm/protocol/MicrohilCommandFormatter.kt
new file mode 100644
index 0000000..e5d2240
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/MicrohilCommandFormatter.kt
@@ -0,0 +1,67 @@
+package com.abcomm.protocol
+
+/**
+ * Default implementation of CommandFormatter for microHIL protocol.
+ */
+class MicrohilCommandFormatter : CommandFormatter {
+
+ override fun formatChannel(channel: Int, on: Boolean): String {
+ require(channel in MicrohilProtocolConstants.MIN_CHANNEL..MicrohilProtocolConstants.MAX_CHANNEL) {
+ "Channel must be between ${MicrohilProtocolConstants.MIN_CHANNEL} and ${MicrohilProtocolConstants.MAX_CHANNEL}"
+ }
+ val state = if (on) MicrohilProtocolConstants.STATE_ON else MicrohilProtocolConstants.STATE_OFF
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_CH}$channel#$state${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+
+ override fun formatAllChannels(on: Boolean): String {
+ val state = if (on) MicrohilProtocolConstants.STATE_ON else MicrohilProtocolConstants.STATE_OFF
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_ALL}$state${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+
+ override fun formatQueryAllStatus(): String =
+ "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_ALL}${MicrohilProtocolConstants.CMD_STAT}${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+
+ override fun formatQueryChannelStatus(channel: Int): String {
+ require(channel in MicrohilProtocolConstants.MIN_CHANNEL..MicrohilProtocolConstants.MAX_CHANNEL) {
+ "Channel must be between ${MicrohilProtocolConstants.MIN_CHANNEL} and ${MicrohilProtocolConstants.MAX_CHANNEL}"
+ }
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_CH}$channel#${MicrohilProtocolConstants.CMD_STAT}${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+
+ override fun formatQueryBoardId(): String =
+ "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_SYS}${MicrohilProtocolConstants.CMD_ID}${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+
+ override fun formatQueryVersion(): String =
+ "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_SYS}${MicrohilProtocolConstants.CMD_VERSION}${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+
+ override fun formatReset(): String =
+ "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_SYS}${MicrohilProtocolConstants.CMD_RESET}${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+
+ override fun formatMask(mask: String): String {
+ require(mask.length == MicrohilProtocolConstants.MASK_LENGTH && mask.all { it == '0' || it == '1' }) {
+ "Mask must be exactly ${MicrohilProtocolConstants.MASK_LENGTH} characters of 0s and 1s"
+ }
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_ALL}${MicrohilProtocolConstants.CMD_MASK}#$mask${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+
+ override fun formatTimer(channel: Int, seconds: Int): String {
+ require(channel in MicrohilProtocolConstants.MIN_CHANNEL..MicrohilProtocolConstants.MAX_CHANNEL) {
+ "Channel must be between ${MicrohilProtocolConstants.MIN_CHANNEL} and ${MicrohilProtocolConstants.MAX_CHANNEL}"
+ }
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_CH}$channel#${MicrohilProtocolConstants.CMD_TMR}#$seconds${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+
+ override fun formatPulse(channel: Int, durationMs: Int): String {
+ require(channel in MicrohilProtocolConstants.MIN_CHANNEL..MicrohilProtocolConstants.MAX_CHANNEL) {
+ "Channel must be between ${MicrohilProtocolConstants.MIN_CHANNEL} and ${MicrohilProtocolConstants.MAX_CHANNEL}"
+ }
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_CH}$channel#${MicrohilProtocolConstants.CMD_PULSE}#$durationMs${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+
+ override fun formatBlink(channel: Int, onMs: Int, offMs: Int, count: Int): String {
+ require(channel in MicrohilProtocolConstants.MIN_CHANNEL..MicrohilProtocolConstants.MAX_CHANNEL) {
+ "Channel must be between ${MicrohilProtocolConstants.MIN_CHANNEL} and ${MicrohilProtocolConstants.MAX_CHANNEL}"
+ }
+ return "${MicrohilProtocolConstants.FRAME_START}${MicrohilProtocolConstants.CMD_PREFIX_CH}$channel#${MicrohilProtocolConstants.CMD_BLINK}#$onMs#$offMs#$count${MicrohilProtocolConstants.CMD_SUFFIX_END}${MicrohilProtocolConstants.FRAME_END}"
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/MicrohilFrameParser.kt b/app/src/main/java/com/abcomm/protocol/MicrohilFrameParser.kt
new file mode 100644
index 0000000..18b7f3b
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/MicrohilFrameParser.kt
@@ -0,0 +1,44 @@
+package com.abcomm.protocol
+
+/**
+ * Default stream frame parser for microHIL '<...>' framing.
+ */
+class MicrohilFrameParser(
+ private val frameStart: Char = MicrohilProtocolConstants.FRAME_START,
+ private val frameEnd: Char = MicrohilProtocolConstants.FRAME_END
+) : FrameParser {
+ private val currentFrame = StringBuilder()
+ private var isReceiving = false
+
+ @Synchronized
+ override fun process(chunk: String): List {
+ val completedFrames = mutableListOf()
+ for (c in chunk) {
+ when {
+ c == frameStart -> {
+ isReceiving = true
+ currentFrame.clear()
+ }
+ c == frameEnd -> {
+ if (isReceiving) {
+ completedFrames.add(currentFrame.toString())
+ currentFrame.clear()
+ isReceiving = false
+ }
+ }
+ isReceiving -> {
+ if (c != '\r' && c != '\n') {
+ currentFrame.append(c)
+ }
+ }
+ }
+ }
+ return completedFrames
+ }
+
+ @Synchronized
+ override fun reset() {
+ currentFrame.clear()
+ isReceiving = false
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/MicrohilProtocolConstants.kt b/app/src/main/java/com/abcomm/protocol/MicrohilProtocolConstants.kt
new file mode 100644
index 0000000..5fac151
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/MicrohilProtocolConstants.kt
@@ -0,0 +1,36 @@
+package com.abcomm.protocol
+
+/**
+ * Protocol constants defining frame boundaries, channel numbers,
+ * command keywords, and system strings for microHIL firmware.
+ */
+object MicrohilProtocolConstants {
+ const val FRAME_START = '<'
+ const val FRAME_END = '>'
+
+ const val MIN_CHANNEL = 1
+ const val MAX_CHANNEL = 8
+ const val CHANNEL_COUNT = 8
+ const val MASK_LENGTH = 8
+
+ const val CMD_PREFIX_CH = "mh#ch#"
+ const val CMD_PREFIX_ALL = "mh#all#"
+ const val CMD_PREFIX_SYS = "mh#sys#"
+ const val CMD_SUFFIX_END = "#end"
+
+ const val STATE_ON = "on"
+ const val STATE_OFF = "off"
+
+ const val CMD_STAT = "stat"
+ const val CMD_ID = "id"
+ const val CMD_VERSION = "version"
+ const val CMD_RESET = "reset"
+ const val CMD_MASK = "mask"
+ const val CMD_TMR = "tmr"
+ const val CMD_PULSE = "pulse"
+ const val CMD_BLINK = "blink"
+
+ const val RESP_CHANNELS_PREFIX = "mh#sys#channels:"
+ const val RESP_MASK_APPLIED_PREFIX = "mh#sys#channels mask applied:"
+ const val RESP_RESETTING_KEYWORD = "system resetting"
+}
diff --git a/app/src/main/java/com/abcomm/protocol/MicrohilResponseParser.kt b/app/src/main/java/com/abcomm/protocol/MicrohilResponseParser.kt
new file mode 100644
index 0000000..776ffa2
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/MicrohilResponseParser.kt
@@ -0,0 +1,41 @@
+package com.abcomm.protocol
+
+import com.abcomm.protocol.matchers.AllChannelsSnapshotMatcher
+import com.abcomm.protocol.matchers.AllChannelsStateMatcher
+import com.abcomm.protocol.matchers.BoardIdMatcher
+import com.abcomm.protocol.matchers.ChannelStateMatcher
+import com.abcomm.protocol.matchers.FirmwareVersionMatcher
+import com.abcomm.protocol.matchers.MaskAppliedMatcher
+import com.abcomm.protocol.matchers.SystemResettingMatcher
+
+/**
+ * Evaluates incoming frames against an ordered collection of ResponseMatchers,
+ * returning the first matched DeviceResponse or DeviceResponse.Unknown if unmatched.
+ */
+class MicrohilResponseParser(
+ private val matchers: List = defaultMatchers()
+) : ResponseParser {
+
+ override fun parse(frame: String): DeviceResponse {
+ val trimmed = frame.trim()
+ for (matcher in matchers) {
+ val response = matcher.match(trimmed)
+ if (response != null) {
+ return response
+ }
+ }
+ return DeviceResponse.Unknown(trimmed)
+ }
+
+ companion object {
+ fun defaultMatchers(): List = listOf(
+ ChannelStateMatcher(),
+ AllChannelsStateMatcher(),
+ AllChannelsSnapshotMatcher(),
+ MaskAppliedMatcher(),
+ BoardIdMatcher(),
+ FirmwareVersionMatcher(),
+ SystemResettingMatcher()
+ )
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/ResponseMatcher.kt b/app/src/main/java/com/abcomm/protocol/ResponseMatcher.kt
new file mode 100644
index 0000000..a30de7d
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/ResponseMatcher.kt
@@ -0,0 +1,8 @@
+package com.abcomm.protocol
+
+/**
+ * Pattern matcher evaluating raw frame text and returning the corresponding DeviceResponse model if matched.
+ */
+interface ResponseMatcher {
+ fun match(frame: String): DeviceResponse?
+}
diff --git a/app/src/main/java/com/abcomm/protocol/ResponseParser.kt b/app/src/main/java/com/abcomm/protocol/ResponseParser.kt
new file mode 100644
index 0000000..3ee4273
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/ResponseParser.kt
@@ -0,0 +1,8 @@
+package com.abcomm.protocol
+
+/**
+ * Parser converting raw ASCII response frames into strongly-typed DeviceResponse domain objects.
+ */
+interface ResponseParser {
+ fun parse(frame: String): DeviceResponse
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/AllChannelsSnapshotMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/AllChannelsSnapshotMatcher.kt
new file mode 100644
index 0000000..4afbbb4
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/AllChannelsSnapshotMatcher.kt
@@ -0,0 +1,34 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.MicrohilProtocolConstants
+import com.abcomm.protocol.ResponseMatcher
+
+class AllChannelsSnapshotMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ val trimmed = frame.trim()
+ if (trimmed.startsWith(MicrohilProtocolConstants.RESP_CHANNELS_PREFIX) &&
+ trimmed.endsWith(MicrohilProtocolConstants.CMD_SUFFIX_END)
+ ) {
+ val content = trimmed
+ .removePrefix(MicrohilProtocolConstants.RESP_CHANNELS_PREFIX)
+ .removeSuffix(MicrohilProtocolConstants.CMD_SUFFIX_END)
+ .trim()
+ val states = BooleanArray(MicrohilProtocolConstants.CHANNEL_COUNT) { false }
+ CHANNEL_ITEM_REGEX.findAll(content).forEach { matchResult ->
+ val chIndex = matchResult.groupValues[1].toInt() - 1
+ val isOn = matchResult.groupValues[2].equals("ON", ignoreCase = true)
+ if (chIndex in 0 until MicrohilProtocolConstants.CHANNEL_COUNT) {
+ states[chIndex] = isOn
+ }
+ }
+ return DeviceResponse.AllChannelsSnapshot(states.toList())
+ }
+ return null
+ }
+
+ companion object {
+ private val CHANNEL_ITEM_REGEX =
+ Regex("""([1-8]):(ON|OFF)""", RegexOption.IGNORE_CASE)
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/AllChannelsStateMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/AllChannelsStateMatcher.kt
new file mode 100644
index 0000000..d197249
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/AllChannelsStateMatcher.kt
@@ -0,0 +1,18 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.MicrohilProtocolConstants
+import com.abcomm.protocol.ResponseMatcher
+
+class AllChannelsStateMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ val match = ALL_CHANNELS_REGEX.matchEntire(frame.trim()) ?: return null
+ val isOn = match.groupValues[1].equals(MicrohilProtocolConstants.STATE_ON, ignoreCase = true)
+ return DeviceResponse.AllChannelsState(isOn)
+ }
+
+ companion object {
+ private val ALL_CHANNELS_REGEX =
+ Regex("""mh#sys#all channels\s+(on|off)#end""", RegexOption.IGNORE_CASE)
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/BoardIdMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/BoardIdMatcher.kt
new file mode 100644
index 0000000..3a36cd6
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/BoardIdMatcher.kt
@@ -0,0 +1,16 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.ResponseMatcher
+
+class BoardIdMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ val match = BOARD_ID_REGEX.matchEntire(frame.trim()) ?: return null
+ return DeviceResponse.BoardId(match.groupValues[1])
+ }
+
+ companion object {
+ private val BOARD_ID_REGEX =
+ Regex("""mh#sys#(mh:[^#]+)#end""")
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/ChannelStateMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/ChannelStateMatcher.kt
new file mode 100644
index 0000000..cd0d09e
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/ChannelStateMatcher.kt
@@ -0,0 +1,19 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.MicrohilProtocolConstants
+import com.abcomm.protocol.ResponseMatcher
+
+class ChannelStateMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ val match = CHANNEL_REGEX.matchEntire(frame.trim()) ?: return null
+ val ch = match.groupValues[1].toInt()
+ val isOn = match.groupValues[2].equals(MicrohilProtocolConstants.STATE_ON, ignoreCase = true)
+ return DeviceResponse.ChannelState(ch, isOn)
+ }
+
+ companion object {
+ private val CHANNEL_REGEX =
+ Regex("""mh#sys#channel\s+([1-8])\s+(on|off)#end""", RegexOption.IGNORE_CASE)
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/FirmwareVersionMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/FirmwareVersionMatcher.kt
new file mode 100644
index 0000000..1db7389
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/FirmwareVersionMatcher.kt
@@ -0,0 +1,16 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.ResponseMatcher
+
+class FirmwareVersionMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ val match = VERSION_REGEX.matchEntire(frame.trim()) ?: return null
+ return DeviceResponse.FirmwareVersion(match.groupValues[1])
+ }
+
+ companion object {
+ private val VERSION_REGEX =
+ Regex("""mh#sys#(microHIL[^#]*)#end""", RegexOption.IGNORE_CASE)
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/MaskAppliedMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/MaskAppliedMatcher.kt
new file mode 100644
index 0000000..f99ed10
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/MaskAppliedMatcher.kt
@@ -0,0 +1,18 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.ResponseMatcher
+
+class MaskAppliedMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ val match = MASK_REGEX.matchEntire(frame.trim()) ?: return null
+ val maskStr = match.groupValues[1]
+ val states = maskStr.map { it == '1' }
+ return DeviceResponse.AllChannelsSnapshot(states)
+ }
+
+ companion object {
+ private val MASK_REGEX =
+ Regex("""mh#sys#channels mask applied:\s*([01]{8})#end""", RegexOption.IGNORE_CASE)
+ }
+}
diff --git a/app/src/main/java/com/abcomm/protocol/matchers/SystemResettingMatcher.kt b/app/src/main/java/com/abcomm/protocol/matchers/SystemResettingMatcher.kt
new file mode 100644
index 0000000..9e33b18
--- /dev/null
+++ b/app/src/main/java/com/abcomm/protocol/matchers/SystemResettingMatcher.kt
@@ -0,0 +1,14 @@
+package com.abcomm.protocol.matchers
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.MicrohilProtocolConstants
+import com.abcomm.protocol.ResponseMatcher
+
+class SystemResettingMatcher : ResponseMatcher {
+ override fun match(frame: String): DeviceResponse? {
+ if (frame.contains(MicrohilProtocolConstants.RESP_RESETTING_KEYWORD, ignoreCase = true)) {
+ return DeviceResponse.SystemResetting
+ }
+ return null
+ }
+}
diff --git a/app/src/main/java/com/abcomm/settings/AppSettings.kt b/app/src/main/java/com/abcomm/settings/AppSettings.kt
new file mode 100644
index 0000000..7a030b9
--- /dev/null
+++ b/app/src/main/java/com/abcomm/settings/AppSettings.kt
@@ -0,0 +1,19 @@
+package com.abcomm.settings
+
+import com.abcomm.communication.ConnectionMode
+
+/**
+ * Domain model representing application configuration.
+ */
+data class AppSettings(
+ val wifiIp: String = DEFAULT_WIFI_IP,
+ val wifiPort: Int = DEFAULT_WIFI_PORT,
+ val connectionMode: ConnectionMode = ConnectionMode.BLE
+) {
+ companion object {
+ const val DEFAULT_WIFI_IP = "192.168.1.100"
+ const val DEFAULT_WIFI_PORT = 5000
+ const val MIN_PORT = 1
+ const val MAX_PORT = 65535
+ }
+}
diff --git a/app/src/main/java/com/abcomm/settings/AppSettingsRepository.kt b/app/src/main/java/com/abcomm/settings/AppSettingsRepository.kt
new file mode 100644
index 0000000..5201dd2
--- /dev/null
+++ b/app/src/main/java/com/abcomm/settings/AppSettingsRepository.kt
@@ -0,0 +1,12 @@
+package com.abcomm.settings
+
+import com.abcomm.communication.ConnectionMode
+
+/**
+ * Repository interface providing persistent storage for Wi-Fi settings and connection mode.
+ */
+interface AppSettingsRepository {
+ fun getSettings(): AppSettings
+ fun saveWifiTarget(ip: String, port: Int)
+ fun saveConnectionMode(mode: ConnectionMode)
+}
diff --git a/app/src/main/java/com/abcomm/settings/SharedPreferencesSettingsRepository.kt b/app/src/main/java/com/abcomm/settings/SharedPreferencesSettingsRepository.kt
new file mode 100644
index 0000000..a75d43c
--- /dev/null
+++ b/app/src/main/java/com/abcomm/settings/SharedPreferencesSettingsRepository.kt
@@ -0,0 +1,51 @@
+package com.abcomm.settings
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.abcomm.communication.ConnectionMode
+
+/**
+ * SharedPreferences implementation of AppSettingsRepository.
+ */
+class SharedPreferencesSettingsRepository(
+ private val sharedPreferences: SharedPreferences
+) : AppSettingsRepository {
+
+ constructor(context: Context) : this(
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ )
+
+ override fun getSettings(): AppSettings {
+ val ip = sharedPreferences.getString(KEY_WIFI_IP, AppSettings.DEFAULT_WIFI_IP)
+ ?: AppSettings.DEFAULT_WIFI_IP
+ val port = sharedPreferences.getInt(KEY_WIFI_PORT, AppSettings.DEFAULT_WIFI_PORT)
+ val modeStr = sharedPreferences.getString(KEY_CONN_MODE, ConnectionMode.BLE.name)
+ ?: ConnectionMode.BLE.name
+ val mode = try {
+ ConnectionMode.valueOf(modeStr)
+ } catch (_: Exception) {
+ ConnectionMode.BLE
+ }
+ return AppSettings(wifiIp = ip, wifiPort = port, connectionMode = mode)
+ }
+
+ override fun saveWifiTarget(ip: String, port: Int) {
+ sharedPreferences.edit()
+ .putString(KEY_WIFI_IP, ip)
+ .putInt(KEY_WIFI_PORT, port)
+ .apply()
+ }
+
+ override fun saveConnectionMode(mode: ConnectionMode) {
+ sharedPreferences.edit()
+ .putString(KEY_CONN_MODE, mode.name)
+ .apply()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "abcomm_prefs"
+ private const val KEY_WIFI_IP = "wifi_ip"
+ private const val KEY_WIFI_PORT = "wifi_port"
+ private const val KEY_CONN_MODE = "conn_mode"
+ }
+}
diff --git a/app/src/main/java/com/abcomm/ui/BluetoothDeviceManager.kt b/app/src/main/java/com/abcomm/ui/BluetoothDeviceManager.kt
new file mode 100644
index 0000000..be350b7
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/BluetoothDeviceManager.kt
@@ -0,0 +1,29 @@
+package com.abcomm.ui
+
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.BluetoothDevice
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import androidx.core.content.ContextCompat
+
+/**
+ * Wraps Android BluetoothManager and adapter to inspect Bluetooth state and retrieve paired/bonded devices.
+ */
+class BluetoothDeviceManager(private val context: Context) : BluetoothDeviceProvider {
+
+ private val bluetoothAdapter: BluetoothAdapter? by lazy {
+ val manager = ContextCompat.getSystemService(context, BluetoothManager::class.java)
+ manager?.adapter
+ }
+
+ override fun isBluetoothSupported(): Boolean = bluetoothAdapter != null
+
+ override fun isBluetoothEnabled(): Boolean = bluetoothAdapter?.isEnabled == true
+
+ @SuppressLint("MissingPermission")
+ override fun getBondedDevices(): List {
+ val paired = bluetoothAdapter?.bondedDevices
+ return paired?.toList().orEmpty()
+ }
+}
diff --git a/app/src/main/java/com/abcomm/ui/BluetoothDeviceProvider.kt b/app/src/main/java/com/abcomm/ui/BluetoothDeviceProvider.kt
new file mode 100644
index 0000000..25f00eb
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/BluetoothDeviceProvider.kt
@@ -0,0 +1,12 @@
+package com.abcomm.ui
+
+import android.bluetooth.BluetoothDevice
+
+/**
+ * Interface defining contract for querying Bluetooth availability and bonded devices.
+ */
+interface BluetoothDeviceProvider {
+ fun isBluetoothSupported(): Boolean
+ fun isBluetoothEnabled(): Boolean
+ fun getBondedDevices(): List
+}
diff --git a/app/src/main/java/com/abcomm/ui/BluetoothPermissionChecker.kt b/app/src/main/java/com/abcomm/ui/BluetoothPermissionChecker.kt
new file mode 100644
index 0000000..0bc89da
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/BluetoothPermissionChecker.kt
@@ -0,0 +1,12 @@
+package com.abcomm.ui
+
+import android.content.Context
+
+/**
+ * Interface defining contract for Bluetooth runtime permission checks.
+ */
+interface BluetoothPermissionChecker {
+ fun getRequiredPermissions(): Array
+ fun getMissingPermissions(context: Context): List
+ fun hasAllPermissions(context: Context): Boolean
+}
diff --git a/app/src/main/java/com/abcomm/ui/BluetoothPermissionHelper.kt b/app/src/main/java/com/abcomm/ui/BluetoothPermissionHelper.kt
new file mode 100644
index 0000000..6d17748
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/BluetoothPermissionHelper.kt
@@ -0,0 +1,34 @@
+package com.abcomm.ui
+
+import android.Manifest
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Build
+import androidx.core.content.ContextCompat
+
+/**
+ * Evaluates and queries required Android Bluetooth runtime permissions across different Android SDK levels.
+ */
+class BluetoothPermissionHelper : BluetoothPermissionChecker {
+
+ override fun getRequiredPermissions(): Array {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ arrayOf(
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_CONNECT
+ )
+ } else {
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
+ }
+ }
+
+ override fun getMissingPermissions(context: Context): List {
+ return getRequiredPermissions().filter {
+ ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
+ }
+ }
+
+ override fun hasAllPermissions(context: Context): Boolean {
+ return getMissingPermissions(context).isEmpty()
+ }
+}
diff --git a/app/src/main/java/com/abcomm/ui/MainUiState.kt b/app/src/main/java/com/abcomm/ui/MainUiState.kt
new file mode 100644
index 0000000..6d50721
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/MainUiState.kt
@@ -0,0 +1,27 @@
+package com.abcomm.ui
+
+import com.abcomm.communication.ConnectionMode
+import com.abcomm.communication.ConnectionStatus
+import com.abcomm.protocol.MicrohilProtocolConstants
+import com.abcomm.settings.AppSettings
+
+/**
+ * Immutable state representation consumed by the UI layer, detailing connectivity,
+ * relay channel boolean states, hardware IDs, and firmware info.
+ */
+data class MainUiState(
+ val connectionStatus: ConnectionStatus = ConnectionStatus.Disconnected,
+ val channelStates: List = List(MicrohilProtocolConstants.CHANNEL_COUNT) { false },
+ val connectionMode: ConnectionMode = ConnectionMode.BLE,
+ val wifiHost: String = AppSettings.DEFAULT_WIFI_IP,
+ val wifiPort: Int = AppSettings.DEFAULT_WIFI_PORT,
+ val lastResponse: String = "",
+ val boardId: String = "",
+ val firmwareVersion: String = ""
+) {
+ val isConnected: Boolean
+ get() = connectionStatus is ConnectionStatus.Connected
+
+ val isConnecting: Boolean
+ get() = connectionStatus is ConnectionStatus.Connecting
+}
diff --git a/app/src/main/java/com/abcomm/ui/MainViewModel.kt b/app/src/main/java/com/abcomm/ui/MainViewModel.kt
new file mode 100644
index 0000000..c0c57e1
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/MainViewModel.kt
@@ -0,0 +1,212 @@
+package com.abcomm.ui
+
+import androidx.lifecycle.ViewModel
+import com.abcomm.communication.CommunicationProvider
+import com.abcomm.communication.CommunicationProviderRegistry
+import com.abcomm.communication.ConnectionMode
+import com.abcomm.communication.ConnectionStatus
+import com.abcomm.communication.ConnectionTarget
+import com.abcomm.communication.DefaultCommunicationProviderRegistry
+import com.abcomm.protocol.CommandFormatter
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.MicrohilCommandFormatter
+import com.abcomm.protocol.MicrohilProtocolConstants
+import com.abcomm.protocol.MicrohilResponseParser
+import com.abcomm.protocol.ResponseParser
+import com.abcomm.settings.AppSettings
+import com.abcomm.settings.AppSettingsRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * Manages UI state, coordinates communication providers across Bluetooth/Wi-Fi modes,
+ * dispatches microHIL formatted commands, and processes incoming device telemetry.
+ */
+class MainViewModel(
+ val providerRegistry: CommunicationProviderRegistry,
+ val commandFormatter: CommandFormatter = MicrohilCommandFormatter(),
+ val responseParser: ResponseParser = MicrohilResponseParser(),
+ val settingsRepository: AppSettingsRepository? = null
+) : ViewModel() {
+
+ // Test convenience constructor for single mock communicator
+ constructor(communicator: CommunicationProvider) : this(
+ providerRegistry = DefaultCommunicationProviderRegistry(
+ mapOf(
+ ConnectionMode.BLE to communicator,
+ ConnectionMode.WIFI to communicator
+ )
+ )
+ )
+
+ private val _uiState: MutableStateFlow = run {
+ val initialSettings = settingsRepository?.getSettings()
+ MutableStateFlow(
+ MainUiState(
+ connectionMode = initialSettings?.connectionMode ?: ConnectionMode.BLE,
+ wifiHost = initialSettings?.wifiIp ?: AppSettings.DEFAULT_WIFI_IP,
+ wifiPort = initialSettings?.wifiPort ?: AppSettings.DEFAULT_WIFI_PORT
+ )
+ )
+ }
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var activeCommunicator: CommunicationProvider
+
+ init {
+ activeCommunicator = providerRegistry.getProvider(_uiState.value.connectionMode)
+ setupActiveCommunicator(activeCommunicator)
+ }
+
+ private fun setupActiveCommunicator(provider: CommunicationProvider) {
+ provider.setStatusListener { status ->
+ if (provider === activeCommunicator) {
+ updateStatus(status)
+ }
+ }
+ provider.setResponseListener { response ->
+ if (provider === activeCommunicator) {
+ handleIncomingResponse(response)
+ }
+ }
+ }
+
+ private fun updateStatus(status: ConnectionStatus) {
+ val wasConnected = _uiState.value.isConnected
+ val isNowConnected = status is ConnectionStatus.Connected
+
+ _uiState.value = _uiState.value.copy(
+ connectionStatus = status,
+ channelStates = if (isNowConnected) _uiState.value.channelStates else List(MicrohilProtocolConstants.CHANNEL_COUNT) { false },
+ boardId = if (isNowConnected) _uiState.value.boardId else "",
+ firmwareVersion = if (isNowConnected) _uiState.value.firmwareVersion else ""
+ )
+
+ // Automatically query status and device info when connection is established
+ if (isNowConnected && !wasConnected) {
+ queryAllStatus()
+ queryDeviceInfo()
+ }
+ }
+
+ private fun handleIncomingResponse(rawResponse: String) {
+ _uiState.value = _uiState.value.copy(lastResponse = rawResponse)
+
+ when (val parsed = responseParser.parse(rawResponse)) {
+ is DeviceResponse.ChannelState -> {
+ if (parsed.channel in MicrohilProtocolConstants.MIN_CHANNEL..MicrohilProtocolConstants.MAX_CHANNEL) {
+ val newStates = _uiState.value.channelStates.toMutableList()
+ newStates[parsed.channel - 1] = parsed.isOn
+ _uiState.value = _uiState.value.copy(channelStates = newStates)
+ }
+ }
+ is DeviceResponse.AllChannelsState -> {
+ _uiState.value = _uiState.value.copy(
+ channelStates = List(MicrohilProtocolConstants.CHANNEL_COUNT) { parsed.isOn }
+ )
+ }
+ is DeviceResponse.AllChannelsSnapshot -> {
+ if (parsed.states.size == MicrohilProtocolConstants.CHANNEL_COUNT) {
+ _uiState.value = _uiState.value.copy(channelStates = parsed.states)
+ }
+ }
+ is DeviceResponse.BoardId -> {
+ _uiState.value = _uiState.value.copy(boardId = parsed.id)
+ }
+ is DeviceResponse.FirmwareVersion -> {
+ _uiState.value = _uiState.value.copy(firmwareVersion = parsed.version)
+ }
+ is DeviceResponse.SystemResetting -> {
+ _uiState.value = _uiState.value.copy(
+ channelStates = List(MicrohilProtocolConstants.CHANNEL_COUNT) { false },
+ boardId = "",
+ firmwareVersion = ""
+ )
+ }
+ is DeviceResponse.Unknown -> {
+ // Keep raw response stored
+ }
+ }
+ }
+
+ fun setConnectionMode(mode: ConnectionMode) {
+ if (_uiState.value.connectionMode == mode) return
+ if (isConnected()) {
+ disconnect()
+ }
+ activeCommunicator = providerRegistry.getProvider(mode)
+ setupActiveCommunicator(activeCommunicator)
+
+ _uiState.value = _uiState.value.copy(
+ connectionMode = mode,
+ connectionStatus = ConnectionStatus.Disconnected,
+ boardId = "",
+ firmwareVersion = ""
+ )
+ settingsRepository?.saveConnectionMode(mode)
+ }
+
+ fun setWifiTarget(host: String, port: Int) {
+ _uiState.value = _uiState.value.copy(
+ wifiHost = host,
+ wifiPort = port
+ )
+ settingsRepository?.saveWifiTarget(host, port)
+ }
+
+ fun connect(target: ConnectionTarget) {
+ activeCommunicator.connect(target)
+ }
+
+ fun disconnect() {
+ activeCommunicator.disconnect()
+ }
+
+ fun toggleChannel(index: Int) {
+ if (!_uiState.value.isConnected) return
+ require(index in 0 until MicrohilProtocolConstants.CHANNEL_COUNT) {
+ "Channel index must be 0 until ${MicrohilProtocolConstants.CHANNEL_COUNT}"
+ }
+
+ val currentState = _uiState.value.channelStates[index]
+ val newState = !currentState
+
+ val newStates = _uiState.value.channelStates.toMutableList()
+ newStates[index] = newState
+ _uiState.value = _uiState.value.copy(channelStates = newStates)
+
+ val channelNum = index + 1
+ activeCommunicator.sendCommand(commandFormatter.formatChannel(channelNum, newState))
+ }
+
+ fun setAllChannels(on: Boolean) {
+ if (!_uiState.value.isConnected) return
+
+ activeCommunicator.sendCommand(commandFormatter.formatAllChannels(on))
+ _uiState.value = _uiState.value.copy(
+ channelStates = List(MicrohilProtocolConstants.CHANNEL_COUNT) { on }
+ )
+ }
+
+ fun queryAllStatus() {
+ if (_uiState.value.isConnected) {
+ activeCommunicator.sendCommand(commandFormatter.formatQueryAllStatus())
+ }
+ }
+
+ fun queryDeviceInfo() {
+ if (_uiState.value.isConnected) {
+ activeCommunicator.sendCommand(commandFormatter.formatQueryBoardId())
+ activeCommunicator.sendCommand(commandFormatter.formatQueryVersion())
+ }
+ }
+
+ fun resetDevice() {
+ if (_uiState.value.isConnected) {
+ activeCommunicator.sendCommand(commandFormatter.formatReset())
+ }
+ }
+
+ fun isConnected(): Boolean = activeCommunicator.isConnected()
+}
diff --git a/app/src/main/java/com/abcomm/ui/MainViewModelFactory.kt b/app/src/main/java/com/abcomm/ui/MainViewModelFactory.kt
new file mode 100644
index 0000000..b19d522
--- /dev/null
+++ b/app/src/main/java/com/abcomm/ui/MainViewModelFactory.kt
@@ -0,0 +1,45 @@
+package com.abcomm.ui
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import com.abcomm.communication.BluetoothService
+import com.abcomm.communication.CommunicationProvider
+import com.abcomm.communication.ConnectionMode
+import com.abcomm.communication.DefaultCommunicationProviderRegistry
+import com.abcomm.communication.WifiService
+import com.abcomm.protocol.CommandFormatter
+import com.abcomm.protocol.MicrohilCommandFormatter
+import com.abcomm.protocol.MicrohilResponseParser
+import com.abcomm.protocol.ResponseParser
+import com.abcomm.settings.AppSettingsRepository
+
+/**
+ * ViewModelProvider.Factory assembling dependencies and creating MainViewModel instances.
+ */
+class MainViewModelFactory(
+ private val settingsRepository: AppSettingsRepository,
+ private val bluetoothService: CommunicationProvider = BluetoothService(),
+ private val wifiService: CommunicationProvider = WifiService(),
+ private val commandFormatter: CommandFormatter = MicrohilCommandFormatter(),
+ private val responseParser: ResponseParser = MicrohilResponseParser()
+) : ViewModelProvider.Factory {
+
+ override fun create(modelClass: Class): T {
+ if (modelClass.isAssignableFrom(MainViewModel::class.java)) {
+ val registry = DefaultCommunicationProviderRegistry(
+ mapOf(
+ ConnectionMode.BLE to bluetoothService,
+ ConnectionMode.WIFI to wifiService
+ )
+ )
+ @Suppress("UNCHECKED_CAST")
+ return MainViewModel(
+ providerRegistry = registry,
+ commandFormatter = commandFormatter,
+ responseParser = responseParser,
+ settingsRepository = settingsRepository
+ ) as T
+ }
+ throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}")
+ }
+}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 4b63897..7ea3a5f 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -20,40 +20,201 @@
app:strokeColor="@color/cyber_cyan"
app:strokeWidth="1dp">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ android:textStyle="bold" />
-
+
+ android:textSize="10sp"
+ android:visibility="gone"
+ tools:text="DEVICE: mh:333:2023:0 | FW: microHIL v1.0.0"
+ tools:visibility="visible" />
+
+
+
+
+
+
+
+
-
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 837213f..d473c49 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,7 +17,24 @@
HARDWARE OFFLINE: ENABLE BT
NO TARGETS DETECTED
LINK OFFLINE
+ INVALID IP OR PORT
SELECT TARGET DEVICE
ABORT
+
+ BLE
+ WIFI (TCP)
+ TARGET IP
+ PORT
+ 192.168.1.100
+ 5000
+ LAST RESP: %1$s
+ DEVICE: %1$s | FW: %2$s
+ SYNC
+ RESET
+ REBOOT CONFIRMATION
+ Are you sure you want to reboot the microHIL device?
+ REBOOT
+ Syncing device status...
+ N/A
diff --git a/app/src/test/java/com/abcomm/BluetoothPermissionHelperTest.kt b/app/src/test/java/com/abcomm/BluetoothPermissionHelperTest.kt
new file mode 100644
index 0000000..6966da9
--- /dev/null
+++ b/app/src/test/java/com/abcomm/BluetoothPermissionHelperTest.kt
@@ -0,0 +1,17 @@
+package com.abcomm
+
+import com.abcomm.ui.BluetoothPermissionHelper
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BluetoothPermissionHelperTest {
+
+ @Test
+ fun `getRequiredPermissions returns non-empty permissions array`() {
+ val helper = BluetoothPermissionHelper()
+ val permissions = helper.getRequiredPermissions()
+ assertNotNull(permissions)
+ assertTrue(permissions.isNotEmpty())
+ }
+}
diff --git a/app/src/test/java/com/abcomm/MainViewModelFactoryTest.kt b/app/src/test/java/com/abcomm/MainViewModelFactoryTest.kt
new file mode 100644
index 0000000..7250eb2
--- /dev/null
+++ b/app/src/test/java/com/abcomm/MainViewModelFactoryTest.kt
@@ -0,0 +1,33 @@
+package com.abcomm
+
+import com.abcomm.settings.AppSettings
+import com.abcomm.settings.AppSettingsRepository
+import com.abcomm.ui.MainViewModel
+import com.abcomm.ui.MainViewModelFactory
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Assert.assertNotNull
+import org.junit.Test
+
+class MainViewModelFactoryTest {
+
+ @Test
+ fun `create returns MainViewModel instance when requested`() {
+ val settingsRepository = mockk()
+ every { settingsRepository.getSettings() } returns AppSettings()
+ val factory = MainViewModelFactory(settingsRepository)
+
+ val viewModel = factory.create(MainViewModel::class.java)
+ assertNotNull(viewModel)
+ }
+
+ @Test(expected = IllegalArgumentException::class)
+ fun `create throws IllegalArgumentException for unknown ViewModel class`() {
+ val settingsRepository = mockk()
+ every { settingsRepository.getSettings() } returns AppSettings()
+ val factory = MainViewModelFactory(settingsRepository)
+
+ class UnknownViewModel : androidx.lifecycle.ViewModel()
+ factory.create(UnknownViewModel::class.java)
+ }
+}
diff --git a/app/src/test/java/com/abcomm/MainViewModelTest.kt b/app/src/test/java/com/abcomm/MainViewModelTest.kt
index f326516..017b0dd 100644
--- a/app/src/test/java/com/abcomm/MainViewModelTest.kt
+++ b/app/src/test/java/com/abcomm/MainViewModelTest.kt
@@ -1,7 +1,9 @@
package com.abcomm
-import io.mockk.confirmVerified
-import io.mockk.every
+import com.abcomm.communication.CommunicationProvider
+import com.abcomm.communication.ConnectionMode
+import com.abcomm.communication.ConnectionStatus
+import com.abcomm.ui.MainViewModel
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
@@ -38,52 +40,59 @@ class MainViewModelTest {
@Test
fun `initial state is disconnected`() {
val state = viewModel.uiState.value
- assertEquals("Disconnected", state.status)
+ assertEquals(ConnectionStatus.Disconnected, state.connectionStatus)
assertFalse(state.isConnected)
assertFalse(state.isConnecting)
+ assertEquals(ConnectionMode.BLE, state.connectionMode)
assertEquals(List(8) { false }, state.channelStates)
+ assertEquals("", state.boardId)
+ assertEquals("", state.firmwareVersion)
}
@Test
- fun `status update changes uiState`() {
- val statusListenerSlot = slot<(String) -> Unit>()
+ fun `status update changes uiState and triggers queries on connect`() {
+ val statusListenerSlot = slot<(ConnectionStatus) -> Unit>()
verify { communicator.setStatusListener(capture(statusListenerSlot)) }
// Update to Connecting
- statusListenerSlot.captured("Connecting...")
+ statusListenerSlot.captured(ConnectionStatus.Connecting("microHIL"))
assertTrue(viewModel.uiState.value.isConnecting)
assertFalse(viewModel.uiState.value.isConnected)
- assertEquals("Connecting...", viewModel.uiState.value.status)
+ assertEquals(ConnectionStatus.Connecting("microHIL"), viewModel.uiState.value.connectionStatus)
// Update to Connected
- statusListenerSlot.captured("Connected to Device")
+ statusListenerSlot.captured(ConnectionStatus.Connected("microHIL"))
assertFalse(viewModel.uiState.value.isConnecting)
assertTrue(viewModel.uiState.value.isConnected)
- assertEquals("Connected to Device", viewModel.uiState.value.status)
+ assertEquals(ConnectionStatus.Connected("microHIL"), viewModel.uiState.value.connectionStatus)
+
+ // Verifies auto-query commands sent upon connection
+ verify { communicator.sendCommand("") }
+ verify { communicator.sendCommand("") }
+ verify { communicator.sendCommand("") }
}
@Test
- fun `toggleChannel sends correct command when connected`() {
+ fun `toggleChannel sends correct framed command when connected`() {
// Setup connected state
- val statusListenerSlot = slot<(String) -> Unit>()
+ val statusListenerSlot = slot<(ConnectionStatus) -> Unit>()
verify { communicator.setStatusListener(capture(statusListenerSlot)) }
- statusListenerSlot.captured("Connected")
+ statusListenerSlot.captured(ConnectionStatus.Connected("microHIL"))
// Toggle channel 0 (index 0 -> channel 1)
viewModel.toggleChannel(0)
- verify { communicator.sendCommand("mh#ch#1#on#end") }
+ verify { communicator.sendCommand("") }
assertTrue(viewModel.uiState.value.channelStates[0])
// Toggle again to turn off
viewModel.toggleChannel(0)
- verify { communicator.sendCommand("mh#ch#1#off#end") }
+ verify { communicator.sendCommand("") }
assertFalse(viewModel.uiState.value.channelStates[0])
}
@Test
fun `toggleChannel does nothing when disconnected`() {
- // Initially disconnected
viewModel.toggleChannel(0)
verify(exactly = 0) { communicator.sendCommand(any()) }
@@ -91,29 +100,82 @@ class MainViewModelTest {
}
@Test
- fun `setAllChannels sends correct command when connected`() {
- // Setup connected state
- val statusListenerSlot = slot<(String) -> Unit>()
+ fun `setAllChannels sends correct framed command when connected`() {
+ val statusListenerSlot = slot<(ConnectionStatus) -> Unit>()
verify { communicator.setStatusListener(capture(statusListenerSlot)) }
- statusListenerSlot.captured("Connected")
+ statusListenerSlot.captured(ConnectionStatus.Connected("microHIL"))
// Set all on
viewModel.setAllChannels(true)
- verify { communicator.sendCommand("mh#ch#all#on#end") }
+ verify { communicator.sendCommand("") }
assertTrue(viewModel.uiState.value.channelStates.all { it })
// Set all off
viewModel.setAllChannels(false)
- verify { communicator.sendCommand("mh#ch#all#off#end") }
+ verify { communicator.sendCommand("") }
assertTrue(viewModel.uiState.value.channelStates.all { !it })
}
@Test
- fun `setAllChannels does nothing when disconnected`() {
- // Initially disconnected
- viewModel.setAllChannels(true)
+ fun `incoming response updates channel states from snapshot`() {
+ val responseListenerSlot = slot<(String) -> Unit>()
+ verify { communicator.setResponseListener(capture(responseListenerSlot)) }
+
+ val snapshot = "mh#sys#channels: 1:ON 2:OFF 3:ON 4:OFF 5:OFF 6:OFF 7:OFF 8:ON #end"
+ responseListenerSlot.captured(snapshot)
+
+ val states = viewModel.uiState.value.channelStates
+ assertTrue(states[0])
+ assertFalse(states[1])
+ assertTrue(states[2])
+ assertTrue(states[7])
+ assertEquals(snapshot, viewModel.uiState.value.lastResponse)
+ }
- verify(exactly = 0) { communicator.sendCommand(any()) }
+ @Test
+ fun `incoming response updates board ID and firmware version`() {
+ val responseListenerSlot = slot<(String) -> Unit>()
+ verify { communicator.setResponseListener(capture(responseListenerSlot)) }
+
+ responseListenerSlot.captured("mh#sys#mh:333:2023:0#end")
+ assertEquals("mh:333:2023:0", viewModel.uiState.value.boardId)
+
+ responseListenerSlot.captured("mh#sys#microHIL v1.0.0#end")
+ assertEquals("microHIL v1.0.0", viewModel.uiState.value.firmwareVersion)
+ }
+
+ @Test
+ fun `disconnection resets channel states and clears device info`() {
+ val statusListenerSlot = slot<(ConnectionStatus) -> Unit>()
+ val responseListenerSlot = slot<(String) -> Unit>()
+ verify { communicator.setStatusListener(capture(statusListenerSlot)) }
+ verify { communicator.setResponseListener(capture(responseListenerSlot)) }
+
+ // Connect and receive info + channels
+ statusListenerSlot.captured(ConnectionStatus.Connected("microHIL"))
+ responseListenerSlot.captured("mh#sys#mh:333:2023:0#end")
+ responseListenerSlot.captured("mh#sys#channels: 1:ON 2:ON 3:ON 4:ON 5:ON 6:ON 7:ON 8:ON #end")
+
+ assertTrue(viewModel.uiState.value.isConnected)
+ assertEquals("mh:333:2023:0", viewModel.uiState.value.boardId)
+ assertTrue(viewModel.uiState.value.channelStates[0])
+
+ // Trigger Disconnect
+ statusListenerSlot.captured(ConnectionStatus.Disconnected)
+
+ assertFalse(viewModel.uiState.value.isConnected)
+ assertEquals(ConnectionStatus.Disconnected, viewModel.uiState.value.connectionStatus)
+ assertEquals("", viewModel.uiState.value.boardId)
+ assertEquals("", viewModel.uiState.value.firmwareVersion)
assertTrue(viewModel.uiState.value.channelStates.all { !it })
}
+
+ @Test
+ fun `switching connection mode updates uiState`() {
+ viewModel.setConnectionMode(ConnectionMode.WIFI)
+ assertEquals(ConnectionMode.WIFI, viewModel.uiState.value.connectionMode)
+
+ viewModel.setConnectionMode(ConnectionMode.BLE)
+ assertEquals(ConnectionMode.BLE, viewModel.uiState.value.connectionMode)
+ }
}
diff --git a/app/src/test/java/com/abcomm/MicrohilProtocolTest.kt b/app/src/test/java/com/abcomm/MicrohilProtocolTest.kt
new file mode 100644
index 0000000..f81c95c
--- /dev/null
+++ b/app/src/test/java/com/abcomm/MicrohilProtocolTest.kt
@@ -0,0 +1,114 @@
+package com.abcomm
+
+import com.abcomm.protocol.CommandFormatter
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.MicrohilCommandFormatter
+import com.abcomm.protocol.MicrohilFrameParser
+import com.abcomm.protocol.MicrohilResponseParser
+import com.abcomm.protocol.ResponseParser
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class MicrohilProtocolTest {
+
+ private val formatter: CommandFormatter = MicrohilCommandFormatter()
+ private val responseParser: ResponseParser = MicrohilResponseParser()
+
+ @Test
+ fun `formatChannel creates valid framed command`() {
+ assertEquals("", formatter.formatChannel(1, true))
+ assertEquals("", formatter.formatChannel(8, false))
+ }
+
+ @Test
+ fun `formatAllChannels creates valid framed command`() {
+ assertEquals("", formatter.formatAllChannels(true))
+ assertEquals("", formatter.formatAllChannels(false))
+ }
+
+ @Test
+ fun `formatQuery commands create valid framed commands`() {
+ assertEquals("", formatter.formatQueryAllStatus())
+ assertEquals("", formatter.formatQueryChannelStatus(3))
+ assertEquals("", formatter.formatQueryBoardId())
+ assertEquals("", formatter.formatQueryVersion())
+ assertEquals("", formatter.formatReset())
+ assertEquals("", formatter.formatMask("10101010"))
+ }
+
+ @Test
+ fun `FrameParser handles complete frame`() {
+ val parser = MicrohilFrameParser()
+ val frames = parser.process("")
+ assertEquals(1, frames.size)
+ assertEquals("mh#sys#all channels on#end", frames[0])
+ }
+
+ @Test
+ fun `FrameParser handles split chunks`() {
+ val parser = MicrohilFrameParser()
+ val frames1 = parser.process("")
+ assertEquals(1, frames2.size)
+ assertEquals("mh#sys#channel 2 on#end", frames2[0])
+ }
+
+ @Test
+ fun `FrameParser handles multiple frames in single chunk`() {
+ val parser = MicrohilFrameParser()
+ val frames = parser.process("")
+ assertEquals(2, frames.size)
+ assertEquals("mh#sys#mh:333:2023:0#end", frames[0])
+ assertEquals("mh#sys#microHIL v1.0.0#end", frames[1])
+ }
+
+ @Test
+ fun `parseResponse parses channel single state`() {
+ val resp1 = responseParser.parse("mh#sys#channel 3 on#end")
+ assertTrue(resp1 is DeviceResponse.ChannelState)
+ assertEquals(3, (resp1 as DeviceResponse.ChannelState).channel)
+ assertTrue(resp1.isOn)
+
+ val resp2 = responseParser.parse("mh#sys#channel 5 off#end")
+ assertTrue(resp2 is DeviceResponse.ChannelState)
+ assertEquals(5, (resp2 as DeviceResponse.ChannelState).channel)
+ assertFalse(resp2.isOn)
+ }
+
+ @Test
+ fun `parseResponse parses all channels status snapshot`() {
+ val raw = "mh#sys#channels: 1:ON 2:OFF 3:ON 4:OFF 5:OFF 6:OFF 7:OFF 8:ON #end"
+ val resp = responseParser.parse(raw)
+ assertTrue(resp is DeviceResponse.AllChannelsSnapshot)
+ val snapshot = resp as DeviceResponse.AllChannelsSnapshot
+ assertTrue(snapshot.states[0])
+ assertFalse(snapshot.states[1])
+ assertTrue(snapshot.states[2])
+ assertFalse(snapshot.states[3])
+ assertFalse(snapshot.states[4])
+ assertFalse(snapshot.states[5])
+ assertFalse(snapshot.states[6])
+ assertTrue(snapshot.states[7])
+ }
+
+ @Test
+ fun `parseResponse parses board ID and firmware version`() {
+ val idResp = responseParser.parse("mh#sys#mh:333:2023:0#end")
+ assertTrue(idResp is DeviceResponse.BoardId)
+ assertEquals("mh:333:2023:0", (idResp as DeviceResponse.BoardId).id)
+
+ val verResp = responseParser.parse("mh#sys#microHIL v1.0.0#end")
+ assertTrue(verResp is DeviceResponse.FirmwareVersion)
+ assertEquals("microHIL v1.0.0", (verResp as DeviceResponse.FirmwareVersion).version)
+ }
+
+ @Test
+ fun `parseResponse parses system reset`() {
+ val resetResp = responseParser.parse("mh#sys#system resetting...#end")
+ assertTrue(resetResp is DeviceResponse.SystemResetting)
+ }
+}
diff --git a/app/src/test/java/com/abcomm/ResponseMatchersTest.kt b/app/src/test/java/com/abcomm/ResponseMatchersTest.kt
new file mode 100644
index 0000000..71f0a8d
--- /dev/null
+++ b/app/src/test/java/com/abcomm/ResponseMatchersTest.kt
@@ -0,0 +1,92 @@
+package com.abcomm
+
+import com.abcomm.protocol.DeviceResponse
+import com.abcomm.protocol.matchers.AllChannelsSnapshotMatcher
+import com.abcomm.protocol.matchers.AllChannelsStateMatcher
+import com.abcomm.protocol.matchers.BoardIdMatcher
+import com.abcomm.protocol.matchers.ChannelStateMatcher
+import com.abcomm.protocol.matchers.FirmwareVersionMatcher
+import com.abcomm.protocol.matchers.MaskAppliedMatcher
+import com.abcomm.protocol.matchers.SystemResettingMatcher
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ResponseMatchersTest {
+
+ @Test
+ fun `ChannelStateMatcher parses valid frame and rejects invalid`() {
+ val matcher = ChannelStateMatcher()
+ val res = matcher.match("mh#sys#channel 4 on#end")
+ assertTrue(res is DeviceResponse.ChannelState)
+ assertEquals(4, (res as DeviceResponse.ChannelState).channel)
+ assertTrue(res.isOn)
+
+ assertNull(matcher.match("mh#sys#all channels on#end"))
+ }
+
+ @Test
+ fun `AllChannelsStateMatcher parses valid frame and rejects invalid`() {
+ val matcher = AllChannelsStateMatcher()
+ val res = matcher.match("mh#sys#all channels off#end")
+ assertTrue(res is DeviceResponse.AllChannelsState)
+ assertFalse((res as DeviceResponse.AllChannelsState).isOn)
+
+ assertNull(matcher.match("mh#sys#channel 1 on#end"))
+ }
+
+ @Test
+ fun `AllChannelsSnapshotMatcher parses valid snapshot and rejects invalid`() {
+ val matcher = AllChannelsSnapshotMatcher()
+ val res = matcher.match("mh#sys#channels: 1:ON 2:OFF 3:OFF 4:OFF 5:OFF 6:OFF 7:OFF 8:ON #end")
+ assertTrue(res is DeviceResponse.AllChannelsSnapshot)
+ val snapshot = res as DeviceResponse.AllChannelsSnapshot
+ assertTrue(snapshot.states[0])
+ assertFalse(snapshot.states[1])
+ assertTrue(snapshot.states[7])
+
+ assertNull(matcher.match("mh#sys#something else#end"))
+ }
+
+ @Test
+ fun `MaskAppliedMatcher parses valid mask and rejects invalid`() {
+ val matcher = MaskAppliedMatcher()
+ val res = matcher.match("mh#sys#channels mask applied: 10101010#end")
+ assertTrue(res is DeviceResponse.AllChannelsSnapshot)
+ val snapshot = res as DeviceResponse.AllChannelsSnapshot
+ assertEquals(listOf(true, false, true, false, true, false, true, false), snapshot.states)
+
+ assertNull(matcher.match("invalid frame"))
+ }
+
+ @Test
+ fun `BoardIdMatcher parses valid board id and rejects invalid`() {
+ val matcher = BoardIdMatcher()
+ val res = matcher.match("mh#sys#mh:333:2023:0#end")
+ assertTrue(res is DeviceResponse.BoardId)
+ assertEquals("mh:333:2023:0", (res as DeviceResponse.BoardId).id)
+
+ assertNull(matcher.match("mh#sys#microHIL v1.0.0#end"))
+ }
+
+ @Test
+ fun `FirmwareVersionMatcher parses valid version and rejects invalid`() {
+ val matcher = FirmwareVersionMatcher()
+ val res = matcher.match("mh#sys#microHIL v1.0.0#end")
+ assertTrue(res is DeviceResponse.FirmwareVersion)
+ assertEquals("microHIL v1.0.0", (res as DeviceResponse.FirmwareVersion).version)
+
+ assertNull(matcher.match("mh#sys#mh:333:2023:0#end"))
+ }
+
+ @Test
+ fun `SystemResettingMatcher parses resetting keyword and rejects invalid`() {
+ val matcher = SystemResettingMatcher()
+ val res = matcher.match("mh#sys#system resetting...#end")
+ assertTrue(res is DeviceResponse.SystemResetting)
+
+ assertNull(matcher.match("mh#sys#ok#end"))
+ }
+}
diff --git a/app/src/test/java/com/abcomm/SharedPreferencesSettingsRepositoryTest.kt b/app/src/test/java/com/abcomm/SharedPreferencesSettingsRepositoryTest.kt
new file mode 100644
index 0000000..0bf30e3
--- /dev/null
+++ b/app/src/test/java/com/abcomm/SharedPreferencesSettingsRepositoryTest.kt
@@ -0,0 +1,56 @@
+package com.abcomm
+
+import android.content.SharedPreferences
+import com.abcomm.communication.ConnectionMode
+import com.abcomm.settings.AppSettingsRepository
+import com.abcomm.settings.SharedPreferencesSettingsRepository
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class SharedPreferencesSettingsRepositoryTest {
+
+ private val sharedPreferences = mockk(relaxed = true)
+ private val editor = mockk(relaxed = true)
+ private lateinit var repository: AppSettingsRepository
+
+ @Before
+ fun setUp() {
+ every { sharedPreferences.edit() } returns editor
+ every { editor.putString(any(), any()) } returns editor
+ every { editor.putInt(any(), any()) } returns editor
+ repository = SharedPreferencesSettingsRepository(sharedPreferences)
+ }
+
+ @Test
+ fun `getSettings returns stored settings when present`() {
+ every { sharedPreferences.getString("wifi_ip", any()) } returns "192.168.4.1"
+ every { sharedPreferences.getInt("wifi_port", any()) } returns 8080
+ every { sharedPreferences.getString("conn_mode", any()) } returns "WIFI"
+
+ val settings = repository.getSettings()
+ assertEquals("192.168.4.1", settings.wifiIp)
+ assertEquals(8080, settings.wifiPort)
+ assertEquals(ConnectionMode.WIFI, settings.connectionMode)
+ }
+
+ @Test
+ fun `saveWifiTarget writes to shared preferences`() {
+ repository.saveWifiTarget("10.0.0.1", 9000)
+
+ verify { editor.putString("wifi_ip", "10.0.0.1") }
+ verify { editor.putInt("wifi_port", 9000) }
+ verify { editor.apply() }
+ }
+
+ @Test
+ fun `saveConnectionMode writes to shared preferences`() {
+ repository.saveConnectionMode(ConnectionMode.WIFI)
+
+ verify { editor.putString("conn_mode", "WIFI") }
+ verify { editor.apply() }
+ }
+}
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..d0c3cbf
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..5b9ed57
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,225 @@
+.. ABComm documentation master file
+
+===============================================
+ABComm - Advanced Bluetooth & Wi-Fi Relay Control
+===============================================
+
+.. image:: https://raw.githubusercontent.com/electux/abcomm/main/docs/logo.svg
+ :align: right
+ :width: 25%
+
+**ABComm** is a futuristic Android client application designed for high-performance, real-time control of 8-channel relay boards powered by **Raspberry Pi Pico** running **microHIL** firmware.
+
+Developed with **Kotlin**, **Android Jetpack**, and **Kotlin Coroutines**.
+
+The application features a Cyberpunk-styled interface supporting dual-mode connectivity (**Bluetooth Low Energy / RFCOMM** and **Wi-Fi TCP Socket**), automated hardware telemetry synchronization, and robust error handling.
+
+.. image:: https://img.shields.io/badge/License-MIT-yellow.svg
+ :target: https://opensource.org/licenses/MIT
+ :alt: License: MIT
+
+.. image:: https://img.shields.io/github/issues/electux/abcomm.svg
+ :target: https://github.com/electux/abcomm/issues
+ :alt: GitHub issues open
+
+.. image:: https://img.shields.io/github/contributors/electux/abcomm.svg
+ :target: https://github.com/electux/abcomm/graphs/contributors
+ :alt: GitHub contributors
+
+---
+
+Table of Contents
+=================
+
+.. contents::
+ :local:
+ :depth: 2
+
+✨ Features
+===========
+
+* **Dual Connectivity**: Seamlessly switch between **Bluetooth (BLE / RFCOMM)** and **Wi-Fi (TCP Socket)**.
+* **Settings Persistence**: User-configured Wi-Fi IP address and Port are securely persisted via SharedPreferences.
+* **8-Channel Independent Control**: Instant toggle for individual channels (1 to 8) with dynamic active/inactive states.
+* **Master Controls**: Quick-action **ALL ON** and **ALL OFF** buttons for simultaneous relay switching.
+* **Automated Telemetry Sync**: Automatically queries and displays hardware Board ID (``mh:333:2023:0``), Firmware Version (``microHIL v1.0.0``), and live relay states on connect.
+* **Manual Sync & Device Reboot**: Dedicated **SYNC** button for manual state refreshing and **RESET** button with a confirmation dialog.
+* **Robust Disconnection Handling**: Immediate socket cleanup and automatic UI state reset to ``OFF`` when the device disconnects or powers down.
+* **Clean Architecture**: 100% Type-Safe (``ConnectionStatus``, ``DeviceResponse``), Dependency Inversion (DIP), Open/Closed (OCP) response matchers, and Coroutine-based background I/O (``Dispatchers.IO``).
+
+📡 microHIL Communication Protocol
+==================================
+
+All messages exchanged between the ABComm Android client and the Raspberry Pi Pico server are framed with ``<`` at the start and ``>`` at the end:
+
+.. list-table:: microHIL Protocol Specification
+ :widths: 25 35 40
+ :header-rows: 1
+
+ * - Action
+ - Command Frame
+ - Response Format
+ * - **Toggle Channel ON**
+ - ````
+ - ````
+ * - **Toggle Channel OFF**
+ - ````
+ - ````
+ * - **All Channels ON**
+ - ````
+ - ````
+ * - **All Channels OFF**
+ - ````
+ - ````
+ * - **Query All Channels**
+ - ````
+ - ````
+ * - **Query Board ID**
+ - ````
+ - ````
+ * - **Query Firmware Version**
+ - ````
+ - ````
+ * - **System Reboot**
+ - ````
+ - ````
+ * - **Set Channel Mask**
+ - ````
+ - ````
+
+🚀 Installation & Building
+==========================
+
+Developed and tested on **Android 14 (API 34)** and backwards compatible down to **Android 7.0 (API 24)**.
+
+Build from Source
+-----------------
+
+.. code-block:: bash
+
+ # 1. Clone repository
+ git clone https://github.com/electux/abcomm.git
+ cd abcomm
+
+ # 2. Build Debug APK
+ ./gradlew assembleDebug
+
+ # Output APK path:
+ # app/build/outputs/apk/debug/app-debug.apk
+
+Run Unit Tests
+--------------
+
+Execute the complete test suite (Protocol formatters, Stream parsers, OCP Matchers, ViewModel state, and Repositories):
+
+.. code-block:: bash
+
+ ./gradlew testDebugUnitTest
+
+📦 Dependencies & Permissions
+=============================
+
+The app declares and dynamically requests appropriate permissions:
+
+* **Bluetooth**: ``BLUETOOTH_SCAN``, ``BLUETOOTH_CONNECT`` (Android 12+ / API 31+), ``ACCESS_FINE_LOCATION`` (Android 11 and earlier).
+* **Wi-Fi / Network**: ``INTERNET``, ``ACCESS_NETWORK_STATE``.
+
+📁 Project Architecture
+=======================
+
+The codebase strictly follows the **Single Type per File** and **SOLID** principles, organized into domain packages:
+
+.. code-block:: text
+
+ app/src/main/java/com/abcomm/
+ ├── protocol/
+ │ ├── MicrohilProtocolConstants.kt # Protocol frame delimiters and command keywords
+ │ ├── CommandFormatter.kt # Contract for outbound command formatting
+ │ ├── MicrohilCommandFormatter.kt # Implementation of CommandFormatter
+ │ ├── FrameParser.kt # Stream framing contract (<...>)
+ │ ├── MicrohilFrameParser.kt # Chunked stream frame extractor
+ │ ├── DeviceResponse.kt # Sealed interface for typed device responses
+ │ ├── ResponseParser.kt # Response parsing contract
+ │ ├── ResponseMatcher.kt # Extensible response matcher interface (OCP)
+ │ ├── MicrohilResponseParser.kt # ResponseParser delegating to matcher list
+ │ └── matchers/ # Individual pattern matchers for each response
+ │ ├── ChannelStateMatcher.kt
+ │ ├── AllChannelsStateMatcher.kt
+ │ ├── AllChannelsSnapshotMatcher.kt
+ │ ├── MaskAppliedMatcher.kt
+ │ ├── BoardIdMatcher.kt
+ │ ├── FirmwareVersionMatcher.kt
+ │ └── SystemResettingMatcher.kt
+ │
+ ├── communication/
+ │ ├── ConnectionMode.kt # Enum: BLE, WIFI
+ │ ├── ConnectionTarget.kt # Sealed interface: Bluetooth(device), Wifi(host, port)
+ │ ├── ConnectionStatus.kt # Sealed interface: Disconnected, Connecting, Connected, Error
+ │ ├── ConnectionController.kt # Lifecycle management contract
+ │ ├── CommandSender.kt # Command dispatch contract
+ │ ├── ConnectionObservable.kt # Status and response observer contract
+ │ ├── CommunicationProvider.kt # Composite provider interface
+ │ ├── CommunicationProviderRegistry.kt # Dynamic provider resolution contract
+ │ ├── DefaultCommunicationProviderRegistry.kt
+ │ ├── BluetoothService.kt # RFCOMM Bluetooth provider (Coroutines / Dispatchers.IO)
+ │ └── WifiService.kt # TCP Socket Wi-Fi provider (Coroutines / Dispatchers.IO)
+ │
+ ├── settings/
+ │ ├── AppSettings.kt # Configuration data model and port boundaries
+ │ ├── AppSettingsRepository.kt # Storage abstraction contract
+ │ └── SharedPreferencesSettingsRepository.kt
+ │
+ ├── ui/
+ │ ├── MainUiState.kt # Immutable UI State data model
+ │ ├── MainViewModel.kt # State machine orchestrating UI & hardware
+ │ ├── MainViewModelFactory.kt # Dependency injection factory
+ │ ├── BluetoothPermissionChecker.kt # Permission checker interface
+ │ ├── BluetoothPermissionHelper.kt # Android SDK version-aware permission helper
+ │ ├── BluetoothDeviceProvider.kt # Bluetooth adapter abstraction interface
+ │ └── BluetoothDeviceManager.kt # Paired device manager
+ │
+ └── MainActivity.kt # Primary Android Activity view layer
+
+🛠 Usage Guide
+==============
+
+Bluetooth (BLE / RFCOMM) Mode
+-----------------------------
+
+1. Select the **BLE** mode toggle at the top of the screen.
+2. Tap **CONNECT**.
+3. Grant Bluetooth permissions if prompted.
+4. Select your Raspberry Pi Pico device from the paired devices list.
+5. Once connected, device info and current relay states will load automatically.
+
+Wi-Fi (TCP Socket) Mode
+-----------------------
+
+1. Select the **WIFI** mode toggle at the top.
+2. Enter the **IP Address** and **Port** of your microHIL device (e.g. ``192.168.1.100``, Port ``5000``). Values are automatically saved for subsequent app launches.
+3. Tap **CONNECT**.
+4. Telemetry and relay buttons will update automatically upon connection.
+
+Testing with Python Mock Server
+-------------------------------
+
+You can test Wi-Fi communication without physical hardware using the included mock server:
+
+.. code-block:: bash
+
+ # Run the mock server from the repository root
+ python3 wifi/wifi_server.py
+
+The mock server binds to ``0.0.0.0:5000`` and emulates real microHIL firmware behavior (board ID, version, channel toggling, and snapshots).
+
+👥 Contributing
+===============
+
+Contributions are welcome! Please read `CONTRIBUTING.md `_ for development guidelines.
+
+📄 License
+==========
+
+Copyright (C) 2026 by `electux.github.io/abcomm `_
+
+**ABComm** is open-source software licensed under the **MIT License**.
diff --git a/docs/make.bat b/docs/make.bat
new file mode 100644
index 0000000..dc1312a
--- /dev/null
+++ b/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd
diff --git a/docs/source/conf.py b/docs/source/conf.py
new file mode 100644
index 0000000..c42d305
--- /dev/null
+++ b/docs/source/conf.py
@@ -0,0 +1,29 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = 'ABComm'
+copyright = '2026, Vladimir Roncevic'
+author = 'Vladimir Roncevic'
+release = 'https://github.com/electux/abcomm/releases'
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = []
+
+templates_path = ['_templates']
+exclude_patterns = []
+
+root_doc = 'index.rst'
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = 'classic'
+html_static_path = ['_static']
diff --git a/docs/source/index.rst.rst b/docs/source/index.rst.rst
new file mode 100644
index 0000000..79be073
--- /dev/null
+++ b/docs/source/index.rst.rst
@@ -0,0 +1,20 @@
+.. ABComm documentation master file, created by
+ sphinx-quickstart on Tue Sep 1 08:06:46 2026.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to ABComm's documentation!
+==================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 1ef76ff..4ee56f7 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,5 +1,5 @@
[versions]
-agp = "9.3.1"
+agp = "9.3.2"
coreKtx = "1.19.0"
junit = "4.13.2"
junitVersion = "1.3.0"