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. [![Build Status](https://github.com/electux/abcomm/actions/workflows/android.yml/badge.svg)](https://github.com/electux/abcomm/actions) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![GitHub issues open](https://img.shields.io/github/issues/electux/abcomm.svg)](https://github.com/electux/abcomm/issues) [![GitHub contributors](https://img.shields.io/github/contributors/electux/abcomm.svg)](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 [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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"