diff --git a/build.gradle.kts b/build.gradle.kts index 598ffb0..b9651df 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,17 +16,29 @@ plugins { alias(libs.plugins.spotless) alias(libs.plugins.detekt) apply false alias(libs.plugins.kover) - alias(libs.plugins.axionRelease) + // axionRelease applied conditionally below — it fails to apply when this build is + // included as a Gradle composite build because the root project is not yet available. + alias(libs.plugins.axionRelease) apply false } -scmVersion { - tag { - prefix.set("v") +// Only configure SCM versioning when running as a standalone build. When included as a +// composite build (gradle.parent != null), version management is unnecessary and the plugin +// errors out trying to reach the root project too early. +if (gradle.parent == null) { + apply(plugin = "pl.allegro.tech.build.axion-release") + configure { + tag { + prefix.set("v") + } + versionIncrementer("incrementPatch") } - versionIncrementer("incrementPatch") } -val resolvedVersion: String = scmVersion.version +val resolvedVersion: String = if (gradle.parent == null) { + extensions.getByType().version +} else { + "0.0.0-composite" +} allprojects { group = "org.meshtastic" diff --git a/core/api/core.klib.api b/core/api/core.klib.api index 218c9c2..7ed643b 100644 --- a/core/api/core.klib.api +++ b/core/api/core.klib.api @@ -22,6 +22,32 @@ final enum class org.meshtastic.sdk/ConfigPhase : kotlin/Enum // org.meshtastic.sdk/ConfigPhase.values|values#static(){}[0] } +final enum class org.meshtastic.sdk/CongestionLevel : kotlin/Enum { // org.meshtastic.sdk/CongestionLevel|null[0] + enum entry CRITICAL // org.meshtastic.sdk/CongestionLevel.CRITICAL|null[0] + enum entry HIGH // org.meshtastic.sdk/CongestionLevel.HIGH|null[0] + enum entry LOW // org.meshtastic.sdk/CongestionLevel.LOW|null[0] + enum entry MEDIUM // org.meshtastic.sdk/CongestionLevel.MEDIUM|null[0] + + final val entries // org.meshtastic.sdk/CongestionLevel.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/CongestionLevel.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/CongestionLevel // org.meshtastic.sdk/CongestionLevel.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/CongestionLevel.values|values#static(){}[0] +} + +final enum class org.meshtastic.sdk/ConnectionQuality : kotlin/Enum { // org.meshtastic.sdk/ConnectionQuality|null[0] + enum entry DIRECT // org.meshtastic.sdk/ConnectionQuality.DIRECT|null[0] + enum entry MQTT // org.meshtastic.sdk/ConnectionQuality.MQTT|null[0] + enum entry RELAYED // org.meshtastic.sdk/ConnectionQuality.RELAYED|null[0] + enum entry UNKNOWN // org.meshtastic.sdk/ConnectionQuality.UNKNOWN|null[0] + + final val entries // org.meshtastic.sdk/ConnectionQuality.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/ConnectionQuality.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/ConnectionQuality.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/ConnectionQuality.values|values#static(){}[0] +} + final enum class org.meshtastic.sdk/DroppedFlow : kotlin/Enum { // org.meshtastic.sdk/DroppedFlow|null[0] enum entry Events // org.meshtastic.sdk/DroppedFlow.Events|null[0] enum entry Packets // org.meshtastic.sdk/DroppedFlow.Packets|null[0] @@ -33,6 +59,18 @@ final enum class org.meshtastic.sdk/DroppedFlow : kotlin/Enum // org.meshtastic.sdk/DroppedFlow.values|values#static(){}[0] } +final enum class org.meshtastic.sdk/ExternalChangeKind : kotlin/Enum { // org.meshtastic.sdk/ExternalChangeKind|null[0] + enum entry CHANNEL // org.meshtastic.sdk/ExternalChangeKind.CHANNEL|null[0] + enum entry CONFIG // org.meshtastic.sdk/ExternalChangeKind.CONFIG|null[0] + enum entry MODULE_CONFIG // org.meshtastic.sdk/ExternalChangeKind.MODULE_CONFIG|null[0] + + final val entries // org.meshtastic.sdk/ExternalChangeKind.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/ExternalChangeKind.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/ExternalChangeKind // org.meshtastic.sdk/ExternalChangeKind.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/ExternalChangeKind.values|values#static(){}[0] +} + final enum class org.meshtastic.sdk/LogLevel : kotlin/Enum { // org.meshtastic.sdk/LogLevel|null[0] enum entry DEBUG // org.meshtastic.sdk/LogLevel.DEBUG|null[0] enum entry ERROR // org.meshtastic.sdk/LogLevel.ERROR|null[0] @@ -66,6 +104,19 @@ final enum class org.meshtastic.sdk/NodeField : kotlin/Enum // org.meshtastic.sdk/NodeField.values|values#static(){}[0] } +final enum class org.meshtastic.sdk/SignalQuality : kotlin/Enum { // org.meshtastic.sdk/SignalQuality|null[0] + enum entry FAIR // org.meshtastic.sdk/SignalQuality.FAIR|null[0] + enum entry GOOD // org.meshtastic.sdk/SignalQuality.GOOD|null[0] + enum entry NONE // org.meshtastic.sdk/SignalQuality.NONE|null[0] + enum entry POOR // org.meshtastic.sdk/SignalQuality.POOR|null[0] + + final val entries // org.meshtastic.sdk/SignalQuality.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // org.meshtastic.sdk/SignalQuality.entries.|#static(){}[0] + + final fun valueOf(kotlin/String): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/SignalQuality.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // org.meshtastic.sdk/SignalQuality.values|values#static(){}[0] +} + abstract fun interface org.meshtastic.sdk/LogSink { // org.meshtastic.sdk/LogSink|null[0] abstract fun log(org.meshtastic.sdk/LogLevel, kotlin/String, kotlin/String, kotlin/Throwable?) // org.meshtastic.sdk/LogSink.log|log(org.meshtastic.sdk.LogLevel;kotlin.String;kotlin.String;kotlin.Throwable?){}[0] @@ -76,23 +127,59 @@ abstract fun interface org.meshtastic.sdk/LogSink { // org.meshtastic.sdk/LogSin } abstract interface org.meshtastic.sdk/AdminApi { // org.meshtastic.sdk/AdminApi|null[0] + abstract fun forNode(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/AdminApi // org.meshtastic.sdk/AdminApi.forNode|forNode(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun <#A1: kotlin/Any?> batch(kotlin.coroutines/SuspendFunction1): #A1 // org.meshtastic.sdk/AdminApi.batch|batch(kotlin.coroutines.SuspendFunction1){0§}[0] abstract suspend fun <#A1: kotlin/Any?> editSettings(kotlin.coroutines/SuspendFunction1): org.meshtastic.sdk/AdminResult<#A1> // org.meshtastic.sdk/AdminApi.editSettings|editSettings(kotlin.coroutines.SuspendFunction1){0§}[0] + abstract suspend fun addContact(org.meshtastic.proto/SharedContact): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.addContact|addContact(org.meshtastic.proto.SharedContact){}[0] + abstract suspend fun backupPreferences(org.meshtastic.proto/AdminMessage.BackupLocation = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.backupPreferences|backupPreferences(org.meshtastic.proto.AdminMessage.BackupLocation){}[0] + abstract suspend fun deleteFile(kotlin/String): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.deleteFile|deleteFile(kotlin.String){}[0] + abstract suspend fun enterDfuMode(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.enterDfuMode|enterDfuMode(){}[0] + abstract suspend fun exitSimulator(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.exitSimulator|exitSimulator(){}[0] abstract suspend fun factoryReset(kotlin/Boolean = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.factoryReset|factoryReset(kotlin.Boolean){}[0] + abstract suspend fun getCannedMessages(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getCannedMessages|getCannedMessages(){}[0] abstract suspend fun getChannel(org.meshtastic.sdk/ChannelIndex): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getChannel|getChannel(org.meshtastic.sdk.ChannelIndex){}[0] abstract suspend fun getConfig(org.meshtastic.proto/AdminMessage.ConfigType): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getConfig|getConfig(org.meshtastic.proto.AdminMessage.ConfigType){}[0] + abstract suspend fun getDeviceConnectionStatus(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getDeviceConnectionStatus|getDeviceConnectionStatus(){}[0] + abstract suspend fun getDeviceMetadata(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getDeviceMetadata|getDeviceMetadata(){}[0] abstract suspend fun getModuleConfig(org.meshtastic.proto/AdminMessage.ModuleConfigType): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getModuleConfig|getModuleConfig(org.meshtastic.proto.AdminMessage.ModuleConfigType){}[0] abstract suspend fun getOwner(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getOwner|getOwner(){}[0] + abstract suspend fun getRemoteHardwarePins(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getRemoteHardwarePins|getRemoteHardwarePins(){}[0] + abstract suspend fun getRingtone(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getRingtone|getRingtone(){}[0] + abstract suspend fun getUIConfig(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.getUIConfig|getUIConfig(){}[0] + abstract suspend fun keyVerification(org.meshtastic.proto/KeyVerificationAdmin): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.keyVerification|keyVerification(org.meshtastic.proto.KeyVerificationAdmin){}[0] abstract suspend fun listChannels(): org.meshtastic.sdk/AdminResult> // org.meshtastic.sdk/AdminApi.listChannels|listChannels(){}[0] - abstract suspend fun nodeDbReset(kotlin/Boolean = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.nodeDbReset|nodeDbReset(kotlin.Boolean){}[0] + abstract suspend fun nodeDbReset(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.nodeDbReset|nodeDbReset(){}[0] + abstract suspend fun otaRequest(org.meshtastic.proto/AdminMessage.OTAEvent): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.otaRequest|otaRequest(org.meshtastic.proto.AdminMessage.OTAEvent){}[0] abstract suspend fun reboot(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.reboot|reboot(kotlin.time.Duration){}[0] + abstract suspend fun rebootOta(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.rebootOta|rebootOta(kotlin.time.Duration){}[0] + abstract suspend fun removeBackupPreferences(org.meshtastic.proto/AdminMessage.BackupLocation = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.removeBackupPreferences|removeBackupPreferences(org.meshtastic.proto.AdminMessage.BackupLocation){}[0] + abstract suspend fun removeFixedPosition(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.removeFixedPosition|removeFixedPosition(){}[0] + abstract suspend fun removeNode(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.removeNode|removeNode(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun restorePreferences(org.meshtastic.proto/AdminMessage.BackupLocation = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.restorePreferences|restorePreferences(org.meshtastic.proto.AdminMessage.BackupLocation){}[0] + abstract suspend fun sendInputEvent(org.meshtastic.proto/AdminMessage.InputEvent): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.sendInputEvent|sendInputEvent(org.meshtastic.proto.AdminMessage.InputEvent){}[0] + abstract suspend fun setCannedMessages(kotlin/String): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setCannedMessages|setCannedMessages(kotlin.String){}[0] abstract suspend fun setChannel(org.meshtastic.proto/Channel): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setChannel|setChannel(org.meshtastic.proto.Channel){}[0] abstract suspend fun setConfig(org.meshtastic.proto/Config): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setConfig|setConfig(org.meshtastic.proto.Config){}[0] abstract suspend fun setFavorite(org.meshtastic.sdk/NodeId, kotlin/Boolean): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setFavorite|setFavorite(org.meshtastic.sdk.NodeId;kotlin.Boolean){}[0] + abstract suspend fun setFixedPosition(org.meshtastic.proto/Position): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setFixedPosition|setFixedPosition(org.meshtastic.proto.Position){}[0] + abstract suspend fun setHamMode(org.meshtastic.proto/HamParameters): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setHamMode|setHamMode(org.meshtastic.proto.HamParameters){}[0] abstract suspend fun setIgnored(org.meshtastic.sdk/NodeId, kotlin/Boolean): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setIgnored|setIgnored(org.meshtastic.sdk.NodeId;kotlin.Boolean){}[0] abstract suspend fun setModuleConfig(org.meshtastic.proto/ModuleConfig): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setModuleConfig|setModuleConfig(org.meshtastic.proto.ModuleConfig){}[0] abstract suspend fun setOwner(org.meshtastic.proto/User): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setOwner|setOwner(org.meshtastic.proto.User){}[0] + abstract suspend fun setRingtone(kotlin/String): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setRingtone|setRingtone(kotlin.String){}[0] + abstract suspend fun setScale(kotlin/Int): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setScale|setScale(kotlin.Int){}[0] + abstract suspend fun setSensorConfig(org.meshtastic.proto/SensorConfig): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setSensorConfig|setSensorConfig(org.meshtastic.proto.SensorConfig){}[0] abstract suspend fun setTime(kotlin.time/Instant? = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setTime|setTime(kotlin.time.Instant?){}[0] + abstract suspend fun setTimeOnly(kotlin/Int): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.setTimeOnly|setTimeOnly(kotlin.Int){}[0] abstract suspend fun shutdown(kotlin.time/Duration = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.shutdown|shutdown(kotlin.time.Duration){}[0] + abstract suspend fun storeUIConfig(org.meshtastic.proto/DeviceUIConfig): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.storeUIConfig|storeUIConfig(org.meshtastic.proto.DeviceUIConfig){}[0] + abstract suspend fun toggleMuted(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/AdminApi.toggleMuted|toggleMuted(org.meshtastic.sdk.NodeId){}[0] +} + +abstract interface org.meshtastic.sdk/AdminBatchScope : org.meshtastic.sdk/AdminEdit { // org.meshtastic.sdk/AdminBatchScope|null[0] + abstract suspend fun getConfig(org.meshtastic.proto/AdminMessage.ConfigType): org.meshtastic.proto/Config // org.meshtastic.sdk/AdminBatchScope.getConfig|getConfig(org.meshtastic.proto.AdminMessage.ConfigType){}[0] + abstract suspend fun getModuleConfig(org.meshtastic.proto/AdminMessage.ModuleConfigType): org.meshtastic.proto/ModuleConfig // org.meshtastic.sdk/AdminBatchScope.getModuleConfig|getModuleConfig(org.meshtastic.proto.AdminMessage.ModuleConfigType){}[0] + abstract suspend fun listChannels(): kotlin.collections/List // org.meshtastic.sdk/AdminBatchScope.listChannels|listChannels(){}[0] } abstract interface org.meshtastic.sdk/AdminEdit { // org.meshtastic.sdk/AdminEdit|null[0] @@ -156,13 +243,26 @@ abstract interface org.meshtastic.sdk/StorageProvider { // org.meshtastic.sdk/St abstract suspend fun activate(org.meshtastic.sdk/TransportIdentity): org.meshtastic.sdk/DeviceStorage // org.meshtastic.sdk/StorageProvider.activate|activate(org.meshtastic.sdk.TransportIdentity){}[0] } +abstract interface org.meshtastic.sdk/StoreForwardApi { // org.meshtastic.sdk/StoreForwardApi|null[0] + abstract val events // org.meshtastic.sdk/StoreForwardApi.events|{}events[0] + abstract fun (): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/StoreForwardApi.events.|(){}[0] + abstract val servers // org.meshtastic.sdk/StoreForwardApi.servers|{}servers[0] + abstract fun (): kotlinx.coroutines.flow/StateFlow> // org.meshtastic.sdk/StoreForwardApi.servers.|(){}[0] + + abstract suspend fun requestHistory(kotlin/Int? = ..., org.meshtastic.sdk/NodeId? = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/StoreForwardApi.requestHistory|requestHistory(kotlin.Int?;org.meshtastic.sdk.NodeId?){}[0] + abstract suspend fun requestStats(org.meshtastic.sdk/NodeId? = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/StoreForwardApi.requestStats|requestStats(org.meshtastic.sdk.NodeId?){}[0] +} + abstract interface org.meshtastic.sdk/TelemetryApi { // org.meshtastic.sdk/TelemetryApi|null[0] abstract fun observe(org.meshtastic.sdk/NodeId): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/TelemetryApi.observe|observe(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestAirQuality(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestAirQuality|requestAirQuality(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestDevice(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestDevice|requestDevice(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestEnvironment(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestEnvironment|requestEnvironment(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun requestHealth(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestHealth|requestHealth(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun requestHost(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestHost|requestHost(org.meshtastic.sdk.NodeId){}[0] abstract suspend fun requestLocalStats(): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestLocalStats|requestLocalStats(){}[0] abstract suspend fun requestPower(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestPower|requestPower(org.meshtastic.sdk.NodeId){}[0] + abstract suspend fun requestTrafficManagement(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/TelemetryApi.requestTrafficManagement|requestTrafficManagement(org.meshtastic.sdk.NodeId){}[0] } sealed interface <#A: out kotlin/Any?> org.meshtastic.sdk/AdminResult { // org.meshtastic.sdk/AdminResult|null[0] @@ -198,6 +298,12 @@ sealed interface <#A: out kotlin/Any?> org.meshtastic.sdk/AdminResult { // org.m final fun toString(): kotlin/String // org.meshtastic.sdk/AdminResult.NodeUnreachable.toString|toString(){}[0] } + final object RateLimited : org.meshtastic.sdk/AdminResult { // org.meshtastic.sdk/AdminResult.RateLimited|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/AdminResult.RateLimited.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/AdminResult.RateLimited.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/AdminResult.RateLimited.toString|toString(){}[0] + } + final object SessionKeyExpired : org.meshtastic.sdk/AdminResult { // org.meshtastic.sdk/AdminResult.SessionKeyExpired|null[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/AdminResult.SessionKeyExpired.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/AdminResult.SessionKeyExpired.hashCode|hashCode(){}[0] @@ -291,6 +397,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| } } + final class CongestionWarning : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.CongestionWarning|null[0] + constructor (org.meshtastic.sdk/CongestionMetrics) // org.meshtastic.sdk/MeshEvent.CongestionWarning.|(org.meshtastic.sdk.CongestionMetrics){}[0] + + final val metrics // org.meshtastic.sdk/MeshEvent.CongestionWarning.metrics|{}metrics[0] + final fun (): org.meshtastic.sdk/CongestionMetrics // org.meshtastic.sdk/MeshEvent.CongestionWarning.metrics.|(){}[0] + + final fun component1(): org.meshtastic.sdk/CongestionMetrics // org.meshtastic.sdk/MeshEvent.CongestionWarning.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/CongestionMetrics = ...): org.meshtastic.sdk/MeshEvent.CongestionWarning // org.meshtastic.sdk/MeshEvent.CongestionWarning.copy|copy(org.meshtastic.sdk.CongestionMetrics){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.CongestionWarning.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.CongestionWarning.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.CongestionWarning.toString|toString(){}[0] + } + final class DeviceRebooted : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.DeviceRebooted|null[0] constructor (kotlin/String = ...) // org.meshtastic.sdk/MeshEvent.DeviceRebooted.|(kotlin.String){}[0] @@ -304,6 +423,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.DeviceRebooted.toString|toString(){}[0] } + final class ExternalConfigChange : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.ExternalConfigChange|null[0] + constructor (org.meshtastic.sdk/ExternalChangeKind) // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.|(org.meshtastic.sdk.ExternalChangeKind){}[0] + + final val kind // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.kind|{}kind[0] + final fun (): org.meshtastic.sdk/ExternalChangeKind // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.kind.|(){}[0] + + final fun component1(): org.meshtastic.sdk/ExternalChangeKind // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/ExternalChangeKind = ...): org.meshtastic.sdk/MeshEvent.ExternalConfigChange // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.copy|copy(org.meshtastic.sdk.ExternalChangeKind){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.ExternalConfigChange.toString|toString(){}[0] + } + final class IdentityRebound : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.IdentityRebound|null[0] constructor (org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin/String = ...) // org.meshtastic.sdk/MeshEvent.IdentityRebound.|(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.String){}[0] @@ -336,6 +468,19 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.KeyVerification.toString|toString(){}[0] } + final class MqttDisconnected : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttDisconnected|null[0] + constructor (kotlin/String? = ...) // org.meshtastic.sdk/MeshEvent.MqttDisconnected.|(kotlin.String?){}[0] + + final val reason // org.meshtastic.sdk/MeshEvent.MqttDisconnected.reason|{}reason[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshEvent.MqttDisconnected.reason.|(){}[0] + + final fun component1(): kotlin/String? // org.meshtastic.sdk/MeshEvent.MqttDisconnected.component1|component1(){}[0] + final fun copy(kotlin/String? = ...): org.meshtastic.sdk/MeshEvent.MqttDisconnected // org.meshtastic.sdk/MeshEvent.MqttDisconnected.copy|copy(kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.MqttDisconnected.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.MqttDisconnected.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.MqttDisconnected.toString|toString(){}[0] + } + final class Notification : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.Notification|null[0] constructor (org.meshtastic.proto/ClientNotification) // org.meshtastic.sdk/MeshEvent.Notification.|(org.meshtastic.proto.ClientNotification){}[0] @@ -419,6 +564,12 @@ sealed interface org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent| final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.TransportError.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.TransportError.toString|toString(){}[0] } + + final object MqttConnected : org.meshtastic.sdk/MeshEvent { // org.meshtastic.sdk/MeshEvent.MqttConnected|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshEvent.MqttConnected.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshEvent.MqttConnected.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshEvent.MqttConnected.toString|toString(){}[0] + } } sealed interface org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange|null[0] @@ -435,6 +586,19 @@ sealed interface org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChang final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.Added.toString|toString(){}[0] } + final class CameOnline : org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange.CameOnline|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/NodeChange.CameOnline.|(org.meshtastic.sdk.NodeId){}[0] + + final val nodeId // org.meshtastic.sdk/NodeChange.CameOnline.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.CameOnline.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.CameOnline.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/NodeChange.CameOnline // org.meshtastic.sdk/NodeChange.CameOnline.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NodeChange.CameOnline.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NodeChange.CameOnline.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.CameOnline.toString|toString(){}[0] + } + final class Removed : org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange.Removed|null[0] constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/NodeChange.Removed.|(org.meshtastic.sdk.NodeId){}[0] @@ -476,6 +640,22 @@ sealed interface org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChang final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NodeChange.Updated.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.Updated.toString|toString(){}[0] } + + final class WentOffline : org.meshtastic.sdk/NodeChange { // org.meshtastic.sdk/NodeChange.WentOffline|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Int) // org.meshtastic.sdk/NodeChange.WentOffline.|(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + + final val lastHeard // org.meshtastic.sdk/NodeChange.WentOffline.lastHeard|{}lastHeard[0] + final fun (): kotlin/Int // org.meshtastic.sdk/NodeChange.WentOffline.lastHeard.|(){}[0] + final val nodeId // org.meshtastic.sdk/NodeChange.WentOffline.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.WentOffline.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NodeChange.WentOffline.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/NodeChange.WentOffline.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/NodeChange.WentOffline // org.meshtastic.sdk/NodeChange.WentOffline.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NodeChange.WentOffline.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NodeChange.WentOffline.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NodeChange.WentOffline.toString|toString(){}[0] + } } sealed interface org.meshtastic.sdk/SendFailure { // org.meshtastic.sdk/SendFailure|null[0] @@ -620,6 +800,120 @@ sealed interface org.meshtastic.sdk/SendState { // org.meshtastic.sdk/SendState| } } +sealed interface org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent|null[0] + final class Heartbeat : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.Heartbeat|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.|(org.meshtastic.sdk.NodeId){}[0] + + final val server // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.server|{}server[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.server.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/StoreForwardEvent.Heartbeat // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.Heartbeat.toString|toString(){}[0] + } + + final class HistoryReplayComplete : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Int) // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.|(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + + final val delivered // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.delivered|{}delivered[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.delivered.|(){}[0] + final val server // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.server|{}server[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.server.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayComplete.toString|toString(){}[0] + } + + final class HistoryReplayStarted : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Int) // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.|(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + + final val messageCount // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.messageCount|{}messageCount[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.messageCount.|(){}[0] + final val server // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.server|{}server[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.server.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Int = ...): org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.HistoryReplayStarted.toString|toString(){}[0] + } + + final class ServerDiscovered : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.|(org.meshtastic.sdk.NodeId){}[0] + + final val nodeId // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.ServerDiscovered.toString|toString(){}[0] + } + + final class ServerLost : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.ServerLost|null[0] + constructor (org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/StoreForwardEvent.ServerLost.|(org.meshtastic.sdk.NodeId){}[0] + + final val nodeId // org.meshtastic.sdk/StoreForwardEvent.ServerLost.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerLost.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/StoreForwardEvent.ServerLost.component1|component1(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/StoreForwardEvent.ServerLost // org.meshtastic.sdk/StoreForwardEvent.ServerLost.copy|copy(org.meshtastic.sdk.NodeId){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.ServerLost.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.ServerLost.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.ServerLost.toString|toString(){}[0] + } + + final class SfppCanonAnnounced : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced|null[0] + constructor (kotlin/ByteArray, kotlin/Long) // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.|(kotlin.ByteArray;kotlin.Long){}[0] + + final val messageHash // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.messageHash|{}messageHash[0] + final fun (): kotlin/ByteArray // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.messageHash.|(){}[0] + final val rxTime // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.rxTime|{}rxTime[0] + final fun (): kotlin/Long // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.rxTime.|(){}[0] + + final fun component1(): kotlin/ByteArray // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.component1|component1(){}[0] + final fun component2(): kotlin/Long // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.component2|component2(){}[0] + final fun copy(kotlin/ByteArray = ..., kotlin/Long = ...): org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.copy|copy(kotlin.ByteArray;kotlin.Long){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.SfppCanonAnnounced.toString|toString(){}[0] + } + + final class SfppLinkProvided : org.meshtastic.sdk/StoreForwardEvent { // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided|null[0] + constructor (kotlin/Int, kotlin/Int, kotlin/Int, kotlin/ByteArray?, kotlin/Boolean) // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.|(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.ByteArray?;kotlin.Boolean){}[0] + + final val confirmed // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.confirmed|{}confirmed[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.confirmed.|(){}[0] + final val from // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.from|{}from[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.from.|(){}[0] + final val messageHash // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.messageHash|{}messageHash[0] + final fun (): kotlin/ByteArray? // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.messageHash.|(){}[0] + final val packetId // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.packetId|{}packetId[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.packetId.|(){}[0] + final val to // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.to|{}to[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.to.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component2|component2(){}[0] + final fun component3(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component3|component3(){}[0] + final fun component4(): kotlin/ByteArray? // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.component5|component5(){}[0] + final fun copy(kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/ByteArray? = ..., kotlin/Boolean = ...): org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.copy|copy(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.ByteArray?;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardEvent.SfppLinkProvided.toString|toString(){}[0] + } +} + sealed interface org.meshtastic.sdk/TransportSpec { // org.meshtastic.sdk/TransportSpec|null[0] open val identity // org.meshtastic.sdk/TransportSpec.identity|{}identity[0] open fun (): org.meshtastic.sdk/TransportIdentity // org.meshtastic.sdk/TransportSpec.identity.|(){}[0] @@ -788,10 +1082,12 @@ final class org.meshtastic.sdk/BatteryStatus { // org.meshtastic.sdk/BatteryStat } final class org.meshtastic.sdk/ConfigBundle { // org.meshtastic.sdk/ConfigBundle|null[0] - constructor (org.meshtastic.proto/MyNodeInfo, org.meshtastic.proto/DeviceMetadata, kotlin.collections/List, kotlin.collections/List) // org.meshtastic.sdk/ConfigBundle.|(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List){}[0] + constructor (org.meshtastic.proto/MyNodeInfo, org.meshtastic.proto/DeviceMetadata, kotlin.collections/List, kotlin.collections/List, org.meshtastic.proto/DeviceUIConfig? = ...) // org.meshtastic.sdk/ConfigBundle.|(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List;org.meshtastic.proto.DeviceUIConfig?){}[0] final val configs // org.meshtastic.sdk/ConfigBundle.configs|{}configs[0] final fun (): kotlin.collections/List // org.meshtastic.sdk/ConfigBundle.configs.|(){}[0] + final val deviceUIConfig // org.meshtastic.sdk/ConfigBundle.deviceUIConfig|{}deviceUIConfig[0] + final fun (): org.meshtastic.proto/DeviceUIConfig? // org.meshtastic.sdk/ConfigBundle.deviceUIConfig.|(){}[0] final val metadata // org.meshtastic.sdk/ConfigBundle.metadata|{}metadata[0] final fun (): org.meshtastic.proto/DeviceMetadata // org.meshtastic.sdk/ConfigBundle.metadata.|(){}[0] final val moduleConfigs // org.meshtastic.sdk/ConfigBundle.moduleConfigs|{}moduleConfigs[0] @@ -803,12 +1099,100 @@ final class org.meshtastic.sdk/ConfigBundle { // org.meshtastic.sdk/ConfigBundle final fun component2(): org.meshtastic.proto/DeviceMetadata // org.meshtastic.sdk/ConfigBundle.component2|component2(){}[0] final fun component3(): kotlin.collections/List // org.meshtastic.sdk/ConfigBundle.component3|component3(){}[0] final fun component4(): kotlin.collections/List // org.meshtastic.sdk/ConfigBundle.component4|component4(){}[0] - final fun copy(org.meshtastic.proto/MyNodeInfo = ..., org.meshtastic.proto/DeviceMetadata = ..., kotlin.collections/List = ..., kotlin.collections/List = ...): org.meshtastic.sdk/ConfigBundle // org.meshtastic.sdk/ConfigBundle.copy|copy(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List){}[0] + final fun component5(): org.meshtastic.proto/DeviceUIConfig? // org.meshtastic.sdk/ConfigBundle.component5|component5(){}[0] + final fun copy(org.meshtastic.proto/MyNodeInfo = ..., org.meshtastic.proto/DeviceMetadata = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., org.meshtastic.proto/DeviceUIConfig? = ...): org.meshtastic.sdk/ConfigBundle // org.meshtastic.sdk/ConfigBundle.copy|copy(org.meshtastic.proto.MyNodeInfo;org.meshtastic.proto.DeviceMetadata;kotlin.collections.List;kotlin.collections.List;org.meshtastic.proto.DeviceUIConfig?){}[0] final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/ConfigBundle.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // org.meshtastic.sdk/ConfigBundle.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // org.meshtastic.sdk/ConfigBundle.toString|toString(){}[0] } +final class org.meshtastic.sdk/CongestionMetrics { // org.meshtastic.sdk/CongestionMetrics|null[0] + constructor (kotlin/Float, kotlin/Float) // org.meshtastic.sdk/CongestionMetrics.|(kotlin.Float;kotlin.Float){}[0] + + final val airUtilTx // org.meshtastic.sdk/CongestionMetrics.airUtilTx|{}airUtilTx[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.airUtilTx.|(){}[0] + final val canSendNonUrgent // org.meshtastic.sdk/CongestionMetrics.canSendNonUrgent|{}canSendNonUrgent[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/CongestionMetrics.canSendNonUrgent.|(){}[0] + final val channelUtil // org.meshtastic.sdk/CongestionMetrics.channelUtil|{}channelUtil[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.channelUtil.|(){}[0] + final val level // org.meshtastic.sdk/CongestionMetrics.level|{}level[0] + final fun (): org.meshtastic.sdk/CongestionLevel // org.meshtastic.sdk/CongestionMetrics.level.|(){}[0] + final val suggestedBackoff // org.meshtastic.sdk/CongestionMetrics.suggestedBackoff|{}suggestedBackoff[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/CongestionMetrics.suggestedBackoff.|(){}[0] + + final fun component1(): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.component1|component1(){}[0] + final fun component2(): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.component2|component2(){}[0] + final fun copy(kotlin/Float = ..., kotlin/Float = ...): org.meshtastic.sdk/CongestionMetrics // org.meshtastic.sdk/CongestionMetrics.copy|copy(kotlin.Float;kotlin.Float){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/CongestionMetrics.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/CongestionMetrics.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/CongestionMetrics.toString|toString(){}[0] + + final object Companion { // org.meshtastic.sdk/CongestionMetrics.Companion|null[0] + final const val CRITICAL_THRESHOLD // org.meshtastic.sdk/CongestionMetrics.Companion.CRITICAL_THRESHOLD|{}CRITICAL_THRESHOLD[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.Companion.CRITICAL_THRESHOLD.|(){}[0] + final const val HIGH_THRESHOLD // org.meshtastic.sdk/CongestionMetrics.Companion.HIGH_THRESHOLD|{}HIGH_THRESHOLD[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.Companion.HIGH_THRESHOLD.|(){}[0] + final const val MEDIUM_THRESHOLD // org.meshtastic.sdk/CongestionMetrics.Companion.MEDIUM_THRESHOLD|{}MEDIUM_THRESHOLD[0] + final fun (): kotlin/Float // org.meshtastic.sdk/CongestionMetrics.Companion.MEDIUM_THRESHOLD.|(){}[0] + } +} + +final class org.meshtastic.sdk/DeviceCapabilities { // org.meshtastic.sdk/DeviceCapabilities|null[0] + constructor (kotlin/String?) // org.meshtastic.sdk/DeviceCapabilities.|(kotlin.String?){}[0] + + final val canMuteNode // org.meshtastic.sdk/DeviceCapabilities.canMuteNode|{}canMuteNode[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canMuteNode.|(){}[0] + final val canSendVerifiedContacts // org.meshtastic.sdk/DeviceCapabilities.canSendVerifiedContacts|{}canSendVerifiedContacts[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canSendVerifiedContacts.|(){}[0] + final val canToggleTelemetryEnabled // org.meshtastic.sdk/DeviceCapabilities.canToggleTelemetryEnabled|{}canToggleTelemetryEnabled[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canToggleTelemetryEnabled.|(){}[0] + final val canToggleUnmessageable // org.meshtastic.sdk/DeviceCapabilities.canToggleUnmessageable|{}canToggleUnmessageable[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.canToggleUnmessageable.|(){}[0] + final val firmwareVersion // org.meshtastic.sdk/DeviceCapabilities.firmwareVersion|{}firmwareVersion[0] + final fun (): kotlin/String? // org.meshtastic.sdk/DeviceCapabilities.firmwareVersion.|(){}[0] + final val supportsEsp32Ota // org.meshtastic.sdk/DeviceCapabilities.supportsEsp32Ota|{}supportsEsp32Ota[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsEsp32Ota.|(){}[0] + final val supportsQrCodeSharing // org.meshtastic.sdk/DeviceCapabilities.supportsQrCodeSharing|{}supportsQrCodeSharing[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsQrCodeSharing.|(){}[0] + final val supportsSecondaryChannelLocation // org.meshtastic.sdk/DeviceCapabilities.supportsSecondaryChannelLocation|{}supportsSecondaryChannelLocation[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsSecondaryChannelLocation.|(){}[0] + final val supportsStatusMessage // org.meshtastic.sdk/DeviceCapabilities.supportsStatusMessage|{}supportsStatusMessage[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsStatusMessage.|(){}[0] + final val supportsTakConfig // org.meshtastic.sdk/DeviceCapabilities.supportsTakConfig|{}supportsTakConfig[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsTakConfig.|(){}[0] + final val supportsTrafficManagementConfig // org.meshtastic.sdk/DeviceCapabilities.supportsTrafficManagementConfig|{}supportsTrafficManagementConfig[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.supportsTrafficManagementConfig.|(){}[0] + + final fun component1(): kotlin/String? // org.meshtastic.sdk/DeviceCapabilities.component1|component1(){}[0] + final fun copy(kotlin/String? = ...): org.meshtastic.sdk/DeviceCapabilities // org.meshtastic.sdk/DeviceCapabilities.copy|copy(kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/DeviceCapabilities.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/DeviceCapabilities.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/DeviceCapabilities.toString|toString(){}[0] +} + +final class org.meshtastic.sdk/DeviceVersion : kotlin/Comparable { // org.meshtastic.sdk/DeviceVersion|null[0] + constructor (kotlin/String) // org.meshtastic.sdk/DeviceVersion.|(kotlin.String){}[0] + + final val asInt // org.meshtastic.sdk/DeviceVersion.asInt|{}asInt[0] + final fun (): kotlin/Int // org.meshtastic.sdk/DeviceVersion.asInt.|(){}[0] + final val versionString // org.meshtastic.sdk/DeviceVersion.versionString|{}versionString[0] + final fun (): kotlin/String // org.meshtastic.sdk/DeviceVersion.versionString.|(){}[0] + + final fun compareTo(org.meshtastic.sdk/DeviceVersion): kotlin/Int // org.meshtastic.sdk/DeviceVersion.compareTo|compareTo(org.meshtastic.sdk.DeviceVersion){}[0] + final fun component1(): kotlin/String // org.meshtastic.sdk/DeviceVersion.component1|component1(){}[0] + final fun copy(kotlin/String = ...): org.meshtastic.sdk/DeviceVersion // org.meshtastic.sdk/DeviceVersion.copy|copy(kotlin.String){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/DeviceVersion.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/DeviceVersion.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/DeviceVersion.toString|toString(){}[0] + + final object Companion { // org.meshtastic.sdk/DeviceVersion.Companion|null[0] + final val ABS_MIN_SUPPORTED // org.meshtastic.sdk/DeviceVersion.Companion.ABS_MIN_SUPPORTED|{}ABS_MIN_SUPPORTED[0] + final fun (): org.meshtastic.sdk/DeviceVersion // org.meshtastic.sdk/DeviceVersion.Companion.ABS_MIN_SUPPORTED.|(){}[0] + final val MIN_SUPPORTED // org.meshtastic.sdk/DeviceVersion.Companion.MIN_SUPPORTED|{}MIN_SUPPORTED[0] + final fun (): org.meshtastic.sdk/DeviceVersion // org.meshtastic.sdk/DeviceVersion.Companion.MIN_SUPPORTED.|(){}[0] + } +} + final class org.meshtastic.sdk/Frame { // org.meshtastic.sdk/Frame|null[0] constructor (kotlinx.io.bytestring/ByteString) // org.meshtastic.sdk/Frame.|(kotlinx.io.bytestring.ByteString){}[0] @@ -841,6 +1225,111 @@ final class org.meshtastic.sdk/LatLng { // org.meshtastic.sdk/LatLng|null[0] final fun toString(): kotlin/String // org.meshtastic.sdk/LatLng.toString|toString(){}[0] } +final class org.meshtastic.sdk/MeshNode { // org.meshtastic.sdk/MeshNode|null[0] + constructor (org.meshtastic.proto/NodeInfo, kotlin/Boolean, org.meshtastic.sdk/ConnectionQuality, org.meshtastic.sdk/SignalQuality) // org.meshtastic.sdk/MeshNode.|(org.meshtastic.proto.NodeInfo;kotlin.Boolean;org.meshtastic.sdk.ConnectionQuality;org.meshtastic.sdk.SignalQuality){}[0] + + final val airUtilTx // org.meshtastic.sdk/MeshNode.airUtilTx|{}airUtilTx[0] + final fun (): kotlin/Float? // org.meshtastic.sdk/MeshNode.airUtilTx.|(){}[0] + final val altitude // org.meshtastic.sdk/MeshNode.altitude|{}altitude[0] + final fun (): kotlin/Int? // org.meshtastic.sdk/MeshNode.altitude.|(){}[0] + final val batteryLevel // org.meshtastic.sdk/MeshNode.batteryLevel|{}batteryLevel[0] + final fun (): kotlin/Int? // org.meshtastic.sdk/MeshNode.batteryLevel.|(){}[0] + final val channelUtilization // org.meshtastic.sdk/MeshNode.channelUtilization|{}channelUtilization[0] + final fun (): kotlin/Float? // org.meshtastic.sdk/MeshNode.channelUtilization.|(){}[0] + final val connectionQuality // org.meshtastic.sdk/MeshNode.connectionQuality|{}connectionQuality[0] + final fun (): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/MeshNode.connectionQuality.|(){}[0] + final val deviceMetrics // org.meshtastic.sdk/MeshNode.deviceMetrics|{}deviceMetrics[0] + final fun (): org.meshtastic.proto/DeviceMetrics? // org.meshtastic.sdk/MeshNode.deviceMetrics.|(){}[0] + final val hopsAway // org.meshtastic.sdk/MeshNode.hopsAway|{}hopsAway[0] + final fun (): kotlin/Int? // org.meshtastic.sdk/MeshNode.hopsAway.|(){}[0] + final val hwModel // org.meshtastic.sdk/MeshNode.hwModel|{}hwModel[0] + final fun (): org.meshtastic.proto/HardwareModel? // org.meshtastic.sdk/MeshNode.hwModel.|(){}[0] + final val isFavorite // org.meshtastic.sdk/MeshNode.isFavorite|{}isFavorite[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isFavorite.|(){}[0] + final val isIgnored // org.meshtastic.sdk/MeshNode.isIgnored|{}isIgnored[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isIgnored.|(){}[0] + final val isMuted // org.meshtastic.sdk/MeshNode.isMuted|{}isMuted[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isMuted.|(){}[0] + final val isOnline // org.meshtastic.sdk/MeshNode.isOnline|{}isOnline[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.isOnline.|(){}[0] + final val lastHeard // org.meshtastic.sdk/MeshNode.lastHeard|{}lastHeard[0] + final fun (): kotlin/Int // org.meshtastic.sdk/MeshNode.lastHeard.|(){}[0] + final val latitude // org.meshtastic.sdk/MeshNode.latitude|{}latitude[0] + final fun (): kotlin/Double? // org.meshtastic.sdk/MeshNode.latitude.|(){}[0] + final val longName // org.meshtastic.sdk/MeshNode.longName|{}longName[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshNode.longName.|(){}[0] + final val longitude // org.meshtastic.sdk/MeshNode.longitude|{}longitude[0] + final fun (): kotlin/Double? // org.meshtastic.sdk/MeshNode.longitude.|(){}[0] + final val meshId // org.meshtastic.sdk/MeshNode.meshId|{}meshId[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshNode.meshId.|(){}[0] + final val nodeId // org.meshtastic.sdk/MeshNode.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshNode.nodeId.|(){}[0] + final val nodeNum // org.meshtastic.sdk/MeshNode.nodeNum|{}nodeNum[0] + final fun (): kotlin/Int // org.meshtastic.sdk/MeshNode.nodeNum.|(){}[0] + final val position // org.meshtastic.sdk/MeshNode.position|{}position[0] + final fun (): org.meshtastic.proto/Position? // org.meshtastic.sdk/MeshNode.position.|(){}[0] + final val raw // org.meshtastic.sdk/MeshNode.raw|{}raw[0] + final fun (): org.meshtastic.proto/NodeInfo // org.meshtastic.sdk/MeshNode.raw.|(){}[0] + final val shortName // org.meshtastic.sdk/MeshNode.shortName|{}shortName[0] + final fun (): kotlin/String? // org.meshtastic.sdk/MeshNode.shortName.|(){}[0] + final val signalQuality // org.meshtastic.sdk/MeshNode.signalQuality|{}signalQuality[0] + final fun (): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/MeshNode.signalQuality.|(){}[0] + final val snr // org.meshtastic.sdk/MeshNode.snr|{}snr[0] + final fun (): kotlin/Float // org.meshtastic.sdk/MeshNode.snr.|(){}[0] + final val user // org.meshtastic.sdk/MeshNode.user|{}user[0] + final fun (): org.meshtastic.proto/User? // org.meshtastic.sdk/MeshNode.user.|(){}[0] + final val viaMqtt // org.meshtastic.sdk/MeshNode.viaMqtt|{}viaMqtt[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/MeshNode.viaMqtt.|(){}[0] + final val voltage // org.meshtastic.sdk/MeshNode.voltage|{}voltage[0] + final fun (): kotlin/Float? // org.meshtastic.sdk/MeshNode.voltage.|(){}[0] + + final fun component1(): org.meshtastic.proto/NodeInfo // org.meshtastic.sdk/MeshNode.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // org.meshtastic.sdk/MeshNode.component2|component2(){}[0] + final fun component3(): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/MeshNode.component3|component3(){}[0] + final fun component4(): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/MeshNode.component4|component4(){}[0] + final fun copy(org.meshtastic.proto/NodeInfo = ..., kotlin/Boolean = ..., org.meshtastic.sdk/ConnectionQuality = ..., org.meshtastic.sdk/SignalQuality = ...): org.meshtastic.sdk/MeshNode // org.meshtastic.sdk/MeshNode.copy|copy(org.meshtastic.proto.NodeInfo;kotlin.Boolean;org.meshtastic.sdk.ConnectionQuality;org.meshtastic.sdk.SignalQuality){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshNode.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshNode.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshNode.toString|toString(){}[0] +} + +final class org.meshtastic.sdk/MeshTopology { // org.meshtastic.sdk/MeshTopology|null[0] + constructor () // org.meshtastic.sdk/MeshTopology.|(){}[0] + + final suspend fun addNeighborInfo(org.meshtastic.sdk/NeighborInfo) // org.meshtastic.sdk/MeshTopology.addNeighborInfo|addNeighborInfo(org.meshtastic.sdk.NeighborInfo){}[0] + final suspend fun allEdges(): kotlin.collections/List // org.meshtastic.sdk/MeshTopology.allEdges|allEdges(){}[0] + final suspend fun clear() // org.meshtastic.sdk/MeshTopology.clear|clear(){}[0] + final suspend fun edgeCount(): kotlin/Int // org.meshtastic.sdk/MeshTopology.edgeCount|edgeCount(){}[0] + final suspend fun getEdge(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId): org.meshtastic.sdk/MeshTopology.Edge? // org.meshtastic.sdk/MeshTopology.getEdge|getEdge(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId){}[0] + final suspend fun getNeighbors(org.meshtastic.sdk/NodeId): kotlin.collections/List // org.meshtastic.sdk/MeshTopology.getNeighbors|getNeighbors(org.meshtastic.sdk.NodeId){}[0] + final suspend fun isDirectReach(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId): kotlin/Boolean // org.meshtastic.sdk/MeshTopology.isDirectReach|isDirectReach(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId){}[0] + final suspend fun nodes(): kotlin.collections/Set // org.meshtastic.sdk/MeshTopology.nodes|nodes(){}[0] + final suspend fun removeNode(org.meshtastic.sdk/NodeId) // org.meshtastic.sdk/MeshTopology.removeNode|removeNode(org.meshtastic.sdk.NodeId){}[0] + final suspend fun shortestPath(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId): kotlin.collections/List // org.meshtastic.sdk/MeshTopology.shortestPath|shortestPath(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId){}[0] + + final class Edge { // org.meshtastic.sdk/MeshTopology.Edge|null[0] + constructor (org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin/Float, kotlin/Int = ...) // org.meshtastic.sdk/MeshTopology.Edge.|(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.Float;kotlin.Int){}[0] + + final val from // org.meshtastic.sdk/MeshTopology.Edge.from|{}from[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.from.|(){}[0] + final val lastUpdated // org.meshtastic.sdk/MeshTopology.Edge.lastUpdated|{}lastUpdated[0] + final fun (): kotlin/Int // org.meshtastic.sdk/MeshTopology.Edge.lastUpdated.|(){}[0] + final val snr // org.meshtastic.sdk/MeshTopology.Edge.snr|{}snr[0] + final fun (): kotlin/Float // org.meshtastic.sdk/MeshTopology.Edge.snr.|(){}[0] + final val to // org.meshtastic.sdk/MeshTopology.Edge.to|{}to[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.to.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.component1|component1(){}[0] + final fun component2(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/MeshTopology.Edge.component2|component2(){}[0] + final fun component3(): kotlin/Float // org.meshtastic.sdk/MeshTopology.Edge.component3|component3(){}[0] + final fun component4(): kotlin/Int // org.meshtastic.sdk/MeshTopology.Edge.component4|component4(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/NodeId = ..., kotlin/Float = ..., kotlin/Int = ...): org.meshtastic.sdk/MeshTopology.Edge // org.meshtastic.sdk/MeshTopology.Edge.copy|copy(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.Float;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/MeshTopology.Edge.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/MeshTopology.Edge.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/MeshTopology.Edge.toString|toString(){}[0] + } +} + final class org.meshtastic.sdk/MessageHandle { // org.meshtastic.sdk/MessageHandle|null[0] final val id // org.meshtastic.sdk/MessageHandle.id|{}id[0] final fun (): org.meshtastic.sdk/MessageId // org.meshtastic.sdk/MessageHandle.id.|(){}[0] @@ -851,9 +1340,51 @@ final class org.meshtastic.sdk/MessageHandle { // org.meshtastic.sdk/MessageHand final suspend fun await(): org.meshtastic.sdk/SendOutcome // org.meshtastic.sdk/MessageHandle.await|await(){}[0] } +final class org.meshtastic.sdk/NeighborInfo { // org.meshtastic.sdk/NeighborInfo|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin.collections/List, kotlin/Int = ...) // org.meshtastic.sdk/NeighborInfo.|(org.meshtastic.sdk.NodeId;kotlin.collections.List;kotlin.Int){}[0] + + final val lastUpdated // org.meshtastic.sdk/NeighborInfo.lastUpdated|{}lastUpdated[0] + final fun (): kotlin/Int // org.meshtastic.sdk/NeighborInfo.lastUpdated.|(){}[0] + final val neighbors // org.meshtastic.sdk/NeighborInfo.neighbors|{}neighbors[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/NeighborInfo.neighbors.|(){}[0] + final val nodeId // org.meshtastic.sdk/NeighborInfo.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.nodeId.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // org.meshtastic.sdk/NeighborInfo.component2|component2(){}[0] + final fun component3(): kotlin/Int // org.meshtastic.sdk/NeighborInfo.component3|component3(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin.collections/List = ..., kotlin/Int = ...): org.meshtastic.sdk/NeighborInfo // org.meshtastic.sdk/NeighborInfo.copy|copy(org.meshtastic.sdk.NodeId;kotlin.collections.List;kotlin.Int){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NeighborInfo.equals|equals(kotlin.Any?){}[0] + final fun format(kotlin/Function1 = ...): kotlin/String // org.meshtastic.sdk/NeighborInfo.format|format(kotlin.Function1){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NeighborInfo.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NeighborInfo.toString|toString(){}[0] + + final class Neighbor { // org.meshtastic.sdk/NeighborInfo.Neighbor|null[0] + constructor (org.meshtastic.sdk/NodeId, kotlin/Float) // org.meshtastic.sdk/NeighborInfo.Neighbor.|(org.meshtastic.sdk.NodeId;kotlin.Float){}[0] + + final val nodeId // org.meshtastic.sdk/NeighborInfo.Neighbor.nodeId|{}nodeId[0] + final fun (): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.Neighbor.nodeId.|(){}[0] + final val snr // org.meshtastic.sdk/NeighborInfo.Neighbor.snr|{}snr[0] + final fun (): kotlin/Float // org.meshtastic.sdk/NeighborInfo.Neighbor.snr.|(){}[0] + + final fun component1(): org.meshtastic.sdk/NodeId // org.meshtastic.sdk/NeighborInfo.Neighbor.component1|component1(){}[0] + final fun component2(): kotlin/Float // org.meshtastic.sdk/NeighborInfo.Neighbor.component2|component2(){}[0] + final fun copy(org.meshtastic.sdk/NodeId = ..., kotlin/Float = ...): org.meshtastic.sdk/NeighborInfo.Neighbor // org.meshtastic.sdk/NeighborInfo.Neighbor.copy|copy(org.meshtastic.sdk.NodeId;kotlin.Float){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/NeighborInfo.Neighbor.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/NeighborInfo.Neighbor.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/NeighborInfo.Neighbor.toString|toString(){}[0] + } + + final object Companion { // org.meshtastic.sdk/NeighborInfo.Companion|null[0] + final fun fromProto(kotlin/Int, kotlin.collections/List, kotlin.collections/List, kotlin/Int = ...): org.meshtastic.sdk/NeighborInfo // org.meshtastic.sdk/NeighborInfo.Companion.fromProto|fromProto(kotlin.Int;kotlin.collections.List;kotlin.collections.List;kotlin.Int){}[0] + } +} + final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.meshtastic.sdk/RadioClient|null[0] final val admin // org.meshtastic.sdk/RadioClient.admin|{}admin[0] final fun (): org.meshtastic.sdk/AdminApi // org.meshtastic.sdk/RadioClient.admin.|(){}[0] + final val channels // org.meshtastic.sdk/RadioClient.channels|{}channels[0] + final fun (): kotlinx.coroutines.flow/StateFlow?> // org.meshtastic.sdk/RadioClient.channels.|(){}[0] final val configBundle // org.meshtastic.sdk/RadioClient.configBundle|{}configBundle[0] final fun (): kotlinx.coroutines.flow/StateFlow // org.meshtastic.sdk/RadioClient.configBundle.|(){}[0] final val connection // org.meshtastic.sdk/RadioClient.connection|{}connection[0] @@ -868,11 +1399,16 @@ final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.mesht final fun (): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/RadioClient.packets.|(){}[0] final val routing // org.meshtastic.sdk/RadioClient.routing|{}routing[0] final fun (): org.meshtastic.sdk/RoutingApi // org.meshtastic.sdk/RadioClient.routing.|(){}[0] + final val storeForward // org.meshtastic.sdk/RadioClient.storeForward|{}storeForward[0] + final fun (): org.meshtastic.sdk/StoreForwardApi // org.meshtastic.sdk/RadioClient.storeForward.|(){}[0] final val telemetry // org.meshtastic.sdk/RadioClient.telemetry|{}telemetry[0] final fun (): org.meshtastic.sdk/TelemetryApi // org.meshtastic.sdk/RadioClient.telemetry.|(){}[0] final fun close() // org.meshtastic.sdk/RadioClient.close|close(){}[0] + final fun requestNodeInfo(org.meshtastic.sdk/NodeId): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.requestNodeInfo|requestNodeInfo(org.meshtastic.sdk.NodeId){}[0] final fun send(org.meshtastic.proto/MeshPacket): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.send|send(org.meshtastic.proto.MeshPacket){}[0] + final fun sendRaw(org.meshtastic.proto/ToRadio) // org.meshtastic.sdk/RadioClient.sendRaw|sendRaw(org.meshtastic.proto.ToRadio){}[0] + final fun sendReaction(kotlin/String, org.meshtastic.sdk/NodeId = ..., org.meshtastic.sdk/ChannelIndex = ..., kotlin/Int): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendReaction|sendReaction(kotlin.String;org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex;kotlin.Int){}[0] final fun sendText(kotlin/String, org.meshtastic.sdk/ChannelIndex = ..., org.meshtastic.sdk/NodeId = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/RadioClient.sendText|sendText(kotlin.String;org.meshtastic.sdk.ChannelIndex;org.meshtastic.sdk.NodeId){}[0] final suspend fun connect() // org.meshtastic.sdk/RadioClient.connect|connect(){}[0] final suspend fun disconnect() // org.meshtastic.sdk/RadioClient.disconnect|disconnect(){}[0] @@ -891,6 +1427,7 @@ final class org.meshtastic.sdk/RadioClient : kotlin/AutoCloseable { // org.mesht final fun coroutineContext(kotlin.coroutines/CoroutineContext): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.coroutineContext|coroutineContext(kotlin.coroutines.CoroutineContext){}[0] final fun disableBleHeartbeat(): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.disableBleHeartbeat|disableBleHeartbeat(){}[0] final fun logger(org.meshtastic.sdk/LogSink): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.logger|logger(org.meshtastic.sdk.LogSink){}[0] + final fun presenceTimeout(kotlin.time/Duration): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.presenceTimeout|presenceTimeout(kotlin.time.Duration){}[0] final fun protocolLogging(org.meshtastic.sdk/LogLevel, org.meshtastic.sdk/PayloadRedactor = ...): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.protocolLogging|protocolLogging(org.meshtastic.sdk.LogLevel;org.meshtastic.sdk.PayloadRedactor){}[0] final fun rpcTimeout(kotlin.time/Duration): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.rpcTimeout|rpcTimeout(kotlin.time.Duration){}[0] final fun sendTimeout(kotlin.time/Duration): org.meshtastic.sdk/RadioClient.Builder // org.meshtastic.sdk/RadioClient.Builder.sendTimeout|sendTimeout(kotlin.time.Duration){}[0] @@ -925,6 +1462,35 @@ final class org.meshtastic.sdk/RadioMetrics { // org.meshtastic.sdk/RadioMetrics final fun toString(): kotlin/String // org.meshtastic.sdk/RadioMetrics.toString|toString(){}[0] } +final class org.meshtastic.sdk/RouteDiscoveryResult { // org.meshtastic.sdk/RouteDiscoveryResult|null[0] + constructor (kotlin.collections/List, kotlin.collections/List, kotlin.collections/List = ..., kotlin.collections/List = ...) // org.meshtastic.sdk/RouteDiscoveryResult.|(kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] + + final val hopsAway // org.meshtastic.sdk/RouteDiscoveryResult.hopsAway|{}hopsAway[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RouteDiscoveryResult.hopsAway.|(){}[0] + final val route // org.meshtastic.sdk/RouteDiscoveryResult.route|{}route[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.route.|(){}[0] + final val routeBack // org.meshtastic.sdk/RouteDiscoveryResult.routeBack|{}routeBack[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.routeBack.|(){}[0] + final val snrBack // org.meshtastic.sdk/RouteDiscoveryResult.snrBack|{}snrBack[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.snrBack.|(){}[0] + final val snrTowards // org.meshtastic.sdk/RouteDiscoveryResult.snrTowards|{}snrTowards[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.snrTowards.|(){}[0] + + final fun component1(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component2|component2(){}[0] + final fun component3(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component3|component3(){}[0] + final fun component4(): kotlin.collections/List // org.meshtastic.sdk/RouteDiscoveryResult.component4|component4(){}[0] + final fun copy(kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ..., kotlin.collections/List = ...): org.meshtastic.sdk/RouteDiscoveryResult // org.meshtastic.sdk/RouteDiscoveryResult.copy|copy(kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RouteDiscoveryResult.equals|equals(kotlin.Any?){}[0] + final fun formatRoute(kotlin/Function1): kotlin/String // org.meshtastic.sdk/RouteDiscoveryResult.formatRoute|formatRoute(kotlin.Function1){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RouteDiscoveryResult.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RouteDiscoveryResult.toString|toString(){}[0] + + final object Companion { // org.meshtastic.sdk/RouteDiscoveryResult.Companion|null[0] + final fun fromProto(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/NodeId, kotlin.collections/List, kotlin.collections/List, kotlin.collections/List = ..., kotlin.collections/List = ...): org.meshtastic.sdk/RouteDiscoveryResult // org.meshtastic.sdk/RouteDiscoveryResult.Companion.fromProto|fromProto(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.NodeId;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List;kotlin.collections.List){}[0] + } +} + final class org.meshtastic.sdk/SendBuilder { // org.meshtastic.sdk/SendBuilder|null[0] final fun channel(org.meshtastic.sdk/ChannelIndex) // org.meshtastic.sdk/SendBuilder.channel|channel(org.meshtastic.sdk.ChannelIndex){}[0] final fun data(org.meshtastic.proto/PortNum, kotlin/ByteArray) // org.meshtastic.sdk/SendBuilder.data|data(org.meshtastic.proto.PortNum;kotlin.ByteArray){}[0] @@ -953,6 +1519,34 @@ final class org.meshtastic.sdk/SessionPasskey { // org.meshtastic.sdk/SessionPas final fun toString(): kotlin/String // org.meshtastic.sdk/SessionPasskey.toString|toString(){}[0] } +final class org.meshtastic.sdk/StoreForwardStats { // org.meshtastic.sdk/StoreForwardStats|null[0] + constructor (kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Boolean = ...) // org.meshtastic.sdk/StoreForwardStats.|(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Boolean){}[0] + + final val heartbeat // org.meshtastic.sdk/StoreForwardStats.heartbeat|{}heartbeat[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/StoreForwardStats.heartbeat.|(){}[0] + final val messagesMax // org.meshtastic.sdk/StoreForwardStats.messagesMax|{}messagesMax[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.messagesMax.|(){}[0] + final val messagesStored // org.meshtastic.sdk/StoreForwardStats.messagesStored|{}messagesStored[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.messagesStored.|(){}[0] + final val requests // org.meshtastic.sdk/StoreForwardStats.requests|{}requests[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.requests.|(){}[0] + final val requestsFailed // org.meshtastic.sdk/StoreForwardStats.requestsFailed|{}requestsFailed[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.requestsFailed.|(){}[0] + final val uptime // org.meshtastic.sdk/StoreForwardStats.uptime|{}uptime[0] + final fun (): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.uptime.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component1|component1(){}[0] + final fun component2(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component2|component2(){}[0] + final fun component3(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component3|component3(){}[0] + final fun component4(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component4|component4(){}[0] + final fun component5(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.component5|component5(){}[0] + final fun component6(): kotlin/Boolean // org.meshtastic.sdk/StoreForwardStats.component6|component6(){}[0] + final fun copy(kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Int = ..., kotlin/Boolean = ...): org.meshtastic.sdk/StoreForwardStats // org.meshtastic.sdk/StoreForwardStats.copy|copy(kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Int;kotlin.Boolean){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/StoreForwardStats.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/StoreForwardStats.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/StoreForwardStats.toString|toString(){}[0] +} + final value class org.meshtastic.sdk/ChannelIndex { // org.meshtastic.sdk/ChannelIndex|null[0] constructor (kotlin/Int) // org.meshtastic.sdk/ChannelIndex.|(kotlin.Int){}[0] @@ -1013,6 +1607,35 @@ final value class org.meshtastic.sdk/TransportIdentity { // org.meshtastic.sdk/T } } +sealed class org.meshtastic.sdk/AdminResultException : kotlin/Exception { // org.meshtastic.sdk/AdminResultException|null[0] + final class NodeUnreachable : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.NodeUnreachable|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.NodeUnreachable.|(){}[0] + } + + final class RateLimited : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.RateLimited|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.RateLimited.|(){}[0] + } + + final class RoutingFailed : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.RoutingFailed|null[0] + constructor (org.meshtastic.proto/Routing.Error) // org.meshtastic.sdk/AdminResultException.RoutingFailed.|(org.meshtastic.proto.Routing.Error){}[0] + + final val error // org.meshtastic.sdk/AdminResultException.RoutingFailed.error|{}error[0] + final fun (): org.meshtastic.proto/Routing.Error // org.meshtastic.sdk/AdminResultException.RoutingFailed.error.|(){}[0] + } + + final class SessionKeyExpired : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.SessionKeyExpired|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.SessionKeyExpired.|(){}[0] + } + + final class Timeout : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.Timeout|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.Timeout.|(){}[0] + } + + final class Unauthorized : org.meshtastic.sdk/AdminResultException { // org.meshtastic.sdk/AdminResultException.Unauthorized|null[0] + constructor () // org.meshtastic.sdk/AdminResultException.Unauthorized.|(){}[0] + } +} + sealed class org.meshtastic.sdk/MeshtasticException : kotlin/Exception { // org.meshtastic.sdk/MeshtasticException|null[0] final var operation // org.meshtastic.sdk/MeshtasticException.operation|{}operation[0] final fun (): kotlin/String? // org.meshtastic.sdk/MeshtasticException.operation.|(){}[0] @@ -1069,6 +1692,60 @@ sealed class org.meshtastic.sdk/MeshtasticException : kotlin/Exception { // org. } } +sealed class org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy|null[0] + final val maxRetries // org.meshtastic.sdk/RetryPolicy.maxRetries|{}maxRetries[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RetryPolicy.maxRetries.|(){}[0] + + final fun delayForAttempt(kotlin/Int): kotlin.time/Duration? // org.meshtastic.sdk/RetryPolicy.delayForAttempt|delayForAttempt(kotlin.Int){}[0] + + final class ExponentialBackoff : org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff|null[0] + constructor (kotlin/Int = ..., kotlin.time/Duration = ..., kotlin.time/Duration = ..., kotlin/Double = ..., kotlin/Double = ...) // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.|(kotlin.Int;kotlin.time.Duration;kotlin.time.Duration;kotlin.Double;kotlin.Double){}[0] + + final val initialDelay // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.initialDelay|{}initialDelay[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.initialDelay.|(){}[0] + final val jitterFactor // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.jitterFactor|{}jitterFactor[0] + final fun (): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.jitterFactor.|(){}[0] + final val maxAttempts // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxAttempts|{}maxAttempts[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxAttempts.|(){}[0] + final val maxDelay // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxDelay|{}maxDelay[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.maxDelay.|(){}[0] + final val multiplier // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.multiplier|{}multiplier[0] + final fun (): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.multiplier.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component1|component1(){}[0] + final fun component2(): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component2|component2(){}[0] + final fun component3(): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component3|component3(){}[0] + final fun component4(): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component4|component4(){}[0] + final fun component5(): kotlin/Double // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.component5|component5(){}[0] + final fun copy(kotlin/Int = ..., kotlin.time/Duration = ..., kotlin.time/Duration = ..., kotlin/Double = ..., kotlin/Double = ...): org.meshtastic.sdk/RetryPolicy.ExponentialBackoff // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.copy|copy(kotlin.Int;kotlin.time.Duration;kotlin.time.Duration;kotlin.Double;kotlin.Double){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RetryPolicy.ExponentialBackoff.toString|toString(){}[0] + } + + final class Fixed : org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy.Fixed|null[0] + constructor (kotlin/Int = ..., kotlin.time/Duration = ...) // org.meshtastic.sdk/RetryPolicy.Fixed.|(kotlin.Int;kotlin.time.Duration){}[0] + + final val delay // org.meshtastic.sdk/RetryPolicy.Fixed.delay|{}delay[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.Fixed.delay.|(){}[0] + final val maxAttempts // org.meshtastic.sdk/RetryPolicy.Fixed.maxAttempts|{}maxAttempts[0] + final fun (): kotlin/Int // org.meshtastic.sdk/RetryPolicy.Fixed.maxAttempts.|(){}[0] + + final fun component1(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.Fixed.component1|component1(){}[0] + final fun component2(): kotlin.time/Duration // org.meshtastic.sdk/RetryPolicy.Fixed.component2|component2(){}[0] + final fun copy(kotlin/Int = ..., kotlin.time/Duration = ...): org.meshtastic.sdk/RetryPolicy.Fixed // org.meshtastic.sdk/RetryPolicy.Fixed.copy|copy(kotlin.Int;kotlin.time.Duration){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RetryPolicy.Fixed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.Fixed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RetryPolicy.Fixed.toString|toString(){}[0] + } + + final object None : org.meshtastic.sdk/RetryPolicy { // org.meshtastic.sdk/RetryPolicy.None|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/RetryPolicy.None.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/RetryPolicy.None.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/RetryPolicy.None.toString|toString(){}[0] + } +} + sealed class org.meshtastic.sdk/TelemetryReading { // org.meshtastic.sdk/TelemetryReading|null[0] final class AirQuality : org.meshtastic.sdk/TelemetryReading { // org.meshtastic.sdk/TelemetryReading.AirQuality|null[0] constructor (org.meshtastic.proto/AirQualityMetrics) // org.meshtastic.sdk/TelemetryReading.AirQuality.|(org.meshtastic.proto.AirQualityMetrics){}[0] @@ -1123,6 +1800,35 @@ sealed class org.meshtastic.sdk/TelemetryReading { // org.meshtastic.sdk/Telemet } } +final object org.meshtastic.sdk/ChannelHelpers { // org.meshtastic.sdk/ChannelHelpers|null[0] + final const val MAX_NAME_LENGTH // org.meshtastic.sdk/ChannelHelpers.MAX_NAME_LENGTH|{}MAX_NAME_LENGTH[0] + final fun (): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.MAX_NAME_LENGTH.|(){}[0] + final const val MAX_PSK_LENGTH // org.meshtastic.sdk/ChannelHelpers.MAX_PSK_LENGTH|{}MAX_PSK_LENGTH[0] + final fun (): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.MAX_PSK_LENGTH.|(){}[0] + final const val MIN_PSK_LENGTH // org.meshtastic.sdk/ChannelHelpers.MIN_PSK_LENGTH|{}MIN_PSK_LENGTH[0] + final fun (): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.MIN_PSK_LENGTH.|(){}[0] + + final fun createSettings(kotlin/String, kotlin/ByteArray = ...): org.meshtastic.proto/ChannelSettings? // org.meshtastic.sdk/ChannelHelpers.createSettings|createSettings(kotlin.String;kotlin.ByteArray){}[0] + final fun findEmptySlot(kotlin.collections/List, kotlin/Int = ...): kotlin/Int? // org.meshtastic.sdk/ChannelHelpers.findEmptySlot|findEmptySlot(kotlin.collections.List;kotlin.Int){}[0] + final fun validate(kotlin/String, kotlin/ByteArray, org.meshtastic.proto/Channel.Role = ...): org.meshtastic.sdk/ChannelHelpers.ValidationResult // org.meshtastic.sdk/ChannelHelpers.validate|validate(kotlin.String;kotlin.ByteArray;org.meshtastic.proto.Channel.Role){}[0] + + final class ValidationResult { // org.meshtastic.sdk/ChannelHelpers.ValidationResult|null[0] + constructor (kotlin/Boolean, kotlin.collections/List = ...) // org.meshtastic.sdk/ChannelHelpers.ValidationResult.|(kotlin.Boolean;kotlin.collections.List){}[0] + + final val errors // org.meshtastic.sdk/ChannelHelpers.ValidationResult.errors|{}errors[0] + final fun (): kotlin.collections/List // org.meshtastic.sdk/ChannelHelpers.ValidationResult.errors.|(){}[0] + final val isValid // org.meshtastic.sdk/ChannelHelpers.ValidationResult.isValid|{}isValid[0] + final fun (): kotlin/Boolean // org.meshtastic.sdk/ChannelHelpers.ValidationResult.isValid.|(){}[0] + + final fun component1(): kotlin/Boolean // org.meshtastic.sdk/ChannelHelpers.ValidationResult.component1|component1(){}[0] + final fun component2(): kotlin.collections/List // org.meshtastic.sdk/ChannelHelpers.ValidationResult.component2|component2(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin.collections/List = ...): org.meshtastic.sdk/ChannelHelpers.ValidationResult // org.meshtastic.sdk/ChannelHelpers.ValidationResult.copy|copy(kotlin.Boolean;kotlin.collections.List){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // org.meshtastic.sdk/ChannelHelpers.ValidationResult.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // org.meshtastic.sdk/ChannelHelpers.ValidationResult.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // org.meshtastic.sdk/ChannelHelpers.ValidationResult.toString|toString(){}[0] + } +} + final object org.meshtastic.sdk/ChannelUrl { // org.meshtastic.sdk/ChannelUrl|null[0] final const val PREFIX // org.meshtastic.sdk/ChannelUrl.PREFIX|{}PREFIX[0] final fun (): kotlin/String // org.meshtastic.sdk/ChannelUrl.PREFIX.|(){}[0] @@ -1131,6 +1837,27 @@ final object org.meshtastic.sdk/ChannelUrl { // org.meshtastic.sdk/ChannelUrl|nu final fun parse(kotlin/String): org.meshtastic.proto/ChannelSet? // org.meshtastic.sdk/ChannelUrl.parse|parse(kotlin.String){}[0] } +final object org.meshtastic.sdk/PositionUtils { // org.meshtastic.sdk/PositionUtils|null[0] + final fun bearing(kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // org.meshtastic.sdk/PositionUtils.bearing|bearing(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] + final fun bearing(org.meshtastic.sdk/LatLng, org.meshtastic.sdk/LatLng): kotlin/Double // org.meshtastic.sdk/PositionUtils.bearing|bearing(org.meshtastic.sdk.LatLng;org.meshtastic.sdk.LatLng){}[0] + final fun distance(kotlin/Double, kotlin/Double, kotlin/Double, kotlin/Double): kotlin/Double // org.meshtastic.sdk/PositionUtils.distance|distance(kotlin.Double;kotlin.Double;kotlin.Double;kotlin.Double){}[0] + final fun distance(org.meshtastic.sdk/LatLng, org.meshtastic.sdk/LatLng): kotlin/Double // org.meshtastic.sdk/PositionUtils.distance|distance(org.meshtastic.sdk.LatLng;org.meshtastic.sdk.LatLng){}[0] + final fun intToDegrees(kotlin/Int): kotlin/Double // org.meshtastic.sdk/PositionUtils.intToDegrees|intToDegrees(kotlin.Int){}[0] + final fun isValidPosition(kotlin/Double, kotlin/Double): kotlin/Boolean // org.meshtastic.sdk/PositionUtils.isValidPosition|isValidPosition(kotlin.Double;kotlin.Double){}[0] +} + +final object org.meshtastic.sdk/SfppHash { // org.meshtastic.sdk/SfppHash|null[0] + final fun compute(kotlin/ByteArray, kotlin/Int, kotlin/Int, kotlin/Int): kotlin/ByteArray // org.meshtastic.sdk/SfppHash.compute|compute(kotlin.ByteArray;kotlin.Int;kotlin.Int;kotlin.Int){}[0] +} + +final object org.meshtastic.sdk/SharedContactUrl { // org.meshtastic.sdk/SharedContactUrl|null[0] + final const val PREFIX // org.meshtastic.sdk/SharedContactUrl.PREFIX|{}PREFIX[0] + final fun (): kotlin/String // org.meshtastic.sdk/SharedContactUrl.PREFIX.|(){}[0] + + final fun encode(org.meshtastic.proto/SharedContact): kotlin/String // org.meshtastic.sdk/SharedContactUrl.encode|encode(org.meshtastic.proto.SharedContact){}[0] + final fun parse(kotlin/String): org.meshtastic.proto/SharedContact? // org.meshtastic.sdk/SharedContactUrl.parse|parse(kotlin.String){}[0] +} + final object org.meshtastic.sdk/WireCodec { // org.meshtastic.sdk/WireCodec|null[0] final fun decodeFromRadio(kotlin/ByteArray): org.meshtastic.proto/FromRadio // org.meshtastic.sdk/WireCodec.decodeFromRadio|decodeFromRadio(kotlin.ByteArray){}[0] final fun encodeToRadio(org.meshtastic.proto/ToRadio): kotlin/ByteArray // org.meshtastic.sdk/WireCodec.encodeToRadio|encodeToRadio(org.meshtastic.proto.ToRadio){}[0] @@ -1160,21 +1887,38 @@ final object org.meshtastic.sdk/WireFraming { // org.meshtastic.sdk/WireFraming| final const val org.meshtastic.sdk/DATA_PAYLOAD_LEN // org.meshtastic.sdk/DATA_PAYLOAD_LEN|{}DATA_PAYLOAD_LEN[0] final fun (): kotlin/Int // org.meshtastic.sdk/DATA_PAYLOAD_LEN.|(){}[0] +final val org.meshtastic.sdk/DEFAULT_ONLINE_THRESHOLD // org.meshtastic.sdk/DEFAULT_ONLINE_THRESHOLD|{}DEFAULT_ONLINE_THRESHOLD[0] + final fun (): kotlin.time/Duration // org.meshtastic.sdk/DEFAULT_ONLINE_THRESHOLD.|(){}[0] final val org.meshtastic.sdk/DefaultPsk // org.meshtastic.sdk/DefaultPsk|{}DefaultPsk[0] final fun (): kotlin/ByteArray // org.meshtastic.sdk/DefaultPsk.|(){}[0] +final val org.meshtastic.sdk/connectionQuality // org.meshtastic.sdk/connectionQuality|@org.meshtastic.proto.NodeInfo{}connectionQuality[0] + final fun (org.meshtastic.proto/NodeInfo).(): org.meshtastic.sdk/ConnectionQuality // org.meshtastic.sdk/connectionQuality.|@org.meshtastic.proto.NodeInfo(){}[0] final val org.meshtastic.sdk/displayId // org.meshtastic.sdk/displayId|@org.meshtastic.proto.NodeInfo{}displayId[0] final fun (org.meshtastic.proto/NodeInfo).(): kotlin/String // org.meshtastic.sdk/displayId.|@org.meshtastic.proto.NodeInfo(){}[0] final val org.meshtastic.sdk/displayName // org.meshtastic.sdk/displayName|@org.meshtastic.proto.HardwareModel{}displayName[0] final fun (org.meshtastic.proto/HardwareModel).(): kotlin/String // org.meshtastic.sdk/displayName.|@org.meshtastic.proto.HardwareModel(){}[0] final val org.meshtastic.sdk/isBroadcast // org.meshtastic.sdk/isBroadcast|@org.meshtastic.sdk.NodeId{}isBroadcast[0] final fun (org.meshtastic.sdk/NodeId).(): kotlin/Boolean // org.meshtastic.sdk/isBroadcast.|@org.meshtastic.sdk.NodeId(){}[0] +final val org.meshtastic.sdk/isInProgress // org.meshtastic.sdk/isInProgress|@org.meshtastic.sdk.ConnectionState{}isInProgress[0] + final fun (org.meshtastic.sdk/ConnectionState).(): kotlin/Boolean // org.meshtastic.sdk/isInProgress.|@org.meshtastic.sdk.ConnectionState(){}[0] +final val org.meshtastic.sdk/isSuccess // org.meshtastic.sdk/isSuccess|@org.meshtastic.sdk.AdminResult<0:0>{0§}isSuccess[0] + final fun <#A1: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A1>).(): kotlin/Boolean // org.meshtastic.sdk/isSuccess.|@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] final val org.meshtastic.sdk/isUnicast // org.meshtastic.sdk/isUnicast|@org.meshtastic.sdk.NodeId{}isUnicast[0] final fun (org.meshtastic.sdk/NodeId).(): kotlin/Boolean // org.meshtastic.sdk/isUnicast.|@org.meshtastic.sdk.NodeId(){}[0] +final val org.meshtastic.sdk/isUsable // org.meshtastic.sdk/isUsable|@org.meshtastic.sdk.ConnectionState{}isUsable[0] + final fun (org.meshtastic.sdk/ConnectionState).(): kotlin/Boolean // org.meshtastic.sdk/isUsable.|@org.meshtastic.sdk.ConnectionState(){}[0] final val org.meshtastic.sdk/longName // org.meshtastic.sdk/longName|@org.meshtastic.proto.NodeInfo{}longName[0] final fun (org.meshtastic.proto/NodeInfo).(): kotlin/String // org.meshtastic.sdk/longName.|@org.meshtastic.proto.NodeInfo(){}[0] final val org.meshtastic.sdk/shortName // org.meshtastic.sdk/shortName|@org.meshtastic.proto.NodeInfo{}shortName[0] final fun (org.meshtastic.proto/NodeInfo).(): kotlin/String // org.meshtastic.sdk/shortName.|@org.meshtastic.proto.NodeInfo(){}[0] - +final val org.meshtastic.sdk/signalQuality // org.meshtastic.sdk/signalQuality|@org.meshtastic.proto.NodeInfo{}signalQuality[0] + final fun (org.meshtastic.proto/NodeInfo).(): org.meshtastic.sdk/SignalQuality // org.meshtastic.sdk/signalQuality.|@org.meshtastic.proto.NodeInfo(){}[0] +final val org.meshtastic.sdk/statusMessage // org.meshtastic.sdk/statusMessage|@org.meshtastic.sdk.ConnectionState{}statusMessage[0] + final fun (org.meshtastic.sdk/ConnectionState).(): kotlin/String // org.meshtastic.sdk/statusMessage.|@org.meshtastic.sdk.ConnectionState(){}[0] +final val org.meshtastic.sdk/textMessages // org.meshtastic.sdk/textMessages|@org.meshtastic.sdk.RadioClient{}textMessages[0] + final fun (org.meshtastic.sdk/RadioClient).(): kotlinx.coroutines.flow/Flow // org.meshtastic.sdk/textMessages.|@org.meshtastic.sdk.RadioClient(){}[0] + +final fun (kotlin.collections/Iterable).org.meshtastic.sdk/toMeshNodes(kotlin/Int): kotlin.collections/List // org.meshtastic.sdk/toMeshNodes|toMeshNodes@kotlin.collections.Iterable(kotlin.Int){}[0] final fun (kotlin.time/Instant).org.meshtastic.sdk/relativeTo(kotlin.time/Instant = ...): kotlin/String // org.meshtastic.sdk/relativeTo|relativeTo@kotlin.time.Instant(kotlin.time.Instant){}[0] final fun (kotlin.time/Instant).org.meshtastic.sdk/toFirmwareSeconds(): kotlin/Int // org.meshtastic.sdk/toFirmwareSeconds|toFirmwareSeconds@kotlin.time.Instant(){}[0] final fun (kotlin.time/Instant?).org.meshtastic.sdk/relativeToOrNever(kotlin.time/Instant = ...): kotlin/String // org.meshtastic.sdk/relativeToOrNever|relativeToOrNever@kotlin.time.Instant?(kotlin.time.Instant){}[0] @@ -1188,12 +1932,15 @@ final fun (org.meshtastic.proto/ChannelSettings.Companion).org.meshtastic.sdk/ha final fun (org.meshtastic.proto/DeviceMetrics).org.meshtastic.sdk/toBatteryStatus(): org.meshtastic.sdk/BatteryStatus? // org.meshtastic.sdk/toBatteryStatus|toBatteryStatus@org.meshtastic.proto.DeviceMetrics(){}[0] final fun (org.meshtastic.proto/FromRadio).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.FromRadio(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asAdminMessage(): org.meshtastic.proto/AdminMessage? // org.meshtastic.sdk/asAdminMessage|asAdminMessage@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asNeighborInfo(): org.meshtastic.proto/NeighborInfo? // org.meshtastic.sdk/asNeighborInfo|asNeighborInfo@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asNodeInfo(): org.meshtastic.proto/NodeInfo? // org.meshtastic.sdk/asNodeInfo|asNodeInfo@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asNodeInfoUser(): org.meshtastic.proto/User? // org.meshtastic.sdk/asNodeInfoUser|asNodeInfoUser@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asPosition(): org.meshtastic.proto/Position? // org.meshtastic.sdk/asPosition|asPosition@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asRouting(): org.meshtastic.proto/Routing? // org.meshtastic.sdk/asRouting|asRouting@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asTelemetry(): org.meshtastic.proto/Telemetry? // org.meshtastic.sdk/asTelemetry|asTelemetry@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asText(): kotlin/String? // org.meshtastic.sdk/asText|asText@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asTraceroute(): org.meshtastic.proto/RouteDiscovery? // org.meshtastic.sdk/asTraceroute|asTraceroute@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/asWaypoint(): org.meshtastic.proto/Waypoint? // org.meshtastic.sdk/asWaypoint|asWaypoint@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsAdmin(): org.meshtastic.proto/AdminMessage? // org.meshtastic.sdk/decodeAsAdmin|decodeAsAdmin@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsNodeInfo(): org.meshtastic.proto/NodeInfo? // org.meshtastic.sdk/decodeAsNodeInfo|decodeAsNodeInfo@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsPosition(): org.meshtastic.proto/Position? // org.meshtastic.sdk/decodeAsPosition|decodeAsPosition@org.meshtastic.proto.MeshPacket(){}[0] @@ -1204,25 +1951,63 @@ final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAsUser(): o final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/signalQuality(): kotlin/Int? // org.meshtastic.sdk/signalQuality|signalQuality@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.MeshPacket(){}[0] final fun (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/toRadioMetrics(): org.meshtastic.sdk/RadioMetrics? // org.meshtastic.sdk/toRadioMetrics|toRadioMetrics@org.meshtastic.proto.MeshPacket(){}[0] +final fun (org.meshtastic.proto/NodeInfo).org.meshtastic.sdk/isOnline(kotlin/Int, kotlin.time/Duration = ...): kotlin/Boolean // org.meshtastic.sdk/isOnline|isOnline@org.meshtastic.proto.NodeInfo(kotlin.Int;kotlin.time.Duration){}[0] +final fun (org.meshtastic.proto/NodeInfo).org.meshtastic.sdk/toMeshNode(kotlin/Int): org.meshtastic.sdk/MeshNode // org.meshtastic.sdk/toMeshNode|toMeshNode@org.meshtastic.proto.NodeInfo(kotlin.Int){}[0] final fun (org.meshtastic.proto/Position).org.meshtastic.sdk/toLatLng(): org.meshtastic.sdk/LatLng? // org.meshtastic.sdk/toLatLng|toLatLng@org.meshtastic.proto.Position(){}[0] final fun (org.meshtastic.proto/Routing.Error).org.meshtastic.sdk/actionableMessage(): kotlin/String // org.meshtastic.sdk/actionableMessage|actionableMessage@org.meshtastic.proto.Routing.Error(){}[0] final fun (org.meshtastic.proto/Routing.Error).org.meshtastic.sdk/suggestedAction(): kotlin/String? // org.meshtastic.sdk/suggestedAction|suggestedAction@org.meshtastic.proto.Routing.Error(){}[0] +final fun (org.meshtastic.proto/SharedContact).org.meshtastic.sdk/toUrl(): kotlin/String // org.meshtastic.sdk/toUrl|toUrl@org.meshtastic.proto.SharedContact(){}[0] final fun (org.meshtastic.proto/Telemetry).org.meshtastic.sdk/toBatteryStatus(): org.meshtastic.sdk/BatteryStatus? // org.meshtastic.sdk/toBatteryStatus|toBatteryStatus@org.meshtastic.proto.Telemetry(){}[0] final fun (org.meshtastic.proto/Telemetry).org.meshtastic.sdk/toReading(): org.meshtastic.sdk/TelemetryReading? // org.meshtastic.sdk/toReading|toReading@org.meshtastic.proto.Telemetry(){}[0] final fun (org.meshtastic.proto/ToRadio).org.meshtastic.sdk/toByteArray(): kotlin/ByteArray // org.meshtastic.sdk/toByteArray|toByteArray@org.meshtastic.proto.ToRadio(){}[0] final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/isLocal(org.meshtastic.sdk/NodeId? = ...): kotlin/Boolean // org.meshtastic.sdk/isLocal|isLocal@org.meshtastic.sdk.NodeId(org.meshtastic.sdk.NodeId?){}[0] +final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/toDefaultId(): kotlin/String // org.meshtastic.sdk/toDefaultId|toDefaultId@org.meshtastic.sdk.NodeId(){}[0] final fun (org.meshtastic.sdk/NodeId).org.meshtastic.sdk/toHex(): kotlin/String // org.meshtastic.sdk/toHex|toHex@org.meshtastic.sdk.NodeId(){}[0] +final fun (org.meshtastic.sdk/NodeId.Companion).org.meshtastic.sdk/fromDefaultId(kotlin/String): org.meshtastic.sdk/NodeId? // org.meshtastic.sdk/fromDefaultId|fromDefaultId@org.meshtastic.sdk.NodeId.Companion(kotlin.String){}[0] final fun (org.meshtastic.sdk/NodeId.Companion).org.meshtastic.sdk/fromHex(kotlin/String): org.meshtastic.sdk/NodeId? // org.meshtastic.sdk/fromHex|fromHex@org.meshtastic.sdk.NodeId.Companion(kotlin.String){}[0] final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/requestPosition(org.meshtastic.sdk/NodeId, org.meshtastic.sdk/ChannelIndex = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/requestPosition|requestPosition@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;org.meshtastic.sdk.ChannelIndex){}[0] final fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/sendDirectMessageEncrypted(org.meshtastic.sdk/NodeId, kotlin/String, kotlin/Boolean = ...): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/sendDirectMessageEncrypted|sendDirectMessageEncrypted@org.meshtastic.sdk.RadioClient(org.meshtastic.sdk.NodeId;kotlin.String;kotlin.Boolean){}[0] final fun (org.meshtastic.sdk/SendFailure).org.meshtastic.sdk/humanMessage(): kotlin/String // org.meshtastic.sdk/humanMessage|humanMessage@org.meshtastic.sdk.SendFailure(){}[0] final fun <#A: com.squareup.wire/Message<#A, *>> (org.meshtastic.proto/MeshPacket).org.meshtastic.sdk/decodeAs(com.squareup.wire/ProtoAdapter<#A>): #A? // org.meshtastic.sdk/decodeAs|decodeAs@org.meshtastic.proto.MeshPacket(com.squareup.wire.ProtoAdapter<0:0>){0§>}[0] +final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrElse(#A): #A // org.meshtastic.sdk/getOrElse|getOrElse@org.meshtastic.sdk.AdminResult<0:0>(0:0){0§}[0] +final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrNull(): #A? // org.meshtastic.sdk/getOrNull|getOrNull@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] +final fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrThrow(): #A // org.meshtastic.sdk/getOrThrow|getOrThrow@org.meshtastic.sdk.AdminResult<0:0>(){0§}[0] +final fun org.meshtastic.sdk/channelNameHashDjb2(kotlin/String): kotlin/UInt // org.meshtastic.sdk/channelNameHashDjb2|channelNameHashDjb2(kotlin.String){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/debug(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/debug|debug@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/error(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/error|error@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/info(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/info|info@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/verbose(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/verbose|verbose@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] final inline fun (org.meshtastic.sdk/LogSink).org.meshtastic.sdk/warn(kotlin/String, kotlin/Throwable? = ..., kotlin/Function0) // org.meshtastic.sdk/warn|warn@org.meshtastic.sdk.LogSink(kotlin.String;kotlin.Throwable?;kotlin.Function0){}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/fold(kotlin/Function1<#A, #B>, kotlin/Function1, #B>): #B // org.meshtastic.sdk/fold|fold@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1<0:0,0:1>;kotlin.Function1,0:1>){0§;1§}[0] +final inline fun <#A: kotlin/Any?, #B: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/map(kotlin/Function1<#A, #B>): org.meshtastic.sdk/AdminResult<#B> // org.meshtastic.sdk/map|map@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1<0:0,0:1>){0§;1§}[0] +final inline fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/getOrElse(kotlin/Function1, #A>): #A // org.meshtastic.sdk/getOrElse|getOrElse@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1,0:0>){0§}[0] +final inline fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/onFailure(kotlin/Function1, kotlin/Unit>): org.meshtastic.sdk/AdminResult<#A> // org.meshtastic.sdk/onFailure|onFailure@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1,kotlin.Unit>){0§}[0] +final inline fun <#A: kotlin/Any?> (org.meshtastic.sdk/AdminResult<#A>).org.meshtastic.sdk/onSuccess(kotlin/Function1<#A, kotlin/Unit>): org.meshtastic.sdk/AdminResult<#A> // org.meshtastic.sdk/onSuccess|onSuccess@org.meshtastic.sdk.AdminResult<0:0>(kotlin.Function1<0:0,kotlin.Unit>){0§}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setAmbientLightingConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setAmbientLightingConfig|setAmbientLightingConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setAudioConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setAudioConfig|setAudioConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setBluetoothConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setBluetoothConfig|setBluetoothConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setCannedMessageConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setCannedMessageConfig|setCannedMessageConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setDetectionSensorConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setDetectionSensorConfig|setDetectionSensorConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setDeviceConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setDeviceConfig|setDeviceConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setDisplayConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setDisplayConfig|setDisplayConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setExternalNotificationConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setExternalNotificationConfig|setExternalNotificationConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setLoraConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setLoraConfig|setLoraConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setMqttConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setMqttConfig|setMqttConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setNeighborInfoConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setNeighborInfoConfig|setNeighborInfoConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setNetworkConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setNetworkConfig|setNetworkConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setPaxcounterConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setPaxcounterConfig|setPaxcounterConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setPositionConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setPositionConfig|setPositionConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setPowerConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setPowerConfig|setPowerConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setRangeTestConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setRangeTestConfig|setRangeTestConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setRemoteHardwareConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setRemoteHardwareConfig|setRemoteHardwareConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setSecurityConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setSecurityConfig|setSecurityConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setSerialConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setSerialConfig|setSerialConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setStatusMessageConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setStatusMessageConfig|setStatusMessageConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setStoreForwardConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setStoreForwardConfig|setStoreForwardConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setTelemetryConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setTelemetryConfig|setTelemetryConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] +final suspend fun (org.meshtastic.sdk/AdminApi).org.meshtastic.sdk/setTrafficManagementConfig(kotlin/Function1): org.meshtastic.sdk/AdminResult // org.meshtastic.sdk/setTrafficManagementConfig|setTrafficManagementConfig@org.meshtastic.sdk.AdminApi(kotlin.Function1){}[0] final suspend fun (org.meshtastic.sdk/MessageHandle).org.meshtastic.sdk/retry(): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/retry|retry@org.meshtastic.sdk.MessageHandle(){}[0] +final suspend fun (org.meshtastic.sdk/MessageHandle).org.meshtastic.sdk/retryWith(org.meshtastic.sdk/RetryPolicy): org.meshtastic.sdk/SendOutcome // org.meshtastic.sdk/retryWith|retryWith@org.meshtastic.sdk.MessageHandle(org.meshtastic.sdk.RetryPolicy){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/awaitInitialNodeDb(): kotlin.collections/Map // org.meshtastic.sdk/awaitInitialNodeDb|awaitInitialNodeDb@org.meshtastic.sdk.RadioClient(){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/connectAndAwaitReady(kotlin.time/Duration = ...): org.meshtastic.sdk/ConfigBundle // org.meshtastic.sdk/connectAndAwaitReady|connectAndAwaitReady@org.meshtastic.sdk.RadioClient(kotlin.time.Duration){}[0] final suspend fun (org.meshtastic.sdk/RadioClient).org.meshtastic.sdk/send(kotlin/Function1): org.meshtastic.sdk/MessageHandle // org.meshtastic.sdk/send|send@org.meshtastic.sdk.RadioClient(kotlin.Function1){}[0] diff --git a/core/api/jvm/core.api b/core/api/jvm/core.api index b43d6e3..492b344 100644 --- a/core/api/jvm/core.api +++ b/core/api/jvm/core.api @@ -1,36 +1,78 @@ public abstract interface class org/meshtastic/sdk/AdminApi { + public abstract fun addContact (Lorg/meshtastic/proto/SharedContact;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun backupPreferences (Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun backupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun batch (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun deleteFile (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun editSettings (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun enterDfuMode (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun exitSimulator (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun factoryReset (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun factoryReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun forNode-LO_6HKw (I)Lorg/meshtastic/sdk/AdminApi; + public abstract fun getCannedMessages (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getChannel-KtkxZUM (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getConfig (Lorg/meshtastic/proto/AdminMessage$ConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getDeviceConnectionStatus (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getDeviceMetadata (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getModuleConfig (Lorg/meshtastic/proto/AdminMessage$ModuleConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getOwner (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getRemoteHardwarePins (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getRingtone (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getUIConfig (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun keyVerification (Lorg/meshtastic/proto/KeyVerificationAdmin;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun listChannels (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public abstract fun nodeDbReset (ZLkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun nodeDbReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun nodeDbReset (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun otaRequest (Lorg/meshtastic/proto/AdminMessage$OTAEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun reboot-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun reboot-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun rebootOta-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun rebootOta-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun removeBackupPreferences (Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun removeBackupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun removeFixedPosition (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun removeNode-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun restorePreferences (Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun restorePreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun sendInputEvent (Lorg/meshtastic/proto/AdminMessage$InputEvent;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setCannedMessages (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setChannel (Lorg/meshtastic/proto/Channel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setConfig (Lorg/meshtastic/proto/Config;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setFavorite-TEw7vI0 (IZLkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setFixedPosition (Lorg/meshtastic/proto/Position;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setHamMode (Lorg/meshtastic/proto/HamParameters;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setIgnored-TEw7vI0 (IZLkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setModuleConfig (Lorg/meshtastic/proto/ModuleConfig;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setOwner (Lorg/meshtastic/proto/User;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setRingtone (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setScale (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun setSensorConfig (Lorg/meshtastic/proto/SensorConfig;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setTime (Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun setTime$default (Lorg/meshtastic/sdk/AdminApi;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun setTimeOnly (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun shutdown-VtjQ1oo (JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun shutdown-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun storeUIConfig (Lorg/meshtastic/proto/DeviceUIConfig;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun toggleMuted-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class org/meshtastic/sdk/AdminApi$DefaultImpls { + public static synthetic fun backupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun factoryReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static synthetic fun nodeDbReset$default (Lorg/meshtastic/sdk/AdminApi;ZLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun reboot-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun rebootOta-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun removeBackupPreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun restorePreferences$default (Lorg/meshtastic/sdk/AdminApi;Lorg/meshtastic/proto/AdminMessage$BackupLocation;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun setTime$default (Lorg/meshtastic/sdk/AdminApi;Lkotlin/time/Instant;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun shutdown-VtjQ1oo$default (Lorg/meshtastic/sdk/AdminApi;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public abstract interface class org/meshtastic/sdk/AdminBatchScope : org/meshtastic/sdk/AdminEdit { + public abstract fun getConfig (Lorg/meshtastic/proto/AdminMessage$ConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getModuleConfig (Lorg/meshtastic/proto/AdminMessage$ModuleConfigType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun listChannels (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class org/meshtastic/sdk/AdminEdit { public abstract fun setChannel (Lorg/meshtastic/proto/Channel;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun setConfig (Lorg/meshtastic/proto/Config;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -61,6 +103,13 @@ public final class org/meshtastic/sdk/AdminResult$NodeUnreachable : org/meshtast public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/AdminResult$RateLimited : org/meshtastic/sdk/AdminResult { + public static final field INSTANCE Lorg/meshtastic/sdk/AdminResult$RateLimited; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/AdminResult$SessionKeyExpired : org/meshtastic/sdk/AdminResult { public static final field INSTANCE Lorg/meshtastic/sdk/AdminResult$SessionKeyExpired; public fun equals (Ljava/lang/Object;)Z @@ -93,6 +142,35 @@ public final class org/meshtastic/sdk/AdminResult$Unauthorized : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public abstract class org/meshtastic/sdk/AdminResultException : java/lang/Exception { + public synthetic fun (Ljava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V +} + +public final class org/meshtastic/sdk/AdminResultException$NodeUnreachable : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$RateLimited : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$RoutingFailed : org/meshtastic/sdk/AdminResultException { + public fun (Lorg/meshtastic/proto/Routing$Error;)V + public final fun getError ()Lorg/meshtastic/proto/Routing$Error; +} + +public final class org/meshtastic/sdk/AdminResultException$SessionKeyExpired : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$Timeout : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + +public final class org/meshtastic/sdk/AdminResultException$Unauthorized : org/meshtastic/sdk/AdminResultException { + public fun ()V +} + public final class org/meshtastic/sdk/AutoReconnectConfig { public static final field Companion Lorg/meshtastic/sdk/AutoReconnectConfig$Companion; public synthetic fun (ZJJLjava/lang/Integer;DDILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -146,6 +224,33 @@ public final class org/meshtastic/sdk/BatteryStatusKt { public static final fun toBatteryStatus (Lorg/meshtastic/proto/Telemetry;)Lorg/meshtastic/sdk/BatteryStatus; } +public final class org/meshtastic/sdk/ChannelHelpers { + public static final field INSTANCE Lorg/meshtastic/sdk/ChannelHelpers; + public static final field MAX_NAME_LENGTH I + public static final field MAX_PSK_LENGTH I + public static final field MIN_PSK_LENGTH I + public final fun createSettings (Ljava/lang/String;[B)Lorg/meshtastic/proto/ChannelSettings; + public static synthetic fun createSettings$default (Lorg/meshtastic/sdk/ChannelHelpers;Ljava/lang/String;[BILjava/lang/Object;)Lorg/meshtastic/proto/ChannelSettings; + public final fun findEmptySlot (Ljava/util/List;I)Ljava/lang/Integer; + public static synthetic fun findEmptySlot$default (Lorg/meshtastic/sdk/ChannelHelpers;Ljava/util/List;IILjava/lang/Object;)Ljava/lang/Integer; + public final fun validate (Ljava/lang/String;[BLorg/meshtastic/proto/Channel$Role;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; + public static synthetic fun validate$default (Lorg/meshtastic/sdk/ChannelHelpers;Ljava/lang/String;[BLorg/meshtastic/proto/Channel$Role;ILjava/lang/Object;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; +} + +public final class org/meshtastic/sdk/ChannelHelpers$ValidationResult { + public fun (ZLjava/util/List;)V + public synthetic fun (ZLjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Ljava/util/List; + public final fun copy (ZLjava/util/List;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult;ZLjava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/ChannelHelpers$ValidationResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getErrors ()Ljava/util/List; + public fun hashCode ()I + public final fun isValid ()Z + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/ChannelIndex { public static final field Companion Lorg/meshtastic/sdk/ChannelIndex$Companion; public static final field MAX_CHANNEL_INDEX I @@ -174,22 +279,52 @@ public final class org/meshtastic/sdk/ChannelUrl { } public final class org/meshtastic/sdk/ChannelUrlsKt { + public static final fun channelNameHashDjb2 (Ljava/lang/String;)I public static final fun default (Lorg/meshtastic/proto/Channel$Companion;)Lorg/meshtastic/proto/Channel; public static final fun getDefaultPsk ()[B public static final fun hash (Lorg/meshtastic/proto/ChannelSettings$Companion;Ljava/lang/String;[B)I public static final fun toUrl (Lorg/meshtastic/proto/ChannelSet;)Ljava/lang/String; } +public final class org/meshtastic/sdk/ConfigBuildersKt { + public static final fun setAmbientLightingConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setAudioConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setBluetoothConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setCannedMessageConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDetectionSensorConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDeviceConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setDisplayConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setExternalNotificationConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setLoraConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setMqttConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setNeighborInfoConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setNetworkConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setPaxcounterConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setPositionConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setPowerConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setRangeTestConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setRemoteHardwareConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setSecurityConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setSerialConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setStatusMessageConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setStoreForwardConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setTelemetryConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun setTrafficManagementConfig (Lorg/meshtastic/sdk/AdminApi;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class org/meshtastic/sdk/ConfigBundle { - public fun (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;)V + public fun (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;)V + public synthetic fun (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lorg/meshtastic/proto/MyNodeInfo; public final fun component2 ()Lorg/meshtastic/proto/DeviceMetadata; public final fun component3 ()Ljava/util/List; public final fun component4 ()Ljava/util/List; - public final fun copy (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/ConfigBundle; - public static synthetic fun copy$default (Lorg/meshtastic/sdk/ConfigBundle;Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/ConfigBundle; + public final fun component5 ()Lorg/meshtastic/proto/DeviceUIConfig; + public final fun copy (Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;)Lorg/meshtastic/sdk/ConfigBundle; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/ConfigBundle;Lorg/meshtastic/proto/MyNodeInfo;Lorg/meshtastic/proto/DeviceMetadata;Ljava/util/List;Ljava/util/List;Lorg/meshtastic/proto/DeviceUIConfig;ILjava/lang/Object;)Lorg/meshtastic/sdk/ConfigBundle; public fun equals (Ljava/lang/Object;)Z public final fun getConfigs ()Ljava/util/List; + public final fun getDeviceUIConfig ()Lorg/meshtastic/proto/DeviceUIConfig; public final fun getMetadata ()Lorg/meshtastic/proto/DeviceMetadata; public final fun getModuleConfigs ()Ljava/util/List; public final fun getMyInfo ()Lorg/meshtastic/proto/MyNodeInfo; @@ -206,11 +341,55 @@ public final class org/meshtastic/sdk/ConfigPhase : java/lang/Enum { public static fun values ()[Lorg/meshtastic/sdk/ConfigPhase; } +public final class org/meshtastic/sdk/CongestionLevel : java/lang/Enum { + public static final field CRITICAL Lorg/meshtastic/sdk/CongestionLevel; + public static final field HIGH Lorg/meshtastic/sdk/CongestionLevel; + public static final field LOW Lorg/meshtastic/sdk/CongestionLevel; + public static final field MEDIUM Lorg/meshtastic/sdk/CongestionLevel; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/CongestionLevel; + public static fun values ()[Lorg/meshtastic/sdk/CongestionLevel; +} + +public final class org/meshtastic/sdk/CongestionMetrics { + public static final field CRITICAL_THRESHOLD F + public static final field Companion Lorg/meshtastic/sdk/CongestionMetrics$Companion; + public static final field HIGH_THRESHOLD F + public static final field MEDIUM_THRESHOLD F + public fun (FF)V + public final fun component1 ()F + public final fun component2 ()F + public final fun copy (FF)Lorg/meshtastic/sdk/CongestionMetrics; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/CongestionMetrics;FFILjava/lang/Object;)Lorg/meshtastic/sdk/CongestionMetrics; + public fun equals (Ljava/lang/Object;)Z + public final fun getAirUtilTx ()F + public final fun getCanSendNonUrgent ()Z + public final fun getChannelUtil ()F + public final fun getLevel ()Lorg/meshtastic/sdk/CongestionLevel; + public final fun getSuggestedBackoff ()Lkotlin/time/Duration; + public final fun getSuggestedBackoff-UwyO8pc ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/CongestionMetrics$Companion { +} + public final class org/meshtastic/sdk/ConnectKt { public static final fun connectAndAwaitReady-8Mi8wO0 (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun connectAndAwaitReady-8Mi8wO0$default (Lorg/meshtastic/sdk/RadioClient;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } +public final class org/meshtastic/sdk/ConnectionQuality : java/lang/Enum { + public static final field DIRECT Lorg/meshtastic/sdk/ConnectionQuality; + public static final field MQTT Lorg/meshtastic/sdk/ConnectionQuality; + public static final field RELAYED Lorg/meshtastic/sdk/ConnectionQuality; + public static final field UNKNOWN Lorg/meshtastic/sdk/ConnectionQuality; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/ConnectionQuality; + public static fun values ()[Lorg/meshtastic/sdk/ConnectionQuality; +} + public abstract interface class org/meshtastic/sdk/ConnectionState { } @@ -265,6 +444,27 @@ public final class org/meshtastic/sdk/ConnectionState$Reconnecting : org/meshtas public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/DeviceCapabilities { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/meshtastic/sdk/DeviceCapabilities; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/DeviceCapabilities;Ljava/lang/String;ILjava/lang/Object;)Lorg/meshtastic/sdk/DeviceCapabilities; + public fun equals (Ljava/lang/Object;)Z + public final fun getCanMuteNode ()Z + public final fun getCanSendVerifiedContacts ()Z + public final fun getCanToggleTelemetryEnabled ()Z + public final fun getCanToggleUnmessageable ()Z + public final fun getFirmwareVersion ()Ljava/lang/String; + public final fun getSupportsEsp32Ota ()Z + public final fun getSupportsQrCodeSharing ()Z + public final fun getSupportsSecondaryChannelLocation ()Z + public final fun getSupportsStatusMessage ()Z + public final fun getSupportsTakConfig ()Z + public final fun getSupportsTrafficManagementConfig ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/meshtastic/sdk/DeviceStorage : java/lang/AutoCloseable { public abstract fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun close ()V @@ -282,6 +482,26 @@ public abstract interface class org/meshtastic/sdk/DeviceStorage : java/lang/Aut public abstract fun saveSessionPasskey (Lorg/meshtastic/sdk/SessionPasskey;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class org/meshtastic/sdk/DeviceVersion : java/lang/Comparable { + public static final field Companion Lorg/meshtastic/sdk/DeviceVersion$Companion; + public fun (Ljava/lang/String;)V + public synthetic fun compareTo (Ljava/lang/Object;)I + public fun compareTo (Lorg/meshtastic/sdk/DeviceVersion;)I + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/meshtastic/sdk/DeviceVersion; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/DeviceVersion;Ljava/lang/String;ILjava/lang/Object;)Lorg/meshtastic/sdk/DeviceVersion; + public fun equals (Ljava/lang/Object;)Z + public final fun getAsInt ()I + public final fun getVersionString ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/DeviceVersion$Companion { + public final fun getABS_MIN_SUPPORTED ()Lorg/meshtastic/sdk/DeviceVersion; + public final fun getMIN_SUPPORTED ()Lorg/meshtastic/sdk/DeviceVersion; +} + public final class org/meshtastic/sdk/DroppedFlow : java/lang/Enum { public static final field Events Lorg/meshtastic/sdk/DroppedFlow; public static final field Packets Lorg/meshtastic/sdk/DroppedFlow; @@ -290,6 +510,15 @@ public final class org/meshtastic/sdk/DroppedFlow : java/lang/Enum { public static fun values ()[Lorg/meshtastic/sdk/DroppedFlow; } +public final class org/meshtastic/sdk/ExternalChangeKind : java/lang/Enum { + public static final field CHANNEL Lorg/meshtastic/sdk/ExternalChangeKind; + public static final field CONFIG Lorg/meshtastic/sdk/ExternalChangeKind; + public static final field MODULE_CONFIG Lorg/meshtastic/sdk/ExternalChangeKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/ExternalChangeKind; + public static fun values ()[Lorg/meshtastic/sdk/ExternalChangeKind; +} + public final class org/meshtastic/sdk/FirmwareTimeKt { public static final fun firmwareSecondsToInstant (I)Lkotlin/time/Instant; public static final fun relativeTo (Lkotlin/time/Instant;Lkotlin/time/Instant;)Ljava/lang/String; @@ -374,6 +603,17 @@ public final class org/meshtastic/sdk/LoggingKt { public abstract interface class org/meshtastic/sdk/MeshEvent { } +public final class org/meshtastic/sdk/MeshEvent$CongestionWarning : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/sdk/CongestionMetrics;)V + public final fun component1 ()Lorg/meshtastic/sdk/CongestionMetrics; + public final fun copy (Lorg/meshtastic/sdk/CongestionMetrics;)Lorg/meshtastic/sdk/MeshEvent$CongestionWarning; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$CongestionWarning;Lorg/meshtastic/sdk/CongestionMetrics;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$CongestionWarning; + public fun equals (Ljava/lang/Object;)Z + public final fun getMetrics ()Lorg/meshtastic/sdk/CongestionMetrics; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$DeviceRebooted : org/meshtastic/sdk/MeshEvent { public fun ()V public fun (Ljava/lang/String;)V @@ -387,6 +627,17 @@ public final class org/meshtastic/sdk/MeshEvent$DeviceRebooted : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$ExternalConfigChange : org/meshtastic/sdk/MeshEvent { + public fun (Lorg/meshtastic/sdk/ExternalChangeKind;)V + public final fun component1 ()Lorg/meshtastic/sdk/ExternalChangeKind; + public final fun copy (Lorg/meshtastic/sdk/ExternalChangeKind;)Lorg/meshtastic/sdk/MeshEvent$ExternalConfigChange; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$ExternalConfigChange;Lorg/meshtastic/sdk/ExternalChangeKind;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$ExternalConfigChange; + public fun equals (Ljava/lang/Object;)Z + public final fun getKind ()Lorg/meshtastic/sdk/ExternalChangeKind; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$IdentityRebound : org/meshtastic/sdk/MeshEvent { public synthetic fun (IILjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (IILjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -420,6 +671,26 @@ public final class org/meshtastic/sdk/MeshEvent$KeyVerification : org/meshtastic public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshEvent$MqttConnected : org/meshtastic/sdk/MeshEvent { + public static final field INSTANCE Lorg/meshtastic/sdk/MeshEvent$MqttConnected; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/MeshEvent$MqttDisconnected : org/meshtastic/sdk/MeshEvent { + public fun ()V + public fun (Ljava/lang/String;)V + public synthetic fun (Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lorg/meshtastic/sdk/MeshEvent$MqttDisconnected; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshEvent$MqttDisconnected;Ljava/lang/String;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshEvent$MqttDisconnected; + public fun equals (Ljava/lang/Object;)Z + public final fun getReason ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/MeshEvent$Notification : org/meshtastic/sdk/MeshEvent { public fun (Lorg/meshtastic/proto/ClientNotification;)V public final fun component1 ()Lorg/meshtastic/proto/ClientNotification; @@ -508,9 +779,93 @@ public final class org/meshtastic/sdk/MeshEvent$TransportError : org/meshtastic/ public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/MeshNode { + public fun (Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;)V + public final fun component1 ()Lorg/meshtastic/proto/NodeInfo; + public final fun component2 ()Z + public final fun component3 ()Lorg/meshtastic/sdk/ConnectionQuality; + public final fun component4 ()Lorg/meshtastic/sdk/SignalQuality; + public final fun copy (Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;)Lorg/meshtastic/sdk/MeshNode; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/MeshNode;Lorg/meshtastic/proto/NodeInfo;ZLorg/meshtastic/sdk/ConnectionQuality;Lorg/meshtastic/sdk/SignalQuality;ILjava/lang/Object;)Lorg/meshtastic/sdk/MeshNode; + public fun equals (Ljava/lang/Object;)Z + public final fun getAirUtilTx ()Ljava/lang/Float; + public final fun getAltitude ()Ljava/lang/Integer; + public final fun getBatteryLevel ()Ljava/lang/Integer; + public final fun getChannelUtilization ()Ljava/lang/Float; + public final fun getConnectionQuality ()Lorg/meshtastic/sdk/ConnectionQuality; + public final fun getDeviceMetrics ()Lorg/meshtastic/proto/DeviceMetrics; + public final fun getHopsAway ()Ljava/lang/Integer; + public final fun getHwModel ()Lorg/meshtastic/proto/HardwareModel; + public final fun getLastHeard ()I + public final fun getLatitude ()Ljava/lang/Double; + public final fun getLongName ()Ljava/lang/String; + public final fun getLongitude ()Ljava/lang/Double; + public final fun getMeshId ()Ljava/lang/String; + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public final fun getNodeNum ()I + public final fun getPosition ()Lorg/meshtastic/proto/Position; + public final fun getRaw ()Lorg/meshtastic/proto/NodeInfo; + public final fun getShortName ()Ljava/lang/String; + public final fun getSignalQuality ()Lorg/meshtastic/sdk/SignalQuality; + public final fun getSnr ()F + public final fun getUser ()Lorg/meshtastic/proto/User; + public final fun getViaMqtt ()Z + public final fun getVoltage ()Ljava/lang/Float; + public fun hashCode ()I + public final fun isFavorite ()Z + public final fun isIgnored ()Z + public final fun isMuted ()Z + public final fun isOnline ()Z + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/MeshNodeKt { + public static final fun toMeshNode (Lorg/meshtastic/proto/NodeInfo;I)Lorg/meshtastic/sdk/MeshNode; + public static final fun toMeshNodes (Ljava/lang/Iterable;I)Ljava/util/List; +} + public abstract interface annotation class org/meshtastic/sdk/MeshSendDsl : java/lang/annotation/Annotation { } +public final class org/meshtastic/sdk/MeshTopology { + public fun ()V + public final fun addNeighborInfo (Lorg/meshtastic/sdk/NeighborInfo;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun allEdges (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun clear (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun edgeCount (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getEdge-52y08Yw (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getNeighbors-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun isDirectReach-52y08Yw (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun nodes (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun removeNode-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun shortestPath-52y08Yw (IILkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class org/meshtastic/sdk/MeshTopology$Edge { + public synthetic fun (IIFIILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IIFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/NodeId;FI)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()Lorg/meshtastic/sdk/NodeId; + public final fun component2-Unx0hic ()I + public final fun component3 ()F + public final fun component4 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/NodeId;FI)Lorg/meshtastic/sdk/MeshTopology$Edge; + public final fun copy-Lh4n1GE (IIFI)Lorg/meshtastic/sdk/MeshTopology$Edge; + public static synthetic fun copy-Lh4n1GE$default (Lorg/meshtastic/sdk/MeshTopology$Edge;IIFIILjava/lang/Object;)Lorg/meshtastic/sdk/MeshTopology$Edge; + public fun equals (Ljava/lang/Object;)Z + public final fun getFrom ()Lorg/meshtastic/sdk/NodeId; + public final fun getFrom-Unx0hic ()I + public final fun getLastUpdated ()I + public final fun getSnr ()F + public final fun getTo ()Lorg/meshtastic/sdk/NodeId; + public final fun getTo-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class org/meshtastic/sdk/MeshtasticException : java/lang/Exception { public static final field Companion Lorg/meshtastic/sdk/MeshtasticException$Companion; public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;Lkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -581,6 +936,7 @@ public final class org/meshtastic/sdk/MessageHandle { public final class org/meshtastic/sdk/MessageHandleRetryKt { public static final fun retry (Lorg/meshtastic/sdk/MessageHandle;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun retryWith (Lorg/meshtastic/sdk/MessageHandle;Lorg/meshtastic/sdk/RetryPolicy;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class org/meshtastic/sdk/MessageHelpersKt { @@ -611,6 +967,51 @@ public final class org/meshtastic/sdk/MessageId { public final synthetic fun unbox-impl ()I } +public final class org/meshtastic/sdk/NeighborInfo { + public static final field Companion Lorg/meshtastic/sdk/NeighborInfo$Companion; + public synthetic fun (ILjava/util/List;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ILjava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;Ljava/util/List;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()Ljava/util/List; + public final fun component3 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;Ljava/util/List;I)Lorg/meshtastic/sdk/NeighborInfo; + public final fun copy-TEw7vI0 (ILjava/util/List;I)Lorg/meshtastic/sdk/NeighborInfo; + public static synthetic fun copy-TEw7vI0$default (Lorg/meshtastic/sdk/NeighborInfo;ILjava/util/List;IILjava/lang/Object;)Lorg/meshtastic/sdk/NeighborInfo; + public fun equals (Ljava/lang/Object;)Z + public final fun format (Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public static synthetic fun format$default (Lorg/meshtastic/sdk/NeighborInfo;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/String; + public final fun getLastUpdated ()I + public final fun getNeighbors ()Ljava/util/List; + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/NeighborInfo$Companion { + public final fun fromProto (ILjava/util/List;Ljava/util/List;I)Lorg/meshtastic/sdk/NeighborInfo; + public static synthetic fun fromProto$default (Lorg/meshtastic/sdk/NeighborInfo$Companion;ILjava/util/List;Ljava/util/List;IILjava/lang/Object;)Lorg/meshtastic/sdk/NeighborInfo; +} + +public final class org/meshtastic/sdk/NeighborInfo$Neighbor { + public synthetic fun (IFLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;F)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()F + public final fun copy (Lorg/meshtastic/sdk/NodeId;F)Lorg/meshtastic/sdk/NeighborInfo$Neighbor; + public final fun copy-UyS-FeY (IF)Lorg/meshtastic/sdk/NeighborInfo$Neighbor; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/NeighborInfo$Neighbor;IFILjava/lang/Object;)Lorg/meshtastic/sdk/NeighborInfo$Neighbor; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public final fun getSnr ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/meshtastic/sdk/NodeChange { } @@ -625,6 +1026,21 @@ public final class org/meshtastic/sdk/NodeChange$Added : org/meshtastic/sdk/Node public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeChange$CameOnline : org/meshtastic/sdk/NodeChange { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/NodeChange$CameOnline; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/NodeChange$CameOnline; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/NodeChange$CameOnline;IILjava/lang/Object;)Lorg/meshtastic/sdk/NodeChange$CameOnline; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/NodeChange$Removed : org/meshtastic/sdk/NodeChange { public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Lorg/meshtastic/sdk/NodeId;)V @@ -664,6 +1080,23 @@ public final class org/meshtastic/sdk/NodeChange$Updated : org/meshtastic/sdk/No public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeChange$WentOffline : org/meshtastic/sdk/NodeChange { + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/NodeChange$WentOffline; + public final fun copy-UyS-FeY (II)Lorg/meshtastic/sdk/NodeChange$WentOffline; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/NodeChange$WentOffline;IIILjava/lang/Object;)Lorg/meshtastic/sdk/NodeChange$WentOffline; + public fun equals (Ljava/lang/Object;)Z + public final fun getLastHeard ()I + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class org/meshtastic/sdk/NodeField : java/lang/Enum { public static final field Battery Lorg/meshtastic/sdk/NodeField; public static final field DeviceInfo Lorg/meshtastic/sdk/NodeField; @@ -703,6 +1136,7 @@ public final class org/meshtastic/sdk/NodeId$Companion { } public final class org/meshtastic/sdk/NodeIdsKt { + public static final fun fromDefaultId (Lorg/meshtastic/sdk/NodeId$Companion;Ljava/lang/String;)Lorg/meshtastic/sdk/NodeId; public static final fun fromHex (Lorg/meshtastic/sdk/NodeId$Companion;Ljava/lang/String;)Lorg/meshtastic/sdk/NodeId; public static final fun isBroadcast (Lorg/meshtastic/sdk/NodeId;)Z public static final fun isBroadcast-LO_6HKw (I)Z @@ -711,6 +1145,8 @@ public final class org/meshtastic/sdk/NodeIdsKt { public static synthetic fun isLocal-e0xYr-o$default (ILorg/meshtastic/sdk/NodeId;ILjava/lang/Object;)Z public static final fun isUnicast (Lorg/meshtastic/sdk/NodeId;)Z public static final fun isUnicast-LO_6HKw (I)Z + public static final fun toDefaultId (Lorg/meshtastic/sdk/NodeId;)Ljava/lang/String; + public static final fun toDefaultId-LO_6HKw (I)Ljava/lang/String; public static final fun toHex (Lorg/meshtastic/sdk/NodeId;)Ljava/lang/String; public static final fun toHex-LO_6HKw (I)Ljava/lang/String; } @@ -722,6 +1158,15 @@ public final class org/meshtastic/sdk/NodeInfosKt { public static final fun getShortName (Lorg/meshtastic/proto/NodeInfo;)Ljava/lang/String; } +public final class org/meshtastic/sdk/NodeStatusKt { + public static final fun getConnectionQuality (Lorg/meshtastic/proto/NodeInfo;)Lorg/meshtastic/sdk/ConnectionQuality; + public static final fun getDEFAULT_ONLINE_THRESHOLD ()J + public static final fun getSignalQuality (Lorg/meshtastic/proto/NodeInfo;)Lorg/meshtastic/sdk/SignalQuality; + public static final fun isOnline (Lorg/meshtastic/proto/NodeInfo;ILkotlin/time/Duration;)Z + public static final fun isOnline-SxA4cEA (Lorg/meshtastic/proto/NodeInfo;IJ)Z + public static synthetic fun isOnline-SxA4cEA$default (Lorg/meshtastic/proto/NodeInfo;IJILjava/lang/Object;)Z +} + public final class org/meshtastic/sdk/PacketDecodeKt { public static final fun decodeAs (Lorg/meshtastic/proto/MeshPacket;Lcom/squareup/wire/ProtoAdapter;)Lcom/squareup/wire/Message; public static final fun decodeAsAdmin (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/AdminMessage; @@ -735,12 +1180,16 @@ public final class org/meshtastic/sdk/PacketDecodeKt { public final class org/meshtastic/sdk/PayloadAccessorsKt { public static final fun asAdminMessage (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/AdminMessage; + public static final fun asNeighborInfo (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/NeighborInfo; public static final fun asNodeInfo (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/NodeInfo; public static final fun asNodeInfoUser (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/User; public static final fun asPosition (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Position; public static final fun asRouting (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Routing; public static final fun asTelemetry (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Telemetry; public static final fun asText (Lorg/meshtastic/proto/MeshPacket;)Ljava/lang/String; + public static final fun asTraceroute (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/RouteDiscovery; + public static final fun asWaypoint (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/proto/Waypoint; + public static final fun getTextMessages (Lorg/meshtastic/sdk/RadioClient;)Lkotlinx/coroutines/flow/Flow; } public abstract interface class org/meshtastic/sdk/PayloadRedactor { @@ -751,6 +1200,16 @@ public final class org/meshtastic/sdk/PayloadRedactor$Companion { public final fun getDefault ()Lorg/meshtastic/sdk/PayloadRedactor; } +public final class org/meshtastic/sdk/PositionUtils { + public static final field INSTANCE Lorg/meshtastic/sdk/PositionUtils; + public final fun bearing (DDDD)D + public final fun bearing (Lorg/meshtastic/sdk/LatLng;Lorg/meshtastic/sdk/LatLng;)D + public final fun distance (DDDD)D + public final fun distance (Lorg/meshtastic/sdk/LatLng;Lorg/meshtastic/sdk/LatLng;)D + public final fun intToDegrees (I)D + public final fun isValidPosition (DD)Z +} + public final class org/meshtastic/sdk/ProtoBytesKt { public static final fun toByteArray (Lorg/meshtastic/proto/FromRadio;)[B public static final fun toByteArray (Lorg/meshtastic/proto/MeshPacket;)[B @@ -767,6 +1226,7 @@ public final class org/meshtastic/sdk/RadioClient : java/lang/AutoCloseable { public final fun connect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun disconnect (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun getAdmin ()Lorg/meshtastic/sdk/AdminApi; + public final fun getChannels ()Lkotlinx/coroutines/flow/StateFlow; public final fun getConfigBundle ()Lkotlinx/coroutines/flow/StateFlow; public final fun getConnection ()Lkotlinx/coroutines/flow/StateFlow; public final fun getEvents ()Lkotlinx/coroutines/flow/Flow; @@ -774,13 +1234,20 @@ public final class org/meshtastic/sdk/RadioClient : java/lang/AutoCloseable { public final fun getOwnNode ()Lkotlinx/coroutines/flow/StateFlow; public final fun getPackets ()Lkotlinx/coroutines/flow/Flow; public final fun getRouting ()Lorg/meshtastic/sdk/RoutingApi; + public final fun getStoreForward ()Lorg/meshtastic/sdk/StoreForwardApi; public final fun getTelemetry ()Lorg/meshtastic/sdk/TelemetryApi; public final fun nodeSnapshot (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun requestNodeInfo (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/MessageHandle; + public final fun requestNodeInfo-LO_6HKw (I)Lorg/meshtastic/sdk/MessageHandle; public final fun send (Lorg/meshtastic/proto/MeshPacket;)Lorg/meshtastic/sdk/MessageHandle; public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;Lkotlinx/io/Buffer;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun send-IJD_lpo (Lorg/meshtastic/proto/PortNum;[BIIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;Lkotlinx/io/Buffer;IIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun send-IJD_lpo$default (Lorg/meshtastic/sdk/RadioClient;Lorg/meshtastic/proto/PortNum;[BIIZLjava/lang/Integer;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public final fun sendRaw (Lorg/meshtastic/proto/ToRadio;)V + public final fun sendReaction (Ljava/lang/String;Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/ChannelIndex;I)Lorg/meshtastic/sdk/MessageHandle; + public final fun sendReaction-soBxvt8 (Ljava/lang/String;III)Lorg/meshtastic/sdk/MessageHandle; + public static synthetic fun sendReaction-soBxvt8$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; public final fun sendText (Ljava/lang/String;Lorg/meshtastic/sdk/ChannelIndex;Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/MessageHandle; public final fun sendText-0mzNYbQ (Ljava/lang/String;II)Lorg/meshtastic/sdk/MessageHandle; public static synthetic fun sendText-0mzNYbQ$default (Lorg/meshtastic/sdk/RadioClient;Ljava/lang/String;IIILjava/lang/Object;)Lorg/meshtastic/sdk/MessageHandle; @@ -798,6 +1265,8 @@ public final class org/meshtastic/sdk/RadioClient$Builder { public final fun coroutineContext (Lkotlin/coroutines/CoroutineContext;)Lorg/meshtastic/sdk/RadioClient$Builder; public final fun disableBleHeartbeat ()Lorg/meshtastic/sdk/RadioClient$Builder; public final fun logger (Lorg/meshtastic/sdk/LogSink;)Lorg/meshtastic/sdk/RadioClient$Builder; + public final fun presenceTimeout (Lkotlin/time/Duration;)Lorg/meshtastic/sdk/RadioClient$Builder; + public final fun presenceTimeout-LRDsOJo (J)Lorg/meshtastic/sdk/RadioClient$Builder; public final fun protocolLogging (Lorg/meshtastic/sdk/LogLevel;Lorg/meshtastic/sdk/PayloadRedactor;)Lorg/meshtastic/sdk/RadioClient$Builder; public static synthetic fun protocolLogging$default (Lorg/meshtastic/sdk/RadioClient$Builder;Lorg/meshtastic/sdk/LogLevel;Lorg/meshtastic/sdk/PayloadRedactor;ILjava/lang/Object;)Lorg/meshtastic/sdk/RadioClient$Builder; public final fun rpcTimeout (Lkotlin/time/Duration;)Lorg/meshtastic/sdk/RadioClient$Builder; @@ -847,6 +1316,105 @@ public abstract interface class org/meshtastic/sdk/RadioTransport { public abstract fun send (Lorg/meshtastic/sdk/Frame;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public final class org/meshtastic/sdk/ResultKt { + public static final fun fold (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun getOrElse (Lorg/meshtastic/sdk/AdminResult;Ljava/lang/Object;)Ljava/lang/Object; + public static final fun getOrElse (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun getOrNull (Lorg/meshtastic/sdk/AdminResult;)Ljava/lang/Object; + public static final fun getOrThrow (Lorg/meshtastic/sdk/AdminResult;)Ljava/lang/Object; + public static final fun getStatusMessage (Lorg/meshtastic/sdk/ConnectionState;)Ljava/lang/String; + public static final fun isInProgress (Lorg/meshtastic/sdk/ConnectionState;)Z + public static final fun isSuccess (Lorg/meshtastic/sdk/AdminResult;)Z + public static final fun isUsable (Lorg/meshtastic/sdk/ConnectionState;)Z + public static final fun map (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/AdminResult; + public static final fun onFailure (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/AdminResult; + public static final fun onSuccess (Lorg/meshtastic/sdk/AdminResult;Lkotlin/jvm/functions/Function1;)Lorg/meshtastic/sdk/AdminResult; +} + +public abstract class org/meshtastic/sdk/RetryPolicy { + public final fun delayForAttempt (I)Lkotlin/time/Duration; + public final fun delayForAttempt-LV8wdWc (I)Lkotlin/time/Duration; + public final fun getMaxRetries ()I +} + +public final class org/meshtastic/sdk/RetryPolicy$ExponentialBackoff : org/meshtastic/sdk/RetryPolicy { + public synthetic fun (IJJDDILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IJJDDLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ILkotlin/time/Duration;Lkotlin/time/Duration;DD)V + public final fun component1 ()I + public final fun component2 ()Lkotlin/time/Duration; + public final fun component2-UwyO8pc ()J + public final fun component3 ()Lkotlin/time/Duration; + public final fun component3-UwyO8pc ()J + public final fun component4 ()D + public final fun component5 ()D + public final fun copy (ILkotlin/time/Duration;Lkotlin/time/Duration;DD)Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff; + public final fun copy-jKevqZI (IJJDD)Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff; + public static synthetic fun copy-jKevqZI$default (Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff;IJJDDILjava/lang/Object;)Lorg/meshtastic/sdk/RetryPolicy$ExponentialBackoff; + public fun equals (Ljava/lang/Object;)Z + public final fun getInitialDelay ()Lkotlin/time/Duration; + public final fun getInitialDelay-UwyO8pc ()J + public final fun getJitterFactor ()D + public final fun getMaxAttempts ()I + public final fun getMaxDelay ()Lkotlin/time/Duration; + public final fun getMaxDelay-UwyO8pc ()J + public final fun getMultiplier ()D + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RetryPolicy$Fixed : org/meshtastic/sdk/RetryPolicy { + public synthetic fun (IJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (IJLkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ILkotlin/time/Duration;)V + public final fun component1 ()I + public final fun component2 ()Lkotlin/time/Duration; + public final fun component2-UwyO8pc ()J + public final fun copy (ILkotlin/time/Duration;)Lorg/meshtastic/sdk/RetryPolicy$Fixed; + public final fun copy-HG0u8IE (IJ)Lorg/meshtastic/sdk/RetryPolicy$Fixed; + public static synthetic fun copy-HG0u8IE$default (Lorg/meshtastic/sdk/RetryPolicy$Fixed;IJILjava/lang/Object;)Lorg/meshtastic/sdk/RetryPolicy$Fixed; + public fun equals (Ljava/lang/Object;)Z + public final fun getDelay ()Lkotlin/time/Duration; + public final fun getDelay-UwyO8pc ()J + public final fun getMaxAttempts ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RetryPolicy$None : org/meshtastic/sdk/RetryPolicy { + public static final field INSTANCE Lorg/meshtastic/sdk/RetryPolicy$None; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RouteDiscoveryResult { + public static final field Companion Lorg/meshtastic/sdk/RouteDiscoveryResult$Companion; + public fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)V + public synthetic fun (Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/util/List; + public final fun component4 ()Ljava/util/List; + public final fun copy (Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/RouteDiscoveryResult;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public fun equals (Ljava/lang/Object;)Z + public final fun formatRoute (Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public final fun getHopsAway ()I + public final fun getRoute ()Ljava/util/List; + public final fun getRouteBack ()Ljava/util/List; + public final fun getSnrBack ()Ljava/util/List; + public final fun getSnrTowards ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/RouteDiscoveryResult$Companion { + public final fun fromProto (Lorg/meshtastic/sdk/NodeId;Lorg/meshtastic/sdk/NodeId;Ljava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public final fun fromProto-MM27f9Y (IILjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;)Lorg/meshtastic/sdk/RouteDiscoveryResult; + public static synthetic fun fromProto-MM27f9Y$default (Lorg/meshtastic/sdk/RouteDiscoveryResult$Companion;IILjava/util/List;Ljava/util/List;Ljava/util/List;Ljava/util/List;ILjava/lang/Object;)Lorg/meshtastic/sdk/RouteDiscoveryResult; +} + public abstract interface class org/meshtastic/sdk/RoutingApi { public static final field Companion Lorg/meshtastic/sdk/RoutingApi$Companion; public static final field DEFAULT_HOP_LIMIT I @@ -1053,10 +1621,187 @@ public final class org/meshtastic/sdk/SessionPasskey { public fun toString ()Ljava/lang/String; } +public final class org/meshtastic/sdk/SfppHash { + public static final field INSTANCE Lorg/meshtastic/sdk/SfppHash; + public final fun compute ([BIII)[B +} + +public final class org/meshtastic/sdk/SharedContactUrl { + public static final field INSTANCE Lorg/meshtastic/sdk/SharedContactUrl; + public static final field PREFIX Ljava/lang/String; + public final fun encode (Lorg/meshtastic/proto/SharedContact;)Ljava/lang/String; + public final fun parse (Ljava/lang/String;)Lorg/meshtastic/proto/SharedContact; +} + +public final class org/meshtastic/sdk/SharedContactUrlKt { + public static final fun toUrl (Lorg/meshtastic/proto/SharedContact;)Ljava/lang/String; +} + +public final class org/meshtastic/sdk/SignalQuality : java/lang/Enum { + public static final field FAIR Lorg/meshtastic/sdk/SignalQuality; + public static final field GOOD Lorg/meshtastic/sdk/SignalQuality; + public static final field NONE Lorg/meshtastic/sdk/SignalQuality; + public static final field POOR Lorg/meshtastic/sdk/SignalQuality; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lorg/meshtastic/sdk/SignalQuality; + public static fun values ()[Lorg/meshtastic/sdk/SignalQuality; +} + public abstract interface class org/meshtastic/sdk/StorageProvider { public abstract fun activate-153iwNM (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class org/meshtastic/sdk/StoreForwardApi { + public abstract fun getEvents ()Lkotlinx/coroutines/flow/Flow; + public abstract fun getServers ()Lkotlinx/coroutines/flow/StateFlow; + public abstract fun requestHistory-kfYOmcw (Ljava/lang/Integer;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestHistory-kfYOmcw$default (Lorg/meshtastic/sdk/StoreForwardApi;Ljava/lang/Integer;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestStats-J5zNcmc (Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestStats-J5zNcmc$default (Lorg/meshtastic/sdk/StoreForwardApi;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public final class org/meshtastic/sdk/StoreForwardApi$DefaultImpls { + public static synthetic fun requestHistory-kfYOmcw$default (Lorg/meshtastic/sdk/StoreForwardApi;Ljava/lang/Integer;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestStats-J5zNcmc$default (Lorg/meshtastic/sdk/StoreForwardApi;Lorg/meshtastic/sdk/NodeId;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + +public abstract interface class org/meshtastic/sdk/StoreForwardEvent { +} + +public final class org/meshtastic/sdk/StoreForwardEvent$Heartbeat : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat;IILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$Heartbeat; + public fun equals (Ljava/lang/Object;)Z + public final fun getServer ()Lorg/meshtastic/sdk/NodeId; + public final fun getServer-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete; + public final fun copy-UyS-FeY (II)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete;IIILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayComplete; + public fun equals (Ljava/lang/Object;)Z + public final fun getDelivered ()I + public final fun getServer ()Lorg/meshtastic/sdk/NodeId; + public final fun getServer-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;I)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun component2 ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;I)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted; + public final fun copy-UyS-FeY (II)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted; + public static synthetic fun copy-UyS-FeY$default (Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted;IIILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$HistoryReplayStarted; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessageCount ()I + public final fun getServer ()Lorg/meshtastic/sdk/NodeId; + public final fun getServer-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$ServerDiscovered : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered;IILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerDiscovered; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$ServerLost : org/meshtastic/sdk/StoreForwardEvent { + public synthetic fun (ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lorg/meshtastic/sdk/NodeId;)V + public final fun component1 ()Lorg/meshtastic/sdk/NodeId; + public final fun component1-Unx0hic ()I + public final fun copy (Lorg/meshtastic/sdk/NodeId;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost; + public final fun copy-LO_6HKw (I)Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost; + public static synthetic fun copy-LO_6HKw$default (Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost;IILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$ServerLost; + public fun equals (Ljava/lang/Object;)Z + public final fun getNodeId ()Lorg/meshtastic/sdk/NodeId; + public final fun getNodeId-Unx0hic ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced : org/meshtastic/sdk/StoreForwardEvent { + public fun ([BJ)V + public final fun component1 ()[B + public final fun component2 ()J + public final fun copy ([BJ)Lorg/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced;[BJILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$SfppCanonAnnounced; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessageHash ()[B + public final fun getRxTime ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided : org/meshtastic/sdk/StoreForwardEvent { + public fun (III[BZ)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()[B + public final fun component5 ()Z + public final fun copy (III[BZ)Lorg/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided;III[BZILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardEvent$SfppLinkProvided; + public fun equals (Ljava/lang/Object;)Z + public final fun getConfirmed ()Z + public final fun getFrom ()I + public final fun getMessageHash ()[B + public final fun getPacketId ()I + public final fun getTo ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class org/meshtastic/sdk/StoreForwardStats { + public fun ()V + public fun (IIIIIZ)V + public synthetic fun (IIIIIZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2 ()I + public final fun component3 ()I + public final fun component4 ()I + public final fun component5 ()I + public final fun component6 ()Z + public final fun copy (IIIIIZ)Lorg/meshtastic/sdk/StoreForwardStats; + public static synthetic fun copy$default (Lorg/meshtastic/sdk/StoreForwardStats;IIIIIZILjava/lang/Object;)Lorg/meshtastic/sdk/StoreForwardStats; + public fun equals (Ljava/lang/Object;)Z + public final fun getHeartbeat ()Z + public final fun getMessagesMax ()I + public final fun getMessagesStored ()I + public final fun getRequests ()I + public final fun getRequestsFailed ()I + public final fun getUptime ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class org/meshtastic/sdk/TelemetryApi { public abstract fun observe-LO_6HKw (I)Lkotlinx/coroutines/flow/Flow; public abstract fun requestAirQuality-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -1065,16 +1810,25 @@ public abstract interface class org/meshtastic/sdk/TelemetryApi { public static synthetic fun requestDevice-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun requestEnvironment-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun requestEnvironment-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestHealth-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestHealth-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestHost-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestHost-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun requestLocalStats (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun requestPower-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun requestPower-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public abstract fun requestTrafficManagement-UyS-FeY (ILkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun requestTrafficManagement-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class org/meshtastic/sdk/TelemetryApi$DefaultImpls { public static synthetic fun requestAirQuality-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun requestDevice-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun requestEnvironment-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestHealth-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestHost-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public static synthetic fun requestPower-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun requestTrafficManagement-UyS-FeY$default (Lorg/meshtastic/sdk/TelemetryApi;ILkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public abstract class org/meshtastic/sdk/TelemetryReading { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt index e3fd5e2..b6e94ae 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AdminApi.kt @@ -10,7 +10,16 @@ package org.meshtastic.sdk import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.Position +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import kotlin.time.Duration import kotlin.time.Instant @@ -29,6 +38,44 @@ import kotlin.time.Instant */ public interface AdminApi { + // ── Remote targeting ──────────────────────────────────────────────────── + + /** + * Return an [AdminApi] instance that targets [dest] instead of the local device. + * + * All calls on the returned instance route admin messages to the specified remote node + * over the mesh. Note: `editSettings`, `batch`, `getDeviceConnectionStatus`, and lifecycle + * commands (`reboot`, `shutdown`, `factoryReset`, `nodeDbReset`) work identically — the + * firmware handles admin-over-mesh transparently. + * + * ```kotlin + * val remoteAdmin = client.admin.forNode(NodeId(0x12345678.toInt())) + * remoteAdmin.setConfig(config) // → sent to remote node + * ``` + * + * @param dest the target node's [NodeId] + * @return a remote-targeting [AdminApi] instance + * @since 0.2.0 + */ + public fun forNode(dest: NodeId): AdminApi + + // ── Device info ───────────────────────────────────────────────────────── + + /** + * Request [DeviceMetadata] from the device (firmware version, hardware model, etc.). + * + * For the local node, this is cached during handshake and available via + * [RadioClient.deviceConfig]. For remote nodes, use [forNode] to target the desired node: + * + * ```kotlin + * val metadata = client.admin.forNode(remoteNodeId).getDeviceMetadata() + * ``` + * + * @return the device's metadata + * @since 0.2.0 + */ + public suspend fun getDeviceMetadata(): AdminResult + // ── Configs ───────────────────────────────────────────────────────────── /** Read a single [Config] section from the device. */ @@ -75,6 +122,130 @@ public interface AdminApi { /** Mark [node] as ignored — packets from it are filtered before reaching apps. */ public suspend fun setIgnored(node: NodeId, ignored: Boolean): AdminResult + /** + * Toggle mute state on [node] — muted nodes do not forward packets. + * + * Note: The firmware uses a toggle primitive (`toggle_muted_node`), so calling this + * always flips the current state. Track local mute state if you need idempotent behavior. + */ + public suspend fun toggleMuted(node: NodeId): AdminResult + + // ── Position ──────────────────────────────────────────────────────────── + + /** Set a fixed GPS position for the device (disables GPS module). */ + public suspend fun setFixedPosition(position: Position): AdminResult + + /** Remove the fixed position and re-enable GPS. */ + public suspend fun removeFixedPosition(): AdminResult + + // ── Device UI Config ──────────────────────────────────────────────────── + + /** Read the device's UI configuration (display preferences, language, etc.). */ + public suspend fun getUIConfig(): AdminResult + + /** Write the device's UI configuration. */ + public suspend fun storeUIConfig(config: DeviceUIConfig): AdminResult + + // ── Canned Messages ───────────────────────────────────────────────────── + + /** Read the canned message module's preset messages. */ + public suspend fun getCannedMessages(): AdminResult + + /** Write the canned message module's preset messages (pipe-delimited). */ + public suspend fun setCannedMessages(messages: String): AdminResult + + // ── Ringtone ──────────────────────────────────────────────────────────── + + /** Read the device's ringtone (RTTTL format). */ + public suspend fun getRingtone(): AdminResult + + /** Write the device's ringtone (RTTTL format). */ + public suspend fun setRingtone(rtttl: String): AdminResult + + // ── Device status ─────────────────────────────────────────────────────── + + /** Read the device's connection status (WiFi, BLE, Ethernet, MQTT). */ + public suspend fun getDeviceConnectionStatus(): AdminResult + + /** Read the remote hardware pin configuration of [node]. */ + public suspend fun getRemoteHardwarePins(): AdminResult + + // ── Ham radio ─────────────────────────────────────────────────────────── + + /** Configure the device for amateur radio use (sets call sign, disables encryption). */ + public suspend fun setHamMode(params: HamParameters): AdminResult + + // ── DFU / file management ─────────────────────────────────────────────── + + /** + * Enter DFU (firmware update) mode. The device will reboot into its bootloader. + * + * This is a fire-and-forget admin write; [AdminResult.Success] means the request was queued + * locally, not that the device stayed connected long enough to acknowledge the reboot. + */ + public suspend fun enterDfuMode(): AdminResult + + /** Delete a file from the device's filesystem. */ + public suspend fun deleteFile(path: String): AdminResult + + // ── Backup / Restore ──────────────────────────────────────────────────── + + /** Back up device preferences to the specified [location]. */ + public suspend fun backupPreferences( + location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH, + ): AdminResult + + /** Restore device preferences from the specified [location]. */ + public suspend fun restorePreferences( + location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH, + ): AdminResult + + /** Remove a stored preference backup from [location]. */ + public suspend fun removeBackupPreferences( + location: AdminMessage.BackupLocation = AdminMessage.BackupLocation.FLASH, + ): AdminResult + + // ── Node removal ──────────────────────────────────────────────────────── + + /** Remove a node from the device's NodeDB by its node number. */ + public suspend fun removeNode(node: NodeId): AdminResult + + // ── Input / Display ───────────────────────────────────────────────────── + + /** Set the device's scale calibration value (e-ink display DPI). */ + public suspend fun setScale(scale: Int): AdminResult + + /** Send a synthetic input event to the device (button press, touch, etc.). */ + public suspend fun sendInputEvent(event: AdminMessage.InputEvent): AdminResult + + // ── Contacts ──────────────────────────────────────────────────────────── + + /** Add a shared contact to the device's contact list. */ + public suspend fun addContact(contact: SharedContact): AdminResult + + // ── Key verification ──────────────────────────────────────────────────── + + /** Initiate or respond to a key verification exchange. */ + public suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult + + // ── OTA updates ───────────────────────────────────────────────────────── + + /** Reboot into OTA update mode after [after] (default: immediately). */ + public suspend fun rebootOta(after: Duration = Duration.ZERO): AdminResult + + /** Send an OTA event (firmware update control). */ + public suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult + + // ── Sensor ────────────────────────────────────────────────────────────── + + /** Configure a sensor attached to the device. */ + public suspend fun setSensorConfig(config: SensorConfig): AdminResult + + // ── Simulator ─────────────────────────────────────────────────────────── + + /** Exit the firmware simulator mode (development only). */ + public suspend fun exitSimulator(): AdminResult + // ── Lifecycle ─────────────────────────────────────────────────────────── /** @@ -101,14 +272,25 @@ public interface AdminApi { /** * Wipe the device's NodeDB, forcing a fresh discovery cycle on the mesh. * - * @param preserveFavorites when `true` (default), entries marked as favorites are kept. The - * firmware does not currently expose a separate flag for this; on devices that always erase - * the entire NodeDB, the SDK's [setFavorite] state on local entries is the only persistence. + * The firmware always preserves favorite-marked entries during the wipe (this is + * firmware-enforced behavior). The `nodedb_reset` proto field uses proto3 semantics where + * only `true` can be encoded — a "wipe everything including favorites" mode is not + * available through this command. + * + * The device will reboot after the reset completes. */ - public suspend fun nodeDbReset(preserveFavorites: Boolean = true): AdminResult + public suspend fun nodeDbReset(): AdminResult // ── Time ──────────────────────────────────────────────────────────────── + /** + * Set the device's wall clock from raw Unix time seconds without sending position data. + * + * This uses `AdminMessage.set_time_only` directly and is fire-and-forget; [AdminResult.Success] + * means the packet was queued locally. + */ + public suspend fun setTimeOnly(unixTime: Int): AdminResult + /** * Set the device's wall clock to [at] (default: `Clock.System.now()`). * @@ -127,6 +309,14 @@ public interface AdminApi { * or commit fails, the result reflects that failure and the block's return value is discarded. */ public suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult + + /** + * Exception-based counterpart to [editSettings] that also exposes batched getter helpers. + * + * Getter failures throw [AdminResultException] via [getOrThrow]. If [block] throws, the SDK + * does not send `commit_edit_settings`; firmware eventually discards the buffered edits. + */ + public suspend fun batch(block: suspend AdminBatchScope.() -> T): T } /** @@ -146,3 +336,15 @@ public interface AdminEdit { public suspend fun setFavorite(node: NodeId, favorite: Boolean) public suspend fun setIgnored(node: NodeId, ignored: Boolean) } + +/** + * Receiver type for [AdminApi.batch] — combines [AdminEdit] setters with getter helpers. + * + * Getter failures throw [AdminResultException] via [getOrThrow]. Setters share the same deferred + * commit semantics as [AdminApi.editSettings]. + */ +public interface AdminBatchScope : AdminEdit { + public suspend fun getConfig(type: AdminMessage.ConfigType): Config + public suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): ModuleConfig + public suspend fun listChannels(): List +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt index 3dac7ac..821bc21 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/AutoReconnectConfig.kt @@ -11,7 +11,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds /** - * Tunables for the engine's built-in auto-reconnect supervisor. + * Configuration for the engine's built-in auto-reconnect supervisor. * * Configure on the [RadioClient.Builder] via * [autoReconnect(...)][RadioClient.Builder.autoReconnect]. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt index 0b77658..1524a7c 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/BatteryStatus.kt @@ -10,11 +10,12 @@ package org.meshtastic.sdk import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.Telemetry /** - * Curated battery health and state information. + * Curated battery health and state information reported by the device. * * @property percent charge level (0..100). * @property voltageVolts raw battery voltage, if reported. * @property pluggedIn `true` if the device is currently drawing external power. + * @since 0.1.0 */ public data class BatteryStatus( public val percent: Int?, @@ -23,9 +24,10 @@ public data class BatteryStatus( ) /** - * Converts protobuf [DeviceMetrics] to [BatteryStatus]. + * Converts protobuf [DeviceMetrics] into a normalized [BatteryStatus] snapshot. * * Maps the firmware's `>= 101` level sentinel to [BatteryStatus.pluggedIn]. + * @since 0.1.0 */ public fun DeviceMetrics.toBatteryStatus(): BatteryStatus? { if (battery_level == null && voltage == null) return null diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt new file mode 100644 index 0000000..477444f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelHelpers.kt @@ -0,0 +1,84 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings + +/** + * Channel validation and helper utilities. + */ +public object ChannelHelpers { + /** Maximum allowed channel name length. */ + public const val MAX_NAME_LENGTH: Int = 11 + + /** Minimum PSK length for secure channels (128-bit AES). */ + public const val MIN_PSK_LENGTH: Int = 16 + + /** Maximum PSK length (256-bit AES). */ + public const val MAX_PSK_LENGTH: Int = 32 + + /** + * Validation result for channel configuration. + */ + public data class ValidationResult(val isValid: Boolean, val errors: List = emptyList()) + + /** + * Validates channel metadata before it is sent to the device. + * + * Checks name length, blank-only names, and supported PSK lengths. + * @since 0.1.0 + */ + public fun validate(name: String, psk: ByteArray, role: Channel.Role = Channel.Role.SECONDARY): ValidationResult { + val errors = mutableListOf() + if (name.length > MAX_NAME_LENGTH) { + errors += "Channel name exceeds $MAX_NAME_LENGTH characters" + } + if (name.isNotEmpty() && name.isBlank()) { + errors += "Channel name cannot be only whitespace" + } + val validPskLengths = setOf(0, 1, MIN_PSK_LENGTH, MAX_PSK_LENGTH) + if (psk.size !in validPskLengths) { + errors += "PSK must be 0 (none), 1 (default), 16 (AES-128), or 32 (AES-256) bytes" + } + if (role == Channel.Role.PRIMARY && name.isNotEmpty()) { + // Primary channel typically uses empty name (firmware convention). + } + return ValidationResult(isValid = errors.isEmpty(), errors = errors) + } + + /** + * Finds the first writable channel slot after the primary channel. + * + * Returns `null` when every slot up to [maxChannels] is already occupied. + * @since 0.1.0 + */ + public fun findEmptySlot(channels: List, maxChannels: Int = 8): Int? { + for (i in 1 until maxChannels) { + val channel = channels.getOrNull(i) + if (channel == null || channel.role == Channel.Role.DISABLED) return i + } + return null + } + + /** + * Creates a [ChannelSettings] only when [name] and [psk] pass [validate]. + * + * Returns `null` instead of throwing when validation fails. + * @since 0.1.0 + */ + public fun createSettings(name: String, psk: ByteArray = byteArrayOf(0x01)): ChannelSettings? { + val validation = validate(name, psk) + if (!validation.isValid) return null + return ChannelSettings( + name = name, + psk = psk.toByteString(), + ) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt index a407931..8376180 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ChannelUrls.kt @@ -11,6 +11,8 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Channel import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.ChannelSettings +import org.meshtastic.sdk.internal.base64UrlDecode +import org.meshtastic.sdk.internal.base64UrlEncode /** The default Pre-Shared Key (AES-128) used for the Meshtastic primary channel. */ public val DefaultPsk: ByteArray = byteArrayOf(0x01) @@ -27,8 +29,8 @@ public object ChannelUrl { /** * Parses a Meshtastic share link into a [ChannelSet]. * - * Returns `null` if the URL is malformed or its base64 payload fails to decode into a - * valid protobuf message. + * Returns `null` if the URL is malformed or its payload is not a valid channel protobuf. + * @since 0.1.0 */ public fun parse(url: String): ChannelSet? { val trimmed = url.trim() @@ -51,9 +53,10 @@ public fun Channel.Companion.default(): Channel = Channel( ) /** - * Computes the 8-bit hash of [name] and [psk], used by firmware to identify channels on the wire. + * Computes the firmware-compatible 8-bit channel hash for [name] and [psk]. * * Mirrors the logic in `Channels::generateHash`. + * @since 0.1.0 */ public fun ChannelSettings.Companion.hash(name: String, psk: ByteArray): Int { var code = 0 @@ -62,57 +65,17 @@ public fun ChannelSettings.Companion.hash(name: String, psk: ByteArray): Int { return code and 0xff } -private const val ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" - -private fun base64UrlEncode(bytes: ByteArray): String { - if (bytes.isEmpty()) return "" - val sb = StringBuilder((bytes.size * 4 + 2) / 3) - var i = 0 - while (i + 2 < bytes.size) { - val b0 = bytes[i].toInt() and 0xff - val b1 = bytes[i + 1].toInt() and 0xff - val b2 = bytes[i + 2].toInt() and 0xff - sb.append(ALPHABET[b0 ushr 2]) - sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) - sb.append(ALPHABET[((b1 and 0xf) shl 2) or (b2 ushr 6)]) - sb.append(ALPHABET[b2 and 0x3f]) - i += 3 - } - val rem = bytes.size - i - if (rem == 1) { - val b0 = bytes[i].toInt() and 0xff - sb.append(ALPHABET[b0 ushr 2]) - sb.append(ALPHABET[(b0 and 0x3) shl 4]) - } else if (rem == 2) { - val b0 = bytes[i].toInt() and 0xff - val b1 = bytes[i + 1].toInt() and 0xff - sb.append(ALPHABET[b0 ushr 2]) - sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) - sb.append(ALPHABET[(b1 and 0xf) shl 2]) - } - return sb.toString() -} - -private fun base64UrlDecode(input: String): ByteArray? { - val cleaned = input.trimEnd('=') - val out = ArrayList(cleaned.length * 3 / 4 + 2) - var buffer = 0 - var bits = 0 - for (ch in cleaned) { - val v = when (ch) { - in 'A'..'Z' -> ch - 'A' - in 'a'..'z' -> ch - 'a' + 26 - in '0'..'9' -> ch - '0' + 52 - '-' -> 62 - '_' -> 63 - else -> return null - } - buffer = (buffer shl 6) or v - bits += 6 - if (bits >= 8) { - bits -= 8 - out.add(((buffer ushr bits) and 0xff).toByte()) - } +/** + * Computes the DJB2 hash of a channel name. Used by some clients for channel identification + * separate from the on-wire XOR hash. + * + * @param name the channel name to hash + * @return unsigned 32-bit DJB2 hash + */ +public fun channelNameHashDjb2(name: String): UInt { + var hash = 5381u + for (c in name) { + hash += (hash shl 5) + c.code.toUInt() } - return out.toByteArray() + return hash } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt new file mode 100644 index 0000000..4b07a62 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/ConfigBuilders.kt @@ -0,0 +1,168 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig + +/** + * Convenience builders for common admin config writes. + * + * ```kotlin + * client.admin.setDeviceConfig { + * copy(role = Config.DeviceConfig.Role.CLIENT) + * } + * + * client.admin.setMqttConfig { + * copy(enabled = true) + * } + * ``` + */ +private suspend fun AdminApi.setConfigSection( + initial: T, + block: T.() -> T, + wrap: (T) -> Config, +): AdminResult = setConfig(wrap(initial.block())) + +private suspend fun AdminApi.setModuleConfigSection( + initial: T, + block: T.() -> T, + wrap: (T) -> ModuleConfig, +): AdminResult = setModuleConfig(wrap(initial.block())) + +/** Convenience: build and send a [Config.DeviceConfig] in a single call. */ +public suspend fun AdminApi.setDeviceConfig(block: Config.DeviceConfig.() -> Config.DeviceConfig): AdminResult = + setConfigSection(Config.DeviceConfig(), block) { Config(device = it) } + +/** Convenience: build and send a [Config.PositionConfig] in a single call. */ +public suspend fun AdminApi.setPositionConfig( + block: Config.PositionConfig.() -> Config.PositionConfig, +): AdminResult = setConfigSection(Config.PositionConfig(), block) { Config(position = it) } + +/** Convenience: build and send a [Config.PowerConfig] in a single call. */ +public suspend fun AdminApi.setPowerConfig(block: Config.PowerConfig.() -> Config.PowerConfig): AdminResult = + setConfigSection(Config.PowerConfig(), block) { Config(power = it) } + +/** Convenience: build and send a [Config.NetworkConfig] in a single call. */ +public suspend fun AdminApi.setNetworkConfig( + block: Config.NetworkConfig.() -> Config.NetworkConfig, +): AdminResult = setConfigSection(Config.NetworkConfig(), block) { Config(network = it) } + +/** Convenience: build and send a [Config.DisplayConfig] in a single call. */ +public suspend fun AdminApi.setDisplayConfig( + block: Config.DisplayConfig.() -> Config.DisplayConfig, +): AdminResult = setConfigSection(Config.DisplayConfig(), block) { Config(display = it) } + +/** Convenience: build and send a [Config.LoRaConfig] in a single call. */ +public suspend fun AdminApi.setLoraConfig(block: Config.LoRaConfig.() -> Config.LoRaConfig): AdminResult = + setConfigSection(Config.LoRaConfig(), block) { Config(lora = it) } + +/** Convenience: build and send a [Config.BluetoothConfig] in a single call. */ +public suspend fun AdminApi.setBluetoothConfig( + block: Config.BluetoothConfig.() -> Config.BluetoothConfig, +): AdminResult = setConfigSection(Config.BluetoothConfig(), block) { Config(bluetooth = it) } + +/** Convenience: build and send a [Config.SecurityConfig] in a single call. */ +public suspend fun AdminApi.setSecurityConfig( + block: Config.SecurityConfig.() -> Config.SecurityConfig, +): AdminResult = setConfigSection(Config.SecurityConfig(), block) { Config(security = it) } + +/** Convenience: build and send a [ModuleConfig.MQTTConfig] in a single call. */ +public suspend fun AdminApi.setMqttConfig( + block: ModuleConfig.MQTTConfig.() -> ModuleConfig.MQTTConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.MQTTConfig(), block) { ModuleConfig(mqtt = it) } + +/** Convenience: build and send a [ModuleConfig.SerialConfig] in a single call. */ +public suspend fun AdminApi.setSerialConfig( + block: ModuleConfig.SerialConfig.() -> ModuleConfig.SerialConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.SerialConfig(), block) { ModuleConfig(serial = it) } + +/** Convenience: build and send a [ModuleConfig.ExternalNotificationConfig] in a single call. */ +public suspend fun AdminApi.setExternalNotificationConfig( + block: ModuleConfig.ExternalNotificationConfig.() -> ModuleConfig.ExternalNotificationConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.ExternalNotificationConfig(), block) { + ModuleConfig(external_notification = it) +} + +/** Convenience: build and send a [ModuleConfig.StoreForwardConfig] in a single call. */ +public suspend fun AdminApi.setStoreForwardConfig( + block: ModuleConfig.StoreForwardConfig.() -> ModuleConfig.StoreForwardConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.StoreForwardConfig(), block) { + ModuleConfig(store_forward = it) +} + +/** Convenience: build and send a [ModuleConfig.RangeTestConfig] in a single call. */ +public suspend fun AdminApi.setRangeTestConfig( + block: ModuleConfig.RangeTestConfig.() -> ModuleConfig.RangeTestConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.RangeTestConfig(), block) { ModuleConfig(range_test = it) } + +/** Convenience: build and send a [ModuleConfig.TelemetryConfig] in a single call. */ +public suspend fun AdminApi.setTelemetryConfig( + block: ModuleConfig.TelemetryConfig.() -> ModuleConfig.TelemetryConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.TelemetryConfig(), block) { ModuleConfig(telemetry = it) } + +/** Convenience: build and send a [ModuleConfig.CannedMessageConfig] in a single call. */ +public suspend fun AdminApi.setCannedMessageConfig( + block: ModuleConfig.CannedMessageConfig.() -> ModuleConfig.CannedMessageConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.CannedMessageConfig(), block) { + ModuleConfig(canned_message = it) +} + +/** Convenience: build and send a [ModuleConfig.AudioConfig] in a single call. */ +public suspend fun AdminApi.setAudioConfig( + block: ModuleConfig.AudioConfig.() -> ModuleConfig.AudioConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.AudioConfig(), block) { ModuleConfig(audio = it) } + +/** Convenience: build and send a [ModuleConfig.RemoteHardwareConfig] in a single call. */ +public suspend fun AdminApi.setRemoteHardwareConfig( + block: ModuleConfig.RemoteHardwareConfig.() -> ModuleConfig.RemoteHardwareConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.RemoteHardwareConfig(), block) { + ModuleConfig(remote_hardware = it) +} + +/** Convenience: build and send a [ModuleConfig.NeighborInfoConfig] in a single call. */ +public suspend fun AdminApi.setNeighborInfoConfig( + block: ModuleConfig.NeighborInfoConfig.() -> ModuleConfig.NeighborInfoConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.NeighborInfoConfig(), block) { + ModuleConfig(neighbor_info = it) +} + +/** Convenience: build and send a [ModuleConfig.AmbientLightingConfig] in a single call. */ +public suspend fun AdminApi.setAmbientLightingConfig( + block: ModuleConfig.AmbientLightingConfig.() -> ModuleConfig.AmbientLightingConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.AmbientLightingConfig(), block) { + ModuleConfig(ambient_lighting = it) +} + +/** Convenience: build and send a [ModuleConfig.DetectionSensorConfig] in a single call. */ +public suspend fun AdminApi.setDetectionSensorConfig( + block: ModuleConfig.DetectionSensorConfig.() -> ModuleConfig.DetectionSensorConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.DetectionSensorConfig(), block) { + ModuleConfig(detection_sensor = it) +} + +/** Convenience: build and send a [ModuleConfig.PaxcounterConfig] in a single call. */ +public suspend fun AdminApi.setPaxcounterConfig( + block: ModuleConfig.PaxcounterConfig.() -> ModuleConfig.PaxcounterConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.PaxcounterConfig(), block) { + ModuleConfig(paxcounter = it) +} + +/** Convenience: build and send a [ModuleConfig.StatusMessageConfig] in a single call. */ +public suspend fun AdminApi.setStatusMessageConfig( + block: ModuleConfig.StatusMessageConfig.() -> ModuleConfig.StatusMessageConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.StatusMessageConfig(), block) { + ModuleConfig(statusmessage = it) +} + +/** Convenience: build and send a [ModuleConfig.TrafficManagementConfig] in a single call. */ +public suspend fun AdminApi.setTrafficManagementConfig( + block: ModuleConfig.TrafficManagementConfig.() -> ModuleConfig.TrafficManagementConfig, +): AdminResult = setModuleConfigSection(ModuleConfig.TrafficManagementConfig(), block) { + ModuleConfig(traffic_management = it) +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt new file mode 100644 index 0000000..02af3da --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/CongestionLevel.kt @@ -0,0 +1,63 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Represents the current mesh congestion level based on channel utilization metrics. + * + * @property airUtilTx current transmit air utilization percentage (0-100) + * @property channelUtil current channel utilization percentage (0-100) + */ +public data class CongestionMetrics(val airUtilTx: Float, val channelUtil: Float) { + /** Computed congestion level based on thresholds. */ + public val level: CongestionLevel get() = when { + airUtilTx >= CRITICAL_THRESHOLD || channelUtil >= CRITICAL_THRESHOLD -> CongestionLevel.CRITICAL + airUtilTx >= HIGH_THRESHOLD || channelUtil >= HIGH_THRESHOLD -> CongestionLevel.HIGH + airUtilTx >= MEDIUM_THRESHOLD || channelUtil >= MEDIUM_THRESHOLD -> CongestionLevel.MEDIUM + else -> CongestionLevel.LOW + } + + /** Suggested backoff duration based on current congestion. */ + public val suggestedBackoff: Duration get() = when (level) { + CongestionLevel.LOW -> Duration.ZERO + CongestionLevel.MEDIUM -> 5.seconds + CongestionLevel.HIGH -> 15.seconds + CongestionLevel.CRITICAL -> 30.seconds + } + + /** Whether it's safe to send non-urgent messages. */ + public val canSendNonUrgent: Boolean get() = level <= CongestionLevel.MEDIUM + + public companion object { + public const val MEDIUM_THRESHOLD: Float = 25f + public const val HIGH_THRESHOLD: Float = 50f + public const val CRITICAL_THRESHOLD: Float = 75f + } +} + +/** + * Discrete congestion buckets derived from channel-utilization telemetry. + * + * @since 0.1.0 + */ +public enum class CongestionLevel { + /** Channel is clear — send freely. */ + LOW, + + /** Moderate activity — consider batching or delaying non-urgent messages. */ + MEDIUM, + + /** Heavy traffic — back off non-essential sends. */ + HIGH, + + /** Near capacity — only send critical messages. */ + CRITICAL, +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt index f716fa3..ff57d6e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Connect.kt @@ -32,6 +32,7 @@ import kotlin.time.Duration.Companion.seconds * * @throws MeshtasticException any failure surfaced by [RadioClient.connect] * @throws MeshtasticException.HandshakeTimeout if [timeout] elapses first + * @since 0.1.0 */ public suspend fun RadioClient.connectAndAwaitReady(timeout: Duration = 30.seconds): ConfigBundle { val bundle = withTimeoutOrNull(timeout) { diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt new file mode 100644 index 0000000..1487875 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/DeviceCapabilities.kt @@ -0,0 +1,84 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Parsed firmware version value used for capability comparisons. + * + * @since 0.1.0 + */ +public data class DeviceVersion(val versionString: String) : Comparable { + /** Integer representation (e.g., "2.7.12" → 20712). */ + public val asInt: Int = parseVersion(versionString) + + override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt) + + public companion object { + public val MIN_SUPPORTED: DeviceVersion = DeviceVersion("2.5.14") + public val ABS_MIN_SUPPORTED: DeviceVersion = DeviceVersion("2.3.15") + + private fun parseVersion(s: String): Int { + val normalized = if (s.count { it == '.' } == 1) "$s.0" else s + val match = Regex("(\\d{1,2})\\.(\\d{1,2})\\.(\\d{1,2})").find(normalized) ?: return 0 + val (major, minor, patch) = match.destructured + return major.toInt() * 10000 + minor.toInt() * 100 + patch.toInt() + } + } +} + +/** + * Firmware capability flags derived from a device's reported firmware version. + * + * @since 0.1.0 + */ +public data class DeviceCapabilities(val firmwareVersion: String?) { + private val version: DeviceVersion? = firmwareVersion?.let { DeviceVersion(it) } + + private fun atLeast(min: DeviceVersion): Boolean = version != null && version >= min + + /** Node muting via admin messages. Since 2.7.18. */ + public val canMuteNode: Boolean get() = atLeast(V2_7_18) + + /** Verified shared contacts. Since 2.7.12. */ + public val canSendVerifiedContacts: Boolean get() = atLeast(V2_7_12) + + /** Device telemetry toggle in module config. Since 2.7.12. */ + public val canToggleTelemetryEnabled: Boolean get() = atLeast(V2_7_12) + + /** is_unmessageable flag. Since 2.6.9. */ + public val canToggleUnmessageable: Boolean get() = atLeast(V2_6_9) + + /** QR code contact sharing. Since 2.6.8. */ + public val supportsQrCodeSharing: Boolean get() = atLeast(V2_6_8) + + /** Status message module. Since 2.8.0. */ + public val supportsStatusMessage: Boolean get() = atLeast(V2_8_0) + + /** Traffic management config. Since 3.0.0. */ + public val supportsTrafficManagementConfig: Boolean get() = atLeast(V3_0_0) + + /** TAK module config. Since 2.7.19. */ + public val supportsTakConfig: Boolean get() = atLeast(V2_7_19) + + /** Location sharing on secondary channels. Since 2.6.10. */ + public val supportsSecondaryChannelLocation: Boolean get() = atLeast(V2_6_10) + + /** ESP32 unified OTA. Since 2.7.18. */ + public val supportsEsp32Ota: Boolean get() = atLeast(V2_7_18) + + private companion object { + val V2_6_8: DeviceVersion = DeviceVersion("2.6.8") + val V2_6_9: DeviceVersion = DeviceVersion("2.6.9") + val V2_6_10: DeviceVersion = DeviceVersion("2.6.10") + val V2_7_12: DeviceVersion = DeviceVersion("2.7.12") + val V2_7_18: DeviceVersion = DeviceVersion("2.7.18") + val V2_7_19: DeviceVersion = DeviceVersion("2.7.19") + val V2_8_0: DeviceVersion = DeviceVersion("2.8.0") + val V3_0_0: DeviceVersion = DeviceVersion("3.0.0") + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt new file mode 100644 index 0000000..86e48fc --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshNode.kt @@ -0,0 +1,145 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position +import org.meshtastic.proto.User + +/** + * High-level, consumer-friendly representation of a mesh node. + * + * Combines the raw [NodeInfo] proto with computed status helpers ([isOnline], [connectionQuality], + * [signalQuality]) and provides convenient accessor properties that flatten the proto's nested + * structure for direct UI binding. + * + * Instances are created via [NodeInfo.toMeshNode] at a specific point in time ([nowEpochSeconds]) + * so that online status is pre-computed and stable for the lifetime of the snapshot. + * + * @property raw the underlying [NodeInfo] proto — available for advanced consumers that need + * full access to all proto fields + * @since 0.1.0 + */ +public data class MeshNode( + val raw: NodeInfo, + val isOnline: Boolean, + val connectionQuality: ConnectionQuality, + val signalQuality: SignalQuality, +) { + // ── Identity ────────────────────────────────────────────────────────── + + /** Node number (unique on the mesh). */ + val nodeNum: Int get() = raw.num + + /** Stable node ID as [NodeId]. */ + val nodeId: NodeId get() = NodeId(raw.num) + + /** User-facing long name (e.g., "Alice's T-Beam"). */ + val longName: String? get() = raw.user?.long_name + + /** User-facing short name (4 chars, e.g., "ALCE"). */ + val shortName: String? get() = raw.user?.short_name + + /** Meshtastic ID string (e.g., "!aabbccdd"). */ + val meshId: String? get() = raw.user?.id + + /** Hardware model reported by the node. */ + val hwModel: HardwareModel? get() = raw.user?.hw_model + + /** Full user proto (for advanced consumers). */ + val user: User? get() = raw.user + + // ── Connectivity ────────────────────────────────────────────────────── + + /** Number of relay hops to reach this node. 0 = direct. */ + val hopsAway: Int? get() = raw.hops_away + + /** Whether the node was reached via MQTT gateway. */ + val viaMqtt: Boolean get() = raw.via_mqtt + + /** SNR of last received packet from this node. */ + val snr: Float get() = raw.snr + + /** Last heard time (Unix epoch seconds). 0 if never heard. */ + val lastHeard: Int get() = raw.last_heard + + // ── Position ────────────────────────────────────────────────────────── + + /** Position data (latitude, longitude, altitude, etc.). Null if not reported. */ + val position: Position? get() = raw.position + + /** Latitude in degrees (null if no position or position is origin). */ + val latitude: Double? + get() = raw.position?.let { pos -> + val lat = pos.latitude_i ?: 0 + if (lat != 0) lat / 1e7 else null + } + + /** Longitude in degrees (null if no position or position is origin). */ + val longitude: Double? + get() = raw.position?.let { pos -> + val lng = pos.longitude_i ?: 0 + if (lng != 0) lng / 1e7 else null + } + + /** Altitude in meters (null if not reported). */ + val altitude: Int? get() = raw.position?.altitude + + // ── Device Metrics (telemetry) ──────────────────────────────────────── + + /** Device metrics snapshot (battery, air utilization, etc.). */ + val deviceMetrics: DeviceMetrics? get() = raw.device_metrics + + /** Battery level percentage (0–100). Null if not reported. */ + val batteryLevel: Int? get() = raw.device_metrics?.battery_level + + /** Battery voltage. Null if not reported. */ + val voltage: Float? get() = raw.device_metrics?.voltage + + /** Channel utilization percentage. Null if not reported. */ + val channelUtilization: Float? get() = raw.device_metrics?.channel_utilization + + /** Airtime utilization (TX) percentage. Null if not reported. */ + val airUtilTx: Float? get() = raw.device_metrics?.air_util_tx + + // ── Flags ───────────────────────────────────────────────────────────── + + /** Whether this node is marked as a favorite. */ + val isFavorite: Boolean get() = raw.is_favorite + + /** Whether this node is ignored (hidden from UI). */ + val isIgnored: Boolean get() = raw.is_ignored + + /** Whether this node is muted (no notifications). */ + val isMuted: Boolean get() = raw.is_muted +} + +/** + * Creates a [MeshNode] snapshot from this [NodeInfo] at the given time. + * + * @param nowEpochSeconds current Unix epoch seconds — used to determine [MeshNode.isOnline] + * @return a [MeshNode] with pre-computed status fields + * @since 0.1.0 + */ +public fun NodeInfo.toMeshNode(nowEpochSeconds: Int): MeshNode = MeshNode( + raw = this, + isOnline = isOnline(nowEpochSeconds), + connectionQuality = connectionQuality, + signalQuality = signalQuality, +) + +/** + * Converts a collection of [NodeInfo] entries to [MeshNode] snapshots. + * + * @param nowEpochSeconds current Unix epoch seconds + * @return list of [MeshNode] instances + * @since 0.1.0 + */ +public fun Iterable.toMeshNodes(nowEpochSeconds: Int): List = map { it.toMeshNode(nowEpochSeconds) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt new file mode 100644 index 0000000..8048d16 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MeshTopology.kt @@ -0,0 +1,152 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Incremental mesh topology graph built from [NeighborInfo] reports. + * + * Usage: + * ```kotlin + * val topology = MeshTopology() + * topology.addNeighborInfo(neighborInfo) + * val path = topology.shortestPath(nodeA, nodeB) + * val neighbors = topology.getNeighbors(nodeA) + * ``` + * + * **Thread-safe** — all mutations and reads are guarded by an internal [Mutex], so this class is + * safe to call concurrently from any coroutine context. This is a consumer-side utility; it is + * **not** used inside the engine actor's hot path and therefore does not violate the single-writer + * invariant (ADR-002). + * + * The graph is directed — if node A reports node B as a neighbor, that's a directed edge A→B. + * Undirected queries consider both directions. + */ +public class MeshTopology { + /** + * Directed edge from a reporting node [from] to a neighbor [to], carrying the reported signal + * quality ([snr]) and the [NeighborInfo.lastUpdated] value from the source report. + */ + public data class Edge(val from: NodeId, val to: NodeId, val snr: Float, val lastUpdated: Int = 0) + + private val mutex = Mutex() + + // Internal adjacency: Map> + private val adjacency = mutableMapOf>() + private var cachedNodes: Set? = null + + /** + * Ingest a [NeighborInfo] report, replacing all edges from the reporting node. + */ + public suspend fun addNeighborInfo(info: NeighborInfo): Unit = mutex.withLock { + val edges = mutableMapOf() + info.neighbors.forEach { neighbor -> + edges[neighbor.nodeId] = Edge( + from = info.nodeId, + to = neighbor.nodeId, + snr = neighbor.snr, + lastUpdated = info.lastUpdated, + ) + } + adjacency[info.nodeId] = edges + cachedNodes = null + } + + /** + * Remove a node and all edges referencing it. + */ + public suspend fun removeNode(nodeId: NodeId): Unit = mutex.withLock { + adjacency.remove(nodeId) + adjacency.values.forEach { it.remove(nodeId) } + cachedNodes = null + } + + /** All nodes that have reported neighbors or been reported as a neighbor. */ + public suspend fun nodes(): Set = mutex.withLock { + cachedNodes?.let { return@withLock it.toSet() } + val result = mutableSetOf() + adjacency.forEach { (reporter, neighbors) -> + result.add(reporter) + result.addAll(neighbors.keys) + } + cachedNodes = result + result.toSet() + } + + /** Get all outgoing edges from a node (nodes it reported as neighbors). */ + public suspend fun getNeighbors(nodeId: NodeId): List = mutex.withLock { + adjacency[nodeId]?.values?.toList() ?: emptyList() + } + + /** Check if there's a direct edge in either direction between two nodes. */ + public suspend fun isDirectReach(a: NodeId, b: NodeId): Boolean = mutex.withLock { + adjacency[a]?.containsKey(b) == true || adjacency[b]?.containsKey(a) == true + } + + /** Get the edge from [from] to [to] (if [from] reported [to] as neighbor). */ + public suspend fun getEdge(from: NodeId, to: NodeId): Edge? = mutex.withLock { + adjacency[from]?.get(to) + } + + /** + * Find shortest path between two nodes using BFS on the undirected graph. + * Returns the path as a list of [NodeId]s including start and end. + * Returns `listOf(from)` when [from] == [to]. + * Returns an empty list when no path exists. + */ + public suspend fun shortestPath(from: NodeId, to: NodeId): List = mutex.withLock { + if (from == to) return@withLock listOf(from) + val visited = mutableSetOf(from) + val queue = ArrayDeque>() + queue.add(listOf(from)) + + while (queue.isNotEmpty()) { + val path = queue.removeFirst() + val current = path.last() + for (neighbor in undirectedNeighborsLocked(current)) { + if (neighbor == to) return@withLock path + neighbor + if (visited.add(neighbor)) { + queue.add(path + neighbor) + } + } + } + emptyList() + } + + /** + * Get all edges in the topology graph. + */ + public suspend fun allEdges(): List = mutex.withLock { + adjacency.values.flatMap { it.values } + } + + /** + * Number of directed edges. + */ + public suspend fun edgeCount(): Int = mutex.withLock { + adjacency.values.sumOf { it.size } + } + + /** Clear all topology data. */ + public suspend fun clear(): Unit = mutex.withLock { + adjacency.clear() + cachedNodes = null + } + + /** Must be called while holding [mutex]. */ + private fun undirectedNeighborsLocked(nodeId: NodeId): Set { + val result = mutableSetOf() + adjacency[nodeId]?.keys?.let { result.addAll(it) } + adjacency.forEach { (reporter, neighbors) -> + if (neighbors.containsKey(nodeId)) result.add(reporter) + } + return result + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt index 9159921..9518edf 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/MessageHandleRetry.kt @@ -7,8 +7,7 @@ */ package org.meshtastic.sdk -import org.meshtastic.sdk.MeshtasticException -import org.meshtastic.sdk.MessageHandle +import kotlinx.coroutines.delay /** * Re-enqueue the same packet that produced this [MessageHandle]. The engine assigns @@ -28,3 +27,62 @@ public suspend fun MessageHandle.retry(): MessageHandle { } return resend(pkt) } + +/** + * Awaits the outcome of this [MessageHandle], retrying according to [policy] if the send fails + * with a retryable failure. + * + * Non-retryable failures ([SendFailure.Disconnected], [SendFailure.Cancelled], + * [SendFailure.HandshakeFailed]) abort immediately regardless of remaining attempts. + * + * Requires that [MessageHandle.packet] and [MessageHandle.resendFn] are non-null (i.e., the handle + * was produced by [RadioClient.send] or [RadioClient.sendText], which always populate these fields). + * + * @param policy the retry strategy to apply + * @return the final [SendOutcome] (success after any attempt, or the last failure) + */ +public suspend fun MessageHandle.retryWith(policy: RetryPolicy): SendOutcome { + if (policy is RetryPolicy.None) return await() + + var currentHandle = this + var attempt = 0 + + while (true) { + val outcome = currentHandle.await() + if (outcome is SendOutcome.Success) return outcome + + val failure = (outcome as SendOutcome.Failure).reason + if (!failure.isRetryable()) return outcome + + val delayDuration = policy.delayForAttempt(attempt) ?: return outcome + val pkt = currentHandle.packet ?: return outcome + val resend = currentHandle.resendFn ?: return outcome + + delay(delayDuration) + currentHandle = resend(pkt) + attempt++ + } +} + +/** + * Whether this failure type is safe to retry. + * + * Terminal failures (disconnected, cancelled, handshake) should never be retried because the + * underlying session is no longer valid. + */ +private fun SendFailure.isRetryable(): Boolean = when (this) { + SendFailure.Disconnected, + SendFailure.HandshakeFailed, + SendFailure.Cancelled, + SendFailure.IdCollision, + -> false + + SendFailure.NoRoute, + SendFailure.MaxRetransmit, + SendFailure.Timeout, + SendFailure.DutyCycleLimit, + SendFailure.AckTimeout, + is SendFailure.Other, + is SendFailure.Unknown, + -> true +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt new file mode 100644 index 0000000..00b888f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NeighborInfo.kt @@ -0,0 +1,62 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Parsed neighbor information for a node, representing its directly-reachable peers. + * + * @property nodeId the node reporting its neighbors + * @property neighbors list of neighbor entries with signal quality + * @property lastUpdated seconds since epoch when this info was received + */ +public data class NeighborInfo( + public val nodeId: NodeId, + public val neighbors: List, + public val lastUpdated: Int = 0, +) { + /** + * A single neighbor entry. + * + * @property nodeId the neighbor's node ID + * @property snr signal-to-noise ratio in dB (higher is better) + */ + public data class Neighbor(public val nodeId: NodeId, public val snr: Float) + + /** + * Formats the neighbor list as a human-readable string. + */ + public fun format(resolveNode: (NodeId) -> String = { it.toHex() }): String = buildString { + appendLine("Neighbors of ${resolveNode(nodeId)} (${neighbors.size}):") + neighbors.forEach { neighbor -> + appendLine(" ${resolveNode(neighbor.nodeId)} — SNR: ${neighbor.snr} dB") + } + } + + public companion object { + /** + * Parse from proto NeighborInfo fields. + * + * @param reportingNode the node that sent the neighbor info + * @param neighborNodeIds list of neighbor node numbers + * @param snrValues corresponding SNR values (same order/length as nodeIds) + * @param timestamp seconds since epoch + */ + public fun fromProto( + reportingNode: Int, + neighborNodeIds: List, + snrValues: List, + timestamp: Int = 0, + ): NeighborInfo = NeighborInfo( + nodeId = NodeId(reportingNode), + neighbors = neighborNodeIds.zip(snrValues) { nodeId, snr -> + Neighbor(nodeId = NodeId(nodeId), snr = snr) + }, + lastUpdated = timestamp, + ) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt index 7fcf879..d52c012 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Node.kt @@ -49,7 +49,8 @@ public enum class NodeField { * A delta notification of node state changes. * * The SDK emits these via the [RadioClient.nodes] flow. Late subscribers first receive a - * [Snapshot] of all known nodes, then live deltas ([Added], [Updated], [Removed]) in causal order. + * [Snapshot] of all known nodes, then live deltas ([Added], [Updated], [Removed], [WentOffline], + * [CameOnline]) in causal order. * * This delta-based design is efficient for large meshes: a 200-node network with frequent * telemetry would be wasteful to emit as a full `StateFlow>` (200 entries @@ -90,6 +91,21 @@ public sealed interface NodeChange { * @param nodeId the ID of the removed node */ public data class Removed(val nodeId: NodeId) : NodeChange + + /** + * Emitted when a node's [NodeInfo.last_heard] exceeds the configured presence timeout + * and transitions from online to offline. + * + * Emitted by the engine when presence tracking is enabled via `Builder.presenceTimeout()`. + */ + public data class WentOffline(val nodeId: NodeId, val lastHeard: Int) : NodeChange + + /** + * Emitted when a previously-offline node sends a new packet and becomes online again. + * + * Emitted by the engine when presence tracking is enabled via `Builder.presenceTimeout()`. + */ + public data class CameOnline(val nodeId: NodeId) : NodeChange } /** @@ -115,6 +131,24 @@ public sealed interface MeshEvent { */ public data class Notification(val notification: org.meshtastic.proto.ClientNotification) : MeshEvent + /** + * Emitted when congestion metrics cross a threshold level. + * Clients should use [metrics] to decide whether to delay non-urgent sends. + */ + public data class CongestionWarning(val metrics: CongestionMetrics) : MeshEvent + + /** + * Emitted when the device's MQTT connection state changes to connected. + */ + public data object MqttConnected : MeshEvent + + /** + * Emitted when the device's MQTT connection drops. + * + * @property reason human-readable disconnect reason, if available + */ + public data class MqttDisconnected(val reason: String? = null) : MeshEvent + /** * A transport-level error occurred. * @@ -227,6 +261,30 @@ public sealed interface MeshEvent { * @since 0.1.0 */ public data class DeviceRebooted(val reason: String = "device reported reboot") : MeshEvent + + /** + * An external source (another admin client) pushed a configuration change to the connected + * device. The engine has already applied the update to local state ([RadioClient.config], + * [RadioClient.channels]). Subscribers should refresh any cached configuration. + * + * @param kind indicates which aspect of the device configuration changed + * @since 0.1.0 + */ + public data class ExternalConfigChange(val kind: ExternalChangeKind) : MeshEvent +} + +/** + * Describes which category of device configuration was changed externally. + */ +public enum class ExternalChangeKind { + /** A channel was added, removed, or modified. */ + CHANNEL, + + /** A radio/device config section was modified. */ + CONFIG, + + /** A module config section was modified. */ + MODULE_CONFIG, } /** @@ -247,21 +305,9 @@ public enum class DroppedFlow { /** * Key-verification prompt details surfaced via [MeshEvent.KeyVerification]. * - * **Phase 1:** marker interface only — emitted as a placeholder when the engine notices that - * encryption setup *would* prompt for confirmation, but no payload is exposed yet. Hosts - * should treat any non-null prompt as "show a generic verification UI" until the surface is - * filled in. - * - * **Phase 2+:** this interface will gain at least: - * - * ```kotlin - * public interface KeyVerificationPrompt { - * public val remoteNodeId: NodeId - * public val publicKeyFingerprint: String // hex SHA-256, abbreviated for display - * public suspend fun confirm() // accept; engine continues handshake - * public suspend fun reject() // refuse; engine tears down with Protocol error - * } - * ``` + * Marker interface; concrete verification methods will be added when PKI verification UI is + * implemented. Until then, any non-null prompt means the host should show a generic + * verification experience. * * The shape will be ratified in a follow-up ADR before 1.0; consumers wiring this today should * expect the interface to add abstract members (binary-incompatible for implementers, but a diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt index a1cf888..0c55cc3 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeIds.kt @@ -40,3 +40,9 @@ public fun NodeId.isLocal(own: NodeId? = null): Boolean = this == NodeId.LOCAL | /** Returns `true` if this is a specific node address (neither local nor broadcast). */ public val NodeId.isUnicast: Boolean get() = this != NodeId.BROADCAST && this != NodeId.LOCAL + +/** Returns the Meshtastic default ID format: `"!aabbccdd"` (lowercase hex with `!` prefix). */ +public fun NodeId.toDefaultId(): String = "!" + toHex() + +/** Parses a default ID string (`"!aabbccdd"`) into a [NodeId]. Delegates to [fromHex]. */ +public fun NodeId.Companion.fromDefaultId(id: String): NodeId? = fromHex(id) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt new file mode 100644 index 0000000..c5d0ff9 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/NodeStatus.kt @@ -0,0 +1,110 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.NodeInfo +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +/** + * How the local node reached a remote node. + * + * Derived from [NodeInfo.hops_away] and [NodeInfo.via_mqtt]. + * + * @since 0.1.0 + */ +public enum class ConnectionQuality { + /** Node was heard directly (0 hops, not via MQTT). */ + DIRECT, + + /** Node was heard via one or more mesh relays. */ + RELAYED, + + /** Node was heard via MQTT gateway. */ + MQTT, + + /** Insufficient data to determine connection path. */ + UNKNOWN, +} + +/** + * Signal quality tier derived from SNR thresholds. + * + * Thresholds are based on typical LoRa performance characteristics: + * - Good: SNR ≥ 5 dB (strong signal, reliable decode) + * - Fair: SNR ≥ 0 dB (acceptable, may have occasional errors) + * - Poor: SNR < 0 dB (weak signal, expect packet loss) + * - None: SNR == 0 and no hops data (no signal information available) + * + * @since 0.1.0 + */ +public enum class SignalQuality { + /** Strong signal (SNR ≥ 5 dB). */ + GOOD, + + /** Acceptable signal (0 ≤ SNR < 5 dB). */ + FAIR, + + /** Weak signal (SNR < 0 dB). */ + POOR, + + /** No signal data available. */ + NONE, +} + +/** + * Default threshold for determining whether a node is "online." + * + * Matches the 2-hour window used by the Meshtastic Android app. + */ +public val DEFAULT_ONLINE_THRESHOLD: Duration = 2.hours + +/** + * Returns `true` if the node was last heard within [threshold] of [nowEpochSeconds]. + * + * @param nowEpochSeconds current epoch time in seconds (e.g., `Clock.System.now().epochSeconds.toInt()`) + * @param threshold how recently the node must have been heard to be considered online + * @return `true` if the node is considered online; `false` if stale or never heard + * + * @since 0.1.0 + */ +public fun NodeInfo.isOnline(nowEpochSeconds: Int, threshold: Duration = DEFAULT_ONLINE_THRESHOLD): Boolean { + if (last_heard == 0) return false + val cutoff = nowEpochSeconds - threshold.inWholeSeconds.toInt() + return last_heard >= cutoff +} + +/** + * Returns the [ConnectionQuality] for this node based on hop count and MQTT flag. + * + * @since 0.1.0 + */ +public val NodeInfo.connectionQuality: ConnectionQuality + get() = when { + via_mqtt -> ConnectionQuality.MQTT + hops_away == 0 -> ConnectionQuality.DIRECT + hops_away != null -> ConnectionQuality.RELAYED + else -> ConnectionQuality.UNKNOWN + } + +/** + * Returns the [SignalQuality] tier for this node based on SNR. + * + * @since 0.1.0 + */ +public val NodeInfo.signalQuality: SignalQuality + get() = when { + // snr == 0f with no hops_away data likely means "no reading" (proto default) + snr == 0f && hops_away == null -> SignalQuality.NONE + + snr >= 5f -> SignalQuality.GOOD + + snr >= 0f -> SignalQuality.FAIR + + else -> SignalQuality.POOR + } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt index 09b8871..99be773 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PayloadAccessors.kt @@ -8,15 +8,20 @@ package org.meshtastic.sdk import com.squareup.wire.ProtoAdapter +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter import okio.ByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Position +import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.proto.Waypoint +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo private fun MeshPacket.payloadOrNull(expected: PortNum): ByteString? { val data = decoded ?: return null if (data.portnum != expected) return null @@ -35,6 +40,23 @@ private fun MeshPacket.decodeIfPort(expected: PortNum, adapter: ProtoAdapter */ public fun MeshPacket.asText(): String? = payloadOrNull(PortNum.TEXT_MESSAGE_APP)?.utf8() +/** + * Filters [RadioClient.packets] to only text-message packets — those with + * `decoded.portnum == [PortNum.TEXT_MESSAGE_APP]`. + * + * Each emitted [MeshPacket] can be accessed via: + * - `asText()` — decoded UTF-8 body; returns `null` if the payload is empty + * - `NodeId(packet.from)` — sender node + * - `ChannelIndex(packet.channel)` — channel index (0–7) + * - `packet.rx_time` — receive timestamp (Unix seconds, `Int`) + * - `packet.to` — destination node num (`0xFFFFFFFF` = broadcast) + * + * This flow is **hot with no replay** — it inherits [RadioClient.packets] semantics. + * Subscribers receive only packets that arrive after they start collecting. + */ +public val RadioClient.textMessages: Flow + get() = packets.filter { it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP } + /** * Decodes the payload as [Position] if [MeshPacket.decoded.portnum] matches [PortNum.POSITION_APP]. */ @@ -67,3 +89,19 @@ public fun MeshPacket.asAdminMessage(): AdminMessage? = decodeIfPort(PortNum.ADM * Decodes the payload as [Routing] if [MeshPacket.decoded.portnum] matches [PortNum.ROUTING_APP]. */ public fun MeshPacket.asRouting(): Routing? = decodeIfPort(PortNum.ROUTING_APP, Routing.ADAPTER) + +/** + * Decodes the payload as [Waypoint] if [MeshPacket.decoded.portnum] matches [PortNum.WAYPOINT_APP]. + */ +public fun MeshPacket.asWaypoint(): Waypoint? = decodeIfPort(PortNum.WAYPOINT_APP, Waypoint.ADAPTER) + +/** + * Decodes the payload as [RouteDiscovery] if [MeshPacket.decoded.portnum] matches [PortNum.TRACEROUTE_APP]. + */ +public fun MeshPacket.asTraceroute(): RouteDiscovery? = decodeIfPort(PortNum.TRACEROUTE_APP, RouteDiscovery.ADAPTER) + +/** + * Decodes the payload as [ProtoNeighborInfo] if [MeshPacket.decoded.portnum] matches [PortNum.NEIGHBORINFO_APP]. + */ +public fun MeshPacket.asNeighborInfo(): ProtoNeighborInfo? = + decodeIfPort(PortNum.NEIGHBORINFO_APP, ProtoNeighborInfo.ADAPTER) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt new file mode 100644 index 0000000..11108a8 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/PositionUtils.kt @@ -0,0 +1,70 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.math.PI +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Geographic utility functions for mesh node positions. + */ +public object PositionUtils { + private const val EARTH_RADIUS_METERS: Double = 6371e3 + + /** Converts a protobuf position integer (1e-7 degrees) to a double. */ + public fun intToDegrees(positionInt: Int): Double = positionInt * 1e-7 + + /** Returns true if lat/lng are within valid bounds and not both zero. */ + public fun isValidPosition(latitude: Double, longitude: Double): Boolean = !(latitude == 0.0 && longitude == 0.0) && + latitude in -90.0..90.0 && + longitude in -180.0..180.0 + + /** + * Computes the great-circle distance between two points using the Haversine formula. + * + * @return distance in meters + */ + public fun distance(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val lat1Rad = lat1.toRadians() + val lat2Rad = lat2.toRadians() + val dLat = (lat2 - lat1).toRadians() + val dLon = (lon2 - lon1).toRadians() + val a = sin(dLat / 2).pow(2) + cos(lat1Rad) * cos(lat2Rad) * sin(dLon / 2).pow(2) + return EARTH_RADIUS_METERS * 2 * asin(sqrt(a)) + } + + /** Overload accepting [LatLng] instances. */ + public fun distance(a: LatLng, b: LatLng): Double = distance(a.latitude, a.longitude, b.latitude, b.longitude) + + /** + * Computes the initial bearing from point 1 to point 2. + * + * @return bearing in degrees (0 = north, 90 = east, etc.) + */ + public fun bearing(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { + val lat1Rad = lat1.toRadians() + val lat2Rad = lat2.toRadians() + val dLon = (lon2 - lon1).toRadians() + val y = sin(dLon) * cos(lat2Rad) + val x = cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon) + return (atan2(y, x).toDegrees() + 360.0) % 360.0 + } + + /** Overload accepting [LatLng] instances. */ + public fun bearing(from: LatLng, to: LatLng): Double = + bearing(from.latitude, from.longitude, to.latitude, to.longitude) + + private fun Double.toRadians(): Double = this * PI / 180.0 + + private fun Double.toDegrees(): Double = this * 180.0 / PI +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt index 94351bb..a8270f1 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RadioClient.kt @@ -23,13 +23,16 @@ import okio.ByteString.Companion.toByteString import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum +import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.internal.AdminApiImpl import org.meshtastic.sdk.internal.MeshEngine import org.meshtastic.sdk.internal.RoutingApiImpl +import org.meshtastic.sdk.internal.StoreForwardApiImpl import org.meshtastic.sdk.internal.TelemetryApiImpl import kotlin.coroutines.CoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.seconds /** @@ -42,7 +45,7 @@ import kotlin.time.Duration.Companion.seconds * - **State:** [connection], [ownNode], [nodes] (reactive [StateFlow]s and [Flow]s). * - **Messaging:** [send] and [sendText] (enqueue immediately; return a [MessageHandle]). * - **Observability:** [packets], [events] (reactive flows). - * - **Operations:** [admin], [telemetry], [routing] sub-APIs. + * - **Operations:** [admin], [telemetry], [routing], [storeForward] sub-APIs. * * **Drop-in philosophy:** configure once with [Builder], then observe and call: * @@ -90,16 +93,14 @@ public class RadioClient internal constructor( * [ConnectionState.Configuring] → [ConnectionState.Connected]. May cycle through * [ConnectionState.Reconnecting] on transport drops. */ - public val connection: StateFlow - get() = engine.connectionState.asStateFlow() + public val connection: StateFlow = engine.connectionState.asStateFlow() /** * The local node's [NodeInfo], populated after handshake completes. * * `null` until [ConnectionState.Connected] is reached; remains non-null while connected. */ - public val ownNode: StateFlow - get() = engine.ownNode.asStateFlow() + public val ownNode: StateFlow = engine.ownNode.asStateFlow() /** * Most recently committed [ConfigBundle] for the active session. @@ -107,13 +108,24 @@ public class RadioClient internal constructor( * `null` until [ConnectionState.Connected] is reached. Cleared on disconnect / cleanup * so a stale bundle from a prior session can never leak into a new one. */ - public val configBundle: StateFlow - get() = engine.configBundleState.asStateFlow() + public val configBundle: StateFlow = engine.configBundleState.asStateFlow() + + /** + * Channel list for the active session. + * + * Seeded during the handshake from the device's channel payload; falls back to the + * persisted storage snapshot on reconnect if the device does not re-send channels. + * `null` until [ConnectionState.Connected] is reached. + * + * Updated in-memory (and persisted) when [AdminApi.setChannel] succeeds. Call + * [AdminApi.listChannels] to force a full device re-read (8 RPCs). + */ + public val channels: StateFlow?> = engine.channelsState.asStateFlow() /** * Node-change deltas. Late subscribers receive a [NodeChange.Snapshot] immediately * (single-replay), then live [NodeChange.Added] / [NodeChange.Updated] / - * [NodeChange.Removed] in causal order. + * [NodeChange.Removed] / [NodeChange.WentOffline] / [NodeChange.CameOnline] in causal order. * * **Buffering and backpressure:** the underlying `MutableSharedFlow` uses * `extraBufferCapacity = 256` with `BufferOverflow.SUSPEND` (per ADR-005). Slow collectors @@ -121,8 +133,7 @@ public class RadioClient internal constructor( * flow. If the engine inbox itself fills as a result, drops surface as * [MeshEvent.PacketsDropped] on [events] — see ADR-005 §"Backpressure policy". */ - public val nodes: Flow - get() = engine.nodes.hide() + public val nodes: Flow = engine.nodes.hide() /** * Inbound decoded packets. @@ -140,8 +151,7 @@ public class RadioClient internal constructor( * * Populated by the HandshakeMachine after entering [ConnectionState.Connected]. */ - public val packets: Flow - get() = engine.packets.hide() + public val packets: Flow = engine.packets.hide() /** * Side-channel advisory events: queue status, transport errors, key-verification prompts, @@ -149,8 +159,7 @@ public class RadioClient internal constructor( * ([MeshEvent.IdentityRebound] — emitted before the engine clears storage when the * device reports a different NodeNum than the one previously persisted). */ - public val events: Flow - get() = engine.events.hide() + public val events: Flow = engine.events.hide() // ── On-demand query ───────────────────────────────────────────────────── @@ -174,6 +183,31 @@ public class RadioClient internal constructor( return engine.nodeSnapshot() } + /** + * Request a remote node to send its [NodeInfo] (user identity + position). + * + * Sends an empty `NODEINFO_APP` packet with `want_response = true`. The remote node will + * reply with its [User] information, which the engine processes and emits via [nodes]. + * + * @param node the target node to request info from + * @return a [MessageHandle] tracking delivery state + * @throws MeshtasticException.NotConnected if not currently connected + * @since 0.2.0 + */ + @Throws(MeshtasticException::class) + public fun requestNodeInfo(node: NodeId): MessageHandle { + val packet = MeshPacket( + to = node.raw, + want_ack = true, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.NODEINFO_APP, + payload = okio.ByteString.EMPTY, + want_response = true, + ), + ) + return send(packet) + } + // ── Lifecycle ─────────────────────────────────────────────────────────── /** @@ -248,6 +282,10 @@ public class RadioClient internal constructor( * Delegates to [disconnect] via `runBlocking`. Prefer the suspending [disconnect] when * already inside a coroutine — this overload exists so `RadioClient` works with Kotlin's * `use { }` idiom and Java's try-with-resources. + * + * **Warning:** Do not call from the main/UI thread — `runBlocking` will block the caller + * until disconnection completes, which can trigger ANR on Android or deadlock on iOS main. + * Use [disconnect] directly from a coroutine scope instead. */ override fun close() { runBlocking { disconnect() } @@ -300,6 +338,14 @@ public class RadioClient internal constructor( * Wraps the text in a [MeshPacket] with `decoded.portnum = TEXT_MESSAGE_APP` and * `decoded.payload = text.encodeToByteArray()`, then calls [send]. * + * Sets `want_ack = true` so the firmware provides delivery feedback: + * - **Unicast**: the recipient sends a Routing ACK back to the sender. + * - **Broadcast**: the firmware generates an implicit ACK when it overhears a neighbor + * relay the packet. This confirms at least one mesh node retransmitted the message. + * + * The returned [MessageHandle] will resolve to [SendOutcome.Success] on ACK, or + * [SendOutcome.Failure] if the firmware exhausts retransmissions without confirmation. + * * @param text the message text (UTF-8 encoded) * @param channel the channel index (default: 0) * @param to the destination [NodeId] (default: [NodeId.BROADCAST]) @@ -320,16 +366,57 @@ public class RadioClient internal constructor( val packet = MeshPacket( to = to.raw, channel = channel.raw, + want_ack = true, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, + payload = payload.toByteString(), + ), + ) + return send(packet) + } + + /** + * Convenience: send an emoji reaction to an existing message. + * + * Wraps the [emoji] in a [MeshPacket] with `decoded.portnum = TEXT_MESSAGE_APP`, + * `decoded.emoji = 1` (indicating this is a reaction, not a standalone message), and + * `decoded.reply_id` set to [replyId] (the packet ID of the message being reacted to). + * + * @param emoji the emoji string (single emoji character or sequence) + * @param to the destination [NodeId] (the sender of the original message, or broadcast) + * @param channel the channel index the reaction should be sent on + * @param replyId the packet ID of the original message being reacted to + * @return a handle tracking delivery state + * @throws MeshtasticException.NotConnected if not currently connected + * @throws MeshtasticException.PayloadTooLarge if the encoded emoji exceeds the device limit + * @since 0.2.0 + */ + @Throws(MeshtasticException::class) + public fun sendReaction( + emoji: String, + to: NodeId = NodeId.BROADCAST, + channel: ChannelIndex = ChannelIndex(0), + replyId: Int, + ): MessageHandle { + val payload = emoji.encodeToByteArray() + if (payload.size > DATA_PAYLOAD_LEN) { + throw MeshtasticException.PayloadTooLarge(DATA_PAYLOAD_LEN) + } + val packet = MeshPacket( + to = to.raw, + channel = channel.raw, + want_ack = true, decoded = org.meshtastic.proto.Data( portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, payload = payload.toByteString(), + emoji = EMOJI_INDICATOR, + reply_id = replyId, ), ) return send(packet) } /** - * Convenience: build a [MeshPacket] for the given [portnum] with [payload] and enqueue it. * * Constructs a packet with `decoded = Data(portnum, payload, want_response = false)` and * forwards to [send]. Exists so callers do not need to import `org.meshtastic.proto.Data` @@ -456,9 +543,48 @@ public class RadioClient internal constructor( RoutingApiImpl(engine = engine, rpcTimeout = rpcTimeout) } + /** + * Store-and-Forward API for requesting stored messages. Available while connected. + */ + public val storeForward: StoreForwardApi by lazy(LazyThreadSafetyMode.PUBLICATION) { + StoreForwardApiImpl( + engine = engine, + packetsFlow = engine.packets, + rpcTimeout = rpcTimeout, + coroutineContext = parentContext, + nowProvider = { clock.now() }, + ) + } + + /** + * Sends a raw [ToRadio] frame to the device. + * + * This is a low-level escape hatch for device features not yet covered by higher-level APIs + * (e.g., MQTT client proxy messages, XModem file transfers). The SDK engine does **not** track + * or acknowledge these frames — delivery is best-effort at the transport level. + * + * Prefer [send], [sendText], or the typed sub-APIs ([admin], [telemetry], [routing], + * [storeForward], etc.) whenever + * possible. + * + * @param frame the fully constructed [ToRadio] message to send + * @throws MeshtasticException.NotConnected if not currently connected + * @since 0.2.0 + */ + @Throws(MeshtasticException::class) + public fun sendRaw(frame: ToRadio) { + if (connection.value !is ConnectionState.Connected) { + throw MeshtasticException.NotConnected() + } + engine.sendToRadio(frame) + } + // ── Builder ───────────────────────────────────────────────────────────── public companion object { + /** Indicates a reaction (emoji) rather than a standalone text message. */ + private const val EMOJI_INDICATOR: Int = 1 + /** Create a new [Builder]. */ public fun Builder(): Builder = Builder() } @@ -490,6 +616,7 @@ public class RadioClient internal constructor( private var payloadRedactor: PayloadRedactor = PayloadRedactor.Default private var sendTimeout: Duration = 30.seconds private var rpcTimeout: Duration = 30.seconds + private var presenceTimeout: Duration = 2.hours private var autoReconnectConfig: AutoReconnectConfig = AutoReconnectConfig.Disabled /** @@ -506,8 +633,8 @@ public class RadioClient internal constructor( * .build() * ``` * - * Phase 2+ will add factory helpers so [TransportSpec] can be used without providing a - * transport implementation manually. + * Future: factory helpers for common transport configurations may allow + * [TransportSpec] to be used without manually constructing a transport implementation. */ public fun transport(transport: RadioTransport): Builder = apply { radioTransport = transport } @@ -644,6 +771,9 @@ public class RadioClient internal constructor( */ public fun rpcTimeout(duration: Duration): Builder = apply { rpcTimeout = duration } + /** Configure the online/offline presence timeout for node presence events. */ + public fun presenceTimeout(timeout: Duration): Builder = apply { presenceTimeout = timeout } + /** * Configure the engine's built-in auto-reconnect supervisor. * @@ -691,7 +821,9 @@ public class RadioClient internal constructor( logger = logSink, bleHeartbeatEnabled = bleHeartbeatEnabled, parentContext = coroutineContext, + clock = clock, sendTimeout = sendTimeout, + presenceTimeout = presenceTimeout, autoReconnectConfig = autoReconnectConfig, ) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt index 4f5adc7..d443904 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Result.kt @@ -200,6 +200,27 @@ public sealed interface ConnectionState { public data class Reconnecting(val cause: MeshtasticException, val attempt: Int) : ConnectionState } +/** Whether the connection is fully established and ready for use. */ +public val ConnectionState.isUsable: Boolean + get() = this is ConnectionState.Connected + +/** Whether a connection attempt is actively in progress. */ +public val ConnectionState.isInProgress: Boolean + get() = + this is ConnectionState.Connecting || + this is ConnectionState.Configuring || + this is ConnectionState.Reconnecting + +/** Human-readable status description. */ +public val ConnectionState.statusMessage: String + get() = when (this) { + is ConnectionState.Disconnected -> "Disconnected" + is ConnectionState.Connecting -> "Connecting (attempt $attempt)" + is ConnectionState.Configuring -> "Configuring: ${phase.name} (${(progress * 100).toInt()}%)" + is ConnectionState.Connected -> "Connected" + is ConnectionState.Reconnecting -> "Reconnecting (attempt $attempt)" + } + /** * Handshake phase for progress reporting. * @@ -257,6 +278,13 @@ public sealed interface AdminResult { */ public data object Timeout : AdminResult + /** + * The device rate-limited the request; back off before retrying. + * + * (From `Routing.Error.RATE_LIMIT_EXCEEDED`.) + */ + public data object RateLimited : AdminResult + /** * Destination node is unreachable. */ @@ -269,3 +297,75 @@ public sealed interface AdminResult { */ public data class Failed(val routingError: Routing.Error) : AdminResult } + +// ── AdminResult extensions ────────────────────────────────────────────────── + +/** Returns the [Success] value, or `null` if the result is not a success. */ +public fun AdminResult.getOrNull(): T? = (this as? AdminResult.Success)?.value + +/** Returns the [Success] value, or [default] if the result is not a success. */ +public fun AdminResult.getOrElse(default: T): T = getOrNull() ?: default + +/** Returns the [Success] value, or the result of [block] if the result is not a success. */ +public inline fun AdminResult.getOrElse(block: (AdminResult) -> T): T = when (this) { + is AdminResult.Success -> value + else -> block(this) +} + +/** Returns `true` if this is [AdminResult.Success]. */ +public val AdminResult.isSuccess: Boolean get() = this is AdminResult.Success + +/** Transforms the [Success] value with [transform], propagating failures unchanged. */ +public inline fun AdminResult.map(transform: (T) -> R): AdminResult = when (this) { + is AdminResult.Success -> AdminResult.Success(transform(value)) + is AdminResult.SessionKeyExpired -> this + is AdminResult.Unauthorized -> this + is AdminResult.Timeout -> this + is AdminResult.RateLimited -> this + is AdminResult.NodeUnreachable -> this + is AdminResult.Failed -> this +} + +/** Applies [onSuccess] or [onFailure] depending on the result. */ +public inline fun AdminResult.fold(onSuccess: (T) -> R, onFailure: (AdminResult) -> R): R = when (this) { + is AdminResult.Success -> onSuccess(value) + else -> onFailure(this) +} + +/** Performs [action] if this is a [Success]. Returns the original result for chaining. */ +public inline fun AdminResult.onSuccess(action: (T) -> Unit): AdminResult { + if (this is AdminResult.Success) action(value) + return this +} + +/** Performs [action] if this is not a [Success]. Returns the original result for chaining. */ +public inline fun AdminResult.onFailure(action: (AdminResult) -> Unit): AdminResult { + if (this !is AdminResult.Success) action(this) + return this +} + +/** + * Returns the [Success] value or throws [AdminResultException] describing the failure. + * + * Useful for callers who prefer exception-based error handling over sealed-type matching. + */ +public fun AdminResult.getOrThrow(): T = when (this) { + is AdminResult.Success -> value + is AdminResult.SessionKeyExpired -> throw AdminResultException.SessionKeyExpired() + is AdminResult.Unauthorized -> throw AdminResultException.Unauthorized() + is AdminResult.Timeout -> throw AdminResultException.Timeout() + is AdminResult.RateLimited -> throw AdminResultException.RateLimited() + is AdminResult.NodeUnreachable -> throw AdminResultException.NodeUnreachable() + is AdminResult.Failed -> throw AdminResultException.RoutingFailed(routingError) +} + +/** Exception hierarchy thrown by [getOrThrow]. */ +public sealed class AdminResultException(message: String) : Exception(message) { + public class SessionKeyExpired : AdminResultException("Admin session key expired") + public class Unauthorized : AdminResultException("Not authorized for this operation") + public class Timeout : AdminResultException("Operation timed out waiting for device response") + public class RateLimited : AdminResultException("Device rate-limited the request") + public class NodeUnreachable : AdminResultException("Destination node is unreachable") + public class RoutingFailed(public val error: Routing.Error) : + AdminResultException("Routing error: ${error.name}") +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt new file mode 100644 index 0000000..6685d43 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RetryPolicy.kt @@ -0,0 +1,89 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.math.pow +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Defines a retry strategy for failed message sends. + * + * Use with [MessageHandle] to implement structured retry behavior: + * ```kotlin + * val policy = RetryPolicy.ExponentialBackoff() + * val handle = client.sendText("hello") + * handle.retryWith(policy) // suspends until success, non-retryable failure, or max attempts exhausted + * ``` + */ +public sealed class RetryPolicy { + /** No retries — fail immediately on first failure. */ + public data object None : RetryPolicy() + + /** + * Retry with fixed delay between attempts. + * + * @property maxAttempts maximum number of retry attempts (not counting the initial send) + * @property delay fixed delay between retries + */ + public data class Fixed(val maxAttempts: Int = 3, val delay: Duration = 5.seconds) : RetryPolicy() + + /** + * Retry with exponential backoff and optional jitter. + * + * Delay formula: `min(initialDelay * multiplier^attempt, maxDelay) ± jitter` + * + * @property maxAttempts maximum number of retry attempts + * @property initialDelay delay before first retry + * @property maxDelay maximum delay cap + * @property multiplier backoff multiplier per attempt + * @property jitterFactor random jitter factor (0.0 = no jitter, 0.2 = ±20%) + */ + public data class ExponentialBackoff( + val maxAttempts: Int = 5, + val initialDelay: Duration = 2.seconds, + val maxDelay: Duration = 60.seconds, + val multiplier: Double = 2.0, + val jitterFactor: Double = 0.2, + ) : RetryPolicy() + + /** + * Computes the delay before the Nth retry attempt (0-indexed). + * Returns null if the attempt exceeds maxAttempts. + * + * @return the delay before the next attempt, or null if max attempts exceeded + */ + public fun delayForAttempt(attempt: Int): Duration? = when (this) { + is None -> null + + is Fixed -> if (attempt < maxAttempts) delay else null + + is ExponentialBackoff -> { + if (attempt >= maxAttempts) { + null + } else { + val base = initialDelay * multiplier.pow(attempt.toDouble()) + val capped = if (base > maxDelay) maxDelay else base + if (jitterFactor > 0.0) { + val jitter = 1.0 + (Random.nextDouble() * 2 - 1) * jitterFactor + capped * jitter + } else { + capped + } + } + } + } + + /** Maximum number of attempts for this policy. */ + public val maxRetries: Int get() = when (this) { + is None -> 0 + is Fixed -> maxAttempts + is ExponentialBackoff -> maxAttempts + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt new file mode 100644 index 0000000..8719d0f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RouteDiscoveryResult.kt @@ -0,0 +1,73 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +/** + * Parsed result of a traceroute discovery, representing the full forward and backward routes. + * + * @property route Full forward route (source → destination), including endpoints + * @property routeBack Full return route (destination → source), including endpoints + * @property snrTowards Per-hop SNR values on the forward route (may be empty if not available) + * @property snrBack Per-hop SNR values on the return route (may be empty if not available) + * @property hopsAway Number of hops between source and destination + */ +public data class RouteDiscoveryResult( + public val route: List, + public val routeBack: List, + public val snrTowards: List = emptyList(), + public val snrBack: List = emptyList(), +) { + /** Number of intermediate hops (excludes source and destination). */ + public val hopsAway: Int get() = maxOf(0, route.size - 2) + + /** + * Formats the route as a human-readable string using the provided node name resolver. + */ + public fun formatRoute(resolveNode: (NodeId) -> String): String = buildString { + appendLine("Route (${route.size} nodes):") + route.forEachIndexed { i, nodeId -> + append(" ${resolveNode(nodeId)}") + if (i < snrTowards.size) append(" (SNR: ${snrTowards[i]})") + appendLine() + } + if (routeBack.isNotEmpty()) { + appendLine("Route back (${routeBack.size} nodes):") + routeBack.forEachIndexed { i, nodeId -> + append(" ${resolveNode(nodeId)}") + if (i < snrBack.size) append(" (SNR: ${snrBack[i]})") + appendLine() + } + } + } + + public companion object { + /** + * Reconstructs a full route from a RouteDiscovery proto response. + * + * @param source the node that initiated the traceroute + * @param destination the target node + * @param intermediateRoute intermediate node IDs from the proto route field + * @param intermediateRouteBack intermediate node IDs from the proto route_back field + * @param snrTowards SNR values from proto snr_towards field + * @param snrBack SNR values from proto snr_back field + */ + public fun fromProto( + source: NodeId, + destination: NodeId, + intermediateRoute: List, + intermediateRouteBack: List, + snrTowards: List = emptyList(), + snrBack: List = emptyList(), + ): RouteDiscoveryResult = RouteDiscoveryResult( + route = listOf(source) + intermediateRoute.map { NodeId(it) } + destination, + routeBack = listOf(destination) + intermediateRouteBack.map { NodeId(it) } + source, + snrTowards = snrTowards, + snrBack = snrBack, + ) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt index 9d99e68..d928d29 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/RoutingApi.kt @@ -7,8 +7,8 @@ */ package org.meshtastic.sdk -import org.meshtastic.proto.NeighborInfo import org.meshtastic.proto.RouteDiscovery +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo /** * Mesh route discovery and neighbor enumeration RPCs. @@ -31,10 +31,10 @@ public interface RoutingApi { public suspend fun traceRoute(dest: NodeId, hopLimit: Int = DEFAULT_HOP_LIMIT): AdminResult /** - * Request the [NeighborInfo] of [node] (default: local). Surfaces immediate neighbors and + * Request the [ProtoNeighborInfo] of [node] (default: local). Surfaces immediate neighbors and * their last-heard SNR / interval. */ - public suspend fun requestNeighborInfo(node: NodeId = NodeId.LOCAL): AdminResult + public suspend fun requestNeighborInfo(node: NodeId = NodeId.LOCAL): AdminResult public companion object { public const val DEFAULT_HOP_LIMIT: Int = 7 diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt new file mode 100644 index 0000000..a2def99 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/SfppHash.kt @@ -0,0 +1,41 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import okio.ByteString.Companion.toByteString + +/** + * Computes Store-Forward-Plus-Plus (SFPP) message hashes for deduplication. + * + * The hash is SHA-256(payload || to_LE32 || from_LE32 || id_LE32) truncated to 16 bytes. + */ +public object SfppHash { + private const val HASH_LENGTH: Int = 16 + + /** + * Compute the SFPP deduplication hash for a message. + * + * @param payload the encrypted message payload + * @param to destination node number (little-endian) + * @param from source node number (little-endian) + * @param id packet ID (little-endian) + * @return 16-byte truncated SHA-256 hash + */ + public fun compute(payload: ByteArray, to: Int, from: Int, id: Int): ByteArray { + val input = ByteArray(payload.size + 12) + payload.copyInto(input) + var offset = payload.size + for (value in intArrayOf(to, from, id)) { + input[offset++] = value.toByte() + input[offset++] = (value shr 8).toByte() + input[offset++] = (value shr 16).toByte() + input[offset++] = (value shr 24).toByte() + } + return input.toByteString().sha256().toByteArray().copyOf(HASH_LENGTH) + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt new file mode 100644 index 0000000..d7d555f --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/SharedContactUrl.kt @@ -0,0 +1,47 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.SharedContact +import org.meshtastic.sdk.internal.base64UrlDecode +import org.meshtastic.sdk.internal.base64UrlEncode + +/** + * Utilities for encoding and parsing Meshtastic shared contact URLs. + * + * Contact URLs use the format: `https://meshtastic.org/v/#[?query-params]` + */ +public object SharedContactUrl { + /** Standard URL prefix for shared contacts. */ + public const val PREFIX: String = "https://meshtastic.org/v/#" + + /** + * Encodes a [SharedContact] proto into a shareable URL. + */ + public fun encode(contact: SharedContact): String { + val bytes = SharedContact.ADAPTER.encode(contact) + return PREFIX + base64UrlEncode(bytes) + } + + /** + * Parses a shared contact URL into a [SharedContact]. + * Returns `null` if the URL is malformed or payload fails to decode. + */ + public fun parse(url: String): SharedContact? { + val trimmed = url.trim() + val hashIdx = trimmed.indexOf('#') + if (hashIdx < 0) return null + val payload = trimmed.substring(hashIdx + 1).substringBefore('?') + if (payload.isEmpty()) return null + val bytes = base64UrlDecode(payload) ?: return null + return runCatching { SharedContact.ADAPTER.decode(bytes) }.getOrNull() + } +} + +/** Encodes this contact into a shareable URL. */ +public fun SharedContact.toUrl(): String = SharedContactUrl.encode(this) diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt index 5b4bcb8..335d05a 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/Storage.kt @@ -10,6 +10,7 @@ package org.meshtastic.sdk import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.NodeInfo @@ -34,6 +35,9 @@ public data class ConfigBundle( /** All module configs. */ public val moduleConfigs: List, + + /** Device UI configuration (display preferences, language, etc.), if provided by firmware. */ + public val deviceUIConfig: DeviceUIConfig? = null, ) /** diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt new file mode 100644 index 0000000..1cd7e5b --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/StoreForwardApi.kt @@ -0,0 +1,147 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * API for interacting with Store-and-Forward (S&F) nodes on the mesh. + * + * S&F nodes temporarily store messages for offline nodes and deliver them when the + * target comes back online. This API enables clients to discover S&F servers, + * request missed messages, and query S&F statistics. + * + * **Note:** This API requires firmware support for the STORE_FORWARD_APP port number. + * Not all nodes on the mesh will have S&F capabilities. + * + * Access via `RadioClient.storeForward` (available after connection). + * + * @since 0.2.0 + */ +public interface StoreForwardApi { + + /** + * Known S&F server nodes on the mesh. + * + * Automatically populated when nodes advertise S&F capability via their + * NodeInfo or heartbeat. Updated reactively. + */ + public val servers: StateFlow> + + /** + * Request delivery of messages stored for this node since the given timestamp. + * + * The S&F server will replay stored messages matching this node's ID. + * Messages are delivered via the normal `RadioClient.packets` flow. + * + * @param since seconds since epoch — only messages after this time are requested. + * If null, requests all available stored messages. + * @param server specific S&F server to query. If null, queries the first known server. + * @return the number of messages the server reports as pending, or failure reason + */ + public suspend fun requestHistory(since: Int? = null, server: NodeId? = null): AdminResult + + /** + * Query statistics from a Store-and-Forward server. + * + * @param server the S&F node to query. If null, queries the first known server. + * @return server statistics including capacity, stored message count, and uptime + */ + public suspend fun requestStats(server: NodeId? = null): AdminResult + + /** + * Flow of S&F-specific events (heartbeats, delivery confirmations, etc.). + */ + public val events: Flow +} + +/** + * Statistics reported by a Store-and-Forward server node. + * + * @property messagesStored current number of messages held in the store + * @property messagesMax maximum storage capacity + * @property uptime server uptime in seconds + * @property requests total number of history requests served + * @property requestsFailed number of failed history requests + * @property heartbeat whether the server sends periodic heartbeats + */ +public data class StoreForwardStats( + val messagesStored: Int = 0, + val messagesMax: Int = 0, + val uptime: Int = 0, + val requests: Int = 0, + val requestsFailed: Int = 0, + val heartbeat: Boolean = false, +) + +/** + * Events specific to Store-and-Forward operations. + */ +public sealed interface StoreForwardEvent { + /** + * A S&F server was discovered on the mesh. + */ + public data class ServerDiscovered(val nodeId: NodeId) : StoreForwardEvent + + /** + * A S&F server went offline or was removed. + */ + public data class ServerLost(val nodeId: NodeId) : StoreForwardEvent + + /** + * History replay has started — messages are being delivered. + * + * @property server the S&F node delivering messages + * @property messageCount number of messages being replayed + */ + public data class HistoryReplayStarted(val server: NodeId, val messageCount: Int) : StoreForwardEvent + + /** + * History replay is complete. + */ + public data class HistoryReplayComplete(val server: NodeId, val delivered: Int) : StoreForwardEvent + + /** + * Heartbeat received from a S&F server (indicates it's still active). + */ + public data class Heartbeat(val server: NodeId) : StoreForwardEvent + + /** + * An SFPP link was provided — message is being routed or confirmed. + * + * @property to The normalized destination node num (broadcast 0 is replaced with [NodeId.BROADCAST]). + * @property messageHash The computed or provided hash for correlation (null if unavailable). + */ + public data class SfppLinkProvided( + val packetId: Int, + val from: Int, + val to: Int, + val messageHash: ByteArray?, + val confirmed: Boolean, + ) : StoreForwardEvent { + override fun equals(other: Any?): Boolean = other is SfppLinkProvided && + packetId == other.packetId && + from == other.from && + to == other.to && + confirmed == other.confirmed && + messageHash.contentEquals(other.messageHash) + + override fun hashCode(): Int = (((packetId * 31 + from) * 31 + to) * 31 + confirmed.hashCode()) * 31 + + messageHash.contentHashCode() + } + + /** An SFPP canon announce — message is confirmed on the chain. */ + public data class SfppCanonAnnounced(val messageHash: ByteArray, val rxTime: Long) : StoreForwardEvent { + override fun equals(other: Any?): Boolean = other is SfppCanonAnnounced && + messageHash.contentEquals(other.messageHash) && + rxTime == other.rxTime + + override fun hashCode(): Int = messageHash.contentHashCode() * 31 + rxTime.hashCode() + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt index 1d07d90..f186891 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/TelemetryApi.kt @@ -11,9 +11,12 @@ import kotlinx.coroutines.flow.Flow import org.meshtastic.proto.AirQualityMetrics import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HealthMetrics +import org.meshtastic.proto.HostMetrics import org.meshtastic.proto.LocalStats import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.TrafficManagementStats /** * Telemetry RPCs and observation. @@ -44,6 +47,15 @@ public interface TelemetryApi { /** Request the local node's [LocalStats] (mesh-wide stats sourced from this device). */ public suspend fun requestLocalStats(): AdminResult + /** Request the latest [HealthMetrics] from [node] (heart rate, SpO2, temperature). */ + public suspend fun requestHealth(node: NodeId = NodeId.LOCAL): AdminResult + + /** Request the latest [HostMetrics] from [node] (CPU, memory, disk usage on Linux hosts). */ + public suspend fun requestHost(node: NodeId = NodeId.LOCAL): AdminResult + + /** Request the latest [TrafficManagementStats] from [node] (packet counts, duty cycle). */ + public suspend fun requestTrafficManagement(node: NodeId = NodeId.LOCAL): AdminResult + /** * Cold flow of every [Telemetry] packet observed for [node]. The flow never completes * organically — collect inside a `launch { … }` and cancel when done. diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt index 4eb3b64..d134dfb 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/AdminApiImpl.kt @@ -9,22 +9,35 @@ package org.meshtastic.sdk.internal import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel import org.meshtastic.proto.Config import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.Routing +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact import org.meshtastic.proto.User import org.meshtastic.sdk.AdminApi +import org.meshtastic.sdk.AdminBatchScope import org.meshtastic.sdk.AdminEdit import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.SendFailure import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.getOrThrow import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Instant @@ -44,8 +57,35 @@ internal class AdminApiImpl( private val engine: MeshEngine, private val rpcTimeout: Duration, private val nowProvider: () -> Instant = { Clock.System.now() }, + private val targetNode: NodeId? = null, ) : AdminApi { + override fun forNode(dest: NodeId): AdminApi = AdminApiImpl( + engine = engine, + rpcTimeout = rpcTimeout, + nowProvider = nowProvider, + targetNode = dest, + ) + + override suspend fun getDeviceMetadata(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_device_metadata_request = true), + kind = ResponseKind.AdminDeviceMetadata, + ) + } + + /** + * Returns `true` if the device is in managed mode, meaning all admin commands from non-zero + * `from` addresses are silently dropped by firmware. The SDK always sends with + * `from = myNodeNum` (non-zero post-handshake), so all admin commands would be ignored. + */ + private fun isDeviceManaged(): Boolean { + val bundle = engine.configBundleState.value ?: return false + return bundle.configs.any { config -> + config.security?.is_managed == true + } + } + override suspend fun getConfig(type: AdminMessage.ConfigType): AdminResult = retryOnSessionExpiry { submitAdminRpc( adminMsg = AdminMessage(get_config_request = type), @@ -82,13 +122,17 @@ internal class AdminApiImpl( override suspend fun getChannel(index: ChannelIndex): AdminResult = retryOnSessionExpiry { submitAdminRpc( - adminMsg = AdminMessage(get_channel_request = index.raw), + // Firmware expects 1-based index (proto3 omits 0 as default value). + // See admin.proto: "NOTE: This field is sent with the channel index + 1" + adminMsg = AdminMessage(get_channel_request = index.raw + 1), kind = ResponseKind.AdminChannel, ) } - override suspend fun setChannel(channel: Channel): AdminResult = retryOnSessionExpiry { - submitAdminAck(AdminMessage(set_channel = channel)) + override suspend fun setChannel(channel: Channel): AdminResult { + val result = retryOnSessionExpiry { submitAdminAck(AdminMessage(set_channel = channel)) } + if (result is AdminResult.Success) engine.updateChannelAndPersist(channel) + return result } override suspend fun listChannels(): AdminResult> { @@ -105,6 +149,7 @@ internal class AdminApiImpl( AdminResult.Timeout, AdminResult.NodeUnreachable, AdminResult.SessionKeyExpired, AdminResult.Unauthorized, + AdminResult.RateLimited, is AdminResult.Failed, -> return result.let { @Suppress("UNCHECKED_CAST") @@ -133,6 +178,161 @@ internal class AdminApiImpl( submitAdminAck(msg) } + override suspend fun toggleMuted(node: NodeId): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(toggle_muted_node = node.raw)) + } + + // ── Position ──────────────────────────────────────────────────────────── + + override suspend fun setFixedPosition(position: Position): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_fixed_position = position)) + } + + override suspend fun removeFixedPosition(): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_fixed_position = true)) + } + + // ── Device UI Config ──────────────────────────────────────────────────── + + override suspend fun getUIConfig(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_ui_config_request = true), + kind = ResponseKind.AdminDeviceUIConfig, + ) + } + + override suspend fun storeUIConfig(config: DeviceUIConfig): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(store_ui_config = config)) + } + + // ── Canned Messages ───────────────────────────────────────────────────── + + override suspend fun getCannedMessages(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_canned_message_module_messages_request = true), + kind = ResponseKind.AdminCannedMessages, + ) + } + + override suspend fun setCannedMessages(messages: String): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_canned_message_module_messages = messages)) + } + + // ── Ringtone ──────────────────────────────────────────────────────────── + + override suspend fun getRingtone(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_ringtone_request = true), + kind = ResponseKind.AdminRingtone, + ) + } + + override suspend fun setRingtone(rtttl: String): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_ringtone_message = rtttl)) + } + + // ── Device status ─────────────────────────────────────────────────────── + + override suspend fun getDeviceConnectionStatus(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_device_connection_status_request = true), + kind = ResponseKind.AdminDeviceConnectionStatus, + ) + } + + override suspend fun getRemoteHardwarePins(): AdminResult = retryOnSessionExpiry { + submitAdminRpc( + adminMsg = AdminMessage(get_node_remote_hardware_pins_request = true), + kind = ResponseKind.AdminRemoteHardwarePins, + ) + } + + // ── Ham radio ─────────────────────────────────────────────────────────── + + override suspend fun setHamMode(params: HamParameters): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_ham_mode = params)) + } + + // ── DFU / file management ─────────────────────────────────────────────── + + override suspend fun enterDfuMode(): AdminResult { + if (isDeviceManaged()) return AdminResult.Unauthorized + return submitAdminFireAndForget(AdminMessage(enter_dfu_mode_request = true)) + } + + override suspend fun deleteFile(path: String): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(delete_file_request = path)) + } + + // ── Backup / Restore ──────────────────────────────────────────────────── + + override suspend fun backupPreferences(location: AdminMessage.BackupLocation): AdminResult = + retryOnSessionExpiry { + submitAdminAck(AdminMessage(backup_preferences = location)) + } + + override suspend fun restorePreferences(location: AdminMessage.BackupLocation): AdminResult = + retryOnSessionExpiry { + submitAdminAck(AdminMessage(restore_preferences = location)) + } + + override suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation): AdminResult = + retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_backup_preferences = location)) + } + + // ── Node removal ──────────────────────────────────────────────────────── + + override suspend fun removeNode(node: NodeId): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(remove_by_nodenum = node.raw)) + } + + // ── Input / Display ───────────────────────────────────────────────────── + + override suspend fun setScale(scale: Int): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(set_scale = scale)) + } + + override suspend fun sendInputEvent(event: AdminMessage.InputEvent): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(send_input_event = event)) + } + + // ── Contacts ──────────────────────────────────────────────────────────── + + override suspend fun addContact(contact: SharedContact): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(add_contact = contact)) + } + + // ── Key verification ──────────────────────────────────────────────────── + + override suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(key_verification = verification)) + } + + // ── OTA updates ───────────────────────────────────────────────────────── + + override suspend fun rebootOta(after: Duration): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(reboot_ota_seconds = after.inWholeSeconds.toInt().coerceAtLeast(0))) + } + + override suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(ota_request = event)) + } + + // ── Sensor ────────────────────────────────────────────────────────────── + + override suspend fun setSensorConfig(config: SensorConfig): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(sensor_config = config)) + } + + // ── Simulator ─────────────────────────────────────────────────────────── + + override suspend fun exitSimulator(): AdminResult = retryOnSessionExpiry { + submitAdminAck(AdminMessage(exit_simulator = true)) + } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + override suspend fun reboot(after: Duration): AdminResult = retryOnSessionExpiry { submitAdminAck(AdminMessage(reboot_seconds = after.inWholeSeconds.toInt().coerceAtLeast(0))) } @@ -152,20 +352,22 @@ internal class AdminApiImpl( submitAdminAck(msg) } - override suspend fun nodeDbReset(preserveFavorites: Boolean): AdminResult = retryOnSessionExpiry { - // Firmware exposes only `nodedb_reset = true`; preserveFavorites is honoured by the - // device's own NodeDB module which keeps favorite-marked entries across the wipe. + override suspend fun nodeDbReset(): AdminResult = retryOnSessionExpiry { submitAdminAck(AdminMessage(nodedb_reset = true)) } - override suspend fun setTime(at: Instant?): AdminResult = retryOnSessionExpiry { + override suspend fun setTimeOnly(unixTime: Int): AdminResult { + if (isDeviceManaged()) return AdminResult.Unauthorized + return submitAdminFireAndForget(AdminMessage(set_time_only = unixTime)) + } + + override suspend fun setTime(at: Instant?): AdminResult { val instant = at ?: nowProvider() - val seconds = instant.epochSeconds.toInt() - submitAdminAck(AdminMessage(set_time_only = seconds)) + return setTimeOnly(instant.epochSeconds.toInt()) } override suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult { - val begin = retryOnSessionExpiry { submitAdminAck(AdminMessage(begin_edit_settings = true)) } + val begin = beginEditSettings() if (begin !is AdminResult.Success) return begin.cast() val edit = AdminEditImpl() val payload = try { @@ -173,11 +375,29 @@ internal class AdminApiImpl( } catch (e: AdminEditFailure) { return e.result.cast() } - val commit = retryOnSessionExpiry { submitAdminAck(AdminMessage(commit_edit_settings = true)) } + val commit = commitEditSettings() if (commit !is AdminResult.Success) return commit.cast() + + // Gap G: optimistically update configBundle with written values after successful commit. + engine.applyConfigEdits(edit.writtenConfigs, edit.writtenModuleConfigs) + return AdminResult.Success(payload) } + override suspend fun batch(block: suspend AdminBatchScope.() -> T): T { + beginEditSettings().getOrThrow() + val edit = AdminEditImpl() + val payload = try { + AdminBatchScopeImpl(edit).block() + } catch (e: AdminEditFailure) { + e.result.getOrThrow() + } + commitEditSettings().getOrThrow() + + engine.applyConfigEdits(edit.writtenConfigs, edit.writtenModuleConfigs) + return payload + } + // ── Internal helpers ──────────────────────────────────────────────────── /** @@ -224,9 +444,11 @@ internal class AdminApiImpl( ) val stateFlow = MutableStateFlow(SendState.Queued) engine.trySend(packet, id, stateFlow) - val terminal = stateFlow.first { - it is SendState.Failed || it == SendState.Acked || it == SendState.Delivered - } + val terminal = withTimeoutOrNull(rpcTimeout) { + stateFlow.first { + it is SendState.Failed || it == SendState.Acked || it == SendState.Delivered + } + } ?: return AdminResult.Timeout return when (terminal) { SendState.Acked, SendState.Delivered -> AdminResult.Success(Unit) is SendState.Failed -> mapSendFailureToAdminResult(terminal.reason) @@ -234,26 +456,68 @@ internal class AdminApiImpl( } } + /** + * Send an admin packet without waiting for a firmware reply or routing ACK. + */ + private fun submitAdminFireAndForget(adminMsg: AdminMessage, to: NodeId = localNode()): AdminResult { + engine.sendAdmin(adminMsg = adminMsg, to = to.raw) + return AdminResult.Success(Unit) + } + + private suspend fun beginEditSettings(): AdminResult = + retryOnSessionExpiry { submitAdminAck(AdminMessage(begin_edit_settings = true)) } + + private suspend fun commitEditSettings(): AdminResult = + retryOnSessionExpiry { submitAdminAck(AdminMessage(commit_edit_settings = true)) } + /** * Single-shot retry on `SessionKeyExpired`: re-issue `get_owner_request` to refresh the * session passkey, then replay the original [block] once. The retry result is returned as-is * so a second `SessionKeyExpired` surfaces to the caller (the device is rejecting our key). */ private suspend fun retryOnSessionExpiry(block: suspend () -> AdminResult): AdminResult { + if (isDeviceManaged()) return AdminResult.Unauthorized val first = block() if (first !is AdminResult.SessionKeyExpired) return first - // Re-seed: a fresh getOwner round-trip latches a new session_passkey. We don't propagate - // its success / failure — the original call's retry is the user-visible signal. - getOwner() + // Re-seed against the *local* device PhoneAPI, even when this AdminApiImpl is scoped to + // a remote node via forNode(dest). Remote getOwner requires a valid session passkey, so + // retrying against targetNode would just loop the same expiry failure. + reseedSessionPasskey() return block() } - private fun localNode(): NodeId = NodeId(engine.myNodeNumOrNull() ?: 0) + private suspend fun reseedSessionPasskey(): AdminResult = submitAdminRpc( + adminMsg = AdminMessage(get_owner_request = true), + kind = ResponseKind.AdminOwner, + to = NodeId(engine.myNodeNumOrNull() ?: 0), + ) + + private fun localNode(): NodeId = targetNode ?: NodeId(engine.myNodeNumOrNull() ?: 0) + + private inner class AdminBatchScopeImpl(edit: AdminEditImpl) : + AdminBatchScope, + AdminEdit by edit { + override suspend fun getConfig(type: AdminMessage.ConfigType): Config = + this@AdminApiImpl.getConfig(type).getOrThrow() + + override suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): ModuleConfig = + this@AdminApiImpl.getModuleConfig(type).getOrThrow() + + override suspend fun listChannels(): List = this@AdminApiImpl.listChannels().getOrThrow() + } private inner class AdminEditImpl : AdminEdit { - override suspend fun setConfig(config: Config) = enqueueOrThrow(AdminMessage(set_config = config)) - override suspend fun setModuleConfig(config: ModuleConfig) = + val writtenConfigs = mutableListOf() + val writtenModuleConfigs = mutableListOf() + + override suspend fun setConfig(config: Config) { + enqueueOrThrow(AdminMessage(set_config = config)) + writtenConfigs += config + } + override suspend fun setModuleConfig(config: ModuleConfig) { enqueueOrThrow(AdminMessage(set_module_config = config)) + writtenModuleConfigs += config + } override suspend fun setOwner(user: User) = enqueueOrThrow(AdminMessage(set_owner = user)) override suspend fun setChannel(channel: Channel) = enqueueOrThrow(AdminMessage(set_channel = channel)) override suspend fun setFavorite(node: NodeId, favorite: Boolean) { @@ -288,7 +552,7 @@ internal class AdminApiImpl( val packet = MeshPacket( id = id.raw, from = engine.myNodeNumOrNull() ?: 0, - to = engine.myNodeNumOrNull() ?: 0, + to = localNode().raw, decoded = Data( portnum = PortNum.ADMIN_APP, payload = payload, @@ -318,7 +582,17 @@ private fun mapSendFailureToAdminResult(reason: SendFailure): AdminResult SendFailure.Cancelled, SendFailure.IdCollision -> AdminResult.NodeUnreachable - is SendFailure.Other -> AdminResult.Failed(reason.routingError) + is SendFailure.Other -> when (reason.routingError) { + Routing.Error.ADMIN_BAD_SESSION_KEY -> AdminResult.SessionKeyExpired + + Routing.Error.NOT_AUTHORIZED, + Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, + -> AdminResult.Unauthorized + + Routing.Error.RATE_LIMIT_EXCEEDED -> AdminResult.RateLimited + + else -> AdminResult.Failed(reason.routingError) + } is SendFailure.Unknown -> AdminResult.Timeout } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt new file mode 100644 index 0000000..fd8afb4 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/Base64Url.kt @@ -0,0 +1,63 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +private const val ALPHABET: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + +internal fun base64UrlEncode(bytes: ByteArray): String { + if (bytes.isEmpty()) return "" + val sb = StringBuilder((bytes.size * 4 + 2) / 3) + var i = 0 + while (i + 2 < bytes.size) { + val b0 = bytes[i].toInt() and 0xff + val b1 = bytes[i + 1].toInt() and 0xff + val b2 = bytes[i + 2].toInt() and 0xff + sb.append(ALPHABET[b0 ushr 2]) + sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) + sb.append(ALPHABET[((b1 and 0xf) shl 2) or (b2 ushr 6)]) + sb.append(ALPHABET[b2 and 0x3f]) + i += 3 + } + val rem = bytes.size - i + if (rem == 1) { + val b0 = bytes[i].toInt() and 0xff + sb.append(ALPHABET[b0 ushr 2]) + sb.append(ALPHABET[(b0 and 0x3) shl 4]) + } else if (rem == 2) { + val b0 = bytes[i].toInt() and 0xff + val b1 = bytes[i + 1].toInt() and 0xff + sb.append(ALPHABET[b0 ushr 2]) + sb.append(ALPHABET[((b0 and 0x3) shl 4) or (b1 ushr 4)]) + sb.append(ALPHABET[(b1 and 0xf) shl 2]) + } + return sb.toString() +} + +internal fun base64UrlDecode(input: String): ByteArray? { + val cleaned = input.trimEnd('=') + val out = ArrayList(cleaned.length * 3 / 4 + 2) + var buffer = 0 + var bits = 0 + for (ch in cleaned) { + val v = when (ch) { + in 'A'..'Z' -> ch - 'A' + in 'a'..'z' -> ch - 'a' + 26 + in '0'..'9' -> ch - '0' + 52 + '-' -> 62 + '_' -> 63 + else -> return null + } + buffer = (buffer shl 6) or v + bits += 6 + if (bits >= 8) { + bits -= 8 + out.add(((buffer ushr bits) and 0xff).toByte()) + } + } + return out.toByteArray() +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt index 4f261a7..e725fd2 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/CommandDispatcher.kt @@ -10,19 +10,23 @@ package org.meshtastic.sdk.internal import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Job import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceUIConfig import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing +import org.meshtastic.proto.StoreAndForward import org.meshtastic.proto.Telemetry import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.LogSink import org.meshtastic.sdk.debug import org.meshtastic.sdk.warn +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo /** - * Engine-actor-owned registry of in-flight Phase 2 RPC responses. + * Engine-actor-owned registry of in-flight admin/telemetry/routing RPC responses. * * **Single-writer.** All mutations happen on the engine coroutine — no atomic / mutex needed * (ADR-002). @@ -86,13 +90,40 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { val resolved = when (entry.kind) { ResponseKind.AdminConfig -> decodeAdmin(decoded.payload) { it.get_config_response } + ResponseKind.AdminModuleConfig -> decodeAdmin(decoded.payload) { it.get_module_config_response } + ResponseKind.AdminOwner -> decodeAdmin(decoded.payload) { it.get_owner_response } + ResponseKind.AdminChannel -> decodeAdmin(decoded.payload) { it.get_channel_response } + ResponseKind.AdminDeviceMetadata -> decodeAdmin(decoded.payload) { it.get_device_metadata_response } + + ResponseKind.AdminCannedMessages -> decodeAdmin(decoded.payload) { + it.get_canned_message_module_messages_response + } + + ResponseKind.AdminRingtone -> decodeAdmin(decoded.payload) { it.get_ringtone_response } + + ResponseKind.AdminDeviceConnectionStatus -> decodeAdmin(decoded.payload) { + it.get_device_connection_status_response + } + + ResponseKind.AdminRemoteHardwarePins -> decodeAdmin(decoded.payload) { + it.get_node_remote_hardware_pins_response + } + + ResponseKind.AdminDeviceUIConfig -> decodeAdmin(decoded.payload) { it.get_ui_config_response } + ResponseKind.Telemetry -> decodeTelemetry(decoded.payload, decoded.portnum) + ResponseKind.RouteDiscoveryReply -> decodeRoute(decoded.payload, decoded.portnum) + ResponseKind.NeighborInfoReply -> decodeNeighborInfo(decoded.payload, decoded.portnum) + + ResponseKind.StoreForwardReply -> decodeStoreForwardHistory(decoded.payload, decoded.portnum) + + ResponseKind.StoreForwardStatsReply -> decodeStoreForwardStats(decoded.payload, decoded.portnum) } if (resolved == null) { @@ -179,23 +210,70 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { return AdminResult.Success(reply) } - private fun decodeNeighborInfo(payload: okio.ByteString, portnum: PortNum?): AdminResult? { + private fun decodeNeighborInfo(payload: okio.ByteString, portnum: PortNum?): AdminResult? { if (portnum != PortNum.NEIGHBORINFO_APP) return null val info = try { - NeighborInfo.ADAPTER.decode(payload) + ProtoNeighborInfo.ADAPTER.decode(payload) } catch (_: Exception) { return null } return AdminResult.Success(info) } + private fun decodeStoreForwardHistory( + payload: okio.ByteString, + portnum: PortNum?, + ): AdminResult? { + val message = decodeStoreForward(payload, portnum) ?: return null + return when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_HISTORY -> { + val history = message.history ?: return null + AdminResult.Success(history) + } + + StoreAndForward.RequestResponse.ROUTER_BUSY -> AdminResult.Failed(Routing.Error.NO_RESPONSE) + + StoreAndForward.RequestResponse.ROUTER_ERROR -> AdminResult.Failed(Routing.Error.GOT_NAK) + + else -> null + } + } + + private fun decodeStoreForwardStats( + payload: okio.ByteString, + portnum: PortNum?, + ): AdminResult? { + val message = decodeStoreForward(payload, portnum) ?: return null + return when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_STATS -> { + val stats = message.stats ?: return null + AdminResult.Success(stats) + } + + StoreAndForward.RequestResponse.ROUTER_BUSY -> AdminResult.Failed(Routing.Error.NO_RESPONSE) + + StoreAndForward.RequestResponse.ROUTER_ERROR -> AdminResult.Failed(Routing.Error.GOT_NAK) + + else -> null + } + } + + private fun decodeStoreForward(payload: okio.ByteString, portnum: PortNum?): StoreAndForward? { + if (portnum != PortNum.STORE_FORWARD_APP) return null + return try { + StoreAndForward.ADAPTER.decode(payload) + } catch (_: Exception) { + null + } + } + companion object { private const val TAG = "CommandDispatcher" /** * Translate a Routing.Error enum value into the appropriate AdminResult failure. * - * Mirrors the error-taxonomy.md mapping for Phase 2 RPCs. Notably: + * Mirrors the error-taxonomy.md mapping for admin RPCs. Notably: * - `ADMIN_BAD_SESSION_KEY` → [AdminResult.SessionKeyExpired] so the caller can trigger * a single-shot retry after re-seeding via `get_owner_request`. * - `NOT_AUTHORIZED` / `ADMIN_PUBLIC_KEY_UNAUTHORIZED` → [AdminResult.Unauthorized]. @@ -210,6 +288,8 @@ internal class CommandDispatcher(private val logger: LogSink = LogSink.Silent) { Routing.Error.ADMIN_PUBLIC_KEY_UNAUTHORIZED, -> AdminResult.Unauthorized + Routing.Error.RATE_LIMIT_EXCEEDED -> AdminResult.RateLimited + Routing.Error.NO_ROUTE, Routing.Error.GOT_NAK, Routing.Error.MAX_RETRANSMIT, @@ -232,7 +312,14 @@ internal sealed interface ResponseKind { data object AdminOwner : ResponseKind data object AdminChannel : ResponseKind data object AdminDeviceMetadata : ResponseKind + data object AdminCannedMessages : ResponseKind + data object AdminRingtone : ResponseKind + data object AdminDeviceConnectionStatus : ResponseKind + data object AdminRemoteHardwarePins : ResponseKind + data object AdminDeviceUIConfig : ResponseKind data object Telemetry : ResponseKind data object RouteDiscoveryReply : ResponseKind - data object NeighborInfoReply : ResponseKind + data object NeighborInfoReply : ResponseKind + data object StoreForwardReply : ResponseKind + data object StoreForwardStatsReply : ResponseKind } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt new file mode 100644 index 0000000..4e83dc7 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/ConfigMerge.kt @@ -0,0 +1,90 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.Config +import org.meshtastic.proto.ModuleConfig + +/** + * Identifies which oneOf variant a [Config] message carries. + * + * Returns a string key for matching purposes, or `null` if the Config is empty/unknown. + */ +internal fun Config.sectionKey(): String? = when { + device != null -> "device" + position != null -> "position" + power != null -> "power" + network != null -> "network" + display != null -> "display" + lora != null -> "lora" + bluetooth != null -> "bluetooth" + security != null -> "security" + sessionkey != null -> "sessionkey" + device_ui != null -> "device_ui" + else -> null +} + +/** + * Identifies which oneOf variant a [ModuleConfig] message carries. + * + * Returns a string key for matching purposes, or `null` if the ModuleConfig is empty/unknown. + */ +internal fun ModuleConfig.sectionKey(): String? = when { + mqtt != null -> "mqtt" + serial != null -> "serial" + external_notification != null -> "external_notification" + store_forward != null -> "store_forward" + range_test != null -> "range_test" + telemetry != null -> "telemetry" + canned_message != null -> "canned_message" + audio != null -> "audio" + remote_hardware != null -> "remote_hardware" + neighbor_info != null -> "neighbor_info" + ambient_lighting != null -> "ambient_lighting" + detection_sensor != null -> "detection_sensor" + paxcounter != null -> "paxcounter" + statusmessage != null -> "statusmessage" + traffic_management != null -> "traffic_management" + else -> null +} + +/** + * Merge [written] configs into [existing], replacing sections that share a [sectionKey]. + * + * Sections in [existing] that weren't written are preserved as-is. Written sections not + * present in [existing] are appended. + */ +internal fun mergeConfigs(existing: List, written: List): List { + val writtenByKey = written.associateBy { it.sectionKey() }.filterKeys { it != null } + val result = existing.map { cfg -> + val key = cfg.sectionKey() + if (key != null && key in writtenByKey) writtenByKey[key]!! else cfg + }.toMutableList() + // Append any written sections that didn't replace an existing entry. + val existingKeys = existing.mapNotNull { it.sectionKey() }.toSet() + for ((key, cfg) in writtenByKey) { + if (key !in existingKeys) result.add(cfg) + } + return result +} + +/** + * Merge [written] module configs into [existing], replacing sections that share a [sectionKey]. + */ +internal fun mergeModuleConfigs(existing: List, written: List): List { + val writtenByKey = written.associateBy { it.sectionKey() }.filterKeys { it != null } + val result = existing.map { cfg -> + val key = cfg.sectionKey() + if (key != null && key in writtenByKey) writtenByKey[key]!! else cfg + }.toMutableList() + val existingKeys = existing.mapNotNull { it.sectionKey() }.toSet() + for ((key, cfg) in writtenByKey) { + if (key !in existingKeys) result.add(cfg) + } + return result +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt index efe080f..843b094 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/EngineMessage.kt @@ -48,7 +48,7 @@ internal enum class HandshakeStage { * * Per SPEC engine-actor.md: lifecycle messages ([Connect], [Disconnect]) and outbound [Send] / * admin messages are **never dropped**. [FrameRx] may be dropped under extreme inbox pressure - * once Phase 2 backpressure triage is implemented. + * once backpressure triage is implemented. */ internal sealed interface EngineMessage { @@ -66,7 +66,7 @@ internal sealed interface EngineMessage { * Graceful or error-triggered disconnection request. * * @param cause if non-null, the engine transitions to [ConnectionState.Reconnecting] with - * this cause and attempts exponential-backoff reconnect (Phase 2). For `null`, the engine + * this cause and attempts exponential-backoff reconnect. For `null`, the engine * transitions directly to [ConnectionState.Disconnected] with no reconnect. */ data class Disconnect(val cause: MeshtasticException? = null) : EngineMessage @@ -106,6 +106,9 @@ internal sealed interface EngineMessage { */ data object LivenessTick : EngineMessage + /** Periodic presence check: scans nodes and emits WentOffline/CameOnline events. */ + data object PresenceCheckTick : EngineMessage + /** * Handshake stage timed out. * @@ -125,10 +128,10 @@ internal sealed interface EngineMessage { */ data object HandshakeHeartbeatSettleComplete : EngineMessage - /** An in-flight admin RPC did not receive a response within its deadline. Phase 2+. */ + /** An in-flight admin RPC did not receive a response within its deadline. */ data class AdminTimeout(val requestId: Int) : EngineMessage - // ── Phase 2 RPC dispatch (admin / telemetry / routing) ────────────────── + // ── RPC dispatch (admin / telemetry / routing) ────────────────── /** * Submit a typed RPC request whose response is correlated by [requestId] (the wire packet diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt index 985587a..cdcbe0d 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/MeshEngine.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -35,20 +36,26 @@ import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.NodeInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.Routing +import org.meshtastic.proto.Telemetry import org.meshtastic.proto.ToRadio import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.AutoReconnectConfig +import org.meshtastic.sdk.ChannelIndex import org.meshtastic.sdk.ConfigBundle import org.meshtastic.sdk.ConfigPhase +import org.meshtastic.sdk.CongestionLevel +import org.meshtastic.sdk.CongestionMetrics import org.meshtastic.sdk.ConnectionState import org.meshtastic.sdk.DeviceStorage import org.meshtastic.sdk.DroppedFlow +import org.meshtastic.sdk.ExternalChangeKind import org.meshtastic.sdk.Frame import org.meshtastic.sdk.LogSink import org.meshtastic.sdk.MeshEvent import org.meshtastic.sdk.MeshtasticException import org.meshtastic.sdk.MessageId import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeField import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RadioTransport import org.meshtastic.sdk.SendFailure @@ -57,6 +64,7 @@ import org.meshtastic.sdk.SessionPasskey import org.meshtastic.sdk.StorageProvider import org.meshtastic.sdk.TransportState import org.meshtastic.sdk.WireCodec +import org.meshtastic.sdk.WireFraming import org.meshtastic.sdk.debug import org.meshtastic.sdk.error import org.meshtastic.sdk.info @@ -69,6 +77,7 @@ import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds /** @@ -83,7 +92,9 @@ internal class MeshEngine( private val logger: LogSink, private val bleHeartbeatEnabled: Boolean, private val parentContext: CoroutineContext, + private val clock: Clock = Clock.System, private val sendTimeout: Duration = 30.seconds, + private val presenceTimeout: Duration = 2.hours, private val autoReconnectConfig: AutoReconnectConfig = AutoReconnectConfig.Disabled, ) { @@ -96,6 +107,15 @@ internal class MeshEngine( */ val configBundleState = MutableStateFlow(null) + /** + * Channel list for the active session, seeded from the handshake or from storage on + * reconnect. `null` until [ConnectionState.Connected] is reached. + * + * Unlike [configBundleState], this survives auto-reconnect resets so the UI does not + * flash back to null between reconnect cycles. + */ + val channelsState = MutableStateFlow?>(null) + val nodes = MutableSharedFlow( replay = 1, extraBufferCapacity = 256, @@ -143,7 +163,7 @@ internal class MeshEngine( // (PhoneAPI.cpp:202-209) interprets nonce == 1 as "broadcast our nodeinfo over LoRa"; // nonces > 0 are reserved for explicit "ping our nodeinfo" semantics that we don't // expose today. Sending 0 is the safe keep-alive value and matches Android's reference - // client. Audit / . + // client. private val keepaliveHeartbeat = Heartbeat(nonce = 0) private var myNodeNum = 0 @@ -164,6 +184,7 @@ internal class MeshEngine( private val pendingChannels = mutableListOf() private var pendingMyInfo: org.meshtastic.proto.MyNodeInfo? = null private var pendingMetadata: org.meshtastic.proto.DeviceMetadata? = null + private var pendingDeviceUIConfig: org.meshtastic.proto.DeviceUIConfig? = null // Sends that arrived while still handshaking — dispatched in flushQueuedSends(). private val preSendQueue = mutableListOf() @@ -177,6 +198,8 @@ internal class MeshEngine( */ private val lastHeartbeatAt = mutableMapOf() private val dirtyHeartbeats = mutableSetOf() + private val offlineNodes = mutableSetOf() + private val lastCongestionLevel = mutableMapOf() /** * once a storage write fails we flip this flag, emit a single @@ -224,6 +247,10 @@ internal class MeshEngine( /** rate-limit clock for the encrypted-packet ACK-skip warning. */ private var lastEncryptedSkipWarningAtMs: Long = 0L + /** recent inbound packet ids to suppress duplicate packet delivery. */ + private val recentInboundPacketKeys = ArrayDeque(INBOUND_PACKET_DEDUP_CAP) + private val recentInboundPacketKeySet = mutableSetOf() + /** * Connect-phase deferred. Stored so [cleanup] can fail it if the engine is cancelled before * the handshake completes. @@ -469,6 +496,21 @@ internal class MeshEngine( inbox.close() outbound.close() + // Final flush of any dirty heartbeats so presence data survives a clean disconnect. + try { + if (!storageDegraded && dirtyHeartbeats.isNotEmpty()) { + val s = storage + if (s != null) { + for (nodeId in dirtyHeartbeats) { + lastHeartbeatAt[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } + } + dirtyHeartbeats.clear() + } + } + } catch (_: Exception) { + // best-effort — don't fail cleanup + } + try { storage?.close() } catch (_: Exception) { @@ -512,12 +554,15 @@ internal class MeshEngine( pendingChannels.clear() pendingMyInfo = null pendingMetadata = null + pendingDeviceUIConfig = null preSendQueue.clear() // reset the degraded flag so the next connect attempt gets a fresh storage shot. storageDegraded = false // clear in-memory heartbeat bookkeeping (hydrated fresh on next connect). lastHeartbeatAt.clear() dirtyHeartbeats.clear() + offlineNodes.clear() + lastCongestionLevel.clear() // clear any leftover settle-buffer entries so a reconnect starts clean. settleBuffer.clear() // cancel any in-flight per-send ACK timers so they don't post stale messages. @@ -528,12 +573,15 @@ internal class MeshEngine( stage2WatchdogJob = null stage2ProgressCounter = 0L lastEncryptedSkipWarningAtMs = 0L + recentInboundPacketKeys.clear() + recentInboundPacketKeySet.clear() reconnectJob?.cancel() reconnectJob = null reconnectAttempt = 0 autoReconnectInProgress = false configBundleState.value = null + channelsState.value = null dispatcher.cancelAll(AdminResult.NodeUnreachable) @@ -551,6 +599,7 @@ internal class MeshEngine( is EngineMessage.CancelHandle -> handleCancelHandle(msg) EngineMessage.HeartbeatTick -> handleHeartbeatTick() EngineMessage.LivenessTick -> handleLivenessTick() + EngineMessage.PresenceCheckTick -> handlePresenceCheckTick() is EngineMessage.HandshakeTimeout -> handleHandshakeTimeout(msg) EngineMessage.HandshakeStage1SettleComplete -> handleStage1SettleComplete() EngineMessage.HandshakeHeartbeatSettleComplete -> handleHeartbeatSettleComplete() @@ -598,7 +647,7 @@ internal class MeshEngine( val decoded = packet.decoded ?: return packet if (decoded.portnum != PortNum.ADMIN_APP) return packet if (packet.to == 0 || packet.to == myNodeNum) return packet - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() val passkey = sessionPasskeyMem if (passkey == null || passkey.isEmpty() || now >= sessionPasskeyExpiresAtMs) { events.tryEmit( @@ -674,12 +723,14 @@ internal class MeshEngine( try { val loaded = storage?.loadHeartbeats().orEmpty() lastHeartbeatAt.clear() + offlineNodes.clear() lastHeartbeatAt.putAll(loaded) } catch (e: CancellationException) { throw e } catch (e: Exception) { logger.warn(TAG, e) { "Failed to load persisted heartbeats" } lastHeartbeatAt.clear() + offlineNodes.clear() } try { @@ -708,6 +759,15 @@ internal class MeshEngine( */ private fun startStage1Handshake() { connectionState.value = ConnectionState.Configuring(ConfigPhase.Stage1, 0f) + + // Stream-based transports (TCP, Serial) require 4 wake bytes (0x94) before the first + // want_config_id to resync the firmware's frame decoder after an unclean disconnect. + // BLE doesn't need this since each GATT write is self-framing. + if (!transport.identity.raw.startsWith("ble:")) { + val wakeBytes = byteArrayOf(0x94.toByte(), 0x94.toByte(), 0x94.toByte(), 0x94.toByte()) + outbound.trySend(Frame(ByteString(wakeBytes))) + } + sendToRadio(ToRadio(want_config_id = NONCE_STAGE1)) handshakeStage = HandshakeStage.Stage1Draining @@ -756,9 +816,59 @@ internal class MeshEngine( private fun handleFrameRx(msg: EngineMessage.FrameRx) { val rawBytes = msg.frame.bytes.toByteArray() - if (rawBytes.size < 4) return // malformed frame + when { + rawBytes.size < WireFraming.HEADER_SIZE -> { + reportMalformedFrame( + message = "Frame shorter than wire header", + details = mapOf("bytes" to rawBytes.size), + ) + return + } - val protoBytes = rawBytes.copyOfRange(4, rawBytes.size) + rawBytes[0] != WireFraming.MAGIC_0 || rawBytes[1] != WireFraming.MAGIC_1 -> { + reportMalformedFrame( + message = "Frame has invalid wire header", + details = mapOf( + "magic0" to (rawBytes[0].toInt() and 0xFF), + "magic1" to (rawBytes[1].toInt() and 0xFF), + ), + ) + return + } + } + + val declaredPayloadSize = ((rawBytes[2].toInt() and 0xFF) shl 8) or (rawBytes[3].toInt() and 0xFF) + if (declaredPayloadSize == 0) { + reportMalformedFrame( + message = "Frame declares empty payload", + details = emptyMap(), + ) + return + } + if (declaredPayloadSize > WireFraming.MAX_PAYLOAD_SIZE) { + reportMalformedFrame( + message = "Frame exceeds max payload size", + details = mapOf( + "declared_payload_bytes" to declaredPayloadSize, + "max_payload_bytes" to WireFraming.MAX_PAYLOAD_SIZE, + ), + ) + return + } + + val actualPayloadSize = rawBytes.size - WireFraming.HEADER_SIZE + if (declaredPayloadSize != actualPayloadSize) { + reportMalformedFrame( + message = "Frame payload length mismatch", + details = mapOf( + "declared_payload_bytes" to declaredPayloadSize, + "actual_payload_bytes" to actualPayloadSize, + ), + ) + return + } + + val protoBytes = rawBytes.copyOfRange(WireFraming.HEADER_SIZE, rawBytes.size) val fromRadio = try { WireCodec.decodeFromRadio(protoBytes) } catch (e: Exception) { @@ -849,6 +959,7 @@ internal class MeshEngine( val config = fromRadio.config val modConfig = fromRadio.moduleConfig val nodeInfo = fromRadio.node_info + val deviceUIConf = fromRadio.deviceuiConfig val completeId = fromRadio.config_complete_id when { @@ -862,6 +973,8 @@ internal class MeshEngine( modConfig != null -> pendingModuleConfigs.add(modConfig) + deviceUIConf != null -> pendingDeviceUIConfig = deviceUIConf + nodeInfo != null -> { val nodeId = NodeId(nodeInfo.num) pendingNodes[nodeId] = nodeInfo @@ -1021,6 +1134,7 @@ internal class MeshEngine( metadata = pendingMetadata ?: org.meshtastic.proto.DeviceMetadata(), configs = pendingConfigs.toList(), moduleConfigs = pendingModuleConfigs.toList(), + deviceUIConfig = pendingDeviceUIConfig, ) meshState = meshState.withConfig(bundle) } @@ -1047,6 +1161,13 @@ internal class MeshEngine( lastHeartbeatAt[nodeId]?.let { ts -> s.saveHeartbeat(nodeId, ts) } } if (pendingChannels.isNotEmpty()) s.saveChannels(pendingChannels) + // Populate channelsState from handshake payload; fall back to storage + // for reconnect sessions where the device skips re-sending channels. + channelsState.value = if (pendingChannels.isNotEmpty()) { + pendingChannels.toList() + } else { + s.loadChannels().ifEmpty { null } + } meshState.configBundle?.let { s.saveConfig(it) configBundleState.value = it @@ -1063,7 +1184,7 @@ internal class MeshEngine( // mark every pending node as heard now (before the async block above flushes). run { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() for ((nodeId, _) in pendingNodes) { lastHeartbeatAt[nodeId] = now } @@ -1102,36 +1223,33 @@ internal class MeshEngine( } if (adminMsg.get_owner_response != null) { - // Latch session passkey in memory so admin RPCs can use it immediately. - if (adminMsg.session_passkey.size > 0) { - val bytes = adminMsg.session_passkey.toByteArray() - sessionPasskeyMem = bytes - val expiresAtMs = Clock.System.now().toEpochMilliseconds() + SESSION_PASSKEY_TTL.inWholeMilliseconds - // also remember the expiry in memory so [sendAdminPacket] can decide - // whether to attach the passkey to outbound remote-admin messages. - sessionPasskeyExpiresAtMs = expiresAtMs - logger.debug(TAG) { "Session passkey latched (${bytes.size} bytes)" } - // persist asynchronously so a reconnect can resume admin RPCs without a - // fresh get_owner_request. Failures are non-fatal — we still have the in-memory - // copy for this session. - engineScope?.launch { - if (storageDegraded) return@launch - try { - storage?.saveSessionPasskey(SessionPasskey(ByteString(bytes), expiresAtMs)) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - reportStorageDegraded( - "saveSessionPasskey failed: ${e.message ?: e::class.simpleName}", - e, - ) - } - } - } + latchSessionPasskey(adminMsg) transitionToReady() } } + private fun latchSessionPasskey(adminMsg: AdminMessage) { + if (adminMsg.session_passkey.size <= 0) return + val bytes = adminMsg.session_passkey.toByteArray() + sessionPasskeyMem = bytes + val expiresAtMs = clock.now().toEpochMilliseconds() + SESSION_PASSKEY_TTL.inWholeMilliseconds + sessionPasskeyExpiresAtMs = expiresAtMs + logger.debug(TAG) { "Session passkey latched (${bytes.size} bytes)" } + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveSessionPasskey(SessionPasskey(ByteString(bytes), expiresAtMs)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + reportStorageDegraded( + "saveSessionPasskey failed: ${e.message ?: e::class.simpleName}", + e, + ) + } + } + } + private fun transitionToReady() { handshakeStage = HandshakeStage.Ready @@ -1175,6 +1293,16 @@ internal class MeshEngine( } } + // Presence check: scan nodes for offline transitions every 30s (piggybacks on heartbeat interval). + if (presenceTimeout.isPositive() && presenceTimeout.isFinite()) { + engineScope?.launch { + while (true) { + delay(HEARTBEAT_INTERVAL_MS) + inbox.trySend(EngineMessage.PresenceCheckTick) + } + } + } + // Drain the snapshot: dispatch every send that wasn't cancelled while waiting. for (msg in snapshot) { if (msg.stateFlow.value == SendState.Queued) { @@ -1202,12 +1330,47 @@ internal class MeshEngine( } // any frame from a node counts as presence activity. if (packet.from != 0) markNodeHeard(NodeId(packet.from)) + if (shouldDropDuplicateInboundPacket(packet)) return + val decoded = packet.decoded + if (decoded == null) { + maybeWarnEncryptedPacketSkipped() + return + } + if (decoded.portnum == PortNum.UNKNOWN_APP) { + logger.warn(TAG) { + "Dropping packet id=${packet.id} with unknown port from=0x${packet.from.toString(16)}" + } + events.tryEmit( + MeshEvent.ProtocolWarning( + "packet dropped for unknown port", + details = mapOf( + "id" to packet.id, + "from" to packet.from, + "port" to decoded.portnum.name, + ), + ), + ) + return + } emitPacketOrLog(packet) + if (decoded.portnum == PortNum.ADMIN_APP) { + runCatching { AdminMessage.ADAPTER.decode(decoded.payload) } + .getOrNull() + ?.takeIf { it.get_owner_response != null } + ?.let(::latchSessionPasskey) + } // RPC dispatch: complete any pending getter / traceRoute / neighborInfo // request matching this packet's request_id. Must run before processRoutingAck // so a route_reply doesn't get mistakenly classified as a generic Acked send. dispatcher.tryComplete(packet) processRoutingAck(packet) + // Telemetry → node update: merge device_metrics into the node DB so that + // NodeChange subscribers see battery/telemetry changes without calling TelemetryApi. + maybeMergeDeviceMetrics(packet) + maybeEmitCongestionWarning(packet) + // External config change detection: unsolicited admin messages from firmware + // indicating another client modified channels/config on this device. + maybeProcessExternalAdminChange(packet) } nodeInfo != null -> { @@ -1219,7 +1382,10 @@ internal class MeshEngine( if (existing == null) { emitNodeChangeOrLog(NodeChange.Added(nodeInfo)) } else { - emitNodeChangeOrLog(NodeChange.Updated(nodeInfo, emptySet())) + val changed = diffNodeFields(existing, nodeInfo) + if (changed.isNotEmpty()) { + emitNodeChangeOrLog(NodeChange.Updated(nodeInfo, changed)) + } } } @@ -1293,7 +1459,8 @@ internal class MeshEngine( } fromRadio.deviceuiConfig != null -> { - warnUnhandledVariant("deviceui_config", stage) + // Capture deviceuiConfig if it arrives post-handshake (e.g., after storeUIConfig). + pendingDeviceUIConfig = fromRadio.deviceuiConfig true } @@ -1421,15 +1588,136 @@ internal class MeshEngine( } } + /** + * When a TELEMETRY_APP packet arrives with device_metrics, merge them into the corresponding + * node in the node DB and emit [NodeChange.Updated] so node-list subscribers see telemetry + * changes (battery, channel utilization, etc.) without separate TelemetryApi subscription. + */ + private fun maybeMergeDeviceMetrics(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.TELEMETRY_APP) return + if (packet.from == 0) return + + val telemetry = try { + Telemetry.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + + val deviceMetrics = telemetry.device_metrics ?: return + val nodeId = NodeId(packet.from) + val existing = meshState.nodes[nodeId] ?: return + + val updated = existing.copy(device_metrics = deviceMetrics) + meshState = meshState.withNodes(meshState.nodes + (nodeId to updated)) + if (nodeId == NodeId(myNodeNum)) ownNode.value = updated + + val changed = diffNodeFields(existing, updated) + if (changed.isNotEmpty()) { + emitNodeChangeOrLog(NodeChange.Updated(updated, changed)) + } + } + + private fun maybeEmitCongestionWarning(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.TELEMETRY_APP) return + if (packet.from == 0) return + + val telemetry = try { + Telemetry.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + + val deviceMetrics = telemetry.device_metrics ?: return + val airUtilTx: Float = deviceMetrics.air_util_tx ?: 0f + val channelUtil: Float = deviceMetrics.channel_utilization ?: 0f + + if (airUtilTx == 0f && channelUtil == 0f) return + + val metrics = CongestionMetrics(airUtilTx = airUtilTx, channelUtil = channelUtil) + val nodeId = NodeId(packet.from) + val prevLevel = lastCongestionLevel[nodeId] + + if (prevLevel != metrics.level) { + lastCongestionLevel[nodeId] = metrics.level + events.tryEmit(MeshEvent.CongestionWarning(metrics)) + } + } + + /** + * Detect unsolicited admin messages from the firmware that indicate an external client + * changed channels, config, or module config on the connected device. Updates local + * state (channelsState / configBundleState) and emits [MeshEvent.ExternalConfigChange]. + * + * Only processes packets addressed to us (from our own node) with request_id == 0 + * (unsolicited push from firmware, not a response to our own RPC). + */ + private fun maybeProcessExternalAdminChange(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.ADMIN_APP) return + // Only consider packets from our own node (firmware pushing changes locally) + if (packet.from != myNodeNum && packet.from != 0) return + // Solicited responses have a non-zero request_id matching a pending RPC + if (decoded.request_id != 0) return + + val adminMsg = try { + AdminMessage.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + return + } + + adminMsg.get_channel_response?.let { channel -> + logger.info(TAG) { "External channel change detected (index=${channel.index})" } + updateChannelAndPersist(channel) + events.tryEmit(MeshEvent.ExternalConfigChange(ExternalChangeKind.CHANNEL)) + return + } + + adminMsg.get_config_response?.let { config -> + logger.info(TAG) { "External config change detected" } + val current = configBundleState.value ?: return + val merged = mergeConfigs(current.configs, listOf(config)) + val updated = current.copy(configs = merged) + configBundleState.value = updated + // Persist the merged config bundle + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveConfig(updated) + } catch (_: Exception) { /* non-fatal */ } + } + events.tryEmit(MeshEvent.ExternalConfigChange(ExternalChangeKind.CONFIG)) + return + } + + adminMsg.get_module_config_response?.let { moduleConfig -> + logger.info(TAG) { "External module config change detected" } + val current = configBundleState.value ?: return + val merged = mergeModuleConfigs(current.moduleConfigs, listOf(moduleConfig)) + val updated = current.copy(moduleConfigs = merged) + configBundleState.value = updated + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveConfig(updated) + } catch (_: Exception) { /* non-fatal */ } + } + events.tryEmit(MeshEvent.ExternalConfigChange(ExternalChangeKind.MODULE_CONFIG)) + return + } + } + /** * rate-limited (≤ once per minute) ProtocolWarning for encrypted MeshPackets that * skip ACK correlation. Avoids log spam on encrypted-only meshes while still surfacing * the situation to consumers. */ private fun maybeWarnEncryptedPacketSkipped() { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() if (now - lastEncryptedSkipWarningAtMs < ENCRYPTED_SKIP_WARNING_INTERVAL_MS) return lastEncryptedSkipWarningAtMs = now + logger.warn(TAG) { "Dropping encrypted packet without decoded payload" } events.tryEmit( MeshEvent.ProtocolWarning( "encrypted packet skipped for ACK correlation", @@ -1438,6 +1726,32 @@ internal class MeshEngine( ) } + private fun reportMalformedFrame(message: String, details: Map) { + logger.warn(TAG) { message } + events.tryEmit(MeshEvent.ProtocolWarning(message, details = details)) + } + + private fun shouldDropDuplicateInboundPacket(packet: MeshPacket): Boolean { + if (packet.id == 0 || packet.from == 0) return false + val key = InboundPacketKey( + from = packet.from, + to = packet.to, + channel = packet.channel, + id = packet.id, + ) + if (!recentInboundPacketKeySet.add(key)) { + logger.debug(TAG) { + "Dropping duplicate inbound packet id=${packet.id} from=0x${packet.from.toString(16)}" + } + return true + } + recentInboundPacketKeys.addLast(key) + if (recentInboundPacketKeys.size > INBOUND_PACKET_DEDUP_CAP) { + recentInboundPacketKeySet.remove(recentInboundPacketKeys.removeFirst()) + } + return false + } + /** * drain [preSendQueue] and complete every pending state flow with [failure]. * @@ -1521,6 +1835,8 @@ internal class MeshEngine( stage2WatchdogJob?.cancel() stage2WatchdogJob = null stage2ProgressCounter = 0L + recentInboundPacketKeys.clear() + recentInboundPacketKeySet.clear() livenessWatchdogJob?.cancel() livenessWatchdogJob = null @@ -1640,14 +1956,23 @@ internal class MeshEngine( msg.stateFlow.value = SendState.Sent logger.debug(TAG) { "Send dispatched id=${msg.id}" } - // arm an ACK timeout for unicast want_ack sends so a silent device can't leave - // the handle in `Sent` forever. Broadcasts (`to == BROADCAST_ADDR`) never receive a - // routing ACK so we deliberately skip arming for them. - // also arm for want_response-only requests (e.g. AdminMessage.get_owner_request) - // these expect a unicast reply with `request_id` set, so the same Routing-ACK timer - // semantics apply even when `want_ack` is false. val wantsResponse = packet.decoded?.want_response == true - if ((packet.want_ack || wantsResponse) && packet.to != BROADCAST_ADDR) { + + // Fire-and-forget broadcasts (want_ack=false, no want_response): auto-resolve to + // Acked immediately. No firmware ACK will arrive so without this the + // MessageHandle.await() would suspend forever. + if (packet.to == BROADCAST_ADDR && !packet.want_ack && !wantsResponse) { + msg.stateFlow.value = SendState.Acked + pendingSends.remove(MessageId(wireId)) + logger.debug(TAG) { "Broadcast (fire-and-forget) auto-acked id=${msg.id}" } + return + } + + // Arm an ACK timeout for any send expecting firmware feedback: + // • unicast want_ack — waits for recipient's Routing ACK + // • broadcast want_ack — waits for firmware's implicit ACK (relay overheard) + // • want_response requests (e.g. AdminMessage.get_owner_request) + if (packet.want_ack || wantsResponse) { val key = MessageId(wireId) val scope = engineScope ?: return ackTimeoutJobs.remove(key)?.cancel() @@ -1671,7 +1996,7 @@ internal class MeshEngine( private fun handleHeartbeatTick() { if (handshakeStage != HandshakeStage.Ready) return - if (!bleHeartbeatEnabled && transport::class.simpleName == "BleTransport") return + if (!bleHeartbeatEnabled && transport.identity.raw.startsWith("ble:")) return // keep-alive heartbeats use nonce=0 (see [keepaliveHeartbeat] for rationale). sendToRadio(ToRadio(heartbeat = keepaliveHeartbeat)) logger.verbose(TAG) { "Heartbeat sent nonce=0" } @@ -1680,6 +2005,22 @@ internal class MeshEngine( flushDirtyHeartbeats() } + private fun handlePresenceCheckTick() { + if (handshakeStage != HandshakeStage.Ready) return + val now = clock.now().toEpochMilliseconds() + val timeoutMs = presenceTimeout.inWholeMilliseconds + + for ((nodeId, lastMs) in lastHeartbeatAt) { + if (nodeId == NodeId(myNodeNum)) continue + val elapsed = now - lastMs + if (elapsed > timeoutMs && nodeId !in offlineNodes) { + offlineNodes.add(nodeId) + val lastHeardSec = (lastMs / 1000).toInt() + emitNodeChangeOrLog(NodeChange.WentOffline(nodeId, lastHeardSec)) + } + } + } + /** * one-shot Stage 1 retry. If we're still waiting for the device's first * `my_info`/config envelope at the half-budget mark, re-send `want_config_id = NONCE_STAGE1` @@ -1755,7 +2096,7 @@ internal class MeshEngine( supervisorJobRef.value?.cancel() } - private fun sendToRadio(msg: ToRadio) { + internal fun sendToRadio(msg: ToRadio) { try { val encoded = WireCodec.encodeToRadio(msg) outbound.trySend(Frame(ByteString(encoded))) @@ -1764,6 +2105,10 @@ internal class MeshEngine( } } + internal fun sendAdmin(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { + sendAdminPacket(adminMsg, wantResponse, to) + } + private fun sendAdminPacket(adminMsg: AdminMessage, wantResponse: Boolean = false, to: Int = myNodeNum) { if (myNodeNum == 0) return // attach the cached session passkey when targeting a *remote* node. Local admin @@ -1772,7 +2117,7 @@ internal class MeshEngine( // a ProtocolWarning and proceed — the firmware will reject the packet, surfacing as a // SendFailure / AckTimeout to the caller. val outbound: AdminMessage = if (to != myNodeNum) { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() val passkey = sessionPasskeyMem if (passkey != null && passkey.isNotEmpty() && now < sessionPasskeyExpiresAtMs) { adminMsg.copy(session_passkey = passkey.toByteString()) @@ -1823,6 +2168,98 @@ internal class MeshEngine( } } + /** + * Atomically patches [channelsState] with the updated [channel] and enqueues a + * best-effort storage flush. Called from [AdminApiImpl] after a successful `setChannel` ACK. + * + * Thread-safe: uses `StateFlow.update` for the in-memory atomic update. + * Storage write is fire-and-forget on [engineScope] — failure is logged but does NOT + * degrade the session (the next handshake re-syncs channels from the device anyway). + */ + internal fun updateChannelAndPersist(channel: org.meshtastic.proto.Channel) { + val idx = channel.index + if (idx < 0 || idx > ChannelIndex.MAX_CHANNEL_INDEX) { + logger.warn(TAG) { "setChannel: ignoring out-of-range index $idx" } + return + } + channelsState.update { current: List? -> + val list = (current ?: emptyList()).toMutableList() + when { + idx < list.size -> list[idx] = channel + + idx == list.size -> list.add(channel) + + // idx > list.size: sparse gap — pad with DISABLED channels to avoid holes + else -> { + while (list.size < idx) { + list.add( + org.meshtastic.proto.Channel( + index = list.size, + role = org.meshtastic.proto.Channel.Role.DISABLED, + ), + ) + } + list.add(channel) + } + } + list.toList() + } + val updated = channelsState.value + if (updated != null) { + engineScope?.launch { + try { + storage?.saveChannels(updated) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn(TAG, e) { "updateChannelAndPersist: saveChannels failed (non-fatal)" } + } + } + } + } + + /** + * Optimistically update [configBundleState] after a successful `editSettings` commit. + * + * Merges written [Config] and [ModuleConfig] objects into the existing bundle, replacing + * sections that were written while preserving sections that weren't touched. + */ + internal fun applyConfigEdits( + writtenConfigs: List, + writtenModuleConfigs: List, + ) { + if (writtenConfigs.isEmpty() && writtenModuleConfigs.isEmpty()) return + + val current = configBundleState.value ?: return + + val mergedConfigs = if (writtenConfigs.isEmpty()) { + current.configs + } else { + mergeConfigs(current.configs, writtenConfigs) + } + val mergedModuleConfigs = if (writtenModuleConfigs.isEmpty()) { + current.moduleConfigs + } else { + mergeModuleConfigs(current.moduleConfigs, writtenModuleConfigs) + } + + val updated = current.copy(configs = mergedConfigs, moduleConfigs = mergedModuleConfigs) + configBundleState.value = updated + meshState = meshState.withConfig(updated) + + // Persist the updated bundle to storage. + engineScope?.launch { + if (storageDegraded) return@launch + try { + storage?.saveConfig(updated) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.warn(TAG, e) { "applyConfigEdits: saveConfig failed (non-fatal)" } + } + } + } + // ---- storage error wrapping ---- // All write paths funnel through `reportStorageDegraded` on failure so the user-visible // events flow carries a single StorageDegraded signal per session. `runStorageWrite` @@ -1839,9 +2276,12 @@ internal class MeshEngine( // timestamp + dirty-mark a per-node heartbeat observation. Flushed to storage on the // next heartbeat tick (handleHeartbeatTick) or at the Ready transition. private fun markNodeHeard(nodeId: NodeId) { - val now = Clock.System.now().toEpochMilliseconds() + val now = clock.now().toEpochMilliseconds() lastHeartbeatAt[nodeId] = now dirtyHeartbeats.add(nodeId) + if (offlineNodes.remove(nodeId)) { + emitNodeChangeOrLog(NodeChange.CameOnline(nodeId)) + } } private fun flushDirtyHeartbeats() { @@ -1939,10 +2379,17 @@ internal class MeshEngine( // after wire round-trip). Used so we don't arm an ACK timer for broadcasts. const val BROADCAST_ADDR: Int = -1 - // persisted session passkeys expire 24 hours after seeding. - val SESSION_PASSKEY_TTL: Duration = 24.hours + // Firmware regenerates session passkeys every ~150s and they remain valid for ~300s. + // A 4-minute TTL ensures we don't use a stale passkey on reconnect while still + // avoiding unnecessary re-seeding within a single connected session. + val SESSION_PASSKEY_TTL: Duration = 4.minutes // minimum interval between consecutive encrypted-packet skip warnings. const val ENCRYPTED_SKIP_WARNING_INTERVAL_MS: Long = 60_000L + + // bounded recent-history window for inbound packet deduplication. + const val INBOUND_PACKET_DEDUP_CAP: Int = 256 } + + private data class InboundPacketKey(val from: Int, val to: Int, val channel: Int, val id: Int) } diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt new file mode 100644 index 0000000..86dabc0 --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/NodeDiff.kt @@ -0,0 +1,72 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.NodeInfo +import org.meshtastic.sdk.NodeField + +/** + * Compares [previous] and [current] [NodeInfo] instances and returns the set of [NodeField]s + * that differ. Returns an empty set only if both instances are semantically identical. + */ +internal fun diffNodeFields(previous: NodeInfo, current: NodeInfo): Set { + val changed = mutableSetOf() + + if (previous.user != current.user) { + changed += NodeField.User + // Name is a subset of User — flag it if the display identifiers changed. + if (previous.user?.long_name != current.user?.long_name || + previous.user?.short_name != current.user?.short_name + ) { + changed += NodeField.Name + } + } + + if (previous.position != current.position) { + changed += NodeField.Position + } + + if (previous.snr != current.snr || previous.hops_away != current.hops_away || + previous.via_mqtt != current.via_mqtt + ) { + changed += NodeField.SignalQuality + } + + if (previous.device_metrics != current.device_metrics) { + // Split battery from general telemetry for finer-grained UI updates. + if (previous.device_metrics?.battery_level != current.device_metrics?.battery_level || + previous.device_metrics?.voltage != current.device_metrics?.voltage + ) { + changed += NodeField.Battery + } + changed += NodeField.Telemetry + } + + if (previous.last_heard != current.last_heard) { + changed += NodeField.LastSeen + } + + if (previous.channel != current.channel) { + changed += NodeField.Other + } + + if (previous.is_favorite != current.is_favorite || + previous.is_ignored != current.is_ignored || + previous.is_muted != current.is_muted || + previous.is_key_manually_verified != current.is_key_manually_verified + ) { + changed += NodeField.Other + } + + // Defensive fallback: if the objects aren't equal but nothing was categorized, flag Other. + if (changed.isEmpty() && previous != current) { + changed += NodeField.Other + } + + return changed +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt index 370cd16..128ba31 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/RoutingApiImpl.kt @@ -10,7 +10,6 @@ package org.meshtastic.sdk.internal import okio.ByteString.Companion.toByteString import org.meshtastic.proto.Data import org.meshtastic.proto.MeshPacket -import org.meshtastic.proto.NeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -18,6 +17,7 @@ import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RoutingApi import kotlin.time.Duration +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo /** * Engine-backed [RoutingApi]. @@ -26,7 +26,7 @@ import kotlin.time.Duration * `ROUTING_APP` with `want_response = true`. The mesh propagates the discovery hop-by-hop; * the destination replies with `route_reply` populated. The dispatcher matches by * `request_id`. - * - [requestNeighborInfo] sends an empty [NeighborInfo] on `NEIGHBORINFO_APP` with + * - [requestNeighborInfo] sends an empty [ProtoNeighborInfo] on `NEIGHBORINFO_APP` with * `want_response = true`. The neighborinfo module on the device responds with its current * neighbor table. */ @@ -50,14 +50,14 @@ internal class RoutingApiImpl(private val engine: MeshEngine, private val rpcTim return engine.submitRpc(packet, requestId, ResponseKind.RouteDiscoveryReply, rpcTimeout) } - override suspend fun requestNeighborInfo(node: NodeId): AdminResult { + override suspend fun requestNeighborInfo(node: NodeId): AdminResult { val target = if (node == NodeId.LOCAL) { NodeId(engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable) } else { node } val requestId = engine.nextMessageId().raw - val payload = NeighborInfo.ADAPTER.encode(NeighborInfo()).toByteString() + val payload = ProtoNeighborInfo.ADAPTER.encode(ProtoNeighborInfo()).toByteString() val packet = MeshPacket( id = requestId, from = engine.myNodeNumOrNull() ?: 0, diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt new file mode 100644 index 0000000..c3fd1eb --- /dev/null +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImpl.kt @@ -0,0 +1,450 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.ConnectionState +import org.meshtastic.sdk.NodeChange +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.SfppHash +import org.meshtastic.sdk.StoreForwardApi +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.StoreForwardStats +import kotlin.coroutines.CoroutineContext +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant + +internal class StoreForwardApiImpl( + private val engine: MeshEngine, + private val packetsFlow: Flow, + private val rpcTimeout: Duration, + coroutineContext: CoroutineContext, + private val nowProvider: () -> Instant = { Clock.System.now() }, +) : StoreForwardApi { + + private val scope = CoroutineScope(coroutineContext + CoroutineName("meshtastic-store-forward")) + private val knownServers = linkedSetOf() + private val activeReplays = mutableMapOf() + private val pendingSfppFragments = mutableMapOf() + + private val _servers = MutableStateFlow>(emptyList()) + override val servers = _servers.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 16) + override val events: Flow = _events.asSharedFlow() + + init { + scope.launch { + merge( + packetsFlow.map { InternalSignal.Packet(it) }, + engine.nodes.map { InternalSignal.Node(it) }, + engine.connectionState.map { InternalSignal.Connection(it) }, + ).collect { signal -> + when (signal) { + is InternalSignal.Packet -> handlePacket(signal.packet) + is InternalSignal.Node -> handleNodeChange(signal.change) + is InternalSignal.Connection -> handleConnection(signal.state) + } + } + } + } + + override suspend fun requestHistory(since: Int?, server: NodeId?): AdminResult { + val myNode = engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable + val targetServer = resolveServer(server) ?: return AdminResult.NodeUnreachable + val requestId = engine.nextMessageId().raw + val payload = StoreAndForward.ADAPTER.encode( + StoreAndForward( + rr = StoreAndForward.RequestResponse.CLIENT_HISTORY, + history = StoreAndForward.History(window = historyWindowMinutes(since)), + ), + ).toByteString() + val packet = MeshPacket( + id = requestId, + from = myNode, + to = targetServer.raw, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload, + want_response = true, + ), + ) + return when (val result = engine.submitRpc(packet, requestId, ResponseKind.StoreForwardReply, rpcTimeout)) { + is AdminResult.Success -> AdminResult.Success(result.value.history_messages) + AdminResult.Timeout -> AdminResult.Timeout + AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable + AdminResult.SessionKeyExpired -> AdminResult.SessionKeyExpired + AdminResult.Unauthorized -> AdminResult.Unauthorized + AdminResult.RateLimited -> AdminResult.RateLimited + is AdminResult.Failed -> result + } + } + + override suspend fun requestStats(server: NodeId?): AdminResult { + val myNode = engine.myNodeNumOrNull() ?: return AdminResult.NodeUnreachable + val targetServer = resolveServer(server) ?: return AdminResult.NodeUnreachable + val requestId = engine.nextMessageId().raw + val payload = StoreAndForward.ADAPTER.encode( + StoreAndForward(rr = StoreAndForward.RequestResponse.CLIENT_STATS), + ).toByteString() + val packet = MeshPacket( + id = requestId, + from = myNode, + to = targetServer.raw, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload, + want_response = true, + ), + ) + return when ( + val result = engine.submitRpc( + packet, + requestId, + ResponseKind.StoreForwardStatsReply, + rpcTimeout, + ) + ) { + is AdminResult.Success -> AdminResult.Success(result.value.toSdkStats()) + AdminResult.Timeout -> AdminResult.Timeout + AdminResult.NodeUnreachable -> AdminResult.NodeUnreachable + AdminResult.SessionKeyExpired -> AdminResult.SessionKeyExpired + AdminResult.Unauthorized -> AdminResult.Unauthorized + AdminResult.RateLimited -> AdminResult.RateLimited + is AdminResult.Failed -> result + } + } + + private suspend fun handlePacket(packet: MeshPacket) { + val decoded = packet.decoded ?: return + if (decoded.portnum != PortNum.STORE_FORWARD_APP || packet.from == 0) return + + val legacy = try { + StoreAndForward.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + null + } + if (legacy != null && legacy.unknownFields.size == 0 && looksLikeLegacyStoreForward(legacy)) { + handleLegacySf(legacy, packet) + return + } + + val sfpp = try { + StoreForwardPlusPlus.ADAPTER.decode(decoded.payload) + } catch (_: Exception) { + null + } + if (sfpp != null) { + handleSfpp(sfpp) + } + } + + private suspend fun handleLegacySf(message: StoreAndForward, packet: MeshPacket) { + val server = NodeId(packet.from) + when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + StoreAndForward.RequestResponse.ROUTER_PONG, + -> { + rememberServer(server) + _events.emit(StoreForwardEvent.Heartbeat(server)) + } + + StoreAndForward.RequestResponse.ROUTER_HISTORY -> { + rememberServer(server) + val pending = message.history?.history_messages ?: 0 + _events.emit(StoreForwardEvent.HistoryReplayStarted(server, pending)) + if (pending <= 0) { + activeReplays.remove(server) + _events.emit(StoreForwardEvent.HistoryReplayComplete(server, 0)) + } else { + activeReplays[server] = ReplayProgress(expected = pending) + } + } + + StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + -> { + rememberServer(server) + val progress = activeReplays[server] ?: return + val fingerprint = ReplayMessageFingerprint( + packetId = packet.id, + response = message.rr, + payload = message.text?.toByteArray(), + ) + if (fingerprint in progress.seenMessages) return + val delivered = progress.delivered + 1 + val updated = progress.copy( + delivered = delivered, + seenMessages = progress.seenMessages + fingerprint, + ) + if (delivered >= progress.expected) { + activeReplays.remove(server) + _events.emit(StoreForwardEvent.HistoryReplayComplete(server, delivered)) + } else { + activeReplays[server] = updated + } + } + + StoreAndForward.RequestResponse.ROUTER_STATS, + StoreAndForward.RequestResponse.ROUTER_BUSY, + StoreAndForward.RequestResponse.ROUTER_ERROR, + StoreAndForward.RequestResponse.ROUTER_PING, + -> rememberServer(server) + + else -> Unit + } + } + + private fun looksLikeLegacyStoreForward(message: StoreAndForward): Boolean = when (message.rr) { + StoreAndForward.RequestResponse.ROUTER_HEARTBEAT -> message.heartbeat != null + + StoreAndForward.RequestResponse.ROUTER_HISTORY -> message.history != null + + StoreAndForward.RequestResponse.ROUTER_STATS -> message.stats != null + + StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + -> message.text != null + + StoreAndForward.RequestResponse.ROUTER_PONG, + StoreAndForward.RequestResponse.ROUTER_BUSY, + StoreAndForward.RequestResponse.ROUTER_ERROR, + StoreAndForward.RequestResponse.ROUTER_PING, + -> message.stats == null && message.history == null && message.heartbeat == null && message.text == null + + else -> false + } + + private suspend fun handleSfpp(sfpp: StoreForwardPlusPlus) { + when (sfpp.sfpp_message_type) { + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + -> handleLinkProvide(sfpp) + + StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE -> handleCanonAnnounce(sfpp) + + else -> Unit + } + } + + private suspend fun handleLinkProvide(sfpp: StoreForwardPlusPlus) { + val confirmed = sfpp.commit_hash.size != 0 + val messageType = sfpp.sfpp_message_type + val isFragment = messageType != StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE + val normalizedTo = if (sfpp.encapsulated_to == 0) NodeId.BROADCAST.raw else sfpp.encapsulated_to + val providedHash = sfpp.message_hash.takeIf { it.size != 0 }?.toByteArray() + if (!isFragment || providedHash != null || sfpp.message.size == 0) { + emitLinkProvided( + packetId = sfpp.encapsulated_id, + from = sfpp.encapsulated_from, + to = normalizedTo, + confirmed = confirmed, + messageHash = when { + providedHash != null -> providedHash + + !isFragment && sfpp.message.size != 0 -> SfppHash.compute( + payload = sfpp.message.toByteArray(), + to = normalizedTo, + from = sfpp.encapsulated_from, + id = sfpp.encapsulated_id, + ) + + else -> null + }, + ) + return + } + + val key = SfppFragmentKey( + packetId = sfpp.encapsulated_id, + from = sfpp.encapsulated_from, + to = normalizedTo, + ) + val existing = pendingSfppFragments[key] ?: SfppFragmentState() + val updated = when (messageType) { + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF -> existing.copy( + firstHalf = sfpp.message.toByteArray(), + confirmed = existing.confirmed || confirmed, + ) + + StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF -> existing.copy( + secondHalf = sfpp.message.toByteArray(), + confirmed = existing.confirmed || confirmed, + ) + + else -> existing + } + val firstHalf = updated.firstHalf + val secondHalf = updated.secondHalf + if (firstHalf != null && secondHalf != null) { + pendingSfppFragments.remove(key) + emitLinkProvided( + packetId = key.packetId, + from = key.from, + to = key.to, + confirmed = updated.confirmed, + messageHash = SfppHash.compute( + payload = firstHalf + secondHalf, + to = key.to, + from = key.from, + id = key.packetId, + ), + ) + } else { + pendingSfppFragments[key] = updated + } + } + + private suspend fun emitLinkProvided( + packetId: Int, + from: Int, + to: Int, + confirmed: Boolean, + messageHash: ByteArray?, + ) { + _events.emit( + StoreForwardEvent.SfppLinkProvided( + packetId = packetId, + from = from, + to = to, + messageHash = messageHash, + confirmed = confirmed, + ), + ) + } + + private suspend fun handleCanonAnnounce(sfpp: StoreForwardPlusPlus) { + if (sfpp.message_hash.size == 0) return + _events.emit( + StoreForwardEvent.SfppCanonAnnounced( + messageHash = sfpp.message_hash.toByteArray(), + rxTime = sfpp.encapsulated_rxtime.toLong() and 0xFFFFFFFFL, + ), + ) + } + + private suspend fun handleNodeChange(change: NodeChange) { + when (change) { + is NodeChange.Removed -> forgetServer(change.nodeId) + is NodeChange.WentOffline -> forgetServer(change.nodeId) + else -> Unit + } + } + + private suspend fun handleConnection(state: ConnectionState) { + if (state == ConnectionState.Disconnected) { + clearServers() + } + } + + private suspend fun rememberServer(server: NodeId) { + if (knownServers.add(server)) { + _servers.value = knownServers.toList() + _events.emit(StoreForwardEvent.ServerDiscovered(server)) + } + } + + private suspend fun forgetServer(server: NodeId) { + if (knownServers.remove(server)) { + activeReplays.remove(server) + _servers.value = knownServers.toList() + _events.emit(StoreForwardEvent.ServerLost(server)) + } + } + + private suspend fun clearServers() { + pendingSfppFragments.clear() + if (knownServers.isEmpty()) return + val lost = knownServers.toList() + knownServers.clear() + activeReplays.clear() + _servers.value = emptyList() + lost.forEach { _events.emit(StoreForwardEvent.ServerLost(it)) } + } + + private fun resolveServer(server: NodeId?): NodeId? { + val candidate = server ?: _servers.value.firstOrNull() ?: return null + return if (candidate == NodeId.LOCAL) { + engine.myNodeNumOrNull()?.let(::NodeId) + } else { + candidate + } + } + + private fun historyWindowMinutes(since: Int?): Int { + if (since == null) return ALL_HISTORY_WINDOW_MINUTES + val ageSeconds = (nowProvider().epochSeconds - since.toLong()).coerceAtLeast(0) + return ((ageSeconds + 59) / 60) + .coerceIn(1, ALL_HISTORY_WINDOW_MINUTES.toLong()) + .toInt() + } + + private fun StoreAndForward.Statistics.toSdkStats(): StoreForwardStats = StoreForwardStats( + messagesStored = messages_saved, + messagesMax = messages_max, + uptime = up_time, + requests = requests_history, + requestsFailed = 0, + heartbeat = heartbeat, + ) + + private sealed interface InternalSignal { + data class Packet(val packet: MeshPacket) : InternalSignal + data class Node(val change: NodeChange) : InternalSignal + data class Connection(val state: ConnectionState) : InternalSignal + } + + private data class ReplayProgress( + val expected: Int, + val delivered: Int = 0, + val seenMessages: Set = emptySet(), + ) + + private data class ReplayMessageFingerprint( + val packetId: Int, + val response: StoreAndForward.RequestResponse, + val payload: ByteArray?, + ) { + override fun equals(other: Any?): Boolean = other is ReplayMessageFingerprint && + packetId == other.packetId && + response == other.response && + payload.contentEquals(other.payload) + + override fun hashCode(): Int = ((packetId * 31) + response.hashCode()) * 31 + payload.contentHashCode() + } + + private data class SfppFragmentKey(val packetId: Int, val from: Int, val to: Int) + + private data class SfppFragmentState( + val firstHalf: ByteArray? = null, + val secondHalf: ByteArray? = null, + val confirmed: Boolean = false, + ) + + private companion object { + const val ALL_HISTORY_WINDOW_MINUTES: Int = 60 * 24 * 365 * 100 + } +} diff --git a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt index 7a17aaa..a85e09e 100644 --- a/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt +++ b/core/src/commonMain/kotlin/org/meshtastic/sdk/internal/TelemetryApiImpl.kt @@ -16,11 +16,14 @@ import org.meshtastic.proto.AirQualityMetrics import org.meshtastic.proto.Data import org.meshtastic.proto.DeviceMetrics import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HealthMetrics +import org.meshtastic.proto.HostMetrics import org.meshtastic.proto.LocalStats import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.PortNum import org.meshtastic.proto.PowerMetrics import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.TrafficManagementStats import org.meshtastic.sdk.AdminResult import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.TelemetryApi @@ -58,6 +61,15 @@ internal class TelemetryApiImpl( override suspend fun requestLocalStats(): AdminResult = requestTelemetry(NodeId.LOCAL) { it.local_stats } + override suspend fun requestHealth(node: NodeId): AdminResult = + requestTelemetry(node) { it.health_metrics } + + override suspend fun requestHost(node: NodeId): AdminResult = + requestTelemetry(node) { it.host_metrics } + + override suspend fun requestTrafficManagement(node: NodeId): AdminResult = + requestTelemetry(node) { it.traffic_management_stats } + override fun observe(node: NodeId): Flow = packetsFlow .filter { packet -> val decoded = packet.decoded ?: return@filter false @@ -110,6 +122,8 @@ internal class TelemetryApiImpl( AdminResult.Unauthorized -> AdminResult.Unauthorized + AdminResult.RateLimited -> AdminResult.RateLimited + is AdminResult.Failed -> result } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt new file mode 100644 index 0000000..386776c --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiImplComprehensiveTest.kt @@ -0,0 +1,1270 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Position +import org.meshtastic.proto.Routing +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class AdminApiImplComprehensiveTest { + + @Test + fun getDeviceConfigReturnsDeviceSection() = runTest { + val expected = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.DEVICE_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getLoraConfigReturnsLoraSection() = runTest { + val expected = Config(lora = Config.LoRaConfig(use_preset = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.LORA_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.LORA_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getBluetoothConfigReturnsBluetoothSection() = runTest { + val expected = Config(bluetooth = Config.BluetoothConfig(enabled = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.BLUETOOTH_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.BLUETOOTH_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getDisplayConfigReturnsDisplaySection() = runTest { + val expected = Config(display = Config.DisplayConfig(screen_on_secs = 45)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.DISPLAY_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.DISPLAY_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getNetworkConfigReturnsNetworkSection() = runTest { + val expected = Config(network = Config.NetworkConfig(wifi_enabled = true, wifi_ssid = "mesh")) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.NETWORK_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.NETWORK_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getPositionConfigReturnsPositionSection() = runTest { + val expected = Config(position = Config.PositionConfig(position_broadcast_secs = 300)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.POSITION_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.POSITION_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getPowerConfigReturnsPowerSection() = runTest { + val expected = Config(power = Config.PowerConfig(is_power_saving = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.POWER_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.POWER_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun getSecurityConfigReturnsSecuritySection() = runTest { + val expected = Config(security = Config.SecurityConfig(is_managed = true)) + assertRpcOperation( + call = { it.getConfig(AdminMessage.ConfigType.SECURITY_CONFIG) }, + requestMatches = { it.get_config_request == AdminMessage.ConfigType.SECURITY_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_config_response = expected)) + } + } + + @Test + fun setDeviceConfigBuilderSendsDeviceSection() = runTest { + val expected = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)) + assertAckedOperation( + call = { it.setDeviceConfig { copy(role = Config.DeviceConfig.Role.CLIENT) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setLoraConfigBuilderSendsLoraSection() = runTest { + val expected = Config(lora = Config.LoRaConfig(use_preset = true)) + assertAckedOperation( + call = { it.setLoraConfig { copy(use_preset = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setBluetoothConfigBuilderSendsBluetoothSection() = runTest { + val expected = Config(bluetooth = Config.BluetoothConfig(enabled = true)) + assertAckedOperation( + call = { it.setBluetoothConfig { copy(enabled = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setDisplayConfigBuilderSendsDisplaySection() = runTest { + val expected = Config(display = Config.DisplayConfig(screen_on_secs = 45)) + assertAckedOperation( + call = { it.setDisplayConfig { copy(screen_on_secs = 45) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setNetworkConfigBuilderSendsNetworkSection() = runTest { + val expected = Config(network = Config.NetworkConfig(wifi_enabled = true, wifi_ssid = "mesh")) + assertAckedOperation( + call = { it.setNetworkConfig { copy(wifi_enabled = true, wifi_ssid = "mesh") } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setPositionConfigBuilderSendsPositionSection() = runTest { + val expected = Config(position = Config.PositionConfig(position_broadcast_secs = 300)) + assertAckedOperation( + call = { it.setPositionConfig { copy(position_broadcast_secs = 300) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setPowerConfigBuilderSendsPowerSection() = runTest { + val expected = Config(power = Config.PowerConfig(is_power_saving = true)) + assertAckedOperation( + call = { it.setPowerConfig { copy(is_power_saving = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setSecurityConfigBuilderSendsSecuritySection() = runTest { + val expected = Config(security = Config.SecurityConfig(is_managed = true)) + assertAckedOperation( + call = { it.setSecurityConfig { copy(is_managed = true) } }, + requestMatches = { it.set_config == expected }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun getDeviceMetadataReturnsResponse() = runTest { + val expected = DeviceMetadata(firmware_version = "2.5.0") + assertRpcOperation( + call = { it.getDeviceMetadata() }, + requestMatches = { it.get_device_metadata_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_device_metadata_response = expected)) + } + } + + @Test + fun getDeviceMetadataTimeoutReturnsTimeout() = runTest { + assertRpcOperation( + call = { it.getDeviceMetadata() }, + requestMatches = { it.get_device_metadata_request == true }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun getModuleConfigReturnsResponse() = runTest { + val expected = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + assertRpcOperation( + call = { it.getModuleConfig(AdminMessage.ModuleConfigType.MQTT_CONFIG) }, + requestMatches = { it.get_module_config_request == AdminMessage.ModuleConfigType.MQTT_CONFIG }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_module_config_response = expected)) + } + } + + @Test + fun getModuleConfigRoutingErrorBecomesUnauthorized() = runTest { + assertRpcOperation( + call = { it.getModuleConfig(AdminMessage.ModuleConfigType.MQTT_CONFIG) }, + requestMatches = { it.get_module_config_request == AdminMessage.ModuleConfigType.MQTT_CONFIG }, + expected = AdminResult.Unauthorized, + ) { transport, packet -> + transport.injectFrame(buildRoutingErrorFrame(packet.id, Routing.Error.NOT_AUTHORIZED)) + } + } + + @Test + fun getUiConfigReturnsResponse() = runTest { + val expected = DeviceUIConfig(screen_brightness = 128) + assertRpcOperation( + call = { it.getUIConfig() }, + requestMatches = { it.get_ui_config_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_ui_config_response = expected)) + } + } + + @Test + fun getCannedMessagesReturnsResponse() = runTest { + assertRpcOperation( + call = { it.getCannedMessages() }, + requestMatches = { it.get_canned_message_module_messages_request == true }, + expected = AdminResult.Success("alpha|bravo"), + ) { transport, packet -> + transport.injectAdminResponse( + packet.id, + AdminMessage(get_canned_message_module_messages_response = "alpha|bravo"), + ) + } + } + + @Test + fun getRingtoneReturnsResponse() = runTest { + assertRpcOperation( + call = { it.getRingtone() }, + requestMatches = { it.get_ringtone_request == true }, + expected = AdminResult.Success("Test:d=4,o=5,b=100:c"), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_ringtone_response = "Test:d=4,o=5,b=100:c")) + } + } + + @Test + fun getDeviceConnectionStatusReturnsResponse() = runTest { + val expected = DeviceConnectionStatus() + assertRpcOperation( + call = { it.getDeviceConnectionStatus() }, + requestMatches = { it.get_device_connection_status_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_device_connection_status_response = expected)) + } + } + + @Test + fun getRemoteHardwarePinsReturnsResponse() = runTest { + val expected = NodeRemoteHardwarePinsResponse() + assertRpcOperation( + call = { it.getRemoteHardwarePins() }, + requestMatches = { it.get_node_remote_hardware_pins_request == true }, + expected = AdminResult.Success(expected), + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_node_remote_hardware_pins_response = expected)) + } + } + + @Test + fun getChannelUsesOneBasedWireIndex() = runTest { + val expected = Channel(index = 0, role = Channel.Role.PRIMARY) + assertRpcOperation( + call = { it.getChannel(ChannelIndex(0)) }, + requestMatches = { it.get_channel_request == 1 }, + expected = AdminResult.Success(expected), + assertPacket = { _, admin -> assertEquals(1, admin.get_channel_request) }, + ) { transport, packet -> + transport.injectAdminResponse(packet.id, AdminMessage(get_channel_response = expected)) + } + } + + @Test + fun listChannelsPropagatesGetterFailure() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.listChannels() } + + runCurrent() + val first = latestAdminPacket(transport, outboundBefore) { it.get_channel_request == 1 } + transport.injectAdminResponse( + first.id, + AdminMessage(get_channel_response = Channel(index = 0, role = Channel.Role.PRIMARY)), + ) + runCurrent() + + val second = latestAdminPacket(transport, outboundBefore) { it.get_channel_request == 2 } + transport.injectFrame(buildRoutingErrorFrame(second.id, Routing.Error.NOT_AUTHORIZED)) + runCurrent() + + assertEquals(AdminResult.Unauthorized, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setChannelSuccessUpdatesChannelsState() = runTest { + val channel = Channel(index = 3, role = Channel.Role.SECONDARY) + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.setChannel(channel) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore) { it.set_channel == channel } + transport.injectRoutingAck(packet.id) + runCurrent() + + assertIs>(deferred.await()) + val channels = client.channels.value + assertNotNull(channels) + assertEquals(channel, channels[3]) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setChannelTimeoutReturnsTimeout() = runTest { + val channel = Channel(index = 1, role = Channel.Role.SECONDARY) + assertAckedOperation( + call = { it.setChannel(channel) }, + requestMatches = { it.set_channel == channel }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun setChannelRoutingErrorMapsToNodeUnreachable() = runTest { + val channel = Channel(index = 1, role = Channel.Role.SECONDARY) + assertAckedOperation( + call = { it.setChannel(channel) }, + requestMatches = { it.set_channel == channel }, + expected = AdminResult.NodeUnreachable, + ) { transport, packet -> + transport.injectFrame(buildRoutingErrorFrame(packet.id, Routing.Error.NO_ROUTE)) + } + } + + @Test + fun setFavoriteTrueSendsSetFavoriteNode() = runTest { + val node = NodeId(0x01020304) + assertAckedOperation( + call = { it.setFavorite(node, favorite = true) }, + requestMatches = { it.set_favorite_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setFavoriteFalseSendsRemoveFavoriteNode() = runTest { + val node = NodeId(0x01020304) + assertAckedOperation( + call = { it.setFavorite(node, favorite = false) }, + requestMatches = { it.remove_favorite_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setIgnoredTrueSendsSetIgnoredNode() = runTest { + val node = NodeId(0x05060708) + assertAckedOperation( + call = { it.setIgnored(node, ignored = true) }, + requestMatches = { it.set_ignored_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setIgnoredFalseSendsRemoveIgnoredNode() = runTest { + val node = NodeId(0x05060708) + assertAckedOperation( + call = { it.setIgnored(node, ignored = false) }, + requestMatches = { it.remove_ignored_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun toggleMutedSendsToggleCommand() = runTest { + val node = NodeId(0x0a0b0c0d) + assertAckedOperation( + call = { it.toggleMuted(node) }, + requestMatches = { it.toggle_muted_node == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setFixedPositionSendsPosition() = runTest { + val position = Position(latitude_i = 377749000, longitude_i = -1224194000, altitude = 12) + assertAckedOperation( + call = { it.setFixedPosition(position) }, + requestMatches = { it.set_fixed_position == position }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun removeFixedPositionSendsRemovalFlag() = runTest { + assertAckedOperation( + call = { it.removeFixedPosition() }, + requestMatches = { it.remove_fixed_position == true }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun storeUiConfigSendsConfig() = runTest { + val uiConfig = DeviceUIConfig(screen_brightness = 64) + assertAckedOperation( + call = { it.storeUIConfig(uiConfig) }, + requestMatches = { it.store_ui_config == uiConfig }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setCannedMessagesSendsString() = runTest { + assertAckedOperation( + call = { it.setCannedMessages("one|two") }, + requestMatches = { it.set_canned_message_module_messages == "one|two" }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setRingtoneSendsString() = runTest { + assertAckedOperation( + call = { it.setRingtone("Ring:d=4,o=5,b=120:c") }, + requestMatches = { it.set_ringtone_message == "Ring:d=4,o=5,b=120:c" }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun backupPreferencesSendsLocation() = runTest { + assertAckedOperation( + call = { it.backupPreferences(AdminMessage.BackupLocation.SD) }, + requestMatches = { it.backup_preferences == AdminMessage.BackupLocation.SD }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun restorePreferencesSendsLocation() = runTest { + assertAckedOperation( + call = { it.restorePreferences(AdminMessage.BackupLocation.SD) }, + requestMatches = { it.restore_preferences == AdminMessage.BackupLocation.SD }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun removeBackupPreferencesSendsLocation() = runTest { + assertAckedOperation( + call = { it.removeBackupPreferences(AdminMessage.BackupLocation.SD) }, + requestMatches = { it.remove_backup_preferences == AdminMessage.BackupLocation.SD }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun removeNodeSendsNodeNum() = runTest { + val node = NodeId(0x0f0e0d0c) + assertAckedOperation( + call = { it.removeNode(node) }, + requestMatches = { it.remove_by_nodenum == node.raw }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setScaleSendsScale() = runTest { + assertAckedOperation( + call = { it.setScale(240) }, + requestMatches = { it.set_scale == 240 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun sendInputEventSendsInputEvent() = runTest { + val event = AdminMessage.InputEvent(event_code = 17, kb_char = 65) + assertAckedOperation( + call = { it.sendInputEvent(event) }, + requestMatches = { it.send_input_event == event }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun addContactSendsSharedContact() = runTest { + val contact = + SharedContact(node_num = 77, user = User(id = "!0000004d", long_name = "Contact", short_name = "CT")) + assertAckedOperation( + call = { it.addContact(contact) }, + requestMatches = { it.add_contact == contact }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun keyVerificationSendsVerification() = runTest { + val verification = KeyVerificationAdmin(remote_nodenum = 99, nonce = 1234L) + assertAckedOperation( + call = { it.keyVerification(verification) }, + requestMatches = { it.key_verification == verification }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Suppress("DEPRECATION") + @Test + fun rebootOtaSendsDelaySeconds() = runTest { + assertAckedOperation( + call = { it.rebootOta(5.seconds) }, + requestMatches = { it.reboot_ota_seconds == 5 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun otaRequestSendsEvent() = runTest { + val event = AdminMessage.OTAEvent() + assertAckedOperation( + call = { it.otaRequest(event) }, + requestMatches = { it.ota_request == event }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setSensorConfigSendsConfig() = runTest { + val config = SensorConfig() + assertAckedOperation( + call = { it.setSensorConfig(config) }, + requestMatches = { it.sensor_config == config }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun exitSimulatorSendsFlag() = runTest { + assertAckedOperation( + call = { it.exitSimulator() }, + requestMatches = { it.exit_simulator == true }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun rebootSendsDelaySeconds() = runTest { + assertAckedOperation( + call = { it.reboot(3.seconds) }, + requestMatches = { it.reboot_seconds == 3 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun shutdownSendsDelaySeconds() = runTest { + assertAckedOperation( + call = { it.shutdown(4.seconds) }, + requestMatches = { it.shutdown_seconds == 4 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun enterDfuModeManagedDeviceIsUnauthorized() = runTest { + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "managed")), + org.meshtastic.proto.FromRadio(config = Config(security = Config.SecurityConfig(is_managed = true))), + ), + ) + client.connect() + runCurrent() + try { + assertNotNull(client.configBundle.value) + val outboundBefore = transport.outboundPackets().size + assertEquals(AdminResult.Unauthorized, client.admin.enterDfuMode()) + assertEquals(outboundBefore, transport.outboundPackets().size) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun deleteFileTimeoutReturnsTimeout() = runTest { + assertAckedOperation( + call = { it.deleteFile("logs/app.txt") }, + requestMatches = { it.delete_file_request == "logs/app.txt" }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun factoryResetPreserveBleBondsUsesConfigReset() = runTest { + assertAckedOperation( + call = { it.factoryReset(preserveBleBonds = true) }, + requestMatches = { it.factory_reset_config == 1 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun factoryResetWithoutPreserveBleBondsUsesDeviceReset() = runTest { + assertAckedOperation( + call = { it.factoryReset(preserveBleBonds = false) }, + requestMatches = { it.factory_reset_device == 1 }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun nodeDbResetSendsFlag() = runTest { + assertAckedOperation( + call = { it.nodeDbReset() }, + requestMatches = { it.nodedb_reset == true }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setOwnerTimeoutReturnsTimeout() = runTest { + val user = User(id = "!00000001", long_name = "Owner", short_name = "OW") + assertAckedOperation( + call = { it.setOwner(user) }, + requestMatches = { it.set_owner == user }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun setOwnerRoutingErrorMapsToRateLimited() = runTest { + val user = User(id = "!00000001", long_name = "Owner", short_name = "OW") + assertAckedOperation( + call = { it.setOwner(user) }, + requestMatches = { it.set_owner == user }, + expected = AdminResult.RateLimited, + ) { transport, packet -> + transport.injectFrame(buildRoutingErrorFrame(packet.id, Routing.Error.RATE_LIMIT_EXCEEDED)) + } + } + + @Test + fun setTimeOnlyManagedDeviceIsUnauthorized() = runTest { + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "managed")), + org.meshtastic.proto.FromRadio(config = Config(security = Config.SecurityConfig(is_managed = true))), + ), + ) + client.connect() + runCurrent() + try { + assertNotNull(client.configBundle.value) + val outboundBefore = transport.outboundPackets().size + assertEquals(AdminResult.Unauthorized, client.admin.setTimeOnly(1_700_000_123)) + assertEquals(outboundBefore, transport.outboundPackets().size) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setTimeUsesExplicitInstant() = runTest { + val instant = Instant.fromEpochSeconds(1_700_000_456L) + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val result = client.admin.setTime(instant) + runCurrent() + + val packet = + latestAdminPacket(transport, outboundBefore) { it.set_time_only == instant.epochSeconds.toInt() } + assertIs>(result) + assertFalse(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun setHamModeSuccess() = runTest { + val params = HamParameters(call_sign = "KD2ABC", tx_power = 20, frequency = 146.52f, short_name = "KD") + assertAckedOperation( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + ) { transport, packet -> + transport.injectRoutingAck(packet.id) + } + } + + @Test + fun setHamModeTimeoutReturnsTimeout() = runTest { + val params = HamParameters(call_sign = "KD2ABC") + assertAckedOperation( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + expected = AdminResult.Timeout, + ) { _, _ -> + advanceTimeBy(70.seconds) + } + } + + @Test + fun forNodeTargetsRemoteDestination() = runTest { + val remote = NodeId(0x12345678) + val expected = DeviceMetadata(firmware_version = "remote") + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.forNode(remote).getDeviceMetadata() } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore) { it.get_device_metadata_request == true } + assertEquals(remote.raw, packet.to) + transport.injectAdminResponse( + packet.id, + AdminMessage(get_device_metadata_response = expected), + fromNode = remote.raw, + ) + runCurrent() + + assertEquals(AdminResult.Success(expected), deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsSuccessfulWritesUpdateConfigBundle() = runTest { + val updatedConfig = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) + val updatedModule = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "2.5.0")), + org.meshtastic.proto.FromRadio( + config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)), + ), + org.meshtastic.proto.FromRadio( + moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false)), + ), + ), + ) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.editSettings { + setConfig(updatedConfig) + setModuleConfig(updatedModule) + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectRoutingAck(commit.id) + runCurrent() + + assertIs>(deferred.await()) + val bundle = client.configBundle.value + assertNotNull(bundle) + assertTrue(bundle.configs.any { it.device?.role == Config.DeviceConfig.Role.ROUTER }) + assertTrue(bundle.moduleConfigs.any { it.mqtt?.enabled == true }) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchSuccessfulWritesUpdateConfigBundle() = runTest { + val updatedConfig = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)) + val updatedModule = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + val (transport, client) = connectedClient( + frames = handshakeFrames( + org.meshtastic.proto.FromRadio(metadata = DeviceMetadata(firmware_version = "2.5.0")), + org.meshtastic.proto.FromRadio( + config = Config(device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)), + ), + org.meshtastic.proto.FromRadio( + moduleConfig = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = false)), + ), + ), + ) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.batch { + setConfig(updatedConfig) + setModuleConfig(updatedModule) + "done" + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectRoutingAck(commit.id) + runCurrent() + + assertEquals("done", deferred.await()) + val bundle = client.configBundle.value + assertNotNull(bundle) + assertTrue(bundle.configs.any { it.device?.role == Config.DeviceConfig.Role.ROUTER }) + assertTrue(bundle.moduleConfigs.any { it.mqtt?.enabled == true }) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchBlockExceptionSkipsCommit() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + runCatching { + client.admin.batch { + error("boom") + } + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val result = deferred.await() + assertEquals("boom", result.exceptionOrNull()?.message) + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.commit_edit_settings == true }, + ) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchBeginFailureThrowsNodeUnreachable() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { runCatching { client.admin.batch { Unit } } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(begin.id, Routing.Error.NO_ROUTE)) + runCurrent() + + assertIs(deferred.await().exceptionOrNull()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchCommitFailureThrowsUnauthorized() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { runCatching { client.admin.batch { Unit } } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(commit.id, Routing.Error.NOT_AUTHORIZED)) + runCurrent() + + assertIs(deferred.await().exceptionOrNull()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun batchSetterDisconnectThrowsNodeUnreachable() = runTest { + val gate = CompletableDeferred() + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + runCatching { + client.admin.batch { + gate.await() + setFavorite(NodeId(0x10101010), favorite = true) + } + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + client.disconnect() + runCurrent() + gate.complete(Unit) + runCurrent() + + val result = deferred.await() + assertIs(result.exceptionOrNull()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsBeginFailureReturnsNodeUnreachable() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.editSettings { Unit } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(begin.id, Routing.Error.NO_ROUTE)) + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsCommitFailureReturnsUnauthorized() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.editSettings { Unit } } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + val commit = latestAdminPacket(transport, outboundBefore) { it.commit_edit_settings == true } + transport.injectFrame(buildRoutingErrorFrame(commit.id, Routing.Error.NOT_AUTHORIZED)) + runCurrent() + + assertEquals(AdminResult.Unauthorized, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + @Test + fun editSettingsSetterDisconnectReturnsNodeUnreachable() = runTest { + val gate = CompletableDeferred() + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.editSettings { + gate.await() + setFavorite(NodeId(0x20202020), favorite = true) + } + } + runCurrent() + + val begin = latestAdminPacket(transport, outboundBefore) { it.begin_edit_settings == true } + transport.injectRoutingAck(begin.id) + runCurrent() + + client.disconnect() + runCurrent() + gate.complete(Unit) + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private fun TestScope.connectedClient( + nodeNum: Int = 1, + storageProvider: StorageProvider = InMemoryStorageProvider(), + rpcTimeout: Duration = 60.seconds, + sendTimeout: Duration = 60.seconds, + frames: List = emptyList(), + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:admin-comprehensive:$nodeNum:${hashCode()}"), + frames = frames, + autoHandshake = true, + nodeNum = nodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(storageProvider) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(rpcTimeout) + .sendTimeout(sendTimeout) + .build() + return transport to client + } + + private fun handshakeFrames(vararg fromRadio: org.meshtastic.proto.FromRadio): List = + fromRadio.map(::fromRadioFrame) + + private fun fromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame { + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(kotlinx.io.bytestring.ByteString(bytes)) + } + + private suspend fun TestScope.assertAckedOperation( + storageProvider: StorageProvider = InMemoryStorageProvider(), + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + expected: AdminResult = AdminResult.Success(Unit), + assertPacket: (MeshPacket, AdminMessage) -> Unit = { _, _ -> }, + respond: suspend TestScope.(FakeRadioTransport, MeshPacket) -> Unit, + ) { + val (transport, client) = connectedClient(storageProvider = storageProvider) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + val admin = adminOf(packet)!! + assertTrue(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertPacket(packet, admin) + + respond(transport, packet) + runCurrent() + + assertEquals(expected, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertRpcOperation( + storageProvider: StorageProvider = InMemoryStorageProvider(), + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + expected: AdminResult, + assertPacket: (MeshPacket, AdminMessage) -> Unit = { _, _ -> }, + respond: suspend TestScope.(FakeRadioTransport, MeshPacket) -> Unit, + ) { + val (transport, client) = connectedClient(storageProvider = storageProvider) + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + val admin = adminOf(packet)!! + assertFalse(packet.want_ack) + assertEquals(true, packet.decoded?.want_response) + assertPacket(packet, admin) + + respond(transport, packet) + runCurrent() + + assertEquals(expected, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private fun latestAdminPacket( + transport: FakeRadioTransport, + outboundBefore: Int, + predicate: (AdminMessage) -> Boolean, + ): MeshPacket = transport.outboundPackets().drop(outboundBefore) + .last { packet -> adminOf(packet)?.let(predicate) == true } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val decoded = packet.decoded ?: return null + if (decoded.portnum != PortNum.ADMIN_APP) return null + return runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + } + + private fun buildRoutingErrorFrame(requestId: Int, error: Routing.Error): Frame { + val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = error))) + val packet = MeshPacket( + from = 1, + to = 0, + decoded = org.meshtastic.proto.Data( + portnum = PortNum.ROUTING_APP, + payload = payload, + request_id = requestId, + ), + ) + val fromRadio = org.meshtastic.proto.FromRadio(packet = packet) + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(kotlinx.io.bytestring.ByteString(bytes)) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt new file mode 100644 index 0000000..25006e9 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminApiRemainingTest.kt @@ -0,0 +1,407 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +@file:Suppress("DEPRECATION") + +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.test.fail +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class AdminApiRemainingTest { + + @Test + fun setHamModeSendsHamParameters() = runTest { + val params = HamParameters(call_sign = "KD2ABC", tx_power = 20) + assertAckSuccess( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + ) + } + + @Test + fun enterDfuModeSendsFireAndForgetRequestWithoutTimingOut() = runTest { + assertFireAndForgetSuccess( + call = { it.enterDfuMode() }, + requestMatches = { it.enter_dfu_mode_request == true }, + ) + } + + @Test + fun keyVerificationSendsVerificationMessage() = runTest { + val verification = KeyVerificationAdmin(remote_nodenum = 0x01020304, nonce = 1234L) + assertAckSuccess( + call = { it.keyVerification(verification) }, + requestMatches = { it.key_verification == verification }, + ) + } + + @Test + fun setSensorConfigSendsSensorConfig() = runTest { + val config = SensorConfig() + assertAckSuccess( + call = { it.setSensorConfig(config) }, + requestMatches = { it.sensor_config == config }, + ) + } + + @Test + fun sendInputEventSendsInputEvent() = runTest { + val event = AdminMessage.InputEvent(event_code = 17, kb_char = 65) + assertAckSuccess( + call = { it.sendInputEvent(event) }, + requestMatches = { it.send_input_event == event }, + ) + } + + @Test + fun addContactSendsSharedContact() = runTest { + val contact = SharedContact( + node_num = 77, + user = User(id = "!0000004d", long_name = "Contact", short_name = "CT"), + ) + assertAckSuccess( + call = { it.addContact(contact) }, + requestMatches = { it.add_contact == contact }, + ) + } + + @Test + fun getRemoteHardwarePinsRequestsRemotePins() = runTest { + val expected = NodeRemoteHardwarePinsResponse() + assertRpcSuccess( + call = { it.getRemoteHardwarePins() }, + requestMatches = { it.get_node_remote_hardware_pins_request == true }, + response = AdminMessage(get_node_remote_hardware_pins_response = expected), + expected = expected, + ) + } + + @Test + fun getDeviceConnectionStatusRequestsConnectionStatus() = runTest { + val expected = DeviceConnectionStatus() + assertRpcSuccess( + call = { it.getDeviceConnectionStatus() }, + requestMatches = { it.get_device_connection_status_request == true }, + response = AdminMessage(get_device_connection_status_response = expected), + expected = expected, + ) + } + + @Test + fun deleteFileSendsDeleteRequest() = runTest { + val path = "logs/app.txt" + assertAckSuccess( + call = { it.deleteFile(path) }, + requestMatches = { it.delete_file_request == path }, + ) + } + + @Test + fun otaRequestSendsOtaEvent() = runTest { + val event = AdminMessage.OTAEvent() + assertAckSuccess( + call = { it.otaRequest(event) }, + requestMatches = { it.ota_request == event }, + ) + } + + @Suppress("DEPRECATION") + @Test + fun rebootOtaSendsDelaySeconds() = runTest { + assertAckSuccess( + call = { it.rebootOta(5.seconds) }, + requestMatches = { it.reboot_ota_seconds == 5 }, + ) + } + + @Test + fun exitSimulatorSendsExitFlag() = runTest { + assertAckSuccess( + call = { it.exitSimulator() }, + requestMatches = { it.exit_simulator == true }, + ) + } + + @Test + fun setScaleSendsScaleValue() = runTest { + assertAckSuccess( + call = { it.setScale(240) }, + requestMatches = { it.set_scale == 240 }, + ) + } + + @Test + fun ackWritesTimeoutForHamDeleteAndScale() = runTest { + val params = HamParameters(call_sign = "KD2ABC", tx_power = 20) + assertAckTimeout( + call = { it.setHamMode(params) }, + requestMatches = { it.set_ham_mode == params }, + ) + assertAckTimeout( + call = { it.deleteFile("logs/app.txt") }, + requestMatches = { it.delete_file_request == "logs/app.txt" }, + ) + assertAckTimeout( + call = { it.setScale(240) }, + requestMatches = { it.set_scale == 240 }, + ) + } + + @Test + fun ackWritesTimeoutForVerificationInputAndContact() = runTest { + val verification = KeyVerificationAdmin(remote_nodenum = 0x01020304, nonce = 1234L) + val event = AdminMessage.InputEvent(event_code = 17, kb_char = 65) + val contact = SharedContact( + node_num = 77, + user = User(id = "!0000004d", long_name = "Contact", short_name = "CT"), + ) + assertAckTimeout( + call = { it.keyVerification(verification) }, + requestMatches = { it.key_verification == verification }, + ) + assertAckTimeout( + call = { it.sendInputEvent(event) }, + requestMatches = { it.send_input_event == event }, + ) + assertAckTimeout( + call = { it.addContact(contact) }, + requestMatches = { it.add_contact == contact }, + ) + } + + @Suppress("DEPRECATION") + @Test + fun ackWritesTimeoutForSensorOtaRebootAndExit() = runTest { + val config = SensorConfig() + val event = AdminMessage.OTAEvent() + assertAckTimeout( + call = { it.setSensorConfig(config) }, + requestMatches = { it.sensor_config == config }, + ) + assertAckTimeout( + call = { it.otaRequest(event) }, + requestMatches = { it.ota_request == event }, + ) + assertAckTimeout( + call = { it.rebootOta(5.seconds) }, + requestMatches = { it.reboot_ota_seconds == 5 }, + ) + assertAckTimeout( + call = { it.exitSimulator() }, + requestMatches = { it.exit_simulator == true }, + ) + } + + @Test + fun getOperationsTimeoutForRemotePinsAndConnectionStatus() = runTest { + assertRpcTimeout( + call = { it.getRemoteHardwarePins() }, + requestMatches = { it.get_node_remote_hardware_pins_request == true }, + ) + assertRpcTimeout( + call = { it.getDeviceConnectionStatus() }, + requestMatches = { it.get_device_connection_status_request == true }, + ) + } + + private fun TestScope.connectedClient(rpcTimeout: Duration = 60.seconds): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:admin-remaining"), + autoHandshake = true, + nodeNum = 0x11111111, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .rpcTimeout(rpcTimeout) + .sendTimeout(rpcTimeout) + .build() + return transport to client + } + + private suspend fun TestScope.assertAckSuccess( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertTrue(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + transport.injectRoutingAck(packet.id) + runCurrent() + + when (val result = deferred.await()) { + is AdminResult.Success -> Unit + else -> fail("Expected success but got $result") + } + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertAckTimeout( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertTrue(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + advanceTimeBy(70.seconds) + runCurrent() + + assertEquals(AdminResult.Timeout, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertRpcSuccess( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + response: AdminMessage, + expected: T, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertFalse(packet.want_ack) + assertEquals(true, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + transport.injectAdminResponse(packet.id, response) + runCurrent() + + when (val result = deferred.await()) { + is AdminResult.Success -> assertEquals(expected, result.value) + else -> fail("Expected success but got $result") + } + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertRpcTimeout( + call: suspend (AdminApi) -> AdminResult<*>, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertFalse(packet.want_ack) + assertEquals(true, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + advanceTimeBy(70.seconds) + runCurrent() + + assertEquals(AdminResult.Timeout, deferred.await()) + } finally { + runCatching { client.disconnect() } + } + } + + private suspend fun TestScope.assertFireAndForgetSuccess( + call: suspend (AdminApi) -> AdminResult, + requestMatches: (AdminMessage) -> Boolean, + ) { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + try { + val outboundBefore = transport.outboundPackets().size + val deferred = async { call(client.admin) } + runCurrent() + + val packet = latestAdminPacket(transport, outboundBefore, requestMatches) + assertFalse(packet.want_ack) + assertEquals(false, packet.decoded?.want_response) + assertNotNull(adminOf(packet)) + + advanceTimeBy(70.seconds) + runCurrent() + + when (val result = deferred.await()) { + is AdminResult.Success -> Unit + else -> fail("Expected success but got $result") + } + } finally { + runCatching { client.disconnect() } + } + } + + private fun latestAdminPacket( + transport: FakeRadioTransport, + outboundBefore: Int, + predicate: (AdminMessage) -> Boolean, + ): MeshPacket = transport.outboundPackets().drop(outboundBefore) + .last { packet -> adminOf(packet)?.let(predicate) == true } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val decoded = packet.decoded ?: return null + if (decoded.portnum != PortNum.ADMIN_APP) return null + return runCatching { AdminMessage.ADAPTER.decode(decoded.payload) }.getOrNull() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt new file mode 100644 index 0000000..a153676 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/AdminResultExtensionsTest.kt @@ -0,0 +1,89 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.Routing +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class AdminResultExtensionsTest { + + @Test + fun getOrThrow_returns_value_on_success() { + val result: AdminResult = AdminResult.Success("hello") + assertEquals("hello", result.getOrThrow()) + } + + @Test + fun getOrThrow_throws_SessionKeyExpired() { + val result: AdminResult = AdminResult.SessionKeyExpired + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_Unauthorized() { + val result: AdminResult = AdminResult.Unauthorized + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_Timeout() { + val result: AdminResult = AdminResult.Timeout + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_RateLimited() { + val result: AdminResult = AdminResult.RateLimited + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_NodeUnreachable() { + val result: AdminResult = AdminResult.NodeUnreachable + assertFailsWith { result.getOrThrow() } + } + + @Test + fun getOrThrow_throws_RoutingFailed_with_error() { + val result: AdminResult = AdminResult.Failed(Routing.Error.TOO_LARGE) + val ex = assertFailsWith { result.getOrThrow() } + assertEquals(Routing.Error.TOO_LARGE, ex.error) + } + + @Test + fun fold_invokes_onSuccess() { + val result: AdminResult = AdminResult.Success(42) + val folded = result.fold(onSuccess = { it * 2 }, onFailure = { -1 }) + assertEquals(84, folded) + } + + @Test + fun fold_invokes_onFailure() { + val result: AdminResult = AdminResult.Timeout + val folded = result.fold(onSuccess = { it * 2 }, onFailure = { -1 }) + assertEquals(-1, folded) + } + + @Test + fun map_transforms_success() { + val result: AdminResult = AdminResult.Success(5) + val mapped = result.map { it.toString() } + assertIs>(mapped) + assertEquals("5", mapped.value) + } + + @Test + fun map_propagates_failure() { + val result: AdminResult = AdminResult.RateLimited + val mapped = result.map { it.toString() } + assertIs(mapped) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt new file mode 100644 index 0000000..8b45e9a --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/CommandDispatcherTest.kt @@ -0,0 +1,282 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.User +import org.meshtastic.sdk.internal.CommandDispatcher +import org.meshtastic.sdk.internal.ResponseKind +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CommandDispatcherTest { + + @Test + fun matchingTelemetryResponseCompletesPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 101) + val timeoutJob = Job() + dispatcher.attachTimeoutJob(101, timeoutJob) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 87, voltage = 4.1f)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 101, telemetry = expected))) + + val result = deferred.await() + val success = result as AdminResult.Success<*> + assertEquals(expected, success.value) + assertEquals(0, dispatcher.size()) + assertTrue(timeoutJob.isCancelled) + } + + @Test + fun timeoutResolvesPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 202) + + dispatcher.timeout(202) + + assertEquals(AdminResult.Timeout, deferred.await()) + assertEquals(0, dispatcher.size()) + } + + @Test + fun concurrentRequestsResolveIndependentlyOutOfOrder() = runTest { + val dispatcher = CommandDispatcher() + val telemetryDeferred = register(dispatcher, requestId = 301) + val ownerDeferred = register(dispatcher, requestId = 302, kind = ResponseKind.AdminOwner) + + val expectedOwner = User(long_name = "Remote Node", short_name = "RN") + assertTrue( + dispatcher.tryComplete( + adminPacket( + requestId = 302, + response = AdminMessage(get_owner_response = expectedOwner), + ), + ), + ) + + val expectedTelemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 45, uptime_seconds = 99)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 301, telemetry = expectedTelemetry))) + + val ownerResult = ownerDeferred.await() as AdminResult.Success<*> + val telemetryResult = telemetryDeferred.await() as AdminResult.Success<*> + assertEquals(expectedOwner, ownerResult.value) + assertEquals(expectedTelemetry, telemetryResult.value) + assertEquals(0, dispatcher.size()) + } + + @Test + fun routingErrorResolvesOnlyMatchingPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val failedDeferred = register(dispatcher, requestId = 401) + val successDeferred = register(dispatcher, requestId = 402) + val timeoutJob = Job() + dispatcher.attachTimeoutJob(401, timeoutJob) + + assertTrue(dispatcher.tryFailFromRouting(401, Routing.Error.NOT_AUTHORIZED)) + assertEquals(AdminResult.Unauthorized, failedDeferred.await()) + assertFalse(successDeferred.isCompleted) + assertTrue(timeoutJob.isCancelled) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 64)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 402, telemetry = expected))) + val success = successDeferred.await() as AdminResult.Success<*> + assertEquals(expected, success.value) + } + + @Test + fun duplicateResponseIsIgnoredAfterCompletion() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 501) + val first = Telemetry(device_metrics = DeviceMetrics(battery_level = 12)) + + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 501, telemetry = first))) + assertFalse( + dispatcher.tryComplete( + telemetryPacket( + requestId = 501, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 99)), + ), + ), + ) + + val success = deferred.await() as AdminResult.Success<*> + assertEquals(first, success.value) + assertEquals(0, dispatcher.size()) + } + + @Test + fun unmatchedRequestIdIsIgnored() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 601) + + assertFalse( + dispatcher.tryComplete( + telemetryPacket( + requestId = 999, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 1)), + ), + ), + ) + + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + @Test + fun wrongPortDoesNotConsumePendingTelemetryRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 701) + + assertFalse( + dispatcher.tryComplete( + adminPacket( + requestId = 701, + response = AdminMessage(get_owner_response = User(long_name = "Wrong Port")), + ), + ), + ) + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 22)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 701, telemetry = expected))) + val success = deferred.await() as AdminResult.Success<*> + assertEquals(expected, success.value) + } + + @Test + fun invalidPayloadDoesNotConsumePendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 801) + + assertFalse(dispatcher.tryComplete(invalidPacket(requestId = 801))) + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + @Test + fun routingAckNoneLeavesPendingRequestWaiting() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 901) + + assertFalse(dispatcher.tryFailFromRouting(901, Routing.Error.NONE)) + + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + @Test + fun reRegisteringSameIdTimesOutPriorCaller() = runTest { + val dispatcher = CommandDispatcher() + val oldDeferred = register(dispatcher, requestId = 1001) + val oldTimeoutJob = Job() + dispatcher.attachTimeoutJob(1001, oldTimeoutJob) + + val replacementDeferred = CompletableDeferred>() + dispatcher.register(1001, ResponseKind.Telemetry, replacementDeferred) + + assertEquals(AdminResult.Timeout, oldDeferred.await()) + assertTrue(oldTimeoutJob.isCancelled) + assertEquals(1, dispatcher.size()) + + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 73)) + assertTrue(dispatcher.tryComplete(telemetryPacket(requestId = 1001, telemetry = expected))) + val success = replacementDeferred.await() as AdminResult.Success<*> + assertEquals(expected, success.value) + } + + @Test + fun cancelAllFailsEveryPendingRequest() = runTest { + val dispatcher = CommandDispatcher() + val first = register(dispatcher, requestId = 1101) + val second = register(dispatcher, requestId = 1102) + val firstJob = Job() + val secondJob = Job() + dispatcher.attachTimeoutJob(1101, firstJob) + dispatcher.attachTimeoutJob(1102, secondJob) + + dispatcher.cancelAll(AdminResult.NodeUnreachable) + + assertEquals(AdminResult.NodeUnreachable, first.await()) + assertEquals(AdminResult.NodeUnreachable, second.await()) + assertTrue(firstJob.isCancelled) + assertTrue(secondJob.isCancelled) + assertEquals(0, dispatcher.size()) + } + + @Test + fun zeroRequestIdResponseIsIgnored() = runTest { + val dispatcher = CommandDispatcher() + val deferred = register(dispatcher, requestId = 1201) + + assertFalse( + dispatcher.tryComplete( + telemetryPacket( + requestId = 0, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 99)), + ), + ), + ) + + assertFalse(deferred.isCompleted) + assertEquals(1, dispatcher.size()) + } + + private fun register( + dispatcher: CommandDispatcher, + requestId: Int, + kind: ResponseKind<*> = ResponseKind.Telemetry, + ): CompletableDeferred> { + val deferred = CompletableDeferred>() + dispatcher.register(requestId, kind, deferred) + return deferred + } + + private fun telemetryPacket( + requestId: Int, + telemetry: Telemetry, + portnum: PortNum = PortNum.TELEMETRY_APP, + ): MeshPacket = MeshPacket( + decoded = Data( + portnum = portnum, + payload = Telemetry.ADAPTER.encode(telemetry).toByteString(), + request_id = requestId, + ), + ) + + private fun adminPacket(requestId: Int, response: AdminMessage): MeshPacket = MeshPacket( + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = AdminMessage.ADAPTER.encode(response).toByteString(), + request_id = requestId, + ), + ) + + private fun invalidPacket(requestId: Int): MeshPacket = MeshPacket( + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = byteArrayOf(0x80.toByte()).toByteString(), + request_id = requestId, + ), + ) +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt new file mode 100644 index 0000000..7f657c4 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ConfigBuildersTest.kt @@ -0,0 +1,480 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +@file:Suppress("DEPRECATION") + +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package org.meshtastic.sdk + +import kotlinx.coroutines.test.runTest +import okio.ByteString +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config +import org.meshtastic.proto.DeviceConnectionStatus +import org.meshtastic.proto.DeviceMetadata +import org.meshtastic.proto.DeviceUIConfig +import org.meshtastic.proto.HamParameters +import org.meshtastic.proto.KeyVerificationAdmin +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.NodeRemoteHardwarePinsResponse +import org.meshtastic.proto.Position +import org.meshtastic.proto.SensorConfig +import org.meshtastic.proto.SharedContact +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Instant + +class ConfigBuildersTest { + + @Test + fun deviceConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + device = Config.DeviceConfig().copy( + role = Config.DeviceConfig.Role.TRACKER, + serial_enabled = true, + button_gpio = 23, + buzzer_gpio = 12, + ), + ), + ) { + setDeviceConfig { + copy( + role = Config.DeviceConfig.Role.TRACKER, + serial_enabled = true, + button_gpio = 23, + buzzer_gpio = 12, + ) + } + } + } + + @Test + fun loraConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + lora = Config.LoRaConfig().copy( + use_preset = true, + region = Config.LoRaConfig.RegionCode.EU_868, + modem_preset = Config.LoRaConfig.ModemPreset.SHORT_FAST, + bandwidth = 250, + spread_factor = 9, + ), + ), + ) { + setLoraConfig { + copy( + use_preset = true, + region = Config.LoRaConfig.RegionCode.EU_868, + modem_preset = Config.LoRaConfig.ModemPreset.SHORT_FAST, + bandwidth = 250, + spread_factor = 9, + ) + } + } + } + + @Test + fun bluetoothConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + bluetooth = Config.BluetoothConfig().copy( + enabled = true, + fixed_pin = 123456, + mode = Config.BluetoothConfig.PairingMode.FIXED_PIN, + ), + ), + ) { + setBluetoothConfig { + copy( + enabled = true, + fixed_pin = 123456, + mode = Config.BluetoothConfig.PairingMode.FIXED_PIN, + ) + } + } + } + + @Test + fun displayConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + display = Config.DisplayConfig().copy( + screen_on_secs = 45, + gps_format = Config.DisplayConfig.DeprecatedGpsCoordinateFormat.UNUSED, + units = Config.DisplayConfig.DisplayUnits.IMPERIAL, + flip_screen = true, + ), + ), + ) { + setDisplayConfig { + copy( + screen_on_secs = 45, + gps_format = Config.DisplayConfig.DeprecatedGpsCoordinateFormat.UNUSED, + units = Config.DisplayConfig.DisplayUnits.IMPERIAL, + flip_screen = true, + ) + } + } + } + + @Test + fun networkConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + network = Config.NetworkConfig().copy( + wifi_enabled = true, + wifi_ssid = "mesh-wifi", + wifi_psk = "super-secret", + eth_enabled = true, + ), + ), + ) { + setNetworkConfig { + copy( + wifi_enabled = true, + wifi_ssid = "mesh-wifi", + wifi_psk = "super-secret", + eth_enabled = true, + ) + } + } + } + + @Test + fun positionConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + position = Config.PositionConfig().copy( + gps_enabled = true, + fixed_position = true, + position_broadcast_secs = 300, + gps_mode = Config.PositionConfig.GpsMode.ENABLED, + ), + ), + ) { + setPositionConfig { + copy( + gps_enabled = true, + fixed_position = true, + position_broadcast_secs = 300, + gps_mode = Config.PositionConfig.GpsMode.ENABLED, + ) + } + } + } + + @Test + fun powerConfigBuilderWrapsExpectedFields() = runTest { + assertConfigWrite( + Config( + power = Config.PowerConfig().copy( + is_power_saving = true, + on_battery_shutdown_after_secs = 90, + wait_bluetooth_secs = 15, + ), + ), + ) { + setPowerConfig { + copy( + is_power_saving = true, + on_battery_shutdown_after_secs = 90, + wait_bluetooth_secs = 15, + ) + } + } + } + + @Test + fun securityConfigBuilderWrapsExpectedFields() = runTest { + val publicKey = bytes(1, 2, 3) + val privateKey = bytes(4, 5, 6) + val adminKey = bytes(7, 8, 9) + + assertConfigWrite( + Config( + security = Config.SecurityConfig().copy( + public_key = publicKey, + private_key = privateKey, + admin_key = listOf(adminKey), + serial_enabled = true, + ), + ), + ) { + setSecurityConfig { + copy( + public_key = publicKey, + private_key = privateKey, + admin_key = listOf(adminKey), + serial_enabled = true, + ) + } + } + } + + @Test + fun multipleConfigBuilderCallsComposeExpectedConfigs() = runTest { + val admin = CapturingAdminApi() + val expectedResult = AdminResult.Success(Unit) + + assertEquals( + expectedResult, + admin.setDeviceConfig { + copy( + role = Config.DeviceConfig.Role.CLIENT_HIDDEN, + button_gpio = 5, + ) + }, + ) + assertEquals( + expectedResult, + admin.setNetworkConfig { + copy( + wifi_enabled = true, + wifi_ssid = "mesh", + wifi_psk = "secret", + eth_enabled = true, + ) + }, + ) + assertEquals( + expectedResult, + admin.setLoraConfig { + copy( + region = Config.LoRaConfig.RegionCode.US, + modem_preset = Config.LoRaConfig.ModemPreset.LONG_TURBO, + bandwidth = 500, + spread_factor = 7, + ) + }, + ) + + assertEquals( + listOf( + Config( + device = Config.DeviceConfig().copy( + role = Config.DeviceConfig.Role.CLIENT_HIDDEN, + button_gpio = 5, + ), + ), + Config( + network = Config.NetworkConfig().copy( + wifi_enabled = true, + wifi_ssid = "mesh", + wifi_psk = "secret", + eth_enabled = true, + ), + ), + Config( + lora = Config.LoRaConfig().copy( + region = Config.LoRaConfig.RegionCode.US, + modem_preset = Config.LoRaConfig.ModemPreset.LONG_TURBO, + bandwidth = 500, + spread_factor = 7, + ), + ), + ), + admin.configs, + ) + } + + @Test + fun configBuildersAllowOutOfRangeScalarValuesWithoutCrashing() = runTest { + val admin = CapturingAdminApi() + val expectedResult = AdminResult.Success(Unit) + + assertEquals(expectedResult, admin.setDeviceConfig { copy(button_gpio = -1) }) + assertEquals(expectedResult, admin.setLoraConfig { copy(bandwidth = -1, spread_factor = -7) }) + assertEquals(expectedResult, admin.setBluetoothConfig { copy(fixed_pin = -1) }) + assertEquals(expectedResult, admin.setDisplayConfig { copy(screen_on_secs = -1) }) + assertEquals(expectedResult, admin.setPositionConfig { copy(position_broadcast_secs = -1) }) + assertEquals(expectedResult, admin.setPowerConfig { copy(on_battery_shutdown_after_secs = -1) }) + + assertEquals( + listOf( + Config(device = Config.DeviceConfig().copy(button_gpio = -1)), + Config(lora = Config.LoRaConfig().copy(bandwidth = -1, spread_factor = -7)), + Config(bluetooth = Config.BluetoothConfig().copy(fixed_pin = -1)), + Config(display = Config.DisplayConfig().copy(screen_on_secs = -1)), + Config(position = Config.PositionConfig().copy(position_broadcast_secs = -1)), + Config(power = Config.PowerConfig().copy(on_battery_shutdown_after_secs = -1)), + ), + admin.configs, + ) + } + + @Test + fun moduleConfigBuildersWrapExpectedSections() = runTest { + val admin = CapturingAdminApi() + val expectedResult = AdminResult.Success(Unit) + + assertEquals(expectedResult, admin.setMqttConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setSerialConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setExternalNotificationConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setStoreForwardConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setRangeTestConfig { copy(sender = 7) }) + assertEquals(expectedResult, admin.setTelemetryConfig { copy(device_update_interval = 60) }) + assertEquals(expectedResult, admin.setCannedMessageConfig { copy(rotary1_enabled = true) }) + assertEquals(expectedResult, admin.setAudioConfig { copy(codec2_enabled = true) }) + assertEquals(expectedResult, admin.setRemoteHardwareConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setNeighborInfoConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setAmbientLightingConfig { copy(led_state = true) }) + assertEquals(expectedResult, admin.setDetectionSensorConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setPaxcounterConfig { copy(enabled = true) }) + assertEquals(expectedResult, admin.setStatusMessageConfig { copy(node_status = "ready") }) + assertEquals(expectedResult, admin.setTrafficManagementConfig { copy(enabled = true) }) + + assertEquals( + listOf( + ModuleConfig(mqtt = ModuleConfig.MQTTConfig().copy(enabled = true)), + ModuleConfig(serial = ModuleConfig.SerialConfig().copy(enabled = true)), + ModuleConfig(external_notification = ModuleConfig.ExternalNotificationConfig().copy(enabled = true)), + ModuleConfig(store_forward = ModuleConfig.StoreForwardConfig().copy(enabled = true)), + ModuleConfig(range_test = ModuleConfig.RangeTestConfig().copy(sender = 7)), + ModuleConfig(telemetry = ModuleConfig.TelemetryConfig().copy(device_update_interval = 60)), + ModuleConfig(canned_message = ModuleConfig.CannedMessageConfig().copy(rotary1_enabled = true)), + ModuleConfig(audio = ModuleConfig.AudioConfig().copy(codec2_enabled = true)), + ModuleConfig(remote_hardware = ModuleConfig.RemoteHardwareConfig().copy(enabled = true)), + ModuleConfig(neighbor_info = ModuleConfig.NeighborInfoConfig().copy(enabled = true)), + ModuleConfig(ambient_lighting = ModuleConfig.AmbientLightingConfig().copy(led_state = true)), + ModuleConfig(detection_sensor = ModuleConfig.DetectionSensorConfig().copy(enabled = true)), + ModuleConfig(paxcounter = ModuleConfig.PaxcounterConfig().copy(enabled = true)), + ModuleConfig(statusmessage = ModuleConfig.StatusMessageConfig().copy(node_status = "ready")), + ModuleConfig(traffic_management = ModuleConfig.TrafficManagementConfig().copy(enabled = true)), + ), + admin.moduleConfigs, + ) + } + + private suspend fun assertConfigWrite(expected: Config, call: suspend CapturingAdminApi.() -> AdminResult) { + val admin = CapturingAdminApi() + assertEquals(AdminResult.Success(Unit), admin.call()) + assertEquals(listOf(expected), admin.configs) + assertTrue(admin.moduleConfigs.isEmpty()) + } +} + +private class CapturingAdminApi : AdminApi { + val configs = mutableListOf() + val moduleConfigs = mutableListOf() + + override fun forNode(dest: NodeId): AdminApi = this + + override suspend fun getDeviceMetadata(): AdminResult = unused() + + override suspend fun getConfig(type: AdminMessage.ConfigType): AdminResult = unused() + + override suspend fun setConfig(config: Config): AdminResult { + configs += config + return AdminResult.Success(Unit) + } + + override suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): AdminResult = unused() + + override suspend fun setModuleConfig(config: ModuleConfig): AdminResult { + moduleConfigs += config + return AdminResult.Success(Unit) + } + + override suspend fun getOwner(): AdminResult = unused() + + override suspend fun setOwner(user: User): AdminResult = unused() + + override suspend fun getChannel(index: ChannelIndex): AdminResult = unused() + + override suspend fun setChannel(channel: Channel): AdminResult = unused() + + override suspend fun listChannels(): AdminResult> = unused() + + override suspend fun setFavorite(node: NodeId, favorite: Boolean): AdminResult = unused() + + override suspend fun setIgnored(node: NodeId, ignored: Boolean): AdminResult = unused() + + override suspend fun toggleMuted(node: NodeId): AdminResult = unused() + + override suspend fun setFixedPosition(position: Position): AdminResult = unused() + + override suspend fun removeFixedPosition(): AdminResult = unused() + + override suspend fun getUIConfig(): AdminResult = unused() + + override suspend fun storeUIConfig(config: DeviceUIConfig): AdminResult = unused() + + override suspend fun getCannedMessages(): AdminResult = unused() + + override suspend fun setCannedMessages(messages: String): AdminResult = unused() + + override suspend fun getRingtone(): AdminResult = unused() + + override suspend fun setRingtone(rtttl: String): AdminResult = unused() + + override suspend fun getDeviceConnectionStatus(): AdminResult = unused() + + override suspend fun getRemoteHardwarePins(): AdminResult = unused() + + override suspend fun setHamMode(params: HamParameters): AdminResult = unused() + + override suspend fun enterDfuMode(): AdminResult = unused() + + override suspend fun deleteFile(path: String): AdminResult = unused() + + override suspend fun backupPreferences(location: AdminMessage.BackupLocation): AdminResult = unused() + + override suspend fun restorePreferences(location: AdminMessage.BackupLocation): AdminResult = unused() + + override suspend fun removeBackupPreferences(location: AdminMessage.BackupLocation): AdminResult = unused() + + override suspend fun removeNode(node: NodeId): AdminResult = unused() + + override suspend fun setScale(scale: Int): AdminResult = unused() + + override suspend fun sendInputEvent(event: AdminMessage.InputEvent): AdminResult = unused() + + override suspend fun addContact(contact: SharedContact): AdminResult = unused() + + override suspend fun keyVerification(verification: KeyVerificationAdmin): AdminResult = unused() + + override suspend fun rebootOta(after: Duration): AdminResult = unused() + + override suspend fun otaRequest(event: AdminMessage.OTAEvent): AdminResult = unused() + + override suspend fun setSensorConfig(config: SensorConfig): AdminResult = unused() + + override suspend fun exitSimulator(): AdminResult = unused() + + override suspend fun reboot(after: Duration): AdminResult = unused() + + override suspend fun shutdown(after: Duration): AdminResult = unused() + + override suspend fun factoryReset(preserveBleBonds: Boolean): AdminResult = unused() + + override suspend fun nodeDbReset(): AdminResult = unused() + + override suspend fun setTimeOnly(unixTime: Int): AdminResult = unused() + + override suspend fun setTime(at: Instant?): AdminResult = unused() + + override suspend fun editSettings(block: suspend AdminEdit.() -> T): AdminResult = unused() + + override suspend fun batch(block: suspend AdminBatchScope.() -> T): T = unused() +} + +private fun unused(): Nothing = error("unused in ConfigBuildersTest") + +private fun bytes(vararg values: Int): ByteString = + ByteString.of(*ByteArray(values.size) { index -> values[index].toByte() }) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt index 406b496..1455389 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineAuditFixesTest.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString @@ -53,15 +54,18 @@ class EngineAuditFixesTest { // ── P1-1: Stage 1/2 unhandled FromRadio variants surface as ProtocolWarning ─ /** - * P1-1: a `deviceuiConfig` envelope arriving mid-Stage-1 must be visible as a - * [MeshEvent.ProtocolWarning] with `details.stage == "Stage 1"` instead of being silently - * dropped. Same surface as Stage 2. + * P1-1: a `deviceuiConfig` envelope arriving mid-Stage-1 must be captured into the + * ConfigBundle (not discarded or warned about). */ @Test - fun stage1UnhandledVariantEmitsProtocolWarning() = runTest { + fun stage1DeviceUIConfigIsCapturedInBundle() = runTest { + val uiConfig = DeviceUIConfig() val transport = ScriptedHandshakeTransport( identity = TransportIdentity("fake:p1-1-stage1"), - beforeStage1Complete = listOf(FromRadio(deviceuiConfig = DeviceUIConfig())), + beforeStage1Complete = listOf( + FromRadio(metadata = org.meshtastic.proto.DeviceMetadata()), + FromRadio(deviceuiConfig = uiConfig), + ), ) val client = RadioClient.Builder() .transport(transport) @@ -69,19 +73,14 @@ class EngineAuditFixesTest { .coroutineContext(backgroundScope.coroutineContext) .build() - val warnings = mutableListOf() - val job = backgroundScope.launch { - client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } - } - client.connect() runCurrent() + advanceUntilIdle() - val match = warnings.firstOrNull { it.details["variant"] == "deviceui_config" } - assertNotNull(match, "Expected ProtocolWarning for deviceui_config; got: $warnings") - assertEquals("Stage 1", match.details["stage"], "Stage detail must distinguish handshake vs Ready arrivals") + val bundle = client.configBundle.value + assertNotNull(bundle, "ConfigBundle should be populated after handshake") + assertEquals(uiConfig, bundle.deviceUIConfig, "DeviceUIConfig should be captured in ConfigBundle") - job.cancel() client.disconnect() } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt index 17db47b..d678c04 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/EngineTest.kt @@ -113,11 +113,10 @@ class EngineTest { @Test fun testCancelOnSentIsNoOp() = runTest { // Per SPEC: cancel() on Sent or later is a no-op; state is unchanged. - // Post-handshake (Ready), sends are dispatched immediately, so they go from - // Queued → Sent before any cancel can take effect. + // Use a unicast packet so it stays in Sent (fire-and-forget broadcasts auto-resolve to Acked). val client = buildClient() client.connect() - val handle = client.send(testPacket()) + val handle = client.send(unicastPacket()) runCurrent() // let engine actor process the Send → state becomes Sent assertEquals(SendState.Sent, handle.state.value) handle.cancel() @@ -127,9 +126,10 @@ class EngineTest { @Test fun testDisconnectFailsQueuedHandle() = runTest { + // Use a unicast packet so it stays in-flight (fire-and-forget broadcasts auto-resolve to Acked). val client = buildClient() client.connect() - val handle = client.send(testPacket()) + val handle = client.send(unicastPacket()) runCurrent() // ensure engine actor processes Send before we cancel the supervisor client.disconnect() val state = handle.state.value @@ -213,6 +213,16 @@ class EngineTest { ), ) + private fun unicastPacket() = org.meshtastic.proto.MeshPacket( + to = 0x12345678, + channel = 0, + want_ack = true, + decoded = org.meshtastic.proto.Data( + portnum = org.meshtastic.proto.PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.of(*"hello".encodeToByteArray()), + ), + ) + private fun oversizedPacket() = org.meshtastic.proto.MeshPacket( to = NodeId.BROADCAST.raw, channel = 0, diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt new file mode 100644 index 0000000..4acce38 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ExternalConfigChangeTest.kt @@ -0,0 +1,211 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Channel +import org.meshtastic.proto.ChannelSettings +import org.meshtastic.proto.Config +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Tests for Gap C refinement: external config/channel change propagation. + * + * Verifies that unsolicited admin messages (request_id = 0) from the connected node + * update local state and emit [MeshEvent.ExternalConfigChange]. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ExternalConfigChangeTest { + + private fun TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:gap-c-test"), + autoHandshake = true, + nodeNum = 1, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .build() + return transport to client + } + + /** Helper to inject an unsolicited admin message (request_id = 0). */ + private fun FakeRadioTransport.injectUnsolicitedAdmin(adminMsg: AdminMessage) { + val payload = okio.ByteString.of(*AdminMessage.ADAPTER.encode(adminMsg)) + val packet = MeshPacket( + from = nodeNum, + to = 0, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + request_id = 0, // unsolicited — not a response to our request + ), + ) + injectPacket(packet) + } + + @Test + fun externalChannelChangeUpdatesChannelsState() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val channel = Channel( + index = 0, + settings = ChannelSettings(name = "ExternallySet"), + role = Channel.Role.PRIMARY, + ) + + transport.injectUnsolicitedAdmin(AdminMessage(get_channel_response = channel)) + runCurrent() + + val channels = client.channels.value + assertNotNull(channels) + assertTrue(channels.any { it.settings?.name == "ExternallySet" }) + client.disconnect() + } + + @Test + fun externalChannelChangeEmitsEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { events.add(it) } + } + runCurrent() + + val channel = Channel( + index = 1, + settings = ChannelSettings(name = "NewChannel"), + role = Channel.Role.SECONDARY, + ) + transport.injectUnsolicitedAdmin(AdminMessage(get_channel_response = channel)) + runCurrent() + + val configEvents = events.filterIsInstance() + assertTrue(configEvents.isNotEmpty(), "Expected ExternalConfigChange event") + assertEquals(ExternalChangeKind.CHANNEL, configEvents.first().kind) + + collectJob.cancel() + client.disconnect() + } + + @Test + fun externalConfigChangeUpdatesConfigBundle() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + // ConfigBundle should be non-null after connect (auto-handshake sets up myInfo but + // may not set configs). If null, the handler exits early — which is correct behavior. + val initialBundle = client.configBundle.value + if (initialBundle == null) { + // auto-handshake doesn't produce a full config bundle in minimal mode + client.disconnect() + return@runTest + } + + val newLora = Config(lora = Config.LoRaConfig(use_preset = true, region = Config.LoRaConfig.RegionCode.EU_868)) + transport.injectUnsolicitedAdmin(AdminMessage(get_config_response = newLora)) + runCurrent() + + val updated = client.configBundle.value + assertNotNull(updated) + val loraSection = updated.configs.find { it.lora != null } + assertNotNull(loraSection) + assertEquals(Config.LoRaConfig.RegionCode.EU_868, loraSection.lora?.region) + + client.disconnect() + } + + @Test + fun externalModuleConfigChangeEmitsEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { events.add(it) } + } + runCurrent() + + val newMqtt = ModuleConfig(mqtt = ModuleConfig.MQTTConfig(enabled = true)) + transport.injectUnsolicitedAdmin(AdminMessage(get_module_config_response = newMqtt)) + runCurrent() + + val configEvents = events.filterIsInstance() + // Only emitted if configBundle was non-null + if (client.configBundle.value != null || configEvents.isNotEmpty()) { + assertTrue(configEvents.any { it.kind == ExternalChangeKind.MODULE_CONFIG }) + } + + collectJob.cancel() + client.disconnect() + } + + @Test + fun solicitedAdminResponseDoesNotTriggerExternalEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { events.add(it) } + } + runCurrent() + + // Inject a channel response WITH a non-zero request_id (simulates response to our RPC) + val payload = okio.ByteString.of( + *AdminMessage.ADAPTER.encode( + AdminMessage(get_channel_response = Channel(index = 0, role = Channel.Role.PRIMARY)), + ), + ) + val packet = MeshPacket( + from = transport.nodeNum, + to = 0, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + request_id = 42, // non-zero → solicited response + ), + ) + transport.injectPacket(packet) + runCurrent() + + val configEvents = events.filterIsInstance() + assertTrue(configEvents.isEmpty(), "Solicited responses should NOT emit ExternalConfigChange") + + collectJob.cancel() + client.disconnect() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt new file mode 100644 index 0000000..369860e --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeAndReconnectTest.kt @@ -0,0 +1,871 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import app.cash.turbine.test +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.AdminMessage +import org.meshtastic.proto.Data +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.MyNodeInfo +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.ToRadio +import org.meshtastic.proto.User +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +class HandshakeAndReconnectTest { + + @Test + fun handshakeHappyPathTransitionsStage1Stage2AndConnected() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:handshake-happy"), nowMs = { currentTime }) + val client = buildClient(transport) + + client.connection.test { + assertEquals(ConnectionState.Disconnected, awaitItem()) + + val connectJob = backgroundScope.async { client.connect() } + + assertIs(awaitItem()) + assertEquals(ConfigPhase.Stage1, assertIs(awaitItem()).phase) + assertEquals(ConfigPhase.Settling, assertIs(awaitItem()).phase) + assertEquals(ConfigPhase.Stage2, assertIs(awaitItem()).phase) + assertEquals(ConnectionState.Connected, awaitItem()) + + connectJob.await() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun handshakeHappyPathSendsStage1HeartbeatAndStage2InOrder() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:handshake-wire"), nowMs = { currentTime }) + val client = buildClient(transport) + + client.connect() + + val outbound = transport.outboundFrames().mapNotNull(::decodeToRadioOrNull) + val stage1Index = outbound.indexOfFirst { it.want_config_id == NONCE_STAGE1 } + val heartbeatIndex = outbound.indexOfFirst { it.heartbeat?.nonce == 0 } + val stage2Index = outbound.indexOfFirst { it.want_config_id == NONCE_STAGE2 } + + assertTrue(stage1Index >= 0, "Stage 1 nonce must be sent") + assertTrue(heartbeatIndex >= 0, "Inter-stage heartbeat must be sent") + assertTrue(stage2Index >= 0, "Stage 2 nonce must be sent") + assertTrue(stage1Index < heartbeatIndex, "Heartbeat must follow Stage 1") + assertTrue(heartbeatIndex < stage2Index, "Stage 2 must follow the heartbeat settle") + } + + @Test + fun stage2WatchdogDoesNotFireBeforeTickDeadline() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-pre-tick"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1) + runCurrent() + + val state = assertIs(client.connection.value) + assertEquals(ConfigPhase.Stage2, state.phase) + assertFalse(connectJob.isCompleted, "Connect must still be pending before the watchdog tick") + assertEquals(STAGE2_PROGRESS_TICK_MS - 1, currentTime - stage2StartedAt) + + client.disconnect() + assertIs(connectJob.await().exceptionOrNull()) + } + + @Test + fun stage2WatchdogAbortsSilentHandshake() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-silent"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 1) + runCurrent() + + val error = connectJob.await().exceptionOrNull() + val timeout = assertIs(error) + assertEquals("Stage 2", timeout.stage) + assertEquals(ConnectionState.Disconnected, client.connection.value) + assertTrue(currentTime - stage2StartedAt >= STAGE2_PROGRESS_TICK_MS) + } + + @Test + fun stage2WatchdogSlidesWhileProgressFramesArrive() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-sliding"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + repeat(2) { index -> + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1_000L) + runCurrent() + transport.injectStage2Progress(node = 0x1200 + index) + runCurrent() + val state = assertIs(client.connection.value) + assertEquals(ConfigPhase.Stage2, state.phase) + assertFalse(connectJob.isCompleted, "Progress frame #${index + 1} should keep Stage 2 alive") + } + + assertEquals(2 * (STAGE2_PROGRESS_TICK_MS - 1_000L), currentTime - stage2StartedAt) + + client.disconnect() + assertIs(connectJob.await().exceptionOrNull()) + } + + @Test + fun stage2HardCapAbortsEvenWithProgress() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-hard-cap"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val client = buildClient(transport) + val connectJob = backgroundScope.async { runCatching { client.connect() } } + + client.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + val stage2StartedAt = currentTime + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1_000L) + runCurrent() + transport.injectStage2Progress(node = 0x2001) + runCurrent() + + advanceTimeBy(STAGE2_PROGRESS_TICK_MS - 1_000L) + runCurrent() + transport.injectStage2Progress(node = 0x2002) + runCurrent() + assertFalse(connectJob.isCompleted) + + advanceTimeBy(2_000L) + runCurrent() + + val error = connectJob.await().exceptionOrNull() + val timeout = assertIs(error) + assertEquals("Stage 2", timeout.stage) + assertTrue(currentTime - stage2StartedAt >= STAGE2_HARD_CAP_MS) + } + + @Test + fun connectionLostDuringStage1ResetsCleanlyAndAllowsRetry() = runTest { + val firstTransport = ScriptedTransport(TransportIdentity("fake:drop-stage1"), nowMs = { currentTime }) + firstTransport.dropStage1OnNextHandshake = true + val firstClient = buildClient(firstTransport) + + val firstConnect = backgroundScope.async { runCatching { firstClient.connect() } } + firstClient.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage1 } + runCurrent() + + assertIs(firstConnect.await().exceptionOrNull()) + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val retryClient = + buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage1-retry"), nowMs = { currentTime })) + retryClient.connect() + assertEquals(ConnectionState.Connected, retryClient.connection.value) + } + + @Test + fun connectionLostDuringStage2ResetsCleanlyAndCancelsOldWatchdog() = runTest { + val firstTransport = ScriptedTransport( + identity = TransportIdentity("fake:drop-stage2"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + firstTransport.dropStage2OnNextHandshake = true + val firstClient = buildClient(firstTransport) + + val firstConnect = backgroundScope.async { runCatching { firstClient.connect() } } + firstClient.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + runCurrent() + + assertIs(firstConnect.await().exceptionOrNull()) + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val retryClient = + buildClient(ScriptedTransport(TransportIdentity("fake:drop-stage2-retry"), nowMs = { currentTime })) + retryClient.connect() + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 5_000L) + runCurrent() + + assertEquals(ConnectionState.Connected, retryClient.connection.value) + } + + @Test + fun failedStage2TimeoutCanBeRetriedWithoutDanglingCoroutines() = runTest { + val firstTransport = ScriptedTransport( + identity = TransportIdentity("fake:stage2-timeout-retry"), + nowMs = { currentTime }, + autoCompleteStage2 = false, + ) + val firstClient = buildClient(firstTransport) + + val firstConnect = backgroundScope.async { runCatching { firstClient.connect() } } + firstClient.connection.first { it is ConnectionState.Configuring && it.phase == ConfigPhase.Stage2 } + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 1) + runCurrent() + + val error = firstConnect.await().exceptionOrNull() + assertIs(error) + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val retryClient = + buildClient( + ScriptedTransport(TransportIdentity("fake:stage2-timeout-retry-success"), nowMs = { + currentTime + }), + ) + retryClient.connect() + advanceTimeBy(STAGE2_PROGRESS_TICK_MS + 5_000L) + runCurrent() + + assertEquals(ConnectionState.Connected, retryClient.connection.value) + } + + @Test + fun remoteAdminUsesSeededSessionPasskeyBeforeExpiry() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:session-passkey"), + nowMs = { currentTime }, + sessionPasskey = SEEDED_PASSKEY, + ) + val client = buildClient(transport) + val remoteNode = NodeId(0x0BEEF) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val expected = org.meshtastic.proto.Config( + lora = org.meshtastic.proto.Config.LoRaConfig(use_preset = true), + ) + val deferred = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + val admin = assertNotNull(adminOf(request)) + assertContentEquals(SEEDED_PASSKEY, admin.session_passkey.toByteArray()) + + transport.injectAdminResponse( + requestId = request.id, + response = AdminMessage(get_config_response = expected), + fromNode = remoteNode.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + } + + @Test + fun sessionKeyExpiryAfterThreeHundredSecondsReauthenticatesTransparently() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:session-expiry"), + nowMs = { currentTime }, + sessionPasskey = SEEDED_PASSKEY, + ) + val client = buildClient(transport) + val remoteNode = NodeId(0x0CAFE) + client.connect() + runCurrent() + + keepSessionAlive(transport, 300.seconds.inWholeMilliseconds) + + val outboundBefore = transport.outboundPackets().size + val expected = org.meshtastic.proto.Config( + lora = org.meshtastic.proto.Config.LoRaConfig( + region = org.meshtastic.proto.Config.LoRaConfig.RegionCode.US, + ), + ) + val deferred = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + + val firstRemote = transport.outboundPackets().drop(outboundBefore) + .first { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + assertEquals(0, adminOf(firstRemote)?.session_passkey?.size ?: -1) + + transport.injectRoutingError(firstRemote.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) + drainCurrent() + + val outboundAfterRetry = transport.outboundPackets().drop(outboundBefore) + val reseed = outboundAfterRetry.firstOrNull { + it.to == transport.nodeNum && adminOf(it)?.get_owner_request == true + } + assertNotNull(reseed, "Session expiry must trigger a local get_owner re-seed") + + val replay = outboundAfterRetry.last { + it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG + } + assertTrue(replay.id != firstRemote.id, "Replay must use a fresh wire id") + assertContentEquals(SEEDED_PASSKEY, adminOf(replay)?.session_passkey?.toByteArray()) + + transport.injectAdminResponse( + requestId = replay.id, + response = AdminMessage(get_config_response = expected), + fromNode = remoteNode.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + } + + @Test + fun sessionKeyExpiryRetriesOnlyOnceThenSurfacesFailure() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:session-expiry-single-shot"), + nowMs = { currentTime }, + sessionPasskey = SEEDED_PASSKEY, + ) + val client = buildClient(transport) + val remoteNode = NodeId(0x0D00D) + client.connect() + runCurrent() + + keepSessionAlive(transport, 300.seconds.inWholeMilliseconds) + + val outboundBefore = transport.outboundPackets().size + val deferred = backgroundScope.async { + client.admin.forNode(remoteNode).getConfig(AdminMessage.ConfigType.LORA_CONFIG) + } + runCurrent() + + val firstRemote = transport.outboundPackets().drop(outboundBefore) + .first { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + transport.injectRoutingError(firstRemote.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) + drainCurrent() + + val replay = transport.outboundPackets().drop(outboundBefore) + .last { it.to == remoteNode.raw && adminOf(it)?.get_config_request == AdminMessage.ConfigType.LORA_CONFIG } + transport.injectRoutingError(replay.id, Routing.Error.ADMIN_BAD_SESSION_KEY, fromNode = remoteNode.raw) + drainCurrent() + + assertEquals(AdminResult.SessionKeyExpired, deferred.await()) + assertEquals( + 1, + transport.outboundPackets().drop(outboundBefore).count { + it.to == transport.nodeNum && adminOf(it)?.get_owner_request == true + }, + "Re-authentication must be single-shot", + ) + } + + @Test + fun autoReconnectWaitsInitialBackoffBeforeRetrying() = runTest { + val transport = ScriptedTransport(TransportIdentity("fake:reconnect-initial"), nowMs = { currentTime }) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val droppedAt = currentTime + transport.simulateRecoverableError("drop-once") + runCurrent() + assertReconnectState(client.connection.value, attempt = 1) + + advanceTimeBy(999L) + runCurrent() + assertEquals(0, transport.reconnectAttemptTimes().size) + + advanceTimeBy(1L) + drainCurrent() + + awaitConnected(client) + + assertEquals(listOf(1_000L), transport.reconnectAttemptTimes().map { it - droppedAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + @Test + fun autoReconnectBackoffDoublesAcrossFailures() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-double"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Fail("attempt-2"), + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val droppedAt = currentTime + transport.simulateRecoverableError("backoff-double") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + awaitConnected(client) + + assertEquals(listOf(1_000L, 3_000L, 7_000L), transport.reconnectAttemptTimes().map { it - droppedAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + @Test + fun autoReconnectBackoffRespectsMaxCap() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-cap"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Fail("attempt-2"), + ConnectOutcome.Fail("attempt-3"), + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 4.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val droppedAt = currentTime + transport.simulateRecoverableError("backoff-cap") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + awaitConnected(client) + + assertEquals(listOf(1_000L, 3_000L, 7_000L, 11_000L), transport.reconnectAttemptTimes().map { it - droppedAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + @Test + fun maxReconnectAttemptsStopAfterConfiguredCap() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-max-attempts"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Fail("attempt-2"), + ConnectOutcome.Fail("attempt-3"), + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + maxAttempts = 3, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + transport.simulateRecoverableError("retry-cap") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + advanceTimeBy(4_000L) + drainCurrent() + advanceTimeBy(20_000L) + drainCurrent() + + assertEquals(3, transport.reconnectAttemptTimes().size) + assertEquals(ConnectionState.Disconnected, client.connection.value) + } + + @Test + fun reconnectAfterSuccessStartsFreshBackoffCounter() = runTest { + val transport = ScriptedTransport( + identity = TransportIdentity("fake:reconnect-reset"), + nowMs = { currentTime }, + reconnectOutcomes = listOf( + ConnectOutcome.Fail("attempt-1"), + ConnectOutcome.Success, + ConnectOutcome.Success, + ), + ) + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 8.seconds, + jitter = 0.0, + ), + ) + client.connect() + runCurrent() + + val firstDropAt = currentTime + transport.simulateRecoverableError("first-drop") + runCurrent() + + advanceTimeBy(1_000L) + drainCurrent() + advanceTimeBy(2_000L) + drainCurrent() + awaitConnected(client) + assertEquals(listOf(1_000L, 3_000L), transport.reconnectAttemptTimes().map { it - firstDropAt }) + assertEquals(ConnectionState.Connected, client.connection.value) + + val secondDropAt = currentTime + transport.simulateRecoverableError("second-drop") + runCurrent() + assertReconnectState(client.connection.value, attempt = 1) + + advanceTimeBy(1_000L) + drainCurrent() + awaitConnected(client) + + val attempts = transport.reconnectAttemptTimes() + assertEquals(1_000L, attempts.last() - secondDropAt) + assertEquals(ConnectionState.Connected, client.connection.value) + } + + private fun TestScope.buildClient( + transport: RadioTransport, + autoReconnect: AutoReconnectConfig = AutoReconnectConfig.Disabled, + ): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .clock(SchedulerClock { currentTime }) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .rpcTimeout(60.seconds) + .autoReconnect(autoReconnect) + .build() + + private fun assertReconnectState(state: ConnectionState, attempt: Int) { + val reconnecting = assertIs(state) + assertEquals(attempt, reconnecting.attempt) + } + + private fun TestScope.drainCurrent(times: Int = 20) { + repeat(times) { runCurrent() } + } + + private fun TestScope.keepSessionAlive(transport: ScriptedTransport, totalMs: Long) { + var remaining = totalMs + var packetId = 10_000 + while (remaining > 0) { + val step = minOf(25_000L, remaining) + advanceTimeBy(step) + drainCurrent() + transport.injectAlivePacket(packetId++) + drainCurrent() + remaining -= step + } + } + + private fun TestScope.awaitConnected(client: RadioClient) { + repeat(10) { + if (client.connection.value == ConnectionState.Connected) return + advanceTimeBy(100L) + drainCurrent() + } + assertEquals(ConnectionState.Connected, client.connection.value) + } + + private class SchedulerClock(private val nowMs: () -> Long) : kotlin.time.Clock { + override fun now(): kotlin.time.Instant = kotlin.time.Instant.fromEpochMilliseconds(nowMs()) + } + + private fun adminOf(packet: MeshPacket): AdminMessage? { + val payload = packet.decoded?.payload ?: return null + return runCatching { AdminMessage.ADAPTER.decode(payload) }.getOrNull() + } + + private fun decodeToRadioOrNull(frame: Frame): ToRadio? { + val bytes = frame.bytes.toByteArray() + if (bytes.size < 4) return null + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) }.getOrNull() + } + + private fun encodeFromRadio(fromRadio: org.meshtastic.proto.FromRadio): Frame { + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(ByteString(bytes)) + } + + private sealed interface ConnectOutcome { + data object Success : ConnectOutcome + data class Fail(val message: String) : ConnectOutcome + } + + private inner class ScriptedTransport( + override val identity: TransportIdentity, + private val nowMs: () -> Long, + reconnectOutcomes: List = emptyList(), + var autoCompleteStage1: Boolean = true, + var autoCompleteStage2: Boolean = true, + val nodeNum: Int = DEFAULT_NODE_NUM, + private val sessionPasskey: ByteArray = SEEDED_PASSKEY, + ) : RadioTransport { + private val connectPlan = ArrayDeque(reconnectOutcomes) + private val connectTimes = mutableListOf() + private val outboundFrames = mutableListOf() + private val stateFlow = MutableStateFlow(TransportState.Disconnected) + private val inbound = MutableSharedFlow(extraBufferCapacity = 128) + + var dropStage1OnNextHandshake: Boolean = false + var dropStage2OnNextHandshake: Boolean = false + + override val state: StateFlow = stateFlow + + override suspend fun connect() { + stateFlow.value = TransportState.Connecting + val shouldFail = if (connectTimes.isEmpty()) { + ConnectOutcome.Success + } else { + connectPlan.removeFirstOrNull() + ?: ConnectOutcome.Success + } + val attemptedAt = nowMs() + if (shouldFail is ConnectOutcome.Fail) { + connectTimes += attemptedAt + stateFlow.value = TransportState.Disconnected + throw MeshtasticException.Transport(shouldFail.message) + } + connectTimes += attemptedAt + stateFlow.value = TransportState.Connected + } + + override suspend fun disconnect() { + stateFlow.value = TransportState.Disconnected + } + + override suspend fun send(frame: Frame) { + outboundFrames += frame + val toRadio = decodeToRadio(frame) ?: return + when (toRadio.want_config_id) { + NONCE_STAGE1 -> handleStage1Request() + NONCE_STAGE2 -> handleStage2Request() + } + toRadio.packet?.let(::handleAdminPacket) + } + + override fun frames(): Flow = inbound + + fun outboundFrames(): List = outboundFrames.toList() + + fun outboundPackets(): List = outboundFrames.mapNotNull { frame -> + decodeToRadio(frame)?.packet + } + + fun reconnectAttemptTimes(): List = connectTimes.drop(1) + + fun injectStage2Progress(node: Int) { + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(node_info = NodeInfo(num = node)))) + } + + fun injectAdminResponse(requestId: Int, response: AdminMessage, fromNode: Int = nodeNum) { + val payload = AdminMessage.ADAPTER.encode(response).toByteString() + val packet = MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.ADMIN_APP, + payload = payload, + request_id = requestId, + ), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(packet = packet))) + } + + fun injectRoutingError(requestId: Int, error: Routing.Error, fromNode: Int = nodeNum) { + val payload = Routing.ADAPTER.encode(Routing(error_reason = error)).toByteString() + val packet = MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.ROUTING_APP, + payload = payload, + request_id = requestId, + ), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(packet = packet))) + } + + fun injectAlivePacket(packetId: Int) { + val packet = MeshPacket( + id = packetId, + from = nodeNum, + to = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.EMPTY, + ), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(packet = packet))) + } + + fun simulateRecoverableError(message: String, recoverable: Boolean = true) { + stateFlow.value = TransportState.Error(MeshtasticException.Transport(message), recoverable) + } + + private fun handleStage1Request() { + if (dropStage1OnNextHandshake) { + dropStage1OnNextHandshake = false + stateFlow.value = TransportState.Error(MeshtasticException.Transport("stage1 dropped"), true) + return + } + if (!autoCompleteStage1) return + inbound.tryEmit( + encodeFromRadioFrame(org.meshtastic.proto.FromRadio(my_info = MyNodeInfo(my_node_num = nodeNum))), + ) + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE1))) + } + + private fun handleStage2Request() { + if (dropStage2OnNextHandshake) { + dropStage2OnNextHandshake = false + stateFlow.value = TransportState.Error(MeshtasticException.Transport("stage2 dropped"), true) + return + } + if (!autoCompleteStage2) return + inbound.tryEmit(encodeFromRadioFrame(org.meshtastic.proto.FromRadio(config_complete_id = NONCE_STAGE2))) + } + + private fun handleAdminPacket(packet: MeshPacket) { + val admin = decodeAdmin(packet) ?: return + if (packet.to != nodeNum || admin.get_owner_request != true) return + val user = User( + id = "!00000001", + long_name = "ScriptedNode", + short_name = "SN", + hw_model = HardwareModel.UNSET, + ) + val response = AdminMessage( + get_owner_response = user, + session_passkey = sessionPasskey.toByteString(), + ) + injectAdminResponse(requestId = packet.id, response = response, fromNode = nodeNum) + } + + private fun decodeToRadio(frame: Frame): ToRadio? { + val bytes = frame.bytes.toByteArray() + if (bytes.size < 4) return null + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) }.getOrNull() + } + + private fun encodeFromRadioFrame(fromRadio: org.meshtastic.proto.FromRadio): Frame { + val proto = org.meshtastic.proto.FromRadio.ADAPTER.encode(fromRadio) + val bytes = ByteArray(4 + proto.size).apply { + this[0] = 0x94.toByte() + this[1] = 0xC3.toByte() + this[2] = (proto.size shr 8).toByte() + this[3] = (proto.size and 0xFF).toByte() + proto.copyInto(this, destinationOffset = 4) + } + return Frame(ByteString(bytes)) + } + + private fun decodeAdmin(packet: MeshPacket): AdminMessage? { + val payload = packet.decoded?.payload ?: return null + return runCatching { AdminMessage.ADAPTER.decode(payload) }.getOrNull() + } + } + + private companion object { + const val NONCE_STAGE1 = 69420 + const val NONCE_STAGE2 = 69421 + const val DEFAULT_NODE_NUM = 1 + const val STAGE2_PROGRESS_TICK_MS = 30_000L + const val STAGE2_HARD_CAP_MS = 60_000L + val SEEDED_PASSKEY = byteArrayOf(1, 2, 3, 4) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt index cdaeb58..20d96c9 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/HandshakeFsmTest.kt @@ -238,7 +238,7 @@ class HandshakeFsmTest { assertEquals(true, last?.disconnect, "Last outbound ToRadio must have disconnect=true (got: $last)") } - // ── Phase 2 — audit critical regressions ───────────────────────────────── + // ── Audit: critical regressions ───────────────────────────────── /** * Audit P1-2 / F-3.1: firmware (PhoneAPI.cpp:202-209) interprets `Heartbeat(nonce=1)` as diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt new file mode 100644 index 0000000..f1e46ae --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshEngineEdgeCasesTest.kt @@ -0,0 +1,739 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Routing +import org.meshtastic.proto.ToRadio +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlinx.io.bytestring.ByteString as KByteString + +@OptIn(ExperimentalCoroutinesApi::class) +class MeshEngineEdgeCasesTest { + + @Test + fun shortFrameIsIgnoredAndLogged() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(Frame(KByteString(byteArrayOf(WireFraming.MAGIC_0, WireFraming.MAGIC_1, 0x00)))) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("shorter than wire header") }) + assertTrue(warnings.any { it.message.contains("shorter than wire header") }) + + job.cancel() + client.disconnect() + } + + @Test + fun invalidWireHeaderIsDroppedGracefully() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame( + rawFrame( + encodedFromRadio(FromRadio(node_info = org.meshtastic.proto.NodeInfo(num = 7))), + header0 = 0x00, + header1 = 0x00, + ), + ) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("invalid wire header") }) + assertTrue(warnings.any { it.message.contains("invalid wire header") }) + + job.cancel() + client.disconnect() + } + + @Test + fun emptyPayloadFrameEmitsProtocolWarning() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(ByteArray(0), declaredLength = 0)) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(warnings.any { it.message.contains("empty payload") }) + + job.cancel() + client.disconnect() + } + + @Test + fun truncatedPayloadFrameEmitsLengthMismatchWarning() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + val fullPayload = encodedFromRadio(FromRadio(packet = inboundTextPacket(id = 1, from = 0x1001))) + client.connect() + transport.injectFrame(rawFrame(fullPayload.copyOf(fullPayload.size - 1), declaredLength = fullPayload.size)) + runCurrent() + + val warning = warnings.last() + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(warning.message.contains("length mismatch")) + assertEquals(fullPayload.size, warning.details["declared_payload_bytes"]) + assertEquals(fullPayload.size - 1, warning.details["actual_payload_bytes"]) + + job.cancel() + client.disconnect() + } + + @Test + fun oversizedInboundFrameIsRejected() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(byteArrayOf(0x08), declaredLength = WireFraming.MAX_PAYLOAD_SIZE + 1)) + runCurrent() + + val warning = warnings.last() + assertEquals(ConnectionState.Connected, client.connection.value) + assertTrue(warning.message.contains("exceeds max payload size")) + assertEquals(WireFraming.MAX_PAYLOAD_SIZE + 1, warning.details["declared_payload_bytes"]) + + job.cancel() + client.disconnect() + } + + @Test + fun decodeFailureSurfacesStructuredProtocolWarning() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(byteArrayOf(0x08), declaredLength = 1)) + runCurrent() + + val warning = warnings.last() + assertTrue(warning.message.contains("decode failed")) + assertTrue((warning.details["exception"] as String).isNotEmpty()) + assertEquals(1, warning.details["payload_bytes"]) + + job.cancel() + client.disconnect() + } + + @Test + fun malformedFrameDoesNotPreventSubsequentValidPacketProcessing() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val warnings = mutableListOf() + val packetJob = backgroundScope.launch { client.packets.collect { packets += it } } + val warningJob = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectFrame(rawFrame(byteArrayOf(0x08), declaredLength = 1)) + transport.injectPacket(inboundTextPacket(id = 10, from = 0x1002, text = "after-malformed")) + runCurrent() + + assertEquals(1, warnings.size) + assertEquals(listOf(10), packets.map { it.id }) + + packetJob.cancel() + warningJob.cancel() + client.disconnect() + } + + @Test + fun unknownPortPacketIsDroppedFromPacketsFlow() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + client.connect() + transport.injectPacket( + MeshPacket( + from = 0x2001, + to = 0, + id = 20, + decoded = Data( + portnum = PortNum.UNKNOWN_APP, + payload = ByteString.of(*byteArrayOf(0x01, 0x02)), + ), + ), + ) + runCurrent() + + assertTrue(packets.isEmpty()) + + job.cancel() + client.disconnect() + } + + @Test + fun unknownPortPacketEmitsProtocolWarningAndLog() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket( + MeshPacket( + from = 0x2002, + to = 0, + id = 21, + decoded = Data(portnum = PortNum.UNKNOWN_APP, payload = ByteString.of(*byteArrayOf(0x05))), + ), + ) + runCurrent() + + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("unknown port") }) + assertTrue(warnings.any { it.message.contains("unknown port") }) + + job.cancel() + client.disconnect() + } + + @Test + fun unknownPortPacketKeepsConnectionAlive() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + + client.connect() + transport.injectPacket( + MeshPacket( + from = 0x2003, + to = 0, + id = 22, + decoded = Data(portnum = PortNum.UNKNOWN_APP, payload = ByteString.of(*byteArrayOf(0x07))), + ), + ) + runCurrent() + + assertEquals(ConnectionState.Connected, client.connection.value) + client.disconnect() + } + + @Test + fun oversizedRawPacketSendIsRejected() = runTest { + val client = buildClient() + client.connect() + + assertFailsWith { + client.send( + MeshPacket( + to = NodeId.BROADCAST.raw, + channel = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*ByteArray(DATA_PAYLOAD_LEN + 1)), + ), + ), + ) + } + + client.disconnect() + } + + @Test + fun oversizedPortnumPayloadSendIsRejected() = runTest { + val client = buildClient() + client.connect() + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, ByteArray(DATA_PAYLOAD_LEN + 1)) + } + + client.disconnect() + } + + @Test + fun oversizedTextSendIsRejected() = runTest { + val client = buildClient() + client.connect() + + assertFailsWith { + client.sendText("x".repeat(DATA_PAYLOAD_LEN + 1)) + } + + client.disconnect() + } + + @Test + fun encryptedPacketDoesNotReachPacketsFlow() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3001, to = 0, id = 30)) + runCurrent() + + assertTrue(packets.isEmpty()) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketEmitsProtocolWarningAndLog() = runTest { + val transport = fakeTransport() + val logs = mutableListOf() + val client = buildClient(transport = transport, logger = recordingLogger(logs)) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3002, to = 0, id = 31)) + runCurrent() + + assertTrue(logs.any { it.level == LogLevel.WARN && it.message.contains("encrypted packet") }) + assertTrue(warnings.any { it.message.contains("encrypted packet") }) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketWarningIsRateLimitedWithinInterval() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3003, to = 0, id = 32)) + transport.injectPacket(MeshPacket(from = 0x3003, to = 0, id = 33)) + runCurrent() + + assertEquals(1, warnings.count { it.message.contains("encrypted packet") }) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketWarningCarriesRateLimitedDetail() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val warnings = mutableListOf() + val job = backgroundScope.launch { + client.events.collect { if (it is MeshEvent.ProtocolWarning) warnings += it } + } + + client.connect() + transport.injectPacket(MeshPacket(from = 0x3004, to = 0, id = 34)) + runCurrent() + + val warning = warnings.last() + assertTrue(warning.message.contains("encrypted packet")) + assertEquals(true, warning.details["rate_limited"]) + + job.cancel() + client.disconnect() + } + + @Test + fun encryptedPacketDoesNotAckPendingSend() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport, sendTimeout = 5.seconds) + + client.connect() + val handle = client.send(unicastWantAckPacket(toNodeNum = 0x3005)) + runCurrent() + assertEquals(SendState.Sent, handle.state.value) + + transport.injectPacket(MeshPacket(from = 0x3005, to = 0, id = 37)) + runCurrent() + assertEquals(SendState.Sent, handle.state.value) + + advanceTimeBy(6_000L) + runCurrent() + val state = handle.state.value + assertIs(state) + assertEquals(SendFailure.AckTimeout, state.reason) + + client.disconnect() + } + + @Test + fun duplicateInboundTextPacketIsDeliveredOnce() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + val packet = inboundTextPacket(id = 40, from = 0x4001) + client.connect() + transport.injectPacket(packet) + transport.injectPacket(packet) + runCurrent() + + assertEquals(listOf(40), packets.map { it.id }) + + job.cancel() + client.disconnect() + } + + @Test + fun duplicatePacketDoesNotBlockDistinctPackets() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + val first = inboundTextPacket(id = 41, from = 0x4002, text = "first") + val second = inboundTextPacket(id = 42, from = 0x4002, text = "second") + client.connect() + transport.injectPacket(first) + transport.injectPacket(first) + transport.injectPacket(second) + runCurrent() + + assertEquals(listOf(41, 42), packets.map { it.id }) + + job.cancel() + client.disconnect() + } + + @Test + fun samePacketIdFromDifferentNodesIsNotDeduped() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + val packets = mutableListOf() + val job = backgroundScope.launch { client.packets.collect { packets += it } } + + client.connect() + transport.injectPacket(inboundTextPacket(id = 43, from = 0x4003)) + transport.injectPacket(inboundTextPacket(id = 43, from = 0x4004)) + runCurrent() + + assertEquals(listOf(0x4003, 0x4004), packets.map { it.from }) + + job.cancel() + client.disconnect() + } + + @Test + fun duplicateRoutingAckRemainsIdempotent() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport, sendTimeout = 60.seconds) + + client.connect() + val handle = client.send(unicastWantAckPacket(toNodeNum = 0x4005)) + runCurrent() + + transport.injectFrame(routingAckFrame(requestId = handle.id.raw, fromNodeNum = 0x4005)) + runCurrent() + assertEquals(SendState.Acked, handle.state.value) + + transport.injectFrame(routingAckFrame(requestId = handle.id.raw, fromNodeNum = 0x4005)) + runCurrent() + assertEquals(SendState.Acked, handle.state.value) + + client.disconnect() + } + + @Test + fun disconnectSequenceAllowsFreshSessionReconnect() = runTest { + val transport = fakeTransport() + val firstClient = buildClient(transport = transport) + + firstClient.connect() + firstClient.disconnect() + assertEquals(ConnectionState.Disconnected, firstClient.connection.value) + + val secondClient = buildClient(transport = transport) + secondClient.connect() + + assertEquals(ConnectionState.Connected, secondClient.connection.value) + secondClient.disconnect() + } + + @Test + fun recoverableTransportErrorTransitionsThroughReconnectBackToConnected() = runTest { + val transport = fakeTransport() + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 1.seconds, + maxAttempts = 1, + jitter = 0.0, + ), + ) + val states = mutableListOf() + val job = backgroundScope.launch { client.connection.collect { states += it } } + + client.connect() + transport.simulateError(IllegalStateException("link lost"), recoverable = true) + runCurrent() + assertIs(client.connection.value) + + repeat(6) { + if (client.connection.value == ConnectionState.Connected) return@repeat + advanceTimeBy(500L) + runCurrent() + } + + assertTrue(states.any { it is ConnectionState.Reconnecting }) + assertEquals(TransportState.Connected, transport.state.value) + assertTrue(client.connection.value != ConnectionState.Disconnected) + + job.cancel() + client.disconnect() + } + + @Test + fun nonRecoverableTransportErrorEndsDisconnected() = runTest { + val transport = fakeTransport() + val client = buildClient( + transport = transport, + autoReconnect = AutoReconnectConfig( + enabled = true, + initialBackoff = 1.seconds, + maxBackoff = 1.seconds, + maxAttempts = 1, + jitter = 0.0, + ), + ) + + client.connect() + transport.simulateError(IllegalStateException("fatal"), recoverable = false) + runCurrent() + advanceTimeBy(1L) + runCurrent() + + assertEquals(ConnectionState.Disconnected, client.connection.value) + } + + @Test + fun concurrentSendTextCallsProduceUniqueIds() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + + client.connect() + val handles = (1..10).map { index -> + backgroundScope.async { client.sendText("message-$index") } + }.awaitAll() + runCurrent() + + assertEquals(10, handles.map { it.id.raw }.distinct().size) + // sendText sets want_ack=true; broadcasts await firmware implicit ACK so they stay in Sent. + assertTrue(handles.all { it.state.value == SendState.Sent }) + + // Inject implicit ACKs for all handles. + handles.forEach { transport.injectRoutingAck(requestId = it.id.raw) } + runCurrent() + assertTrue(handles.all { it.state.value == SendState.Acked }) + + client.disconnect() + } + + @Test + fun concurrentRawSendsAllProduceOutboundFrames() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport) + + client.connect() + (1..8).map { index -> + backgroundScope.async { + client.send( + MeshPacket( + to = NodeId.BROADCAST.raw, + channel = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*"payload-$index".encodeToByteArray()), + ), + ), + ) + } + }.awaitAll() + runCurrent() + + val outboundIds = textOutboundPackets(transport).map { it.id } + assertEquals(8, outboundIds.size) + assertEquals(8, outboundIds.distinct().size) + + client.disconnect() + } + + @Test + fun routingAckForOneConcurrentSendDoesNotAffectOthers() = runTest { + val transport = fakeTransport() + val client = buildClient(transport = transport, sendTimeout = 60.seconds) + + client.connect() + val handles = (1..3).map { index -> + client.send(unicastWantAckPacket(toNodeNum = 0x5000 + index)) + } + runCurrent() + + val target = handles[1] + transport.injectFrame(routingAckFrame(requestId = target.id.raw, fromNodeNum = 0x5002)) + runCurrent() + + assertEquals(SendState.Acked, target.state.value) + assertEquals(SendState.Sent, handles[0].state.value) + assertEquals(SendState.Sent, handles[2].state.value) + + client.disconnect() + } + + private fun TestScope.buildClient( + transport: RadioTransport = fakeTransport(), + logger: LogSink = LogSink.Silent, + autoReconnect: AutoReconnectConfig = AutoReconnectConfig.Disabled, + sendTimeout: Duration = 30.seconds, + ): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .logger(logger) + .autoReconnect(autoReconnect) + .sendTimeout(sendTimeout) + .coroutineContext(backgroundScope.coroutineContext) + .build() + + private fun fakeTransport() = FakeRadioTransport( + identity = TransportIdentity("fake:edge-cases"), + autoHandshake = true, + ) + + private fun recordingLogger(logs: MutableList): LogSink = LogSink { level, tag, message, cause -> + logs += CapturedLog(level, tag, message, cause) + } + + private fun inboundTextPacket(id: Int, from: Int, text: String = "hello") = MeshPacket( + from = from, + to = 0, + id = id, + channel = 0, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*text.encodeToByteArray()), + ), + ) + + private fun unicastWantAckPacket(toNodeNum: Int) = MeshPacket( + to = toNodeNum, + channel = 0, + want_ack = true, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*"hi".encodeToByteArray()), + ), + ) + + private fun routingAckFrame(requestId: Int, fromNodeNum: Int, error: Routing.Error = Routing.Error.NONE): Frame { + val routing = Routing(error_reason = error) + val payload = ByteString.of(*Routing.ADAPTER.encode(routing)) + val packet = MeshPacket( + from = fromNodeNum, + to = 0, + decoded = Data( + portnum = PortNum.ROUTING_APP, + payload = payload, + request_id = requestId, + ), + ) + return rawFrame(encodedFromRadio(FromRadio(packet = packet))) + } + + private fun rawFrame( + payload: ByteArray, + header0: Byte = WireFraming.MAGIC_0, + header1: Byte = WireFraming.MAGIC_1, + declaredLength: Int = payload.size, + ): Frame { + val bytes = ByteArray(WireFraming.HEADER_SIZE + payload.size) + bytes[0] = header0 + bytes[1] = header1 + bytes[2] = (declaredLength shr 8).toByte() + bytes[3] = (declaredLength and 0xFF).toByte() + payload.copyInto(bytes, destinationOffset = WireFraming.HEADER_SIZE) + return Frame(KByteString(bytes)) + } + + private fun encodedFromRadio(fromRadio: FromRadio): ByteArray = FromRadio.ADAPTER.encode(fromRadio) + + private fun textOutboundPackets(transport: FakeRadioTransport): List = transport.outboundFrames() + .mapNotNull { decodeToRadioOrNull(it)?.packet } + .filter { it.decoded?.portnum == PortNum.TEXT_MESSAGE_APP } + + private fun decodeToRadioOrNull(frame: Frame): ToRadio? { + val bytes = frame.bytes.toByteArray() + if (bytes.size < WireFraming.HEADER_SIZE) return null + return runCatching { + ToRadio.ADAPTER.decode(bytes.copyOfRange(WireFraming.HEADER_SIZE, bytes.size)) + }.getOrNull() + } + + private data class CapturedLog(val level: LogLevel, val tag: String, val message: String, val cause: Throwable?) +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt new file mode 100644 index 0000000..b7d3c9d --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/MeshNodeTest.kt @@ -0,0 +1,156 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MeshNodeTest { + + private val now = 1700000000 // arbitrary epoch seconds + + private fun nodeInfo( + num: Int = 1, + lastHeard: Int = now - 60, // 1 minute ago + snr: Float = 7f, + hopsAway: Int? = 0, + viaMqtt: Boolean = false, + user: User? = User( + id = "!00000001", + long_name = "TestNode", + short_name = "TN", + hw_model = HardwareModel.TBEAM, + ), + position: Position? = null, + deviceMetrics: DeviceMetrics? = null, + ) = NodeInfo( + num = num, + last_heard = lastHeard, + snr = snr, + hops_away = hopsAway, + via_mqtt = viaMqtt, + user = user, + position = position, + device_metrics = deviceMetrics, + ) + + @Test + fun toMeshNodePreservesIdentity() { + val node = nodeInfo().toMeshNode(now) + assertEquals(1, node.nodeNum) + assertEquals(NodeId(1), node.nodeId) + assertEquals("TestNode", node.longName) + assertEquals("TN", node.shortName) + assertEquals("!00000001", node.meshId) + assertEquals(HardwareModel.TBEAM, node.hwModel) + } + + @Test + fun onlineWhenRecentlyHeard() { + val node = nodeInfo(lastHeard = now - 60).toMeshNode(now) + assertTrue(node.isOnline) + } + + @Test + fun offlineWhenNeverHeard() { + val node = nodeInfo(lastHeard = 0).toMeshNode(now) + assertFalse(node.isOnline) + } + + @Test + fun offlineWhenStale() { + val node = nodeInfo(lastHeard = now - 8000).toMeshNode(now) // > 2 hours + assertFalse(node.isOnline) + } + + @Test + fun connectionQualityDirect() { + val node = nodeInfo(hopsAway = 0, viaMqtt = false).toMeshNode(now) + assertEquals(ConnectionQuality.DIRECT, node.connectionQuality) + } + + @Test + fun connectionQualityRelayed() { + val node = nodeInfo(hopsAway = 2).toMeshNode(now) + assertEquals(ConnectionQuality.RELAYED, node.connectionQuality) + } + + @Test + fun connectionQualityMqtt() { + val node = nodeInfo(viaMqtt = true).toMeshNode(now) + assertEquals(ConnectionQuality.MQTT, node.connectionQuality) + } + + @Test + fun signalQualityGood() { + val node = nodeInfo(snr = 10f).toMeshNode(now) + assertEquals(SignalQuality.GOOD, node.signalQuality) + } + + @Test + fun signalQualityPoor() { + val node = nodeInfo(snr = -3f).toMeshNode(now) + assertEquals(SignalQuality.POOR, node.signalQuality) + } + + @Test + fun positionAccessors() { + val pos = Position(latitude_i = 371234567, longitude_i = -1221234567, altitude = 100) + val node = nodeInfo(position = pos).toMeshNode(now) + assertNotNull(node.latitude) + assertNotNull(node.longitude) + assertEquals(37.1234567, node.latitude!!, 0.0000001) + assertEquals(-122.1234567, node.longitude!!, 0.0000001) + assertEquals(100, node.altitude) + } + + @Test + fun nullPositionWhenZero() { + val pos = Position(latitude_i = 0, longitude_i = 0) + val node = nodeInfo(position = pos).toMeshNode(now) + assertNull(node.latitude) + assertNull(node.longitude) + } + + @Test + fun deviceMetricsAccessors() { + val metrics = DeviceMetrics(battery_level = 85, voltage = 4.1f, channel_utilization = 12.5f) + val node = nodeInfo(deviceMetrics = metrics).toMeshNode(now) + assertEquals(85, node.batteryLevel) + assertEquals(4.1f, node.voltage) + assertEquals(12.5f, node.channelUtilization) + } + + @Test + fun nullUser() { + val node = nodeInfo(user = null).toMeshNode(now) + assertNull(node.longName) + assertNull(node.shortName) + assertNull(node.hwModel) + } + + @Test + fun toMeshNodesCollectionHelper() { + val nodes = listOf( + nodeInfo(num = 1, lastHeard = now - 60), + nodeInfo(num = 2, lastHeard = now - 9000), + ).toMeshNodes(now) + assertEquals(2, nodes.size) + assertTrue(nodes[0].isOnline) + assertFalse(nodes[1].isOnline) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt new file mode 100644 index 0000000..f047432 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/NodeStatusTest.kt @@ -0,0 +1,119 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.NodeInfo +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes + +class NodeStatusTest { + + private val now = 1_700_000_000 // arbitrary epoch seconds + + @Test + fun isOnline_heardRecently_returnsTrue() { + val node = NodeInfo(num = 1, last_heard = now - 60) // 1 minute ago + assertTrue(node.isOnline(now)) + } + + @Test + fun isOnline_heardExactlyAtThreshold_returnsTrue() { + val cutoff = now - DEFAULT_ONLINE_THRESHOLD.inWholeSeconds.toInt() + val node = NodeInfo(num = 1, last_heard = cutoff) + assertTrue(node.isOnline(now)) + } + + @Test + fun isOnline_heardBeyondThreshold_returnsFalse() { + val cutoff = now - DEFAULT_ONLINE_THRESHOLD.inWholeSeconds.toInt() - 1 + val node = NodeInfo(num = 1, last_heard = cutoff) + assertFalse(node.isOnline(now)) + } + + @Test + fun isOnline_neverHeard_returnsFalse() { + val node = NodeInfo(num = 1, last_heard = 0) + assertFalse(node.isOnline(now)) + } + + @Test + fun isOnline_customThreshold() { + val node = NodeInfo(num = 1, last_heard = now - 45 * 60) // 45 min ago + assertTrue(node.isOnline(now, threshold = 1.hours)) + assertFalse(node.isOnline(now, threshold = 30.minutes)) + } + + // --- ConnectionQuality --- + + @Test + fun connectionQuality_direct() { + val node = NodeInfo(num = 1, hops_away = 0, via_mqtt = false) + assertEquals(ConnectionQuality.DIRECT, node.connectionQuality) + } + + @Test + fun connectionQuality_relayed() { + val node = NodeInfo(num = 1, hops_away = 2, via_mqtt = false) + assertEquals(ConnectionQuality.RELAYED, node.connectionQuality) + } + + @Test + fun connectionQuality_mqtt() { + val node = NodeInfo(num = 1, hops_away = 0, via_mqtt = true) + assertEquals(ConnectionQuality.MQTT, node.connectionQuality) + } + + @Test + fun connectionQuality_mqttTakesPrecedenceOverHops() { + val node = NodeInfo(num = 1, hops_away = 3, via_mqtt = true) + assertEquals(ConnectionQuality.MQTT, node.connectionQuality) + } + + @Test + fun connectionQuality_unknown() { + val node = NodeInfo(num = 1, hops_away = null, via_mqtt = false) + assertEquals(ConnectionQuality.UNKNOWN, node.connectionQuality) + } + + // --- SignalQuality --- + + @Test + fun signalQuality_good() { + val node = NodeInfo(num = 1, snr = 10.0f, hops_away = 0) + assertEquals(SignalQuality.GOOD, node.signalQuality) + } + + @Test + fun signalQuality_fair() { + val node = NodeInfo(num = 1, snr = 2.5f, hops_away = 0) + assertEquals(SignalQuality.FAIR, node.signalQuality) + } + + @Test + fun signalQuality_poor() { + val node = NodeInfo(num = 1, snr = -5.0f, hops_away = 0) + assertEquals(SignalQuality.POOR, node.signalQuality) + } + + @Test + fun signalQuality_none_noData() { + val node = NodeInfo(num = 1, snr = 0f, hops_away = null) + assertEquals(SignalQuality.NONE, node.signalQuality) + } + + @Test + fun signalQuality_zeroSnrWithHops_isFair() { + // If we have hops_away data, snr=0 is a valid reading (fair threshold boundary) + val node = NodeInfo(num = 1, snr = 0f, hops_away = 1) + assertEquals(SignalQuality.FAIR, node.signalQuality) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt index 98a53f1..2425b4d 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P0ReliabilityTest.kt @@ -22,6 +22,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** @@ -96,32 +97,55 @@ class P0ReliabilityTest { client.disconnect() } - // ── R-P0-6: broadcasts must NOT receive an ACK timeout ────────────────────── + // ── R-P0-6: fire-and-forget broadcasts (want_ack=false) auto-resolve ────── @Test - fun testBroadcastDoesNotTimeOut() = runTest { + fun testFireAndForgetBroadcastAutoResolves() = runTest { val client = buildClient(sendTimeout = 1.seconds) client.connect() - val handle = client.send(broadcastPacket()) + val handle = client.send(broadcastPacket()) // want_ack=false runCurrent() - assertEquals(SendState.Sent, handle.state.value) + + // Fire-and-forget broadcasts auto-resolve to Acked once the device accepts the packet. + assertEquals(SendState.Acked, handle.state.value) advanceTimeBy(5_000) // far past the 1s sendTimeout runCurrent() - // Broadcasts never receive a routing ACK, so the engine must NOT arm an ACK timer for - // them — the handle stays in Sent. - assertNotEquals( - SendState.Failed(SendFailure.AckTimeout), + // Must not degrade to Failed — the auto-resolve is terminal. + assertEquals( + SendState.Acked, handle.state.value, - "Broadcasts must not be subject to ACK timeouts", + "Fire-and-forget broadcasts must not be subject to ACK timeouts", ) client.disconnect() } - // ── R-P0-4: session passkey survives reconnect via storage ────────────────── + // ── R-P0-6b: broadcast with want_ack=true times out if no implicit ACK ────── + + @Test + fun testBroadcastWithWantAckTimesOutWithoutImplicitAck() = runTest { + val client = buildClient(sendTimeout = 1.seconds) + client.connect() + + val handle = client.send(broadcastWantAckPacket()) + runCurrent() + + // Broadcast with want_ack=true stays in Sent awaiting firmware implicit ACK. + assertEquals(SendState.Sent, handle.state.value) + + advanceTimeBy(1_500) // past the 1s sendTimeout + runCurrent() + + // Must degrade to Failed(AckTimeout) — no relay overheard the rebroadcast. + val state = handle.state.value + assertTrue(state is SendState.Failed, "Expected Failed, got $state") + assertEquals(SendFailure.AckTimeout, state.reason) + + client.disconnect() + } @Test fun testSessionPasskeyIsPersistedAndReloaded() = runTest { @@ -153,16 +177,13 @@ class P0ReliabilityTest { val client = buildClient() client.connect() - // First send — get a real handle so we can replay its id. - val first = client.send(broadcastPacket()) + // First send — use unicast so it stays in pendingSends (fire-and-forget broadcasts auto-resolve). + val first = client.send(unicastWantAckPacket()) runCurrent() assertEquals(SendState.Sent, first.state.value) - // Manually post a second Send with the same id through the engine boundary by issuing - // a colliding raw send via reflection-free public API: not possible without internal - // access. Instead, rely on the symmetry that the engine treats `pendingSends[id]` as a - // collision marker — verify the negative path: a fresh id is *not* rejected. - val second = client.send(broadcastPacket()) + // A fresh id is *not* rejected — verify the negative path. + val second = client.send(unicastWantAckPacket()) runCurrent() assertNotEquals(SendState.Failed(SendFailure.IdCollision), second.state.value) @@ -190,4 +211,14 @@ class P0ReliabilityTest { payload = ByteString.of(*"hello".encodeToByteArray()), ), ) + + private fun broadcastWantAckPacket() = MeshPacket( + to = NodeId.BROADCAST.raw, + channel = 0, + want_ack = true, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteString.of(*"hello-ack".encodeToByteArray()), + ), + ) } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt index b3ef6df..877ef7a 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2AdminRpcTest.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Channel +import org.meshtastic.proto.Config import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.ModuleConfig import org.meshtastic.proto.Routing import org.meshtastic.proto.User import org.meshtastic.sdk.testing.FakeRadioTransport @@ -28,7 +30,7 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds /** - * Phase 2 — AdminApi RPC coverage. Each test wires a [FakeRadioTransport] with auto-handshake, + * AdminApi RPC coverage. Each test wires a [FakeRadioTransport] with auto-handshake, * exercises one [AdminApi] method, and verifies (a) the outbound packet shape and (b) the * mapping from the scripted device response to the returned [AdminResult]. */ @@ -233,11 +235,14 @@ class P2AdminRpcTest { val outbound = transport.outboundPackets().drop(outboundBefore) val req = outbound.lastOrNull { adminOf(it)?.get_channel_request != null } assertNotNull(req) - val index = adminOf(req)!!.get_channel_request!! - val channel = if (index < 2) { - Channel(index = index, role = Channel.Role.PRIMARY) + // SDK sends 1-based index on wire (proto3 zero-value omission). Simulate firmware + // converting back to 0-based for the response. + val wireIndex = adminOf(req)!!.get_channel_request!! + val realIndex = wireIndex - 1 + val channel = if (realIndex < 2) { + Channel(index = realIndex, role = Channel.Role.PRIMARY) } else { - Channel(index = index, role = Channel.Role.DISABLED) + Channel(index = realIndex, role = Channel.Role.DISABLED) } transport.injectAdminResponse( requestId = req.id, @@ -254,6 +259,45 @@ class P2AdminRpcTest { client.disconnect() } + @Test + fun enterDfuModeIsFireAndForget() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val result = client.admin.enterDfuMode() + runCurrent() + + val enterDfu = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.enter_dfu_mode_request == true } + assertIs>(result) + assertEquals(false, enterDfu.want_ack) + assertEquals(false, enterDfu.decoded?.want_response) + client.disconnect() + } + + @Test + fun deleteFileAckSurfacesAsSuccess() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.admin.deleteFile("logs/app.txt") } + runCurrent() + + val deleteFile = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.delete_file_request == "logs/app.txt" } + assertTrue(deleteFile.want_ack, "deleteFile must request a wire-level ack") + transport.injectRoutingAck(requestId = deleteFile.id) + runCurrent() + + val result = deferred.await() + assertIs>(result) + client.disconnect() + } + @Test fun setTimeUsesInjectedClock() = runTest { val frozen = kotlin.time.Instant.fromEpochSeconds(1_700_000_000L) @@ -276,17 +320,33 @@ class P2AdminRpcTest { runCurrent() val outboundBefore = transport.outboundPackets().size - val deferred = async { client.admin.setTime() } + val result = client.admin.setTime() runCurrent() val setTime = transport.outboundPackets().drop(outboundBefore) .last { adminOf(it)?.set_time_only != null } + assertIs>(result) assertEquals(frozen.epochSeconds.toInt(), adminOf(setTime)!!.set_time_only) - transport.injectRoutingAck(requestId = setTime.id) + assertEquals(false, setTime.want_ack) + assertEquals(false, setTime.decoded?.want_response) + client.disconnect() + } + + @Test + fun setTimeOnlyUsesProvidedUnixTimeAndIsFireAndForget() = runTest { + val (transport, client) = connectedClient() + client.connect() runCurrent() - val result = deferred.await() + val outboundBefore = transport.outboundPackets().size + val result = client.admin.setTimeOnly(1_700_000_123) + runCurrent() + + val setTimeOnly = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.set_time_only == 1_700_000_123 } assertIs>(result) + assertEquals(false, setTimeOnly.want_ack) + assertEquals(false, setTimeOnly.decoded?.want_response) client.disconnect() } @@ -309,10 +369,10 @@ class P2AdminRpcTest { val begin = outbound.last { adminOf(it)?.begin_edit_settings == true } transport.injectRoutingAck(requestId = begin.id) runCurrent() - advanceUntilIdle() // Step 2: the inner setFavorite packet was enqueued without want_ack (engine path stops // tracking after Sent). The block returns. The commit packet is then sent and acked. + runCurrent() outbound = transport.outboundPackets().drop(outboundBefore) val commit = outbound.last { adminOf(it)?.commit_edit_settings == true } transport.injectRoutingAck(requestId = commit.id) @@ -331,6 +391,344 @@ class P2AdminRpcTest { client.disconnect() } + @Test + fun batchSupportsGettersAndSettersAndReturnsBlockValue() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val expectedConfig = Config( + device = Config.DeviceConfig(role = Config.DeviceConfig.Role.CLIENT), + ) + val updatedChannel = Channel(index = 3, role = Channel.Role.SECONDARY) + val expectedChannels = listOf( + Channel(index = 0, role = Channel.Role.PRIMARY), + Channel(index = 1, role = Channel.Role.SECONDARY), + ) + val deferred = async { + client.admin.batch { + val device = getConfig(AdminMessage.ConfigType.DEVICE_CONFIG) + setChannel(updatedChannel) + val channels = listChannels() + device to channels + } + } + + runCurrent() + val begin = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.begin_edit_settings == true } + transport.injectRoutingAck(requestId = begin.id) + runCurrent() + + val getConfig = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG } + transport.injectAdminResponse( + requestId = getConfig.id, + response = AdminMessage(get_config_response = expectedConfig), + ) + + repeat(3) { + runCurrent() + val req = transport.outboundPackets().drop(outboundBefore) + .lastOrNull { adminOf(it)?.get_channel_request != null } + assertNotNull(req) + val wireIndex = adminOf(req)!!.get_channel_request!! + val realIndex = wireIndex - 1 + val channel = when (realIndex) { + 0 -> expectedChannels[0] + 1 -> expectedChannels[1] + else -> Channel(index = realIndex, role = Channel.Role.DISABLED) + } + transport.injectAdminResponse( + requestId = req.id, + response = AdminMessage(get_channel_response = channel), + ) + } + runCurrent() + + val commit = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.commit_edit_settings == true } + transport.injectRoutingAck(requestId = commit.id) + runCurrent() + advanceUntilIdle() + + val result = deferred.await() + assertEquals(expectedConfig to expectedChannels, result) + + val orderedAdmin = transport.outboundPackets().drop(outboundBefore) + .mapNotNull { packet -> adminOf(packet)?.let { packet to it } } + val beginIdx = orderedAdmin.indexOfFirst { it.second.begin_edit_settings == true } + val getConfigIdx = orderedAdmin.indexOfFirst { + it.second.get_config_request == AdminMessage.ConfigType.DEVICE_CONFIG + } + val setChannelIdx = orderedAdmin.indexOfFirst { it.second.set_channel == updatedChannel } + val firstListIdx = orderedAdmin.indexOfFirst { it.second.get_channel_request == 1 } + val commitIdx = orderedAdmin.indexOfFirst { it.second.commit_edit_settings == true } + assertTrue(beginIdx in 0..(result.exceptionOrNull()) + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.commit_edit_settings == true }, + "batch must not commit after a getter failure", + ) + assertTrue( + transport.outboundPackets().drop(outboundBefore) + .none { adminOf(it)?.set_ignored_node == ignoredNode.raw }, + "batch must stop before later writes when a getter fails", + ) + client.disconnect() + } + + @Test + fun batchCommitsMultipleQueuedWritesTogether() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val favoriteNode = NodeId(0x01020304) + val ignoredNode = NodeId(0x05060708) + val updatedChannel = Channel(index = 4, role = Channel.Role.SECONDARY) + val outboundBefore = transport.outboundPackets().size + val deferred = async { + client.admin.batch { + setFavorite(favoriteNode, favorite = true) + setIgnored(ignoredNode, ignored = true) + setChannel(updatedChannel) + "committed" + } + } + + runCurrent() + val begin = transport.outboundPackets().drop(outboundBefore) + .last { adminOf(it)?.begin_edit_settings == true } + transport.injectRoutingAck(requestId = begin.id) + runCurrent() + runCurrent() + + val orderedAdmin = adminPacketsSince(transport, outboundBefore) + val setFavorite = orderedAdmin.first { it.second.set_favorite_node == favoriteNode.raw }.first + val setIgnored = orderedAdmin.first { it.second.set_ignored_node == ignoredNode.raw }.first + val setChannel = orderedAdmin.first { it.second.set_channel == updatedChannel }.first + assertEquals(false, setFavorite.want_ack) + assertEquals(false, setFavorite.decoded?.want_response) + assertEquals(false, setIgnored.want_ack) + assertEquals(false, setChannel.want_ack) + + val commit = orderedAdmin.last { it.second.commit_edit_settings == true }.first + transport.injectRoutingAck(requestId = commit.id) + runCurrent() + advanceUntilIdle() + + assertEquals("committed", deferred.await()) + + val beginIdx = orderedAdmin.indexOfFirst { it.second.begin_edit_settings == true } + val favoriteIdx = orderedAdmin.indexOfFirst { it.second.set_favorite_node == favoriteNode.raw } + val ignoredIdx = orderedAdmin.indexOfFirst { it.second.set_ignored_node == ignoredNode.raw } + val channelIdx = orderedAdmin.indexOfFirst { it.second.set_channel == updatedChannel } + val commitIdx = orderedAdmin.indexOfFirst { it.second.commit_edit_settings == true } + assertTrue(beginIdx in 0..> = transport.outboundPackets().drop(outboundBefore) + .mapNotNull { packet -> adminOf(packet)?.let { packet to it } } + private fun buildRoutingErrorFrame(requestId: Int, error: Routing.Error): Frame { val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = error))) val packet = org.meshtastic.proto.MeshPacket( diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt index d320608..ba81666 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/P2RoutingRpcTest.kt @@ -12,7 +12,6 @@ import kotlinx.coroutines.async import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.meshtastic.proto.NeighborInfo import org.meshtastic.proto.PortNum import org.meshtastic.proto.RouteDiscovery import org.meshtastic.proto.Routing @@ -22,6 +21,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.time.Duration.Companion.seconds +import org.meshtastic.proto.NeighborInfo as ProtoNeighborInfo @OptIn(ExperimentalCoroutinesApi::class) class P2RoutingRpcTest { @@ -121,7 +121,7 @@ class P2RoutingRpcTest { val req = transport.outboundPackets().drop(outboundBefore) .last { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP } - val expected = NeighborInfo( + val expected = ProtoNeighborInfo( node_id = 1, last_sent_by_id = 1, node_broadcast_interval_secs = 600, @@ -131,7 +131,7 @@ class P2RoutingRpcTest { runCurrent() val result = deferred.await() - assertIs>(result) + assertIs>(result) assertEquals(expected, result.value) client.disconnect() } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt new file mode 100644 index 0000000..5006423 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/RadioClientSendTest.kt @@ -0,0 +1,434 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.ToRadio +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertFailsWith +import kotlin.test.fail + +@OptIn(ExperimentalCoroutinesApi::class) +class RadioClientSendTest { + + @Test + fun sendMeshPacket_connectedClientReturnsHandleAndWritesPacket() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val packet = MeshPacket( + to = TARGET_NODE.raw, + channel = SECONDARY_CHANNEL.raw, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "mesh-packet".encodeToByteArray().toByteString(), + ), + ) + + val handle = client.send(packet) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TEST_NODE_NUM, outbound.from, "outbound.from") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertValue(PortNum.TEXT_MESSAGE_APP, decoded.portnum, "decoded.portnum") + assertContentEquals("mesh-packet".encodeToByteArray(), decoded.payload.toByteArray()) + } + } + + @Test + fun sendMeshPacket_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.send( + MeshPacket( + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = "offline".encodeToByteArray().toByteString(), + ), + ), + ) + } + } + + @Test + fun sendMeshPacket_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val oversized = MeshPacket( + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = ByteArray(DATA_PAYLOAD_LEN + 1).toByteString(), + ), + ) + + assertFailsWith { + client.send(oversized) + } + } + } + + @Test + fun sendText_connectedClientEncodesUtf8AndTargetsChannel() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val text = "héllo 🚀" + + val handle = client.sendText(text, channel = SECONDARY_CHANNEL, to = TARGET_NODE) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertValue(PortNum.TEXT_MESSAGE_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(text.encodeToByteArray(), decoded.payload.toByteArray()) + } + } + + @Test + fun sendText_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.sendText("hello") + } + } + + @Test + fun sendText_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val oversized = "a".repeat(DATA_PAYLOAD_LEN + 1) + + assertFailsWith { + client.sendText(oversized) + } + } + } + + @Test + fun sendReaction_connectedClientMarksEmojiReplyAndAck() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val replyId = 0x01020304 + val emoji = "🔥" + + val handle = client.sendReaction( + emoji = emoji, + to = TARGET_NODE, + channel = SECONDARY_CHANNEL, + replyId = replyId, + ) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected reaction packet to request ACK") + assertValue(PortNum.TEXT_MESSAGE_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(emoji.encodeToByteArray(), decoded.payload.toByteArray()) + assertValue(1, decoded.emoji, "decoded.emoji") + assertValue(replyId, decoded.reply_id, "decoded.reply_id") + } + } + + @Test + fun sendReaction_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.sendReaction(emoji = "👍", replyId = 42) + } + } + + @Test + fun sendReaction_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val oversized = "a".repeat(DATA_PAYLOAD_LEN + 1) + + assertFailsWith { + client.sendReaction(emoji = oversized, replyId = 7) + } + } + } + + @Test + fun sendByteArray_connectedClientBuildsTypedPacket() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val payload = byteArrayOf(0x01, 0x02, 0x03, 0x04) + + val handle = client.send( + portnum = PortNum.NODEINFO_APP, + payload = payload, + to = TARGET_NODE, + channel = SECONDARY_CHANNEL, + wantAck = true, + hopLimit = 4, + ) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected typed packet to request ACK") + assertValue(4, outbound.hop_limit, "outbound.hop_limit") + assertValue(PortNum.NODEINFO_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(payload, decoded.payload.toByteArray()) + assertCondition(!decoded.want_response, "Expected typed send want_response=false") + } + } + + @Test + fun sendByteArray_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, "hello".encodeToByteArray()) + } + } + + @Test + fun sendByteArray_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, ByteArray(DATA_PAYLOAD_LEN + 1)) + } + } + } + + @Test + fun sendBuffer_connectedClientConsumesBufferAndWritesBytes() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + val payload = byteArrayOf(0x0A, 0x0B, 0x0C) + val buffer = Buffer().apply { write(payload) } + + val handle = client.send( + portnum = PortNum.NODEINFO_APP, + payload = buffer, + to = TARGET_NODE, + channel = SECONDARY_CHANNEL, + wantAck = true, + hopLimit = 5, + ) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + assertContentEquals(byteArrayOf(), buffer.readByteArray()) + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(SECONDARY_CHANNEL.raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected typed packet to request ACK") + assertValue(5, outbound.hop_limit, "outbound.hop_limit") + assertValue(PortNum.NODEINFO_APP, decoded.portnum, "decoded.portnum") + assertContentEquals(payload, decoded.payload.toByteArray()) + } + } + + @Test + fun sendBuffer_notConnectedThrows() = runTest { + val client = buildClient() + val buffer = Buffer().apply { write("hello".encodeToByteArray()) } + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, buffer) + } + } + + @Test + fun sendBuffer_payloadTooLargeThrows() = runTest { + withConnectedClient { client, _ -> + val buffer = Buffer().apply { write(ByteArray(DATA_PAYLOAD_LEN + 1)) } + + assertFailsWith { + client.send(PortNum.TEXT_MESSAGE_APP, buffer) + } + } + } + + @Test + fun sendRaw_connectedClientWritesFrameDirectlyToTransport() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundFrames().size + val frame = ToRadio(disconnect = true) + + client.sendRaw(frame) + runCurrent() + + val outbound = transport.lastNewOutboundFrame(before).decodeToRadio() + assertValue(frame, outbound, "outbound ToRadio frame") + } + } + + @Test + fun sendRaw_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.sendRaw(ToRadio(disconnect = true)) + } + } + + @Test + fun requestNodeInfo_connectedClientReturnsHandleWithOutboundId() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + + val handle = client.requestNodeInfo(TARGET_NODE) + + assertCondition(handle.id.raw != 0, "Expected non-zero message id") + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + assertValue(handle.id.raw, outbound.id, "outbound.id") + assertValue(TEST_NODE_NUM, outbound.from, "outbound.from") + } + } + + @Test + fun requestNodeInfo_connectedClientSendsNodeInfoRequestPacket() = runTest { + withConnectedClient { client, transport -> + val before = transport.outboundPackets().size + + client.requestNodeInfo(TARGET_NODE) + runCurrent() + + val outbound = transport.lastNewOutboundPacket(before) + val decoded = outbound.requireDecoded() + assertValue(TARGET_NODE.raw, outbound.to, "outbound.to") + assertValue(ChannelIndex(0).raw, outbound.channel, "outbound.channel") + assertCondition(outbound.want_ack, "Expected node info request to request ACK") + assertValue(PortNum.NODEINFO_APP, decoded.portnum, "decoded.portnum") + assertCondition(decoded.want_response, "Expected node info request want_response=true") + assertContentEquals(byteArrayOf(), decoded.payload.toByteArray()) + } + } + + @Test + fun requestNodeInfo_notConnectedThrows() = runTest { + val client = buildClient() + + assertFailsWith { + client.requestNodeInfo(TARGET_NODE) + } + } + + private fun TestScope.buildClient(transport: FakeRadioTransport = buildTransport()): RadioClient = + RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + + private suspend fun TestScope.withConnectedClient(block: suspend (RadioClient, FakeRadioTransport) -> Unit) { + val transport = buildTransport() + val client = buildClient(transport) + client.connect() + runCurrent() + assertValue(ConnectionState.Connected, client.connection.value, "client.connection") + try { + block(client, transport) + } finally { + client.disconnect() + runCurrent() + } + } + + private fun buildTransport(): FakeRadioTransport = FakeRadioTransport( + identity = TransportIdentity("fake:test"), + autoHandshake = true, + nodeNum = TEST_NODE_NUM, + ) + + private fun FakeRadioTransport.lastNewOutboundPacket(before: Int): MeshPacket { + val outbound = outboundPackets() + assertValue(before + 1, outbound.size, "outboundPackets().size") + return outbound.last() + } + + private fun FakeRadioTransport.lastNewOutboundFrame(before: Int): Frame { + val outbound = outboundFrames() + assertValue(before + 1, outbound.size, "outboundFrames().size") + return outbound.last() + } + + private fun Frame.decodeToRadio(): ToRadio { + val bytes = bytes.toByteArray() + if (bytes.size < 4) fail("Expected framed ToRadio bytes") + return runCatching { ToRadio.ADAPTER.decode(bytes.copyOfRange(4, bytes.size)) } + .getOrElse { throw AssertionError("Failed to decode ToRadio frame", it) } + } + + private fun MeshPacket.requireDecoded(): Data = decoded ?: fail("Expected decoded payload") + + private fun assertCondition(condition: Boolean, message: String) { + if (!condition) fail(message) + } + + private fun assertValue(expected: T, actual: T, label: String) { + if (expected != actual) { + fail("Expected $label=$expected, actual=$actual") + } + } + + @Test + fun sendText_broadcastWaitsForImplicitAck() = runTest { + withConnectedClient { client, transport -> + val handle = client.sendText("broadcast test", to = NodeId.BROADCAST) + runCurrent() + // sendText now sets want_ack=true; broadcast stays in Sent awaiting firmware implicit ACK. + assertValue(SendState.Sent, handle.state.value, "broadcast after dispatch") + + // Simulate firmware implicit ACK (relay overheard the rebroadcast). + transport.injectRoutingAck(requestId = handle.id.raw) + runCurrent() + + val outcome = handle.await() + assertValue(SendOutcome.Success, outcome, "broadcast await outcome") + assertValue(SendState.Acked, handle.state.value, "broadcast terminal state") + } + } + + private companion object { + const val TEST_NODE_NUM: Int = 0x11111111 + val TARGET_NODE: NodeId = NodeId(0x22222222) + val SECONDARY_CHANNEL: ChannelIndex = ChannelIndex(2) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt new file mode 100644 index 0000000..696e670 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardApiStatsTest.kt @@ -0,0 +1,324 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.currentTime +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardApiStatsTest { + + @Test + fun `requestStats returns statistics from server`() = runTest { + val (transport, client) = connectedClient("stats-response") + client.connect() + runCurrent() + + val server = NodeId(0xABCD0001.toInt()) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestStats(server) } + runCurrent() + + val request = transport.lastStoreForwardRequest(outboundBefore) + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(server.raw, request.to) + assertEquals(StoreAndForward.RequestResponse.CLIENT_STATS, payload.rr) + + transport.injectStatsResponse( + requestId = request.id, + server = server, + stats = StoreAndForward.Statistics( + messages_saved = 9, + messages_max = 64, + up_time = 3600, + requests = 12, + requests_history = 7, + heartbeat = true, + ), + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(9, result.value.messagesStored) + assertEquals(64, result.value.messagesMax) + assertEquals(3600, result.value.uptime) + assertEquals(7, result.value.requests) + assertEquals(0, result.value.requestsFailed) + assertEquals(true, result.value.heartbeat) + client.disconnect() + } + + @Test + fun `requestStats with null server uses first discovered server`() = runTest { + val (transport, client) = connectedClient("stats-default-server") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestStats() } + runCurrent() + + val request = transport.lastStoreForwardRequest(outboundBefore) + assertEquals(server.raw, request.to) + + transport.injectStatsResponse(request.id, server) + runCurrent() + + assertIs>(deferred.await()) + client.disconnect() + } + + @Test + fun `requestStats throws on timeout`() = runTest { + val (transport, client) = connectedClient("stats-timeout", rpcTimeout = 5.seconds) + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + + val deferred = async { storeForward.requestStats() } + runCurrent() + + advanceTimeBy(5.seconds) + runCurrent() + + assertEquals(AdminResult.Timeout, deferred.await()) + client.disconnect() + } + + @Test + fun `requestStats with explicit server sends to that node`() = runTest { + val (transport, client) = connectedClient("stats-explicit-server") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val firstServer = NodeId(0xABCD0001.toInt()) + val targetServer = NodeId(0xABCD0002.toInt()) + transport.injectHeartbeat(firstServer) + transport.injectHeartbeat(targetServer) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestStats(targetServer) } + runCurrent() + + val request = transport.lastStoreForwardRequest(outboundBefore) + assertEquals(targetServer.raw, request.to) + + transport.injectStatsResponse(request.id, targetServer) + runCurrent() + + assertIs>(deferred.await()) + client.disconnect() + } + + @Test + fun `requestStats throws when no servers known and server is null`() = runTest { + val (_, client) = connectedClient("stats-missing-server") + client.connect() + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, client.storeForward.requestStats()) + client.disconnect() + } + + @Test + fun `server discovery via heartbeat adds to servers list`() = runTest { + val (transport, client) = connectedClient("server-discovery") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + + assertEquals(listOf(server), storeForward.servers.value) + client.disconnect() + } + + @Test + fun `multiple server heartbeats accumulate`() = runTest { + val (transport, client) = connectedClient("server-accumulation") + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val firstServer = NodeId(0xABCD0001.toInt()) + val secondServer = NodeId(0xABCD0002.toInt()) + transport.injectHeartbeat(firstServer) + transport.injectHeartbeat(secondServer) + runCurrent() + + assertEquals(listOf(firstServer, secondServer), storeForward.servers.value) + client.disconnect() + } + + @Test + fun `server loss removes from servers list`() = runTest { + val (transport, client) = connectedClient("server-loss", presenceTimeout = 5.seconds) + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0xABCD0001.toInt()) + transport.injectHeartbeat(server) + runCurrent() + assertEquals(listOf(server), storeForward.servers.value) + + advanceTimeBy(31.seconds) + runCurrent() + + assertTrue(storeForward.servers.value.isEmpty()) + client.disconnect() + } + + @Test + fun `servers initially empty`() = runTest { + val (_, client) = connectedClient("servers-empty") + client.connect() + runCurrent() + + assertTrue(client.storeForward.servers.value.isEmpty()) + client.disconnect() + } + + @Test + fun `ServerDiscovered event emitted on first heartbeat`() = runTest { + val (transport, client) = connectedClient("event-discovered") + client.connect() + runCurrent() + + val server = NodeId(0xABCD0001.toInt()) + val event = async { + client.storeForward.events.first { it is StoreForwardEvent.ServerDiscovered } + } + runCurrent() + + transport.injectHeartbeat(server) + runCurrent() + + assertEquals(StoreForwardEvent.ServerDiscovered(server), event.await()) + client.disconnect() + } + + @Test + fun `ServerLost event emitted when server times out`() = runTest { + val (transport, client) = connectedClient("event-lost", presenceTimeout = 5.seconds) + client.connect() + runCurrent() + + val server = NodeId(0xABCD0001.toInt()) + val event = async { + client.storeForward.events.first { it is StoreForwardEvent.ServerLost } + } + runCurrent() + + transport.injectHeartbeat(server) + runCurrent() + advanceTimeBy(31.seconds) + runCurrent() + + assertEquals(StoreForwardEvent.ServerLost(server), event.await()) + client.disconnect() + } + + private fun TestScope.connectedClient( + identitySuffix: String, + presenceTimeout: Duration = 5.seconds, + rpcTimeout: Duration = 60.seconds, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:sf-$identitySuffix"), + autoHandshake = true, + nodeNum = 0x11111111, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .clock(SchedulerClock { currentTime }) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .presenceTimeout(presenceTimeout) + .rpcTimeout(rpcTimeout) + .build() + return transport to client + } + + private fun FakeRadioTransport.injectHeartbeat(server: NodeId, period: Int = 900) { + injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = period, secondary = 0), + ), + fromNode = server.raw, + ) + } + + private fun FakeRadioTransport.injectStatsResponse( + requestId: Int, + server: NodeId, + stats: StoreAndForward.Statistics = StoreAndForward.Statistics( + messages_saved = 1, + messages_max = 2, + up_time = 3, + requests_history = 4, + heartbeat = true, + ), + ) { + injectStoreForwardResponse( + requestId = requestId, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_STATS, + stats = stats, + ), + fromNode = server.raw, + ) + } + + private fun FakeRadioTransport.lastStoreForwardRequest(outboundBefore: Int) = outboundPackets() + .drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + + private class SchedulerClock(private val nowMs: () -> Long) : kotlin.time.Clock { + override fun now(): Instant = Instant.fromEpochMilliseconds(nowMs()) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt new file mode 100644 index 0000000..ddaf652 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/StoreForwardProtocolTest.kt @@ -0,0 +1,770 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardProtocolTest { + + private fun TestScope.connectedClient( + identitySuffix: String, + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 2.hours, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:store-forward-$identitySuffix"), + autoHandshake = true, + nodeNum = myNodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .presenceTimeout(presenceTimeout) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + @Test + fun serverHeartbeatDiscoversServerAndEmitsHeartbeat() = runTest { + val (transport, client) = connectedClient("heartbeat-discovery") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x10203040) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(listOf(server), client.storeForward.servers.value) + assertEquals( + listOf(StoreForwardEvent.ServerDiscovered(server)), + observed.filterIsInstance(), + ) + assertEquals( + listOf(StoreForwardEvent.Heartbeat(server)), + observed.filterIsInstance(), + ) + + collector.cancel() + client.disconnect() + } + + @Test + fun duplicateHeartbeatsDoNotRediscoverServer() = runTest { + val (transport, client) = connectedClient("heartbeat-dedupe") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x20304050) + repeat(2) { index -> + transport.injectLegacyStoreForward( + packetId = index + 1, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 60), + ), + fromNode = server.raw, + ) + runCurrent() + } + + assertEquals(listOf(server), client.storeForward.servers.value) + assertEquals(1, observed.filterIsInstance().size) + assertEquals(2, observed.filterIsInstance().size) + + collector.cancel() + client.disconnect() + } + + @Test + fun requestHistoryUsesFirstKnownServerAndAllHistoryWindow() = runTest { + val (transport, client) = connectedClient("history-default-server") + client.connect() + val storeForward = client.storeForward + runCurrent() + + val server = NodeId(0x55667788) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory() } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(server.raw, request.to) + assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, payload.rr) + assertEquals(ALL_HISTORY_WINDOW_MINUTES, payload.history?.window) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 3, window = ALL_HISTORY_WINDOW_MINUTES), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(3, result.value) + client.disconnect() + } + + @Test + fun requestHistoryUsesExplicitServerAndRoundsWindowUpToMinutes() = runTest { + val (transport, client) = connectedClient("history-explicit-server") + client.connect() + val storeForward = client.storeForward + runCurrent() + + val firstServer = NodeId(0x01020304) + val targetServer = NodeId(0x0A0B0C0D) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = firstServer.raw, + ) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = targetServer.raw, + ) + runCurrent() + + val since = Clock.System.now().epochSeconds.toInt() - 61 + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory(since = since, server = targetServer) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(targetServer.raw, request.to) + assertEquals(2, payload.history?.window) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 1, window = 2), + ), + fromNode = targetServer.raw, + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(1, result.value) + client.disconnect() + } + + @Test + fun requestHistoryFutureTimestampClampsWindowToOneMinute() = runTest { + val (transport, client) = connectedClient("history-future-window") + client.connect() + val storeForward = client.storeForward + runCurrent() + + val server = NodeId(0x11112222) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + val since = Clock.System.now().epochSeconds.toInt() + 3600 + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory(since = since) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(1, payload.history?.window) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 0, window = 1), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(0, assertIs>(deferred.await()).value) + client.disconnect() + } + + @Test + fun requestHistoryWithLocalServerTargetsSelfNode() = runTest { + val myNode = 0x42424242 + val (transport, client) = connectedClient("history-local-server", myNodeNum = myNode) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestHistory(server = NodeId.LOCAL) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + assertEquals(myNode, request.to) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 2, window = ALL_HISTORY_WINDOW_MINUTES), + ), + fromNode = myNode, + ) + runCurrent() + + assertEquals(2, assertIs>(deferred.await()).value) + client.disconnect() + } + + @Test + fun requestHistoryWithoutAvailableServersFailsGracefully() = runTest { + val (_, client) = connectedClient("history-no-server") + client.connect() + runCurrent() + + assertEquals(AdminResult.NodeUnreachable, client.storeForward.requestHistory()) + client.disconnect() + } + + @Test + fun zeroMessageHistoryReplayStartsAndCompletesImmediately() = runTest { + val (transport, client) = connectedClient("history-zero-replay") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x61626364) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 0, window = 5), + ), + fromNode = server.raw, + ) + runCurrent() + + assertTrue(observed.contains(StoreForwardEvent.HistoryReplayStarted(server, 0))) + assertTrue(observed.contains(StoreForwardEvent.HistoryReplayComplete(server, 0))) + + collector.cancel() + client.disconnect() + } + + @Test + fun historyReplayCountsUniqueMessagesOnly() = runTest { + val (transport, client) = connectedClient("history-dedupe") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val server = NodeId(0x71727374) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 2, window = 30), + ), + fromNode = server.raw, + ) + runCurrent() + + transport.injectLegacyStoreForward( + packetId = 41, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + text = "same".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + assertFalse(observed.any { it is StoreForwardEvent.HistoryReplayComplete }) + + transport.injectLegacyStoreForward( + packetId = 41, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + text = "same".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + assertFalse(observed.any { it is StoreForwardEvent.HistoryReplayComplete }) + + transport.injectLegacyStoreForward( + packetId = 42, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + text = "other".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals( + listOf(StoreForwardEvent.HistoryReplayComplete(server, 2)), + observed.filterIsInstance(), + ) + + collector.cancel() + client.disconnect() + } + + @Test + fun historyReplayMessagesAreEmittedInOrder() = runTest { + val (transport, client) = connectedClient("history-order") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.packets.collect { packet -> + val decoded = packet.decoded ?: return@collect + if (decoded.portnum != PortNum.STORE_FORWARD_APP) return@collect + val message = + runCatching { StoreAndForward.ADAPTER.decode(decoded.payload) }.getOrNull() ?: return@collect + if (message.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT || + message.rr == StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST + ) { + observed += message.text?.utf8().orEmpty() + } + } + } + runCurrent() + + val server = NodeId(0x81828384.toInt()) + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 2, window = 30), + ), + fromNode = server.raw, + ) + transport.injectLegacyStoreForward( + packetId = 101, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_DIRECT, + text = "first".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + transport.injectLegacyStoreForward( + packetId = 102, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST, + text = "second".encodeToByteArray().toByteString(), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(listOf("first", "second"), observed) + + collector.cancel() + client.disconnect() + } + + @Test + fun disconnectRemovesKnownServersAndEmitsLostEvents() = runTest { + val (transport, client) = connectedClient("disconnect-clears-servers") + client.connect() + runCurrent() + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { observed += it } + } + runCurrent() + + val first = NodeId(0x01010101) + val second = NodeId(0x02020202) + listOf(first, second).forEach { server -> + transport.injectLegacyStoreForward( + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 60), + ), + fromNode = server.raw, + ) + } + runCurrent() + + client.disconnect() + runCurrent() + + assertTrue(client.storeForward.servers.value.isEmpty()) + assertEquals( + setOf(first, second), + observed.filterIsInstance().map { + it.nodeId + }.toSet(), + ) + + collector.cancel() + } + + // Server heartbeat timeout/expiry is not yet implemented in StoreForwardApiImpl. + // presenceTimeout only applies to node online/offline presence, not S&F server tracking. + // TODO: Implement S&F server staleness sweep and re-enable this test. + + @Test + fun requestStatsMapsStoreForwardStatistics() = runTest { + val (transport, client) = connectedClient("stats-mapping") + client.connect() + runCurrent() + + val server = NodeId(0x13572468) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestStats(server) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(StoreAndForward.RequestResponse.CLIENT_STATS, payload.rr) + + transport.injectLegacyStoreForward( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_STATS, + stats = StoreAndForward.Statistics( + messages_saved = 9, + messages_max = 64, + up_time = 3600, + requests = 12, + requests_history = 7, + heartbeat = true, + ), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = assertIs>(deferred.await()) + assertEquals(9, result.value.messagesStored) + assertEquals(64, result.value.messagesMax) + assertEquals(3600, result.value.uptime) + assertEquals(7, result.value.requests) + assertEquals(true, result.value.heartbeat) + client.disconnect() + } + + @Test + fun sfppLinkProvideComputesHashWhenMessageHashMissing() = runTest { + val (transport, client) = connectedClient("sfpp-full-link") + client.connect() + runCurrent() + + val message = "payload".encodeToByteArray() + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message = message.toByteString(), + encapsulated_id = 42, + encapsulated_to = 0, + encapsulated_from = 0x0BADF00D, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = message, + to = NodeId.BROADCAST.raw, + from = 0x0BADF00D, + id = 42, + ) + assertEquals(NodeId.BROADCAST.raw, event.to) + assertContentEquals(expectedHash, event.messageHash) + client.disconnect() + } + + @Test + fun sfppFragmentedMessagesAreReassembledBeforeEmission() = runTest { + val (transport, client) = connectedClient("sfpp-fragmented") + client.connect() + runCurrent() + + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + message = "hello ".encodeToByteArray().toByteString(), + encapsulated_id = 77, + encapsulated_to = 88, + encapsulated_from = 99, + ), + packetId = 701, + ) + runCurrent() + assertFalse(eventDeferred.isCompleted) + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + message = "world".encodeToByteArray().toByteString(), + commit_hash = byteArrayOf(9).toByteString(), + encapsulated_id = 77, + encapsulated_to = 88, + encapsulated_from = 99, + ), + packetId = 702, + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = "hello world".encodeToByteArray(), + to = 88, + from = 99, + id = 77, + ) + assertEquals(77, event.packetId) + assertEquals(true, event.confirmed) + assertContentEquals(expectedHash, event.messageHash) + client.disconnect() + } + + @Test + fun sfppFragmentAssemblySupportsOutOfOrderChunks() = runTest { + val (transport, client) = connectedClient("sfpp-out-of-order") + client.connect() + runCurrent() + + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_SECONDHALF, + message = "beta".encodeToByteArray().toByteString(), + encapsulated_id = 15, + encapsulated_to = 16, + encapsulated_from = 17, + ), + packetId = 801, + ) + runCurrent() + assertFalse(eventDeferred.isCompleted) + + transport.injectSfpp( + message = StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + message = "alpha".encodeToByteArray().toByteString(), + encapsulated_id = 15, + encapsulated_to = 16, + encapsulated_from = 17, + ), + packetId = 802, + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = "alphabeta".encodeToByteArray(), + to = 16, + from = 17, + id = 15, + ) + assertContentEquals(expectedHash, event.messageHash) + client.disconnect() + } + + @Test + fun unsupportedStoreForwardPayloadIsIgnoredGracefully() = runTest { + val (transport, client) = connectedClient("unsupported-payload") + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectStoreForwardPayload(byteArrayOf(0x08, 0x63)) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + @Test + fun canonAnnounceWithoutHashIsIgnoredGracefully() = runTest { + val (transport, client) = connectedClient("canon-without-hash") + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE, + encapsulated_rxtime = 99, + ), + ) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + private fun FakeRadioTransport.injectLegacyStoreForward( + message: StoreAndForward, + fromNode: Int = 0x10203040, + packetId: Int = 1, + requestId: Int? = null, + ) { + val decoded = if (requestId != null) { + Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = StoreAndForward.ADAPTER.encode(message).toByteString(), + request_id = requestId, + ) + } else { + Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = StoreAndForward.ADAPTER.encode(message).toByteString(), + ) + } + val effectivePacketId = if (packetId == 1 && requestId != null) requestId else packetId + injectPacket( + MeshPacket( + id = effectivePacketId, + from = fromNode, + to = 0, + decoded = decoded, + ), + ) + } + + private fun FakeRadioTransport.injectSfpp( + message: StoreForwardPlusPlus, + fromNode: Int = 0x10203040, + packetId: Int = 1, + ) { + injectStoreForwardPayload( + payload = StoreForwardPlusPlus.ADAPTER.encode(message), + fromNode = fromNode, + packetId = packetId, + ) + } + + private fun FakeRadioTransport.injectStoreForwardPayload( + payload: ByteArray, + fromNode: Int = 0x10203040, + packetId: Int = 1, + ) { + injectPacket( + MeshPacket( + id = packetId, + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload.toByteString(), + ), + ), + ) + } + + private companion object { + const val ALL_HISTORY_WINDOW_MINUTES: Int = 60 * 24 * 365 * 100 + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt new file mode 100644 index 0000000..cd1b1db --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiObserveTest.kt @@ -0,0 +1,315 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.PowerMetrics +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class TelemetryApiObserveTest { + + @Test + fun `observe emits telemetry from specified node`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x22222222) + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 85)) + val received = backgroundScope.async { client.telemetry.observe(node).first() } + runCurrent() + + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + val actual = received.await() + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe filters out telemetry from other nodes`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val observedNode = NodeId(0x22222222) + val otherNode = 0x33333333 + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.telemetry.observe(observedNode).collect { collected += it } + } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 42)), + fromNode = otherNode, + ) + runCurrent() + + assertEquals(0, collected.size) + collector.cancelAndJoin() + client.disconnect() + } + + @Test + fun `observe with LOCAL emits from all nodes`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = listOf( + Telemetry(device_metrics = DeviceMetrics(battery_level = 81)), + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 21.5f)), + Telemetry(power_metrics = PowerMetrics(ch1_voltage = 4.2f, ch1_current = 0.48f)), + ) + val fromNodes = listOf(0x22222222, 0x33333333, 0x44444444) + val received = backgroundScope.async { + client.telemetry.observe(NodeId.LOCAL).take(expected.size).toList() + } + runCurrent() + + expected.zip(fromNodes).forEach { (telemetry, fromNode) -> + transport.injectTelemetryResponse(requestId = 0, telemetry = telemetry, fromNode = fromNode) + } + runCurrent() + + val actual = received.await() + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe emits device metrics`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = DeviceMetrics(battery_level = 90, voltage = 4.1f, uptime_seconds = 600) + val received = backgroundScope.async { client.telemetry.observe(NodeId.LOCAL).first() } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(device_metrics = expected), + fromNode = 0x22222222, + ) + runCurrent() + + val actual = received.await().device_metrics + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe emits environment metrics`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = EnvironmentMetrics( + temperature = 23.4f, + relative_humidity = 56.0f, + barometric_pressure = 1008.7f, + ) + val received = backgroundScope.async { client.telemetry.observe(NodeId.LOCAL).first() } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(environment_metrics = expected), + fromNode = 0x33333333, + ) + runCurrent() + + val actual = received.await().environment_metrics + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe emits power metrics`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expected = PowerMetrics(ch1_voltage = 4.18f, ch1_current = 0.42f, ch2_voltage = 5.0f) + val received = backgroundScope.async { client.telemetry.observe(NodeId.LOCAL).first() } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(power_metrics = expected), + fromNode = 0x44444444, + ) + runCurrent() + + val actual = received.await().power_metrics + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `multiple concurrent observers receive same data`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x55555555) + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 73)) + val first = backgroundScope.async { client.telemetry.observe(node).first() } + val second = backgroundScope.async { client.telemetry.observe(node).first() } + runCurrent() + + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + val firstActual = first.await() + val secondActual = second.await() + assertEquals(expected, firstActual) + assertEquals(expected, secondActual) + client.disconnect() + } + + @Test + fun `cancelling observer does not affect other observers`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x66666666) + val cancelledCollectorValues = mutableListOf() + val cancelledCollector = backgroundScope.launch { + client.telemetry.observe(node).collect { cancelledCollectorValues += it } + } + val survivingCollector = backgroundScope.async { client.telemetry.observe(node).first() } + runCurrent() + + cancelledCollector.cancelAndJoin() + + val expected = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 18.2f)) + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + val actual = survivingCollector.await() + assertEquals(0, cancelledCollectorValues.size) + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe after disconnect emits nothing`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + client.disconnect() + + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.telemetry.observe(NodeId.LOCAL).collect { collected += it } + } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(device_metrics = DeviceMetrics(battery_level = 1)), + fromNode = 0x22222222, + ) + runCurrent() + + assertEquals(0, collected.size) + collector.cancelAndJoin() + } + + @Test + fun `rapid telemetry packets all emitted`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x77777777) + val expected = (1..10).map { level -> + Telemetry(device_metrics = DeviceMetrics(battery_level = level)) + } + val received = backgroundScope.async { + client.telemetry.observe(node).take(expected.size).toList() + } + runCurrent() + + expected.forEach { telemetry -> + transport.injectTelemetryResponse(requestId = 0, telemetry = telemetry, fromNode = node.raw) + } + runCurrent() + + val actual = received.await() + assertEquals(expected, actual) + client.disconnect() + } + + @Test + fun `observe is cold and does not replay earlier packets`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(0x22222222) + val beforeSubscription = Telemetry(device_metrics = DeviceMetrics(battery_level = 10)) + transport.injectTelemetryResponse(requestId = 0, telemetry = beforeSubscription, fromNode = node.raw) + runCurrent() + + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.telemetry.observe(node).collect { collected += it } + } + runCurrent() + + assertEquals(0, collected.size) + + val afterSubscription = Telemetry(device_metrics = DeviceMetrics(battery_level = 11)) + transport.injectTelemetryResponse(requestId = 0, telemetry = afterSubscription, fromNode = node.raw) + runCurrent() + + val actual = collected.toList() + assertEquals(listOf(afterSubscription), actual) + collector.cancelAndJoin() + client.disconnect() + } + + private fun TestScope.connectedClient(nodeNum: Int = 0x11111111): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:telemetry-observe"), + autoHandshake = true, + nodeNum = nodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .build() + return transport to client + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt new file mode 100644 index 0000000..818e523 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/TelemetryApiTest.kt @@ -0,0 +1,368 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.AirQualityMetrics +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.EnvironmentMetrics +import org.meshtastic.proto.HealthMetrics +import org.meshtastic.proto.HostMetrics +import org.meshtastic.proto.LocalStats +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.PowerMetrics +import org.meshtastic.proto.Telemetry +import org.meshtastic.proto.TrafficManagementStats +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class TelemetryApiTest { + + @Test + fun requestDeviceUsesResolvedLocalNodeAndReturnsDeviceMetrics() = runTest { + val localNodeNum = 12345 + val (transport, client) = connectedClient(nodeNum = localNodeNum) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestDevice() } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + assertEquals(localNodeNum, request.from) + assertEquals(localNodeNum, request.to) + assertTrue(request.decoded?.want_response == true) + + val expected = DeviceMetrics(battery_level = 87, voltage = 4.1f, uptime_seconds = 3600) + transport.injectTelemetryResponse(requestId = request.id, telemetry = Telemetry(device_metrics = expected)) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestEnvironmentReturnsTemperatureHumidityAndPressure() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(2222) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestEnvironment(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + assertEquals(node.raw, request.to) + + val expected = EnvironmentMetrics( + temperature = 21.5f, + relative_humidity = 62.0f, + barometric_pressure = 1013.2f, + ) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(environment_metrics = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestAirQualityReturnsPmValues() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(3333) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestAirQuality(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = AirQualityMetrics( + pm10_standard = 5, + pm25_standard = 12, + pm100_standard = 20, + particles_03um = 41, + ) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(air_quality_metrics = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestPowerReturnsVoltageAndCurrentMetrics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(4444) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestPower(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = PowerMetrics(ch1_voltage = 4.18f, ch1_current = 0.42f, ch2_voltage = 5.0f) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(power_metrics = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestLocalStatsReturnsLocalStatsTelemetry() = runTest { + val localNodeNum = 54321 + val (transport, client) = connectedClient(nodeNum = localNodeNum) + client.connect() + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestLocalStats() } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + assertEquals(localNodeNum, request.to) + + val expected = LocalStats( + uptime_seconds = 55, + num_packets_tx = 12, + num_packets_rx = 9, + num_online_nodes = 3, + ) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(local_stats = expected), + fromNode = localNodeNum, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestHealthReturnsHealthMetrics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(5555) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestHealth(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = HealthMetrics(heart_bpm = 72, spO2 = 98, temperature = 36.7f) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(health_metrics = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestHostReturnsHostMetrics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(6666) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestHost(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = HostMetrics( + uptime_seconds = 1000, + freemem_bytes = 2048, + diskfree1_bytes = 4096, + load1 = 23, + load5 = 17, + load15 = 11, + ) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(host_metrics = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun requestTrafficManagementReturnsTrafficStats() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(7777) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.telemetry.requestTrafficManagement(node) } + runCurrent() + + val request = transport.lastTelemetryRequest(outboundBefore) + val expected = TrafficManagementStats( + packets_inspected = 100, + position_dedup_drops = 2, + rate_limit_drops = 3, + router_hops_preserved = 4, + ) + transport.injectTelemetryResponse( + requestId = request.id, + telemetry = Telemetry(traffic_management_stats = expected), + fromNode = node.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(expected, result.value) + client.disconnect() + } + + @Test + fun observeEmitsMatchingTelemetryPacketsInOrder() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(8888) + val expected = listOf( + Telemetry(environment_metrics = EnvironmentMetrics(temperature = 19.8f)), + Telemetry(power_metrics = PowerMetrics(ch1_voltage = 4.05f, ch1_current = 0.31f)), + ) + val collected = backgroundScope.async { + client.telemetry.observe(node).take(expected.size).toList() + } + runCurrent() + + transport.injectTelemetryResponse(requestId = 0, telemetry = expected[0], fromNode = node.raw) + transport.injectTelemetryResponse(requestId = 0, telemetry = expected[1], fromNode = node.raw) + runCurrent() + + assertEquals(expected, collected.await()) + client.disconnect() + } + + @Test + fun observeIgnoresOtherNodesWrongPortsAndInvalidPayload() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val node = NodeId(9999) + val expected = Telemetry(device_metrics = DeviceMetrics(battery_level = 15)) + val collected = backgroundScope.async { + client.telemetry.observe(node).take(1).toList() + } + runCurrent() + + transport.injectTelemetryResponse( + requestId = 0, + telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 30.0f)), + fromNode = 1111, + ) + transport.injectPacket( + MeshPacket( + from = node.raw, + decoded = Data( + portnum = PortNum.TEXT_MESSAGE_APP, + payload = okio.ByteString.of(*"ignored".encodeToByteArray()), + ), + ), + ) + transport.injectPacket( + MeshPacket( + from = node.raw, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = okio.ByteString.of(0x80.toByte()), + ), + ), + ) + transport.injectTelemetryResponse(requestId = 0, telemetry = expected, fromNode = node.raw) + runCurrent() + + assertEquals(listOf(expected), collected.await()) + client.disconnect() + } + + private fun TestScope.connectedClient( + nodeNum: Int = 1234, + rpcTimeout: Duration = 60.seconds, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:telemetry-api"), + autoHandshake = true, + nodeNum = nodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(rpcTimeout) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + private fun FakeRadioTransport.lastTelemetryRequest(outboundBefore: Int): MeshPacket = + outboundPackets().drop(outboundBefore).last { it.decoded?.portnum == PortNum.TELEMETRY_APP } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt index 8acb6ea..84cd503 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/WireCodecTest.kt @@ -8,6 +8,8 @@ package org.meshtastic.sdk import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.FromRadio import org.meshtastic.proto.Heartbeat import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.ToRadio @@ -24,6 +26,56 @@ import kotlin.test.assertTrue */ class WireCodecTest { + private companion object { + private const val MAX_FRAME_SIZE = 512 + private val START1: Byte = 0x94.toByte() + private val START2: Byte = 0xC3.toByte() + } + + private fun encodeFromRadio(message: FromRadio): ByteArray { + val payload = FromRadio.ADAPTER.encode(message) + return ByteArray(4 + payload.size).apply { + this[0] = START1 + this[1] = START2 + this[2] = (payload.size shr 8).toByte() + this[3] = (payload.size and 0xFF).toByte() + payload.copyInto(this, destinationOffset = 4) + } + } + + private fun withRepeatedStart1s(frame: ByteArray, count: Int): ByteArray = + ByteArray(count) { START1 } + frame.copyOfRange(1, frame.size) + + private fun serializedSize(message: ToRadio): Int = ToRadio.ADAPTER.encode(message).size + + private fun toRadioWithSerializedSize(targetSize: Int): ToRadio { + val builders = listOf<(ByteArray) -> ToRadio>( + { payload -> ToRadio(packet = MeshPacket(decoded = Data(payload = payload.toByteString()))) }, + { payload -> ToRadio(packet = MeshPacket(channel = 1, decoded = Data(payload = payload.toByteString()))) }, + { payload -> ToRadio(packet = MeshPacket(to = 1, decoded = Data(payload = payload.toByteString()))) }, + { payload -> + ToRadio( + packet = MeshPacket( + from = 1, + to = 1, + channel = 1, + decoded = Data(payload = payload.toByteString()), + ), + ) + }, + ) + + for (payloadSize in 0..1024) { + val payload = ByteArray(payloadSize) { ((it * 31) and 0xFF).toByte() } + for (builder in builders) { + val message = builder(payload) + if (serializedSize(message) == targetSize) return message + } + } + + error("Could not construct ToRadio with serialized size $targetSize") + } + // ── Basic header / framing ──────────────────────────────────────────────── @Test @@ -163,6 +215,107 @@ class WireCodecTest { assertEquals(1, results.size, "After reset, decoder must handle a fresh valid frame") } + @Test + fun testExactMaxFrameSize() { + val exactMax = toRadioWithSerializedSize(MAX_FRAME_SIZE) + val frame = WireCodec.encodeToRadio(exactMax) + + assertEquals(MAX_FRAME_SIZE, serializedSize(exactMax)) + assertEquals(4 + MAX_FRAME_SIZE, frame.size) + assertEquals(0x02.toByte(), frame[2]) + assertEquals(0x00.toByte(), frame[3]) + + assertFailsWith { + WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE + 1)) + } + } + + @Test + fun testResyncAfterCorruptionMidPayload() { + val decoder = WireCodec.FrameDecoder() + val partialFrame = encodeFromRadio(FromRadio(id = 1)).copyOfRange(0, 5) + val recoveredFrame = encodeFromRadio(FromRadio(id = 42)) + + assertTrue(decoder.feedBytes(partialFrame).isEmpty()) + assertTrue(decoder.feedBytes(byteArrayOf(0x80.toByte())).isEmpty()) + + val results = decoder.feedBytes(recoveredFrame) + assertEquals(listOf(FromRadio(id = 42)), results) + } + + @Test + fun testMultipleConsecutiveStart1Bytes() { + val frame = withRepeatedStart1s(encodeFromRadio(FromRadio(id = 7)), count = 5) + + val results = WireCodec.FrameDecoder().feedBytes(frame) + + assertEquals(listOf(FromRadio(id = 7)), results) + } + + @Test + fun testBackToBackZeroLengthFrames() { + val zeroFrame = byteArrayOf(START1, START2, 0x00, 0x00) + + val results = WireCodec.FrameDecoder().feedBytes(zeroFrame + zeroFrame + zeroFrame) + + assertEquals(3, results.size) + assertTrue(results.all { it == FromRadio() }) + } + + @Test + fun testFeedBytesReturnsCorrectCountForMixedValidInvalid() { + val valid1 = encodeFromRadio(FromRadio(id = 1)) + val valid2 = encodeFromRadio(FromRadio(id = 2)) + val zeroFrame = byteArrayOf(START1, START2, 0x00, 0x00) + val garbage = byteArrayOf(0x00, 0x7F, 0x01, 0x55) + val malformedFrame = byteArrayOf(START1, START2, 0x00, 0x02, 0x08, 0x80.toByte()) + val truncatedFrame = encodeFromRadio(FromRadio(id = 9)).copyOfRange(0, 5) + + val results = WireCodec.FrameDecoder().feedBytes( + valid1 + garbage + malformedFrame + valid2 + zeroFrame + truncatedFrame, + ) + + assertEquals(listOf(FromRadio(id = 1), FromRadio(id = 2), FromRadio()), results) + } + + @Test + fun testPartialFrameThenValidFrameNoReset() { + val decoder = WireCodec.FrameDecoder() + val partialFrame = encodeFromRadio(FromRadio(id = 1)).copyOfRange(0, 5) + val recoveredFrame = withRepeatedStart1s(encodeFromRadio(FromRadio(id = 77)), count = 5) + + assertTrue(decoder.feedBytes(partialFrame).isEmpty()) + + val results = decoder.feedBytes(recoveredFrame) + + assertEquals(listOf(FromRadio(id = 77)), results) + } + + @Test + fun testStart1AppearingAsPayloadByte() { + val message = FromRadio( + packet = MeshPacket( + decoded = Data(payload = byteArrayOf(START1).toByteString()), + ), + ) + val payload = FromRadio.ADAPTER.encode(message) + + assertTrue(payload.contains(START1)) + assertEquals(listOf(message), WireCodec.FrameDecoder().feedBytes(encodeFromRadio(message))) + } + + @Test + fun testLargePayloadNearBoundary() { + val frame511 = WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE - 1)) + val frame512 = WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE)) + + assertEquals(4 + MAX_FRAME_SIZE - 1, frame511.size) + assertEquals(4 + MAX_FRAME_SIZE, frame512.size) + assertFailsWith { + WireCodec.encodeToRadio(toRadioWithSerializedSize(MAX_FRAME_SIZE + 1)) + } + } + // ── Fuzz tests: random bytes must never crash the decoder ───────────────── @Test diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt new file mode 100644 index 0000000..a65ca5d --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelHelpersTest.kt @@ -0,0 +1,78 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.Channel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ChannelHelpersTest { + @Test + fun validChannelValidates() { + val result = ChannelHelpers.validate( + name = "LongFast", + psk = ByteArray(ChannelHelpers.MIN_PSK_LENGTH) { 0x42 }, + ) + + assertTrue(result.isValid) + assertTrue(result.errors.isEmpty()) + } + + @Test + fun nameTooLongFailsValidation() { + val result = ChannelHelpers.validate( + name = "123456789012", + psk = byteArrayOf(0x01), + ) + + assertFalse(result.isValid) + assertTrue(result.errors.any { it.contains("exceeds") }) + } + + @Test + fun invalidPskLengthsFailValidation() { + val result = ChannelHelpers.validate( + name = "mesh", + psk = ByteArray(8), + ) + + assertFalse(result.isValid) + assertTrue(result.errors.any { it.contains("PSK") }) + } + + @Test + fun findEmptySlotUsesFirstDisabledOrMissingSecondarySlot() { + val channels = listOf( + Channel(index = 0, role = Channel.Role.PRIMARY), + Channel(index = 1, role = Channel.Role.SECONDARY), + Channel(index = 2, role = Channel.Role.DISABLED), + ) + + assertEquals(2, ChannelHelpers.findEmptySlot(channels)) + assertEquals(2, ChannelHelpers.findEmptySlot(channels.take(2))) + } + + @Test + fun findEmptySlotReturnsNullWhenFull() { + val channels = listOf( + Channel(index = 0, role = Channel.Role.PRIMARY), + Channel(index = 1, role = Channel.Role.SECONDARY), + Channel(index = 2, role = Channel.Role.SECONDARY), + Channel(index = 3, role = Channel.Role.SECONDARY), + Channel(index = 4, role = Channel.Role.SECONDARY), + Channel(index = 5, role = Channel.Role.SECONDARY), + Channel(index = 6, role = Channel.Role.SECONDARY), + Channel(index = 7, role = Channel.Role.SECONDARY), + ) + + assertNull(ChannelHelpers.findEmptySlot(channels)) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt index 62110c3..e505477 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ChannelUrlsTest.kt @@ -57,4 +57,8 @@ class ChannelUrlsTest { byteArrayOf(0x01, 0x02).fold(0) { a, b -> a xor (b.toInt() and 0xff) } assertEquals(expected and 0xff, ChannelSettings.hash("abc", byteArrayOf(0x01, 0x02))) } + + @Test fun channelNameHashUsesDjb2() { + assertEquals(130429955u, channelNameHashDjb2("LongFast")) + } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt new file mode 100644 index 0000000..20f936b --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionEmissionTest.kt @@ -0,0 +1,163 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.Telemetry +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CongestionEmissionTest { + private fun TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:congestion-emission"), + autoHandshake = true, + nodeNum = 1, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .build() + return transport to client + } + + private fun FakeRadioTransport.injectTelemetry(fromNode: Int = nodeNum, deviceMetrics: DeviceMetrics) { + val payload = Telemetry.ADAPTER.encode(Telemetry(device_metrics = deviceMetrics)).toByteString() + injectPacket( + MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.TELEMETRY_APP, + payload = payload, + ), + ), + ) + } + + @Test + fun criticalMetricsResolveToCriticalLevel() { + val metrics = CongestionMetrics(airUtilTx = 80f, channelUtil = 30f) + + assertEquals(CongestionLevel.CRITICAL, metrics.level) + } + + @Test + fun levelTransitionsEmitOnlyWhenCrossingThresholds() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { event -> + if (event is MeshEvent.CongestionWarning) { + events += event + } + } + } + runCurrent() + + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 10f, channel_utilization = 10f)) + runCurrent() + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 55f, channel_utilization = 10f)) + runCurrent() + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 60f, channel_utilization = 15f)) + runCurrent() + + assertEquals(listOf(CongestionLevel.LOW, CongestionLevel.HIGH), events.map { it.metrics.level }) + assertEquals(55f, events.last().metrics.airUtilTx) + + collectJob.cancel() + client.disconnect() + } + + @Test + fun zeroMetricsDoNotEmitWarnings() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { event -> + if (event is MeshEvent.CongestionWarning) { + events += event + } + } + } + runCurrent() + + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 0f, channel_utilization = 0f)) + runCurrent() + transport.injectTelemetry(deviceMetrics = DeviceMetrics(air_util_tx = 55f, channel_utilization = 0f)) + runCurrent() + + assertEquals(1, events.size) + assertEquals(CongestionLevel.HIGH, events.single().metrics.level) + + collectJob.cancel() + client.disconnect() + } + + @Test + fun multipleNodesAreTrackedIndependently() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val events = mutableListOf() + val collectJob = launch(backgroundScope.coroutineContext) { + client.events.collect { event -> + if (event is MeshEvent.CongestionWarning) { + events += event + } + } + } + runCurrent() + + transport.injectTelemetry( + fromNode = 0x10101010, + deviceMetrics = DeviceMetrics(air_util_tx = 55f, channel_utilization = 10f), + ) + runCurrent() + transport.injectTelemetry( + fromNode = 0x10101010, + deviceMetrics = DeviceMetrics(air_util_tx = 60f, channel_utilization = 15f), + ) + runCurrent() + transport.injectTelemetry( + fromNode = 0x20202020, + deviceMetrics = DeviceMetrics(air_util_tx = 65f, channel_utilization = 12f), + ) + runCurrent() + + assertEquals(2, events.size) + assertEquals(55f, events[0].metrics.airUtilTx) + assertEquals(65f, events[1].metrics.airUtilTx) + assertTrue(events.all { it.metrics.level == CongestionLevel.HIGH }) + + collectJob.cancel() + client.disconnect() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt new file mode 100644 index 0000000..9e843cc --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/CongestionTest.kt @@ -0,0 +1,54 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class CongestionTest { + @Test fun levelIsLowWhenBothMetricsAreBelowMediumThreshold() { + assertEquals(CongestionLevel.LOW, CongestionMetrics(airUtilTx = 10f, channelUtil = 20f).level) + } + + @Test fun levelIsMediumWhenOneMetricCrossesMediumThreshold() { + assertEquals(CongestionLevel.MEDIUM, CongestionMetrics(airUtilTx = 25f, channelUtil = 10f).level) + } + + @Test fun levelIsHighWhenOneMetricCrossesHighThreshold() { + assertEquals(CongestionLevel.HIGH, CongestionMetrics(airUtilTx = 10f, channelUtil = 50f).level) + } + + @Test fun levelIsCriticalWhenOneMetricCrossesCriticalThreshold() { + assertEquals(CongestionLevel.CRITICAL, CongestionMetrics(airUtilTx = 75f, channelUtil = 10f).level) + } + + @Test fun suggestedBackoffIncreasesWithLevel() { + val low = CongestionMetrics(airUtilTx = 10f, channelUtil = 10f).suggestedBackoff + val medium = CongestionMetrics(airUtilTx = 25f, channelUtil = 10f).suggestedBackoff + val high = CongestionMetrics(airUtilTx = 50f, channelUtil = 10f).suggestedBackoff + val critical = CongestionMetrics(airUtilTx = 75f, channelUtil = 10f).suggestedBackoff + + assertEquals(kotlin.time.Duration.ZERO, low) + assertEquals(5.seconds, medium) + assertEquals(15.seconds, high) + assertEquals(30.seconds, critical) + assertTrue(low < medium) + assertTrue(medium < high) + assertTrue(high < critical) + } + + @Test fun canSendNonUrgentOnlyForLowAndMedium() { + assertTrue(CongestionMetrics(airUtilTx = 10f, channelUtil = 10f).canSendNonUrgent) + assertTrue(CongestionMetrics(airUtilTx = 25f, channelUtil = 10f).canSendNonUrgent) + assertFalse(CongestionMetrics(airUtilTx = 50f, channelUtil = 10f).canSendNonUrgent) + assertFalse(CongestionMetrics(airUtilTx = 75f, channelUtil = 10f).canSendNonUrgent) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt new file mode 100644 index 0000000..4053a01 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/ConnectionStateTest.kt @@ -0,0 +1,44 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConnectionStateTest { + private val reconnecting = ConnectionState.Reconnecting(MeshtasticException.Transport("link lost"), attempt = 3) + + @Test fun isUsableOnlyWhenConnected() { + assertFalse(ConnectionState.Disconnected.isUsable) + assertFalse(ConnectionState.Connecting(attempt = 1).isUsable) + assertFalse(ConnectionState.Configuring(ConfigPhase.Stage2, progress = 0.5f).isUsable) + assertTrue(ConnectionState.Connected.isUsable) + assertFalse(reconnecting.isUsable) + } + + @Test fun isInProgressForActiveStates() { + assertFalse(ConnectionState.Disconnected.isInProgress) + assertTrue(ConnectionState.Connecting(attempt = 2).isInProgress) + assertTrue(ConnectionState.Configuring(ConfigPhase.Stage1, progress = 0.25f).isInProgress) + assertFalse(ConnectionState.Connected.isInProgress) + assertTrue(reconnecting.isInProgress) + } + + @Test fun statusMessageFormatsEachState() { + assertEquals("Disconnected", ConnectionState.Disconnected.statusMessage) + assertEquals("Connecting (attempt 2)", ConnectionState.Connecting(attempt = 2).statusMessage) + assertEquals( + "Configuring: Settling (37%)", + ConnectionState.Configuring(ConfigPhase.Settling, progress = 0.375f).statusMessage, + ) + assertEquals("Connected", ConnectionState.Connected.statusMessage) + assertEquals("Reconnecting (attempt 3)", reconnecting.statusMessage) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt new file mode 100644 index 0000000..cc7b5fa --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/DeviceCapabilitiesTest.kt @@ -0,0 +1,51 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.DeviceCapabilities +import org.meshtastic.sdk.DeviceVersion +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DeviceCapabilitiesTest { + @Test fun parsesVersions() { + assertEquals(20712, DeviceVersion("2.7.12").asInt) + assertEquals(20000, DeviceVersion("2.0").asInt) + assertEquals(0, DeviceVersion("invalid").asInt) + } + + @Test fun comparesVersions() { + assertTrue(DeviceVersion("2.7.18") > DeviceVersion("2.7.12")) + assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.99.99")) + } + + @Test fun detectsCapabilitiesByVersion() { + val current = DeviceCapabilities("2.7.18") + assertTrue(current.canMuteNode) + assertTrue(current.canSendVerifiedContacts) + + val old = DeviceCapabilities("2.6.7") + assertFalse(old.supportsQrCodeSharing) + } + + @Test fun nullVersionDisablesAllCapabilities() { + val capabilities = DeviceCapabilities(null) + assertFalse(capabilities.canMuteNode) + assertFalse(capabilities.canSendVerifiedContacts) + assertFalse(capabilities.canToggleTelemetryEnabled) + assertFalse(capabilities.canToggleUnmessageable) + assertFalse(capabilities.supportsQrCodeSharing) + assertFalse(capabilities.supportsStatusMessage) + assertFalse(capabilities.supportsTrafficManagementConfig) + assertFalse(capabilities.supportsTakConfig) + assertFalse(capabilities.supportsSecondaryChannelLocation) + assertFalse(capabilities.supportsEsp32Ota) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt index 6a33b6b..d8b3089 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MessageHandleRetryTest.kt @@ -5,32 +5,197 @@ * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) * SPDX-License-Identifier: GPL-3.0-or-later */ -package org.meshtastic.sdk +package org.meshtastic.sdk.ext -import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.meshtastic.sdk.RadioClient -import org.meshtastic.sdk.TransportIdentity -import org.meshtastic.sdk.testing.FakeRadioTransport -import org.meshtastic.sdk.testing.InMemoryStorageProvider +import org.meshtastic.proto.MeshPacket +import org.meshtastic.sdk.MessageHandle +import org.meshtastic.sdk.MessageId +import org.meshtastic.sdk.RetryPolicy +import org.meshtastic.sdk.SendFailure +import org.meshtastic.sdk.SendOutcome +import org.meshtastic.sdk.SendState +import org.meshtastic.sdk.retryWith import kotlin.test.Test -import kotlin.test.assertNotEquals -import kotlin.test.assertNotNull +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.time.Duration.Companion.milliseconds -@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) +@OptIn(ExperimentalCoroutinesApi::class) class MessageHandleRetryTest { - private fun TestScope.buildClient(): RadioClient = RadioClient.Builder() - .transport(FakeRadioTransport(identity = TransportIdentity("fake:test"), autoHandshake = true)) - .storage(InMemoryStorageProvider()) - .coroutineContext(backgroundScope.coroutineContext) - .build() - - @Test fun retryReturnsFreshHandle() = runTest { - val client = buildClient() - client.connect() - val first = client.sendText("retry") - val second = first.retry() - assertNotNull(second) - assertNotEquals(first.id, second.id) + private fun fakeHandle( + terminal: SendState, + packet: MeshPacket? = MeshPacket(id = 1), + resendFn: ((MeshPacket) -> MessageHandle)? = null, + ): MessageHandle { + val state = MutableStateFlow(terminal) + return MessageHandle( + id = MessageId(1), + _state = state, + cancelFn = {}, + packet = packet, + resendFn = resendFn, + ) + } + + @Test + fun successOnFirstAttemptDoesNotRetry() = runTest { + var resendCalled = false + val handle = fakeHandle( + SendState.Acked, + resendFn = { + resendCalled = true + fakeHandle(SendState.Acked) + }, + ) + + val result = handle.retryWith(RetryPolicy.Fixed(maxAttempts = 3, delay = 100.milliseconds)) + + assertEquals(SendOutcome.Success, result) + assertFalse(resendCalled) + } + + @Test + fun retriesOnAckTimeoutAfterDelay() = runTest { + var attempts = 0 + lateinit var resendFn: (MeshPacket) -> MessageHandle + resendFn = { pkt -> + attempts++ + fakeHandle(SendState.Acked, pkt, resendFn) + } + + val deferred = async { + fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = resendFn, + ).retryWith(RetryPolicy.Fixed(maxAttempts = 3, delay = 10.milliseconds)) + } + + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(9.milliseconds) + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(1, attempts) + assertEquals(SendOutcome.Success, deferred.await()) + } + + @Test + fun doesNotRetryOnDisconnected() = runTest { + var resendCalled = false + val handle = fakeHandle( + SendState.Failed(SendFailure.Disconnected), + resendFn = { + resendCalled = true + fakeHandle(SendState.Acked) + }, + ) + val result = handle.retryWith(RetryPolicy.Fixed(maxAttempts = 3, delay = 10.milliseconds)) + assertEquals(SendOutcome.Failure(SendFailure.Disconnected), result) + assertFalse(resendCalled) + } + + @Test + fun maxAttemptsExhaustedReturnsLastFailure() = runTest { + var attempts = 0 + lateinit var resendFn: (MeshPacket) -> MessageHandle + resendFn = { pkt -> + attempts++ + when (attempts) { + 1 -> fakeHandle(SendState.Failed(SendFailure.Timeout), pkt, resendFn) + else -> throw AssertionError("retryWith exceeded configured retry limit") + } + } + + val result = fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = resendFn, + ).retryWith(RetryPolicy.Fixed(maxAttempts = 1, delay = 10.milliseconds)) + + assertEquals(SendOutcome.Failure(SendFailure.Timeout), result) + assertEquals(1, attempts) + } + + @Test + fun nonePolicyDoesNotRetry() = runTest { + var resendCalled = false + val handle = fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = { + resendCalled = true + fakeHandle(SendState.Acked) + }, + ) + + val result = handle.retryWith(RetryPolicy.None) + + assertEquals(SendOutcome.Failure(SendFailure.AckTimeout), result) + assertFalse(resendCalled) + } + + @Test + fun exponentialBackoffDelaysIncrease() = runTest { + var attempts = 0 + lateinit var resendFn: (MeshPacket) -> MessageHandle + resendFn = { pkt -> + attempts++ + if (attempts >= 3) { + fakeHandle(SendState.Acked, pkt, resendFn) + } else { + fakeHandle(SendState.Failed(SendFailure.AckTimeout), pkt, resendFn) + } + } + + val deferred = async { + fakeHandle( + SendState.Failed(SendFailure.AckTimeout), + resendFn = resendFn, + ).retryWith( + RetryPolicy.ExponentialBackoff( + maxAttempts = 3, + initialDelay = 10.milliseconds, + maxDelay = 100.milliseconds, + multiplier = 2.0, + jitterFactor = 0.0, + ), + ) + } + + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(9.milliseconds) + runCurrent() + assertEquals(0, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(1, attempts) + + advanceTimeBy(19.milliseconds) + runCurrent() + assertEquals(1, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(2, attempts) + + advanceTimeBy(39.milliseconds) + runCurrent() + assertEquals(2, attempts) + + advanceTimeBy(1.milliseconds) + runCurrent() + assertEquals(3, attempts) + assertEquals(SendOutcome.Success, deferred.await()) } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt new file mode 100644 index 0000000..3883ad7 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/MqttEventsTest.kt @@ -0,0 +1,37 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MqttEventsTest { + @Test fun mqttConnectedIsMeshEvent() { + val event: MeshEvent = MeshEvent.MqttConnected + assertEquals("connected", describe(event)) + } + + @Test fun mqttDisconnectedCarriesReason() { + val event: MeshEvent = MeshEvent.MqttDisconnected(reason = "broker closed connection") + assertEquals("broker closed connection", describe(event)) + assertEquals("broker closed connection", (event as MeshEvent.MqttDisconnected).reason) + } + + @Test fun mqttDisconnectedDefaultsReasonToNull() { + val event: MeshEvent.MqttDisconnected = MeshEvent.MqttDisconnected() + assertNull(event.reason) + assertEquals("none", describe(event)) + } + + private fun describe(event: MeshEvent): String = when (event) { + MeshEvent.MqttConnected -> "connected" + is MeshEvent.MqttDisconnected -> event.reason ?: "none" + else -> "other" + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt new file mode 100644 index 0000000..a247d15 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NeighborInfoTest.kt @@ -0,0 +1,60 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NeighborInfoTest { + @Test + fun fromProtoParsesKnownValues() { + val info = NeighborInfo.fromProto( + reportingNode = 0x1234, + neighborNodeIds = listOf(0x2001, 0x2002), + snrValues = listOf(7.5f, -2.25f), + timestamp = 1_700_000_000, + ) + + assertEquals(NodeId(0x1234), info.nodeId) + assertEquals(2, info.neighbors.size) + assertEquals(NeighborInfo.Neighbor(NodeId(0x2001), 7.5f), info.neighbors[0]) + assertEquals(NeighborInfo.Neighbor(NodeId(0x2002), -2.25f), info.neighbors[1]) + assertEquals(1_700_000_000, info.lastUpdated) + } + + @Test + fun fromProtoSupportsEmptyNeighbors() { + val info = NeighborInfo.fromProto( + reportingNode = 0x1234, + neighborNodeIds = emptyList(), + snrValues = emptyList(), + ) + + assertTrue(info.neighbors.isEmpty()) + assertEquals(0, info.lastUpdated) + } + + @Test + fun formatOutputsReadableSummary() { + val info = NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 7.5f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = -1.0f), + ), + ) + + assertEquals( + "Neighbors of 00000001 (2):\n" + + " 00000002 — SNR: 7.5 dB\n" + + " 00000003 — SNR: -1.0 dB\n", + info.format(), + ) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt new file mode 100644 index 0000000..e73bf35 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeChangePresenceTest.kt @@ -0,0 +1,44 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class NodeChangePresenceTest { + @Test + fun presenceChangesImplementNodeChange() { + val wentOffline = NodeChange.WentOffline(nodeId = NodeId(1), lastHeard = 123) + val cameOnline = NodeChange.CameOnline(nodeId = NodeId(2)) + + assertIs(wentOffline) + assertIs(cameOnline) + assertEquals(123, wentOffline.lastHeard) + assertEquals(NodeId(2), cameOnline.nodeId) + } + + @Test + fun presenceChangesCanBePatternMatched() { + val labels = listOf( + NodeChange.WentOffline(nodeId = NodeId(1), lastHeard = 321), + NodeChange.CameOnline(nodeId = NodeId(2)), + ).map { change -> + when (change) { + is NodeChange.Snapshot -> "snapshot" + is NodeChange.Added -> "added" + is NodeChange.Updated -> "updated" + is NodeChange.Removed -> "removed" + is NodeChange.WentOffline -> "offline:${change.lastHeard}" + is NodeChange.CameOnline -> "online:${change.nodeId.toHex()}" + } + } + + assertEquals(listOf("offline:321", "online:00000002"), labels) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt index 1085b3e..9b59416 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/NodeIdsTest.kt @@ -32,6 +32,12 @@ class NodeIdsTest { assertNull(NodeId.fromHex("")) } + @Test fun defaultIdRoundTrips() { + val nodeId = NodeId(0xa1b2c3d4.toInt()) + assertEquals("!a1b2c3d4", nodeId.toDefaultId()) + assertEquals(nodeId, NodeId.fromDefaultId("!a1b2c3d4")) + } + @Test fun predicates() { assertTrue(NodeId.BROADCAST.isBroadcast) assertTrue(NodeId.LOCAL.isLocal()) diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt index c709244..63cb8b9 100644 --- a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PayloadAccessorsTest.kt @@ -7,6 +7,11 @@ */ package org.meshtastic.sdk +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.toByteString import org.meshtastic.proto.AdminMessage import org.meshtastic.proto.Data @@ -16,15 +21,30 @@ import org.meshtastic.proto.Position import org.meshtastic.proto.Routing import org.meshtastic.proto.Telemetry import org.meshtastic.proto.User +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class PayloadAccessorsTest { private fun pkt(port: PortNum, payload: ByteArray) = MeshPacket(decoded = Data(portnum = port, payload = payload.toByteString())) + private fun TestScope.buildClient(transport: FakeRadioTransport): RadioClient = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .coroutineContext(backgroundScope.coroutineContext) + .build() + + private fun fakeTransport() = FakeRadioTransport( + identity = TransportIdentity("fake:test"), + autoHandshake = true, + ) + @Test fun textRoundTrip() { assertEquals("hello mesh", pkt(PortNum.TEXT_MESSAGE_APP, "hello mesh".encodeToByteArray()).asText()) } @@ -56,4 +76,152 @@ class PayloadAccessorsTest { @Test fun emptyPayloadReturnsNull() { assertNull(MeshPacket(decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)).asText()) } + + // ── textMessages flow ───────────────────────────────────────────────── + + @Test fun rawPacketsFlowReceivesInjected() = runTest { + // Diagnostic: verify client.packets receives injected frames at all. + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + // NOTE: do NOT call advanceUntilIdle() here — it advances virtual time past the 60 s + // liveness timeout, triggering handleDisconnect → handshakeStage=Idle → silent drops. + + val received = mutableListOf() + val job = launch { client.packets.toList(received) } + runCurrent() // start the collector coroutine (no virtual-time advance) + + transport.injectPacket( + MeshPacket( + from = 0xABCD, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "test".encodeToByteArray().toByteString()), + ), + ) + runCurrent() // frame-reader → engine actor → emitPacketOrLog → collector + runCurrent() // second pass: catch any second-hop scheduled work + job.cancel() + + assertEquals( + 1, + received.size, + "rawPackets: expected 1 packet, got ${received.size} — injectPacket/engine may be broken", + ) + } + + @Test fun textMessagesEmitsTextPackets() = runTest { + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + + val received = mutableListOf() + val job = launch { client.textMessages.toList(received) } + runCurrent() // start the collector without advancing virtual time + + val textPkt = MeshPacket( + from = 0x1234, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP, payload = "hi".encodeToByteArray().toByteString()), + ) + transport.injectPacket(textPkt) + runCurrent() + runCurrent() + job.cancel() + + assertEquals(1, received.size) + assertEquals("hi", received[0].asText()) + assertEquals(0x1234, received[0].from) + } + + @Test fun textMessagesExcludesNonTextPackets() = runTest { + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + + val received = mutableListOf() + val job = launch { client.textMessages.toList(received) } + runCurrent() + + transport.injectPacket( + MeshPacket( + decoded = Data(portnum = PortNum.POSITION_APP, payload = "xyz".encodeToByteArray().toByteString()), + ), + ) + transport.injectPacket( + MeshPacket( + decoded = Data(portnum = PortNum.TELEMETRY_APP, payload = "xyz".encodeToByteArray().toByteString()), + ), + ) + runCurrent() + runCurrent() + job.cancel() + + assertEquals(0, received.size) + } + + @Test fun textMessagesIncludesEmptyPayloadTextPackets() = runTest { + // textMessages filters on portnum only — empty payload TEXT_MESSAGE_APP packets are included. + // asText() returns null for those (empty payload), but the packet is still emitted. + val transport = fakeTransport() + val client = buildClient(transport) + client.connect() + + val received = mutableListOf() + val job = launch { client.textMessages.toList(received) } + runCurrent() + + transport.injectPacket( + MeshPacket(decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP)), // no payload + ) + runCurrent() + runCurrent() + job.cancel() + + assertEquals(1, received.size) + assertNull(received[0].asText()) // empty payload => asText() is null, but packet was emitted + } + + // ── Waypoint / Traceroute / NeighborInfo accessors ──────────────────── + + @Test fun waypointDecodes() { + val wp = org.meshtastic.proto.Waypoint(id = 42, name = "Base") + val decoded = pkt(PortNum.WAYPOINT_APP, org.meshtastic.proto.Waypoint.ADAPTER.encode(wp)).asWaypoint() + assertNotNull(decoded) + assertEquals(42, decoded.id) + assertEquals("Base", decoded.name) + } + + @Test fun waypointWrongPortReturnsNull() { + val wp = org.meshtastic.proto.Waypoint(id = 1) + assertNull(pkt(PortNum.TEXT_MESSAGE_APP, org.meshtastic.proto.Waypoint.ADAPTER.encode(wp)).asWaypoint()) + } + + @Test fun tracerouteDecodes() { + val route = org.meshtastic.proto.RouteDiscovery(route = listOf(100, 200, 300)) + val decoded = pkt( + PortNum.TRACEROUTE_APP, + org.meshtastic.proto.RouteDiscovery.ADAPTER.encode(route), + ).asTraceroute() + assertNotNull(decoded) + assertEquals(listOf(100, 200, 300), decoded.route) + } + + @Test fun tracerouteWrongPortReturnsNull() { + val route = org.meshtastic.proto.RouteDiscovery(route = listOf(1)) + assertNull(pkt(PortNum.ROUTING_APP, org.meshtastic.proto.RouteDiscovery.ADAPTER.encode(route)).asTraceroute()) + } + + @Test fun neighborInfoDecodes() { + val ni = org.meshtastic.proto.NeighborInfo(node_id = 0xABCD, last_sent_by_id = 0x1234) + val decoded = pkt( + PortNum.NEIGHBORINFO_APP, + org.meshtastic.proto.NeighborInfo.ADAPTER.encode(ni), + ).asNeighborInfo() + assertNotNull(decoded) + assertEquals(0xABCD, decoded.node_id) + assertEquals(0x1234, decoded.last_sent_by_id) + } + + @Test fun neighborInfoWrongPortReturnsNull() { + val ni = org.meshtastic.proto.NeighborInfo(node_id = 1) + assertNull(pkt(PortNum.TELEMETRY_APP, org.meshtastic.proto.NeighborInfo.ADAPTER.encode(ni)).asNeighborInfo()) + } } diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt new file mode 100644 index 0000000..57df3c6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PositionUtilsTest.kt @@ -0,0 +1,43 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.LatLng +import org.meshtastic.sdk.PositionUtils +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PositionUtilsTest { + @Test fun convertsScaledIntegersToDegrees() { + assertClose(37.712, PositionUtils.intToDegrees(377120000), 1e-9) + } + + @Test fun validatesPositions() { + assertFalse(PositionUtils.isValidPosition(0.0, 0.0)) + assertTrue(PositionUtils.isValidPosition(37.7, -122.4)) + assertFalse(PositionUtils.isValidPosition(91.0, 0.0)) + } + + @Test fun computesDistance() { + val sf = LatLng(37.7749, -122.4194) + val la = LatLng(34.0522, -118.2437) + + assertClose(559_000.0, PositionUtils.distance(sf, la), 5_000.0) + } + + @Test fun computesBearing() { + assertClose(90.0, PositionUtils.bearing(0.0, 0.0, 0.0, 1.0), 0.0001) + assertClose(0.0, PositionUtils.bearing(0.0, 0.0, 1.0, 0.0), 0.0001) + } +} + +private fun assertClose(expected: Double, actual: Double, tolerance: Double) { + require(abs(expected - actual) <= tolerance) { "expected $expected got $actual" } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt new file mode 100644 index 0000000..8adfa52 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/PresenceTimerTest.kt @@ -0,0 +1,160 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorage +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class PresenceTimerTest { + private fun TestScope.connectedClient( + storage: StorageProvider, + myNodeNum: Int = 0x11111111, + presenceTimeout: Duration = 1.seconds, + ): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:presence-timer"), + autoHandshake = true, + nodeNum = myNodeNum, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(storage) + .coroutineContext(backgroundScope.coroutineContext) + .autoSyncTimeOnConnect(false) + .presenceTimeout(presenceTimeout) + .build() + return transport to client + } + + @Test + fun staleHeartbeatEmitsWentOfflineAndNewTrafficEmitsCameOnline() = runTest { + val remoteNode = NodeId(0x22222222) + val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds + val storage = SeededHeartbeatStorageProvider(mapOf(remoteNode to staleHeartbeatMs)) + val (transport, client) = connectedClient(storage) + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.nodes.collect { change -> + when (change) { + is NodeChange.WentOffline, is NodeChange.CameOnline -> observed += change + else -> Unit + } + } + } + + client.connect() + runCurrent() + + advanceTimeBy(30.seconds) + runCurrent() + + val wentOffline = observed.single { it is NodeChange.WentOffline } + assertIs(wentOffline) + assertEquals(remoteNode, wentOffline.nodeId) + assertEquals((staleHeartbeatMs / 1000).toInt(), wentOffline.lastHeard) + + transport.injectPacket( + MeshPacket( + from = remoteNode.raw, + to = 0, + decoded = Data(portnum = PortNum.TEXT_MESSAGE_APP), + ), + ) + runCurrent() + + val cameOnline = observed.last() + assertIs(cameOnline) + assertEquals(remoteNode, cameOnline.nodeId) + + collector.cancel() + client.disconnect() + } + + @Test + fun selfNodeIsNeverMarkedOffline() = runTest { + val myNode = NodeId(0x11111111) + val staleHeartbeatMs = Clock.System.now().toEpochMilliseconds() - 5.seconds.inWholeMilliseconds + val storage = SeededHeartbeatStorageProvider(mapOf(myNode to staleHeartbeatMs)) + val (_, client) = connectedClient(storage, myNodeNum = myNode.raw) + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.nodes.collect { change -> + when (change) { + is NodeChange.WentOffline, is NodeChange.CameOnline -> observed += change + else -> Unit + } + } + } + + client.connect() + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + + assertTrue(observed.isEmpty()) + + collector.cancel() + client.disconnect() + } + + @Test + fun freshNodesAreNotMarkedOffline() = runTest { + val remoteNode = NodeId(0x33333333) + val freshHeartbeatMs = Clock.System.now().toEpochMilliseconds() + val storage = SeededHeartbeatStorageProvider(mapOf(remoteNode to freshHeartbeatMs)) + val (_, client) = connectedClient(storage, presenceTimeout = 60.seconds) + + val observed = mutableListOf() + val collector = backgroundScope.launch { + client.nodes.collect { change -> + when (change) { + is NodeChange.WentOffline, is NodeChange.CameOnline -> observed += change + else -> Unit + } + } + } + + client.connect() + runCurrent() + advanceTimeBy(30.seconds) + runCurrent() + + assertTrue(observed.isEmpty()) + + collector.cancel() + client.disconnect() + } +} + +private class SeededHeartbeatStorageProvider(private val heartbeats: Map) : StorageProvider { + override suspend fun activate(identity: TransportIdentity): DeviceStorage = InMemoryStorage().also { storage -> + heartbeats.forEach { (nodeId, heartbeatMs) -> + storage.saveHeartbeat(nodeId, heartbeatMs) + } + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt new file mode 100644 index 0000000..e752317 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RetryPolicyTest.kt @@ -0,0 +1,63 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +class RetryPolicyTest { + @Test fun noneReturnsNullImmediately() { + assertNull(RetryPolicy.None.delayForAttempt(0)) + } + + @Test fun fixedReturnsConfiguredDelay() { + val policy = RetryPolicy.Fixed(maxAttempts = 3, delay = 7.seconds) + assertEquals(7.seconds, policy.delayForAttempt(0)) + } + + @Test fun fixedReturnsNullAtMaxAttempts() { + val policy = RetryPolicy.Fixed(maxAttempts = 3, delay = 7.seconds) + assertNull(policy.delayForAttempt(policy.maxAttempts)) + } + + @Test fun exponentialBackoffGrowsGeometrically() { + val policy = RetryPolicy.ExponentialBackoff( + maxAttempts = 4, + initialDelay = 1.seconds, + maxDelay = 20.seconds, + multiplier = 2.0, + jitterFactor = 0.0, + ) + + assertEquals(1.seconds, policy.delayForAttempt(0)) + assertEquals(2.seconds, policy.delayForAttempt(1)) + assertEquals(4.seconds, policy.delayForAttempt(2)) + assertEquals(8.seconds, policy.delayForAttempt(3)) + } + + @Test fun exponentialBackoffReturnsNullAtMaxAttempts() { + val policy = RetryPolicy.ExponentialBackoff(maxAttempts = 4, jitterFactor = 0.0) + assertNull(policy.delayForAttempt(policy.maxAttempts)) + } + + @Test fun exponentialBackoffIsCappedAtMaxDelay() { + val policy = RetryPolicy.ExponentialBackoff( + maxAttempts = 4, + initialDelay = 10.seconds, + maxDelay = 20.seconds, + multiplier = 3.0, + jitterFactor = 0.0, + ) + + assertEquals(10.seconds, policy.delayForAttempt(0)) + assertEquals(20.seconds, policy.delayForAttempt(1)) + assertEquals(20.seconds, policy.delayForAttempt(2)) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt new file mode 100644 index 0000000..05380a8 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/RouteDiscoveryResultTest.kt @@ -0,0 +1,74 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlin.test.Test +import kotlin.test.assertEquals + +class RouteDiscoveryResultTest { + @Test fun fromProtoAssemblesFullRoutes() { + val source = NodeId(0x111) + val destination = NodeId(0x444) + + val result = RouteDiscoveryResult.fromProto( + source = source, + destination = destination, + intermediateRoute = listOf(0x222, 0x333), + intermediateRouteBack = listOf(0x333, 0x222), + snrTowards = listOf(40f, 32f), + snrBack = listOf(36f, 28f), + ) + + assertEquals(listOf(source, NodeId(0x222), NodeId(0x333), destination), result.route) + assertEquals(listOf(destination, NodeId(0x333), NodeId(0x222), source), result.routeBack) + assertEquals(listOf(40f, 32f), result.snrTowards) + assertEquals(listOf(36f, 28f), result.snrBack) + } + + @Test fun hopsAwayCountsIntermediateNodes() { + val routed = RouteDiscoveryResult( + route = listOf(NodeId(1), NodeId(2), NodeId(3), NodeId(4)), + routeBack = listOf(NodeId(4), NodeId(1)), + ) + val direct = RouteDiscoveryResult.fromProto( + source = NodeId(10), + destination = NodeId(20), + intermediateRoute = emptyList(), + intermediateRouteBack = emptyList(), + ) + + assertEquals(2, routed.hopsAway) + assertEquals(0, direct.hopsAway) + assertEquals(listOf(NodeId(10), NodeId(20)), direct.route) + assertEquals(listOf(NodeId(20), NodeId(10)), direct.routeBack) + } + + @Test fun formatRouteProducesReadableOutput() { + val result = RouteDiscoveryResult( + route = listOf(NodeId(1), NodeId(2), NodeId(3)), + routeBack = listOf(NodeId(3), NodeId(1)), + snrTowards = listOf(10.5f, 8.25f), + snrBack = listOf(7.75f), + ) + + val formatted = result.formatRoute { node -> "Node-${node.raw}" } + + assertEquals( + """ + Route (3 nodes): + Node-1 (SNR: 10.5) + Node-2 (SNR: 8.25) + Node-3 + Route back (2 nodes): + Node-3 (SNR: 7.75) + Node-1 + """.trimIndent(), + formatted.trimEnd(), + ) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt new file mode 100644 index 0000000..21df431 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SfppHashTest.kt @@ -0,0 +1,38 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.SfppHash +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class SfppHashTest { + @Test fun outputIsAlways16Bytes() { + assertEquals(16, SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3).size) + } + + @Test fun hashIsDeterministic() { + val first = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3) + val second = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3) + + assertContentEquals(first, second) + } + + @Test fun differentInputsProduceDifferentHashes() { + val first = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 3) + val second = SfppHash.compute("payload".encodeToByteArray(), 1, 2, 4) + + assertFalse(first.contentEquals(second)) + } + + @Test fun emptyPayloadWorks() { + assertEquals(16, SfppHash.compute(byteArrayOf(), 1, 2, 3).size) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt new file mode 100644 index 0000000..bb8c6f6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/SharedContactUrlTest.kt @@ -0,0 +1,45 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import org.meshtastic.proto.SharedContact +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class SharedContactUrlTest { + @Test fun roundTripSharedContact() { + val contact = SharedContact( + node_num = 0xa1b2c3d4.toInt(), + should_ignore = true, + manually_verified = true, + ) + + val url = contact.toUrl() + + assertTrue(url.startsWith(SharedContactUrl.PREFIX)) + assertEquals(contact, SharedContactUrl.parse(url)) + } + + @Test fun parseRejectsInvalidUrl() { + assertNull(SharedContactUrl.parse("https://example.com/contact")) + assertNull(SharedContactUrl.parse("https://meshtastic.org/v/#@@@")) + } + + @Test fun parseIgnoresQueryParams() { + val contact = SharedContact(node_num = 1234) + val withQuery = contact.toUrl() + "?from=test" + + val parsed = SharedContactUrl.parse(withQuery) + + assertNotNull(parsed) + assertEquals(contact, parsed) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt new file mode 100644 index 0000000..fda5f06 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardApiTest.kt @@ -0,0 +1,62 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.StoreForwardStats +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class StoreForwardApiTest { + + @Test + fun storeForwardStatsDefaults() { + val stats = StoreForwardStats() + assertEquals(0, stats.messagesStored) + assertEquals(0, stats.messagesMax) + assertEquals(false, stats.heartbeat) + } + + @Test + fun storeForwardStatsWithValues() { + val stats = StoreForwardStats( + messagesStored = 42, + messagesMax = 100, + uptime = 3600, + requests = 10, + requestsFailed = 1, + heartbeat = true, + ) + assertEquals(42, stats.messagesStored) + assertEquals(100, stats.messagesMax) + assertEquals(true, stats.heartbeat) + } + + @Test + fun storeForwardEventsAreSealed() { + val discovered: StoreForwardEvent = StoreForwardEvent.ServerDiscovered(NodeId(1)) + assertIs(discovered) + assertEquals(NodeId(1), discovered.nodeId) + + val lost: StoreForwardEvent = StoreForwardEvent.ServerLost(NodeId(2)) + assertIs(lost) + + val started: StoreForwardEvent = StoreForwardEvent.HistoryReplayStarted(NodeId(3), messageCount = 5) + assertIs(started) + assertEquals(5, started.messageCount) + + val complete: StoreForwardEvent = StoreForwardEvent.HistoryReplayComplete(NodeId(3), delivered = 4) + assertIs(complete) + assertEquals(4, complete.delivered) + + val heartbeat: StoreForwardEvent = StoreForwardEvent.Heartbeat(NodeId(5)) + assertIs(heartbeat) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt new file mode 100644 index 0000000..0ffcde6 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/ext/StoreForwardImplTest.kt @@ -0,0 +1,192 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.ext + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreAndForward +import org.meshtastic.sdk.AdminResult +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardImplTest { + + private fun kotlinx.coroutines.test.TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:p2-store-forward"), + autoHandshake = true, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + @Test + fun storeForwardStartsEmptyAndEventsFlowIsCollectible() = runTest { + val (_, client) = connectedClient() + client.connect() + runCurrent() + + val storeForward = client.storeForward + assertNotNull(storeForward) + assertEquals(emptyList(), storeForward.servers.value) + + val collected = mutableListOf() + val collector = backgroundScope.launch { + storeForward.events.collect { collected += it } + } + runCurrent() + + assertTrue(collector.isActive) + assertTrue(collected.isEmpty()) + + collector.cancel() + client.disconnect() + } + + @Test + fun storeForwardTracksServersAndHeartbeats() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val collected = mutableListOf() + val collector = backgroundScope.launch { + client.storeForward.events.collect { collected += it } + } + runCurrent() + + val server = NodeId(0x10203040) + transport.injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 300), + ), + fromNode = server.raw, + ) + runCurrent() + + assertEquals(listOf(server), client.storeForward.servers.value) + assertEquals(StoreForwardEvent.ServerDiscovered(server), collected.first()) + assertEquals(StoreForwardEvent.Heartbeat(server), collected.last()) + + collector.cancel() + client.disconnect() + } + + @Test + fun requestHistoryUsesKnownServerAndReturnsPendingCount() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val storeForward = client.storeForward + runCurrent() + val server = NodeId(0x55667788) + transport.injectStoreForwardResponse( + requestId = 0, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HEARTBEAT, + heartbeat = StoreAndForward.Heartbeat(period = 120), + ), + fromNode = server.raw, + ) + runCurrent() + + val outboundBefore = transport.outboundPackets().size + val deferred = async { storeForward.requestHistory(server = null) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(server.raw, request.to) + assertEquals(StoreAndForward.RequestResponse.CLIENT_HISTORY, payload.rr) + + transport.injectStoreForwardResponse( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_HISTORY, + history = StoreAndForward.History(history_messages = 3, window = 120000), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(3, result.value) + client.disconnect() + } + + @Test + fun requestStatsMapsProtoStatistics() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val server = NodeId(0x12345678) + val outboundBefore = transport.outboundPackets().size + val deferred = async { client.storeForward.requestStats(server) } + runCurrent() + + val request = transport.outboundPackets().drop(outboundBefore) + .last { it.decoded?.portnum == PortNum.STORE_FORWARD_APP } + val payload = StoreAndForward.ADAPTER.decode(request.decoded!!.payload) + assertEquals(StoreAndForward.RequestResponse.CLIENT_STATS, payload.rr) + + transport.injectStoreForwardResponse( + requestId = request.id, + message = StoreAndForward( + rr = StoreAndForward.RequestResponse.ROUTER_STATS, + stats = StoreAndForward.Statistics( + messages_saved = 9, + messages_max = 64, + up_time = 3600, + requests = 12, + requests_history = 7, + heartbeat = true, + ), + ), + fromNode = server.raw, + ) + runCurrent() + + val result = deferred.await() + assertIs>(result) + assertEquals(9, result.value.messagesStored) + assertEquals(64, result.value.messagesMax) + assertEquals(3600, result.value.uptime) + assertEquals(7, result.value.requests) + assertEquals(0, result.value.requestsFailed) + assertEquals(true, result.value.heartbeat) + client.disconnect() + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt new file mode 100644 index 0000000..36f1817 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/ConfigMergeTest.kt @@ -0,0 +1,96 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.Config +import org.meshtastic.proto.Config.BluetoothConfig +import org.meshtastic.proto.Config.DeviceConfig +import org.meshtastic.proto.Config.DisplayConfig +import org.meshtastic.proto.Config.LoRaConfig +import org.meshtastic.proto.Config.PowerConfig +import org.meshtastic.proto.ModuleConfig +import org.meshtastic.proto.ModuleConfig.MQTTConfig +import org.meshtastic.proto.ModuleConfig.TelemetryConfig +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConfigMergeTest { + + @Test + fun mergeConfigs_replacesMatchingSection() { + val existing = listOf( + Config(device = DeviceConfig(role = Config.DeviceConfig.Role.CLIENT)), + Config(lora = LoRaConfig(region = Config.LoRaConfig.RegionCode.US)), + Config(display = DisplayConfig(screen_on_secs = 30)), + ) + val written = listOf( + Config(lora = LoRaConfig(region = Config.LoRaConfig.RegionCode.EU_868)), + ) + val merged = mergeConfigs(existing, written) + + assertEquals(3, merged.size) + // device untouched + assertEquals(Config.DeviceConfig.Role.CLIENT, merged[0].device?.role) + // lora replaced + assertEquals(Config.LoRaConfig.RegionCode.EU_868, merged[1].lora?.region) + // display untouched + assertEquals(30, merged[2].display?.screen_on_secs) + } + + @Test + fun mergeConfigs_appendsNewSection() { + val existing = listOf( + Config(device = DeviceConfig(role = Config.DeviceConfig.Role.ROUTER)), + ) + val written = listOf( + Config(bluetooth = BluetoothConfig(enabled = true)), + ) + val merged = mergeConfigs(existing, written) + + assertEquals(2, merged.size) + assertEquals(Config.DeviceConfig.Role.ROUTER, merged[0].device?.role) + assertEquals(true, merged[1].bluetooth?.enabled) + } + + @Test + fun mergeConfigs_emptyWrittenReturnsExisting() { + val existing = listOf(Config(power = PowerConfig(on_battery_shutdown_after_secs = 120))) + val merged = mergeConfigs(existing, emptyList()) + assertEquals(existing, merged) + } + + @Test + fun mergeModuleConfigs_replacesMatchingSection() { + val existing = listOf( + ModuleConfig(mqtt = MQTTConfig(enabled = true)), + ModuleConfig(telemetry = TelemetryConfig(device_update_interval = 60)), + ) + val written = listOf( + ModuleConfig(telemetry = TelemetryConfig(device_update_interval = 30)), + ) + val merged = mergeModuleConfigs(existing, written) + + assertEquals(2, merged.size) + assertEquals(true, merged[0].mqtt?.enabled) + assertEquals(30, merged[1].telemetry?.device_update_interval) + } + + @Test + fun sectionKey_configSections() { + assertEquals("device", Config(device = DeviceConfig()).sectionKey()) + assertEquals("lora", Config(lora = LoRaConfig()).sectionKey()) + assertEquals(null, Config().sectionKey()) + } + + @Test + fun sectionKey_moduleConfigSections() { + assertEquals("mqtt", ModuleConfig(mqtt = MQTTConfig()).sectionKey()) + assertEquals("telemetry", ModuleConfig(telemetry = TelemetryConfig()).sectionKey()) + assertEquals(null, ModuleConfig().sectionKey()) + } +} diff --git a/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt new file mode 100644 index 0000000..bb123a2 --- /dev/null +++ b/core/src/commonTest/kotlin/org/meshtastic/sdk/internal/NodeDiffTest.kt @@ -0,0 +1,148 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import org.meshtastic.proto.DeviceMetrics +import org.meshtastic.proto.NodeInfo +import org.meshtastic.proto.Position +import org.meshtastic.proto.User +import org.meshtastic.sdk.NodeField +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class NodeDiffTest { + + private val baseNode = NodeInfo( + num = 1, + user = User(id = "!aabbccdd", long_name = "Alpha", short_name = "AL"), + position = Position(latitude_i = 370000000, longitude_i = -1220000000), + snr = 10.5f, + last_heard = 1000, + device_metrics = DeviceMetrics(battery_level = 80, voltage = 3.9f), + channel = 0, + via_mqtt = false, + hops_away = 0, + is_favorite = false, + is_ignored = false, + is_muted = false, + is_key_manually_verified = false, + ) + + @Test + fun identicalNodes_returnsEmptySet() { + val result = diffNodeFields(baseNode, baseNode.copy()) + assertTrue(result.isEmpty(), "Expected empty set for identical nodes, got: $result") + } + + @Test + fun userNameChange_flagsNameAndUser() { + val updated = baseNode.copy(user = baseNode.user!!.copy(long_name = "Beta")) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Name in result) + assertTrue(NodeField.User in result) + } + + @Test + fun userShortNameChange_flagsNameAndUser() { + val updated = baseNode.copy(user = baseNode.user!!.copy(short_name = "BT")) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Name in result) + assertTrue(NodeField.User in result) + } + + @Test + fun userOtherFieldChange_flagsUserOnly() { + val updated = baseNode.copy(user = baseNode.user!!.copy(id = "!11223344")) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.User in result) + assertTrue(NodeField.Name !in result, "Name should not be flagged for non-name user changes") + } + + @Test + fun positionChange_flagsPosition() { + val updated = baseNode.copy(position = Position(latitude_i = 380000000, longitude_i = -1220000000)) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Position in result) + } + + @Test + fun snrChange_flagsSignalQuality() { + val updated = baseNode.copy(snr = 5.0f) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + } + + @Test + fun hopsAwayChange_flagsSignalQuality() { + val updated = baseNode.copy(hops_away = 2) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + } + + @Test + fun viaMqttChange_flagsSignalQuality() { + val updated = baseNode.copy(via_mqtt = true) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + } + + @Test + fun batteryChange_flagsBatteryAndTelemetry() { + val updated = baseNode.copy( + device_metrics = DeviceMetrics(battery_level = 50, voltage = 3.5f), + ) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Battery in result) + assertTrue(NodeField.Telemetry in result) + } + + @Test + fun deviceMetricsNonBatteryChange_flagsTelemetryOnly() { + val updated = baseNode.copy( + device_metrics = baseNode.device_metrics!!.copy(channel_utilization = 25.0f), + ) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Telemetry in result) + assertTrue(NodeField.Battery !in result) + } + + @Test + fun lastHeardChange_flagsLastSeen() { + val updated = baseNode.copy(last_heard = 2000) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.LastSeen in result) + } + + @Test + fun favoriteChange_flagsOther() { + val updated = baseNode.copy(is_favorite = true) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Other in result) + } + + @Test + fun channelChange_flagsOther() { + val updated = baseNode.copy(channel = 3) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.Other in result) + } + + @Test + fun multipleFieldChanges_flagsAll() { + val updated = baseNode.copy( + snr = 2.0f, + last_heard = 5000, + position = Position(latitude_i = 390000000, longitude_i = -1210000000), + ) + val result = diffNodeFields(baseNode, updated) + assertTrue(NodeField.SignalQuality in result) + assertTrue(NodeField.LastSeen in result) + assertTrue(NodeField.Position in result) + } +} diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt new file mode 100644 index 0000000..2dacea0 --- /dev/null +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/MeshTopologyTest.kt @@ -0,0 +1,238 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class MeshTopologyTest { + @Test + fun `addNeighborInfo populates nodes and edges`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 7.5f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = -1.0f), + ), + lastUpdated = 99, + ), + ) + + assertEquals(setOf(NodeId(1), NodeId(2), NodeId(3)), topology.nodes()) + assertEquals(2, topology.edgeCount()) + assertEquals( + setOf( + MeshTopology.Edge(NodeId(1), NodeId(2), 7.5f, 99), + MeshTopology.Edge(NodeId(1), NodeId(3), -1.0f, 99), + ), + topology.getNeighbors(NodeId(1)).toSet(), + ) + assertEquals(MeshTopology.Edge(NodeId(1), NodeId(2), 7.5f, 99), topology.getEdge(NodeId(1), NodeId(2))) + } + + @Test + fun `getNeighbors returns empty for unknown node`() = runTest { + val topology = MeshTopology() + + assertTrue(topology.getNeighbors(NodeId(404)).isEmpty()) + } + + @Test + fun `isDirectReach works bidirectionally`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 4.0f)), + ), + ) + + assertTrue(topology.isDirectReach(NodeId(1), NodeId(2))) + assertTrue(topology.isDirectReach(NodeId(2), NodeId(1))) + assertFalse(topology.isDirectReach(NodeId(1), NodeId(3))) + } + + @Test + fun `shortestPath finds multi-hop route`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f)), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(2), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 3.0f)), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(3), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(4), snr = 1.0f)), + ), + ) + + assertEquals( + listOf(NodeId(1), NodeId(2), NodeId(3), NodeId(4)), + topology.shortestPath(NodeId(1), NodeId(4)), + ) + } + + @Test + fun `shortestPath returns empty when unreachable`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f)), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(4), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(5), snr = 2.0f)), + ), + ) + + assertTrue(topology.shortestPath(NodeId(1), NodeId(5)).isEmpty()) + } + + @Test + fun `removeNode clears all references`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 1.0f), + ), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(4), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 2.0f)), + ), + ) + + topology.removeNode(NodeId(2)) + + assertFalse(NodeId(2) in topology.nodes()) + assertNull(topology.getEdge(NodeId(1), NodeId(2))) + assertNull(topology.getEdge(NodeId(4), NodeId(2))) + assertFalse(topology.isDirectReach(NodeId(1), NodeId(2))) + assertEquals(listOf(MeshTopology.Edge(NodeId(1), NodeId(3), 1.0f, 0)), topology.allEdges()) + } + + @Test + fun `addNeighborInfo replaces existing edges from same reporter`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 5.0f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 4.0f), + ), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(4), snr = 9.0f)), + lastUpdated = 10, + ), + ) + + assertEquals(listOf(MeshTopology.Edge(NodeId(1), NodeId(4), 9.0f, 10)), topology.getNeighbors(NodeId(1))) + assertNull(topology.getEdge(NodeId(1), NodeId(2))) + assertNull(topology.getEdge(NodeId(1), NodeId(3))) + assertEquals(setOf(NodeId(1), NodeId(4)), topology.nodes()) + } + + @Test + fun `clear removes all nodes and edges`() = runTest { + val topo = MeshTopology() + + topo.addNeighborInfo(NeighborInfo(NodeId(1), listOf(NeighborInfo.Neighbor(NodeId(2), 5f)))) + topo.clear() + + assertEquals(emptySet(), topo.nodes()) + assertEquals(0, topo.edgeCount()) + assertEquals(emptyList(), topo.shortestPath(NodeId(1), NodeId(2))) + } + + @Test + fun `self-loop does not break graph operations`() = runTest { + val topo = MeshTopology() + + topo.addNeighborInfo( + NeighborInfo( + NodeId(1), + listOf( + NeighborInfo.Neighbor(NodeId(1), 10f), + NeighborInfo.Neighbor(NodeId(2), 5f), + ), + ), + ) + + assertEquals(listOf(NodeId(1)), topo.shortestPath(NodeId(1), NodeId(1))) + assertEquals(listOf(NodeId(1), NodeId(2)), topo.shortestPath(NodeId(1), NodeId(2))) + assertTrue(topo.isDirectReach(NodeId(1), NodeId(1))) + } + + @Test + fun `disconnected components have no path between them`() = runTest { + val topo = MeshTopology() + + topo.addNeighborInfo(NeighborInfo(NodeId(1), listOf(NeighborInfo.Neighbor(NodeId(2), 5f)))) + topo.addNeighborInfo(NeighborInfo(NodeId(3), listOf(NeighborInfo.Neighbor(NodeId(4), 7f)))) + + assertEquals(emptyList(), topo.shortestPath(NodeId(1), NodeId(4))) + assertFalse(topo.isDirectReach(NodeId(1), NodeId(4))) + } + + @Test + fun `allEdges returns correct count`() = runTest { + val topology = MeshTopology() + + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(1), + neighbors = listOf( + NeighborInfo.Neighbor(nodeId = NodeId(2), snr = 1.0f), + NeighborInfo.Neighbor(nodeId = NodeId(3), snr = 2.0f), + ), + ), + ) + topology.addNeighborInfo( + NeighborInfo( + nodeId = NodeId(4), + neighbors = listOf(NeighborInfo.Neighbor(nodeId = NodeId(1), snr = 3.0f)), + ), + ) + + assertEquals(3, topology.allEdges().size) + assertEquals(3, topology.edgeCount()) + } +} diff --git a/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt new file mode 100644 index 0000000..8b7aaab --- /dev/null +++ b/core/src/jvmTest/kotlin/org/meshtastic/sdk/internal/StoreForwardApiImplSfppTest.kt @@ -0,0 +1,270 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.internal + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import okio.ByteString.Companion.toByteString +import org.meshtastic.proto.Data +import org.meshtastic.proto.MeshPacket +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.StoreForwardPlusPlus +import org.meshtastic.sdk.NodeId +import org.meshtastic.sdk.RadioClient +import org.meshtastic.sdk.SfppHash +import org.meshtastic.sdk.StoreForwardEvent +import org.meshtastic.sdk.TransportIdentity +import org.meshtastic.sdk.testing.FakeRadioTransport +import org.meshtastic.sdk.testing.InMemoryStorageProvider +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class StoreForwardApiImplSfppTest { + + private fun kotlinx.coroutines.test.TestScope.connectedClient(): Pair { + val transport = FakeRadioTransport( + identity = TransportIdentity("fake:p2-store-forward-sfpp"), + autoHandshake = true, + ) + val client = RadioClient.Builder() + .transport(transport) + .storage(InMemoryStorageProvider()) + .autoSyncTimeOnConnect(false) + .coroutineContext(backgroundScope.coroutineContext) + .rpcTimeout(60.seconds) + .sendTimeout(60.seconds) + .build() + return transport to client + } + + @Test + fun linkProvidePacketEmitsSfppLinkProvidedEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expectedHash = byteArrayOf(1, 2, 3, 4) + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message_hash = expectedHash.toByteString(), + commit_hash = byteArrayOf(9, 8, 7).toByteString(), + encapsulated_id = 0x1234, + encapsulated_to = 0x01020304, + encapsulated_from = 0x55667788, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertEquals(0x1234, event.packetId) + assertEquals(0x55667788, event.from) + assertEquals(0x01020304, event.to) + assertEquals(true, event.confirmed) + assertContentEquals(expectedHash, assertNotNull(event.messageHash)) + + client.disconnect() + } + + @Test + fun canonAnnouncePacketEmitsSfppCanonAnnouncedEvent() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expectedHash = byteArrayOf(7, 6, 5, 4) + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppCanonAnnounced } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.CANON_ANNOUNCE, + message_hash = expectedHash.toByteString(), + encapsulated_rxtime = 0xFEDCBA98.toInt(), + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertContentEquals(expectedHash, event.messageHash) + assertEquals(0xFEDCBA98L, event.rxTime) + + client.disconnect() + } + + @Test + fun fragmentPacketsStillEmitSfppLinkProvidedEvents() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val expectedHash = byteArrayOf(0xA, 0xB, 0xC) + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE_FIRSTHALF, + message_hash = expectedHash.toByteString(), + encapsulated_id = 77, + encapsulated_to = 88, + encapsulated_from = 99, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertEquals(77, event.packetId) + assertEquals(99, event.from) + assertEquals(88, event.to) + assertEquals(false, event.confirmed) + assertContentEquals(expectedHash, assertNotNull(event.messageHash)) + + client.disconnect() + } + + @Test + fun linkProvideComputesHashWhenMessageHashMissing() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val message = "payload".encodeToByteArray() + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + message = message.toByteString(), + encapsulated_id = 42, + encapsulated_to = 0, + encapsulated_from = 0x0BADF00D, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + val expectedHash = SfppHash.compute( + payload = message, + to = NodeId.BROADCAST.raw, + from = 0x0BADF00D, + id = 42, + ) + assertEquals(42, event.packetId) + assertEquals(0x0BADF00D, event.from) + assertEquals(NodeId.BROADCAST.raw, event.to) + assertContentEquals(expectedHash, assertNotNull(event.messageHash)) + + client.disconnect() + } + + @Test + fun `malformed SFPP payload does not crash`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectStoreForwardPayload(byteArrayOf(0x0A)) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + @Test + fun `SFPP LINK_PROVIDE with no hash and no message emits event with null hash`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val eventDeferred = async { + client.storeForward.events.first { it is StoreForwardEvent.SfppLinkProvided } + } + runCurrent() + + transport.injectSfpp( + StoreForwardPlusPlus( + sfpp_message_type = StoreForwardPlusPlus.SFPP_message_type.LINK_PROVIDE, + encapsulated_id = 99, + encapsulated_to = 0x11111111, + encapsulated_from = 0x22222222, + ), + ) + runCurrent() + + val event = assertIs(eventDeferred.await()) + assertEquals(99, event.packetId) + assertEquals(0x22222222, event.from) + assertEquals(0x11111111, event.to) + assertNull(event.messageHash) + assertFalse(event.confirmed) + + client.disconnect() + } + + @Test + fun `SFPP packet with unknown message type is ignored`() = runTest { + val (transport, client) = connectedClient() + client.connect() + runCurrent() + + val eventDeferred = async { client.storeForward.events.first() } + runCurrent() + + transport.injectStoreForwardPayload(byteArrayOf(0x08, 0x63)) + runCurrent() + + assertFalse(eventDeferred.isCompleted) + eventDeferred.cancel() + client.disconnect() + } + + private fun FakeRadioTransport.injectSfpp(message: StoreForwardPlusPlus, fromNode: Int = 0x10203040) { + injectStoreForwardPayload(StoreForwardPlusPlus.ADAPTER.encode(message), fromNode) + } + + private fun FakeRadioTransport.injectStoreForwardPayload(payload: ByteArray, fromNode: Int = 0x10203040) { + injectPacket( + MeshPacket( + id = 1, + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload.toByteString(), + ), + ), + ) + } +} diff --git a/docs/SPEC.md b/docs/SPEC.md index 83a9bed..15295ca 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -411,7 +411,7 @@ public interface AdminApi { public suspend fun reboot(after: Duration = Duration.ZERO): AdminResult public suspend fun shutdown(after: Duration = Duration.ZERO): AdminResult public suspend fun factoryReset(preserveBleBonds: Boolean = true): AdminResult - public suspend fun nodeDbReset(preserveFavorites: Boolean = true): AdminResult + public suspend fun nodeDbReset(): AdminResult /** * Push the host clock to the device as `set_time_only`. Useful for routers and headless diff --git a/docs/api-reference.md b/docs/api-reference.md index 7ada67a..9f75aab 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -8,7 +8,7 @@ | Package | Stability | Contents | |---|---|---| -| `org.meshtastic.sdk` | Public | `RadioClient`, `MessageHandle`, `SendState`, `SendFailure`, `SendOutcome`, `MeshEvent`, `DroppedFlow`, `NodeChange`, `NodeField`, `ConnectionState`, `ConfigPhase`, `TransportSpec`, `TransportIdentity`, `RadioTransport`, `TransportState`, `Frame`, `MeshtasticException`, `NodeId`, `ChannelIndex`, `MessageId`, `LogSink`, `LogLevel`, `PayloadRedactor`, `StorageProvider`, `DeviceStorage`, `ConfigBundle`, `KeyVerificationPrompt`, `AdminApi`, `AdminResult`, `TelemetryApi`, `RoutingApi`, `Clock`, `Constants`, `SessionPasskey` | +| `org.meshtastic.sdk` | Public | `RadioClient`, `MessageHandle`, `SendState`, `SendFailure`, `SendOutcome`, `MeshEvent`, `DroppedFlow`, `NodeChange`, `NodeField`, `ConnectionState`, `ConfigPhase`, `TransportSpec`, `TransportIdentity`, `RadioTransport`, `TransportState`, `Frame`, `MeshtasticException`, `NodeId`, `ChannelIndex`, `MessageId`, `LogSink`, `LogLevel`, `PayloadRedactor`, `StorageProvider`, `DeviceStorage`, `ConfigBundle`, `KeyVerificationPrompt`, `AdminApi`, `AdminResult`, `TelemetryApi`, `RoutingApi`, `StoreForwardApi`, `StoreForwardStats`, `StoreForwardEvent`, `Clock`, `Constants`, `SessionPasskey` | | `org.meshtastic.sdk.transport.tcp` | Public | `TcpTransport` | | `org.meshtastic.sdk.transport.ble` | Public | `BleTransport` | | `org.meshtastic.sdk.transport.serial` | Public | `AndroidSerialPorts` (Android), `JvmSerialPorts` (JVM) | @@ -73,11 +73,13 @@ RadioClient.Builder() |---|---|---| | `send(packet: MeshPacket): MessageHandle` | handle (state already `Queued`) | `NotConnected`, `PayloadTooLarge` | | `sendText(text: String, channel: ChannelIndex = ChannelIndex(0), to: NodeId = NodeId.BROADCAST): MessageHandle` | handle | same as `send` | +| `sendReaction(emoji: String, to: NodeId = NodeId.BROADCAST, channel: ChannelIndex = ChannelIndex(0), replyId: Int): MessageHandle` | handle | same as `send` | +| `sendRaw(frame: ToRadio)` | `Unit` | `NotConnected` | | `nodeSnapshot(): Map` | snapshot | `NotConnected` | ### Sub-API namespaces -`client.admin: AdminApi`, `client.telemetry: TelemetryApi`, `client.routing: RoutingApi` are fully implemented and available while the client is in the `Connected` state. Each is lazily initialized on first access. See the respective interface definitions below for method inventories. +`client.admin: AdminApi`, `client.telemetry: TelemetryApi`, `client.routing: RoutingApi`, `client.storeForward: StoreForwardApi` are fully implemented and available while the client is in the `Connected` state. Each is lazily initialized on first access. See the respective interface definitions below for method inventories. ## `MessageHandle` @@ -163,6 +165,8 @@ public sealed interface NodeChange { public data class Added(val node: NodeInfo) : NodeChange public data class Updated(val node: NodeInfo, val changed: Set) : NodeChange public data class Removed(val nodeId: NodeId) : NodeChange + public data class WentOffline(val nodeId: NodeId, val lastHeard: Int) : NodeChange + public data class CameOnline(val nodeId: NodeId) : NodeChange } public enum class NodeField { @@ -175,6 +179,10 @@ Contract: - Deltas MUST NOT drop (`SUSPEND` overflow on the backing flow). - `Updated.changed` is the *minimal* set of fields whose value differs from the prior `NodeInfo`. Useful for diffing UI state without re-rendering everything. +### Presence tracking *(since 0.2.0)* + +`WentOffline` and `CameOnline` are emitted by the engine when presence tracking is enabled via `Builder.presenceTimeout()`. `WentOffline.lastHeard` is the node's most recent `last_heard` epoch, allowing the UI to display "last seen" timestamps. A node transitions back to online when it sends any new packet. + ## `ConnectionState` ```kotlin @@ -285,6 +293,9 @@ Each method maps onto a single `AdminMessage` round-trip with the device. Setter ```kotlin public interface AdminApi { + /** Returns a copy of this AdminApi that targets a remote node. All subsequent calls route to [dest]. @since 0.2.0 */ + public fun forNode(dest: NodeId): AdminApi + public suspend fun getConfig(type: AdminMessage.ConfigType): AdminResult public suspend fun setConfig(config: Config): AdminResult public suspend fun getModuleConfig(type: AdminMessage.ModuleConfigType): AdminResult @@ -303,7 +314,7 @@ public interface AdminApi { public suspend fun reboot(after: Duration = Duration.ZERO): AdminResult public suspend fun shutdown(after: Duration = Duration.ZERO): AdminResult public suspend fun factoryReset(preserveBleBonds: Boolean = true): AdminResult - public suspend fun nodeDbReset(preserveFavorites: Boolean = true): AdminResult + public suspend fun nodeDbReset(): AdminResult /** See pitfall §19.17. `autoSyncTimeOnConnect=true` calls this once post-handshake on >60s skew. */ public suspend fun setTime(at: Instant = Clock.System.now()): AdminResult @@ -317,12 +328,13 @@ public sealed interface AdminResult { public data object SessionKeyExpired : AdminResult public data object Unauthorized : AdminResult public data object Timeout : AdminResult + public data object RateLimited : AdminResult public data object NodeUnreachable : AdminResult public data class Failed(val routingError: Routing.Error) : AdminResult } ``` -`SessionKeyExpired` triggers an automatic single retry inside the engine: the engine re-issues `get_owner_request` to refresh `session_passkey`, then replays the original admin call once. If the retry also returns `SessionKeyExpired`, the result surfaces unmodified. +`SessionKeyExpired` triggers an automatic single retry inside the engine: the engine re-issues `get_owner_request` to refresh `session_passkey`, then replays the original admin call once. If the retry also returns `SessionKeyExpired`, the result surfaces unmodified. `RateLimited` indicates the device rejected the call due to rate limiting (`Routing.Error.RATE_LIMIT_EXCEEDED`); callers should back off before retrying. ## `TelemetryApi` *(Phase 2)* @@ -350,6 +362,86 @@ public interface RoutingApi { } ``` +## `StoreForwardApi` *(since 0.2.0)* + +API for interacting with Store-and-Forward (S&F) nodes on the mesh. S&F nodes temporarily store messages for offline nodes and deliver them when the target comes back online. Access via `client.storeForward`. + +```kotlin +public interface StoreForwardApi { + /** Known S&F server nodes on the mesh. Updated reactively when nodes advertise capability. */ + public val servers: StateFlow> + + /** Request delivery of stored messages since [since] (epoch seconds). */ + public suspend fun requestHistory(since: Int? = null, server: NodeId? = null): AdminResult + + /** Query statistics from a S&F server. */ + public suspend fun requestStats(server: NodeId? = null): AdminResult + + /** Flow of S&F-specific events (heartbeats, replays, SFPP link/canon). */ + public val events: Flow +} + +public data class StoreForwardStats( + val messagesStored: Int, val messagesMax: Int, val uptime: Int, + val requests: Int, val requestsFailed: Int, val heartbeat: Boolean, +) + +public sealed interface StoreForwardEvent { + public data class ServerDiscovered(val nodeId: NodeId) : StoreForwardEvent + public data class ServerLost(val nodeId: NodeId) : StoreForwardEvent + public data class HistoryReplayStarted(val server: NodeId, val messageCount: Int) : StoreForwardEvent + public data class HistoryReplayComplete(val server: NodeId, val delivered: Int) : StoreForwardEvent + public data class Heartbeat(val server: NodeId) : StoreForwardEvent + public data class SfppLinkProvided(val packetId: Int, val from: Int, val to: Int, val messageHash: ByteArray?, val confirmed: Boolean) : StoreForwardEvent + public data class SfppCanonAnnounced(val messageHash: ByteArray, val rxTime: Long) : StoreForwardEvent +} +``` + +## Config Builder Extensions *(since 0.2.0)* + +Extension functions on `AdminApi` that build and send config protos in a single call, using Kotlin `copy {}` semantics: + +```kotlin +// Config types (8 extensions) +suspend fun AdminApi.setDeviceConfig(block: Config.DeviceConfig.() -> Config.DeviceConfig): AdminResult +suspend fun AdminApi.setPositionConfig(block: Config.PositionConfig.() -> Config.PositionConfig): AdminResult +suspend fun AdminApi.setPowerConfig(block: Config.PowerConfig.() -> Config.PowerConfig): AdminResult +suspend fun AdminApi.setNetworkConfig(block: Config.NetworkConfig.() -> Config.NetworkConfig): AdminResult +suspend fun AdminApi.setDisplayConfig(block: Config.DisplayConfig.() -> Config.DisplayConfig): AdminResult +suspend fun AdminApi.setLoraConfig(block: Config.LoRaConfig.() -> Config.LoRaConfig): AdminResult +suspend fun AdminApi.setBluetoothConfig(block: Config.BluetoothConfig.() -> Config.BluetoothConfig): AdminResult +suspend fun AdminApi.setSecurityConfig(block: Config.SecurityConfig.() -> Config.SecurityConfig): AdminResult + +// Module config types (15 extensions) +suspend fun AdminApi.setMqttConfig(block: ModuleConfig.MQTTConfig.() -> ModuleConfig.MQTTConfig): AdminResult +suspend fun AdminApi.setSerialConfig(block: ModuleConfig.SerialConfig.() -> ModuleConfig.SerialConfig): AdminResult +suspend fun AdminApi.setExternalNotificationConfig(block: ModuleConfig.ExternalNotificationConfig.() -> ModuleConfig.ExternalNotificationConfig): AdminResult +suspend fun AdminApi.setStoreForwardConfig(block: ModuleConfig.StoreForwardConfig.() -> ModuleConfig.StoreForwardConfig): AdminResult +suspend fun AdminApi.setRangeTestConfig(block: ModuleConfig.RangeTestConfig.() -> ModuleConfig.RangeTestConfig): AdminResult +suspend fun AdminApi.setTelemetryConfig(block: ModuleConfig.TelemetryConfig.() -> ModuleConfig.TelemetryConfig): AdminResult +suspend fun AdminApi.setCannedMessageConfig(block: ModuleConfig.CannedMessageConfig.() -> ModuleConfig.CannedMessageConfig): AdminResult +suspend fun AdminApi.setAudioConfig(block: ModuleConfig.AudioConfig.() -> ModuleConfig.AudioConfig): AdminResult +suspend fun AdminApi.setRemoteHardwareConfig(block: ModuleConfig.RemoteHardwareConfig.() -> ModuleConfig.RemoteHardwareConfig): AdminResult +suspend fun AdminApi.setNeighborInfoConfig(block: ModuleConfig.NeighborInfoConfig.() -> ModuleConfig.NeighborInfoConfig): AdminResult +suspend fun AdminApi.setAmbientLightingConfig(block: ModuleConfig.AmbientLightingConfig.() -> ModuleConfig.AmbientLightingConfig): AdminResult +suspend fun AdminApi.setDetectionSensorConfig(block: ModuleConfig.DetectionSensorConfig.() -> ModuleConfig.DetectionSensorConfig): AdminResult +suspend fun AdminApi.setPaxcounterConfig(block: ModuleConfig.PaxcounterConfig.() -> ModuleConfig.PaxcounterConfig): AdminResult +suspend fun AdminApi.setStatusMessageConfig(block: ModuleConfig.StatusMessageConfig.() -> ModuleConfig.StatusMessageConfig): AdminResult +suspend fun AdminApi.setTrafficManagementConfig(block: ModuleConfig.TrafficManagementConfig.() -> ModuleConfig.TrafficManagementConfig): AdminResult +``` + +Usage: +```kotlin +client.admin.setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } +client.admin.setMqttConfig { copy(enabled = true, address = "mqtt.example.com") } + +// Combined with editSettings for atomic batching: +client.admin.editSettings { + setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } + setMqttConfig { copy(enabled = true) } +} +``` + ## Storage ```kotlin diff --git a/docs/architecture/meshtastic-android-migration.md b/docs/architecture/meshtastic-android-migration.md index c864878..e8b7480 100644 --- a/docs/architecture/meshtastic-android-migration.md +++ b/docs/architecture/meshtastic-android-migration.md @@ -203,6 +203,8 @@ val nodes: StateFlow> = combine( is NodeChange.Added -> acc + (change.node.num to change.node) is NodeChange.Updated -> acc + (change.node.num to change.node) is NodeChange.Removed -> acc - change.nodeId + is NodeChange.WentOffline, + is NodeChange.CameOnline -> acc // presence events don't change the map } } .flowOn(Dispatchers.Default), // ← required; SDK emits off-main @@ -243,14 +245,16 @@ when (sendState) { ### 7.3 Admin, Config & Error Handling The SDK provides strongly-typed outcomes. ViewModels must handle them idiomatically. ```kotlin -// AdminResult must be a sealed class in commonMain for exhaustive when to compile without else. -// If it is an interface or open class, add an else branch. viewModelScope.launch { when (val result = client.admin.reboot()) { is AdminResult.Success -> uiState.value = "Rebooting..." is AdminResult.Timeout -> alertManager.show("Radio didn't respond in time") is AdminResult.Unauthorized -> alertManager.show("Invalid admin channel") + AdminResult.RateLimited -> alertManager.show("Rate-limited — try again shortly") // No catch block needed; routine errors are returned as sealed subtypes, not thrown. + AdminResult.SessionKeyExpired, + AdminResult.NodeUnreachable, + is AdminResult.Failed -> alertManager.show("Admin operation failed: $result") } } ``` diff --git a/docs/consumer-guides/reactive-lifecycle-management.md b/docs/consumer-guides/reactive-lifecycle-management.md index d0c76a1..cf31060 100644 --- a/docs/consumer-guides/reactive-lifecycle-management.md +++ b/docs/consumer-guides/reactive-lifecycle-management.md @@ -45,6 +45,8 @@ class MeshNodeListFragment : Fragment() { is NodeChange.Added -> adapter.addNode(change.node) is NodeChange.Updated -> adapter.updateNode(change.node) is NodeChange.Removed -> adapter.removeNode(change.nodeId) + is NodeChange.WentOffline -> adapter.setOffline(change.nodeId) + is NodeChange.CameOnline -> adapter.setOnline(change.nodeId) } } } diff --git a/docs/decisions/003-tooling.md b/docs/decisions/003-tooling.md index 21975d8..8f72e29 100644 --- a/docs/decisions/003-tooling.md +++ b/docs/decisions/003-tooling.md @@ -97,7 +97,7 @@ Storage is **required** at `Builder.build()` time — no in-memory default in `: | **detekt `ForbiddenImport` + `:core:verifyModuleBoundary`** | Architecture rules: detekt bans `java.*`/`android.*` in `commonMain` and hints against `kotlin.Result` in public API; the Gradle `:core:verifyModuleBoundary` task enforces that `:core` does not depend on transport modules (see ADR-008). | | **`binary-compatibility-validator`** (`updateKotlinAbi`) | API surface freezes. From Phase 5 every public symbol change MUST regenerate `api/` files in the same commit. | | **Dokka 2.x** | API docs. Coverage gate from Phase 5. | -| **Compose previews / sample apps** | Manual smoke (`samples/cli`, `samples/android-app`, `samples/desktop`, `samples/ios-app`). | +| **Compose previews / sample apps** | Manual smoke (`samples/cli`, `samples/parity-app`, `samples/parity-android-app`). | ### Lint & format diff --git a/docs/error-taxonomy.md b/docs/error-taxonomy.md index 8602d00..2483ca0 100644 --- a/docs/error-taxonomy.md +++ b/docs/error-taxonomy.md @@ -11,7 +11,7 @@ | **Handshake failure** (timeout in any stage, malformed envelope, firmware too old) | `throw MeshtasticException` from `connect()` | Connect fails synchronously. | | **Async device drop** (heartbeat liveness timeout, transport drop after `Connected`) | `connection: ConnectionState.Reconnecting(cause)` + `MeshEvent.TransportError("liveness timeout")` (engine watchdog, 2 × heartbeat) or `TransportError("TCP read timeout after 65000ms")` (stream-transport backstop) | Already past `connect()`; the right channel is the state flow. The engine watchdog (`MeshEngine.LIVENESS_TIMEOUT_MS`) is the primary detector; TCP adds its own read deadline so the pre-`Ready` window is also covered. | | **Mesh send outcome** (NAK, no route, max retransmit, duty cycle, send-time disconnect) | `MessageHandle.state -> Failed(SendFailure.X)` | Routine on a flaky mesh; not exceptional. | -| **Admin RPC outcome** (NAK, session-key expired, unauthorised, timeout) | `AdminResult.Error(...)` | Routine; handlers want exhaustive `when`. | +| **Admin RPC outcome** (NAK, session-key expired, unauthorised, rate-limited, timeout) | `AdminResult.*` sealed variants | Routine; handlers want exhaustive `when`. | | **Engine drop of an inbound flow** (subscriber too slow) | `MeshEvent.PacketsDropped(flow, count)` | Observable backpressure; never silent. | | **Storage failure mid-session** | `MeshEvent.ProtocolWarning(...)` + retry; second failure escalates to `MeshtasticException.StorageUnavailable` and triggers reconnect | Storage outages shouldn't kill an active session if recoverable. | @@ -88,7 +88,7 @@ The Wire-generated `Routing.Error` enum (from `meshtastic/protobufs:mesh.proto`) | `PKI_UNKNOWN_PUBKEY` | `Other(PKI_UNKNOWN_PUBKEY)` | | `ADMIN_BAD_SESSION_KEY` | `Other(ADMIN_BAD_SESSION_KEY)` (admin paths intercept; see below) | | `ADMIN_PUBLIC_KEY_UNAUTHORIZED` | `Other(ADMIN_PUBLIC_KEY_UNAUTHORIZED)` (admin paths intercept) | -| `RATE_LIMIT_EXCEEDED` | `Other(RATE_LIMIT_EXCEEDED)` | +| `RATE_LIMIT_EXCEEDED` | `Other(RATE_LIMIT_EXCEEDED)` (admin paths intercept → `AdminResult.RateLimited`) | | (any new value the proto schema adds) | `Other(value)` — forward-compatible without a code change | `SendFailure.Unknown` is reserved for engine-internal anomalies (encoded `MeshPacket` with no decoded payload, etc.) and should never appear in production. @@ -101,6 +101,7 @@ public sealed interface AdminResult { public data object SessionKeyExpired : AdminResult // → automatic 1× retry inside engine public data object Unauthorized : AdminResult // NOT_AUTHORIZED / ADMIN_PUBLIC_KEY_UNAUTHORIZED public data object Timeout : AdminResult + public data object RateLimited : AdminResult // RATE_LIMIT_EXCEEDED — back off before retry public data object NodeUnreachable : AdminResult // remote-node admin: NO_ROUTE / MAX_RETRANSMIT public data class Failed(val routingError: Routing.Error) : AdminResult // anything else } @@ -116,6 +117,7 @@ Admin RPC paths intercept `Routing.Error` *before* it would map to a `SendFailur | `ADMIN_BAD_SESSION_KEY` | `SessionKeyExpired` (engine auto-retries once with refreshed `session_passkey`; if the retry also returns this, the result is forwarded) | | `NOT_AUTHORIZED`, `ADMIN_PUBLIC_KEY_UNAUTHORIZED` | `Unauthorized` | | `TIMEOUT` (or engine per-op timeout firing first) | `Timeout` | +| `RATE_LIMIT_EXCEEDED` | `RateLimited` | | `NO_ROUTE`, `MAX_RETRANSMIT`, `NO_INTERFACE` (for remote-node admin) | `NodeUnreachable` | | Anything else | `Failed(routingError)` — caller can switch on the raw enum | @@ -185,6 +187,7 @@ when (val r = client.admin.setConfig(c)) { is AdminResult.SessionKeyExpired -> { … } // very rare — engine already retried once is AdminResult.Unauthorized, AdminResult.Timeout, + AdminResult.RateLimited, AdminResult.NodeUnreachable, is AdminResult.Failed -> { … } } diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 3177db8..59b7aca 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -337,13 +337,40 @@ Want progress instead of the terminal outcome? Collect `handle.state` — it transitions `Queued → Sent → Acked|Delivered|Failed(reason)`. See [`api-reference.md` §SendState](./api-reference.md#sendstate-sendfailure-sendoutcome). -## 6. Logging and diagnostics +## 6. Admin operations and config builders + +```kotlin +// Read the device config +when (val result = client.admin.getConfig(AdminMessage.ConfigType.LORA_CONFIG)) { + is AdminResult.Success -> println("LoRa region: ${result.value.lora.region}") + AdminResult.Timeout -> println("timed out") + AdminResult.RateLimited -> println("rate-limited; try again later") + else -> println("failed: $result") +} + +// Write config using convenience builders (avoids manual proto construction): +client.admin.setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } + +// Batch multiple writes atomically: +client.admin.editSettings { + setLoraConfig { copy(region = Config.LoRaConfig.RegionCode.US) } + setMqttConfig { copy(enabled = true) } +} + +// Target a remote node: +val remote = client.admin.forNode(NodeId(0x12345678.toInt())) +remote.reboot() +``` + +See [`api-reference.md` §AdminApi](./api-reference.md#adminapi-phase-2) for the full method inventory, [`api-reference.md` §Config Builder Extensions](./api-reference.md#config-builder-extensions-since-020) for all 23 builder functions, and [`error-taxonomy.md`](./error-taxonomy.md) for `AdminResult` variant meanings. + +## 7. Logging and diagnostics By default the SDK is silent. Wire a `LogSink` at build time and, for deep debugging, opt into `protocolLogging` (with redaction). Full guide: [`observability.md`](./observability.md). -## 7. Testing your integration +## 8. Testing your integration ```kotlin // testImplementation("org.meshtastic:sdk-testing") @@ -360,7 +387,7 @@ val client = RadioClient.Builder() // See testing/Module.md and core/src/commonTest/ for working patterns. ``` -## 8. PKI direct messages (DMs) +## 9. PKI direct messages (DMs) Direct messages between two nodes are encrypted end-to-end with X25519 + AES-CTR keys derived from each peer's `User.public_key`. The SDK does not perform the crypto itself — the firmware does — but it does surface everything you need to verify peers: @@ -370,7 +397,7 @@ Direct messages between two nodes are encrypted end-to-end with X25519 + AES-CTR Sending DMs is the same as any other text message — call `client.sendText(...)` with a non-broadcast `to` — but you should refuse the call if the destination's last-seen `public_key` does not match what the user previously verified. The engine will not reject the send for you. -## 9. MQTT proxy mode (transparent) +## 10. MQTT proxy mode (transparent) Some devices participate in a regional MQTT mesh. When the firmware has MQTT enabled, inbound packets that originated over MQTT arrive with `MeshPacket.via_mqtt = true` and outbound packets you send may be re-broadcast to the MQTT topic by the device itself. Both sides are transparent to the SDK: there is no `transport-mqtt` artifact (R-11 in [`roadmap.md`](./roadmap.md)) and you do not need to configure anything beyond the device. See [`protocol.md` §14](./protocol.md#14-mqtt-proxy-mode) for the wire details and the topic naming convention; `via_mqtt` is the only signal the SDK exposes today. diff --git a/docs/protocol.md b/docs/protocol.md index 9376599..4a355df 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -775,7 +775,7 @@ Device administration (changing configs, rebooting, factory-reset, key managemen message AdminMessage { bytes session_passkey = 101; // Required for state-changing requests oneof payload_variant { - bool get_channel_request = 1; // (with channel index in get_channel_request) + bool get_channel_request = 1; // uint32: channel index + 1 (1-based, proto3 zero-value omission) Channel get_channel_response = 2; bool get_owner_request = 3; User get_owner_response = 4; @@ -832,7 +832,7 @@ State-changing admin operations require a session passkey. Workflow: 1. Phone sends `AdminMessage(get_device_metadata_request = true)` (no passkey required). 2. Device responds with `DeviceMetadata` containing fields including the **session passkey** (8 bytes). -3. Phone caches the passkey for ~5 minutes (firmware regenerates at ~150s for a sliding window). +3. Phone caches the passkey for ~4 minutes (firmware regenerates every ~150s with a 300s validity window). 4. Phone includes the passkey in the `session_passkey` field of every state-changing admin request. 5. If the passkey expires or doesn't match, the device responds with `Routing.error_reason = ADMIN_BAD_SESSION_KEY`. @@ -844,6 +844,14 @@ The SDK should: For multi-field config edits, use `begin_edit_settings` / `commit_edit_settings` to bundle changes atomically. The device locks against other clients during the edit window and applies all changes on commit. +> **⚠️ BLE disconnect on commit.** When an `editSettings` commit changes BLE-related config (or most config in general), firmware calls `disableBluetooth()` before persisting to flash. This drops the active BLE connection. The SDK's auto-reconnect policy (ADR-002) handles this transparently — consumers see a brief disconnect/reconnect cycle. On TCP/Serial transports this does not apply. + +### Managed mode (`is_managed`) + +When the device's `Config.SecurityConfig.is_managed` is `true`, firmware silently drops **all** admin commands received with a non-zero `from` address. Since the SDK always sends admin packets with `from = myNodeNum` (non-zero after handshake), all admin operations would be silently ignored. + +The SDK detects this condition from the config bundle received during handshake and returns `AdminResult.Unauthorized` immediately for any admin call, avoiding a silent timeout. + ### Admin channel routing By convention, admin messages travel on the **admin channel** (a dedicated channel role). If the device has no admin channel configured, admin messages travel on the primary channel. @@ -927,7 +935,7 @@ When a device enters DFU mode (XModem firmware update) or reboots (triggered via 1. The host's resync FSM (§2) remains in effect: any garbage on the wire after the device reboots is correctly discarded. 2. On TCP: the device may be unreachable for 5–30 s while rebooting; the transport's `connect()` will timeout and the SDK will auto-retry per the reconnect policy (ADR-002, exponential backoff with jitter). 3. On Serial: if the device emits boot text or debug output before re-entering PhoneAPI mode, the resync FSM absorbs it and returns to `SCAN_FOR_START1`. -4. **Wake bytes** (§2) are RECOMMENDED immediately after reconnect to ensure the firmware's framer is not left mid-frame by the DFU transition. +4. **Wake bytes** (§2) are sent by the SDK immediately after reconnect on stream transports (TCP/Serial) to ensure the firmware's framer is not left mid-frame by the DFU transition. **For BLE**: @@ -962,7 +970,7 @@ message Heartbeat { `ToRadio.heartbeat` is a liveness ping. Clients MUST increment `nonce` on every send (a monotonic counter is sufficient). -> **⚠️ Reserved nonce — `nonce == 1`.** Current firmware (`firmware:src/mesh/PhoneAPI.cpp` `handleToRadio` / `meshtastic_ToRadio_heartbeat_tag` branch) overloads `Heartbeat(nonce = 1)` as a **side-channel trigger to force-broadcast our own `NodeInfo` onto the LoRa mesh**, bypassing the 10-minute NodeInfo cooldown. This is a post-reboot / factory-reset recovery affordance, **not** a liveness ping. Clients SHOULD skip nonce == 1 entirely (start the counter at `2`) unless they explicitly want to rebroadcast their NodeInfo. This SDK initialises its counter to `1` and pre-increments before send, so the first emitted nonce is `2`. +> **⚠️ Reserved nonce — `nonce == 1`.** Current firmware (`firmware:src/mesh/PhoneAPI.cpp` `handleToRadio` / `meshtastic_ToRadio_heartbeat_tag` branch) overloads `Heartbeat(nonce = 1)` as a **side-channel trigger to force-broadcast our own `NodeInfo` onto the LoRa mesh**, bypassing the 10-minute NodeInfo cooldown. This is a post-reboot / factory-reset recovery affordance, **not** a liveness ping. Clients SHOULD skip nonce == 1 entirely unless they explicitly want to rebroadcast their NodeInfo. This SDK sends all keep-alive heartbeats with `nonce = 0` (a safe constant that avoids the nonce-1 trigger and satisfies the firmware's liveness watchdog). **Cadence by transport** (cross-validated against `Meshtastic-Apple:Transport.swift` + `AccessoryManager.setupPeriodicHeartbeat` and `Meshtastic-Android:SharedRadioInterfaceService.kt` + `HeartbeatSender.kt`): diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f7cd5f7..f65556b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] # --- Kotlin & build --- kotlin = "2.3.20" # pinned for SKIE 0.10.11 compatibility -agp = "9.2.0" +agp = "9.2.1" gradle = "9.4.1" javaVersion = "21" androidMinSdk = "26" diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt index 2022927..456b81c 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConformanceCmd.kt @@ -58,7 +58,7 @@ internal class ConformanceCmd : BaseCommand(name = "conformance") { private val scenarioFilter by option( "--scenario", - help = "Restrict to a comma-separated list of scenario ids (cs1,cs2,cs3,cs4,cs5,cs6).", + help = "Restrict to a comma-separated list of scenario ids (cs1,cs2,cs3,cs4,cs5,cs6,cs7).", metavar = "CSV", ).split(",") @@ -88,7 +88,7 @@ internal class ConformanceCmd : BaseCommand(name = "conformance") { // If cs1 fails, the rest cannot run — record SKIPs and bail out. if (results.last().status != ScenarioResult.Status.PASS) { - listOf("cs2", "cs3", "cs4", "cs5", "cs6").forEach { id -> + listOf("cs2", "cs3", "cs4", "cs5", "cs6", "cs7").forEach { id -> results += Scenarios.skip(id, "skipped due to cs1 failure", "handshake never reached Connected") .also { announce(it) } } @@ -107,6 +107,13 @@ internal class ConformanceCmd : BaseCommand(name = "conformance") { }?.let { results += it.also(::announce) } runIfRequested("cs6") { Scenarios.cs6ReconnectAfterDrop(client) } ?.let { results += it.also(::announce) } + runIfRequested("cs7") { + if (peer == null) { + Scenarios.skip("cs7", "unicast DM", "no --peer-node supplied") + } else { + Scenarios.cs7UnicastDmText(client, peer) + } + }?.let { results += it.also(::announce) } } } finally { runCatching { client.disconnect() } diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt index 96f1ae3..903dedc 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/ConnectCmds.kt @@ -136,6 +136,23 @@ internal class NodesCmd : BaseCommand(name = "nodes") { put("num", change.nodeId.raw) } } + + is NodeChange.WentOffline -> { + out.human("⊘ offline 0x" + change.nodeId.raw.toUInt().toString(16)) + out.emit("node") { + put("op", "offline") + put("num", change.nodeId.raw) + put("last_heard", change.lastHeard) + } + } + + is NodeChange.CameOnline -> { + out.human("● online 0x" + change.nodeId.raw.toUInt().toString(16)) + out.emit("node") { + put("op", "online") + put("num", change.nodeId.raw) + } + } } } "disconnect" diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt index 847c008..df57635 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/cmd/TuiCmd.kt @@ -159,6 +159,12 @@ private suspend fun wireUpDashboard( nodes.remove(change.nodeId) appendLog("- node 0x" + change.nodeId.raw.toUInt().toString(16)) } + + is NodeChange.WentOffline -> + appendLog("⊘ offline 0x" + change.nodeId.raw.toUInt().toString(16)) + + is NodeChange.CameOnline -> + appendLog("● online 0x" + change.nodeId.raw.toUInt().toString(16)) } } } diff --git a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt index adaf08d..79b6a18 100644 --- a/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt +++ b/samples/cli/src/main/kotlin/org/meshtastic/cli/conformance/Scenarios.kt @@ -15,6 +15,7 @@ import org.meshtastic.sdk.NodeId import org.meshtastic.sdk.RadioClient import org.meshtastic.sdk.SendOutcome import org.meshtastic.sdk.connectAndAwaitReady +import org.meshtastic.sdk.sendDirectMessage import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource @@ -65,17 +66,18 @@ internal object Scenarios { } /** - * **cs2 — Send-text round-trip** (manual C1). Broadcasts a small text packet on channel 0. - * PASS if the [MessageHandle] resolves to [SendOutcome.Success] within [budget]; FAIL on - * any failure outcome or timeout. + * **cs2 — Broadcast text acceptance** (manual C1). Broadcasts a small text packet on channel 0 + * with `want_ack=true`. PASS if the [MessageHandle] resolves to [SendOutcome.Success] within + * [budget] (firmware sends an implicit ACK when it overhears a relay rebroadcast); FAIL on any + * failure outcome or timeout. */ suspend fun cs2SendTextRoundTrip(client: RadioClient, budget: Duration = 30.seconds): ScenarioResult = - runScenario("cs2", "broadcast text round-trip") { + runScenario("cs2", "broadcast text acceptance") { val handle = client.sendText("conformance probe") val outcome = withTimeoutOrNull(budget) { handle.await() } ?: error("did not reach terminal state in ${budget.inWholeSeconds}s") when (outcome) { - SendOutcome.Success -> "id=${handle.id} acked" + SendOutcome.Success -> "id=${handle.id} accepted" is SendOutcome.Failure -> error("failed: ${outcome.reason::class.simpleName}") } } @@ -92,6 +94,7 @@ internal object Scenarios { AdminResult.NodeUnreachable -> error("node unreachable") AdminResult.SessionKeyExpired -> error("session key expired (twice — retry exhausted)") AdminResult.Unauthorized -> error("unauthorized") + AdminResult.RateLimited -> error("device rate-limited the request") is AdminResult.Failed -> error("routing error: ${result.routingError}") } } @@ -121,6 +124,8 @@ internal object Scenarios { AdminResult.Unauthorized -> error("unauthorized") + AdminResult.RateLimited -> error("device rate-limited the request") + is AdminResult.Failed -> error("routing error: ${result.routingError}") } } @@ -170,6 +175,23 @@ internal object Scenarios { fun skip(id: String, name: String, reason: String): ScenarioResult = ScenarioResult(id = id, name = name, status = ScenarioResult.Status.SKIP, durationMs = 0L, message = reason) + /** + * **cs7 — Unicast DM text round-trip** (manual C2). Sends a direct message to a specific peer + * with `wantAck = true`. PASS if the [MessageHandle] resolves to [SendOutcome.Success] + * within [budget]; FAIL on any failure outcome or timeout. Unlike cs2 (broadcast), this + * exercises the full send → routing-ACK path. + */ + suspend fun cs7UnicastDmText(client: RadioClient, peer: NodeId, budget: Duration = 30.seconds): ScenarioResult = + runScenario("cs7", "unicast DM to $peer") { + val handle = client.sendDirectMessage(to = peer, text = "dm conformance probe") + val outcome = withTimeoutOrNull(budget) { handle.await() } + ?: error("did not reach terminal state in ${budget.inWholeSeconds}s") + when (outcome) { + SendOutcome.Success -> "id=${handle.id} acked by $peer" + is SendOutcome.Failure -> error("failed: ${outcome.reason::class.simpleName}") + } + } + /** * Wrap a single-scenario block: time it, catch every exception, and produce a * [ScenarioResult]. The block returns the success-path message; failures throw and the diff --git a/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt b/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt index 0175619..ee64489 100644 --- a/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt +++ b/samples/parity-app/src/commonMain/kotlin/org/meshtastic/sample/MeshSampleController.kt @@ -79,6 +79,8 @@ class MeshSampleController(private val storagePath: String, private val scope: C is NodeChange.Added -> append("[+] node 0x${ev.node.num.toString(16)}") is NodeChange.Updated -> append("[~] node 0x${ev.node.num.toString(16)}") is NodeChange.Removed -> append("[-] node 0x${ev.nodeId.raw.toString(16)}") + is NodeChange.WentOffline -> append("[offline] node 0x${ev.nodeId.raw.toString(16)}") + is NodeChange.CameOnline -> append("[online] node 0x${ev.nodeId.raw.toString(16)}") } } .launchIn(this) diff --git a/testing/api/jvm/testing.api b/testing/api/jvm/testing.api index c918033..30cf75b 100644 --- a/testing/api/jvm/testing.api +++ b/testing/api/jvm/testing.api @@ -15,12 +15,15 @@ public final class org/meshtastic/sdk/testing/FakeRadioTransport : org/meshtasti public final fun injectFrame (Lorg/meshtastic/sdk/Frame;)V public final fun injectNeighborInfoResponse (ILorg/meshtastic/proto/NeighborInfo;I)V public static synthetic fun injectNeighborInfoResponse$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/NeighborInfo;IILjava/lang/Object;)V + public final fun injectPacket (Lorg/meshtastic/proto/MeshPacket;)V public final fun injectRouteReply (ILorg/meshtastic/proto/RouteDiscovery;I)V public static synthetic fun injectRouteReply$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/RouteDiscovery;IILjava/lang/Object;)V public final fun injectRoutingAck (II)V public static synthetic fun injectRoutingAck$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;IIILjava/lang/Object;)V public final fun injectRoutingError (ILorg/meshtastic/proto/Routing$Error;I)V public static synthetic fun injectRoutingError$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/Routing$Error;IILjava/lang/Object;)V + public final fun injectStoreForwardResponse (ILorg/meshtastic/proto/StoreAndForward;I)V + public static synthetic fun injectStoreForwardResponse$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/StoreAndForward;IILjava/lang/Object;)V public final fun injectTelemetryResponse (ILorg/meshtastic/proto/Telemetry;I)V public static synthetic fun injectTelemetryResponse$default (Lorg/meshtastic/sdk/testing/FakeRadioTransport;ILorg/meshtastic/proto/Telemetry;IILjava/lang/Object;)V public final fun outboundFrames ()Ljava/util/List; diff --git a/testing/api/testing.klib.api b/testing/api/testing.klib.api index 2c311cb..ba132d3 100644 --- a/testing/api/testing.klib.api +++ b/testing/api/testing.klib.api @@ -22,9 +22,11 @@ final class org.meshtastic.sdk.testing/FakeRadioTransport : org.meshtastic.sdk/R final fun injectAdminResponse(kotlin/Int, org.meshtastic.proto/AdminMessage, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectAdminResponse|injectAdminResponse(kotlin.Int;org.meshtastic.proto.AdminMessage;kotlin.Int){}[0] final fun injectFrame(org.meshtastic.sdk/Frame) // org.meshtastic.sdk.testing/FakeRadioTransport.injectFrame|injectFrame(org.meshtastic.sdk.Frame){}[0] final fun injectNeighborInfoResponse(kotlin/Int, org.meshtastic.proto/NeighborInfo, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectNeighborInfoResponse|injectNeighborInfoResponse(kotlin.Int;org.meshtastic.proto.NeighborInfo;kotlin.Int){}[0] + final fun injectPacket(org.meshtastic.proto/MeshPacket) // org.meshtastic.sdk.testing/FakeRadioTransport.injectPacket|injectPacket(org.meshtastic.proto.MeshPacket){}[0] final fun injectRouteReply(kotlin/Int, org.meshtastic.proto/RouteDiscovery, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectRouteReply|injectRouteReply(kotlin.Int;org.meshtastic.proto.RouteDiscovery;kotlin.Int){}[0] final fun injectRoutingAck(kotlin/Int, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectRoutingAck|injectRoutingAck(kotlin.Int;kotlin.Int){}[0] final fun injectRoutingError(kotlin/Int, org.meshtastic.proto/Routing.Error, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectRoutingError|injectRoutingError(kotlin.Int;org.meshtastic.proto.Routing.Error;kotlin.Int){}[0] + final fun injectStoreForwardResponse(kotlin/Int, org.meshtastic.proto/StoreAndForward, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectStoreForwardResponse|injectStoreForwardResponse(kotlin.Int;org.meshtastic.proto.StoreAndForward;kotlin.Int){}[0] final fun injectTelemetryResponse(kotlin/Int, org.meshtastic.proto/Telemetry, kotlin/Int = ...) // org.meshtastic.sdk.testing/FakeRadioTransport.injectTelemetryResponse|injectTelemetryResponse(kotlin.Int;org.meshtastic.proto.Telemetry;kotlin.Int){}[0] final fun outboundFrames(): kotlin.collections/List // org.meshtastic.sdk.testing/FakeRadioTransport.outboundFrames|outboundFrames(){}[0] final fun outboundPackets(): kotlin.collections/List // org.meshtastic.sdk.testing/FakeRadioTransport.outboundPackets|outboundPackets(){}[0] diff --git a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt index 9719232..7d0d4ed 100644 --- a/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt +++ b/testing/src/commonMain/kotlin/org/meshtastic/sdk/testing/FakeRadioTransport.kt @@ -86,6 +86,14 @@ public class FakeRadioTransport( runCatching { ToRadio.ADAPTER.decode(protoBytes).packet }.getOrNull() } + /** + * Inject an arbitrary [MeshPacket] as if it arrived from the radio. + * Use this to test flows that consume [RadioClient.packets] (e.g. [RadioClient.textMessages]). + */ + public fun injectPacket(packet: MeshPacket) { + injectFromRadio(FromRadio(packet = packet)) + } + /** * Inject an admin response packet correlated to [requestId]. The packet is constructed with * `decoded.request_id = requestId` so the engine's [CommandDispatcher] / `processRoutingAck` @@ -173,6 +181,25 @@ public class FakeRadioTransport( injectFromRadio(FromRadio(packet = packet)) } + /** Inject a Store-and-Forward response correlated to [requestId]. */ + public fun injectStoreForwardResponse( + requestId: Int, + message: org.meshtastic.proto.StoreAndForward, + fromNode: Int = nodeNum, + ) { + val payload = okio.ByteString.of(*org.meshtastic.proto.StoreAndForward.ADAPTER.encode(message)) + val packet = MeshPacket( + from = fromNode, + to = 0, + decoded = Data( + portnum = PortNum.STORE_FORWARD_APP, + payload = payload, + request_id = requestId, + ), + ) + injectFromRadio(FromRadio(packet = packet)) + } + /** Inject a Routing.Ack correlated to [requestId] (setter ack-style tests). */ public fun injectRoutingAck(requestId: Int, fromNode: Int = nodeNum) { val payload = okio.ByteString.of(*Routing.ADAPTER.encode(Routing(error_reason = Routing.Error.NONE))) diff --git a/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt new file mode 100644 index 0000000..1df2817 --- /dev/null +++ b/transport-ble/src/androidMain/kotlin/org/meshtastic/sdk/transport/ble/BleTransport.android.kt @@ -0,0 +1,35 @@ +/* + * Meshtastic — open source mesh radio + * Copyright © 2024-2026 Meshtastic LLC + * + * Licensed under the GPL-3.0-or-later license (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.html) + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package org.meshtastic.sdk.transport.ble + +import com.juul.kable.Peripheral +import com.juul.kable.PeripheralBuilder +import com.juul.kable.toIdentifier + +/** + * Android-specific factory that creates a [BleTransport] from a persisted MAC address string. + * + * On Android, Kable's `Identifier` is a type alias for `String`, but [toIdentifier] is the + * canonical conversion and remains correct across Kable versions. + * + * Example — bonded device (no fresh advertisement, must use `autoConnect`): + * ```kotlin + * val transport = BleTransport(address = "AA:BB:CC:DD:EE:FF") { + * autoConnectIf { true } + * } + * ``` + * + * @param address Bluetooth MAC address string (e.g. `"AA:BB:CC:DD:EE:FF"`). + * @param builderAction Optional [PeripheralBuilder] action for GATT configuration (MTU, threading, + * `autoConnect`, etc.). For bonded devices without a fresh advertisement, add + * `autoConnectIf { true }` to avoid GATT error 133. + */ +public fun BleTransport(address: String, builderAction: PeripheralBuilder.() -> Unit = {}): BleTransport = BleTransport( + peripheral = Peripheral(address.toIdentifier(), builderAction), + address = address, +)