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(LinkedHashSet()) private var central: BluetoothCentralManager? = null private var current: BluetoothPeripheral? = null private var btCommandInput: BluetoothGattCharacteristic? = null private fun onConnect(device: Device, output: FlowBus) { 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) { 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) { 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) } 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) { 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) { 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) { running = false connected = false bonusLevel = lastLevel bonusTime += lastTime bonusCals += lastCals bonusMeters += bonusMeters 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 } }