feat: add mawaqit plugin (#53)
* feat: add mawaqit plugin * Address review feedback * Address validator errors
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user