Add timers

This commit is contained in:
Michael Hansen
2024-05-24 15:14:15 -05:00
parent 9b35e0334f
commit 16847ef5e9
8 changed files with 127 additions and 7 deletions
+4
View File
@@ -1,5 +1,9 @@
# Changelog # Changelog
## 1.3.0
- Add support for voice timers
## 1.2.0 ## 1.2.0
- Add `--tts-played-command` - Add `--tts-played-command`
+1 -1
View File
@@ -1,3 +1,3 @@
wyoming==1.5.3 wyoming==1.5.4
zeroconf==0.88.0 zeroconf==0.88.0
pyring-buffer==1.0.0 pyring-buffer==1.0.0
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
1.2.0 1.3.0
+48 -3
View File
@@ -1,4 +1,5 @@
"""Main entry point for Wyoming satellite.""" """Main entry point for Wyoming satellite."""
import argparse import argparse
import asyncio import asyncio
import logging import logging
@@ -22,6 +23,7 @@ from .settings import (
MicSettings, MicSettings,
SatelliteSettings, SatelliteSettings,
SndSettings, SndSettings,
TimerSettings,
VadSettings, VadSettings,
WakeSettings, WakeSettings,
WakeWordAndPipeline, WakeWordAndPipeline,
@@ -225,6 +227,23 @@ async def main() -> None:
"--disconnected-command", "--disconnected-command",
help="Command to run when disconnected from the server", help="Command to run when disconnected from the server",
) )
parser.add_argument(
"--timer-started-command",
help="Command to run when a timer starts",
)
parser.add_argument(
"--timer-updated-command",
help="Command to run when a timer is paused, resumed, or has time added or removed",
)
parser.add_argument(
"--timer-cancelled-command",
"--timer-canceled-command",
help="Command to run when a timer is cancelled",
)
parser.add_argument(
"--timer-finished-command",
help="Command to run when a timer finishes",
)
# Sounds # Sounds
parser.add_argument( parser.add_argument(
@@ -233,6 +252,17 @@ async def main() -> None:
parser.add_argument( parser.add_argument(
"--done-wav", help="WAV file to play when voice command is done" "--done-wav", help="WAV file to play when voice command is done"
) )
parser.add_argument(
"--timer-finished-wav", help="WAV file to play when a timer finishes"
)
parser.add_argument(
"--timer-finished-wav-repeat",
nargs=2,
metavar=("repeat", "delay"),
type=float,
default=(1, 0),
help="Number of times to play timer finished WAV and delay between repeats in seconds",
)
# Satellite details # Satellite details
parser.add_argument("--uri", required=True, help="unix:// or tcp://") parser.add_argument("--uri", required=True, help="unix:// or tcp://")
@@ -296,6 +326,10 @@ async def main() -> None:
_LOGGER.fatal("%s does not exist", args.done_wav) _LOGGER.fatal("%s does not exist", args.done_wav)
sys.exit(1) sys.exit(1)
if args.timer_finished_wav and (not Path(args.timer_finished_wav).is_file()):
_LOGGER.fatal("%s does not exist", args.timer_finished_wav)
sys.exit(1)
if args.vad and (args.wake_uri or args.wake_command): if args.vad and (args.wake_uri or args.wake_command):
_LOGGER.warning("VAD is not used with local wake word detection") _LOGGER.warning("VAD is not used with local wake word detection")
@@ -347,9 +381,11 @@ async def main() -> None:
names=[ names=[
WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name WakeWordAndPipeline(*wake_name) for wake_name in args.wake_word_name
], ],
refractory_seconds=args.wake_refractory_seconds refractory_seconds=(
if args.wake_refractory_seconds > 0 args.wake_refractory_seconds
else None, if args.wake_refractory_seconds > 0
else None
),
), ),
snd=SndSettings( snd=SndSettings(
uri=args.snd_uri, uri=args.snd_uri,
@@ -379,6 +415,15 @@ async def main() -> None:
connected=split_command(args.connected_command), connected=split_command(args.connected_command),
disconnected=split_command(args.disconnected_command), disconnected=split_command(args.disconnected_command),
), ),
timer=TimerSettings(
started=split_command(args.timer_started_command),
updated=split_command(args.timer_updated_command),
cancelled=split_command(args.timer_cancelled_command),
finished=split_command(args.timer_finished_command),
finished_wav=args.timer_finished_wav,
finished_wav_plays=int(args.timer_finished_wav_repeat[0]),
finished_wav_delay=args.timer_finished_wav_repeat[1],
),
debug_recording_dir=args.debug_recording_dir, debug_recording_dir=args.debug_recording_dir,
) )
+35
View File
@@ -28,6 +28,7 @@ from wyoming.satellite import (
StreamingStopped, StreamingStopped,
) )
from wyoming.snd import Played, SndProcessAsyncClient from wyoming.snd import Played, SndProcessAsyncClient
from wyoming.timer import TimerCancelled, TimerFinished, TimerStarted, TimerUpdated
from wyoming.tts import Synthesize from wyoming.tts import Synthesize
from wyoming.vad import VoiceStarted, VoiceStopped from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection, WakeProcessAsyncClient from wyoming.wake import Detect, Detection, WakeProcessAsyncClient
@@ -289,6 +290,18 @@ class SatelliteBase:
elif Error.is_type(event.type): elif Error.is_type(event.type):
_LOGGER.warning(event) _LOGGER.warning(event)
await self.trigger_error(Error.from_event(event)) await self.trigger_error(Error.from_event(event))
elif TimerStarted.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_started(TimerStarted.from_event(event))
elif TimerUpdated.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_updated(TimerUpdated.from_event(event))
elif TimerCancelled.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_cancelled(TimerCancelled.from_event(event))
elif TimerFinished.is_type(event.type):
_LOGGER.debug(event)
await self.trigger_timer_finished(TimerFinished.from_event(event))
# Forward everything except audio to event service # Forward everything except audio to event service
if not AudioChunk.is_type(event.type): if not AudioChunk.is_type(event.type):
@@ -852,6 +865,28 @@ class SatelliteBase:
"""Called when an error occurs on the server.""" """Called when an error occurs on the server."""
await run_event_command(self.settings.event.error, error.text) await run_event_command(self.settings.event.error, error.text)
async def trigger_timer_started(self, timer_started: TimerStarted) -> None:
"""Called when timer-started event is received."""
await run_event_command(self.settings.timer.started, timer_started)
async def trigger_timer_updated(self, timer_updated: TimerUpdated) -> None:
"""Called when timer-updated event is received."""
await run_event_command(self.settings.timer.updated, timer_updated)
async def trigger_timer_cancelled(self, timer_cancelled: TimerCancelled) -> None:
"""Called when timer-cancelled event is received."""
await run_event_command(self.settings.timer.cancelled, timer_cancelled.id)
async def trigger_timer_finished(self, timer_finished: TimerFinished) -> None:
"""Called when timer-finished event is received."""
await run_event_command(self.settings.timer.finished, timer_finished.id)
for _ in range(self.settings.timer.finished_wav_plays):
await self._play_wav(
self.settings.timer.finished_wav,
mute_microphone=self.settings.mic.mute_during_awake_wav,
)
await asyncio.sleep(self.settings.timer.finished_wav_delay)
async def forward_event(self, event: Event) -> None: async def forward_event(self, event: Event) -> None:
"""Forward an event to the event service.""" """Forward an event to the event service."""
if self._event_queue is not None: if self._event_queue is not None:
+28
View File
@@ -1,4 +1,5 @@
"""Satellite settings.""" """Satellite settings."""
from abc import ABC from abc import ABC
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -172,6 +173,32 @@ class EventSettings(ServiceSettings):
disconnected: Optional[List[str]] = None disconnected: Optional[List[str]] = None
@dataclass(frozen=True)
class TimerSettings:
"""Voice timer settings."""
started: Optional[List[str]] = None
"""Command to run when a timer starts."""
updated: Optional[List[str]] = None
"""Command to run when a timer is paused, resumed, or has time added or removed."""
cancelled: Optional[List[str]] = None
"""Command to run when a timer is cancelled."""
finished: Optional[List[str]] = None
"""Command to run when a timer finishes."""
finished_wav: Optional[str] = None
"""WAV file to play when a timer finishes."""
finished_wav_plays: int = 1
"""Number of times to play finished WAV."""
finished_wav_delay: float = 0
"""Delay in seconds between repeats of finished WAV."""
@dataclass(frozen=True) @dataclass(frozen=True)
class SatelliteSettings: class SatelliteSettings:
"""Wyoming satellite settings.""" """Wyoming satellite settings."""
@@ -181,6 +208,7 @@ class SatelliteSettings:
wake: WakeSettings = field(default_factory=WakeSettings) wake: WakeSettings = field(default_factory=WakeSettings)
snd: SndSettings = field(default_factory=SndSettings) snd: SndSettings = field(default_factory=SndSettings)
event: EventSettings = field(default_factory=EventSettings) event: EventSettings = field(default_factory=EventSettings)
timer: TimerSettings = field(default_factory=TimerSettings)
restart_timeout: float = 5.0 restart_timeout: float = 5.0
+10 -2
View File
@@ -1,24 +1,32 @@
"""Miscellaneous utilities.""" """Miscellaneous utilities."""
import argparse import argparse
import asyncio import asyncio
import json
import logging import logging
import re import re
import shlex import shlex
import unicodedata import unicodedata
import uuid import uuid
from functools import lru_cache from functools import lru_cache
from typing import List, Optional from typing import List, Optional, Union
from wyoming.event import Eventable
_LOGGER = logging.getLogger() _LOGGER = logging.getLogger()
async def run_event_command( async def run_event_command(
command: Optional[List[str]], command_input: Optional[str] = None command: Optional[List[str]], command_input: Optional[Union[str, Eventable]] = None
) -> None: ) -> None:
"""Run a custom event command with optional input.""" """Run a custom event command with optional input."""
if not command: if not command:
return return
if isinstance(command_input, Eventable):
# Convert event to JSON
event_dict = command_input.event().to_dict()
command_input = json.dumps(event_dict, ensure_ascii=False)
_LOGGER.debug("Running %s", command) _LOGGER.debug("Running %s", command)
program, *program_args = command program, *program_args = command
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(