Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions community/timers-alarms-reminders/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Timers, Alarms & Reminders

A combined interactive + background daemon ability for OpenHome that handles timers, alarms, and reminders through natural voice interaction.

## How It Works

**main.py** (Interactive Skill) — Triggered by hotwords like "set a timer", "remind me", "wake me up". Uses the LLM to classify intent (create/list/cancel/delete) and parse natural language times. Writes events to `scheduled_events.json`.

**background.py** (Background Daemon) — Polls `scheduled_events.json` every 15 seconds. Fires due events with type-appropriate behavior (timers get a spoken notification, alarms play a sound, reminders speak the message). Writes `upcoming_schedule.md` so the Personality stays aware of what's scheduled.

## Voice Commands

| Action | What to say | Example response |
|--------|------------|-----------------|
| **Set a timer** | "set a timer for 5 minutes" / "timer for 30 seconds" / "start a 10 minute timer" | "5 minutes timer started." |
| **Set an alarm** | "set an alarm for 7 AM" / "wake me up at 6:30" / "alarm for 10 PM tomorrow" | "Alarm set for 7:00 AM." |
| **Set a reminder** | "remind me to call mom at 3 PM" / "reminder to take medicine at noon tomorrow" | "Got it. I'll remind you about call mom at 3:00 PM." |
| **List everything** | "list all" / "show all" / "show me everything" / "what do I have" | "You have 3 things scheduled. An alarm at 7 AM. A 5 minute timer..." |
| **List by type** | "list alarms" / "show timers" / "list reminders" | "You have one thing scheduled. An alarm at 7:00 AM." |
| **Cancel one event** | "cancel my 7 AM alarm" / "cancel the call mom reminder" | "Done. Your 7:00 AM alarm has been cancelled." |
| **Delete all of a type** | "delete alarms" / "remove timers" / "clear reminders" | "Deleted all 2 alarms." |
| **Delete everything** | "delete all" / "clear everything" / "delete everything" | "Done. Everything has been cleared." |
| **Exit** | "quit" / "exit" / "goodbye" / "bye" / "never mind" | "All done. Handing you back." |
| **Decline to do more** | "no" / "done" / "all done" / "I'm good" | "All done. Handing you back." |

### Bare Trigger Words

Saying just the trigger word starts a guided flow:

| You say | What happens |
|---------|-------------|
| "alarms" | Lists your alarms (if any), asks what you'd like to do |
| "timers" | Lists your timers (if any), asks what you'd like to do |
| "reminders" | Lists your reminders (if any), asks what you'd like to do |
| "schedule" | Lists everything scheduled, asks what you'd like to do |

If none exist, it asks "Want to set one?" — saying "yes" / "sure" / "yeah" starts creating one.

### Multi-Action Sessions

After each action the assistant asks "Anything else?" — you can chain commands:
1. "set an alarm for 7 AM" → "set a timer for 20 minutes" → "list all" → "done"

You can mix types freely — start with "alarms", then set a timer, then list reminders.

## Event Types

| Type | Trigger Example | Firing Behavior |
|------|----------------|-----------------|
| Timer | "set a timer for 20 minutes" | Speaks "Your 20-minute timer is done!" |
| Alarm | "wake me up at 7am" | Plays alarm.mp3 + speaks notification |
| Reminder | "remind me to call Sarah at 3pm" | Speaks "Reminder: call Sarah" |

## Files

| File | Purpose |
|------|---------|
| `main.py` | Interactive voice flow — classify, parse, CRUD events |
| `background.py` | Background daemon — poll, fire, update context |
| `config.json` | Ability name + hotwords |
| `alarm.mp3` | Alarm sound file |
| `scheduled_events.json` | Shared event store (persistent, user-level) |
| `upcoming_schedule.md` | Personality context file (auto-injected) |

## Known Limitations

- Timers under 15 seconds may fire up to 15s late (poll interval).
- Events are session-scoped — the daemon only runs while the user is connected. Alarms set for after the session ends won't fire until the next session.
- JSON file I/O uses delete-then-write pattern to avoid append corruption.
- List/delete/show commands are handled instantly (fast-path). Create and cancel go through the LLM, which may add a small delay.
Empty file.
Binary file added community/timers-alarms-reminders/alarm.mp3
Binary file not shown.
292 changes: 292 additions & 0 deletions community/timers-alarms-reminders/background.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
import json
from datetime import datetime
from time import time
from zoneinfo import ZoneInfo

