luci-app-wifihistory: add WiFi station history
authorDeborah Olaboye <redacted>
Tue, 3 Feb 2026 12:34:16 +0000 (13:34 +0100)
committerPaul Donald <redacted>
Mon, 9 Feb 2026 04:02:51 +0000 (05:02 +0100)
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 <redacted>
applications/luci-app-wifihistory/Makefile [new file with mode: 0644]
applications/luci-app-wifihistory/htdocs/luci-static/resources/view/status/wifihistory.js [new file with mode: 0644]
applications/luci-app-wifihistory/root/etc/init.d/wifihistory [new file with mode: 0755]
applications/luci-app-wifihistory/root/usr/sbin/wifihistory [new file with mode: 0755]
applications/luci-app-wifihistory/root/usr/share/luci/menu.d/luci-app-wifihistory.json [new file with mode: 0644]
applications/luci-app-wifihistory/root/usr/share/rpcd/acl.d/luci-app-wifihistory.json [new file with mode: 0644]
applications/luci-app-wifihistory/root/usr/share/rpcd/ucode/wifihistory.uc [new file with mode: 0644]

diff --git a/applications/luci-app-wifihistory/Makefile b/applications/luci-app-wifihistory/Makefile
new file mode 100644 (file)
index 0000000..1127059
--- /dev/null
@@ -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
diff --git a/applications/luci-app-wifihistory/htdocs/luci-static/resources/view/status/wifihistory.js b/applications/luci-app-wifihistory/htdocs/luci-static/resources/view/status/wifihistory.js
new file mode 100644 (file)
index 0000000..076c97a
--- /dev/null
@@ -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
+});
diff --git a/applications/luci-app-wifihistory/root/etc/init.d/wifihistory b/applications/luci-app-wifihistory/root/etc/init.d/wifihistory
new file mode 100755 (executable)
index 0000000..2811c68
--- /dev/null
@@ -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"
+}
diff --git a/applications/luci-app-wifihistory/root/usr/sbin/wifihistory b/applications/luci-app-wifihistory/root/usr/sbin/wifihistory
new file mode 100755 (executable)
index 0000000..c0cfcd2
--- /dev/null
@@ -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();
diff --git a/applications/luci-app-wifihistory/root/usr/share/luci/menu.d/luci-app-wifihistory.json b/applications/luci-app-wifihistory/root/usr/share/luci/menu.d/luci-app-wifihistory.json
new file mode 100644 (file)
index 0000000..b43adda
--- /dev/null
@@ -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 } }
+               }
+       }
+}
diff --git a/applications/luci-app-wifihistory/root/usr/share/rpcd/acl.d/luci-app-wifihistory.json b/applications/luci-app-wifihistory/root/usr/share/rpcd/acl.d/luci-app-wifihistory.json
new file mode 100644 (file)
index 0000000..b0f790f
--- /dev/null
@@ -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" ]
+                       }
+               }
+       }
+}
diff --git a/applications/luci-app-wifihistory/root/usr/share/rpcd/ucode/wifihistory.uc b/applications/luci-app-wifihistory/root/usr/share/rpcd/ucode/wifihistory.uc
new file mode 100644 (file)
index 0000000..918ad98
--- /dev/null
@@ -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 };
git clone https://git.99rst.org/PROJECT