luci-app-wifihistory: add WiFi station history

Add a new application that tracks WiFi associated stations
over time and displays history of connected devices. A ucode
service polls iwinfo assoclist and persists station data to
a JSON file. The LuCI view shows connected/disconnected
status, MAC, hostname, signal, and timestamps.

Closes: #8109
Signed-off-by: Deborah Olaboye <deboraholaboye@gmail.com>
This commit is contained in:
Deborah Olaboye
2026-02-03 13:34:16 +01:00
committed by Paul Donald
parent 7d6784fa59
commit 9c4b2ee8de
7 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI support for WiFi Station History
LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +ucode +ucode-mod-fs +ucode-mod-ubus
LUCI_DESCRIPTION:=Track and display history of WiFi associated stations
PKG_LICENSE:=Apache-2.0
include ../../luci.mk
# call BuildPackage - OpenWrt buildroot signature

View File

@@ -0,0 +1,181 @@
'use strict';
'require view';
'require poll';
'require rpc';
'require ui';
const callGetHistory = rpc.declare({
object: 'luci.wifihistory',
method: 'getHistory',
expect: { history: {} }
});
const callClearHistory = rpc.declare({
object: 'luci.wifihistory',
method: 'clearHistory',
expect: { result: true }
});
function formatDate(epoch) {
if (!epoch || epoch <= 0)
return '-';
return new Date(epoch * 1000).toLocaleString();
}
return view.extend({
load() {
return callGetHistory();
},
updateTable(table, history) {
const stations = Object.values(history);
/* Sort connected stations first, then by most recently seen */
stations.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return (b.last_seen || 0) - (a.last_seen || 0);
});
const rows = [];
for (const s of stations) {
let hint;
if (s.hostname && s.ipv4 && s.ipv6)
hint = '%s (%s, %s)'.format(s.hostname, s.ipv4, s.ipv6);
else if (s.hostname && (s.ipv4 || s.ipv6))
hint = '%s (%s)'.format(s.hostname, s.ipv4 || s.ipv6);
else if (s.ipv4 || s.ipv6)
hint = s.ipv4 || s.ipv6;
else
hint = '-';
let sig_value = '-';
let sig_title = '';
if (s.signal && s.signal !== 0) {
if (s.noise && s.noise !== 0) {
sig_value = '%d/%d\xa0%s'.format(s.signal, s.noise, _('dBm'));
sig_title = '%s: %d %s / %s: %d %s / %s %d'.format(
_('Signal'), s.signal, _('dBm'),
_('Noise'), s.noise, _('dBm'),
_('SNR'), s.signal - s.noise);
}
else {
sig_value = '%d\xa0%s'.format(s.signal, _('dBm'));
sig_title = '%s: %d %s'.format(_('Signal'), s.signal, _('dBm'));
}
}
let icon;
if (!s.connected) {
icon = L.resource('icons/signal-none.svg');
}
else {
/* Estimate signal quality as percentage:
* Map dBm range [-110, -40] to [0%, 100%] */
const q = Math.min((s.signal + 110) / 70 * 100, 100);
if (q == 0)
icon = L.resource('icons/signal-000-000.svg');
else if (q < 25)
icon = L.resource('icons/signal-000-025.svg');
else if (q < 50)
icon = L.resource('icons/signal-025-050.svg');
else if (q < 75)
icon = L.resource('icons/signal-050-075.svg');
else
icon = L.resource('icons/signal-075-100.svg');
}
rows.push([
E('span', {
'class': 'ifacebadge',
'style': s.connected ? '' : 'opacity:0.5',
'title': s.connected ? _('Connected') : _('Disconnected')
}, [
E('img', { 'src': icon, 'style': 'width:16px;height:16px' }),
E('span', {}, [ ' ', s.connected ? _('Yes') : _('No') ])
]),
s.mac,
hint,
s.network || '-',
E('span', { 'title': sig_title }, sig_value),
formatDate(s.first_seen),
formatDate(s.last_seen)
]);
}
cbi_update_table(table, rows, E('em', _('No station history available')));
},
handleClearHistory(ev) {
return ui.showModal(_('Clear Station History'), [
E('p', _('This will permanently delete all recorded station history. Are you sure?')),
E('div', { 'class': 'right' }, [
E('button', {
'class': 'btn',
'click': ui.hideModal
}, _('Cancel')), ' ',
E('button', {
'class': 'btn cbi-button-negative',
'click': ui.createHandlerFn(this, function() {
return callClearHistory().then(L.bind(function() {
ui.hideModal();
return callGetHistory().then(L.bind(function(history) {
this.updateTable('#wifi_history_table', history);
}, this));
}, this));
})
}, _('Clear'))
])
]);
},
render(history) {
const isReadonlyView = !L.hasViewPermission();
const v = E([], [
E('h2', _('Station History')),
E('div', { 'class': 'cbi-map-descr' },
_('This page displays a history of all WiFi stations that have connected to this device, including currently connected and previously seen devices.')),
E('div', { 'class': 'cbi-section' }, [
E('div', { 'class': 'right', 'style': 'margin-bottom:1em' }, [
E('button', {
'class': 'btn cbi-button-negative',
'disabled': isReadonlyView,
'click': ui.createHandlerFn(this, 'handleClearHistory')
}, _('Clear History'))
]),
E('table', { 'class': 'table', 'id': 'wifi_history_table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Connected')),
E('th', { 'class': 'th' }, _('MAC address')),
E('th', { 'class': 'th' }, _('Host')),
E('th', { 'class': 'th' }, _('Network')),
E('th', { 'class': 'th' }, '%s / %s'.format(_('Signal'), _('Noise'))),
E('th', { 'class': 'th' }, _('First seen')),
E('th', { 'class': 'th' }, _('Last seen'))
])
])
])
]);
this.updateTable(v.querySelector('#wifi_history_table'), history);
poll.add(L.bind(function() {
return callGetHistory().then(L.bind(function(history) {
this.updateTable('#wifi_history_table', history);
}, this));
}, this), 5);
return v;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});

