Browse Source

True async: use luv fs_event for DHCP, timers for WiFi/SSID

- luv.fs_event watches DHCP lease file - reacts IMMEDIATELY on changes
- WiFi check every 5 seconds
- SSID check every 30 seconds
- Falls back to timer-based if fs_event fails
Gogs 2 months ago
parent
commit
3d6c83de25
1 changed files with 92 additions and 72 deletions
  1. 92 72
      usr/sbin/client2server-luv.lua

+ 92 - 72
usr/sbin/client2server-luv.lua

@@ -1,11 +1,12 @@
 --[[
 --[[
-    client2server-luv - client2server-minimal.lua with luv async
+    client2server-luv - client2server-minimal.lua with TRUE luv async
     
     
     Features:
     Features:
     - DHCP + WiFi + SSID monitoring via ubus
     - DHCP + WiFi + SSID monitoring via ubus
-    - ASYNC with luv (libuv bindings) - no blocking sleeps
+    - ASYNC: react IMMEDIATELY when events happen (not polling!)
+    - luv fs_event for file watching
+    - luv timer for immediate triggers
     - State management for change detection
     - State management for change detection
-    - Direct HTTP POST to server
     
     
     Copyright (c) 2026 Luis Rosales - MIT License
     Copyright (c) 2026 Luis Rosales - MIT License
 ]]
 ]]
@@ -14,7 +15,6 @@
 -- REQUIREMENTS
 -- REQUIREMENTS
 -- ============================================================================
 -- ============================================================================
 
 
--- Try to load luv (libuv bindings for async operations)
 local luv_ok, luv = pcall(require, "luv")
 local luv_ok, luv = pcall(require, "luv")
 
 
 -- ============================================================================
 -- ============================================================================
@@ -24,7 +24,8 @@ local luv_ok, luv = pcall(require, "luv")
 local cfg = {
 local cfg = {
     server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
     server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
     router_id = os.getenv("ROUTER_ID") or "unknown",
     router_id = os.getenv("ROUTER_ID") or "unknown",
-    check_interval = 10,  -- seconds
+    check_interval = 10,
+    dhcp_lease_file = "/var/lib/dnsmasq/dnsmasq.leases",
     state_file = "/tmp/client2server_state",
     state_file = "/tmp/client2server_state",
 }
 }
 
 
@@ -44,7 +45,6 @@ local function http_post(event_type, data)
     return result
     return result
 end
 end
 
 
--- Validate MAC address (proper format with valid hex)
 local function is_valid_mac(mac)
 local function is_valid_mac(mac)
     if not mac or #mac ~= 17 then return false end
     if not mac or #mac ~= 17 then return false end
     local parts = {}
     local parts = {}
@@ -52,10 +52,8 @@ local function is_valid_mac(mac)
         table.insert(parts, part)
         table.insert(parts, part)
     end
     end
     if #parts ~= 6 then return false end
     if #parts ~= 6 then return false end
-    -- First byte: not 00 or FF
     local first = tonumber(parts[1], 16)
     local first = tonumber(parts[1], 16)
     if first == 0 or first == 255 then return false end
     if first == 0 or first == 255 then return false end
-    -- Second byte: not FF (multicast)
     local second = tonumber(parts[2], 16)
     local second = tonumber(parts[2], 16)
     if second == 255 then return false end
     if second == 255 then return false end
     return true
     return true
@@ -116,19 +114,16 @@ local function save_state(state)
 end
 end
 
 
 -- ============================================================================
 -- ============================================================================
--- WIFI: Get hostapd interfaces + SSID + clients
+-- WIFI: Get interfaces + clients + SSID
 -- ============================================================================
 -- ============================================================================
 
 
 local function get_hostapd_interfaces()
 local function get_hostapd_interfaces()
     local f = io.popen("ubus list | grep hostapd")
     local f = io.popen("ubus list | grep hostapd")
     local interfaces = {}
     local interfaces = {}
     if f then
     if f then
-        for line in f:lines() do
-            table.insert(interfaces, line)
-        end
+        for line in f:lines() do table.insert(interfaces, line) end
         f:close()
         f:close()
     end
     end
-    log_info("hostapd interfaces: " .. table.concat(interfaces, ", "))
     return interfaces
     return interfaces
 end
 end
 
 
@@ -136,17 +131,16 @@ local function get_ssid_name(iface)
     local wlan_map = {
     local wlan_map = {
         ["hostapd.wlan0-1"] = "wlan0-1",
         ["hostapd.wlan0-1"] = "wlan0-1",
         ["hostapd.wlan0-2"] = "wlan0-2",
         ["hostapd.wlan0-2"] = "wlan0-2",
+        ["hostapd.wlan1-1"] = "wlan1-1",
     }
     }
