Skip to content

luci-app-openthread: add new application - #8871

Open
LorbusChris wants to merge 1 commit into
openwrt:masterfrom
LorbusChris:luci-app-openthread
Open

luci-app-openthread: add new application#8871
LorbusChris wants to merge 1 commit into
openwrt:masterfrom
LorbusChris:luci-app-openthread

Conversation

@LorbusChris

@LorbusChris LorbusChris commented Jul 25, 2026

Copy link
Copy Markdown

Adds a LuCI web interface for the OpenThread Border Router (openthread-br),
proposed to be moved out of the ot-br-posix tree at the maintainers' request
(openthread/ot-br-posix#3431, openwrt/packages#29791) and rewritten for the
modern client-side LuCI framework.

Features: network overview with a live neighbor table, network scan & join,
network settings (channel/PAN ID/keys) including MAC-filter management, joiner
commissioning, and a topology list view.

Architecture: client-side JS views (view.extend / rpc.declare / poll /
ui / dom) over an rpcd ucode backend (luci.openthread) that brokers
ACL-gated access to otbr-agent's ubus API. The raw otbr ubus object is not
exposed to LuCI sessions. Depends on rpcd-mod-ucode.

The app reads otbr-agent's existing ubus reply fields, so it works against the
currently packaged openthread-br — no ot-br-posix change is required.

Coordination

Both are still proposals under review; nothing has been removed anywhere yet.

The app reads otbr-agent's existing ubus reply fields, so no ot-br-posix change is
required for it to work. Note otbr-agent returns its otError code in a capital
Error field; the ucode backend maps it to lowercase error internally, so no
daemon-side change is implied by that either.

Interface names shown in the UI come from otbr-agent's interfacename reply
rather than a hardcoded wpan0.

On access control: the read ACL covers status, neighbour and scan methods only.
Every method that can expose or change credentials — network key, PSKc, MAC
filter, joiner management — requires the write ACL. Form inputs use
non-functional placeholders rather than example values, and the PSKd, EUI-64 and
network-key fields are validity-checked client-side before any call is issued.

Testing

Syntax/convention validation: JS/ucode parse, ACL/menu JSON valid, i18n-scan
template generated, luci.mk app layout, and every ubus reply field the app reads
cross-checked against the names otbr-agent's ubus server actually emits. Not yet
exercised on a live device.

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the new package (1 commit). The rpcd ucode backend and ACL/menu wiring look sound: every exposed luci.openthread method is ACL-gated, the raw otbr ubus object is not re-exposed, and secret-returning calls (settings_get, get_networkkey) sit under the write grant. Backend coupling verified where possible — threadstart/threadstop match the otbr ubus object used by the openthread-br proto script; the remaining otbr-agent method names live in ot-br-posix (not in the pre-cloned trees) and per the PR body were cross-checked against the daemon. The same-named-package conflict with the old in-feed luci-app-openthread is already tracked in the PR description (openwrt/packages#30091). A few inline nits/questions below.


Generated by Claude Code

@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch 2 times, most recently from 015144a to 4c759f2 Compare July 25, 2026 17:24
@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch from 4c759f2 to 24ec23c Compare July 25, 2026 22:18
LorbusChris added a commit to LorbusChris/openwrt-packages that referenced this pull request Jul 25, 2026
The LuCI web interface for OpenThread Border Router is proposed for the
openwrt/luci repository as luci-app-openthread (openwrt/luci#8871),
following the standard convention for LuCI apps, as requested in
openwrt#29791 and
openthread/ot-br-posix#3431.

Remove the luci-app-openthread subpackage (and its install recipe) so
the UI is no longer shipped from two places.

Split out of openwrt#30091 so it can be reviewed and land independently of the
2026.07.0 update.

Signed-off-by: Christian Glombek <c.glombek@cosa.systems>
Assisted-By: Claude Fable 5
LorbusChris added a commit to LorbusChris/openwrt-packages that referenced this pull request Jul 25, 2026
The LuCI web interface for OpenThread Border Router is proposed for the
openwrt/luci repository as luci-app-openthread (openwrt/luci#8871),
following the standard convention for LuCI apps, as requested in
openwrt#29791 and
openthread/ot-br-posix#3431.

Remove the luci-app-openthread subpackage (and its install recipe) so
the UI is no longer shipped from two places.

Split out of openwrt#30091 so it can be reviewed and land independently of the
2026.07.0 update.

Signed-off-by: Christian Glombek <c.glombek@cosa.systems>
Assisted-By: Claude Fable 5
@LorbusChris

Copy link
Copy Markdown
Author

I'd appreciate if someone could approve the CI workflows here :)

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the changes since f1c07d3 (branch was amended into a single commit, 24ec23c). All three earlier threads are addressed correctly:

  • add.jsvalueplaceholder on both joiner fields, plus a PSKd check whose character class ([0-9A-HJ-NPR-Y]{6,32}) matches the base32-thread alphabet described in the field's own help text.
  • settings.js / view.js — the hardcoded wpan0 is gone; both titles now render interfacename. Verified the backing ubus method exists: the legacy app read the same threadget("interfacename").InterfaceName pair (102-openwrt-fix-LuCI-icons.patch:20 in the openthread-br feed package), and settings_get was extended to return it alongside the existing state_summary field.
  • luci.openthread — the capital-Error normalization looks right, and join.js dropping get_networkkey is a clean removal: the method and its ACL entry are both gone and no caller remains (git grep get_networkkey is empty).

openthread.pot was regenerated consistently with the moved line numbers and the two reworded %s (%s) titles. CI has no completed checks on this head (workflow approval still pending), so nothing to ground against there.

Two follow-ups inline — one nit on validation parity, one question about error propagation on the read paths.


Generated by Claude Code

@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch from 24ec23c to 233d49f Compare July 26, 2026 06:15
LorbusChris added a commit to LorbusChris/openwrt-packages that referenced this pull request Jul 26, 2026
The LuCI web interface for OpenThread Border Router is proposed for the
openwrt/luci repository as luci-app-openthread (openwrt/luci#8871),
following the standard convention for LuCI apps, as requested in
openwrt#29791 and
openthread/ot-br-posix#3431.

Remove the luci-app-openthread subpackage (and its install recipe) so
the UI is no longer shipped from two places.

Split out of openwrt#30091 so it can be reviewed and land independently of the
2026.07.0 update.

Signed-off-by: Christian Glombek <c.glombek@cosa.systems>
Assisted-By: Claude Fable 5

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the changes since 24ec23c (branch amended again into a single commit, 233d49c). Both threads from the last round are addressed:

  • add.js — EUI-64 check taken as suggested, now /^[0-9a-f]{16}$/i, matching the hex promise in its own error message and the checks in join.js / settings.js.
  • luci.openthread — all three read paths now bail out with { error } instead of synthesizing a partial reply. summarize_state dropping the s?. optional chaining is safe: otbr() never returns null, it substitutes { error: 255 }. settings_get reusing the state reply instead of calling otbr('state') twice is a nice side effect. The if (s.error) guard also behaves correctly on the normal path, since otbr-agent's Error: 0 normalizes to a falsy error.

No new translatable strings in this round, so openthread.pot is still consistent. No CI checks have run against this head (workflow approval still pending), so nothing to ground against there.

Three follow-ups inline, all about how far the error now propagates: the settings.js error path still renders a positive "network is enabled" claim, view.js is the one read consumer that did not get the new notify(), and the overview's 2 s poll bypasses it.


Generated by Claude Code

@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch from 233d49f to 0dcade0 Compare July 26, 2026 20:25

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the changes since 233d49c (branch amended again into a single commit, 0dcade0). Diff is three view files plus the regenerated template. All three threads from the last round are addressed:

  • settings.jsrender() now reads s.error first and returns an error-only card, so the "Thread network is enabled" claim, the live Disable, the mode hint and LEAVE are all unreachable on a failed read. The stale comment about blank inputs is gone too.
  • view.js — same treatment, plus the otbrErrors.notify(st) that was missing.
  • overview.js — the poll blind spot is handled the right way round: renderStatusRow() itself renders the unreachable row, so the 2 s tick surfaces a daemon that dies mid-session without toast spam, and the load path keeps its one-shot notify().

alert-message warning matches the dominant in-tree class for this kind of card (18 uses across applications/), and otbrErrors.translate(x) || x degrades correctly for a non-numeric code. Checked the regenerated openthread.pot mechanically: every #: reference resolves to a line that actually contains its msgid at 0dcade0, and the new otbr-agent is not reachable (%s) string is listed against all three call sites.

CI on this head: the three FormalityCheck runs pass; the build workflow is still awaiting maintainer approval, so nothing to ground against there.

One follow-up inline — view.js has the same poll-vs-load-path asymmetry that overview.js just fixed.


Generated by Claude Code

@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch from 0dcade0 to 83b422c Compare July 27, 2026 17:13

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the changes since 0dcade0 (branch amended again into a single commit, 83b422c). Diff is view.js plus the regenerated template. The thread from the last round is addressed the way it was framed:

  • renderLeaderTable and renderNeighborsTable each handle error themselves and render an unreachable row, so the 2 s poll surfaces a daemon that dies mid-session — matching the renderStatusRow treatment in overview.js, comment and all.
  • The early return in render() is gone, so poll.add is now always reached: a load against a dead otbr-agent no longer strands the page until a manual reload. That was the more consequential half of the last comment.
  • The heading falls back to the plain Thread View when st.error is set, so the Thread View: () rendering can't occur.
  • otbrErrors.notify(st) is kept above as the one-shot load-time signal, so there is no per-tick toast spam.

Checked the regenerated openthread.pot mechanically: every #: reference resolves to a line that actually contains its msgid at 83b422c (spot-checked all 24 changed view.js references), no msgids were added or removed, and the existing otbr-agent is not reachable (%s) entry correctly gained the two new call sites at view.js:40 and view.js:87 alongside the three older ones.

CI on this head: the three FormalityCheck runs pass; the build workflow is still awaiting maintainer approval, so nothing to ground against there.

Two follow-ups inline, both cosmetic — a width style that the new error branch dropped, and the heading being the one node the poll doesn't refresh.


Generated by Claude Code


function renderNeighborsTable(neigh) {
if (neigh && neigh.error) {
return E('table', { 'class': 'table' }, [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this error table drops the 'style': 'width:90%;margin-left:5%' that the normal return of the same function carries at view.js:129.`` Its wrapper neighborsBox is a bare `E('div', {})`, so the width comes from the table itself — meaning the unreachable row renders full-bleed and left-flush while the leader box above it stays inset at 5% (that one gets its width from the `cbi-map` wrapper, so its error branch is unaffected). Since the poll swaps between the two variants every 2 s, the section visibly jumps in and out of alignment as the daemon goes down and comes back.

Suggested change
return E('table', { 'class': 'table' }, [
return E('table', { 'class': 'table', 'style': 'width:90%;margin-left:5%' }, [

Generated by Claude Code

Comment on lines +148 to +150
E('h2', {}, [ st.error
? _('Thread View')
: _('Thread View: %s (%s)').format(st.networkname || '', st.interfacename || '') ]),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this heading is now the only part of the page that the poll does not refresh. leaderBox and neighborsBox are re-rendered every 2 s by the dom.content() calls at view.js:169-174,`` but the h2 is built once from the load-time `st`, so both directions of the transition end up inconsistent:

  • daemon dies mid-session → stale Thread View: MyNet (wpan0) sits above two "otbr-agent is not reachable" tables;
  • load failed, daemon comes back → tables repopulate but the title stays the bare Thread View, which is exactly the self-repair the early return was dropped for.

Keeping a reference to the title node and dom.content()-ing it from the poll callback next to the other two boxes would put it back in step. Purely cosmetic either way — the tables already carry the real signal.


Generated by Claude Code

@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch 6 times, most recently from 833b3cd to 19d2773 Compare July 28, 2026 23:27
@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch 6 times, most recently from 5e3a874 to 711a5de Compare July 29, 2026 03:13
Add a LuCI web interface for the OpenThread Border Router (openthread-br
package): network overview with a live neighbor table, network scan and
join, network settings including MAC-filter management, joiner
commissioning, and a network topology list view.

The application is client-side JS (view.extend / rpc.declare) with an
rpcd ucode backend (luci.openthread) that brokers ACL-gated access to
otbr-agent's ubus API; the raw otbr ubus object is not exposed to LuCI
sessions.

This migrates the app out of the ot-br-posix repository, where a legacy
Lua/htm version was previously maintained under src/openwrt and packaged
by the openthread-br feed package, per the maintainers' request in
openthread/ot-br-posix#3431 and openwrt/packages#29791. The UI has been
rewritten for the modern client-side LuCI framework as part of the move.

Signed-off-by: Christian Glombek <c.glombek@cosa.systems>
Assisted-By: Claude Fable 5
@LorbusChris
LorbusChris force-pushed the luci-app-openthread branch from 711a5de to 7f9db59 Compare July 29, 2026 04:47

@openwrt-ai openwrt-ai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the changes since 83b422c (branch amended again into a single commit, 7f9db59). This round is a restructure rather than a fixup: the five separate views and their menu entries are gone, everything is now the single overview page with the former pages as modals, and the backend gained radio_restart, secret_get, Active Dataset TLV decoding, a deferred scan and SRP host-name lookup via ot-ctl. Both nits from the last round are moot — view.js no longer exists.

What I checked on the new surface:

  • Secrets. settings_get no longer hands out networkkey/pskc; they moved behind secret_get, which is in the write grant, as is radio_restart. The read grant still only covers state_summary, neighbors_summary and scan — with one exception I've raised inline (the MAC filter list is now part of the read-gated neighbour summary).
  • Deferred scan. Matches the sanctioned rpcd pattern — return the ubus.defer() handle and reply from the completion callback (example-plugin.uc:108-122), which rpcd recognises via the ubus.deferred resource check in ucode.c:463 and guards with its own timeout. Same shape as the in-tree luci.upnp. The !d fallback covers the defer-failed case.
  • radio_restart. The proto match is right: the feed registers add_protocol openthread (openthread-proto.sh:107), and /usr/sbin/ot-ctl is the same path that script uses, so the openthread-br dependency in the Makefile covers the new CLI reads.
  • Dataset decoding. The TLV walk is bounded on both ends (i + 4 <= n, truncated-value break), and the Channel TLV correctly skips the channel-page byte before taking the 2-byte channel.
  • Translations. openthread.pot is regenerated consistently: all 157 #: references resolve to a line that actually contains their msgid at 7f9db59.
  • CI on this head. The three FormalityCheck runs pass; the build workflow is still awaiting maintainer approval, so nothing to ground against there.

Five follow-ups inline. The first one is not cosmetic: in allowlist (and disable) mode the new per-neighbour action reads the filter list as if it were always a deny list, so allowlisted devices get an Unblock button that in fact removes them from the allowlist.


Generated by Claude Code

if (!bss.ExtAddress)
return '';

var blocked = (neigh.blocked || []).indexOf(bss.ExtAddress) >= 0;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neigh.blocked is the raw macfilteraddr.addrlist, returned regardless of the filter mode (luci.openthread:184-185)`` — in allowlist mode that list is the allow list. Since this check runs before the `neigh.macfilter == 'allowlist'` guard below, every allowlisted neighbour renders an Unblock button whose handler calls `macfilter_remove(addr, 'allowlist')`, i.e. drops the device from the allowlist and thereby blocks it. The guard on line 836 is unreachable for exactly the devices it was written for. The `disable` case has the same shape: the daemon keeps its entries while the filter is off, so a leftover address shows "Unblock" although nothing is being filtered.

Suggested change
var blocked = (neigh.blocked || []).indexOf(bss.ExtAddress) >= 0;
var blocked = (neigh.macfilter == 'denylist') &&
(neigh.blocked || []).indexOf(bss.ExtAddress) >= 0;

Worth double-checking the letter case too: this is an exact indexOf match between bss.ExtAddress from the neighbour list and the entries of addrlist, so the two ubus replies have to agree on case for a blocked device to be recognised at all.


Generated by Claude Code

Comment on lines +181 to +185
// The MAC filter is how a node can be kept off this border router;
// report it alongside the neighbours so the overview can offer it
// without the settings read, which is privileged (it exposes keys).
result.macfilter = otbr('macfilterstate').state;
result.blocked = otbr('macfilteraddr').addrlist ?? [];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This moves the MAC filter state and the full filter address list from the write-gated settings_get into the read-gated neighbors_summary, so a session holding only the read grant of luci-app-openthread now gets the filter contents (which devices the operator excluded, or in allowlist mode the complete roster of permitted EUI-64s).

The stated justification no longer holds after this same commit: settings_get does not expose keys any more — networkkey/pskc moved to the write-gated secret_get a few lines further down. So "the settings read, which is privileged (it exposes keys)" describes the previous revision.

Is the read-side exposure intended? If the overview needs the filter only to label the Block/Unblock buttons, keeping it in the (now key-free, still write-gated) settings_get would avoid widening the read grant; if it is intended, the comment should say so rather than cite the key exposure.


Generated by Claude Code

Comment on lines +95 to +101
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');
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two questions about this helper, both because it sits on the polled path: summarize_neighbors is what the overview's 2 s poll calls (overview.js:962-968).``

  1. Cost. With at least one neighbour present, every tick forks ot-ctl srp server host, and if the SRP server has any host, a second ot-ctl childip — two process spawns every 2 s per open browser page, on top of the dozen-odd ubus round-trips the two summaries already make. The host names change on the order of minutes, not seconds; caching the map with a short TTL, or building it only on the load path and reusing it across ticks, would keep the poll cheap on the low-end hardware this typically runs on.

  2. Interface. ot-ctl is invoked with no interface argument, so it uses whatever CLI socket it defaults to, while the rest of this file deliberately reports the daemon's interfacename instead of assuming wpan0 (that was the fix in an earlier round). Counter-evidence that this may be fine: the feed's own proto script also calls it bare (openthread-proto.sh:22). But if otbr-agent can be run on a non-default interface, these two reads would silently come back empty while every ubus-sourced field on the page keeps working — the Host column would just show ? with no hint why. Passing the known interface name explicitly would remove the question.


Generated by Claude Code

Comment on lines +203 to +206
// Scan and join run as modals on this page, mirroring the wireless
// overview. Unlike wireless, a Thread active scan is a single multi-second
// operation (deferred at the rpcd layer), so the modal shows a spinner and
// offers an explicit repeat instead of a continuous refresh.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this describes the opposite of what the modal does. handleScan registers poll.add(state.pollFn, 5) (line 316), so the scan repeats every 5 s for as long as the modal is open, results accumulate in state.cache and stale entries are dimmed rather than dropped — which is what the comment at line 313 says ("Scan continuously while the modal is open, like the wireless scan"). There is no explicit repeat control; the modal's only button is Dismiss.

Suggested change
// Scan and join run as modals on this page, mirroring the wireless
// overview. Unlike wireless, a Thread active scan is a single multi-second
// operation (deferred at the rpcd layer), so the modal shows a spinner and
// offers an explicit repeat instead of a continuous refresh.
// 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 scan returns and then rescans every 5s, accumulating results.

Generated by Claude Code

E('button', {
'class': 'cbi-button cbi-button-add',
'click': ui.createHandlerFn({}, function() {
if (macAddInput.value.length != 16) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: length-only, so zzzzzzzzzzzzzzzz is accepted and forwarded to macfilteradd, even though the error message on the next line already promises "16 hex characters". Every other input in this file checks the character class — extended PAN ID at line 500, network key at 507, PSKc at 510, the joiner EUI-64 at 568 — and this is the same EUI-64 the joiner field validates as hex.

Suggested change
if (macAddInput.value.length != 16) {
if (!/^[0-9a-f]{16}$/i.test(macAddInput.value)) {

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

add package Introduces a new package Makefile build script

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants