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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions TASKER.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".tasker.ActivityConfigAvaActivity"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/tasker_condition_ava_activity"
android:theme="@style/Theme.Ava">
<intent-filter>
<action android:name="com.twofortyfouram.locale.intent.action.EDIT_CONDITION" />
</intent-filter>
</activity>
</application>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -93,6 +97,7 @@ class VoiceSatelliteService() : LifecycleService() {
createVoiceSatelliteServiceNotificationChannel(this)
updateNotificationOnStateChanges()
startSettingsWatcher()
startTaskerStateObserver()
}

class VoiceSatelliteBinder(val service: VoiceSatelliteService) : Binder()
Expand Down Expand Up @@ -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(
Expand Down
124 changes: 124 additions & 0 deletions app/src/main/java/com/example/ava/tasker/ActivityConfigAvaActivity.kt
Original file line number Diff line number Diff line change
@@ -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<AvaActivityInput>) :
TaskerPluginConfigHelper<AvaActivityInput, Unit, AvaActivityRunner>(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<AvaActivityInput> {
override val context get() = applicationContext
private val taskerHelper by lazy { AvaActivityHelper(this) }

private var inputState = AvaActivityInput()

override val inputForTasker: TaskerInput<AvaActivityInput>
get() = TaskerInput(inputState)

override fun assignFromInput(input: TaskerInput<AvaActivityInput>) {
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()
}
}
94 changes: 94 additions & 0 deletions app/src/main/java/com/example/ava/tasker/AvaActivityCondition.kt
Original file line number Diff line number Diff line change
@@ -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<AvaActivityInput, Unit>() {
override fun getSatisfiedCondition(
context: Context,
input: TaskerInput<AvaActivityInput>,
update: Unit?
): TaskerPluginResultCondition<Unit> {
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<VoiceTimer>? = null
private set

fun updateState(state: EspHomeState, timers: List<VoiceTimer>) {
Timber.d("Tasker state updating: state=$state, timers=${timers.size}")
currentState = state
currentTimers = timers
}
}
}
12 changes: 12 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<string name="label_start_service">Start</string>
<string name="label_stop_service">Stop</string>
<string name="label_ok">OK</string>
<string name="label_save">Save</string>
<string name="label_cancel">Cancel</string>
<string name="label_disabled">Disabled</string>
<string name="label_settings">Settings</string>
Expand Down Expand Up @@ -33,4 +34,15 @@
<string name="satellite_state_processing">Processing</string>
<string name="satellite_state_responding">Responding</string>
<string name="satellite_state_server_error">Server Error: %1$s</string>

<string name="tasker_condition_ava_activity">Ava Activity</string>
<string name="tasker_condition_description">Condition is satisfied if any enabled filter matches</string>
<string name="tasker_filter_conversing">Conversing</string>
<string name="tasker_filter_conversing_description">Listening or replying to a voice command</string>
<string name="tasker_filter_timer_ringing">Timer Ringing</string>
<string name="tasker_filter_timer_ringing_description">A voice timer is ringing</string>
<string name="tasker_filter_timer_running">Timer Running</string>
<string name="tasker_filter_timer_running_description">At least one timer is counting down</string>
<string name="tasker_filter_timer_paused">Timer Paused</string>
<string name="tasker_filter_timer_paused_description">At least one paused timer remains</string>
</resources>
1 change: 0 additions & 1 deletion app/src/main/res/values/themes.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Theme.Ava" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ media3CommonKtx = "1.9.2"
media3Exoplayer = "1.9.2"
material3 = "1.4.0"
documentfile = "1.1.0"
taskerpluginlibrary = "0.4.10"
timber = "5.0.1"

[libraries]
Expand Down Expand Up @@ -63,6 +64,7 @@ androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplaye
androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3Exoplayer" }
material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
taskerpluginlibrary = { module = "com.joaomgcd:taskerpluginlibrary", version.ref = "taskerpluginlibrary" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }

[plugins]
Expand Down