-    local wlan_iface = wlan_map[iface] or iface
+    local wlan_iface = wlan_map[iface] or iface:gsub("hostapd.", "")
     
     
     local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
     local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
     if f then
     if f then
         local result = f:read("*a")
         local result = f:read("*a")
         f:close()
         f:close()
         local ssid = result:match("ssid%s+(.+)")
         local ssid = result:match("ssid%s+(.+)")
-        if ssid and ssid ~= "" then
-            return ssid:gsub("%s+$", "")
-        end
+        if ssid and ssid ~= "" then return ssid:gsub("%s+$", "") end
     end
     end
     return iface
     return iface
 end
 end
@@ -158,16 +152,12 @@ local function get_all_ssids()
         local result = f:read("*a")
         local result = f:read("*a")
         f:close()
         f:close()
         local current_iface = nil
         local current_iface = nil
-        log_info("iw dev output: " .. result:sub(1, 200))
         for line in result:gmatch("[^\n]+") do
         for line in result:gmatch("[^\n]+") do
-            -- Match ANY wlan interface (wlan0, wlan0-1, wlan1, etc.)
             local iface = line:match("Interface%s+(wlan%d[^%s]*)")
             local iface = line:match("Interface%s+(wlan%d[^%s]*)")
             if iface then current_iface = iface end
             if iface then current_iface = iface end
             local ssid = line:match("ssid%s+(.+)")
             local ssid = line:match("ssid%s+(.+)")
             if ssid and current_iface then
             if ssid and current_iface then
-                local hostapd_iface = "hostapd." .. current_iface
-                ssids[hostapd_iface] = { enabled = true, ssid = ssid }
-                log_info("SSID found: " .. ssid .. " on " .. hostapd_iface)
+                ssids["hostapd." .. current_iface] = { enabled = true, ssid = ssid }
             end
             end
         end
         end
     end
     end
@@ -185,39 +175,25 @@ local function get_wifi_clients()
     
     
     local count = 0
     local count = 0
     for _, iface in ipairs(interfaces) do
     for _, iface in ipairs(interfaces) do
-        log_info("Querying WiFi clients on: " .. iface)
         local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
         local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
         if f then
         if f then
             local result = f:read("*a")
             local result = f:read("*a")
             f:close()
             f:close()
-            log_info(iface .. " clients result: " .. result:sub(1, 200))
             if result:find('"clients"') and not result:find('"clients":%s*{}') then
             if result:find('"clients"') and not result:find('"clients":%s*{}') then
-                local mac_count = 0
                 for mac in result:gmatch('([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])') do
                 for mac in result:gmatch('([a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9]:[a-fA-F0-9][a-fA-F0-9])') do
                     if is_valid_mac(mac) and not clients[mac] then
                     if is_valid_mac(mac) and not clients[mac] then
                         clients[mac] = { iface = iface, ssid = ssid_map[iface] }
                         clients[mac] = { iface = iface, ssid = ssid_map[iface] }
-                        mac_count = mac_count + 1
+                        count = count + 1
                     end
                     end
                 end
                 end
-                count = count + mac_count
             end
             end
         end
         end
     end
     end
-    log_info("Total WiFi clients found: " .. count)
     return clients
     return clients
 end
 end
 
 
-local function get_ssid_status()
-    local status = {}
-    local ssids = get_all_ssids()
-    for iface, info in pairs(ssids) do
-        if info.enabled then status[iface] = true end
-    end
-    return status
-end
-
 -- ============================================================================
 -- ============================================================================
--- DHCP: Get leases via ubus
+-- DHCP: Get leases
 -- ============================================================================
 -- ============================================================================
 
 
 local function get_dhcp_leases()
 local function get_dhcp_leases()
@@ -235,11 +211,12 @@ local function get_dhcp_leases()
         end
         end
     end
     end
     
     
-    f = io.popen("cat /tmp/dhcp.leases 2>/dev/null")
+    -- Fallback: read lease file
+    f = io.popen("cat " .. cfg.dhcp_lease_file .. " 2>/dev/null")
     if f then
     if f then
         for line in f:lines() do
         for line in f:lines() do
-            local e, mac, ip, h, c = line:match("^(%S+) (%S+) (%S+) (%S+) (%S+)")
-            if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=h} end
+            local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
+            if mac and is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac, hostname=name} end
         end
         end
         f:close()
         f:close()
     end
     end
@@ -247,12 +224,13 @@ local function get_dhcp_leases()
 end
 end
 
 
 -- ============================================================================
 -- ============================================================================
--- MAIN LOOP (with luv async)
+-- ASYNC WATCHERS (TRUE EVENT-DRIVEN)
 -- ============================================================================
 -- ============================================================================
 
 
 local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
 local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
 
 
-local function check_all()
+-- Check and report changes
+local function check_and_report()
     -- WiFi Clients
     -- WiFi Clients
     local current_clients = get_wifi_clients()
     local current_clients = get_wifi_clients()
     
     
