release: Mark VIII 1.1
Wireless client mode (connect to WiFi as client): - settings/wifi/client API: state, scan, connect, disconnect, route - Internet Connection topbar dialog and functional Settings > Networking card - routing toggle syncing UCI flag and daemon state - fix trailing-slash hash routes (View not available) - hidden-SSID filtering, encryption classification (Open/WPA2/WPA3/mixed) - tests for client state, scan parsing, connect/disconnect, routing
This commit is contained in:
@@ -1803,7 +1803,8 @@ function payloadCard(item, actions, tags) {
|
||||
|
||||
function setPayloadBusy(button, busy, label) {
|
||||
button.disabled = busy;
|
||||
if (label) button.textContent = busy ? label : button.dataset.label;
|
||||
button.textContent = busy ? (label || button.dataset.label || button.textContent)
|
||||
: (button.dataset.label || button.textContent);
|
||||
}
|
||||
|
||||
views.modules = (root) => {
|
||||
@@ -2042,6 +2043,213 @@ function apiError(message) {
|
||||
return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error');
|
||||
}
|
||||
|
||||
const WIFI_CLIENT_ENC_VALUES = {
|
||||
'Open': 'open', 'WPA2': 'wpa2', 'WPA3': 'wpa3', 'WPA2/WPA3': 'wpa2wpa3'
|
||||
};
|
||||
|
||||
function clientModeEncValue(label) {
|
||||
return WIFI_CLIENT_ENC_VALUES[label] || (label && label !== 'Open' ? 'wpa2' : 'open');
|
||||
}
|
||||
|
||||
function clientModePanel(box, options) {
|
||||
const state = { status: null, networks: [], connecting: false, generation: 0 };
|
||||
const statusEl = h('div', { class: 'client-status' });
|
||||
const routingSwitch = h('input', { type: 'checkbox' });
|
||||
const routingRow = h('label', { class: 'switch client-routing' },
|
||||
routingSwitch, h('span', { class: 'track' }), 'Route LAN clients through this connection');
|
||||
const actionsEl = h('div', { class: 'client-actions' });
|
||||
const netsEl = h('div', { class: 'client-networks' });
|
||||
|
||||
routingSwitch.addEventListener('change', () => {
|
||||
if (!(state.status || {}).enabled) {
|
||||
routingSwitch.checked = false;
|
||||
App.toast('Client mode must be enabled before routing can be changed', 'error');
|
||||
return;
|
||||
}
|
||||
PagerAPI.post('/api/settings/wifi/client/route', { routed: routingSwitch.checked })
|
||||
.then(() => {
|
||||
if (state.status) state.status.routed = routingSwitch.checked;
|
||||
App.toast('Client routing ' + (routingSwitch.checked ? 'enabled' : 'disabled'));
|
||||
App.checkInternet(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
routingSwitch.checked = !routingSwitch.checked;
|
||||
App.toast(e.message || 'Failed to update routing', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
function renderStatus() {
|
||||
const s = state.status || {};
|
||||
statusEl.innerHTML = '';
|
||||
const lines = [];
|
||||
if (s.connected) {
|
||||
lines.push(['Status', 'Connected'], ['Network', s.connected_ssid || s.ssid || '\u2014'],
|
||||
['IP Address', s.ip || '\u2014'],
|
||||
['Signal', s.signal != null ? s.signal + ' dBm' : '\u2014']);
|
||||
} else if (s.enabled) {
|
||||
lines.push(['Status', 'Enabled \u2014 not associated'], ['Network', s.ssid || '\u2014']);
|
||||
} else {
|
||||
lines.push(['Status', 'Disabled']);
|
||||
}
|
||||
lines.forEach(([k, v]) => statusEl.appendChild(h('div', { class: 'client-kv' },
|
||||
h('span', { text: k }), h('code', { text: v }))));
|
||||
routingSwitch.checked = !!s.routed;
|
||||
routingRow.classList.toggle('hidden', !s.enabled);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
PagerAPI.get('/api/settings/wifi/client').then((r) => {
|
||||
state.status = r.data || {};
|
||||
renderStatus();
|
||||
}).catch(apiError('Failed to load client mode status'));
|
||||
}
|
||||
|
||||
function renderNetworks() {
|
||||
netsEl.innerHTML = '';
|
||||
if (!state.networks.length) {
|
||||
netsEl.appendChild(h('div', { class: 'empty', text: 'No networks shown. Scan to discover nearby WiFi.' }));
|
||||
return;
|
||||
}
|
||||
state.networks.forEach((net) => {
|
||||
const needsPw = net.encryption !== 'Open';
|
||||
const row = h('div', { class: 'client-net-row' },
|
||||
h('div', { class: 'client-net-main' },
|
||||
h('span', { class: 'client-net-ssid', text: net.ssid || '(hidden)' }),
|
||||
h('span', { class: 'client-net-enc', text: net.encryption })),
|
||||
h('span', { class: 'client-net-signal', text: net.signal != null ? net.signal + ' dBm' : '\u2014' }),
|
||||
btn(needsPw ? 'Connect' : 'Join', () => connectRow(row, net), needsPw ? '' : 'primary'));
|
||||
netsEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function connectRow(row, net) {
|
||||
row.innerHTML = '';
|
||||
const encSel = h('select', {},
|
||||
h('option', { value: 'wpa2', text: 'WPA2' }),
|
||||
h('option', { value: 'wpa3', text: 'WPA3' }),
|
||||
h('option', { value: 'wpa2wpa3', text: 'WPA2/WPA3' }));
|
||||
const value = clientModeEncValue(net.encryption);
|
||||
encSel.value = value === 'open' ? 'wpa2' : value;
|
||||
const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'new-password' });
|
||||
const routedCb = h('input', { type: 'checkbox' });
|
||||
const routedLabel = h('label', { class: 'switch client-routing' }, routedCb,
|
||||
h('span', { class: 'track' }), 'Route LAN clients');
|
||||
const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel,
|
||||
btn('Connect', () => doConnect({ ssid: net.ssid, encryption: encSel.value,
|
||||
password: pw.value, routed: routedCb.checked }), 'primary'),
|
||||
btn('Cancel', () => renderNetworks(), 'ghost'));
|
||||
row.appendChild(form);
|
||||
pw.focus();
|
||||
}
|
||||
|
||||
function doConnect(opts) {
|
||||
if (state.connecting) return;
|
||||
state.connecting = true;
|
||||
setBusy(true);
|
||||
PagerAPI.post('/api/settings/wifi/client/connect', opts)
|
||||
.then(() => {
|
||||
App.toast('Connecting to ' + opts.ssid + '\u2026');
|
||||
state.status = null;
|
||||
pollConnected(opts.ssid);
|
||||
})
|
||||
.catch((e) => {
|
||||
state.connecting = false;
|
||||
setBusy(false);
|
||||
App.toast(e.message || 'Failed to connect', 'error');
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function pollConnected(targetSsid) {
|
||||
const gen = ++state.generation;
|
||||
let attempts = 0;
|
||||
let ipWait = 0;
|
||||
const tick = () => {
|
||||
if (gen !== state.generation) return;
|
||||
PagerAPI.get('/api/settings/wifi/client').then((r) => {
|
||||
const s = r.data || {};
|
||||
state.status = s;
|
||||
renderStatus();
|
||||
if (s.connected && s.ip) {
|
||||
finish('Connected to ' + (s.connected_ssid || targetSsid));
|
||||
} else if (s.connected && ++ipWait < 6) {
|
||||
setTimeout(tick, 2000);
|
||||
} else if (!s.connected && ++attempts >= 22) {
|
||||
finish('Timed out waiting for ' + targetSsid + ' to connect', 'error');
|
||||
} else if (!s.connected) {
|
||||
setTimeout(tick, 2000);
|
||||
} else {
|
||||
finish('Connected to ' + (s.connected_ssid || targetSsid));
|
||||
}
|
||||
}).catch(() => {
|
||||
state.connecting = false;
|
||||
setBusy(false);
|
||||
App.toast('Failed to check connection status', 'error');
|
||||
});
|
||||
};
|
||||
function finish(message, kind) {
|
||||
state.connecting = false;
|
||||
setBusy(false);
|
||||
App.toast(message, kind);
|
||||
App.checkInternet(false);
|
||||
}
|
||||
setTimeout(tick, 2000);
|
||||
}
|
||||
|
||||
function doDisconnect() {
|
||||
if (state.connecting) return;
|
||||
PagerAPI.post('/api/settings/wifi/client/disconnect')
|
||||
.then(() => {
|
||||
App.toast('WiFi client disabled');
|
||||
state.networks = [];
|
||||
refresh();
|
||||
App.checkInternet(false);
|
||||
})
|
||||
.catch((e) => App.toast(e.message || 'Failed to disconnect', 'error'));
|
||||
}
|
||||
|
||||
function scan() {
|
||||
if (state.connecting) return;
|
||||
netsEl.textContent = 'Scanning\u2026';
|
||||
PagerAPI.post('/api/settings/wifi/client/scan').then((r) => {
|
||||
state.networks = (r.data && r.data.networks) || [];
|
||||
renderNetworks();
|
||||
}).catch((e) => {
|
||||
netsEl.innerHTML = '';
|
||||
netsEl.appendChild(h('div', { class: 'empty', text: 'Scan failed: ' + (e.message || 'unknown error') }));
|
||||
});
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
const buttons = actionsEl.querySelectorAll('button');
|
||||
buttons.forEach((b) => { b.disabled = busy; });
|
||||
}
|
||||
|
||||
actionsEl.appendChild(btn('Scan for Networks', scan));
|
||||
actionsEl.appendChild(btn('Disconnect', doDisconnect));
|
||||
actionsEl.appendChild(btn('Refresh', refresh, 'ghost'));
|
||||
if (options && options.close) actionsEl.appendChild(btn('Close', options.close, 'ghost'));
|
||||
|
||||
box.appendChild(statusEl);
|
||||
box.appendChild(routingRow);
|
||||
box.appendChild(actionsEl);
|
||||
box.appendChild(netsEl);
|
||||
refresh();
|
||||
return { refresh };
|
||||
}
|
||||
|
||||
views.openClientModeModal = () => {
|
||||
const overlay = h('div', { class: 'modal-overlay' });
|
||||
function close() { overlay.remove(); }
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
const modal = h('div', { class: 'modal client-modal' },
|
||||
h('div', { class: 'modal-title', text: 'Internet Connection' }),
|
||||
h('div', { class: 'modal-body' }));
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
clientModePanel(modal.querySelector('.modal-body'), { close });
|
||||
};
|
||||
|
||||
views.settings = (root) => {
|
||||
const box = settingsShell(root, '#/settings');
|
||||
const user = settingsCard(box, 'User Management & Timezone');
|
||||
@@ -2160,7 +2368,8 @@ views.settings = (root) => {
|
||||
|
||||
views.settings_networking = (root) => {
|
||||
const box = settingsShell(root, '#/settings/networking');
|
||||
settingsCard(box, 'Wireless Client Mode', 'The Pager firmware owns client-mode association. Status is shown here; connection changes remain in the native Pager interface to protect PineAP radio state.');
|
||||
const client = settingsCard(box, 'Wireless Client Mode', 'Connect the Pager to an in-range WiFi network for internet access. The management AP keeps running; the radio channel follows the selected network.');
|
||||
clientModePanel(client, {});
|
||||
const recon = settingsCard(box, 'Recon Wireless Interfaces');
|
||||
const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' });
|
||||
recon.appendChild(reconBody);
|
||||
|
||||
Reference in New Issue
Block a user