Files
community-plugins/hotspot/service.luau
T
CleboostandGitHub fc1c390a7f feat(wifi-hotspot): add NetworkManager hotspot control plugin (#120)
* feat(wifi-hotspot): add NetworkManager hotspot control plugin

Expose bar toggle, panel, and IPC to start/stop a Wi-Fi hotspot and list connected clients.

* refactor(hotspot): rename plugin directory to hotspot

Align the folder name with the cleboost/hotspot plugin id.

* refactor(hotspot): align plugin id and state keys with hotspot slug

Update manifest id, IPC references, and shared state keys after the directory rename.
2026-07-27 13:56:18 -04:00

503 lines
12 KiB
Luau

--!nonstrict
local STATUS_KEY = "hotspot_status"
local COMMAND_KEY = "hotspot_command"
local busy = false
local revision = 0
local available = noctalia.commandExists("nmcli")
local iwAvailable = noctalia.commandExists("iw")
local ipAvailable = noctalia.commandExists("ip")
local refreshing = false
local refreshAgain = false
local status = {
active = false,
busy = false,
available = available,
error = "",
revision = 0,
ssid = "",
interface = "",
connection = "",
gateway = "",
clients = {},
clientCount = 0,
}
local refresh
local setActive
local function publish(errorMessage)
revision += 1
status.busy = busy
status.available = available
status.revision = revision
status.error = errorMessage or status.error or ""
status.clientCount = #(status.clients or {})
noctalia.state.set(STATUS_KEY, status)
end
local function shellQuote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function showNotification(title, body, icon, urgency)
local payload = noctalia.json.encode({
app_name = noctalia.tr("title"),
summary = title,
body = body or "",
icon = icon,
urgency = urgency,
})
if payload == nil then
if urgency == "critical" then
noctalia.notifyError(title, body)
else
noctalia.notify(title, body)
end
return
end
noctalia.runAsync("noctalia msg notification-show " .. shellQuote(payload))
end
local function runCommand(command, callback, timeoutMs)
local started = noctalia.runAsync(command, callback, timeoutMs or 15000)
if not started and type(callback) == "function" then
callback({
exitCode = -1,
stdout = "",
stderr = noctalia.tr("error.command_start"),
timedOut = false,
})
end
return started
end
local function validateSettings()
local ssid = noctalia.string.trim(noctalia.getConfig("ssid") or "")
if ssid == "" then
return false, noctalia.tr("error.missing_ssid")
end
local password = tostring(noctalia.getConfig("password") or "")
if #password < 8 then
return false, noctalia.tr("error.password_too_short")
end
return true, ssid, password
end
local function parseNmcliTriple(stdout, filterType)
local entries = {}
for line in tostring(stdout or ""):gmatch("[^\n]+") do
local name, entryType, extra = line:match("^([^:]+):([^:]+):([^:]*)$")
if entryType == filterType and name ~= nil and name ~= "" then
table.insert(entries, { name = name, device = extra or "" })
end
end
return entries
end
local function pickWifiInterface(callback)
local configured = noctalia.string.trim(noctalia.getConfig("interface") or "")
if configured ~= "" then
callback(configured)
return
end
runCommand("nmcli -t -f DEVICE,TYPE,STATE device status", function(result)
local devices = parseNmcliTriple(result.stdout, "wifi")
callback(devices[1] and devices[1].name or nil)
end)
end
local function parseIwStations(stdout)
local clients = {}
local current = nil
for line in tostring(stdout or ""):gmatch("[^\n]+") do
local mac = line:match("^Station ([%x:]+)")
if mac ~= nil then
current = { mac = mac:lower(), ip = "", signal = nil }
table.insert(clients, current)
elseif current ~= nil then
local signal = line:match("signal:%s*([%-%d]+)")
if signal ~= nil then
current.signal = tonumber(signal)
end
end
end
return clients
end
local function parseIpNeigh(stdout)
local byMac = {}
for line in tostring(stdout or ""):gmatch("[^\n]+") do
local ip, mac = line:match("^([%d%.]+)%s+lladdr%s+([%x:]+)")
if ip ~= nil and mac ~= nil then
byMac[mac:lower()] = ip
end
end
return byMac
end
local function mergeClients(stations, neighByMac)
local merged = {}
local seen = {}
for _, client in ipairs(stations) do
table.insert(merged, {
mac = client.mac,
ip = neighByMac[client.mac] or client.ip or "",
signal = client.signal,
})
seen[client.mac] = true
end
for mac, ip in pairs(neighByMac) do
if not seen[mac] then
table.insert(merged, { mac = mac, ip = ip, signal = nil })
end
end
table.sort(merged, function(left, right)
if left.ip ~= right.ip then
if left.ip == "" then return false end
if right.ip == "" then return true end
return left.ip < right.ip
end
return left.mac < right.mac
end)
return merged
end
local function fetchClients(iface, callback)
if iface == nil or iface == "" then
callback({})
return
end
local stations = {}
local neighByMac = {}
local remaining = 0
local function finish()
remaining -= 1
if remaining > 0 then return end
callback(mergeClients(stations, neighByMac))
end
if iwAvailable then
remaining += 1
runCommand("iw dev " .. shellQuote(iface) .. " station dump", function(result)
if result.exitCode == 0 then
stations = parseIwStations(result.stdout)
end
finish()
end)
end
if ipAvailable then
remaining += 1
runCommand("ip neigh show dev " .. shellQuote(iface), function(result)
if result.exitCode == 0 then
neighByMac = parseIpNeigh(result.stdout)
end
finish()
end)
end
if remaining == 0 then
callback({})
end
end
local function inspectConnection(entry, callback)
local command = "nmcli -g 802-11-wireless.mode,802-11-wireless.ssid,IP4.ADDRESS connection show "
.. shellQuote(entry.name)
runCommand(command, function(result)
if result.exitCode ~= 0 then
callback(nil)
return
end
local mode, ssid, gateway
local index = 0
for line in tostring(result.stdout or ""):gmatch("[^\n]+") do
index += 1
local value = noctalia.string.trim(line)
if index == 1 then mode = value
elseif index == 2 then ssid = value
elseif index == 3 then gateway = value end
end
if mode ~= "ap" then
callback(nil)
return
end
callback({
active = true,
connection = entry.name,
interface = entry.device,
ssid = ssid or "",
gateway = gateway or "",
})
end)
end
local function findActiveHotspot(callback)
runCommand("nmcli -t -f NAME,TYPE,DEVICE connection show --active", function(result)
if result.exitCode ~= 0 then
local detail = noctalia.string.trim(result.stderr or "")
callback(nil, detail ~= "" and detail or noctalia.tr("error.status_failed"))
return
end
local entries = parseNmcliTriple(result.stdout, "802-11-wireless")
if #entries == 0 then
callback(nil)
return
end
local index = 1
local function nextEntry()
local entry = entries[index]
if entry == nil then
callback(nil)
return
end
inspectConnection(entry, function(info)
if info ~= nil then
callback(info)
return
end
index += 1
nextEntry()
end)
end
nextEntry()
end)
end
local function completeRefresh(callback, active)
refreshing = false
if callback then callback(active) end
if refreshAgain then
refreshAgain = false
refresh()
end
end
refresh = function(callback)
if not available then
status.active = false
status.clients = {}
publish(noctalia.tr("error.missing_dependencies"))
if callback then callback(false) end
return
end
if refreshing then
refreshAgain = true
if callback then callback(status.active) end
return
end
refreshing = true
findActiveHotspot(function(info, err)
if err ~= nil then
status.active = false
status.clients = {}
publish(err)
completeRefresh(callback, false)
return
end
if info == nil then
status.active = false
status.ssid = ""
status.interface = ""
status.connection = ""
status.gateway = ""
status.clients = {}
publish()
completeRefresh(callback, false)
return
end
status.active = true
status.ssid = info.ssid
status.interface = info.interface
status.connection = info.connection
status.gateway = info.gateway
fetchClients(info.interface, function(clients)
status.clients = clients
publish()
completeRefresh(callback, true)
end)
end)
end
local function finishToggle(expectedActive, result)
if result.exitCode ~= 0 then
busy = false
local detail = noctalia.string.trim(result.stderr or "")
if detail == "" then detail = noctalia.string.trim(result.stdout or "") end
if detail == "" then detail = noctalia.tr("error.command_failed") end
publish(detail)
showNotification(noctalia.tr("title"), detail, "alert-circle", "critical")
refresh()
return
end
refresh(function(currentActive)
busy = false
publish()
if currentActive ~= expectedActive then
showNotification(noctalia.tr("title"), noctalia.tr("error.state_mismatch"), "alert-circle", "critical")
elseif currentActive then
showNotification(
noctalia.tr("notification.enabled.title"),
noctalia.tr("notification.enabled.body", { ssid = status.ssid or noctalia.getConfig("ssid") or "" }),
"wifi",
"normal"
)
else
showNotification(
noctalia.tr("notification.disabled.title"),
noctalia.tr("notification.disabled.body"),
"wifi-off",
"normal"
)
end
end)
end
local function startHotspot()
local ok, ssidOrError, password = validateSettings()
if not ok then
busy = false
publish(ssidOrError)
showNotification(noctalia.tr("title"), ssidOrError, "alert-circle", "critical")
return
end
pickWifiInterface(function(iface)
if iface == nil or iface == "" then
busy = false
local message = noctalia.tr("error.no_wifi_device")
publish(message)
showNotification(noctalia.tr("title"), message, "alert-circle", "critical")
return
end
local command = table.concat({
"nmcli device wifi hotspot",
"ifname " .. shellQuote(iface),
"ssid " .. shellQuote(ssidOrError),
"password " .. shellQuote(password),
}, " ")
runCommand(command, function(result)
finishToggle(true, result)
end, 30000)
end)
end
local function stopHotspot()
if status.connection ~= nil and status.connection ~= "" then
runCommand("nmcli connection down " .. shellQuote(status.connection), function(result)
finishToggle(false, result)
end)
return
end
findActiveHotspot(function(info)
if info == nil then
busy = false
status.active = false
status.clients = {}
publish()
showNotification(
noctalia.tr("notification.disabled.title"),
noctalia.tr("notification.disabled.body"),
"wifi-off",
"normal"
)
return
end
runCommand("nmcli connection down " .. shellQuote(info.connection), function(result)
finishToggle(false, result)
end)
end)
end
setActive = function(nextActive)
if busy or not available then return end
if nextActive then
local ok, message = validateSettings()
if not ok then
publish(message)
showNotification(noctalia.tr("title"), message, "alert-circle", "critical")
return
end
end
busy = true
publish()
if nextActive then
startHotspot()
else
stopHotspot()
end
end
local function handleCommand(action)
if action == "toggle" then
setActive(not status.active)
elseif action == "enable" then
setActive(true)
elseif action == "disable" then
setActive(false)
elseif action == "refresh" or action == "status" then
refresh()
end
end
noctalia.state.watch(COMMAND_KEY, function(command)
if type(command) ~= "table" or type(command.action) ~= "string" then return end
handleCommand(command.action)
end)
publish(available and "" or noctalia.tr("error.missing_dependencies"))
refresh()
function update()
local seconds = tonumber(noctalia.getConfig("refresh_interval")) or 5
noctalia.setUpdateInterval(math.max(2, seconds) * 1000)
if not busy then
refresh()
end
end
function onConfigChanged()
update()
end
function onIpc(event, _payload)
handleCommand(event)
end