瀏覽代碼

Integrate client2server-minimal.lua with luv async

- Use minimal.lua code as base
- Add luv timer for async check loop
- Falls back to blocking if luv not available
- Same features: DHCP, WiFi, SSID monitoring
Gogs 2 月之前
父節點
當前提交
ce1f9b31f3
共有 1 個文件被更改,包括 252 次插入575 次删除
  1. 252 575
      usr/sbin/client2server-luv.lua

+ 252 - 575
usr/sbin/client2server-luv.lua

@@ -1,14 +1,12 @@
 --[[
-    client2server-luv - Lua WebSocket Event Forwarder for OpenWrt with luv async
-
+    client2server-luv - client2server-minimal.lua with luv async
+    
     Features:
-    - WebSocket connection to central server
-    - Auto-reconnect on disconnect
-    - Local buffer (store-and-forward while offline)
-    - DHCP/WiFi/Interface event tracking
-    - ASYNC monitoring via luv (libuv bindings)
-    - Parallel polling for 50+ devices
-
+    - DHCP + WiFi + SSID monitoring via ubus
+    - ASYNC with luv (libuv bindings) - no blocking sleeps
+    - State management for change detection
+    - Direct HTTP POST to server
+    
     Copyright (c) 2026 Luis Rosales - MIT License
 ]]
 
@@ -19,668 +17,347 @@
 -- Try to load luv (libuv bindings for async operations)
 local luv_ok, luv = pcall(require, "luv")
 
--- ============================================================================
--- HTTP POST (direct curl - works on minimal OpenWrt)
--- ============================================================================
-
-local function http_post(event_type, data)
-    local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
-    local url = cfg.url:gsub("wss", "https"):gsub("ws", "http")
-    if url == cfg.url then url = cfg.url .. "/api/events" end
-    
-    local cmd = string.format(
-        'curl -s -X POST "%s" -H "Content-Type: application/json" -d "%s" 2>/dev/null',
-        url, json:gsub('"', '\\"')
-    )
-    local f = io.popen(cmd, "r")
-    local result = f and f:read("*a") or ""
-    if f then f:close() end
-    
-    if result and result:match("OK") then
-        log("info", "Event " .. event_type .. " OK")
-        return true
-    else
-        log("warn", "Event " .. event_type .. " fail: " .. result:sub(1, 50))
-        return false
-    end
-end
-
 -- ============================================================================
 -- CONFIG
 -- ============================================================================
 
 local cfg = {
-    url = os.getenv("SERVER_URL") or "wss://your-server.com/ws",
-    token = os.getenv("SERVER_TOKEN") or "secret-token",
+    server_url = os.getenv("SERVER_URL") or "http://163.245.193.47:3843",
     router_id = os.getenv("ROUTER_ID") or "unknown",
-    reconnect_delay = 5,
-    ping_interval = 30,
-    buffer_file = "/tmp/event_buffer",
-    max_buffer = 100,
-    poll_interval = 30,        -- device poll interval (seconds)
-    wan_poll_interval = 30,     -- WAN poll interval (seconds)
-    dhcp_poll_interval = 5,   -- DHCP poll interval (seconds)
-    poll_timeout = 5000,       -- poll timeout (ms)
+    check_interval = 10,  -- seconds
+    state_file = "/tmp/client2server_state",
 }
 
--- Load from UCI if available
-pcall(function()
-    local uci = require("luci.model.uci").cursor()
-    cfg.url = uci:get("event-forwarder", "server", "url") or cfg.url
-    cfg.token = uci:get("event-forwarder", "server", "token") or cfg.token
-    cfg.router_id = uci:get("event-forwarder", "router", "id") or cfg.router_id
-end)
-
 -- ============================================================================
 -- UTILITIES
 -- ============================================================================
 
-local function log(level, msg)
-    os.execute(string.format('logger -t "client2server-luv" -p user.%s "%s" 2>/dev/null', level, msg))
+local function log_info(msg)
+    os.execute('logger -t client2server -p user.info "' .. msg .. '"')
 end
 
