Add timers
This commit is contained in:
@@ -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
@@ -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 @@
|
|||||||
1.2.0
|
1.3.0
|
||||||
|
|||||||
@@ -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=(
|
||||||
|
args.wake_refractory_seconds
|
||||||
if args.wake_refractory_seconds > 0
|
if args.wake_refractory_seconds > 0
|
||||||
else None,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user