From e279a8c1f25f9da272b4f150a0741ac7196de46c Mon Sep 17 00:00:00 2001 From: Xavier Vello Date: Mon, 9 Mar 2026 14:18:19 +0100 Subject: [PATCH] tasker: Ava Activity state plugin --- README.md | 1 + TASKER.md | 27 ++++ app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 10 ++ .../ava/services/VoiceSatelliteService.kt | 12 ++ .../ava/tasker/ActivityConfigAvaActivity.kt | 124 ++++++++++++++++++ .../ava/tasker/AvaActivityCondition.kt | 94 +++++++++++++ app/src/main/res/values/strings.xml | 12 ++ app/src/main/res/values/themes.xml | 1 - gradle/libs.versions.toml | 2 + 10 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 TASKER.md create mode 100644 app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt create mode 100644 app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt diff --git a/README.md b/README.md index c651f50..fdd9ab5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Requires Android 8 or above. - Microphone mute switch - Wake/preannounce sounds - Timers +- [Tasker integration](TASKER.md) # Todo - Improved Assist feedback on screen diff --git a/TASKER.md b/TASKER.md new file mode 100644 index 0000000..ff4c711 --- /dev/null +++ b/TASKER.md @@ -0,0 +1,27 @@ +# State of the Tasker integration + +Ava integrates with the paid [Tasker](https://tasker.joaoapps.com/) app to support automations. +We will be tracking use cases and progress on [this issue](https://github.com/brownard/Ava/issues/49). + +You can also use the [Home Assistant Plug-In for Tasker](https://github.com/MarkAdamson/home-assistant-plugin-for-tasker) +to query the voice assistant state via the Home Assistant server. + +> [!NOTE] +> The project will not provide general support for Tasker. You should check out their website and +> the `r/tasker` subreddit for support. + +## State Condition: Ava Activity + +The `Ava Activity` state can be used to trigger tasks when the voice satellite enters or exits +specific states: + +- A conversation is happening (listening, thinking, replying) +- Voice timers are active (ringing, running, paused) + +This [example profile](taskerprofile://H4sIAAAAAAAA/71YXZOiRhR9Hn8FRVU2Lwmfik6WoQpddsuKqxPASVJ5oHqlYUmwsZrW7Pz7dNPgguIow1TmYcRzb597+tIf92r6IP8H4g+AACHHD6IohIfkQVRFgRweREMyJE0RrcGd+YizKElh4bSjz5ooHOCDqDHjnbkJAYGWOh7rqj5R7sfDe92UOcjMsG42JsZ4rJoyPJqjFMS5NTFl/sCgJLQ0U6b/2ZdtEioWJSw+S0C1hgWgFgDaQss+AMHekOSQkGdTZgizeIRGKVRvMqTURVPVCFCvpyzZwNpIjnKHLKSyNUMdK8pQG1EbAwrTdI/CMh0AxwpnvDOfQJoX4AGkJcZothL5N4syTJ6jbI/BVkqzDUihlCACEZHgN4KBNF2s3ak1y9AB4jxB8S8CwXs48JMtxIJLgTNsj9ARY+I6hHmFtJ/J8w5af4MDkFKAYskjmEbvFpdzHINXU7WqGRyBM5fT6NMsSyFA9UENcgSJFFIwTfLNVwmgEGdJKJFiuUuus3Ce7KUfPNnu3J4uHM96l5L3fEY2xuCZ74Z3MXnPDAFgWNCRU2HDf4AYDxyMMyzM6OoZzCOBfIUY/pgLAAmQGX4S6BwISNB3SGArjUWWe4R+vXK1Ur7N41L8Z5jnIL5Z/5a795iCWk1Brr0WhtHN3fHdvm5J8OX01+J0xXcXcPvCLLZKLtkzf75aBvPl49oPZgvb8yy2y+A3sN3RfcUElQPoqVcdXXO025Mr2i7z91J36Wjow9ZdkLteLh335nyx4xPiTiIbEfoJfIOUndN1kOT84bt28LvtBbPV8uP809p1PgRT5+PKdcrTuC/L24i5eO6/CW8Hja7z29rxfErgzz87q7VvGQr9u03I+eA+kU9zMqf3bHzrUr7A1kFPda4FrvO4sGdO8Kvzp2fdFr19bN/gfTbTC4S3yMr3X74UtWB4y7apeXcjf902OB1fxiSsfAx2YJ9THREtWansBtbid1HAuVNjNOaFa5mcJtbmeCVOw6s5nlfDzUAl1uZ4LVDdixf3Mqvuefkv8/qff6GrrWoEVN5h6OLp/WOyKubMXbvkfryuyruKdgVREtcurxof3f0Vn075QMr6N7nofuSi/WH9m1w2cOyZ9XzFCBZEb+ngVGWkTpSLHZyqj+61Sb2Do42afmzXWOvlbTCESFih750YU54hrnRDylZsXLVirLGaKEa9xarNSynnNVKUamacroVZbWNWtSvMJW/Dpr5g00qbcabHlFl+z/I8bMvzyNDG95fzPNFHmnKS52FrnqOoS6J1pUc21JfS0WbTX7ANm7b/9bWq1+fR8lr5J//JxBr8B+9E9JtAEQAA) +turns the screen on when conversing and when timers are running or ringing (but ignores paused timers). + +> [!IMPORTANT] +> Tasker only runs tasks when the desired state is achieved, but not when the reason for this state +> change (for example timer running -> ringing). For this, you have to setup several profiles with +> different conditions. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bc1267e..c6952bd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.documentfile) implementation(libs.timber) + implementation(libs.taskerpluginlibrary) ksp(libs.hilt.android.compiler) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0c7c72b..034536d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,16 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt index 9aa8fad..19aa11f 100644 --- a/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt +++ b/app/src/main/java/com/example/ava/services/VoiceSatelliteService.kt @@ -28,10 +28,14 @@ import com.example.ava.settings.MicrophoneSettingsStore import com.example.ava.settings.PlayerSettingsStore import com.example.ava.settings.VoiceSatelliteSettings import com.example.ava.settings.VoiceSatelliteSettingsStore +import com.example.ava.tasker.ActivityConfigAvaActivity +import com.example.ava.tasker.AvaActivityRunner import com.example.ava.utils.translate import com.example.ava.wakelocks.WifiWakeLock +import com.joaomgcd.taskerpluginlibrary.extensions.requestQuery import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.first @@ -93,6 +97,7 @@ class VoiceSatelliteService() : LifecycleService() { createVoiceSatelliteServiceNotificationChannel(this) updateNotificationOnStateChanges() startSettingsWatcher() + startTaskerStateObserver() } class VoiceSatelliteBinder(val service: VoiceSatelliteService) : Binder() @@ -149,6 +154,13 @@ class VoiceSatelliteService() : LifecycleService() { }.launchIn(lifecycleScope) } + private fun startTaskerStateObserver() { + combine(voiceSatelliteState, voiceTimers) { state, timers -> + AvaActivityRunner.updateState(state, timers) + ActivityConfigAvaActivity::class.java.requestQuery(this) + }.launchIn(lifecycleScope) + } + private suspend fun createVoiceSatellite(satelliteSettings: VoiceSatelliteSettings): VoiceSatellite { val microphoneSettings = microphoneSettingsStore.get() val audioInput = VoiceSatelliteAudioInputImpl( diff --git a/app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt b/app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt new file mode 100644 index 0000000..242ffbd --- /dev/null +++ b/app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt @@ -0,0 +1,124 @@ +package com.example.ava.tasker + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults.topAppBarColors +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.example.ava.R +import com.example.ava.ui.screens.settings.components.SwitchSetting +import com.example.ava.ui.theme.AvaTheme +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelper +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput + +class AvaActivityHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelper(config) { + override val runnerClass = AvaActivityRunner::class.java + override val inputClass = AvaActivityInput::class.java + override val outputClass = Unit::class.java +} + +@OptIn(ExperimentalMaterial3Api::class) +class ActivityConfigAvaActivity : ComponentActivity(), + TaskerPluginConfig { + override val context get() = applicationContext + private val taskerHelper by lazy { AvaActivityHelper(this) } + + private var inputState = AvaActivityInput() + + override val inputForTasker: TaskerInput + get() = TaskerInput(inputState) + + override fun assignFromInput(input: TaskerInput) { + inputState = input.regular + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AvaTheme { + var state by remember { mutableStateOf(inputState) } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + colors = topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + title = { + Text(stringResource(R.string.tasker_condition_ava_activity)) + }, + actions = { + TextButton( + onClick = { + inputState = state + taskerHelper.finishForTasker() + } + ) { + Text(stringResource(R.string.label_save)) + } + } + ) + } + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Text( + text = stringResource(R.string.tasker_condition_description), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(16.dp) + ) + SwitchSetting( + name = stringResource(R.string.tasker_filter_conversing), + description = stringResource(R.string.tasker_filter_conversing_description), + value = state.conversing, + onCheckedChange = { state = state.copy(conversing = it) } + ) + SwitchSetting( + name = stringResource(R.string.tasker_filter_timer_ringing), + description = stringResource(R.string.tasker_filter_timer_ringing_description), + value = state.timerRinging, + onCheckedChange = { state = state.copy(timerRinging = it) } + ) + SwitchSetting( + name = stringResource(R.string.tasker_filter_timer_running), + description = stringResource(R.string.tasker_filter_timer_running_description), + value = state.timerRunning, + onCheckedChange = { state = state.copy(timerRunning = it) } + ) + SwitchSetting( + name = stringResource(R.string.tasker_filter_timer_paused), + description = stringResource(R.string.tasker_filter_timer_paused_description), + value = state.timerPaused, + onCheckedChange = { state = state.copy(timerPaused = it) } + ) + } + } + } + } + taskerHelper.onCreate() + } +} diff --git a/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt b/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt new file mode 100644 index 0000000..ca2a1e4 --- /dev/null +++ b/app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt @@ -0,0 +1,94 @@ +package com.example.ava.tasker + +import android.content.Context +import com.example.ava.esphome.EspHomeState +import com.example.ava.esphome.voicesatellite.Listening +import com.example.ava.esphome.voicesatellite.Processing +import com.example.ava.esphome.voicesatellite.Responding +import com.example.ava.esphome.voicesatellite.VoiceTimer +import com.joaomgcd.taskerpluginlibrary.condition.TaskerPluginRunnerConditionState +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.input.TaskerInputField +import com.joaomgcd.taskerpluginlibrary.input.TaskerInputRoot +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultCondition +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionSatisfied +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionUnsatisfied +import timber.log.Timber + +/** + * Holds the tasker configuration for evaluating the state condition. + * + * Because of limitations in Tasker, it cannot be a data class, so toString and copy + * are implemented manually. + */ +@TaskerInputRoot +class AvaActivityInput @JvmOverloads constructor( + @field:TaskerInputField("conversing", labelResIdName = "tasker_filter_conversing") + val conversing: Boolean = true, + @field:TaskerInputField("timer_ringing", labelResIdName = "tasker_filter_timer_ringing") + val timerRinging: Boolean = true, + @field:TaskerInputField("timer_running", labelResIdName = "tasker_filter_timer_running") + val timerRunning: Boolean = false, + @field:TaskerInputField("timer_paused", labelResIdName = "tasker_filter_timer_paused") + val timerPaused: Boolean = false +) { + override fun toString(): String = + "AvaActivityInput(conversing=$conversing, timerRinging=$timerRinging, timerRunning=$timerRunning, timerPaused=$timerPaused)" + + fun copy( + conversing: Boolean = this.conversing, + timerRinging: Boolean = this.timerRinging, + timerRunning: Boolean = this.timerRunning, + timerPaused: Boolean = this.timerPaused + ): AvaActivityInput = AvaActivityInput(conversing, timerRinging, timerRunning, timerPaused) +} + +val conversingStates = setOf(Listening, Processing, Responding) + +/** + * Holds the condition evaluation logic: Tasker calls getSatisfiedCondition when + * we signal a change (updateState is called by VoiceSatelliteService). + * + * Because of limitations in Tasker, we cannot hold a reactive state, so we get + * state pushed in through a companion object. + */ +class AvaActivityRunner : + TaskerPluginRunnerConditionState() { + override fun getSatisfiedCondition( + context: Context, + input: TaskerInput, + update: Unit? + ): TaskerPluginResultCondition { + val filter = input.regular + Timber.d("Evaluating tasker condition: $filter - $currentState - $currentTimers") + + val timers = currentTimers ?: return TaskerPluginResultConditionUnsatisfied() + val satisfied = when { + filter.conversing && currentState in conversingStates -> true + filter.timerRinging && timers.any { it is VoiceTimer.Ringing } -> true + filter.timerRunning && timers.any { it is VoiceTimer.Running } -> true + filter.timerPaused && timers.any { it is VoiceTimer.Paused } -> true + else -> false + } + + Timber.d("Evaluated to $satisfied") + return if (satisfied) { + TaskerPluginResultConditionSatisfied(context) + } else { + TaskerPluginResultConditionUnsatisfied() + } + } + + companion object { + var currentState: EspHomeState? = null + private set + var currentTimers: List? = null + private set + + fun updateState(state: EspHomeState, timers: List) { + Timber.d("Tasker state updating: state=$state, timers=${timers.size}") + currentState = state + currentTimers = timers + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fbc365c..eecbf4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,7 @@ Start Stop OK + Save Cancel Disabled Settings @@ -33,4 +34,15 @@ Processing Responding Server Error: %1$s + + Ava Activity + Condition is satisfied if any enabled filter matches + Conversing + Listening or replying to a voice command + Timer Ringing + A voice timer is ringing + Timer Running + At least one timer is counting down + Timer Paused + At least one paused timer remains \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index f3f6344..41f1287 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,4 @@ -