Skip to content

Commit af12501

Browse files
luci-app-wifihistory: add WiFi station history tracking
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: DeborahOlaboye <deboraholaboye@gmail.com> Signed-off-by: Deborah Olaboye <deboraholaboye@gmail.com>
1 parent 63ddd66 commit af12501

File tree

7 files changed

+409
-0
lines changed

7 files changed

+409
-0
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
include $(TOPDIR)/rules.mk
2+
3+
LUCI_TITLE:=LuCI support for WiFi Station History
4+
LUCI_DEPENDS:=+luci-base +rpcd-mod-iwinfo +ucode +ucode-mod-fs +ucode-mod-ubus
5+
LUCI_DESCRIPTION:=Track and display history of WiFi associated stations
6+
7+
PKG_LICENSE:=Apache-2.0
8+
9+
include ../../luci.mk
10+
11+
# call BuildPackage - OpenWrt buildroot signature
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
'use strict';
2+
'require view';
3+
'require poll';
4+
'require rpc';
5+
'require ui';
6+
7+
const callGetHistory = rpc.declare({
8+
object: 'luci.wifihistory',
9+
method: 'getHistory',
10+
expect: { history: {} }
11+
});
12+
13+
const callClearHistory = rpc.declare({
14+
object: 'luci.wifihistory',
15+
method: 'clearHistory',
16+
expect: { result: true }
17+
});
18+
19+
function formatDate(epoch) {
20+
if (!epoch || epoch <= 0)
21+
return '-';
22+
23+
return new Date(epoch * 1000).toLocaleString();
24+
}
25+
26+
return view.extend({
27+
load() {
28+
return callGetHistory();
29+
},
30+
31+
updateTable(table, history) {
32+
const stations = Object.values(history);
33+
34+
/* Sort connected stations first, then by most recently seen */
35+
stations.sort((a, b) => {
36+
if (a.connected !== b.connected)
37+
return a.connected ? -1 : 1;
38+
39+
return (b.last_seen || 0) - (a.last_seen || 0);
40+
});
41+
42+
const rows = [];
43+
44+
for (const s of stations) {
45+
let hint;
46+
if (s.hostname && s.ipv4 && s.ipv6)
47+
hint = '%s (%s, %s)'.format(s.hostname, s.ipv4, s.ipv6);
48+
else if (s.hostname && (s.ipv4 || s.ipv6))
49+
hint = '%s (%s)'.format(s.hostname, s.ipv4 || s.ipv6);
50+
else if (s.ipv4 || s.ipv6)
51+
hint = s.ipv4 || s.ipv6;
52+
else
53+
hint = '-';
54+
55+
let sig_value = '-';
56+
let sig_title = '';
57+
58+
if (s.signal && s.signal !== 0) {
59+
if (s.noise && s.noise !== 0) {
60+
sig_value = '%d/%d\xa0%s'.format(s.signal, s.noise, _('dBm'));
61+
sig_title = '%s: %d %s / %s: %d %s / %s %d'.format(
62+
_('Signal'), s.signal, _('dBm'),
63+
_('Noise'), s.noise, _('dBm'),
64+
_('SNR'), s.signal - s.noise);
65+
}
66+
else {
67+
sig_value = '%d\xa0%s'.format(s.signal, _('dBm'));
68+
sig_title = '%s: %d %s'.format(_('Signal'), s.signal, _('dBm'));
69+
}
70+
}
71+
72+
let icon;
73+
if (!s.connected) {
74+
icon = L.resource('icons/signal-none.svg');
75+
}
76+
else {
77+
/* Estimate signal quality as percentage:
78+
* Map dBm range [-110, -40] to [0%, 100%] */
79+
const q = Math.min((s.signal + 110) / 70 * 100, 100);
80+
if (q == 0)
81+
icon = L.resource('icons/signal-000-000.svg');
82+
else if (q < 25)
83+
icon = L.resource('icons/signal-000-025.svg');
84+
else if (q < 50)
85+
icon = L.resource('icons/signal-025-050.svg');
86+
else if (q < 75)
87+
icon = L.resource('icons/signal-050-075.svg');
88+
else
89+
icon = L.resource('icons/signal-075-100.svg');
90+
}
91+
92+
rows.push([
93+
E('span', {
94+
'class': 'ifacebadge',
95+
'style': s.connected ? '' : 'opacity:0.5',
96+
'title': s.connected ? _('Connected') : _('Disconnected')
97+
}, [
98+
E('img', { 'src': icon, 'style': 'width:16px;height:16px' }),
99+
E('span', {}, [ ' ', s.connected ? _('Yes') : _('No') ])
100+
]),
101+
s.mac,
102+
hint,
103+
s.network || '-',
104+
E('span', { 'title': sig_title }, sig_value),
105+
formatDate(s.first_seen),
106+
formatDate(s.last_seen)
107+
]);
108+
}
109+
110+
cbi_update_table(table, rows, E('em', _('No station history available')));
111+
},
112+
113+
handleClearHistory(ev) {
114+
return ui.showModal(_('Clear Station History'), [
115+
E('p', _('This will permanently delete all recorded station history. Are you sure?')),
116+
E('div', { 'class': 'right' }, [
117+
E('button', {
118+
'class': 'btn',
119+
'click': ui.hideModal
120+
}, _('Cancel')), ' ',
121+
E('button', {
122+
'class': 'btn cbi-button-negative',
123+
'click': ui.createHandlerFn(this, function() {
124+
return callClearHistory().then(L.bind(function() {
125+
ui.hideModal();
126+
return callGetHistory().then(L.bind(function(history) {
127+
this.updateTable('#wifi_history_table', history);
128+
}, this));
129+
}, this));
130+
})
131+
}, _('Clear'))
132+
])
133+
]);
134+
},
135+
136+
render(history) {
137+
const isReadonlyView = !L.hasViewPermission();
138+
139+
const v = E([], [
140+
E('h2', _('Station History')),
141+
E('div', { 'class': 'cbi-map-descr' },
142+
_('This page displays a history of all WiFi stations that have connected to this device, including currently connected and previously seen devices.')),
143+
144+
E('div', { 'class': 'cbi-section' }, [
145+
E('div', { 'class': 'right', 'style': 'margin-bottom:1em' }, [
146+
E('button', {
147+
'class': 'btn cbi-button-negative',
148+
'disabled': isReadonlyView,
149+
'click': ui.createHandlerFn(this, 'handleClearHistory')
150+
}, _('Clear History'))
151+
]),
152+
153+
E('table', { 'class': 'table', 'id': 'wifi_history_table' }, [
154+
E('tr', { 'class': 'tr table-titles' }, [
155+
E('th', { 'class': 'th' }, _('Connected')),
156+
E('th', { 'class': 'th' }, _('MAC address')),
157+
E('th', { 'class': 'th' }, _('Host')),
158+
E('th', { 'class': 'th' }, _('Network')),
159+
E('th', { 'class': 'th' }, '%s / %s'.format(_('Signal'), _('Noise'))),
160+
E('th', { 'class': 'th' }, _('First seen')),
161+
E('th', { 'class': 'th' }, _('Last seen'))
162+
])
163+
])
164+
])
165+
]);
166+
167+
this.updateTable(v.querySelector('#wifi_history_table'), history);
168+
169+
poll.add(L.bind(function() {
170+
return callGetHistory().then(L.bind(function(history) {
171+
this.updateTable('#wifi_history_table', history);
172+
}, this));
173+
}, this), 5);
174+
175+
return v;
176+
},
177+
178+
handleSaveApply: null,
179+
handleSave: null,
180+
handleReset: null
181+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#!/bin/sh /etc/rc.common
2+
3+
START=99
4+
STOP=10
5+
USE_PROCD=1
6+
7+
NAME=wifihistory
8+
PROG=/usr/sbin/wifihistory
9+
INTERVAL=30
10+
11+
start_service() {
12+
mkdir -p /var/lib/wifihistory
13+
14+
procd_open_instance "$NAME"
15+
procd_set_param command /bin/sh -c "while true; do /usr/bin/ucode $PROG; sleep $INTERVAL; done"
16+
procd_set_param respawn 3600 5 5
17+
procd_set_param stdout 1
18+
procd_set_param stderr 1
19+
procd_close_instance
20+
}
21+
22+
service_triggers() {
23+
procd_add_reload_trigger "wireless"
24+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env ucode
2+
3+
'use strict';
4+
5+
import { readfile, writefile, open, mkdir, rename } from 'fs';
6+
import { connect } from 'ubus';
7+
8+
const HISTORY_DIR = '/var/lib/wifihistory';
9+
const HISTORY_FILE = HISTORY_DIR + '/history.json';
10+
const LOCK_FILE = '/var/lock/wifihistory.lock';
11+
12+
function load_history() {
13+
let content = readfile(HISTORY_FILE);
14+
if (content == null)
15+
return {};
16+
17+
try {
18+
return json(content) || {};
19+
}
20+
catch (e) {
21+
return {};
22+
}
23+
}
24+
25+
function save_history(data) {
26+
let tmp = HISTORY_FILE + '.tmp';
27+
28+
writefile(tmp, sprintf('%J', data));
29+
rename(tmp, HISTORY_FILE);
30+
}
31+
32+
function poll_stations() {
33+
let ubus = connect();
34+
if (!ubus) {
35+
warn('Failed to connect to ubus\n');
36+
return;
37+
}
38+
39+
let now = time();
40+
let history = load_history();
41+
let seen_macs = {};
42+
43+
let wifi_status = ubus.call('network.wireless', 'status');
44+
if (!wifi_status)
45+
return;
46+
47+
let hints = ubus.call('luci-rpc', 'getHostHints') || {};
48+
49+
for (let radio in wifi_status) {
50+
let ifaces = wifi_status[radio]?.interfaces;
51+
if (!ifaces)
52+
continue;
53+
54+
for (let iface in ifaces) {
55+
let ifname = iface?.ifname;
56+
if (!ifname)
57+
continue;
58+
59+
let info = ubus.call('iwinfo', 'info', { device: ifname });
60+
let ssid = info?.ssid || '';
61+
62+
let assoc = ubus.call('iwinfo', 'assoclist', { device: ifname });
63+
if (!assoc?.results)
64+
continue;
65+
66+
for (let bss in assoc.results) {
67+
let mac = bss?.mac;
68+
if (!mac)
69+
continue;
70+
71+
mac = uc(mac);
72+
seen_macs[mac] = true;
73+
74+
let hostname = hints?.[mac]?.name || '';
75+
let ipv4 = hints?.[mac]?.ipaddrs?.[0] || '';
76+
let ipv6 = hints?.[mac]?.ip6addrs?.[0] || '';
77+
78+
let existing = history[mac];
79+
let first_seen = existing?.first_seen || now;
80+
81+
history[mac] = {
82+
mac: mac,
83+
hostname: hostname,
84+
ipv4: ipv4,
85+
ipv6: ipv6,
86+
network: ssid,
87+
ifname: ifname,
88+
connected: true,
89+
signal: bss?.signal || 0,
90+
noise: bss?.noise || 0,
91+
first_seen: first_seen,
92+
last_seen: now
93+
};
94+
}
95+
}
96+
}
97+
98+
for (let mac in history)
99+
if (!seen_macs[mac])
100+
history[mac].connected = false;
101+
102+
ubus.disconnect();
103+
104+
save_history(history);
105+
}
106+
107+
mkdir(HISTORY_DIR);
108+
109+
let lock_fd = open(LOCK_FILE, 'w');
110+
if (!lock_fd) {
111+
warn('Failed to open lock file\n');
112+
exit(1);
113+
}
114+
115+
if (!lock_fd.lock('xn')) {
116+
warn('Another instance is already running\n');
117+
lock_fd.close();
118+
exit(1);
119+
}
120+
121+
poll_stations();
122+
123+
lock_fd.lock('u');
124+
lock_fd.close();
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"admin/status/wifihistory": {
3+
"title": "Station History",
4+
"order": 8,
5+
"action": {
6+
"type": "view",
7+
"path": "status/wifihistory"
8+
},
9+
"depends": {
10+
"acl": [ "luci-app-wifihistory" ],
11+
"uci": { "wireless": { "@wifi-device": true } }
12+
}
13+
}
14+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"luci-app-wifihistory": {
3+
"description": "Grant access to WiFi station history",
4+
"read": {
5+
"ubus": {
6+
"iwinfo": [ "assoclist" ],
7+
"luci.wifihistory": [ "getHistory" ]
8+
},
9+
"file": {
10+
"/var/lib/wifihistory/history.json": [ "read" ]
11+
}
12+
},
13+
"write": {
14+
"ubus": {
15+
"luci.wifihistory": [ "clearHistory" ]
16+
},
17+
"file": {
18+
"/var/lib/wifihistory/history.json": [ "write" ]
19+
}
20+
}
21+
}
22+
}

0 commit comments

Comments
 (0)