From 356837ee0a80ad316521405ae881f7a4a077fb7a Mon Sep 17 00:00:00 2001 From: Koda YeenBean Date: Fri, 10 Jul 2026 15:44:09 +0200 Subject: [PATCH] added devcontainer --- .devcontainer/Dockerfile | 36 ++ .devcontainer/devcontainer.json | 16 + .devcontainer/openwrt.config | 5 + Makefile | 61 ++ Makefile.standalone | 45 ++ README.md | 93 ++- files/openuf.conf | 17 + files/openuf.init | 27 + src/announce.c | 283 +++++++++ src/announce.h | 34 + src/clients.c | 275 ++++++++ src/clients.h | 83 +++ src/config.c | 39 ++ src/config.h | 44 ++ src/crypto.c | 162 +++++ src/crypto.h | 45 ++ src/http.c | 156 +++++ src/http.h | 18 + src/inform.c | 1039 +++++++++++++++++++++++++++++++ src/inform.h | 35 ++ src/lldp.c | 303 +++++++++ src/lldp.h | 72 +++ src/main.c | 207 ++++++ src/models.c | 199 ++++++ src/state.c | 135 ++++ src/state.h | 27 + src/sysinfo.c | 298 +++++++++ src/sysinfo.h | 73 +++ src/ufmodel.h | 81 +++ src/wlan.c | 885 ++++++++++++++++++++++++++ src/wlan.h | 36 ++ 31 files changed, 4828 insertions(+), 1 deletion(-) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/openwrt.config create mode 100644 Makefile create mode 100644 Makefile.standalone create mode 100644 files/openuf.conf create mode 100644 files/openuf.init create mode 100644 src/announce.c create mode 100644 src/announce.h create mode 100644 src/clients.c create mode 100644 src/clients.h create mode 100644 src/config.c create mode 100644 src/config.h create mode 100644 src/crypto.c create mode 100644 src/crypto.h create mode 100644 src/http.c create mode 100644 src/http.h create mode 100644 src/inform.c create mode 100644 src/inform.h create mode 100644 src/lldp.c create mode 100644 src/lldp.h create mode 100644 src/main.c create mode 100644 src/models.c create mode 100644 src/state.c create mode 100644 src/state.h create mode 100644 src/sysinfo.c create mode 100644 src/sysinfo.h create mode 100644 src/ufmodel.h create mode 100644 src/wlan.c create mode 100644 src/wlan.h diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..1d9df78 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,36 @@ +FROM debian:trixie + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + build-essential \ + clang \ + flex \ + bison \ + g++ \ + gawk \ + gettext \ + git \ + libncurses-dev \ + libssl-dev \ + python3 \ + rsync \ + unzip \ + zlib1g-dev \ + file \ + wget \ + patch \ + time \ + sudo \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN adduser openwrt --disabled-password --gecos "" && \ + echo "openwrt ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers + +USER openwrt +WORKDIR /home/openwrt + +RUN git clone https://github.com/openwrt/openwrt.git && \ + mkdir -p /home/openwrt/openwrt/package/OpenUniFi + +WORKDIR /home/openwrt/openwrt/package/OpenUniFi \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6bbd2de --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "OpenUniFi Development", + "build": { + "dockerfile": "Dockerfile" + }, + "remoteUser": "openwrt", + + // 1. Overrides the default volume mount path + "workspaceMount": "source=${localWorkspaceFolder},target=/home/openwrt/openwrt/package/OpenUniFi,type=bind,consistency=cached", + + // 2. Tells VS Code to open this directory when the container starts + "workspaceFolder": "/home/openwrt/openwrt/package/OpenUniFi", + + // 3. Runs OpenWrt configuration routines after mounting your package + "postCreateCommand": "cd /home/openwrt/openwrt && ./scripts/feeds update -a && ./scripts/feeds install -a && cp .devcontainer/openwrt.config .config || true && make defconfig" +} \ No newline at end of file diff --git a/.devcontainer/openwrt.config b/.devcontainer/openwrt.config new file mode 100644 index 0000000..5166500 --- /dev/null +++ b/.devcontainer/openwrt.config @@ -0,0 +1,5 @@ +CONFIG_TARGET_mpc85xx=y +CONFIG_TARGET_mpc85xx_p1020=y +CONFIG_TARGET_mpc85xx_p1020_DEVICE_hpe_msm460=y +CONFIG_PACKAGE_kmod-tun=m +CONFIG_PACKAGE_openuf=m \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..761cb0e --- /dev/null +++ b/Makefile @@ -0,0 +1,61 @@ +# openuf — OpenWrt SDK package Makefile +include $(TOPDIR)/rules.mk + +PKG_NAME := openuf +PKG_VERSION := 0.4.0 +PKG_RELEASE := 1 + +PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME) + +include $(INCLUDE_DIR)/package.mk + +define Package/openuf + SECTION := net + CATEGORY := Network + TITLE := openUF — UniFi bridge daemon for OpenWrt + DEPENDS := +libmbedtls +libuci +libjson-c +kmod-tun + URL := https://github.com/openuf/openuf +endef + +define Package/openuf/description + Emulates a UniFi U6 IW access point, allowing OpenWrt to be managed + by a UniFi Network controller. Supports adoption, WiFi config push + (band steering, fast roaming, WPA3, PMF), client reporting, CPU/RAM + stats, and LLDP topology. +endef + +define Build/Prepare + mkdir -p $(PKG_BUILD_DIR) + $(CP) ./src/* $(PKG_BUILD_DIR)/ +endef + +TARGET_CFLAGS += -I$(STAGING_DIR)/usr/include -DENABLE_LOGGING=1 +TARGET_LDFLAGS += -lmbedtls -lmbedcrypto -luci -ljson-c + +define Build/Compile + $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ + -o $(PKG_BUILD_DIR)/openuf \ + $(PKG_BUILD_DIR)/main.c \ + $(PKG_BUILD_DIR)/config.c \ + $(PKG_BUILD_DIR)/state.c \ + $(PKG_BUILD_DIR)/crypto.c \ + $(PKG_BUILD_DIR)/http.c \ + $(PKG_BUILD_DIR)/announce.c \ + $(PKG_BUILD_DIR)/inform.c \ + $(PKG_BUILD_DIR)/wlan.c \ + $(PKG_BUILD_DIR)/sysinfo.c \ + $(PKG_BUILD_DIR)/clients.c \ + $(PKG_BUILD_DIR)/lldp.c \ + $(PKG_BUILD_DIR)/models.c +endef + +define Package/openuf/install + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) $(PKG_BUILD_DIR)/openuf $(1)/usr/sbin/openuf + $(INSTALL_DIR) $(1)/etc/openuf + $(INSTALL_CONF) ./files/openuf.conf $(1)/etc/openuf/openuf.conf + $(INSTALL_DIR) $(1)/etc/init.d + $(INSTALL_BIN) ./files/openuf.init $(1)/etc/init.d/openuf +endef + +$(eval $(call BuildPackage,openuf)) diff --git a/Makefile.standalone b/Makefile.standalone new file mode 100644 index 0000000..07a0657 --- /dev/null +++ b/Makefile.standalone @@ -0,0 +1,45 @@ +# openuf — Makefile para compilar directamente en el dispositivo +# +# Requisitos: +# opkg install gcc make \ +# libmbedtls-dev libuci-dev libjson-c-dev \ +# lldpd (opcional, para leer vecinos LLDP) +# +# Uso: +# make -f Makefile.standalone +# make -f Makefile.standalone install + +CC = gcc +CFLAGS = -Wall -Wextra -O2 -I/usr/include -DENABLE_LOGGING=1 +LDFLAGS = -lmbedtls -lmbedcrypto -luci -ljson-c + +SRCS = src/main.c \ + src/config.c \ + src/state.c \ + src/crypto.c \ + src/http.c \ + src/announce.c \ + src/inform.c \ + src/wlan.c \ + src/sysinfo.c \ + src/clients.c \ + src/lldp.c \ + src/models.c + +TARGET = openuf + +all: $(TARGET) + +$(TARGET): $(SRCS) + $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) + +install: $(TARGET) + install -m 755 $(TARGET) /usr/sbin/openuf + [ -f /etc/openuf/openuf.conf ] || install -D -m 644 files/openuf.conf /etc/openuf/openuf.conf + install -m 755 files/openuf.init /etc/init.d/openuf +# /etc/init.d/openuf enable + +clean: + rm -f $(TARGET) + +.PHONY: all install clean diff --git a/README.md b/README.md index 722e800..5d24f6a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,93 @@ -# OpenUF +# openUF — C +Daemon that makes an OpenWrt router appear as a **UniFi U6 InWall** to the UniFi Network controller. + +## Implemented Features + +| Feature | Description | Implementation | +| --- | --- | --- | +| **L2 Discovery** | UDP broadcast + multicast every 10s | `announce.c` → port 10001 | +| **Adoption** | AES-128-CBC handshake with the controller | `inform.c` → `handle_response()` | +| **WiFi Config** | Creates WiFi networks from the controller via UCI | `wlan.c` → `wlan_apply_config()` | +| **Band Steering** | 802.11k/v Neighbor Reports + BSS Transition | `wlan.c` → `apply_vap()` | +| **Fast Roaming** | 802.11r FT with mobility_domain derived from MAC | `wlan.c` → `apply_vap()` | +| **WPA3 / PMF** | SAE, SAE-mixed, 802.11w 0/1/2 | `wlan.c` → `sec_to_uci()` | +| **WiFi Clients** | MAC, signal, bitrate, bytes per VAP | `clients.c` → `iw station dump` | +| **Wired Clients** | MACs from bridge FDB | `clients.c` → `bridge fdb` | +| **CPU / RAM** | Real-time usage | `sysinfo.c` → `/proc/stat` + `/proc/meminfo` | +| **Interfaces** | Speed, duplex, rx/tx counters | `sysinfo.c` → `/proc/net/dev` | +| **Channel / RF** | Channel utilization, noise, tx_power | `sysinfo.c` → `iw survey dump` | +| **LLDP Send** | Custom frames via AF_PACKET raw socket | `lldp.c` → `lldp_send_frame()` | +| **LLDP Read** | Neighbors for UniFi topology | `lldp.c` → `lldpctl -f json` | + +## Quick Installation + +```sh +# On the OpenWrt device: +opkg update +opkg install gcc make libmbedtls-dev libuci-dev libjson-c-dev + +# Compile and install +make -f Makefile.standalone install + +# Configure +vi /etc/openuf/openuf.conf # adjust controller_ip and lan_if + +# Start +/etc/init.d/openuf start +/etc/init.d/openuf enable # start on boot + +``` + +## Configuration + +```ini +controller_ip = 192.168.1.1 # UniFi controller IP +lan_if = br-lan # LAN interface (for MAC and IP) +ufmodel = u6-inwall # emulated model +inform_interval = 10 # seconds between informs +enable_announce = 1 +enable_inform = 1 + +``` + +## U6 InWall Model + +A **U6 IW** is emulated because it has 5 GbE ports, which covers most OpenWrt routers. The model reports: + +* 5 ethernet ports (eth0-eth4) +* 2.4 GHz WiFi 6 Radio (HE/802.11ax) +* 5 GHz WiFi 6 Radio (HE/802.11ax) + +## LLDP + +For visual topology in UniFi: + +```sh +opkg install lldpd +/etc/init.d/lldpd start +/etc/init.d/lldpd enable + +``` + +openuf sends its own LLDP frames even without lldpd (raw socket). +With lldpd installed, it also reports upstream neighbors (switches). + +## Adoption + +The process is automatic: + +1. The AP appears as "Pending" in UniFi. +2. Click on "Adopt" → the controller sends a new key. +3. The AP applies the key and becomes "Connected". +4. The controller pushes the WiFi configuration (SSIDs, channels, etc.). + +To reset: `rm /etc/openuf/state.json && reboot` + +## Dependencies + +```sh +opkg install libmbedtls libuci libjson-c +opkg install lldpd # optional, for topology + +``` \ No newline at end of file diff --git a/files/openuf.conf b/files/openuf.conf new file mode 100644 index 0000000..ee77d86 --- /dev/null +++ b/files/openuf.conf @@ -0,0 +1,17 @@ +# openuf — configuration +# +# controller_ip: UniFi controller IP address or hostname +# lan_if: primary LAN interface (used for the AP MAC and IP) +# ufmodel: emulated model (u6-inwall recommended) +# inform_interval: seconds between inform requests (minimum 5) +# enable_announce: 1=enable UDP discovery (port 10001) +# enable_inform: 1=enable HTTP inform requests (adoption and telemetry) +# enable_logging: 1=enable logging to /var/log/openuf.log + +controller_ip = 10.10.10.1 +lan_if = br-lan +ufmodel = uapg2-ac-lr +inform_interval = 10 +enable_announce = 1 +enable_inform = 1 +enable_logging = 1 diff --git a/files/openuf.init b/files/openuf.init new file mode 100644 index 0000000..4c8ee86 --- /dev/null +++ b/files/openuf.init @@ -0,0 +1,27 @@ +#!/bin/sh /etc/rc.common +# openuf init script (procd) + +USE_PROCD=1 +START=95 +STOP=10 + +CONF=/etc/openuf/openuf.conf +PROG=/usr/sbin/openuf + +start_service() { + procd_open_instance + procd_set_param command "$PROG" -c "$CONF" + procd_set_param respawn 3600 5 0 + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +stop_service() { + killall openuf 2>/dev/null +} + +reload_service() { + stop_service + start_service +} diff --git a/src/announce.c b/src/announce.c new file mode 100644 index 0000000..612cef0 --- /dev/null +++ b/src/announce.c @@ -0,0 +1,283 @@ +/* + * openuf - announce.c + * + * Implementa el protocolo de descubrimiento UDP de UniFi (puerto 10001). + * + * ── Destinos ───────────────────────────────────────────────────────── + * El protocolo especifica que los paquetes de anuncio se envían a DOS destinos: + * 1. Broadcast: 255.255.255.255:10001 + * 2. Multicast: 233.89.188.1:10001 ← requerido para redes con multicast + * + * El controlador UniFi escucha en ambas direcciones. + * Usar sólo broadcast puede fallar en redes donde el broadcast está filtrado. + * + * ── Formato del paquete ────────────────────────────────────────────── + * Header: [0x02][0x06][0x00][total_payload_len] (4 bytes fijos) + * TLVs: [type:1][len_hi:1][len_lo:1][value:len] + * + * ── Modelo U6 InWall ───────────────────────────────────────────────── + * Se emula este modelo específicamente porque: + * - Tiene 5 puertos GbE (eth0-eth4): cubre la mayoría de routers OpenWrt + * - Soporta WiFi 6 (802.11ax) en 2.4 GHz y 5 GHz + * - Tiene PoE passthrough (útil para redes de campus) + * - Es un modelo actual y bien soportado por el controlador + */ + +#include +#include +#include +#include +#include +#include +#include + +#include "announce.h" +#include "config.h" + +#ifdef ENABLE_LOGGING +#include +extern FILE *log_fp; +#define LOG(fmt, ...) do { if (log_fp) { fprintf(log_fp, "[%s] " fmt "\n", __func__, ##__VA_ARGS__); fflush(log_fp); } } while(0) +#else +#define LOG(fmt, ...) do {} while(0) +#endif + +/* ─── Packet type constants ─────────────────────────────────────── */ +#define PKT_TYPE_HW_ADDR 0x01 +#define PKT_TYPE_IP_ADDR 0x02 +#define PKT_TYPE_FWVER_VERBOSE 0x03 +#define PKT_TYPE_UPTIME 0x0a +#define PKT_TYPE_HOSTNAME 0x0b +#define PKT_TYPE_PLATFORM 0x0c +#define PKT_TYPE_INC_COUNTER 0x12 +#define PKT_TYPE_HW_ADDR2 0x13 +#define PKT_TYPE_PLATFORM2 0x15 +#define PKT_TYPE_FWVER_SHORT 0x16 +#define PKT_TYPE_FWVER_FACTORY 0x1b + +/* Fixed capability blob (types 0x17–0x1a) */ +static const unsigned char PKT_BLOB[] = { + 0x17, 0x00, 0x01, 0x01, + 0x18, 0x00, 0x01, 0x00, + 0x19, 0x00, 0x01, 0x01, + 0x1a, 0x00, 0x01, 0x00, +}; + +/* ─── TLV helpers ───────────────────────────────────────────────── */ +static int tlv_append(unsigned char *pkt, int pos, int max, + uint8_t type, const unsigned char *val, int vlen) +{ + if (pos + 3 + vlen > max) return pos; + pkt[pos++] = type; + pkt[pos++] = (vlen >> 8) & 0xff; + pkt[pos++] = vlen & 0xff; + memcpy(pkt + pos, val, vlen); + return pos + vlen; +} + +static int tlv_str(unsigned char *pkt, int pos, int max, + uint8_t type, const char *str) +{ + return tlv_append(pkt, pos, max, type, + (const unsigned char *)str, strlen(str)); +} + +static void put32be(unsigned char *p, uint32_t v) +{ + p[0] = (v >> 24) & 0xff; + p[1] = (v >> 16) & 0xff; + p[2] = (v >> 8) & 0xff; + p[3] = v & 0xff; +} + +/* ─── parse_mac ─────────────────────────────────────────────────── */ +static void parse_mac(const char *s, unsigned char out[6]) +{ + unsigned int b[6] = {0}; + sscanf(s, "%x:%x:%x:%x:%x:%x", + &b[0], &b[1], &b[2], &b[3], &b[4], &b[5]); + for (int i = 0; i < 6; i++) out[i] = (unsigned char)b[i]; +} + +static void parse_ip(const char *s, unsigned char out[4]) +{ + unsigned int b[4] = {0}; + sscanf(s, "%u.%u.%u.%u", &b[0], &b[1], &b[2], &b[3]); + for (int i = 0; i < 4; i++) out[i] = (unsigned char)b[i]; +} + +/* ─── announce_init ─────────────────────────────────────────────── */ +int announce_init(announce_ctx_t *ctx, + const uf_model_t *m, + const char *mac_str, + const char *ip_str) +{ + memset(ctx, 0, sizeof(*ctx)); + ctx->sockfd = -1; + ctx->sockfd_mcast = -1; + + unsigned char mac[6], ip[4]; + parse_mac(mac_str, mac); + parse_ip(ip_str, ip); + + unsigned char *p = ctx->pkt; + int pos = 0; + int max = (int)sizeof(ctx->pkt); + + /* Packet header: version=2, reserved=6, flags=0, len (filled later) */ + p[pos++] = 0x02; + p[pos++] = 0x06; + p[pos++] = 0x00; + p[pos++] = 0x00; /* total_payload_len – patched at end */ + + /* IP_ADDR TLV: mac(6) + ip(4) */ + { + unsigned char val[10]; + memcpy(val, mac, 6); + memcpy(val + 6, ip, 4); + pos = tlv_append(p, pos, max, PKT_TYPE_IP_ADDR, val, 10); + } + + /* HW_ADDR: mac */ + pos = tlv_append(p, pos, max, PKT_TYPE_HW_ADDR, mac, 6); + + /* UPTIME: 4-byte BE – record offset for patching */ + { + unsigned char u4[4] = {0, 0, 0, 10}; + ctx->uptime_offset = pos + 3; /* offset of the value bytes */ + pos = tlv_append(p, pos, max, PKT_TYPE_UPTIME, u4, 4); + } + + /* HOSTNAME */ + pos = tlv_str(p, pos, max, PKT_TYPE_HOSTNAME, m->display_name); + + /* PLATFORM */ + pos = tlv_str(p, pos, max, PKT_TYPE_PLATFORM, m->platform); + + /* FWVER_VERBOSE: "
-." */
+    {
+        char fwv[128];
+        snprintf(fwv, sizeof(fwv), "%s%s-openUF-%s.%s",
+                 m->fw_pre, m->fw_ver, OPENUF_VERSION, m->fw_buildtime);
+        pos = tlv_str(p, pos, max, PKT_TYPE_FWVER_VERBOSE, fwv);
+    }
+
+    /* FWVER_SHORT: "-openUF-" */
+    {
+        char fws[64];
+        snprintf(fws, sizeof(fws), "%s-openUF-%s", m->fw_ver, OPENUF_VERSION);
+        pos = tlv_str(p, pos, max, PKT_TYPE_FWVER_SHORT, fws);
+    }
+
+    /* PLATFORM2 */
+    pos = tlv_str(p, pos, max, PKT_TYPE_PLATFORM2, m->platform);
+
+    /* Capability blob */
+    memcpy(p + pos, PKT_BLOB, sizeof(PKT_BLOB));
+    pos += sizeof(PKT_BLOB);
+
+    /* HW_ADDR2: mac */
+    pos = tlv_append(p, pos, max, PKT_TYPE_HW_ADDR2, mac, 6);
+
+    /* INC_COUNTER: 4 bytes – record offset for patching */
+    {
+        unsigned char c4[4] = {0, 0, 0, 0};
+        ctx->ctr_offset = pos + 3;
+        pos = tlv_append(p, pos, max, PKT_TYPE_INC_COUNTER, c4, 4);
+    }
+
+    /* FWVER_FACTORY */
+    pos = tlv_str(p, pos, max, PKT_TYPE_FWVER_FACTORY, m->fw_factoryver);
+
+    /* Patch total payload length (byte 3 = total - 4 header bytes) */
+    p[3] = (unsigned char)((pos - 4) & 0xff);
+
+    ctx->pkt_len = pos;
+    ctx->counter = 0;
+    ctx->uptime  = 10;
+
+    /* ── Socket para broadcast 255.255.255.255 ─────────────────── */
+    ctx->sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+    if (ctx->sockfd < 0) {
+        perror("[openuf] announce socket");
+        return -1;
+    }
+    int on = 1;
+    setsockopt(ctx->sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on));
+    /* Bind a puerto efímero — OpenWrt no permite setpeername() a broadcast */
+    struct sockaddr_in bind_addr = {
+        .sin_family      = AF_INET,
+        .sin_addr.s_addr = INADDR_ANY,
+        .sin_port        = 0,
+    };
+    bind(ctx->sockfd, (struct sockaddr *)&bind_addr, sizeof(bind_addr));
+
+    /* ── Socket para multicast 233.89.188.1 ────────────────────── */
+    /* El controlador UniFi también escucha en este grupo multicast.
+     * Esto es necesario cuando broadcast está filtrado en la red. */
+    ctx->sockfd_mcast = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
+    if (ctx->sockfd_mcast >= 0) {
+        int ttl = 1; /* TTL=1: no cruzar router */
+        setsockopt(ctx->sockfd_mcast, IPPROTO_IP, IP_MULTICAST_TTL,
+                   &ttl, sizeof(ttl));
+        int loop = 0;
+        setsockopt(ctx->sockfd_mcast, IPPROTO_IP, IP_MULTICAST_LOOP,
+                   &loop, sizeof(loop));
+        bind(ctx->sockfd_mcast, (struct sockaddr *)&bind_addr, sizeof(bind_addr));
+    }
+
+    return 0;
+}
+
+/* ─── announce_send ─────────────────────────────────────────────── */
+int announce_send(announce_ctx_t *ctx)
+{
+    ctx->counter++;
+    ctx->uptime += 10;
+
+    /* Patch counter and uptime in the packet buffer */
+    put32be(ctx->pkt + ctx->ctr_offset,    ctx->counter);
+    put32be(ctx->pkt + ctx->uptime_offset, ctx->uptime);
+
+    int ret = 0;
+
+    /* ── Envío 1: Broadcast 255.255.255.255:10001 ─────────────── */
+    struct sockaddr_in dest_bcast = {
+        .sin_family      = AF_INET,
+        .sin_port        = htons(ANNOUNCE_PORT),
+        .sin_addr.s_addr = INADDR_BROADCAST,
+    };
+    if (sendto(ctx->sockfd, ctx->pkt, ctx->pkt_len, 0,
+               (struct sockaddr *)&dest_bcast, sizeof(dest_bcast)) < 0) {
+        perror("[openuf] announce sendto broadcast");
+        ret = -1;
+    }
+
+    /* ── Envío 2: Multicast 233.89.188.1:10001 ────────────────── */
+    if (ctx->sockfd_mcast >= 0) {
+        struct sockaddr_in dest_mcast = {
+            .sin_family = AF_INET,
+            .sin_port   = htons(ANNOUNCE_PORT),
+        };
+        inet_pton(AF_INET, "233.89.188.1", &dest_mcast.sin_addr);
+        if (sendto(ctx->sockfd_mcast, ctx->pkt, ctx->pkt_len, 0,
+                   (struct sockaddr *)&dest_mcast, sizeof(dest_mcast)) < 0) {
+            /* No es error crítico — algunos kernels no tienen ruta multicast */
+        }
+    }
+
+    return ret;
+}
+
+/* ─── announce_close ────────────────────────────────────────────── */
+void announce_close(announce_ctx_t *ctx)
+{
+    if (ctx->sockfd >= 0) {
+        close(ctx->sockfd);
+        ctx->sockfd = -1;
+    }
+    if (ctx->sockfd_mcast >= 0) {
+        close(ctx->sockfd_mcast);
+        ctx->sockfd_mcast = -1;
+    }
+}
diff --git a/src/announce.h b/src/announce.h
new file mode 100644
index 0000000..46bb04d
--- /dev/null
+++ b/src/announce.h
@@ -0,0 +1,34 @@
+#ifndef OPENUF_ANNOUNCE_H
+#define OPENUF_ANNOUNCE_H
+
+#include "ufmodel.h"
+
+/* Announce context – keeps mutable state between sends */
+typedef struct {
+    int            sockfd;       /* socket broadcast */
+    int            sockfd_mcast; /* socket multicast 233.89.188.1 */
+    unsigned char  pkt[512];
+    int            pkt_len;
+    int            ctr_offset;   /* byte offset of counter field in pkt */
+    int            uptime_offset;
+    uint32_t       counter;
+    uint32_t       uptime;
+} announce_ctx_t;
+
+/* Build the static part of the announce packet and open the UDP socket.
+ * mac_str  : "aa:bb:cc:dd:ee:ff"
+ * ip_str   : "192.168.1.x"
+ * Returns 0 on success. */
+int  announce_init(announce_ctx_t *ctx,
+                   const uf_model_t *model,
+                   const char *mac_str,
+                   const char *ip_str);
+
+/* Send one announce burst to 255.255.255.255:10001.
+ * Increments counter and uptime. Returns 0 on success. */
+int  announce_send(announce_ctx_t *ctx);
+
+/* Close the socket */
+void announce_close(announce_ctx_t *ctx);
+
+#endif /* OPENUF_ANNOUNCE_H */
diff --git a/src/clients.c b/src/clients.c
new file mode 100644
index 0000000..59e25a6
--- /dev/null
+++ b/src/clients.c
@@ -0,0 +1,275 @@
+/*
+ * openuf - clients.c
+ *
+ * Enumera clientes para el payload inform → sta_table.
+ *
+ * ── Parseo de iw dev station dump ───────────────────────────────────
+ *
+ * La salida tiene bloques por cliente:
+ *
+ *   Station aa:bb:cc:dd:ee:ff (on wlan0)
+ *     inactive time:   120 ms
+ *     rx bytes:        2000000
+ *     rx packets:      2000
+ *     tx bytes:        5000000
+ *     tx packets:      5000
+ *     signal:          -62 [-62, -65] dBm
+ *     tx bitrate:      144.4 MBit/s MCS 15
+ *     rx bitrate:      108.0 MBit/s
+ *     connected time:  1800 seconds
+ *
+ * Detectamos el inicio de cada cliente con "Station XX:XX:..." y
+ * rellenamos los campos hasta encontrar el siguiente cliente.
+ *
+ * ── ARP: /proc/net/arp ─────────────────────────────────────────────
+ *
+ *   IP           HW type  Flags   HW addr            Mask  Device
+ *   192.168.1.x  0x1      0x2     aa:bb:cc:dd:ee:ff  *     br-lan
+ *
+ *   Flags 0x2 = entrada completa (reachable).
+ *   Flags 0x0 = incompleta (no responde ARP), ignorar.
+ */
+
+#define _GNU_SOURCE
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "clients.h"
+
+/* ─── Normalizar MAC a minúsculas ─────────────────────────────────── */
+static void mac_lower(const char *src, char *dst, size_t sz)
+{
+    for (size_t i = 0; src[i] && i < sz-1; i++)
+        dst[i] = tolower((unsigned char)src[i]);
+    dst[strlen(src) < sz ? strlen(src) : sz-1] = '\0';
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   /proc/net/arp — MAC → IP
+   ═══════════════════════════════════════════════════════════════════ */
+int clients_mac_to_ip(const char *mac, char *ip_out, size_t sz)
+{
+    ip_out[0] = '\0';
+    FILE *f = fopen("/proc/net/arp", "r");
+    if (!f) return -1;
+
+    char line[256];
+    fgets(line, sizeof(line), f); /* skip header */
+
+    char ml[32] = {0};
+    mac_lower(mac, ml, sizeof(ml));
+
+    while (fgets(line, sizeof(line), f)) {
+        char ip[64], hw_type[16], flags[16], hw[32], mask[16], dev[32];
+        if (sscanf(line, "%63s %15s %15s %31s %15s %31s",
+                   ip, hw_type, flags, hw, mask, dev) != 6) continue;
+        if (strcmp(flags, "0x2") != 0) continue;
+        char hl[32] = {0};
+        mac_lower(hw, hl, sizeof(hl));
+        if (strcmp(ml, hl) == 0) {
+            strncpy(ip_out, ip, sz-1);
+            fclose(f); return 0;
+        }
+    }
+    fclose(f);
+    return -1;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   /tmp/dhcp.leases — MAC → hostname
+   ═══════════════════════════════════════════════════════════════════ */
+int clients_mac_to_hostname(const char *mac, char *out, size_t sz)
+{
+    out[0] = '\0';
+    static const char *files[] = {
+        "/tmp/dhcp.leases",
+        "/var/lib/misc/dnsmasq.leases",
+        NULL
+    };
+
+    char ml[32] = {0};
+    mac_lower(mac, ml, sizeof(ml));
+
+    for (int fi = 0; files[fi]; fi++) {
+        FILE *f = fopen(files[fi], "r");
+        if (!f) continue;
+        char line[256];
+        while (fgets(line, sizeof(line), f)) {
+            long ts;
+            char lm[32], lip[64], lh[64], lcid[64];
+            if (sscanf(line, "%ld %31s %63s %63s %63s",
+                       &ts, lm, lip, lh, lcid) < 4) continue;
+            char ll[32] = {0};
+            mac_lower(lm, ll, sizeof(ll));
+            if (strcmp(ml, ll) == 0 && strcmp(lh, "*") != 0) {
+                strncpy(out, lh, sz-1);
+                fclose(f); return 0;
+            }
+        }
+        fclose(f);
+    }
+    return -1;
+}
+
+/* ─── Parsear tasa de bits "144.4 MBit/s ..." → kbps ───────────── */
+static long parse_rate_kbps(const char *s)
+{
+    float r = 0;
+    sscanf(s, "%f MBit/s", &r);
+    return (long)(r * 1000.0f);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   iw dev  station dump → array sta_info_t
+   ═══════════════════════════════════════════════════════════════════ */
+int clients_read_wifi(const char *wlan_iface,
+                      const char *radio_band,
+                      int         channel,
+                      sta_info_t *out,
+                      int         max_out)
+{
+    char cmd[128];
+    snprintf(cmd, sizeof(cmd),
+             "iw dev %s station dump 2>/dev/null", wlan_iface);
+    FILE *p = popen(cmd, "r");
+    if (!p) return 0;
+
+    int count = 0;
+    sta_info_t *cur = NULL;
+    char line[256];
+
+    while (fgets(line, sizeof(line), p)) {
+        line[strcspn(line, "\r\n")] = '\0';
+
+        /* ── Nueva estación ──────────────────────────────────────── */
+        char mac[32], on_iface[32];
+        if (sscanf(line, "Station %31s (on %31[^)])", mac, on_iface) == 2) {
+            if (count >= max_out) break;
+            cur = &out[count++];
+            memset(cur, 0, sizeof(*cur));
+            strncpy(cur->mac,      mac,        sizeof(cur->mac)-1);
+            strncpy(cur->vap_name, wlan_iface, sizeof(cur->vap_name)-1);
+            strncpy(cur->radio,    radio_band, sizeof(cur->radio)-1);
+            cur->channel = channel;
+            cur->noise   = -95;
+            continue;
+        }
+        if (!cur) continue;
+
+        /* ── Contadores ──────────────────────────────────────────── */
+        long long llv;
+        if (sscanf(line, " rx bytes: %lld", &llv) == 1) { cur->rx_bytes   = llv; continue; }
+        if (sscanf(line, " tx bytes: %lld", &llv) == 1) { cur->tx_bytes   = llv; continue; }
+        if (sscanf(line, " rx packets: %lld", &llv) == 1) { cur->rx_packets = llv; continue; }
+        if (sscanf(line, " tx packets: %lld", &llv) == 1) { cur->tx_packets = llv; continue; }
+
+        /* ── Señal ───────────────────────────────────────────────── */
+        int sig;
+        if (sscanf(line, " signal: %d", &sig) == 1) { cur->signal = sig; continue; }
+
+        /* ── Bitrate ─────────────────────────────────────────────── */
+        char rest[128];
+        if (sscanf(line, " tx bitrate: %127[^\n]", rest) == 1) {
+            cur->tx_rate = parse_rate_kbps(rest); continue;
+        }
+        if (sscanf(line, " rx bitrate: %127[^\n]", rest) == 1) {
+            cur->rx_rate = parse_rate_kbps(rest); continue;
+        }
+
+        /* ── Tiempo conectado ────────────────────────────────────── */
+        int upt;
+        if (sscanf(line, " connected time: %d seconds", &upt) == 1) {
+            cur->uptime = upt; continue;
+        }
+    }
+    pclose(p);
+
+    /* ── Enriquecer: IP, hostname, rssi, CCQ ─────────────────────── */
+    for (int i = 0; i < count; i++) {
+        sta_info_t *s = &out[i];
+        clients_mac_to_ip(s->mac, s->ip, sizeof(s->ip));
+        clients_mac_to_hostname(s->mac, s->hostname, sizeof(s->hostname));
+        if (!s->hostname[0])
+            strncpy(s->hostname, s->mac, sizeof(s->hostname)-1);
+
+        /* RSN = SNR estimado (signal - noise) */
+        s->rssi = s->signal - s->noise;
+        if (s->rssi < 0) s->rssi = 0;
+
+        /* CCQ: métrica 0-1000
+         * -50 dBm → 1000 (excelente)
+         * -90 dBm → 0    (muy malo)
+         * fórmula lineal: (signal + 90) * 25, limitado 0-1000 */
+        int ccq = (s->signal + 90) * 25;
+        s->ccq = (ccq < 0) ? 0 : (ccq > 1000) ? 1000 : ccq;
+    }
+    return count;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Construir JSON sta_table para un VAP
+   ═══════════════════════════════════════════════════════════════════
+
+   El JSON array resultante se anida dentro de vap_table[i].sta_table
+   en el payload inform. Ejemplo de entrada:
+   {
+     "mac": "aa:bb:cc:dd:ee:ff",
+     "ip": "192.168.1.100",
+     "hostname": "mi-movil",
+     "signal": -62,
+     "rssi": 33,
+     "noise": -95,
+     "tx_rate": 144000,
+     "rx_rate": 108000,
+     "tx_bytes": 5000000,
+     "rx_bytes": 2000000,
+     "tx_packets": 5000,
+     "rx_packets": 2000,
+     "uptime": 1800,
+     "radio": "ng",
+     "channel": 6,
+     "vap_name": "ath0",
+     "is_11r": false,
+     "ccq": 700
+   }
+*/
+struct json_object *clients_build_sta_table(const char *wlan_iface,
+                                            const char *radio_band,
+                                            int         channel,
+                                            const char *vap_name)
+{
+    sta_info_t stas[MAX_STA];
+    int n = clients_read_wifi(wlan_iface, radio_band, channel,
+                              stas, MAX_STA);
+
+    struct json_object *arr = json_object_new_array();
+    for (int i = 0; i < n; i++) {
+        sta_info_t *s = &stas[i];
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "mac",        json_object_new_string(s->mac));
+        json_object_object_add(o, "ip",         json_object_new_string(s->ip));
+        json_object_object_add(o, "hostname",   json_object_new_string(s->hostname));
+        json_object_object_add(o, "signal",     json_object_new_int(s->signal));
+        json_object_object_add(o, "rssi",       json_object_new_int(s->rssi));
+        json_object_object_add(o, "noise",      json_object_new_int(s->noise));
+        json_object_object_add(o, "tx_rate",    json_object_new_int64(s->tx_rate));
+        json_object_object_add(o, "rx_rate",    json_object_new_int64(s->rx_rate));
+        json_object_object_add(o, "tx_bytes",   json_object_new_int64(s->tx_bytes));
+        json_object_object_add(o, "rx_bytes",   json_object_new_int64(s->rx_bytes));
+        json_object_object_add(o, "tx_packets", json_object_new_int64(s->tx_packets));
+        json_object_object_add(o, "rx_packets", json_object_new_int64(s->rx_packets));
+        json_object_object_add(o, "uptime",     json_object_new_int(s->uptime));
+        json_object_object_add(o, "radio",      json_object_new_string(s->radio));
+        json_object_object_add(o, "channel",    json_object_new_int(s->channel));
+        json_object_object_add(o, "vap_name",   json_object_new_string(
+            vap_name ? vap_name : wlan_iface));
+        json_object_object_add(o, "is_11r",     json_object_new_boolean(s->is_11r));
+        json_object_object_add(o, "ccq",        json_object_new_int(s->ccq));
+        json_object_object_add(o, "idletime",   json_object_new_int(0));
+        json_object_array_add(arr, o);
+    }
+    return arr;
+}
diff --git a/src/clients.h b/src/clients.h
new file mode 100644
index 0000000..ea6684e
--- /dev/null
+++ b/src/clients.h
@@ -0,0 +1,83 @@
+#ifndef OPENUF_CLIENTS_H
+#define OPENUF_CLIENTS_H
+
+/*
+ * openuf - clients.h
+ *
+ * Enumera clientes conectados (WiFi y ethernet) para el sta_table
+ * del payload inform.
+ *
+ * ── WiFi: iw dev  station dump ────────────────────────────
+ *
+ *   Por cada cliente asociado devuelve:
+ *     MAC, señal (dBm), tx/rx bitrate (MBit/s), tx/rx bytes,
+ *     tx/rx packets, connected time (segundos)
+ *
+ * ── IP del cliente: /proc/net/arp ───────────────────────────────
+ *
+ *   Cruce MAC → IP. Solo entradas completas (flags=0x2).
+ *
+ * ── Hostname: /tmp/dhcp.leases (dnsmasq) ────────────────────────
+ *
+ *   Formato: timestamp MAC IP hostname client-id
+ *
+ * ── Ethernet: bridge fdb show ───────────────────────────────────
+ *
+ *   MACs dinámicas (no permanent, no multicast) en el bridge.
+ *
+ * ── CCQ (Client Connection Quality) ─────────────────────────────
+ *
+ *   Métrica 0-1000 basada en RSSI. El controlador la muestra
+ *   como barra de calidad de señal del cliente.
+ *     CCQ = clamp((signal + 90) * 25, 0, 1000)
+ */
+
+#include 
+#include 
+#include 
+
+#define MAX_STA 128
+
+typedef struct {
+    char      mac[32];
+    char      ip[64];
+    char      hostname[64];
+    int       signal;     /* RSSI dBm (negativo) */
+    int       noise;      /* dBm */
+    int       rssi;       /* SNR ≈ signal - noise */
+    long      tx_rate;    /* kbps */
+    long      rx_rate;
+    long long tx_bytes;
+    long long rx_bytes;
+    long long tx_packets;
+    long long rx_packets;
+    int       uptime;     /* segundos conectado */
+    char      radio[8];   /* "ng" / "na" / "6g" */
+    int       channel;
+    char      vap_name[32];
+    bool      is_11r;
+    int       ccq;
+    bool      is_wired;
+} sta_info_t;
+
+/* Lee clientes WiFi de una interfaz. Devuelve nº de clientes. */
+int clients_read_wifi(const char *wlan_iface,
+                      const char *radio_band,
+                      int         channel,
+                      sta_info_t *out,
+                      int         max_out);
+
+/* Construye JSON array sta_table para un VAP.
+ * El caller debe liberar con json_object_put(). */
+struct json_object *clients_build_sta_table(const char *wlan_iface,
+                                            const char *radio_band,
+                                            int         channel,
+                                            const char *vap_name);
+
+/* Busca IP en /proc/net/arp dado un MAC. */
+int clients_mac_to_ip(const char *mac, char *ip_out, size_t sz);
+
+/* Busca hostname en /tmp/dhcp.leases dado un MAC. */
+int clients_mac_to_hostname(const char *mac, char *out, size_t sz);
+
+#endif /* OPENUF_CLIENTS_H */
diff --git a/src/config.c b/src/config.c
new file mode 100644
index 0000000..f34d9bf
--- /dev/null
+++ b/src/config.c
@@ -0,0 +1,39 @@
+#include 
+#include 
+#include 
+#include "config.h"
+
+void config_load(openuf_config_t *cfg)
+{
+    /* Defaults */
+    strncpy(cfg->controller_ip,   DEFAULT_CONTROLLER_IP,   sizeof(cfg->controller_ip) - 1);
+    strncpy(cfg->lan_if,          DEFAULT_LAN_IF,          sizeof(cfg->lan_if) - 1);
+    strncpy(cfg->ufmodel,         DEFAULT_UFMODEL,         sizeof(cfg->ufmodel) - 1);
+    cfg->inform_interval = DEFAULT_INFORM_INTERVAL;
+    cfg->enable_announce = 1;
+    cfg->enable_inform   = 1;
+    cfg->enable_logging  = 1;
+
+    FILE *f = fopen(OPENUF_CONF_FILE, "r");
+    if (!f) return;
+
+    char line[256];
+    while (fgets(line, sizeof(line), f)) {
+        /* Strip newline */
+        line[strcspn(line, "\r\n")] = '\0';
+        /* Skip comments / empty */
+        if (line[0] == '#' || line[0] == '\0') continue;
+
+        char key[64] = {0}, val[192] = {0};
+        if (sscanf(line, " %63[^= ] = %191s", key, val) != 2) continue;
+
+        if      (!strcmp(key, "controller_ip"))    strncpy(cfg->controller_ip, val, sizeof(cfg->controller_ip) - 1);
+        else if (!strcmp(key, "lan_if"))           strncpy(cfg->lan_if,        val, sizeof(cfg->lan_if) - 1);
+        else if (!strcmp(key, "ufmodel"))          strncpy(cfg->ufmodel,       val, sizeof(cfg->ufmodel) - 1);
+        else if (!strcmp(key, "inform_interval"))  cfg->inform_interval = atoi(val);
+        else if (!strcmp(key, "enable_announce"))  cfg->enable_announce = atoi(val);
+        else if (!strcmp(key, "enable_inform"))    cfg->enable_inform   = atoi(val);
+        else if (!strcmp(key, "enable_logging"))   cfg->enable_logging = atoi(val);
+    }
+    fclose(f);
+}
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..e81fe49
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,44 @@
+#ifndef OPENUF_CONFIG_H
+#define OPENUF_CONFIG_H
+
+/* ─── Build-time defaults (override with /etc/openuf/openuf.conf) ─── */
+#define OPENUF_VERSION          "0.3-C"
+#define OPENUF_STATE_FILE       "/etc/openuf/state.json"
+#define OPENUF_CONF_FILE        "/etc/openuf/openuf.conf"
+
+#ifndef ENABLE_LOGGING
+#define ENABLE_LOGGING          1
+#endif
+#define DEFAULT_CONTROLLER_IP   "10.10.10.1"
+#define DEFAULT_LAN_IF          "br-lan"
+#define DEFAULT_UFMODEL         "uapg2-ac-lr"
+#define DEFAULT_INFORM_INTERVAL 10
+#define ANNOUNCE_INTERVAL       10
+#define ANNOUNCE_PORT           10001
+#define INFORM_PORT             8080
+#define INFORM_PATH             "/inform"
+#define DEFAULT_AUTH_KEY        "ba86f2bbe107c7c57eb5f2690775c712"
+
+#if ENABLE_LOGGING
+#include 
+extern FILE *log_fp;
+#define LOG(fmt, ...) do { if (log_fp) { fprintf(log_fp, "[%s] " fmt "\n", __func__, ##__VA_ARGS__); fflush(log_fp); } } while(0)
+#else
+#define LOG(fmt, ...) do {} while(0)
+#endif
+
+typedef struct {
+    char controller_ip[64];
+    char lan_if[32];
+    char ufmodel[32];          /* "u6-inwall" | "u6-lite" */
+    int  inform_interval;
+    int  enable_announce;
+    int  enable_inform;
+    int  enable_logging;
+} openuf_config_t;
+
+/* Parse /etc/openuf/openuf.conf (simple key=value).
+ * Fills *cfg with defaults first, then overrides from file. */
+void config_load(openuf_config_t *cfg);
+
+#endif /* OPENUF_CONFIG_H */
diff --git a/src/crypto.c b/src/crypto.c
new file mode 100644
index 0000000..54a7603
--- /dev/null
+++ b/src/crypto.c
@@ -0,0 +1,162 @@
+#include 
+#include 
+#include 
+
+#include 
+#include 
+#include 
+#include 
+
+#include "crypto.h"
+
+/* ─── Hex / binary helpers ──────────────────────────────────────── */
+void crypto_hex2bin(const char *hex, unsigned char *bin, size_t bin_len)
+{
+    for (size_t i = 0; i < bin_len; i++) {
+        unsigned int b = 0;
+        sscanf(hex + i * 2, "%02x", &b);
+        bin[i] = (unsigned char)b;
+    }
+}
+
+void crypto_bin2hex(const unsigned char *bin, size_t bin_len, char *hex_out)
+{
+    for (size_t i = 0; i < bin_len; i++)
+        sprintf(hex_out + i * 2, "%02x", bin[i]);
+    hex_out[bin_len * 2] = '\0';
+}
+
+/* ─── Random hex ────────────────────────────────────────────────── */
+int crypto_random_hex(unsigned char *hex_out, int nbytes)
+{
+    mbedtls_entropy_context   entropy;
+    mbedtls_ctr_drbg_context  ctr_drbg;
+    unsigned char buf[64];
+    int ret;
+
+    if (nbytes > 64) return -1;
+
+    mbedtls_entropy_init(&entropy);
+    mbedtls_ctr_drbg_init(&ctr_drbg);
+
+    ret = mbedtls_ctr_drbg_seed(&ctr_drbg, mbedtls_entropy_func, &entropy,
+                                 (const unsigned char *)"openuf", 6);
+    if (ret != 0) goto out;
+
+    ret = mbedtls_ctr_drbg_random(&ctr_drbg, buf, nbytes);
+    if (ret != 0) goto out;
+
+    crypto_bin2hex(buf, nbytes, (char *)hex_out);
+    ret = 0;
+out:
+    mbedtls_ctr_drbg_free(&ctr_drbg);
+    mbedtls_entropy_free(&entropy);
+    return ret;
+}
+
+/* ─── AES-128-CBC encrypt (PKCS#7 padding) ──────────────────────── */
+int crypto_encrypt(const char *key_hex, const char *iv_hex,
+                   const unsigned char *in, size_t in_len,
+                   unsigned char *out)
+{
+    unsigned char key[16], iv[16];
+    crypto_hex2bin(key_hex, key, 16);
+    crypto_hex2bin(iv_hex,  iv,  16);
+
+    /* PKCS#7: pad to next 16-byte block */
+    size_t pad    = 16 - (in_len % 16);
+    size_t padded = in_len + pad;
+
+    unsigned char *tmp = malloc(padded);
+    if (!tmp) return -1;
+    memcpy(tmp, in, in_len);
+    memset(tmp + in_len, (unsigned char)pad, pad);
+
+    mbedtls_aes_context ctx;
+    mbedtls_aes_init(&ctx);
+    if (mbedtls_aes_setkey_enc(&ctx, key, 128) != 0) {
+        mbedtls_aes_free(&ctx); free(tmp); return -1;
+    }
+
+    /* iv is modified in place by CBC – use a copy */
+    unsigned char iv_copy[16];
+    memcpy(iv_copy, iv, 16);
+
+    int ret = mbedtls_aes_crypt_cbc(&ctx, MBEDTLS_AES_ENCRYPT,
+                                    padded, iv_copy, tmp, out);
+    mbedtls_aes_free(&ctx);
+    free(tmp);
+    return (ret == 0) ? (int)padded : -1;
+}
+
+/* ─── AES-128-CBC decrypt (PKCS#7 unpadding) ────────────────────── */
+int crypto_decrypt(const char *key_hex, const char *iv_hex,
+                   const unsigned char *in, size_t in_len,
+                   unsigned char *out)
+{
+    if (in_len == 0 || in_len % 16 != 0) return -1;
+
+    unsigned char key[16], iv[16];
+    crypto_hex2bin(key_hex, key, 16);
+    crypto_hex2bin(iv_hex,  iv,  16);
+
+    unsigned char iv_copy[16];
+    memcpy(iv_copy, iv, 16);
+
+    mbedtls_aes_context ctx;
+    mbedtls_aes_init(&ctx);
+    if (mbedtls_aes_setkey_dec(&ctx, key, 128) != 0) {
+        mbedtls_aes_free(&ctx); return -1;
+    }
+
+    int ret = mbedtls_aes_crypt_cbc(&ctx, MBEDTLS_AES_DECRYPT,
+                                    in_len, iv_copy, in, out);
+    mbedtls_aes_free(&ctx);
+    if (ret != 0) return -1;
+
+    /* Remove PKCS#7 padding */
+    unsigned char pad = out[in_len - 1];
+    if (pad == 0 || pad > 16) return -1;
+    return (int)(in_len - pad);
+}
+
+int crypto_gcm_encrypt(const char *key_hex, const char *iv_hex,
+                       const unsigned char *aad, size_t aad_len,
+                       const unsigned char *in, size_t in_len,
+                       unsigned char *out, unsigned char tag[16])
+{
+    unsigned char key[16], iv[16];
+    crypto_hex2bin(key_hex, key, sizeof(key));
+    crypto_hex2bin(iv_hex, iv, sizeof(iv));
+
+    mbedtls_gcm_context ctx;
+    mbedtls_gcm_init(&ctx);
+    int ret = mbedtls_gcm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES,
+                                 key, 128);
+    if (ret == 0)
+        ret = mbedtls_gcm_crypt_and_tag(&ctx, MBEDTLS_GCM_ENCRYPT,
+                                       in_len, iv, sizeof(iv),
+                                       aad, aad_len, in, out, 16, tag);
+    mbedtls_gcm_free(&ctx);
+    return ret == 0 ? (int)in_len : -1;
+}
+
+int crypto_gcm_decrypt(const char *key_hex, const char *iv_hex,
+                       const unsigned char *aad, size_t aad_len,
+                       const unsigned char *in, size_t in_len,
+                       const unsigned char tag[16], unsigned char *out)
+{
+    unsigned char key[16], iv[16];
+    crypto_hex2bin(key_hex, key, sizeof(key));
+    crypto_hex2bin(iv_hex, iv, sizeof(iv));
+
+    mbedtls_gcm_context ctx;
+    mbedtls_gcm_init(&ctx);
+    int ret = mbedtls_gcm_setkey(&ctx, MBEDTLS_CIPHER_ID_AES,
+                                 key, 128);
+    if (ret == 0)
+        ret = mbedtls_gcm_auth_decrypt(&ctx, in_len, iv, sizeof(iv),
+                                      aad, aad_len, tag, 16, in, out);
+    mbedtls_gcm_free(&ctx);
+    return ret == 0 ? (int)in_len : -1;
+}
diff --git a/src/crypto.h b/src/crypto.h
new file mode 100644
index 0000000..9f48c11
--- /dev/null
+++ b/src/crypto.h
@@ -0,0 +1,45 @@
+#ifndef OPENUF_CRYPTO_H
+#define OPENUF_CRYPTO_H
+
+#include 
+
+/*
+ * AES-128-CBC helpers using mbedTLS.
+ *
+ * All keys and IVs are passed as 32-char hex strings (16 bytes).
+ * All in/out buffers are raw binary.
+ */
+
+/* Generate random bytes, return as hex string.
+ * hex_out must be at least nbytes*2+1 bytes. */
+int  crypto_random_hex(unsigned char *hex_out, int nbytes);
+
+/* AES-128-CBC encrypt.
+ * out must be >= in_len + 16 (PKCS#7 padded to block boundary).
+ * Returns ciphertext length, or -1 on error. */
+int  crypto_encrypt(const char *key_hex, const char *iv_hex,
+                    const unsigned char *in, size_t in_len,
+                    unsigned char *out);
+
+/* AES-128-CBC decrypt.
+ * out must be >= in_len bytes.
+ * Returns plaintext length (PKCS#7 unpadded), or -1 on error. */
+int  crypto_decrypt(const char *key_hex, const char *iv_hex,
+                    const unsigned char *in, size_t in_len,
+                    unsigned char *out);
+
+/* AES-128-GCM with a 16-byte authentication tag. */
+int crypto_gcm_encrypt(const char *key_hex, const char *iv_hex,
+                       const unsigned char *aad, size_t aad_len,
+                       const unsigned char *in, size_t in_len,
+                       unsigned char *out, unsigned char tag[16]);
+int crypto_gcm_decrypt(const char *key_hex, const char *iv_hex,
+                       const unsigned char *aad, size_t aad_len,
+                       const unsigned char *in, size_t in_len,
+                       const unsigned char tag[16], unsigned char *out);
+
+/* Hex <-> binary conversions */
+void crypto_hex2bin(const char *hex, unsigned char *bin, size_t bin_len);
+void crypto_bin2hex(const unsigned char *bin, size_t bin_len, char *hex_out);
+
+#endif /* OPENUF_CRYPTO_H */
diff --git a/src/http.c b/src/http.c
new file mode 100644
index 0000000..3fa498a
--- /dev/null
+++ b/src/http.c
@@ -0,0 +1,156 @@
+/*
+ * openuf - http.c
+ *
+ * Tiny HTTP/1.0 POST over raw TCP.  Avoids libcurl dependency.
+ * Handles chunked responses by reading until connection close.
+ */
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "http.h"
+
+#define RECV_CHUNK 4096
+
+/* Parse "http://host:port/path" into components */
+static int parse_url(const char *url,
+                     char *host, size_t host_sz,
+                     int  *port,
+                     char *path, size_t path_sz)
+{
+    *port = 80;
+
+    /* skip "http://" */
+    const char *p = url;
+    if (strncmp(p, "http://", 7) == 0) p += 7;
+    else if (strncmp(p, "https://", 8) == 0) {
+        p += 8; *port = 443;
+    }
+
+    /* find end of host[:port] section */
+    const char *slash = strchr(p, '/');
+    size_t hp_len = slash ? (size_t)(slash - p) : strlen(p);
+
+    /* split host and port */
+    const char *colon = memchr(p, ':', hp_len);
+    if (colon) {
+        size_t hlen = (size_t)(colon - p);
+        if (hlen >= host_sz) return -1;
+        memcpy(host, p, hlen);
+        host[hlen] = '\0';
+        *port = atoi(colon + 1);
+    } else {
+        if (hp_len >= host_sz) return -1;
+        memcpy(host, p, hp_len);
+        host[hp_len] = '\0';
+    }
+
+    /* path */
+    if (slash)
+        snprintf(path, path_sz, "%s", slash);
+    else
+        snprintf(path, path_sz, "/");
+
+    return 0;
+}
+
+int http_post(const char *url,
+              const char *content_type,
+              const unsigned char *body, size_t body_len,
+              unsigned char **resp_out, size_t *resp_len)
+{
+    char host[128], path[256];
+    int  port;
+
+    *resp_out = NULL;
+    *resp_len = 0;
+
+    if (parse_url(url, host, sizeof(host), &port, path, sizeof(path)) != 0)
+        return -1;
+
+    /* Resolve host */
+    struct hostent *he = gethostbyname(host);
+    if (!he) return -1;
+
+    int fd = socket(AF_INET, SOCK_STREAM, 0);
+    if (fd < 0) return -1;
+
+    /* 10-second connect timeout */
+    struct timeval tv = { .tv_sec = 10, .tv_usec = 0 };
+    setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+    setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+
+    struct sockaddr_in sa = {
+        .sin_family = AF_INET,
+        .sin_port   = htons((uint16_t)port),
+    };
+    memcpy(&sa.sin_addr, he->h_addr_list[0], he->h_length);
+
+    if (connect(fd, (struct sockaddr *)&sa, sizeof(sa)) != 0) {
+        close(fd); return -1;
+    }
+
+    /* Build request */
+    char hdr[512];
+    int  hdr_len = snprintf(hdr, sizeof(hdr),
+        "POST %s HTTP/1.0\r\n"
+        "Host: %s:%d\r\n"
+        "Content-Type: %s\r\n"
+        "Content-Length: %zu\r\n"
+        "User-Agent: AirControl Agent v1.0\r\n"
+        "Connection: close\r\n"
+        "\r\n",
+        path, host, port, content_type, body_len);
+
+    if (write(fd, hdr, hdr_len) != hdr_len ||
+        write(fd, body, body_len) != (ssize_t)body_len) {
+        close(fd); return -1;
+    }
+
+    /* Read full response */
+    size_t  total = 0, cap = RECV_CHUNK;
+    unsigned char *buf = malloc(cap);
+    if (!buf) { close(fd); return -1; }
+
+    ssize_t n;
+    while ((n = read(fd, buf + total, cap - total)) > 0) {
+        total += n;
+        if (total >= cap) {
+            cap *= 2;
+            unsigned char *nb = realloc(buf, cap);
+            if (!nb) { free(buf); close(fd); return -1; }
+            buf = nb;
+        }
+    }
+    close(fd);
+
+    if (total < 12) { free(buf); return -1; }
+
+    /* Parse HTTP status line */
+    int status = 0;
+    sscanf((char *)buf, "HTTP/%*s %d", &status);
+
+    /* Find body (after \r\n\r\n) */
+    unsigned char *body_start = (unsigned char *)memmem(buf, total,
+                                                         "\r\n\r\n", 4);
+    if (!body_start) { free(buf); return status; }
+    body_start += 4;
+
+    size_t body_sz = total - (size_t)(body_start - buf);
+    *resp_out = malloc(body_sz + 1);
+    if (*resp_out) {
+        memcpy(*resp_out, body_start, body_sz);
+        (*resp_out)[body_sz] = '\0';
+        *resp_len = body_sz;
+    }
+    free(buf);
+    return status;
+}
diff --git a/src/http.h b/src/http.h
new file mode 100644
index 0000000..101b7bf
--- /dev/null
+++ b/src/http.h
@@ -0,0 +1,18 @@
+#ifndef OPENUF_HTTP_H
+#define OPENUF_HTTP_H
+
+#include 
+
+/*
+ * Minimal HTTP/1.0 POST client (raw TCP sockets, no libcurl).
+ *
+ * Posts 'body' of 'body_len' bytes to the given URL.
+ * Allocates *resp_out (caller must free) and sets *resp_len.
+ * Returns HTTP status code (200, etc.) or -1 on error.
+ */
+int http_post(const char *url,
+              const char *content_type,
+              const unsigned char *body, size_t body_len,
+              unsigned char **resp_out, size_t *resp_len);
+
+#endif /* OPENUF_HTTP_H */
diff --git a/src/inform.c b/src/inform.c
new file mode 100644
index 0000000..5d5ef7b
--- /dev/null
+++ b/src/inform.c
@@ -0,0 +1,1039 @@
+/*
+ * openuf - inform.c
+ *
+ * Protocolo Inform de UniFi — implementación completa.
+ *
+ * ── CÓMO FUNCIONA ────────────────────────────────────────────────────
+ *
+ * Cada 10 segundos el AP hace HTTP POST a http://:8080/inform
+ * con un paquete binario TNBU que contiene JSON cifrado con AES-128-CBC.
+ *
+ * El controlador responde con otro paquete TNBU. El AP descifra, parsea
+ * el JSON y ejecuta la acción (_type).
+ *
+ * ── PAQUETE BINARIO TNBU ─────────────────────────────────────────────
+ *
+ *   Offset  Bytes  Campo
+ *   ------  -----  -----
+ *   0       4      Magic "TNBU"
+ *   4       4      Versión paquete (=0), uint32 BE
+ *   8       6      MAC del AP
+ *   14      2      Flags: bit0=cifrado, bit1=zlib
+ *   16      16     IV de AES (cuando cifrado)
+ *   32      4      Versión de datos (=1), uint32 BE
+ *   36      4      Longitud del payload, uint32 BE
+ *   40      N      Payload JSON, cifrado con AES-128-CBC
+ *
+ * ── CÓMO SE LEEN LOS PARÁMETROS ──────────────────────────────────────
+ *
+ *   CPU:        sysinfo_cpu_percent()          → /proc/stat (delta 2 llamadas)
+ *   RAM:        sysinfo_mem()                  → /proc/meminfo
+ *   Interfaces: sysinfo_iface()                → /proc/net/dev + /sys/class/net/
+ *   Radios:     sysinfo_radio()                → iw dev  info + survey
+ *   VAPs UCI:   wlan_get_vap_table()           → libuci wireless.*
+ *   Clientes WiFi: clients_build_sta_table()   → iw dev  station dump
+ *   Clientes IP: clients_mac_to_ip()           → /proc/net/arp
+ *   Clientes nombre: clients_mac_to_hostname() → /tmp/dhcp.leases
+ *   LLDP vecinos: lldp_read_neighbors()        → lldpctl -f json
+ *
+ * ── CICLO DE ADOPCIÓN ────────────────────────────────────────────────
+ *
+ *   1. AP envía inform con key=DEFAULT, default=true, state=1
+ *   2. Controller responde: {_type:"cmd", cmd:"set-adopt",
+ *                            key:"nuevaclave32hex", uri:"http://..."}
+ *   3. AP guarda nueva clave + URL en state.json, adopted=true
+ *   4. AP envía inform con nueva clave, state=4, default=false
+ *   5. Controller responde: {_type:"setstate", radio_table:[...], vap_table:[...]}
+ *   6. AP aplica config WiFi via wlan_apply_config() → libuci → wifi reload
+ */
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "inform.h"
+#include "crypto.h"
+#include "http.h"
+#include "wlan.h"
+#include "state.h"
+#include "config.h"
+#include "sysinfo.h"
+#include "clients.h"
+#include "lldp.h"
+
+/* ─── Big-endian helpers ────────────────────────────────────────── */
+static void put32be(unsigned char *p, uint32_t v)
+{
+    p[0]=(v>>24)&0xff; p[1]=(v>>16)&0xff;
+    p[2]=(v>> 8)&0xff; p[3]=v&0xff;
+}
+static void put16be(unsigned char *p, uint16_t v)
+{
+    p[0]=(v>>8)&0xff; p[1]=v&0xff;
+}
+static uint32_t get32be(const unsigned char *p)
+{
+    return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)|
+           ((uint32_t)p[2]<<8)|(uint32_t)p[3];
+}
+static uint16_t get16be(const unsigned char *p)
+{
+    return ((uint16_t)p[0]<<8)|(uint16_t)p[1];
+}
+
+static int valid_authkey(const char *key)
+{
+    if (!key || strlen(key) != 32)
+        return 0;
+    for (size_t i = 0; i < 32; i++)
+        if (!isxdigit((unsigned char)key[i]))
+            return 0;
+    return 1;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   sys_stats — CPU y memoria del sistema
+   ═══════════════════════════════════════════════════════════════════
+   El controlador muestra CPU y RAM en la vista del dispositivo.
+   Leemos /proc/stat y /proc/meminfo directamente.
+*/
+static struct json_object *build_sys_stats(void)
+{
+    struct json_object *o = json_object_new_object();
+
+    mem_stats_t mem;
+    if (sysinfo_mem(&mem) == 0) {
+        long used_kb = mem.total_kb - mem.free_kb
+                       - mem.buffer_kb - mem.cached_kb;
+        if (used_kb < 0) used_kb = 0;
+        json_object_object_add(o, "mem_total",
+            json_object_new_int64(mem.total_kb * 1024LL));
+        json_object_object_add(o, "mem_used",
+            json_object_new_int64(used_kb * 1024LL));
+        json_object_object_add(o, "mem_buffer",
+            json_object_new_int64(mem.buffer_kb * 1024LL));
+    } else {
+        json_object_object_add(o, "mem_total",  json_object_new_int(0));
+        json_object_object_add(o, "mem_used",   json_object_new_int(0));
+        json_object_object_add(o, "mem_buffer", json_object_new_int(0));
+    }
+
+    /* CPU — delta respecto a llamada anterior (cada ~10s da buen promedio) */
+    json_object_object_add(o, "cpu",
+        json_object_new_int(sysinfo_cpu_percent()));
+
+    return o;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   if_table — estadísticas de interfaces de red
+   ═══════════════════════════════════════════════════════════════════
+   Reportamos todos los puertos ethernet del modelo.
+   Leemos /proc/net/dev para contadores y /sys/class/net//
+   para velocidad, duplex y estado del enlace.
+*/
+static struct json_object *build_if_table(const uf_model_t *m,
+                                          const openuf_state_t *st)
+{
+    struct json_object *arr = json_object_new_array();
+
+    for (int i = 0; i < m->port_table_len; i++) {
+        const char *ifname = m->port_table[i].ifname;
+        iface_stats_t stats;
+        sysinfo_iface(ifname, &stats);
+
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "name",
+            json_object_new_string(ifname));
+        json_object_object_add(o, "mac",
+            json_object_new_string(stats.mac[0] ? stats.mac : st->mac));
+        json_object_object_add(o, "ip",
+            json_object_new_string(stats.ip[0] ? stats.ip : st->ip));
+        json_object_object_add(o, "up",
+            json_object_new_boolean(stats.up));
+        json_object_object_add(o, "speed",
+            json_object_new_int(stats.speed > 0 ? stats.speed : 1000));
+        json_object_object_add(o, "full_duplex",
+            json_object_new_boolean(stats.full_duplex));
+        json_object_object_add(o, "num_port",
+            json_object_new_int(1));
+        json_object_object_add(o, "rx_bytes",
+            json_object_new_int64(stats.rx_bytes));
+        json_object_object_add(o, "tx_bytes",
+            json_object_new_int64(stats.tx_bytes));
+        json_object_object_add(o, "rx_packets",
+            json_object_new_int64(stats.rx_packets));
+        json_object_object_add(o, "tx_packets",
+            json_object_new_int64(stats.tx_packets));
+        json_object_object_add(o, "rx_errors",
+            json_object_new_int64(stats.rx_errors));
+        json_object_object_add(o, "tx_errors",
+            json_object_new_int64(stats.tx_errors));
+        json_object_object_add(o, "rx_dropped",
+            json_object_new_int64(stats.rx_dropped));
+        json_object_object_add(o, "tx_dropped",
+            json_object_new_int64(stats.tx_dropped));
+        json_object_object_add(o, "rx_multicast",
+            json_object_new_int64(stats.rx_multicast));
+        json_object_array_add(arr, o);
+    }
+    return arr;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   radio_table — definición estática del hardware de radio
+   ═══════════════════════════════════════════════════════════════════
+   Describe las capacidades físicas de cada radio al controlador.
+   El controlador usa esto para saber qué frecuencias y modos soporta.
+*/
+static void build_radio_table(struct json_object *root,
+                               const uf_model_t *m)
+{
+    struct json_object *arr = json_object_new_array();
+    for (int i = 0; i < m->radio_table_len; i++) {
+        const uf_radio_t *r = &m->radio_table[i];
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "name",          json_object_new_string(r->name));
+        json_object_object_add(o, "radio",         json_object_new_string(r->radio));
+        json_object_object_add(o, "channel",       json_object_new_int(r->channel));
+        json_object_object_add(o, "ht",            json_object_new_string(r->ht));
+        json_object_object_add(o, "min_txpower",   json_object_new_int(r->min_txpower));
+        json_object_object_add(o, "max_txpower",   json_object_new_int(r->max_txpower));
+        json_object_object_add(o, "nss",           json_object_new_int(r->nss));
+        json_object_object_add(o, "tx_power",      json_object_new_int(r->tx_power));
+        json_object_object_add(o, "radio_caps",    json_object_new_int(r->radio_caps));
+        json_object_object_add(o, "antenna_gain",  json_object_new_int(r->antenna_gain));
+        json_object_object_add(o, "he_enabled",    json_object_new_boolean(r->he_enabled));
+        json_object_object_add(o, "builtin_antenna",   json_object_new_boolean(true));
+        json_object_object_add(o, "builtin_ant_gain",  json_object_new_int(0));
+        json_object_array_add(arr, o);
+    }
+    json_object_object_add(root, "radio_table", arr);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   radio_table_stats — estadísticas dinámicas de canal
+   ═══════════════════════════════════════════════════════════════════
+   Leemos en tiempo real la utilización del canal con:
+     iw dev wlan0 survey dump    → active/busy/tx/rx time
+     iw dev wlan0 info           → canal actual, potencia
+   El controlador muestra estos datos en la vista de RF.
+*/
+static struct json_object *build_radio_table_stats(const uf_model_t *m)
+{
+    struct json_object *arr = json_object_new_array();
+
+    for (int i = 0; i < m->radio_map_len; i++) {
+        const uf_radio_map_t *rm = &m->radio_map[i];
+
+        /* Mapear "radio0" → "wlan0" por convención OpenWrt */
+        char wlan_iface[32];
+        int ridx = 0;
+        sscanf(rm->device, "radio%d", &ridx);
+        snprintf(wlan_iface, sizeof(wlan_iface), "wlan%d", ridx);
+
+        /* Nombre del radio en la tabla estática */
+        const char *radio_name = (i < m->radio_table_len)
+                                 ? m->radio_table[i].name : wlan_iface;
+        int default_ch = (i < m->radio_table_len)
+                         ? m->radio_table[i].channel : 6;
+        int default_pwr = (i < m->radio_table_len)
+                          ? m->radio_table[i].tx_power : 20;
+
+        radio_stats_t rs;
+        if (sysinfo_radio(wlan_iface, &rs) != 0) {
+            memset(&rs, 0, sizeof(rs));
+            rs.noise = -95;
+        }
+
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "name",
+            json_object_new_string(radio_name));
+        json_object_object_add(o, "channel",
+            json_object_new_int(rs.channel ? rs.channel : default_ch));
+        json_object_object_add(o, "tx_power",
+            json_object_new_int(rs.tx_power ? rs.tx_power : default_pwr));
+        json_object_object_add(o, "cu_self_tx",
+            json_object_new_int(rs.cu_self_tx));
+        json_object_object_add(o, "cu_self_rx",
+            json_object_new_int(rs.cu_self_rx));
+        json_object_object_add(o, "cu_total",
+            json_object_new_int(rs.cu_total));
+        json_object_object_add(o, "num_sta",
+            json_object_new_int(rs.num_sta));
+        json_object_object_add(o, "noise",
+            json_object_new_int(rs.noise));
+        json_object_array_add(arr, o);
+    }
+    return arr;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   port_table — estado real de los puertos ethernet
+   ═══════════════════════════════════════════════════════════════════
+   Leemos /sys/class/net//speed y operstate para
+   reflejar el estado real de cada puerto en el controlador.
+*/
+static void build_port_table(struct json_object *root,
+                              const uf_model_t *m)
+{
+    struct json_object *arr = json_object_new_array();
+    for (int i = 0; i < m->port_table_len; i++) {
+        const uf_port_t *pt = &m->port_table[i];
+        iface_stats_t stats;
+        sysinfo_iface(pt->ifname, &stats);
+
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "ifname",
+            json_object_new_string(pt->ifname));
+        json_object_object_add(o, "name",
+            json_object_new_string(pt->name));
+        json_object_object_add(o, "port_idx",
+            json_object_new_int(pt->port_idx));
+        json_object_object_add(o, "poe_caps",
+            json_object_new_int(pt->poe_caps));
+        json_object_object_add(o, "media",
+            json_object_new_string(pt->media));
+        json_object_object_add(o, "speed",
+            json_object_new_int(stats.speed > 0 ? stats.speed : pt->speed));
+        json_object_object_add(o, "up",
+            json_object_new_boolean(stats.up));
+        json_object_object_add(o, "is_uplink",
+            json_object_new_boolean(pt->is_uplink));
+        json_object_object_add(o, "full_duplex",
+            json_object_new_boolean(stats.full_duplex));
+        json_object_object_add(o, "rx_bytes",
+            json_object_new_int64(stats.rx_bytes));
+        json_object_object_add(o, "tx_bytes",
+            json_object_new_int64(stats.tx_bytes));
+        json_object_array_add(arr, o);
+    }
+    json_object_object_add(root, "port_table", arr);
+}
+
+static void build_eth_table(struct json_object *root, const uf_model_t *m)
+{
+    struct json_object *arr = json_object_new_array();
+    for (int i = 0; i < m->ethernet_table_len; i++) {
+        const uf_eth_entry_t *e = &m->ethernet_table[i];
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "name",     json_object_new_string(e->name));
+        json_object_object_add(o, "num_port", json_object_new_int(e->num_port));
+        json_object_array_add(arr, o);
+    }
+    json_object_object_add(root, "ethernet_table", arr);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   vap_table — VAPs activas con clientes conectados (sta_table)
+   ═══════════════════════════════════════════════════════════════════
+   Para cada VAP activa en UCI:
+   1. Leemos estadísticas de la interfaz wlan con sysinfo_iface()
+   2. Obtenemos el canal actual con sysinfo_radio()
+   3. Enumeramos clientes con clients_build_sta_table()
+      → iw dev wlan0 station dump (señal, bitrate, bytes, uptime)
+      → /proc/net/arp (MAC → IP)
+      → /tmp/dhcp.leases (MAC → hostname)
+
+   El sta_table anidado es lo que el controlador usa para:
+   - Mostrar clientes en el dashboard
+   - Calcular estadísticas por cliente
+   - Dibujar la topología de la red
+*/
+static struct json_object *build_vap_table(const uf_model_t *m)
+{
+    /* Obtener lista de VAPs desde UCI */
+    struct json_object *uci_vaps = wlan_get_vap_table(m);
+    int nvaps = json_object_array_length(uci_vaps);
+
+    struct json_object *arr = json_object_new_array();
+
+    for (int i = 0; i < nvaps; i++) {
+        struct json_object *vap = json_object_array_get_idx(uci_vaps, i);
+        struct json_object *v;
+
+        const char *essid    = "";
+        const char *vap_name = "";
+        const char *radio    = "ng";
+        const char *bssid    = "00:00:00:00:00:00";
+
+        if (json_object_object_get_ex(vap, "essid",  &v)) essid    = json_object_get_string(v);
+        if (json_object_object_get_ex(vap, "name",   &v)) vap_name = json_object_get_string(v);
+        if (json_object_object_get_ex(vap, "radio",  &v)) radio    = json_object_get_string(v);
+        if (json_object_object_get_ex(vap, "bssid",  &v)) bssid    = json_object_get_string(v);
+
+        /* Mapear banda → interfaz wlan y canal actual */
+        char wlan_iface[32] = "wlan0";
+        int  channel = 6;
+        for (int j = 0; j < m->radio_map_len; j++) {
+            if (strcmp(m->radio_map[j].band, radio) == 0) {
+                int idx = 0;
+                sscanf(m->radio_map[j].device, "radio%d", &idx);
+                snprintf(wlan_iface, sizeof(wlan_iface), "wlan%d", idx);
+                radio_stats_t rs;
+                if (sysinfo_radio(wlan_iface, &rs) == 0 && rs.channel)
+                    channel = rs.channel;
+                else if (idx < m->radio_table_len)
+                    channel = m->radio_table[idx].channel;
+                break;
+            }
+        }
+
+        /* Estadísticas de la interfaz inalámbrica */
+        iface_stats_t iface_st;
+        sysinfo_iface(wlan_iface, &iface_st);
+
+        /* Clientes conectados a esta VAP */
+        struct json_object *sta_tbl =
+            clients_build_sta_table(wlan_iface, radio, channel, vap_name);
+        int num_sta = json_object_array_length(sta_tbl);
+
+        /* Calcular tx_power del radio correspondiente */
+        int tx_pwr = 20;
+        radio_stats_t rs2;
+        if (sysinfo_radio(wlan_iface, &rs2) == 0 && rs2.tx_power)
+            tx_pwr = rs2.tx_power;
+
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "essid",
+            json_object_new_string(essid));
+        json_object_object_add(o, "bssid",
+            json_object_new_string(bssid));
+        json_object_object_add(o, "name",
+            json_object_new_string(vap_name));
+        json_object_object_add(o, "radio",
+            json_object_new_string(radio));
+        json_object_object_add(o, "up",
+            json_object_new_boolean(iface_st.up));
+        json_object_object_add(o, "channel",
+            json_object_new_int(channel));
+        json_object_object_add(o, "tx_power",
+            json_object_new_int(tx_pwr));
+        json_object_object_add(o, "num_sta",
+            json_object_new_int(num_sta));
+        json_object_object_add(o, "rx_bytes",
+            json_object_new_int64(iface_st.rx_bytes));
+        json_object_object_add(o, "tx_bytes",
+            json_object_new_int64(iface_st.tx_bytes));
+        json_object_object_add(o, "rx_packets",
+            json_object_new_int64(iface_st.rx_packets));
+        json_object_object_add(o, "tx_packets",
+            json_object_new_int64(iface_st.tx_packets));
+        json_object_object_add(o, "rx_errors",
+            json_object_new_int64(iface_st.rx_errors));
+        json_object_object_add(o, "tx_errors",
+            json_object_new_int64(iface_st.tx_errors));
+        json_object_object_add(o, "rx_dropped",
+            json_object_new_int64(iface_st.rx_dropped));
+        json_object_object_add(o, "tx_dropped",
+            json_object_new_int64(iface_st.tx_dropped));
+        json_object_object_add(o, "id",
+            json_object_new_string("user"));
+        json_object_object_add(o, "usage",
+            json_object_new_string("user"));
+        json_object_object_add(o, "ccq",
+            json_object_new_int(0));
+        /* sta_table anidado — clientes de ESTA VAP */
+        json_object_object_add(o, "sta_table", sta_tbl);
+
+        json_object_array_add(arr, o);
+    }
+    json_object_put(uci_vaps);
+    return arr;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   build_payload — ensamblado completo del JSON inform
+   ═══════════════════════════════════════════════════════════════════ */
+static char *build_payload(const openuf_state_t *st,
+                            const uf_model_t *m,
+                            long uptime)
+{
+    /* MAC sin colones → serial (uppercase) */
+    char mac_clean[32] = {0};
+    {
+        const char *s = st->mac; int j = 0;
+        for (int i = 0; s[i] && j < 12; i++)
+            if (s[i] != ':') {
+                char c = s[i];
+                if (c >= 'a' && c <= 'f') c -= 32;
+                mac_clean[j++] = c;
+            }
+    }
+
+    char fw_version[64];
+    snprintf(fw_version, sizeof(fw_version), "%s%s", m->fw_pre, m->fw_ver);
+
+    char inform_url_buf[256];
+    if (st->inform_url[0])
+        strncpy(inform_url_buf, st->inform_url, sizeof(inform_url_buf)-1);
+    else
+        snprintf(inform_url_buf, sizeof(inform_url_buf),
+                 "http://unifi:%d%s", INFORM_PORT, INFORM_PATH);
+
+    struct json_object *root = json_object_new_object();
+
+    /* ── Identidad del dispositivo ──────────────────────────────── */
+    json_object_object_add(root, "mac",
+        json_object_new_string(st->mac));
+    json_object_object_add(root, "serial",
+        json_object_new_string(mac_clean));
+    json_object_object_add(root, "model",
+        json_object_new_string(m->model));
+    json_object_object_add(root, "model_display",
+        json_object_new_string(m->model_display));
+    json_object_object_add(root, "display_name",
+        json_object_new_string(m->display_name));
+    json_object_object_add(root, "board_rev",
+        json_object_new_int(m->board_rev));
+    json_object_object_add(root, "version",
+        json_object_new_string(fw_version));
+    json_object_object_add(root, "bootrom_version",
+        json_object_new_string("openuf-v0.4"));
+    json_object_object_add(root, "required_version",
+        json_object_new_string("2.4.4"));
+    json_object_object_add(root, "ip",
+        json_object_new_string(st->ip));
+    json_object_object_add(root, "hostname",
+        json_object_new_string(st->hostname[0] ? st->hostname : m->display_name));
+    json_object_object_add(root, "inform_url",
+        json_object_new_string(inform_url_buf));
+    json_object_object_add(root, "uptime",
+        json_object_new_int64(uptime));
+    json_object_object_add(root, "time",
+        json_object_new_int64((long long)uptime));
+    json_object_object_add(root, "state",
+        json_object_new_int(st->adopted ? 4 : 1));
+    json_object_object_add(root, "default",
+        json_object_new_boolean(!st->adopted));
+    json_object_object_add(root, "cfgversion",
+        json_object_new_string(st->cfgversion));
+    json_object_object_add(root, "x_authkey",
+        json_object_new_string(st->adopted ? st->authkey : DEFAULT_AUTH_KEY));
+    json_object_object_add(root, "_default_key",
+        json_object_new_boolean(!st->adopted));
+    json_object_object_add(root, "has_eth1",
+        json_object_new_boolean(m->has_eth1));
+    json_object_object_add(root, "isolated",
+        json_object_new_boolean(false));
+    json_object_object_add(root, "locating",
+        json_object_new_boolean(false));
+    json_object_object_add(root, "uplink",
+        json_object_new_string("eth0"));
+    json_object_object_add(root, "country_code",
+        json_object_new_int(0));
+
+    /* ── CPU + RAM ──────────────────────────────────────────────── */
+    json_object_object_add(root, "sys_stats", build_sys_stats());
+
+    /* ── Interfaces ethernet con contadores reales ──────────────── */
+    json_object_object_add(root, "if_table", build_if_table(m, st));
+
+    /* ── Capacidades de radio (estático del modelo) ─────────────── */
+    build_radio_table(root, m);
+
+    /* ── Utilización de canal en tiempo real ────────────────────── */
+    json_object_object_add(root, "radio_table_stats",
+        build_radio_table_stats(m));
+
+    /* ── Puertos ethernet con estado real ───────────────────────── */
+    build_port_table(root, m);
+    build_eth_table(root, m);
+
+    /* ── VAPs con clientes WiFi (sta_table anidado) ─────────────── */
+    json_object_object_add(root, "vap_table", build_vap_table(m));
+
+    /* ── Vecinos LLDP para topología visual ─────────────────────── */
+    json_object_object_add(root, "lldp_table", lldp_read_neighbors());
+
+    /* Contadores globales */
+    json_object_object_add(root, "bytes_r",  json_object_new_int(0));
+    json_object_object_add(root, "bytes_d",  json_object_new_int(0));
+    json_object_object_add(root, "num_sta",  json_object_new_int(0));
+
+    const char *s = json_object_to_json_string(root);
+    
+    /* Log shows what authkey is actually in the payload */
+    LOG("Payload state=%d, default=%s, adopted=%d, cfgversion=%s, config_applied=%d, x_authkey=%.8s...",
+        st->adopted ? 4 : 1, 
+        !st->adopted ? "true" : "false",
+        st->adopted,
+        st->cfgversion,
+        st->config_applied,
+        st->authkey[0] ? st->authkey : "DEFAULT");
+    
+    char *copy = strdup(s);
+    json_object_put(root);
+    return copy;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Paquete binario TNBU
+   ═══════════════════════════════════════════════════════════════════ */
+static unsigned char *build_packet(const char *mac_hex,
+                                   const char *key_hex,
+                                   const char *payload,
+                                   int use_aes_gcm,
+                                   size_t *out_len)
+{
+    unsigned char iv_hex[33] = {0};
+    if (crypto_random_hex(iv_hex, 16) != 0) return NULL;
+
+    unsigned char mac_bin[6];
+    crypto_hex2bin(mac_hex, mac_bin, 6);
+
+    size_t pl_len = strlen(payload);
+    size_t body_len = use_aes_gcm
+                    ? pl_len + 16
+                    : pl_len + (16 - (pl_len % 16));
+    size_t pkt_len = 40 + body_len;
+    unsigned char *pkt = malloc(pkt_len);
+    if (!pkt) return NULL;
+
+    unsigned char *p = pkt;
+    memcpy(p, INFORM_MAGIC, 4);         p += 4;
+    put32be(p, INFORM_PKT_VERSION);     p += 4;
+    memcpy(p, mac_bin, 6);              p += 6;
+    put16be(p, INFORM_FLAG_ENCRYPTED |
+               (use_aes_gcm ? INFORM_FLAG_GCM : 0)); p += 2;
+
+    unsigned char iv_bin[16];
+    crypto_hex2bin((char *)iv_hex, iv_bin, 16);
+    memcpy(p, iv_bin, 16);              p += 16;
+    put32be(p, INFORM_DATA_VERSION);    p += 4;
+    put32be(p, (uint32_t)body_len);     p += 4;
+
+    int enc_len;
+    if (use_aes_gcm) {
+        unsigned char tag[16];
+        enc_len = crypto_gcm_encrypt(key_hex, (char *)iv_hex,
+                                     pkt, 40,
+                                     (const unsigned char *)payload, pl_len,
+                                     p, tag);
+        if (enc_len >= 0)
+            memcpy(p + enc_len, tag, sizeof(tag));
+    } else {
+        enc_len = crypto_encrypt(key_hex, (char *)iv_hex,
+                                 (const unsigned char *)payload, pl_len, p);
+    }
+    if (enc_len < 0) {
+        free(pkt);
+        return NULL;
+    }
+
+    *out_len = pkt_len;
+    return pkt;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Parsear respuesta binaria del controlador
+   ═══════════════════════════════════════════════════════════════════ */
+static char *parse_packet(const unsigned char *data, size_t data_len,
+                           const char *key_hex)
+{
+    if (data_len < 40) return NULL;
+    if (memcmp(data, INFORM_MAGIC, 4) != 0) return NULL;
+
+    uint16_t flags   = get16be(data + 14);
+    const unsigned char *iv_bin = data + 16;
+    uint32_t body_len = get32be(data + 36);
+    const unsigned char *body   = data + 40;
+
+    if (40 + body_len > data_len) return NULL;
+
+    if ((flags & INFORM_FLAG_GCM) != 0) {
+        if (body_len < 16) return NULL;
+        size_t cipher_len = body_len - 16;
+        char iv_hex[33];
+        crypto_bin2hex(iv_bin, 16, iv_hex);
+        unsigned char *plain = malloc(cipher_len + 1);
+        if (!plain) return NULL;
+        int pl = crypto_gcm_decrypt(key_hex, iv_hex, data, 40,
+                                    body, cipher_len, body + cipher_len,
+                                    plain);
+        if (pl < 0) { free(plain); return NULL; }
+        plain[pl] = '\0';
+        return (char *)plain;
+    }
+
+    if (flags & INFORM_FLAG_ENCRYPTED) {
+        char iv_hex[33];
+        crypto_bin2hex(iv_bin, 16, iv_hex);
+        unsigned char *plain = malloc(body_len + 1);
+        if (!plain) return NULL;
+        int pl = crypto_decrypt(key_hex, iv_hex, body, body_len, plain);
+        if (pl < 0) { free(plain); return NULL; }
+        plain[pl] = '\0';
+        return (char *)plain;
+    }
+
+    char *copy = malloc(body_len + 1);
+    if (!copy) return NULL;
+    memcpy(copy, body, body_len);
+    copy[body_len] = '\0';
+    return copy;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Procesar comando JSON del controlador
+   ═══════════════════════════════════════════════════════════════════
+
+   _type == "noop"     → no hacer nada
+   _type == "cmd"      → set-adopt / reboot / reset / locate
+   _type == "setstate" → aplicar radio_table + vap_table via UCI
+   _type == "setparam" → cambiar un parámetro individual
+*/
+static void handle_response(openuf_state_t *st,
+                             const uf_model_t *model,
+                             struct json_object *resp,
+                             char *action_out)
+{
+    struct json_object *v;
+    const char *type = "noop";
+    if (json_object_object_get_ex(resp, "_type", &v))
+        type = json_object_get_string(v);
+
+    LOG("Handling response type: %s", type);
+
+    /* ── noop ────────────────────────────────────────────────────── */
+    if (!strcmp(type, "noop")) {
+        strcpy(action_out, "noop");
+        return;
+    }
+
+    /* ── setparam ────────────────────────────────────────────────── */
+    if (!strcmp(type, "setparam")) {
+        int received_adoption_key = 0;
+        int applied_system_cfg = 0;
+
+        /* First parse mgmt_cfg used by modern controllers. */
+        if (json_object_object_get_ex(resp, "mgmt_cfg", &v)) {
+            const char *mgmt_cfg = json_object_get_string(v);
+            LOG("Parsing mgmt_cfg: %s", mgmt_cfg);
+            
+            /* Parse newline-separated key=value pairs. */
+            char cfg_copy[2048];
+            strncpy(cfg_copy, mgmt_cfg, sizeof(cfg_copy)-1);
+            cfg_copy[sizeof(cfg_copy)-1] = '\0';
+            
+            char *line = strtok(cfg_copy, "\n");
+            while (line) {
+                char *eq = strchr(line, '=');
+                if (eq) {
+                    *eq = '\0';
+                    const char *key = line;
+                    const char *val = eq + 1;
+                    
+                    if (!strcmp(key, "authkey"))
+                        LOG("mgmt_cfg param: authkey = %.8s...", val);
+                    else
+                        LOG("mgmt_cfg param: %s = %s", key, val);
+                    
+                    if (!strcmp(key, "authkey")) {
+                        if (valid_authkey(val) &&
+                            strcmp(st->authkey, val) != 0) {
+                            int replacing_key = st->authkey[0] &&
+                                strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0;
+                            strncpy(st->authkey, val,
+                                    sizeof(st->authkey)-1);
+                            st->authkey[sizeof(st->authkey)-1] = '\0';
+                            received_adoption_key = 1;
+                            LOG("%s device key from setparam",
+                                replacing_key ? "Replaced" : "Accepted");
+                        } else if (!valid_authkey(val)) {
+                            LOG("Ignoring invalid authkey from setparam");
+                        }
+                    } else if (!strcmp(key, "cfgversion")) {
+                        /*
+                         * This is the version the controller wants, not proof
+                         * that its setstate has been applied locally.
+                         */
+                        LOG("Controller requested cfgversion=%s; currently applied=%s",
+                            val, st->cfgversion);
+                    } else if (!strcmp(key, "use_aes_gcm")) {
+                        st->use_aes_gcm = !strcmp(val, "true") ||
+                                          !strcmp(val, "1");
+                        LOG("AES-GCM %s for subsequent inform packets",
+                            st->use_aes_gcm ? "enabled" : "disabled");
+                    } else if (!strcmp(key, "mgmt_url")) {
+                        /* Could save mgmt_url for future use */
+                    }
+                    /* Other management parameters are currently informational. */
+                }
+                line = strtok(NULL, "\n");
+            }
+        }
+
+        struct json_object *system_cfg_obj;
+        if (json_object_object_get_ex(resp, "system_cfg",
+                                      &system_cfg_obj)) {
+            const char *system_cfg = json_object_get_string(system_cfg_obj);
+            LOG("Applying legacy system_cfg, length=%zu",
+                strlen(system_cfg));
+            if (wlan_apply_system_cfg(system_cfg, model) == 0) {
+                applied_system_cfg = 1;
+                st->config_applied = true;
+                st->config_schema = OPENUF_CONFIG_SCHEMA;
+                if (json_object_object_get_ex(resp, "cfgversion", &v))
+                    snprintf(st->cfgversion, sizeof(st->cfgversion), "%s",
+                             json_object_get_string(v));
+                LOG("Legacy system_cfg applied successfully, cfgversion=%s",
+                    st->cfgversion);
+            } else {
+                st->config_applied = false;
+                strncpy(st->cfgversion, "0",
+                        sizeof(st->cfgversion) - 1);
+                LOG("Legacy system_cfg failed; requesting provisioning retry");
+            }
+        }
+        
+        /* Fall back to the direct key/value format used by older controllers. */
+        if (json_object_object_get_ex(resp, "key", &v)) {
+            const char *key = json_object_get_string(v);
+            struct json_object *val_o;
+            if (json_object_object_get_ex(resp, "value", &val_o)) {
+                const char *val = json_object_get_string(val_o);
+                LOG("setparam key=%s val=%s", key, val);
+                if (!strcmp(key, "inform_url"))
+                    strncpy(st->inform_url, val, sizeof(st->inform_url)-1);
+                else if (!strcmp(key, "authkey") && valid_authkey(val) &&
+                         strcmp(st->authkey, val) != 0) {
+                    int replacing_key = st->authkey[0] &&
+                        strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0;
+                    strncpy(st->authkey, val, sizeof(st->authkey)-1);
+                    st->authkey[sizeof(st->authkey)-1] = '\0';
+                    received_adoption_key = 1;
+                    LOG("%s device key from direct setparam",
+                        replacing_key ? "Replaced" : "Accepted");
+                }
+            }
+        }
+
+        /*
+         * Modern controllers complete adoption by returning the per-device
+         * key in setparam. Mark the device adopted before its next inform so
+         * both the payload and packet encryption switch to that key.
+         */
+        if (received_adoption_key) {
+            st->adopted = true;
+            LOG("Adoption completed through setparam; next inform will use the controller key");
+        }
+        
+        state_save(st);
+        LOG("State saved after setparam");
+        strcpy(action_out, applied_system_cfg ? "provisioned" :
+                           received_adoption_key ? "adopted" : "setparam");
+        return;
+    }
+
+    /* ── cmd ─────────────────────────────────────────────────────── */
+    if (!strcmp(type, "cmd")) {
+        const char *cmd = "";
+        if (json_object_object_get_ex(resp, "cmd", &v))
+            cmd = json_object_get_string(v);
+
+        if (!strcmp(cmd, "set-adopt") || !strcmp(cmd, "adopt")) {
+            if (json_object_object_get_ex(resp, "uri", &v))
+                strncpy(st->inform_url, json_object_get_string(v),
+                        sizeof(st->inform_url)-1);
+            if (json_object_object_get_ex(resp, "key", &v))
+                strncpy(st->authkey, json_object_get_string(v),
+                        sizeof(st->authkey)-1);
+            st->adopted = true;
+            state_save(st);
+            strcpy(action_out, "adopted");
+            LOG("Adopted successfully. Key: %.8s...", st->authkey);
+
+        } else if (!strcmp(cmd, "reboot")) {
+            strcpy(action_out, "reboot");
+            system("reboot &");
+
+        } else if (!strcmp(cmd, "reset")) {
+            strcpy(action_out, "reset");
+            system("rm -f " OPENUF_STATE_FILE);
+            system("reboot &");
+
+        } else if (!strcmp(cmd, "locate")) {
+            /* Parpadear LED — en OpenWrt: echo 1 > /sys/class/leds/.../trigger */
+            strcpy(action_out, "locate");
+        } else {
+            snprintf(action_out, 64, "cmd:%s", cmd);
+        }
+        return;
+    }
+
+    /* ── setstate — configuración WiFi del controlador ──────────── */
+    if (!strcmp(type, "setstate")) {
+        if (json_object_object_get_ex(resp, "cfgversion", &v))
+            snprintf(st->cfgversion, sizeof(st->cfgversion),
+                     "%s", json_object_get_string(v));
+
+        struct json_object *rt = NULL, *vt = NULL;
+        json_object_object_get_ex(resp, "radio_table", &rt);
+        json_object_object_get_ex(resp, "vap_table",   &vt);
+        int apply_ok = 0;
+        if (rt || vt) {
+            printf("[openuf] Applying controller WiFi configuration...\n");
+            apply_ok = wlan_apply_config(resp, model) == 0;
+        } else {
+            LOG("setstate contained neither radio_table nor vap_table");
+        }
+
+        st->config_applied = apply_ok;
+        if (apply_ok)
+            st->config_schema = OPENUF_CONFIG_SCHEMA;
+        if (!apply_ok) {
+            strncpy(st->cfgversion, "0", sizeof(st->cfgversion) - 1);
+            LOG("WiFi configuration failed; cfgversion reset so the controller retries");
+        }
+        state_save(st);
+        strcpy(action_out, apply_ok ? "setstate" : "setstate-failed");
+        return;
+    }
+
+    snprintf(action_out, 64, "unknown:%s", type);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   inform_send — función principal pública
+   ═══════════════════════════════════════════════════════════════════ */
+int inform_send(openuf_state_t *st,
+                const uf_model_t *model,
+                long uptime,
+                char *err_out)
+{
+    if (!st->inform_url[0]) {
+        LOG("No inform_url set");
+        strncpy(err_out, "no inform_url", 127);
+        return -1;
+    }
+
+    const char *key_hex = (st->authkey[0]) ? st->authkey : DEFAULT_AUTH_KEY;
+    
+    /* CRITICAL: When not adopted, ALWAYS use DEFAULT_AUTH_KEY */
+    if (!st->adopted && st->authkey[0] && strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0) {
+        LOG("WARNING: Device not adopted but has custom authkey! Using DEFAULT instead!");
+        key_hex = DEFAULT_AUTH_KEY;
+    }
+    
+    LOG("Sending inform: adopted=%d, authkey=%.8s..., inform_url=%s", 
+        st->adopted, key_hex, st->inform_url);
+
+    /* MAC sin colones */
+    char mac_hex[32] = {0};
+    {
+        const char *s = st->mac; int j = 0;
+        for (int i = 0; s[i] && j < 12; i++)
+            if (s[i] != ':') mac_hex[j++] = s[i];
+    }
+
+    char *payload = build_payload(st, model, uptime);
+    if (!payload) { strncpy(err_out, "build_payload OOM", 127); return -1; }
+
+    LOG("Built payload, length: %zu", strlen(payload));
+
+    unsigned char *resp_body = NULL;
+    size_t resp_len = 0;
+    int status = -1;
+    int selected_gcm = st->use_aes_gcm;
+
+    /*
+     * A controller remembers the negotiated cipher. If local state was
+     * created before use_aes_gcm was persisted, it rejects CBC with HTTP 400
+     * and cannot send another setparam. Retry once with the other cipher.
+     */
+    for (int attempt = 0; attempt < 2; attempt++) {
+        size_t pkt_len = 0;
+        unsigned char *pkt = build_packet(mac_hex, key_hex, payload,
+                                          selected_gcm, &pkt_len);
+        if (!pkt) {
+            free(payload);
+            strncpy(err_out, "build_packet failed", 127);
+            return -1;
+        }
+
+        LOG("Built packet, length: %zu, cipher: %s", pkt_len,
+            selected_gcm ? "AES-GCM" : "AES-CBC");
+
+        status = http_post(st->inform_url,
+                           "application/x-binary-data",
+                           pkt, pkt_len,
+                           &resp_body, &resp_len);
+        free(pkt);
+
+        LOG("HTTP POST to %s, status: %d, response length: %zu",
+            st->inform_url, status, resp_len);
+
+        if (status != 400 || !st->adopted || attempt != 0)
+            break;
+
+        free(resp_body);
+        resp_body = NULL;
+        resp_len = 0;
+        selected_gcm = !selected_gcm;
+        LOG("Controller rejected %s; retrying once with %s",
+            selected_gcm ? "AES-CBC" : "AES-GCM",
+            selected_gcm ? "AES-GCM" : "AES-CBC");
+    }
+    free(payload);
+
+    if (status < 0) {
+        snprintf(err_out, 127, "HTTP connect failed");
+        return -1;
+    }
+    if (status != 200) {
+        snprintf(err_out, 127, "HTTP %d", status);
+        free(resp_body);
+        return -1;
+    }
+
+    if (st->use_aes_gcm != selected_gcm) {
+        st->use_aes_gcm = selected_gcm;
+        state_save(st);
+        LOG("Recovered cipher state; persisted aes_gcm=%d",
+            st->use_aes_gcm);
+    }
+
+    if (!resp_body || resp_len == 0) { 
+        LOG("No response body");
+        free(resp_body); 
+        return 0; 
+    }
+
+    char *resp_json = parse_packet(resp_body, resp_len, key_hex);
+    free(resp_body);
+    if (!resp_json) { 
+        LOG("Failed to parse response packet");
+        snprintf(err_out, 127, "parse_packet failed"); 
+        return -1; 
+    }
+
+    LOG("Parsed response JSON, length=%zu", strlen(resp_json));
+
+    struct json_object *resp_obj = json_tokener_parse(resp_json);
+    free(resp_json);
+    if (!resp_obj) { 
+        LOG("Failed to parse JSON");
+        snprintf(err_out, 127, "JSON parse failed"); 
+        return -1; 
+    }
+
+    struct json_object *response_type;
+    if (json_object_object_get_ex(resp_obj, "_type", &response_type))
+        LOG("Parsed response type: %s",
+            json_object_get_string(response_type));
+
+    char action[64] = "noop";
+    handle_response(st, model, resp_obj, action);
+    json_object_put(resp_obj);
+
+    LOG("Response action: %s", action);
+
+    if (strcmp(action, "noop") != 0)
+        printf("[openuf] Action: %s\n", action);
+
+    return 0;
+}
diff --git a/src/inform.h b/src/inform.h
new file mode 100644
index 0000000..117dff7
--- /dev/null
+++ b/src/inform.h
@@ -0,0 +1,35 @@
+#ifndef OPENUF_INFORM_H
+#define OPENUF_INFORM_H
+
+#include "state.h"
+#include "ufmodel.h"
+
+/*
+ * UniFi Inform protocol constants
+ *
+ * Binary packet layout (big-endian):
+ *   [4]  Magic  "TNBU"
+ *   [4]  Packet version = 0
+ *   [6]  Device MAC
+ *   [2]  Flags  (0x0001 = encrypted)
+ *   [16] AES-CBC IV
+ *   [4]  Data version = 1
+ *   [4]  Payload length
+ *   [N]  AES-128-CBC encrypted JSON payload
+ */
+
+#define INFORM_MAGIC        "TNBU"
+#define INFORM_PKT_VERSION  0
+#define INFORM_DATA_VERSION 1
+#define INFORM_FLAG_ENCRYPTED 0x0001
+#define INFORM_FLAG_GCM       0x0008
+
+/* Send one inform cycle.
+ * Updates *st in place (adopted flag, auth key, inform_url, cfgversion).
+ * Returns 0 on success, -1 on error (sets err_out[0..127]). */
+int inform_send(openuf_state_t *st,
+                const uf_model_t *model,
+                long uptime,
+                char *err_out);
+
+#endif /* OPENUF_INFORM_H */
diff --git a/src/lldp.c b/src/lldp.c
new file mode 100644
index 0000000..ad2f59d
--- /dev/null
+++ b/src/lldp.c
@@ -0,0 +1,303 @@
+/*
+ * openuf - lldp.c
+ *
+ * LLDP completo: envío de frames propios + lectura de vecinos.
+ *
+ * ── Construcción del frame ────────────────────────────────────────
+ *
+ * Los TLVs LLDP tienen cabecera de 2 bytes:
+ *   bit 15..9  → tipo (7 bits)
+ *   bit 8..0   → longitud (9 bits, max 511 bytes)
+ *
+ *   uint16_t header_be = (type << 9) | (len & 0x1ff)
+ *
+ * Ejemplo: Chassis ID TLV (type=1), 7 bytes de valor:
+ *   header = (1 << 9) | 7 = 0x0207
+ *   → bytes: 0x02 0x07 [subtype=4] [MAC 6 bytes]
+ *
+ * ── Envío con AF_PACKET ───────────────────────────────────────────
+ *
+ *   1. socket(AF_PACKET, SOCK_RAW, htons(0x88cc))
+ *   2. ioctl(SIOCGIFINDEX) → ifindex
+ *   3. Construir frame completo en buffer
+ *   4. sendto() con sockaddr_ll
+ *
+ *   Sin CAP_NET_RAW (no root) → socket() devuelve EPERM.
+ *   Lo ignoramos silenciosamente (LLDP es opcional).
+ *
+ * ── Lectura de vecinos con lldpctl ───────────────────────────────
+ *
+ *   lldpctl -f json retorna:
+ *   {
+ *     "lldp": {
+ *       "interface": [
+ *         {
+ *           "name": "eth0",
+ *           "chassis": {
+ *             "id":   {"type":"mac", "value":"aa:bb:..."},
+ *             "name": {"value":"switch1"},
+ *             "descr":{"value":"Cisco Catalyst 2960"}
+ *           },
+ *           "port": {
+ *             "id":   {"type":"ifname", "value":"Gi1/0/3"},
+ *             "descr":{"value":"to-AP"}
+ *           }
+ *         }
+ *       ]
+ *     }
+ *   }
+ */
+
+#define _GNU_SOURCE
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "lldp.h"
+
+/* ─── Constantes ────────────────────────────────────────────────── */
+static const uint8_t LLDP_DST[6]  = {0x01,0x80,0xc2,0x00,0x00,0x0e};
+#define LLDP_ETHERTYPE   0x88cc
+#define CAP_WLAN_AP      0x0040
+
+/* ─── Escribir TLV en buffer ────────────────────────────────────── */
+static int tlv_write(uint8_t *buf, int pos, int maxlen,
+                     int type, const uint8_t *val, int vlen)
+{
+    if (pos + 2 + vlen > maxlen) return pos;
+    uint16_t hdr = (uint16_t)((type << 9) | (vlen & 0x1ff));
+    buf[pos++] = (hdr >> 8) & 0xff;
+    buf[pos++] =  hdr       & 0xff;
+    if (val && vlen > 0) { memcpy(buf+pos, val, vlen); pos += vlen; }
+    return pos;
+}
+
+static int tlv_str(uint8_t *buf, int pos, int maxlen,
+                   int type, const char *str)
+{
+    return tlv_write(buf, pos, maxlen, type,
+                     (const uint8_t*)str, (int)strlen(str));
+}
+
+/* ─── Parsear MAC "aa:bb:cc:dd:ee:ff" → bytes ──────────────────── */
+static void parse_mac(const char *s, uint8_t out[6])
+{
+    unsigned int b[6]={0};
+    sscanf(s,"%x:%x:%x:%x:%x:%x",&b[0],&b[1],&b[2],&b[3],&b[4],&b[5]);
+    for(int i=0;i<6;i++) out[i]=(uint8_t)b[i];
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   lldp_send_frame
+   ═══════════════════════════════════════════════════════════════════ */
+int lldp_send_frame(const char *ifname,
+                    const char *mac_str,
+                    const char *hostname,
+                    const char *model_desc,
+                    int ttl)
+{
+    /* Socket raw — requiere root */
+    int fd = socket(AF_PACKET, SOCK_RAW, htons(LLDP_ETHERTYPE));
+    if (fd < 0) return -1;   /* EPERM sin root → silencioso */
+
+    struct ifreq ifr;
+    memset(&ifr, 0, sizeof(ifr));
+    strncpy(ifr.ifr_name, ifname, IFNAMSIZ-1);
+    if (ioctl(fd, SIOCGIFINDEX, &ifr) < 0) { close(fd); return -1; }
+    int ifindex = ifr.ifr_ifindex;
+
+    uint8_t src[6];
+    parse_mac(mac_str, src);
+
+    uint8_t frame[1518];
+    int pos = 0;
+
+    /* Ethernet header */
+    memcpy(frame,   LLDP_DST, 6);  pos += 6;  /* dst */
+    memcpy(frame+6, src, 6);       pos += 6;  /* src */
+    frame[pos++] = 0x88;
+    frame[pos++] = 0xcc;           /* EtherType 0x88cc */
+
+    /* TLV: Chassis ID (type=1): subtype=4(MAC) + MAC */
+    {
+        uint8_t v[7]; v[0]=4; memcpy(v+1,src,6);
+        pos = tlv_write(frame, pos, sizeof(frame), 1, v, 7);
+    }
+
+    /* TLV: Port ID (type=2): subtype=5(ifname) + nombre */
+    {
+        size_t nlen = strlen(ifname);
+        uint8_t v[64]; v[0]=5; memcpy(v+1,ifname,nlen);
+        pos = tlv_write(frame, pos, sizeof(frame), 2, v, (int)nlen+1);
+    }
+
+    /* TLV: TTL (type=3): uint16 BE */
+    {
+        uint8_t v[2] = {(uint8_t)(ttl>>8),(uint8_t)(ttl&0xff)};
+        pos = tlv_write(frame, pos, sizeof(frame), 3, v, 2);
+    }
+
+    /* TLV: System Name (type=5) */
+    if (hostname && hostname[0])
+        pos = tlv_str(frame, pos, sizeof(frame), 5, hostname);
+
+    /* TLV: System Description (type=6) */
+    if (model_desc && model_desc[0])
+        pos = tlv_str(frame, pos, sizeof(frame), 6, model_desc);
+
+    /* TLV: System Capabilities (type=7): caps + enabled (WLAN AP) */
+    {
+        uint8_t v[4] = {
+            0x00, (uint8_t)(CAP_WLAN_AP >> 8),
+            0x00, (uint8_t)(CAP_WLAN_AP & 0xff)
+        };
+        /* Corregir: CAP_WLAN_AP = 0x0040, un solo byte basta */
+        v[1] = 0x00; v[0] = 0x00;
+        /* bit 6 de los 16 bits de capabilities */
+        uint16_t cap = CAP_WLAN_AP;
+        v[0] = (cap >> 8) & 0xff; v[1] = cap & 0xff;
+        v[2] = v[0]; v[3] = v[1]; /* enabled = same */
+        pos = tlv_write(frame, pos, sizeof(frame), 7, v, 4);
+    }
+
+    /* TLV: End (type=0, len=0) */
+    pos = tlv_write(frame, pos, sizeof(frame), 0, NULL, 0);
+
+    struct sockaddr_ll sa;
+    memset(&sa, 0, sizeof(sa));
+    sa.sll_family  = AF_PACKET;
+    sa.sll_ifindex = ifindex;
+    sa.sll_halen   = ETH_ALEN;
+    memcpy(sa.sll_addr, LLDP_DST, 6);
+
+    ssize_t sent = sendto(fd, frame, pos, 0,
+                          (struct sockaddr*)&sa, sizeof(sa));
+    close(fd);
+    return (sent > 0) ? 0 : -1;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   lldp_available
+   ═══════════════════════════════════════════════════════════════════ */
+bool lldp_available(void)
+{
+    return (access("/usr/sbin/lldpctl", X_OK) == 0 ||
+            access("/usr/bin/lldpctl", X_OK) == 0);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   lldp_read_neighbors — parsea JSON de lldpctl
+   ═══════════════════════════════════════════════════════════════════
+
+   Navega: root → "lldp" → "interface" (array) → cada vecino.
+   Por cada vecino extrae: chassis.id, chassis.name, chassis.descr,
+   port.id, port.descr, y el nombre de la interfaz local.
+
+   El resultado se incluye en lldp_table[] del payload inform.
+   El controlador lo usa para dibujar las líneas de conexión en
+   la topología visual (qué switch/puerto conecta a este AP).
+*/
+struct json_object *lldp_read_neighbors(void)
+{
+    struct json_object *result = json_object_new_array();
+    if (!lldp_available()) return result;
+
+    FILE *p = popen("lldpctl -f json 2>/dev/null", "r");
+    if (!p) return result;
+
+    /* Leer toda la salida (limitado a 16KB) */
+    char buf[16384] = {0};
+    size_t total = 0, n;
+    char tmp[1024];
+    while ((n = fread(tmp, 1, sizeof(tmp), p)) > 0 &&
+           total + n < sizeof(buf)-1) {
+        memcpy(buf+total, tmp, n); total += n;
+    }
+    pclose(p);
+    if (!total) return result;
+
+    struct json_object *root = json_tokener_parse(buf);
+    if (!root) return result;
+
+    /* Navegar: root.lldp.interface[] */
+    struct json_object *lldp_o, *iface_arr;
+    if (!json_object_object_get_ex(root, "lldp",      &lldp_o))  goto done;
+    if (!json_object_object_get_ex(lldp_o, "interface", &iface_arr)) goto done;
+    if (!json_object_is_type(iface_arr, json_type_array))            goto done;
+
+    int ni = json_object_array_length(iface_arr);
+    for (int i = 0; i < ni; i++) {
+        struct json_object *iface = json_object_array_get_idx(iface_arr, i);
+        if (!iface) continue;
+
+        /* Puerto local */
+        struct json_object *tmp_o;
+        const char *local_port = "";
+        if (json_object_object_get_ex(iface, "name", &tmp_o))
+            local_port = json_object_get_string(tmp_o);
+
+        /* Chassis */
+        const char *chassis_id="", *sys_name="", *sys_desc="";
+        struct json_object *chassis;
+        if (json_object_object_get_ex(iface, "chassis", &chassis)) {
+            struct json_object *cid, *cname, *cdescr;
+            if (json_object_object_get_ex(chassis, "id", &cid)) {
+                struct json_object *cv;
+                if (json_object_object_get_ex(cid, "value", &cv))
+                    chassis_id = json_object_get_string(cv);
+            }
+            if (json_object_object_get_ex(chassis, "name", &cname)) {
+                struct json_object *cv;
+                if (json_object_object_get_ex(cname, "value", &cv))
+                    sys_name = json_object_get_string(cv);
+            }
+            if (json_object_object_get_ex(chassis, "descr", &cdescr)) {
+                struct json_object *cv;
+                if (json_object_object_get_ex(cdescr, "value", &cv))
+                    sys_desc = json_object_get_string(cv);
+            }
+        }
+
+        /* Port */
+        const char *port_id="", *port_desc="";
+        struct json_object *port;
+        if (json_object_object_get_ex(iface, "port", &port)) {
+            struct json_object *pid, *pdesc;
+            if (json_object_object_get_ex(port, "id", &pid)) {
+                struct json_object *pv;
+                if (json_object_object_get_ex(pid, "value", &pv))
+                    port_id = json_object_get_string(pv);
+            }
+            if (json_object_object_get_ex(port, "descr", &pdesc)) {
+                struct json_object *pv;
+                if (json_object_object_get_ex(pdesc, "value", &pv))
+                    port_desc = json_object_get_string(pv);
+            }
+        }
+
+        struct json_object *e = json_object_new_object();
+        json_object_object_add(e, "local_port", json_object_new_string(local_port));
+        json_object_object_add(e, "chassis_id", json_object_new_string(chassis_id));
+        json_object_object_add(e, "port_id",    json_object_new_string(port_id));
+        json_object_object_add(e, "sys_name",   json_object_new_string(sys_name));
+        json_object_object_add(e, "sys_desc",   json_object_new_string(sys_desc));
+        json_object_object_add(e, "port_desc",  json_object_new_string(port_desc));
+        json_object_object_add(e, "port_table", json_object_new_array());
+        json_object_array_add(result, e);
+    }
+
+done:
+    json_object_put(root);
+    return result;
+}
diff --git a/src/lldp.h b/src/lldp.h
new file mode 100644
index 0000000..c6f1b87
--- /dev/null
+++ b/src/lldp.h
@@ -0,0 +1,72 @@
+#ifndef OPENUF_LLDP_H
+#define OPENUF_LLDP_H
+
+/*
+ * openuf - lldp.h
+ *
+ * LLDP (Link Layer Discovery Protocol — IEEE 802.1AB)
+ *
+ * ── ENVÍO de frames LLDP propios ────────────────────────────────
+ *
+ *   El AP transmite frames LLDP por cada puerto ethernet.
+ *   Esto permite al switch upstream registrar al AP como vecino,
+ *   y al controlador UniFi construir el mapa de topología visual.
+ *
+ *   Frame Ethernet:
+ *     dst  = 01:80:c2:00:00:0e  (multicast LLDP estándar)
+ *     src  = MAC del AP
+ *     type = 0x88cc
+ *
+ *   Payload (TLVs encadenados):
+ *     Header TLV = [type:7bits | len_hi:1bit][len_lo:8bits]
+ *
+ *     TLV type=1  Chassis ID   subtype=4(MAC), value=MAC[6]
+ *     TLV type=2  Port ID      subtype=5(ifname), value="eth0"
+ *     TLV type=3  TTL          value=uint16_BE
+ *     TLV type=5  System Name  value=hostname
+ *     TLV type=6  System Desc  value="modelo versión"
+ *     TLV type=7  Capabilities cap=0x0040(WLAN-AP), en=0x0040
+ *     TLV type=0  End of LLDPDU  len=0
+ *
+ * ── LECTURA de vecinos: lldpctl -f json ─────────────────────────
+ *
+ *   Si lldpd está instalado, leemos los vecinos detectados
+ *   y los incluimos en lldp_table del payload inform.
+ *
+ *   lldp_table en el JSON inform:
+ *   [{
+ *     "local_port": "eth0",
+ *     "chassis_id": "aa:bb:cc:...",
+ *     "port_id":    "Gi1/0/3",
+ *     "sys_name":   "switch-piso1",
+ *     "sys_desc":   "Cisco Catalyst 2960",
+ *     "port_desc":  "to-AP"
+ *   }]
+ *
+ * ── SIN lldpd ───────────────────────────────────────────────────
+ *
+ *   lldp_send_frame() funciona sin lldpd (usa raw socket directo).
+ *   lldp_read_neighbors() retorna array vacío si no hay lldpctl.
+ */
+
+#include 
+#include 
+
+/* Envía un frame LLDP por raw socket AF_PACKET.
+ * Requiere ejecutar como root (CAP_NET_RAW).
+ * Devuelve 0 si ok, -1 si error (sin root → error silencioso). */
+int lldp_send_frame(const char *ifname,
+                    const char *mac_str,
+                    const char *hostname,
+                    const char *model_desc,
+                    int         ttl);
+
+/* Lee vecinos LLDP de lldpctl y retorna JSON array lldp_table.
+ * Si lldpctl no está, retorna array vacío (no falla).
+ * Caller libera con json_object_put(). */
+struct json_object *lldp_read_neighbors(void);
+
+/* true si lldpctl está instalado */
+bool lldp_available(void);
+
+#endif /* OPENUF_LLDP_H */
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..b9d4462
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,207 @@
+/*
+ * openuf - main.c
+ *
+ * Daemon principal. Bucle con tres tareas:
+ *   1. Announce  – UDP broadcast+multicast cada 10s (descubrimiento L2)
+ *   2. Inform    – HTTP POST cifrado cada 10s (adopción + telemetría)
+ *   3. LLDP      – Raw frame L2 cada 30s (topología visual en UniFi)
+ */
+
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "config.h"
+#include "state.h"
+#include "ufmodel.h"
+#include "announce.h"
+#include "inform.h"
+#include "lldp.h"
+
+#if ENABLE_LOGGING
+FILE *log_fp = NULL;
+#endif
+
+#define LLDP_INTERVAL  30   /* seconds between LLDP frames */
+#define LLDP_TTL      120   /* LLDP record lifetime in seconds */
+
+static int get_mac(const char *iface, char *out, size_t sz)
+{
+    char path[128];
+    snprintf(path, sizeof(path), "/sys/class/net/%s/address", iface);
+    FILE *f = fopen(path, "r");
+    if (!f) return -1;
+    char buf[32] = {0};
+    fgets(buf, sizeof(buf), f);
+    fclose(f);
+    buf[strcspn(buf, "\r\n")] = '\0';
+    if (strlen(buf) < 11) return -1;
+    strncpy(out, buf, sz-1);
+    return 0;
+}
+
+static int get_ip(const char *iface, char *out, size_t sz)
+{
+    int fd = socket(AF_INET, SOCK_DGRAM, 0);
+    if (fd < 0) return -1;
+    struct ifreq ifr;
+    memset(&ifr, 0, sizeof(ifr));
+    strncpy(ifr.ifr_name, iface, IFNAMSIZ-1);
+    int ret = -1;
+    if (ioctl(fd, SIOCGIFADDR, &ifr) == 0) {
+        struct sockaddr_in *sa = (struct sockaddr_in *)&ifr.ifr_addr;
+        strncpy(out, inet_ntoa(sa->sin_addr), sz-1);
+        ret = 0;
+    }
+    close(fd);
+    return ret;
+}
+
+int main(int argc, char *argv[])
+{
+    for (int i = 1; i < argc-1; i++)
+        if (!strcmp(argv[i], "-c"))
+            setenv("OPENUF_CONF", argv[i+1], 1);
+
+    openuf_config_t cfg;
+    config_load(&cfg);
+
+#if ENABLE_LOGGING
+    if (cfg.enable_logging) {
+        log_fp = fopen("/var/log/openuf.log", "a");
+        if (log_fp) {
+            LOG("Logging enabled");
+        }
+    }
+#endif
+
+    const uf_model_t *model = ufmodel_find(cfg.ufmodel);
+
+    char mac_str[32] = "00:00:00:00:00:00";
+    char ip_str[64]  = "192.168.1.1";
+
+    if (get_mac(cfg.lan_if, mac_str, sizeof(mac_str)) != 0)
+        get_mac("eth0", mac_str, sizeof(mac_str));
+    if (get_ip(cfg.lan_if, ip_str, sizeof(ip_str)) != 0)
+        get_ip("eth0", ip_str, sizeof(ip_str));
+
+    openuf_state_t state;
+    state_load(&state);
+    strncpy(state.mac, mac_str, sizeof(state.mac)-1);
+    strncpy(state.ip,  ip_str,  sizeof(state.ip)-1);
+    if (!state.hostname[0])
+        strncpy(state.hostname, model->display_name, sizeof(state.hostname)-1);
+
+    /* Log initial state */
+    LOG("Initial device state: adopted=%d, authkey=%.8s...", state.adopted, 
+        state.authkey[0] ? state.authkey : "DEFAULT");
+
+    if (!state.adopted || !state.inform_url[0])
+        snprintf(state.inform_url, sizeof(state.inform_url),
+                 "http://%s:%d%s", cfg.controller_ip, INFORM_PORT, INFORM_PATH);
+    state_save(&state);
+
+    printf("[openuf] Starting  model=%-8s  MAC=%s  IP=%s\n",
+           model->model, mac_str, ip_str);
+    printf("[openuf] Controller: %s\n", state.inform_url);
+    printf("[openuf] Adopted: %s\n", state.adopted ? "yes" : "no");
+    printf("[openuf] LLDP available: %s\n",
+           lldp_available() ? "yes (lldpd)" : "no (transmit only)");
+    fflush(stdout);
+
+    LOG("Daemon started");
+
+    /* ── Announce socket ────────────────────────────────────────── */
+    announce_ctx_t ann;
+    if (cfg.enable_announce) {
+        if (announce_init(&ann, model, mac_str, ip_str) != 0) {
+            LOG("Failed to initialize announce service");
+            cfg.enable_announce = 0;
+        }
+    }
+
+    /* ── Descripción LLDP del dispositivo ───────────────────────── */
+    char lldp_desc[128];
+    snprintf(lldp_desc, sizeof(lldp_desc),
+             "%s %s%s (openuf)",
+             model->model_display, model->fw_pre, model->fw_ver);
+
+    /* ── Bucle principal ─────────────────────────────────────────── */
+    time_t start_time    = time(NULL);
+    time_t last_announce = 0;
+    time_t last_inform   = 0;
+    time_t last_lldp     = 0;
+
+    printf("[openuf] Main loop started\n");
+    fflush(stdout);
+
+    while (1) {
+        time_t now = time(NULL);
+
+        /* Announce L2/UDP */
+        if (cfg.enable_announce &&
+            (now - last_announce) >= ANNOUNCE_INTERVAL) {
+            LOG("Sending announce");
+            int announce_rc = announce_send(&ann);
+            LOG("Announce completed with result=%d", announce_rc);
+            last_announce = now;
+        }
+
+        /* LLDP frames por cada interfaz ethernet */
+        if ((now - last_lldp) >= LLDP_INTERVAL) {
+            LOG("Sending LLDP frames");
+            for (int i = 0; i < model->port_table_len; i++) {
+                const char *iface = model->port_table[i].ifname;
+                /* Leer MAC real de la interfaz si disponible */
+                char iface_mac[32];
+                if (get_mac(iface, iface_mac, sizeof(iface_mac)) != 0)
+                    strncpy(iface_mac, mac_str, sizeof(iface_mac)-1);
+                int lldp_rc = lldp_send_frame(iface, iface_mac,
+                                              state.hostname, lldp_desc,
+                                              LLDP_TTL);
+                LOG("LLDP frame interface=%s mac=%s result=%d",
+                    iface, iface_mac, lldp_rc);
+            }
+            last_lldp = now;
+        }
+
+        /* Inform HTTP POST */
+        if (cfg.enable_inform &&
+            (now - last_inform) >= cfg.inform_interval) {
+            last_inform = now;
+            LOG("Sending inform");
+
+            /* Actualizar IP en cada ciclo */
+            char new_ip[64] = {0};
+            if (get_ip(cfg.lan_if, new_ip, sizeof(new_ip)) == 0 ||
+                get_ip("eth0",     new_ip, sizeof(new_ip)) == 0)
+                strncpy(state.ip, new_ip, sizeof(state.ip)-1);
+
+            long uptime = (long)(now - start_time);
+            char err[128] = {0};
+            if (inform_send(&state, model, uptime, err) != 0) {
+                LOG("inform error: %s", err);
+            }
+        }
+
+        sleep(1);
+    }
+
+    announce_close(&ann);
+
+#if ENABLE_LOGGING
+    if (log_fp) {
+        LOG("Shutting down");
+        fclose(log_fp);
+    }
+#endif
+
+    return 0;
+}
diff --git a/src/models.c b/src/models.c
new file mode 100644
index 0000000..ec27ccd
--- /dev/null
+++ b/src/models.c
@@ -0,0 +1,199 @@
+/*
+ * openuf - models.c
+ *
+ * Descriptores de hardware para los modelos emulados.
+ *
+ * ── Por qué U6 InWall como modelo principal ─────────────────────────
+ *
+ * Se elige U6 IW porque:
+ *   • 5 puertos GbE (eth0-eth4) → cubre la mayoría de routers OpenWrt
+ *     con 4 LAN + 1 WAN convertidos a puertos independientes
+ *   • WiFi 6 (802.11ax) en 2.4 GHz y 5 GHz
+ *   • Sin PoE uplink (el router alimenta por adaptador)
+ *   • Firmware string reconocido por UniFi Network 7.x+
+ *
+ * Los 5 puertos se mapean así en un router típico OpenWrt:
+ *   eth0 → Puerto 1 (WAN físico, reconfigurado como LAN)
+ *   eth1 → Puerto 2
+ *   eth2 → Puerto 3
+ *   eth3 → Puerto 4
+ *   eth4 → Puerto 5 (o CPU en SoCs sin eth4 físico)
+ */
+
+#include "ufmodel.h"
+#include 
+
+/* ═══════════════════════════════════════════════════════════════════
+   U6 InWall — 5 puertos GbE + WiFi 6 (2.4+5 GHz)
+   ═══════════════════════════════════════════════════════════════════ */
+static const uf_radio_t u6iw_radios[] = {
+    /* name     radio  ch  ht       min max nss pwr  caps ant he */
+    { "wifi0", "ng",   6, "HT40",   5, 23,  2,  20,   4,  0, true },
+    { "wifi1", "na",  36, "HT80",   5, 23,  2,  20,   7,  0, true },
+};
+
+/* 5 puertos: puerto 0 es uplink, 1-4 son LAN */
+static const uf_port_t u6iw_ports[] = {
+    /* ifname  name    idx  poe_caps  media  speed  up     uplink  duplex */
+    { "eth0", "eth0",  0,   255,     "GE", 1000, false, true,  true },
+    { "eth1", "eth1",  1,     0,     "GE", 1000, false, false, true },
+    { "eth2", "eth2",  2,     0,     "GE", 1000, false, false, true },
+    { "eth3", "eth3",  3,     0,     "GE", 1000, false, false, true },
+    { "eth4", "eth4",  4,     4,     "GE", 1000, false, false, true },
+};
+
+static const uf_eth_entry_t u6iw_eth[] = {
+    { "eth0", 5 },
+};
+
+static const uf_radio_map_t u6iw_rmap[] = {
+    { "ng", "radio0" },
+    { "na", "radio1" },
+};
+
+const uf_model_t model_u6inwall = {
+    .model              = "U6IW",
+    .model_display      = "U6 IW",
+    .display_name       = "U6-IW",
+    .platform           = "U6IW",
+    .board_rev          = 3,
+    .has_eth1           = true,
+    .fw_pre             = "U6IW.mt7622_5_4.v",
+    .fw_ver             = "6.6.55.14430",
+    .fw_buildtime       = "230901.1200",
+    .fw_factoryver      = "6.6.55.14430",
+    .radio_table        = u6iw_radios,
+    .radio_table_len    = 2,
+    .port_table         = u6iw_ports,
+    .port_table_len     = 5,
+    .ethernet_table     = u6iw_eth,
+    .ethernet_table_len = 1,
+    .radio_map          = u6iw_rmap,
+    .radio_map_len      = 2,
+};
+
+/* ═══════════════════════════════════════════════════════════════════
+   U6 Lite — 1 puerto GbE + WiFi 6 (2.4+5 GHz)
+   ═══════════════════════════════════════════════════════════════════ */
+static const uf_radio_t u6lite_radios[] = {
+    { "wifi0", "ng",  6, "HT40",  5, 23, 2, 20, 4, 0, true },
+    { "wifi1", "na", 36, "HT80",  5, 23, 2, 20, 7, 0, true },
+};
+static const uf_port_t u6lite_ports[] = {
+    { "eth0", "eth0", 0, 255, "GE", 1000, false, true, true },
+};
+static const uf_eth_entry_t u6lite_eth[] = { { "eth0", 1 } };
+static const uf_radio_map_t u6lite_rmap[] = {
+    { "ng", "radio0" }, { "na", "radio1" },
+};
+const uf_model_t model_u6lite = {
+    .model="U6LITE", .model_display="U6 Lite", .display_name="U6-Lite",
+    .platform="U6LITE", .board_rev=3, .has_eth1=false,
+    .fw_pre="U6LITE.mt7622_5_4.v", .fw_ver="6.6.55.14430",
+    .fw_buildtime="230901.1200", .fw_factoryver="6.6.55.14430",
+    .radio_table=u6lite_radios, .radio_table_len=2,
+    .port_table=u6lite_ports,   .port_table_len=1,
+    .ethernet_table=u6lite_eth, .ethernet_table_len=1,
+    .radio_map=u6lite_rmap,     .radio_map_len=2,
+};
+
+/* ═══════════════════════════════════════════════════════════════════
+   UAP Gen 1 — 1 puerto Fast Ethernet + WiFi N 2.4 GHz
+   ═══════════════════════════════════════════════════════════════════ */
+static const uf_radio_t uapg1_radios[] = {
+    { "wifi0", "ng", 6, "HT20", 5, 23, 2, 20, 4, 0, false },
+};
+static const uf_port_t uapg1_ports[] = {
+    { "eth0", "eth0", 0, 255, "GE", 100, false, true, true },
+};
+static const uf_eth_entry_t uapg1_eth[] = { { "eth0", 1 } };
+static const uf_radio_map_t uapg1_rmap[] = { { "ng", "radio0" } };
+const uf_model_t model_uapg1 = {
+    .model="BZ2", .model_display="UAP", .display_name="UAP",
+    .platform="BZ2", .board_rev=1, .has_eth1=false,
+    .fw_pre="BZ2.ar7240.v", .fw_ver="6.6.55.14430",
+    .fw_buildtime="230901.1200", .fw_factoryver="6.6.55.14430",
+    .radio_table=uapg1_radios, .radio_table_len=1,
+    .port_table=uapg1_ports,   .port_table_len=1,
+    .ethernet_table=uapg1_eth, .ethernet_table_len=1,
+    .radio_map=uapg1_rmap,     .radio_map_len=1,
+};
+
+/* ═══════════════════════════════════════════════════════════════════
+   UAP Gen 1 LR
+   ═══════════════════════════════════════════════════════════════════ */
+static const uf_radio_t uapg1lr_radios[] = {
+    { "wifi0", "ng", 6, "HT20", 5, 23, 2, 22, 4, 0, false },
+};
+static const uf_port_t uapg1lr_ports[] = {
+    { "eth0", "eth0", 0, 255, "GE", 100, false, true, true },
+};
+static const uf_eth_entry_t uapg1lr_eth[] = { { "eth0", 1 } };
+static const uf_radio_map_t uapg1lr_rmap[] = { { "ng", "radio0" } };
+const uf_model_t model_uapg1lr = {
+    .model="BZ2LR", .model_display="UAP-LR", .display_name="UAP-LR",
+    .platform="BZ2LR", .board_rev=1, .has_eth1=false,
+    .fw_pre="BZ2LR.ar7240.v", .fw_ver="6.6.55.14430",
+    .fw_buildtime="230901.1200", .fw_factoryver="6.6.55.14430",
+    .radio_table=uapg1lr_radios, .radio_table_len=1,
+    .port_table=uapg1lr_ports,   .port_table_len=1,
+    .ethernet_table=uapg1lr_eth, .ethernet_table_len=1,
+    .radio_map=uapg1lr_rmap,     .radio_map_len=1,
+};
+
+/* ═══════════════════════════════════════════════════════════════════
+   UAP AC LR — 1 puerto GbE + WiFi AC dual-band
+   ═══════════════════════════════════════════════════════════════════ */
+static const uf_radio_t uapg2aclr_radios[] = {
+    { "wifi0", "ng",  6, "HT40",  5, 23, 2, 20, 4, 0, false },
+    { "wifi1", "na", 36, "HT80",  5, 23, 2, 20, 7, 0, false },
+};
+static const uf_port_t uapg2aclr_ports[] = {
+    { "eth0", "eth0", 0, 255, "GE", 1000, false, true, true },
+};
+static const uf_eth_entry_t uapg2aclr_eth[] = { { "eth0", 1 } };
+static const uf_radio_map_t uapg2aclr_rmap[] = {
+    { "ng", "radio0" }, { "na", "radio1" },
+};
+const uf_model_t model_uapg2aclr = {
+    .model="U2IW", .model_display="UAP-AC-LR", .display_name="UAP-AC-LR",
+    .platform="U2IW", .board_rev=2, .has_eth1=false,
+    .fw_pre="U2IW.qca956x.v", .fw_ver="6.7.54.15663",
+    .fw_buildtime="260615.1200", .fw_factoryver="6.7.54.15663",
+    .radio_table=uapg2aclr_radios, .radio_table_len=2,
+    .port_table=uapg2aclr_ports,   .port_table_len=1,
+    .ethernet_table=uapg2aclr_eth, .ethernet_table_len=1,
+    .radio_map=uapg2aclr_rmap,     .radio_map_len=2,
+};
+
+/* ─── Registro de modelos ─────────────────────────────────────── */
+static const uf_model_t *all_models[] = {
+    &model_u6inwall,
+    &model_u6lite,
+    &model_uapg1,
+    &model_uapg1lr,
+    &model_uapg2aclr,
+    NULL
+};
+
+const uf_model_t *ufmodel_find(const char *name)
+{
+    if (!name) return &model_u6inwall;
+    for (int i = 0; all_models[i]; i++) {
+        const uf_model_t *m = all_models[i];
+        if (!strcasecmp(name, m->model)        ||
+            !strcasecmp(name, m->model_display) ||
+            !strcasecmp(name, m->display_name)  ||
+            !strcasecmp(name, m->platform))
+            return m;
+    }
+    /* Aliases de configuración */
+    if (!strcasecmp(name, "u6-inwall") ||
+        !strcasecmp(name, "u6iw"))     return &model_u6inwall;
+    if (!strcasecmp(name, "u6-lite"))  return &model_u6lite;
+    if (!strcasecmp(name, "uapg1"))    return &model_uapg1;
+    if (!strcasecmp(name, "uapg1-lr")) return &model_uapg1lr;
+    if (!strcasecmp(name, "uapg2-ac-lr")) return &model_uapg2aclr;
+
+    return &model_u6inwall;  /* default */
+}
diff --git a/src/state.c b/src/state.c
new file mode 100644
index 0000000..25d4ca2
--- /dev/null
+++ b/src/state.c
@@ -0,0 +1,135 @@
+#include 
+#include 
+#include 
+#include 
+#include "state.h"
+#include "config.h"
+
+#if ENABLE_LOGGING
+#include 
+extern FILE *log_fp;
+#define LOG(fmt, ...) do { if (log_fp) { fprintf(log_fp, "[%s] " fmt "\n", __func__, ##__VA_ARGS__); fflush(log_fp); } } while(0)
+#else
+#define LOG(fmt, ...) do {} while(0)
+#endif
+
+static void state_defaults(openuf_state_t *st)
+{
+    memset(st, 0, sizeof(*st));
+    st->adopted = false;
+    strncpy(st->authkey,    DEFAULT_AUTH_KEY, sizeof(st->authkey) - 1);
+    strncpy(st->cfgversion, "0",              sizeof(st->cfgversion) - 1);
+    st->config_applied = false;
+    st->config_schema = 0;
+    st->use_aes_gcm = false;
+}
+
+void state_load(openuf_state_t *st)
+{
+    state_defaults(st);
+
+    FILE *f = fopen(OPENUF_STATE_FILE, "r");
+    if (!f) {
+        LOG("State file not found, using defaults");
+        return;
+    }
+
+    /* Read whole file */
+    fseek(f, 0, SEEK_END);
+    long sz = ftell(f);
+    rewind(f);
+    if (sz <= 0 || sz > 4096) { fclose(f); return; }
+
+    char *buf = malloc(sz + 1);
+    if (!buf) { fclose(f); return; }
+    fread(buf, 1, sz, f);
+    buf[sz] = '\0';
+    fclose(f);
+
+    struct json_object *root = json_tokener_parse(buf);
+    free(buf);
+    if (!root) {
+        LOG("Failed to parse state file");
+        return;
+    }
+
+    struct json_object *v;
+#define LOAD_STR(field, key) \
+    if (json_object_object_get_ex(root, key, &v) && json_object_is_type(v, json_type_string)) \
+        strncpy(st->field, json_object_get_string(v), sizeof(st->field) - 1)
+#define LOAD_BOOL(field, key) \
+    if (json_object_object_get_ex(root, key, &v)) \
+        st->field = json_object_get_boolean(v)
+#define LOAD_INT(field, key) \
+    if (json_object_object_get_ex(root, key, &v)) \
+        st->field = json_object_get_int(v)
+
+    LOAD_BOOL(adopted,    "adopted");
+    LOAD_STR (authkey,    "authkey");
+    LOAD_STR (inform_url, "inform_url");
+    LOAD_STR (cfgversion, "cfgversion");
+    LOAD_BOOL(config_applied, "config_applied");
+    LOAD_INT (config_schema,  "config_schema");
+    LOAD_BOOL(use_aes_gcm, "use_aes_gcm");
+    LOAD_STR (mac,        "mac");
+    LOAD_STR (ip,         "ip");
+    LOAD_STR (hostname,   "hostname");
+
+    /*
+     * Older versions stored cfgversion from setparam before applying the
+     * corresponding setstate. Force one provisioning request when migrating.
+     */
+    if (!st->config_applied ||
+        st->config_schema < OPENUF_CONFIG_SCHEMA) {
+        st->config_applied = false;
+        strncpy(st->cfgversion, "0", sizeof(st->cfgversion) - 1);
+    }
+
+    /* CRITICAL: If not adopted, force DEFAULT_AUTH_KEY */
+    if (!st->adopted) {
+        LOG("Device not adopted - resetting authkey to DEFAULT");
+        strncpy(st->authkey, DEFAULT_AUTH_KEY, sizeof(st->authkey) - 1);
+    }
+
+    LOG("State loaded: adopted=%d, authkey=%.8s..., inform_url=%s, aes_gcm=%d",
+        st->adopted, st->authkey[0] ? st->authkey : "DEFAULT",
+        st->inform_url, st->use_aes_gcm);
+
+    json_object_put(root);
+}
+
+int state_save(const openuf_state_t *st)
+{
+    /* Ensure directory exists */
+    mkdir("/etc/openuf", 0755);
+
+    LOG("Saving state: adopted=%d, authkey=%.8s..., inform_url=%s, aes_gcm=%d",
+        st->adopted, st->authkey[0] ? st->authkey : "DEFAULT",
+        st->inform_url, st->use_aes_gcm);
+
+    struct json_object *root = json_object_new_object();
+    json_object_object_add(root, "adopted",    json_object_new_boolean(st->adopted));
+    json_object_object_add(root, "authkey",    json_object_new_string(st->authkey));
+    json_object_object_add(root, "inform_url", json_object_new_string(st->inform_url));
+    json_object_object_add(root, "cfgversion", json_object_new_string(st->cfgversion));
+    json_object_object_add(root, "config_applied", json_object_new_boolean(st->config_applied));
+    json_object_object_add(root, "config_schema", json_object_new_int(st->config_schema));
+    json_object_object_add(root, "use_aes_gcm", json_object_new_boolean(st->use_aes_gcm));
+    json_object_object_add(root, "mac",        json_object_new_string(st->mac));
+    json_object_object_add(root, "ip",         json_object_new_string(st->ip));
+    json_object_object_add(root, "hostname",   json_object_new_string(st->hostname));
+
+    const char *s = json_object_to_json_string_ext(root, JSON_C_TO_STRING_PRETTY);
+
+    FILE *f = fopen(OPENUF_STATE_FILE, "w");
+    if (!f) { 
+        LOG("Failed to open state file for writing");
+        json_object_put(root); 
+        return -1; 
+    }
+    fputs(s, f);
+    fclose(f);
+    json_object_put(root);
+    LOG("State saved successfully");
+    return 0;
+}
diff --git a/src/state.h b/src/state.h
new file mode 100644
index 0000000..a8fa5e6
--- /dev/null
+++ b/src/state.h
@@ -0,0 +1,27 @@
+#ifndef OPENUF_STATE_H
+#define OPENUF_STATE_H
+
+#include 
+
+#define OPENUF_CONFIG_SCHEMA 3
+
+typedef struct {
+    bool  adopted;
+    char  authkey[64];
+    char  inform_url[256];
+    char  cfgversion[32];
+    bool  config_applied;
+    int   config_schema;
+    bool  use_aes_gcm;
+    char  mac[32];
+    char  ip[64];
+    char  hostname[64];
+} openuf_state_t;
+
+/* Load state from OPENUF_STATE_FILE.  Fills defaults if file missing. */
+void state_load(openuf_state_t *st);
+
+/* Persist state to OPENUF_STATE_FILE (creates /etc/openuf/ if needed). */
+int  state_save(const openuf_state_t *st);
+
+#endif /* OPENUF_STATE_H */
diff --git a/src/sysinfo.c b/src/sysinfo.c
new file mode 100644
index 0000000..ff904b0
--- /dev/null
+++ b/src/sysinfo.c
@@ -0,0 +1,298 @@
+/*
+ * openuf - sysinfo.c
+ *
+ * Lee estadísticas del sistema para el payload inform.
+ *
+ * ── CPU: /proc/stat ──────────────────────────────────────────────────
+ *
+ *   Formato: cpu  user nice system idle iowait irq softirq steal
+ *
+ *   El uso se calcula con dos snapshots separados en el tiempo:
+ *     activo = user + nice + system + irq + softirq + steal
+ *     total  = activo + idle + iowait
+ *     uso %  = (Δactivo / Δtotal) × 100
+ *
+ * ── Memoria: /proc/meminfo ───────────────────────────────────────────
+ *
+ *   MemTotal, MemFree, Buffers, Cached
+ *   used = total - free - buffers - cached
+ *
+ * ── Interfaces: /proc/net/dev + /sys/class/net// ─────────────
+ *
+ *   /proc/net/dev        → contadores acumulados rx/tx
+ *   /sys/class/net/speed → velocidad negociada (Mbps)
+ *   /sys/class/net/duplex → "full" / "half"
+ *   /sys/class/net/operstate → "up" / "down" / "unknown"
+ *   /sys/class/net/address → MAC
+ *   ioctl SIOCGIFADDR    → IP
+ *
+ * ── Radio: iw dev  info + survey dump ─────────────────────────
+ *
+ *   info: canal actual, potencia TX
+ *   survey dump: active/busy/tx/rx time → calcular % utilización
+ */
+
+#define _GNU_SOURCE
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "sysinfo.h"
+
+/* ═══════════════════════════════════════════════════════════════════
+   Memoria
+   ═══════════════════════════════════════════════════════════════════ */
+int sysinfo_mem(mem_stats_t *out)
+{
+    memset(out, 0, sizeof(*out));
+    FILE *f = fopen("/proc/meminfo", "r");
+    if (!f) return -1;
+
+    char line[128];
+    while (fgets(line, sizeof(line), f)) {
+        long val = 0;
+        if      (sscanf(line, "MemTotal: %ld kB",  &val) == 1) out->total_kb  = val;
+        else if (sscanf(line, "MemFree: %ld kB",   &val) == 1) out->free_kb   = val;
+        else if (sscanf(line, "Buffers: %ld kB",   &val) == 1) out->buffer_kb = val;
+        else if (sscanf(line, "Cached: %ld kB",    &val) == 1) out->cached_kb = val;
+    }
+    fclose(f);
+    return (out->total_kb > 0) ? 0 : -1;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   CPU
+   ═══════════════════════════════════════════════════════════════════ */
+typedef struct {
+    unsigned long long user, nice, sys, idle, iowait, irq, softirq, steal;
+} cpu_snap_t;
+
+static cpu_snap_t g_prev = {0};
+static int        g_valid = 0;
+
+static int read_cpu(cpu_snap_t *s)
+{
+    FILE *f = fopen("/proc/stat", "r");
+    if (!f) return -1;
+    int r = fscanf(f, "cpu %llu %llu %llu %llu %llu %llu %llu %llu",
+                   &s->user, &s->nice, &s->sys, &s->idle,
+                   &s->iowait, &s->irq, &s->softirq, &s->steal);
+    fclose(f);
+    return (r >= 4) ? 0 : -1;
+}
+
+int sysinfo_cpu_percent(void)
+{
+    cpu_snap_t cur;
+    if (read_cpu(&cur) != 0) return 0;
+
+    if (!g_valid) { g_prev = cur; g_valid = 1; return 0; }
+
+    unsigned long long da = (cur.user - g_prev.user)
+                          + (cur.nice - g_prev.nice)
+                          + (cur.sys  - g_prev.sys)
+                          + (cur.irq  - g_prev.irq)
+                          + (cur.softirq - g_prev.softirq)
+                          + (cur.steal - g_prev.steal);
+    unsigned long long di = (cur.idle  - g_prev.idle)
+                          + (cur.iowait - g_prev.iowait);
+    unsigned long long dt = da + di;
+    g_prev = cur;
+    return (dt == 0) ? 0 : (int)((da * 100) / dt);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Interfaz de red
+   ═══════════════════════════════════════════════════════════════════ */
+static int read_sysfs_str(const char *iface, const char *file,
+                           char *out, size_t sz)
+{
+    char path[128];
+    snprintf(path, sizeof(path), "/sys/class/net/%s/%s", iface, file);
+    FILE *f = fopen(path, "r");
+    if (!f) return -1;
+    char buf[64] = {0};
+    fgets(buf, sizeof(buf), f);
+    fclose(f);
+    buf[strcspn(buf, "\r\n")] = '\0';
+    strncpy(out, buf, sz - 1);
+    return (strlen(out) > 0) ? 0 : -1;
+}
+
+static int read_sysfs_int(const char *iface, const char *file)
+{
+    char buf[32] = {0};
+    if (read_sysfs_str(iface, file, buf, sizeof(buf)) != 0) return -1;
+    int v = -1; sscanf(buf, "%d", &v); return v;
+}
+
+static void read_ip_ioctl(const char *iface, char *out, size_t sz)
+{
+    int fd = socket(AF_INET, SOCK_DGRAM, 0);
+    if (fd < 0) return;
+    struct ifreq ifr;
+    memset(&ifr, 0, sizeof(ifr));
+    strncpy(ifr.ifr_name, iface, IFNAMSIZ - 1);
+    if (ioctl(fd, SIOCGIFADDR, &ifr) == 0) {
+        struct sockaddr_in *sa = (struct sockaddr_in *)&ifr.ifr_addr;
+        strncpy(out, inet_ntoa(sa->sin_addr), sz - 1);
+    }
+    close(fd);
+}
+
+int sysinfo_iface(const char *ifname, iface_stats_t *out)
+{
+    memset(out, 0, sizeof(*out));
+    strncpy(out->name, ifname, sizeof(out->name) - 1);
+
+    /* MAC, operstate, speed, duplex */
+    read_sysfs_str(ifname, "address",  out->mac, sizeof(out->mac));
+    char opstate[32] = {0};
+    read_sysfs_str(ifname, "operstate", opstate, sizeof(opstate));
+    out->up = (strcmp(opstate, "up") == 0 || strcmp(opstate, "unknown") == 0);
+    int sp = read_sysfs_int(ifname, "speed");
+    out->speed = (sp > 0) ? sp : 1000;
+
+    char dup[16] = {0};
+    read_sysfs_str(ifname, "duplex", dup, sizeof(dup));
+    out->full_duplex = (strncmp(dup, "full", 4) == 0);
+
+    /* IP */
+    read_ip_ioctl(ifname, out->ip, sizeof(out->ip));
+
+    /* Contadores de /proc/net/dev */
+    FILE *f = fopen("/proc/net/dev", "r");
+    if (!f) return 0;
+
+    char line[512];
+    fgets(line, sizeof(line), f); /* skip header lines */
+    fgets(line, sizeof(line), f);
+
+    while (fgets(line, sizeof(line), f)) {
+        char *colon = strchr(line, ':');
+        if (!colon) continue;
+
+        /* Extraer nombre de interfaz (puede tener espacios al inicio) */
+        size_t end = colon - line;
+        while (end > 0 && line[end-1] == ' ') end--;
+        size_t start = 0;
+        while (start < end && line[start] == ' ') start++;
+        char name[32] = {0};
+        size_t nlen = end - start;
+        if (nlen >= sizeof(name)) continue;
+        strncpy(name, line + start, nlen);
+
+        if (strcmp(name, ifname) != 0) continue;
+
+        long long rb,rp,re,rd,rf,rframe,rcomp,rmulti;
+        long long tb,tp,te,td,tf,tcol,tcomp,tcarr;
+        sscanf(colon+1,
+               "%lld %lld %lld %lld %lld %lld %lld %lld"
+               " %lld %lld %lld %lld %lld %lld %lld %lld",
+               &rb,&rp,&re,&rd,&rf,&rframe,&rcomp,&rmulti,
+               &tb,&tp,&te,&td,&tf,&tcol,&tcomp,&tcarr);
+        out->rx_bytes    = rb; out->rx_packets  = rp;
+        out->rx_errors   = re; out->rx_dropped  = rd;
+        out->rx_multicast= rmulti;
+        out->tx_bytes    = tb; out->tx_packets  = tp;
+        out->tx_errors   = te; out->tx_dropped  = td;
+        break;
+    }
+    fclose(f);
+    return 0;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Radio WiFi
+   ═══════════════════════════════════════════════════════════════════
+
+   1. iw dev wlan0 info  → canal y potencia
+      Ejemplo:
+        Interface wlan0
+          channel 6 (2437 MHz), width: 20 MHz
+          txpower 20.00 dBm
+
+   2. iw dev wlan0 survey dump  → utilización del canal
+      Buscamos el bloque con "[in use]":
+        frequency: 2437 MHz [in use]
+        channel active time: 12345 ms
+        channel busy time:     987 ms
+        channel transmit time: 456 ms
+        channel receive time:  321 ms
+
+   Calculamos:
+     cu_total   = busy/active × 100
+     cu_self_tx = transmit/active × 100
+     cu_self_rx = receive/active × 100
+*/
+int sysinfo_radio(const char *iface, radio_stats_t *out)
+{
+    memset(out, 0, sizeof(*out));
+    strncpy(out->iface, iface, sizeof(out->iface) - 1);
+    out->noise = -95;
+
+    char cmd[128];
+
+    /* iw dev  info */
+    snprintf(cmd, sizeof(cmd), "iw dev %s info 2>/dev/null", iface);
+    FILE *p = popen(cmd, "r");
+    if (!p) return -1;
+
+    char line[256];
+    while (fgets(line, sizeof(line), p)) {
+        int ch; float mhz;
+        if (sscanf(line, " channel %d (%f MHz)", &ch, &mhz) == 2)
+            out->channel = ch;
+        float tp;
+        if (sscanf(line, " txpower %f dBm", &tp) == 1)
+            out->tx_power = (int)tp;
+    }
+    pclose(p);
+
+    /* iw dev  survey dump */
+    snprintf(cmd, sizeof(cmd), "iw dev %s survey dump 2>/dev/null", iface);
+    p = popen(cmd, "r");
+    if (!p) return 0;
+
+    long long active=0, busy=0, tx_t=0, rx_t=0;
+    int in_use = 0;
+    while (fgets(line, sizeof(line), p)) {
+        if (strstr(line, "[in use]")) {
+            in_use = 1; active=busy=tx_t=rx_t=0; continue;
+        }
+        if (!in_use) continue;
+        /* Nueva frecuencia sin [in use] resetea el bloque */
+        if (strstr(line, "frequency:") && !strstr(line, "[in use]")) {
+            in_use = 0; continue;
+        }
+        float noise; long long val;
+        if (sscanf(line, " noise: %f dBm", &noise) == 1) out->noise = (int)noise;
+        if (sscanf(line, " channel active time: %lld ms", &val) == 1)  active = val;
+        if (sscanf(line, " channel busy time: %lld ms", &val) == 1)    busy   = val;
+        if (sscanf(line, " channel transmit time: %lld ms", &val) == 1) tx_t  = val;
+        if (sscanf(line, " channel receive time: %lld ms", &val) == 1)  rx_t  = val;
+    }
+    pclose(p);
+
+    if (active > 0) {
+        out->cu_total   = (int)(busy * 100 / active);
+        out->cu_self_tx = (int)(tx_t * 100 / active);
+        out->cu_self_rx = (int)(rx_t * 100 / active);
+    }
+
+    /* Número de clientes asociados */
+    snprintf(cmd, sizeof(cmd),
+             "iw dev %s station dump 2>/dev/null | grep -c '^Station'",
+             iface);
+    p = popen(cmd, "r");
+    if (p) { fscanf(p, "%d", &out->num_sta); pclose(p); }
+
+    return 0;
+}
diff --git a/src/sysinfo.h b/src/sysinfo.h
new file mode 100644
index 0000000..068eb76
--- /dev/null
+++ b/src/sysinfo.h
@@ -0,0 +1,73 @@
+#ifndef OPENUF_SYSINFO_H
+#define OPENUF_SYSINFO_H
+
+/*
+ * openuf - sysinfo.h
+ *
+ * Lee estadísticas del sistema (CPU, RAM, interfaces, radios).
+ * Todas las lecturas son del kernel Linux directamente:
+ *
+ *   /proc/stat        → uso CPU (deltas entre dos snapshots)
+ *   /proc/meminfo     → memoria total/libre/buffer/cache
+ *   /proc/net/dev     → contadores rx/tx por interfaz
+ *   /sys/class/net/   → speed, duplex, operstate, MAC
+ *   iw dev  info  → canal actual, potencia TX
+ *   iw dev  survey dump → utilización del canal
+ */
+
+#include 
+
+/* ── Memoria ─────────────────────────────────────────────────────── */
+typedef struct {
+    long total_kb;
+    long free_kb;
+    long buffer_kb;
+    long cached_kb;
+} mem_stats_t;
+
+int sysinfo_mem(mem_stats_t *out);
+
+/* ── CPU ─────────────────────────────────────────────────────────── */
+/* Retorna % uso CPU (0-100). Primera llamada retorna 0 (toma snapshot).
+ * Las siguientes calculan el delta respecto a la anterior.
+ * Con intervalo de 10s da un buen promedio de uso. */
+int sysinfo_cpu_percent(void);
+
+/* ── Interfaz de red ─────────────────────────────────────────────── */
+typedef struct {
+    char      name[32];
+    char      mac[32];
+    char      ip[64];
+    bool      up;
+    int       speed;        /* Mbps: 10/100/1000; -1 si no disponible */
+    bool      full_duplex;
+    long long rx_bytes;
+    long long tx_bytes;
+    long long rx_packets;
+    long long tx_packets;
+    long long rx_errors;
+    long long tx_errors;
+    long long rx_dropped;
+    long long tx_dropped;
+    long long rx_multicast;
+} iface_stats_t;
+
+int sysinfo_iface(const char *ifname, iface_stats_t *out);
+
+/* ── Radio WiFi ─────────────────────────────────────────────────── */
+typedef struct {
+    char name[32];
+    char iface[32];
+    int  channel;
+    int  tx_power;
+    int  cu_total;    /* % uso canal total */
+    int  cu_self_tx;  /* % tiempo transmitiendo */
+    int  cu_self_rx;  /* % tiempo recibiendo */
+    int  num_sta;
+    int  noise;       /* dBm */
+} radio_stats_t;
+
+/* iface: "wlan0", "wlan1" */
+int sysinfo_radio(const char *iface, radio_stats_t *out);
+
+#endif /* OPENUF_SYSINFO_H */
diff --git a/src/ufmodel.h b/src/ufmodel.h
new file mode 100644
index 0000000..d58f5f3
--- /dev/null
+++ b/src/ufmodel.h
@@ -0,0 +1,81 @@
+#ifndef OPENUF_UFMODEL_H
+#define OPENUF_UFMODEL_H
+
+#include 
+
+/* ─── Radio entry ─────────────────────────────────────────────────── */
+typedef struct {
+    const char *name;        /* "wifi0", "wifi1" */
+    const char *radio;       /* "ng" (2.4 GHz) | "na" (5 GHz) | "6g" */
+    int         channel;
+    const char *ht;          /* "HT20", "HT40", "HT80" */
+    int         min_txpower;
+    int         max_txpower;
+    int         nss;
+    int         tx_power;
+    int         radio_caps;
+    int         antenna_gain;
+    bool        he_enabled;
+} uf_radio_t;
+
+/* ─── Ethernet port entry ─────────────────────────────────────────── */
+typedef struct {
+    const char *ifname;
+    const char *name;
+    int         port_idx;
+    int         poe_caps;
+    const char *media;       /* "GE" */
+    int         speed;
+    bool        up;
+    bool        is_uplink;
+    bool        full_duplex;
+} uf_port_t;
+
+/* ─── Ethernet table entry ────────────────────────────────────────── */
+typedef struct {
+    const char *name;
+    int         num_port;
+} uf_eth_entry_t;
+
+/* ─── Radio map entry (band → OpenWrt device) ─────────────────────── */
+typedef struct {
+    const char *band;   /* "ng", "na", "6g" */
+    const char *device; /* "radio0", "radio1" */
+} uf_radio_map_t;
+
+/* ─── Full model descriptor ───────────────────────────────────────── */
+typedef struct {
+    const char     *model;          /* "U6IW", "U6LITE" */
+    const char     *model_display;  /* "U6 IW" */
+    const char     *display_name;   /* "U6-IW" */
+    const char     *platform;       /* used in announce PKT_PLATFORM */
+    int             board_rev;
+    bool            has_eth1;
+
+    /* Firmware strings */
+    const char     *fw_pre;         /* "U6IW.mt7622_5_4.v" */
+    const char     *fw_ver;         /* "6.6.55.14430" */
+    const char     *fw_buildtime;   /* "230901.1200" */
+    const char     *fw_factoryver;
+
+    /* Tables */
+    const uf_radio_t     *radio_table;
+    int                   radio_table_len;
+    const uf_port_t      *port_table;
+    int                   port_table_len;
+    const uf_eth_entry_t *ethernet_table;
+    int                   ethernet_table_len;
+    const uf_radio_map_t *radio_map;
+    int                   radio_map_len;
+} uf_model_t;
+
+/* ─── Model registry ──────────────────────────────────────────────── */
+const uf_model_t *ufmodel_find(const char *name);
+
+extern const uf_model_t model_u6inwall;
+extern const uf_model_t model_u6lite;
+extern const uf_model_t model_uapg1;
+extern const uf_model_t model_uapg1lr;
+extern const uf_model_t model_uapg2aclr;
+
+#endif /* OPENUF_UFMODEL_H */
diff --git a/src/wlan.c b/src/wlan.c
new file mode 100644
index 0000000..eb93e17
--- /dev/null
+++ b/src/wlan.c
@@ -0,0 +1,885 @@
+/*
+ * openuf - wlan.c
+ *
+ * Traduce la configuración WiFi del controlador UniFi en settings
+ * UCI de OpenWrt usando libuci directamente (sin shell).
+ *
+ * ── CÓMO SE APLICA LA CONFIGURACIÓN ────────────────────────────────
+ *
+ * El controlador envía "setstate" con:
+ *   radio_table[]  → configuración de las radios (canal, potencia, HT)
+ *   vap_table[]    → configuración de las redes WiFi (SSID, clave, roaming...)
+ *
+ * Este módulo:
+ *   1. Borra todas las wifi-iface UCI con prefijo "openuf_"
+ *   2. Aplica radio_table → wireless..channel/txpower/htmode
+ *   3. Crea nuevas wifi-iface por cada VAP con su configuración
+ *   4. Ejecuta "wifi reload" para aplicar sin reiniciar
+ *
+ * ── MAPEO DE SEGURIDAD ──────────────────────────────────────────────
+ *
+ *   UniFi          OpenWrt UCI         Descripción
+ *   ─────────────────────────────────────────────
+ *   open           none                Sin contraseña
+ *   wpapsk         psk                 WPA Personal
+ *   wpa2psk        psk2                WPA2 Personal
+ *   wpapskwpa2psk  psk-mixed           WPA/WPA2 mixto
+ *   wpa3           sae                 WPA3 Personal
+ *   wpa3transition sae-mixed           WPA2+WPA3 transición
+ *   wpa2enterprise wpa2                WPA2 Enterprise (RADIUS)
+ *   wpa3enterprise wpa3                WPA3 Enterprise
+ *
+ * ── BAND STEERING (802.11k/v) ──────────────────────────────────────
+ *
+ *   Cuando UniFi activa band_steering, configuramos en UCI:
+ *     ieee80211k = 1   → Neighbor Reports (AP informa a cliente de otros APs)
+ *     ieee80211v = 1   → BSS Transition Management (AP puede pedir que el
+ *                         cliente se mueva a otro AP/radio)
+ *     rrm_neighbor_report = 1
+ *     bss_transition = 1
+ *
+ *   El hostapd de OpenWrt usa estos flags para implementar 802.11k/v.
+ *   Band steering real requiere lógica adicional (daemon externo o
+ *   script que monitoriza RSSI y envía BTM Request).
+ *
+ * ── FAST ROAMING (802.11r) ─────────────────────────────────────────
+ *
+ *   Cuando UniFi activa fast_roaming_enabled:
+ *     ieee80211r = 1          → FT (Fast BSS Transition)
+ *     ft_over_ds = 1          → FT sobre Distribution System (más compatible)
+ *     mobility_domain = XXXX  → Mismo dominio en todos los APs del site
+ *     ft_psk_generate_local = 1  → PSK sin servidor FT externo
+ *
+ *   El mobility_domain se deriva de los primeros 2 bytes del MAC del AP.
+ *   Todos los APs del mismo site deben usar el mismo mobility_domain.
+ *
+ * ── PMF (Protected Management Frames / 802.11w) ─────────────────────
+ *
+ *   pmf_mode  → ieee80211w:
+ *     "disabled" → 0 (sin PMF)
+ *     "optional" → 1 (PMF opcional, compatible con clientes sin PMF)
+ *     "required" → 2 (PMF obligatorio, solo clientes con PMF)
+ *
+ *   WPA3 siempre requiere PMF=2.
+ *
+ * ── LECTURA DE VAPs DESDE UCI ───────────────────────────────────────
+ *
+ *   wlan_get_vap_table() itera todas las wifi-iface de /etc/config/wireless
+ *   que tengan prefijo "openuf_" y construye el JSON vap_table para
+ *   incluirlo en el payload inform.
+ *
+ *   Para cada VAP leemos: ssid, device, bssid, encryption, key, disabled
+ *   y los traducimos al formato que espera el controlador.
+ */
+
+#include 
+#include 
+#include 
+#include 
+#include 
+
+#include "wlan.h"
+#include "ufmodel.h"
+
+/* ─── Mapeo de seguridad UniFi → OpenWrt UCI ────────────────────── */
+static const char *sec_to_uci(const char *uf)
+{
+    if (!uf || !strcmp(uf,"open"))          return "none";
+    if (!strcmp(uf,"wpapsk"))               return "psk";
+    if (!strcmp(uf,"wpa2psk"))              return "psk2";
+    if (!strcmp(uf,"wpapskwpa2psk"))        return "psk-mixed";
+    if (!strcmp(uf,"wpa3"))                 return "sae";
+    if (!strcmp(uf,"wpa3transition"))       return "sae-mixed";
+    if (!strcmp(uf,"wpa2enterprise"))       return "wpa2";
+    if (!strcmp(uf,"wpa3enterprise"))       return "wpa3";
+    return "psk2";   /* default */
+}
+
+/* Mapeo inverso: UCI → UniFi (para wlan_get_vap_table) */
+static const char *sec_to_unifi(const char *uci)
+{
+    if (!uci || !strcmp(uci,"none"))        return "open";
+    if (!strcmp(uci,"psk"))                 return "wpapsk";
+    if (!strcmp(uci,"psk2"))                return "wpa2psk";
+    if (!strcmp(uci,"psk-mixed"))           return "wpapskwpa2psk";
+    if (!strcmp(uci,"sae"))                 return "wpa3";
+    if (!strcmp(uci,"sae-mixed"))           return "wpa3transition";
+    if (!strcmp(uci,"wpa2"))                return "wpa2enterprise";
+    if (!strcmp(uci,"wpa3"))                return "wpa3enterprise";
+    return "wpa2psk";
+}
+
+/* ─── Nombre de sección UCI seguro (máx 15 chars) ──────────────── */
+static void safe_section_name(const char *ssid, char *out, size_t sz)
+{
+    size_t j = 0;
+    for (size_t i = 0; ssid[i] && j < sz-1 && j < 15; i++) {
+        char c = ssid[i];
+        if ((c>='a'&&c<='z')||(c>='A'&&c<='Z')||
+            (c>='0'&&c<='9')||c=='_'||c=='-')
+            out[j++] = c;
+        else
+            out[j++] = '_';
+    }
+    out[j] = '\0';
+}
+
+/* ─── libuci: set un valor en wireless ─────────────────────────── */
+static int uci_set_val(struct uci_context *ctx,
+                       const char *path, const char *val)
+{
+    struct uci_ptr ptr;
+    char *p = malloc(strlen(path) + strlen(val) + 2);
+    if (!p) return -1;
+    sprintf(p, "%s=%s", path, val);
+    int ret = uci_lookup_ptr(ctx, &ptr, p, true);
+    free(p);
+    if (ret != UCI_OK) return -1;
+    return (uci_set(ctx, &ptr) == UCI_OK) ? 0 : -1;
+}
+
+/* Wrapper que formatea path y value en printf style */
+#define UCI_SET(ctx, pkg, sec, opt, val) do { \
+    char _path[256]; \
+    snprintf(_path, sizeof(_path), "%s.%s.%s", pkg, sec, opt); \
+    uci_set_val(ctx, _path, val); \
+} while(0)
+
+#define UCI_SET_INT(ctx, pkg, sec, opt, ival) do { \
+    char _v[32]; snprintf(_v, sizeof(_v), "%d", ival); \
+    UCI_SET(ctx, pkg, sec, opt, _v); \
+} while(0)
+
+/* ─── Encontrar/crear sección UCI ──────────────────────────────── */
+static int uci_ensure_section(struct uci_context *ctx,
+                               struct uci_package *pkg,
+                               const char *sec_name,
+                               const char *sec_type)
+{
+    struct uci_element *e;
+    uci_foreach_element(&pkg->sections, e) {
+        struct uci_section *s = uci_to_section(e);
+        if (!strcmp(s->e.name, sec_name) && !strcmp(s->type, sec_type))
+            return 0;  /* ya existe */
+    }
+    /* Create a named section: wireless.=. */
+    char *p = malloc(strlen(pkg->e.name) + strlen(sec_name) +
+                     strlen(sec_type) + 3);
+    if (!p) return -1;
+    sprintf(p, "%s.%s=%s", pkg->e.name, sec_name, sec_type);
+    struct uci_ptr ptr;
+    int ret = uci_lookup_ptr(ctx, &ptr, p, true);
+    if (ret == UCI_OK)
+        ret = uci_set(ctx, &ptr);
+    free(p);
+    return ret == UCI_OK ? 0 : -1;
+}
+
+static int ensure_vlan_network(int vid)
+{
+    struct uci_context *ctx = uci_alloc_context();
+    if (!ctx) return -1;
+
+    struct uci_package *pkg = NULL;
+    if (uci_load(ctx, "network", &pkg) != UCI_OK) {
+        uci_free_context(ctx);
+        return -1;
+    }
+
+    char device_section[48], interface_section[32];
+    char device_name[32], vid_string[16];
+    snprintf(device_section, sizeof(device_section),
+             "openuf_vlan%d", vid);
+    snprintf(interface_section, sizeof(interface_section),
+             "vlan%d", vid);
+    snprintf(device_name, sizeof(device_name), "br-lan.%d", vid);
+    snprintf(vid_string, sizeof(vid_string), "%d", vid);
+
+    int ok = uci_ensure_section(ctx, pkg, device_section, "device") == 0 &&
+             uci_ensure_section(ctx, pkg, interface_section, "interface") == 0;
+    if (ok) {
+        UCI_SET(ctx, "network", device_section, "type", "8021q");
+        UCI_SET(ctx, "network", device_section, "ifname", "br-lan");
+        UCI_SET(ctx, "network", device_section, "vid", vid_string);
+        UCI_SET(ctx, "network", device_section, "name", device_name);
+        UCI_SET(ctx, "network", interface_section, "proto", "none");
+        UCI_SET(ctx, "network", interface_section, "device", device_name);
+        ok = uci_commit(ctx, &pkg, false) == UCI_OK;
+    }
+
+    uci_unload(ctx, pkg);
+    uci_free_context(ctx);
+    if (ok)
+        printf("[openuf] Configured VLAN %d as network '%s' on br-lan\n",
+               vid, interface_section);
+    return ok ? 0 : -1;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   wlan_clear — remove all VAPs before applying controller ownership
+   ═══════════════════════════════════════════════════════════════════ */
+void wlan_clear(void)
+{
+    struct uci_context *ctx = uci_alloc_context();
+    if (!ctx) return;
+
+    struct uci_package *pkg = NULL;
+    if (uci_load(ctx, "wireless", &pkg) != UCI_OK) {
+        uci_free_context(ctx);
+        return;
+    }
+
+    /* Recopilar secciones a eliminar (no modificar durante iteración) */
+    char *to_del[64];
+    int ndel = 0;
+    struct uci_element *e;
+    uci_foreach_element(&pkg->sections, e) {
+        struct uci_section *s = uci_to_section(e);
+        if (!strcmp(s->type, "wifi-iface") && ndel < 64) {
+            to_del[ndel++] = strdup(s->e.name);
+        }
+    }
+
+    for (int i = 0; i < ndel; i++) {
+        struct uci_ptr ptr;
+        char path[128];
+        snprintf(path, sizeof(path), "wireless.%s", to_del[i]);
+        if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK)
+            uci_delete(ctx, &ptr);
+        free(to_del[i]);
+    }
+
+    if (ndel > 0) {
+        uci_commit(ctx, &pkg, false);
+        printf("[openuf] wlan_clear: removed %d existing VAPs\n", ndel);
+    }
+
+    uci_unload(ctx, pkg);
+    uci_free_context(ctx);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   wlan_apply_radio — aplicar config de radio (canal, HT, potencia)
+   ═══════════════════════════════════════════════════════════════════
+
+   Lectura de parámetros del JSON del controlador:
+     channel     → wireless..channel
+     ht          → wireless..htmode ("HT20" / "HT40" / "HT80" / "HE80")
+     tx_power    → wireless..txpower
+     min_rssi    → no se mapea a UCI (requiere daemon externo)
+*/
+void wlan_apply_radio(struct json_object *radio_json,
+                      const char *device_name)
+{
+    struct uci_context *ctx = uci_alloc_context();
+    if (!ctx) return;
+
+    struct uci_package *pkg = NULL;
+    if (uci_load(ctx, "wireless", &pkg) != UCI_OK) {
+        uci_free_context(ctx); return;
+    }
+
+    struct json_object *v;
+    char path[256];
+
+    /* Map UniFi band names to OpenWrt mac80211 band names. */
+    if (json_object_object_get_ex(radio_json, "radio", &v)) {
+        const char *radio = json_object_get_string(v);
+        const char *band = !strcmp(radio, "ng") ? "2g" :
+                           !strcmp(radio, "na") ? "5g" :
+                           !strcmp(radio, "6g") ? "6g" : NULL;
+        if (band) {
+            snprintf(path, sizeof(path), "wireless.%s.band=%s",
+                     device_name, band);
+            struct uci_ptr ptr;
+            if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK)
+                uci_set(ctx, &ptr);
+        }
+    }
+
+#define RP(key, uci_opt) \
+    if (json_object_object_get_ex(radio_json, key, &v)) { \
+        snprintf(path, sizeof(path), "wireless.%s.%s=%s", \
+                 device_name, uci_opt, json_object_get_string(v)); \
+        struct uci_ptr ptr; \
+        if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) \
+            uci_set(ctx, &ptr); \
+    }
+
+    RP("ht",       "htmode");
+
+    /* Canal: 0 = auto en UniFi */
+    if (json_object_object_get_ex(radio_json, "channel", &v)) {
+        int ch = json_object_get_int(v);
+        if (ch == 0) {
+            snprintf(path, sizeof(path), "wireless.%s.channel=auto", device_name);
+        } else {
+            snprintf(path, sizeof(path), "wireless.%s.channel=%d", device_name, ch);
+        }
+        struct uci_ptr ptr;
+        if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK)
+            uci_set(ctx, &ptr);
+    }
+
+    /* tx_power */
+    if (json_object_object_get_ex(radio_json, "tx_power", &v)) {
+        snprintf(path, sizeof(path), "wireless.%s.txpower=%d",
+                 device_name, json_object_get_int(v));
+        struct uci_ptr ptr;
+        if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK)
+            uci_set(ctx, &ptr);
+    }
+
+    /* Habilitar el radio */
+    snprintf(path, sizeof(path), "wireless.%s.disabled=0", device_name);
+    struct uci_ptr ptr;
+    if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK)
+        uci_set(ctx, &ptr);
+
+#undef RP
+
+    uci_commit(ctx, &pkg, false);
+    uci_unload(ctx, pkg);
+    uci_free_context(ctx);
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   Crear una VAP (wifi-iface UCI) desde un JSON VAP del controlador
+   ═══════════════════════════════════════════════════════════════════
+
+   Parámetros del controlador que leemos y cómo los mapeamos:
+
+   essid                → wireless.openuf_X.ssid
+   x_passphrase         → wireless.openuf_X.key
+   security             → wireless.openuf_X.encryption (via sec_to_uci)
+   hide_ssid            → wireless.openuf_X.hidden
+   guest_policy         → wireless.openuf_X.isolate (aislamiento de clientes)
+   fast_roaming_enabled → ieee80211r, ft_over_ds, mobility_domain, ft_psk_generate_local
+   band_steering        → ieee80211k, ieee80211v, rrm_neighbor_report, bss_transition
+   pmf_mode             → ieee80211w (0/1/2)
+   wpa3_support         → añadir "sae-mixed" si WPA2+WPA3
+   uapsd                → uapsd (U-APSD power saving)
+   vlan_id              → wireless.openuf_X.vlan_id (si ≠ 0)
+*/
+static int apply_vap(struct uci_context *ctx,
+                     struct uci_package *pkg,
+                     struct json_object *vap_json,
+                     const char *device_name,
+                     const char *mac_str,
+                     int vap_idx)
+{
+    struct json_object *v;
+    const char *essid    = "";
+    const char *security = "wpa2psk";
+    const char *pass     = "";
+
+    if (json_object_object_get_ex(vap_json, "essid",       &v)) essid    = json_object_get_string(v);
+    if (json_object_object_get_ex(vap_json, "security",    &v)) security = json_object_get_string(v);
+    if (json_object_object_get_ex(vap_json, "x_passphrase",&v)) pass     = json_object_get_string(v);
+
+    /* Nombre de sección: openuf__ */
+    char safe[16] = {0};
+    safe_section_name(essid, safe, sizeof(safe));
+    char sec_name[48];
+    snprintf(sec_name, sizeof(sec_name), "openuf_%d_%s", vap_idx, safe);
+
+    if (uci_ensure_section(ctx, pkg, sec_name, "wifi-iface") != 0) {
+        printf("[openuf] Failed to create VAP section '%s'\n", sec_name);
+        return -1;
+    }
+
+    UCI_SET(ctx, "wireless", sec_name, "device",     device_name);
+    UCI_SET(ctx, "wireless", sec_name, "mode",       "ap");
+    UCI_SET(ctx, "wireless", sec_name, "ssid",       essid);
+    UCI_SET(ctx, "wireless", sec_name, "network",    "lan");
+    UCI_SET(ctx, "wireless", sec_name, "encryption", sec_to_uci(security));
+
+    /* Contraseña */
+    if (pass && pass[0] && strcmp(security,"open") != 0)
+        UCI_SET(ctx, "wireless", sec_name, "key", pass);
+
+    /* SSID oculto */
+    int hidden = 0;
+    if (json_object_object_get_ex(vap_json, "hide_ssid", &v))
+        hidden = json_object_get_boolean(v) ? 1 : 0;
+    UCI_SET_INT(ctx, "wireless", sec_name, "hidden", hidden);
+
+    /* Aislamiento de clientes (guest network) */
+    int isolate = 0;
+    if (json_object_object_get_ex(vap_json, "guest_policy", &v))
+        isolate = json_object_get_boolean(v) ? 1 : 0;
+    UCI_SET_INT(ctx, "wireless", sec_name, "isolate", isolate);
+
+    /* U-APSD (ahorro de energía para clientes móviles) */
+    int uapsd = 1;
+    if (json_object_object_get_ex(vap_json, "uapsd", &v))
+        uapsd = json_object_get_boolean(v) ? 1 : 0;
+    UCI_SET_INT(ctx, "wireless", sec_name, "uapsd", uapsd);
+
+    /* ── PMF (Protected Management Frames / 802.11w) ──────────────
+     * "disabled" → 0, "optional" → 1, "required" → 2
+     * WPA3 (sae/sae-mixed) siempre requiere ieee80211w=2 */
+    int pmf = 0;
+    if (json_object_object_get_ex(vap_json, "pmf_mode", &v)) {
+        const char *pm = json_object_get_string(v);
+        if (!strcmp(pm, "optional")) pmf = 1;
+        if (!strcmp(pm, "required")) pmf = 2;
+    }
+    /* WPA3 obliga PMF=2 */
+    if (!strcmp(security,"wpa3") || !strcmp(security,"wpa3transition") ||
+        !strcmp(security,"wpa3enterprise"))
+        pmf = 2;
+    UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211w", pmf);
+
+    /* ── Fast Roaming (802.11r FT) ────────────────────────────────
+     * Permite que los clientes se muevan entre APs sin re-autenticación
+     * completa. El handshake FT sólo tarda ~50ms vs ~200-300ms normal. */
+    int ft = 0;
+    if (json_object_object_get_ex(vap_json, "fast_roaming_enabled", &v))
+        ft = json_object_get_boolean(v) ? 1 : 0;
+    if (ft) {
+        UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211r",          1);
+        UCI_SET_INT(ctx, "wireless", sec_name, "ft_over_ds",          1);
+        UCI_SET_INT(ctx, "wireless", sec_name, "ft_psk_generate_local", 1);
+        /* mobility_domain: derivar de MAC del AP (2 bytes) */
+        char mdomain[8] = {0};
+        if (mac_str && strlen(mac_str) >= 5) {
+            /* Usar bytes 0 y 1 de la MAC como dominio */
+            char b0[3]={mac_str[0],mac_str[1],0};
+            char b1[3]={mac_str[3],mac_str[4],0};
+            unsigned int v0=0,v1=0;
+            sscanf(b0,"%x",&v0); sscanf(b1,"%x",&v1);
+            snprintf(mdomain, sizeof(mdomain), "%02x%02x", v0, v1);
+        } else {
+            strcpy(mdomain, "1234");
+        }
+        UCI_SET(ctx, "wireless", sec_name, "mobility_domain", mdomain);
+    } else {
+        UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211r", 0);
+    }
+
+    /* ── Band Steering (802.11k/v) ────────────────────────────────
+     * 802.11k: Neighbor Reports → el AP informa al cliente qué otros
+     *          APs existen para facilitar el roaming.
+     * 802.11v: BSS Transition Management → el AP puede "sugerir" al
+     *          cliente que se mueva a otro AP con mejor señal. */
+    int band_steer = 0;
+    if (json_object_object_get_ex(vap_json, "band_steering", &v))
+        band_steer = json_object_get_boolean(v) ? 1 : 0;
+    if (band_steer) {
+        UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211k",           1);
+        UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211v",           1);
+        UCI_SET_INT(ctx, "wireless", sec_name, "rrm_neighbor_report",  1);
+        UCI_SET_INT(ctx, "wireless", sec_name, "bss_transition",       1);
+    } else {
+        UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211k", 0);
+        UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211v", 0);
+    }
+
+    /* ── VLAN ──────────────────────────────────────────────────────
+     * Si vlan_id ≠ 0, configurar la interfaz con VLAN tagging. */
+    if (json_object_object_get_ex(vap_json, "vlan_id", &v)) {
+        int vid = json_object_get_int(v);
+        if (vid > 0) {
+            if (ensure_vlan_network(vid) != 0) {
+                printf("[openuf] Failed to configure VLAN network %d\n", vid);
+                return -1;
+            }
+            UCI_SET_INT(ctx, "wireless", sec_name, "vlan_id", vid);
+            /* Establecer network a vlanXXX si existe */
+            char vlan_net[32];
+            snprintf(vlan_net, sizeof(vlan_net), "vlan%d", vid);
+            UCI_SET(ctx, "wireless", sec_name, "network", vlan_net);
+        }
+    }
+
+    printf("[openuf] VAP '%s' → %s enc=%s ft=%d bs=%d pmf=%d\n",
+           essid, sec_name, sec_to_uci(security), ft, band_steer, pmf);
+    return 0;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   wlan_apply_config — aplicar configuración completa del controlador
+   ═══════════════════════════════════════════════════════════════════
+
+   Llamado desde inform.c → handle_response() cuando _type=="setstate".
+   config_json es el JSON completo del controlador.
+
+   Proceso:
+   1. Eliminar VAPs antiguas (prefijo openuf_)
+   2. Aplicar radio_table (canal, potencia, htmode) por radio
+   3. Crear una VAP por cada entrada en vap_table
+   4. Hacer commit UCI
+   5. Ejecutar "wifi reload" para aplicar sin reiniciar el AP
+*/
+int wlan_apply_config(struct json_object *config_json,
+                      const uf_model_t *model)
+{
+    struct json_object *rt_arr = NULL, *vt_arr = NULL, *v;
+    json_object_object_get_ex(config_json, "radio_table", &rt_arr);
+    json_object_object_get_ex(config_json, "vap_table",   &vt_arr);
+
+    /* Obtener MAC del AP para mobility_domain */
+    char mac_str[32] = "00:00:00:00:00:00";
+    {
+        char path[128];
+        snprintf(path, sizeof(path), "/sys/class/net/eth0/address");
+        FILE *f = fopen(path, "r");
+        if (f) { fgets(mac_str, sizeof(mac_str), f); fclose(f); }
+        mac_str[strcspn(mac_str, "\r\n")] = '\0';
+    }
+
+    /* 1. Limpiar VAPs antiguas */
+    wlan_clear();
+
+    /* 2. Aplicar radio_table */
+    if (rt_arr && json_object_is_type(rt_arr, json_type_array)) {
+        int nr = json_object_array_length(rt_arr);
+        for (int i = 0; i < nr; i++) {
+            struct json_object *r = json_object_array_get_idx(rt_arr, i);
+            if (!r) continue;
+            /* Buscar el device UCI correspondiente a esta banda */
+            const char *radio_band = "";
+            if (json_object_object_get_ex(r, "radio", &v))
+                radio_band = json_object_get_string(v);
+            const char *device_name = "radio0";
+            for (int j = 0; j < model->radio_map_len; j++) {
+                if (!strcmp(model->radio_map[j].band, radio_band)) {
+                    device_name = model->radio_map[j].device;
+                    break;
+                }
+            }
+            wlan_apply_radio(r, device_name);
+        }
+    }
+
+    /*
+     * Load the package after the per-radio commits, otherwise this context
+     * contains a stale copy that can overwrite those changes on commit.
+     */
+    struct uci_context *ctx = uci_alloc_context();
+    if (!ctx) {
+        printf("[openuf] Failed to allocate UCI context\n");
+        return -1;
+    }
+    struct uci_package *pkg = NULL;
+    if (uci_load(ctx, "wireless", &pkg) != UCI_OK) {
+        char *uci_error = NULL;
+        uci_get_errorstr(ctx, &uci_error, "wireless");
+        printf("[openuf] Failed to load UCI wireless configuration: %s\n",
+               uci_error ? uci_error : "unknown UCI error");
+        free(uci_error);
+        uci_free_context(ctx);
+        return -1;
+    }
+
+    /*
+     * Once UniFi provisioning owns Wi-Fi, disable OpenWrt's generated
+     * default VAPs. Leaving them enabled keeps broadcasting "OpenWrt"
+     * alongside the controller-managed SSIDs.
+     */
+    int disabled_defaults = 0;
+    struct uci_element *default_element;
+    uci_foreach_element(&pkg->sections, default_element) {
+        struct uci_section *section = uci_to_section(default_element);
+        if (!strcmp(section->type, "wifi-iface") &&
+            !strncmp(section->e.name, "default_radio", 13)) {
+            UCI_SET(ctx, "wireless", section->e.name, "disabled", "1");
+            disabled_defaults++;
+        }
+    }
+    if (disabled_defaults)
+        printf("[openuf] Disabled %d default OpenWrt VAPs\n",
+               disabled_defaults);
+
+    /* 3. Crear VAPs */
+    if (vt_arr && json_object_is_type(vt_arr, json_type_array)) {
+        int nv = json_object_array_length(vt_arr);
+        for (int i = 0; i < nv; i++) {
+            struct json_object *vap = json_object_array_get_idx(vt_arr, i);
+            if (!vap) continue;
+
+            /* Buscar device UCI para este VAP */
+            const char *radio_band = "ng";
+            if (json_object_object_get_ex(vap, "radio", &v))
+                radio_band = json_object_get_string(v);
+            const char *device_name = "radio0";
+            for (int j = 0; j < model->radio_map_len; j++) {
+                if (!strcmp(model->radio_map[j].band, radio_band)) {
+                    device_name = model->radio_map[j].device;
+                    break;
+                }
+            }
+            if (apply_vap(ctx, pkg, vap, device_name, mac_str, i) != 0) {
+                uci_unload(ctx, pkg);
+                uci_free_context(ctx);
+                return -1;
+            }
+        }
+    }
+
+    /* 4. Commit UCI */
+    if (uci_commit(ctx, &pkg, false) != UCI_OK) {
+        char *uci_error = NULL;
+        uci_get_errorstr(ctx, &uci_error, "wireless");
+        printf("[openuf] Failed to commit UCI wireless configuration: %s\n",
+               uci_error ? uci_error : "unknown UCI error");
+        free(uci_error);
+        uci_unload(ctx, pkg);
+        uci_free_context(ctx);
+        return -1;
+    }
+    uci_unload(ctx, pkg);
+    uci_free_context(ctx);
+
+    /* 5. Aplicar cambios sin reiniciar (wifi reload recarga hostapd) */
+    printf("[openuf] Running wifi reload...\n");
+    system("ubus call network reload >/dev/null 2>&1");
+    system("wifi reload 2>/dev/null &");
+    return 0;
+}
+
+static int system_cfg_get(const char *cfg, const char *key,
+                          char *out, size_t out_size)
+{
+    size_t key_len = strlen(key);
+    const char *line = cfg;
+
+    while (line && *line) {
+        const char *end = strchr(line, '\n');
+        size_t line_len = end ? (size_t)(end - line) : strlen(line);
+        if (line_len > key_len && !strncmp(line, key, key_len) &&
+            line[key_len] == '=') {
+            size_t value_len = line_len - key_len - 1;
+            if (value_len >= out_size) value_len = out_size - 1;
+            memcpy(out, line + key_len + 1, value_len);
+            out[value_len] = '\0';
+            return 1;
+        }
+        line = end ? end + 1 : NULL;
+    }
+    return 0;
+}
+
+int wlan_apply_system_cfg(const char *system_cfg,
+                          const uf_model_t *model)
+{
+    if (!system_cfg || !system_cfg[0])
+        return -1;
+
+    struct json_object *root = json_object_new_object();
+    struct json_object *radios = json_object_new_array();
+    struct json_object *vaps = json_object_new_array();
+    char key[64], value[256];
+
+    for (int i = 1; i <= 4; i++) {
+        snprintf(key, sizeof(key), "radio.%d.ieee_mode", i);
+        if (!system_cfg_get(system_cfg, key, value, sizeof(value)))
+            continue;
+
+        struct json_object *radio = json_object_new_object();
+        const char *band = strstr(value, "11na") ? "na" : "ng";
+        json_object_object_add(radio, "radio",
+                               json_object_new_string(band));
+
+        const char *ht = strstr(value, "ht80") ? "HT80" :
+                         strstr(value, "ht40") ? "HT40" : "HT20";
+        json_object_object_add(radio, "ht", json_object_new_string(ht));
+
+        snprintf(key, sizeof(key), "radio.%d.channel", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value)))
+            json_object_object_add(radio, "channel",
+                json_object_new_int(!strcmp(value, "auto") ? 0 : atoi(value)));
+
+        snprintf(key, sizeof(key), "radio.%d.txpower", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value)) &&
+            strcmp(value, "auto"))
+            json_object_object_add(radio, "tx_power",
+                                   json_object_new_int(atoi(value)));
+        json_object_array_add(radios, radio);
+    }
+
+    for (int i = 1; i <= 32; i++) {
+        snprintf(key, sizeof(key), "aaa.%d.ssid", i);
+        if (!system_cfg_get(system_cfg, key, value, sizeof(value)))
+            continue;
+
+        struct json_object *vap = json_object_new_object();
+        json_object_object_add(vap, "essid",
+                               json_object_new_string(value));
+
+        snprintf(key, sizeof(key), "aaa.%d.status", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value)) &&
+            strcmp(value, "enabled")) {
+            json_object_put(vap);
+            continue;
+        }
+
+        snprintf(key, sizeof(key), "wireless.%d.parent", i);
+        const char *band = "ng";
+        if (system_cfg_get(system_cfg, key, value, sizeof(value)) &&
+            !strcmp(value, "wifi1"))
+            band = "na";
+        json_object_object_add(vap, "radio", json_object_new_string(band));
+
+        snprintf(key, sizeof(key), "aaa.%d.wpa.psk", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value))) {
+            json_object_object_add(vap, "security",
+                                   json_object_new_string("wpa2psk"));
+            json_object_object_add(vap, "x_passphrase",
+                                   json_object_new_string(value));
+        } else {
+            json_object_object_add(vap, "security",
+                                   json_object_new_string("open"));
+        }
+
+        snprintf(key, sizeof(key), "aaa.%d.hide_ssid", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value)))
+            json_object_object_add(vap, "hide_ssid",
+                json_object_new_boolean(!strcmp(value, "true")));
+
+        snprintf(key, sizeof(key), "aaa.%d.ft.status", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value)))
+            json_object_object_add(vap, "fast_roaming_enabled",
+                json_object_new_boolean(!strcmp(value, "enabled")));
+
+        snprintf(key, sizeof(key), "aaa.%d.pmf.mode", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value))) {
+            const char *pmf = !strcmp(value, "2") ? "required" :
+                              !strcmp(value, "1") ? "optional" : "disabled";
+            json_object_object_add(vap, "pmf_mode",
+                                   json_object_new_string(pmf));
+        }
+
+        snprintf(key, sizeof(key), "aaa.%d.br.devname", i);
+        if (system_cfg_get(system_cfg, key, value, sizeof(value))) {
+            const char *dot = strrchr(value, '.');
+            if (dot && atoi(dot + 1) > 0)
+                json_object_object_add(vap, "vlan_id",
+                                       json_object_new_int(atoi(dot + 1)));
+        }
+        json_object_array_add(vaps, vap);
+    }
+
+    json_object_object_add(root, "radio_table", radios);
+    json_object_object_add(root, "vap_table", vaps);
+    printf("[openuf] Parsed legacy system_cfg: %zu radios, %zu VAPs\n",
+           json_object_array_length(radios), json_object_array_length(vaps));
+    int result = wlan_apply_config(root, model);
+    json_object_put(root);
+    return result;
+}
+
+/* ═══════════════════════════════════════════════════════════════════
+   wlan_get_vap_table — leer VAPs activas desde UCI
+   ═══════════════════════════════════════════════════════════════════
+
+   Itera todas las wifi-iface con prefijo "openuf_" en /etc/config/wireless
+   y construye el JSON vap_table para incluir en el payload inform.
+
+   Campos que leemos de UCI → campos en el JSON:
+     ssid       → essid
+     device     → (usado para buscar radio y BSSID)
+     encryption → security (via sec_to_unifi)
+     hidden     → hide_ssid
+     ieee80211r → fast_roaming_enabled
+     ieee80211k → band_steering
+     ieee80211w → pmf_mode ("disabled"/"optional"/"required")
+     disabled   → up (inverso)
+
+   También intentamos leer el BSSID real de la interfaz wlan
+   desde /sys/class/net//address.
+*/
+struct json_object *wlan_get_vap_table(const uf_model_t *model)
+{
+    struct json_object *arr = json_object_new_array();
+
+    struct uci_context *ctx = uci_alloc_context();
+    if (!ctx) return arr;
+
+    struct uci_package *pkg = NULL;
+    if (uci_load(ctx, "wireless", &pkg) != UCI_OK) {
+        uci_free_context(ctx);
+        return arr;
+    }
+
+    struct uci_element *e;
+    uci_foreach_element(&pkg->sections, e) {
+        struct uci_section *sec = uci_to_section(e);
+        if (strcmp(sec->type, "wifi-iface") != 0) continue;
+        /* Solo reportar VAPs gestionadas por openuf */
+        if (strncmp(sec->e.name, "openuf_", 7) != 0) continue;
+
+#define UCI_GET(opt) uci_lookup_option_string(ctx, sec, opt)
+
+        const char *ssid   = UCI_GET("ssid");
+        const char *device = UCI_GET("device");
+        const char *enc    = UCI_GET("encryption");
+        const char *dis    = UCI_GET("disabled");
+        const char *r11    = UCI_GET("ieee80211r");
+        const char *k11    = UCI_GET("ieee80211k");
+        const char *w11    = UCI_GET("ieee80211w");
+        const char *hidden = UCI_GET("hidden");
+
+        if (!ssid) ssid = "";
+        if (!device) device = "radio0";
+
+        /* Banda de este radio */
+        const char *radio_band = "ng";
+        for (int j = 0; j < model->radio_map_len; j++) {
+            if (!strcmp(model->radio_map[j].device, device)) {
+                radio_band = model->radio_map[j].band;
+                break;
+            }
+        }
+
+        /* Nombre de la interfaz wlan (wlan0 para radio0, etc.) */
+        char wlan_iface[32] = "wlan0";
+        int ridx = 0;
+        sscanf(device, "radio%d", &ridx);
+        snprintf(wlan_iface, sizeof(wlan_iface), "wlan%d", ridx);
+
+        /* Leer BSSID real desde sysfs */
+        char bssid[32] = "00:00:00:00:00:00";
+        {
+            char path[128];
+            snprintf(path, sizeof(path), "/sys/class/net/%s/address", wlan_iface);
+            FILE *f = fopen(path, "r");
+            if (f) {
+                fgets(bssid, sizeof(bssid), f); fclose(f);
+                bssid[strcspn(bssid, "\r\n")] = '\0';
+            }
+        }
+
+        /* PMF: ieee80211w → "disabled"/"optional"/"required" */
+        const char *pmf = "disabled";
+        if (w11) {
+            if (!strcmp(w11,"1")) pmf = "optional";
+            if (!strcmp(w11,"2")) pmf = "required";
+        }
+
+        bool ft_on = (r11 && !strcmp(r11,"1"));
+        bool bs_on = (k11 && !strcmp(k11,"1"));
+        bool hid   = (hidden && !strcmp(hidden,"1"));
+        bool up    = !(dis && !strcmp(dis,"1"));
+
+        struct json_object *o = json_object_new_object();
+        json_object_object_add(o, "essid",               json_object_new_string(ssid));
+        json_object_object_add(o, "bssid",               json_object_new_string(bssid));
+        json_object_object_add(o, "name",                json_object_new_string(sec->e.name));
+        json_object_object_add(o, "radio",               json_object_new_string(radio_band));
+        json_object_object_add(o, "security",            json_object_new_string(sec_to_unifi(enc)));
+        json_object_object_add(o, "up",                  json_object_new_boolean(up));
+        json_object_object_add(o, "hide_ssid",           json_object_new_boolean(hid));
+        json_object_object_add(o, "fast_roaming_enabled",json_object_new_boolean(ft_on));
+        json_object_object_add(o, "band_steering",       json_object_new_boolean(bs_on));
+        json_object_object_add(o, "pmf_mode",            json_object_new_string(pmf));
+        json_object_object_add(o, "num_sta",             json_object_new_int(0));
+        json_object_array_add(arr, o);
+#undef UCI_GET
+    }
+
+    uci_unload(ctx, pkg);
+    uci_free_context(ctx);
+    return arr;
+}
diff --git a/src/wlan.h b/src/wlan.h
new file mode 100644
index 0000000..fb20580
--- /dev/null
+++ b/src/wlan.h
@@ -0,0 +1,36 @@
+#ifndef OPENUF_WLAN_H
+#define OPENUF_WLAN_H
+
+#include 
+#include "ufmodel.h"
+
+/*
+ * Translate UniFi WLAN/VAP config into OpenWrt UCI wireless settings.
+ * All openuf-managed interfaces are named  openuf_NN_  so they
+ * can be safely removed on re-provision.
+ */
+
+/* Remove all UCI wifi-iface sections whose name starts with "openuf_" */
+void wlan_clear(void);
+
+/* Apply radio-level settings from a UniFi radio_table entry.
+ * radio_json : JSON object with fields: channel, ht, tx_power
+ * device_name: OpenWrt radio device ("radio0", "radio1")  */
+void wlan_apply_radio(struct json_object *radio_json,
+                      const char *device_name);
+
+/* Apply full config pushed by controller (setstate).
+ * config_json: decoded setstate JSON object
+ * model      : model descriptor for radio_map lookup */
+int wlan_apply_config(struct json_object *config_json,
+                      const uf_model_t *model);
+
+/* Apply the legacy newline-separated system_cfg format. */
+int wlan_apply_system_cfg(const char *system_cfg,
+                          const uf_model_t *model);
+
+/* Build vap_table JSON array from current UCI state.
+ * Caller owns returned json_object. */
+struct json_object *wlan_get_vap_table(const uf_model_t *model);
+
+#endif /* OPENUF_WLAN_H */