package io.rebble.libpebblecommon.services import co.touchlab.kermit.Logger import io.rebble.libpebblecommon.connection.ConnectedPebble import io.rebble.libpebblecommon.connection.PebbleProtocolHandler import io.rebble.libpebblecommon.connection.PhoneCapabilities import io.rebble.libpebblecommon.connection.PlatformFlags import io.rebble.libpebblecommon.di.ConnectionCoroutineScope import io.rebble.libpebblecommon.metadata.WatchColor import io.rebble.libpebblecommon.metadata.WatchColor.Pebble2DuoBlack import io.rebble.libpebblecommon.metadata.WatchColor.Pebble2DuoWhite import io.rebble.libpebblecommon.metadata.WatchColor.TimeRoundBlackGoldPolish20 import io.rebble.libpebblecommon.metadata.WatchColor.TimeRoundBlackSilverPolish20 import io.rebble.libpebblecommon.metadata.WatchHardwarePlatform import io.rebble.libpebblecommon.metadata.WatchType.CHALK import io.rebble.libpebblecommon.packets.FirmwareProperty import io.rebble.libpebblecommon.packets.PhoneAppVersion import io.rebble.libpebblecommon.packets.PingPong import io.rebble.libpebblecommon.packets.ProtocolCapsFlag import io.rebble.libpebblecommon.packets.ResetMessage import io.rebble.libpebblecommon.packets.SystemMessage import io.rebble.libpebblecommon.packets.TimeMessage import io.rebble.libpebblecommon.packets.WatchFactoryData import io.rebble.libpebblecommon.packets.WatchFirmwareVersion import io.rebble.libpebblecommon.packets.WatchVersion import io.rebble.libpebblecommon.packets.WatchVersion.WatchVersionResponse import io.rebble.libpebblecommon.structmapper.SInt import io.rebble.libpebblecommon.structmapper.StructMapper import io.rebble.libpebblecommon.util.DataBuffer import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Deferred import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.datetime.TimeZone import kotlinx.datetime.offsetAt import kotlin.concurrent.atomics.AtomicReference import kotlin.time.Clock import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant /** * Singleton to handle sending notifications cleanly, as well as TODO: receiving/acting on action events */ class SystemService( private val protocolHandler: PebbleProtocolHandler, private val scope: ConnectionCoroutineScope, private val phoneCapabilities: PhoneCapabilities, private val platformFlags: PlatformFlags ) : ProtocolService, ConnectedPebble.Debug, ConnectedPebble.Time { private val logger = Logger.withTag("SystemService") private val lastSentTimezoneId: AtomicReference = AtomicReference(TimeZone.currentSystemDefault().id) // The watch RTC crystal drifts; re-sync periodically while connected. private val timeSyncInterval = 1.hours private val _appVersionRequest = CompletableDeferred() val appVersionRequest: Deferred = _appVersionRequest private var watchVersionCallback: CompletableDeferred? = null private var watchModelCallback: CompletableDeferred? = null private var firmwareUpdateStartResponseCallback: CompletableDeferred? = null private var pongCallback: CompletableDeferred? = null suspend fun requestWatchVersion(): WatchInfo { val callback = CompletableDeferred() watchVersionCallback = callback protocolHandler.send(WatchVersion.WatchVersionRequest()) val watchVersion = callback.await() val fwVersion = watchVersion.running.firmwareVersion() logger.d("fwVersion = $fwVersion") // A little hacky, I wish this was just included in the watch version response val color = requestWatchColor() logger.d("watchVersion = $watchVersion") return watchVersion.watchInfo(color) } suspend fun requestWatchColor(): WatchColor { val callback = CompletableDeferred() watchModelCallback = callback protocolHandler.send(WatchFactoryData.WatchFactoryDataRequest("mfg_color")) val modelBytes = callback.await() val color = SInt(StructMapper()).also { it.fromBytes(DataBuffer(modelBytes)) }.get() return WatchColor.fromProtocolNumber(color) } suspend fun sendPhoneVersionResponse() { // TODO put all this stuff in libpebble config protocolHandler.send( PhoneAppVersion.AppVersionResponse( UInt.MAX_VALUE, 0u, platformFlags.flags, 2u, 4u, 4u, 2u, ProtocolCapsFlag.makeFlags(phoneCapabilities.capabilities.toList()) ) ) } suspend fun sendFirmwareUpdateStart( bytesAlreadyTransferred: UInt, bytesToSend: UInt ): SystemMessage.FirmwareUpdateStartStatus { val callback = CompletableDeferred() firmwareUpdateStartResponseCallback = callback protocolHandler.send( SystemMessage.FirmwareUpdateStart( bytesAlreadyTransferred, bytesToSend ) ) val response = callback.await() return SystemMessage.FirmwareUpdateStartStatus.fromValue(response.response.get()) } suspend fun sendFirmwareUpdateComplete() { protocolHandler.send(SystemMessage.FirmwareUpdateComplete()) } override suspend fun sendPing(cookie: UInt): UInt { // TODO can just read the inbound messages directly in these val pong = CompletableDeferred() pongCallback = pong protocolHandler.send(PingPong.Ping(cookie)) return pong.await().cookie.get() } override fun resetIntoPrf() { scope.launch { protocolHandler.send(ResetMessage.ResetIntoPrf) } } override fun createCoreDump() { scope.launch { protocolHandler.send(ResetMessage.CoreDump) } } override fun factoryReset() { scope.launch { protocolHandler.send(ResetMessage.FactoryReset) } } fun init() { scope.launch { while (true) { delay(timeSyncInterval) updateTime() } } scope.launch { protocolHandler.inboundMessages.collect { packet -> when (packet) { is WatchVersionResponse -> { watchVersionCallback?.complete(packet) watchVersionCallback = null } is WatchFactoryData.WatchFactoryDataResponse -> { watchModelCallback?.complete(packet.model.get()) watchModelCallback = null } is WatchFactoryData.WatchFactoryDataError -> { watchModelCallback?.completeExceptionally(Exception("Failed to fetch watch model")) watchModelCallback = null } is PhoneAppVersion.AppVersionRequest -> { _appVersionRequest.complete(packet) } is SystemMessage.FirmwareUpdateStartResponse -> { firmwareUpdateStartResponseCallback?.complete(packet) firmwareUpdateStartResponseCallback = null } is PingPong.Pong -> { pongCallback?.complete(packet) pongCallback = null } is TimeMessage.GetTimeUtcRequest-> { updateTime() } } } } } override suspend fun updateTime() { logger.d("updateTime") val time = Clock.System.now() val timeZone = TimeZone.currentSystemDefault() lastSentTimezoneId.store(timeZone.id) // SetUTC only carries whole seconds and the watch RTC is whole-second, so // round to nearest rather than truncating: floor() alone biases the watch // ~0.5s slow, compounded by BLE/apply latency. Rounding removes the // systematic truncation bias. val timeUtcSeconds = (time + 500.milliseconds).epochSeconds val tzOffsetMinutes = timeZone.offsetAt(time).totalSeconds.seconds.inWholeMinutes logger.v("time=$time timeZone=$timeZone timeUtcSeconds=$timeUtcSeconds tzOffsetMinutes=$tzOffsetMinutes") protocolHandler.send( TimeMessage.SetUTC( unixTime = timeUtcSeconds.toUInt(), utcOffset = tzOffsetMinutes.toShort(), timeZoneName = timeZone.id, ) ) } override suspend fun updateTimeIfNeeded() { val currentTz = TimeZone.currentSystemDefault().id val previousTz = lastSentTimezoneId.load() if (currentTz != previousTz) { logger.d("Timezone changed while backgrounded: $previousTz -> $currentTz") updateTime() } } } private val FIRMWARE_VERSION_REGEX = Regex("v?([0-9]+)\\.([0-9]+)(?:\\.([0-9]+))?(?:-(.*))?") data class FirmwareVersion( val stringVersion: String, val timestamp: Instant, val major: Int, val minor: Int, val patch: Int, val suffix: String?, val gitHash: String, val isRecovery: Boolean, // val hardwarePlatform: ? // val metadataVersion: ? val isDualSlot: Boolean, val isSlot0: Boolean, ) : Comparable { private fun code(): Int = patch + (minor * 1_000) + (major * 1_000_000) override fun compareTo(other: FirmwareVersion): Int { val diff = code() - other.code() return if (diff == 0) { timestamp.compareTo(other.timestamp) } else { diff } } override fun equals(other: Any?): Boolean { val otherFw = other as? FirmwareVersion ?: return false return code() == otherFw.code() && timestamp == other.timestamp } override fun hashCode(): Int { return code() + timestamp.hashCode() } companion object { fun from( tag: String, isRecovery: Boolean, gitHash: String, timestamp: Instant, isDualSlot: Boolean, isSlot0: Boolean, ): FirmwareVersion? { val match = FIRMWARE_VERSION_REGEX.find(tag) if (match == null) { Logger.w("Couldn't decode fw version: '$tag'") return null } val major = match.groupValues.get(1).toInt() val minor = match.groupValues.get(2).toInt() val patch = match.groupValues.get(3).toIntOrNull() ?: 0 val suffix = match.groupValues.get(4) // TODO empty or null-and-crash? return FirmwareVersion( stringVersion = tag, timestamp = timestamp, major = major, minor = minor, patch = patch, suffix = suffix, gitHash = gitHash, isRecovery = isRecovery, isDualSlot = isDualSlot, isSlot0 = isSlot0, ) } fun FirmwareVersion.slot(): Int? = when { isDualSlot && isSlot0 -> 0 isDualSlot && !isSlot0 -> 1 else -> null } } } fun WatchFirmwareVersion.firmwareVersion(): FirmwareVersion? { val properties = FirmwareProperty.fromFlags(flags.get()) Logger.d { "WatchFirmwareVersion flags = ${flags.get()}" } return FirmwareVersion.from( tag = versionTag.get(), gitHash = gitHash.get(), isRecovery = properties.contains(FirmwareProperty.IsRecoveryFirmware), timestamp = Instant.fromEpochSeconds(timestamp.get().toLong()), isDualSlot = properties.contains(FirmwareProperty.IsDualSlot), isSlot0 = properties.contains(FirmwareProperty.IsSlot0), ) } data class WatchInfo( val runningFwVersion: FirmwareVersion, val recoveryFwVersion: FirmwareVersion?, val platform: WatchHardwarePlatform, val bootloaderTimestamp: Instant, val board: String, val serial: String, val btAddress: String, val resourceCrc: Long, val resourceTimestamp: Instant, val language: String, val languageVersion: Int, val capabilities: Set, val isUnfaithful: Boolean, val healthInsightsVersion: Int?, val javascriptVersion: Int?, val color: WatchColor, ) private val FwVersionSupportsCustomVibePatterns = FirmwareVersion( stringVersion = "4.9.97", timestamp = Instant.DISTANT_FUTURE, major = 4, minor = 9, patch = 97, suffix = null, gitHash = "", isRecovery = false, isDualSlot = false, isSlot0 = false, ) fun WatchVersionResponse.watchInfo(color: WatchColor): WatchInfo { val runningFwVersion = running.firmwareVersion() checkNotNull(runningFwVersion) val recoveryFwVersion = recovery.firmwareVersion() val extraCapabilities = buildSet { if (runningFwVersion >= FwVersionSupportsCustomVibePatterns) { // This capability was added to firmware later - keeping the hack in here for now add(ProtocolCapsFlag.SupportsCustomVibePatterns) } } val platform = WatchHardwarePlatform.fromProtocolNumber(running.hardwarePlatform.get()).let { platform -> when { // Hack: Obelix PVT reports platform code 0 running.hardwarePlatform.get() == 0u.toUByte() && serial.get().startsWith("C111") && running.versionTag.get() == "v4.9.100" -> WatchHardwarePlatform.CORE_OBELIX_PVT else -> platform } } val actualColor = when { // Polished PTR color codes were re-used for Asterix - but we can report the correct color // if it was a Chalk. color == Pebble2DuoBlack && platform.watchType == CHALK -> TimeRoundBlackSilverPolish20 color == Pebble2DuoWhite && platform.watchType == CHALK -> TimeRoundBlackGoldPolish20 else -> color } return WatchInfo( runningFwVersion = runningFwVersion, recoveryFwVersion = recoveryFwVersion, platform = platform, bootloaderTimestamp = Instant.fromEpochSeconds(bootloaderTimestamp.get().toLong()), board = board.get(), serial = serial.get(), btAddress = btAddress.get().toByteArray().toMacAddressString(), resourceCrc = resourceCrc.get().toLong(), resourceTimestamp = Instant.fromEpochSeconds(resourceTimestamp.get().toLong()), language = language.get(), languageVersion = languageVersion.get().toInt(), capabilities = ProtocolCapsFlag.fromFlags(capabilities.get()) + extraCapabilities, isUnfaithful = isUnfaithful.get() ?: true, healthInsightsVersion = healthInsightsVersion.get()?.toInt(), javascriptVersion = javascriptVersion.get()?.toInt(), color = actualColor, ) } fun ByteArray.toMacAddressString(): String { require(size == 6) { "MAC address must be 6 bytes long" } return reversed().joinToString(":") { byte -> val intRepresentation = byte.toInt() and 0xFF intRepresentation.toString(16).padStart(2, '0').uppercase() } }