[SOLVED] Custom menu "Associated Stations": from luci-lua-runtime to JSON

Hi all,

starting from the "old" post https://forum.openwrt.org/t/dumb-ap-associated-stations-resolver/153358/69 me & AI managed to get the custom menu Associated Stations working satisfactorily as expected.

Here follows what I did:

  1. Installed luci-lua-runtime

  2. Created the /pr_home/associated_stations.sh script:

associated_stations.sh
#!/bin/sh

# Script: /pr_home/associated_stations.sh

# Purpose: Generate raw HTML content (styles and table data) for LuCI to capture and display.

# Ref. https://forum.openwrt.org/t/dumb-ap-associated-stations-resolver/153358/69

# It runs onboard two dumb AP, so the script uses /etc/hosts and /etc/ethers to get IP and MAC

TIMESTAMP=$(date '+%d/%m/%Y - %H:%M:%S')

# Establish who I am
ROUTER=$(uci get system.@system[0].hostname)


# ****** Generate the HTML header and CSS block (captured as output) ******

cat << EOF
    <style>
        /* Embed all necessary CSS directly */
        .wrapper {
            font-family: Arial, sans-serif;
            margin: 0px;
        }
        h1 {
            font-size: 1.8em;
            text-align: center;
            margin-bottom: 20px;
        }
        .timestamp {
            text-align: center;
            font-size: 0.9em;
            color: #666;
            margin-bottom: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
            margin-top: 20px;
        }
        th, td {
            border: 1px solid #ddd;
            padding: 8px;
            text-align: left;
            font-size: 0.9em;
        }
        th {
            background-color: #f2f2f2;
        }
        .perfect-signal {
            color: green;
        }
        .good-signal {
            color: blue;
        }
        .medium-signal {
            color: orange;
        }
        .unstable-connection {
            color: purple;
        }
        .unlikely-connection {
            color: red;
        }
        .unknown {
            color: #999;
            font-style: italic;
        }

        /* Responsive table */
        @media (max-width: 768px) {
            table {
                width: 100%;
                border: none;
                font-size: 0.8em;
            }
            th, td {
                padding: 8px;
                text-align: left;
                display: block;
                width: 100%;
            }
            td {
                border-top: 1px solid #ddd;
            }
            th {
                display: none;
            }
            tr {
                margin-bottom: 15px;
                display: block;
                background-color: #fff;
                padding: 10px;
                box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
            }
            td:before {
                content: attr(data-label);
                font-weight: bold;
                display: block;
                margin-bottom: 5px;
            }
        }
    </style>
<div class="wrapper">
<h1>$ROUTER's Associated Stations and IPv4 Neighbours</h1>
<p class="timestamp">Last Updated: $TIMESTAMP</p>

<center><p> Signal Strength:                                
<span class='perfect-signal'>Perfect signal: green </span> -
<span class='good-signal'>Good signal: blue </span> -
<span class='medium-signal'>Medium signal: orange </span> -
<span class='unstable-connection'>Unstable connection: purple </span> -
<span class='unlikely-connection'>Unlikely connection: red </span>
</p></center>

<center><p> Status:                                
<span class='perfect-signal'>REACHABLE: the neighbour entry is valid until the reachability timeout expires</span> -
<span class='good-signal'>STALE: the neighbour entry is valid but considered suspicious; this state doesn't change the neighbour</span> -
<span class='unlikely-connection'>FAILED: the maximum number of probes has been exceeded without success; neighbour validation has ultimately failed </span>
</p></center>

<table>
    <thead>
        <tr>
            <th>Device</th>
            <th>Freq or Speed</th>
            <th>MAC Address</th>
            <th>Host</th>
            <th>IP Address</th>
            <th>Signal Strength or Status</th>
            <th>RX MBit/s</th>
            <th>TX MBit/s</th>
        </tr>
    </thead>
    <tbody>
EOF


#****** Get the list of wireless interfaces ******