-local function get_hostname()
-    -- Try multiple methods to get hostname
-    local f = io.popen("cat /proc/sys/kernel/hostname 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo unknown")
-    local h = f and f:read("*a") or "unknown"
-    if f then f:close() end
-    return (h:gsub("%s+$", ""))
+local function http_post(event_type, data)
+    local json = '{"router_id":"' .. cfg.router_id .. '","event":"' .. event_type .. '","data":' .. data .. '}'
+    local f = io.popen("curl -s -X POST '" .. cfg.server_url .. "/api/events' -H 'Content-Type: application/json' -d '" .. json .. "'", "r")
+    local result = f:read("*a")
+    f:close()
+    return result
 end
 
-cfg.router_id = cfg.router_id == "unknown" and get_hostname() or cfg.router_id
-
-local function json_encode(t)
+-- Validate MAC address (proper format with valid hex)
+local function is_valid_mac(mac)
+    if not mac or #mac ~= 17 then return false end
     local parts = {}
-    for k, v in pairs(t) do
-        if type(v) == "string" then
-            table.insert(parts, string.format('"%s": "%s"', k, v:gsub('"', '\\"')))
-        elseif type(v) == "number" then
-            table.insert(parts, string.format('"%s": %s', k, tostring(v)))
-        elseif type(v) == "boolean" then
-            table.insert(parts, string.format('"%s": %s', k, tostring(v)))
-        end
+    for part in mac:gmatch("[a-fA-F0-9][a-fA-F0-9]") do
+        table.insert(parts, part)
     end
-    return "{" .. table.concat(parts, ",") .. "}"
+    if #parts ~= 6 then return false end
+    -- First byte: not 00 or FF
+    local first = tonumber(parts[1], 16)
+    if first == 0 or first == 255 then return false end
+    -- Second byte: not FF (multicast)
+    local second = tonumber(parts[2], 16)
+    if second == 255 then return false end
+    return true
 end
 
-local function json_decode(str)
-    local result = {}
-    for k, v in str:gmatch('"([^"]+)":%s*([^},]+)') do
-        v = v:gsub('[%s"]+', '')
-        if v == "true" or v == "false" then
-            result[k] = v == "true"
-        elseif tonumber(v) then
-            result[k] = tonumber(v)
-        else
-            result[k] = v
-        end
+local function send_event(event_type, data)
+    log_info("Event: " .. event_type)
+    local resp = http_post(event_type, data)
+    if resp and resp:match("OK") then
+        log_info("OK")
+    else
+        log_info("Fail: " .. (resp or "nil"):sub(1, 50))
     end
-    return result
 end
 
 -- ============================================================================
--- BUFFER (OFFLINE SUPPORT)
+-- STATE MANAGEMENT
 -- ============================================================================
 
-local buffer = {
-    events = {},
-    dirty = false,
-}
-
-function buffer.load()
-    local f = io.open(cfg.buffer_file, "r")
-    if not f then return end
-
-    for line in f:lines() do
-        if line and line ~= "" then
-            table.insert(buffer.events, line)
+local function load_state()
+    local f = io.open(cfg.state_file, "r")
+    if f then
+        local content = f:read("*a")
+        f:close()
+        local clients = {}
+        local ssid_enabled = {}
+        local dhcp = {}
+        for line in content:gmatch("[^\n]+") do
+            local t, v = line:match("^([^:]+):(.+)$")
+            if t == "client" then
+                local mac, iface, ssid = v:match("^(.+)|(.+)|(.+)$")
+                if mac and iface and is_valid_mac(mac) then clients[mac] = {iface=iface, ssid=ssid} end
+            elseif t == "ssid" then ssid_enabled[v] = true
+            elseif t == "dhcp" then
+                local mac, ip = v:match("^(.+)->(.+)$")
+                if mac and ip and is_valid_mac(mac) then dhcp[mac] = {ip=ip, mac=mac} end
+            end
         end
+        return { clients = clients, ssid_enabled = ssid_enabled, dhcp = dhcp }
     end