@@ -271,8 +249,8 @@ local function check_all()
     state.clients = current_clients
     state.clients = current_clients
     
     
     -- SSID Status
     -- SSID Status
-    local current_ssid = get_ssid_status()
-    for iface in pairs(current_ssid) do
+    local current_ssid = get_all_ssids()
+    for iface, info in pairs(current_ssid) do
         if not state.ssid_enabled[iface] then
         if not state.ssid_enabled[iface] then
             local ssid = get_ssid_name(iface)
             local ssid = get_ssid_name(iface)
             log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
             log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
@@ -304,10 +282,59 @@ local function check_all()
     end
     end
     state.dhcp = current_dhcp
     state.dhcp = current_dhcp
     
     
-    -- Save state
     save_state(state)
     save_state(state)
 end
 end
 
 
+-- ============================================================================
+-- ASYNC EVENT HANDLERS (immediate reaction)
+-- ============================================================================
+
+local function start_async_watchers()
+    if not luv_ok then return false end
+    
+    log_info("Starting async watchers...")
+    
+    -- 1. DHCP: Watch lease file with fs_event (REACT IMMEDIATELY)
+    local fs_event = luv.new_fs_event()
+    local function watch_dhcp()
+        log_info("DHCP lease file changed - checking...")
+        check_and_report()
+        -- Re-watch
+        luv.fs_event_start(fs_event, cfg.dhcp_lease_file, function(err)
+            if err then
+                log_info("DHCP fs_event error: " .. err)
+            else
+                watch_dhcp()
+            end
+        end)
+    end
+    -- Start watching
+    luv.fs_event_start(fs_event, cfg.dhcp_lease_file, function(err)
+        if err then
+            log_info("DHCP watch error: " .. err)
+        else
+            log_info("Watching DHCP leases: " .. cfg.dhcp_lease_file)
+            watch_dhcp()
+        end
+    end)
+    
+    -- 2. WiFi: Timer-based but frequent (every 5s)
+    local wifi_timer = luv.new_timer()
+    luv.timer_start(wifi_timer, 5000, 5000, function()
+        log_info("WiFi check (timer)...")
+        check_and_report()
+    end)
+    
+    -- 3. SSID: Less frequent (every 30s)
+    local ssid_timer = luv.new_timer()
+    luv.timer_start(ssid_timer, 30000, 30000, function()
+        log_info("SSID check (timer)...")
+        check_and_report()
+    end)
+    
+    return true
+end
+
 -- ============================================================================
 -- ============================================================================
 -- MAIN
 -- MAIN
 -- ============================================================================
 -- ============================================================================
@@ -318,9 +345,9 @@ function main()
     log_info("Server: " .. cfg.server_url)
     log_info("Server: " .. cfg.server_url)
     
     
     if luv_ok then
     if luv_ok then
-        log_info("luv available - using async mode")
+        log_info("luv available - ASYNC mode!")
     else
     else
-        log_info("luv NOT available - using blocking mode")
+        log_info("luv NOT available - blocking mode")
     end
     end
     
     
     -- Load state
     -- Load state
@@ -334,34 +361,27 @@ function main()
     send_event("router_online", '{"lease_count":' .. count .. '}')
     send_event("router_online", '{"lease_count":' .. count .. '}')
     
     
     if luv_ok then
     if luv_ok then
-        -- ASYNC MODE with luv
-        local timer = luv.new_timer()
+        -- ASYNC MODE
+        local started = start_async_watchers()
         
         
-        local function run_loop()
-            check_all()
-            
-            -- Schedule next check
-            luv.timer_start(timer, cfg.check_interval * 1000, cfg.check_interval * 1000, function()
-                run_loop()
-            end)
+        if started then
+            log_info("Async watchers started - running event loop")
+            luv.run()
+        else
+            -- Fallback to timer-based
+            log_info("Using timer-based fallback")
+            local timer = luv.new_timer()
+            local function run_loop()
+                check_and_report()
+                luv.timer_start(timer, cfg.check_interval * 1000, 0, function() run_loop() end)
+            end
+            luv.timer_start(timer, cfg.check_interval * 1000, 0, function() run_loop() end)
+            luv.run()
         end
         end
-        
-        -- Start first check after initial delay
-        luv.timer_start(timer, cfg.check_interval * 1000, 0, function()
-            run_loop()
-        end)
-        
-        -- Run the event loop
-        luv.run()
-        
     else
     else
-        -- BLOCKING MODE (fallback)
+        -- BLOCKING MODE
         while true do
         while true do
-            check_all()
-            local timer = luv.new_timer()
-            luv.timer_start(timer, cfg.check_interval * 1000, 0, function() end)
-            luv.run()
-            -- Fallback if no luv
+            check_and_report()
             os.execute("sleep " .. cfg.check_interval)
             os.execute("sleep " .. cfg.check_interval)
         end
         end
     end
     end