From 72a09516015000b41b5a212da43889f6142f58e0 Mon Sep 17 00:00:00 2001 From: Ulrik Johnsen Date: Thu, 14 May 2026 16:26:50 +0200 Subject: [PATCH 1/6] DataLogging extension for custom data in progress --- .../libpebblecommon/connection/LibPebble.kt | 50 ++++++- .../datalogging/Datalogging.kt | 43 +++++- .../libpebblecommon/di/LibPebbleModule.kt | 137 ++++++++++++------ 3 files changed, 177 insertions(+), 53 deletions(-) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt index a01ec7c28..234c7c2ff 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt @@ -65,6 +65,7 @@ import io.rebble.libpebblecommon.web.LockerModelWrapper import kotlinx.coroutines.Deferred import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.mapNotNull @@ -78,6 +79,50 @@ import kotlin.uuid.Uuid data class PhoneCapabilities(val capabilities: Set) data class PlatformFlags(val flags: UInt) +data class CustomDataLoggingEvent( + val sessionId: UByte, + val appUuid: Uuid, + val tag: UInt, + val data: ByteArray, + val itemSize: UShort, + val itemsLeft: UInt, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CustomDataLoggingEvent) return false + return sessionId == other.sessionId && + appUuid == other.appUuid && + tag == other.tag && + data.contentEquals(other.data) && + itemSize == other.itemSize && + itemsLeft == other.itemsLeft + } + + override fun hashCode(): Int { + var result = sessionId.hashCode() + result = 31 * result + appUuid.hashCode() + result = 31 * result + tag.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + itemSize.hashCode() + result = 31 * result + itemsLeft.hashCode() + return result + } +} + +interface CustomDataLogging { + /* + * Emits every CustomDataLoggingEvent from any watch app whose DataLogging tag is + * not taken by health or Memfault. + * Filter by CustomDataLoggingEvent.appUuid or .tag to isolate specific data streams. + */ + val customData: SharedFlow + get() = _noopCustomData + + companion object { + private val _noopCustomData: SharedFlow = MutableSharedFlow() + } +} + typealias PebbleDevices = StateFlow> sealed class PebbleConnectionEvent { @@ -88,7 +133,7 @@ sealed class PebbleConnectionEvent { @Stable interface LibPebble : Scanning, RequestSync, LockerApi, NotificationApps, CallManagement, Calendar, OtherPebbleApps, PKJSToken, Watches, Errors, Contacts, AnalyticsEvents, HealthApi, WatchPrefs, - SystemGeolocation, Timeline, Vibrations, Weather, HealthDataApi { + SystemGeolocation, Timeline, Vibrations, Weather, HealthDataApi, CustomDataLogging { fun init() val config: StateFlow @@ -394,13 +439,14 @@ class LibPebble3( private val vibePatternDao: VibePatternDao, private val watchPreferences: WatchPrefs, private val weatherManager: WeatherManager, + private val datalogging: CustomDataLogging, ) : LibPebble, Scanning by scanning, RequestSync by webSyncManager, LockerApi by locker, NotificationApps by notificationApi, Calendar by phoneCalendarSyncer, OtherPebbleApps by otherPebbleApps, PKJSToken by jsTokenUtil, Watches by watchManager, Errors by errorTracker, Contacts by contacts, AnalyticsEvents by analytics, HealthApi by health, SystemGeolocation by systemGeolocation, Timeline by timeline, Vibrations by notificationApi, WatchPrefs by watchPreferences, Weather by weatherManager, - HealthDataApi by health { + HealthDataApi by health, CustomDataLogging by datalogging { private val logger = Logger.withTag("LibPebble3") private val initialized = AtomicBoolean(false) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt index 2206a9606..551c4c684 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt @@ -9,14 +9,27 @@ import io.rebble.libpebblecommon.structmapper.SUInt import io.rebble.libpebblecommon.structmapper.StructMappable import io.rebble.libpebblecommon.util.DataBuffer import io.rebble.libpebblecommon.util.Endian +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch import kotlin.uuid.Uuid class Datalogging( private val webServices: WebServices, private val healthDataProcessor: HealthDataProcessor, -) { + private val libPebbleCoroutineScope: LibPebbleCoroutineScope, +) : CustomDataLogging { private val logger = Logger.withTag("Datalogging") + private val _customData = + MutableSharedFlow( + extraBufferCapacity = 1024, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val customData: SharedFlow = _customData.asSharedFlow() + fun logData( sessionId: UByte, uuid: Uuid, @@ -48,6 +61,7 @@ class Datalogging( offset += size } } + ANALYTICS_HEARTBEAT_TAG -> { // Fixed-size native_heartbeat_record items (no inner length prefix). val size = itemSize.toInt() @@ -64,15 +78,37 @@ class Datalogging( } } } + if (uuid != SYSTEM_APP_UUID && tag == CUSTOM_DATA_TAG) { + libPebbleCoroutineScope.launch { + _customData.tryEmit( + CustomDataLoggingEvent( + sessionId = sessionId, + appUuid = uuid, + tag = tag, + data = data, + itemSize = itemSize, + itemsLeft = itemsLeft, + ), + ) + } + } } - fun openSession(sessionId: UByte, tag: UInt, applicationUuid: Uuid, itemSize: UShort) { + fun openSession( + sessionId: UByte, + tag: UInt, + applicationUuid: Uuid, + itemSize: UShort, + ) { if (tag in HealthDataProcessor.HEALTH_TAGS) { healthDataProcessor.handleSessionOpen(sessionId, tag, applicationUuid, itemSize) } } - fun closeSession(sessionId: UByte, tag: UInt) { + fun closeSession( + sessionId: UByte, + tag: UInt, + ) { if (tag in HealthDataProcessor.HEALTH_TAGS) { healthDataProcessor.handleSessionClose(sessionId) } @@ -81,6 +117,7 @@ class Datalogging( companion object { private val MEMFAULT_CHUNKS_TAG: UInt = 86u private val ANALYTICS_HEARTBEAT_TAG: UInt = 87u + private val CUSTOM_DATA_TAG: UInt = 0x0091u } } diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt index cde0d0643..79064cc5e 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt @@ -2,8 +2,6 @@ package io.rebble.libpebblecommon.di import co.touchlab.kermit.Logger import com.russhwolf.settings.Settings -import io.rebble.libpebblecommon.database.dao.HealthSettingsEntryRealDao -import io.rebble.libpebblecommon.database.entity.HealthSettingsEntryDao import io.ktor.client.HttpClient import io.rebble.libpebblecommon.BleConfigFlow import io.rebble.libpebblecommon.ErrorTracker @@ -22,6 +20,7 @@ import io.rebble.libpebblecommon.connection.AppContext import io.rebble.libpebblecommon.connection.ConnectionFailureHandler import io.rebble.libpebblecommon.connection.Contacts import io.rebble.libpebblecommon.connection.CreatePlatformIdentifier +import io.rebble.libpebblecommon.connection.CustomDataLogging import io.rebble.libpebblecommon.connection.LegacyBtClassicMigrator import io.rebble.libpebblecommon.connection.LibPebble import io.rebble.libpebblecommon.connection.LibPebble3 @@ -97,10 +96,12 @@ import io.rebble.libpebblecommon.contacts.PhoneContactsSyncer import io.rebble.libpebblecommon.database.BlobDbDatabaseManager import io.rebble.libpebblecommon.database.Database import io.rebble.libpebblecommon.database.RealBlobDbDatabaseManager +import io.rebble.libpebblecommon.database.dao.HealthSettingsEntryRealDao import io.rebble.libpebblecommon.database.dao.LockerEntryRealDao import io.rebble.libpebblecommon.database.dao.NotificationAppRealDao import io.rebble.libpebblecommon.database.dao.RealWatchPrefs import io.rebble.libpebblecommon.database.dao.TimelineNotificationRealDao +import io.rebble.libpebblecommon.database.entity.HealthSettingsEntryDao import io.rebble.libpebblecommon.database.entity.LockerEntryDao import io.rebble.libpebblecommon.database.entity.NotificationAppItemDao import io.rebble.libpebblecommon.database.entity.TimelineNotificationDao @@ -175,7 +176,10 @@ data class ConnectionScopeProperties( ) interface ConnectionAnalyticsLogger { - fun logEvent(name: String, props: Map? = null) + fun logEvent( + name: String, + props: Map? = null, + ) } class RealConnectionAnalyticsLogger( @@ -196,18 +200,21 @@ fun LibPebbleAnalytics.logWatchEvent( props: Map? = null, ) { logEvent( - "watch.$name", (props ?: emptyMap()) + - mapOf( - "color" to color.jsName, - "platform" to color.platform.name, - ) + "watch.$name", + (props ?: emptyMap()) + + mapOf( + "color" to color.jsName, + "platform" to color.platform.name, + ), ) } interface ConnectionScope { val identifier: PebbleIdentifier val pebbleConnector: PebbleConnector + fun close() + val closed: AtomicBoolean val firmwareUpdateManager: FirmwareUpdateManager val firmwareUpdater: FirmwareUpdater @@ -247,7 +254,9 @@ interface ConnectionScopeFactory { fun createScope(props: ConnectionScopeProperties): ConnectionScope } -class RealConnectionScopeFactory(private val koin: Koin) : ConnectionScopeFactory { +class RealConnectionScopeFactory( + private val koin: Koin, +) : ConnectionScopeFactory { override fun createScope(props: ConnectionScopeProperties): ConnectionScope { val uuid = Uuid.random() val scope = @@ -266,37 +275,44 @@ class RealConnectionScopeFactory(private val koin: Koin) : ConnectionScopeFactor /** * Essentially, GlobalScope for libpebble. Use this everywhere that would otherwise use GlobalScope. */ -class LibPebbleCoroutineScope(override val coroutineContext: CoroutineContext) : CoroutineScope +class LibPebbleCoroutineScope( + override val coroutineContext: CoroutineContext, +) : CoroutineScope /** * Per-connection coroutine scope, torn down when the connection ends. */ -class ConnectionCoroutineScope(override val coroutineContext: CoroutineContext) : CoroutineScope +class ConnectionCoroutineScope( + override val coroutineContext: CoroutineContext, +) : CoroutineScope /** * Lazy/provider for when we need to get out of a circular dependency. */ -class HackyProvider(val getter: () -> T) { +class HackyProvider( + val getter: () -> T, +) { fun get(): T = getter() } expect val platformModule: Module -val CommonPhoneCapabilities = setOf( - ProtocolCapsFlag.SupportsAppRunStateProtocol, - ProtocolCapsFlag.SupportsInfiniteLogDump, +val CommonPhoneCapabilities = + setOf( + ProtocolCapsFlag.SupportsAppRunStateProtocol, + ProtocolCapsFlag.SupportsInfiniteLogDump, // ProtocolCapsFlag.SupportsLocalization, - ProtocolCapsFlag.SupportsAppDictation, - ProtocolCapsFlag.Supports8kAppMessage, - ProtocolCapsFlag.SupportsSettingsSync, + ProtocolCapsFlag.SupportsAppDictation, + ProtocolCapsFlag.Supports8kAppMessage, + ProtocolCapsFlag.SupportsSettingsSync, // ProtocolCapsFlag.SupportsHealthInsights, // ProtocolCapsFlag.SupportsUnreadCoreDump, - ProtocolCapsFlag.SupportsWeatherApp, + ProtocolCapsFlag.SupportsWeatherApp, // ProtocolCapsFlag.SupportsRemindersApp, // ProtocolCapsFlag.SupportsWorkoutApp, // ProtocolCapsFlag.SupportsSmoothFwInstallProgress, // ProtocolCapsFlag.SupportsFwUpdateAcrossDisconnection, -) + ) // https://insert-koin.io/docs/reference/koin-core/context-isolation/ private object LibPebbleKoinContext { @@ -342,11 +358,13 @@ fun initKoin( single { get().knownWatchDao() } single { get().lockerEntryDao() } binds arrayOf(LockerEntryDao::class, LockerEntryRealDao::class) single { get().notificationAppDao() } binds arrayOf(NotificationAppItemDao::class, NotificationAppRealDao::class) - single { get().timelineNotificationDao() } binds arrayOf(TimelineNotificationDao::class, TimelineNotificationRealDao::class) + single { get().timelineNotificationDao() } binds + arrayOf(TimelineNotificationDao::class, TimelineNotificationRealDao::class) single { get().timelinePinDao() } single { get().timelineReminderDao() } single { get().calendarDao() } - single { get().healthSettingsDao() } binds arrayOf(HealthSettingsEntryDao::class, HealthSettingsEntryRealDao::class) + single { get().healthSettingsDao() } binds + arrayOf(HealthSettingsEntryDao::class, HealthSettingsEntryRealDao::class) single { get().lockerAppPermissionDao() } single { get().notificationsDao() } single { get().contactDao() } @@ -405,6 +423,7 @@ fun initKoin( get(), get(), get(), + get(), ) } bind LibPebble::class single { RealConnectionScopeFactory(koin) } bind ConnectionScopeFactory::class @@ -421,7 +440,7 @@ fun initKoin( url = "wss://cloudpebble-proxy.repebble.com/device-v2", protocolVersion = CloudpebbleProxyProtocolVersion.V2, scope = get(), - token = proxyTokenProvider + token = proxyTokenProvider, ) } single { HttpClient() } @@ -434,16 +453,17 @@ fun initKoin( singleOf(::MissedCallSyncer) singleOf(::FirmwareDownloader) singleOf(::JsTokenUtil) - singleOf(::Datalogging) + singleOf(::Datalogging) bind CustomDataLogging::class singleOf(::Health) singleOf(::ErrorTracker) singleOf(::RealConnectionFailureHandler) bind ConnectionFailureHandler::class singleOf(::PhoneContactsSyncer) singleOf(::ContactsApi) bind Contacts::class - singleOf(::RealLibPebbleAnalytics) binds arrayOf( - LibPebbleAnalytics::class, - AnalyticsEvents::class - ) + singleOf(::RealLibPebbleAnalytics) binds + arrayOf( + LibPebbleAnalytics::class, + AnalyticsEvents::class, + ) factory { Json { // Important that everything uses this - otherwise future additions to web apis will @@ -483,16 +503,37 @@ fun initKoin( scoped { // We ran out of helper function overloads with enough params... RealPebbleConnector( - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), - get(), get(), get(), get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), + get(), ) } bind PebbleConnector::class scopedOf(::PebbleProtocolRunner) @@ -552,13 +593,14 @@ fun initKoin( scopedOf(::RealFirmwareUpdateManager) bind FirmwareUpdateManager::class scoped { DevConnectionManager( - transport = get().flow.map { - if (it.watchConfig.lanDevConnection) { - get() - } else { - get() - } - }, + transport = + get().flow.map { + if (it.watchConfig.lanDevConnection) { + get() + } else { + get() + } + }, identifier = get(), protocolHandler = get(), companionAppLifecycleManager = get(), @@ -567,14 +609,13 @@ fun initKoin( } scopedOf(::VoiceSessionManager) - // TODO we ccoouulllddd scope this further to inject more things that we still // pass in as args // - transport connected = has connected gatt client // - fully connected = has WatchInfo (more useful) } - } - ) + }, + ), ) return koin } From 6be116121f039ee98fe805c51ee276817464318d Mon Sep 17 00:00:00 2001 From: Ulrik Johnsen Date: Tue, 19 May 2026 06:52:29 +0200 Subject: [PATCH 2/6] Formatting --- .../libpebblecommon/connection/LibPebble.kt | 19 +++--- .../datalogging/Datalogging.kt | 64 +++++++++++++------ 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt index 234c7c2ff..90c7bb440 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt @@ -88,8 +88,12 @@ data class CustomDataLoggingEvent( val itemsLeft: UInt, ) { override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is CustomDataLoggingEvent) return false + if (this === other) { + return true + } + if (other !is CustomDataLoggingEvent) { + return false + } return sessionId == other.sessionId && appUuid == other.appUuid && tag == other.tag && @@ -109,15 +113,14 @@ data class CustomDataLoggingEvent( } } +fun interface CustomDataLoggingSink { + suspend fun onData(event: CustomDataLoggingEvent) +} + interface CustomDataLogging { - /* - * Emits every CustomDataLoggingEvent from any watch app whose DataLogging tag is - * not taken by health or Memfault. - * Filter by CustomDataLoggingEvent.appUuid or .tag to isolate specific data streams. - */ val customData: SharedFlow get() = _noopCustomData - + fun setDataSink(sink: CustomDataLoggingSink?) {} companion object { private val _noopCustomData: SharedFlow = MutableSharedFlow() } diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt index 551c4c684..ba25fb493 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt @@ -2,7 +2,11 @@ package io.rebble.libpebblecommon.datalogging import co.touchlab.kermit.Logger import io.rebble.libpebblecommon.SystemAppIDs.SYSTEM_APP_UUID +import io.rebble.libpebblecommon.connection.CustomDataLogging +import io.rebble.libpebblecommon.connection.CustomDataLoggingEvent +import io.rebble.libpebblecommon.connection.CustomDataLoggingSink import io.rebble.libpebblecommon.connection.WebServices +import io.rebble.libpebblecommon.di.LibPebbleCoroutineScope import io.rebble.libpebblecommon.services.WatchInfo import io.rebble.libpebblecommon.structmapper.SBytes import io.rebble.libpebblecommon.structmapper.SUInt @@ -10,16 +14,18 @@ import io.rebble.libpebblecommon.structmapper.StructMappable import io.rebble.libpebblecommon.util.DataBuffer import io.rebble.libpebblecommon.util.Endian import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch +import kotlin.concurrent.atomics.AtomicReference import kotlin.uuid.Uuid class Datalogging( private val webServices: WebServices, private val healthDataProcessor: HealthDataProcessor, - private val libPebbleCoroutineScope: LibPebbleCoroutineScope, + libPebbleScope: LibPebbleCoroutineScope, ) : CustomDataLogging { private val logger = Logger.withTag("Datalogging") @@ -29,8 +35,30 @@ class Datalogging( onBufferOverflow = BufferOverflow.DROP_OLDEST, ) override val customData: SharedFlow = _customData.asSharedFlow() + private val sinkRef = AtomicReference(null) + private val sinkChannel = Channel( + capacity = SINK_CHANNEL_CAPACITY, + onBufferOverflow = BufferOverflow.SUSPEND, + ) - fun logData( + init { + libPebbleScope.launch { + for (event in sinkChannel) { + val sink = sinkRef.load() ?: continue + try { + sink.onData(event) + } catch (e: Throwable) { + logger.e(e) { "Sink threw for tag=${event.tag} uuid=${event.appUuid}" } + } + } + } + } + + override fun setDataSink(sink: CustomDataLoggingSink?) { + sinkRef.store(sink) + } + + suspend fun logData( sessionId: UByte, uuid: Uuid, tag: UInt, @@ -49,8 +77,6 @@ class Datalogging( if (uuid == SYSTEM_APP_UUID) { when (tag) { MEMFAULT_CHUNKS_TAG -> { - // A single SendDataItems payload can contain multiple items, - // each itemSize bytes. Parse each one as a MemfaultChunk. val size = itemSize.toInt() var offset = 0 while (offset + size <= data.size) { @@ -63,7 +89,6 @@ class Datalogging( } ANALYTICS_HEARTBEAT_TAG -> { - // Fixed-size native_heartbeat_record items (no inner length prefix). val size = itemSize.toInt() if (size <= 0) { logger.w { "Analytics heartbeat with itemSize=$size; ignoring" } @@ -77,21 +102,22 @@ class Datalogging( } } } + return } - if (uuid != SYSTEM_APP_UUID && tag == CUSTOM_DATA_TAG) { - libPebbleCoroutineScope.launch { - _customData.tryEmit( - CustomDataLoggingEvent( - sessionId = sessionId, - appUuid = uuid, - tag = tag, - data = data, - itemSize = itemSize, - itemsLeft = itemsLeft, - ), - ) - } + + val event = CustomDataLoggingEvent( + sessionId = sessionId, + appUuid = uuid, + tag = tag, + data = data, + itemSize = itemSize, + itemsLeft = itemsLeft, + ) + + if (sinkRef.load() != null) { + sinkChannel.send(event) } + _customData.tryEmit(event) } fun openSession( @@ -117,7 +143,7 @@ class Datalogging( companion object { private val MEMFAULT_CHUNKS_TAG: UInt = 86u private val ANALYTICS_HEARTBEAT_TAG: UInt = 87u - private val CUSTOM_DATA_TAG: UInt = 0x0091u + private const val SINK_CHANNEL_CAPACITY = 256 } } From e00be479acd8c39cad915692a8543013cbd89545 Mon Sep 17 00:00:00 2001 From: Ulrik Johnsen Date: Tue, 19 May 2026 07:14:25 +0200 Subject: [PATCH 3/6] Formatting to better display actual changes made in the files. --- .../libpebblecommon/connection/LibPebble.kt | 2 +- .../libpebblecommon/di/LibPebbleModule.kt | 133 +++++++----------- 2 files changed, 48 insertions(+), 87 deletions(-) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt index 90c7bb440..0a300e126 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/connection/LibPebble.kt @@ -442,7 +442,7 @@ class LibPebble3( private val vibePatternDao: VibePatternDao, private val watchPreferences: WatchPrefs, private val weatherManager: WeatherManager, - private val datalogging: CustomDataLogging, + private val datalogging: CustomDataLogging, ) : LibPebble, Scanning by scanning, RequestSync by webSyncManager, LockerApi by locker, NotificationApps by notificationApi, Calendar by phoneCalendarSyncer, OtherPebbleApps by otherPebbleApps, PKJSToken by jsTokenUtil, Watches by watchManager, diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt index 79064cc5e..f8b35e1ff 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/di/LibPebbleModule.kt @@ -2,6 +2,8 @@ package io.rebble.libpebblecommon.di import co.touchlab.kermit.Logger import com.russhwolf.settings.Settings +import io.rebble.libpebblecommon.database.dao.HealthSettingsEntryRealDao +import io.rebble.libpebblecommon.database.entity.HealthSettingsEntryDao import io.ktor.client.HttpClient import io.rebble.libpebblecommon.BleConfigFlow import io.rebble.libpebblecommon.ErrorTracker @@ -96,12 +98,10 @@ import io.rebble.libpebblecommon.contacts.PhoneContactsSyncer import io.rebble.libpebblecommon.database.BlobDbDatabaseManager import io.rebble.libpebblecommon.database.Database import io.rebble.libpebblecommon.database.RealBlobDbDatabaseManager -import io.rebble.libpebblecommon.database.dao.HealthSettingsEntryRealDao import io.rebble.libpebblecommon.database.dao.LockerEntryRealDao import io.rebble.libpebblecommon.database.dao.NotificationAppRealDao import io.rebble.libpebblecommon.database.dao.RealWatchPrefs import io.rebble.libpebblecommon.database.dao.TimelineNotificationRealDao -import io.rebble.libpebblecommon.database.entity.HealthSettingsEntryDao import io.rebble.libpebblecommon.database.entity.LockerEntryDao import io.rebble.libpebblecommon.database.entity.NotificationAppItemDao import io.rebble.libpebblecommon.database.entity.TimelineNotificationDao @@ -176,10 +176,7 @@ data class ConnectionScopeProperties( ) interface ConnectionAnalyticsLogger { - fun logEvent( - name: String, - props: Map? = null, - ) + fun logEvent(name: String, props: Map? = null) } class RealConnectionAnalyticsLogger( @@ -200,21 +197,18 @@ fun LibPebbleAnalytics.logWatchEvent( props: Map? = null, ) { logEvent( - "watch.$name", - (props ?: emptyMap()) + - mapOf( - "color" to color.jsName, - "platform" to color.platform.name, - ), + "watch.$name", (props ?: emptyMap()) + + mapOf( + "color" to color.jsName, + "platform" to color.platform.name, + ) ) } interface ConnectionScope { val identifier: PebbleIdentifier val pebbleConnector: PebbleConnector - fun close() - val closed: AtomicBoolean val firmwareUpdateManager: FirmwareUpdateManager val firmwareUpdater: FirmwareUpdater @@ -254,9 +248,7 @@ interface ConnectionScopeFactory { fun createScope(props: ConnectionScopeProperties): ConnectionScope } -class RealConnectionScopeFactory( - private val koin: Koin, -) : ConnectionScopeFactory { +class RealConnectionScopeFactory(private val koin: Koin) : ConnectionScopeFactory { override fun createScope(props: ConnectionScopeProperties): ConnectionScope { val uuid = Uuid.random() val scope = @@ -275,44 +267,37 @@ class RealConnectionScopeFactory( /** * Essentially, GlobalScope for libpebble. Use this everywhere that would otherwise use GlobalScope. */ -class LibPebbleCoroutineScope( - override val coroutineContext: CoroutineContext, -) : CoroutineScope +class LibPebbleCoroutineScope(override val coroutineContext: CoroutineContext) : CoroutineScope /** * Per-connection coroutine scope, torn down when the connection ends. */ -class ConnectionCoroutineScope( - override val coroutineContext: CoroutineContext, -) : CoroutineScope +class ConnectionCoroutineScope(override val coroutineContext: CoroutineContext) : CoroutineScope /** * Lazy/provider for when we need to get out of a circular dependency. */ -class HackyProvider( - val getter: () -> T, -) { +class HackyProvider(val getter: () -> T) { fun get(): T = getter() } expect val platformModule: Module -val CommonPhoneCapabilities = - setOf( - ProtocolCapsFlag.SupportsAppRunStateProtocol, - ProtocolCapsFlag.SupportsInfiniteLogDump, +val CommonPhoneCapabilities = setOf( + ProtocolCapsFlag.SupportsAppRunStateProtocol, + ProtocolCapsFlag.SupportsInfiniteLogDump, // ProtocolCapsFlag.SupportsLocalization, - ProtocolCapsFlag.SupportsAppDictation, - ProtocolCapsFlag.Supports8kAppMessage, - ProtocolCapsFlag.SupportsSettingsSync, + ProtocolCapsFlag.SupportsAppDictation, + ProtocolCapsFlag.Supports8kAppMessage, + ProtocolCapsFlag.SupportsSettingsSync, // ProtocolCapsFlag.SupportsHealthInsights, // ProtocolCapsFlag.SupportsUnreadCoreDump, - ProtocolCapsFlag.SupportsWeatherApp, + ProtocolCapsFlag.SupportsWeatherApp, // ProtocolCapsFlag.SupportsRemindersApp, // ProtocolCapsFlag.SupportsWorkoutApp, // ProtocolCapsFlag.SupportsSmoothFwInstallProgress, // ProtocolCapsFlag.SupportsFwUpdateAcrossDisconnection, - ) +) // https://insert-koin.io/docs/reference/koin-core/context-isolation/ private object LibPebbleKoinContext { @@ -358,13 +343,11 @@ fun initKoin( single { get().knownWatchDao() } single { get().lockerEntryDao() } binds arrayOf(LockerEntryDao::class, LockerEntryRealDao::class) single { get().notificationAppDao() } binds arrayOf(NotificationAppItemDao::class, NotificationAppRealDao::class) - single { get().timelineNotificationDao() } binds - arrayOf(TimelineNotificationDao::class, TimelineNotificationRealDao::class) + single { get().timelineNotificationDao() } binds arrayOf(TimelineNotificationDao::class, TimelineNotificationRealDao::class) single { get().timelinePinDao() } single { get().timelineReminderDao() } single { get().calendarDao() } - single { get().healthSettingsDao() } binds - arrayOf(HealthSettingsEntryDao::class, HealthSettingsEntryRealDao::class) + single { get().healthSettingsDao() } binds arrayOf(HealthSettingsEntryDao::class, HealthSettingsEntryRealDao::class) single { get().lockerAppPermissionDao() } single { get().notificationsDao() } single { get().contactDao() } @@ -440,7 +423,7 @@ fun initKoin( url = "wss://cloudpebble-proxy.repebble.com/device-v2", protocolVersion = CloudpebbleProxyProtocolVersion.V2, scope = get(), - token = proxyTokenProvider, + token = proxyTokenProvider ) } single { HttpClient() } @@ -459,11 +442,10 @@ fun initKoin( singleOf(::RealConnectionFailureHandler) bind ConnectionFailureHandler::class singleOf(::PhoneContactsSyncer) singleOf(::ContactsApi) bind Contacts::class - singleOf(::RealLibPebbleAnalytics) binds - arrayOf( - LibPebbleAnalytics::class, - AnalyticsEvents::class, - ) + singleOf(::RealLibPebbleAnalytics) binds arrayOf( + LibPebbleAnalytics::class, + AnalyticsEvents::class + ) factory { Json { // Important that everything uses this - otherwise future additions to web apis will @@ -503,37 +485,16 @@ fun initKoin( scoped { // We ran out of helper function overloads with enough params... RealPebbleConnector( - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), - get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), + get(), get(), get(), get(), ) } bind PebbleConnector::class scopedOf(::PebbleProtocolRunner) @@ -593,14 +554,13 @@ fun initKoin( scopedOf(::RealFirmwareUpdateManager) bind FirmwareUpdateManager::class scoped { DevConnectionManager( - transport = - get().flow.map { - if (it.watchConfig.lanDevConnection) { - get() - } else { - get() - } - }, + transport = get().flow.map { + if (it.watchConfig.lanDevConnection) { + get() + } else { + get() + } + }, identifier = get(), protocolHandler = get(), companionAppLifecycleManager = get(), @@ -609,13 +569,14 @@ fun initKoin( } scopedOf(::VoiceSessionManager) + // TODO we ccoouulllddd scope this further to inject more things that we still // pass in as args // - transport connected = has connected gatt client // - fully connected = has WatchInfo (more useful) } - }, - ), + } + ) ) return koin } From 738fa7d992c5224f3393a391be6106509f2899c7 Mon Sep 17 00:00:00 2001 From: Ulrik Johnsen Date: Tue, 19 May 2026 07:19:44 +0200 Subject: [PATCH 4/6] Comments --- .../io/rebble/libpebblecommon/datalogging/Datalogging.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt index ba25fb493..e73626ae4 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt @@ -77,6 +77,8 @@ class Datalogging( if (uuid == SYSTEM_APP_UUID) { when (tag) { MEMFAULT_CHUNKS_TAG -> { + // A single SendDataItems payload can contain multiple items, + // each itemSize bytes. Parse each one as a MemfaultChunk. val size = itemSize.toInt() var offset = 0 while (offset + size <= data.size) { @@ -89,6 +91,7 @@ class Datalogging( } ANALYTICS_HEARTBEAT_TAG -> { + // Fixed-size native_heartbeat_record items (no inner length prefix). val size = itemSize.toInt() if (size <= 0) { logger.w { "Analytics heartbeat with itemSize=$size; ignoring" } From ac2dea3bf961221b8177acd701acfbbda690a598 Mon Sep 17 00:00:00 2001 From: Ulrik Johnsen Date: Tue, 19 May 2026 19:48:13 +0200 Subject: [PATCH 5/6] Removed magic number for buffer capacity --- .../datalogging/Datalogging.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt index e73626ae4..716df6801 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/datalogging/Datalogging.kt @@ -31,15 +31,16 @@ class Datalogging( private val _customData = MutableSharedFlow( - extraBufferCapacity = 1024, + extraBufferCapacity = BUFFER_CAPACITY, onBufferOverflow = BufferOverflow.DROP_OLDEST, ) override val customData: SharedFlow = _customData.asSharedFlow() private val sinkRef = AtomicReference(null) - private val sinkChannel = Channel( - capacity = SINK_CHANNEL_CAPACITY, - onBufferOverflow = BufferOverflow.SUSPEND, - ) + private val sinkChannel = + Channel( + capacity = SINK_CHANNEL_CAPACITY, + onBufferOverflow = BufferOverflow.SUSPEND, + ) init { libPebbleScope.launch { @@ -107,15 +108,15 @@ class Datalogging( } return } - - val event = CustomDataLoggingEvent( - sessionId = sessionId, - appUuid = uuid, - tag = tag, - data = data, - itemSize = itemSize, - itemsLeft = itemsLeft, - ) + val event = + CustomDataLoggingEvent( + sessionId = sessionId, + appUuid = uuid, + tag = tag, + data = data, + itemSize = itemSize, + itemsLeft = itemsLeft, + ) if (sinkRef.load() != null) { sinkChannel.send(event) @@ -147,6 +148,7 @@ class Datalogging( private val MEMFAULT_CHUNKS_TAG: UInt = 86u private val ANALYTICS_HEARTBEAT_TAG: UInt = 87u private const val SINK_CHANNEL_CAPACITY = 256 + private const val BUFFER_CAPACITY = 256 } } From 303ba6c23dccb512cc6d5b5a2515bf25cdba8d0f Mon Sep 17 00:00:00 2001 From: Ulrik Johnsen Date: Tue, 19 May 2026 19:49:07 +0200 Subject: [PATCH 6/6] Moved the ACK/NACK to happen after payload has been handled, not before --- .../services/DataLoggingService.kt | 92 ++++++++++--------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/DataLoggingService.kt b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/DataLoggingService.kt index 55b28085f..7a163dec7 100644 --- a/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/DataLoggingService.kt +++ b/libpebble3/src/commonMain/kotlin/io/rebble/libpebblecommon/services/DataLoggingService.kt @@ -43,55 +43,56 @@ class DataLoggingService( } fun initialInit() { - protocolHandler.inboundMessages.onEach { - when (it) { - is DataLoggingIncomingPacket.OpenSession -> { - val id = it.sessionId.get() - val tag = it.tag.get() - val applicationUuid = it.applicationUUID.get() - val itemSize = it.dataItemSize.get() - logger.d { "Session opened: $id tag: $tag (accepted: $acceptSessions)" } - sessions[id] = DataLoggingSession(id, tag, applicationUuid, itemSize) - datalogging.openSession(id, tag, applicationUuid, itemSize) - sendAckNack(id) - } - - is DataLoggingIncomingPacket.SendDataItems -> { - val id = it.sessionId.get() - val session = sessions[id] - if (session == null) { - logger.e { "Session not found: $id" } - return@onEach + protocolHandler.inboundMessages + .onEach { + when (it) { + is DataLoggingIncomingPacket.OpenSession -> { + val id = it.sessionId.get() + val tag = it.tag.get() + val applicationUuid = it.applicationUUID.get() + val itemSize = it.dataItemSize.get() + logger.d { "Session opened: $id tag: $tag (accepted: $acceptSessions)" } + sessions[id] = DataLoggingSession(id, tag, applicationUuid, itemSize) + datalogging.openSession(id, tag, applicationUuid, itemSize) + sendAckNack(id) } - sendAckNack(id) - val info = watchInfo - if (info == null) { - logger.e { "watch info is null" } - return@onEach + + is DataLoggingIncomingPacket.SendDataItems -> { + val id = it.sessionId.get() + val session = sessions[id] + if (session == null) { + logger.e { "Session not found: $id" } + return@onEach + } + val info = watchInfo + if (info == null) { + logger.e { "watch info is null" } + return@onEach + } + datalogging.logData( + sessionId = id, + uuid = session.uuid, + tag = session.tag, + data = it.payload.get().toByteArray(), + watchInfo = info, + itemSize = session.itemSize, + itemsLeft = it.itemsLeftAfterThis.get(), + ) + sendAckNack(id) } - datalogging.logData( - sessionId = id, - uuid = session.uuid, - tag = session.tag, - data = it.payload.get().toByteArray(), - watchInfo = info, - itemSize = session.itemSize, - itemsLeft = it.itemsLeftAfterThis.get(), - ) - } - is DataLoggingIncomingPacket.CloseSession -> { - val id = it.sessionId.get() - val session = sessions[id] - logger.d { "Session closed: $id" } - if (session != null) { - datalogging.closeSession(id, session.tag) + is DataLoggingIncomingPacket.CloseSession -> { + val id = it.sessionId.get() + val session = sessions[id] + logger.d { "Session closed: $id" } + if (session != null) { + datalogging.closeSession(id, session.tag) + } + sessions.remove(id) + sendAckNack(id) } - sessions.remove(id) - sendAckNack(id) } - } - }.launchIn(scope) + }.launchIn(scope) } } @@ -100,4 +101,5 @@ data class DataLoggingSession( val tag: UInt, val uuid: Uuid, val itemSize: UShort, -) \ No newline at end of file +) +