diff --git a/applications/luci-app-openthread/Makefile b/applications/luci-app-openthread/Makefile new file mode 100644 index 000000000000..2d100033d4e2 --- /dev/null +++ b/applications/luci-app-openthread/Makefile @@ -0,0 +1,18 @@ +# +# Copyright (c) 2026 The OpenThread Authors +# +# This is free software, licensed under the BSD-3-Clause license. +# + +include $(TOPDIR)/rules.mk + +LUCI_TITLE:=LuCI Support for OpenThread Border Router +LUCI_DEPENDS:=+luci-base +openthread-br +rpcd-mod-ucode +LUCI_PKGARCH:=all + +PKG_LICENSE:=BSD-3-Clause +PKG_MAINTAINER:=Christian Glombek + +include ../../luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/applications/luci-app-openthread/htdocs/luci-static/resources/openthread/errors.js b/applications/luci-app-openthread/htdocs/luci-static/resources/openthread/errors.js new file mode 100644 index 000000000000..e42eb1d1303b --- /dev/null +++ b/applications/luci-app-openthread/htdocs/luci-static/resources/openthread/errors.js @@ -0,0 +1,55 @@ +'use strict'; +'require baseclass'; +'require ui'; + +var ERRORS = { + 1: 'Failed', + 2: 'Drop', + 3: 'NoBufs', + 4: 'NoRoute', + 5: 'Busy', + 6: 'Parse', + 7: 'InvalidArgs', + 8: 'Security', + 9: 'AddressQuery', + 10: 'NoAddress', + 11: 'Abort', + 12: 'NotImplemented', + 13: 'InvalidState', + 14: 'NoAck', + 15: 'ChannelAccessFailure', + 16: 'Detached', + 17: 'FcsErr', + 18: 'NoFrameReceived', + 19: 'UnknownNeighbor', + 20: 'InvalidSourceAddress', + 21: 'AddressFiltered', + 22: 'DestinationAddressFiltered', + 23: 'NotFound', + 24: 'Already', + 26: 'Ipv6AddressCreationFailure', + 27: 'NotCapable', + 28: 'ResponseTimeout', + 29: 'Duplicated', + 30: 'ReassemblyTimeout', + 31: 'NotTmf', + 32: 'NonLowpanDataFrame', + 33: 'DisabledFeature', + 34: 'LinkMarginLow', + 255: 'GenericError', +}; + +return baseclass.extend({ + translate: function(code) { + var n = parseInt(code); + if (!n) return null; + return ERRORS[n] || 'UnknownErrorType'; + }, + + notify: function(result) { + var msg = this.translate(result && result.error); + if (msg) + ui.addNotification(null, E('p', _('Error: %s').format(msg)), 'danger'); + return result; + }, +}); diff --git a/applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js b/applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js new file mode 100644 index 000000000000..ee25706909ee --- /dev/null +++ b/applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js @@ -0,0 +1,1206 @@ +'use strict'; +'require view'; +'require dom'; +'require poll'; +'require rpc'; +'require ui'; +'require openthread/errors as otbrErrors'; + +var callStateSummary = rpc.declare({ + object: 'luci.openthread', + method: 'state_summary' +}); + +var callNeighborsSummary = rpc.declare({ + object: 'luci.openthread', + method: 'neighbors_summary' +}); + +var callScan = rpc.declare({ + object: 'luci.openthread', + method: 'scan' +}); + +var callAttach = rpc.declare({ + object: 'luci.openthread', + method: 'attach', + params: [ 'panid', 'channel', 'networkkey' ] +}); + +var callLeave = rpc.declare({ + object: 'luci.openthread', + method: 'leave' +}); + +var callJoinerRemove = rpc.declare({ + object: 'luci.openthread', + method: 'joiner_remove', + params: [ 'eui64' ] +}); + +var callSettingsGet = rpc.declare({ + object: 'luci.openthread', + method: 'settings_get' +}); + +var callSecretGet = rpc.declare({ + object: 'luci.openthread', + method: 'secret_get', + params: [ 'name' ] +}); + +var callSettingsApply = rpc.declare({ + object: 'luci.openthread', + method: 'settings_apply', + params: [ 'networkname', 'channel', 'panid', 'extpanid', + 'mode', 'networkkey', 'pskc', 'macfilter', 'macfilter_confirmed' ] +}); + +var callMacfilterClear = rpc.declare({ + object: 'luci.openthread', + method: 'macfilter_clear', + params: [ 'confirmed' ] +}); + +var callMacfilterAdd = rpc.declare({ + object: 'luci.openthread', + method: 'macfilter_add', + params: [ 'addr', 'state', 'confirmed' ] +}); + +var callMacfilterRemove = rpc.declare({ + object: 'luci.openthread', + method: 'macfilter_remove', + params: [ 'addr', 'state', 'confirmed' ] +}); + +var callCommissionerStart = rpc.declare({ + object: 'luci.openthread', + method: 'commissioner_start' +}); + +var callJoinerAdd = rpc.declare({ + object: 'luci.openthread', + method: 'joiner_add', + params: [ 'pskd', 'eui64' ] +}); + +var callRadioRestart = rpc.declare({ + object: 'luci.openthread', + method: 'radio_restart' +}); + +var callThreadStart = rpc.declare({ + object: 'luci.openthread', + method: 'thread_start' +}); + +var callThreadStop = rpc.declare({ + object: 'luci.openthread', + method: 'thread_stop' +}); + +function pctIcon(pct) { + var base = L.resource('icons'); + if (pct <= 0) return base + '/signal-000-000.svg'; + if (pct < 25) return base + '/signal-000-025.svg'; + if (pct < 50) return base + '/signal-025-050.svg'; + if (pct < 75) return base + '/signal-050-075.svg'; + return base + '/signal-075-100.svg'; +} + +// The same badge the wireless pages use, fed from what a Thread link +// reports: the received signal in dBm where available (neighbor entries), +// the 0-3 link quality indicator otherwise (the parent entry carries no +// RSSI). The percent mapping matches wireless.js render_signal_badge(). +function rssiBadge(avg, last, title) { + return E('div', { 'class': 'ifacebadge', 'title': title, 'data-signal': avg }, [ + E('img', { 'src': pctIcon(Math.min((avg + 110) / 70 * 100, 100)) }), + E('span', {}, [ ' ', '%d/%d dBm'.format(avg, last) ]) + ]); +} + +function lqiBadge(lqi, title) { + var pct = [0, 30, 60, 100][Number(lqi)] ?? 100; + return E('div', { 'class': 'ifacebadge', 'title': title, 'data-signal': pct }, [ + E('img', { 'src': pctIcon(pct) }), + E('span', {}, [ ' ', 'LQI %d'.format(Number(lqi)) ]) + ]); +} + +function noSignalBadge(title, disabled) { + return E('div', { 'class': 'ifacebadge', 'title': title }, [ + E('img', { 'src': L.resource('icons/signal-%s.svg').format(disabled ? 'none' : '000-000') }), + E('span', {}, [ ' ', disabled + ? E('em', {}, E('small', {}, [ _('disabled') ])) + : '--- dBm' ]) + ]); +} + +// The ubus reply carries numbers as space-padded strings (" -77"). +function num(v) { + var n = parseInt(String(v ?? '').trim(), 10); + return isNaN(n) ? null : n; +} + +function strongestNeighbor(neigh) { + var best = null; + (neigh.neighbor || []).forEach(function(bss) { + var avg = num(bss.AvgRssi); + if (avg != null && (best == null || avg > best.avg)) + best = { avg: avg, last: num(bss.LastRssi) ?? avg, lqi: bss.LinkQualityIn }; + }); + return best; +} + +// The daemon reports roles lowercase; present them as proper names. +function roleName(state) { + switch (state) { + case 'disabled': return _('Disabled'); + case 'detached': return _('Detached'); + case 'child': return _('Child'); + case 'router': return _('Router'); + case 'leader': return _('Leader'); + default: return state || '?'; + } +} + +function transRole(bss) { + var role = (bss.Role == 'C') ? _('Child') + : (bss.Role == 'R') ? _('Router') + : (bss.Role || '?'); + // A neighbor without the rx-on-when-idle flag is a sleepy end device. + if (bss.Mode != null && bss.Mode.indexOf('r') < 0) + return E('span', {}, [ role, ' ', E('small', {}, _('(sleepy)')) ]); + return role; +} + +// A failure below the method itself (no session, missing ACL, rpcd down) +// resolves to a plain ubus status number rather than a reply object; a +// caller checking only .error would take that for success. +function replyError(r) { + if (typeof r == 'number') + return r || 255; + if (r == null || typeof r != 'object') + return 255; + return r.error || 0; +} + +function notifyReply(r) { + var err = replyError(r); + if (err) + otbrErrors.notify({ error: err }); + return err; +} + +// A filter edit the backend refused: it reports what stopped it rather +// than half-applying an edit whose meaning depends on the mode. +var filterRefusals = { + empties_allowlist: _('Nothing was changed: an active allowlist with no entries blocks every device. Clear it from the network settings, where the affected list is shown, or change the filter mode first.'), + leftovers: _('Nothing was changed: the MAC filter list still holds entries from an earlier list, which this mode would give the opposite meaning. Review the MAC filter in the network settings.'), + unreadable: _('Nothing was changed: the current MAC filter state could not be read.'), + mode_unchanged: _('The filter list was edited, but the filter mode could not be changed.'), + unconfirmed_mode: _('Nothing was changed: the request would also switch the filter mode on, which changes what every other device may do. Change the mode from the network settings, where the effect is spelled out.') +}; + +function notifyFilterReply(r) { + // A refusal is not a failure: report what stopped it, not an error. + if (r && r.unchanged) { + ui.addNotification(null, E('p', + filterRefusals[r.reason] || filterRefusals.unreadable), 'warning'); + return 0; + } + var err = replyError(r); + // Already means the device is on the list already. + if (err && err != 24) + otbrErrors.notify({ error: err }); + if (r && r.reason == 'mode_unchanged') + ui.addNotification(null, E('p', filterRefusals.mode_unchanged), 'warning'); + return (err == 24) ? 0 : err; +} + +function actionButton(label, cls, onclick, title) { + return E('button', { + 'class': cls, + 'title': title || label, + 'click': ui.createHandlerFn({}, onclick) + }, label); +} + +// Scan and join run as modals on this page, mirroring the wireless +// overview. A Thread active scan is a single multi-second operation +// (deferred at the rpcd layer), so the modal shows a spinner until the +// first results land, then rescans every few seconds while it stays +// open; entries that stop answering are dimmed rather than dropped. +function scanTableRows(entries, configured) { + var rows = [E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th col-2 middle center' }, _('Signal')), + E('th', { 'class': 'th col-4 middle left' }, _('Network Name')), + E('th', { 'class': 'th col-2 middle center hide-xs' }, _('Channel')), + E('th', { 'class': 'th col-2 middle left hide-xs' }, _('PAN Id')), + E('th', { 'class': 'th cbi-section-actions right' }, ' ') + ])]; + + entries.forEach(function(e) { + var net = e.net; + var rssi = num(net.Rssi); + var dim = e.stale ? 'opacity:0.5' : ''; + rows.push(E('tr', { 'class': 'tr', 'style': dim }, [ + E('td', { 'class': 'td col-2 middle center' }, + (rssi != null) + ? E('div', { 'class': 'ifacebadge', 'title': _('Signal: %d dBm / Quality: %s').format(rssi, net.Lqi) }, [ + E('img', { 'src': pctIcon(Math.min((rssi + 110) / 70 * 100, 100)) }), + E('span', {}, [ ' ', '%d dBm'.format(rssi) ]) + ]) + : E('em', {}, _('unknown'))), + E('td', { 'class': 'td col-4 middle left' }, net.NetworkName ? [ net.NetworkName ] : E('em', {}, _('hidden'))), + E('td', { 'class': 'td col-2 middle center hide-xs' }, String(net.Channel ?? '?')), + E('td', { 'class': 'td col-2 middle left hide-xs' }, String(net.PanId ?? '?')), + E('td', { 'class': 'td middle cbi-section-actions' }, + E('button', { + 'class': 'cbi-button cbi-button-action important', + 'click': ui.createHandlerFn({}, function() { return handleJoin(net, configured); }) + }, _('Join Network'))) + ])); + }); + + if (rows.length == 1) + rows.push(E('tr', { 'class': 'tr placeholder' }, + E('td', { 'class': 'td', 'colspan': 5 }, + E('em', {}, _('No networks found'))))); + + return rows; +} + +var scanState = null; + +function scanKey(net) { + return net.ExtendedPanId || (String(net.PanId) + '/' + String(net.Channel)); +} + +function runScanTick(state, table) { + // One active scan takes several seconds; never start a second one + // while the previous is in flight, or the daemon reports Busy. + if (state.busy || state.done) + return; + state.busy = true; + return callScan().then(function(r) { + state.busy = false; + if (state.done) + return; + var nets = (r && r.scan_list) || []; + for (var k in state.cache) + state.cache[k].stale = true; + nets.forEach(function(net) { + state.cache[scanKey(net)] = { net: net, stale: false }; + }); + var list = Object.values(state.cache).sort(function(a, b) { + return (num(b.net.Rssi) ?? -999) - (num(a.net.Rssi) ?? -999); + }); + if (list.length) + dom.content(table, scanTableRows(list, state.configured)); + }).catch(function() { + state.busy = false; + }); +} + +function stopScan() { + if (scanState) { + scanState.done = true; + if (scanState.pollFn) + poll.remove(scanState.pollFn); + scanState = null; + } +} + +function handleScan(st) { + var table = E('table', { 'class': 'table' }, [ + E('tr', { 'class': 'tr placeholder' }, + E('td', { 'class': 'td' }, + E('em', { 'class': 'spinning' }, _('Starting Thread scan...')))) + ]); + + var state = { cache: {}, busy: false, done: false, configured: !!(st && st.configured) }; + scanState = state; + + var md = ui.showModal(_('Join Network: Thread Scan'), [ + table, + E('div', { 'class': 'right' }, + E('button', { + 'class': 'btn', + 'click': function(ev) { + var m = dom.parent(ev.target, 'div[aria-modal="true"]'); + if (m) m.style.maxWidth = ''; + stopScan(); + ui.hideModal(); + } + }, _('Dismiss'))) + ]); + md.style.maxWidth = '90%'; + + // Scan continuously while the modal is open, like the wireless scan; + // results accumulate and networks that stop answering dim out. + state.pollFn = function() { return runScanTick(state, table); }; + poll.add(state.pollFn, 5); + runScanTick(state, table); +} + +function handleJoin(net, configured) { + stopScan(); + var keyInput = E('input', { + 'type': 'password', + 'class': 'cbi-input-text', + 'placeholder': _('32 hex characters'), + 'style': 'width:100%' + }); + + function attach() { + var key = keyInput.value; + if (!/^[0-9a-f]{32}$/i.test(key)) { + ui.addNotification(null, E('p', _('Network key length must be 32 hex characters')), 'danger'); + return; + } + return callAttach(String(net.PanId), Number(net.Channel), key).then(function(r) { + if (notifyReply(r)) + return; + ui.hideModal(); + location.reload(); + }); + } + + ui.showModal(_('Joining Network: %q').replace(/%q/, '"%h"'.format(net.NetworkName || net.PanId)), [ + configured + ? E('p', { 'class': 'alert-message warning' }, + _('One agent manages exactly one Thread network: joining this network replaces the currently configured one.')) + : E([]), + E('p', {}, _('Enter the network key to ensure you join the right network.')), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('Network Key')), + E('div', { 'class': 'cbi-value-field' }, keyInput) + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive important', + 'click': ui.createHandlerFn({}, attach) + }, _('Attach')) + ]) + ], 'cbi-modal'); + keyInput.focus(); +} + +function cbiValue(title, node, hint) { + var children = [ + E('label', { 'class': 'cbi-value-title' }, title), + E('div', { 'class': 'cbi-value-field' }, node) + ]; + if (hint) + children.push(E('div', { 'class': 'cbi-value-description' }, hint)); + return E('div', { 'class': 'cbi-value' }, children); +} + +// The network settings form, ported from the former settings page into a +// modal like the wireless per-network edit. Enable/Disable and Leave are +// not repeated here: the network row carries them. +function handleSettings() { + return callSettingsGet().then(function(s) { + otbrErrors.notify(s); + if (s && s.error) + return; + s = s || {}; + var disabled = (s.state == 'disabled'); + + var nameInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': s.networkname || '' }); + var channelInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': String(s.channel ?? '') }); + var panidInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': String(s.panid ?? '') }); + var extpanidInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': s.extpanid || '' }); + var modeInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'value': s.mode || '' }); + if (!disabled) modeInput.disabled = true; + // The settings do not carry the secrets; the field stays empty and + // means "unchanged" unless it is revealed or typed into. + var networkkeyInput = E('input', { + 'type': 'password', + 'class': 'cbi-input-text', + 'placeholder': _('unchanged') + }); + var pskcInput = E('input', { + 'type': 'password', + 'class': 'cbi-input-text', + 'placeholder': _('unchanged') + }); + + function revealButton(name, input) { + return actionButton(_('Reveal'), 'cbi-button cbi-button-neutral', + function() { + return callSecretGet(name).then(function(r) { + if (notifyReply(r)) + return; + input.value = r.value || ''; + input.type = 'text'; + }); + }, _('Read this value from the border router')); + } + + // The placeholder keeps the select from naming a mode nobody knows: + // with the state unreadable it would otherwise fall back to reading + // 'Disable' as a statement of fact. + var macSelect = E('select', { 'class': 'cbi-input-select' }, [ + E('option', { 'value': '', 'disabled': true, 'hidden': true }, _('Unknown')), + E('option', { 'value': 'disable' }, _('Disable')), + E('option', { 'value': 'allowlist' }, _('Allowlist')), + E('option', { 'value': 'denylist' }, _('Denylist')) + ]); + macSelect.value = s.macfilterstate || ''; + var macModeNote = E('em', { 'style': 'display:none' }, + _('The filter mode could not be read.')); + var macAddInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': _('16 hex characters') }); + var macListBox = E('div', {}); + var macClearButton = E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': ui.createHandlerFn({}, function() { + if (!macReadable()) + return; + // Match macfilter_clear's own refusal condition: an active + // allowlist with entries. An empty one blocks everything + // already, so there is no hazard to confirm. + var asked = (s.macfilterstate == 'allowlist' && (s.addrlist || []).length > 0); + if (asked && + !confirm(_('The MAC filter is an active allowlist. Clearing it leaves no device admitted, which blocks every device until the filter mode is changed. Clear it anyway?'))) + return; + // Claim approval only for a hazard the operator was actually + // shown, so the backend still refuses one that appeared since. + return callMacfilterClear(asked).then(function(r) { + notifyFilterReply(r); + return refreshMacList(); + }); + }) + }, _('Clear')); + var macAddButton = E('button', { + 'class': 'cbi-button cbi-button-add', + 'click': ui.createHandlerFn({}, function() { + if (!macReadable()) + return; + if (!/^[0-9a-f]{16}$/i.test(macAddInput.value)) { + ui.addNotification(null, E('p', _('Address length must be 16 hex characters')), 'danger'); + return; + } + return callMacfilterAdd(macAddInput.value, '').then(function(r) { + if (!notifyFilterReply(r) && !(r && r.unchanged)) + macAddInput.value = ''; + return refreshMacList(); + }); + }) + }, _('Add')); + + function macState() { return macSelect.value; } + + var applyButton = E('button', { + 'class': 'cbi-button cbi-button-positive important', + 'click': ui.createHandlerFn({}, doApply) + }, _('Save & Apply')); + + function rebuildMacList(addrs) { + if (addrs == null) { + dom.content(macListBox, E('em', {}, + _('The filter list could not be read.'))); + return; + } + var rows = addrs.map(function(addr) { + return E('div', {}, [ + E('code', {}, addr), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': ui.createHandlerFn({}, function() { + if (!macReadable()) + return; + // The list edit stands on its own here; the + // mode is applied by Save & Apply, which + // shows what it would reinterpret. + return callMacfilterRemove(addr, '').then(function(r) { + notifyFilterReply(r); + return refreshMacList(); + }); + }) + }, _('Remove')) + ]); + }); + dom.content(macListBox, rows); + } + + function refreshMacList() { + return callSettingsGet().then(function(r) { + // Keep both halves of the snapshot in step: doApply() sizes + // the mode-switch warning from the list, and macReadable() + // gates on the mode as well. Leaving either at its load-time + // value lets the guard trust state the router has since + // stopped reporting. + s.addrlist = (r || {}).addrlist; + s.macfilterstate = (r || {}).macfilterstate; + rebuildMacList(s.addrlist); + applyMacReadability(); + // ui.createHandlerFn re-enables the button that started this + // once the handler's promise settles, which happens after the + // line above. Re-apply the verdict behind it so a control the + // state no longer supports does not come back live. + window.setTimeout(applyMacReadability, 0); + }); + } + + // An unreadable filter state is not a disabled one. settings_apply + // refuses a mode change when either half of its snapshot -- the mode + // or the list -- is unknown, so mirror that rule here, where the modal + // has already said why on screen, rather than let the operator answer + // a prompt about state nobody can see. + // + // Only the controls that act on the filter are gated. The rest of the + // form -- name, channel, PAN IDs, device mode, the secrets -- does not + // depend on it, so Save & Apply stays live and doApply() sends an empty + // mode when the mode is not being changed. + function macReadable() { return s.addrlist != null && s.macfilterstate != null; } + // No change can be claimed against an unknown baseline: with the mode + // unreadable this stays false, so doApply() sends an empty mode and + // the warning machinery stays quiet. + function macModeChanged() { return s.macfilterstate != null && macState() != s.macfilterstate; } + + function applyMacVisibility() { + // Keep the rows while the list is unreadable: they hold the + // "could not be read" box, which is the one on-screen explanation + // after the revert below -- a title on a disabled select does not + // reliably surface as a tooltip. + macRows.style.display = + (macSelect.value == 'disable' && s.addrlist != null) ? 'none' : ''; + } + + // Re-run from refreshMacList(), but the movement is one-way in + // practice: a refresh can take readability away, yet once these + // controls are disabled nothing re-reads the state, so reopening + // the modal is the way back. + function applyMacReadability() { + var readable = macReadable(); + var hint = readable ? '' + : _('The MAC filter state could not be read, so it cannot be changed.'); + // The select must not keep asserting a mode. With the mode half + // unreadable it shows the Unknown placeholder and the note below + // says why; with only the list gone, a pick made while everything + // was still readable is reverted, since doApply() would send it + // and have settings_apply refuse the whole form as 'unreadable' + // -- with no way back, the select being disabled. It would also + // size the mode-switch warning from a list that reads as empty + // because it could not be read. + if (s.macfilterstate == null) + macSelect.value = ''; + else if (!readable && macModeChanged()) + macSelect.value = s.macfilterstate; + applyMacVisibility(); + macModeNote.style.display = (s.macfilterstate == null) ? '' : 'none'; + // The per-address Remove buttons are rebuilt with the list, so + // collect them fresh: with the list readable but the mode not, + // the rows are on screen and every Remove would come back + // 'unreadable' from the same snapshot rule. + var removes = macListBox.querySelectorAll('button'); + [macSelect, macClearButton, macAddButton].concat(Array.from(removes)) + .forEach(function(el) { + el.disabled = !readable; + el.title = hint; + }); + } + macSelect.addEventListener('change', applyMacVisibility); + rebuildMacList(s.addrlist); + + var macRows = E('div', {}, [ + cbiValue(_('Existing Address'), [ + macListBox, + macClearButton + ], _('This will clear all existing macfilter addresses.')), + cbiValue(_('Add Address'), [ + macAddInput, + ' ', + macAddButton + ]) + ]); + + function doApply() { + var ch = parseInt(channelInput.value, 10); + if (isNaN(ch) || ch < 11 || ch > 26) { + ui.addNotification(null, E('p', _('Channel must be a number between 11 and 26')), 'danger'); return; + } + if (isNaN(Number(panidInput.value))) { + ui.addNotification(null, E('p', _('PAN ID must be a number')), 'danger'); return; + } + if (!/^[0-9a-f]{16}$/i.test(extpanidInput.value)) { + ui.addNotification(null, E('p', _('Extended PAN ID must be 16 hex characters')), 'danger'); return; + } + if (!/^[rsdn]+$/.test(modeInput.value)) { + ui.addNotification(null, E('p', _('Mode must consist of "r", "s", "d", "n"')), 'danger'); return; + } + // Empty means unchanged, so only a value that was entered is checked. + if (networkkeyInput.value && !/^[0-9a-f]{32}$/i.test(networkkeyInput.value)) { + ui.addNotification(null, E('p', _('Network key length must be 32 hex characters')), 'danger'); return; + } + if (pskcInput.value && !/^[0-9a-f]{32}$/i.test(pskcInput.value)) { + ui.addNotification(null, E('p', _('Network password length must be 32 hex characters')), 'danger'); return; + } + // The filter mode is the one field whose meaning depends on the + // list beside it: name what the change would do before it is + // applied, since the operator can see the list here. + var entries = (s.addrlist || []).length; + var warn = macModeChanged() + ? (entries > 0 + ? _('The MAC filter list holds %d entries written for the previous mode. Switching the mode changes what they mean: %s. Continue?') + .format(entries, macState() == 'allowlist' + ? _('they become the only admitted devices and every other device is blocked') + : macState() == 'denylist' + ? _('they become blocked devices') + : _('the filter stops applying and none of them is filtered any more')) + : (macState() == 'allowlist' + ? _('An allowlist with no entries admits no device, so every device is blocked until entries are added. Continue?') + : null)) + : null; + if (warn && !confirm(warn)) + return; + return callSettingsApply( + nameInput.value, ch, panidInput.value, extpanidInput.value, + // An empty filter mode means "leave it as it is". Sending the + // current one instead would take the unreadable-list path in + // settings_apply even when the mode is not being changed, and + // refuse the whole form over a list nothing here touches. + modeInput.value, networkkeyInput.value, pskcInput.value, + macModeChanged() ? macState() : '', + warn != null + ).then(function(r) { + if (r && r.unchanged) { + notifyFilterReply(r); + return; + } + if (notifyReply(r)) + return; + ui.hideModal(); + location.reload(); + }); + } + + ui.showModal(_('Thread Network: %s (%s)').format(s.networkname || '?', s.interfacename || '?'), [ + E('h4', {}, _('Network Configuration')), + cbiValue(_('Thread Name'), nameInput), + cbiValue(_('Channel'), channelInput), + cbiValue(_('PAN ID'), panidInput), + cbiValue(_('Extended PAN ID'), extpanidInput), + cbiValue(_('Mode'), modeInput, disabled + ? _('Set the thread device mode value, must consist of "r", "s", "d", "n".') + : _('Cannot change mode while the Thread network is started.')), + E('h4', {}, _('Thread Security')), + cbiValue(_('Network Key'), [ networkkeyInput, ' ', revealButton('networkkey', networkkeyInput) ], + _('Left empty the key stays as it is.')), + cbiValue(_('Network Password'), [ pskcInput, ' ', revealButton('pskc', pskcInput) ], + _('The commissioner credential (PSKc). Left empty it stays as it is.')), + E('h4', {}, _('MAC-Filter')), + cbiValue(_('Protocol'), [ macSelect, ' ', macModeNote ]), + macRows, + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Dismiss')), + ' ', + applyButton + ]) + ], 'cbi-modal'); + applyMacVisibility(); + applyMacReadability(); + }); +} + +// Commissioner-based joining, ported from the former add-joiner page. +function handleCommission(st) { + // Petitioning is idempotent: re-opening the dialog while a joiner + // window is active is a no-op success. + callCommissionerStart().then(function(r) { otbrErrors.notify(r); }); + + var pskdInput = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': 'J01NME' }); + var eui64Input = E('input', { 'type': 'text', 'class': 'cbi-input-text', 'placeholder': '2157d22254527111' }); + + function submit() { + if (!/^[0-9A-HJ-NPR-Y]{6,32}$/.test(pskdInput.value)) { + ui.addNotification(null, E('p', _('The Joiner Credential must be 6 to 32 uppercase alphanumeric characters (0-9 and A-Y, excluding I, O, Q and Z)')), 'danger'); + return; + } + if (!/^[0-9a-f]{16}$/i.test(eui64Input.value) && eui64Input.value != '*') { + ui.addNotification(null, E('p', _('eui64 length must be 16 hex characters or "*"')), 'danger'); + return; + } + return callJoinerAdd(pskdInput.value, eui64Input.value).then(function(r) { + if (notifyReply(r)) + return; + ui.hideModal(); + }); + } + + ui.showModal(_('Add Joiner in Network: %s').format(st.networkname || '?'), [ + cbiValue(_('New Joiner Credential'), pskdInput, + _('The Joiner Credential is a device-specific string of all uppercase alphanumeric characters (0-9 and A-Y, excluding I, O, Q and Z), with a length between 6 and 32 characters.')), + cbiValue(_('Restrict to a Specific Joiner'), eui64Input, + _('Use the device\'s factory-assigned IEEE EUI-64 to restrict commissioning to a specific Joiner. Use "*" to allow any joiner.')), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-positive important', + 'click': ui.createHandlerFn({}, submit) + }, _('Add')) + ]) + ], 'cbi-modal'); + pskdInput.focus(); +} + +function handleRemove(st) { + ui.showModal(_('Delete Thread network'), [ + E('p', {}, _('This deletes the network "%h" from this border router: the operational dataset and all Thread configuration are erased and devices on the mesh lose their network. This cannot be undone.').format(st.networkname || '?')), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative important', + 'click': ui.createHandlerFn({}, function() { + return callLeave().then(function(r) { + if (notifyReply(r)) + return; + ui.hideModal(); + location.reload(); + }); + }) + }, _('Delete')) + ]) + ]); +} + +// The table mirrors the wireless overview: one device row for the radio +// with the radio-wide actions, then one row for the network on it. There +// is exactly one network per agent (a single 'otbr' ubus object bound to +// one radio), so Create is only offered while nothing is configured. +function renderRadioRow(st) { + var stat = E('div', {}, [ + E('big', {}, E('strong', {}, _('Generic MAC 802.15.4 Thread'))), + E('div') + ]); + // The 2 s poll re-renders through here, so the unreachable case has to + // be visible in the row itself; a load-time notification would not + // survive a daemon that dies while the page is open. + if (st.error) + dom.append(stat.lastElementChild, + E('em', {}, _('otbr-agent is not reachable (%s)').format(otbrErrors.translate(st.error) || st.error))); + else + // 802.15.4 channel k (11-26) sits at 2405 + 5 * (k - 11) MHz; the + // 2.4 GHz O-QPSK PHY has a single 250 kbit/s rate. + L.itemlist(stat.lastElementChild, [ + _('Channel'), (st.channel != null) + ? '%d (%.3f GHz)'.format(st.channel, (2405 + 5 * (st.channel - 11)) / 1000) + : '?', + _('Bitrate'), (st.channel != null) ? '250 kbit/s' : null + ], ' | '); + + // Restart stays available while the agent is unreachable: bouncing the + // interface is exactly how to recover a wedged agent. + var buttons = [ + actionButton(_('Restart'), 'cbi-button cbi-button-neutral', + function() { + return callRadioRestart().then(notifyReply); + }, _('Restart the Thread interface')) + ]; + if (!st.error) { + buttons.push(actionButton(_('Scan'), 'cbi-button cbi-button-action important', + function() { handleScan(st); }, _('Find and join a Thread network'))); + if (!st.configured) + buttons.push(actionButton(_('Create'), 'cbi-button cbi-button-add', + handleSettings, _('Create a new Thread network'))); + } + + return E('tr', { 'class': 'tr cbi-section-table-row' }, [ + E('td', { 'class': 'td cbi-value-field' }, + E('div', { 'class': 'center' }, + E('span', { 'class': 'ifacebadge' }, [ + E('img', { 'src': L.resource('icons/wifi%s.svg').format(st.error ? '_disabled' : '') }), + ' ', + st.interfacename || '?' + ]))), + E('td', { 'class': 'td cbi-value-field' }, stat), + E('td', { 'class': 'td middle cbi-section-actions' }, E('div', {}, buttons)) + ]); +} + +function renderNetworkRow(st, neigh) { + var disabled = (st.state == 'disabled'); + var attached = (st.state == 'leader' || st.state == 'router' || st.state == 'child'); + + // Mirror the wireless network row: a signal badge in dBm. The device + // itself has no single link, so the strongest neighbor stands in; a + // child that only knows its parent falls back to the LQI badge. + var badge; + if (disabled) { + badge = noSignalBadge(_('Thread is disabled'), true); + } else { + var best = strongestNeighbor(neigh || {}); + if (best) + badge = rssiBadge(best.avg, best.last, + _('Strongest neighbor: average / last RSSI')); + else if (st.state == 'child' && (neigh?.neighbor || [])[0]?.LinkQualityIn != null) + badge = lqiBadge(neigh.neighbor[0].LinkQualityIn, _('Link quality to the parent router')); + else + badge = noSignalBadge(attached ? _('No mesh neighbors') : _('Not attached'), false); + } + + // itemlist() emits a separator whenever further items remain, even if + // they are all null-skipped; append the disabled note conditionally so + // an active network's line does not end in a dangling separator. + var items = [ + _('Network'), st.networkname || '?', + _('Role'), disabled ? null : roleName(st.state), + _('PAN ID'), (st.panid != null) ? String(st.panid) : null + ]; + if (disabled) + items.push('', E('em', {}, _('Thread is disabled'))); + var statDiv = L.itemlist(E('div'), items, [ ' | ', E('br') ]); + + var buttons = [ + disabled + ? actionButton(_('Enable'), 'cbi-button cbi-button-neutral', + function() { + return callThreadStart().then(function(r) { + if (!notifyReply(r)) + location.reload(); + }); + }, _('Enable this network')) + : actionButton(_('Disable'), 'cbi-button cbi-button-neutral', + function() { + return callThreadStop().then(function(r) { + if (!notifyReply(r)) + location.reload(); + }); + }, _('Disable this network')), + actionButton(_('Edit'), 'cbi-button cbi-button-action important', + handleSettings, _('Edit this network')), + actionButton(_('Remove'), 'cbi-button cbi-button-negative remove', + function() { handleRemove(st); }, + _('Delete this network')) + ]; + + return E('tr', { 'class': 'tr cbi-section-table-row' }, [ + E('td', { 'class': 'td cbi-value-field' }, + E('div', { 'class': 'center' }, badge)), + E('td', { 'class': 'td cbi-value-field' }, statDiv), + E('td', { 'class': 'td middle cbi-section-actions' }, E('div', {}, buttons)) + ]); +} + +function renderTableRows(st, neigh) { + var rows = [ renderRadioRow(st) ]; + if (!st.error) { + if (st.configured) + rows.push(renderNetworkRow(st, neigh)); + else + rows.push(E('tr', { 'class': 'tr cbi-section-table-row placeholder' }, + E('td', { 'class': 'td', 'colspan': 3 }, + E('em', {}, _('No Thread network is configured yet. Create a new network or scan for one to join.'))))); + } + return rows; +} + +// Merged from the former Thread View page: the partition's leader data. +// The same network badge the neighbour rows carry, so the leader line +// reads as belonging to a network rather than floating on its own. +function networkBadge(st, disabled) { + return E('span', { 'class': 'ifacebadge' }, [ + E('img', { 'src': L.resource('icons/wifi%s.svg').format(disabled ? '_disabled' : '') }), + E('span', {}, [ ' ', st.networkname || '?', ' ', + E('small', {}, [ '(%s)'.format(st.interfacename || '?') ]) ]) + ]); +} + +function renderLeaderTable(st) { + var ld = st.leaderdata; + var rows = [E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th nowrap' }, _('Network')), + E('th', { 'class': 'th center' }, _('Leader Router Id')), + E('th', { 'class': 'th center' }, _('Partition Id')), + E('th', { 'class': 'th center' }, _('Weighting')), + E('th', { 'class': 'th center' }, _('Data Version')), + E('th', { 'class': 'th center' }, _('Stable Data Version')) + ])]; + // data-title drives the column labels the themes show on narrow + // screens, and the classes mirror the header cell by cell. + if (ld) + rows.push(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td nowrap', 'data-title': _('Network') }, networkBadge(st, false)), + E('td', { 'class': 'td center', 'data-title': _('Leader Router Id') }, String(ld.LeaderRouterId ?? '')), + E('td', { 'class': 'td center', 'data-title': _('Partition Id') }, String(ld.PartitionId ?? '')), + E('td', { 'class': 'td center', 'data-title': _('Weighting') }, String(ld.Weighting ?? '')), + E('td', { 'class': 'td center', 'data-title': _('Data Version') }, String(ld.DataVersion ?? '')), + E('td', { 'class': 'td center', 'data-title': _('Stable Data Version') }, String(ld.StableDataVersion ?? '')) + ])); + else + rows.push(E('tr', { 'class': 'tr placeholder' }, + E('td', { 'class': 'td', 'colspan': 6 }, + E('em', {}, _('No information available'))))); + return E('table', { 'class': 'table' }, rows); +} + +// Thread has no way to evict a node: there is no protocol operation to +// remove a device from a network, and its credentials cannot be revoked +// individually. The closest available action is the MAC filter, which +// makes this border router ignore the node's frames until it times out. +function filterModal(title, intro, warning, buttonLabel, buttonClass, action) { + ui.showModal(title, [ + E('p', {}, intro), + E('p', { 'class': 'alert-message warning' }, warning), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')), + ' ', + E('button', { + 'class': buttonClass + ' important', + 'click': ui.createHandlerFn({}, function() { + return action().then(function(r) { + notifyFilterReply(r); + ui.hideModal(); + }); + }) + }, buttonLabel) + ]) + ]); +} + +// Thread cannot evict a device, so every direction of the MAC filter +// deserves the same caveats before it fires. Blocking with the filter off +// also turns it on, which is a router-wide change and has to be on screen +// before it is claimed as acknowledged. +var blockWarning = function(activatesDenylist) { + var w = []; + if (activatesDenylist) + w.push(_('The MAC filter is currently off on this border router. Blocking this device also switches it to denylist mode, a router-wide setting that from then on decides which devices are refused.'), + E('br'), E('br')); + w.push( + _('This is not an eviction: Thread has no operation to remove a device from a network. The device keeps the network credentials, drops out of this router only once its link times out (usually a few minutes), and can rejoin through another router in the mesh. The block is also lost when the border router restarts.'), + E('br'), E('br'), + _('To remove a device for good, change the network key from the network settings, which requires commissioning every other device again.')); + return w; +}; + +function handleBlock(bss, neigh) { + var name = bss.Hostname || bss.ExtAddress || '?'; + // The row is rendered either with the filter off or with the denylist + // already on; only the first case is a mode change, and only there does + // the modal spell one out. + var activates = (neigh.macfilter == 'disable'); + filterModal(_('Block device'), + [ _('Stop accepting traffic from'), ' ', E('strong', {}, [ name ]), ' ', + _('on this border router.') ], + blockWarning(activates), + _('Block'), 'cbi-button cbi-button-negative', + function() { + // Passing the mode along switches the filter on when this is + // the only entry; the daemon reports an existing entry as + // Already and leaves the mode alone next to leftovers. The + // acknowledgement the backend's mode-activation guard asks for + // is only ours to give when the modal named the switch, so it + // is true exactly then: if the mode drifted under a + // two-second-old row, unconfirmed_mode is the right answer. + return callMacfilterAdd(bss.ExtAddress, 'denylist', activates); + }); +} + +function handleAllowlistBlock(bss) { + var name = bss.Hostname || bss.ExtAddress || '?'; + filterModal(_('Block device'), + [ _('Stop accepting traffic from'), ' ', E('strong', {}, [ name ]), ' ', + _('on this border router by removing it from the allowlist.') ], + blockWarning(false), + _('Block'), 'cbi-button cbi-button-negative', + function() { + // The row was rendered under the allowlist, so this changes one + // entry and no mode. Nothing to confirm. + return callMacfilterRemove(bss.ExtAddress, 'allowlist'); + }); +} + +function handleAllow(bss) { + var name = bss.Hostname || bss.ExtAddress || '?'; + filterModal(_('Allow device'), + [ _('Accept traffic from'), ' ', E('strong', {}, [ name ]), ' ', + _('on this border router by adding it to the allowlist.') ], + [ _('This does not commission the device: joining the network still requires its credentials. The filter list is kept in memory and does not survive a border router restart.') ], + _('Allow'), 'cbi-button cbi-button-positive', + function() { + // Same here: the allowlist is already on, so no confirmation is + // due. Sending one would let a stale row turn the allowlist on + // with this device as its only entry, which blocks the rest of + // the mesh -- the very thing the guard exists for. + return callMacfilterAdd(bss.ExtAddress, 'allowlist'); + }); +} + +function neighborAction(bss, neigh) { + if (!bss.ExtAddress) + return ''; + + // Without a mode reading, or without knowing whether this device is on + // the list, there is no safe action to offer: adding to or removing + // from the shared list means opposite things per mode. + if (!neigh.macfilter || bss.Filtered == null) + return E('button', { + 'class': 'cbi-button cbi-button-remove', + 'disabled': 'disabled', + 'title': _('The MAC filter state could not be read.') + }, _('Block')); + + // In allowlist mode the filter list holds the admitted devices, so + // removal is the block and there is nothing to unblock. + if (neigh.macfilter == 'allowlist') { + if (bss.Filtered) + return actionButton(_('Block'), 'cbi-button cbi-button-remove', + function() { handleAllowlistBlock(bss); }, + _('Remove this device from the allowlist and stop accepting its traffic')); + return actionButton(_('Allow'), 'cbi-button cbi-button-neutral', + function() { handleAllow(bss); }, + _('Add this device to the allowlist')); + } + + if (bss.Filtered && neigh.macfilter == 'denylist') + return actionButton(_('Unblock'), 'cbi-button cbi-button-neutral', + function() { + return callMacfilterRemove(bss.ExtAddress, 'denylist') + .then(notifyFilterReply); + }, _('Accept traffic from this device again')); + + // With the filter disabled the list is inert; the block modal enables + // the denylist as it adds the sole entry, and stands down next to + // leftovers from an earlier list rather than weaponising them. + return actionButton(_('Block'), 'cbi-button cbi-button-remove', + function() { handleBlock(bss, neigh); }, + _('Stop accepting traffic from this device')); +} + +// Styled after the wireless page's Associated Stations list: one row per +// mesh peer with a network badge and a dBm signal badge. When this device +// is a child the single row is its parent, which reports link quality +// instead of RSSI. +function renderNeighborTable(neigh, st) { + var rows = [E('tr', { 'class': 'tr table-titles' }, [ + E('th', { 'class': 'th nowrap' }, _('Network')), + E('th', { 'class': 'th' }, _('Role')), + E('th', { 'class': 'th' }, _('Host')), + E('th', { 'class': 'th hide-xs' }, _('Extended MAC')), + E('th', { 'class': 'th', 'title': _('Routing Locator: the mesh-internal short address encoding router and child id') }, _('RLOC16')), + E('th', { 'class': 'th' }, _('Signal (avg / last)')), + E('th', { 'class': 'th', 'title': _('Time since this device was last heard from') }, _('Age')), + E('th', { 'class': 'th cbi-section-actions' }, ' ') + ])]; + + (neigh.neighbor || []).forEach(function(bss) { + var avg = num(bss.AvgRssi); + var badge = (avg != null) + ? rssiBadge(avg, num(bss.LastRssi) ?? avg, _('Average / last received signal')) + : (bss.LinkQualityIn != null) + ? lqiBadge(bss.LinkQualityIn, _('Link quality')) + : noSignalBadge(_('No signal information'), false); + rows.push(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td nowrap', 'data-title': _('Network') }, networkBadge(st, false)), + E('td', { 'class': 'td', 'data-title': _('Role') }, transRole(bss)), + E('td', { 'class': 'td', 'data-title': _('Host') }, [ bss.Hostname || '?' ]), + E('td', { 'class': 'td hide-xs', 'data-title': _('Extended MAC') }, [ bss.ExtAddress || '?' ]), + E('td', { 'class': 'td', 'data-title': _('RLOC16') }, [ bss.Rloc16 || '?' ]), + E('td', { 'class': 'td', 'data-title': _('Signal (avg / last)') }, badge), + E('td', { 'class': 'td', 'data-title': _('Age') }, + (num(bss.Age) != null) ? '%ds'.format(num(bss.Age)) : '-'), + E('td', { 'class': 'td middle cbi-section-actions' }, neighborAction(bss, neigh)) + ])); + }); + + // Devices admitted through commissioner-based joining appear here while + // their join is pending (merged from the former Thread View page). + for (var i = 0; i < (neigh.joinernum || 0); i++) { + var j = (neigh.joinerlist || [])[i] || {}; + var eui64 = j.isAny ? '*' : (j.eui64 || ''); + rows.push(E('tr', { 'class': 'tr' }, [ + E('td', { 'class': 'td nowrap', 'data-title': _('Network') }, networkBadge(st, true)), + E('td', { 'class': 'td', 'data-title': _('Role') }, + E('span', {}, [ _('Joiner'), ' ', E('small', {}, _('(pending)')) ])), + E('td', { 'class': 'td', 'data-title': _('Host') }, '-'), + E('td', { 'class': 'td hide-xs', 'data-title': _('Extended MAC') }, [ eui64 || '?' ]), + E('td', { 'class': 'td', 'data-title': _('RLOC16') }, '-'), + E('td', { 'class': 'td', 'data-title': _('Signal (avg / last)') }, '-'), + E('td', { 'class': 'td', 'data-title': _('Age') }, '-'), + E('td', { 'class': 'td middle cbi-section-actions' }, + E('button', { + 'class': 'cbi-button cbi-button-remove', + 'click': ui.createHandlerFn({}, (function(e64) { + return function() { + return callJoinerRemove(e64).then(function(r) { + otbrErrors.notify(r); + }); + }; + })(eui64)) + }, _('Remove'))) + ])); + } + + if (rows.length == 1) + rows.push(E('tr', { 'class': 'tr placeholder' }, + E('td', { 'class': 'td', 'colspan': 8 }, + E('em', {}, _('No information available'))))); + + return E('table', { 'class': 'table assoclist', 'id': 'neighbors' }, rows); +} + +return view.extend({ + load: function() { + return Promise.all([ + callStateSummary(), + callNeighborsSummary() + ]); + }, + + render: function(data) { + var st = data[0] || {}; + var neigh = data[1] || {}; + + // A read that failed at the ubus layer carries an error instead of + // state; surface it rather than rendering an empty overview. + otbrErrors.notify(st); + + var tbody = E('tbody', { 'class': 'tbody cbi-section-tbody' }, renderTableRows(st, neigh)); + var neighborsBox = E('div', { 'id': 'thread-neighbors' }, renderNeighborTable(neigh, st)); + var leaderBox = E('div', { 'id': 'thread-leader' }, renderLeaderTable(st)); + + // h3 section titles inside cbi-sections, matching the wireless page's + // heading hierarchy. + var view = E([], [ + E('div', { 'class': 'cbi-section cbi-tblsection' }, [ + E('h3', {}, _('Thread Overview')), + E('table', { 'class': 'table cbi-section-table' }, tbody) + ]), + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Leader')), + leaderBox + ]), + E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('Neighbors')), + neighborsBox, + E('div', { 'class': 'right' }, + actionButton(_('Commission device\u2026'), 'cbi-button cbi-button-add', + function() { return handleCommission(st); }, + _('Admit a new device into this Thread network (commissioner-based joining)'))) + ]) + ]); + + poll.add(L.bind(function() { + return Promise.all([callStateSummary(), callNeighborsSummary()]).then(function(d) { + dom.content(tbody, renderTableRows(d[0] || {}, d[1] || {})); + dom.content(neighborsBox, renderNeighborTable(d[1] || {}, d[0] || {})); + dom.content(leaderBox, renderLeaderTable(d[0] || {})); + }); + }, this), 2); + + return view; + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null, +}); diff --git a/applications/luci-app-openthread/po/templates/openthread.pot b/applications/luci-app-openthread/po/templates/openthread.pot new file mode 100644 index 000000000000..b24096124bac --- /dev/null +++ b/applications/luci-app-openthread/po/templates/openthread.pot @@ -0,0 +1,709 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1123 +msgid "(pending)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:174 +msgid "(sleepy)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:457 +msgid "16 hex characters" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:353 +msgid "32 hex characters" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1025 +msgid "Accept traffic from" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1069 +msgid "Accept traffic from this device again" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:494 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:748 +msgid "Add" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:617 +msgid "Add Address" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:737 +msgid "Add Joiner in Network: %s" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1061 +msgid "Add this device to the allowlist" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:485 +msgid "Address length must be 16 hex characters" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1188 +msgid "" +"Admit a new device into this Thread network (commissioner-based joining)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1091 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1109 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1128 +msgid "Age" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1028 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1059 +msgid "Allow" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1024 +msgid "Allow device" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:451 +msgid "Allowlist" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:658 +msgid "" +"An allowlist with no entries admits no device, so every device is blocked " +"until entries are added. Continue?" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:387 +msgid "Attach" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1098 +msgid "Average / last received signal" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:797 +msgid "Bitrate" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:995 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1014 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1050 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1056 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1074 +msgid "Block" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:991 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1010 +msgid "Block device" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:382 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:743 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:758 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:954 +msgid "Cancel" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:692 +msgid "Cannot change mode while the Thread network is started." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:239 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:687 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:794 +msgid "Channel" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:627 +msgid "Channel must be a number between 11 and 26" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:161 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:169 +msgid "Child" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:478 +msgid "Clear" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1186 +msgid "Commission device…" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:812 +msgid "Create" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:813 +msgid "Create a new Thread network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:924 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:935 +msgid "Data Version" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:770 +msgid "Delete" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:755 +msgid "Delete Thread network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:882 +msgid "Delete this network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:452 +msgid "Denylist" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:160 +msgid "Detached" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:450 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:871 +msgid "Disable" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:877 +msgid "Disable this network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:159 +msgid "Disabled" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:337 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:702 +msgid "Dismiss" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:878 +msgid "Edit" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:879 +msgid "Edit this network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:864 +msgid "Enable" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:870 +msgid "Enable this network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:376 +msgid "Enter the network key to ensure you join the right network." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/openthread/errors.js:52 +msgid "Error: %s" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:613 +msgid "Existing Address" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1088 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1106 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1125 +msgid "Extended MAC" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:689 +msgid "Extended PAN ID" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:633 +msgid "Extended PAN ID must be 16 hex characters" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:810 +msgid "Find and join a Thread network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:781 +msgid "Generic MAC 802.15.4 Thread" +msgstr "" + +#: applications/luci-app-openthread/root/usr/share/rpcd/acl.d/luci-app-openthread.json:3 +msgid "Grant access to OpenThread Border Router controls" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1087 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1105 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1124 +msgid "Host" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:263 +msgid "Join Network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:326 +msgid "Join Network: Thread Scan" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1123 +msgid "Joiner" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:371 +msgid "Joining Network: %q" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:163 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1179 +msgid "Leader" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:921 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:932 +msgid "Leader Router Id" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:695 +msgid "Left empty the key stays as it is." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1100 +msgid "Link quality" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:845 +msgid "Link quality to the parent router" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:698 +msgid "MAC-Filter" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:690 +msgid "Mode" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:636 +msgid "Mode must consist of \"r\", \"s\", \"d\", \"n\"" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1183 +msgid "Neighbors" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:854 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:920 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:931 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1085 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1103 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1121 +msgid "Network" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:685 +msgid "Network Configuration" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:378 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:694 +msgid "Network Key" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:238 +msgid "Network Name" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:696 +msgid "Network Password" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:360 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:640 +msgid "Network key length must be 32 hex characters" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:643 +msgid "Network password length must be 32 hex characters" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:738 +msgid "New Joiner Credential" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:901 +msgid "" +"No Thread network is configured yet. Create a new network or scan for one to " +"join." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:941 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1146 +msgid "No information available" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:847 +msgid "No mesh neighbors" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:270 +msgid "No networks found" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1101 +msgid "No signal information" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:847 +msgid "Not attached" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:199 +msgid "" +"Nothing was changed: an active allowlist with no entries blocks every " +"device. Clear it from the network settings, where the affected list is " +"shown, or change the filter mode first." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:200 +msgid "" +"Nothing was changed: the MAC filter list still holds entries from an earlier " +"list, which this mode would give the opposite meaning. Review the MAC filter " +"in the network settings." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:201 +msgid "Nothing was changed: the current MAC filter state could not be read." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:203 +msgid "" +"Nothing was changed: the request would also switch the filter mode on, which " +"changes what every other device may do. Change the mode from the network " +"settings, where the effect is spelled out." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:374 +msgid "" +"One agent manages exactly one Thread network: joining this network replaces " +"the currently configured one." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:688 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:856 +msgid "PAN ID" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:630 +msgid "PAN ID must be a number" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:240 +msgid "PAN Id" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:922 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:933 +msgid "Partition Id" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:699 +msgid "Protocol" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1089 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1107 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1126 +msgid "RLOC16" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:442 +msgid "Read this value from the border router" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:526 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:880 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1139 +msgid "Remove" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1058 +msgid "Remove this device from the allowlist and stop accepting its traffic" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:803 +msgid "Restart" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:806 +msgid "Restart the Thread interface" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:740 +msgid "Restrict to a Specific Joiner" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:434 +msgid "Reveal" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:855 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1086 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1104 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1122 +msgid "Role" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:162 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:170 +msgid "Router" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1089 +msgid "" +"Routing Locator: the mesh-internal short address encoding router and child id" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:501 +msgid "Save & Apply" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:809 +msgid "Scan" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:691 +msgid "" +"Set the thread device mode value, must consist of \"r\", \"s\", \"d\", \"n\"." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:237 +msgid "Signal" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1090 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1108 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1127 +msgid "Signal (avg / last)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:251 +msgid "Signal: %d dBm / Quality: %s" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:925 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:936 +msgid "Stable Data Version" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:320 +msgid "Starting Thread scan..." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:992 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1011 +msgid "Stop accepting traffic from" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1076 +msgid "Stop accepting traffic from this device" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:843 +msgid "Strongest neighbor: average / last RSSI" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:739 +msgid "" +"The Joiner Credential is a device-specific string of all uppercase " +"alphanumeric characters (0-9 and A-Y, excluding I, O, Q and Z), with a " +"length between 6 and 32 characters." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:723 +msgid "" +"The Joiner Credential must be 6 to 32 uppercase alphanumeric characters (0-9 " +"and A-Y, excluding I, O, Q and Z)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:469 +msgid "" +"The MAC filter is an active allowlist. Clearing it leaves no device " +"admitted, which blocks every device until the filter mode is changed. Clear " +"it anyway?" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:976 +msgid "" +"The MAC filter is currently off on this border router. Blocking this device " +"also switches it to denylist mode, a router-wide setting that from then on " +"decides which devices are refused." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:651 +msgid "" +"The MAC filter list holds %d entries written for the previous mode. " +"Switching the mode changes what they mean: %s. Continue?" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:583 +msgid "The MAC filter state could not be read, so it cannot be changed." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1049 +msgid "The MAC filter state could not be read." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:697 +msgid "The commissioner credential (PSKc). Left empty it stays as it is." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:506 +msgid "The filter list could not be read." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:202 +msgid "The filter list was edited, but the filter mode could not be changed." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:456 +msgid "The filter mode could not be read." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:756 +msgid "" +"This deletes the network \"%h\" from this border router: the operational " +"dataset and all Thread configuration are erased and devices on the mesh lose " +"their network. This cannot be undone." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1027 +msgid "" +"This does not commission the device: joining the network still requires its " +"credentials. The filter list is kept in memory and does not survive a border " +"router restart." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:979 +msgid "" +"This is not an eviction: Thread has no operation to remove a device from a " +"network. The device keeps the network credentials, drops out of this router " +"only once its link times out (usually a few minutes), and can rejoin through " +"another router in the mesh. The block is also lost when the border router " +"restarts." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:616 +msgid "This will clear all existing macfilter addresses." +msgstr "" + +#: applications/luci-app-openthread/root/usr/share/luci/menu.d/luci-app-openthread.json:3 +msgid "Thread" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:686 +msgid "Thread Name" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:684 +msgid "Thread Network: %s (%s)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1175 +msgid "Thread Overview" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:693 +msgid "Thread Security" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:838 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:859 +msgid "Thread is disabled" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1091 +msgid "Time since this device was last heard from" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:981 +msgid "" +"To remove a device for good, change the network key from the network " +"settings, which requires commissioning every other device again." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1065 +msgid "Unblock" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:449 +msgid "Unknown" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:741 +msgid "" +"Use the device's factory-assigned IEEE EUI-64 to restrict commissioning to a " +"specific Joiner. Use \"*\" to allow any joiner." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:923 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:934 +msgid "Weighting" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:135 +msgid "disabled" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:727 +msgid "eui64 length must be 16 hex characters or \"*\"" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:256 +msgid "hidden" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1026 +msgid "on this border router by adding it to the allowlist." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:1012 +msgid "on this border router by removing it from the allowlist." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:993 +msgid "on this border router." +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:789 +msgid "otbr-agent is not reachable (%s)" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:656 +msgid "the filter stops applying and none of them is filtered any more" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:655 +msgid "they become blocked devices" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:653 +msgid "they become the only admitted devices and every other device is blocked" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:425 +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:430 +msgid "unchanged" +msgstr "" + +#: applications/luci-app-openthread/htdocs/luci-static/resources/view/openthread/overview.js:255 +msgid "unknown" +msgstr "" diff --git a/applications/luci-app-openthread/root/usr/share/luci/menu.d/luci-app-openthread.json b/applications/luci-app-openthread/root/usr/share/luci/menu.d/luci-app-openthread.json new file mode 100644 index 000000000000..c8bea4edf97a --- /dev/null +++ b/applications/luci-app-openthread/root/usr/share/luci/menu.d/luci-app-openthread.json @@ -0,0 +1,8 @@ +{ + "admin/network/thread": { + "title": "Thread", + "order": 16, + "action": { "type": "view", "path": "openthread/overview" }, + "depends": { "acl": [ "luci-app-openthread" ] } + } +} diff --git a/applications/luci-app-openthread/root/usr/share/rpcd/acl.d/luci-app-openthread.json b/applications/luci-app-openthread/root/usr/share/rpcd/acl.d/luci-app-openthread.json new file mode 100644 index 000000000000..07728b9e0f79 --- /dev/null +++ b/applications/luci-app-openthread/root/usr/share/rpcd/acl.d/luci-app-openthread.json @@ -0,0 +1,34 @@ +{ + "luci-app-openthread": { + "description": "Grant access to OpenThread Border Router controls", + "read": { + "ubus": { + "luci.openthread": [ + "state_summary", + "neighbors_summary", + "scan" + ] + } + }, + "write": { + "ubus": { + "luci.openthread": [ + "settings_get", + "secret_get", + "settings_apply", + "attach", + "radio_restart", + "thread_start", + "thread_stop", + "leave", + "commissioner_start", + "joiner_add", + "joiner_remove", + "macfilter_clear", + "macfilter_add", + "macfilter_remove" + ] + } + } + } +} diff --git a/applications/luci-app-openthread/root/usr/share/rpcd/ucode/luci.openthread b/applications/luci-app-openthread/root/usr/share/rpcd/ucode/luci.openthread new file mode 100644 index 000000000000..6ca66a4295ad --- /dev/null +++ b/applications/luci-app-openthread/root/usr/share/rpcd/ucode/luci.openthread @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2026, The OpenThread Authors. +'use strict'; + +let ubus = require('ubus').connect(); + +function otbr(method, args) { + // On total ubus failure (otbr-agent not running), synthesize an error + // reply; 255 maps to GenericError in the UI. + let r = ubus.call('otbr', method, args ?? {}); + if (r == null) + return { error: 255 }; + // The daemon reports its otError code in an 'Error' field (see + // AppendResult() in ot-br-posix src/openwrt/ubus/otubus.cpp); normalize + // it to the lowercase 'error' used throughout this file. + if (r.error == null && r.Error != null) + r.error = r.Error; + return r; +} + +function radio_restart() { + // Bouncing the netifd interface that runs the border router respawns + // otbr-agent; that is the closest analogue of restarting a radio. + let dump = ubus.call('network.interface', 'dump'); + for (let iface in dump?.interface) { + if (iface.proto != 'thread' && iface.proto != 'openthread') + continue; + ubus.call('network.interface.' + iface.interface, 'down'); + ubus.call('network.interface.' + iface.interface, 'up'); + return { error: 0, interface: iface.interface }; + } + return { error: 255 }; +} + +function summarize_state() { + let s = otbr('state'); + // Distinguish "otbr-agent is not reachable" from "Thread is disabled"; + // without this both render as a disabled network. + if (s.error) + return { error: s.error }; + let result = { state: s.State ?? 'disabled' }; + if (result.state != 'disabled') { + result.configured = true; + result.networkname = otbr('networkname').NetworkName; + result.panid = otbr('panid').PanId; + result.channel = otbr('channel').Channel; + result.rloc16 = otbr('rloc16').rloc16; + let leader = otbr('leaderdata').leaderdata; + if (leader) result.leaderdata = leader; + } else { + // A stopped network keeps its committed dataset, which the stock + // getters reflect even while Thread is disabled; report it so the + // UI can offer to enable the network rather than create one. An + // unprovisioned device reads as the broadcast PAN id. + // The stock ubus formats the PAN id "0x%04x", but parse rather + // than compare the spelling: a bare hex string or a plain number + // must not make a factory-fresh device read as provisioned, and + // anything unparseable counts as unprovisioned. + let raw = otbr('panid').PanId; + // ucode's numeric coercion handles "0xffff", "65535" and a plain + // number alike, and yields NaN for anything else; NaN != NaN, so + // an unparseable reply counts as unprovisioned. + let pan = +raw; + result.configured = (raw != null && pan == pan && pan != 0xffff); + if (result.configured) { + result.networkname = otbr('networkname').NetworkName; + result.panid = raw; + result.channel = otbr('channel').Channel; + } + } + result.interfacename = otbr('interfacename').InterfaceName; + return result; +} + +function otctl(args) { + // The SRP registrations are not exposed over ubus; read them from the + // CLI socket the same way ot-ctl does. Root-only rpcd context. + let fs = require('fs'); + let bin = fs.access('/usr/sbin/ot-ctl', 'x') ? '/usr/sbin/ot-ctl' : 'ot-ctl'; + return fs.popen(bin + ' ' + args, 'r'); +} + +function srp_hostnames() { + // address (lowercase) -> registered SRP host name + let p = otctl('srp server host'); + if (!p) + return {}; + let map = {}; + let host = null; + for (let line = p.read('line'); length(line); line = p.read('line')) { + line = trim(line); + let m = match(line, /^([^.\s]+)\.default\.service\.arpa\.$/); + if (m) { + host = m[1]; + continue; + } + m = match(line, /^addresses: \[(.+)\]$/); + if (m && host) + for (let a in split(m[1], ', ')) + map[lc(trim(a))] = host; + } + p.close(); + return map; +} + +function child_addresses() { + // child rloc16 (lowercase hex, no 0x) -> [registered addresses] + let p = otctl('childip'); + if (!p) + return {}; + let map = {}; + for (let line = p.read('line'); length(line); line = p.read('line')) { + let m = match(trim(line), /^([0-9a-fA-F]{4}): (.+)$/); + if (m) { + let k = lc(m[1]); + map[k] ??= []; + push(map[k], lc(trim(m[2]))); + } + } + p.close(); + return map; +} + +// SRP registrations change on the order of minutes; the overview polls +// every two seconds. Cache the CLI reads so a watching browser does not +// fork ot-ctl twice per tick. +let srp_cache = { at: 0, hosts: {}, children: {} }; + +function srp_lookup() { + let now = time(); + if (srp_cache.at == 0 || now < srp_cache.at || now - srp_cache.at > 30) { + srp_cache.hosts = srp_hostnames(); + srp_cache.children = length(srp_cache.hosts) ? child_addresses() : {}; + srp_cache.at = now; + } + return srp_cache; +} + +// One address list serves both filter modes, so an edit that is harmless +// under one meaning locks devices out under the other. Every mutation is +// therefore evaluated against the mode and the current contents. A read +// that failed is not an empty list: the daemon reports an unreadable mode +// as the literal string "error", and always emits addrlist as an array, +// so anything else means the state is unknown and no edit is safe. +function macfilter_mode() { + let mode = otbr('macfilterstate').state; + return (mode == null || lc(mode) == 'error') ? null : lc(mode); +} + +function macfilter_list() { + let list = otbr('macfilteraddr').addrlist; + return (type(list) == 'array') ? map(list, lc) : null; +} + +function macfilter_snapshot() { + let mode = macfilter_mode(); + let list = macfilter_list(); + return (mode == null || list == null) ? null : { mode: mode, list: list }; +} + +// An absent or empty state argument means "leave the mode as it is". +function macfilter_want(state) { + return length(state) ? lc(state) : null; +} + +// What makes a mode change unsafe, given the list it would reinterpret. +// Returns null when the change is safe (or is not a change at all). +function macfilter_hazard(want, mode, list) { + if (want == null || want == mode) + return null; + // Entries written for the previous meaning would flip to the opposite. + if (length(list) > 0) + return 'leftovers'; + // An allowlist that admits nobody filters every device. + if (want == 'allowlist' && length(list) == 0) + return 'empties_allowlist'; + return null; +} + +function summarize_neighbors() { + let s = otbr('state'); + if (s.error) + return { error: s.error }; + let state = s.State; + let result = { state: state, neighbor: [] }; + + if (state == 'disabled') + return result; + + let list = (state == 'child') + ? otbr('parent').parent_list + : otbr('neighbor').neighbor_list; + for (let n in list) + push(result.neighbor, n); + + // A Thread device announces itself to the mesh by registering a host + // with the SRP server on this border router; that name is the closest + // thing to a hostname the mesh has. Children are matched through their + // registered addresses. (Matter devices register their extended MAC as + // the host name by design, so for them this adds no friendly name.) + if (length(result.neighbor)) { + let srp = srp_lookup(); + let hosts = srp.hosts; + if (length(hosts)) { + let caddrs = srp.children; + for (let n in result.neighbor) { + let k = lc(replace(n.Rloc16 ?? '', '0x', '')); + for (let a in (caddrs[k] ?? [])) { + if (hosts[a] != null) { + n.Hostname = hosts[a]; + break; + } + } + } + } + } + + // The filter mode and each neighbour's own standing on it are what + // the overview needs to offer per-device actions; the full address + // list stays with the other settings behind the write grant. + // Both are left unset when unreadable, so the page can tell "not + // filtered" apart from "unknown" and withhold the per-device actions. + result.macfilter = macfilter_mode(); + let addrlist = macfilter_list(); + if (addrlist != null) + for (let n in result.neighbor) + n.Filtered = index(addrlist, lc(n.ExtAddress ?? '')) >= 0; + + let j = otbr('joinernum'); + result.joinernum = j.joinernum ?? 0; + result.joinerlist = []; + if ((j.joinernum ?? 0) != 0 && j.joinerList) + for (let entry in j.joinerList) + push(result.joinerlist, entry); + + return result; +} + + + +return { + 'luci.openthread': { + state_summary: { + args: {}, + call: function() { + return summarize_state(); + } + }, + radio_restart: { + args: {}, + call: function() { + return radio_restart(); + } + }, + neighbors_summary: { + args: {}, + call: function() { + return summarize_neighbors(); + } + }, + settings_get: { + args: {}, + call: function() { + let st = otbr('state'); + // Otherwise every field comes back null and the form renders + // blank inputs that an admin can Apply against a dead daemon. + if (st.error) + return { error: st.error }; + let addrs = macfilter_list(); + return { + networkname: otbr('networkname').NetworkName, + interfacename: otbr('interfacename').InterfaceName, + panid: otbr('panid').PanId, + channel: otbr('channel').Channel, + extpanid: otbr('extpanid').ExtPanId, + mode: otbr('mode').Mode, + // The network key and the commissioner credential are not + // handed out with the rest of the settings; a page that + // only displays the configuration has no use for them. + // secret_get fetches one when the operator asks for it. + partitionid: otbr('partitionid').Partitionid, + macfilterstate: macfilter_mode(), + addrlist: addrs, + state: st.State, + }; + } + }, + secret_get: { + args: { name: 'string' }, + call: function(req) { + // Reading a secret is an explicit request for that one value, + // so it can be logged and reasoned about on its own. + if (req.args?.name == 'networkkey') { + let r = otbr('networkkey'); + return { error: r.error ?? 0, value: r.Networkkey }; + } + if (req.args?.name == 'pskc') { + let r = otbr('pskc'); + return { error: r.error ?? 0, value: r.pskc }; + } + return { error: 7 /* OT_ERROR_INVALID_ARGS */ }; + } + }, + scan: { + args: {}, + call: function(req) { + // Deferred: the active scan takes several seconds and rpcd + // serves all of LuCI from one event loop, so a synchronous + // call here would freeze every page for the scan duration. + let replied = false; + let d = ubus.defer('otbr', 'scan', {}, function(rc, reply) { + replied = true; + req.reply({ + scan_list: reply?.scan_list ?? [], + error: reply?.error ?? (rc ? 255 : 0) + }); + }); + if (!d) + return replied ? null : { scan_list: [], error: 255 }; + return d; + } + }, + + settings_apply: { + args: { + networkname: 'string', + channel: 0, + panid: 'string', + extpanid: 'string', + mode: 'string', + networkkey: 'string', + pskc: 'string', + macfilter: 'string', + macfilter_confirmed: true, + }, + call: function(req) { + let a = req.args; + let st = otbr('state'); + if (st.error) + return { error: st.error }; + let state = st.State; + // The filter mode is the one setting whose meaning depends + // on data the form does not carry. Refuse a change that + // would reinterpret an existing list or activate an + // allowlist that admits nobody, unless the operator + // confirmed it with the list on screen. + let want = macfilter_want(a.macfilter); + if (want != null) { + let mode = macfilter_mode(); + let list = macfilter_list(); + if (mode == null || list == null) + return { error: 0, unchanged: true, reason: 'unreadable' }; + // Nothing to do, and nothing to warn about. + if (want == mode) + want = null; + else { + let hazard = macfilter_hazard(want, mode, list); + if (hazard != null && !a.macfilter_confirmed) + return { error: 0, unchanged: true, reason: hazard }; + } + } + let result; + if (state == 'disabled') { + // An empty secret means "leave as it is": the form does not + // carry the current values, so it cannot resubmit them. + let steps = [ + [ 'setnetworkname', { networkname: a.networkname } ], + [ 'setchannel', { channel: a.channel } ], + [ 'setpanid', { panid: a.panid } ], + [ 'setextpanid', { extpanid: a.extpanid } ], + [ 'setmode', { mode: a.mode } ], + ]; + if (length(a.networkkey)) + push(steps, [ 'setnetworkkey', { networkkey: a.networkkey } ]); + if (length(a.pskc)) + push(steps, [ 'setpskc', { pskc: a.pskc } ]); + for (let step in steps) { + let r = otbr(step[0], step[1]); + if ((r?.error ?? 0) != 0) + return { error: r.error }; + } + if (want != null) { + let r = otbr('macfiltersetstate', { state: want }); + if ((r?.error ?? 0) != 0) + return { error: r.error }; + } + result = otbr('threadstart'); + } else { + let args = { + networkname: a.networkname, + extpanid: a.extpanid, + panid: a.panid, + channel: sprintf('%d', a.channel), + }; + if (length(a.networkkey)) + args.networkkey = a.networkkey; + if (length(a.pskc)) + args.pskc = a.pskc; + result = otbr('mgmtset', args); + if ((result?.error ?? 0) == 0 && want != null) + result = otbr('macfiltersetstate', { state: want }); + } + return { error: result?.error ?? 0 }; + } + }, + attach: { + args: { panid: 'string', channel: 0, networkkey: 'string' }, + call: function(req) { + let a = req.args; + let steps = [ + [ 'setpanid', { panid: a.panid } ], + [ 'setchannel', { channel: a.channel } ], + [ 'setnetworkkey', { networkkey: a.networkkey } ], + ]; + for (let step in steps) { + let r = otbr(step[0], step[1]); + if ((r?.error ?? 0) != 0) + return { error: r.error }; + } + let r = otbr('threadstart'); + return { error: r?.error ?? 0 }; + } + }, + thread_start: { + args: {}, + call: function() { + let r = otbr('threadstart'); + return { error: r?.error ?? 0 }; + } + }, + thread_stop: { + args: {}, + call: function() { + let r = otbr('threadstop'); + return { error: r?.error ?? 0 }; + } + }, + leave: { + args: {}, + call: function() { + let r = otbr('leave'); + return { error: r?.error ?? 0 }; + } + }, + commissioner_start: { + args: {}, + call: function() { + let r = otbr('commissionerstart'); + return { error: r?.error ?? 0 }; + } + }, + joiner_add: { + args: { pskd: 'string', eui64: 'string' }, + call: function(req) { + let r = otbr('joineradd', { pskd: req.args.pskd, eui64: req.args.eui64 }); + return { error: r?.error ?? 0 }; + } + }, + joiner_remove: { + args: { eui64: 'string' }, + call: function(req) { + let r = otbr('joinerremove', { eui64: req.args.eui64 }); + return { error: r?.error ?? 0 }; + } + }, + macfilter_clear: { + args: { confirmed: true }, + call: function(req) { + let snap = macfilter_snapshot(); + if (snap == null) + return { error: 0, unchanged: true, reason: 'unreadable' }; + // Emptying an active allowlist blocks every device, so it + // needs the operator's explicit go-ahead; the settings + // modal asks with the list on screen. + if (snap.mode == 'allowlist' && length(snap.list) > 0 && !req.args.confirmed) + return { error: 0, unchanged: true, reason: 'empties_allowlist' }; + let r = otbr('macfilterclear'); + return { error: r?.error ?? 0 }; + } + }, + macfilter_add: { + args: { addr: 'string', state: 'string', confirmed: true }, + call: function(req) { + if (!length(req.args.addr)) + return { error: 7 }; + let snap = macfilter_snapshot(); + if (snap == null) + return { error: 0, unchanged: true, reason: 'unreadable' }; + let addr = lc(req.args.addr); + let want = macfilter_want(req.args.state); + let others = filter(snap.list, a => a != addr); + // Turning a mode on next to entries from an earlier list + // would give those entries the opposite meaning, and the + // address added here would join a list that means the + // opposite of the intent. Change nothing at all. + let hazard = macfilter_hazard(want, snap.mode, others); + // Only leftovers can apply to an add. The address written here + // is in the list this produces, so it can never leave an active + // allowlist empty -- and reporting that would tell the operator + // the opposite of what would have happened. + if (hazard == 'empties_allowlist') + hazard = null; + if (hazard != null) + return { error: 0, unchanged: true, reason: hazard }; + // Adding under the mode already in force affects this device + // only, but switching the mode on decides what every other + // device may do -- an allowlist of one blocks the rest of the + // mesh. macfilter_clear and settings_apply make the caller + // acknowledge that; so does this. + if (want != null && want != snap.mode && !req.args.confirmed) + return { error: 0, unchanged: true, reason: 'unconfirmed_mode' }; + let r = otbr('macfilteradd', { addr: req.args.addr }); + let err = r?.error ?? 0; + // Already (24) means the entry was there; the mode may + // still be switched on for it. + if (err != 0 && err != 24) + return { error: err }; + if (want != null && want != snap.mode) { + let sr = otbr('macfiltersetstate', { state: want }); + if ((sr?.error ?? 0) != 0) + return { error: sr.error, reason: 'mode_unchanged' }; + } + return { error: err }; + } + }, + macfilter_remove: { + args: { addr: 'string', state: 'string', confirmed: true }, + call: function(req) { + if (!length(req.args.addr)) + return { error: 7 }; + let snap = macfilter_snapshot(); + if (snap == null) + return { error: 0, unchanged: true, reason: 'unreadable' }; + let addr = lc(req.args.addr); + let want = macfilter_want(req.args.state); + let remaining = filter(snap.list, a => a != addr); + // A mode switch would reinterpret whatever stays behind. + let hazard = macfilter_hazard(want, snap.mode, remaining); + if (hazard != null) + return { error: 0, unchanged: true, reason: hazard }; + // An active allowlist with no entries admits nobody, so + // emptying one filters every neighbour rather than this + // single device. + if ((want ?? snap.mode) == 'allowlist' && + length(remaining) == 0 && length(snap.list) > 0) + return { error: 0, unchanged: true, reason: 'empties_allowlist' }; + // Same as in macfilter_add: a mode this call would switch on + // reinterprets the whole mesh, not just this address. + if (want != null && want != snap.mode && !req.args.confirmed) + return { error: 0, unchanged: true, reason: 'unconfirmed_mode' }; + let r = otbr('macfilterremove', { addr: req.args.addr }); + let err = r?.error ?? 0; + if (err != 0) + return { error: err }; + if (want != null && want != snap.mode) { + let sr = otbr('macfiltersetstate', { state: want }); + if ((sr?.error ?? 0) != 0) + return { error: sr.error, reason: 'mode_unchanged' }; + } + return { error: err }; + } + }, + } +};