for DEVICE in $(iwinfo | grep ESSID | cut -d' ' -f1); do

    # Get clients for this interface
    iwinfo $DEVICE assoclist | while read -r line; do
        if echo "$line" | grep -q "dBm"; then

            case $ROUTER in
                Alfa )

                    if [ $DEVICE == "phy1-ap0" ]; then
                        FREQ="2.4 GHz"
                    else 
                        FREQ="5 GHz"
                    fi;;

                Bravo )

                    if [ $DEVICE == "phy1-ap0" ]; then
                        FREQ="5 GHz"
                    else 
                        FREQ="2.4 GHz"
                    fi;;

                * )
                 # Other interfaces
                 exit 0;;
      
            esac

            # Extract MAC and signal strength
            MAC=$(echo "$line" | awk '{print $1}')
            SIGNAL=$(echo $line | awk '{print $2}')

            # Get hostname and IP from ethers and hosts
            HOSTNAME=$(grep -i "$MAC" /etc/ethers | awk '{print $2}')

            # Skip to the next two lines to extract RX and TX
            read -r line_rx
            RX=$(echo "$line_rx" | grep -o 'RX: [0-9.]\+' | awk '{print $2}')
            read -r line_tx
            TX=$(echo "$line_tx" | grep -o 'TX: [0-9.]\+' | awk '{print $2}')
          
            if [ ! -z "$HOSTNAME" ]; then
                IP=$(grep -iE "$HOSTNAME"$ /etc/hosts | awk '{print $1}')
            else
                IP="Unknown"
                HOSTNAME=$(echo "$MAC" | sed 's/://g')
            fi
            
            # Format signal strength with color coding
            if [ ! -z "$SIGNAL" ]; then
                
                if [ $SIGNAL -ge -30 ]; then
                    SIGNAL_DISPLAY="<span class='perfect-signal'>${SIGNAL} dBm</span>"          
                elif [ $SIGNAL -ge -50 ]; then
                    SIGNAL_DISPLAY="<span class='good-signal'>${SIGNAL} dBm</span>"
                elif [ $SIGNAL -ge -70 ]; then
                    SIGNAL_DISPLAY="<span class='medium-signal'>${SIGNAL} dBm</span>"
                elif [ $SIGNAL -ge -80 ]; then
                    SIGNAL_DISPLAY="<span class='unstable-connection'>${SIGNAL} dBm</span>" 
                else
                    SIGNAL_DISPLAY="<span class='unlikely-connection'>${SIGNAL} dBm</span>"
                fi
            else
                SIGNAL_DISPLAY="<span class='unknown'>No Signal</span>"
            fi
            
            # Add row to the HTML table with data-labels for mobile view (no redirection)
            echo "<tr><td data-label='Device'>$DEVICE</td><td data-label='Freq or Speed'>$FREQ</td><td data-label='MAC Address'>$MAC</td><td data-label='Host'>$HOSTNAME</td><td data-label='IP Address'>$IP</td><td data-label='Signal Strength or Status'>$SIGNAL_DISPLAY</td><td data-label='RX'>$RX</td><td data-label='TX'>$TX</td></tr>"
        fi
    done
done


#****** Get the IPv4 neighbours for br-lan interface scanning nud states for 3 states: reachable, stale, failed ******

DEVICE="br-lan"

ip neigh show dev br-lan nud reachable | while read -r line; do
        
    SPEED=$(cat /sys/class/net/$DEVICE/speed)

    # Extract STATUS
    STATUS=$(echo "$line" | awk '{print $10}')
   
    # Extract IP
    IP=$(echo "$line" | awk '{print $1}')
   
    # Extract MAC
    MAC=$(echo "$line" | awk '{print $3}')
   
    # Get hostname from ethers
    HOSTNAME=$(grep -i "$MAC" /etc/ethers | awk '{print $2}')

    STATUS_DISPLAY="<span class='perfect-signal'>${STATUS}</span>"  
   
    # Add row to the HTML table with data-labels for mobile view (no redirection)
    echo "<tr><td data-label='Device'>$DEVICE</td><td data-label='Freq or Speed'>$SPEED</td><td data-label='MAC Address'>$MAC</td><td data-label='Host'>$HOSTNAME</td><td data-label='IP Address'>$IP</td><td data-label='Signal Strength or Status'>$STATUS_DISPLAY</td><td data-label='RX'>-</td><td data-label='TX'>-</td></tr>"

done

