sforkowany z mirror/meshtastic-android
341 wiersze
12 KiB
Kotlin
341 wiersze
12 KiB
Kotlin
package com.geeksville.mesh.service
|
|
|
|
import android.annotation.SuppressLint
|
|
import android.app.Service
|
|
import android.content.Context
|
|
import android.content.Intent
|
|
import android.content.SharedPreferences
|
|
import android.os.IBinder
|
|
import androidx.core.content.edit
|
|
import androidx.lifecycle.LifecycleOwner
|
|
import androidx.lifecycle.ServiceLifecycleDispatcher
|
|
import androidx.lifecycle.coroutineScope
|
|
import com.geeksville.android.BinaryLogFile
|
|
import com.geeksville.android.GeeksvilleApplication
|
|
import com.geeksville.android.Logging
|
|
import com.geeksville.concurrent.handledLaunch
|
|
import com.geeksville.mesh.IRadioInterfaceService
|
|
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
|
|
import com.geeksville.util.anonymize
|
|
import com.geeksville.util.ignoreException
|
|
import com.geeksville.util.toRemoteExceptions
|
|
import dagger.hilt.android.AndroidEntryPoint
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.coroutines.flow.collect
|
|
import javax.inject.Inject
|
|
|
|
|
|
open class RadioNotConnectedException(message: String = "Not connected to radio") :
|
|
BLEException(message)
|
|
|
|
|
|
/**
|
|
* Handles the bluetooth link with a mesh radio device. Does not cache any device state,
|
|
* just does bluetooth comms etc...
|
|
*
|
|
* This service is not exposed outside of this process.
|
|
*
|
|
* Note - this class intentionally dumb. It doesn't understand protobuf framing etc...
|
|
* It is designed to be simple so it can be stubbed out with a simulated version as needed.
|
|
*/
|
|
@AndroidEntryPoint
|
|
class RadioInterfaceService : Service(), Logging {
|
|
|
|
// The following is due to the fact that AIDL prevents us from extending from `LifecycleService`:
|
|
private val lifecycleOwner: LifecycleOwner = LifecycleOwner { lifecycleDispatcher.lifecycle }
|
|
private val lifecycleDispatcher: ServiceLifecycleDispatcher by lazy {
|
|
ServiceLifecycleDispatcher(lifecycleOwner)
|
|
}
|
|
|
|
@Inject
|
|
lateinit var bluetoothRepository: BluetoothRepository
|
|
|
|
companion object : Logging {
|
|
/**
|
|
* The RECEIVED_FROMRADIO
|
|
* Payload will be the raw bytes which were contained within a MeshProtos.FromRadio protobuf
|
|
*/
|
|
const val RECEIVE_FROMRADIO_ACTION = "$prefix.RECEIVE_FROMRADIO"
|
|
|
|
/**
|
|
* This is broadcast when connection state changed
|
|
*/
|
|
const val RADIO_CONNECTED_ACTION = "$prefix.CONNECT_CHANGED"
|
|
|
|
const val DEVADDR_KEY = "devAddr2" // the new name for devaddr
|
|
|
|
init {
|
|
/// We keep this var alive so that the following factory objects get created and not stripped during the android build
|
|
val factories = arrayOf<InterfaceFactory>(
|
|
BluetoothInterface,
|
|
SerialInterface,
|
|
TCPInterface,
|
|
MockInterface,
|
|
NopInterface
|
|
)
|
|
info("Using ${factories.size} interface factories")
|
|
}
|
|
|
|
/// This is public only so that SimRadio can bootstrap our message flow
|
|
fun broadcastReceivedFromRadio(context: Context, payload: ByteArray) {
|
|
val intent = Intent(RECEIVE_FROMRADIO_ACTION)
|
|
intent.putExtra(EXTRA_PAYLOAD, payload)
|
|
context.sendBroadcast(intent)
|
|
}
|
|
|
|
fun getPrefs(context: Context): SharedPreferences =
|
|
context.getSharedPreferences("radio-prefs", Context.MODE_PRIVATE)
|
|
|
|
/** Return the device we are configured to use, or null for none
|
|
* device address strings are of the form:
|
|
*
|
|
* at
|
|
*
|
|
* where a is either x for bluetooth or s for serial
|
|
* and t is an interface specific address (macaddr or a device path)
|
|
*/
|
|
@SuppressLint("NewApi")
|
|
fun getDeviceAddress(context: Context): String? {
|
|
// If the user has unpaired our device, treat things as if we don't have one
|
|
val prefs = getPrefs(context)
|
|
var address = prefs.getString(DEVADDR_KEY, null)
|
|
|
|
// If we are running on the emulator we default to the mock interface, so we can have some data to show to the user
|
|
if (address == null && MockInterface.addressValid(context, ""))
|
|
address = MockInterface.prefix.toString()
|
|
|
|
return address
|
|
}
|
|
|
|
/** Like getDeviceAddress, but filtered to return only devices we are currently bonded with
|
|
*
|
|
* at
|
|
*
|
|
* where a is either x for bluetooth or s for serial
|
|
* and t is an interface specific address (macaddr or a device path)
|
|
*/
|
|
@SuppressLint("NewApi")
|
|
fun getBondedDeviceAddress(context: Context): String? {
|
|
// If the user has unpaired our device, treat things as if we don't have one
|
|
val address = getDeviceAddress(context)
|
|
|
|
/// Interfaces can filter addresses to indicate that address is no longer acceptable
|
|
if (address != null) {
|
|
val c = address[0]
|
|
val rest = address.substring(1)
|
|
val isValid = InterfaceFactory.getFactory(c)?.addressValid(context, rest) ?: false
|
|
if (!isValid)
|
|
return null
|
|
}
|
|
return address
|
|
}
|
|
|
|
/// If our service is currently running, this pointer can be used to reach it (in case setBondedDeviceAddress is called)
|
|
private var runningService: RadioInterfaceService? = null
|
|
}
|
|
|
|
private val logSends = false
|
|
private val logReceives = false
|
|
private lateinit var sentPacketsLog: BinaryLogFile // inited in onCreate
|
|
private lateinit var receivedPacketsLog: BinaryLogFile
|
|
|
|
/**
|
|
* We recreate this scope each time we stop an interface
|
|
*/
|
|
var serviceScope = CoroutineScope(Dispatchers.IO + Job())
|
|
|
|
private var radioIf: IRadioInterface = NopInterface()
|
|
|
|
/** true if we have started our interface
|
|
*
|
|
* Note: an interface may be started without necessarily yet having a connection
|
|
*/
|
|
private var isStarted = false
|
|
|
|
/// true if our interface is currently connected to a device
|
|
private var isConnected = false
|
|
|
|
private fun broadcastConnectionChanged(isConnected: Boolean, isPermanent: Boolean) {
|
|
debug("Broadcasting connection=$isConnected")
|
|
val intent = Intent(RADIO_CONNECTED_ACTION)
|
|
intent.putExtra(EXTRA_CONNECTED, isConnected)
|
|
intent.putExtra(EXTRA_PERMANENT, isPermanent)
|
|
sendBroadcast(intent)
|
|
}
|
|
|
|
/// Send a packet/command out the radio link, this routine can block if it needs to
|
|
private fun handleSendToRadio(p: ByteArray) {
|
|
radioIf.handleSendToRadio(p)
|
|
}
|
|
|
|
// Handle an incoming packet from the radio, broadcasts it as an android intent
|
|
fun handleFromRadio(p: ByteArray) {
|
|
if (logReceives) {
|
|
receivedPacketsLog.write(p)
|
|
receivedPacketsLog.flush()
|
|
}
|
|
|
|
// ignoreException { debug("FromRadio: ${MeshProtos.FromRadio.parseFrom(p)}") }
|
|
|
|
broadcastReceivedFromRadio(
|
|
this,
|
|
p
|
|
)
|
|
}
|
|
|
|
fun onConnect() {
|
|
if (!isConnected) {
|
|
isConnected = true
|
|
broadcastConnectionChanged(true, false)
|
|
}
|
|
}
|
|
|
|
fun onDisconnect(isPermanent: Boolean) {
|
|
if (isConnected) {
|
|
isConnected = false
|
|
broadcastConnectionChanged(false, isPermanent)
|
|
}
|
|
}
|
|
|
|
|
|
override fun onCreate() {
|
|
runningService = this
|
|
lifecycleDispatcher.onServicePreSuperOnCreate()
|
|
super.onCreate()
|
|
|
|
lifecycleOwner.lifecycle.coroutineScope.launch {
|
|
bluetoothRepository.state.collect { state ->
|
|
if (state.enabled) {
|
|
startInterface()
|
|
} else {
|
|
stopInterface()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
lifecycleDispatcher.onServicePreSuperOnStart()
|
|
return super.onStartCommand(intent, flags, startId)
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
stopInterface()
|
|
serviceScope.cancel("Destroying RadioInterface")
|
|
runningService = null
|
|
lifecycleDispatcher.onServicePreSuperOnDestroy()
|
|
super.onDestroy()
|
|
}
|
|
|
|
override fun onBind(intent: Intent?): IBinder? {
|
|
lifecycleDispatcher.onServicePreSuperOnBind()
|
|
return binder
|
|
}
|
|
|
|
|
|
/** Start our configured interface (if it isn't already running) */
|
|
private fun startInterface() {
|
|
if (radioIf !is NopInterface)
|
|
warn("Can't start interface - $radioIf is already running")
|
|
else {
|
|
val address = getBondedDeviceAddress(this)
|
|
if (address == null)
|
|
warn("No bonded mesh radio, can't start interface")
|
|
else {
|
|
info("Starting radio ${address.anonymize}")
|
|
isStarted = true
|
|
|
|
if (logSends)
|
|
sentPacketsLog = BinaryLogFile(this, "sent_log.pb")
|
|
if (logReceives)
|
|
receivedPacketsLog = BinaryLogFile(this, "receive_log.pb")
|
|
|
|
val c = address[0]
|
|
val rest = address.substring(1)
|
|
radioIf =
|
|
InterfaceFactory.getFactory(c)?.createInterface(this, rest) ?: NopInterface()
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun stopInterface() {
|
|
val r = radioIf
|
|
info("stopping interface $r")
|
|
isStarted = false
|
|
radioIf = NopInterface()
|
|
r.close()
|
|
|
|
// cancel any old jobs and get ready for the new ones
|
|
serviceScope.cancel("stopping interface")
|
|
serviceScope = CoroutineScope(Dispatchers.IO + Job())
|
|
|
|
if (logSends)
|
|
sentPacketsLog.close()
|
|
if (logReceives)
|
|
receivedPacketsLog.close()
|
|
|
|
// Don't broadcast disconnects if we were just using the nop device
|
|
if (r !is NopInterface)
|
|
onDisconnect(isPermanent = true) // Tell any clients we are now offline
|
|
}
|
|
|
|
|
|
/**
|
|
* Change to a new device
|
|
*
|
|
* @return true if the device changed, false if no change
|
|
*/
|
|
@SuppressLint("NewApi")
|
|
private fun setBondedDeviceAddress(address: String?): Boolean {
|
|
return if (getBondedDeviceAddress(this) == address && isStarted) {
|
|
warn("Ignoring setBondedDevice ${address.anonymize}, because we are already using that device")
|
|
false
|
|
} else {
|
|
// Record that this use has configured a new radio
|
|
GeeksvilleApplication.analytics.track(
|
|
"mesh_bond"
|
|
)
|
|
|
|
// Ignore any errors that happen while closing old device
|
|
ignoreException {
|
|
stopInterface()
|
|
}
|
|
|
|
// The device address "n" can be used to mean none
|
|
|
|
debug("Setting bonded device to ${address.anonymize}")
|
|
|
|
getPrefs(this).edit(commit = true) {
|
|
if (address == null)
|
|
this.remove(DEVADDR_KEY)
|
|
else
|
|
putString(DEVADDR_KEY, address)
|
|
}
|
|
|
|
// Force the service to reconnect
|
|
startInterface()
|
|
true
|
|
}
|
|
}
|
|
|
|
private val binder = object : IRadioInterfaceService.Stub() {
|
|
|
|
override fun setDeviceAddress(deviceAddr: String?): Boolean = toRemoteExceptions {
|
|
setBondedDeviceAddress(deviceAddr)
|
|
}
|
|
|
|
/** If the service is not currently connected to the radio, try to connect now. At boot the radio interface service will
|
|
* not connect to a radio until this call is received. */
|
|
override fun connect() = toRemoteExceptions {
|
|
// We don't start actually talking to our device until MeshService binds to us - this prevents
|
|
// broadcasting connection events before MeshService is ready to receive them
|
|
startInterface()
|
|
}
|
|
|
|
override fun sendToRadio(a: ByteArray) {
|
|
// Do this in the IO thread because it might take a while (and we don't care about the result code)
|
|
serviceScope.handledLaunch { handleSendToRadio(a) }
|
|
}
|
|
}
|
|
}
|