diff --git a/ORLib/build.gradle b/ORLib/build.gradle index 567bb8f..f0ddddc 100644 --- a/ORLib/build.gradle +++ b/ORLib/build.gradle @@ -64,6 +64,20 @@ dependencies { implementation platform('com.google.firebase:firebase-bom:33.12.0') implementation 'com.google.firebase:firebase-messaging-ktx' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' + implementation 'com.github.espressif:esp-idf-provisioning-android:lib-2.2.3' + implementation 'org.greenrobot:eventbus:3.3.1' + + implementation 'com.google.protobuf:protobuf-javalite:4.33.2' + implementation('com.google.protobuf:protobuf-kotlin:4.33.2') { + exclude group: 'com.google.protobuf', module: 'protobuf-java' + } + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + implementation 'com.github.iammohdzaki:Password-Generator:0.6' + + api project(':protobuf') } diff --git a/ORLib/src/main/java/io/openremote/orlib/service/ESPProvisionProvider.kt b/ORLib/src/main/java/io/openremote/orlib/service/ESPProvisionProvider.kt new file mode 100644 index 0000000..589aa21 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/ESPProvisionProvider.kt @@ -0,0 +1,327 @@ +package io.openremote.orlib.service + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.annotation.VisibleForTesting +import androidx.core.app.ActivityCompat +import io.openremote.orlib.R +import io.openremote.orlib.service.espprovision.CallbackChannel +import io.openremote.orlib.service.espprovision.DeviceConnection +import io.openremote.orlib.service.espprovision.DeviceProvision +import io.openremote.orlib.service.espprovision.DeviceRegistry +import io.openremote.orlib.service.espprovision.WifiProvisioner +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.net.URL + +object ESPProvisionProviderActions { + const val PROVIDER_INIT = "PROVIDER_INIT" + const val PROVIDER_ENABLE = "PROVIDER_ENABLE" + const val PROVIDER_DISABLE = "PROVIDER_DISABLE" + const val START_BLE_SCAN = "START_BLE_SCAN" + const val STOP_BLE_SCAN = "STOP_BLE_SCAN" + const val CONNECT_TO_DEVICE = "CONNECT_TO_DEVICE" + const val DISCONNECT_FROM_DEVICE = "DISCONNECT_FROM_DEVICE" + const val START_WIFI_SCAN = "START_WIFI_SCAN" + const val STOP_WIFI_SCAN = "STOP_WIFI_SCAN" + const val SEND_WIFI_CONFIGURATION = "SEND_WIFI_CONFIGURATION" + const val PROVISION_DEVICE = "PROVISION_DEVICE" + const val EXIT_PROVISIONING = "EXIT_PROVISIONING" +} + +class ESPProvisionProvider(val context: Context, val apiURL: URL = URL("http://localhost:8080/api/master")) { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + val deviceRegistry: DeviceRegistry + var deviceConnection: DeviceConnection? = null + + private var searchDeviceTimeout: Long = 120 + private var searchDeviceMaxIterations = 25 + + var wifiProvisioner: WifiProvisioner? = null + private var searchWifiTimeout: Long = 120 + private var searchWifiMaxIterations = 25 + + init { + deviceRegistry = DeviceRegistry(context, searchDeviceTimeout, searchDeviceMaxIterations) + } + + interface ESPProvisionCallback { + fun accept(responseData: Map) + } + + companion object { + private const val espProvisionDisabledKey = "espProvisionDisabled" + private const val version = "beta" + + const val TAG = "ESPProvisionProvider" + + const val ENABLE_BLUETOOTH_ESPPROVISION_REQUEST_CODE = 655 + const val BLUETOOTH_PERMISSION_ESPPROVISION_REQUEST_CODE = 656 + } + + private val bluetoothAdapter: BluetoothAdapter by lazy { + val bluetoothManager = + context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager + bluetoothManager.adapter + } + + fun initialize(): Map { + val sharedPreferences = + context.getSharedPreferences(context.getString(R.string.app_name), Context.MODE_PRIVATE) + + return hashMapOf( + "action" to ESPProvisionProviderActions.PROVIDER_INIT, + "provider" to "espprovision", + "version" to version, + "requiresPermission" to true, + "hasPermission" to hasPermission(), + "success" to true, + "enabled" to false, + "disabled" to sharedPreferences.contains(espProvisionDisabledKey) + ) + } + + @SuppressLint("MissingPermission") + fun enable(callback: ESPProvisionCallback, activity: Activity) { + deviceRegistry.callbackChannel = CallbackChannel(callback, "espprovision") + deviceRegistry.enable() + + if (!bluetoothAdapter.isEnabled) { + Log.d("ESP", "BLE not enabled") + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + activity.startActivityForResult(enableBtIntent, + ESPProvisionProvider.Companion.ENABLE_BLUETOOTH_ESPPROVISION_REQUEST_CODE + ) + } else if (!hasPermission()) { + Log.d("ESP", "Does not have permissions") + requestPermissions(activity) + } + + + if (bluetoothAdapter.isEnabled && hasPermission()) { + providerEnabled(deviceRegistry.callbackChannel) + } + } + + fun providerEnabled(callbackChannel: CallbackChannel?) { + val sharedPreferences = + context.getSharedPreferences( + context.getString(R.string.app_name), + Context.MODE_PRIVATE + ) + + sharedPreferences.edit() + .remove(espProvisionDisabledKey) + .apply() + + callbackChannel?.sendMessage(ESPProvisionProviderActions.PROVIDER_ENABLE, + hashMapOf( + "hasPermission" to hasPermission(), + "success" to true, + "enabled" to true, + "disabled" to sharedPreferences.contains(espProvisionDisabledKey) + ) + ) + } + + @SuppressLint("MissingPermission") + fun disable(): Map { + deviceRegistry.disable() + +// disconnectFromDevice() + + val sharedPreferences = + context.getSharedPreferences(context.getString(R.string.app_name), Context.MODE_PRIVATE) + sharedPreferences.edit() + .putBoolean(espProvisionDisabledKey, true) + .apply() + + return hashMapOf( + "action" to ESPProvisionProviderActions.PROVIDER_DISABLE, + "provider" to "espprovision" + ) + } + + @SuppressLint("MissingPermission") + fun onRequestPermissionsResult( + activity: Activity, + requestCode: Int, + prefix: String? + ) { + Log.d("espprovision", "onRequestPermissionsResult called with prefix >" + prefix + "<") + if (requestCode == BLUETOOTH_PERMISSION_ESPPROVISION_REQUEST_CODE) { + val hasPermission = hasPermission() + if (hasPermission) { + if (!bluetoothAdapter.isEnabled) { + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + activity.startActivityForResult(enableBtIntent, ENABLE_BLUETOOTH_ESPPROVISION_REQUEST_CODE) + } else { + providerEnabled(deviceRegistry.callbackChannel) + if (prefix != null) { + deviceRegistry.startDevicesScan(prefix) + } + } + } + } else if (requestCode == ENABLE_BLUETOOTH_ESPPROVISION_REQUEST_CODE) { + if (bluetoothAdapter.isEnabled) { + providerEnabled(deviceRegistry.callbackChannel) + if (prefix != null) { + deviceRegistry.startDevicesScan(prefix) + } + } + } + } + + // Device scan + + @SuppressLint("MissingPermission") + fun startDevicesScan(prefix: String?, activity: Activity, callback: ESPProvisionCallback) { + deviceRegistry.callbackChannel = CallbackChannel(callback, "espprovision") + if (!bluetoothAdapter.isEnabled) { + Log.d("ESP", "BLE not enabled") + val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) + activity.startActivityForResult(enableBtIntent, + ESPProvisionProvider.Companion.ENABLE_BLUETOOTH_ESPPROVISION_REQUEST_CODE + ) + } else if (!hasPermission()) { + Log.d("ESP", "Does not have permissions") + requestPermissions(activity) + } else { + deviceRegistry.startDevicesScan(prefix) + } + } + + @SuppressLint("MissingPermission") + fun stopDevicesScan() { + deviceRegistry.stopDevicesScan() + } + + // MARK: Device connect/disconnect + + @SuppressLint("MissingPermission") + fun connectTo(deviceId: String, pop: String? = null, username: String? = null) { + if (deviceConnection == null) { + deviceConnection = DeviceConnection(deviceRegistry, deviceRegistry.callbackChannel) + } + deviceConnection?.connectTo(deviceId, pop, username) + } + + fun disconnectFromDevice() { + wifiProvisioner?.stopWifiScan() + deviceConnection?.disconnectFromDevice() + } + + fun exitProvisioning() { + if (deviceConnection == null) { + return + } + if (!deviceConnection!!.isConnected) { + sendExitProvisioningError(ESPProviderErrorCode.NOT_CONNECTED, "No connection established to device") + return + } + deviceConnection!!.exitProvisioning() + deviceRegistry?.callbackChannel?.sendMessage( + ESPProvisionProviderActions.EXIT_PROVISIONING, + mapOf("exit" to true) + ) + } + + private fun sendExitProvisioningError(error: ESPProviderErrorCode, errorMessage: String?) { + val data = mutableMapOf() + + data["exit"] = false + data["errorCode"] = error.code + errorMessage?.let { + data["errorMessage"] = it + } + + deviceRegistry?.callbackChannel?.sendMessage(ESPProvisionProviderActions.EXIT_PROVISIONING, data) + } + + // Wifi scan + + fun startWifiScan() { + if (wifiProvisioner == null) { + wifiProvisioner = WifiProvisioner(deviceConnection, deviceRegistry.callbackChannel, searchWifiTimeout, searchWifiMaxIterations) + } + wifiProvisioner!!.startWifiScan() + } + + fun stopWifiScan() { + wifiProvisioner?.stopWifiScan() + } + + fun sendWifiConfiguration(ssid: String, password: String) { + if (wifiProvisioner == null) { + wifiProvisioner = WifiProvisioner(deviceConnection, deviceRegistry.callbackChannel, searchWifiTimeout, searchWifiMaxIterations) + } + wifiProvisioner!!.sendWifiConfiguration(ssid, password) + } + + // OR Configuration + + fun provisionDevice(userToken: String) { + val deviceProvision = DeviceProvision(deviceConnection, deviceRegistry.callbackChannel, apiURL) + CoroutineScope(Dispatchers.IO).launch { + deviceProvision.provision(userToken) + } + } + + private fun requestPermissions(activity: Activity) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + ActivityCompat.requestPermissions( + activity, + arrayOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT + ), + ESPProvisionProvider.Companion.BLUETOOTH_PERMISSION_ESPPROVISION_REQUEST_CODE + ) + } else { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + ESPProvisionProvider.Companion.BLUETOOTH_PERMISSION_ESPPROVISION_REQUEST_CODE + ) + } + } + + private fun hasPermission() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + context.checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && + context.checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + } else { + context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + +} + +data class ESPProviderException(val errorCode: ESPProviderErrorCode, val errorMessage: String) : Exception() + +enum class ESPProviderErrorCode(val code: Int) { + UNKNOWN_DEVICE(100), + + BLE_COMMUNICATION_ERROR(200), + + NOT_CONNECTED(300), + COMMUNICATION_ERROR(301), + + SECURITY_ERROR(400), + + WIFI_CONFIGURATION_ERROR(500), + WIFI_COMMUNICATION_ERROR(501), + WIFI_AUTHENTICATION_ERROR(502), + WIFI_NETWORK_NOT_FOUND(503), + + TIMEOUT_ERROR(600), + + GENERIC_ERROR(10000); +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/CallbackChannel.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/CallbackChannel.kt new file mode 100644 index 0000000..ad8eb76 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/CallbackChannel.kt @@ -0,0 +1,16 @@ +package io.openremote.orlib.service.espprovision + +import io.openremote.orlib.service.ESPProvisionProvider + +class CallbackChannel(private val espProvisionCallback: ESPProvisionProvider.ESPProvisionCallback, private val provider: String) { + + fun sendMessage(action: String, data: Map? = null) { + var payload: MutableMap = hashMapOf( + "action" to action, + "provider" to "espprovision") + + data?.let { payload.putAll(it) } + + espProvisionCallback.accept(payload) + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceConnection.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceConnection.kt new file mode 100644 index 0000000..d137a4f --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceConnection.kt @@ -0,0 +1,251 @@ +package io.openremote.orlib.service.espprovision + +import android.Manifest +import android.text.TextUtils +import android.util.Log +import androidx.annotation.RequiresPermission +import com.espressif.provisioning.DeviceConnectionEvent +import com.espressif.provisioning.ESPConstants +import com.espressif.provisioning.ESPDevice +import io.openremote.orlib.service.ESPProviderErrorCode +import io.openremote.orlib.service.ESPProviderException +import io.openremote.orlib.service.ESPProvisionProvider +import io.openremote.orlib.service.ESPProvisionProviderActions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.json.JSONException +import org.json.JSONObject +import java.util.UUID + +class DeviceConnection(val deviceRegistry: DeviceRegistry, var callbackChannel: CallbackChannel? = null) { + + companion object { + private const val SEC_TYPE_0: Int = 0 + private const val SEC_TYPE_1: Int = 1 + private const val SEC_TYPE_2: Int = 2 + } + + init { + EventBus.getDefault().register(this) + } +// TODO: must un-register -> need a clean-up routine + + private var bleStatus: BLEStatus = BLEStatus.DISCONNECTED + + var deviceId: UUID? = null + private set + + private var configChannel: ORConfigChannel? = null + + @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.BLUETOOTH]) + fun connectTo(deviceId: String, pop: String? = null, username: String? = null) { + + if (deviceRegistry.bleScanning) { + deviceRegistry.stopDevicesScan() + } + + val devId = UUID.fromString(deviceId) + val dev = deviceRegistry.getDeviceWithId(devId) + + if (dev != null) { +// device = dev.device // TODO: should I set this ? to what ? + this.deviceId = devId + + espDevice?.proofOfPossession = pop ?: "abcd1234" + espDevice?.userName = username ?: "UNUSED" + espDevice?.connectBLEDevice(dev.device, dev.serviceUuid) + + } + } + + fun disconnectFromDevice() { + espDevice?.disconnectDevice() + } + + fun exitProvisioning() { + if (!isConnected) { + throw ESPProviderException( + errorCode = ESPProviderErrorCode.NOT_CONNECTED, + errorMessage = "No connection established to device" + ) + } + + CoroutineScope(Dispatchers.IO).launch { + try { + configChannel?.exitProvisioning() + } catch (e: ORConfigChannelError) { + throw ESPProviderException(ESPProviderErrorCode.COMMUNICATION_ERROR, e.message ?: e.toString()) + } catch (e: Exception) { + throw ESPProviderException(ESPProviderErrorCode.GENERIC_ERROR, e.toString()) + } + } + } + + suspend fun getDeviceInfo(): DeviceInfo { + if (!isConnected) { + throw ESPProviderException( + errorCode = ESPProviderErrorCode.NOT_CONNECTED, + errorMessage = "No connection established to device" + ) + } + + return configChannel!!.getDeviceInfo() + } + + suspend fun sendOpenRemoteConfig( + mqttBrokerUrl: String, + mqttUser: String, + mqttPassword: String, + assetId: String + ) { + if (!isConnected) { + throw ESPProviderException( + errorCode = ESPProviderErrorCode.NOT_CONNECTED, + errorMessage = "No connection established to device" + ) + } + try { + configChannel?.sendOpenRemoteConfig( + mqttBrokerUrl = mqttBrokerUrl, + mqttUser = mqttUser, + mqttPassword = mqttPassword, + assetId = assetId + ) + } catch (e: Exception) { + throw ESPProviderException( + errorCode = ESPProviderErrorCode.COMMUNICATION_ERROR, + errorMessage = e.localizedMessage ?: "Unknown error" + ) + } + } + + suspend fun getBackendConnectionStatus(): BackendConnectionStatus { + if (!isConnected) { + throw ESPProviderException( + errorCode = ESPProviderErrorCode.NOT_CONNECTED, + errorMessage = "No connection established to device" + ) + } + return try { + configChannel?.getBackendConnectionStatus() + ?: throw ESPProviderException( + errorCode = ESPProviderErrorCode.COMMUNICATION_ERROR, + errorMessage = "Channel returned null status" + ) + } catch (e: Exception) { + throw ESPProviderException( + errorCode = ESPProviderErrorCode.COMMUNICATION_ERROR, + errorMessage = e.localizedMessage ?: "Unknown error" + ) + } as BackendConnectionStatus + } + + @Subscribe(threadMode = ThreadMode.MAIN) + fun onDeviceConnectionEvent(event: DeviceConnectionEvent) { + + when (event.getEventType()) { + ESPConstants.EVENT_DEVICE_CONNECTED -> { + Log.d(ESPProvisionProvider.TAG, "Device Connected Event Received") + bleStatus = BLEStatus.CONNECTED + + espDevice?.let { device -> + setSecurityTypeFromVersionInfo(device) + configChannel = ORConfigChannel(device) + } + + sendConnectToDeviceStatus(ESPProviderConnectToDeviceStatus.CONNECTED.value) + } + + ESPConstants.EVENT_DEVICE_DISCONNECTED -> { + bleStatus = BLEStatus.DISCONNECTED + configChannel = null + sendConnectToDeviceStatus(ESPProviderConnectToDeviceStatus.DISCONNECTED.value) + } + + ESPConstants.EVENT_DEVICE_CONNECTION_FAILED -> { + bleStatus = BLEStatus.DISCONNECTED + + // TODO: can I get some error details ? + sendConnectToDeviceStatus(ESPProviderConnectToDeviceStatus.CONNECTION_ERROR.value) + } + } + } + + private fun sendConnectToDeviceStatus(status: String, error: ESPProviderErrorCode? = null, errorMessage: String? = null) { + val data = mutableMapOf("id" to (deviceId?.toString() ?: ""), "status" to status) + + error?.let { + data["errorCode"] = error.code + } + errorMessage?.let { + data["errorMessage"] = it + } + + callbackChannel?.sendMessage(ESPProvisionProviderActions.CONNECT_TO_DEVICE, data) + } + + fun setSecurityTypeFromVersionInfo(device: ESPDevice) { + val protoVerStr: String = device.getVersionInfo() + + try { + val jsonObject = JSONObject(protoVerStr) + val provInfo = jsonObject.getJSONObject("prov") + + if (provInfo != null) { + if (provInfo.has("sec_ver")) { + val serVer = provInfo.optInt("sec_ver") + Log.d(ESPProvisionProvider.TAG, "Security Version : " + serVer) + + when (serVer) { + SEC_TYPE_0 -> { + device.setSecurityType(ESPConstants.SecurityType.SECURITY_0) + } + + SEC_TYPE_1 -> { + device.setSecurityType(ESPConstants.SecurityType.SECURITY_1) + } + + SEC_TYPE_2 -> { + device.setSecurityType(ESPConstants.SecurityType.SECURITY_2) + } + + else -> { + device.setSecurityType(ESPConstants.SecurityType.SECURITY_2) + } + } + } else { + device.setSecurityType(ESPConstants.SecurityType.SECURITY_1) + } + } else { + Log.e(ESPProvisionProvider.TAG, "proto-ver info is not available.") + } + } catch (e: JSONException) { + e.printStackTrace() + Log.d(ESPProvisionProvider.TAG, "Capabilities JSON not available.") + } + } + + val espDevice: ESPDevice? + get() = deviceRegistry.provisionManager?.espDevice + + val isConnected: Boolean + get() = bleStatus == BLEStatus.CONNECTED && espDevice != null && configChannel != null + + enum class ESPProviderConnectToDeviceStatus(val value: String) { + CONNECTED("connected"), + DISCONNECTED("disconnected"), + CONNECTION_ERROR("connectionError"); + + override fun toString(): String = value + } + enum class BLEStatus { + CONNECTING, + CONNECTED, + DISCONNECTED + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceProvision.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceProvision.kt new file mode 100644 index 0000000..53cea38 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceProvision.kt @@ -0,0 +1,124 @@ +package io.openremote.orlib.service.espprovision + +import android.util.Log +import data.entity.Response +import io.openremote.orlib.service.ESPProviderErrorCode +import io.openremote.orlib.service.ESPProviderException +import io.openremote.orlib.service.ESPProvisionProvider +import io.openremote.orlib.service.ESPProvisionProviderActions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import utils.PasswordType +import java.net.URL +import java.util.Locale +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class DeviceProvision(var deviceConnection: DeviceConnection?, var callbackChannel: CallbackChannel?, var apiURL: URL) { + var deviceProvisionAPI: DeviceProvisionAPI + + var backendConnectionTimeoutMillis = 60_000 + + init { + deviceProvisionAPI = DeviceProvisionAPIREST(apiURL) + } + + suspend fun provision(userToken: String) { + if (deviceConnection == null || !deviceConnection!!.isConnected) { + sendProvisionDeviceStatus(false, ESPProviderErrorCode.NOT_CONNECTED, "No connection established to device") + } + + try { + val deviceInfo = deviceConnection!!.getDeviceInfo() + Log.d(ESPProvisionProvider.TAG, "Device id is ${deviceInfo.deviceId}") + + val password = generatePassword() + + val assetId = deviceProvisionAPI.provision(deviceInfo.modelName, deviceInfo.deviceId, password, userToken) + val userName = deviceInfo.deviceId.lowercase(Locale("en")) + + deviceConnection?.sendOpenRemoteConfig( + mqttBrokerUrl = mqttURL, + mqttUser = userName, + mqttPassword = password, + assetId = assetId + ) + + var status = BackendConnectionStatus.CONNECTING + val startTime = System.currentTimeMillis() + + while (status != BackendConnectionStatus.CONNECTED) { + if (System.currentTimeMillis() - startTime > backendConnectionTimeoutMillis) { + sendProvisionDeviceStatus( + connected = false, + error = ESPProviderErrorCode.TIMEOUT_ERROR, + errorMessage = "Timeout waiting for backend to get connected" + ) + return + } + + status = deviceConnection?.getBackendConnectionStatus() + ?: BackendConnectionStatus.DISCONNECTED + } + sendProvisionDeviceStatus(true) + } catch (e: ESPProviderException) { + sendProvisionDeviceStatus(false, e.errorCode, e.errorMessage) + } catch (e: DeviceProvisionAPIError) { + val (errorCode, errorMessage) = mapDeviceProvisionAPIError(e) + sendProvisionDeviceStatus(false, errorCode, errorMessage) + } + } + + private suspend fun sendProvisionDeviceStatus(connected: Boolean, error: ESPProviderErrorCode? = null, errorMessage: String? = null) { + val data = mutableMapOf("connected" to connected) + + error?.let { + data["errorCode"] = it.code + } + errorMessage?.let { + data["errorMessage"] = it + } + + // We bring it back to main context as this eventually is a message to the Web view + withContext(Dispatchers.Main) { + callbackChannel?.sendMessage(ESPProvisionProviderActions.PROVISION_DEVICE, data) + } + } + + private fun mapDeviceProvisionAPIError(error: DeviceProvisionAPIError): Pair { + return when (error) { + is DeviceProvisionAPIError.BusinessError, + is DeviceProvisionAPIError.UnknownError -> ESPProviderErrorCode.GENERIC_ERROR to null + + is DeviceProvisionAPIError.GenericError -> ESPProviderErrorCode.GENERIC_ERROR to error.error.localizedMessage + + is DeviceProvisionAPIError.Unauthorized -> ESPProviderErrorCode.SECURITY_ERROR to null + + is DeviceProvisionAPIError.CommunicationError -> ESPProviderErrorCode.COMMUNICATION_ERROR to error.message + } + } + + // Using https://github.com/iammohdzaki/Password-Generator + private suspend fun generatePassword(): String = suspendCoroutine { continuation -> + PasswordGenerator.Builder(PasswordType.RANDOM) + .showLogs(false) + .includeUpperCaseChars(true) + .includeNumbers(true) + .includeLowerCaseChars(true) + .includeSpecialSymbols(false) + .passwordLength(16) + .callback(object : PasswordGenerator.Callback { + override fun onPasswordGenerated(response: Response) { + continuation.resume(response.password) + } + }) + .build() + .generate() + } + + private val mqttURL: String + get() { + // TODO: is this OK or do we want to get the mqtt url from the server? + return "mqtts://${apiURL.host ?: "localhost"}:8883" + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceProvisionAPI.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceProvisionAPI.kt new file mode 100644 index 0000000..6815339 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceProvisionAPI.kt @@ -0,0 +1,85 @@ +package io.openremote.orlib.service.espprovision + +import android.net.Uri +import android.util.Log +import io.openremote.orlib.service.ESPProvisionProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL + +interface DeviceProvisionAPI { + suspend fun provision(modelName: String, deviceId: String, password: String, token: String): String +} + +class DeviceProvisionAPIREST(private val apiURL: URL) : DeviceProvisionAPI { + + companion object { + private const val TAG = "DeviceProvisionAPIREST" + } + + override suspend fun provision(modelName: String, deviceId: String, password: String, token: String): String = withContext(Dispatchers.IO) { + Log.d(ESPProvisionProvider.TAG, "apiURL $apiURL") + val uri = Uri.parse(apiURL.toString()).buildUpon() + .appendPath("rest") + .appendPath("device") + .build() + + val url = URL(uri.toString()) + Log.d(ESPProvisionProvider.TAG, "Calling URL $url") + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Authorization", "Bearer $token") + connection.doOutput = true + + val requestBody = JSONObject().apply { + put("modelName", modelName) + put("deviceId", deviceId) + put("password", password) + } + + try { + OutputStreamWriter(connection.outputStream).use { writer -> + writer.write(requestBody.toString()) + writer.flush() + } + + val responseCode = connection.responseCode + val responseText = BufferedReader(InputStreamReader( + if (responseCode in 200..299) connection.inputStream else connection.errorStream + )).use { it.readText() } + + if (responseCode !in 200..299) { + Log.d(ESPProvisionProvider.TAG, "Response code $responseCode") + Log.d(ESPProvisionProvider.TAG, "Response text $responseText") + when (responseCode) { + 401 -> throw DeviceProvisionAPIError.Unauthorized + 409 -> throw DeviceProvisionAPIError.BusinessError + else -> throw DeviceProvisionAPIError.UnknownError + } + } + + val json = JSONObject(responseText) + return@withContext json.getString("assetId") + } catch (e: DeviceProvisionAPIError) { + throw e + } catch (e: Exception) { + throw DeviceProvisionAPIError.GenericError(e) + } finally { + connection.disconnect() + } + } +} + +sealed class DeviceProvisionAPIError(message: String? = null, cause: Throwable? = null) : Exception(message, cause) { + object Unauthorized : DeviceProvisionAPIError("Unauthorized") + data class CommunicationError(val reason: String) : DeviceProvisionAPIError(reason) + object BusinessError : DeviceProvisionAPIError("Business logic error") + data class GenericError(val error: Throwable) : DeviceProvisionAPIError(error.message, error) + object UnknownError : DeviceProvisionAPIError("Unknown error") +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceRegistry.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceRegistry.kt new file mode 100644 index 0000000..d66ac30 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/DeviceRegistry.kt @@ -0,0 +1,188 @@ +package io.openremote.orlib.service.espprovision + +import android.Manifest +import android.bluetooth.BluetoothDevice +import android.bluetooth.le.ScanResult +import android.content.Context +import android.util.Log +import androidx.annotation.RequiresPermission +import com.espressif.provisioning.ESPConstants +import com.espressif.provisioning.ESPDevice +import com.espressif.provisioning.ESPProvisionManager +import com.espressif.provisioning.listeners.BleScanListener +import io.openremote.orlib.service.ESPProviderErrorCode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.UUID + +class EspressifProvisionManager(private val provisionManager: ESPProvisionManager) { + init { + provisionManager.createESPDevice(ESPConstants.TransportType.TRANSPORT_BLE, ESPConstants.SecurityType.SECURITY_1) + } + + @RequiresPermission(allOf = [android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.BLUETOOTH_ADMIN, android.Manifest.permission.BLUETOOTH]) + suspend fun searchESPDevices(devicePrefix: String): List { + return withContext(Dispatchers.Main) { + // If on IO: Error during device scan: Can't create handler inside thread Thread[DefaultDispatcher-worker-2,5,main] that has not called Looper.prepare() + // But I don't see any warnings that the main thread is getting blocked + suspendCancellableCoroutine { continuation -> + var devices: MutableList = mutableListOf() + + provisionManager.searchBleEspDevices(devicePrefix, object: BleScanListener { + override fun scanStartFailed() { + // Don't care about that information + } + + override fun onPeripheralFound(device: BluetoothDevice, scanResult: ScanResult) { + if (!scanResult.scanRecord?.deviceName.isNullOrEmpty()) { + var serviceUuid = "" + scanResult.scanRecord?.serviceUuids?.firstOrNull()?.toString()?.let { uuid -> + serviceUuid = uuid + } + scanResult.scanRecord!!.deviceName?.let { deviceName -> + if (devices.find { it.name == deviceName } == null) { + devices.add(DeviceRegistry.DiscoveredDevice(deviceName, serviceUuid, device)) + Log.d("espprovision", "Added device, list is now $devices") + } + } + } + } + + override fun scanCompleted() { + Log.d("espprovision", "Scan completed") + // TODO: I don't want that second param + continuation.resume(devices, onCancellation = null) + } + + override fun onFailure(e: Exception) { + continuation.cancel(e) + } + + }) + } + } + } + + @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.BLUETOOTH]) + fun stopESPDevicesSearch() + { + provisionManager.stopBleScan() + } + + val espDevice: ESPDevice + get() = provisionManager.espDevice +} + +class DeviceRegistry(private val context: Context, searchDeviceTimeout: Long, searchDeviceMaxIterations: Int, var callbackChannel: CallbackChannel? = null) { + private var loopDetector = LoopDetector(searchDeviceTimeout, searchDeviceMaxIterations) + var provisionManager: EspressifProvisionManager? = null + + var bleScanning = false + + private var devices: MutableList = mutableListOf() + private var devicesIndex: MutableMap = mutableMapOf() + + fun enable() { + provisionManager = EspressifProvisionManager(ESPProvisionManager.getInstance(context)) + } + + @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.BLUETOOTH]) + fun disable() { + if (bleScanning) stopDevicesScan() + provisionManager = null + } + + @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.BLUETOOTH]) + fun startDevicesScan(prefix: String? = "") { + Log.d("espprovision", "startDevicesScan called with prefix >" + prefix + "<") + bleScanning = true + resetDevicesList() + loopDetector.reset() + devicesScan(prefix ?: "") + } + + @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.BLUETOOTH]) + fun stopDevicesScan(sendMessage: Boolean = true) { + bleScanning = false + provisionManager?.stopESPDevicesSearch() + if (sendMessage) { + callbackChannel?.sendMessage("STOP_BLE_SCAN", null) + } + } + + @RequiresPermission(allOf = [android.Manifest.permission.ACCESS_FINE_LOCATION, android.Manifest.permission.BLUETOOTH_ADMIN, android.Manifest.permission.BLUETOOTH]) + private fun devicesScan(prefix: String) { + provisionManager?.let { manager -> + if (loopDetector.detectLoop()) { + stopDevicesScan(sendMessage = false) + sendDeviceScanError(ESPProviderErrorCode.TIMEOUT_ERROR) + return + } + + CoroutineScope(Dispatchers.IO).launch { + try { + val deviceList = manager.searchESPDevices(prefix) + Log.d("espprovision", "I got a list of devices $deviceList") + if (bleScanning) { + var devicesChanged = false + for (device in deviceList) { + if (getDeviceNamed(device.name) == null) { + devicesChanged = true + registerDevice(device) + } + } + Log.d("espprovision", "devicesChanges $devicesChanged") + Log.d("espprovision", "devices $devices") + if (devices.isNotEmpty() && devicesChanged) { + Log.d("espprovision", "callbackChannel $callbackChannel") + callbackChannel?.sendMessage( + "START_BLE_SCAN", + mapOf("devices" to devices.map { it.info }) + ) + } + devicesScan(prefix) + } + } catch (e: Exception) { + Log.w("DeviceRegistry", "Error during device scan: ${e.localizedMessage}") + sendDeviceScanError(ESPProviderErrorCode.GENERIC_ERROR) + } + } + } + } + + private fun sendDeviceScanError(error: ESPProviderErrorCode, errorMessage: String? = null) { + val data = mutableMapOf("errorCode" to error.code) + + errorMessage?.let { + data["errorMessage"] = it + } + + callbackChannel?.sendMessage(action = "STOP_BLE_SCAN", data = data) + } + + private fun resetDevicesList() { + devices = mutableListOf() + devicesIndex = mutableMapOf() + } + + private fun getDeviceNamed(name: String): DiscoveredDevice? { + return devices.firstOrNull { it.name == name } + } + + fun getDeviceWithId(id: UUID): DiscoveredDevice? { + return devicesIndex[id] + } + + private fun registerDevice(device: DiscoveredDevice) { + devices.add(device) + devicesIndex[device.id] = device + } + + data class DiscoveredDevice(val name: String, val serviceUuid: String, val device: BluetoothDevice, val id: UUID = UUID.randomUUID()) { + val info: Map + get() = mapOf("id" to id.toString(), "name" to name) + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/LoopDetector.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/LoopDetector.kt new file mode 100644 index 0000000..18d17e3 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/LoopDetector.kt @@ -0,0 +1,29 @@ +package io.openremote.orlib.service.espprovision + +import java.util.Date +import java.util.concurrent.TimeUnit + +class LoopDetector( + private val timeout: Long = TimeUnit.MINUTES.toSeconds(2), + private val maxIterations: Int = 25) { + + private var startTime: Date? = null + private var iterationCount = 0 + + fun reset() { + startTime = Date() + iterationCount = 0 + } + + fun detectLoop(): Boolean { + iterationCount++ + if (iterationCount > maxIterations) { + return true + } + val start = startTime ?: return true + if ((Date().time - start.time) / 1000 > timeout) { + return true + } + return false + } +} diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/ORConfigChannel.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/ORConfigChannel.kt new file mode 100644 index 0000000..3938fd2 --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/ORConfigChannel.kt @@ -0,0 +1,123 @@ +package io.openremote.orlib.service.espprovision + +import com.espressif.provisioning.ESPDevice +import com.espressif.provisioning.listeners.ResponseListener +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resumeWithException +import io.openremote.orlib.service.espprovision.ORConfigChannelProtocol.Request +import io.openremote.orlib.service.espprovision.ORConfigChannelProtocol.Response + +data class DeviceInfo( + val deviceId: String, + val modelName: String +) + +enum class BackendConnectionStatus { + DISCONNECTED, CONNECTING, CONNECTED, FAILED +} +sealed class ORConfigChannelError(message: String) : Exception(message) { + class InvalidRequest(message: String) : ORConfigChannelError(message) + object MessageOutOfOrder : ORConfigChannelError("Message out of order") + class InvalidResponse(message: String) : ORConfigChannelError(message) + object OperationFailure : ORConfigChannelError("Operation failed") + object GenericError : ORConfigChannelError("Generic error") +} + +class ORConfigChannel(private val device: ESPDevice) { + + private var messageId = 0 + + suspend fun getDeviceInfo(): DeviceInfo { + val request = Request.newBuilder() + .setDeviceInfo(Request.DeviceInfo.getDefaultInstance()) + .setId(messageId++.toString()) + .build() + + val response = sendRequest(request) + if (response.hasDeviceInfo()) { + val info = response.deviceInfo + return DeviceInfo(deviceId = info.deviceId, modelName = info.modelName) + } else { + throw ORConfigChannelError.InvalidResponse("Invalid response type") + } + } + + suspend fun sendOpenRemoteConfig( + mqttBrokerUrl: String, + mqttUser: String, + mqttPassword: String, + realm: String = "master", + assetId: String + ) { + val config = Request.OpenRemoteConfig.newBuilder() + .setMqttBrokerUrl(mqttBrokerUrl) + .setUser(mqttUser) + .setMqttPassword(mqttPassword) + .setAssetId(assetId) + .setRealm(realm) + .build() + + val request = Request.newBuilder() + .setOpenRemoteConfig(config) + .setId(messageId++.toString()) + .build() + + val response = sendRequest(request) + if (!response.hasOpenRemoteConfig() || response.openRemoteConfig.status != Response.OpenRemoteConfig.Status.SUCCESS) { + throw ORConfigChannelError.OperationFailure + } + } + + suspend fun getBackendConnectionStatus(): BackendConnectionStatus { + val request = Request.newBuilder() + .setBackendConnectionStatus(Request.BackendConnectionStatus.getDefaultInstance()) + .setId(messageId++.toString()) + .build() + + val response = sendRequest(request) + if (response.hasBackendConnectionStatus()) { + return when (response.backendConnectionStatus.status) { + Response.BackendConnectionStatus.Status.DISCONNECTED -> BackendConnectionStatus.DISCONNECTED + Response.BackendConnectionStatus.Status.CONNECTING -> BackendConnectionStatus.CONNECTING + Response.BackendConnectionStatus.Status.CONNECTED -> BackendConnectionStatus.CONNECTED + Response.BackendConnectionStatus.Status.FAILED -> BackendConnectionStatus.FAILED + else -> throw ORConfigChannelError.InvalidResponse("Unrecognized status") + } + } else { + throw ORConfigChannelError.InvalidResponse("Invalid response type") + } + } + + suspend fun exitProvisioning() { + val request = Request.newBuilder() + .setExitProvisioning(Request.ExitProvisioning.getDefaultInstance()) + .setId(messageId++.toString()) + .build() + sendRequest(request) + } + + private suspend fun sendRequest(request: Request): Response = suspendCancellableCoroutine { cont -> + val data = request.toByteArray() + device.sendDataToCustomEndPoint("or-cfg", data, object: ResponseListener { + override fun onSuccess(returnData: ByteArray?) { + val response = Response.parseFrom(returnData) + if (response.id != request.id) { + cont.resumeWithException(ORConfigChannelError.MessageOutOfOrder) + } else if (!response.hasResult() || response.result.result != Response.ResponseResult.Result.SUCCESS) { + cont.resumeWithException( + ORConfigChannelError.InvalidResponse("Response result was not success") + ) + } else { + // TODO: why the onCancellation ? + cont.resume(response, onCancellation = null) + } + } + + override fun onFailure(e: java.lang.Exception?) { + // TODO: pass some details ? + cont.resumeWithException(ORConfigChannelError.GenericError) + } + + }) + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/service/espprovision/WifiProvisioner.kt b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/WifiProvisioner.kt new file mode 100644 index 0000000..24d077f --- /dev/null +++ b/ORLib/src/main/java/io/openremote/orlib/service/espprovision/WifiProvisioner.kt @@ -0,0 +1,150 @@ +package io.openremote.orlib.service.espprovision + +import com.espressif.provisioning.ESPConstants +import com.espressif.provisioning.ESPConstants.ProvisionFailureReason +import com.espressif.provisioning.WiFiAccessPoint +import com.espressif.provisioning.listeners.ProvisionListener +import com.espressif.provisioning.listeners.WiFiScanListener +import io.openremote.orlib.service.ESPProviderErrorCode +import io.openremote.orlib.service.ESPProvisionProviderActions + +class WifiProvisioner(private var deviceConnection: DeviceConnection? = null, var callbackChannel: CallbackChannel? = null, searchWifiTimeout: Long, searchWifiMaxIterations: Int) { + private var loopDetector = LoopDetector(searchWifiTimeout, searchWifiMaxIterations) + + var wifiScanning = false + private set + + private val wifiNetworks = mutableListOf() + + fun startWifiScan() { + if (deviceConnection?.isConnected != true) { + sendWifiScanError(ESPProviderErrorCode.NOT_CONNECTED) + return + } + wifiScanning = true + loopDetector.reset() + scanWifi() + } + + fun stopWifiScan(sendMessage: Boolean = true) { + wifiScanning = false + if (sendMessage) { + callbackChannel?.sendMessage(ESPProvisionProviderActions.STOP_WIFI_SCAN, null) + } + } + + private fun scanWifi() { + if (loopDetector.detectLoop()) { + stopWifiScan(false) + sendWifiScanError(ESPProviderErrorCode.TIMEOUT_ERROR) + return + } + + deviceConnection?.espDevice?.scanNetworks(object : WiFiScanListener { + override fun onWifiListReceived(wifiList: ArrayList?) { + wifiList?.let { + if (wifiScanning) { + var wifiNetworksChanged = false + it.forEach { wifiAP -> + wifiAP?.let { discoveredAP -> + val ap = wifiNetworks.firstOrNull() { ap -> discoveredAP.wifiName == ap.wifiName } + if (ap != null) { + if (ap.rssi != discoveredAP.rssi) { + wifiNetworksChanged = true + wifiNetworks.remove(ap) + wifiNetworks.add(discoveredAP) + } + } else { + wifiNetworksChanged = true + wifiNetworks.add(discoveredAP) + } + } + } + if (wifiNetworks.isNotEmpty() && wifiNetworksChanged) { + callbackChannel?.sendMessage(ESPProvisionProviderActions.START_WIFI_SCAN, hashMapOf( + "networks" to wifiNetworks.map { network -> + hashMapOf( + "ssid" to network.wifiName, + "signalStrength" to network.rssi) + } + )) + } + scanWifi() + } + } + } + + override fun onWiFiScanFailed(e: Exception) { + stopWifiScan(false) + sendWifiScanError(ESPProviderErrorCode.COMMUNICATION_ERROR, e.toString()) + } + }) + } + + private fun sendWifiScanError(error: ESPProviderErrorCode? = null, errorMessage: String? = null) { + val data = mutableMapOf( + "id" to (deviceConnection?.deviceId?.toString() ?: "N/A") + ) + error?.let { data["errorCode"] = it.code } + errorMessage?.let { data["errorMessage"] = it } + callbackChannel?.sendMessage(ESPProvisionProviderActions.STOP_WIFI_SCAN, data) + } + + fun sendWifiConfiguration(ssid: String, password: String) { + if (deviceConnection?.isConnected != true) { + sendWifiConfigurationStatus(false, ESPProviderErrorCode.NOT_CONNECTED) + return + } + stopWifiScan() + + deviceConnection?.espDevice?.provision(ssid, password, object: ProvisionListener { + override fun createSessionFailed(e: java.lang.Exception?) { + sendWifiConfigurationStatus(false, ESPProviderErrorCode.GENERIC_ERROR, e.toString()) + } + + override fun wifiConfigSent() { + /* ignore */ + } + + override fun wifiConfigFailed(e: java.lang.Exception?) { + sendWifiConfigurationStatus(false, ESPProviderErrorCode.WIFI_CONFIGURATION_ERROR, e.toString()) + } + + override fun wifiConfigApplied() { + /* ignore */ + } + + override fun wifiConfigApplyFailed(e: java.lang.Exception?) { + sendWifiConfigurationStatus(false, ESPProviderErrorCode.GENERIC_ERROR, e.toString()) + } + + override fun provisioningFailedFromDevice(failureReason: ESPConstants.ProvisionFailureReason?) { + sendWifiConfigurationStatus(false, mapProvisionFailureReason(failureReason ?: ProvisionFailureReason.UNKNOWN)) + } + + override fun deviceProvisioningSuccess() { + sendWifiConfigurationStatus(true) + } + + override fun onProvisioningFailed(e: java.lang.Exception?) { + sendWifiConfigurationStatus(false, ESPProviderErrorCode.GENERIC_ERROR, e.toString()) + } + }) + } + + private fun sendWifiConfigurationStatus(connected: Boolean, error: ESPProviderErrorCode? = null, errorMessage: String? = null) { + val data = mutableMapOf("connected" to connected) + error?.let { data["errorCode"] = it.code } + errorMessage?.let { data["errorMessage"] = it } + callbackChannel?.sendMessage(ESPProvisionProviderActions.SEND_WIFI_CONFIGURATION, data) + } + + private fun mapProvisionFailureReason(reason: ProvisionFailureReason): ESPProviderErrorCode { + return when (reason) { + ProvisionFailureReason.AUTH_FAILED -> ESPProviderErrorCode.WIFI_AUTHENTICATION_ERROR + ProvisionFailureReason.NETWORK_NOT_FOUND -> ESPProviderErrorCode.WIFI_NETWORK_NOT_FOUND + ProvisionFailureReason.DEVICE_DISCONNECTED -> ESPProviderErrorCode.NOT_CONNECTED + ProvisionFailureReason.UNKNOWN -> ESPProviderErrorCode.GENERIC_ERROR + } + } +} \ No newline at end of file diff --git a/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt b/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt index bf2fa1b..5c51680 100644 --- a/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt +++ b/ORLib/src/main/java/io/openremote/orlib/ui/OrMainActivity.kt @@ -35,12 +35,16 @@ import io.openremote.orlib.R import io.openremote.orlib.databinding.ActivityOrMainBinding import io.openremote.orlib.service.BleProvider import io.openremote.orlib.service.ConnectivityChangeReceiver +import io.openremote.orlib.service.ESPProviderErrorCode +import io.openremote.orlib.service.ESPProvisionProvider +import io.openremote.orlib.service.ESPProvisionProviderActions import io.openremote.orlib.service.GeofenceProvider import io.openremote.orlib.service.QrScannerProvider import io.openremote.orlib.service.SecureStorageProvider import io.openremote.orlib.shared.SharedData.offlineActivity import org.json.JSONException import org.json.JSONObject +import java.net.URL import java.util.logging.Level import java.util.logging.Logger @@ -73,6 +77,12 @@ open class OrMainActivity : Activity() { private var geofenceProvider: GeofenceProvider? = null private var qrScannerProvider: QrScannerProvider? = null private var bleProvider: BleProvider? = null + + private var espProvisionProvider: ESPProvisionProvider? = null + // We store the prefix here so it can be used to start a scan after permissions request + // A scan is only started if the prefix is NOT null + private var prefix: String? = null + private var secureStorageProvider: SecureStorageProvider? = null private var consoleId: String? = null private var connectFailCount: Int = 0 @@ -501,6 +511,10 @@ open class OrMainActivity : Activity() { notifyClient(responseData) } }) + + + } else if (requestCode == ESPProvisionProvider.BLUETOOTH_PERMISSION_ESPPROVISION_REQUEST_CODE || requestCode == ESPProvisionProvider.ENABLE_BLUETOOTH_ESPPROVISION_REQUEST_CODE) { + espProvisionProvider?.onRequestPermissionsResult(this, requestCode, prefix) } else if (requestCode == pushResponseCode) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { notifyClient( @@ -588,6 +602,10 @@ open class OrMainActivity : Activity() { provider.equals("ble", ignoreCase = true) -> { handleBleProviderMessage(it) } + + provider.equals("espprovision", ignoreCase = true) -> { + handleESPProvisionProviderMessage(it) + } } } } @@ -942,6 +960,107 @@ open class OrMainActivity : Activity() { } } } + + + @Throws(JSONException::class) + private fun handleESPProvisionProviderMessage(data: JSONObject) { + val action = data.getString("action") + if (espProvisionProvider == null) { + if (baseUrl != null) { + espProvisionProvider = ESPProvisionProvider(activity, URL(URL(baseUrl), "/api/master")) + } else { + espProvisionProvider = ESPProvisionProvider(activity) + } + } + when { + action.equals(ESPProvisionProviderActions.PROVIDER_INIT, ignoreCase = true) -> { + val initData: Map = espProvisionProvider!!.initialize() + notifyClient(initData) + } + action.equals(ESPProvisionProviderActions.PROVIDER_ENABLE, ignoreCase = true) -> { + espProvisionProvider?.enable(object : ESPProvisionProvider.ESPProvisionCallback { + override fun accept(responseData: Map) { + notifyClient(responseData) + } + }, activity) + } + + action.equals(ESPProvisionProviderActions.PROVIDER_DISABLE, ignoreCase = true) -> { + val response = espProvisionProvider?.disable() + notifyClient(response) + } + + action.equals(ESPProvisionProviderActions.START_BLE_SCAN, ignoreCase = true) -> { + // Must define a value for prefix, to ensure scan is started in case we need to request BLE permissions (though it should not happen here) + prefix = data.optString("prefix", "") + espProvisionProvider?.startDevicesScan(prefix, + this@OrMainActivity, + object : ESPProvisionProvider.ESPProvisionCallback { + override fun accept(responseData: Map) { + notifyClient(responseData) + } + }) + } + action.equals(ESPProvisionProviderActions.STOP_BLE_SCAN, ignoreCase = true) -> { + espProvisionProvider?.stopDevicesScan() + } + + action.equals(ESPProvisionProviderActions.CONNECT_TO_DEVICE) -> { + val deviceId = data.optString("id") + val pop = data.optString("pop") + if (!deviceId.isNullOrEmpty()) { + espProvisionProvider?.connectTo(deviceId, pop) + } else { + val payload: Map = hashMapOf( + "action" to action, + "provider" to "espprovision", + "errorCode" to ESPProviderErrorCode.UNKNOWN_DEVICE.code, + "errorMessage" to "Missing id parameter" + ) + } + } + + action.equals(ESPProvisionProviderActions.DISCONNECT_FROM_DEVICE) -> { + espProvisionProvider?.disconnectFromDevice() + } + action.equals(ESPProvisionProviderActions.START_WIFI_SCAN) -> { + espProvisionProvider?.startWifiScan() + } + action.equals(ESPProvisionProviderActions.STOP_WIFI_SCAN) -> { + espProvisionProvider?.stopWifiScan() + } + action.equals(ESPProvisionProviderActions.SEND_WIFI_CONFIGURATION) -> { + val ssid = data.optString("ssid") + val password = data.optString("password") + if (!ssid.isNullOrEmpty() && !password.isNullOrEmpty()) { + espProvisionProvider?.sendWifiConfiguration(ssid, password) + } else { + val payload: Map = hashMapOf( + "action" to action, + "provider" to "espprovision", + "errorCode" to ESPProviderErrorCode.WIFI_AUTHENTICATION_ERROR.code, + "errorMessage" to "Missing ssid or password parameter" + ) + } + } + action.equals(ESPProvisionProviderActions.EXIT_PROVISIONING) -> { + espProvisionProvider?.exitProvisioning() + } + action.equals(ESPProvisionProviderActions.PROVISION_DEVICE) -> { + val userToken = data.optString("userToken") + if (!userToken.isNullOrEmpty()) { + espProvisionProvider?.provisionDevice(userToken) + } else { + val payload: Map = hashMapOf( + "action" to action, + "provider" to "espprovision", + "errorCode" to ESPProviderErrorCode.SECURITY_ERROR.code, + "errorMessage" to "Missing userToken parameter" + ) + } + } + } + } } private fun notifyClient(data: Map?) { diff --git a/ORLib/src/test/java/io/openremote/orlib/service/ESPProvisionProviderTest.kt b/ORLib/src/test/java/io/openremote/orlib/service/ESPProvisionProviderTest.kt new file mode 100644 index 0000000..41c1482 --- /dev/null +++ b/ORLib/src/test/java/io/openremote/orlib/service/ESPProvisionProviderTest.kt @@ -0,0 +1,4 @@ +package io.openremote.orlib.service + +class ESPProvisionProviderTest { +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 72ec4ec..ad2e2b5 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,8 @@ buildscript { classpath 'com.android.tools.build:gradle:8.9.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'com.google.gms:google-services:4.4.2' + + classpath "com.google.protobuf:protobuf-gradle-plugin:0.9.6" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } @@ -55,6 +57,7 @@ allprojects { repositories { google() mavenCentral() + maven { url 'https://jitpack.io' } } } diff --git a/protobuf/build.gradle b/protobuf/build.gradle new file mode 100644 index 0000000..9bf010a --- /dev/null +++ b/protobuf/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java-library' +} + +java { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 +} + +apply plugin: 'com.google.protobuf' + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:4.33.2" + } + generateProtoTasks { + all().each { task -> + task.builtins { + java { + option 'lite' + } + } + } + } +} + +dependencies { + implementation 'com.google.protobuf:protobuf-javalite:4.33.2' +} + +sourceSets { + main { + java { + srcDir 'build/generated/source/proto/main/java' + } + proto { + srcDir 'src/main/proto' + } + } +} + +tasks.withType(ProcessResources) { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} diff --git a/protobuf/src/main/proto/ORConfigChannelProtocol.proto b/protobuf/src/main/proto/ORConfigChannelProtocol.proto new file mode 100644 index 0000000..f2168a5 --- /dev/null +++ b/protobuf/src/main/proto/ORConfigChannelProtocol.proto @@ -0,0 +1,84 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +syntax = "proto3"; + +option java_package = "io.openremote.orlib.service.espprovision"; +option java_outer_classname = "ORConfigChannelProtocol"; + +message Response { + message ResponseResult { + enum Result { + SUCCESS = 0; + REQUEST_UNKNOWN = 1; + INTERNAL_ERROR = 2; + ARGUMENT_ERROR = 3; + } + Result result = 1; + } + message OpenRemoteConfig { + enum Status { + SUCCESS = 0; + FAIL = 1; + } + Status status = 1; + } + message BackendConnectionStatus { + enum Status { + DISCONNECTED = 0; + CONNECTING = 1; + CONNECTED = 2; + FAILED = 3; + } + Status status = 1; + } + message DeviceInfo { + string device_id = 1; + string model_name = 3; + } + message ExitProvisioning {} + string id = 1; + ResponseResult result = 2; + oneof body { + DeviceInfo device_info = 6; + BackendConnectionStatus backend_connection_status = 7; + OpenRemoteConfig open_remote_config = 8; + ExitProvisioning exit_provisioning = 9; + } +} + +message Request { + message DeviceInfo {}; + message BackendConnectionStatus{}; + message OpenRemoteConfig { + string mqtt_broker_url = 1; + string user = 2; + string mqtt_password = 3; + string realm = 4; + string asset_id = 5; + } + message ExitProvisioning {}; + string id = 1; + oneof body { + DeviceInfo device_info = 6; + BackendConnectionStatus backend_connection_status = 7; + OpenRemoteConfig open_remote_config = 8; + ExitProvisioning exit_provisioning = 9; + } +}