ip neigh show dev br-lan nud stale | while read -r line; do
    
    SPEED=$(cat /sys/class/net/$DEVICE/speed)
    
    # Extract STATUS
    STATUS=$(echo "$line" | awk '{print $8}')
    
    # Extract IP
    IP=$(echo "$line" | awk '{print $1}')
    
    # Extract MAC
    MAC=$(echo "$line" | awk '{print $3}')
    
    # Get hostname from ethers
    HOSTNAME=$(grep -i "$MAC" /etc/ethers | awk '{print $2}')

    STATUS_DISPLAY="<span class='good-signal'>${STATUS}</span>"  
    
    echo "<tr><td data-label='Device'>br-lan</td><td data-label='Freq or Speed'>$SPEED</td><td data-label='MAC Address'>$MAC</td><td data-label='Host'>$HOSTNAME</td><td data-label='IP Address'>$IP</td><td data-label='Signal Strength or Status'>$STATUS_DISPLAY</td><td data-label='RX'>-</td><td data-label='TX'>-</td></tr>"

done

ip neigh show dev br-lan nud failed | while read -r line; do

    SPEED=$(cat /sys/class/net/$DEVICE/speed)    
    
    # Extract STATUS
    STATUS=$(echo "$line" | awk '{print $6}')
    
    # Extract IP
    IP=$(echo "$line" | awk '{print $1}')
    
    # Extract MAC
    MAC=$(echo "$line" | awk '{print $3}')
    
    # Get hostname from hosts
    HOSTNAME=$(grep -E "^$IP\s" /etc/hosts | awk '{print $2}')
    
    STATUS_DISPLAY="<span class='unlikely-connection'>${STATUS}</span>"  

    echo "<tr><td data-label='Device'>br-lan</td><td data-label='Freq or Speed'>$SPEED</td><td data-label='MAC Address'>$MAC</td><td data-label='Host'>$HOSTNAME</td><td data-label='IP Address'>$IP</td><td data-label='Signal Strength or Status'>$STATUS_DISPLAY</td><td data-label='RX'>-</td><td data-label='TX'>-</td></tr>"
done


#****** Generate the HTML footer to close the table and wrapper div ******

cat << EOF
    </tbody>
</table>
</div>
EOF
  1. Created the /usr/lib/lua/luci/controller/associated_stations.lua menu entry:
associated_stations.lua
module("luci.controller.associated_stations", package.seeall)

function index()
    -- Registers the top-level menu entry: /admin/associated_stations
    -- Using 'call' forces the execution of 'action_show', which registers the page reliably.
    entry({"admin", "associated_stations"}, call("action_show"), "Associated Stations", 70)
end

function action_show()
    -- Set no-cache headers to ensure data is always fresh
    luci.http.header("Cache-Control", "no-cache, no-store, must-revalidate")
    luci.http.header("Expires", "0")
    
    -- Renders the view template where the script execution takes place.
    luci.template.render("associated_stations/status")
end
  1. Created the /usr/lib/lua/luci/view/associated_stations directory to store the status.htm file:
status.htm
-- File /usr/lib/lua/luci/view/associated_stations/status.htm

<%+header%>

<%
    -- 1. Execute the shell script and capture its raw HTML output
    local script_output = luci.sys.exec("/pr_home/associated_stations.sh")
    
    -- 2. Print the output (raw HTML) without escaping the content
    luci.http.write(script_output) 
%>

<%+footer%>

As previously stated, the above works very well.

Unfortunately, we (me & AI) failed to migrate it from luci-lua-runtime to the newer JSON framework: can you help me to do that?

Thanks in advance.

Hammered AI and got a solution:

  1. vi /usr/share/luci/menu.d/associated_stations.json
code
{
    "admin/associated_stations": {
        "title": "Associated Stations",
        "action": {
            "type": "view",
            "path": "associated_stations/status"
        },
        "order": 70
    }
}
  1. vi /pr_home/associated_stations.sh (chmod +x)
code
#!/bin/sh

# Script: /pr_home/associated_stations.sh
# It uses ./wifi-chipset-detect.sh to dynamically get phy* and freq - see https://forum.openwrt.org/t/wifi-chipset-driver-detection-script-works-with-radios-enabled-or-disabled-please-show-your-test-outputs-nov-2025/243275

