Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions applications/luci-app-wifihistory/Makefile
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
});
24 changes: 24 additions & 0 deletions applications/luci-app-wifihistory/root/etc/init.d/wifihistory
Original file line number Diff line number Diff line change
@@ -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"
}
124 changes: 124 additions & 0 deletions applications/luci-app-wifihistory/root/usr/sbin/wifihistory
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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 } }
}
}
}
Original file line number Diff line number Diff line change
@@ -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" ]
}
}
}
}
Loading