You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
364 lines
12 KiB
364 lines
12 KiB
package net.aiterp.git.ykonsole2.infrastructure
|
|
|
|
import com.welie.blessed.*
|
|
import com.welie.blessed.BluetoothGattCharacteristic.WriteType.WITHOUT_RESPONSE
|
|
import kotlinx.coroutines.*
|
|
import net.aiterp.git.ykonsole2.domain.models.Device
|
|
import net.aiterp.git.ykonsole2.domain.runtime.*
|
|
import net.aiterp.git.ykonsole2.infrastructure.drivers.abstracts.ActiveDriver
|
|
import net.aiterp.git.ykonsole2.infrastructure.iconsole.*
|
|
import net.aiterp.git.ykonsole2.application.logging.log
|
|
import java.time.Instant
|
|
import java.util.*
|
|
import kotlin.time.Duration
|
|
import kotlin.time.Duration.Companion.milliseconds
|
|
|
|
class IConsole : ActiveDriver() {
|
|
private val logger = log
|
|
|
|
private var lastTime = 0
|
|
private var lastLevel = 0
|
|
private var running = false
|
|
private var connected = false
|
|
private var wantsConnection = false
|
|
private var health = 0
|
|
|
|
private var tryingSince: Instant? = null
|
|
|
|
private var maxLevel = 20
|
|
private var lastCals = 0
|
|
private var lastMeters = 0
|
|
|
|
private var bonusLevel = 0
|
|
private var bonusTime = 0
|
|
private var bonusCals = 0
|
|
private var bonusMeters = 0
|
|
|
|
private val queue = Collections.synchronizedSet<Request?>(LinkedHashSet())
|
|
|
|
private var central: BluetoothCentralManager? = null
|
|
private var current: BluetoothPeripheral? = null
|
|
|
|
private var btCommandInput: BluetoothGattCharacteristic? = null
|
|
|
|
private fun onConnect(device: Device, output: FlowBus<Event>) {
|
|
if (!device.connectionString.startsWith("iconsole:")) {
|
|
logger.info("Ignoring non-iConsole $device")
|
|
return
|
|
}
|
|
val connectionString = device.connectionString.substring("iconsole:".length)
|
|
|
|
health = 7
|
|
bonusTime = 0
|
|
bonusCals = 0
|
|
bonusMeters = 0
|
|
wantsConnection = true
|
|
|
|
connect(connectionString, output)
|
|
}
|
|
|
|
private fun connect(connectionString: String, output: FlowBus<Event>) {
|
|
health--
|
|
if (health == 0) {
|
|
runBlocking {
|
|
output.emit(ErrorOccurred("Disconnected seven times during exercise"))
|
|
output.emit(Disconnected)
|
|
}
|
|
return
|
|
}
|
|
|
|
central?.stopScan()
|
|
current?.cancelConnection()
|
|
tryingSince = Instant.now()
|
|
|
|
if (central == null) {
|
|
val cbScan = object : BluetoothCentralManagerCallback() {
|
|
override fun onScanStarted() {
|
|
logger.info("Scanning for $connectionString...")
|
|
}
|
|
|
|
override fun onScanStopped() {
|
|
logger.info("Scan stopped")
|
|
|
|
tryingSince?.let { ts ->
|
|
if (ts.isBefore(Instant.now().minusSeconds(20))) {
|
|
central?.stopScan()
|
|
tryingSince = null
|
|
runBlocking {
|
|
output.emit(ErrorOccurred("Connection timeout after 20 seconds"))
|
|
output.emit(Disconnected)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onDiscoveredPeripheral(peripheral: BluetoothPeripheral, scanResult: ScanResult) {
|
|
logger.info("Connecting to ${peripheral.name} (${peripheral.address})...")
|
|
|
|
if (tryingSince != null) {
|
|
val cbPeripheral = object : BluetoothPeripheralCallback() {
|
|
override fun onServicesDiscovered(peripheral: BluetoothPeripheral, services: MutableList<BluetoothGattService>) {
|
|
logger.info("Checking device...")
|
|
|
|
val service1 = services.firstOrNull { it.uuid == S1_SERVICE }
|
|
val service2 = services.firstOrNull { it.uuid == S2_SERVICE }
|
|
|
|
if (service1 != null && service2 != null) {
|
|
btCommandInput = service1.getCharacteristic(S1_CHAR_COMMAND_INPUT)
|
|
val s1out = service1.getCharacteristic(S1_CHAR_DATA_OUTPUT)
|
|
val s1mys = service1.getCharacteristic(S1_CHAR_MYSTERY_OUTPUT)
|
|
val s2mys = service2.getCharacteristic(S2_CHAR_MYSTERY_OUTPUT)
|
|
if (btCommandInput == null || s1out == null || s1mys == null || s2mys == null) {
|
|
logger.warn("Something is off about this i-CONSOLE device")
|
|
output.emitBlocking(ErrorOccurred("(Maybe) a malfunctioning i-CONSOLE device"))
|
|
peripheral.cancelConnection()
|
|
return
|
|
}
|
|
|
|
peripheral.setNotify(btCommandInput!!, true)
|
|
peripheral.setNotify(s1out, true)
|
|
peripheral.setNotify(s1mys, true)
|
|
peripheral.setNotify(s2mys, true)
|
|
|
|
logger.info("Device setup successfully; sending first request...")
|
|
|
|
runBlocking {
|
|
peripheral.writeCharacteristic(
|
|
btCommandInput!!,
|
|
AckRequest.toBytes(),
|
|
WITHOUT_RESPONSE,
|
|
)
|
|
}
|
|
|
|
current = peripheral
|
|
} else {
|
|
logger.warn("This is maybe not an i-CONSOLE device, since it lacks the required services")
|
|
output.emitBlocking(ErrorOccurred("(Maybe) not an i-CONSOLE device"))
|
|
peripheral.cancelConnection()
|
|
}
|
|
}
|
|
|
|
override fun onCharacteristicUpdate(
|
|
peripheral: BluetoothPeripheral,
|
|
value: ByteArray?,
|
|
characteristic: BluetoothGattCharacteristic,
|
|
status: BluetoothCommandStatus,
|
|
) {
|
|
val res = value?.let { Response.fromBytes(it) }
|
|
|
|
if (!connected && res is AckResponse) {
|
|
logger.info("Device is now ready")
|
|
|
|
connected = true
|
|
output.emitBlocking(Connected(initial = bonusTime == 0))
|
|
}
|
|
|
|
if (res is MaxLevelResponse) {
|
|
maxLevel = res.level
|
|
}
|
|
|
|
if (res is ControlStateResponse) {
|
|
if (res.value == 1) {
|
|
running = true
|
|
output.emitBlocking(Started)
|
|
} else {
|
|
running = false
|
|
output.emitBlocking(Stopped)
|
|
}
|
|
}
|
|
|
|
if (res is WorkoutStatusResponse) {
|
|
val currentTime = (res.minutes * 60) + res.seconds
|
|
|
|
if (currentTime > lastTime) {
|
|
lastTime = currentTime
|
|
lastCals = maxOf(res.calories, lastCals)
|
|
lastMeters = maxOf((res.distance * 1000).toInt(), lastMeters)
|
|
|
|
output.emitBlocking(
|
|
ValuesReceived(
|
|
listOfNotNull(
|
|
Time(lastTime + bonusTime),
|
|
Calories(lastCals + bonusCals),
|
|
Distance(lastMeters + bonusMeters),
|
|
Level(res.level),
|
|
RpmSpeed(res.rpm),
|
|
res.pulse.takeIf { it >= 70 }?.let { Pulse(it) },
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tryingSince = null
|
|
central?.connectPeripheral(peripheral, cbPeripheral)
|
|
central?.stopScan()
|
|
}
|
|
}
|
|
|
|
override fun onDisconnectedPeripheral(peripheral: BluetoothPeripheral, status: BluetoothCommandStatus) {
|
|
if (peripheral.address == current?.address) {
|
|
if (wantsConnection) {
|
|
logger.warn("Will try restarting in 10 seconds (disconnection)")
|
|
prepareReconnect(connectionString, output)
|
|
} else {
|
|
current = null
|
|
output.emitBlocking(Disconnected)
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onConnectionFailed(peripheral: BluetoothPeripheral, status: BluetoothCommandStatus) {
|
|
central?.stopScan()
|
|
|
|
runBlocking {
|
|
if (health > 1) {
|
|
logger.warn("Will try restarting in 10 seconds (connection error)")
|
|
prepareReconnect(connectionString, output)
|
|
} else {
|
|
output.emit(ErrorOccurred("Failure: $status"))
|
|
output.emit(Disconnected)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
central = BluetoothCentralManager(cbScan)
|
|
}
|
|
|
|
central?.scanForPeripheralsWithAddresses(arrayOf(connectionString))
|
|
}
|
|
|
|
private fun onDisconnect() {
|
|
lastTime = 0
|
|
lastCals = 0
|
|
lastMeters = 0
|
|
running = false
|
|
connected = false
|
|
wantsConnection = false
|
|
|
|
current?.cancelConnection()
|
|
}
|
|
|
|
private fun onSetValue(value: Value) {
|
|
if (current == null || btCommandInput === null || !running) return
|
|
|
|
if (value is Level) {
|
|
lastLevel = value.toInt()
|
|
queue += SetResistanceLevelRequest(minOf(lastLevel, maxLevel))
|
|
}
|
|
}
|
|
|
|
private fun onStart() {
|
|
if (current == null || btCommandInput === null || running) return
|
|
|
|
if (lastTime == 0) {
|
|
logger.info("STARTED")
|
|
queue += AckRequest
|
|
queue += SetWorkoutModeRequest(0)
|
|
queue += SetWorkoutParamsRequest()
|
|
queue += SetWorkoutControlStateRequest(1)
|
|
|
|
if (bonusLevel > 0) {
|
|
onSetValue(Level(bonusLevel))
|
|
bonusLevel = 0
|
|
}
|
|
} else {
|
|
logger.info("RESUMED")
|
|
queue += SetWorkoutControlStateRequest(1)
|
|
}
|
|
}
|
|
|
|
private fun onStop() {
|
|
if (current == null || btCommandInput === null || !running) return
|
|
|
|
queue += SetWorkoutControlStateRequest(2)
|
|
}
|
|
|
|
override suspend fun onCommand(command: Command, output: FlowBus<Event>) {
|
|
when (command) {
|
|
is ConnectCommand -> onConnect(command.device, output)
|
|
DisconnectCommand -> onDisconnect()
|
|
is SetValueCommand -> onSetValue(command.value)
|
|
StartCommand -> onStart()
|
|
StopCommand -> onStop()
|
|
SkipCommand -> {}
|
|
}
|
|
}
|
|
|
|
override suspend fun onTick(output: FlowBus<Event>) {
|
|
current?.apply {
|
|
if (connected && btCommandInput != null) {
|
|
val s1input = btCommandInput!!
|
|
|
|
// Fetch one queued event when running, otherwise take up to five at a time
|
|
val max = if (running) 1 else 5
|
|
for (i in 0 until max) {
|
|
if (queue.isEmpty()) {
|
|
break
|
|
}
|
|
|
|
val first = queue.first()
|
|
writeCharacteristic(s1input, first.toBytes(), WITHOUT_RESPONSE)
|
|
queue -= first
|
|
delay(pollDuration)
|
|
}
|
|
|
|
if (running) {
|
|
// Fetch current state
|
|
writeCharacteristic(
|
|
s1input,
|
|
GetWorkoutStateRequest.toBytes(),
|
|
WITHOUT_RESPONSE,
|
|
)
|
|
} else {
|
|
// This is just to keep the connection alive, as far as I know…
|
|
runBlocking {
|
|
writeCharacteristic(
|
|
s1input,
|
|
GetMaxLevelRequest.toBytes(),
|
|
WITHOUT_RESPONSE,
|
|
)
|
|
delay(pollDuration)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
delay(pollDuration)
|
|
}
|
|
|
|
private fun prepareReconnect(connectionString: String, output: FlowBus<Event>) {
|
|
running = false
|
|
connected = false
|
|
|
|
bonusLevel = lastLevel
|
|
bonusTime += lastTime
|
|
bonusCals += lastCals
|
|
bonusMeters += lastMeters
|
|
|
|
lastTime = 0
|
|
lastCals = 0
|
|
lastMeters = 0
|
|
|
|
runBlocking {
|
|
output.emit(Stopped)
|
|
delay(timeMillis = 10_000)
|
|
connect(connectionString, output)
|
|
}
|
|
}
|
|
|
|
companion object {
|
|
private val S1_SERVICE = UUID.fromString("49535343-fe7d-4ae5-8fa9-9fafd205e455")
|
|
private val S1_CHAR_COMMAND_INPUT = UUID.fromString("49535343-8841-43f4-a8d4-ecbe34729bb3")
|
|
private val S1_CHAR_DATA_OUTPUT = UUID.fromString("49535343-1e4d-4bd9-ba61-23c647249616")
|
|
private val S1_CHAR_MYSTERY_OUTPUT = UUID.fromString("49535343-4c8a-39b3-2f49-511cff073b7e")
|
|
|
|
private val S2_SERVICE = UUID.fromString("49535343-5d82-6099-9348-7aac4d5fbc51")
|
|
private val S2_CHAR_MYSTERY_OUTPUT = UUID.fromString("49535343-026e-3a9b-954c-97daef17e26e")
|
|
|
|
private val pollDuration: Duration = 500.milliseconds
|
|
}
|
|
}
|