diff --git a/README.md b/README.md index fdd9ab5..7195c13 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ Once connected, the satellite is fully configurable from within Home Assistant a - Custom timer sound: Specify an audio file to play instead of the default one - Repeat timer sound: By default, the timer sound is repeated until stopped by the user +# Logs + +Logs for the app can be sent to and displayed in Home Assistant's logs to help troubleshooting. See here for instructions on how to enable - [Obtaining logs from the device](https://www.home-assistant.io/integrations/esphome/#obtaining-logs-from-the-device) + # Custom wake word models The app includes a default [set of wake words](https://github.com/brownard/Ava/tree/master/app/src/main/assets/wakeWords), however you can also specify a directory containing custom wake words supported by microWakeWord. Create a directory on your device, copy the wake word model(s) as well as valid json file(s) describing each model ([example](https://github.com/brownard/Ava/blob/master/app/src/main/assets/wakeWords/okay_nabu.json)), a minimum valid example json is: diff --git a/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt b/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt index 533d7e1..41c1655 100644 --- a/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt +++ b/app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt @@ -3,6 +3,7 @@ package com.example.ava.esphome import android.Manifest import androidx.annotation.RequiresPermission import com.example.ava.esphome.entities.Entity +import com.example.ava.esphome.logger.Logger import com.example.ava.esphome.voiceassistant.VoiceAssistant import com.example.ava.server.DEFAULT_SERVER_PORT import com.example.ava.server.Server @@ -13,8 +14,11 @@ import com.example.esphomeproto.api.DeviceInfoResponse import com.example.esphomeproto.api.DisconnectRequest import com.example.esphomeproto.api.HelloRequest import com.example.esphomeproto.api.ListEntitiesRequest +import com.example.esphomeproto.api.LogLevel import com.example.esphomeproto.api.PingRequest import com.example.esphomeproto.api.SubscribeHomeAssistantStatesRequest +import com.example.esphomeproto.api.SubscribeLogsRequest +import com.example.esphomeproto.api.SubscribeLogsResponse import com.example.esphomeproto.api.SubscribeVoiceAssistantRequest import com.example.esphomeproto.api.VoiceAssistantAnnounceRequest import com.example.esphomeproto.api.VoiceAssistantConfigurationRequest @@ -56,6 +60,7 @@ class EspHomeDevice( private val server: Server = ServerImpl(), private val deviceInfo: DeviceInfoResponse, val voiceAssistant: VoiceAssistant, + private val logger: Logger? = null, entities: Iterable = emptyList() ) : AutoCloseable { private val entities = entities.toList() @@ -75,6 +80,8 @@ class EspHomeDevice( startConnectedChangedListener() listenForEntityStateChanges() listenForVoiceAssistantResponses() + if (logger != null) + listenForLogResponses() } private fun startServer() { @@ -116,6 +123,11 @@ class EspHomeDevice( .onEach { sendMessage(it) } .launchIn(scope) + private fun listenForLogResponses() = + if (logger == null) error("logger is null") + else logger.subscribe() + .onEach { sendMessage(it) } + .launchIn(scope) private suspend fun handleMessageInternal(message: MessageLite) { Timber.d("Received message: ${message.javaClass.simpleName} $message") @@ -139,6 +151,8 @@ class EspHomeDevice( is PingRequest -> sendMessage(pingResponse { }) + is SubscribeLogsRequest -> logger?.setLogLevel(message.level) + is SubscribeHomeAssistantStatesRequest -> isSubscribedToEntityState.value = true is ListEntitiesRequest -> { @@ -164,7 +178,14 @@ class EspHomeDevice( } private suspend fun sendMessage(message: MessageLite) { - Timber.d("Sending message: ${message.javaClass.simpleName} $message") + // Don't log sending a log response, it will trigger + // another log response causing an infinite loop + if (message !is SubscribeLogsResponse) { + Timber.d("Sending message: ${message.javaClass.simpleName} $message") + } else { + server.sendMessage(message) + return + } server.sendMessage(message) } @@ -176,6 +197,7 @@ class EspHomeDevice( private suspend fun onDisconnected() { isSubscribedToEntityState.value = false isSubscribedToVoiceAssistant.value = false + logger?.setLogLevel(LogLevel.LOG_LEVEL_NONE) _state.value = Disconnected voiceAssistant.onDisconnected() } diff --git a/app/src/main/java/com/example/ava/esphome/android/logger/TimberLogger.kt b/app/src/main/java/com/example/ava/esphome/android/logger/TimberLogger.kt new file mode 100644 index 0000000..597bfe0 --- /dev/null +++ b/app/src/main/java/com/example/ava/esphome/android/logger/TimberLogger.kt @@ -0,0 +1,77 @@ +package com.example.ava.esphome.android.logger + +import android.util.Log +import com.example.ava.esphome.logger.Logger +import com.example.esphomeproto.api.LogLevel +import com.example.esphomeproto.api.SubscribeLogsResponse +import com.example.esphomeproto.api.subscribeLogsResponse +import com.google.protobuf.ByteString +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import timber.log.Timber +import kotlin.concurrent.atomics.ExperimentalAtomicApi + +/** + * A [Logger] implementation that sends [SubscribeLogsResponse] messages for messages logged through [Timber]. + */ +@OptIn(ExperimentalAtomicApi::class) +class TimberLogger : Logger { + /** + * A [Timber.Tree] that sends log messages to a callback. + * Planted when logs are being subscribed to and uprooted when not. + */ + class Tree( + private val callback: ( + level: LogLevel, + message: String + ) -> Unit + ) : Timber.DebugTree() { + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable? + ) { + val level = when (priority) { + Log.VERBOSE -> LogLevel.LOG_LEVEL_VERBOSE + Log.DEBUG -> LogLevel.LOG_LEVEL_DEBUG + Log.INFO -> LogLevel.LOG_LEVEL_INFO + Log.WARN -> LogLevel.LOG_LEVEL_WARN + Log.ERROR, Log.ASSERT -> LogLevel.LOG_LEVEL_ERROR + // Shouldn't ever happen + else -> LogLevel.LOG_LEVEL_ERROR + } + callback(level, if (tag != null) "$tag: $message" else message) + } + } + + private val level = MutableStateFlow(LogLevel.LOG_LEVEL_NONE) + override fun setLogLevel(level: LogLevel) { + this.level.value = level + } + + override fun subscribe(): Flow = level.flatMapLatest { logLevel -> + // LOG_LEVEL_NONE is set when no logs should be sent + if (logLevel == LogLevel.LOG_LEVEL_NONE) { + emptyFlow() + } else callbackFlow { + val tree = Tree { level, message -> + if (logLevel.ordinal >= level.ordinal) + trySend(subscribeLogsResponse { + this.message = ByteString.copyFromUtf8(message) + this.level = level + }) + } + Timber.plant(tree) + awaitClose { + Timber.uproot(tree) + } + }.buffer(Channel.UNLIMITED) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/esphome/logger/Logger.kt b/app/src/main/java/com/example/ava/esphome/logger/Logger.kt new file mode 100644 index 0000000..6b2ac5f --- /dev/null +++ b/app/src/main/java/com/example/ava/esphome/logger/Logger.kt @@ -0,0 +1,10 @@ +package com.example.ava.esphome.logger + +import com.example.esphomeproto.api.LogLevel +import com.example.esphomeproto.api.SubscribeLogsResponse +import kotlinx.coroutines.flow.Flow + +interface Logger { + fun setLogLevel(level: LogLevel) + fun subscribe(): Flow +} \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt index 49328df..675856d 100644 --- a/app/src/main/java/com/example/ava/services/DeviceBuilder.kt +++ b/app/src/main/java/com/example/ava/services/DeviceBuilder.kt @@ -12,6 +12,7 @@ import androidx.media3.common.C.USAGE_MEDIA import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import com.example.ava.esphome.EspHomeDevice +import com.example.ava.esphome.android.logger.TimberLogger import com.example.ava.esphome.entities.MediaPlayerEntity import com.example.ava.esphome.entities.SwitchEntity import com.example.ava.esphome.voiceassistant.VoiceAssistant @@ -60,6 +61,7 @@ class DeviceBuilder @Inject constructor( voiceInput = microphoneSettingsStore.toVoiceInput(), voiceOutput = voiceOutput ), + logger = TimberLogger(), entities = listOf( MediaPlayerEntity( key = 0,