from src.agent.capability import MatchingCapability
from src.main import AgentWorker
from src.agent.capability_worker import CapabilityWorker

EVENTS_FILE = "scheduled_events.json"
SCHEDULE_MD = "upcoming_schedule.md"
POLL_INTERVAL = 5.0
GC_MAX_AGE = 3600 # prune triggered events older than 1 hour


class TimersAlarmsRemindersCapabilityBackground(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None
background_daemon_mode: bool = False

# Do not change following tag of register capability
# {{register capability}}

# ------------------------------------------------------------------
# Safe file I/O
# ------------------------------------------------------------------
async def _read_events_safe(self) -> list:
try:
if not await self.capability_worker.check_if_file_exists(
EVENTS_FILE, False
):
return []
raw = await self.capability_worker.read_file(EVENTS_FILE, False)
if not (raw or "").strip():
return []
parsed = json.loads(raw)
return parsed if isinstance(parsed, list) else []
except Exception as e:
self.worker.editor_logging_handler.error(
"%s: [TAR-W] events read failed: %s" % (time(), e)
)
return []

async def _write_events_safe(self, events: list) -> None:
try:
if await self.capability_worker.check_if_file_exists(EVENTS_FILE, False):
await self.capability_worker.delete_file(EVENTS_FILE, False)
await self.capability_worker.write_file(
EVENTS_FILE,
json.dumps(events, ensure_ascii=False, indent=2),
False,
)
except Exception as e:
self.worker.editor_logging_handler.error(
"%s: [TAR-W] events write failed: %s" % (time(), e)
)

# ------------------------------------------------------------------
# Time parsing
# ------------------------------------------------------------------
def _parse_event_time(self, target_iso: str, tz_name: str):
if not target_iso:
return None
try:
dt = datetime.fromisoformat(target_iso)
if dt.tzinfo is None:
try:
dt = dt.replace(tzinfo=ZoneInfo(tz_name or "UTC"))
except Exception:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt
except Exception:
return None

# ------------------------------------------------------------------
# Fire events
# ------------------------------------------------------------------
async def _fire_event(self, event: dict) -> None:
etype = event.get("type", "alarm")
human_time = event.get("human_time", "")
message = event.get("message", "")
duration_label = event.get("duration_label", "")

await self.capability_worker.send_interrupt_signal()

if etype == "timer":
label = duration_label or human_time
await self.capability_worker.speak(
"Your %s timer is done!" % label
)
elif etype == "alarm":
try:
await self.capability_worker.play_from_audio_file("alarm.mp3")
except Exception as e:
self.worker.editor_logging_handler.error(
"%s: [TAR-W] alarm sound failed: %s" % (time(), e)
)
await self.capability_worker.speak(
"Your %s alarm is going off!" % human_time
)
elif etype == "reminder":
if message:
await self.capability_worker.speak("Reminder: %s" % message)
else:
await self.capability_worker.speak(
"You have a reminder scheduled for now."
)

async def _mark_event_triggered(self, events: list, event_id: str) -> None:
changed = False
for e in events:
if e.get("id") == event_id and e.get("status") == "scheduled":
e["status"] = "triggered"
e["triggered_at_epoch"] = int(time())
changed = True
break
if changed:
await self._write_events_safe(events)

# ------------------------------------------------------------------
# Garbage collection: prune old triggered events
# ------------------------------------------------------------------
def _gc_events(self, events: list) -> list:
cutoff = int(time()) - GC_MAX_AGE
return [
e
for e in events
if not (
e.get("status") == "triggered"
and (e.get("triggered_at_epoch") or 0) < cutoff
)
]

# ------------------------------------------------------------------
# upcoming_schedule.md writer
# ------------------------------------------------------------------
async def _update_schedule_md(self, events: list, tz_name: str) -> None:
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("UTC")
now = datetime.now(tz=tz)

scheduled = [e for e in events if e.get("status") == "scheduled"]

if not scheduled:
content = (
"## Upcoming Schedule\n\n"
"No timers, alarms, or reminders currently scheduled.\n\n"
"_Last updated: %s_\n" % now.strftime("%-I:%M %p")
)
else:
timers = [e for e in scheduled if e.get("type") == "timer"]
alarms = [e for e in scheduled if e.get("type") == "alarm"]
reminders = [e for e in scheduled if e.get("type") == "reminder"]

lines = ["## Upcoming Schedule\n"]

if timers:
lines.append("**Timers:**")
for t in timers:
lines.append(
"- %s timer — fires at %s"
% (t.get("duration_label", ""), t.get("human_time", ""))
)
lines.append("")

if alarms:
lines.append("**Alarms:**")
for a in alarms:
lines.append("- %s alarm" % a.get("human_time", ""))
lines.append("")

if reminders:
lines.append("**Reminders:**")
for r in reminders:
lines.append(
"- %s — %s" % (r.get("message", ""), r.get("human_time", ""))
)
lines.append("")

lines.append("_Last updated: %s_\n" % now.strftime("%-I:%M %p"))
content = "\n".join(lines)

try:
await self.capability_worker.write_file(
SCHEDULE_MD, content, False, mode="w"
)
except Exception:
try:
if await self.capability_worker.check_if_file_exists(
SCHEDULE_MD, False
):
await self.capability_worker.delete_file(SCHEDULE_MD, False)
await self.capability_worker.write_file(SCHEDULE_MD, content, False)
except Exception as e:
self.worker.editor_logging_handler.error(
"%s: [TAR-W] schedule md write failed: %s" % (time(), e)
)

# ------------------------------------------------------------------
# Stale file cleanup on startup
# ------------------------------------------------------------------
async def _clear_stale_md(self) -> None:
try:
if await self.capability_worker.check_if_file_exists(SCHEDULE_MD, False):
await self.capability_worker.delete_file(SCHEDULE_MD, False)
self.worker.editor_logging_handler.info(
"[TAR-W] Cleared stale schedule md on startup"
)
except Exception:
pass

# ------------------------------------------------------------------
# Main watcher loop
# ------------------------------------------------------------------
async def watcher_loop(self):
self.worker.editor_logging_handler.info(
"%s: [TAR-W] Watcher started" % time()
)

await self._clear_stale_md()

while True:
try:
events = await self._read_events_safe()

tz_name = self.capability_worker.get_timezone()
if not tz_name:
tz_name = "UTC"
try:
tz = ZoneInfo(tz_name)
except Exception:
tz = ZoneInfo("UTC")

now = datetime.now(tz=tz)

# Find due events
due = []
for e in events:
if e.get("status") != "scheduled":
continue
target_dt = self._parse_event_time(
e.get("target_iso"), e.get("timezone") or tz_name
)
if not target_dt:
continue
if now >= target_dt:
due.append((e, target_dt))

# Fire due events in chronological order
if due:
due.sort(key=lambda x: x[1])
for event, target_dt in due:
eid = event.get("id", "unknown")
self.worker.editor_logging_handler.info(
"%s: [TAR-W] FIRING %s id=%s target=%s"
% (time(), event.get("type"), eid, target_dt.isoformat())
)
await self._fire_event(event)
await self._mark_event_triggered(events, eid)

# Re-read after marking triggered (writes happened)
events = await self._read_events_safe()

# Garbage collect old triggered events
cleaned = self._gc_events(events)
if len(cleaned) != len(events):
await self._write_events_safe(cleaned)
events = cleaned

# Update personality context
await self._update_schedule_md(events, tz_name)

except Exception as e:
self.worker.editor_logging_handler.error(
"%s: [TAR-W] watcher loop error: %s" % (time(), e)
)
await self.worker.session_tasks.sleep(5.0)
continue

await self.worker.session_tasks.sleep(POLL_INTERVAL)

def call(self, worker: AgentWorker, background_daemon_mode: bool):
self.worker = worker
self.background_daemon_mode = background_daemon_mode
self.capability_worker = CapabilityWorker(self)

self.worker.editor_logging_handler.info(
"[TAR-W] Timers Alarms Reminders watcher initialized"
)
self.worker.session_tasks.create(self.watcher_loop())
Loading
Loading