* feat: add mawaqit plugin * Address review feedback * Address validator errors
409 lines
16 KiB
Luau
409 lines
16 KiB
Luau
--!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
|