Skip to content

Commit a704959

Browse files
mahsumaktasMahsumclaudegithub-actions[bot]uzair401
authored
feat(community): add prayer-times ability (#217)
Co-authored-by: Mahsum <mahsum@Mahsums-Mac-mini.local> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Uzair Ullah <uzairullahmail@gmail.com>
1 parent ab9cda6 commit a704959

4 files changed

Lines changed: 669 additions & 0 deletions

File tree

community/prayer-times/README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Prayer Times
2+
3+
A voice-activated Islamic prayer times assistant with automatic reminders.
4+
5+
## What It Does
6+
7+
**Skill** — Ask about prayer times using natural voice commands:
8+
- "When is the next prayer?" → Tells you the upcoming prayer and its time
9+
- "What are today's prayer times?" → Lists all five daily prayers + sunrise
10+
- "When is Fajr?" → Specific prayer time lookup
11+
- "Set my location to Istanbul, Turkey" → Configure your city
12+
13+
**Background Daemon** — Automatic prayer time reminders:
14+
- 5-minute advance reminder before each prayer
15+
- Notification when prayer time arrives
16+
- Resets daily, fetches fresh times each day
17+
18+
## Setup
19+
20+
1. Upload the ability to OpenHome
21+
2. Set trigger words in the dashboard (suggested below)
22+
3. Say a trigger phrase — the ability will ask for your city on first use
23+
4. Background reminders start automatically
24+
25+
## Suggested Trigger Words
26+
27+
```
28+
prayer times, next prayer, when is fajr, when is dhuhr,
29+
when is asr, when is maghrib, when is isha, salah time,
30+
namaz vakti, prayer schedule
31+
```
32+
33+
## API
34+
35+
Uses [Aladhan Prayer Times API](https://aladhan.com/prayer-times-api)**free, no API key required**.
36+
37+
Supports multiple calculation methods:
38+
| # | Method |
39+
|---|--------|
40+
| 1 | University of Islamic Sciences, Karachi |
41+
| 2 | Islamic Society of North America (ISNA) — default |
42+
| 3 | Muslim World League (MWL) |
43+
| 4 | Umm Al-Qura University, Makkah |
44+
| 5 | Egyptian General Authority of Survey |
45+
| 13 | Diyanet İşleri Başkanlığı (Turkey) |
46+
47+
Change method by saying: "Change calculation method to Diyanet"
48+
49+
## Data Storage
50+
51+
Stores configuration and cached times in `prayer_data.json`:
52+
- City and country
53+
- Calculation method preference
54+
- Last fetched prayer times (refreshed daily)
55+
56+
## Technical Details
57+
58+
- **Type:** Skill + Background Daemon (combo)
59+
- **API:** Aladhan (free, no auth)
60+
- **Dependencies:** `requests` (standard library in OpenHome runtime)
61+
- **Background check interval:** 30 seconds
62+
- **Reminder window:** 5 minutes before prayer
63+
64+
## Author
65+
66+
Mahsum Aktas — [@mahsumaktas](https://github.com/mahsumaktas)

community/prayer-times/__init__.py

Whitespace-only changes.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import json
2+
from datetime import datetime
3+
from time import time
4+
from zoneinfo import ZoneInfo
5+
6+
import requests
7+
8+
from src.agent.capability import MatchingCapability
9+
from src.agent.capability_worker import CapabilityWorker
10+
from src.main import AgentWorker
11+
12+
ALADHAN_URL = "https://api.aladhan.com/v1/timingsByCity"
13+
DATA_FILE = "prayer_data.json"
14+
PRAYER_NAMES = ["Fajr", "Sunrise", "Dhuhr", "Asr", "Maghrib", "Isha"]
15+
DEFAULT_METHOD = 2
16+
17+
# How many minutes before prayer to send reminder
18+
REMINDER_MINUTES = 5
19+
20+
# Check interval in seconds
21+
CHECK_INTERVAL = 30
22+
23+
# Re-read config file every N loops (~5 min) to pick up user changes
24+
CONFIG_REFRESH_LOOPS = 10
25+
26+
# After API failure, wait this many loops before retrying (~5 min)
27+
API_RETRY_COOLDOWN = 10
28+
29+
30+
class PrayerTimesBackground(MatchingCapability):
31+
worker: AgentWorker = None
32+
capability_worker: CapabilityWorker = None
33+
background_daemon_mode: bool = False
34+
35+
# Do not change following tag of register capability
36+
# {{register capability}}
37+
38+
# ------------------------------------------------------------------
39+
# Helpers
40+
# ------------------------------------------------------------------
41+
@staticmethod
42+
def _clean_time(raw: str) -> str:
43+
return raw.split("(")[0].strip()
44+
45+
# ------------------------------------------------------------------
46+
# File helpers
47+
# ------------------------------------------------------------------
48+
async def _read_data(self) -> dict:
49+
try:
50+
if not await self.capability_worker.check_if_file_exists(DATA_FILE, False):
51+
return {}
52+
raw = await self.capability_worker.read_file(DATA_FILE, False)
53+
if not (raw or "").strip():
54+
return {}
55+
parsed = json.loads(raw)
56+
return parsed if isinstance(parsed, dict) else {}
57+
except Exception:
58+
return {}
59+
60+
async def _write_data(self, data: dict) -> None:
61+
payload = json.dumps(data, ensure_ascii=False, indent=2)
62+
try:
63+
await self.capability_worker.delete_file(DATA_FILE, False)
64+
except Exception:
65+
pass
66+
try:
67+
await self.capability_worker.write_file(DATA_FILE, payload, False)
68+
except Exception as e:
69+
self.worker.editor_logging_handler.error(
70+
f"[PrayerBG] Write failed: {e}"
71+
)
72+
73+
# ------------------------------------------------------------------
74+
# API
75+
# ------------------------------------------------------------------
76+
def _fetch_times(self, city: str, country: str, method: int) -> dict | None:
77+
try:
78+
resp = requests.get(
79+
ALADHAN_URL,
80+
params={"city": city, "country": country, "method": method},
81+
timeout=10,
82+
)
83+
if resp.status_code == 200:
84+
return resp.json().get("data", {}).get("timings", {})
85+
except Exception as e:
86+
self.worker.editor_logging_handler.error(
87+
f"[PrayerBG] API error: {e}"
88+
)
89+
return None
90+
91+
# ------------------------------------------------------------------
92+
# Time helpers
93+
# ------------------------------------------------------------------
94+
def _get_now(self) -> datetime:
95+
tz_name = self.capability_worker.get_timezone()
96+
try:
97+
tz = ZoneInfo(tz_name)
98+
except Exception:
99+
tz = ZoneInfo("UTC")
100+
return datetime.now(tz=tz)
101+
102+
def _parse_time(self, time_str: str, now: datetime) -> datetime | None:
103+
try:
104+
clean = self._clean_time(time_str)
105+
h, m = map(int, clean.split(":"))
106+
return now.replace(hour=h, minute=m, second=0, microsecond=0)
107+
except Exception:
108+
return None
109+
110+
async def _safe_interrupt(self, message: str) -> None:
111+
try:
112+
await self.capability_worker.send_interrupt_signal()
113+
await self.capability_worker.speak(message)
114+
except Exception as e:
115+
self.worker.editor_logging_handler.error(
116+
f"[PrayerBG] Interrupt failed: {e}"
117+
)
118+
119+
# ------------------------------------------------------------------
120+
# Background loop
121+
# ------------------------------------------------------------------
122+
async def first_function(self):
123+
self.worker.editor_logging_handler.info(
124+
f"{time()}: Prayer Times background daemon started"
125+
)
126+
127+
# In-memory caches to avoid repeated file reads and time parsing
128+
cached_data: dict = {}
129+
cached_timings: dict = {} # {name: datetime} for today
130+
sent_today: dict[str, bool] = {}
131+
last_date: str = ""
132+
loops_since_file_read: int = CONFIG_REFRESH_LOOPS # force read on first loop
133+
api_retry_countdown: int = 0
134+
135+
while True:
136+
try:
137+
now = self._get_now()
138+
today_str = now.strftime("%Y-%m-%d")
139+
140+
# Reset on new day
141+
if today_str != last_date:
142+
sent_today = {}
143+
cached_timings = {}
144+
last_date = today_str
145+
loops_since_file_read = CONFIG_REFRESH_LOOPS # force re-read
146+
147+
# Re-read config periodically to pick up user changes
148+
if loops_since_file_read >= CONFIG_REFRESH_LOOPS:
149+
cached_data = await self._read_data()
150+
loops_since_file_read = 0
151+
else:
152+
loops_since_file_read += 1
153+
154+
# Skip if not configured yet
155+
if not cached_data.get("city"):
156+
await self.worker.session_tasks.sleep(CHECK_INTERVAL)
157+
continue
158+
159+
# Fetch and parse times once per day
160+
if not cached_timings:
161+
if api_retry_countdown > 0:
162+
api_retry_countdown -= 1
163+
await self.worker.session_tasks.sleep(CHECK_INTERVAL)
164+
continue
165+
166+
# Try cached timings from file first
167+
file_timings = cached_data.get("last_timings", {})
168+
if cached_data.get("last_fetch_date") == today_str and file_timings:
169+
raw_timings = file_timings
170+
else:
171+
raw_timings_api = self._fetch_times(
172+
cached_data["city"],
173+
cached_data["country"],
174+
cached_data.get("method", DEFAULT_METHOD),
175+
)
176+
if not raw_timings_api:
177+
api_retry_countdown = API_RETRY_COOLDOWN
178+
await self.worker.session_tasks.sleep(CHECK_INTERVAL)
179+
continue
180+
raw_timings = {
181+
name: self._clean_time(raw_timings_api.get(name, ""))
182+
for name in PRAYER_NAMES
183+
}
184+
cached_data["last_timings"] = raw_timings
185+
cached_data["last_fetch_date"] = today_str
186+
await self._write_data(cached_data)
187+
188+
# Parse all times into datetime objects (once per day)
189+
for name in PRAYER_NAMES:
190+
t_str = raw_timings.get(name)
191+
if t_str:
192+
dt = self._parse_time(t_str, now)
193+
if dt:
194+
cached_timings[name] = dt
195+
196+
# Check each prayer time (cheap in-memory comparison)
197+
for name, prayer_dt in cached_timings.items():
198+
diff_minutes = (prayer_dt - now).total_seconds() / 60
199+
200+
reminder_key = f"reminder_{name}"
201+
adhan_key = f"adhan_{name}"
202+
203+
# Pre-prayer reminder (5 min before)
204+
if (
205+
0 < diff_minutes <= REMINDER_MINUTES
206+
and reminder_key not in sent_today
207+
):
208+
sent_today[reminder_key] = True
209+
mins = int(diff_minutes)
210+
await self._safe_interrupt(
211+
f"{name} in {mins} minute{'s' if mins != 1 else ''}"
212+
f" — time to get ready."
213+
)
214+
215+
# Adhan time notification (within 1 min window)
216+
if (
217+
-1 <= diff_minutes <= 0
218+
and adhan_key not in sent_today
219+
):
220+
sent_today[adhan_key] = True
221+
await self._safe_interrupt(f"It's time for {name}.")
222+
223+
await self.worker.session_tasks.sleep(CHECK_INTERVAL)
224+
225+
except Exception as e:
226+
self.worker.editor_logging_handler.error(
227+
f"[PrayerBG] Loop error: {e}"
228+
)
229+
await self.worker.session_tasks.sleep(CHECK_INTERVAL)
230+
231+
def call(self, worker: AgentWorker, background_daemon_mode: bool):
232+
self.worker = worker
233+
self.background_daemon_mode = background_daemon_mode
234+
self.capability_worker = CapabilityWorker(self)
235+
self.worker.session_tasks.create(self.first_function())

0 commit comments

Comments
 (0)