From 9c4b2ee8dedd4df676ee3e85dc5bda112236806a Mon Sep 17 00:00:00 2001 From: Deborah Olaboye Date: Tue, 3 Feb 2026 13:34:16 +0100 Subject: [PATCH] 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 --- applications/luci-app-wifihistory/Makefile | 11 ++ .../resources/view/status/wifihistory.js | 181 ++++++++++++++++++ .../root/etc/init.d/wifihistory | 24 +++ .../root/usr/sbin/wifihistory | 124 ++++++++++++ .../luci/menu.d/luci-app-wifihistory.json | 14 ++ .../rpcd/acl.d/luci-app-wifihistory.json | 16 ++ .../root/usr/share/rpcd/ucode/wifihistory.uc | 33 ++++ 7 files changed, 403 insertions(+) create mode 100644 applications/luci-app-wifihistory/Makefile create mode 100644 applications/luci-app-wifihistory/htdocs/luci-static/resources/view/status/wifihistory.js create mode 100755 applications/luci-app-wifihistory/root/etc/init.d/wifihistory create mode 100755 applications/luci-app-wifihistory/root/usr/sbin/wifihistory create mode 100644 applications/luci-app-wifihistory/root/usr/share/luci/menu.d/luci-app-wifihistory.json create mode 100644 applications/luci-app-wifihistory/root/usr/share/rpcd/acl.d/luci-app-wifihistory.json create mode 100644 applications/luci-app-wifihistory/root/usr/share/rpcd/ucode/wifihistory.uc diff --git a/applications/luci-app-wifihistory/Makefile b/applications/luci-app-wifihistory/Makefile new file mode 100644 index 0000000000..112705920d --- /dev/null +++ b/applications/luci-app-wifihistory/Makefile @@ -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 index 0000000000..076c97ab5e --- /dev/null +++ b/applications/luci-app-wifihistory/htdocs/luci-static/resources/view/status/wifihistory.js @@ -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 index 0000000000..2811c68422 --- /dev/null +++ b/applications/luci-app-wifihistory/root/etc/init.d/wifihistory @@ -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 index 0000000000..c0cfcd2a6f --- /dev/null +++ b/applications/luci-app-wifihistory/root/usr/sbin/wifihistory @@ -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 index 0000000000..b43addaf7e --- /dev/null +++ b/applications/luci-app-wifihistory/root/usr/share/luci/menu.d/luci-app-wifihistory.json @@ -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 index 0000000000..b0f790fc41 --- /dev/null +++ b/applications/luci-app-wifihistory/root/usr/share/rpcd/acl.d/luci-app-wifihistory.json @@ -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 index 0000000000..918ad9818b --- /dev/null +++ b/applications/luci-app-wifihistory/root/usr/share/rpcd/ucode/wifihistory.uc @@ -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 };