Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 23 additions & 1 deletion app/src/main/java/com/example/ava/esphome/EspHomeDevice.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<Entity> = emptyList()
) : AutoCloseable {
private val entities = entities.toList()
Expand All @@ -75,6 +80,8 @@ class EspHomeDevice(
startConnectedChangedListener()
listenForEntityStateChanges()
listenForVoiceAssistantResponses()
if (logger != null)
listenForLogResponses()
}

private fun startServer() {
Expand Down Expand Up @@ -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")
Expand All @@ -139,6 +151,8 @@ class EspHomeDevice(

is PingRequest -> sendMessage(pingResponse { })

is SubscribeLogsRequest -> logger?.setLogLevel(message.level)

is SubscribeHomeAssistantStatesRequest -> isSubscribedToEntityState.value = true

is ListEntitiesRequest -> {
Expand All @@ -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)
}

Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SubscribeLogsResponse> = 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)
}
}
10 changes: 10 additions & 0 deletions app/src/main/java/com/example/ava/esphome/logger/Logger.kt
Original file line number Diff line number Diff line change
@@ -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<SubscribeLogsResponse>
}
2 changes: 2 additions & 0 deletions app/src/main/java/com/example/ava/services/DeviceBuilder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,6 +61,7 @@ class DeviceBuilder @Inject constructor(
voiceInput = microphoneSettingsStore.toVoiceInput(),
voiceOutput = voiceOutput
),
logger = TimberLogger(),
entities = listOf(
MediaPlayerEntity(
key = 0,
Expand Down
Loading