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.
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
--!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
|
||||
Reference in New Issue
Block a user