TIMESTAMP=$(date '+%d/%m/%Y - %H:%M:%S')
ROUTER=$(uci get system.@system[0].hostname)

# Run the detection once at the start to avoid slowness in the loop
CHIPSET_DATA=$(/pr_home/wifi-chipset-detect.sh)

RAW_LIST=$(
    # Wireless Stations
    for DEVICE in $(iwinfo | grep ESSID | cut -d' ' -f1); do
        iwinfo "$DEVICE" assoclist > /tmp/assoclist.tmp
        
        # Determine the PHY base name (e.g., extract 'phy0' from 'phy0-ap0')
        PHY_BASE=$(echo "$DEVICE" | cut -d'-' -f1)
        
        # Dynamically extract the first band associated with this PHY from the detection data
        FREQ=$(echo "$CHIPSET_DATA" | awk -v p="$PHY_BASE" '
            $1 ~ "\""p"\"" {found=1} 
            found && /"Bands":/ {getline; gsub(/[", ]/, ""); f=$0; found=0} 
            END {print (f=="" ? "Unknown" : f)}
        ')

        while read -r line; do
            if echo "$line" | grep -q "dBm"; then
                MAC=$(echo "$line" | awk '{print $1}')
                SIGNAL=$(echo "$line" | awk '{print $2}')
                HOSTNAME=$(grep -i "$MAC" /etc/ethers | awk '{print $2}')
                
                read -r line_rx
                RX=$(echo "$line_rx" | grep -o 'RX: [0-9.]\+' | awk '{print $2}')
                read -r line_tx
                TX=$(echo "$line_tx" | grep -o 'TX: [0-9.]\+' | awk '{print $2}')
                
                [ -z "$HOSTNAME" ] && HOSTNAME=$(echo "$MAC" | sed 's/://g')
                IP=$(grep -iE "$HOSTNAME"$ /etc/hosts | awk '{print $1}')
                [ -z "$IP" ] && IP="Unknown"
                
                printf ",{\"device\":\"%s\",\"freq_speed\":\"%s\",\"mac\":\"%s\",\"host\":\"%s\",\"ip\":\"%s\",\"signal_val\":\"%s\",\"rx\":\"%s\",\"tx\":\"%s\",\"type\":\"wireless\"}" \
                    "$DEVICE" "$FREQ" "$MAC" "$HOSTNAME" "$IP" "$SIGNAL" "$RX" "$TX"
            fi
        done < /tmp/assoclist.tmp
    done

    # IPv4 Neighbours (Wired/Bridge)
    DEVICE_LAN="br-lan"
    SPEED=$(cat /sys/class/net/$DEVICE_LAN/speed 2>/dev/null || echo "1000")
    for NUD in reachable stale failed; do
        ip neigh show dev "$DEVICE_LAN" nud "$NUD" | while read -r line; do
            IP=$(echo "$line" | awk '{print $1}')
            MAC=$(echo "$line" | awk '{print $3}')
            case $NUD in
                reachable) STATUS=$(echo "$line" | awk '{print $10}') ;;
                stale)     STATUS=$(echo "$line" | awk '{print $8}') ;;
                failed)    STATUS=$(echo "$line" | awk '{print $6}') ;;
            esac
            HOSTNAME=$(grep -i "$MAC" /etc/ethers | awk '{print $2}')
            [ -z "$HOSTNAME" ] && HOSTNAME=$(grep -E "^$IP\s" /etc/hosts | awk '{print $2}')
            [ -z "$HOSTNAME" ] && HOSTNAME="Unknown"
            printf ",{\"device\":\"%s\",\"freq_speed\":\"%s Mbit/s\",\"mac\":\"%s\",\"host\":\"%s\",\"ip\":\"%s\",\"signal_val\":\"%s\",\"rx\":\"-\",\"tx\":\"-\",\"type\":\"neighbor\"}" \
                "$DEVICE_LAN" "$SPEED" "$MAC" "$HOSTNAME" "$IP" "$STATUS"
        done
    done
)

echo "{\"router\": \"$ROUTER\",\"timestamp\": \"$TIMESTAMP\",\"results\": [$(echo "${RAW_LIST}" | sed 's/^,//')]}"
rm -f /tmp/assoclist.tmp

  1. mkdir /www/luci-static/resources/view/associated_stations and vi /www/luci-static/resources/view/associated_stations/status.js
code
'use strict';
'require view';
'require fs';
'require ui';

return view.extend({
    load: function() {
        return fs.exec('/pr_home/associated_stations.sh').then(function(res) {
            try { 
                return JSON.parse(res.stdout); 
            } catch (e) { 
                return { results: [] }; 
            }
        });
    },

    render: function(data) {
        var styles = E('style', {}, `
            .perfect-signal { color: green; font-weight: bold; }
            .good-signal { color: blue; }
            .medium-signal { color: orange; }
            .unstable-connection { color: purple; }
            .unlikely-connection { color: red; }
            
            .legend-container { text-align: center !important; margin-bottom: 12px; line-height: 1.5; font-size: 0.9em; width: 100%; display: block; }
            h2.centered-title { text-align: center !important; width: 100%; display: block; margin: 15px 0 5px 0; }
            .timestamp-centered { text-align: center; color: #666; margin-bottom: 15px; font-size: 0.85em; }
            
            .swipe-hint { display: none; text-align: center; color: #0066cc; font-size: 0.8em; margin-bottom: 5px; font-weight: bold; }
            
            .table-scroll-box {
                width: 100%;
                overflow-x: auto;
                -webkit-overflow-scrolling: touch;
                border: 1px solid #ddd;
                background: #fff;
                margin-bottom: 30px;
            }

            .station-table { 
                display: table; 
                width: 100%; 
                min-width: 700px; 
                border-collapse: collapse; 
                table-layout: fixed;
                margin-left: auto;
                margin-right: auto;
            }

            .grid-header { display: table-row; background: #f2f2f2; font-weight: bold; }
            .grid-row { display: table-row; }

            .grid-cell {
                display: table-cell;
                border-bottom: 1px solid #ddd;
                border-right: 1px solid #ddd;
                padding: 6px 2px;
                text-align: center;
                font-size: 0.85em;
                vertical-align: middle;
                overflow: hidden;
            }

            .grid-cell.split-header { 
                white-space: normal !important; 
                line-height: 1.1;
                word-break: break-word;
            }

            .grid-row .grid-cell { white-space: nowrap; }

            /* --- STRICT COLUMN WIDTHS --- */
            .col-dev  { width: 65px !important; }
            .col-freq { width: 65px !important; }
            .col-mac  { width: 125px !important; }
            .col-host { width: 110px !important; max-width: 110px; text-overflow: ellipsis; } 
            .col-ip   { width: 90px !important; }
            .col-stat { width: 85px !important; }
            .col-rx   { width: 50px !important; }
            .col-tx   { width: 50px !important; }

            .grid-cell:last-child { border-right: none; }
            .grid-row:last-child .grid-cell { border-bottom: none; }

            @media screen and (max-width: 720px) {
                .swipe-hint { display: block; }
            }
        `);

        var table = E('div', { 'class': 'station-table' });
        
        table.appendChild(E('div', { 'class': 'grid-header' }, [
            E('div', { 'class': 'grid-cell col-dev' }, _('Device')),
            E('div', { 'class': 'grid-cell split-header col-freq' }, [ _('Freq'), E('br'), _('or Speed') ]),
            E('div', { 'class': 'grid-cell col-mac' }, _('MAC Address')),
            E('div', { 'class': 'grid-cell col-host' }, _('Host')),
            E('div', { 'class': 'grid-cell col-ip' }, _('IP Address')),
            E('div', { 'class': 'grid-cell split-header col-stat' }, [ _('Signal'), E('br'), _('Strength'), E('br'), _('or Status') ]),
            E('div', { 'class': 'grid-cell col-rx' }, _('RX')),
            E('div', { 'class': 'grid-cell col-tx' }, _('TX'))
        ]));

        (data.results || []).forEach(function(s) {
            var signalStyle = 'unknown', val = s.signal_val;
            
            if (s.type === 'wireless') {
                var sig = parseInt(val);
                if (sig >= -30) signalStyle = 'perfect-signal';
                else if (sig >= -50) signalStyle = 'good-signal';
                else if (sig >= -70) signalStyle = 'medium-signal';
                else if (sig >= -80) signalStyle = 'unstable-connection';
                else signalStyle = 'unlikely-connection';
                val = val + ' dBm';
            } else {
                if (val === 'REACHABLE') signalStyle = 'perfect-signal';
                else if (val === 'STALE') signalStyle = 'good-signal';
                else signalStyle = 'unlikely-connection';
            }

            // --- MAC Uppercase Logic Applied Here ---
            var formattedMac = s.mac ? s.mac.toUpperCase() : '';

            table.appendChild(E('div', { 'class': 'grid-row' }, [
                E('div', { 'class': 'grid-cell col-dev' }, s.device),
                E('div', { 'class': 'grid-cell col-freq' }, s.freq_speed),
                E('div', { 'class': 'grid-cell col-mac' }, formattedMac),
                E('div', { 'class': 'grid-cell col-host' }, s.host),
                E('div', { 'class': 'grid-cell col-ip' }, s.ip),
                E('div', { 'class': 'grid-cell col-stat' }, E('span', { 'class': signalStyle }, val)),
                E('div', { 'class': 'grid-cell col-rx' }, s.rx),
                E('div', { 'class': 'grid-cell col-tx' }, s.tx)
            ]));
        });

        return E('div', { 'class': 'cbi-map' }, [
            styles,
            E('h2', { 'class': 'centered-title' }, (data.router || 'Router') + _("'s Associated Stations and IPv4 Neighbours")),
            E('p', { 'class': 'timestamp-centered' }, _('Last Updated: ') + data.timestamp),
            
            E('div', { 'class': 'legend-container' }, [
                E('strong', {}, _('Signal Strength: ')),
                E('span', { 'class': 'perfect-signal' }, _('Perfect signal: green')), ' - ',
                E('span', { 'class': 'good-signal' }, _('Good signal: blue')), ' - ',
                E('span', { 'class': 'medium-signal' }, _('Medium signal: orange')), ' - ',
                E('span', { 'class': 'unstable-connection' }, _('Unstable connection: purple')), ' - ',
                E('span', { 'class': 'unlikely-connection' }, _('Unlikely connection: red'))
            ]),

            E('div', { 'class': 'legend-container' }, [
                E('strong', {}, _('Status: ')),
                E('span', { 'class': 'perfect-signal' }, _('REACHABLE: the neighbour entry is valid until the reachability timeout expires')), ' - ',
                E('span', { 'class': 'good-signal' }, _('STALE: the neighbour entry is valid but considered suspicious; this state doesn\'t change the neighbour')), ' - ',
                E('span', { 'class': 'unlikely-connection' }, _('FAILED: the maximum number of probes has been exceeded without success; neighbour validation has ultimately failed'))
            ]),

            E('div', { 'class': 'swipe-hint' }, _('↔ Swipe table to view all columns')),
            E('div', { 'class': 'table-scroll-box' }, table)
        ]);
    },
    handleSaveApply: null, handleSave: null, handleReset: null
});
  1. vi /usr/share/rpcd/acl.d/luci-app-associated-stations.json
code
{
	"luci-app-associated-stations": {
		"description": "Grant access to the associated stations script",
		"read": {
			"file": {
				"/pr_home/associated_stations.sh": [ "exec" ]
			}
		}
	}
}
  1. To test: rm -rf /tmp/luci-indexcache /tmp/luci-modulecache and /etc/init.d/rpcd restart

Works well launched on both PC and mobile phone UI; nevertheless, it's way beyond my current skill level... Enjoy.

Updated: changed /pr_home/associated_stations.sh to dynamically get phy* and freq by means of wifi-chipset-detect.sh - thanks to WiFi chipset + driver detection script – works with radios enabled or disabled. Please show your test outputs! (Nov 2025)”

2 Likes

Nice.

But why using

maybe better some dir that everyone have.

Legacy setting... a custom dir stored onboard the USB key attached to my second dumb AP, where this script was originally born :wink: Nevertheless, it can be easily adapted.