From b6a18858641d305d15def77ae09375706c05315c Mon Sep 17 00:00:00 2001
From: Michael Hansen <mike@rhasspy.org>
Date: Thu, 23 May 2024 14:34:18 -0500
Subject: [PATCH 1/2] Add timers

---
 CHANGELOG.md     |   1 +
 wyoming/timer.py | 149 +++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 150 insertions(+)
 create mode 100644 wyoming/timer.py

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 710b421..e8bb798 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## 1.5.4
 
+- Add support for voice timers
 - Add `speaker` field to `detect` event
 - Refactor HTTP servers
 
diff --git a/wyoming/timer.py b/wyoming/timer.py
new file mode 100644
index 0000000..b8eaffc
--- /dev/null
+++ b/wyoming/timer.py
@@ -0,0 +1,149 @@
+"""Support for voice timers."""
+
+from dataclasses import dataclass
+from typing import Optional
+
+from .event import Event, Eventable
+
+DOMAIN = "timer"
+_STARTED_TYPE = "timer-started"
+_UPDATED_TYPE = "timer-updated"
+_CANCELLED_TYPE = "timer-cancelled"
+_FINISHED_TYPE = "timer-finished"
+
+
+@dataclass
+class TimerStarted(Eventable):
+    """New timer was started."""
+
+    id: str
+    """Unique id of timer."""
+
+    total_seconds: int
+    """Total number of seconds the timer will run for."""
+
+    name: Optional[str] = None
+    """Optional name provided by user."""
+
+    start_hours: Optional[int] = None
+    """Number of hours users requested the timer to run for."""
+
+    start_minutes: Optional[int] = None
+    """Number of minutes users requested the timer to run for."""
+
+    start_seconds: Optional[int] = None
+    """Number of minutes users requested the timer to run for."""
+
+    @staticmethod
+    def is_type(event_type: str) -> bool:
+        return event_type == _STARTED_TYPE
+
+    def event(self) -> Event:
+        data = {"id": self.id, "total_seconds": self.total_seconds}
+        if self.name is not None:
+            data["name"] = self.name
+
+        if self.start_hours is not None:
+            data["start_hours"] = self.start_hours
+
+        if self.start_minutes is not None:
+            data["start_minutes"] = self.start_minutes
+
+        if self.start_seconds is not None:
+            data["start_seconds"] = self.start_seconds
+
+        return Event(
+            type=_STARTED_TYPE,
+            data=data,
+        )
+
+    @staticmethod
+    def from_event(event: Event) -> "TimerStarted":
+        return TimerStarted(
+            id=event.data["id"],
+            total_seconds=event.data["total_seconds"],
+            name=event.data.get("name"),
+            start_hours=event.data.get("start_hours"),
+            start_minutes=event.data.get("start_minutes"),
+            start_seconds=event.data.get("start_seconds"),
+        )
+
+
+@dataclass
+class TimerUpdated(Eventable):
+    """Existing timer was paused, resumed, or had time added or removed."""
+
+    id: str
+    """Unique id of timer."""
+
+    is_active: bool
+    """True if timer is running."""
+
+    total_seconds: int
+    """Number of seconds left on the timer."""
+
+    @staticmethod
+    def is_type(event_type: str) -> bool:
+        return event_type == _UPDATED_TYPE
+
+    def event(self) -> Event:
+        return Event(
+            type=_UPDATED_TYPE,
+            data={
+                "id": self.id,
+                "is_active": self.is_active,
+                "total_seconds": self.total_seconds,
+            },
+        )
+
+    @staticmethod
+    def from_event(event: Event) -> "TimerUpdated":
+        return TimerUpdated(
+            id=event.data["id"],
+            is_active=event.data["is_active"],
+            total_seconds=event.data["total_seconds"],
+        )
+
+
+@dataclass
+class TimerCancelled(Eventable):
+    """Existing timer was cancelled."""
+
+    id: str
+    """Unique id of timer."""
+
+    @staticmethod
+    def is_type(event_type: str) -> bool:
+        return event_type == _CANCELLED_TYPE
+
+    def event(self) -> Event:
+        return Event(
+            type=_CANCELLED_TYPE,
+            data={"id": self.id},
+        )
+
+    @staticmethod
+    def from_event(event: Event) -> "TimerCancelled":
+        return TimerCancelled(id=event.data["id"])
+
+
+@dataclass
+class TimerFinished(Eventable):
+    """Existing timer finished without being cancelled."""
+
+    id: str
+    """Unique id of timer."""
+
+    @staticmethod
+    def is_type(event_type: str) -> bool:
+        return event_type == _FINISHED_TYPE
+
+    def event(self) -> Event:
+        return Event(
+            type=_FINISHED_TYPE,
+            data={"id": self.id},
+        )
+
+    @staticmethod
+    def from_event(event: Event) -> "TimerFinished":
+        return TimerFinished(id=event.data["id"])

From 28c15116a0ae7fce463f1a4a7f6698815777e218 Mon Sep 17 00:00:00 2001
From: Michael Hansen <mike@rhasspy.org>
Date: Fri, 24 May 2024 15:18:09 -0500
Subject: [PATCH 2/2] Update changelog

---
 CHANGELOG.md | 4 ++++
 README.md    | 6 ++++++
 2 files changed, 10 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8bb798..6ce4071 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
 ## 1.5.4
 
 - Add support for voice timers
+    - `timer-started`
+    - `timer-updated`
+    - `timer-cancelled`
+    - `timer-finished`
 - Add `speaker` field to `detect` event
 - Refactor HTTP servers
 
diff --git a/README.md b/README.md
index 77a1506..0fc7df1 100644
--- a/README.md
+++ b/README.md
@@ -228,6 +228,12 @@ Control of one or more remote voice satellites connected to a central server.
 * `streaming-started` - satellite has started streaming audio to the server
 * `streaming-stopped` - satellite has stopped streaming audio to the server
 
+### Timers
+
+* `timer-started` - a new timer has started
+* `timer-updated` - timer has been paused/resumed or time has been added/removed
+* `timer-cancelled` - timer was cancelled
+* `timer-finished` - timer finished without being cancelled
 
 ## Event Flow