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
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