From 652b037453b72d425488116403dcc9d593e8f83b Mon Sep 17 00:00:00 2001 From: Mahsum Date: Mon, 16 Mar 2026 21:14:17 +0300 Subject: [PATCH 1/2] feat(community): add prayer-times ability with background daemon Skill + Background Daemon combo for Islamic prayer times: - Voice queries: next prayer, all times, specific prayer lookup - Background reminders: 5-min advance + adhan notification - Location-based via Aladhan API (free, no key required) - Multiple calculation methods (ISNA, MWL, Diyanet, etc.) Co-Authored-By: Claude Opus 4.6 (1M context) --- community/prayer-times/README.md | 66 +++++ community/prayer-times/__init__.py | 0 community/prayer-times/background.py | 236 ++++++++++++++++++ community/prayer-times/main.py | 350 +++++++++++++++++++++++++++ 4 files changed, 652 insertions(+) create mode 100644 community/prayer-times/README.md create mode 100644 community/prayer-times/__init__.py create mode 100644 community/prayer-times/background.py create mode 100644 community/prayer-times/main.py diff --git a/community/prayer-times/README.md b/community/prayer-times/README.md new file mode 100644 index 00000000..bd05776b --- /dev/null +++ b/community/prayer-times/README.md @@ -0,0 +1,66 @@ +# Prayer Times + +A voice-activated Islamic prayer times assistant with automatic reminders. + +## What It Does + +**Skill** — Ask about prayer times using natural voice commands: +- "When is the next prayer?" → Tells you the upcoming prayer and its time +- "What are today's prayer times?" → Lists all five daily prayers + sunrise +- "When is Fajr?" → Specific prayer time lookup +- "Set my location to Istanbul, Turkey" → Configure your city + +**Background Daemon** — Automatic prayer time reminders: +- 5-minute advance reminder before each prayer +- Notification when prayer time arrives +- Resets daily, fetches fresh times each day + +## Setup + +1. Upload the ability to OpenHome +2. Set trigger words in the dashboard (suggested below) +3. Say a trigger phrase — the ability will ask for your city on first use +4. Background reminders start automatically + +## Suggested Trigger Words + +``` +prayer times, next prayer, when is fajr, when is dhuhr, +when is asr, when is maghrib, when is isha, salah time, +namaz vakti, prayer schedule +``` + +## API + +Uses [Aladhan Prayer Times API](https://aladhan.com/prayer-times-api) — **free, no API key required**. + +Supports multiple calculation methods: +| # | Method | +|---|--------| +| 1 | University of Islamic Sciences, Karachi | +| 2 | Islamic Society of North America (ISNA) — default | +| 3 | Muslim World League (MWL) | +| 4 | Umm Al-Qura University, Makkah | +| 5 | Egyptian General Authority of Survey | +| 13 | Diyanet İşleri Başkanlığı (Turkey) | + +Change method by saying: "Change calculation method to Diyanet" + +## Data Storage + +Stores configuration and cached times in `prayer_data.json`: +- City and country +- Calculation method preference +- Last fetched prayer times (refreshed daily) + +## Technical Details + +- **Type:** Skill + Background Daemon (combo) +- **API:** Aladhan (free, no auth) +- **Dependencies:** `requests` (standard library in OpenHome runtime) +- **Background check interval:** 30 seconds +- **Reminder window:** 5 minutes before prayer + +## Author + +Mahsum Aktas — [@mahsumaktas](https://github.com/mahsumaktas) diff --git a/community/prayer-times/__init__.py b/community/prayer-times/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/community/prayer-times/background.py b/community/prayer-times/background.py new file mode 100644 index 00000000..fea006e0 --- /dev/null +++ b/community/prayer-times/background.py @@ -0,0 +1,236 @@ +import json +from datetime import datetime +from time import time +from zoneinfo import ZoneInfo + +import requests + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +ALADHAN_URL = "https://api.aladhan.com/v1/timingsByCity" +DATA_FILE = "prayer_data.json" +PRAYER_NAMES = ["Fajr", "Sunrise", "Dhuhr", "Asr", "Maghrib", "Isha"] +DEFAULT_METHOD = 2 + +# How many minutes before prayer to send reminder +REMINDER_MINUTES = 5 + +# Check interval in seconds +CHECK_INTERVAL = 30 + +# Re-read config file every N loops (~5 min) to pick up user changes +CONFIG_REFRESH_LOOPS = 10 + +# After API failure, wait this many loops before retrying (~5 min) +API_RETRY_COOLDOWN = 10 + + +class PrayerTimesBackground(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + background_daemon_mode: bool = False + + # Do not change following tag of register capability + #{{register capability}} + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @staticmethod + def _clean_time(raw: str) -> str: + return raw.split("(")[0].strip() + + # ------------------------------------------------------------------ + # File helpers + # ------------------------------------------------------------------ + async def _read_data(self) -> dict: + try: + if not await self.capability_worker.check_if_file_exists(DATA_FILE, False): + return {} + raw = await self.capability_worker.read_file(DATA_FILE, False) + if not (raw or "").strip(): + return {} + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + async def _write_data(self, data: dict) -> None: + payload = json.dumps(data, ensure_ascii=False, indent=2) + try: + await self.capability_worker.delete_file(DATA_FILE, False) + except Exception: + pass + try: + await self.capability_worker.write_file(DATA_FILE, payload, False) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerBG] Write failed: {e}" + ) + + # ------------------------------------------------------------------ + # API + # ------------------------------------------------------------------ + def _fetch_times(self, city: str, country: str, method: int) -> dict | None: + try: + resp = requests.get( + ALADHAN_URL, + params={"city": city, "country": country, "method": method}, + timeout=10, + ) + if resp.status_code == 200: + return resp.json().get("data", {}).get("timings", {}) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerBG] API error: {e}" + ) + return None + + # ------------------------------------------------------------------ + # Time helpers + # ------------------------------------------------------------------ + def _get_now(self) -> datetime: + tz_name = self.capability_worker.get_timezone() + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("UTC") + return datetime.now(tz=tz) + + def _parse_time(self, time_str: str, now: datetime) -> datetime | None: + try: + clean = self._clean_time(time_str) + h, m = map(int, clean.split(":")) + return now.replace(hour=h, minute=m, second=0, microsecond=0) + except Exception: + return None + + def _safe_interrupt(self, message: str) -> None: + try: + self.capability_worker.send_interrupt_signal(message) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerBG] Interrupt failed: {e}" + ) + + # ------------------------------------------------------------------ + # Background loop + # ------------------------------------------------------------------ + async def first_function(self): + self.worker.editor_logging_handler.info( + f"{time()}: Prayer Times background daemon started" + ) + + # In-memory caches to avoid repeated file reads and time parsing + cached_data: dict = {} + cached_timings: dict = {} # {name: datetime} for today + sent_today: dict[str, bool] = {} + last_date: str = "" + loops_since_file_read: int = CONFIG_REFRESH_LOOPS # force read on first loop + api_retry_countdown: int = 0 + + while True: + try: + now = self._get_now() + today_str = now.strftime("%Y-%m-%d") + + # Reset on new day + if today_str != last_date: + sent_today = {} + cached_timings = {} + last_date = today_str + loops_since_file_read = CONFIG_REFRESH_LOOPS # force re-read + + # Re-read config periodically to pick up user changes + if loops_since_file_read >= CONFIG_REFRESH_LOOPS: + cached_data = await self._read_data() + loops_since_file_read = 0 + else: + loops_since_file_read += 1 + + # Skip if not configured yet + if not cached_data.get("city"): + await self.worker.session_tasks.sleep(CHECK_INTERVAL) + continue + + # Fetch and parse times once per day + if not cached_timings: + if api_retry_countdown > 0: + api_retry_countdown -= 1 + await self.worker.session_tasks.sleep(CHECK_INTERVAL) + continue + + # Try cached timings from file first + file_timings = cached_data.get("last_timings", {}) + if cached_data.get("last_fetch_date") == today_str and file_timings: + raw_timings = file_timings + else: + raw_timings_api = self._fetch_times( + cached_data["city"], + cached_data["country"], + cached_data.get("method", DEFAULT_METHOD), + ) + if not raw_timings_api: + api_retry_countdown = API_RETRY_COOLDOWN + await self.worker.session_tasks.sleep(CHECK_INTERVAL) + continue + raw_timings = { + name: self._clean_time(raw_timings_api.get(name, "")) + for name in PRAYER_NAMES + } + cached_data["last_timings"] = raw_timings + cached_data["last_fetch_date"] = today_str + await self._write_data(cached_data) + + # Parse all times into datetime objects (once per day) + for name in PRAYER_NAMES: + t_str = raw_timings.get(name) + if t_str: + dt = self._parse_time(t_str, now) + if dt: + cached_timings[name] = dt + + # Check each prayer time (cheap in-memory comparison) + for name, prayer_dt in cached_timings.items(): + diff_minutes = (prayer_dt - now).total_seconds() / 60 + + reminder_key = f"reminder_{name}" + adhan_key = f"adhan_{name}" + + # Pre-prayer reminder (5 min before) + if ( + 0 < diff_minutes <= REMINDER_MINUTES + and reminder_key not in sent_today + ): + sent_today[reminder_key] = True + mins = int(diff_minutes) + self._safe_interrupt( + f"{name} is in {mins} minute{'s' if mins != 1 else ''}. " + f"Time to prepare." + ) + + # Adhan time notification (within 1 min window) + if ( + -1 <= diff_minutes <= 0 + and adhan_key not in sent_today + ): + sent_today[adhan_key] = True + self._safe_interrupt(f"It's time for {name}.") + + await self.worker.session_tasks.sleep(CHECK_INTERVAL) + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerBG] Loop error: {e}" + ) + await self.worker.session_tasks.sleep(CHECK_INTERVAL) + + self.capability_worker.resume_normal_flow() + + 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.session_tasks.create(self.first_function()) diff --git a/community/prayer-times/main.py b/community/prayer-times/main.py new file mode 100644 index 00000000..bd1c7ad3 --- /dev/null +++ b/community/prayer-times/main.py @@ -0,0 +1,350 @@ +import json +from datetime import datetime +from time import time +from zoneinfo import ZoneInfo + +import requests + +from src.agent.capability import MatchingCapability +from src.agent.capability_worker import CapabilityWorker +from src.main import AgentWorker + +# --------------------------------------------------------------------------- +# Aladhan API — free, no API key required +# https://aladhan.com/prayer-times-api +# --------------------------------------------------------------------------- +ALADHAN_URL = "https://api.aladhan.com/v1/timingsByCity" +DATA_FILE = "prayer_data.json" + +PRAYER_NAMES = ["Fajr", "Sunrise", "Dhuhr", "Asr", "Maghrib", "Isha"] + +# Calculation methods: https://aladhan.com/prayer-times-api#GetTimings +# 2 = ISNA, 3 = MWL, 13 = Diyanet (Turkey) +DEFAULT_METHOD = 2 + +SYSTEM_PROMPT = """You are a prayer-times voice assistant. +Your ONLY job is to understand the user's intent about Islamic prayer times. + +Respond with EXACTLY one JSON object (no extra text): + +1. Query next prayer: + {"intent": "next_prayer"} + +2. Query all today's prayer times: + {"intent": "all_times"} + +3. Query a specific prayer: + {"intent": "specific", "prayer": ""} + +4. Set up location (user mentions a city/country): + {"intent": "setup", "city": "", "country": ""} + +5. Change calculation method: + {"intent": "method", "method": } + Methods: 1=Karachi, 2=ISNA, 3=MWL, 4=Makkah, 5=Egypt, 13=Diyanet + +6. Cannot understand: + {"intent": "unknown"} + +IMPORTANT: Prayer names must be exactly one of: Fajr, Sunrise, Dhuhr, Asr, Maghrib, Isha (capitalize first letter). +""" + + +class PrayerTimesCapability(MatchingCapability): + worker: AgentWorker = None + capability_worker: CapabilityWorker = None + + # Do not change following tag of register capability + #{{register capability}} + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + @staticmethod + def _clean_time(raw: str) -> str: + """Strip timezone suffix: '14:30 (EET)' -> '14:30'""" + return raw.split("(")[0].strip() + + @staticmethod + def _normalize_prayer(name: str) -> str: + """Normalize LLM output to match API keys: 'fajr' -> 'Fajr'""" + return name.strip().capitalize() + + # ------------------------------------------------------------------ + # File helpers (delete-before-write pattern from Alarm template) + # ------------------------------------------------------------------ + async def _read_data(self) -> dict: + try: + if not await self.capability_worker.check_if_file_exists(DATA_FILE, False): + return {} + raw = await self.capability_worker.read_file(DATA_FILE, False) + if not (raw or "").strip(): + return {} + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + async def _write_data(self, data: dict) -> None: + payload = json.dumps(data, ensure_ascii=False, indent=2) + try: + await self.capability_worker.delete_file(DATA_FILE, False) + except Exception: + pass + try: + await self.capability_worker.write_file(DATA_FILE, payload, False) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerTimes] Write failed: {e}" + ) + + # ------------------------------------------------------------------ + # Aladhan API + # ------------------------------------------------------------------ + def _fetch_times(self, city: str, country: str, method: int) -> dict | None: + try: + resp = requests.get( + ALADHAN_URL, + params={"city": city, "country": country, "method": method}, + timeout=10, + ) + if resp.status_code == 200: + return resp.json().get("data", {}).get("timings", {}) + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerTimes] API error: {e}" + ) + return None + + # ------------------------------------------------------------------ + # Time helpers + # ------------------------------------------------------------------ + def _get_now(self) -> datetime: + tz_name = self.capability_worker.get_timezone() + try: + tz = ZoneInfo(tz_name) + except Exception: + tz = ZoneInfo("UTC") + return datetime.now(tz=tz) + + def _parse_time(self, time_str: str, now: datetime) -> datetime | None: + """Parse 'HH:MM' or 'HH:MM (TZ)' into a datetime for today.""" + try: + clean = self._clean_time(time_str) + h, m = map(int, clean.split(":")) + return now.replace(hour=h, minute=m, second=0, microsecond=0) + except Exception: + return None + + def _find_next_prayer(self, timings: dict, now: datetime) -> tuple[str, str] | None: + for name in PRAYER_NAMES: + t = timings.get(name) + if not t: + continue + pt = self._parse_time(t, now) + if pt and pt > now: + return name, self._clean_time(t) + return None + + def _format_all_times(self, timings: dict) -> str: + parts = [] + for name in PRAYER_NAMES: + t = self._clean_time(timings.get(name, "N/A")) + parts.append(f"{name}: {t}") + return ", ".join(parts) + + def _get_timings(self, data: dict, now: datetime) -> dict | None: + """Return today's timings — use cache if fresh, otherwise fetch.""" + today_str = now.strftime("%Y-%m-%d") + cached = data.get("last_timings", {}) + if data.get("last_fetch_date") == today_str and cached: + return cached + + raw_timings = self._fetch_times( + data["city"], + data["country"], + data.get("method", DEFAULT_METHOD), + ) + if not raw_timings: + return None + + timings = { + name: self._clean_time(raw_timings.get(name, "")) + for name in PRAYER_NAMES + } + data["last_timings"] = timings + data["last_fetch_date"] = today_str + return timings + + # ------------------------------------------------------------------ + # Setup flow + # ------------------------------------------------------------------ + async def _setup(self, existing_data: dict | None = None) -> dict | None: + await self.capability_worker.speak( + "I need your location to get accurate prayer times. " + "What city and country are you in?" + ) + user_input = await self.capability_worker.user_response() + + extraction = self.capability_worker.text_to_text_response( + user_input, + [], + 'Extract city and country from user text. ' + 'Return ONLY: {"city": "...", "country": "..."} ' + 'If unclear, use your best guess.', + ) + city = "" + country = "" + try: + ext = json.loads(extraction) + city = ext.get("city", "").strip() + country = ext.get("country", "").strip() + except Exception: + pass + + if not city: + await self.capability_worker.speak( + "Sorry, I couldn't determine your location. Please try again." + ) + return None + + data = { + "city": city, + "country": country, + "method": (existing_data or {}).get("method", DEFAULT_METHOD), + "setup_at": int(time()), + } + await self._write_data(data) + await self.capability_worker.speak( + f"Location set to {city}, {country}. You can change this anytime." + ) + return data + + # ------------------------------------------------------------------ + # Intent parsing + # ------------------------------------------------------------------ + def _parse_intent(self, user_text: str) -> dict: + try: + resp = self.capability_worker.text_to_text_response( + user_text, [], SYSTEM_PROMPT + ) + return json.loads(resp) + except Exception: + return {"intent": "unknown"} + + # ------------------------------------------------------------------ + # Speak helpers + # ------------------------------------------------------------------ + async def _speak_next_prayer(self, timings: dict, now: datetime) -> None: + result = self._find_next_prayer(timings, now) + if result: + name, t = result + await self.capability_worker.speak( + f"The next prayer is {name} at {t}." + ) + else: + await self.capability_worker.speak( + "All prayers for today have passed. " + "Fajr will be the next prayer tomorrow." + ) + + # ------------------------------------------------------------------ + # Main run + # ------------------------------------------------------------------ + async def run(self): + try: + user_text = await self.capability_worker.wait_for_complete_transcription() + + data = await self._read_data() + intent_data = self._parse_intent(user_text) + intent = intent_data.get("intent", "unknown") + + # Handle setup intent or first-time use + if intent == "setup" or not data.get("city"): + if intent == "setup": + city = intent_data.get("city", "").strip() + country = intent_data.get("country", "").strip() + if city and country: + data = { + "city": city, + "country": country, + "method": data.get("method", DEFAULT_METHOD), + "setup_at": int(time()), + } + await self._write_data(data) + await self.capability_worker.speak( + f"Location set to {city}, {country}." + ) + else: + data = await self._setup(data) + elif not data.get("city"): + data = await self._setup(data) + + if not data or not data.get("city"): + return + if intent == "setup": + return + + # Handle method change + if intent == "method": + method = intent_data.get("method", DEFAULT_METHOD) + data["method"] = method + await self._write_data(data) + await self.capability_worker.speak( + f"Calculation method updated to {method}." + ) + return + + # Get today's timings (cached or fresh) + now = self._get_now() + timings = self._get_timings(data, now) + + if not timings: + await self.capability_worker.speak( + "Sorry, I couldn't fetch prayer times right now. " + "Please check your internet connection." + ) + return + + # Write back only if data changed (new fetch) + await self._write_data(data) + + if intent == "next_prayer": + await self._speak_next_prayer(timings, now) + + elif intent == "all_times": + formatted = self._format_all_times(timings) + await self.capability_worker.speak( + f"Today's prayer times for {data['city']}: {formatted}" + ) + + elif intent == "specific": + prayer = self._normalize_prayer(intent_data.get("prayer", "")) + t = timings.get(prayer, "") + if t: + await self.capability_worker.speak( + f"{prayer} is at {t} today." + ) + else: + await self.capability_worker.speak( + f"I couldn't find the time for {prayer}. " + f"Available prayers are: {', '.join(PRAYER_NAMES)}." + ) + + else: + await self._speak_next_prayer(timings, now) + + except Exception as e: + self.worker.editor_logging_handler.error( + f"[PrayerTimes] Error: {e}" + ) + await self.capability_worker.speak( + "Sorry, something went wrong. Please try again." + ) + finally: + self.capability_worker.resume_normal_flow() + + def call(self, worker: AgentWorker): + self.worker = worker + self.capability_worker = CapabilityWorker(self) + self.worker.session_tasks.create(self.run()) From fd7fbe2d5e8b5ec4691978e20f002e388c120f39 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 18:15:12 +0000 Subject: [PATCH 2/2] style: auto-format Python files with autoflake + autopep8 --- community/prayer-times/background.py | 2 +- community/prayer-times/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/community/prayer-times/background.py b/community/prayer-times/background.py index fea006e0..c5dae046 100644 --- a/community/prayer-times/background.py +++ b/community/prayer-times/background.py @@ -33,7 +33,7 @@ class PrayerTimesBackground(MatchingCapability): background_daemon_mode: bool = False # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} # ------------------------------------------------------------------ # Helpers diff --git a/community/prayer-times/main.py b/community/prayer-times/main.py index bd1c7ad3..cef88de5 100644 --- a/community/prayer-times/main.py +++ b/community/prayer-times/main.py @@ -55,7 +55,7 @@ class PrayerTimesCapability(MatchingCapability): capability_worker: CapabilityWorker = None # Do not change following tag of register capability - #{{register capability}} + # {{register capability}} # ------------------------------------------------------------------ # Helpers