-    f:close()
-    log("info", "Loaded " .. #buffer.events .. " buffered events")
+    return { clients = {}, ssid_enabled = {}, dhcp = {} }
 end
 
-function buffer.save()
-    local f = io.open(cfg.buffer_file, "w")
-    if not f then return end
-
-    for _, event in ipairs(buffer.events) do
-        f:write(event .. "\n")
-    end
-    f:close()
-    buffer.dirty = false
-end
-
-function buffer.add(json_event)
-    table.insert(buffer.events, json_event)
-
-    -- Limit buffer size
-    while #buffer.events > cfg.max_buffer do
-        table.remove(buffer.events, 1)
-    end
-
-    buffer.dirty = true
-end
-
-function buffer.flush(send_fn)
-    if #buffer.events == 0 then return end
-
-    local to_send = buffer.events
-    buffer.events = {}
-    buffer.dirty = false
-
-    for _, event in ipairs(to_send) do
-        local ok, err = pcall(send_fn, event)
-        if not ok or err then
-            -- Re-add to buffer on failure
-            table.insert(buffer.events, event)
+local function save_state(state)
+    local f = io.open(cfg.state_file, "w")
+    if f then
+        for mac, info in pairs(state.clients) do
+            f:write("client:" .. mac .. "|" .. (info.iface or "") .. "|" .. (info.ssid or "") .. "\n")
         end
-    end
-
-    buffer.save()
-end
-
-function buffer.clear()
-    buffer.events = {}
-    buffer.dirty = false
-    os.remove(cfg.buffer_file)
-end
-
--- ============================================================================
--- WEBSOCKET (with fallback)
--- ============================================================================
-
-local ws = {}
-
-function ws.connect(url)
-    if ws_client then
-        local sock, err = ws_client.connect(url)
-        if err then
-            log("err", "WS connect error: " .. err)
-            return nil
+        for iface in pairs(state.ssid_enabled) do
+            f:write("ssid:" .. iface .. "\n")
         end
-        return sock
-    end
-    return nil
-end
-
-function ws.send(sock, data)
-    if sock then
-        return sock:send(data)
-    end
-    return false, "no socket"
-end
-
-function ws.close(sock)
-    if sock then
-        sock:close()
+        for mac, info in pairs(state.dhcp) do
+            f:write("dhcp:" .. mac .. "->" .. info.ip .. "\n")
+        end
+        f:close()
     end
 end
 
-function ws.connected(sock)
-    return sock ~= nil
-end
-
-function build_event(event_type, payload)
-    return json_encode({
-        type = event_type,
-        router_id = cfg.router_id,
-        timestamp = os.time(),
-        payload = payload
-    })
-end
-
 -- ============================================================================
--- ASYNC POLLING WITH LUV
+-- WIFI: Get hostapd interfaces + SSID + clients
 -- ============================================================================
 
--- Device polling results storage
-local poll_results = {}
-local poll_count = 0
-local poll_total = 0
-
--- Poll a single device via ubus
-local function poll_single_device(device_id, device_ip)
-    local cmd = string.format(
-        'ubus call network.interface.%s status 2>/dev/null',
-        device_id
-    )
-
-    local f = io.popen(cmd)
-    if not f then
-        return { status = "error", ip = device_ip }
+local function get_hostapd_interfaces()
+    local f = io.popen("ubus list | grep hostapd")
+    local interfaces = {}
+    if f then
+        for line in f:lines() do
+            table.insert(interfaces, line)
+        end
+        f:close()
     end
-
-    local result = f:read("*all")
-    f:close()
-
-    local parsed = json_decode(result)
-    parsed.status = "online"
-    parsed.ip = device_ip
-
-    return parsed
+    return interfaces
 end
 
--- Async parallel polling with luv
-local function poll_devices_parallel(device_list)
-    if not luv then
-        -- Fallback: sequential
-        for _, dev in ipairs(device_list) do
-            poll_results[dev.id] = poll_single_device(dev.id, dev.ip)
+local function get_ssid_name(iface)
+    local wlan_map = {
+        ["hostapd.wlan0-1"] = "wlan0-1",
+        ["hostapd.wlan0-2"] = "wlan0-2",
+    }
+    local wlan_iface = wlan_map[iface] or iface
+    
+    local f = io.popen("iw dev " .. wlan_iface .. " info 2>/dev/null | grep ssid")
+    if f then
+        local result = f:read("*a")
+        f:close()
+        local ssid = result:match("ssid%s+(.+)")
+        if ssid and ssid ~= "" then
+            return ssid:gsub("%s+$", "")
         end
-        return
     end
+    return iface
+end
 
-    poll_results = {}
-    poll_count = 0
-    poll_total = #device_list
-
-    log("info", "Starting parallel poll of " .. poll_total .. " devices")
-
-    -- Poll each device in parallel using luv async
-    for _, dev in ipairs(device_list) do
-        local device_id = dev.id
-        local device_ip = dev.ip
-
-        -- Run in async task
-        luv.new_task(function()
-            local result = poll_single_device(device_id, device_ip)
-            poll_results[device_id] = result
-
-            poll_count = poll_count + 1
-            if poll_count == poll_total then
-                log("info", "All " .. poll_total .. " devices polled")
-                -- Process results here if needed
+local function get_all_ssids()
+    local ssids = {}
+    local f = io.popen("iw dev 2>/dev/null")
+    if f then
+        local result = f:read("*a")
+        f:close()
+        local current_iface = nil
+        for line in result:gmatch("[^\n]+") do
+            local iface = line:match("Interface%s+(wlan%d-%d)")
+            if iface then current_iface = iface end
+            local ssid = line:match("ssid%s+(.+)")
+            if ssid and current_iface then
+                local hostapd_iface = "hostapd." .. current_iface
+                ssids[hostapd_iface] = { enabled = true, ssid = ssid }
             end
-        end)
+        end
     end
+    return ssids
 end
 
--- ============================================================================
--- WIFI EVENTS (hostapd via ubus)
--- ============================================================================
-
-local function monitor_wifi_events()
-    if not luv then
-        log("warn", "luv not available for WiFi monitoring")
-        return
+local function get_wifi_clients()
+    local clients = {}
+    local interfaces = get_hostapd_interfaces()
+    
+    local ssid_map = {}
+    for _, iface in ipairs(interfaces) do
+        ssid_map[iface] = get_ssid_name(iface)
     end
-
-    -- Use luv to watch ubus for wireless events
-    -- Note: ubus doesn't support event subscription directly,
-    -- so we poll hostapd status periodically
-
-    local timer = luv.new_timer()
-    local last_clients = {}
-
-    luv.timer_start(timer, 10000, 10000, function()
-        -- Poll wireless clients
-        local f = io.popen("ubus call hostapd.wlan0-1 get_clients 2>/dev/null")
+    
+    local count = 0
+    for _, iface in ipairs(interfaces) do
+        local f = io.popen("ubus call " .. iface .. " get_clients 2>/dev/null")
         if f then
-            local result = f:read("*all")
+            local result = f:read("*a")
             f:close()
-
-            if result and result ~= "" then
-                -- Parse clients and detect changes
-                local current_clients = {}
-                for mac in result:gmatch('"([^"]+)":') do
-                    current_clients[mac] = true
-                end
-
-                -- Detect new connections
-                for mac, _ in pairs(current_clients) do
-                    if not last_clients[mac] then
-                        local ev = build_event("wifi_connect", {
-                            mac = mac,
-                            interface = "wlan0-1"
-                        })
-                        log("info", "WiFi connected: " .. mac)
-                        buffer.add(ev)
+            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
+                    if is_valid_mac(mac) and not clients[mac] then
+                        clients[mac] = { iface = iface, ssid = ssid_map[iface] }
+                        mac_count = mac_count + 1
                     end
                 end
-
-                -- Detect disconnections
-                for mac, _ in pairs(last_clients) do
-                    if not current_clients[mac] then
-                        local ev = build_event("wifi_disconnect", {
-                            mac = mac,
-                            interface = "wlan0-1"
-                        })
-                        log("info", "WiFi disconnected: " .. mac)
-                        buffer.add(ev)
-                    end
-                end
-
-                last_clients = current_clients
+                count = count + mac_count
             end
         end
-    end)
-
-    log("info", "WiFi event monitor started")
-end
-
--- ============================================================================
--- DHCP LEASES HELPERS (must be before monitor functions)
--- ============================================================================
-
--- Helper: read current leases
-local function read_leases()
-    local leases = {}
-    local f = io.open("/var/lib/dnsmasq/dnsmasq.leases", "r")
-    if not f then return leases end
-
-    for line in f:lines() do
-        local ts, mac, ip, name = line:match("(%d+)%s+(%S+)%s+(%S+)%s+(%S+)")
-        if mac then
-            leases[mac] = { ip = ip, hostname = name, time = tonumber(ts) }
-        end
     end
-    f:close()
-    return leases
+    return clients
 end
 
--- Helper: process leases and detect changes
-local function process_leases(old_leases, callback)
-    local leases = read_leases()
-
-    -- New leases
-    for mac, info in pairs(leases) do
-        if not old_leases[mac] then
-            callback(mac, info.ip, info.hostname, "new")
-        end
-    end
-
-    -- Expired leases
-    for mac, info in pairs(old_leases) do
-        if not leases[mac] then
-            callback(mac, info.ip, info.hostname, "expired")
-        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 LEASES MONITOR
+-- DHCP: Get leases via ubus
 -- ============================================================================
 
-local function monitor_dhcp_leases()
-    if not luv then
-        -- Fallback: sequential file polling
-        monitor_dhcp_sequential()
-        return
-    end
-    
-    -- Use timer-based polling instead of fs_event (more compatible)
-    local timer = luv.new_timer()
-    local old_leases = {}
+local function get_dhcp_leases()
+    local leases = {}
     
-    luv.timer_start(timer, cfg.dhcp_poll_interval * 1000, cfg.dhcp_poll_interval * 1000, function()
-        -- File changed, process leases
-        process_leases(old_leases, function(mac, ip, hostname, action)
-            local ev = build_event("dhcp_lease", {
-                mac = mac,
-                ip = ip,
-                hostname = hostname,
-                action = action
-            })
-            log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
-            buffer.add(ev)
-        end)
-        
-        old_leases = read_leases()
-    end)
+    local f = io.popen("ubus call dhcp ipv4leases 2>/dev/null")
+    if f then
+        local result = f:read("*a")
+        f:close()
+        if result:match('"leases"') then
+            for ip, mac in result:gmatch('"ip":"([%d%.]+)"[^}]*"mac":"([a-fA-F0-9:]+)"') do
+                if is_valid_mac(mac) then leases[mac] = {ip=ip, mac=mac} end
+            end
+            return leases
+        end
+    end
     
-    log("info", "DHCP lease monitor started")
-end
-
--- Fallback: sequential DHCP monitoring
-local function monitor_dhcp_sequential()
-    local old_leases = {}
-
-    while true do
-        process_leases(old_leases, function(mac, ip, hostname, action)
-            local ev = build_event("dhcp_lease", {
-                mac = mac,
-                ip = ip,
-                hostname = hostname,
-                action = action
-            })
-            log("info", "DHCP: " .. action .. " - " .. mac .. " -> " .. ip)
-            buffer.add(ev)
-        end)
-
-        old_leases = read_leases()
-        os.execute("sleep " .. cfg.dhcp_poll_interval)
+    f = io.popen("cat /tmp/dhcp.leases 2>/dev/null")
+    if f then
+        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
+        end
+        f:close()
     end
+    return leases
 end
 
 -- ============================================================================
--- WAN MONITORING
+-- MAIN LOOP (with luv async)
 -- ============================================================================
 
-local last_wan_state = nil
+local state = { clients = {}, ssid_enabled = {}, dhcp = {} }
 
-local function monitor_wan()
-    if not luv then
-        -- Fallback: sequential WAN polling
-        monitor_wan_sequential()
-        return
+local function check_all()
+    -- WiFi Clients
+    local current_clients = get_wifi_clients()
+    
+    for mac, info in pairs(current_clients) do
+        if not state.clients[mac] then
+            log_info("WiFi CONNECTED: " .. mac .. " on " .. info.ssid)
+            send_event("wifi_connected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
+        end
     end
-
-    local timer = luv.new_timer()
-
-    luv.timer_start(timer, cfg.wan_poll_interval * 1000, cfg.wan_poll_interval * 1000, function()
-        local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
-        if f then
-            local status = f:read("*all")
-            f:close()
-
-            local is_up = status:match('"up":%s*true') ~= nil
-
-            if is_up ~= last_wan_state then
-                local ev = build_event("wan_status", {
-                    up = is_up
-                })
-                log("info", "WAN: " .. (is_up and "up" or "down"))
-                buffer.add(ev)
-                last_wan_state = is_up
-            end
+    for mac, info in pairs(state.clients) do
+        if not current_clients[mac] then
+            log_info("WiFi DISCONNECTED: " .. mac .. " from " .. info.ssid)
+            send_event("wifi_disconnected", '{"mac":"' .. mac .. '","ssid":"' .. info.ssid .. '","interface":"' .. info.iface .. '"}')
         end
-    end)
-
-    log("info", "WAN monitor started")
-end
-
--- Fallback: sequential WAN monitoring
-local function monitor_wan_sequential()
-    while true do
-        local f = io.popen("ubus call network.interface.wan status 2>/dev/null")
-        if f then
-            local status = f:read("*all")
-            f:close()
-
-            local is_up = status:match('"up":%s*true') ~= nil
-
-            if is_up ~= last_wan_state then
-                local ev = build_event("wan_status", {
-                    up = is_up
-                })
-                log("info", "WAN: " .. (is_up and "up" or "down"))
-                buffer.add(ev)
-                last_wan_state = is_up
-            end
+    end
+    state.clients = current_clients
+    
+    -- SSID Status
+    local current_ssid = get_ssid_status()
+    for iface in pairs(current_ssid) do
+        if not state.ssid_enabled[iface] then
+            local ssid = get_ssid_name(iface)
+            log_info("SSID ENABLED: " .. ssid .. " (" .. iface .. ")")
+            send_event("ssid_enabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
         end
-
-        os.execute("sleep " .. cfg.wan_poll_interval)
     end
-end
-
--- ============================================================================
--- NETWORK STATUS POLLING
--- ============================================================================
-
-local function poll_network_status()
-    if not luv then
-        -- Sequential fallback
-        local f = io.popen("ubus call network getStatus 2>/dev/null")
-        if f then f:close() end
-        return
+    for iface in pairs(state.ssid_enabled) do
+        if not current_ssid[iface] then
+            local ssid = get_ssid_name(iface)
+            log_info("SSID DISABLED: " .. ssid .. " (" .. iface .. ")")
+            send_event("ssid_disabled", '{"interface":"' .. iface .. '","ssid":"' .. ssid .. '"}')
+        end
     end
-
-    local timer = luv.new_timer()
-
-    luv.timer_start(timer, cfg.poll_interval * 1000, cfg.poll_interval * 1000, function()
-        local f = io.popen("ubus call network getStatus 2>/dev/null")
-        if f then
-            local status = f:read("*all")
-            f:close()
-
-            if status and status ~= "" then
-                local ev = build_event("network_status", {
-                    status = status
-                })
-                buffer.add(ev)
-            end
+    state.ssid_enabled = current_ssid
+    
+    -- DHCP Leases
+    local current_dhcp = get_dhcp_leases()
+    for mac, info in pairs(current_dhcp) do
+        if not state.dhcp[mac] then
+            log_info("DHCP NEW: " .. mac .. " -> " .. info.ip)
+            send_event("dhcp_new", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
         end
-    end)
-
-    log("info", "Network status poller started")
+    end
+    for mac, info in pairs(state.dhcp) do
+        if not current_dhcp[mac] then
+            log_info("DHCP RELEASE: " .. mac .. " -> " .. info.ip)
+            send_event("dhcp_release", '{"mac":"' .. mac .. '","ip":"' .. info.ip .. '"}')
+        end
+    end
+    state.dhcp = current_dhcp
+    
+    -- Save state
+    save_state(state)
 end
 
 -- ============================================================================
--- MAIN EVENT LOOP
+-- MAIN
 -- ============================================================================
 
-local function main()
-    log("info", "client2server-luv starting...")
-    log("info", "Router: " .. cfg.router_id)
-    log("info", "Server: " .. cfg.url)
-
+function main()
+    log_info("Starting client2server-luv (async)...")
+    log_info("Router: " .. cfg.router_id)
+    log_info("Server: " .. cfg.server_url)
+    
     if luv_ok then
-        log("info", "luv available - using async mode")
+        log_info("luv available - using async mode")
     else
-        log("warn", "luv NOT available - using blocking mode")
+        log_info("luv NOT available - using blocking mode")
     end
-
-    -- Load buffered events
-    buffer.load()
-
-    -- Save PID
-    local pf = io.open("/var/run/client2server-luv.pid", "w")
-    if pf then
-        local f = io.popen("echo $$")
-        local pid = f and f:read("*a") or "0"
-        if f then f:close() end
-        pf:write(pid:gsub("%s+", ""))
-        pf:close()
-    end
-
-    local sock = nil
-    local retries = 0
-
+    
+    -- Load state
+    state = load_state()
+    
+    -- Initial event
+    local leases = get_dhcp_leases()
+    local count = 0
+    for _ in pairs(leases) do count = count + 1 end
+    log_info("DHCP leases: " .. count)
+    send_event("router_online", '{"lease_count":' .. count .. '}')
+    
     if luv_ok then
-        -- Use luv event loop
-        -- luv.run() starts the event loop. The callbacks we registered
-        -- (timers, fs_events) will run automatically.
-        local function start_monitors()
-            -- Start all monitors in parallel
-            monitor_wifi_events()
-            monitor_dhcp_leases()
-            monitor_wan()
-            poll_network_status()
-
-            -- Keep the event loop running
-            local idle = luv.new_idle()
-            luv.idle_start(idle, function()
-                -- Idle work - just keep loop alive
-            end)
-        end
-
-        -- Start monitors first, then run the event loop with WebSocket
-        local function run_event_loop()
-            -- Register all monitors
-            monitor_wifi_events()
-            monitor_dhcp_leases()
-            monitor_wan()
-            poll_network_status()
+        -- ASYNC MODE with luv
+        local timer = luv.new_timer()
+        
+        local function run_loop()
+            check_all()
             
-            -- Keep the event loop running with idle
-            local idle = luv.new_idle()
-            luv.idle_start(idle, function()
-                -- Idle work - check WebSocket periodically
-            end)
-            
-            -- Direct HTTP POST on each event (like minimal version)
-            log("info", "Using direct HTTP POST (curl)")
-            
-            -- The monitors call buffer.add() which stores events.
-            -- We need a periodic flush that calls http_post()
-            local flush_timer = luv.new_timer()
-            luv.timer_start(flush_timer, 10000, 10000, function()
-                -- Flush buffered events
-                for _, event_json in ipairs(buffer.events) do
-                    local event_type = "unknown"
-                    event_type = event_json:match('"type":"([^"]+)"') or event_type
-                    local data = event_json  -- Already JSON
-                    http_post(event_type, data)
-                end
-                buffer.clear()
+            -- Schedule next check
+            luv.timer_start(timer, cfg.check_interval * 1000, cfg.check_interval * 1000, function()
+                run_loop()
             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
-        local ok, err = pcall(function() luv.run(run_event_loop) end)
-        if not ok then
-            log("err", "luv.run error: " .. tostring(err))
-            -- Fallback: sequential mode
-            while true do
-                os.execute("sleep 60")
-            end
-        end
+        luv.run()
+        
     else
-        -- Fallback: sequential mode
+        -- BLOCKING MODE (fallback)
         while true do
-            -- Sequential monitoring
-            monitor_dhcp_sequential()
-            monitor_wan_sequential()
-
-            -- WebSocket connection
-            log("info", "Connecting to server...")
-
-            if ws_client then
-                sock = ws.connect(cfg.url)
-            end
-
-            if sock then
-                log("info", "Connected!")
-                retries = 0
-
-                buffer.flush(function(data)
-                    return ws.send(sock, data)
-                end)
-
-                local loop_count = 0
-                while ws.connected(sock) and loop_count < (cfg.ping_interval / 5) do
-                    os.execute("sleep 5")
-                    loop_count = loop_count + 1
-
-                    buffer.flush(function(data)
-                        return ws.send(sock, data)
-                    end)
-                end
-            else
-                log("err", "Connection failed")
-                retries = retries + 1
-            end
-
-            ws.close(sock)
-            buffer.save()
-            os.execute("sleep " .. cfg.reconnect_delay)
+            check_all()
+            local timer = luv.new_timer()
+            luv.timer_start(timer, cfg.check_interval * 1000, 0, function() end)
+            luv.run()
+            -- Fallback if no luv
+            os.execute("sleep " .. cfg.check_interval)
         end
     end
 end
 
--- ============================================================================
--- START
--- ============================================================================
-
 main()