You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

459 lines
19 KiB

/**
* ssid.js SSID Configuration Page
*
* Manages WiFi SSID list and WiFi connection settings (static/DHCP).
* IP fields use shared octet-split component.
*/
import { state, bindIpGroup, bindRadio } from '../state.js';
import { escapeHtml } from '../utils.js';
import { isValidIP, isValidCountryCode, normalizeCountryCode } from '../validator.js';
import { renderIpOctets, setupIpOctets, collectIpValue } from '../components/ip-input.js';
import { DEFAULTS } from '../constants.js';
import { COUNTRY_CODES, CONTINENT_GROUPS, FREQUENT_COUNTRY_CODES } from '../country-codes.js';
const MAX_SSID_COUNT = 10;
/**
* Build the grouped <optgroup>/<option> markup for the Country Code <select>:
* a pinned "Frequently used" group above five continent groups (205 codes).
* `selected` is the current stored code; if it is not a recognized code (an
* empty value, or a stale "EU" from the old free-text field), the dropdown
* falls back to the AE default. The `selected` attribute is applied to the
* first occurrence only, so exactly one option is selected.
*/
function renderCountryOptions(selected) {
const want = (selected || DEFAULTS.WIFI_COUNTRY_CODE).toUpperCase();
const sel = COUNTRY_CODES[want] ? want : DEFAULTS.WIFI_COUNTRY_CODE;
let selPlaced = false;
const option = (code) => {
const isSel = !selPlaced && code === sel;
if (isSel) selPlaced = true;
return `<option value="${code}"${isSel ? ' selected' : ''}>` +
`${escapeHtml(code + ' — ' + COUNTRY_CODES[code])}</option>`;
};
const group = (label, codes) =>
`<optgroup label="${escapeHtml(label)}">` +
codes.map(option).join('') +
`</optgroup>`;
let html = group('Frequently used', FREQUENT_COUNTRY_CODES);
html += CONTINENT_GROUPS.map(g => group(g.region, g.codes)).join('');
return html;
}
export function renderSsidPage(container) {
const device = state.device || {};
const wifi = device.wifi || {};
const ssidList = device.ssid_list || [];
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">📶 WiFi SSID Management</h1>
<p class="page-header__desc">Manage WiFi network list and connection settings.</p>
</div>
<!-- SSID List -->
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">📡</span> SSID List</h2>
<button class="btn btn--outline btn--sm" id="ssid-add-btn"> Add</button>
</div>
<div class="data-table-wrapper">
<table class="data-table" id="ssid-table">
<thead>
<tr>
<th>SSID Name</th>
<th>Password</th>
<th>Security</th>
<th class="col-action"></th>
</tr>
</thead>
<tbody id="ssid-tbody"></tbody>
</table>
</div>
<p class="form-hint mt-sm">Up to 10 SSIDs can be registered.</p>
</div>
<!-- WiFi Connection Settings -->
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🔧</span> WiFi Connection Settings</h2>
</div>
<div class="form-group">
<label class="form-label">Connection Type</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="wifi_type" value="static" ${wifi.type !== 'dhcp' ? 'checked' : ''}>
Static
</label>
<label class="radio-label">
<input type="radio" name="wifi_type" value="dhcp" ${wifi.type === 'dhcp' ? 'checked' : ''}>
DHCP
</label>
</div>
</div>
<div id="wifi-static-fields">
<div class="form-row">
<div class="form-group">
<label class="form-label">IP Address</label>
${renderIpOctets('wifi_ip', wifi.ip)}
</div>
<div class="form-group">
<label class="form-label">Subnet Mask</label>
${renderIpOctets('wifi_netmask', wifi.netmask)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Gateway</label>
${renderIpOctets('wifi_gateway', wifi.gateway)}
</div>
<div class="form-group">
<label class="form-label">DNS 1</label>
${renderIpOctets('wifi_dns1', wifi.dns1)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">DNS 2</label>
${renderIpOctets('wifi_dns2', wifi.dns2)}
</div>
<div class="form-group"></div>
</div>
</div>
</div>
`;
// Setup IP octet event listeners
setupIpOctets(container);
// Populate SSID rows
const tbody = document.getElementById('ssid-tbody');
ssidList.forEach((s, i) => addSsidRow(tbody, s, i));
// Add SSID button (max 10 rows)
const addBtn = document.getElementById('ssid-add-btn');
addBtn.addEventListener('click', () => {
if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return;
addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, ssidList.length);
updateSsidAddButton();
});
updateSsidAddButton();
// Static/DHCP toggle
document.querySelectorAll('input[name="wifi_type"]').forEach(radio => {
radio.addEventListener('change', () => toggleStaticFields());
});
toggleStaticFields();
// Register data collector
window.__pageCollectors.ssid = collectSsidData;
}
function addSsidRow(tbody, data, index) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td><input class="form-input" data-field="ssid" value="${escapeHtml(data.ssid)}" placeholder="SSID Name"></td>
<td>
<div class="input-password">
<input class="form-input" type="password" data-field="password" value="${escapeHtml(data.password)}" placeholder="Password">
<button class="input-password__toggle" type="button" title="Show password">👁</button>
</div>
</td>
<td>
<select class="form-select" data-field="security">
<option value="wpa/wpa2" ${data.security === 'wpa/wpa2' || data.security === 'WPA' ? 'selected' : ''}>WPA/WPA2</option>
<option value="WEP" ${data.security === 'WEP' ? 'selected' : ''}>WEP</option>
<option value="Open" ${data.security === 'Open' ? 'selected' : ''}>Open</option>
</select>
</td>
<td class="col-action">
<button class="btn btn--ghost btn--icon ssid-delete" title="Delete">🗑</button>
</td>
`;
// Toggle password visibility
tr.querySelector('.input-password__toggle').addEventListener('click', (e) => {
const input = tr.querySelector('input[data-field="password"]');
input.type = input.type === 'password' ? 'text' : 'password';
e.target.textContent = input.type === 'password' ? '👁' : '🔒';
});
// Delete row
tr.querySelector('.ssid-delete').addEventListener('click', () => {
tr.remove();
updateSsidAddButton();
});
tbody.appendChild(tr);
}
function updateSsidAddButton() {
const btn = document.getElementById('ssid-add-btn');
const count = document.querySelectorAll('#ssid-tbody tr').length;
if (btn) btn.disabled = count >= MAX_SSID_COUNT;
}
function toggleStaticFields() {
const isDhcp = document.querySelector('input[name="wifi_type"]:checked')?.value === 'dhcp';
const fields = document.getElementById('wifi-static-fields');
if (!fields) return;
fields.querySelectorAll('.form-input').forEach(input => {
input.disabled = isDhcp;
});
fields.style.opacity = isDhcp ? '0.5' : '1';
}
function collectSsidData() {
if (!state.device) state.device = {};
// WiFi settings — collect from octet groups.
// country_code MUST be re-collected here: this object replaces state.device.wifi
// wholesale, so omitting it would silently drop the legacy Java value on save.
const wifiType = document.querySelector('input[name="wifi_type"]:checked')?.value || 'static';
const ccEl = document.getElementById('wifi_country_code');
const countryCode = normalizeCountryCode(
ccEl ? ccEl.value : (state.device.wifi && state.device.wifi.country_code)
) || DEFAULTS.WIFI_COUNTRY_CODE;
state.device.wifi = {
type: wifiType,
ip: collectIpValue('wifi_ip'),
netmask: collectIpValue('wifi_netmask'),
gateway: collectIpValue('wifi_gateway'),
dns1: collectIpValue('wifi_dns1'),
dns2: collectIpValue('wifi_dns2'),
country_code: countryCode,
};
// SSID list
const rows = document.querySelectorAll('#ssid-tbody tr');
state.device.ssid_list = Array.from(rows).map(tr => ({
ssid: tr.querySelector('[data-field="ssid"]')?.value || '',
password: tr.querySelector('[data-field="password"]')?.value || '',
security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2',
}));
}
// ─── WiFi IP fields ─────────────────────────────────────────
const WIFI_IP_FIELDS = ['wifi_ip', 'wifi_netmask', 'wifi_gateway', 'wifi_dns1', 'wifi_dns2'];
const WIFI_IP_LABELS = {
wifi_ip: 'WiFi IP', wifi_netmask: 'WiFi Subnet Mask', wifi_gateway: 'WiFi Gateway',
wifi_dns1: 'WiFi DNS 1', wifi_dns2: 'WiFi DNS 2',
};
const WIFI_STATE_KEYS = { wifi_ip: 'ip', wifi_netmask: 'netmask', wifi_gateway: 'gateway', wifi_dns1: 'dns1', wifi_dns2: 'dns2' };
// ─── New Page Interface ─────────────────────────────────────
const ssidPage = {
render(container) {
const device = state.device || {};
const wifi = device.wifi || {};
const ssidList = device.ssid_list || [];
container.innerHTML = `
<div class="page-header">
<h1 class="page-header__title">📶 WiFi SSID Management</h1>
<p class="page-header__desc">Manage WiFi network list and connection settings.</p>
</div>
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">📡</span> SSID List</h2>
<button class="btn btn--outline btn--sm" id="ssid-add-btn"> Add</button>
</div>
<div class="data-table-wrapper">
<table class="data-table" id="ssid-table">
<thead>
<tr>
<th>SSID Name</th>
<th>Password</th>
<th>Security</th>
<th class="col-action"></th>
</tr>
</thead>
<tbody id="ssid-tbody"></tbody>
</table>
</div>
<p class="form-hint mt-sm">Up to 10 SSIDs can be registered.</p>
</div>
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🔧</span> WiFi Connection Settings</h2>
</div>
<div class="form-group">
<label class="form-label">Connection Type</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="wifi_type" value="static" ${wifi.type !== 'dhcp' ? 'checked' : ''}>
Static
</label>
<label class="radio-label">
<input type="radio" name="wifi_type" value="dhcp" ${wifi.type === 'dhcp' ? 'checked' : ''}>
DHCP
</label>
</div>
</div>
<div id="wifi-static-fields">
<div class="form-row">
<div class="form-group">
<label class="form-label">IP Address</label>
${renderIpOctets('wifi_ip', wifi.ip)}
</div>
<div class="form-group">
<label class="form-label">Subnet Mask</label>
${renderIpOctets('wifi_netmask', wifi.netmask)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Gateway</label>
${renderIpOctets('wifi_gateway', wifi.gateway)}
</div>
<div class="form-group">
<label class="form-label">DNS 1</label>
${renderIpOctets('wifi_dns1', wifi.dns1)}
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">DNS 2</label>
${renderIpOctets('wifi_dns2', wifi.dns2)}
</div>
<div class="form-group"></div>
</div>
</div>
</div>
<div class="card">
<div class="card__header">
<h2 class="card__title"><span class="card__title-icon">🌍</span> Region</h2>
</div>
<div class="form-group">
<label class="form-label" for="wifi_country_code">Country Code</label>
<select class="form-input" id="wifi_country_code" style="max-width: 300px;">
${renderCountryOptions(wifi.country_code || DEFAULTS.WIFI_COUNTRY_CODE)}
</select>
<p class="form-hint">WiFi regulatory region. Defaults to AE (United Arab Emirates); dpworldapp applies it to the WiFi module.</p>
</div>
</div>
`;
},
mount(container) {
setupIpOctets(container);
// Reactive bindings: WiFi type radio
bindRadio(container, 'wifi_type',
() => (state.device?.wifi?.type || 'static'),
(val) => {
if (!state.device) state.device = {};
if (!state.device.wifi) state.device.wifi = {};
state.device.wifi.type = val;
state.isDirty = true;
}
);
// Reactive bindings: WiFi IP groups
WIFI_IP_FIELDS.forEach(ipKey => {
const stateKey = WIFI_STATE_KEYS[ipKey];
bindIpGroup(container, ipKey,
() => state.device?.wifi?.[stateKey] || '',
(val) => {
if (!state.device) state.device = {};
if (!state.device.wifi) state.device.wifi = {};
state.device.wifi[stateKey] = val;
state.isDirty = true;
}
);
});
// Reactive binding: WiFi country code (<select>)
const ccEl = container.querySelector('#wifi_country_code');
if (ccEl) {
ccEl.addEventListener('change', () => {
if (!state.device) state.device = {};
if (!state.device.wifi) state.device.wifi = {};
state.device.wifi.country_code = ccEl.value;
state.isDirty = true;
});
}
// SSID list: populate + event delegation
const tbody = container.querySelector('#ssid-tbody');
const ssidList = state.device?.ssid_list || [];
ssidList.forEach((s, i) => addSsidRow(tbody, s, i));
// SSID list: reactive sync on input/change/delete
tbody.addEventListener('input', () => { this._syncSsidList(tbody); });
tbody.addEventListener('change', () => { this._syncSsidList(tbody); });
// Sync after row deletion (DOM mutation via click on delete button)
tbody.addEventListener('click', (e) => {
if (e.target.closest('.ssid-delete')) {
// Delay to let the row be removed first
setTimeout(() => { this._syncSsidList(tbody); updateSsidAddButton(); }, 0);
}
});
// Add button
const addBtn = container.querySelector('#ssid-add-btn');
addBtn.addEventListener('click', () => {
if (tbody.querySelectorAll('tr').length >= MAX_SSID_COUNT) return;
addSsidRow(tbody, { ssid: '', password: '', security: 'wpa/wpa2' }, tbody.querySelectorAll('tr').length);
this._syncSsidList(tbody);
updateSsidAddButton();
});
updateSsidAddButton();
// Static/DHCP toggle
container.querySelectorAll('input[name="wifi_type"]').forEach(radio => {
radio.addEventListener('change', () => toggleStaticFields());
});
toggleStaticFields();
// Legacy collector (R4)
window.__pageCollectors.ssid = collectSsidData;
},
_syncSsidList(tbody) {
if (!state.device) state.device = {};
const rows = tbody.querySelectorAll('tr');
state.device.ssid_list = Array.from(rows).map(tr => ({
ssid: tr.querySelector('[data-field="ssid"]')?.value || '',
password: tr.querySelector('[data-field="password"]')?.value || '',
security: tr.querySelector('[data-field="security"]')?.value || 'wpa/wpa2',
}));
state.isDirty = true;
},
destroy() {},
validate() {
const errors = [];
const wifi = state.device?.wifi || {};
// Only validate IP fields when static mode
if (wifi.type !== 'dhcp') {
WIFI_IP_FIELDS.forEach(ipKey => {
const stateKey = WIFI_STATE_KEYS[ipKey];
const val = wifi[stateKey];
if (val && val !== '...' && !isValidIP(val)) {
errors.push({ field: ipKey, page: 'ssid', message: `${WIFI_IP_LABELS[ipKey]} — Invalid IP format` });
}
});
}
// Region — WiFi country code: required, exactly 2 uppercase letters
if (!isValidCountryCode(normalizeCountryCode(wifi.country_code))) {
errors.push({
field: 'wifi_country_code', page: 'ssid',
message: 'Country Code — must be a 2-letter ISO 3166-1 alpha-2 code (e.g. KR, US, DE, JP, AE)',
});
}
return errors;
},
};
export default ssidPage;