From 851ede8167d77b9120abd99f248a1f869a1765c5 Mon Sep 17 00:00:00 2001 From: Xavier Vello Date: Wed, 11 Mar 2026 11:36:36 +0100 Subject: [PATCH 1/2] tasker: add actions --- TASKER.md | 5 ++ app/src/main/AndroidManifest.xml | 20 ++++++++ .../esphome/voicesatellite/VoiceSatellite.kt | 9 +++- .../example/ava/tasker/StopRingingAction.kt | 51 +++++++++++++++++++ .../example/ava/tasker/WakeSatelliteAction.kt | 50 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 ++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/example/ava/tasker/StopRingingAction.kt create mode 100644 app/src/main/java/com/example/ava/tasker/WakeSatelliteAction.kt diff --git a/TASKER.md b/TASKER.md index ff4c711..814e830 100644 --- a/TASKER.md +++ b/TASKER.md @@ -10,6 +10,11 @@ to query the voice assistant state via the Home Assistant server. > The project will not provide general support for Tasker. You should check out their website and > the `r/tasker` subreddit for support. +## Actions: + +- **Wake up satellite**: plays the wake sound (if enabled) and listens to voice commands as if the wake word was detected. +- **Stop ringing**: stops the timer ringing sound, as if "stop" was said. + ## State Condition: Ava Activity The `Ava Activity` state can be used to trigger tasks when the voice satellite enters or exits diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 034536d..25307ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,8 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt index 7988550..c77a253 100644 --- a/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt +++ b/app/src/main/java/com/example/ava/esphome/voicesatellite/VoiceSatellite.kt @@ -12,6 +12,8 @@ import com.example.ava.server.DEFAULT_SERVER_PORT import com.example.ava.server.Server import com.example.ava.server.ServerImpl import com.example.ava.settings.VoiceSatelliteSettingsStore +import com.example.ava.tasker.StopRingingRunner +import com.example.ava.tasker.WakeSatelliteRunner import com.example.esphomeproto.api.DeviceInfoResponse import com.example.esphomeproto.api.VoiceAssistantAnnounceRequest import com.example.esphomeproto.api.VoiceAssistantConfigurationRequest @@ -94,8 +96,11 @@ class VoiceSatellite( override fun start() { super.start() startAudioInput() - } + // Wire up tasker actions + WakeSatelliteRunner.register { scope.launch { wakeSatellite() } } + StopRingingRunner.register { scope.launch { stopTimer() } } + } @RequiresPermission(Manifest.permission.RECORD_AUDIO) private fun startAudioInput() = server.isConnected .flatMapLatest { isConnected -> @@ -339,5 +344,7 @@ class VoiceSatellite( override fun close() { super.close() player.close() + WakeSatelliteRunner.unregister() + StopRingingRunner.unregister() } } \ No newline at end of file diff --git a/app/src/main/java/com/example/ava/tasker/StopRingingAction.kt b/app/src/main/java/com/example/ava/tasker/StopRingingAction.kt new file mode 100644 index 0000000..e1bb649 --- /dev/null +++ b/app/src/main/java/com/example/ava/tasker/StopRingingAction.kt @@ -0,0 +1,51 @@ +package com.example.ava.tasker + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import com.example.ava.R +import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultError +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess + +class StopRingingHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelperNoOutputOrInput(config) { + override val runnerClass get() = StopRingingRunner::class.java + +} + +class ActivityConfigStopRinging : Activity(), TaskerPluginConfigNoInput { + override val context: Context get() = this + private val taskerHelper by lazy { StopRingingHelper(this) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + taskerHelper.finishForTasker() + } +} + +class StopRingingRunner : TaskerPluginRunnerActionNoOutputOrInput() { + override fun run(context: Context, input: TaskerInput): TaskerPluginResult { + val cb = callback + ?: return TaskerPluginResultError(Exception(context.getString(R.string.tasker_action_error_not_running))) + cb() + return TaskerPluginResultSucess() + } + + companion object { + @Volatile + private var callback: (() -> Unit)? = null + + fun register(cb: () -> Unit) { + callback = cb + } + + fun unregister() { + callback = null + } + } +} diff --git a/app/src/main/java/com/example/ava/tasker/WakeSatelliteAction.kt b/app/src/main/java/com/example/ava/tasker/WakeSatelliteAction.kt new file mode 100644 index 0000000..5052dd3 --- /dev/null +++ b/app/src/main/java/com/example/ava/tasker/WakeSatelliteAction.kt @@ -0,0 +1,50 @@ +package com.example.ava.tasker + +import android.app.Activity +import android.content.Context +import android.os.Bundle +import com.example.ava.R +import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutputOrInput +import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigNoInput +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultError +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess + +class WakeSatelliteHelper(config: TaskerPluginConfig) : + TaskerPluginConfigHelperNoOutputOrInput(config) { + override val runnerClass get() = WakeSatelliteRunner::class.java +} + +class ActivityConfigWakeSatellite : Activity(), TaskerPluginConfigNoInput { + override val context: Context get() = this + private val taskerHelper by lazy { WakeSatelliteHelper(this) } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + taskerHelper.finishForTasker() + } +} + +class WakeSatelliteRunner : TaskerPluginRunnerActionNoOutputOrInput() { + override fun run(context: Context, input: TaskerInput): TaskerPluginResult { + val cb = callback + ?: return TaskerPluginResultError(Exception(context.getString(R.string.tasker_action_error_not_running))) + cb() + return TaskerPluginResultSucess() + } + + companion object { + @Volatile + private var callback: (() -> Unit)? = null + + fun register(cb: () -> Unit) { + callback = cb + } + + fun unregister() { + callback = null + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 79c46c5..497fdbc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,4 +49,7 @@ At least one timer is counting down Timer Paused At least one paused timer remains + Wake up satellite + Stop ringing + Satellite service not running \ No newline at end of file From 1e715b774f4f49f9501c66877f8ed3101a23d096 Mon Sep 17 00:00:00 2001 From: Xavier Vello Date: Wed, 11 Mar 2026 14:27:23 +0100 Subject: [PATCH 2/2] add unit tests for both actions --- .../java/com/example/ava/TaskerPluginsTest.kt | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 app/src/test/java/com/example/ava/TaskerPluginsTest.kt diff --git a/app/src/test/java/com/example/ava/TaskerPluginsTest.kt b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt new file mode 100644 index 0000000..c3ab25b --- /dev/null +++ b/app/src/test/java/com/example/ava/TaskerPluginsTest.kt @@ -0,0 +1,121 @@ +package com.example.ava + +import android.content.ContextWrapper +import androidx.compose.material3.TimeInput +import com.example.ava.esphome.voicesatellite.AudioResult +import com.example.ava.esphome.voicesatellite.Listening +import com.example.ava.esphome.voicesatellite.VoiceSatellite +import com.example.ava.esphome.voicesatellite.VoiceSatelliteAudioInput +import com.example.ava.esphome.voicesatellite.VoiceSatellitePlayer +import com.example.ava.server.Server +import com.example.ava.stubs.StubAudioPlayer +import com.example.ava.stubs.StubServer +import com.example.ava.stubs.StubVoiceSatelliteAudioInput +import com.example.ava.stubs.StubVoiceSatellitePlayer +import com.example.ava.stubs.StubVoiceSatelliteSettingsStore +import com.example.ava.stubs.stubSettingState +import com.example.ava.tasker.AvaActivityInput +import com.example.ava.tasker.AvaActivityRunner +import com.example.ava.tasker.StopRingingRunner +import com.example.ava.tasker.WakeSatelliteRunner +import com.example.esphomeproto.api.VoiceAssistantRequest +import com.example.esphomeproto.api.VoiceAssistantTimerEvent +import com.example.esphomeproto.api.voiceAssistantTimerEventResponse +import com.joaomgcd.taskerpluginlibrary.input.TaskerInput +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionSatisfied +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultConditionUnsatisfied +import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +class TaskerPluginsTest { + private val dummyContext = ContextWrapper(null) + + private fun TestScope.createSatellite( + server: Server = StubServer(), + audioInput: VoiceSatelliteAudioInput = StubVoiceSatelliteAudioInput(), + player: VoiceSatellitePlayer = StubVoiceSatellitePlayer() + ) = VoiceSatellite( + coroutineContext = this.coroutineContext, + "Test Satellite", + server = server, + audioInput = audioInput, + player = player, + settingsStore = StubVoiceSatelliteSettingsStore() + ).apply { + start() + advanceUntilIdle() + } + + @Test + fun should_handle_wake_satellite_action() = runTest { + val server = StubServer() + val audioInput = StubVoiceSatelliteAudioInput() + val satellite = createSatellite(server = server, audioInput = audioInput) + advanceUntilIdle() + + val result = WakeSatelliteRunner().run(dummyContext, TaskerInput(Unit)) + assert(result is TaskerPluginResultSucess) + advanceUntilIdle() + + assertEquals(Listening, satellite.state.value) + assertEquals(true, audioInput.isStreaming) + assertEquals(1, server.sentMessages.size) + assertEquals(true, (server.sentMessages[0] as VoiceAssistantRequest).start) + + satellite.close() + } + + @Test + fun should_handle_stop_ringing_action() = runTest { + val server = StubServer() + val ttsPlayer = object : StubAudioPlayer() { + val mediaUrls = mutableListOf() + lateinit var onCompletion: () -> Unit + override fun play(mediaUris: Iterable, onCompletion: () -> Unit) { + this.mediaUrls.addAll(mediaUris) + this.onCompletion = onCompletion + } + + var stopped = false + override fun stop() { + stopped = true + } + } + val satellite = createSatellite( + server = server, + player = StubVoiceSatellitePlayer( + ttsPlayer = ttsPlayer, + repeatTimerFinishedSound = stubSettingState(true), + timerFinishedSound = stubSettingState("ring") + ) + ) + + // Make it ring by sending a timer finished event + server.receivedMessages.emit(voiceAssistantTimerEventResponse { + eventType = VoiceAssistantTimerEvent.VOICE_ASSISTANT_TIMER_FINISHED + timerId = "id" + totalSeconds = 60 + secondsLeft = 0 + isActive = true + }) + advanceUntilIdle() + + assertEquals(false, ttsPlayer.stopped) + assertEquals(listOf("ring"), ttsPlayer.mediaUrls) + ttsPlayer.mediaUrls.clear() + + // Trigger StopRingingAction via its runner + val result = StopRingingRunner().run(dummyContext, TaskerInput(Unit)) + assert(result is TaskerPluginResultSucess) + advanceUntilIdle() + + // Should no longer be ringing + assertEquals(true, ttsPlayer.stopped) + + satellite.close() + } +} \ No newline at end of file