View File

@@ -0,0 +1,24 @@
#!/bin/sh /etc/rc.common
START=99
STOP=10
USE_PROCD=1
NAME=wifihistory
PROG=/usr/sbin/wifihistory
INTERVAL=30
start_service() {
mkdir -p /var/lib/wifihistory
procd_open_instance "$NAME"
procd_set_param command /bin/sh -c "while true; do /usr/bin/ucode $PROG; sleep $INTERVAL; done"
procd_set_param respawn 3600 5 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "wireless"
}

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env ucode
'use strict';
import { readfile, writefile, open, mkdir, rename } from 'fs';
import { connect } from 'ubus';
const HISTORY_DIR = '/var/lib/wifihistory';
const HISTORY_FILE = HISTORY_DIR + '/history.json';
const LOCK_FILE = '/var/lock/wifihistory.lock';
function load_history() {
let content = readfile(HISTORY_FILE);
if (content == null)
return {};
try {
return json(content) || {};
}
catch (e) {
return {};
}
}
function save_history(data) {
let tmp = HISTORY_FILE + '.tmp';
writefile(tmp, sprintf('%J', data));
rename(tmp, HISTORY_FILE);
}
function poll_stations() {
let ubus = connect();
if (!ubus) {
warn('Failed to connect to ubus\n');
return;
}
let now = time();
let history = load_history();
let seen_macs = {};
let wifi_status = ubus.call('network.wireless', 'status');
if (!wifi_status)
return;
let hints = ubus.call('luci-rpc', 'getHostHints') || {};
for (let radio in wifi_status) {
let ifaces = wifi_status[radio]?.interfaces;
if (!ifaces)
continue;
for (let iface in ifaces) {
let ifname = iface?.ifname;
if (!ifname)
continue;
let info = ubus.call('iwinfo', 'info', { device: ifname });
let ssid = info?.ssid || '';
let assoc = ubus.call('iwinfo', 'assoclist', { device: ifname });
if (!assoc?.results)
continue;
for (let bss in assoc.results) {
let mac = bss?.mac;
if (!mac)
continue;
mac = uc(mac);
seen_macs[mac] = true;
let hostname = hints?.[mac]?.name || '';
let ipv4 = hints?.[mac]?.ipaddrs?.[0] || '';
let ipv6 = hints?.[mac]?.ip6addrs?.[0] || '';
let existing = history[mac];
let first_seen = existing?.first_seen || now;
history[mac] = {
mac: mac,
hostname: hostname,
ipv4: ipv4,
ipv6: ipv6,
network: ssid,
ifname: ifname,
connected: true,
signal: bss?.signal || 0,
noise: bss?.noise || 0,
first_seen: first_seen,
last_seen: now
};
}
}
}
for (let mac in history)
if (!seen_macs[mac])
history[mac].connected = false;
ubus.disconnect();
save_history(history);
}
mkdir(HISTORY_DIR);
let lock_fd = open(LOCK_FILE, 'w');
if (!lock_fd) {
warn('Failed to open lock file\n');
exit(1);
}
if (!lock_fd.lock('xn')) {
warn('Another instance is already running\n');
lock_fd.close();
exit(1);
}
poll_stations();
lock_fd.lock('u');
lock_fd.close();

View File

@@ -0,0 +1,14 @@
{
"admin/status/wifihistory": {
"title": "Station History",
"order": 8,
"action": {
"type": "view",
"path": "status/wifihistory"
},
"depends": {
"acl": [ "luci-app-wifihistory" ],
"uci": { "wireless": { "@wifi-device": true } }
}
}
}

View File

@@ -0,0 +1,16 @@
{
"luci-app-wifihistory": {
"description": "Grant access to WiFi station history",
"read": {
"ubus": {
"iwinfo": [ "assoclist" ],
"luci.wifihistory": [ "getHistory" ]
}
},
"write": {
"ubus": {
"luci.wifihistory": [ "clearHistory" ]
}
}
}
}

View File

@@ -0,0 +1,33 @@
#!/usr/bin/env ucode
'use strict';
import { readfile, writefile } from 'fs';
const HISTORY_FILE = '/var/lib/wifihistory/history.json';
const methods = {
getHistory: {
call: function() {
let content = readfile(HISTORY_FILE);
if (content == null)
return { history: {} };
try {
return { history: json(content) || {} };
}
catch (e) {
return { history: {} };
}
}
},
clearHistory: {
call: function() {
writefile(HISTORY_FILE, '{}');
return { result: true };
}
}
};
return { 'luci.wifihistory': methods };