feat: add mawaqit plugin (#53)

* feat: add mawaqit plugin

* Address review feedback

* Address validator errors
This commit is contained in:
ycf-anon
2026-07-20 20:45:30 -04:00
committed by GitHub
parent d253a9430c
commit 65503f2ce1
9 changed files with 1610 additions and 0 deletions
+408
View File
@@ -0,0 +1,408 @@
--!nonstrict
-- service.luau — Mawaqit background fetcher
noctalia.setUpdateInterval(5000)
-- ── Config ────────────────────────────────────────────────────────────────────
local city, country, method, school, hijriDayOffset
local tune, tuneFajr, tuneDhuhr, tuneAsr, tuneMaghrib, tuneIsha
local showNotifications, playAzan, azanFile, twelveHourFormat
-- Safely quotes strings for shell execution to prevent injection.
local function shellQuote(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- Escapes POSIX ERE metacharacters so a path can be used as a `pkill -f`
-- pattern without accidentally behaving as a regex.
local function ereEscape(s)
return (s:gsub("([%.%*%+%?%(%)%[%]%^%$|\\{}])", "\\%1"))
end
local function reloadConfig()
local c_city = noctalia.getConfig("city")
local c_country = noctalia.getConfig("country")
local c_method = noctalia.getConfig("method")
local c_school = noctalia.getConfig("school")
local c_hijriDayOffset = noctalia.getConfig("hijriDayOffset")
local c_tune = noctalia.getConfig("tune")
local c_tuneFajr = noctalia.getConfig("tuneFajr")
local c_tuneDhuhr = noctalia.getConfig("tuneDhuhr")
local c_tuneAsr = noctalia.getConfig("tuneAsr")
local c_tuneMaghrib = noctalia.getConfig("tuneMaghrib")
local c_tuneIsha = noctalia.getConfig("tuneIsha")
local c_showNotifications = noctalia.getConfig("showNotifications")
local c_playAzan = noctalia.getConfig("playAzan")
local c_azanFile = noctalia.getConfig("azanFile")
local c_twelveHourFormat = noctalia.getConfig("twelveHourFormat")
city = (type(c_city) == "string" and c_city ~= "") and c_city or "London"
country = (type(c_country) == "string" and c_country ~= "") and c_country or "UK"
method = (type(c_method) == "string" and c_method ~= "") and c_method or "3"
school = (type(c_school) == "string" and c_school ~= "") and c_school or "0"
hijriDayOffset = tonumber(c_hijriDayOffset) or 0
tune = (c_tune == true or c_tune == "true")
tuneFajr = tonumber(c_tuneFajr) or 0
tuneDhuhr = tonumber(c_tuneDhuhr) or 0
tuneAsr = tonumber(c_tuneAsr) or 0
tuneMaghrib = tonumber(c_tuneMaghrib) or 0
tuneIsha = tonumber(c_tuneIsha) or 0
showNotifications = (c_showNotifications ~= false and c_showNotifications ~= "false")
playAzan = (c_playAzan == true or c_playAzan == "true")
azanFile = (type(c_azanFile) == "string" and c_azanFile ~= "") and c_azanFile or "azan1.mp3"
twelveHourFormat = (c_twelveHourFormat == true or c_twelveHourFormat == "true")
end
reloadConfig()
noctalia.log("Mawaqit: city=" .. city .. " country=" .. country .. " method=" .. method)
-- Check azan player availability at startup
local paplayAvailable = noctalia.commandExists("paplay")
local pwcatAvailable = noctalia.commandExists("pw-cat")
if playAzan and not paplayAvailable and not pwcatAvailable then
noctalia.log("Mawaqit: azan enabled but neither paplay nor pw-cat found")
end
-- ── State ─────────────────────────────────────────────────────────────────────
-- Prayers for countdown (no Sunrise — not a salah)
local PRAYER_NAMES = { "Fajr", "Dhuhr", "Asr", "Maghrib", "Isha" }
local AZAN_PRAYERS = { true, true, true, true, true }
local loadedDate = ""
local fetchPending = false
local retryCount = 0
local MAX_RETRIES = 5
local retryTicks = 0
local tomorrowFetched = false
local lastNotified = ""
local lastAzanPlayed = ""
local playingAzanPath = "" -- absolute path of the azan file currently playing, if any
-- ── Helpers ───────────────────────────────────────────────────────────────────
local function parseTime(str)
if not str then return nil end
local h, m = str:match("^(%d+):(%d+)")
if not h then return nil end
return tonumber(h) * 3600 + tonumber(m) * 60
end
local function todayStr()
return os.date("%Y-%m-%d")
end
-- Converts a 24-hour "HH:MM" string to 12-hour display, when the setting is on.
-- Returns the input unchanged (24-hour) otherwise, or if it doesn't parse.
local function formatDisplayTime(clean)
if not twelveHourFormat then return clean end
local h, m = clean:match("^(%d+):(%d+)")
if not h then return clean end
local hourNum = tonumber(h)
local period = hourNum < 12 and "AM" or "PM"
local displayHour = hourNum % 12
if displayHour == 0 then displayHour = 12 end
return string.format("%d:%s %s", displayHour, m, period)
end
local function nowSeconds()
local t = os.date("*t")
return t.hour * 3600 + t.min * 60 + t.sec
end
local function isJumuah()
return os.date("*t").wday == 6
end
local function dateParam(offsetDays)
local t = os.time() + (offsetDays * 86400)
local d = os.date("*t", t)
return string.format("%02d-%02d-%04d", d.day, d.month, d.year)
end
local function applyTune(name, secs)
if not tune then return secs end
local offset = 0
if name == "Fajr" then offset = tuneFajr
elseif name == "Dhuhr" then offset = tuneDhuhr
elseif name == "Asr" then offset = tuneAsr
elseif name == "Maghrib" then offset = tuneMaghrib
elseif name == "Isha" then offset = tuneIsha
end
return secs + offset * 60
end
local function buildUrl(offsetDays)
local dateStr = dateParam(offsetDays or 0)
return string.format(
"https://api.aladhan.com/v1/timingsByCity/%s?city=%s&country=%s&method=%s&school=%s",
dateStr,
noctalia.string.urlEncode(city),
noctalia.string.urlEncode(country),
method, school
)
end
-- Resolves the bundled azan file's absolute path from this plugin's own
-- install directory (works for git-source and path-source installs alike).
local function azanPath()
local pluginDir = noctalia.pluginDir()
if type(pluginDir) ~= "string" or pluginDir == "" then
noctalia.log("Mawaqit: could not resolve plugin directory for azan playback")
return ""
end
local safe = azanFile:match("^[%w%.%-_]+$") and azanFile or "azan1.mp3"
return pluginDir .. "/assets/" .. safe
end
-- ── Fetch ─────────────────────────────────────────────────────────────────────
local function publishError(msg)
noctalia.state.set("error", msg)
end
local function processPrayerData(data)
if not data or data.code ~= 200 then
publishError("API error: " .. tostring(data and data.status or "unknown"))
return false
end
local timings = data.data and data.data.timings
if not timings then
publishError("Parse error: no timings in response")
return false
end
local prayers = {}
for i, name in ipairs(PRAYER_NAMES) do
local raw = timings[name]
if raw then
local clean = raw:match("^(%d+:%d+)") or raw
local secs = parseTime(clean)
if secs then
secs = applyTune(name, secs)
prayers[#prayers+1] = {
name = name,
time = formatDisplayTime(clean),
seconds = secs,
azanPrayer = AZAN_PRAYERS[i],
}
end
end
end
-- Imsak (Ramadan only)
local imsakRaw = timings["Imsak"]
local imsakTime = imsakRaw and formatDisplayTime(imsakRaw:match("^(%d+:%d+)") or imsakRaw) or ""
-- Sunrise (display only, not a prayer)
local sunriseRaw = timings["Sunrise"]
local sunriseTime = sunriseRaw and formatDisplayTime(sunriseRaw:match("^(%d+:%d+)") or sunriseRaw) or ""
-- Hijri date
local hijri = data.data.date and data.data.date.hijri
local greg = (data.data.date and data.data.date.readable) or ""
local hijriDay = 0
local hijriMonth = 0
local hijriYear = 0
local hijriDateStr = ""
local hijriDateAr = ""
if hijri then
hijriDay = math.max(1, math.min(30, (tonumber(hijri.day) or 0) + hijriDayOffset))
hijriMonth = (hijri.month and hijri.month.number) or 0
hijriYear = tonumber(hijri.year) or 0
local monthEn = (hijri.month and hijri.month.en) or ""
local monthAr = (hijri.month and hijri.month.ar) or ""
hijriDateStr = string.format("%d %s %d AH", hijriDay, monthEn, hijriYear)
local function toArabicNumerals(n)
local arabic = {"٠","١","٢","٣","٤","٥","٦","٧","٨","٩"}
return tostring(n):gsub("%d", function(d)
return arabic[tonumber(d) + 1]
end)
end
hijriDateAr = toArabicNumerals(hijriDay) .. " " .. monthAr .. " " .. toArabicNumerals(hijriYear)
end
noctalia.state.set("prayers", prayers)
noctalia.state.set("sunriseTime", sunriseTime)
noctalia.state.set("imsakTime", imsakTime)
noctalia.state.set("hijriDate", hijriDateStr)
noctalia.state.set("hijriDateAr", hijriDateAr)
noctalia.state.set("gregorianDate", greg)
noctalia.state.set("hijriDay", hijriDay)
noctalia.state.set("hijriMonth", hijriMonth)
noctalia.state.set("hijriYear", hijriYear)
noctalia.state.set("isJumuah", isJumuah())
noctalia.state.set("error", "")
loadedDate = todayStr()
lastNotified = ""
lastAzanPlayed = ""
retryCount = 0
noctalia.log("Mawaqit: loaded " .. #prayers .. " prayers for " .. city .. ", " .. country
.. " hijriMonth=" .. hijriMonth)
return true
end
local function fetchTimes()
if fetchPending then return end
reloadConfig()
fetchPending = true
local url = buildUrl(0)
noctalia.log("Mawaqit: fetching " .. url)
noctalia.http({ url = url, follow_redirects = true }, function(res)
fetchPending = false
if not res.ok or res.body == "" then
retryCount += 1
publishError("Network error (status=" .. tostring(res.status) .. ")")
return
end
local ok, data = pcall(function() return noctalia.json.decode(res.body) end)
if not ok or not data then
retryCount += 1
publishError("Parse error")
return
end
processPrayerData(data)
end)
end
local function fetchTomorrowFajr()
local url = buildUrl(1)
noctalia.http({ url = url, follow_redirects = true }, function(res)
if not res.ok or res.body == "" then return end
local ok, data = pcall(function() return noctalia.json.decode(res.body) end)
if not ok or not data or data.code ~= 200 then return end
local timings = data.data and data.data.timings
if not timings then return end
local raw = timings["Fajr"]
if raw then
local clean = raw:match("^(%d+:%d+)") or raw
local secs = parseTime(clean)
if secs then
secs = applyTune("Fajr", secs)
noctalia.state.set("tomorrowFajr", secs)
end
end
end)
end
-- ── Azan playback ─────────────────────────────────────────────────────────────
-- Kills azan playback in progress. Matches on the exact absolute path this
-- plugin passed to paplay/pw-cat (tracked in playingAzanPath), not a generic
-- substring — so it can't touch an unrelated process that happens to share
-- part of that path. This is an interim measure: runAsync doesn't return a
-- PID, so command-line matching is the only way to terminate a detached
-- process with the current API.
local function stopAzan()
if playingAzanPath ~= "" then
local pattern = shellQuote(ereEscape(playingAzanPath))
noctalia.runAsync("pkill -f -- " .. pattern .. " 2>/dev/null || true")
playingAzanPath = ""
end
noctalia.state.set("azanPlaying", false)
end
-- ── Prayer events ─────────────────────────────────────────────────────────────
local cachedPrayers = {}
local function checkPrayerEvents()
if #cachedPrayers == 0 then return end
local now = nowSeconds()
for _, p in ipairs(cachedPrayers) do
if now >= p.seconds and now < p.seconds + 30 and p.azanPrayer then
local key = loadedDate .. "_" .. p.name
local title = (p.name == "Dhuhr" and isJumuah()) and "Jumu'ah" or p.name
if showNotifications and lastNotified ~= key then
lastNotified = key
noctalia.notify(title .. " prayer time", "It is time for " .. title)
end
if playAzan and lastAzanPlayed ~= key then
lastAzanPlayed = key
local path = azanPath()
if path ~= "" and noctalia.fileExists(path) then
noctalia.state.set("azanPlaying", true)
playingAzanPath = path
local cmd
if paplayAvailable then
cmd = "paplay " .. shellQuote(path) .. " 2>/dev/null"
elseif pwcatAvailable then
cmd = "pw-cat -p " .. shellQuote(path) .. " 2>/dev/null"
else
noctalia.log("Mawaqit: no azan player available (install paplay or pw-cat)")
noctalia.state.set("azanPlaying", false)
return
end
noctalia.runAsync(cmd, function(_res)
playingAzanPath = ""
noctalia.state.set("azanPlaying", false)
end)
elseif path ~= "" then
noctalia.log("Mawaqit: azan file not found: " .. path)
end
end
end
end
end
noctalia.state.watch("prayers", function(val)
if type(val) == "table" then cachedPrayers = val end
end)
-- Listen to panel commands via state (cleaner than shell IPC)
noctalia.state.watch("command", function(cmd)
if type(cmd) ~= "table" then return end
if cmd.action == "refresh" then onConfigChanged()
elseif cmd.action == "stopAzan" then stopAzan()
end
end)
-- ── Main tick ─────────────────────────────────────────────────────────────────
function update()
local today = todayStr()
if loadedDate ~= today then
tomorrowFetched = false
fetchTimes()
end
if loadedDate == today and not tomorrowFetched then
tomorrowFetched = true
fetchTomorrowFajr()
end
if retryCount > 0 and retryCount <= MAX_RETRIES then
retryTicks += 1
if retryTicks >= 6 then
retryTicks = 0
fetchTimes()
end
end
checkPrayerEvents()
end
function onConfigChanged()
noctalia.log("Mawaqit: settings changed, reloading config and refreshing")
reloadConfig()
loadedDate = ""
retryCount = 0
cachedPrayers = {}
noctalia.state.set("prayers", {})
fetchTimes()
end
function onIpc(event, _payload)
if event == "refresh" then
onConfigChanged()
end
end
function onExit()
-- Clean up: stop any playing azan when plugin is disabled or noctalia exits
stopAzan()
end