🤞 Sync 2026-10-29 22:46:41

This commit is contained in:
github-actions[bot] committed 2026-10-29 22:46:41 +08:00
1 parent ff521a5a6a
commit 8d20d896c0
78 files changed
+16387 -1210

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=cloudflared-next PKG_NAME:=cloudflared-next
PKG_VERSION:=2026.10.5 PKG_VERSION:=2026.10.6
PKG_RELEASE:=8 PKG_RELEASE:=9
# Architecture mapping for pre-built binaries # Architecture mapping for pre-built binaries
ifeq ($(ARCH),x86_64) ifeq ($(ARCH),x86_64)
+2 -2
View File
@@ -6,9 +6,9 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=dae PKG_NAME:=dae
PKG_VERSION:=2026.10.18 PKG_VERSION:=2026.10.18
PKG_RELEASE:=51 PKG_RELEASE:=52
PKG_SOURCE:=dae-src-2026.10.18-9ec2736d61b0.tar.gz PKG_SOURCE:=dae-src-2026.10.18-32ed09f0d784.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
+2 -2
View File
@@ -6,9 +6,9 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=daed PKG_NAME:=daed
PKG_VERSION:=2026.10.18 PKG_VERSION:=2026.10.18
PKG_RELEASE:=63 PKG_RELEASE:=64
PKG_SOURCE:=daed-src-2026.10.18-ac22e7a32e17.tar.gz PKG_SOURCE:=daed-src-2026.10.18-97c371b47eb5.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION) PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=skip PKG_HASH:=skip
+6 -6
View File
@@ -4,7 +4,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=honk PKG_NAME:=honk
PKG_VERSION:=0.1.0_beta3 PKG_VERSION:=0.1.0_beta3
PKG_RELEASE:=23 PKG_RELEASE:=24
# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49) # Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49)
# because that is what the GitHub release asset name uses. # because that is what the GitHub release asset name uses.
@@ -12,8 +12,8 @@ HONK_RELEASE_TAG:=v0.1.0-beta.3
HONK_ASSET_TAG:=v0.1.0-beta.3 HONK_ASSET_TAG:=v0.1.0-beta.3
# SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh. # SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh.
HONK_HASH_X86_64:=c839b648a443abde8b9bbc8290ebc3c6cd6a5c3e579f391a79016304820354ff HONK_HASH_X86_64:=259836292247434ee1ced18f8239c4486697c4855ac124195ee6f4c80e0df697
HONK_HASH_AARCH64:=216ace91ba157f909fca2ecf1f3b33015251b9481382609542fc6907dd521602 HONK_HASH_AARCH64:=97e49b43c8efc4618f77a9ccb8a1b015e07e5e942728fd466a2d118cd82db169
PKG_LICENSE:=AGPL-3.0-only PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILE:=LICENSE PKG_LICENSE_FILE:=LICENSE
@@ -27,9 +27,9 @@ else ifeq ($(ARCH),aarch64)
HONK_HASH:=$(HONK_HASH_AARCH64) HONK_HASH:=$(HONK_HASH_AARCH64)
endif endif
# Default to the stock-malloc build: lower RSS high-water on small gateways. # mimalloc build: faster QUIC. honk.init sets MIMALLOC_ARENA_RESERVE=0 so the
# Use an empty suffix to switch back to the mimalloc build (faster QUIC). # 1 GiB arena reservation stays out of VmSize. Use "-stock" to go back.
HONK_SUFFIX:=-stock HONK_SUFFIX:=
# Upstream feature variant: -native-api (recommended for OpenWrt gateway), -full, or empty for legacy releases. # Upstream feature variant: -native-api (recommended for OpenWrt gateway), -full, or empty for legacy releases.
HONK_VARIANT:=-native-api HONK_VARIANT:=-native-api
HONK_ASSET:=honk-core-$(HONK_ASSET_TAG)-$(HONK_TARGET)$(HONK_SUFFIX)$(HONK_VARIANT).tar.gz HONK_ASSET:=honk-core-$(HONK_ASSET_TAG)-$(HONK_TARGET)$(HONK_SUFFIX)$(HONK_VARIANT).tar.gz
+3 -1
View File
@@ -87,7 +87,9 @@ start_service() {
hijack_resolv_conf hijack_resolv_conf
procd_open_instance "$CONF" procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" # mimalloc pre-reserves a 1 GiB arena; VSZ only, but it dwarfs the real
# footprint in ps. MIMALLOC_ARENA_RESERVE=0: grow on demand instead.
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" MIMALLOC_ARENA_RESERVE="0"
procd_set_param command "$PROG" --config "$config_file" procd_set_param command "$PROG" --config "$config_file"
procd_set_param netdev "$HONK_HOST_IFACE" "$HONK_PEER_IFACE" procd_set_param netdev "$HONK_HOST_IFACE" "$HONK_PEER_IFACE"
procd_set_param limits core="unlimited" procd_set_param limits core="unlimited"
+2 -2
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-diskman PKG_NAME:=luci-app-diskman
PKG_VERSION:=0.2.13 PKG_VERSION:=0.2.13
PKG_RELEASE:=4 PKG_RELEASE:=5
PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com> PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com>
PKG_LICENSE:=AGPL-3.0 PKG_LICENSE:=AGPL-3.0
@@ -11,7 +11,7 @@ LUCI_TITLE:=Disk Manager interface for LuCI
LUCI_DEPENDS:= \ LUCI_DEPENDS:= \
+luci-compat +blkid +e2fsprogs +parted +smartmontools +dosfstools \ +luci-compat +blkid +e2fsprogs +parted +smartmontools +dosfstools \
+kmod-fs-msdos +kmod-fs-ntfs3 \ +kmod-fs-msdos +kmod-fs-ntfs3 \
+LINUX_5_4:kmod-fs-exfat-oot +!LINUX_5_4:kmod-fs-exfat +exfat-mkfs \ +kmod-fs-exfat +exfat-mkfs \
+exfat-fsck +kmod-nls-cp932 +kmod-nls-cp936 +kmod-nls-cp950 \ +exfat-fsck +kmod-nls-cp932 +kmod-nls-cp936 +kmod-nls-cp950 \
+PACKAGE_$(PKG_NAME)_INCLUDE_btrfs_progs:btrfs-progs \ +PACKAGE_$(PKG_NAME)_INCLUDE_btrfs_progs:btrfs-progs \
+PACKAGE_$(PKG_NAME)_INCLUDE_lsblk:lsblk \ +PACKAGE_$(PKG_NAME)_INCLUDE_lsblk:lsblk \
@@ -13,4 +13,7 @@ uci -q batch <<-EOF >/dev/null
set ucitrack.@mail[-1].init=msmtp set ucitrack.@mail[-1].init=msmtp
commit ucitrack commit ucitrack
EOF EOF
# The SMTP password is in there: root only, as /etc/msmtprc (uci commit
# keeps the file mode)
chmod 600 /etc/config/mail 2>/dev/null
exit 0 exit 0
@@ -146,7 +146,10 @@ return L.view.extend({
_('bpf_burst => same as the default scheduler') + '<br />' + _('bpf_burst => same as the default scheduler') + '<br />' +
_('bpf_red => sends all packets redundantly on all available subflows') + '<br />' + _('bpf_red => sends all packets redundantly on all available subflows') + '<br />' +
_('bpf_first => always picks the first subflow to send data') + '<br />' + _('bpf_first => always picks the first subflow to send data') + '<br />' +
_('bpf_rr => always picks the next available subflow to send data (round-robin)') _('bpf_rr => always picks the next available subflow to send data (round-robin)') + '<br />' +
_('bpf_ecf => sends on the lowest-latency subflow, and on a slower one only when that does not delay the data') + '<br />' +
_('bpf_blest => sends on the lowest-latency subflow, and on a slower one only when that does not block the fastest one') + '<br />' +
_('bpf_overflow => uses the subflows by weight: a lower weight only carries what the higher ones cannot')
); );
scheduler.value("default", _("default")); scheduler.value("default", _("default"));
@@ -178,6 +181,9 @@ return L.view.extend({
// bpf_red => sends all packets redundantly on all available subflows // bpf_red => sends all packets redundantly on all available subflows
// bpf_first => always picks the first subflow to send data // bpf_first => always picks the first subflow to send data
// bpf_rr => always picks the next available subflow to send data (round-robin) // bpf_rr => always picks the next available subflow to send data (round-robin)
// bpf_ecf => sends on the lowest-latency subflow, and on a slower one only when that does not delay the data
// bpf_blest => sends on the lowest-latency subflow, and on a slower one only when that does not block the fastest one
// bpf_overflow => uses the subflows by weight: a lower weight only carries what the higher ones cannot
} }
scheduler.onchange = function(ev, section_id, value) { scheduler.onchange = function(ev, section_id, value) {
@@ -203,6 +209,7 @@ return L.view.extend({
o.depends("mptcp_scheduler", "bpf_weight"); o.depends("mptcp_scheduler", "bpf_weight");
o.depends("mptcp_scheduler", "bpf_weight_rr"); o.depends("mptcp_scheduler", "bpf_weight_rr");
o.depends("mptcp_scheduler", "bpf_burstweight"); o.depends("mptcp_scheduler", "bpf_burstweight");
o.depends("mptcp_scheduler", "bpf_overflow");
/* Every option below that depends() on another field keeps its stored /* Every option below that depends() on another field keeps its stored
* value while hidden: form.js otherwise removes it on any save, so a * value while hidden: form.js otherwise removes it on any save, so a
* save made with another scheduler or with mptcp_pm_type=0 silently * save made with another scheduler or with mptcp_pm_type=0 silently
@@ -402,7 +409,7 @@ return L.view.extend({
// (discussion #4368). // (discussion #4368).
o.rmempty = false; o.rmempty = false;
o = s.option(form.Value, "multipath_weight", _("Weight"), _("Only used by *weight schedulers/path managers. Ignored if no weight scheduler is selected.") + '<br />' + _("A weight >100 make it more attractive, a weight <100 make it less attractive. Max 256")); o = s.option(form.Value, "multipath_weight", _("Weight"), _("Only used by the weight schedulers (bpf_weight, bpf_weight_rr, bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight scheduler is selected.") + '<br />' + _("A weight >100 make it more attractive, a weight <100 make it less attractive. Max 256"));
// 1 to 256, as "ip route" takes it: the schedulers read 0 as no weight // 1 to 256, as "ip route" takes it: the schedulers read 0 as no weight
// (neutral 100) and add the weight to a signed counter // (neutral 100) and add the weight to a signed counter
o.datatype = "and(uinteger,range(1,256))"; o.datatype = "and(uinteger,range(1,256))";
@@ -96,7 +96,7 @@ return L.view.extend({
var mptcpScheduler = normalizeSchedulerValue(uci.get('network', 'globals', 'mptcp_scheduler')); var mptcpScheduler = normalizeSchedulerValue(uci.get('network', 'globals', 'mptcp_scheduler'));
var mptcpHasDscpScheduler = (mptcpScheduler === 'bpf_dscp'); var mptcpHasDscpScheduler = (mptcpScheduler === 'bpf_dscp');
var mptcpHasWeightScheduler = (mptcpScheduler === 'bpf_weight' || mptcpScheduler === 'bpf_weight_rr' || mptcpScheduler === 'bpf_burstweight'); var mptcpHasWeightScheduler = (mptcpScheduler === 'bpf_weight' || mptcpScheduler === 'bpf_weight_rr' || mptcpScheduler === 'bpf_burstweight' || mptcpScheduler === 'bpf_overflow');
var mqvpnEnabled = (mqvpn.enabled === true); var mqvpnEnabled = (mqvpn.enabled === true);
var mqvpnScheduler = mqvpn.scheduler; var mqvpnScheduler = mqvpn.scheduler;
@@ -115,10 +115,10 @@ return L.view.extend({
var showWeight = mptcpHasWeightScheduler || mqvpnHasWeightScheduler; var showWeight = mptcpHasWeightScheduler || mqvpnHasWeightScheduler;
m = new form.Map('network', _('DSCP / Weight Routing'), m = new form.Map('network', _('DSCP / Weight Routing'),
_('Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by the *weight schedulers on both. This page only edits the pins/weights; pick the scheduler that actually uses them on the MPTCP and MQVPN pages themselves.')); _('Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by the weight schedulers on both. This page only edits the pins/weights; pick the scheduler that actually uses them on the MPTCP and MQVPN pages themselves.'));
if (!showDscp && !showWeight) { if (!showDscp && !showWeight) {
m.description += '<br /><br />' + _('Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler (bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages.'); m.description += '<br /><br />' + _('Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler (bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages.');
return m.render(); return m.render();
} }
@@ -170,7 +170,7 @@ return L.view.extend({
if (showWeight) { if (showWeight) {
s = m.section(form.TypedSection, 'interface', _('WAN weight'), s = m.section(form.TypedSection, 'interface', _('WAN weight'),
_('Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.') + ' ' + _('Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) and by mqvpn’s "Weighted RTT"/"Weighted Round Robin" schedulers -- same value, applies to both upload and download (mirrored to the gateway automatically). Unlike the DSCP pins above, weight is a single value per WAN, not per traffic class.') + ' ' +
_('Only WANs with Multipath TCP enabled (the “Multipath TCP” option on the MPTCP page’s Interfaces Settings) are listed below.')); _('Only WANs with Multipath TCP enabled (the “Multipath TCP” option on the MPTCP page’s Interfaces Settings) are listed below.'));
s.filter = function(section) { s.filter = function(section) {
return isEligibleIface(section) && isMultipathEnabledIface(section); return isEligibleIface(section) && isMultipathEnabledIface(section);
+44 -18
View File
@@ -173,13 +173,13 @@ msgstr "Prüfsumme"
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
"Legen Sie fest, welches WAN welche DSCP-markierte Verkehrsklasse trägt – für " "Legen Sie fest, welches WAN welche DSCP-markierte Verkehrsklasse trägt – für "
"den MPTCP-Scheduler bpf_dscp und für mqvpn – und prüfen Sie das WAN-Gewicht, " "den MPTCP-Scheduler bpf_dscp und für mqvpn – und prüfen Sie das WAN-Gewicht, "
"das die *weight-Scheduler auf beiden verwenden. Diese Seite bearbeitet nur " "das die Gewichts-Scheduler auf beiden verwenden. Diese Seite bearbeitet nur "
"die Zuordnungen und Gewichte; wählen Sie den Scheduler, der sie tatsächlich " "die Zuordnungen und Gewichte; wählen Sie den Scheduler, der sie tatsächlich "
"nutzt, auf den Seiten MPTCP und MQVPN selbst." "nutzt, auf den Seiten MPTCP und MQVPN selbst."
@@ -715,13 +715,13 @@ msgstr "Keine WAN-Schnittstellen mit aktiviertem Multipath gefunden."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
"Noch nichts anzuzeigen: Auf den Seiten MPTCP und MQVPN ist derzeit weder ein " "Noch nichts anzuzeigen: Auf den Seiten MPTCP und MQVPN ist derzeit weder ein "
"DSCP-Scheduler (bpf_dscp) noch ein Gewichts-Scheduler (bpf_weight/" "DSCP-Scheduler (bpf_dscp) noch ein Gewichts-Scheduler (bpf_weight/"
"bpf_weight_rr/bpf_burstweight oder mqvpns „Weighted RTT“/„Weighted Round " "bpf_weight_rr/bpf_burstweight/bpf_overflow oder mqvpns „Weighted RTT“/"
"Robin“) ausgewählt." "„Weighted Round Robin“) ausgewählt."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
msgid "One interface must be set as master" msgid "One interface must be set as master"
@@ -737,10 +737,12 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "" msgstr ""
"Wird nur von den *weight-Schedulern/Pfadmanagern verwendet. Wird ignoriert, " "Wird nur von den Gewichts-Schedulern (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) und Pfadmanagern verwendet. Wird ignoriert, "
"wenn kein Gewichts-Scheduler ausgewählt ist." "wenn kein Gewichts-Scheduler ausgewählt ist."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703
@@ -1034,17 +1036,17 @@ msgstr "Upload-Schnittstelle"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
"Wird von den *weight-MPTCP-Schedulern (bpf_weight/bpf_weight_rr) und von den " "Wird von den Gewichts-MPTCP-Schedulern (bpf_weight/bpf_weight_rr/"
"Schedulern „Weighted RTT“/„Weighted Round Robin“ von mqvpn verwendet — " "bpf_overflow) und von den Schedulern „Weighted RTT“/„Weighted Round Robin“ "
"derselbe Wert gilt für Upload und Download (automatisch zum Gateway " "von mqvpn verwendet — derselbe Wert gilt für Upload und Download "
"gespiegelt). Anders als die DSCP-Zuordnungen oben ist das Gewicht ein " "(automatisch zum Gateway gespiegelt). Anders als die DSCP-Zuordnungen oben "
"einzelner Wert je WAN, nicht je Verkehrsklasse." "ist das Gewicht ein einzelner Wert je WAN, nicht je Verkehrsklasse."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297
msgid "Userspace" msgid "Userspace"
@@ -1129,6 +1131,30 @@ msgstr ""
"bpf_rr => wählt zum Senden von Daten immer den nächsten verfügbaren Subflow " "bpf_rr => wählt zum Senden von Daten immer den nächsten verfügbaren Subflow "
"(Round Robin)" "(Round Robin)"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr ""
"bpf_ecf => sendet über den Subflow mit der geringsten Latenz und über einen "
"langsameren nur, wenn das die Daten nicht verzögert"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
"bpf_blest => sendet über den Subflow mit der geringsten Latenz und über "
"einen langsameren nur, wenn das den schnellsten nicht blockiert"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
"bpf_overflow => nutzt die Subflows nach Gewicht: ein niedrigeres Gewicht "
"trägt nur, was die höheren nicht tragen können"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "cwnd" msgstr "cwnd"
+47 -20
View File
@@ -172,13 +172,13 @@ msgstr "Somme de contrôle"
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
"Choisissez quel WAN transporte chaque classe de trafic marquée DSCP — pour " "Choisissez quel WAN transporte chaque classe de trafic marquée DSCP — pour "
"l'ordonnanceur MPTCP bpf_dscp et pour mqvpn — et vérifiez le poids par WAN " "l'ordonnanceur MPTCP bpf_dscp et pour mqvpn — et vérifiez le poids par WAN "
"utilisé par les ordonnanceurs *weight sur les deux. Cette page ne modifie " "utilisé par les ordonnanceurs de poids sur les deux. Cette page ne modifie "
"que les affectations et les poids ; choisissez l'ordonnanceur qui les " "que les affectations et les poids ; choisissez l'ordonnanceur qui les "
"utilise réellement sur les pages MPTCP et MQVPN elles-mêmes." "utilise réellement sur les pages MPTCP et MQVPN elles-mêmes."
@@ -712,13 +712,13 @@ msgstr "Aucune interface WAN avec multipath activé n'a été trouvée."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
"Rien à afficher pour l'instant : aucun ordonnanceur DSCP (bpf_dscp) ni " "Rien à afficher pour l'instant : aucun ordonnanceur DSCP (bpf_dscp) ni "
"ordonnanceur de poids (bpf_weight/bpf_weight_rr/bpf_burstweight, ou " "ordonnanceur de poids (bpf_weight/bpf_weight_rr/bpf_burstweight/"
"« Weighted RTT »/« Weighted Round Robin » de mqvpn) n'est actuellement " "bpf_overflow, ou « Weighted RTT »/« Weighted Round Robin » de mqvpn) n'est "
"sélectionné sur les pages MPTCP ou MQVPN." "actuellement sélectionné sur les pages MPTCP ou MQVPN."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
msgid "One interface must be set as master" msgid "One interface must be set as master"
@@ -735,11 +735,13 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "" msgstr ""
"Utilisé uniquement par les ordonnanceurs/gestionnaires de chemins *weight. " "Utilisé uniquement par les ordonnanceurs de poids (bpf_weight, "
"Ignoré si aucun ordonnanceur de poids n'est sélectionné." "bpf_weight_rr, bpf_burstweight, bpf_overflow) et les gestionnaires de "
"chemins. Ignoré si aucun ordonnanceur de poids n'est sélectionné."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703
msgid "Out" msgid "Out"
@@ -1032,17 +1034,18 @@ msgstr "Interface d'envoi"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
"Utilisé par les ordonnanceurs MPTCP *weight (bpf_weight/bpf_weight_rr) et " "Utilisé par les ordonnanceurs MPTCP de poids (bpf_weight/bpf_weight_rr/"
"par les ordonnanceurs « Weighted RTT »/« Weighted Round Robin » de mqvpn — " "bpf_overflow) et par les ordonnanceurs « Weighted RTT »/« Weighted Round "
"même valeur, appliquée à la fois à l'envoi et à la réception (répliquée " "Robin » de mqvpn — même valeur, appliquée à la fois à l'envoi et à la "
"automatiquement vers la passerelle). Contrairement aux affectations DSCP ci-" "réception (répliquée automatiquement vers la passerelle). Contrairement aux "
"dessus, le poids est une valeur unique par WAN, non par classe de trafic." "affectations DSCP ci-dessus, le poids est une valeur unique par WAN, non par "
"classe de trafic."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297
msgid "Userspace" msgid "Userspace"
@@ -1132,6 +1135,30 @@ msgstr ""
"bpf_rr => choisit toujours le sous-flux disponible suivant pour envoyer les " "bpf_rr => choisit toujours le sous-flux disponible suivant pour envoyer les "
"données (tourniquet)" "données (tourniquet)"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr ""
"bpf_ecf => envoie sur le sous-flux de plus faible latence, et sur un plus "
"lent seulement quand cela ne retarde pas les données"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
"bpf_blest => envoie sur le sous-flux de plus faible latence, et sur un plus "
"lent seulement quand cela ne bloque pas le plus rapide"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
"bpf_overflow => utilise les sous-flux selon leur poids : un poids plus "
"faible ne transporte que ce que les plus élevés ne peuvent pas"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "cwnd" msgstr "cwnd"
+44 -18
View File
@@ -171,13 +171,13 @@ msgstr "Checksum"
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
"Scegli quale WAN trasporta ciascuna classe di traffico marcata DSCP — per lo " "Scegli quale WAN trasporta ciascuna classe di traffico marcata DSCP — per lo "
"scheduler MPTCP bpf_dscp e per mqvpn — e verifica il peso per WAN usato " "scheduler MPTCP bpf_dscp e per mqvpn — e verifica il peso per WAN usato "
"dagli scheduler *weight su entrambi. Questa pagina modifica solo le " "dagli scheduler di peso su entrambi. Questa pagina modifica solo le "
"assegnazioni e i pesi; scegli lo scheduler che li usa davvero nelle pagine " "assegnazioni e i pesi; scegli lo scheduler che li usa davvero nelle pagine "
"MPTCP e MQVPN." "MPTCP e MQVPN."
@@ -712,13 +712,13 @@ msgstr "Nessuna interfaccia WAN con multipath abilitato trovata."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
"Ancora nulla da mostrare: nelle pagine MPTCP o MQVPN non è attualmente " "Ancora nulla da mostrare: nelle pagine MPTCP o MQVPN non è attualmente "
"selezionato né uno scheduler DSCP (bpf_dscp) né uno scheduler di peso " "selezionato né uno scheduler DSCP (bpf_dscp) né uno scheduler di peso "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, o «Weighted RTT»/«Weighted Round " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, o «Weighted "
"Robin» di mqvpn)." "RTT»/«Weighted Round Robin» di mqvpn)."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
msgid "One interface must be set as master" msgid "One interface must be set as master"
@@ -734,10 +734,12 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "" msgstr ""
"Usato solo dagli scheduler/gestori di percorsi *weight. Ignorato se non è " "Usato solo dagli scheduler di peso (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) e dai gestori di percorsi. Ignorato se non è "
"selezionato alcuno scheduler di peso." "selezionato alcuno scheduler di peso."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703
@@ -1033,17 +1035,17 @@ msgstr "Interfaccia di upload"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
"Usato dagli scheduler MPTCP *weight (bpf_weight/bpf_weight_rr) e dagli " "Usato dagli scheduler MPTCP di peso (bpf_weight/bpf_weight_rr/bpf_overflow) "
"scheduler «Weighted RTT»/«Weighted Round Robin» di mqvpn — stesso valore, " "e dagli scheduler «Weighted RTT»/«Weighted Round Robin» di mqvpn — stesso "
"vale sia per l'upload sia per il download (replicato automaticamente verso " "valore, vale sia per l'upload sia per il download (replicato automaticamente "
"il gateway). A differenza delle assegnazioni DSCP qui sopra, il peso è un " "verso il gateway). A differenza delle assegnazioni DSCP qui sopra, il peso è "
"singolo valore per WAN, non per classe di traffico." "un singolo valore per WAN, non per classe di traffico."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297
msgid "Userspace" msgid "Userspace"
@@ -1130,6 +1132,30 @@ msgstr ""
"bpf_rr => sceglie sempre il sottoflusso disponibile successivo per inviare i " "bpf_rr => sceglie sempre il sottoflusso disponibile successivo per inviare i "
"dati (round robin)" "dati (round robin)"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr ""
"bpf_ecf => invia sul sottoflusso a latenza più bassa, e su uno più lento "
"solo quando ciò non ritarda i dati"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
"bpf_blest => invia sul sottoflusso a latenza più bassa, e su uno più lento "
"solo quando ciò non blocca il più veloce"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
"bpf_overflow => usa i sottoflussi in base al peso: un peso inferiore "
"trasporta solo ciò che i superiori non riescono a trasportare"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "cwnd" msgstr "cwnd"
+46 -20
View File
@@ -167,13 +167,13 @@ msgstr "Soma de contraròtle"
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
"Causissètz quin WAN pòrta cada classa de trafic marcada DSCP — per " "Causissètz quin WAN pòrta cada classa de trafic marcada DSCP — per "
"l'ordonançador MPTCP bpf_dscp e per mqvpn — e verificatz lo pes per WAN " "l'ordonançador MPTCP bpf_dscp e per mqvpn — e verificatz lo pes per WAN "
"utilizat pels ordonançadors *weight suls dos. Aquesta pagina modifica " "utilizat pels ordonançadors de pes suls dos. Aquesta pagina modifica "
"solament las afectacions e los peses ; causissètz l'ordonançador que los " "solament las afectacions e los peses ; causissètz l'ordonançador que los "
"utiliza vertadièrament sus las paginas MPTCP e MQVPN elas meteissas." "utiliza vertadièrament sus las paginas MPTCP e MQVPN elas meteissas."
@@ -708,13 +708,13 @@ msgstr "Cap d'interfàcia WAN amb multipath activat pas trobada."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
"Res a mostrar pel moment : cap d'ordonançador DSCP (bpf_dscp) ni " "Res a mostrar pel moment : cap d'ordonançador DSCP (bpf_dscp) ni "
"d'ordonançador de pes (bpf_weight/bpf_weight_rr/bpf_burstweight, o " "d'ordonançador de pes (bpf_weight/bpf_weight_rr/bpf_burstweight/"
"« Weighted RTT »/« Weighted Round Robin » de mqvpn) es pas seleccionat " "bpf_overflow, o « Weighted RTT »/« Weighted Round Robin » de mqvpn) es pas "
"actualament sus las paginas MPTCP o MQVPN." "seleccionat actualament sus las paginas MPTCP o MQVPN."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
msgid "One interface must be set as master" msgid "One interface must be set as master"
@@ -731,11 +731,13 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "" msgstr ""
"Utilizat solament suls ordonançadors/gestionaris de camins *weight. Ignorat " "Utilizat solament pels ordonançadors de pes (bpf_weight, bpf_weight_rr, "
"se cap d'ordonançador de pes es pas seleccionat." "bpf_burstweight, bpf_overflow) e los gestionaris de camins. Ignorat se cap "
"d'ordonançador de pes es pas seleccionat."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703
msgid "Out" msgid "Out"
@@ -1029,17 +1031,17 @@ msgstr "Interfàcia de mandadís"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
"Utilizat pels ordonançadors MPTCP *weight (bpf_weight/bpf_weight_rr) e pels " "Utilizat pels ordonançadors MPTCP de pes (bpf_weight/bpf_weight_rr/"
"ordonançadors « Weighted RTT »/« Weighted Round Robin » de mqvpn — meteissa " "bpf_overflow) e pels ordonançadors « Weighted RTT »/« Weighted Round Robin » "
"valor, aplicada a l'encòp al mandadís e a la recepcion (replicada " "de mqvpn — meteissa valor, aplicada a l'encòp al mandadís e a la recepcion "
"automaticament cap a la palanca). Al contrari de las afectacions DSCP çai-" "(replicada automaticament cap a la palanca). Al contrari de las afectacions "
"sus, lo pes es una valor unica per WAN, pas per classa de trafic." "DSCP çai-sus, lo pes es una valor unica per WAN, pas per classa de trafic."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297
msgid "Userspace" msgid "Userspace"
@@ -1127,6 +1129,30 @@ msgstr ""
"bpf_rr => causís totjorn lo sosflux disponible venent per mandar las donadas " "bpf_rr => causís totjorn lo sosflux disponible venent per mandar las donadas "
"(torniquet)" "(torniquet)"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr ""
"bpf_ecf => manda sul sosflux de latença mai bassa, e sus un mai lent "
"solament quand aquò retarda pas las donadas"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
"bpf_blest => manda sul sosflux de latença mai bassa, e sus un mai lent "
"solament quand aquò bloca pas lo mai rapid"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
"bpf_overflow => utiliza los sosfluxes segon lor pes : un pes mai bas pòrta "
"solament çò que los mai nauts pòdon pas portar"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "cwnd" msgstr "cwnd"
+45 -19
View File
@@ -169,13 +169,13 @@ msgstr "Контрольная сумма"
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
"Выберите, какой WAN несёт каждый класс трафика с меткой DSCP — для " "Выберите, какой WAN несёт каждый класс трафика с меткой DSCP — для "
"планировщика MPTCP bpf_dscp и для mqvpn — и проверьте вес каждого WAN, " "планировщика MPTCP bpf_dscp и для mqvpn — и проверьте вес каждого WAN, "
"используемый планировщиками *weight в обоих. Эта страница изменяет только " "используемый весовыми планировщиками в обоих. Эта страница изменяет только "
"закрепления и веса; сам планировщик, который их использует, выбирается на " "закрепления и веса; сам планировщик, который их использует, выбирается на "
"страницах MPTCP и MQVPN." "страницах MPTCP и MQVPN."
@@ -707,13 +707,13 @@ msgstr "WAN-интерфейсы с включённым multipath не найд
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
"Пока нечего показать: на страницах MPTCP и MQVPN сейчас не выбран ни " "Пока нечего показать: на страницах MPTCP и MQVPN сейчас не выбран ни "
"планировщик DSCP (bpf_dscp), ни весовой планировщик (bpf_weight/" "планировщик DSCP (bpf_dscp), ни весовой планировщик (bpf_weight/"
"bpf_weight_rr/bpf_burstweight либо «Weighted RTT»/«Weighted Round Robin» в " "bpf_weight_rr/bpf_burstweight/bpf_overflow либо «Weighted RTT»/«Weighted "
"mqvpn)." "Round Robin» в mqvpn)."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
msgid "One interface must be set as master" msgid "One interface must be set as master"
@@ -729,11 +729,13 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "" msgstr ""
"Используется только планировщиками и менеджерами путей *weight. " "Используется только весовыми планировщиками (bpf_weight, bpf_weight_rr, "
"Игнорируется, если весовой планировщик не выбран." "bpf_burstweight, bpf_overflow) и менеджерами путей. Игнорируется, если "
"весовой планировщик не выбран."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703
msgid "Out" msgid "Out"
@@ -1026,17 +1028,17 @@ msgstr "Интерфейс передачи"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
"Используется планировщиками MPTCP *weight (bpf_weight/bpf_weight_rr) и " "Используется весовыми планировщиками MPTCP (bpf_weight/bpf_weight_rr/"
"планировщиками «Weighted RTT»/«Weighted Round Robin» в mqvpn — одно и то же " "bpf_overflow) и планировщиками «Weighted RTT»/«Weighted Round Robin» в mqvpn "
"значение применяется и к передаче, и к приёму (автоматически зеркалируется " "— одно и то же значение применяется и к передаче, и к приёму (автоматически "
"на шлюз). В отличие от закреплений DSCP выше, вес — это одно значение на " "зеркалируется на шлюз). В отличие от закреплений DSCP выше, вес — это одно "
"WAN, а не на класс трафика." "значение на WAN, а не на класс трафика."
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297
msgid "Userspace" msgid "Userspace"
@@ -1122,6 +1124,30 @@ msgstr ""
"bpf_rr => всегда выбирает следующий доступный подпоток для отправки данных " "bpf_rr => всегда выбирает следующий доступный подпоток для отправки данных "
"(по кругу)" "(по кругу)"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr ""
"bpf_ecf => отправляет по подпотоку с наименьшей задержкой, а по более "
"медленному — только если это не задерживает данные"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
"bpf_blest => отправляет по подпотоку с наименьшей задержкой, а по более "
"медленному — только если это не блокирует самый быстрый"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
"bpf_overflow => использует подпотоки по весу: подпоток с меньшим весом "
"передаёт только то, что не могут передать более весомые"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "cwnd" msgstr "cwnd"
+28 -9
View File
@@ -165,7 +165,7 @@ msgstr ""
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
@@ -677,8 +677,8 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
@@ -693,7 +693,8 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "" msgstr ""
@@ -971,11 +972,11 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:297
@@ -1052,6 +1053,24 @@ msgid ""
"bpf_rr => always picks the next available subflow to send data (round-robin)" "bpf_rr => always picks the next available subflow to send data (round-robin)"
msgstr "" msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "" msgstr ""
+39 -15
View File
@@ -165,13 +165,13 @@ msgstr "校验和"
msgid "" msgid ""
"Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP " "Choose which WAN carries each DSCP-tagged traffic class -- for the MPTCP "
"bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by " "bpf_dscp scheduler and for mqvpn -- and review the per-WAN weight used by "
"the *weight schedulers on both. This page only edits the pins/weights; pick " "the weight schedulers on both. This page only edits the pins/weights; pick "
"the scheduler that actually uses them on the MPTCP and MQVPN pages " "the scheduler that actually uses them on the MPTCP and MQVPN pages "
"themselves." "themselves."
msgstr "" msgstr ""
"选择由哪个 WAN 承载各个 DSCP 标记的流量类别 —— 用于 MPTCP 的 bpf_dscp 调度器" "选择由哪个 WAN 承载各个 DSCP 标记的流量类别 —— 用于 MPTCP 的 bpf_dscp 调度器"
"和 mqvpn —— 并查看两者中 *weight 调度器所使用的每个 WAN 的权重。本页面只编辑" "和 mqvpn —— 并查看两者中 权重调度器所使用的每个 WAN 的权重。本页面只编辑固定"
"固定关系和权重;真正使用它们的调度器请在 MPTCP 和 MQVPN 页面中选择。" "关系和权重;真正使用它们的调度器请在 MPTCP 和 MQVPN 页面中选择。"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:332 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:332
msgid "Close timeout" msgid "Close timeout"
@@ -689,12 +689,12 @@ msgstr "未找到启用多路径的 WAN 接口。"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:121
msgid "" msgid ""
"Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler " "Nothing to show yet: no DSCP scheduler (bpf_dscp) or weight scheduler "
"(bpf_weight/bpf_weight_rr/bpf_burstweight, or mqvpn’s Weighted RTT/Weighted " "(bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow, or mqvpn’s Weighted "
"Round Robin) is currently selected on the MPTCP or MQVPN pages." "RTT/Weighted Round Robin) is currently selected on the MPTCP or MQVPN pages."
msgstr "" msgstr ""
"暂无可显示内容:MPTCP 或 MQVPN 页面上当前既未选择 DSCP 调度器 (bpf_dscp),也" "暂无可显示内容:MPTCP 或 MQVPN 页面上当前既未选择 DSCP 调度器 (bpf_dscp),也"
"未选择权重调度器 (bpf_weight/bpf_weight_rr/bpf_burstweight,或 mqvpn " "未选择权重调度器 (bpf_weight/bpf_weight_rr/bpf_burstweight/bpf_overflow,或 "
"的“Weighted RTT”/“Weighted Round Robin”)。" "mqvpn 的“Weighted RTT”/“Weighted Round Robin”)。"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:378
msgid "One interface must be set as master" msgid "One interface must be set as master"
@@ -710,9 +710,12 @@ msgstr ""
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:394
msgid "" msgid ""
"Only used by *weight schedulers/path managers. Ignored if no weight " "Only used by the weight schedulers (bpf_weight, bpf_weight_rr, "
"bpf_burstweight, bpf_overflow) and path managers. Ignored if no weight "
"scheduler is selected." "scheduler is selected."
msgstr "仅由 *weight 调度器/路径管理器使用。未选择权重调度器时将被忽略。" msgstr ""
"仅由权重调度器 (bpf_weight、bpf_weight_rr、bpf_burstweight、bpf_overflow) 和"
"路径管理器使用。未选择权重调度器时将被忽略。"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/multipath.js:703
msgid "Out" msgid "Out"
@@ -999,13 +1002,13 @@ msgstr "上传接口"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_dscp_routing.js:173
msgid "" msgid ""
"Used by the *weight MPTCP schedulers (bpf_weight/bpf_weight_rr) and by " "Used by the weight MPTCP schedulers (bpf_weight/bpf_weight_rr/bpf_overflow) "
"mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same value, " "and by mqvpn’s \"Weighted RTT\"/\"Weighted Round Robin\" schedulers -- same "
"applies to both upload and download (mirrored to the gateway automatically). " "value, applies to both upload and download (mirrored to the gateway "
"Unlike the DSCP pins above, weight is a single value per WAN, not per " "automatically). Unlike the DSCP pins above, weight is a single value per "
"traffic class." "WAN, not per traffic class."
msgstr "" msgstr ""
"由 *weight 系列 MPTCP 调度器 (bpf_weight/bpf_weight_rr) 以及 mqvpn " "由权重 MPTCP 调度器 (bpf_weight/bpf_weight_rr/bpf_overflow) 以及 mqvpn "
"的“Weighted RTT”/“Weighted Round Robin”调度器使用 —— 同一个值同时作用于上传和" "的“Weighted RTT”/“Weighted Round Robin”调度器使用 —— 同一个值同时作用于上传和"
"下载(会自动镜像到网关)。与上面的 DSCP 固定不同,权重是每个 WAN 一个值,而不" "下载(会自动镜像到网关)。与上面的 DSCP 固定不同,权重是每个 WAN 一个值,而不"
"是每个流量类别一个值。" "是每个流量类别一个值。"
@@ -1088,6 +1091,27 @@ msgid ""
"bpf_rr => always picks the next available subflow to send data (round-robin)" "bpf_rr => always picks the next available subflow to send data (round-robin)"
msgstr "bpf_rr => 始终选择下一个可用子流发送数据(轮询)" msgstr "bpf_rr => 始终选择下一个可用子流发送数据(轮询)"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:150
msgid ""
"bpf_ecf => sends on the lowest-latency subflow, and on a slower one only "
"when that does not delay the data"
msgstr "bpf_ecf => 通过延迟最低的子流发送,仅在不会延迟数据时才使用较慢的子流"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:151
msgid ""
"bpf_blest => sends on the lowest-latency subflow, and on a slower one only "
"when that does not block the fastest one"
msgstr ""
"bpf_blest => 通过延迟最低的子流发送,仅在不会阻塞最快子流时才使用较慢的子流"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp.js:152
msgid ""
"bpf_overflow => uses the subflows by weight: a lower weight only carries "
"what the higher ones cannot"
msgstr ""
"bpf_overflow => 按权重使用子流:权重较低的子流只承载权重较高的子流无法承载的"
"部分"
#: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251 #: luci-app-mptcp/htdocs/luci-static/resources/view/mptcp/mptcp_diagnostics.js:251
msgid "cwnd" msgid "cwnd"
msgstr "cwnd" msgstr "cwnd"
+8 -3
View File
@@ -1,23 +1,28 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-netstat PKG_NAME:=luci-app-netstat
PKG_VERSION:=1.1.5 PKG_VERSION:=1.2.0
PKG_RELEASE:=5 PKG_RELEASE:=6
PKG_MAINTAINER:=NoobLK <liyanagelsofficial@gmail.com> PKG_MAINTAINER:=NoobLK <liyanagelsofficial@gmail.com>
PKG_LICENSE:=GPL-3.0 PKG_LICENSE:=GPL-3.0
LUCI_TITLE:=NET Stats LUCI_TITLE:=NET Stats
LUCI_DESCRIPTION:=This LuCI app provides net statistic functionality in a web interface. LUCI_DESCRIPTION:=This LuCI app provides net statistic functionality in a web interface.
LUCI_DEPENDS:=+vnstat +luci-compat LUCI_DEPENDS:=+vnstat2 +luci-compat +luci-lib-jsonc
include $(TOPDIR)/feeds/luci/luci.mk include $(TOPDIR)/feeds/luci/luci.mk
define Package/luci-app-netstat/install define Package/luci-app-netstat/install
$(CP) ./files/* $(1)/ $(CP) ./files/* $(1)/
chmod 0755 $(1)/usr/libexec/netstat-vnstat-track
# vnstat package owns /etc/config/vnstat on newer OpenWrt versions. # vnstat package owns /etc/config/vnstat on newer OpenWrt versions.
# Remove it here to avoid file ownership conflicts if present in any tree. # Remove it here to avoid file ownership conflicts if present in any tree.
$(RM) $(1)/etc/config/vnstat $(RM) $(1)/etc/config/vnstat
endef endef
define Package/luci-app-netstat/conffiles
/etc/config/netstats
endef
$(eval $(call BuildPackage,luci-app-netstat)) $(eval $(call BuildPackage,luci-app-netstat))
+107 -117
View File
@@ -1,159 +1,149 @@
# luci-app-netstat
<div align="center"> <div align="center">
**A professional network statistics application for OpenWrt LuCI** # luci-app-netstat
[![License: GPL-3.0](https://img.shields.io/badge/License-GPL%203.0-blue.svg)](LICENSE) **Live traffic rates and vnStat usage history for any OpenWrt router, in LuCI.**
![OpenWrt Compatible](https://img.shields.io/badge/OpenWrt-Compatible-brightgreen)
[Features](#features) • [Installation](#installation) • [Usage](#usage) • [Development](#development) • [License](#license) [![Release](https://img.shields.io/github/v/release/nooblk-98/luci-app-netstat?style=flat-square)](https://github.com/nooblk-98/luci-app-netstat/releases/latest)
[![Build](https://img.shields.io/github/actions/workflow/status/nooblk-98/luci-app-netstat/build.yml?branch=main&style=flat-square&label=build)](https://github.com/nooblk-98/luci-app-netstat/actions/workflows/build.yml)
[![OpenWrt](https://img.shields.io/badge/OpenWrt-24.10%20%7C%2025.x-00b5e2?style=flat-square&logo=openwrt&logoColor=white)](https://openwrt.org)
[![vnStat](https://img.shields.io/badge/requires-vnstat2-orange?style=flat-square)](https://humdi.net/vnstat/)
[![License](https://img.shields.io/badge/license-GPL--3.0-blue?style=flat-square)](Makefile)
[Features](#features) • [Installation](#installation) • [Usage](#usage) • [Configuration](#configuration) • [How it works](#how-it-works) • [Troubleshooting](#troubleshooting)
![Network Usage tab in dark mode](images/usage-dark.png)
</div> </div>
--- luci-app-netstat adds a live traffic dashboard to the LuCI status page and a **Netstat** page with daily and monthly usage charts. Live rates come straight from the kernel, and history comes from [vnStat 2](https://humdi.net/vnstat/).
## Overview It works on any OpenWrt router: there are no hard-coded interface names, device paths or board-specific files. The uplink is found from the routing table, and vnStat is set up to track your interfaces automatically.
![Dashboard Preview](images/luci-app-netstat.png)
**luci-app-netstat** is a modern, feature-rich LuCI application that provides comprehensive network traffic statistics and monitoring for OpenWrt routers. It leverages `vnstat` for accurate traffic monitoring and presents the data through an intuitive web interface.
The application offers real-time network statistics, historical data visualization, and detailed traffic analysis to help you understand your network usage patterns.
## Features ## Features
✨ **Core Features:** - **Live dashboard on Status → Overview:**
- 📊 **Real-time Network Monitoring** - Track network traffic with live statistics - download and upload rates with sparklines, peaks and trend arrows;
- 📈 **Traffic Visualization** - Interactive charts powered by Chart.js - total downloaded and uploaded;
- 📅 **Historical Data** - Monitor trends over days, weeks, months, and years - internet status and public IP, as seen through Passwall or any other proxy;
- 🎨 **Dark Mode Support** - Professional dark theme for comfortable usage - CPU temperature, uptime, RAM and storage.
- 📱 **Responsive Design** - Works seamlessly on desktop and mobile devices - **Usage history:** daily or monthly charts per interface, read from `vnstat --json`.
- ⚙️ **Easy Configuration** - Simple UCI-based configuration system - **Automatic interface detection:** follows the device that holds the default route (PPPoE, DHCP, LTE/5G modems, VPNs, DSA ports), or the interface you choose.
- 🔄 **Automatic Updates** - Uses vnstat for continuous data collection - **Zero-config vnStat:** interfaces are added to vnStat as they come up, so there is nothing to set up by hand.
- **Follows your theme:** uses LuCI's standard sections and tabs, so it matches any theme in light or dark mode, including live switching.
## Requirements - **Lightweight:** shell, Lua and plain JavaScript only; packages are architecture-independent.
- OpenWrt with LuCI installed
- `vnstat` package (required dependency)
- Modern web browser (Chrome, Firefox, Safari, Edge)
## Installation ## Installation
### Automatic Installation ### Requirements
Clone the repository and install using the OpenWrt build system: - OpenWrt **24.10** or **25.x** with LuCI
- **`vnstat2`** (vnStat 2.x): installed automatically as a dependency. The old vnStat 1.x `vnstat` package is not supported.
### From a release
Download the package for your OpenWrt version from the [latest release](https://github.com/nooblk-98/luci-app-netstat/releases/latest), copy it to the router and install it:
```bash ```bash
git clone https://github.com/dotycat/luci-app-netstat.git # OpenWrt 25.x (apk)
cd luci-app-netstat apk update
make apk add --allow-untrusted luci-app-netstat-*.apk
# OpenWrt 24.10 (opkg)
opkg update
opkg install luci-app-netstat_*_all.ipk
``` ```
### Manual Installation ### Build from source
1. Download the latest `.ipk` package from [Releases](../../releases) Add the package to an OpenWrt buildroot or SDK that has the `luci` and `packages` feeds:
2. Upload to your router via SCP:
```bash
scp luci-app-netstat_*.ipk root@<router-ip>:/tmp/
```
3. Install via SSH:
```bash
ssh root@<router-ip>
opkg install /tmp/luci-app-netstat_*.ipk
```
4. Navigate to **LuCI Web Interface** → **Status** → **Network Statistics**
## Configuration
### UCI Configuration
The application configuration is managed via UCI. Edit `/etc/config/netstats` to customize:
```
config netstat
option enabled '1'
option interface 'wan'
option update_interval '60'
```
### vnstat Integration
Ensure vnstat is properly configured and running:
```bash ```bash
opkg install vnstat git clone https://github.com/nooblk-98/luci-app-netstat.git package/luci-app-netstat
service vnstat start make menuconfig # LuCI → Applications → luci-app-netstat
service vnstat enable make package/luci-app-netstat/compile
``` ```
> [!IMPORTANT]
> **Upgrading from 1.1.x?** That version used vnStat 1.x. Remove both packages, install `vnstat2`, then install the new package:
>
> ```bash
> /etc/init.d/vnstat stop
> apk del luci-app-netstat vnstat # or: opkg remove luci-app-netstat vnstat
> ```
>
> To keep your history, set `DatabaseDir` in `/etc/vnstat.conf` to the folder that holds your old 1.x files (often `/etc/vnstat`) **before** vnStat 2 starts. It imports them on first start.
## Usage ## Usage
### Dashboard ### Live dashboard
1. Log into your OpenWrt LuCI web interface Open **Status → Overview**. The Netstat widget refreshes every two seconds.
2. Navigate to **Status** → **Network Statistics**
3. View traffic statistics for your network interfaces
### Available Data ![Live dashboard widget](images/status-widget.png)
- **Current Usage** - Real-time traffic rates ### Usage history
- **Daily Statistics** - Traffic breakdown by day
- **Monthly Analysis** - Monthly usage overview
## Dark / Light Theme Switching Open **Tools → Netstat**:
The NoobWRT theme stores the user's theme preference in **`localStorage`** under the key `luci-theme` (`'dark'` or `'light'`). When dark mode is active the theme also adds the class `dark-theme` to `document.body`. | Tab | What it shows |
|---|---|
| **Network Usage** | Daily or monthly download/upload for the tracked interface, with totals |
| **Settings** | Traffic backend, default view, interface, vnStat database info and reset |
| **About** | Project links, licence and support |
`tabs.htm` (rendered on every Netstat tab) reads this value at page load: <table>
<tr>
<td><img src="images/usage-light.png" alt="Network Usage tab in light mode"></td>
<td><img src="images/settings.png" alt="Settings tab"></td>
</tr>
</table>
```js ## Configuration
var isDark = localStorage.getItem('luci-theme') === 'dark'
|| document.body.classList.contains('dark-theme'); Settings live in `/etc/config/netstats` and can be changed from the **Settings** tab:
| Option | Default | Description |
|---|---|---|
| `prefer` | `''` | Interface to track. Empty means auto (the default-route device). Accepts a device (`eth1`) or a logical interface (`wan`). |
| `backend` | `normal` | Downloaded/uploaded totals on the dashboard: `normal` counts since boot; `vnstat` shows this month from vnStat. Live rates always come from `/proc/net/dev`. |
| `mode` | `daily` | Default view on the Network Usage tab: `daily` or `monthly`. |
| `vnstat_autoadd` | `1` | Set to `0` to stop the app from adding interfaces to vnStat. |
```bash
uci set netstats.@config[0].backend='vnstat'
uci commit netstats
``` ```
If dark mode is detected, a `<style>` block is injected into the page with `!important` overrides for every `.netstat-*` class. This covers: ## How it works
| Selector | Dark value | | Piece | Source |
|---|---| |---|---|
| `.netstat-dashboard` | `#1a1d20` background | | Live rates and since-boot totals | `/proc/net/dev` |
| `.netstat-summary` | `#111315` background | | Uplink detection | `/proc/net/route` and `/proc/net/ipv6_route`; logical names resolved through `ubus` (netifd) |
| `.netstat-chart-wrap` | `#1a1d20` background | | History and monthly totals | `vnstat --json` and `vnstat --dbiflist` |
| `.netstat-about-card` | `#1a1d20` background | | Public IP | `api.ipify.org` via `curl` or `uclient-fetch`, refreshed every 5 minutes |
| `.netstat-chart-title`, `.netstat-summary-item` | `#f3f4f6` text |
| `.netstat-summary-label`, `.netstat-about-label` | `#9ca3af` text |
| `.netstat-about-row a` | `#60a5fa` link color |
The same flag (`window._netstatDark`) is passed to Chart.js so axis labels and grid lines also use dark-friendly colors. OpenWrt starts `vnstatd` with `--noadd`, so out of the box it tracks only the interfaces listed in `/etc/config/vnstat`. This package fills that list for you:
No server-side detection or separate CSS files are needed — everything is driven by the single `localStorage` value the theme toggle button already sets. - `/etc/hotplug.d/iface/90-netstat-vnstat` adds each interface's device when it comes up.
- `/etc/uci-defaults/50_luci-netstat` does the same once at install for interfaces that are already up.
- You can run `/usr/libexec/netstat-vnstat-track` by hand at any time.
## Troubleshooting
**"No data available" on the Network Usage tab**
vnStat writes to its database every few minutes, so a newly tracked interface takes a short while to show data. Check what is tracked with `vnstat --dbiflist 1`.
> [!WARNING]
> OpenWrt's default vnStat database location, `/var/lib/vnstat`, is in RAM, so **history is lost on every reboot**. Set `DatabaseDir` in `/etc/vnstat.conf` to persistent storage (for example `/etc/vnstat`, or a USB disk to avoid flash wear), then run `service vnstat restart`. The Settings tab shows the current location.
**The dashboard still looks or behaves like the old version after an upgrade**
LuCI caches view scripts in the browser until LuCI itself is updated. Clear the browser cache for the router, or open the page in a private window.
**The IP card shows a private address**
This is the WAN device's local address, shown until the public-IP lookup succeeds. The lookup needs internet access from the router itself; with Passwall, enable *localhost proxy* to see the proxied IP.
## Credits ## Credits
- **Original Development**: [SMALLPROGRAM](https://github.com/smallprogram) Originally created by [SMALLPROGRAM](https://github.com/smallprogram); maintained by [nooblk-98](https://github.com/nooblk-98). Bug reports and ideas are welcome in [Issues](https://github.com/nooblk-98/luci-app-netstat/issues).
- **Maintenance**: [nooblk-98](https://github.com/nooblk-98/)
## Contributing
Contributions are welcome! Please feel free to:
- Report bugs and issues
- Submit feature requests
- Create pull requests with improvements
- Improve documentation
## Support
For support, issues, or questions:
- Open an [Issue](../../issues) on GitHub
- Check existing [Discussions](../../discussions)
- Contact: liyanagelsofficial@gmail.com
---
<div align="center">
**Made with ❤️ for the OpenWrt Community**
</div>
+3 -2
View File
@@ -1,5 +1,6 @@
config config config config
option prefer 'wwan0_1' option prefer ''
option backend 'normal' option backend 'normal'
option mode 'daily'
option vnstat_autoadd '1'
@@ -0,0 +1,9 @@
# Start vnStat tracking for interfaces as they come up (modems, PPPoE and
# VPNs often only get their device name at this point).
[ "$ACTION" = ifup ] || exit 0
[ "$INTERFACE" = loopback ] && exit 0
[ -x /usr/libexec/netstat-vnstat-track ] || exit 0
dev="$DEVICE"
[ -n "$dev" ] || dev="$(ubus call network.interface."$INTERFACE" status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null)"
[ -n "$dev" ] && /usr/libexec/netstat-vnstat-track "$dev"
@@ -0,0 +1,16 @@
#!/bin/sh
# First install / first boot: make sure the settings section exists and let
# vnStat start tracking whatever interfaces are already up. On a fresh boot
# netifd is not running yet; the iface hotplug hook adds devices as they
# come up instead.
touch /etc/config/netstats
uci -q get netstats.@config[0] >/dev/null || uci -q add netstats config >/dev/null
uci -q get netstats.@config[0].backend >/dev/null || uci -q set netstats.@config[0].backend='normal'
uci -q get netstats.@config[0].vnstat_autoadd >/dev/null || uci -q set netstats.@config[0].vnstat_autoadd='1'
uci -q commit netstats
[ -x /usr/libexec/netstat-vnstat-track ] && /usr/libexec/netstat-vnstat-track
rm -f /tmp/luci-indexcache* /tmp/luci-netstat-vnstat-month
exit 0
@@ -1,6 +1,8 @@
module("luci.controller.netstat", package.seeall) module("luci.controller.netstat", package.seeall)
local nixio = require("nixio") local nixio = require("nixio")
local nfs = require("nixio.fs")
local uci = require("luci.model.uci").cursor() local uci = require("luci.model.uci").cursor()
local nsu = require("luci.netstat")
function index() function index()
entry({"admin", "tools"}, firstchild(), _("Tools"), 50).dependent = false entry({"admin", "tools"}, firstchild(), _("Tools"), 50).dependent = false
@@ -34,19 +36,7 @@ function getNetdevStats()
end end
end end
-- Quick connectivity check local status = #nsu.default_route_devices() > 0 and "Connected" or "Disconnected"
local status = "Disconnected"
for iface, data in pairs(stats) do
if iface ~= "lo" and (data.rx > 0 or data.tx > 0) then
status = "Connected"
break
end
end
-- ── IP detection ─────────────────────────────────────────────────────────
-- Strategy: try a single fast HTTP call first (2 s timeout), then fall
-- back to local ubus/ifstatus which is instant. The old code tried 11
-- commands with 4 s timeouts each – worst-case 44 s of blocking.
local function read_cmd(cmd) local function read_cmd(cmd)
local p = io.popen(cmd) local p = io.popen(cmd)
@@ -58,38 +48,11 @@ function getNetdevStats()
return line ~= "" and line or nil return line ~= "" and line or nil
end end
-- ── Preferred interface resolution ────────────────────────────────────── -- ── Tracked device ──────────────────────────────────────────────────────
-- The "Preferred Interface" setting may hold either a real netdev name -- The "Preferred Interface" setting (netdev or logical name such as
-- (e.g. "eth2", "wwan0_1") or a logical/config interface name (e.g. -- "wan"), otherwise the default-route device. Both are resolved
-- "modem", "wan") that has no matching key in /proc/net/dev. In the -- generically; no interface names are assumed.
-- latter case resolve it to its actual l3_device via ubus so traffic local prefer_iface = nsu.wan_device(stats) or ""
-- stats can be found.
local prefer_iface = ""
do
local prefer = uci:get_first("netstats", "config", "prefer") or ""
-- Only allow safe characters before using it in a shell command.
if prefer ~= "" and prefer:match("^[%w%-%._]+$") then
if stats[prefer] then
prefer_iface = prefer
else
local l3 = read_cmd(
"ubus call network.interface." .. prefer ..
" status 2>/dev/null | jsonfilter -e '@.l3_device'"
)
if l3 and stats[l3] then
prefer_iface = l3
end
end
end
-- Nothing configured (or unresolvable): use the default-route device
-- so idle interfaces named "wan"/"eth1" don't win over the real uplink.
if prefer_iface == "" then
local dev = read_cmd("ip route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i==\"dev\"){print $(i+1); exit}}'")
if dev and dev:match("^[%w%-%._]+$") and stats[dev] then
prefer_iface = dev
end
end
end
local function is_valid_ip(v) local function is_valid_ip(v)
if not v then return false end if not v then return false end
@@ -103,6 +66,7 @@ function getNetdevStats()
end end
local ip = "N/A" local ip = "N/A"
local ip_source = "none"
-- Determine whether the caller wants a public IP (slow, requires network) -- Determine whether the caller wants a public IP (slow, requires network)
-- or just the fast local WAN address. The JS frontend sends ?fast=1 for -- or just the fast local WAN address. The JS frontend sends ?fast=1 for
@@ -112,30 +76,29 @@ function getNetdevStats()
-- 1. Try public-IP APIs (only when not in fast mode) -- 1. Try public-IP APIs (only when not in fast mode)
if want_public then if want_public then
local public_cmds = { local public_cmds = {
"curl -fsS --max-time 1 'https://api.ipify.org' 2>/dev/null", "curl -fsS --max-time 2 'https://api.ipify.org' 2>/dev/null",
"curl -fsS --max-time 1 'http://api.ipify.org' 2>/dev/null", "curl -fsS --max-time 2 'http://api.ipify.org' 2>/dev/null",
"uclient-fetch -qO- --timeout=1 'https://api.ipify.org' 2>/dev/null", "uclient-fetch -qO- --timeout=2 'https://api.ipify.org' 2>/dev/null",
} }
for _, cmd in ipairs(public_cmds) do for _, cmd in ipairs(public_cmds) do
local v = read_cmd(cmd) local v = read_cmd(cmd)
if is_valid_ip(v) then ip = v; break end if is_valid_ip(v) then ip = v; ip_source = "public"; break end
end end
end end
-- 2. Fall back to local WAN address (instant, no network needed) -- 2. Fall back to the local address of the tracked device
if ip == "N/A" then if ip == "N/A" and prefer_iface ~= "" then
local local_cmds = { local v = nsu.device_ipaddr(prefer_iface)
-- wan IPv4 if is_valid_ip(v) then ip = v; ip_source = "local" end
"ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@[\"ipv4-address\"][0].address'", end
"ifstatus wan 2>/dev/null | jsonfilter -e '@[\"ipv4-address\"][0].address'",
-- wan IPv6 -- ── vnStat totals (only with the vnStat backend) ────────────────────────
"ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@[\"ipv6-address\"][0].address'", -- Live rates always come from /proc/net/dev; the vnStat backend swaps the
"ubus call network.interface.wan6 status 2>/dev/null | jsonfilter -e '@[\"ipv6-address\"][0].address'", -- "downloaded/uploaded" boxes to this month's totals from its database.
} local vnstat_total = nil
for _, cmd in ipairs(local_cmds) do if prefer_iface ~= "" and
local v = read_cmd(cmd) (uci:get_first("netstats", "config", "backend") or "normal") == "vnstat" then
if is_valid_ip(v) then ip = v; break end vnstat_total = nsu.vnstat_month_total(prefer_iface)
end
end end
-- ── System uptime ───────────────────────────────────────────────────────── -- ── System uptime ─────────────────────────────────────────────────────────
@@ -199,9 +162,7 @@ function getNetdevStats()
-- ── Disk space (root filesystem) ────────────────────────────────────────── -- ── Disk space (root filesystem) ──────────────────────────────────────────
local disk_pct, disk_used_mb, disk_total_mb = 0, 0, 0 local disk_pct, disk_used_mb, disk_total_mb = 0, 0, 0
-- Use POSIX output (-P): busybox df wraps long device names onto a second local df = read_cmd("df -k / 2>/dev/null | awk 'NR==2{print $2,$3}'")
-- line without it, which breaks the NR==2 parse (e.g. LXC root device).
local df = read_cmd("df -kP / 2>/dev/null | awk 'NR==2{print $2,$3}'")
if df then if df then
local dtotal, dused = df:match("^(%d+)%s+(%d+)$") local dtotal, dused = df:match("^(%d+)%s+(%d+)$")
dtotal = tonumber(dtotal) or 0 dtotal = tonumber(dtotal) or 0
@@ -215,43 +176,9 @@ function getNetdevStats()
-- ── CPU temperature ─────────────────────────────────────────────────────── -- ── CPU temperature ───────────────────────────────────────────────────────
local cpu_temp = nil local cpu_temp = nil
-- Scan all available thermal zones and pick the highest valid reading
-- Preferred source: CPU-specific hwmon sensors (k10temp/coretemp/zenpower/ -- (avoids hardcoded zone numbers which vary per device)
-- ...). ACPI thermal zones can be absent or bogus (e.g. inside LXC for path in (nfs.glob("/sys/class/thermal/thermal_zone*/temp") or function() end) do
-- containers the host ACPI zone is not the CPU sensor), while hwmon
-- reports the real package temperature on x86.
local cpu_hwmon = { k10temp = true, coretemp = true, zenpower = true, cpu_thermal = true, soc_thermal = true }
if nixio.fs.access("/sys/class/hwmon") then
for h in nixio.fs.dir("/sys/class/hwmon") do
local base = "/sys/class/hwmon/" .. h
local nf = io.open(base .. "/name", "r")
local name = nf and nf:read("*l") or nil
if nf then nf:close() end
if name and cpu_hwmon[name] then
for entry in nixio.fs.dir(base) do
if entry:match("^temp%d+_input$") then
local sf = io.open(base .. "/" .. entry, "r")
local val = sf and sf:read("*l") or nil
if sf then sf:close() end
local t = tonumber(val)
if t and t > 0 then
if t > 1000 then t = t / 1000 end
t = math.floor(t + 0.5)
if cpu_temp == nil or t > cpu_temp then
cpu_temp = t
end
end
end
end
end
end
end
-- Fallback: scan all available thermal zones and pick the highest valid
-- reading (avoids hardcoded zone numbers which vary per device)
for zone = 0, 9 do
if cpu_temp ~= nil then break end
local path = "/sys/class/thermal/thermal_zone" .. zone .. "/temp"
local tf = io.open(path, "r") local tf = io.open(path, "r")
if tf then if tf then
local val = tf:read("*l") local val = tf:read("*l")
@@ -270,8 +197,10 @@ function getNetdevStats()
end end
luci.http.prepare_content("application/json") luci.http.prepare_content("application/json")
luci.http.write_json({ stats = stats, ip = ip, status = status, luci.http.write_json({ stats = stats, ip = ip, ip_source = ip_source,
status = status,
prefer_iface = prefer_iface, prefer_iface = prefer_iface,
vnstat_total = vnstat_total,
uptime = uptime_sec, uptime = uptime_sec,
cpu_pct = cpu_pct, cpu_pct = cpu_pct,
cpu_temp = cpu_temp, cpu_temp = cpu_temp,
@@ -1,6 +1,7 @@
local net = require "luci.model.network" local net = require "luci.model.network"
local sys = require "luci.sys" local sys = require "luci.sys"
local http = require "luci.http" local http = require "luci.http"
local nsu = require "luci.netstat"
local m = Map("netstats", translate("Netstat"), local m = Map("netstats", translate("Netstat"),
translate("Configure traffic monitoring and view usage history from a single page.") translate("Configure traffic monitoring and view usage history from a single page.")
@@ -22,8 +23,10 @@ if active_tab == "settings" then
backend:value("normal", translate("Normal (Real-time)")) backend:value("normal", translate("Normal (Real-time)"))
backend:value("vnstat", translate("vnStat (Historical, Delayed)")) backend:value("vnstat", translate("vnStat (Historical, Delayed)"))
backend.description = translate( backend.description = translate(
"Normal reads live counters from /proc/net/dev. " .. "Live download/upload rates always come from /proc/net/dev. " ..
"vnStat reads the vnStat database which updates on its own schedule." "With Normal the downloaded/uploaded totals count since boot; " ..
"with vnStat they show this month's totals from the vnStat database, " ..
"which vnStat updates every few minutes."
) )
-- View mode -- View mode
@@ -36,33 +39,66 @@ if active_tab == "settings" then
-- Interface preference -- Interface preference
local iface = s:option(ListValue, "prefer", translate("Preferred Interface")) local iface = s:option(ListValue, "prefer", translate("Preferred Interface"))
iface.description = translate( iface.description = translate(
"WAN interface to track. Leave blank for auto-detection.<br>" .. "Interface to track. Leave on auto-detection to follow the device " ..
"<strong>Tip:</strong> If your WAN uses a dynamic name (e.g. wwan0_1) " .. "that holds the default route, whatever it is called on this router.<br>" ..
"it will be detected automatically when left blank." "Logical interfaces (e.g. wan) are resolved to their current device."
) )
iface:value("", translate("Auto detect")) iface:value("", translate("Auto detect"))
local netm = net.init() -- Logical netifd interfaces, live netdevs and anything vnStat already
for _, dev in ipairs(netm:get_interfaces()) do -- has history for. Nothing is hard-coded.
local name = dev:shortname() local seen = { [""] = true, lo = true, loopback = true }
if name and not name:match("^lo$") and not name:match("^br%-") then local function add_choice(name, label)
iface:value(name) if name and not seen[name] and nsu.safe_name(name) then
seen[name] = true
iface:value(name, label or name)
end end
end end
local netm = net.init()
for _, n in ipairs(netm:get_networks()) do
add_choice(n:name(), n:name() .. " (" .. translate("logical interface") .. ")")
end
for _, dev in ipairs(netm:get_interfaces()) do
add_choice(dev:shortname())
end
for _, name in ipairs((nsu.db_interfaces())) do
add_choice(name, name .. " (" .. translate("vnStat history") .. ")")
end
-- Data retention info -- Data retention info
local info = s:option(DummyValue, "_info", translate("vnStat Database")) local info = s:option(DummyValue, "_info", translate("vnStat Database"))
info.rawhtml = true info.rawhtml = true
info.cfgvalue = function() info.cfgvalue = function()
-- Show current database size if available if not nsu.vnstat_installed() then
local size_out = sys.exec("du -sh /etc/vnstat/ 2>/dev/null | cut -f1") or "" return '<span style="font-size:12px;color:#b91c1c;">' ..
translate("vnStat is not installed. Install the vnstat2 package.") ..
'</span>'
end
local dir = nsu.vnstat_dbdir()
local size_out = sys.exec("du -sh '" .. dir:gsub("'", "") .. "' 2>/dev/null | cut -f1") or ""
size_out = size_out:match("^%s*(.-)%s*$") or "" size_out = size_out:match("^%s*(.-)%s*$") or ""
local db_size = size_out ~= "" and (" — " .. size_out) or "" local db_size = size_out ~= "" and (" — " .. size_out) or ""
return string.format(
'<span style="font-size:12px;color:#6b7280;">%s%s</span>', local tracked = table.concat((nsu.db_interfaces()), ", ")
translate("Location: /etc/vnstat/"), if tracked == "" then tracked = translate("none yet") end
db_size
local html = string.format(
'<span style="font-size:12px;opacity:0.75;">%s %s%s<br>%s %s</span>',
translate("Location:"), dir, db_size,
translate("Tracked interfaces:"), tracked
) )
-- /var is a tmpfs on OpenWrt, so the default location loses history
-- on every reboot.
if dir:match("^/var/") or dir:match("^/tmp/") or dir == "/var" or dir == "/tmp" then
html = html .. '<br><span style="font-size:12px;color:#b45309;">' ..
translate("This directory is in RAM, so history is lost on reboot. " ..
"Set DatabaseDir in /etc/vnstat.conf to persistent storage to keep it.") ..
'</span>'
end
return html
end end
-- Reset button with inline JS confirmation -- Reset button with inline JS confirmation
@@ -93,19 +129,16 @@ if active_tab == "settings" then
end end
function reset.write(self, section) function reset.write(self, section)
sys.call("/etc/init.d/vnstat stop 2>/dev/null") -- vnStat 2 keeps everything in one SQLite database, so drop and
sys.call("rm -f /etc/vnstat/* 2>/dev/null") -- re-create each interface's entry instead of deleting files.
local ifaces = nsu.db_interfaces()
-- Re-initialise only interfaces that currently exist sys.call("/etc/init.d/vnstat stop >/dev/null 2>&1")
local netm2 = net.init() for _, name in ipairs(ifaces) do
for _, dev in ipairs(netm2:get_interfaces()) do sys.call(nsu.VNSTAT .. " --remove --force -i " .. name .. " >/dev/null 2>&1")
local name = dev:shortname() sys.call(nsu.VNSTAT .. " --add --force -i " .. name .. " >/dev/null 2>&1")
if name and not name:match("^lo$") and not name:match("^br%-") then
sys.call("vnstat -u -i " .. name .. " >/dev/null 2>&1")
end
end end
sys.call("rm -f /tmp/luci-netstat-vnstat-month")
sys.call("/etc/init.d/vnstat start 2>/dev/null") sys.call("/etc/init.d/vnstat start >/dev/null 2>&1")
m.message = translate("vnStat database has been reset successfully for all interfaces.") m.message = translate("vnStat database has been reset successfully for all interfaces.")
end end
@@ -0,0 +1,239 @@
-- Shared helpers for luci-app-netstat.
--
-- Everything here relies only on what every OpenWrt router has: the vnStat 2
-- CLI, procfs/sysfs and netifd over ubus. There are no board-specific
-- interface names or device paths; the uplink is found from the routing
-- table and history comes from whatever vnStat has in its database.
local nixio = require "nixio"
local fs = require "nixio.fs"
local util = require "luci.util"
local jsonc = require "luci.jsonc"
local uci = require "luci.model.uci"
local M = {}
M.VNSTAT = "/usr/bin/vnstat"
-- Netdev and logical interface names are used in shell commands, so only
-- allow the characters Linux and netifd actually use.
function M.safe_name(name)
return type(name) == "string" and name ~= "" and name:match("^[%w%-%._]+$") ~= nil
end
function M.netdev_exists(name)
return M.safe_name(name) and fs.access("/sys/class/net/" .. name) and true or false
end
function M.vnstat_installed()
return fs.access(M.VNSTAT, "x") and true or false
end
-- Interfaces present in the vnStat database, one per line from
-- "vnstat --dbiflist 1". Returns a sorted list and a lookup set.
function M.db_interfaces()
local list, set = {}, {}
if not M.vnstat_installed() then return list, set end
local out = util.exec(M.VNSTAT .. " --dbiflist 1 2>/dev/null") or ""
for name in out:gmatch("[^\n]+") do
name = name:match("^%s*(.-)%s*$")
if M.safe_name(name) and not set[name] then
set[name] = true
list[#list + 1] = name
end
end
table.sort(list)
return list, set
end
-- Run "vnstat --json <mode> <limit> [-i iface]" and return the raw text if it
-- looks like JSON, or nil (vnStat missing, interface not in the database, ...).
function M.vnstat_json_raw(mode, limit, iface)
if not M.vnstat_installed() then return nil end
local cmd = M.VNSTAT .. " --json " .. mode .. " " .. tostring(tonumber(limit) or 0)
if iface then
if not M.safe_name(iface) then return nil end
cmd = cmd .. " -i " .. iface
end
local out = util.exec(cmd .. " 2>/dev/null") or ""
if not out:match("^%s*{") then return nil end
return out
end
-- Devices that carry a default route, lowest metric first (IPv4, then IPv6).
-- Read straight from the kernel so it works whatever the uplink is called
-- (pppoe-wan, wwan0, eth1, wg0, ...).
function M.default_route_devices()
local found, seen = {}, {}
local function add(dev, metric)
if dev and dev ~= "lo" and not seen[dev] then
seen[dev] = true
found[#found + 1] = { dev = dev, metric = metric or 0 }
end
end
local f = io.open("/proc/net/route", "r")
if f then
local v4 = {}
for line in f:lines() do
local fld = {}
for w in line:gmatch("%S+") do fld[#fld + 1] = w end
-- Iface Destination Gateway Flags RefCnt Use Metric Mask ...
if fld[2] == "00000000" and fld[8] == "00000000" then
v4[#v4 + 1] = { dev = fld[1], metric = tonumber(fld[7]) or 0 }
end
end
f:close()
table.sort(v4, function(a, b) return a.metric < b.metric end)
for _, r in ipairs(v4) do add(r.dev, r.metric) end
end
f = io.open("/proc/net/ipv6_route", "r")
if f then
local v6 = {}
for line in f:lines() do
local fld = {}
for w in line:gmatch("%S+") do fld[#fld + 1] = w end
-- dest prefixlen src srcprefixlen nexthop metric refcnt use flags dev
if fld[1] == "00000000000000000000000000000000" and fld[2] == "00" and fld[10] then
v6[#v6 + 1] = { dev = fld[10], metric = tonumber(fld[6], 16) or 0 }
end
end
f:close()
table.sort(v6, function(a, b) return a.metric < b.metric end)
for _, r in ipairs(v6) do add(r.dev, r.metric) end
end
local devs = {}
for _, r in ipairs(found) do devs[#devs + 1] = r.dev end
return devs
end
-- With a "known" set, a device is only accepted if it is in that set (e.g.
-- the vnStat database); otherwise it has to exist in /sys/class/net.
local function accept(dev, known)
if not M.safe_name(dev) then return nil end
if known then return known[dev] and dev or nil end
return M.netdev_exists(dev) and dev or nil
end
-- Accepts a logical netifd interface name ("wan", "modem") or a netdev name
-- ("eth1", "wwan0") and returns the matching netdev, or nil. Logical names
-- win: on DSA routers the WAN port netdev is often also called "wan", but
-- the logical interface's L3 device (e.g. pppoe-wan) is the real uplink.
function M.resolve_device(name, known)
if not M.safe_name(name) then return nil end
local st = util.ubus("network.interface." .. name, "status", {})
if type(st) == "table" then
local dev = accept(st.l3_device, known) or accept(st.device, known)
if dev then return dev end
end
return accept(name, known)
end
function M.preferred_name()
local c = uci.cursor()
return c:get_first("netstats", "config", "prefer") or ""
end
-- The device to show: the "Preferred Interface" setting if it resolves,
-- otherwise the default-route device.
function M.wan_device(known)
local dev = M.resolve_device(M.preferred_name(), known)
if dev then return dev end
for _, d in ipairs(M.default_route_devices()) do
dev = accept(d, known)
if dev then return dev end
end
return nil
end
local function first_addr(list)
if type(list) == "table" and type(list[1]) == "table" then
return list[1].address
end
return nil
end
-- Local address of a device: netifd's view first, then the kernel's.
function M.device_ipaddr(dev)
if not M.safe_name(dev) then return nil end
local dump = util.ubus("network.interface", "dump", {})
if type(dump) == "table" and type(dump.interface) == "table" then
for _, key in ipairs({ "ipv4-address", "ipv6-address" }) do
for _, ifc in ipairs(dump.interface) do
if ifc.up and ifc.l3_device == dev then
local a = first_addr(ifc[key])
if a then return a end
end
end
end
end
local addrs = nixio.getifaddrs() or {}
for _, fam in ipairs({ "inet", "inet6" }) do
for _, a in ipairs(addrs) do
if a.name == dev and a.family == fam and a.addr
and not a.addr:match("^[Ff][Ee]80:") then
return a.addr
end
end
end
return nil
end
-- This month's rx/tx bytes for a device from the vnStat database, cached in
-- /tmp for a minute because the status widget polls every two seconds and
-- vnStat only writes to its database every few minutes anyway.
local CACHE = "/tmp/luci-netstat-vnstat-month"
function M.vnstat_month_total(dev)
if not M.safe_name(dev) then return nil end
local now = os.time()
local mtime = fs.stat(CACHE, "mtime")
if mtime and now - mtime < 60 and now >= mtime then
local c = fs.readfile(CACHE) or ""
local cdev, rx, tx = c:match("^(%S+) (%d+) (%d+)")
if cdev == dev then
return { rx = tonumber(rx), tx = tonumber(tx) }
end
end
local raw = M.vnstat_json_raw("m", 1, dev)
local data = raw and jsonc.parse(raw)
if type(data) ~= "table" or type(data.interfaces) ~= "table" then return nil end
local ifc = data.interfaces[1]
if type(ifc) ~= "table" or type(ifc.traffic) ~= "table" then return nil end
-- jsonversion 1 (vnStat 1.x) used plural keys and KiB units.
local legacy = tostring(data.jsonversion) == "1"
local months = ifc.traffic[legacy and "months" or "month"]
local scale = legacy and 1024 or 1
local rx, tx = 0, 0
local last = type(months) == "table" and months[#months]
if type(last) == "table" and type(last.date) == "table" then
local t = os.date("*t", now)
if tonumber(last.date.year) == t.year and tonumber(last.date.month) == t.month then
rx = (tonumber(last.rx) or 0) * scale
tx = (tonumber(last.tx) or 0) * scale
end
end
fs.writefile(CACHE, string.format("%s %.0f %.0f\n", dev, rx, tx))
return { rx = rx, tx = tx }
end
-- DatabaseDir from /etc/vnstat.conf, or vnStat's built-in default.
function M.vnstat_dbdir()
local conf = fs.readfile("/etc/vnstat.conf") or ""
for line in conf:gmatch("[^\n]+") do
local dir = line:match("^%s*DatabaseDir%s+\"([^\"]+)\"")
if dir then return dir end
end
return "/var/lib/vnstat"
end
return M
@@ -1,6 +1,6 @@
<div class="netstat-about-card"> <div class="netstat-about-card">
<h3 style="margin:0 0 4px;"><%:LuCI Netstat%></h3> <strong class="netstat-about-title"><%:LuCI Netstat%></strong>
<p style="margin:0 0 16px;font-size:13px;color:#6b7280;"> <p class="netstat-about-sub">
<%:Frontend for traffic monitoring and usage charts on OpenWrt.%> <%:Frontend for traffic monitoring and usage charts on OpenWrt.%>
</p> </p>
@@ -40,22 +40,21 @@
</div> </div>
</div> </div>
<hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0 12px;"> <hr class="netstat-about-sep">
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;"> <p class="netstat-about-foot">
<%:Made with%> ♥ <%:for the OpenWrt community%> <%:Made with%> ♥ <%:for the OpenWrt community%>
</p> </p>
</div> </div>
<style> <style>
.netstat-about-card { /* Layout only: colours and backgrounds come from the active LuCI theme. */
border: 1px solid #dfe3eb; .netstat-about-card { max-width: 520px; }
border-radius: 10px; .netstat-about-title { display: block; margin: 0 0 4px; font-size: 15px; }
background: #fff; .netstat-about-sub { margin: 0 0 16px; font-size: 13px; opacity: 0.7; }
padding: 20px 22px; .netstat-about-sep { border: none; border-top: 1px solid rgba(128, 128, 128, 0.3); margin: 16px 0 12px; }
max-width: 520px; .netstat-about-foot { margin: 0; font-size: 12px; opacity: 0.7; text-align: center; }
}
.netstat-about-grid { display: flex; flex-direction: column; gap: 10px; } .netstat-about-grid { display: flex; flex-direction: column; gap: 10px; }
.netstat-about-row { .netstat-about-row {
display: flex; display: flex;
@@ -68,16 +67,10 @@
.netstat-about-label { .netstat-about-label {
min-width: 110px; min-width: 110px;
flex-shrink: 0; flex-shrink: 0;
color: #6b7280; opacity: 0.7;
font-weight: 500; font-weight: 500;
} }
.netstat-about-row a {
color: #2563eb;
text-decoration: none;
}
.netstat-about-row a:hover { text-decoration: underline; }
@media (max-width: 480px) { @media (max-width: 480px) {
.netstat-about-row { flex-direction: column; gap: 2px; } .netstat-about-row { flex-direction: column; gap: 2px; }
.netstat-about-label { min-width: unset; } .netstat-about-label { min-width: unset; }
@@ -15,112 +15,13 @@ local tabs_def = {
%> %>
<style> <%# Standard LuCI tab markup, so every theme styles it like its own tabs. %>
.netstat-tab-wrap { margin: 4px 0 14px; } <ul class="cbi-tabmenu">
<% for _, tab in ipairs(tabs_def) do %>
.netstat-tabmenu { display:flex; gap:4px; margin:0; padding:0; list-style:none; flex-wrap:wrap; } <li class="<%= active_tab == tab.id and "cbi-tab" or "cbi-tab-disabled" %>">
<a href="<%= tab_url(tab.id) %>"><%= tab.label %></a>
.netstat-tabmenu li a { </li>
display: flex; <% end %>
align-items: center; </ul>
gap: 6px;
padding: 8px 16px;
border: 1px solid #cfd6e2;
border-bottom: none;
border-radius: 6px 6px 0 0;
background: #e6ebf2;
color: #2f3b52;
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: background 0.1s, color 0.1s;
}
.netstat-tabmenu li a:hover:not(.active-link) {
background: #d8e2f0;
}
.netstat-tabmenu li.active a,
.netstat-tabmenu li a.active-link {
background: #f7f9fc;
border-color: #cfd6e2;
border-bottom-color: #f7f9fc;
color: #3659d9;
font-weight: 600;
}
.netstat-tab-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0;
transition: opacity 0.15s;
flex-shrink: 0;
}
.netstat-tabmenu li.active .netstat-tab-dot { opacity: 1; }
.netstat-tab-divider {
border: none;
border-top: 1px solid #cfd6e2;
margin-top: -1px;
margin-bottom: 0;
}
<% if active_tab == "settings" then %>
.cbi-map > .cbi-section:last-of-type {
border: 1px solid #dfe3eb;
border-radius: 10px;
background: #fff;
padding: 16px 18px;
}
<% end %>
</style>
<style id="netstat-dark-style"></style>
<script>
(function () {
var isDark = localStorage.getItem('luci-theme') === 'dark'
|| document.body.classList.contains('dark-theme');
window._netstatDark = isDark;
if (!isDark) return;
document.getElementById('netstat-dark-style').textContent = [
'.netstat-tabmenu li a{background:#2a2d32!important;border-color:#4b5563!important;color:#d1d5db!important}',
'.netstat-tabmenu li.active a{background:#1a1d20!important;border-color:#4b5563!important;border-bottom-color:#1a1d20!important;color:#60a5fa!important}',
'.netstat-tab-divider{border-top-color:#4b5563!important}',
'.netstat-dashboard{background:#1a1d20!important;border-color:#374151!important;color:#f3f4f6!important}',
'.netstat-summary{background:#111315!important;border-color:#374151!important;color:#f3f4f6!important}',
'.netstat-chart-wrap{background:#1a1d20!important;border-color:#374151!important}',
'.netstat-chart-header{background:#1a1d20!important}',
'.netstat-chart-title{color:#f3f4f6!important}',
'.netstat-chart-badge{background:#1e3a5f!important;color:#93c5fd!important}',
'.netstat-summary-label{color:#9ca3af!important}',
'.netstat-summary-item{color:#f3f4f6!important}',
'.netstat-empty{color:#9ca3af!important}',
'.netstat-hint{color:#6b7280!important}',
'.netstat-about-card{background:#1a1d20!important;border-color:#374151!important;color:#f3f4f6!important}',
'.netstat-about-card h3{color:#f3f4f6!important}',
'.netstat-about-card hr{border-top-color:#374151!important}',
'.netstat-about-label{color:#9ca3af!important}',
'.netstat-about-row a{color:#60a5fa!important}',
'.cbi-map>.cbi-section:last-of-type{background:#1a1d20!important;border-color:#374151!important}'
].join('');
})();
</script>
<div class="netstat-tab-wrap">
<ul class="netstat-tabmenu">
<% for _, tab in ipairs(tabs_def) do %>
<li class="<%= active_tab == tab.id and "active" or "" %>">
<a href="<%= tab_url(tab.id) %>">
<span class="netstat-tab-dot"></span>
<%= tab.label %>
</a>
</li>
<% end %>
</ul>
<hr class="netstat-tab-divider" />
</div>
<input type="hidden" name="tab" value="<%=active_tab%>" /> <input type="hidden" name="tab" value="<%=active_tab%>" />
@@ -1,214 +1,116 @@
<% <%
local sys = require "luci.sys" local http = require "luci.http"
local http = require "luci.http" local uci = require "luci.model.uci".cursor()
local uci = require "luci.model.uci".cursor() local jsonc = require "luci.jsonc"
local nsu = require "luci.netstat"
local function to_bytes(value, unit) local have_vnstat = nsu.vnstat_installed()
local m = { KiB=1024, MiB=1048576, GiB=1073741824, TiB=1099511627776 } local ifaces, db = nsu.db_interfaces()
return tonumber(value) * (m[unit] or 1) local saved_mode = uci:get("netstats", "@config[0]", "mode") or "daily"
end
local function format_date(date_str) -- Requested interface, then the Settings choice / default-route device, as
local months = {"Jan","Feb","Mar","Apr","May","Jun", -- long as vnStat has a database entry for it; else the first one it has.
"Jul","Aug","Sep","Oct","Nov","Dec"} local cur_iface = nsu.resolve_device(http.formvalue("iface"), db)
local mo, d, y = date_str:match("(%d%d)/(%d%d)/(%d%d)") or nsu.wan_device(db)
if mo and d and y then or ifaces[1] or ""
return string.format("%02d %s %02d", tonumber(d), months[tonumber(mo)], tonumber(y))
end
return date_str
end
local function today_label() return os.date("%d %b %y") end local view_mode = http.formvalue("mode") or saved_mode
local function curmonth_label() return os.date("%b '%y") end
local function ensure_today(stats)
local lbl = today_label()
for _, s in ipairs(stats) do if s.date == lbl then return stats end end
table.insert(stats, { date=lbl, rx=0, tx=0 })
return stats
end
local function ensure_curmonth(stats)
local lbl = curmonth_label()
for _, s in ipairs(stats) do if s.month == lbl then return stats end end
table.insert(stats, { month=lbl, rx=0, tx=0 })
return stats
end
local function get_interfaces()
local ifaces = {}
local p = io.popen("ls /etc/vnstat/ 2>/dev/null | grep -v '.conf$' | grep -v '.old$'")
if p then
for iface in p:lines() do
iface = iface:match("^%s*(.-)%s*$")
if iface ~= "" then table.insert(ifaces, iface) end
end
p:close()
end
table.sort(ifaces)
return ifaces
end
local function get_daily_stats(iface)
local stats = {}
local out = sys.exec("vnstat -i " .. iface .. " --days 2>/dev/null") or ""
for date, rx_v, rx_u, tx_v, tx_u in
out:gmatch("(%d%d/%d%d/%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
table.insert(stats, {
date = format_date(date),
rx = to_bytes(rx_v, rx_u),
tx = to_bytes(tx_v, tx_u)
})
end
return ensure_today(stats)
end
local function get_monthly_stats(iface)
local stats = {}
local out = sys.exec("vnstat -i " .. iface .. " --months 2>/dev/null") or ""
for month, rx_v, rx_u, tx_v, tx_u in
out:gmatch("(%a+%s+'%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
table.insert(stats, {
month = month,
rx = to_bytes(rx_v, rx_u),
tx = to_bytes(tx_v, tx_u)
})
end
return ensure_curmonth(stats)
end
local function fmt_bytes(v)
if v >= 1099511627776 then return string.format("%.2f TB", v/1099511627776)
elseif v >= 1073741824 then return string.format("%.2f GB", v/1073741824)
elseif v >= 1048576 then return string.format("%.2f MB", v/1048576)
elseif v >= 1024 then return string.format("%.2f KB", v/1024)
end
return tostring(v) .. " B"
end
local ifaces = get_interfaces()
local saved_iface = uci:get("netstats", "@config[0]", "prefer") or ""
local saved_mode = uci:get("netstats", "@config[0]", "mode") or "daily"
local function has_db(name)
for _, i in ipairs(ifaces) do if i == name then return true end end
return false
end
-- Accepts a vnStat netdev name (e.g. "wwan0") or a logical interface name
-- (e.g. "wan") resolved to its l3_device. Returns nil if it has no database.
local function resolve_iface(name)
if not name or name == "" or not name:match("^[%w%-%._]+$") then return nil end
if has_db(name) then return name end
local l3 = sys.exec("ubus call network.interface." .. name ..
" status 2>/dev/null | jsonfilter -e '@.l3_device' 2>/dev/null") or ""
l3 = l3:match("^%s*(.-)%s*$")
if l3 ~= "" and has_db(l3) then return l3 end
return nil
end
-- Auto detect: the default-route device is the real uplink.
local function default_route_iface()
local out = sys.exec("ip route show default 2>/dev/null") or ""
for dev in out:gmatch("dev%s+(%S+)") do
if has_db(dev) then return dev end
end
return nil
end
local cur_iface = resolve_iface(http.formvalue("iface"))
or resolve_iface(saved_iface)
or default_route_iface()
or ifaces[1] or ""
local view_mode = http.formvalue("mode") or saved_mode
if view_mode ~= "daily" and view_mode ~= "monthly" then view_mode = "daily" end if view_mode ~= "daily" and view_mode ~= "monthly" then view_mode = "daily" end
local stats, title = {}, "" local title = view_mode == "monthly" and translate("Monthly") or translate("Daily")
if cur_iface ~= "" then
if view_mode == "monthly" then
stats = get_monthly_stats(cur_iface)
title = translate("Monthly")
else
stats = get_daily_stats(cur_iface)
title = translate("Daily")
end
end
local total_rx, total_tx = 0, 0 -- Raw vnStat JSON, parsed in the browser by netstat/vnstat-parse.js.
for _, s in ipairs(stats) do local raw = ""
total_rx = total_rx + (s.rx or 0) if cur_iface ~= "" then
total_tx = total_tx + (s.tx or 0) raw = nsu.vnstat_json_raw(view_mode == "monthly" and "m" or "d",
view_mode == "monthly" and 12 or 30, cur_iface) or ""
end end
local raw_js = (jsonc.stringify(raw) or '""'):gsub("</", "<\\/")
%> %>
<link rel="stylesheet" href="/luci-static/resources/netstat/netstat-usage.css" /> <link rel="stylesheet" href="/luci-static/resources/netstat/netstat-usage.css" />
<div class="netstat-dashboard"> <div class="netstat-dashboard">
<p style="margin:0 0 12px;font-size:13px;color:#6b7280;"> <p class="netstat-intro">
<%:Usage is displayed using the Interface and View Mode selected in the Settings tab.%> <%:Usage is displayed using the Interface and View Mode selected in the Settings tab.%>
</p> </p>
<% if cur_iface ~= "" and #stats > 0 then %> <% if have_vnstat and cur_iface ~= "" then %>
<div id="netstat-usage-data" style="display:none">
<div class="netstat-summary"> <div class="netstat-summary">
<div class="netstat-summary-item"> <div class="netstat-summary-item">
<span class="netstat-summary-dot rx"></span> <span class="netstat-summary-dot rx"></span>
<span class="netstat-summary-label"><%:Download%>:</span> <span class="netstat-summary-label"><%:Download%>:</span>
<strong><%=fmt_bytes(total_rx)%></strong> <strong id="netstat-total-rx">-</strong>
</div> </div>
<div class="netstat-summary-item"> <div class="netstat-summary-item">
<span class="netstat-summary-dot tx"></span> <span class="netstat-summary-dot tx"></span>
<span class="netstat-summary-label"><%:Upload%>:</span> <span class="netstat-summary-label"><%:Upload%>:</span>
<strong><%=fmt_bytes(total_tx)%></strong> <strong id="netstat-total-tx">-</strong>
</div> </div>
<div class="netstat-summary-item netstat-summary-total"> <div class="netstat-summary-item netstat-summary-total">
<span class="netstat-summary-label"><%:Total%>:</span> <span class="netstat-summary-label"><%:Total%>:</span>
<strong><%=fmt_bytes(total_rx + total_tx)%></strong> <strong id="netstat-total-all">-</strong>
</div> </div>
</div> </div>
<div class="netstat-chart-wrap"> <div class="netstat-chart-wrap">
<div class="netstat-chart-header"> <div class="netstat-chart-header">
<h3 class="netstat-chart-title"><%=title%> <%:Usage%></h3> <span class="netstat-chart-title"><%=title%> <%:Usage%></span>
<span class="netstat-chart-badge"><%=cur_iface%></span> <span class="netstat-chart-badge"><%=cur_iface%></span>
</div> </div>
<canvas id="trafficChart" class="netstat-chart-canvas"></canvas> <div class="netstat-chart-box"><canvas id="trafficChart"></canvas></div>
</div>
</div>
<div id="netstat-usage-empty" class="netstat-empty" style="display:none">
<span class="netstat-empty-icon">&#128202;</span>
<%:No data available for%> <strong><%=cur_iface%></strong>
<div class="netstat-hint">
<%:vnStat may still be collecting its first data. It saves to its database every few minutes, try again shortly.%>
</div>
</div> </div>
<script src="/luci-static/resources/netstat/chart.js"></script> <script src="/luci-static/resources/netstat/chart.js"></script>
<script src="/luci-static/resources/netstat/chartjs-plugin-datalabels"></script> <script src="/luci-static/resources/netstat/chartjs-plugin-datalabels"></script>
<script src="/luci-static/resources/netstat/vnstat-parse.js"></script>
<script> <script>
(function () { (function () {
var isDark = window._netstatDark || false; // Chart text follows the theme's own text colour, so it is readable
var textColor = isDark ? '#f3f4f6' : '#1f2937'; // on light and dark themes alike; grid lines are translucent grey.
var gridColor = isDark ? 'rgba(255,255,255,0.10)' : 'rgba(0,0,0,0.08)'; var dashboard = document.querySelector('.netstat-dashboard');
function themeTextColor() {
return getComputedStyle(dashboard).color || '#888';
}
var textColor = themeTextColor();
var gridColor = 'rgba(128,128,128,0.2)';
var labels = [ var usage = window.netstatVnstat.series(<%=raw_js%>, '<%=cur_iface%>', '<%=view_mode%>');
<% for i, stat in ipairs(stats) do if (!usage.entries) {
local lbl = view_mode == "daily" and stat.date or stat.month document.getElementById('netstat-usage-empty').style.display = '';
%>"<%=lbl%>"<% if i < #stats then %>,<% end %> return;
<% end %> }
]; document.getElementById('netstat-usage-data').style.display = '';
var rxData = [
<% for i, stat in ipairs(stats) do
%><%= math.floor(stat.rx) %><% if i < #stats then %>,<% end %>
<% end %>
];
var txData = [
<% for i, stat in ipairs(stats) do
%><%= math.floor(stat.tx) %><% if i < #stats then %>,<% end %>
<% end %>
];
var labels = usage.labels;
var rxData = usage.rx;
var txData = usage.tx;
// Height follows the number of rows; width follows the container
// (Chart.js responsive mode), so the chart never overflows when the
// theme's sidebar appears or the window is resized.
var BAR_H = 48; var BAR_H = 48;
var canvas = document.getElementById('trafficChart'); var canvas = document.getElementById('trafficChart');
canvas.height = Math.min(Math.max(labels.length * BAR_H, 280), 700); canvas.parentElement.style.height =
canvas.width = canvas.offsetWidth || canvas.parentElement.clientWidth; Math.min(Math.max(labels.length * BAR_H, 280), 700) + 'px';
var ctx = canvas.getContext('2d'); var ctx = canvas.getContext('2d');
function rxGradient() { function rxGradient(c) {
var g = ctx.createLinearGradient(0, 0, canvas.width || 600, 0); var area = c.chart.chartArea;
if (!area) return '#4ade80';
var g = c.chart.ctx.createLinearGradient(area.left, 0, area.right, 0);
g.addColorStop(0, '#86efac'); g.addColorStop(0, '#86efac');
g.addColorStop(1, '#15803d'); g.addColorStop(1, '#15803d');
return g; return g;
@@ -222,9 +124,13 @@ end
return v + ' B'; return v + ' B';
} }
document.getElementById('netstat-total-rx').textContent = fmtBytes(usage.totalRx);
document.getElementById('netstat-total-tx').textContent = fmtBytes(usage.totalTx);
document.getElementById('netstat-total-all').textContent = fmtBytes(usage.totalRx + usage.totalTx);
var maxVal = Math.max.apply(null, rxData.concat(txData).concat([1])); var maxVal = Math.max.apply(null, rxData.concat(txData).concat([1]));
new Chart(ctx, { var chart = new Chart(ctx, {
type: 'bar', type: 'bar',
data: { data: {
labels: labels, labels: labels,
@@ -232,7 +138,7 @@ end
{ {
label: '<%:Download (RX)%>', label: '<%:Download (RX)%>',
data: rxData, data: rxData,
backgroundColor: rxData.map(function() { return rxGradient(); }), backgroundColor: rxGradient,
borderRadius: 4, borderRadius: 4,
borderSkipped: false, borderSkipped: false,
datalabels: { datalabels: {
@@ -267,7 +173,7 @@ end
}, },
options: { options: {
indexAxis: 'y', indexAxis: 'y',
responsive: false, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
animation: { duration: 400 }, animation: { duration: 400 },
plugins: { plugins: {
@@ -303,20 +209,51 @@ end
}, },
plugins: [ChartDataLabels] plugins: [ChartDataLabels]
}); });
// Recolour when the theme switches between light and dark without a
// page reload (themes toggle a class or inline style on <html>/<body>).
function recolour() {
var c = themeTextColor();
if (c === textColor) return;
textColor = c;
chart.options.plugins.legend.labels.color = c;
chart.options.scales.x.ticks.color = c;
chart.options.scales.y.ticks.color = c;
chart.data.datasets[1].datalabels.color = c;
chart.update('none');
}
// Themes may animate the colour change (e.g. "transition: all 0.2s"),
// so check again once it has settled and whenever a colour
// transition finishes.
function recolourSoon() {
setTimeout(recolour, 50);
setTimeout(recolour, 500);
}
document.addEventListener('transitionend', function (ev) {
if (ev.propertyName === 'color') recolour();
});
if (window.MutationObserver) {
var mo = new MutationObserver(recolourSoon);
[document.documentElement, document.body].forEach(function (el) {
mo.observe(el, { attributes: true, attributeFilter: ['class', 'style', 'data-theme', 'data-darkmode'] });
});
var darkStyle = document.getElementById('dark-theme-style');
if (darkStyle) mo.observe(darkStyle, { childList: true, characterData: true, subtree: true });
}
})(); })();
</script> </script>
<% elseif #ifaces == 0 then %> <% elseif not have_vnstat then %>
<div class="netstat-empty"> <div class="netstat-empty">
<span class="netstat-empty-icon">&#128225;</span> <span class="netstat-empty-icon">&#128225;</span>
<%:No vnStat interfaces were found. Make sure vnStat has been initialized.%> <%:vnStat is not installed. Install the vnstat2 package to record traffic history.%>
</div> </div>
<% else %> <% else %>
<div class="netstat-empty"> <div class="netstat-empty">
<span class="netstat-empty-icon">&#128202;</span> <span class="netstat-empty-icon">&#128225;</span>
<%:No data available for%> <strong><%=cur_iface%></strong> <%:vnStat is not tracking any interface yet.%>
<div class="netstat-hint"> <div class="netstat-hint">
<%:vnStat may still be collecting its first data. Try again in a few minutes.%> <%:Interfaces are added automatically when they come up. You can also run "/usr/libexec/netstat-vnstat-track" on the router.%>
</div> </div>
</div> </div>
<% end %> <% end %>
@@ -1,250 +0,0 @@
<%+header%>
<%
local sys = require "luci.sys"
local http = require "luci.http"
local uci = require "luci.model.uci".cursor()
local function to_bytes(value, unit)
local multipliers = {
["KiB"] = 1024,
["MiB"] = 1024^2,
["GiB"] = 1024^3,
["TiB"] = 1024^4
}
return tonumber(value) * (multipliers[unit] or 1)
end
local function format_date(date_str)
local month_names = {"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
local m, d, y = date_str:match("(%d%d)/(%d%d)/(%d%d)")
if m and d and y then
return string.format("%02d %s %02d", tonumber(d), month_names[tonumber(m)], tonumber(y))
end
return date_str
end
local function get_interfaces()
local ifaces = {}
local p = io.popen("ls /etc/vnstat/ 2>/dev/null | grep -v '.conf$' | grep -v '.old$'")
if p then
for iface in p:lines() do
iface = iface:match("^%s*(.-)%s*$")
if iface ~= "" then table.insert(ifaces, iface) end
end
p:close()
end
table.sort(ifaces)
return ifaces
end
local function get_daily_stats(iface)
local stats = {}
local output = sys.exec("vnstat -i " .. iface .. " --days 2>/dev/null") or ""
for date, rx_val, rx_unit, tx_val, tx_unit in
output:gmatch("(%d%d/%d%d/%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
table.insert(stats, {
date = format_date(date),
rx = to_bytes(rx_val, rx_unit),
tx = to_bytes(tx_val, tx_unit)
})
end
return stats
end
local function get_monthly_stats(iface)
local stats = {}
local output = sys.exec("vnstat -i " .. iface .. " --months 2>/dev/null") or ""
for month, rx_val, rx_unit, tx_val, tx_unit in
output:gmatch("(%a+%s+'%d%d)%s+([%d%.]+)%s+([KMGT]iB)%s+|%s+([%d%.]+)%s+([KMGT]iB)") do
table.insert(stats, {
month = month,
rx = to_bytes(rx_val, rx_unit),
tx = to_bytes(tx_val, tx_unit)
})
end
return stats
end
local ifaces = get_interfaces()
local current_iface = http.formvalue("iface") or "wwan0_1"
local view_mode = http.formvalue("mode") or "daily"
local stats, title = {}, ""
if current_iface ~= "" then
if view_mode == "daily" then
stats = get_daily_stats(current_iface)
title = "<%:Daily%>"
else
stats = get_monthly_stats(current_iface)
title = "<%:Monthly%>"
end
end
%>
<div class="cbi-map">
<h2 name="content"><%:Network Usage%></h2>
<div class="cbi-section">
<!-- Interface & Mode Select -->
<form method="get" class="inline">
<div class="cbi-value">
<label class="cbi-value-title"><%:Interface%></label>
<div class="cbi-value-field">
<select name="iface" onchange="this.form.submit()" class="cbi-input-select">
<% if #ifaces == 0 then %>
<option value=""><%:No interfaces found%></option>
<% else %>
<% for _, iface in ipairs(ifaces) do %>
<option value="<%=iface%>" <%=current_iface == iface and 'selected="selected"' or ''%>><%=iface%></option>
<% end %>
<% end %>
</select>
</div>
</div>
<div class="cbi-value">
<label class="cbi-value-title"><%:View Mode%></label>
<div class="cbi-value-field">
<select name="mode" onchange="this.form.submit()" class="cbi-input-select">
<option value="daily" <%=view_mode == "daily" and 'selected="selected"' or ''%>><%:Daily%></option>
<option value="monthly" <%=view_mode == "monthly" and 'selected="selected"' or ''%>><%:Monthly%></option>
</select>
</div>
</div>
</form>
</div>
<% if current_iface ~= "" and #stats > 0 then %>
<div class="cbi-section">
<h3><%=title%> <small>(<%=current_iface%>)</small></h3>
<div style="width: 100%; height: auto; min-height: 350px;">
<canvas id="trafficChart"></canvas>
</div>
</div>
<script src="/luci-static/resources/netstat/chart.js"></script>
<script src="/luci-static/resources/netstat/chartjs-plugin-datalabels"></script>
<script>
// Detect background brightness
function getTextColor() {
const bodyColor = window.getComputedStyle(document.body).backgroundColor;
if (!bodyColor) return '#000';
const rgb = bodyColor.match(/\d+/g).map(Number);
const brightness = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) / 1000;
return brightness < 128 ? '#fff' : '#000';
}
const textColor = getTextColor();
// Dynamic height per row
const barHeight = 60; // bigger for mobile
const chartCanvas = document.getElementById('trafficChart');
chartCanvas.height = Math.max(350, <%=#stats%> * barHeight);
const ctx = chartCanvas.getContext('2d');
// Download gradient (light green → dark green)
function getGradient(ctx, value, maxValue) {
const gradient = ctx.createLinearGradient(0, 0, chartCanvas.width, 0);
gradient.addColorStop(0, '#90ee90'); // light green
gradient.addColorStop(1, '#006400'); // dark green
return gradient;
}
// Format bytes -> readable
function formatBytes(value) {
if (value > 1099511627776) return (value/1099511627776).toFixed(2) + ' TB';
if (value > 1073741824) return (value/1073741824).toFixed(2) + ' GB';
if (value > 1048576) return (value/1048576).toFixed(2) + ' MB';
if (value > 1024) return (value/1024).toFixed(2) + ' KB';
return value + ' B';
}
// Data arrays
const rxData = [
<% for _, stat in ipairs(stats) do %>
<%= math.floor(stat.rx) %>,
<% end %>
];
const txData = [
<% for _, stat in ipairs(stats) do %>
<%= math.floor(stat.tx) %>,
<% end %>
];
const maxValue = Math.max(...rxData, ...txData, 1);
const chart = new Chart(ctx, {
type: 'bar',
data: {
labels: [
<% for _, stat in ipairs(stats) do %>
"<%= view_mode == "daily" and stat.date or stat.month %>",
<% end %>
],
datasets: [
{
label: '<%:Download (RX)%>',
backgroundColor: rxData.map(v => getGradient(ctx, v, maxValue)),
data: rxData,
datalabels: {
color: '#fff',
anchor: 'center',
align: 'center',
formatter: function(value) { return formatBytes(value); }
}
},
{
label: '<%:Upload (TX)%>',
backgroundColor: 'orange',
data: txData,
datalabels: {
color: textColor,
anchor: 'end',
align: 'right',
formatter: function(value) { return formatBytes(value); }
}
}
]
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
labels: { color: textColor }
},
tooltip: {
callbacks: {
label: function(context) {
return formatBytes(context.raw);
}
}
}
},
scales: {
x: {
ticks: {
color: textColor,
callback: function(value) {
return formatBytes(value);
}
}
},
y: {
ticks: { color: textColor, autoSkip: false }
}
}
},
plugins: [ChartDataLabels]
});
</script>
<% else %>
<div class="alert-message warning">
<%:No data available for %> <%=current_iface%>
</div>
<% end %>
</div>
<%+footer%>
+58
View File
@@ -0,0 +1,58 @@
#!/bin/sh
# Make vnStat track this router's network devices without any hard-coded
# names: every netifd interface that is up (WAN, LAN, modem, VPN, ...) gets
# its L3 device added to the "interface" list in /etc/config/vnstat, which
# the vnstat2 init script feeds to "vnstat --add".
#
# netstat-vnstat-track add the devices of all interfaces that are up
# netstat-vnstat-track <dev>... add the given devices
#
# Set "option vnstat_autoadd '0'" in /etc/config/netstats to turn this off.
[ -x /usr/bin/vnstat ] || exit 0
[ "$(uci -q get netstats.@config[0].vnstat_autoadd)" = "0" ] && exit 0
valid_name() {
case "$1" in
""|lo|*[!A-Za-z0-9._-]*) return 1 ;;
esac
return 0
}
discover() {
ubus call network.interface dump 2>/dev/null | \
jsonfilter -e '@.interface[@.up=true].l3_device' 2>/dev/null
}
[ $# -gt 0 ] && devices="$*" || devices="$(discover)"
[ -n "$devices" ] || exit 0
lock /var/lock/netstat-vnstat-track
# vnstat2 ships "config vnstat" in /etc/config/vnstat; recreate it if missing.
touch /etc/config/vnstat
uci -q get vnstat.@vnstat[0] >/dev/null || uci -q add vnstat vnstat >/dev/null
tracked=" $(uci -q get vnstat.@vnstat[0].interface) "
changed=0
for dev in $devices; do
valid_name "$dev" || continue
case "$tracked" in *" $dev "*) continue ;; esac
uci -q add_list vnstat.@vnstat[0].interface="$dev"
tracked="$tracked$dev "
changed=1
done
if [ "$changed" = 1 ]; then
uci -q commit vnstat
# reload runs "vnstat --add" for the list and sends SIGHUP so vnstatd
# picks the new entries up straight away.
if /etc/init.d/vnstat running 2>/dev/null; then
/etc/init.d/vnstat reload >/dev/null 2>&1
elif /etc/init.d/vnstat enabled 2>/dev/null; then
/etc/init.d/vnstat start >/dev/null 2>&1
fi
fi
lock -u /var/lock/netstat-vnstat-track
exit 0
@@ -1,20 +1,24 @@
/* netstat-usage.css */ /* netstat-usage.css
*
* Layout only. Backgrounds, borders and text colours all come from the
* active LuCI theme (the pages sit inside its standard cbi-section and use
* its cbi-tabmenu tabs), so they follow the theme switcher, light or dark.
*/
.netstat-dashboard { .netstat-dashboard {
border: 1px solid #dfe3eb; --ns-muted: 0.7;
border-radius: 10px; }
background: #fff;
padding: 16px 18px; .netstat-intro {
margin: 0 0 12px;
font-size: 13px;
opacity: var(--ns-muted);
} }
.netstat-summary { .netstat-summary {
display: flex; display: flex;
gap: 12px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 16px; margin-bottom: 16px;
padding: 10px 14px;
background: #f7f9fc;
border: 1px solid #dfe3eb;
border-radius: 8px;
font-size: 13px; font-size: 13px;
} }
@@ -23,7 +27,6 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
font-weight: 600; font-weight: 600;
color: #374151;
} }
.netstat-summary-dot { .netstat-summary-dot {
@@ -37,7 +40,7 @@
.netstat-summary-dot.tx { background: #d97706; } .netstat-summary-dot.tx { background: #d97706; }
.netstat-summary-label { .netstat-summary-label {
color: #6b7280; opacity: var(--ns-muted);
font-weight: 400; font-weight: 400;
margin-right: 2px; margin-right: 2px;
} }
@@ -46,48 +49,36 @@
margin-left: auto; margin-left: auto;
} }
.netstat-chart-wrap {
border: 1px solid #dfe4ea;
border-radius: 8px;
padding: 14px;
background: #fff;
overflow: hidden;
}
.netstat-chart-header { .netstat-chart-header {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
gap: 8px;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px;
} }
.netstat-chart-title { .netstat-chart-title {
margin: 0;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #1f2937;
} }
.netstat-chart-badge { .netstat-chart-badge {
font-size: 11px; font-size: 12px;
font-weight: 600; opacity: var(--ns-muted);
padding: 2px 8px;
border-radius: 999px;
background: #e0f2fe;
color: #0369a1;
} }
.netstat-chart-canvas { .netstat-chart-badge::before { content: "("; }
.netstat-chart-badge::after { content: ")"; }
.netstat-chart-box {
position: relative;
width: 100%; width: 100%;
display: block; min-width: 0;
} }
.netstat-empty { .netstat-empty {
padding: 32px; padding: 32px;
text-align: center; text-align: center;
color: #6b7280;
font-size: 14px; font-size: 14px;
} }
@@ -100,11 +91,5 @@
.netstat-hint { .netstat-hint {
margin-top: 6px; margin-top: 6px;
font-size: 12px; font-size: 12px;
color: #9ca3af; opacity: var(--ns-muted);
}
@media (max-width: 640px) {
.netstat-dashboard { padding: 12px; }
.netstat-summary { gap: 8px; }
.netstat-chart-wrap { padding: 10px; }
} }
@@ -0,0 +1,96 @@
/*
* Turns `vnstat --json d|m` output into chart series for the Network Usage
* tab. Handles vnStat 2 (jsonversion "2": bytes, keys "day"/"month") and the
* older jsonversion "1" layout (KiB, keys "days"/"months").
*/
(function (root) {
'use strict';
var MONTHS = ['Jan','Feb','Mar','Apr','May','Jun',
'Jul','Aug','Sep','Oct','Nov','Dec'];
function pad2(n) { return (n < 10 ? '0' : '') + n; }
function dayLabel(y, m, d) {
return pad2(d) + ' ' + MONTHS[m - 1] + ' ' + pad2(y % 100);
}
function monthLabel(y, m) {
return MONTHS[m - 1] + " '" + pad2(y % 100);
}
function findInterface(data, name) {
var list = (data && Array.isArray(data.interfaces)) ? data.interfaces : [];
for (var i = 0; i < list.length; i++)
if (list[i] && list[i].name === name) return list[i];
return list.length === 1 ? list[0] : null;
}
/*
* data: parsed vnStat JSON (or the raw string)
* iface: interface name to pick from data.interfaces
* mode: 'daily' | 'monthly'
* now: Date used to add an empty entry for today / this month
*
* Returns { labels, rx, tx, totalRx, totalTx, entries } where entries is
* the number of rows vnStat actually had (0 while it is still collecting).
*/
function series(data, iface, mode, now) {
var out = { labels: [], rx: [], tx: [], totalRx: 0, totalTx: 0, entries: 0 };
if (typeof data === 'string') {
try { data = JSON.parse(data); } catch (e) { return out; }
}
var ifc = findInterface(data, iface);
if (!ifc || !ifc.traffic) return out;
var legacy = String(data.jsonversion) === '1';
var scale = legacy ? 1024 : 1;
var monthly = mode === 'monthly';
var key = monthly ? (legacy ? 'months' : 'month') : (legacy ? 'days' : 'day');
var rows = Array.isArray(ifc.traffic[key]) ? ifc.traffic[key].slice() : [];
rows = rows.filter(function (r) {
return r && r.date && r.date.year && r.date.month && (monthly || r.date.day);
});
function sortKey(r) {
return r.date.year * 10000 + r.date.month * 100 + (monthly ? 0 : r.date.day);
}
rows.sort(function (a, b) { return sortKey(a) - sortKey(b); });
out.entries = rows.length;
if (!rows.length) return out;
rows.forEach(function (r) {
var rx = (Number(r.rx) || 0) * scale;
var tx = (Number(r.tx) || 0) * scale;
out.labels.push(monthly ? monthLabel(r.date.year, r.date.month)
: dayLabel(r.date.year, r.date.month, r.date.day));
out.rx.push(rx);
out.tx.push(tx);
out.totalRx += rx;
out.totalTx += tx;
});
// Always show the current period, even before any traffic was saved.
now = now || new Date();
var cur = monthly ? monthLabel(now.getFullYear(), now.getMonth() + 1)
: dayLabel(now.getFullYear(), now.getMonth() + 1, now.getDate());
if (out.labels.indexOf(cur) < 0) {
out.labels.push(cur);
out.rx.push(0);
out.tx.push(0);
}
return out;
}
var api = { series: series, findInterface: findInterface };
if (typeof module === 'object' && module.exports)
module.exports = api;
else
root.netstatVnstat = api;
})(typeof window !== 'undefined' ? window : this);
@@ -77,17 +77,50 @@ function parseNetdev(raw) {
return stats; return stats;
} }
// The backend resolves the tracked device (Settings choice or default-route
// device). Without one, fall back to the busiest non-loopback interface
// rather than guessing names.
function getBestWAN(stats, preferred) { function getBestWAN(stats, preferred) {
for (const i of preferred) if (stats[i]) return i; for (const i of preferred) if (stats[i]) return i;
const dynamic = Object.keys(stats).find(i => let best = '', bestBytes = -1;
/^(wwan|usb|ppp|lte|qmi|modem)/.test(i) && i.includes('_')); for (const k of Object.keys(stats)) {
if (dynamic) return dynamic; if (k === 'lo') continue;
const bytes = (parseInt(stats[k].rx) || 0) + (parseInt(stats[k].tx) || 0);
if (bytes > bestBytes) { best = k; bestBytes = bytes; }
}
return best;
}
for (const i of ['pppoe-wan','lte0','usb0','wan','eth1','tun0','wg0']) // Totals for the downloaded/uploaded boxes: this month from vnStat when the
if (stats[i]) return i; // vnStat backend is selected, otherwise the since-boot kernel counters.
function getTotals(data, curr) {
const v = data.vnstat_total;
if (v && typeof v === 'object')
return { rx: parseInt(v.rx) || 0, tx: parseInt(v.tx) || 0 };
return curr;
}
return Object.keys(stats).find(k => k !== 'lo') || ''; const STATS_URL = L.url('admin/tools/get_netdev_stats');
// Normalise one backend response into the shape render()/updateContainer() use.
function toData(r, ip) {
return {
stats: (r && r.stats) || {},
ip: ip,
status: (r && r.status) || 'Disconnected',
uptime: (r && r.uptime) || 0,
cpu_pct: (r && r.cpu_pct) || 0,
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
mem_pct: (r && r.mem_pct) || 0,
mem_used: (r && r.mem_used) || 0,
mem_total: (r && r.mem_total) || 0,
disk_pct: (r && r.disk_pct) || 0,
disk_used: (r && r.disk_used) || 0,
disk_total: (r && r.disk_total) || 0,
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : [],
vnstat_total: (r && r.vnstat_total) || null
};
} }
function formatRate(bps) { function formatRate(bps) {
@@ -381,8 +414,9 @@ function updateContainer(container, data, dt) {
const rxRate = formatRate(rxSpeed * 8); const rxRate = formatRate(rxSpeed * 8);
const txRate = formatRate(txSpeed * 8); const txRate = formatRate(txSpeed * 8);
const totalRx = formatBytes(curr.rx).split(' '); const sums = getTotals(data, curr);
const totalTx = formatBytes(curr.tx).split(' '); const totalRx = formatBytes(sums.rx).split(' ');
const totalTx = formatBytes(sums.tx).split(' ');
const peakRxFmt = formatRate(peakRx * 8); const peakRxFmt = formatRate(peakRx * 8);
const peakTxFmt = formatRate(peakTx * 8); const peakTxFmt = formatRate(peakTx * 8);
@@ -457,18 +491,34 @@ function updateContainer(container, data, dt) {
return true; return true;
} }
// ─── Cached public IP (refreshed every 60 s, not on every 2 s poll) ────────── // ─── Cached public IP (refreshed every 5 min, not on every 2 s poll) ─────────
let _cachedIp = 'N/A'; // The public IP (as seen through any proxy such as Passwall) is shown once
// known; the tracked device's local address is only a fallback until then,
// so the 2 s fast polls must never overwrite it.
let _publicIp = null;
let _localIp = 'N/A';
let _lastIpFetch = 0; let _lastIpFetch = 0;
const IP_REFRESH_MS = 300000; const IP_REFRESH_MS = 300000;
const IP_RETRY_MS = 30000;
function shownIp() {
return _publicIp || _localIp;
}
function noteLocalIp(r) {
if (r && r.ip && r.ip !== 'N/A') _localIp = r.ip;
}
function maybeRefreshIp() { function maybeRefreshIp() {
const now = Date.now(); const now = Date.now();
if (now - _lastIpFetch < IP_REFRESH_MS) return; if (now - _lastIpFetch < (_publicIp ? IP_REFRESH_MS : IP_RETRY_MS)) return;
_lastIpFetch = now; _lastIpFetch = now;
// Full fetch (no ?fast=1) to get public IP; update cache on success // Full fetch (no ?fast=1) to get public IP; update cache on success
fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats', 8000) fetchWithTimeout(STATS_URL, 8000)
.then(r => { if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; }) .then(r => {
if (r && r.ip_source === 'public' && r.ip && r.ip !== 'N/A')
_publicIp = r.ip;
})
.catch(() => {}); .catch(() => {});
} }
@@ -478,29 +528,13 @@ return baseclass.extend({
load() { load() {
// Use ?fast=1 to skip slow public-IP APIs — response is near-instant // Use ?fast=1 to skip slow public-IP APIs — response is near-instant
return fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats?fast=1', 5000) return fetchWithTimeout(STATS_URL + '?fast=1', 5000)
.then(r => { .then(r => {
// Seed the cached IP from the local WAN address on first load // Show the local WAN address until the public IP arrives
if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; noteLocalIp(r);
return { return toData(r, shownIp());
stats: (r && r.stats) || {},
ip: _cachedIp,
status: (r && r.status) || 'Disconnected',
uptime: (r && r.uptime) || 0,
cpu_pct: (r && r.cpu_pct) || 0,
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
mem_pct: (r && r.mem_pct) || 0,
mem_used: (r && r.mem_used) || 0,
mem_total: (r && r.mem_total) || 0,
disk_pct: (r && r.disk_pct) || 0,
disk_used: (r && r.disk_used) || 0,
disk_total: (r && r.disk_total) || 0,
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : []
};
}) })
.catch(() => ({ stats: {}, ip: 'N/A', status: 'Disconnected', .catch(() => toData(null, 'N/A'));
uptime: 0, cpu_pct: 0, cpu_temp: null, mem_pct: 0, mem_used: 0, mem_total: 0,
disk_pct: 0, disk_used: 0, disk_total: 0, preferred: [] }));
}, },
render(data) { render(data) {
@@ -538,8 +572,9 @@ return baseclass.extend({
const rxRate = formatRate(rxSpeed * 8); const rxRate = formatRate(rxSpeed * 8);
const txRate = formatRate(txSpeed * 8); const txRate = formatRate(txSpeed * 8);
const totalRx = formatBytes(curr.rx).split(' '); const sums = getTotals(data, curr);
const totalTx = formatBytes(curr.tx).split(' '); const totalRx = formatBytes(sums.rx).split(' ');
const totalTx = formatBytes(sums.tx).split(' ');
// Build download box manually to inject trend badge // Build download box manually to inject trend badge
const dlBox = createRateBox(_('download'), rxRate.number, rxRate.unit, const dlBox = createRateBox(_('download'), rxRate.number, rxRate.unit,
@@ -573,31 +608,17 @@ return baseclass.extend({
L.Poll.add(() => { L.Poll.add(() => {
// Periodically refresh the public IP in the background (non-blocking) // Periodically refresh the public IP in the background (non-blocking)
maybeRefreshIp(); maybeRefreshIp();
return fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats?fast=1', 5000) return fetchWithTimeout(STATS_URL + '?fast=1', 5000)
.then(r => { .then(r => {
const now2 = Date.now(); const now2 = Date.now();
const dt2 = Math.max(0.1, (now2 - last_time) / 1000); const dt2 = Math.max(0.1, (now2 - last_time) / 1000);
last_time = now2; last_time = now2;
// Update cached IP from local WAN if we got a valid one // Keep the local address current as a fallback only
if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; noteLocalIp(r);
if (_container && _container.isConnected) { if (_container && _container.isConnected) {
updateContainer(_container, { updateContainer(_container, toData(r, shownIp()), dt2);
stats: (r && r.stats) || {},
ip: _cachedIp,
status: (r && r.status) || 'Disconnected',
uptime: (r && r.uptime) || 0,
cpu_pct: (r && r.cpu_pct) || 0,
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
mem_pct: (r && r.mem_pct) || 0,
mem_used: (r && r.mem_used) || 0,
mem_total: (r && r.mem_total) || 0,
disk_pct: (r && r.disk_pct) || 0,
disk_used: (r && r.disk_used) || 0,
disk_total: (r && r.disk_total) || 0,
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : []
}, dt2);
} }
}) })
.catch(() => {}); .catch(() => {});
Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.29 PKG_VERSION:=26.10.29
PKG_RELEASE:=356 PKG_RELEASE:=357
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -309,11 +309,31 @@ local com = self.com
//]]> //]]>
</script> </script>
<style>
#app_version > .cbi-value {
display: flex;
flex-wrap: wrap;
align-items: center;
}
#app_version > .cbi-value > .cbi-value-title {
padding-top: 0;
padding-bottom: 0;
line-height: 1.2;
}
#app_version .app-version-sub {
display: block;
font-size: .85em;
font-weight: normal;
opacity: .7;
}
</style>
<div class="cbi-value"> <div class="cbi-value">
<label class="cbi-value-title">Passwall <%:Version%></label> <label class="cbi-value-title">Passwall <%:Version%>
<span class="app-version-sub"><%=api.get_version()%></span>
</label>
<div class="cbi-value-field"> <div class="cbi-value-field">
<!--div class="cbi-value-description"--> <!--div class="cbi-value-description"-->
<span>【 <%=api.get_version()%> 】</span>
<input class="btn cbi-button cbi-button-apply" type="button" id="<%=api.appname%>-check_btn" data-update-app="<%=api.appname%>" <input class="btn cbi-button cbi-button-apply" type="button" id="<%=api.appname%>-check_btn" data-update-app="<%=api.appname%>"
onclick="onBtnClick(this, appname);" value="<%:Check update%>" /> onclick="onBtnClick(this, appname);" value="<%:Check update%>" />
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=api.appname%>-force_btn" data-update-app="<%=api.appname%>" <input class="btn cbi-button cbi-button-apply" type="button" id="_<%=api.appname%>-force_btn" data-update-app="<%=api.appname%>"
@@ -329,10 +349,10 @@ local com = self.com
<div class="cbi-value"> <div class="cbi-value">
<label class="cbi-value-title"><%=v.name%> <label class="cbi-value-title"><%=v.name%>
<%:Version%> <%:Version%>
<span class="app-version-sub"><span class="com_version" key="<%=k%>"><%=translate("Null")%></span></span>
</label> </label>
<div class="cbi-value-field"> <div class="cbi-value-field">
<!--div class="cbi-value-description"--> <!--div class="cbi-value-description"-->
<span>【 <span class="com_version" key="<%=k%>"><%=translate("Null")%></span> 】</span>
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-check_btn" data-update-app="<%=k%>" <input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-check_btn" data-update-app="<%=k%>"
onclick="onBtnClick(this,'<%=k%>');" value="<%:Check update%>" /> onclick="onBtnClick(this,'<%=k%>');" value="<%:Check update%>" />
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-force_btn" data-update-app="<%=k%>" <input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-force_btn" data-update-app="<%=k%>"
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall2 PKG_NAME:=luci-app-passwall2
PKG_VERSION:=26.10.29 PKG_VERSION:=26.10.29
PKG_RELEASE:=157 PKG_RELEASE:=158
PKG_PO_VERSION:=$(PKG_VERSION) PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \ PKG_CONFIG_DEPENDS:= \
@@ -313,6 +313,16 @@ local com = self.com
</script> </script>
<style> <style>
#app_version > .cbi-value {
display: flex;
flex-wrap: wrap;
align-items: center;
}
#app_version > .cbi-value > .cbi-value-title {
padding-top: 0;
padding-bottom: 0;
line-height: 1.2;
}
#app_version .app-version-sub { #app_version .app-version-sub {
display: block; display: block;
font-size: .85em; font-size: .85em;
+9
View File
@@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
+1
View File
@@ -0,0 +1 @@
__pycache__/
+355
View File
@@ -0,0 +1,355 @@
Valid-License-Identifier: GPL-2.0-only
Valid-License-Identifier: GPL-2.0
Valid-License-Identifier: GPL-2.0-or-later
Valid-License-Identifier: GPL-2.0+
SPDX-URL: https://spdx.org/licenses/GPL-2.0.html
Usage-Guide:
To use this license in source code, put one of the following SPDX
tag/value pairs into a comment according to the placement
guidelines in the licensing rules documentation.
For 'GNU General Public License (GPL) version 2 only' use:
SPDX-License-Identifier: GPL-2.0-only
For 'GNU General Public License (GPL) version 2 or any later version' use:
SPDX-License-Identifier: GPL-2.0-or-later
License-Text:
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Library General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Library General
Public License instead of this License.
@@ -0,0 +1,197 @@
/* Licensed to the public under the Apache License 2.0. */
/* Graph definitions for the data `wwandctl collectd` feeds into collectd.
*
* luci-app-statistics LISTS this directory at runtime and loads every .js it
* finds (statistics/rrdtool.js:144,166), so this ships from luci-app-wwand
* without patching anything over there.
*
* One graph per QUANTITY, never one per unit. RSRP and RSSI are both dBm and
* mean different things — the serving cell's own power against the whole band,
* with ladders 20 dB apart — and sharing an axis leaves the stronger one
* flattened against the edge. That mistake was made once already in the live
* graph and is not repeated here. Within a graph there is one line per radio
* technology, and the colours are the live graph's, so the same radio is the
* same colour in both places.
*/
'use strict';
'require baseclass';
const RAT = {
lte: '0069d9', /* blue */
nr5g: '8e44ad', /* purple */
wcdma:'17a2b8', /* teal */
gsm: '6c757d', /* grey */
any: 'e0a800', /* amber, the band-wide fallback */
};
const LABEL = { lte: 'LTE', nr5g: '5G', wcdma: '3G', gsm: '2G' };
return baseclass.extend({
title: _('Cellular modem'),
rrdargs(graph, host, plugin, plugin_instance, dtype) {
/* What this modem actually recorded. Asked rather than assumed: an
LTE-only modem has no 5G instances, and naming one anyway would point
rrdtool at a file that does not exist — `instances` is taken verbatim
(rrdtool.js:551). */
const have = (type) => graph.dataInstances(host, plugin, plugin_instance, type) || [];
const series = (type, insts, prefix, unit) => {
const options = {};
for (const inst of insts) {
const rat = inst.slice(prefix.length).replace(/^_/, '');
const key = `${type}_${inst.replace(/\W/g, '_')}_value`;
options[key] = {
title: rat.length ? `${unit} ${LABEL[rat] || rat}` : unit,
color: RAT[rat] || RAT.any,
noarea: true,
overlay: true,
};
}
return { instances: { [type]: insts }, options: options };
};
const pick = (type, re) => have(type).filter((i) => re.test(i));
const out = [];
/* serving-cell power: RSRP, and RSCP where a 3G modem reports one */
const rsrp = pick('signal_power', /^rsrp_/);
if (rsrp.length)
out.push({
title: '%H: Signal strength on %pi',
vlabel: 'dBm',
number_format: '%5.1lf dBm',
data: series('signal_power', rsrp, 'rsrp', 'RSRP'),
});
/* band-wide power — its own graph, and its own scale: the RSSI ladder
sits about 20 dB above the RSRP one */
const rssi = pick('signal_power', /^rssi/);
if (rssi.length)
out.push({
title: '%H: Band power (RSSI) on %pi',
vlabel: 'dBm',
number_format: '%5.1lf dBm',
data: series('signal_power', rssi, 'rssi', 'RSSI'),
});
/* quality: RSRQ, and Ec/Io on 3G — both dB, both negative */
const rsrq = pick('signal_power', /^(rsrq|ecio)/);
if (rsrq.length)
out.push({
title: '%H: Signal quality on %pi',
vlabel: 'dB',
number_format: '%5.1lf dB',
data: series('signal_power', rsrq, 'rsrq', 'RSRQ'),
});
/* SINR is a `gauge`, not signal_quality: it runs roughly -20..+30 dB and
collectd's signal_quality has a floor of 0, which would discard every
negative reading. Negative SINR is not exotic — a sponsor box reported
-2.5 dB and -0.8 dB on two modems at once. */
const sinr = pick('gauge', /^sinr_/);
if (sinr.length)
out.push({
title: '%H: SINR on %pi',
vlabel: 'dB',
number_format: '%5.1lf dB',
data: series('gauge', sinr, 'sinr', 'SINR'),
});
/* Aggregation, which is why anyone wants this in an RRD at all: the live
graph shows carriers and bandwidth for as long as a browser is open,
and the question people actually have ("does the second carrier come
back at night?") is about the hours it was not. Two graphs, not one —
a count runs 1..6 and a bandwidth 5..200, and an axis carrying both
flattens the count onto the baseline.
The instance names are `carriers_lte` / `carriers_nr`, so the RAT
suffix is `nr` here where the signal series use `nr5g`; both map to
the same purple through the lookup below. */
const RAT_AGG = { lte: RAT.lte, nr: RAT.nr5g };
const LABEL_AGG = { lte: 'LTE', nr: '5G' };
const aggSeries = (insts, prefix, unit) => {
const options = {};
for (const inst of insts) {
const rat = inst.slice(prefix.length).replace(/^_/, '');
options[`gauge_${inst.replace(/\W/g, '_')}_value`] = {
title: `${unit} ${LABEL_AGG[rat] || rat}`,
color: RAT_AGG[rat] || RAT.any,
noarea: true,
overlay: true,
};
}
return { instances: { gauge: insts }, options: options };
};
const carriers = pick('gauge', /^carriers_/);
if (carriers.length)
out.push({
title: '%H: Aggregated carriers on %pi',
vlabel: 'Carriers',
number_format: '%5.0lf',
data: aggSeries(carriers, 'carriers', 'Carriers'),
});
const bwidth = pick('gauge', /^bandwidth_/);
if (bwidth.length)
out.push({
title: '%H: Aggregate bandwidth on %pi',
vlabel: 'MHz',
number_format: '%5.0lf MHz',
data: aggSeries(bwidth, 'bandwidth', 'MHz'),
});
if (have('temperature').length)
out.push({
title: '%H: Modem temperature on %pi',
vlabel: 'C',
number_format: '%5.1lf C',
data: {
types: [ 'temperature' ],
options: { temperature: { title: 'Temperature', color: 'ff6600', noarea: true } },
},
});
/* state and the recovery counters on one axis: `registered` and
`connected` are 0/1, and a rising `attempts` beside a flat 0 is the
picture of an outage nobody was watching */
const state = pick('gauge', /^(registered|connected|attempts|proto_errors)$/);
if (state.length) {
const options = {};
for (const inst of state)
options[`gauge_${inst}_value`] = {
title: inst,
color: inst == 'registered' || inst == 'connected' ? '00a000' : 'cc0000',
noarea: true,
overlay: true,
};
out.push({
title: '%H: State on %pi',
vlabel: 'Count',
number_format: '%5.0lf',
data: { instances: { gauge: state }, options: options },
});
}
return out;
}
});
@@ -0,0 +1,807 @@
'use strict';
'require view';
'require form';
'require ui';
'require uci';
'require wwand.modemopts as modemopts';
'require wwand.simlist as simlist';
'require wwand.rpc as wrpc';
'require wwand.format as fmt';
/* Network → Modems: interfaces-style overview. One row per configured
wwand_modem with live status columns (state, SIM, registration, signal) and
per-row actions: Config (the modal uci editor, shared wwand.modemopts) and
Tools (jumps to the Modem Tools page for that modem). Detected-but-
unconfigured control devices (ubus modem_probe `present` without
`managed_by`) are listed below with a one-click Configure. The per-ICCID/IMSI
wwand_sim override list (shared wwand.simlist) closes the page. */
/* ubus declarations live in the shared wwand.rpc module */
var callStatus = wrpc.status;
var callProbe = wrpc.probe;
var callSignal = wrpc.signal;
var callSlots = wrpc.slots;
var callContexts = wrpc.contexts;
/* compact registration/SIM mappings live in the shared wwand.format module.
BOUND, not bare references. Both of these compose siblings through `this`
— and a detached method in a strict-mode module gets `this === undefined`,
so the column throws instead of degrading. The alias style is fine; it just
has to survive a method that is not standalone.
fmtReg was NOT bound and shipped that way. It does not throw for everyone,
which is why it lasted: fmtRegistration only reaches `this.fmtMnc(...)` for
a registered numeric PLMN with no usable description — so a network that
reports its name renders fine, and the Registration column of one that does
not throws on every draw (openwrt/packages#37, format.js:651, 2026-09-21).
The unit tests missed it because they call fmt.fmtRegistration() WITH a
receiver, which the view does not. `tools/check-detached-methods.js` now
refuses this shape, because this comment alone already failed to prevent it
once — it sat directly below the defect it describes. */
var fmtReg = fmt.fmtRegistration.bind(fmt);
var fmtSim = fmt.fmtSim.bind(fmt);
/* An RSRP is whole dBm as it comes off the QMI signal TLV, but carries one
decimal once the daemon has overlaid the serving cell's own measurement
(wwand modem_common.overlay_serving_signal, 2026-09-21). `%d` floors — it
would render -60.9 as -61 and quietly throw the finer reading away
(cbi.js:753-754), so the value is printed to whatever precision it has. */
function dbm(v) { return (v == Math.trunc(v)) ? String(v) : v.toFixed(1); }
function fmtSignal(sig) {
var parts = [];
if (sig && sig.lte && fmt.hasSignal(sig.lte.rsrp))
parts.push('LTE %s dBm'.format(dbm(sig.lte.rsrp)));
if (sig && sig.nr5g && fmt.hasSignal(sig.nr5g.rsrp))
parts.push('NR %s dBm'.format(dbm(sig.nr5g.rsrp)));
return parts.length ? parts.join(' / ') : '-';
}
function fmtState(mi) {
if (!mi)
return _('not running');
return mi.state + (mi.control_note ? ' (' + mi.control_note + ')' : '');
}
return view.extend({
load: function() {
return Promise.all([
uci.load('network'),
L.resolveDefault(callStatus(), {}),
L.resolveDefault(callProbe(), {}),
L.resolveDefault(callContexts(), {}),
]).then(function(r) {
var status = r[1] || {};
var names = Object.keys(status);
/* one signal AND one slot read per modem. The fan-out doubles, but
load() runs ONCE per page view — this table does not poll — so
the cost is one extra round trip per modem on opening the page,
not one per second. That buys the SIM column the thing it could
not say before: which slot is live and whether it holds an eSIM. */
return Promise.all([
Promise.all(names.map(function(n) { return L.resolveDefault(callSignal(n), {}); })),
Promise.all(names.map(function(n) { return L.resolveDefault(callSlots(n), {}); })),
]).then(function(both) {
var signals = {}, slots = {};
names.forEach(function(n, i) {
signals[n] = both[0][i] || {};
slots[n] = both[1][i] || {};
});
return { status: status, probe: r[2] || {}, contexts: r[3] || {},
signals: signals, slots: slots };
});
});
},
render: function(data) {
var status = data.status || {};
var signals = data.signals || {};
var simSlots = data.slots || {};
var contexts = data.contexts || {};
/* per-modem connection counts from the context map (keyed by interface;
each entry carries .modem and .state). */
var conns = {};
Object.keys(contexts).forEach(function(cn) {
var c = contexts[cn] || {}, mn = c.modem;
if (!mn) return;
conns[mn] = conns[mn] || { up: 0, total: 0 };
conns[mn].total++;
if (c.state == 'CONNECTED') conns[mn].up++;
});
var m = new form.Map('network', _('Mobile Modems'),
_('Cellular modems managed by wwand. Each modem is referenced from an interface (Network → Interfaces, protocol "Cellular / 5G") by its name; several interfaces can share one modem through different mux channels. "Config" edits the modem, "Tools" opens band/operator/SIM/eSIM/SMS tools for it.'));
var s = m.section(form.GridSection, 'wwand_modem', _('Modems'),
_('Add a modem here, then point an interface at it with the "Modem" field on the interface page.'));
s.addremove = true;
s.anonymous = false;
s.addbtntitle = _('Add modem');
s.nodescriptions = false;
s.modaltitle = function(section_id) { return _('Modem') + ' ' + section_id; };
s.tab('modem', _('Modem & SIM'), _('Which modem, and its SIM.'));
s.tab('radio', _('Radio & Cell'), _('Radio technology, manual operator selection and cell lock.'));
s.tab('resilience', _('Resilience'), _('Recovery, watchdogs and telemetry cadence.'));
/* on this page the edited section IS the wwand_modem — direct storage */
var direct = function(o) { return o; };
var o = s.taboption('modem', form.Value, 'device', _('Modem device'),
_('Network device name (e.g. wwan0), a mux parent, or a control node (/dev/cdc-wdm0). Leave empty and set only the USB path to bind purely by topology. <em>qrtr</em> drives a Qualcomm modem on PCIe/MHI whose QMI is only on the QRTR bus (no cdc-wdm); its data device is the interface\'s L3 device.'));
o.value('qrtr', _('qrtr — QMI over the QRTR bus (PCIe/MHI)'));
/* only meaningful for device 'qrtr': which node, when more than one
serves QMI DMS and WDS (the daemon's default is the first that does) */
o = s.taboption('modem', form.Value, 'qrtr_node', _('QRTR node'),
_('The QRTR node of this modem, for a box with more than one QRTR modem. Leave empty to use the first node that serves QMI DMS and WDS.'));
o.datatype = 'uinteger';
o.depends('device', 'qrtr');
modemopts.addModemSim(s, 'modem', direct);
/* The data endpoint override lives HERE and not in the shared
modemopts: through the interface form's bindModem() a cleared field
is never removed from the modem section (modemsid.js remove()), so
"automatic" could not be chosen back there. Empty means the daemon
derives both from the data netdev's bus (USB: the QMI function's
interface; PCIe: type 3, interface 4). */
o = s.taboption('modem', form.ListValue, 'ep_type', _('Data endpoint type'),
_('Override the QMI data endpoint type sent with the data format and mux binding. Leave on automatic unless a modem refuses the derived endpoint.'));
o.value('', _('automatic'));
o.value('2', _('2 — USB'));
o.value('3', _('3 — PCIe'));
o.value('4', _('4 — embedded'));
o.optional = true;
o = s.taboption('modem', form.Value, 'ep_id', _('Data endpoint interface'),
_('Override the endpoint interface number (USB: the QMI function\'s interface, PCIe: 4). Leave empty for automatic.'));
o.datatype = 'uinteger';
o.optional = true;
modemopts.addRadio(s, 'radio', direct);
modemopts.addCellLock(s, 'radio', direct);
modemopts.addResilience(s, 'resilience', direct);
/* the whole config form lives in the Config modal — keep the table to
the live status columns added below */
s.children.forEach(function(o) { o.modalonly = true; });
/* `hint` becomes the column header's tooltip, via the same fmt.term()
the Status page uses for its row labels. A grid column has room for
one or two words and no room to say what they mean, which is why
these seven headers carried no explanation at all. */
var col = function(name, title, fn, hint) {
var o = s.option(form.DummyValue, name,
hint ? fmt.term(title, hint) : title);
o.modalonly = false;
o.write = function() {};
o.remove = function() {};
o.cfgvalue = function(section_id) { return fn(section_id); };
return o;
};
col('_dev', _('Device'), function(sid) {
var mi = status[sid];
var dev = (mi && mi.netdev) || uci.get('network', sid, 'device') || '?';
return dev + ((mi && mi.model) ? ' (' + mi.model + ')' : '');
},
_('The kernel network device the modem\'s data runs on, with the model behind it. A muxed connection names the child device (wwan0m1), not the parent.'));
col('_backend', _('Backend'), function(sid) {
var mi = status[sid];
return (mi && mi.protocol) ? mi.protocol.toUpperCase() : '—';
},
_('The control protocol wwand speaks to this modem: QMI, MBIM or NCM. Set by `option protocol`, or detected from the control device.'));
col('_conns', _('Connections'), function(sid) {
var c = conns[sid];
if (!c || !c.total) return '—';
return (c.up == c.total) ? '%d'.format(c.up) : '%d / %d'.format(c.up, c.total);
},
_('How many configured interfaces use this modem. Several can share one modem through different mux channels.'));
col('_state', _('State'), function(sid) { return fmtState(status[sid]); },
_('Where the modem is in its bring-up: from ABSENT through SIM_UNLOCK and REGISTERING to READY. A state that does not advance is what the log explains.'));
col('_sim', _('SIM'), function(sid) { return fmtSim(status[sid], simSlots[sid]); },
_('The card the modem is using: its readiness, which physical slot is live, and whether that slot holds an eUICC (eSIM).'));
col('_reg', _('Registration'), function(sid) { return fmtReg(status[sid]); },
_('The network the modem is registered on, or why it is not. A reject cause here is the network refusing, not the modem failing.'));
col('_sig', _('Signal'), function(sid) { return fmtSignal(signals[sid]); },
_('The current signal, per radio technology. RSRP is reception strength, RSRQ quality, SNR the noise margin — RSRP alone does not say whether the link is good.'));
/* the currently-used dial params + PIN for a modem, reconstructed from
UCI (the effective config is resolved only at dial time and is not on
the status bus): PIN from the wwand_modem default, APN/auth/credentials
from the first interface that points at this modem. Feeds the one-click
"Save SIM" (per-ICCID override) button below. */
var simParamsFrom = function(sid) {
var out = {};
var pin = uci.get('network', sid, 'pincode');
if (pin) out.pincode = pin;
var iface = null;
uci.sections('network', 'interface', function(s) {
if (!iface && s.modem == sid &&
(s.proto == 'wwand' || s.proto == 'qmi' || s.proto == 'mbim' || s.proto == 'ncm'))
iface = s;
});
if (iface)
[ 'apn', 'auth', 'username', 'password' ].forEach(function(k) {
if (iface[k] != null && iface[k] !== '') out[k] = iface[k];
});
return out;
};
/* row actions: Config (the native modal editor) + Status/Tools jumps */
s.renderRowActions = function(section_id) {
var td = form.GridSection.prototype.renderRowActions.call(this, section_id, _('Config'));
var box = td.firstElementChild || td;
/* This cell used to hold up to nine buttons — Config, Status, Tools,
Unlock SIM, Save SIM, Reattach, Reboot, Repower and the trailing
Delete — and LuCI's actions column does not wrap, so on a modem
that offered them all the row ran off the right edge of the table
(field report with a screenshot, RUTM11). Everything from Tools on
now lives in one dropdown (below), which leaves four. The wrapping
flex row stays as the backstop for a narrow column: the buttons
keep their theme styling and stay right-aligned, they just fold
onto a second line rather than overflowing. */
box.style.display = 'flex';
box.style.flexWrap = 'wrap';
box.style.justifyContent = 'flex-end';
box.style.gap = '.25em';
var jump = function(label, title, url) {
return E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'title': title,
'click': function(ev) {
ev.preventDefault();
window.location = url + '?modem=' + encodeURIComponent(section_id);
},
}, label);
};
/* The daemon answers a refused hardware step with a token, and the
token used to reach the user verbatim — "Repower unavailable:
multi_modem_needs_reset_gpio." (seen on the Chateau, which carries
two modems on one board power line, 2026-09-12). The refusal is
correct and worth reading: cutting that line would drop the OTHER
modem too. Say so. An unmapped token still falls through, because
a raw token beats no reason at all. */
var HW_REASON = {
multi_modem_needs_reset_gpio: _('this board powers both modems from one line, so cutting it would drop the other modem too — give this modem its own "Modem reset GPIO" to repower it alone'),
no_board_profile: _('this board is not in wwand\'s profile table, so it has no known power or reset line'),
no_power_control: _('the board profile has no modem power line'),
reset_gpio_unavailable: _('the configured reset GPIO could not be driven'),
no_such_modem: _('the daemon does not know this modem'),
no_reset_control: _('no reset control for this modem'),
};
var hwReason = function(res, fallback) {
var e = res && res.error;
return e ? (HW_REASON[e] || e) : fallback;
};
/* Reboot: the modem's own soft reset first, the reset GPIO only as
the fallback (ubus modem_reset; ddimension/openwrt-repo#4).
Confirm first — it drops this modem's connection(s) briefly. */
var reboot = E('button', {
'class': 'btn cbi-button cbi-button-negative',
'title': _('Reset/reboot this modem: its own soft reset first, so it can shut down cleanly; a configured reset GPIO is pulsed only if the modem does not reboot. Its connections drop briefly and recover on their own.'),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
if (!confirm(_('Reset modem "%s" now? Its connection(s) will drop briefly and recover automatically.').format(section_id)))
return;
return wrpc.modemReset(section_id).then(function(res) {
if (res && (res.ok || res.resetting))
ui.addNotification(null, E('p', {}, [ _('Modem reset triggered (%s).').format(res.action || '?') ]), 'info');
else
ui.addNotification(null, E('p', {}, [ _('Reset unavailable: %s.')
.format(hwReason(res, _('no reset control'))) ]), 'warning');
});
}),
}, _('Reboot'));
/* Reinit SIM: power the card in the active slot off and on and
re-read it (ubus modem_sim_reinit) — for a card swapped while
the modem runs, without the modem reboot that used to be the
only way (ddimension/openwrt-repo#3). */
var reinitSim = E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'title': _('Re-initialise the SIM card in the active slot: power it off and on and read it again, e.g. after swapping the card. The connection drops and recovers on its own; not a modem reset.'),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
if (!confirm(_('Re-initialise the SIM of modem "%s" now? Its connection drops and recovers automatically.').format(section_id)))
return;
return wrpc.modemSimReinit(section_id).then(function(res) {
if (res && res.slot != null)
ui.addNotification(null, E('p', {}, [ _('SIM in slot %d re-initialised.').format(res.slot) ]), 'info');
else
ui.addNotification(null, E('p', {}, [ _('SIM re-initialisation failed: %s.').format((res && res.error) || '?') ]), 'warning');
});
}),
}, _('Reinit SIM'));
/* Repower: the HARDWARE rung — reset-GPIO pulse or board power-cycle
(ubus modem_repower). Recovers a hung or vanished modem where the
soft reset can't reach it. Multi-modem boxes need a per-modem
reset_gpio; the daemon surfaces that error here. */
var repower = E('button', {
'class': 'btn cbi-button cbi-button-negative',
'title': _('Hardware repower: pulse the reset GPIO (or power-cycle the modem) — recovers a hung or vanished modem. Needs a reset_gpio on multi-modem boxes.'),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
if (!confirm(_('Hard-repower modem "%s" now? This cuts and restores the modem\'s power/reset line.').format(section_id)))
return;
return wrpc.modemRepower(section_id).then(function(res) {
if (res && res.ok)
ui.addNotification(null, E('p', {}, [ _('Modem repowered (%s).').format(res.action || '?') ]), 'info');
else
ui.addNotification(null, E('p', {}, [ _('Repower unavailable: %s.')
.format(hwReason(res, _('no power/reset control'))) ]), 'warning');
});
}),
}, _('Repower'));
/* Reattach: force a network deregister + re-register (QMI opmode
bounce, else AT+COPS) so automatic selection re-scans — for a
modem stuck on a previously-selected PLMN. Lighter than Reboot:
no modem reset, the PDP context re-establishes on its own. */
var reattach = E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'title': _('Force a network re-registration (deregister + re-attach) so automatic selection re-scans. Use when the modem stays on a network you manually selected earlier. The connection blips and recovers on its own; not a modem reset.'),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
if (!confirm(_('Re-attach modem "%s" to the network now? It briefly deregisters and re-registers; the connection recovers automatically.').format(section_id)))
return;
return wrpc.modemReattach(section_id).then(function(res) {
if (res && res.ok)
ui.addNotification(null, E('p', {}, [ _('Network re-attach triggered (%s).').format(res.via || '?') ]), 'info');
else
ui.addNotification(null, E('p', {}, [ _('Re-attach unavailable: %s.').format((res && res.error) || '?') ]), 'warning');
});
}),
}, _('Reattach'));
/* Save SIM: capture this card's live ICCID + the modem's current
PIN/APN/auth into a per-ICCID wwand_sim override (the list below),
so a swapped-in card keeps its own PIN/APN. Only when a card with a
readable ICCID is present, and refused if one already exists. */
var mi = status[section_id] || {};
var liveIccid = mi.iccid ? ('' + mi.iccid).replace(/[^0-9]+$/, '') : '';
var saveSim = liveIccid ? E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'title': _('Create a per-ICCID SIM override for the inserted card (ICCID %s), pre-filled with the modem\'s current PIN and the interface APN/auth. Edit it in the SIMs list below, then Save & Apply.').format(liveIccid),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
var dup = null;
uci.sections('network', 'wwand_sim', function(sc) {
var ic = ('' + (sc.iccid || '')).replace(/[^0-9]+$/, '');
if (ic && ic == liveIccid) dup = sc['.name'];
});
if (dup) {
ui.addNotification(null, E('p', {}, [ _('A SIM override for ICCID %s already exists (section %s) — edit it in the SIMs list below.').format(liveIccid, dup) ]), 'warning');
return;
}
var p = simParamsFrom(section_id);
ui.addNotification(null, E('p', {}, [ _('Created SIM override for ICCID %s — review it in the SIMs list below, then Save & Apply.').format(liveIccid) ]), 'info');
/* stage in the save callback — see the Configure button:
parse() would strip options of a not-yet-rendered row */
return m.save(function() {
var nsid = uci.add('network', 'wwand_sim');
uci.set('network', nsid, 'iccid', liveIccid);
Object.keys(p).forEach(function(k) { uci.set('network', nsid, k, p[k]); });
}, true);
}),
}, _('Save SIM')) : null;
/* Unlock SIM: shown only while the daemon reports a SIM block.
PUK-class blocks (puk_required / retries_exhausted) get a PUK +
new-PIN dialog (ubus modem_sim_puk — a wrong PUK consumes one of
~10 attempts, 0 left destroys the SIM, hence the loud warning);
PIN-class blocks get the manual PIN release (modem_sim_pin_verify,
entering past the low-retry safety guard). */
var unlockSim = mi.sim_block ? E('button', {
'class': 'btn cbi-button cbi-button-apply',
'title': _('Unlock the blocked SIM (%s)').format(mi.sim_block.reason || '?'),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
var reason = mi.sim_block.reason || '';
var isPuk = (reason == 'puk_required' || reason == 'retries_exhausted');
var fields = isPuk ? [
E('p', { 'class': 'alert-message warning' },
[ _('This SIM is PUK-locked (%s). A WRONG PUK consumes one of ~10 attempts — after the last one the SIM is permanently destroyed. The PUK is printed on the SIM carrier or available from the provider.').format(reason) ]),
E('p', {}, [ _('PUK (8 digits)'), E('br'), E('input', { 'id': 'wwand-puk', 'type': 'text', 'maxlength': 8, 'class': 'cbi-input-text' }) ]),
E('p', {}, [ _('New PIN (4-8 digits)'), E('br'), E('input', { 'id': 'wwand-newpin', 'type': 'text', 'maxlength': 8, 'class': 'cbi-input-text' }) ]),
] : [
E('p', {}, [ _('The daemon refuses to auto-enter the PIN (%s) to protect the last attempts. Enter the PIN to release it manually.').format(reason) ]),
E('p', {}, [ _('PIN'), E('br'), E('input', { 'id': 'wwand-pin', 'type': 'text', 'maxlength': 8, 'class': 'cbi-input-text' }) ]),
];
ui.showModal(isPuk ? _('Enter PUK — SIM %s').format(mi.iccid || section_id)
: _('Release PIN — SIM %s').format(mi.iccid || section_id),
fields.concat([ E('div', { 'class': 'right' }, [
E('button', { 'class': 'btn', 'click': ui.hideModal }, _('Cancel')),
' ',
E('button', { 'class': 'btn cbi-button cbi-button-negative',
'click': ui.createHandlerFn(this, function() {
var done = function(res, okText) {
ui.hideModal();
if (res && res.ok)
ui.addNotification(null, E('p', {}, [ okText ]), 'info');
else
ui.addNotification(null, E('p', {},
[ _('Unlock failed: %s').format((res && (res.error + (res.detail ? ' (' + JSON.stringify(res.detail) + ')' : ''))) || '?') ]), 'error');
};
if (isPuk) {
var puk = (document.getElementById('wwand-puk').value || '').trim();
var npin = (document.getElementById('wwand-newpin').value || '').trim();
if (!/^[0-9]{8}$/.test(puk)) { ui.addNotification(null, E('p', {}, _('The PUK must be exactly 8 digits.')), 'error'); return; }
if (!/^[0-9]{4,8}$/.test(npin)) { ui.addNotification(null, E('p', {}, _('The new PIN must be 4-8 digits.')), 'error'); return; }
return wrpc.simPuk(section_id, puk, npin).then(function(res) {
done(res, _('SIM unblocked, new PIN set. Update the configured PIN in the modem settings!'));
});
}
var pin = (document.getElementById('wwand-pin').value || '').trim();
return wrpc.pinVerify(section_id, pin).then(function(res) {
done(res, _('PIN release requested — the modem restarts its bring-up.'));
});
}),
}, isPuk ? _('Unblock SIM') : _('Enter PIN')),
]) ]));
}),
}, _('Unlock SIM')) : null;
/* Detach / Attach: one entry that flips with the modem's state
(status `detached`). Detach ends the data sessions first, then
detaches from the packet network with the radio on, and the modem
STAYS detached — its interfaces wait (DETACHED) — until Attach or
a modem reset. */
var isDetached = !!mi.detached;
var detachAttach = E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'title': isDetached
? _('Attach the modem to the network again; its interfaces reconnect on their own.')
: _('Stop the data sessions and detach the modem from the network (radio stays on). It stays detached until you attach it again or the modem is reset.'),
'click': ui.createHandlerFn(this, function(ev) {
ev.preventDefault();
if (!isDetached && !confirm(_('Detach modem "%s" from the network? Its connections stop and stay down until you attach it again.').format(section_id)))
return;
var call = isDetached ? wrpc.modemAttach : wrpc.modemDetach;
return call(section_id).then(function(res) {
if (res && res.ok)
ui.addNotification(null, E('p', {}, [ (isDetached ? _('Modem attached (%s).') : _('Modem detached (%s).')).format(res.via || '?') ]), 'info');
else
ui.addNotification(null, E('p', {}, [ (isDetached ? _('Attach failed: %s.') : _('Detach failed: %s.')).format((res && res.error) || '?') ]), 'warning');
});
}),
}, isDetached ? _('Attach') : _('Detach'));
/* Everything from Tools on goes into ONE dropdown, so the row shows
Config, Status, the menu and Delete instead of up to nine buttons
competing for a column that does not wrap. Config and Status stay
outside because they are the two anyone reaches for daily; the
rest are occasional, and three of them ask for confirmation
anyway.
The buttons themselves are unchanged and simply never inserted
into the page — picking an entry clicks the corresponding one, so
every confirm and notification keeps working exactly as it did
when it had its own button.
The BUSY STATE is the exception, and it has to be re-hung here:
ui.createHandlerFn puts `spinning` and `disabled` on the click's
own currentTarget (ui.js:5683-5684), which for a programmatic
click is the detached button nobody can see. So Reattach/Reboot/
Repower ran with no feedback and the menu stayed selectable —
a modem reboot could be fired twice by accident. The dropdown
node carries it instead, for as long as the action runs. */
var menu = {}, acts = {};
var add = function(key, label, el) {
if (!el) return;
menu[key] = label;
acts[key] = el;
};
add('tools', _('Tools'),
jump(_('Tools'), _('Band, operator, SIM, eSIM and SMS tools for this modem'),
L.url('admin/network/wwand-tools')));
add('unlock', _('Unlock SIM'), unlockSim);
add('savesim', _('Save SIM'), saveSim);
add('reinitsim', _('Reinit SIM'), reinitSim);
add('reattach', _('Reattach'), reattach);
add('detach', isDetached ? _('Attach') : _('Detach'), detachAttach);
add('reboot', _('Reboot'), reboot);
add('repower', _('Repower'), repower);
var dd = new ui.Dropdown(null, menu, {
optional: true,
/* explicit order: the default sorts alphabetically, which would
put Repower and Reboot above Tools */
sort: Object.keys(menu),
select_placeholder: _('Actions'),
display_items: 1,
dropdown_items: -1,
});
var ddNode = dd.render();
var acting = false, busy = false;
ddNode.addEventListener('cbi-dropdown-change', function(ev) {
if (acting || busy)
return;
/* detail.value is the selected ITEM ({text, value, element}),
not the key. Note dd.getValue() is no use here: the base
accessor only reads `select`/`input` nodes and a rendered
dropdown is a div, so it always answers null. */
var sel = ev.detail && ev.detail.value;
var el = (sel && sel.value) ? acts[sel.value] : null;
/* This is a menu, not a stored setting, so put it back to the
placeholder — otherwise the entry just used stays displayed
and cannot be picked a second time. setValues() is LuCI
internal (it is what clearChoices(true) uses); it re-selects
the placeholder item, and re-fires this event, hence the
guard. */
acting = true;
dd.setValues(dd.node, {});
acting = false;
if (!el)
return;
/* Busy state on the node the user is actually looking at.
`disabled` on a cbi-dropdown only STYLES it — handleClick
(ui.js) never consults the attribute — so the `busy` flag
above is what actually blocks a second pick; the attribute
is there to make the block visible.
Completion is observed on the hidden button, because
createHandlerFn's `disabled`/`spinning` pair on it is the one
signal the click leaves behind: it does not hand back the
promise. Capped, so a handler that never re-enables its
button cannot leave the menu stuck for good. */
busy = true;
ddNode.classList.add('spinning');
ddNode.setAttribute('disabled', '');
var done = function() {
busy = false;
ddNode.classList.remove('spinning');
ddNode.removeAttribute('disabled');
};
el.click();
if (!el.disabled)
return done();
var waited = 0;
var poll = setInterval(function() {
waited += 150;
if (el.disabled && waited < 120000)
return;
clearInterval(poll);
done();
}, 150);
});
var extra = [
jump(_('Status'), _('Live status page (signal, cells, connection) for this modem'),
L.url('admin/status/wwand')),
ddNode,
];
/* between Config and the trailing Delete button when present */
extra.forEach(function(b) {
if (box.lastElementChild)
box.insertBefore(b, box.lastElementChild);
else
box.appendChild(b);
});
return td;
};
/* Deleting a modem is not like deleting a row in a list: every interface
that names it stops having a modem, and the connection those carry
goes with it on the next Save & Apply. The button sits next to
Config, one position away from where the mouse already is, and LuCI
removes without asking — so ask, and say what is attached. Overriding
handleRemove rather than the button covers every path that removes a
section, not just this one click. */
s.handleRemove = function(section_id /*, ev */) {
var used = [];
uci.sections('network', 'interface', function(sc) {
if (sc.proto == 'wwand' && sc.modem == section_id)
used.push(sc['.name']);
});
var msg = used.length
? _('Delete modem "%s"? It is still used by: %s. Those interfaces lose their modem and their connection when you Save & Apply.')
.format(section_id, used.join(', '))
: _('Delete modem "%s"?').format(section_id);
if (!confirm(msg))
return Promise.resolve();
return form.GridSection.prototype.handleRemove.apply(this, arguments);
};
/* per-ICCID/IMSI SIM overrides (PIN/APN), shared wwand.simlist */
var simSec = simlist.addSimList(m, {});
/* detected control devices without a wwand_modem section — offer a
one-click Configure that stages a prefilled section (saved with the
page's Save & Apply; wwand adopts the modem on config reload) */
var present = (data.probe && data.probe.present) || [];
var loose = present.filter(function(p) { return !p.managed_by; });
var detected = '';
if (loose.length) {
var nextName = function() {
var i = 0;
while (uci.get('network', 'wwmodem' + i))
i++;
return 'wwmodem' + i;
};
detected = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Detected modems (unconfigured)')),
E('p', {}, _('Control devices present on the system without a modem configuration. Configure stages a prefilled modem section — press Save & Apply afterwards, then attach an interface to it (or let autosetup handle it on an unconfigured system).')),
E('table', { 'class': 'table' },
[ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Type')),
E('th', { 'class': 'th' }, _('Device')),
E('th', { 'class': 'th' }, _('Path')),
E('th', { 'class': 'th' }, _('Serial')),
E('th', { 'class': 'th' }, ''),
]) ].concat(loose.map(function(p) {
var dev = p.device || p.netdev || '?';
var path = p.path || p.usb_path;
return E('tr', { 'class': 'tr' }, [
/* arrays: device, path and serial come from the probe,
i.e. from sysfs and the modem's USB descriptors */
E('td', { 'class': 'td' }, [ (p.protocol || p.kind || '?').toUpperCase() ]),
E('td', { 'class': 'td' }, [ dev ]),
E('td', { 'class': 'td' }, [ path || '-' ]),
E('td', { 'class': 'td' }, [ p.serial || '-' ]),
E('td', { 'class': 'td' },
E('button', { 'class': 'btn cbi-button cbi-button-apply',
'click': function(ev) {
/* Persist immediately. Staging the new
section via m.save() lets the page's next
parse() strip its options — parse() drops
staged options of sections without a
rendered row (isActive is a DOM check) — so
the path/device binding is lost on the
subsequent Save & Apply. add/set + save() +
apply() writes it straight to config,
bypassing the Map parse cycle. */
var btn = ev.target;
btn.disabled = true;
btn.textContent = _('configuring…');
var name = nextName();
uci.add('network', 'wwand_modem', name);
/* bind by stable sysfs path (survives
USB enumeration order); device name
only as last resort */
if (path)
uci.set('network', name, 'path', path);
else
uci.set('network', name, 'device', dev);
return uci.save().then(function() {
return uci.apply();
}).then(function() {
location.reload();
}).catch(function(e) {
btn.disabled = false;
btn.textContent = _('Configure');
ui.addNotification(null, E('p', {},
[ _('Could not configure modem: %s').format(e) ]));
});
} }, _('Configure'))),
]);
}))),
]);
}
/* interfaces still on a stock cellular proto (qmi/mbim/ncm) with no
`option modem` — wwand does not manage these until they are migrated.
Offer an in-place migration (proto -> wwand + a linked wwand_modem),
reusing the daemon's tested migrate engine over ubus. */
var legacy = [];
uci.sections('network', 'interface', function(s) {
if ((s.proto == 'qmi' || s.proto == 'mbim' || s.proto == 'ncm' ||
s.proto == 'modemmanager') && !s.modem)
legacy.push(s);
});
var migration = '';
if (legacy.length) {
var checked = {};
var migRows = legacy.map(function(s) {
var name = s['.name'];
checked[name] = true;
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' },
E('input', { 'type': 'checkbox', 'checked': 'checked',
'change': function(ev) { checked[name] = ev.target.checked; } })),
/* arrays: an APN or device name reaches uci from a config
import or a provisioning tool, not only from this page */
E('td', { 'class': 'td' }, [ name ]),
E('td', { 'class': 'td' }, [ (s.proto || '?').toUpperCase() ]),
E('td', { 'class': 'td' }, [ s.device || '-' ]),
E('td', { 'class': 'td' }, [ s.apn || '-' ]),
]);
});
var migBtn = E('button', { 'class': 'btn cbi-button cbi-button-apply',
'click': ui.createHandlerFn(this, function() {
var sel = legacy.map(function(s) { return s['.name']; })
.filter(function(n) { return checked[n]; });
if (!sel.length)
return ui.addNotification(null, E('p', {}, _('No interface selected.')), 'warning');
if (!confirm(_('Migrate %d interface(s) to proto wwand? Each is converted in place — its name, firewall zone and IP settings are kept — and wwand takes over managing it.').format(sel.length)))
return;
return wrpc.migrate(true, sel).then(function(res) {
if (res && (res.ok || res.applied != null)) {
ui.addNotification(null, E('p', {}, [ _('Migrated %d interface(s); reloading…').format(sel.length) ]), 'info');
window.setTimeout(function() { location.reload(); }, 1500);
}
else {
ui.addNotification(null, E('p', {}, [ _('Migration failed: %s').format((res && res.error) || _('unknown')) ]), 'error');
}
});
}) }, _('Migrate selected'));
migration = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Migratable interfaces')),
E('p', {}, _('Interfaces still using a stock cellular protocol (qmi / mbim / ncm / modemmanager) that wwand does not manage yet. Select the ones to migrate and press "Migrate selected": each is converted in place to proto wwand (its name, firewall zone and IP settings are kept) and a wwand modem section is created and linked. When migrating a ModemManager interface, stop and remove the ModemManager service afterwards — it would otherwise keep claiming the modem\'s control port.')),
E('table', { 'class': 'table' },
[ E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, ''),
E('th', { 'class': 'th' }, _('Interface')),
E('th', { 'class': 'th' }, _('Protocol')),
E('th', { 'class': 'th' }, _('Device')),
E('th', { 'class': 'th' }, _('APN')),
]) ].concat(migRows)),
migBtn,
]);
}
/* ?sim=<ICCID>: the SIM cards page's Edit/Create button — open that
card's override. Taken off the URL first, so a reload does not add
a second one. */
var simParam = new URLSearchParams(window.location.search).get('sim');
if (simParam) {
var u = new URL(window.location.href);
u.searchParams.delete('sim');
window.history.replaceState(null, '', u.toString());
}
return m.render().then(function(mapNode) {
if (simParam && /^[0-9A-Fa-f]{6,22}$/.test(simParam))
window.setTimeout(function() { simlist.openSim(simSec, simParam); }, 0);
return E('div', {}, [ mapNode, migration, detected ]);
});
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,132 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require uci';
'require wwand.simlist as simlist';
/* Status -> SIM cards: every SIM card wwand has seen (ubus sim_inventory,
the daemon's siminventory.uc), by ICCID, and where it is — which modem and
slot, which eUICC and profile, or which reader (a remote SIM through
wwand-rsim). Cards that were seen and are gone stay listed as not present,
with when they were last seen: that is what a later ICCID binding needs to
know, and what an operator asks after moving cards around. */
var callInventory = rpc.declare({ object: 'wwand', method: 'sim_inventory', expect: { '': {} } });
function where(c) {
if (c.reader)
return _('reader %s').format(c.reader);
if (c.modem)
return (c.slot != null) ? _('%s, slot %d').format(c.modem, c.slot) : c.modem;
return '?';
}
/* how long ago, from the router's clock: last_seen is the daemon's time() */
function ago(secs) {
if (secs < 120)
return _('%d s ago').format(secs);
if (secs < 7200)
return _('%d min ago').format(Math.floor(secs / 60));
if (secs < 172800)
return _('%d h ago').format(Math.floor(secs / 3600));
return _('%d days ago').format(Math.floor(secs / 86400));
}
function state(c, now) {
if (!c.present)
return E('span', { 'style': 'opacity:.6' }, [ (c.last_seen != null && now != null)
? _('not present, last seen %s').format(ago(Math.max(0, now - c.last_seen)))
: _('not present') ]);
if (c.active)
return E('strong', {}, _('in use'));
return _('present');
}
function esim(c) {
if (!c.eid)
return '';
/* array children throughout: the names come from the card */
return E('span', {}, [
_('eUICC'), ' ', E('code', {}, [ c.eid ]),
c.profile ? ' · ' + _('profile %s').format(c.profile.state || '?') + (c.profile.name ? ' "' + c.profile.name + '"' : '') : '',
]);
}
/* this card's override (PIN, APN, …) on the Modems page, in the editor
there: Edit when it has one, Create when not (Modems page, ?sim=) */
function settingsButton(c) {
if (!c.iccid)
return '';
var have = !!simlist.findSim(c.iccid, c.imsi);
return E('button', {
'class': 'btn cbi-button ' + (have ? 'cbi-button-edit' : 'cbi-button-add'),
'title': have ? _('Edit the settings of this SIM card') : _('Create settings for this SIM card'),
'click': function() {
window.location.href = L.url('admin/network/wwand') + '?sim=' + encodeURIComponent(c.iccid);
},
}, [ have ? _('Edit') : _('Create') ]);
}
return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(callInventory(), {}),
L.resolveDefault(uci.load('network'), null),
]).then(function(r) { return r[0]; });
},
table: function(inv) {
var cards = (inv && inv.cards) || [];
if (!cards.length)
return E('p', {}, E('em', {}, _('No SIM card seen yet.')));
return E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Name')),
E('th', { 'class': 'th' }, _('ICCID')),
E('th', { 'class': 'th' }, _('Where')),
E('th', { 'class': 'th' }, _('State')),
E('th', { 'class': 'th' }, _('IMSI')),
E('th', { 'class': 'th' }, _('eSIM')),
E('th', { 'class': 'th' }, _('Settings')),
]),
].concat(cards.map(function(c) {
return E('tr', { 'class': 'tr' }, [
/* the label from the card's wwand_sim, set on Network → Modems */
E('td', { 'class': 'td' }, [ c.name ? String(c.name) : '—' ]),
E('td', { 'class': 'td' }, E('code', {}, [ c.iccid ])),
E('td', { 'class': 'td' }, [ where(c) ]),
E('td', { 'class': 'td' }, state(c, inv.now)),
E('td', { 'class': 'td' }, [ c.imsi || '—' ]),
E('td', { 'class': 'td' }, esim(c)),
E('td', { 'class': 'td' }, settingsButton(c)),
]);
})));
},
render: function(inv) {
var self = this;
var box = E('div', {}, self.table(inv || {}));
poll.add(function() {
return L.resolveDefault(callInventory(), {}).then(function(i) {
box.replaceChildren(self.table(i || {}));
});
}, 10);
return E([], [
E('h2', {}, _('SIM cards')),
E('div', { 'class': 'cbi-map-descr' },
_('Every SIM card wwand has seen and where it is: in a modem slot, as a profile on an eUICC, or in a reader (remote SIM). A card that was taken out stays listed as not present. On a modem with more than one slot, the cards in the inactive slots appear once the slot list has been read.')),
box,
]);
},
handleSave: null,
handleSaveApply: null,
handleReset: null
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,117 @@
'use strict';
'require baseclass';
/* Shared cellular band/frequency helpers for the wwand LuCI packages
(status page and the proto handler). Single source of truth so the band
tables are not copy-pasted across resources.
E-UTRA (LTE) downlink band table: [band, earfcn_low, earfcn_high, fdl_low_mhz]
from 3GPP TS 36.101 Table 5.7.3-1. Frequency = fdl_low + 0.1*(earfcn -
earfcn_low). EARFCN ranges are disjoint, so order does not matter here. */
var LTE_BANDS = [
[1,0,599,2110],[2,600,1199,1930],[3,1200,1949,1805],[4,1950,2399,2110],
[5,2400,2649,869],[6,2650,2749,875],[7,2750,3449,2620],[8,3450,3799,925],
[9,3800,4149,1844.9],[10,4150,4749,2110],[11,4750,4999,1475.9],
[12,5010,5179,729],[13,5180,5279,746],[14,5280,5379,758],[17,5730,5849,734],
[18,5850,5999,860],[19,6000,6149,875],[20,6150,6449,791],[21,6450,6599,1495.9],
[22,6600,7399,3510],[23,7500,7699,2180],[24,7700,8039,1525],[25,8040,8689,1930],
[26,8690,9039,859],[27,9040,9209,852],[28,9210,9659,758],[29,9660,9769,717],
[30,9770,9869,2350],[31,9870,9919,462.5],[32,9920,10359,1452],
[33,36000,36199,1900],[34,36200,36349,2010],[35,36350,36949,1850],
[36,36950,37549,1930],[37,37550,37749,1910],[38,37750,38249,2570],
[39,38250,38649,1880],[40,38650,39649,2300],[41,39650,41589,2496],
[42,41590,43589,3400],[43,43590,45589,3600],[44,45590,46589,703],
[46,46790,54539,5150],[48,55240,56739,3550],[65,65536,66435,2110],
[66,66436,67335,2110],[68,67536,67835,753],[70,68336,68585,1995],
[71,68586,68935,617]
];
/* NR (5G) downlink band table: ['n<band>', fdl_low_mhz, fdl_high_mhz] from
3GPP TS 38.101-1 (FR1) / -2 (FR2). Ordered narrower-before-broader where
ranges overlap, since nrArfcn() returns the first match (e.g. n78 before
n77, n1 before n65/n66). Covers the common FR1 bands + FR2 mmWave.
Where two bands span the IDENTICAL range (n76/n51, n50/n75, n65/n66,
n41/n90) the order is a deliberate tie-break, not a violation: the more
commonly deployed band is listed first.
That rule outranks reachability, and it costs some. n51, n75, n65 and n90
are unreachable because they duplicate a range exactly; n18 is unreachable
because n5 and n26 cover 860-875 between them, and relabelling ordinary n5
spectrum as a Japan-only band to rescue it would be the worse trade. n74
keeps only the sliver above 1517 for the same reason (see the L-band note).
A regional table cannot be right everywhere at once with first-match
lookup; where it must choose, it chooses what is deployed.
Checking this table means computing REACHABILITY, not pairwise containment:
a band is dead when everything before it already covers its range, which
happens through partial overlaps too. A pairwise-subset check misses
exactly that case. */
var NR_BANDS = [
/* sub-1 GHz */
['n71',617,652],['n29',717,728],['n12',729,746],['n85',728,746],
['n14',758,768],['n28',758,803],['n20',791,821],
/* n5 (global 850) ahead of the regional pair, same deployment rule: behind
it n26 keeps 859-869 and n18 (Japan, 860-875) is unreachable. Ordering
n18 first would relabel 869-875 MHz — ordinary n5 territory nearly
everywhere — as a Japan-only band. */
['n5',869,894],['n26',859,894],['n18',860,875],['n8',925,960],
/* L-band / 1.4-1.5 GHz (SDL; n76 & n51 share 1427-1432, n50 & n75 share 1432-1517) */
['n76',1427,1432],['n51',1427,1432],
/* n50/n75 keep 1432-1517 and n74 follows them, which leaves n74 only the
sliver above 1517. Deliberate, and the DEPLOYMENT rule above decides it
rather than reachability: 1475-1517 is where the European L-band SDL
carriers sit (n50/n75), while n74 is a Japanese FDD band. Putting n74
first would buy its reachability by mislabelling 1477/1490/1510 MHz —
the same trade the n66-before-n65 note below refuses to make. */
['n50',1432,1517],['n75',1432,1517],['n74',1475,1518],
/* 1.8-2.2 GHz */
['n3',1805,1880],['n2',1930,1990],['n25',1930,1995],['n70',1995,2020],
/* n66 before n65: n1 already covers 2110-2170, so the only band this pair
ever decides is 2170-2200 — AWS-3 downlink, which is n66. n65 (extended
IMT-2100) is essentially undeployed there. */
['n1',2110,2170],['n66',2110,2200],['n65',2110,2200],
/* 2.3-2.7 GHz (n7 FDD DL 2620-2690 before the broader TDD n41/n90 2496-2690
so an ARFCN in the overlap resolves to the narrower band first) */
['n30',2350,2360],['n40',2300,2400],['n53',2483.5,2495],['n38',2570,2620],
['n7',2620,2690],['n41',2496,2690],['n90',2496,2690],
/* 3.3-5 GHz */
['n48',3550,3700],['n78',3300,3800],['n77',3300,4200],['n79',4400,5000],
/* FR2 (mmWave) */
['n258',24250,27500],['n261',27500,28350],['n257',26500,29500],
['n260',37000,40000],['n259',39500,43500]
];
/* NR-ARFCN -> centre frequency (MHz), 3GPP TS 38.104 Table 5.4.2.1-1:
FR1 low (0..3 GHz): F = 5 kHz * N -> N/200
FR1 mid (3..24.25 GHz): F = 3000 + 15 kHz * (N - 600000) (n77/n78/n79!)
FR2 (24.25..100 GHz):F = 24250.08 + 60 kHz * (N - 2016667) */
function nrArfcnToMhz(n) {
if (n < 600000) return n / 200;
if (n < 2016667) return 3000 + 0.015 * (n - 600000);
return 24250.08 + 0.06 * (n - 2016667);
}
return baseclass.extend({
LTE_BANDS: LTE_BANDS,
NR_BANDS: NR_BANDS,
/* EARFCN -> { band: 'B<n>', mhz: <number> } or null */
lteEarfcn: function(earfcn) {
for (var i = 0; i < LTE_BANDS.length; i++) {
var b = LTE_BANDS[i];
if (earfcn >= b[1] && earfcn <= b[2])
return { band: 'B' + b[0], mhz: (b[3] + 0.1 * (earfcn - b[1])) };
}
return null;
},
/* NR-ARFCN -> { band: 'n<n>'|null, mhz: <number> } or null */
nrArfcn: function(arfcn) {
if (arfcn == null)
return null;
var mhz = nrArfcnToMhz(arfcn), band = null;
for (var i = 0; i < NR_BANDS.length; i++)
if (mhz >= NR_BANDS[i][1] && mhz <= NR_BANDS[i][2]) { band = NR_BANDS[i][0]; break; }
return { band: band, mhz: mhz };
}
});
@@ -0,0 +1,516 @@
'use strict';
'require baseclass';
'require dom';
'require ui';
'require wwand.rpc as wrpc';
'require wwand.format as fmt';
'require wwand.simlist as simlist';
/* SIM & eSIM management panel, extracted from view/wwand/settings.js: SIM
slots (primary/switch), SIM PIN lock, and — on an eUICC — the eSIM profile
list, profile download with live SGP.22 progress, and the pending operator
notifications. render(ctx, data) returns the node array the settings page
appends; ctx carries the pieces the page owns:
ctx.simSlotUci(slot) — persist the primary SIM slot to uci
ctx.notifyEsimApply(modem, r) — surface how a profile switch applies
data: { modem, info, slots, esimSlot, esim } as assembled by the settings
view — `esimSlot` is the physical slot its eSIM reads were made against, so
this panel acts on the card it listed. */
var callSwitchSlot = wrpc.switchSlot;
var callPinLock = wrpc.pinLock;
var callEsim = wrpc.esim;
// lpac emits terse ES10/ES9+ step tokens on its progress lines; map them to the
// download stages a human recognises (order = the SGP.22 download sequence).
var DL_STEPS = [
[ 'es10b_get_euicc_challenge_and_info', _('eUICC challenge') ],
[ 'es9p_initiate_authentication', _('Contacting SM-DP+') ],
[ 'es10b_authenticate_server', _('Verifying server') ],
[ 'es9p_authenticate_client', _('Authenticating') ],
[ 'es10b_prepare_download', _('Preparing download') ],
[ 'es9p_get_bound_profile_package', _('Fetching profile') ],
[ 'es10b_load_bound_profile_package', _('Installing profile') ],
];
// parse the live bridge log (progress:/result:/data: lines) into a status
function parseActivity(log) {
var seen = {}, order = [], cancelled = false, msg = null, code = null, m;
(log || '').split('\n').forEach(function(l) {
l = l.trim();
if ((m = l.match(/^progress:\s*(\S+)/))) {
if (/cancel_session/.test(m[1])) cancelled = true;
else if (!seen[m[1]]) { seen[m[1]] = true; order.push(m[1]); }
} else if ((m = l.match(/^result:\s*code=(-?\d+)\s*(.*)$/))) {
code = parseInt(m[1], 10);
if (m[2] && m[2].trim() && m[2].trim() != 'success') msg = m[2].trim();
} else if ((m = l.match(/^data:\s*(.+)$/))) {
var d = m[1].trim();
if (d.length && d[0] != '{') msg = d; // human message, not the chip JSON blob
}
});
return { seen: seen, order: order, cancelled: cancelled, msg: msg, code: code };
}
/* the shared .wwe-* CSS now lives in wwand.format (injected once by the hosting
view); this panel and wwand.netsel just use the classes. */
/* an ICCID cell, with the card's name from its wwand_sim when it has one
(ddimension/wwand#39) — text nodes only: the name is operator-entered */
function iccidCell(iccid) {
var name = simlist.simName(iccid);
return name
? [ iccid || '', ' ', E('span', { 'style': 'font-weight:600' }, [ String(name) ]) ]
: [ iccid || '' ];
}
return baseclass.extend({
/* The modem's own eUICC profile read (QMI UIM), for a card whose ES10 lpac
cannot use. Rendered lazily into its own container: it is a second round
trip and only interesting once the lpac path has already failed. */
renderNativeProfiles: function(data) {
var box = E('div', {}, [ E('em', {}, [ _('Asking the modem directly…') ]) ]);
/* the eUICC is not necessarily the ACTIVE slot — ask about the slot
that actually holds it, or neither path can reach the card.
`load()` already chose it; see the comment in render(). */
var slot = data.esimSlot != null ? data.esimSlot : 1;
L.resolveDefault(wrpc.euiccProfiles(data.modem, slot), {}).then(function(r) {
if (!r || r.ok === false || !(r.profiles || []).length) {
var why = (r && r.detail && r.detail.error) || '';
dom.content(box, E('p', {}, [ E('em', {}, [
why == 'no_native_euicc'
? _('This modem has no native eUICC interface either, so the card cannot be enumerated from the router at all.')
: _('The modem returned no profiles for this slot.') ]) ]));
return;
}
dom.content(box, [
E('p', {}, [ _('Read from the modem directly (slot %d), bypassing ES10:').format(slot) ]),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Profile') ]),
E('th', { 'class': 'th' }, [ _('State') ]),
E('th', { 'class': 'th' }, [ 'ICCID' ]),
E('th', { 'class': 'th' }, [ _('Class') ]),
]),
].concat((r.profiles || []).map(function(p) {
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, [ p.name || p.nickname || p.spn || _('(unnamed)') ]),
E('td', { 'class': 'td' }, [ p.active ? _('active') : (p.state || '') ]),
E('td', { 'class': 'td' }, iccidCell(p.iccid)),
E('td', { 'class': 'td' }, [
(p.class || '') + (p.policy && p.policy.delete_not_allowed
? ' · ' + _('delete not allowed') : '') ]),
]);
}))),
]);
});
return box;
},
render: function(ctx, data) {
var self = this;
var esimOk = data.esim && data.esim.ok !== false && data.esim.profiles;
/* EVERY GROUP BELOW IS ONE .cbi-section WITH ITS TITLE INSIDE, the way
LuCI's form.js builds a section (form.js:2482-2490) and the status
page always did. This panel was the one left as a flat run of
headings, paragraphs and sections, so "SIM PIN" sat above its box
and "SIM overrides" had no box at all (ddimension/luci-app-wwand#14,
evidence on 1.6.8_p1). tools/check-section-titles.py now checks
<h4> as well as <h3>, which is how this stays fixed. */
var out = [];
/* WHICH CARD THESE ACTIONS ACT ON. The eUICC is not necessarily the
active slot, so every call below has to name the slot that holds it.
They used to pass a literal 0, which is not a slot at all: the daemon
resolves the argument with `?? 1`, and `??` only replaces a MISSING
value, so the 0 went through and addressed physical slot 0. The read
path was moved off it and these were missed (openwrt/luci#8917
review) — and these are the writing ones: enable, disable and delete
a profile on the wrong card.
THE SLOT COMES FROM `load()`, it is not derived again here. It used
to be, in three places with two different rules: the settings view
took the FIRST slot reporting an eUICC, these two took the LAST. A
modem answering with two eUICC slots would therefore list the
profiles of one card and enable, disable or delete on the other —
silently, since both answers are well-formed. Two such slots are
representable: is_euicc is probed per slot and independently
(wwand sim.uc:638 for QMI UIM, modem_ncm.uc:883,888 for AT).
Found in the openwrt/luci#8917 review, 2026-09-19.
1 when there is no eUICC to point at: the ubus signature types `slot`
as an integer, so a null is not something to send through it, and 1 is
the daemon's own default said out loud. */
var esimSlot = data.esimSlot != null ? data.esimSlot : 1;
var slotRows = (data.slots || []).map(function(sl) {
/* shared row renderer (wwand.format); the persist-to-uci button is
this panel's extra */
/* NOT ON AN INFERRED ROW. This button writes `option sim_slot` to
uci, and on a modem that cannot enumerate its slots the number
in that row is the daemon's placeholder, not a slot anybody
read — persisting it would record a topology decision on no
evidence. See fmt.slotEnumerated. Raised by Codex review,
2026-09-22.
AND NOT ON A SLOT WITH NO CARD IN IT. Pinning a boot preference
to an empty slot asks the modem to come up on nothing; the
status page's "Switch now" has always required a card
(status.js) and this one, on the same rows, offered itself
regardless. `extras` are appended before simSlotRow's own
presence gate, so the renderer never had a say. Reported by obsy
(ddimension/luci-app-wwand#12, 2026-09-22).
Presence, not activity: making the ACTIVE slot the persistent
choice is exactly what somebody would want this button for. */
var primaryBtn = fmt.slotPinnable(sl)
? E('button', { 'class': 'btn cbi-button', 'style': 'margin-left:8px',
'click': ui.createHandlerFn(self, function() {
return ctx.simSlotUci(sl.physical).then(function() {
ui.addNotification(null, E('p', [ _('Primary SIM set to slot %d (persisted).').format(sl.physical) ]), 'info');
});
}) }, _('Set as primary'))
: null;
return fmt.simSlotRow(sl, function(physical) {
return callSwitchSlot(data.modem, physical);
}, primaryBtn ? [ primaryBtn ] : []);
});
out.push(E('div', { 'class': 'cbi-section' }, [ E('h3', {}, _('SIM')) ].concat(
slotRows.length ? slotRows : [ E('em', {}, _('No slot information available.')) ])));
/* --- SIM PIN lock: enable / disable the PIN query, with the current PIN --- */
var minfo = data.info || {};
/* a busy or departed card is now a KNOWN state rather than falling
through to "unknown" — the daemon learns it from the UIM indications,
and it also explains why the PIN state cannot be read right now */
var pinState = (minfo.state == 'SIM_BLOCKED') ? _('blocked — PUK required')
: minfo.sim_busy ? _('card busy — cannot be read right now')
: (minfo.pin1 && minfo.pin1.enabled === false) ? _('disabled (SIM boots without PIN)')
: (minfo.pin1 && minfo.pin1.enabled === true) ? _('enabled')
: minfo.sim_note ? minfo.sim_note
: _('unknown');
var pinIn = E('input', { 'type': 'password', 'class': 'cbi-input-password',
'style': 'width:12em', 'placeholder': _('current PIN') });
var pinAct = function(enable) {
var pin = (pinIn.value || '').trim();
if (!pin) {
ui.addNotification(null, E('p', _('Enter the current SIM PIN first.')), 'warning');
return;
}
return callPinLock(data.modem, pin, enable).then(function(r) {
if (r && r.ok === false)
ui.addNotification(null, E('p', [ _('PIN change failed: %s').format(r.error || '?') ]), 'error');
else
ui.addNotification(null, E('p', enable ? _('SIM PIN query enabled.') : _('SIM PIN query disabled.')), 'info');
});
};
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('SIM PIN')),
/* array child: pinState can now carry daemon text (sim_note), and a
bare string goes in through innerHTML (luci.js:1394-96) */
E('p', {}, [ _('PIN query: '), E('strong', {}, [ pinState ]) ]),
E('div', { 'style': 'margin-bottom:4px' }, [
pinIn, ' ',
E('button', { 'class': 'btn cbi-button cbi-button-apply', 'style': 'margin-left:8px',
'click': ui.createHandlerFn(self, function() { return pinAct(true); }) }, _('Enable PIN')),
E('button', { 'class': 'btn cbi-button cbi-button-remove', 'style': 'margin-left:4px',
'click': ui.createHandlerFn(self, function() { return pinAct(false); }) }, _('Disable PIN'))
]),
E('p', { 'style': 'color:#666;font-size:90%' },
_('Enabling or disabling the PIN query needs the current PIN. Disabling lets the SIM boot without a PIN.'))
]));
/* per-SIM overrides (config wwand_sim) are edited on the Network →
Modems overview page (shared wwand.simlist) — single source, no
duplicate list here. */
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('SIM overrides')),
E('p', {}, [
_('Per-card PIN/APN overrides (matched by ICCID or IMSI) are managed on '),
E('a', { 'href': L.url('admin/network/wwand') }, _('Network → Modems')),
'.'
])
]));
if (!esimOk) {
if (data.esim && data.esim.error == 'esim_not_installed')
out.push(E('p', {}, E('em', {}, _('eSIM management: package wwand-esim is not installed.'))));
else if (data.esim && data.esim.detail && data.esim.detail.error == 'es10_refused') {
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('eSIM')),
E('div', { 'class': 'wwe-banner run' },
[ _('eUICC detected, but the card refuses local eSIM management (ES10 rejected, SW %s).')
.format(data.esim.detail.sw || '6985') ]),
E('p', {}, _('This is the normal behaviour of M2M eUICCs (SGP.02) and of vendor-locked cards: profiles are managed over-the-air by the SIM provider (SM-SR), so downloading or switching profiles from this router is not possible. Use the provider’s portal to manage the card — the router only needs to keep the active profile registered and online.'))
]));
/* The one path left when the CARD refuses local management: ask
the MODEM instead. lpac talks ES10 to the card over an APDU
channel, which an SGP.02 eUICC declines by design; the modem's
own interface does not go through ES10 at all. Not every
firmware implements it — the answer says which case this is
rather than leaving an empty panel. */
out.push(this.renderNativeProfiles(data));
}
return out;
}
/* A CARD A PLUGIN MANAGES (status `esim_managed_by`, e.g. an SGP.32 eIM
client) is not changed from here: the plugin keeps its own record of
the card, and the daemon refuses enable, disable, delete, download
and notify with `esim_managed`. So those controls are not offered at
all rather than offered and then refused; the override (`force`) is
left to the CLI, for someone who knows. */
var managedBy = (data.info && data.info.esim_managed_by) || null;
if (managedBy)
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('Managed card')),
/* array: the plugin name comes from the daemon */
E('p', {}, [ _('This card is managed by %s. Profile changes are made there, not here.').format(managedBy) ]),
]));
var profRows = (data.esim.profiles || []).map(function(p) {
var acts = [];
if (managedBy)
return E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td' }, iccidCell(p.iccid)),
E('td', { 'class': 'td' }, [ p.provider || p.name || p.nickname || '' ]),
E('td', { 'class': 'td' }, [ p.state ]),
E('td', { 'class': 'td' }, E('em', {}, [ _('managed by %s').format(managedBy) ])),
]);
if (p.state != 'enabled')
acts.push(E('button', { 'class': 'btn cbi-button cbi-button-apply',
'click': ui.createHandlerFn(self, function() {
if (!confirm(_('Enable profile %s? The connection will re-establish.').format(p.iccid)))
return;
return callEsim(data.modem, 'enable', esimSlot, p.iccid, '', '').then(function(res) { ctx.notifyEsimApply(data.modem, res) });
}) }, _('Enable')));
else
acts.push(E('button', { 'class': 'btn cbi-button',
'click': ui.createHandlerFn(self, function() {
if (!confirm(_('Disable the active profile %s?').format(p.iccid)))
return;
return callEsim(data.modem, 'disable', esimSlot, p.iccid, '', '').then(function(res) { ctx.notifyEsimApply(data.modem, res) });
}) }, _('Disable')));
acts.push(E('button', { 'class': 'btn cbi-button cbi-button-remove', 'style': 'margin-left:4px',
'click': ui.createHandlerFn(self, function() {
if (!confirm(_('Permanently DELETE profile %s from the eUICC?').format(p.iccid)))
return;
return callEsim(data.modem, 'delete', esimSlot, p.iccid, '', '').then(function() { window.location.reload() });
}) }, _('Delete')));
return E('tr', { 'class': 'tr' }, [
/* arrays, not bare strings: the provider/name/nickname of a
profile is free text the CARRIER put on the eUICC when it was
downloaded, and dom.append() routes a bare string through
innerHTML (luci.js:1394-96) */
E('td', { 'class': 'td' }, iccidCell(p.iccid)),
E('td', { 'class': 'td' }, [ p.provider || p.name || p.nickname || '' ]),
E('td', { 'class': 'td' }, [ p.state ]),
E('td', { 'class': 'td' }, acts),
]);
});
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('eSIM profiles')),
E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, 'ICCID'),
E('th', { 'class': 'th' }, _('Provider')),
E('th', { 'class': 'th' }, _('State')),
E('th', { 'class': 'th' }, ''),
]),
].concat(profRows.length ? profRows : [
E('tr', { 'class': 'tr' }, [ E('td', { 'class': 'td', 'colspan': 4 }, E('em', {}, _('no profiles'))) ]) ]))
]));
// shared activity panel: live progress for downloads / notifications
var panel = E('div', { 'class': 'wwe-panel', 'style': 'display:none' });
var mkBanner = function(kind, icon, text) {
return E('div', { 'class': 'wwe-banner ' + kind }, [
(kind == 'run') ? E('span', { 'class': 'wwe-spin' }) : E('span', {}, icon),
/* array: callers pass lpac/daemon-derived text here */
E('span', {}, [ text ]),
]);
};
var renderPanel = function(st, mode) {
panel.style.display = '';
var a = parseActivity(st.log);
var running = (st.state == 'running');
var kids = [];
if (mode == 'download') {
var doneN = 0;
var lastSeen = a.order.length ? a.order[a.order.length - 1] : null;
var items = DL_STEPS.map(function(s) {
var isDone = !!a.seen[s[0]];
if (isDone) doneN++;
var isCur = running && !a.cancelled && a.code === null && s[0] == lastSeen;
var cls = isDone ? (isCur ? 'wwe-step cur' : 'wwe-step done') : 'wwe-step';
return E('li', { 'class': cls }, [
E('span', { 'class': 'ic' }, isDone ? (isCur ? '⟳' : '✓') : '○'),
E('span', {}, s[1]),
]);
});
// install acknowledgement to the operator (auto_notify) — its
// state comes from the daemon phase, not an lpac progress token
var ackDone = (st.notified === true);
var ackCur = running && st.phase == 'notify';
items.push(E('li', { 'class': ackDone ? 'wwe-step done' : (ackCur ? 'wwe-step cur' : 'wwe-step') }, [
E('span', { 'class': 'ic' }, ackDone ? '✓' : (ackCur ? '⟳' : '○')),
E('span', {}, _('Confirm install to operator')),
]));
var pct = Math.round(doneN / DL_STEPS.length * 100);
var barcls = 'wwe-bar' + (a.code === 0 ? ' ok'
: (!running && (a.code !== null || a.cancelled)) ? ' err' : '');
kids.push(E('ul', { 'class': 'wwe-steps' }, items));
kids.push(E('div', { 'class': barcls }, E('span', { 'style': 'width:' + pct + '%' })));
}
if (running)
kids.push(mkBanner('run', '', mode == 'download' ? _('Downloading profile…') : _('Contacting operator…')));
else if (a.code === 0 && !a.cancelled)
kids.push(mkBanner('ok', '✓', mode != 'download' ? _('Done.')
: (st.notified === false
? _('Profile downloaded (install not yet confirmed to the operator).')
: _('Profile downloaded and confirmed — the eUICC will re-initialise.'))));
else
kids.push(mkBanner('err', '✕', a.msg || _('The operation failed.')));
if (st.log)
kids.push(E('details', { 'class': 'wwe-det' }, [
E('summary', {}, _('Show raw lpac log')),
/* array: this is the raw lpac log, verbatim */
E('pre', { 'class': 'wwe-log' }, [ st.log ]),
]));
dom.content(panel, kids);
};
/* A profile download runs for minutes over rpcd, so a single reply that
is missing or a promise that rejects (rpcd restarted, request timed
out) is normal enough to plan for. Without that, one such reply left
the panel spinning forever with nothing said -- the operation may well
have finished on the modem, and the page would never show it. Retry a
few times, then say so and stop. */
var pollMisses = 0;
var pollStatus = function(mode) {
callEsim(data.modem, 'download_status', esimSlot, '', '', '').then(function(st) {
if (!st || st.state == null) {
if (++pollMisses < 5)
return window.setTimeout(function() { pollStatus(mode) }, 1200);
return dom.content(panel, mkBanner('err', '',
_('Lost contact with the download — reload to see its result.')));
}
pollMisses = 0;
renderPanel(st, mode);
if (st.state == 'running')
window.setTimeout(function() { pollStatus(mode) }, 1200);
else if (mode == 'download' && parseActivity(st.log).code === 0)
window.setTimeout(function() { window.location.reload() }, 3000);
}).catch(function(e) {
if (++pollMisses < 5)
return window.setTimeout(function() { pollStatus(mode) }, 1200);
dom.content(panel, mkBanner('err', '',
_('Lost contact with the download: %s').format(e && e.message || e)));
});
};
var startBusy = function(text) {
panel.style.display = '';
dom.content(panel, mkBanner('run', '', text));
};
var dlHint = (data.esim.backend == 'at')
? _('The modem downloads over its own network attach — no router data path or APN needed.')
: _('lpac downloads on the router over your uplink and relays the eUICC APDUs through wwand — no dedicated modem APN needed.');
if (managedBy)
return out;
var codeIn = E('input', { 'type': 'text', 'class': 'cbi-input-text',
'style': 'width:min(440px,72%);margin-right:6px',
'placeholder': 'LPA:1$rsp.example.com$MATCHING-ID' });
var confIn = E('input', { 'type': 'text', 'class': 'cbi-input-text',
'style': 'width:min(240px,42%);margin-right:6px',
'placeholder': _('confirmation code (optional)') });
var ackChk = E('input', { 'type': 'checkbox', 'checked': 'checked', 'style': 'margin:0 5px 0 0' });
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('Download profile')),
E('p', {}, E('em', {}, dlHint)),
E('div', { 'style': 'margin-bottom:8px' }, [ codeIn, confIn,
E('button', { 'class': 'btn cbi-button cbi-button-apply',
'click': ui.createHandlerFn(self, function() {
if (!(codeIn.value || '').trim()) {
ui.addNotification(null, E('p', _('Enter an activation code first.')), 'warning');
return;
}
startBusy(_('Starting download…'));
return callEsim(data.modem, 'download', esimSlot, '', codeIn.value, confIn.value, ackChk.checked).then(function(res) {
if (res && res.ok === false)
dom.content(panel, mkBanner('err', '✕', _('Could not start: ') + (res.error || '?')));
else
pollStatus('download');
});
}) }, _('Download')),
]),
E('label', { 'style': 'display:inline-flex;align-items:center;font-weight:normal;color:var(--fg-color-2,#666)' }, [
ackChk, _('Confirm the install to the operator automatically (recommended; uncheck only for testing)'),
]),
panel,
]));
// pending eUICC notifications: confirm the download/enable/disable to
// the operator's SM-DP+ (ES9+). Can be done any time the router has
// internet — lpac delivers the queued notifications.
out.push(E('div', { 'class': 'cbi-section' }, [
E('h4', {}, _('Provider confirmations (notifications)')),
E('p', {}, E('em', {}, _('After a download or profile change the eUICC queues notifications that confirm the operation to the operator. Send them once the router has internet.'))),
E('button', { 'class': 'btn cbi-button',
'click': ui.createHandlerFn(self, function() {
startBusy(_('Listing pending notifications…'));
return callEsim(data.modem, 'notifications', esimSlot, '', '', '').then(function(r) {
panel.style.display = '';
dom.content(panel, [
mkBanner((r && r.ok) ? 'ok' : 'err', (r && r.ok) ? '✓' : '✕',
(r && r.log && r.log.trim()) ? _('Pending notifications:') : _('No pending notifications.')),
(r && r.log && r.log.trim())
/* array: raw lpac output, same as the log above */
? E('pre', { 'class': 'wwe-log' }, [ r.log ]) : '',
]);
});
}) }, _('List pending')),
' ',
E('button', { 'class': 'btn cbi-button cbi-button-apply',
'click': ui.createHandlerFn(self, function() {
if (!confirm(_('Send all pending notifications to the operator now?')))
return;
startBusy(_('Sending confirmations…'));
return callEsim(data.modem, 'notify', esimSlot, '', '', '').then(function(res) {
if (res && res.ok === false)
dom.content(panel, mkBanner('err', '✕', _('Failed: ') + (res.error || '?')));
else
pollStatus('notify');
});
}) }, _('Send confirmations')),
]));
return out;
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,609 @@
'use strict';
'require dom';
'require baseclass';
'require wwand.format as fmt';
/* Live signal graphs, shared so they can sit inside the modem status page.
They were a page of their own for a while; they belong beside the numbers
they explain, and a second page meant a second poll of the same ubus method.
THE HISTORY LIVES IN THE BROWSER. Every sample is one `modem_signal` reply
pushed into a ring buffer here; nothing is stored on the router. That is the
deliberate half of the design (ddimension/wwand#14): a daemon-side buffer
would have to be serialised into every status call and would keep sampling
while nobody is watching. The cost is that the window starts empty and is
lost on reload — acceptable for the job this is for, which is aiming an
antenna while looking at it.
THE CALLER OWNS THE SAMPLES. `push()` takes a modem_signal reply the page has
already fetched for its own panels, so the graphs cost no extra RPC. It also
means the graph DOM must OUTLIVE the caller's repaint: status.js rebuilds its
panels every second, and a canvas rebuilt with them would lose its ring
buffers every tick and never draw a line. Keep the node create() returns
outside whatever gets replaced.
ONE INSTANCE PER MODEM. create() is called per modem and holds that modem's
buffers, so a multi-modem router keeps each history separate and switching
between them does not splice one modem's readings onto another's line. Only
the selected modem is sampled — the page fetches signal for the one it is
showing — so an unwatched modem's line simply has no new points; returning to
it shows its earlier window with an honest gap, rather than a fabricated one.
That is also the polite behaviour: polling every modem would keep every
modem's fast telemetry loop awake for graphs nobody is looking at. */
/* WHERE THE THRESHOLDS COME FROM — checked, not guessed.
*
* Teltonika's published recommendations (wiki.teltonika-networks.com,
* "Mobile Signal Strength Recommendations", read 2026-09-10):
*
* RSRP >= -80 excellent · -80..-90 good · -90..-100 fair/poor · < -100 poor
* RSSI > -65 excellent · -65..-75 good · -75..-85 fair · -85..-95 poor
* SINR >= 20 excellent · 13..20 good · 0..13 fair/poor · <= 0 poor
* RSRQ >= -10 excellent · -10..-15 good · -15..-20 fair/poor · <= -20 poor
*
* Worth recording: `board.bars_from_signal()` already implements exactly this —
* RSRP -80/-90/-100 and RSSI -65/-75/-85/-95 (board.uc:136-143) — so the ladder
* behind the router's signal LEDs was never a guess, it just never said where it
* came from. The graph draws the same boundaries the LEDs step at, which is the
* point: what the case shows and what the browser shows should agree.
*
* DO THE RATs NEED THEIR OWN BANDS? Checked, and for RSRP and SINR: no.
* hicelltek.com/en/blog/rsrp-rsrq-sinr-normal-values/ (read 2026-09-10) prints
* 4G and 5G tables side by side and gives SSB-RSRP the same -80/-90/-100/-110
* steps as LTE RSRP, and SSB-SINR the same 20/13/0/-5 as LTE SINR; the wider
* convention agrees for sub-6 GHz, noting only that mmWave wants RSRP above -85.
* So one set of rules per canvas grades both series honestly, and a per-RAT
* ladder would invent a distinction the sources do not make.
*
* RSRQ is where they genuinely part: LTE -10/-12/-15/-17 against NR -10/-13/-15
* (same source), and Teltonika's LTE ladder is a third variant (-10/-15/-20).
* All three agree only on -10 = excellent. The tree follows Teltonika elsewhere,
* so the RSRQ canvas draws Teltonika's and the hint says NR sits a little
* tighter. That much disagreement between published tables is itself the
* finding: RSRQ is a soft indicator of cell load, not a target.
*
* EC/IO IS THE WEAKEST-SOURCED OF THE FOUR and the hint says so rather than
* implying the same rigour. The one figure that can be anchored is Venn
* Telecom's "once the EC/IO is above ~ -7.0 dB, your connection is going to
* suffer" (help.venntelecom.com, read 2026-09-10); the -6/-10/-13 steps drawn
* here are the operator convention that sits around it, and published tables
* for it live in images rather than text. 3G is also the RAT a modem falls back
* TO, so the useful reading is usually "we are on 3G at all", not the exact dB.
*
* Each scale is a readable window, not the full 3GPP reporting range: -120..-50
* dBm covers every labelled boundary with headroom, where -140..-44 would
* squeeze everything interesting into a few pixels. Values outside are clamped
* and the legend still prints the true number. */
const SCALES = {
rsrp: { min: -120, max: -50, unit: 'dBm',
good: -80, fair: -90, weak: -100,
labels: [ _('excellent'), _('good'), _('fair') ],
title: _('Signal strength (RSRP)'),
hint: _('The measure to aim an antenna by, and the same steps the router\'s signal LEDs use: the serving cell\'s own power, RSRP on LTE and 5G, RSCP on 3G. LTE and 5G NR are graded alike, because published tables give SSB-RSRP the same boundaries as LTE RSRP below 6 GHz. RSSI is a different measure on its own graph.') },
rssi: { min: -110, max: -30, unit: 'dBm',
good: -65, fair: -75, weak: -85,
labels: [ _('excellent'), _('good'), _('fair') ],
title: _('Band power (RSSI)'),
hint: _('Total received power in the band — the serving cell plus its neighbours plus interference. Coarser than RSRP and graded 20 dB higher (-65/-75/-85 dBm), which is why it has its own graph: a strong signal reaches -46 dBm, off the top of any scale drawn for RSRP. On 2G it is the only measure there is, and on a modem that reports nothing better it is all you get. Each line carries the radio that measured it; the plain amber one appears only when the reply is untagged.') },
sinr: { min: -10, max: 30, unit: 'dB',
good: 20, fair: 13, weak: 0,
labels: [ _('excellent'), _('good'), _('fair') ],
title: _('Signal quality (SINR)'),
hint: _('How far the wanted signal stands above noise and interference — this, not RSRP, is what throughput follows. 0 dB is the edge of usable. LTE and 5G NR share these boundaries in the published tables.') },
rsrq: { min: -25, max: -3, unit: 'dB',
good: -10, fair: -15, weak: -20,
labels: [ _('excellent'), _('good'), _('fair') ],
title: _('Reference signal quality (RSRQ)'),
hint: _('Mostly a read on how busy the cell is: it falls as the cell fills, even with the antenna untouched. Treat it as an indicator rather than a target — published ladders disagree below -10 dB, and 5G NR is graded a little tighter than the LTE steps drawn here.') },
/* NO QUALITY LADDER, deliberately — and that is why `labels` is absent
rather than empty. There is no "excellent" number of carriers: two is
better than one on a busy cell and irrelevant on an empty one, and an
operator that aggregates three 10 MHz carriers gives less than one that
gives a single 20 MHz. The three coloured rules would have to mean
something to be drawn, so they are not drawn; prepare() skips them for
any scale without labels, and the minute marks and the legend's
current/avg/peak carry the reading instead.
THE TWO ARE SEPARATE CANVASES for the reason this page separated SINR and
RSRQ: a count runs 1..6 and a bandwidth 5..200, and sharing an axis pins
one of them flat against an edge. */
ca: { min: 0, max: 6, minmax: 2, autoscale: true, unit: '', integral: true,
title: _('Aggregated carriers'),
hint: _('How many downlink carriers the modem is aggregating. The lower line is the LTE anchor; the upper one is the total including 5G, so the gap between them is what the 5G leg contributes — under EN-DC both legs serve one link, and stacking them says that better than two separate counts. A secondary carrier that is configured but not activated is not counted: it carries no data. A modem that reports no carrier list at all still shows its serving cell as one carrier. The total line appears only while a 5G carrier is actually serving, not merely visible.') },
bw: { min: 0, max: 100, minmax: 20, autoscale: true, unit: 'MHz',
title: _('Aggregate bandwidth'),
hint: _('The downlink bandwidth of the aggregated carriers added up, per radio technology — the width of the pipe, which is what carrier aggregation is for. It moves independently of the carrier count: three 10 MHz carriers are less than one 20 MHz carrier. Drawn only where the modem reports a width per carrier. The 5G line is usually absent for that reason: the 5G rows of the carrier list carry a bandwidth whose unit this tree has not verified against hardware, and a confidently wrong 100 MHz is worse than a gap.') },
ecio: { min: -20, max: 0, unit: 'dB',
good: -6, fair: -10, weak: -13,
labels: [ _('excellent'), _('good'), _('fair') ],
title: _('3G signal quality (Ec/Io)'),
hint: _('The 3G counterpart to SINR: pilot energy against everything else in the carrier. Above roughly -7 dB a connection starts to suffer; the steps drawn here are the common operator convention around that figure, which is less firmly published than the LTE and 5G ladders. On a modem that normally runs LTE, this canvas appearing at all is the news.') },
};
/* ONE SERIES PER RAT, never one line that quietly changes meaning, and the same
colour for a RAT on every canvas so the eye can follow it across them. */
const RAT = {
lte: '#0069d9', /* blue */
nr: '#8e44ad', /* purple */
umts: '#17a2b8', /* teal */
gsm: '#6c757d', /* grey */
any: '#e0a800', /* amber, the band-wide fallback */
};
const SERIES = {
/* Solid = the serving cell's own power (RSRP, or RSCP on 3G); dashed = the
band-wide RSSI, which counts neighbours and interference too. Same RAT,
same colour, so the pair reads as one radio measured two ways — and the
untagged amber line appears only when the modem tags nothing at all.
THEY ARE ON SEPARATE CANVASES because they are separate quantities with
ladders 20 dB apart. Sharing one pinned a -46 dBm RSSI to the top edge of
an RSRP scale, where it showed nothing (ddimension/wwand#14). */
rsrp: [ { label: _('RSRP LTE'), colour: RAT.lte },
{ label: _('RSRP 5G'), colour: RAT.nr },
{ label: _('RSCP 3G'), colour: RAT.umts } ],
/* dashed here too, though nothing solid shares the canvas: the reader who
has learnt "dashed = band power" on one graph should not have to relearn
it on the next. */
rssi: [ { label: _('RSSI LTE'), colour: RAT.lte, dashed: true },
{ label: _('RSSI 3G'), colour: RAT.umts, dashed: true },
{ label: _('RSSI 2G'), colour: RAT.gsm, dashed: true },
{ label: _('RSSI'), colour: RAT.any, dashed: true } ],
sinr: [ { label: _('SINR LTE'), colour: RAT.lte },
{ label: _('SINR 5G'), colour: RAT.nr } ],
rsrq: [ { label: _('RSRQ LTE'), colour: RAT.lte },
{ label: _('RSRQ 5G'), colour: RAT.nr } ],
ecio: [ { label: _('Ec/Io 3G'), colour: RAT.umts } ],
/* Same colours as every other canvas, so a technology switch reads the same
way here as there: the LTE line ends where the 5G line begins, and both
present is EN-DC. That is the "switched technologies" half of
ddimension/wwand#14 — visible in the picture, without a RAT graph whose
y-axis would have to invent an order for 2G/3G/4G/5G. */
ca: [ { label: _('LTE carriers'), colour: RAT.lte },
{ label: _('total (LTE + 5G)'), colour: RAT.nr } ],
bw: [ { label: _('LTE'), colour: RAT.lte },
{ label: _('total (LTE + 5G)'), colour: RAT.nr } ],
};
/* ONE definition of how a series is stroked, used by the line in the graph and
by its key in the legend. Two copies drifted apart the moment the legend
stopped being a colour block. */
function strokeOf(s) {
return 'stroke:%s;stroke-width:%s%s'.format(
s.colour, s.dashed ? '1.4' : '1.6', s.dashed ? ';stroke-dasharray:5,3' : '');
}
const HEIGHT = 200;
const SVGNS = 'http://www.w3.org/2000/svg';
return baseclass.extend({
/* one canvas: the SVG, its ring buffers, and the legend cells to update */
mkGraph: function(graphs, kind, svgText) {
const scale = SCALES[kind];
const series = SERIES[kind];
const box = E('div', {
'style': 'width:100%%;height:%dpx;border:1px solid #999;background:#fff'.format(HEIGHT),
}, E(svgText));
const svg = box.firstElementChild;
/* The series paths are built HERE, not in the SVG file. The file used to
carry a fixed set of them, which silently capped how many RATs a
canvas could show — adding 3G and 2G meant editing an asset to add
two more <path> elements with hard-coded colours. The canvas is now
just the threshold rules, and each series brings its own line. */
const paths = series.map((s) => {
const p = document.createElementNS(SVGNS, 'path');
p.setAttribute('d', '');
p.setAttribute('style', 'fill:none;stroke-linejoin:round;' + strokeOf(s));
svg.appendChild(p);
return p;
});
const legend = series.map(() => E('span', {}, [ '—' ]));
/* A legend entry appears WITH its series. Listing every RAT the app can
plot told an LTE-only modem's owner that "RSCP 3G" and "RSSI 2G" were
"not reported", which is true and useless — it reads as something
missing rather than something absent by design. The canvases already
hide themselves on the same rule; the rows inside them should not be
noisier than the canvas around them. */
/* THE KEY IS A LINE SAMPLE, NOT A COLOUR BLOCK. Two series on the same
canvas share a RAT colour on purpose — RSRP LTE and RSSI LTE are one
radio measured two ways — and the only thing telling them apart in the
graph is that one is dashed. A square swatch threw exactly that away,
so the legend showed two identical blue chips for two different lines.
The sample is a real SVG stroke with the SAME dash array as the line
it stands for, not a CSS dashed border: `border-top:dashed` is drawn
at each browser's discretion, and Firefox renders it as a fine
dash-dot that is hard to tell from a solid rule at legend size
(reported from Firefox, 2026-09-10). Stroking it the way the graph
strokes it makes the key and the line the same picture by
construction. */
const legendRow = series.map((s, i) => {
const key = document.createElementNS(SVGNS, 'svg');
key.setAttribute('width', '22');
key.setAttribute('height', '10');
key.setAttribute('style', 'vertical-align:middle;margin-right:.45em');
const rule = document.createElementNS(SVGNS, 'line');
rule.setAttribute('x1', '0'); rule.setAttribute('y1', '5');
rule.setAttribute('x2', '22'); rule.setAttribute('y2', '5');
rule.setAttribute('style', strokeOf(s));
key.appendChild(rule);
return E('span', {
'style': 'white-space:nowrap;display:none' },
[ key, s.label + ': ', legend[i] ]);
});
/* Start hidden and reveal on the first real value, stickily. A canvas
only some modems can fill — Ec/Io on an LTE-only stack, RSRQ on a
modem that reports none — would otherwise sit there empty forever,
indistinguishable from a broken graph. Sticky, because a 5G leg
dropping out for ten minutes must leave its canvas in place: hiding
it would erase the very gap that says 5G went away. */
/* The explanation is a MOUSE-OVER, not a paragraph. Printed under every
canvas it cost more vertical space than the graphs themselves and
pushed the panels below off the screen — and it is reference text,
read once and then in the way. It hangs off the heading and the
legend, both marked with the app's dotted-underline "there is more
here" convention (fmt.term), so it is discoverable rather than
hidden. */
const node = E('div', { 'class': 'cbi-section',
'style': 'flex:1;min-width:320px;display:none' }, [
E('h3', {}, [ fmt.term(scale.title, scale.hint) ]),
box,
/* A FLEX ROW, because these entries cannot wrap on their own.
They are appended to the parent by DOM calls, so there is no
whitespace text node between them — and between two adjacent
inline boxes with no intervening space there is no line-break
opportunity at all. The row therefore ran on past the column and
overlapped what came next; Chrome happened to hide it, Firefox
showed it (reported 2026-09-10). `flex-wrap` gives the break
opportunity structurally instead of depending on stray text
nodes, and `gap` replaces the per-entry margin so the spacing
does not double up at a wrap. */
E('div', { 'title': scale.hint,
'style': 'display:flex;flex-wrap:wrap;gap:2px 1.5em;'
+ 'align-items:center;margin:4px 0 6px;font-size:90%;cursor:help' },
legendRow),
]);
graphs.push({
kind: kind, svg: svg, scale: scale, node: node,
series: series, legend: legend, legendRow: legendRow, paths: paths,
values: series.map(() => []),
seen: series.map(() => false),
marks: [],
width: 0, step: 3,
});
return node;
},
/* grid labels + the time marks along the bottom, once the box has a width */
prepare: function(ctx) {
const width = ctx.svg.parentNode.offsetWidth - 2;
if (width <= 0 || width === ctx.width)
return;
ctx.width = width;
ctx.wanted = Math.floor(width / ctx.step);
const G = ctx.svg;
const s = ctx.scale;
/* the quality thresholds, placed by VALUE rather than by a fixed
fraction of the canvas — they mean a signal level, not a position */
const height = HEIGHT - 2;
const yOf = (v) => height - ((v - s.min) / (s.max - s.min)) * height;
/* A scale with no `labels` has no quality ladder — a carrier count and a
bandwidth have no "excellent". It still needs a SCALE: without any
horizontal reference a line floats at some height and the reader
cannot tell 2 carriers from 4. Those canvases get the same three rules
drawn as plain value gridlines instead, placed by draw() because their
top depends on the data. Nothing to do here. */
/* ...but fall THROUGH to the minute marks below. Returning here skipped
them too, so the two new canvases had neither a value scale nor a time
one — a line in an empty box. */
if (s.labels)
for (const t of [ { id: 't_good', v: s.good, txt: s.labels[0] },
{ id: 't_fair', v: s.fair, txt: s.labels[1] },
{ id: 't_poor', v: s.weak, txt: s.labels[2] } ]) {
const line = G.querySelector('#' + t.id);
const label = G.querySelector('#' + t.id + '_l');
const y = yOf(t.v);
if (line) {
line.setAttribute('y1', y);
line.setAttribute('y2', y);
}
if (label) {
label.setAttribute('y', y - 3);
label.textContent = '%s — %d %s'.format(t.txt, Math.round(t.v), s.unit);
}
}
/* a minute mark every 60 samples, like the bandwidth graph.
REMOVE THE OLD ONES FIRST. prepare() runs again whenever the box
changes width — on reveal, on a window resize — and appending a
second set left two ladders of labels overlapping at slightly
different offsets ("2r8m" where two "2m" and "8m" collided). Seen on
the NR7101 the moment the canvases became revealable.
The marks also go BEHIND the series: insertBefore the first path, or a
later repaint would lay grid lines over data drawn earlier. */
const first = ctx.paths[0];
for (const old of ctx.marks)
old.remove();
ctx.marks = [];
for (let i = width % (ctx.step * 60); i < width; i += ctx.step * 60) {
const line = document.createElementNS(SVGNS, 'line');
line.setAttribute('x1', i); line.setAttribute('y1', 0);
line.setAttribute('x2', i); line.setAttribute('y2', '100%');
line.setAttribute('style', 'stroke:#000;stroke-width:0.1');
const text = document.createElementNS(SVGNS, 'text');
text.setAttribute('x', i + 5);
text.setAttribute('y', 15);
text.setAttribute('style', 'fill:#666; font-size:9pt; font-family:sans-serif');
text.appendChild(document.createTextNode(Math.round((width - i) / ctx.step / 60) + 'm'));
G.insertBefore(line, first);
G.insertBefore(text, first);
ctx.marks.push(line, text);
}
},
draw: function(ctx, values) {
/* REVEAL BEFORE MEASURING, or the canvas can never appear: a node at
display:none has offsetWidth 0, prepare() bails on a zero width, and
an early return here would skip the very code that unhides it. That
deadlock is exactly what the first build did — every canvas stayed
hidden and the page showed nothing but the empty-state line. */
let revealed = false;
for (let i = 0; i < ctx.series.length; i++) {
if (values[i] == null || ctx.seen[i])
continue;
ctx.seen[i] = true;
ctx.legendRow[i].style.display = '';
revealed = true;
}
if (revealed) {
ctx.node.style.display = '';
ctx.width = 0; /* it had none while hidden — measure again */
}
this.prepare(ctx);
const height = HEIGHT - 2;
const s = ctx.scale;
/* A dBm has an absolute meaning and a fixed scale to be read against. A
carrier count and a bandwidth do not: 10 MHz on a 0..200 axis is a
line lying on the baseline, which is the same "pinned to an edge and
telling you nothing" that took RSSI off the RSRP canvas. So these two
grow with what they are showing, never below `minmax` so a steady
value does not fill the canvas, and the top is rounded up to keep the
line off the ceiling. Shared across the canvas's series, or the LTE
and 5G lines would be drawn against different axes. */
let smax = s.max;
if (s.autoscale) {
let hi = 0;
for (const buf of ctx.values)
for (const v of buf)
if (v != null && v > hi) hi = v;
/* INCLUDING THE SAMPLE THAT HAS NOT BEEN STORED YET. The buffers are
appended further down, so a value computed from them alone is one
tick stale — and every upward step then drew the new peak clamped
to the old ceiling while the legend already printed it. Visible on
the first 100 MHz sample against a 20 MHz scale. */
for (const v of (values || []))
if (v != null && v > hi) hi = v;
smax = Math.max(s.minmax || 1, Math.ceil((hi * 1.25) / (s.minmax || 1)) * (s.minmax || 1));
}
const span = smax - s.min;
/* THE VALUE GRIDLINES for a scale that has no quality ladder. The three
rules the canvas already carries are reused rather than adding
elements: same lines, neutral colour, and a plain number instead of
"excellent". Without them these two canvases had no horizontal
reference at all — a line at some height and no way to tell two
carriers from four, which is worse than the fixed scale they replaced.
PLACED HERE, NOT IN prepare(), because the top of an auto-scaled axis
moves with the data and prepare() only runs when the box is resized.
THE COLOUR IS !important FOR THE SAME REASON THE THRESHOLDS ARE: LuCI
themes restyle `#view div[style] > svg line[style]` to the theme text
colour (luci-theme-footstrap cascade.css:298-299), which would be
harmless here — but the style attribute is REWRITTEN on every draw, so
it has to carry the full declaration each time or the green of a
previous quality canvas would survive on a reused node. */
if (!s.labels) {
const gridStep = (smax >= 8) ? 1 : 0.5;
const ticks = [];
for (const frac of [ 0.25, 0.5, 0.75 ]) {
let v = s.min + span * frac;
v = s.integral ? Math.round(v) : Math.round(v / gridStep) * gridStep;
if (v > s.min && v < smax && ticks.indexOf(v) < 0)
ticks.push(v);
}
const ids = [ 't_good', 't_fair', 't_poor' ];
for (let k = 0; k < ids.length; k++) {
const line = ctx.svg.querySelector('#' + ids[k]);
const label = ctx.svg.querySelector('#' + ids[k] + '_l');
const v = ticks[k];
const y = (v != null)
? height - ((v - s.min) / span) * height : -10;
if (line) {
line.setAttribute('y1', y);
line.setAttribute('y2', y);
line.setAttribute('style',
'stroke:#888 !important;stroke-width:0.6;stroke-dasharray:4,3');
}
if (label) {
label.setAttribute('y', y - 3);
label.setAttribute('style',
'fill:#888 !important; font-size:9pt; font-family:sans-serif');
label.textContent = (v != null)
? (s.integral ? '' + v : v.toFixed(gridStep < 1 ? 1 : 0))
+ (s.unit ? ' ' + s.unit : '')
: '';
}
}
}
for (let i = 0; i < ctx.series.length; i++) {
const buf = ctx.values[i];
/* buffer every sample even while there is nowhere to draw it: a
canvas revealed a second from now should show the second it was
revealed by, not start from empty */
buf.push(values[i]);
while (buf.length > (ctx.wanted || 1))
buf.shift();
if (!ctx.width)
continue;
/* A line, not a filled area. Upstream's bandwidth graph fills down to
the baseline because zero bytes/s is a meaningful floor; -125 dBm
is not, and the slab it paints buries the threshold rules under a
block of colour.
A GAP IS NOT A ZERO either: a modem reporting no RSRP this second
must leave a hole, never a line dropping to the bottom of the
scale, which would read as "signal lost". A path says that
directly — every run of real values starts a fresh `M`. */
const d = [];
let pen = false;
for (let j = 0; j < buf.length; j++) {
const v = buf[j];
const x = ctx.width - (buf.length - 1 - j) * ctx.step;
if (v == null) {
pen = false;
continue;
}
const clamped = Math.min(smax, Math.max(s.min, v));
const y = height - ((clamped - s.min) / span) * height;
d.push('%s%d,%s'.format(pen ? 'L' : 'M', Math.round(x), y.toFixed(1)));
pen = true;
}
ctx.paths[i].setAttribute('d', d.join(' '));
/* legend: current, and the average and peak over the window that is
actually on screen — a peak from a sample that has scrolled away
is a number the user cannot see any more */
const seen = buf.filter((x) => x != null);
const cur = buf[buf.length - 1];
const avg = seen.length ? seen.reduce((a, b) => a + b, 0) / seen.length : null;
/* ONE msgid, not three. `avg` and `peak` as separate _() calls put
two context-free words in the catalogue and left the sentence
around them untranslatable, so its word order, spacing and
punctuation were fixed for every language.
ONE space before the bracket, not two: LuCI's i18n-scan collapses
whitespace when it builds the catalogue (build/i18n-scan.pl:43,85),
so a literal with a double space can never match the msgid it
generates and the translation would silently never apply. No
shipped template in the tree contains one. */
/* A carrier count is a whole number: "2.0 carriers (avg 2.0, peak
2.0)" reads as a measurement with a precision it does not have.
The average keeps a decimal on purpose — "avg 1.4" over the window
is the useful part, it says the second carrier comes and goes. */
/* THREE forms, and the unit-less one is not a nicety: "1 carriers"
is wrong in English and cannot be fixed by substituting a unit
into a fixed sentence, so the carrier canvas carries no unit at
all and its heading says what is being counted. The existing
msgid is left EXACTLY as it was for every other canvas — an
msgid that changes is a translation that silently stops
applying, and there is a .pot in this tree already. */
dom.content(ctx.legend[i], [ (cur != null)
? (s.integral
? (s.unit
? _('%d %s (avg %.1f, peak %d)').format(cur, s.unit,
avg, Math.max.apply(null, seen))
: _('%d (avg %.1f, peak %d)').format(cur,
avg, Math.max.apply(null, seen)))
: _('%.1f %s (avg %.1f, peak %.1f)').format(cur, s.unit,
avg, Math.max.apply(null, seen)))
: _('not reported') ]);
}
},
/* Build the canvases for one modem. Returns the node to place and a push()
to feed it; the caller keeps both for as long as that modem stays
selected, and drops them (fresh buffers) when it changes. */
create: function(svgText) {
const graphs = [];
/* Every canvas starts hidden, so a modem that reports nothing would
leave a blank strip between the warnings and the panels with no hint
why. Say it instead — and say the right thing: "no RSRP" is not "not
registered", which is the distinction fmt.signalNone() exists to keep
(the panel that used to make that claim was contradicted on screen by
the Serving cell panel beside it). */
const empty = E('em', {}, [ '' ]);
const node = E('div', { 'style': 'display:flex;gap:16px;flex-wrap:wrap' },
[ empty ].concat([ 'rsrp', 'rssi', 'sinr', 'rsrq', 'ecio', 'ca', 'bw' ]
.map((k) => this.mkGraph(graphs, k, svgText))));
return {
node: node,
/* `cells` is optional: the caller already fetches it for its own
panels (cached, so this costs no extra RPC), and a caller that
does not simply leaves the two aggregation canvases hidden — they
reveal on their first real value like every other one. */
push: L.bind(function(sig, reg, cells) {
const sample = fmt.signalSample(sig);
const carriers = fmt.carrierSample(cells);
for (const ctx of graphs)
this.draw(ctx, sample[ctx.kind] ?? carriers[ctx.kind]);
const any = graphs.some((ctx) => ctx.seen.some((v) => v));
empty.style.display = any ? 'none' : '';
if (!any)
dom.content(empty, [ fmt.signalNone(reg) ]);
}, this),
};
},
});
@@ -0,0 +1,404 @@
'use strict';
'require baseclass';
'require dom';
'require rpc';
'require wwand.rpc as wrpc';
'require ui';
/* The wwand log, on the status page, with the filters the log's own structure
already implies.
WHERE IT COMES FROM. `log.read` over ubus, the same source LuCI's own System
Log view uses (tools/views.js, LuCI Master as installed 2026-07-22) — not a
new daemon method and not a shell-out. An entry is
{ time, priority, msg, ... }, where the facility is priority >> 3 and the
severity is priority & 7, and `msg` is "tag[pid]: text".
IT IS NOT A POLL. The status page around it refreshes every second, and a log
that scrolls while you are reading it is worse than one you refresh yourself;
a thousand-line fetch a second would also be the most expensive thing on the
page by a wide margin. Loads once, refreshes on demand, and offers a live
toggle that is off by default. */
const callLogRead = rpc.declare({
object: 'log',
method: 'read',
params: [ 'lines', 'stream', 'oneshot' ],
expect: { log: [] },
});
const SEVERITY = [ 'emerg', 'alert', 'crit', 'err', 'warn', 'notice', 'info', 'debug' ];
/* How many log entries to ASK FOR. Not the same as how many wwand lines come
back: the ring buffer is shared with everything else on the box, and on a
chatty one (dropbear, netifd, the kernel) wwand can be a small fraction of
it. So the fetch is deliberately larger than the display count, and the
footer says how many of the fetched entries were wwand's — otherwise
"100 lines" silently means "the 12 wwand lines that happened to be in the
last 100 of everything". */
const FETCH = { '100': 600, '250': 1500, '1000': 6000, 'all': 20000 };
/* A wwand message is "wwand[pid]: <subject>: <rest>", and the subject is what
the filters are made of:
modem <name>: at2: … -> modem wwmodem0, subsystem at2
interface <name>: state … -> connection wwand0
board <id>: … -> subsystem board
autosetup: … -> subsystem autosetup
hotplug add cdc-wdm0 -> subsystem hotplug (no colon at all)
Anything that does not fit keeps its text and lands under no filter, which is
the right failure: a line the parser does not understand must still be
readable, and must not quietly disappear when a filter is set. */
function parse(msg) {
const m = /^wwand\[(\d+)\]:\s*(.*)$/.exec(msg);
if (!m)
return null;
const out = { pid: m[1], text: m[2], modem: null, conn: null, subsys: null };
let rest = out.text;
let s = /^(modem|interface|board)\s+(\S+?):\s*(.*)$/.exec(rest);
if (s) {
if (s[1] == 'modem') out.modem = s[2];
else if (s[1] == 'interface') out.conn = s[2];
else out.subsys = 'board';
rest = s[3];
}
if (!out.subsys) {
/* the next "word:" is the subsystem — at, at2, qmi, telemetry, … — but
only when it really is one. "state CONNECTED -> IDLE" has no colon,
and a bare AT response can carry one anywhere, so the token is
required to be short and free of spaces. */
const t = /^([a-z0-9_]{1,12}):\s/.exec(rest);
if (t) {
out.subsys = t[1];
out.bare = !s ? t[1] : null; /* see classify() */
} else if (!s) {
out.subsys = (/^([a-z0-9_]{1,16})\b/.exec(rest) || [])[1] || null;
}
}
return out;
}
/* SOME LINES ADDRESS A CONNECTION BY ITS BARE NAME. Most say "interface X: …",
but the datapath says "wwand0: uplink aggregation on (…)" with no prefix, and
parse() can only see a short token followed by a colon — which is exactly what
a subsystem looks like. So `wwand0` turned up in the subsystem list next to
`at` and `telemetry`, and filtering by connection missed those lines.
It cannot be settled by the SHAPE of the name: L3 names are free-form
(wwand0 is a suggestion, not a rule), so a /^wwand\d+$/ test would be right
on our boxes and wrong on anyone else's. What settles it is the log itself —
the same fetch also carries unambiguous "interface X: …" lines, and a bare
token that matches one of those names is that connection. Evidence over
pattern; a name never seen with the prefix stays a subsystem, which is the
safe direction. */
function classify(rows) {
const known = {};
for (const r of rows)
if (r.conn)
known[r.conn] = true;
for (const r of rows) {
if (r.bare && known[r.bare]) {
r.conn = r.bare;
r.subsys = null;
}
delete r.bare;
}
return rows;
}
return baseclass.extend({
/* Returns { node } — the caller places it once and never rebuilds it, the
same contract the graphs have, so the selections survive the page's
per-second repaint of everything else. */
create: function() {
let rows = []; /* the parsed wwand entries, newest last */
let fetched = 0; /* how many raw entries the last read saw */
/* The modem the PAGE is showing, so the log follows the selector above
it instead of making you set the same thing twice.
TWO REASONS IT IS NOT JUST `modem.value = name`. The dropdown is built
from the modems that appear IN THE LOG, and a modem that has said
nothing yet has no option to select — so the wish is remembered and
applied whenever its option turns up. And the page re-renders every
second: re-applying on every tick would yank the filter back the
instant the reader changed it by hand, so it is applied only when the
PAGE's selection actually changes. Theirs wins until then. */
let wantModem = null;
/* A TEXTAREA, not a styled <pre>. The first version painted its own
light background and dark text, which is a white slab in the middle of
a dark theme — the same mistake the threshold rules made in the other
direction (ddimension/wwand#14: a theme repainting our colours). A
read-only textarea is a form control, so every theme already styles it
for its own background, and it is what LuCI's own System Log view uses
— the reader gets the widget they already know, with selection and
scrolling for free. */
const out = E('textarea', {
'readonly': '', 'wrap': 'off', 'rows': 20,
'style': 'width:100%;font-family:monospace;font-size:90%',
}, [ '' ]);
const foot = E('div', { 'style': 'margin-top:.35em;font-size:90%;color:#666' }, [ '' ]);
const sel = (choices, title) => {
const e = E('select', { 'class': 'cbi-input-select', 'title': title },
choices.map((c) => E('option', { 'value': c[0] }, [ c[1] ])));
e.addEventListener('change', () => render());
return e;
};
const count = sel([ [ '100', '100' ], [ '250', '250' ], [ '1000', '1000' ],
[ 'all', _('all') ] ], _('How many wwand lines to show'));
const level = sel([ [ 'any', _('any level') ] ]
.concat(SEVERITY.slice(3).map((s) => [ s, s ])), _('Minimum severity'));
const modem = sel([ [ 'any', _('any modem') ] ], _('Only this modem'));
const conn = sel([ [ 'any', _('any connection') ] ], _('Only this connection'));
const subsys = sel([ [ 'any', _('any subsystem') ] ], _('Only this subsystem'));
/* The dropdowns are built from WHAT THE LOG ACTUALLY CONTAINS, not from
the configuration: a modem that was renamed, removed or never came up
is exactly the one whose lines you are looking for, and a list built
from uci would not offer it. Re-built on every fetch, keeping the
current selection when it is still present. */
const refill = (e, values, anyLabel, prefer) => {
const want = (prefer != null && values.indexOf(prefer) >= 0) ? prefer : e.value;
dom.content(e, [ E('option', { 'value': 'any' }, [ anyLabel ]) ].concat(
values.sort().map((v) => E('option', { 'value': v }, [ v ]))));
e.value = (values.indexOf(want) >= 0) ? want : 'any';
};
const render = () => {
const lim = count.value;
const maxSev = (level.value == 'any') ? 7 : SEVERITY.indexOf(level.value);
let show = rows.filter((r) =>
r.sev <= maxSev &&
(modem.value == 'any' || r.modem == modem.value) &&
(conn.value == 'any' || r.conn == conn.value) &&
(subsys.value == 'any' || r.subsys == subsys.value));
const total = show.length;
if (lim != 'all' && total > +lim)
show = show.slice(total - +lim);
/* .value, not dom.content(): a textarea's text is its value, and
appending a child node to one leaves the box empty on every
browser that follows the spec. */
out.value = show.length
? show.map((r) => '%s %-6s %s'.format(r.stamp, r.sevname, r.text)).join('\n')
: _('No wwand lines matched.');
out.scrollTop = out.scrollHeight;
dom.content(foot, [ _('%d of %d wwand lines, from the last %d log entries on the box.')
.format(show.length, total, fetched) ]);
};
const reload = () => {
/* RE-READ THE DAEMON'S LEVEL HERE, not only at first render. A
daemon that was down when the page opened left the control
disabled and the note stale, and nothing ever asked again — so
starting wwand meant reloading the browser page to get the
control back. Every path that refreshes the log (the button, the
auto-refresh tick, the page's modem switch) comes through this
function, which is exactly when the answer may have changed.
Raised by review, 2026-09-20. */
readDaemonLevel();
return callLogRead(FETCH[count.value] || 600, false, true).then((entries) => {
fetched = entries.length;
rows = [];
const seen = { modem: {}, conn: {}, subsys: {} };
for (const e of entries) {
const p = parse(e.msg || '');
if (!p)
continue;
const sev = (e.priority || 0) & 7;
const d = new Date(e.time);
if (p.modem) seen.modem[p.modem] = true;
if (p.conn) seen.conn[p.conn] = true;
if (p.subsys) seen.subsys[p.subsys] = true;
rows.push({
sev: sev, sevname: SEVERITY[sev] || '?',
stamp: '%02d:%02d:%02d'.format(d.getHours(), d.getMinutes(), d.getSeconds()),
modem: p.modem, conn: p.conn, subsys: p.subsys, text: p.text,
bare: p.bare,
});
}
/* second pass: the bare-name connections, now that every
"interface X:" line in this fetch has been seen */
classify(rows);
seen.conn = {};
seen.subsys = {};
for (const r of rows) {
if (r.conn) seen.conn[r.conn] = true;
if (r.subsys) seen.subsys[r.subsys] = true;
}
refill(modem, Object.keys(seen.modem), _('any modem'), wantModem);
refill(conn, Object.keys(seen.conn), _('any connection'));
refill(subsys, Object.keys(seen.subsys), _('any subsystem'));
render();
}).catch((e) => {
/* An unreadable log is a permission answer, not an empty log —
say which, or the reader concludes the daemon has gone quiet. */
out.value = _('Could not read the system log: %s').format((e && e.message) || e);
dom.content(foot, [ '' ]);
});
};
count.addEventListener('change', () => reload());
const btn = E('button', { 'class': 'btn cbi-button', 'click': ui.createHandlerFn(this, reload) },
[ _('Refresh') ]);
const liveBox = E('input', { 'type': 'checkbox' });
let timer = null;
liveBox.addEventListener('change', () => {
if (timer) { window.clearInterval(timer); timer = null; }
if (liveBox.checked)
/* STOP WHEN THE VIEW IS GONE. Nothing calls back into this widget on
navigation, so a plain setInterval would go on reading the SYSTEM
LOG every five seconds for the life of the LuCI document, holding
the whole widget alive with it. */
timer = window.setInterval(() => {
if (!node.isConnected) {
window.clearInterval(timer);
timer = null;
return;
}
reload();
}, 5000);
});
/* THE DAEMON'S OWN LEVEL, and deliberately not next to the filters.
Everything in the row above narrows what is SHOWN from lines already
fetched; this one changes what the daemon WRITES. Putting them side
by side would read as one more filter, and the difference matters:
raising this one to debug costs the box log volume, and lowering it
throws away lines nothing can get back.
RUNTIME ONLY, on purpose. `set_log_level` does not touch uci
(wwand main.uc), so a daemon restart or a config reload returns to
the configured level — which is what you want from a debugging
switch: it cannot be left on by accident for the life of the box. */
const daemonLevel = E('select', { 'class': 'cbi-input-select' },
SEVERITY.slice(3).map((s) => E('option', { 'value': s }, [ s ])));
const daemonNote = E('span', { 'style': 'color:#666' }, [ '' ]);
/* NO ANSWER IS ITS OWN ANSWER. `resolveDefault` turns a refusal or a
dead daemon into `{}`, and a control that silently keeps showing the
first option would claim the daemon logs at `err` when nothing is
running at all. So an unreadable level disables the control and says
why — and a pending change that never confirms says so too, rather
than leaving "applying…" on screen for good. */
const readDaemonLevel = () => L.resolveDefault(wrpc.globals(), {})
.then((g) => {
const ok = !!(g && g.log_level);
daemonLevel.disabled = !ok;
if (ok) {
daemonLevel.value = g.log_level;
daemonNote.textContent = '';
} else {
daemonNote.textContent = _('daemon not answering');
}
return ok;
});
daemonLevel.addEventListener('change', () => {
const want = daemonLevel.value;
daemonNote.textContent = _('applying…');
/* read back rather than trust the reply: the daemon is the only
thing that knows what it ended up at, and a refused level must
not leave the control showing a lie */
L.resolveDefault(wrpc.setLogLevel(want), {})
.then(() => readDaemonLevel())
.then((ok) => {
if (ok && daemonLevel.value != want)
daemonNote.textContent = _('refused');
});
});
const node = E('div', { 'class': 'cbi-section' }, [
E('h3', {}, [ _('wwand log') ]),
E('div', { 'style': 'display:flex;flex-wrap:wrap;gap:.5em;align-items:center;margin-bottom:.5em' }, [
count, level, modem, conn, subsys, btn,
E('label', { 'style': 'display:flex;align-items:center;gap:.3em;cursor:pointer' },
[ liveBox, _('auto-refresh') ]),
]),
E('div', { 'style': 'display:flex;flex-wrap:wrap;gap:.4em;align-items:center;margin-bottom:.6em' }, [
E('span', { 'style': 'color:#666' }, [ _('Daemon writes at') ]),
daemonLevel,
E('span', {
'title': _('The level the wwand PROCESS logs at — not a display filter. Takes effect at once and is NOT written to the configuration: a restart or a config reload returns to the configured level.'),
'style': 'cursor:help;text-decoration:underline dotted',
}, [ _('(runtime only)') ]),
daemonNote,
]),
out,
foot,
]);
/* reload() reads the daemon level itself (see there), so the first
render needs no separate call */
reload();
return {
node: node,
reload: reload,
/* called by the page when ITS modem selection changes */
selectModem: function(name) {
if (wantModem === name)
return;
wantModem = name;
if (name == null) {
modem.value = 'any';
} else if ([ ...modem.options ].some((o) => o.value == name)) {
modem.value = name;
} else {
/* not in the log yet — refill() will pick it up as soon as
this modem says something */
modem.value = 'any';
}
render();
},
};
},
});
@@ -0,0 +1,33 @@
'use strict';
'require baseclass';
/* MCC/MNC -> operator-name database. GENERATED by tools/gen-mccmnc.py
from the musalbas/mcc-mnc-table dump (ITU E.212) plus the Wikipedia
international-operators table (MCC 901/902/991). Do not edit by hand
-- rerun the generator. Layout: MCC -> { _c: country, MNC: name }. */
var DB = {"202":{"01":"Cosmote","02":"Cosmote","03":"OTE","04":"OSE","05":"Vodafone","07":"AMD Telecom","09":"Wind","10":"Wind","12":"Yuboto","14":"CyTa Mobile","15":"BWS","16":"Inter Telecom","299":"AMD Telecom","999":"Fix Line","_c":"Greece"},"204":{"00":"Intovoice","02":"T-Mobile","03":"Voiceworks Mobile","04":"Vodafone","05":"ElephantTalk","06":"Vectone Mobile","07":"Move / Teleena","08":"KPN","09":"Lycamobile","10":"KPN","12":"KPN","14":"6GMOBILE BV","15":"Ziggo","16":"T-Mobile","17":"Intercity Mobile Communications BV","18":"Ziggo Services","20":"T-Mobile","21":"ProRail","23":"KORE","24":"Private Mobility","26":"SpeakUp","27":"L-mobi","28":"Lancelot","29":"Tismi","299":"88 mobile","30":"ASPIDER Solutions","33":"Truphone","63":"MessageBird","68":"Unify Mobile","69":"KPN","98":"Lancelot","999":"Fix Line","_c":"Netherlands"},"206":{"00":"Proximus","01":"Proximus","02":"Infrabel","04":"Proximus","05":"Base","06":"Lycamobile","07":"Vectone Mobile","08":"VOOmobile","10":"Mobistar / Orange","20":"Base","23":"Dust Mobile","25":"Dense Air","28":"BICS","299":"FEBO","30":"Mobile Vikings","33":"Ericsson","34":"onoff","99":"Lancelot","999":"Fix Line","_c":"Belgium"},"208":{"00":"Tel/Te","01":"Orange","02":"Orange","03":"MobiquiThings","04":"Axialys","05":"GlobalStar","06":"GlobalStar","07":"GlobalStar","08":"SFR","09":"SFR","10":"SFR","11":"SFR","12":"Truphone","13":"SFR","14":"Free Mobile","15":"Free Mobile","16":"Free Mobile","17":"Legos","20":"Bouygues Telecom","21":"Bouygues Telecom","22":"Transatel","23":"Hub One","24":"MobiquiThings","25":"Lycamobile","26":"NRJ","27":"Coriolis","28":"Airmob","29":"Orange","299":"Add-On Multimedia","30":"Syma Mobile","31":"Vectone Mobile","32":"Orange","34":"Cellhire","35":"Free Mobile","36":"Free Mobile","37":"IP Directions","38":"Lebara","39":"Networth Telecom","88":"Bouygues Telecom","89":"Hub One","91":"Orange","92":"IP Directions","94":"Halys","999":"Fix Line","_c":"France"},"212":{"01":"Monaco Telecom","10":"Monaco Telecom","_c":"Monaco"},"213":{"03":"Andorra Telecom / Mobiland","_c":"Andorra"},"214":{"01":"Vodafone","02":"Alta Tecnologia en Comunicacions","03":"Orange","04":"Yoigo","05":"Movistar","06":"Vodafone","07":"Movistar","08":"Euskaltel Movil","09":"Orange","10":"Zinnia","11":"Orange","12":"Venus Movil","14":"Avatel Movil","15":"BT Espana SAU","16":"mobil R","17":"mobil R","18":"Cableuropa SAU (ONO)","19":"Simyo","20":"fonYou Wireless SL","21":"Jazz Telecom SAU","22":"Digi.Mobil","23":"Yoigo","25":"Lycamobile","26":"Lleida","27":"Truphone","29":"Yoigo","299":"ACN","32":"ION Mobile","33":"Yoigo","34":"ION MOBILE","35":"SUMA movil","36":"Alai","37":"Vodafone","38":"Movistar","999":"Fix Line","_c":"Spain"},"216":{"01":"Telenor","02":"MVM NET","03":"Digi","299":"Antenna","30":"Telekom","70":"Vodafone","71":"UPC Magyarorszag Kft.","999":"Fix line","_c":"Hungary"},"218":{"03":"Eronet","05":"m:tel","90":"BH Mobile","_c":"Bosnia and Herzegovina"},"219":{"01":"T-Mobile","02":"Telemach / Tele2","10":"A1 / VIP","12":"TELE FOCUS","999":"Fix Line","_c":"Croatia"},"220":{"01":"Telenor","02":"Telenor","03":"MTS","05":"VIP","11":"Globaltel","20":"VIP","299":"Failed Calls","_c":"Serbia"},"221":{"01":"Vala","02":"IPKO","03":"MTS","06":"Dardafon.Net LLC","07":"D3 mobile","299":"MTS","_c":"Kosovo"},"222":{"00":"Premium Numbers","01":"TIM","02":"Elsacom","06":"Vodafone","07":"Noverca Italia","08":"Fastweb","10":"Vodafone","299":"A-Tono","30":"RFI","33":"Poste Mobile","34":"BT mobile","35":"Lycamobile","36":"Digi","37":"WindTre / Hi3G","39":"SMS.it / LINK Mobility","40":"Agile Telecom","42":"Enel","43":"Telecom Italia Mobile","44":"Mundio","48":"Telecom Italia Mobile","49":"Vianova Mobile","50":"Iliad","51":"ho.","53":"CoopVoce","54":"Plintron","56":"spusu","58":"rdcom","77":"IPSE 2000","88":"WindTre / WIND","99":"WindTre / Hi3G","999":"Fix Line","_c":"Italy"},"225":{"299":"Failed Calls","_c":"Vatican"},"226":{"01":"Vodafone","02":"Romtelecom SA","03":"Telekom","04":"Telekom Romania","05":"Digi Mobil","06":"Telekom Romania","10":"Orange","11":"Enigma Systems","16":"Lycamobile","299":"Iristel","_c":"Romania"},"228":{"01":"Swisscom","02":"Sunrise","03":"Salt Mobile","05":"Comfone","06":"SBB","07":"Sunrise","08":"Sunrise","09":"Comfone","12":"Sunrise","51":"relario","52":"Mundio Mobile AG","53":"Sunrise","54":"Lycamobile","58":"Beeone","59":"Vectone Mobile","60":"Sunrise","62":"Telecom26","65":"Nexphone","66":"Inovia","69":"MTEL","70":"Tismi","999":"Fix Line","_c":"Switzerland"},"230":{"01":"T-Mobile","02":"O2","03":"Vodafone","04":"Nordic Telecom","05":"PODA","07":"T-Mobile","08":"Compatel","09":"Uniphone","299":"+4U Mobile","98":"SZDC","99":"Vodafone","999":"Fix Line","_c":"Czech Republic"},"231":{"01":"Orange","02":"Telekom","03":"Swan / 4ka","04":"Telekom","05":"Orange","06":"O2","07":"Orange","08":"Uniphone","15":"Orange","299":"Vonage","50":"Telekom","99":"ZSR","_c":"Slovakia"},"232":{"01":"A1 Telekom","02":"A1 Telekom","03":"T-Mobile / Magenta","04":"T-Mobile / Magenta","05":"Line truncated
return baseclass.extend({
/* operator name for an mcc/mnc (numbers or strings; 2- and 3-digit MNC
forms are both tried) -- null when unknown */
name: function(mcc, mnc) {
var m = DB['' + mcc];
if (!m || mnc == null) return null;
var s = '' + mnc;
var two = (s.length < 2) ? ('0' + s) : s;
return m[two] || m[s] || m['0' + s] || null;
},
/* country (or 'International') for an mcc -- null when unknown */
country: function(mcc) {
var m = DB['' + mcc];
return m ? (m._c || null) : null;
},
/* "name, country" one-liner, or null */
describe: function(mcc, mnc) {
var n = this.name(mcc, mnc), c = this.country(mcc);
if (!n) return null;
return (c && c != 'International') ? (n + ', ' + c) : n;
}
});
@@ -0,0 +1,710 @@
'use strict';
'require baseclass';
'require uci';
'require form';
'require ui';
'require wwand.format as fmt';
'require wwand.rpc as wrpc';
'require wwand.modemsid as modemsid';
/* Shared wwand_modem option/tab definitions, used by BOTH surfaces:
- the interface proto handler (protocol/wwand.js), inline WireGuard-style,
where `bind(o)` redirects storage to the referenced wwand_modem section;
- the dedicated Modems editor page (view/wwand/modems.js), where the edited
section IS the wwand_modem, so `bind` is a pass-through.
Options are grouped into the agreed tabs: Modem & SIM · Radio & Cell ·
Resilience. The interface-owned Connection tab stays in the proto handler. */
/* ubus declarations live in the shared wwand.rpc module; this module wants the
RAW status object (board/modems), hence statusRaw. */
var callGpioList = wrpc.gpioList;
var callStatus = wrpc.statusRaw;
var callModemReset = wrpc.modemReset;
var callSlots = wrpc.slots;
var callProbe = wrpc.probe;
/* This module is shared by two surfaces, and `section_id` does not mean the same
thing on both: on Network → Modems it IS the wwand_modem name, while on
Network → Interfaces → Modem & SIM it is the *interface* section, whose modem
is whatever `option modem` points at. Every lookup into a status reply goes
through here, because those replies are keyed by modem name — indexing one
with a raw section_id simply misses on the interface page, and misses
quietly: the code reads as if that modem has nothing to report. */
function modemOf(section_id) {
return modemsid.modemSid(section_id) || section_id;
}
/* Add a Combobox choice ONCE. These option objects are shared across every
wwand_modem section of the GridSection, so LuCI calls load() (and thus our
self.value()) once PER section — a plain self.value() then appends the same
probe-derived choice several times, which showed up as duplicate entries in
the Bind-by-serial/IMEI/path and reset-GPIO dropdowns on a multi-modem box.
Guarding against the option's existing keylist makes it idempotent. */
function uniqVal(o, k, label) {
if (!o.keylist) o.keylist = [];
if (o.keylist.indexOf(k) >= 0) return;
o.value(k, label);
}
/* Label for one entry of the daemon's datapath catalog (status globals.datapaths:
{ name, kind: mode|builtin|plugin, proto: [...] | null, description }).
Only the modes get a translated name — everything else is called what
`option mux` must literally say, since a datapath name is also the module
name of its add-on package. The protocol tag stays on the
label even though the list is filtered per row: it is what explains WHY a
given modem is offered these three and not those five. */
function dpLabel(e) {
var names = { auto: _('Automatic'), raw_ip: _('No multiplexing (plain raw IP)'),
untagged: _('No multiplexing (MBIM session 0, untagged)') };
var tags = [];
if (e.kind == 'plugin')
tags.push(_('add-on'));
if (e.proto && e.proto.length)
tags.push(e.proto.map(function(p) { return p.toUpperCase(); }).join('/'));
return (names[e.name] || e.name) + (tags.length ? ' — ' + tags.join(', ') : '');
}
return baseclass.extend({
/* Modem & SIM — hardware identity + SIM. The primary "device" itself is set
elsewhere (the modem dropdown on the interface; the section name / a device
field on the Modems page); this adds the rest. */
addModemSim: function(s, tab, bind) {
var o;
/* NOTE (hardware-identity fields serial/imei/path): these render as
create-enabled Comboboxes, and LuCI's ui.Combobox materializes a
stored value that is absent from the (probe-derived) choice list —
THAT invariant is what keeps Save from deleting an existing binding
when no live probe data is available. Keep them create-enabled
(adding .value() choices does that) and keep the load() fallthrough
to super('load'). */
o = s.taboption(tab, form.Value, 'serial', _('Bind by USB serial'),
_('Pin this modem by its USB iSerial — a stable identity that follows the modem across renumbering and port changes (read before the modem is opened). Pick a detected modem below, or leave empty.'));
o.ucioption = 'serial';
bind(o);
o.load = function(section_id) {
var self = this;
return L.resolveDefault(callProbe(), {}).then(function(res) {
var seen = {};
((res && res.present) || []).forEach(function(p) {
if (!p.serial || seen[p.serial]) return;
seen[p.serial] = true;
uniqVal(self, p.serial, p.serial + (p.model ? ' — ' + p.model : '')
+ (p.usb_path ? ' (' + p.usb_path + ')' : ''));
});
return self.super('load', [section_id]);
});
};
o = s.taboption(tab, form.Value, 'imei', _('Bind by IMEI'),
_('Pin this modem by its IMEI — globally unique and verified after the modem opens. A configured IMEI that does not match blocks bring-up, so the wrong physical modem never gets this SIM/APN. Pick a detected modem below, or leave empty.'));
o.ucioption = 'imei';
o.datatype = 'and(uinteger,minlength(14),maxlength(16))';
bind(o);
o.load = function(section_id) {
var self = this;
return L.resolveDefault(callProbe(), {}).then(function(res) {
var seen = {};
/* managed carries the live-opened IMEI; present[] carries the
pre-open IMEI too — offer both so a configured-but-waiting
modem still shows a pickable identity */
((res && res.managed) || []).concat((res && res.present) || [])
.forEach(function(m) {
if (!m.imei || seen[m.imei]) return;
seen[m.imei] = true;
uniqVal(self, m.imei, m.imei + (m.model ? ' — ' + m.model : ''));
});
return self.super('load', [section_id]);
});
};
o = s.taboption(tab, form.Value, 'path', _('Device path binding'),
_('Stable device topology anchor (like a wifi-device <code>path</code>) — the modem\'s sysfs path relative to /sys/devices, so it survives USB renumbering and is PCIe/MHI-ready. Pick a detected modem below or type a path; empty = bind by the modem device. Prefer <em>Bind by USB serial/IMEI</em> when available.'));
o.ucioption = 'path';
bind(o);
o.load = function(section_id) {
var self = this;
return L.resolveDefault(callProbe(), {}).then(function(res) {
var seen = {};
((res && res.present) || []).forEach(function(p) {
if (!p.path || seen[p.path]) return;
seen[p.path] = true;
uniqVal(self, p.path, p.path + (p.model ? ' — ' + p.model : '')
+ (p.usb_path ? ' (' + p.usb_path + ')' : ''));
});
return self.super('load', [section_id]);
});
};
o = s.taboption(tab, form.Value, 'reset_gpio', _('Modem reset GPIO'),
_('Named GPIO on the modem RESET line. When set, recovery pulses it (invert, wait 30 s, restore) instead of cycling USB power.'));
o.ucioption = 'reset_gpio';
bind(o);
o.load = function(section_id) {
var self = this;
return Promise.all([
L.resolveDefault(callGpioList('/sys/class/gpio/'), []),
L.resolveDefault(callStatus(), {})
]).then(function(res) {
(res[0] || []).forEach(function(n) { uniqVal(self, n, n); });
var brg = (res[1] || {}).board && res[1].board.reset_gpio;
/* NOT "only to override": the board's line serves explicit
resets, but the automatic pulse for a modem that never
answered takes only a line set HERE (wwand recovery.uc
unarmed_reset_line), so leaving this empty switches that
pulse off (ddimension/wwand#40) */
if (brg)
self.description = _('Named GPIO on the modem RESET line (invert, wait 30 s, restore instead of a USB power-cycle). This board provides the reset GPIO "%s", which explicit resets use anyway; set it here as well to let wwand pulse it automatically when the modem never answers after boot.').format(brg);
return self.super('load', [section_id]);
});
};
o = s.taboption(tab, form.Value, 'reset_fallback', _('Reset GPIO fallback'),
_('Seconds a reset waits for the modem\'s own soft reset to take it off USB before the reset GPIO is pulsed (default 30).'));
o.datatype = 'uinteger';
o.placeholder = '30';
o.optional = true;
bind(o);
/* Quectel only. The level is the BOARD's wiring, never a preference:
with the wrong one the modem takes an inserted card for removed —
which is why the default leaves the modem's own value alone. */
o = s.taboption(tab, form.ListValue, 'sim_detect', _('SIM hot-plug detection'),
_('Quectel modems: let the modem notice a SIM card that is inserted or removed while it runs. "Inserted" is the level of the SIM tray\'s detect pin with a card in — it depends on the board; a wrong level makes the modem see no card.'));
o.value('', _('Leave modem setting unchanged'));
o.value('high', _('On, card detected at high level'));
o.value('low', _('On, card detected at low level'));
o.value('off', _('Off'));
o.optional = true;
bind(o);
o = s.taboption(tab, form.Button, '_reset', _('Reset modem now'),
_('Reset this modem: the control protocol\'s soft reset first (QMI/MBIM offline+reset, AT+CFUN=1,1), so it can shut down cleanly; a dedicated reset GPIO is pulsed only if the modem does not reboot. Recovers a modem that hung or dropped off USB.'));
o.inputtitle = _('Reset modem');
o.inputstyle = 'remove';
o.modelabel = false;
o.onclick = function(ev, section_id) {
var modem = modemOf(section_id);
return callModemReset(modem).then(function(res) {
if (res && (res.ok || res.resetting))
ui.addNotification(null, E('p', {}, [ _('Modem reset triggered (%s).').format(res.action || '?') ]), 'info');
else
ui.addNotification(null, E('p', {}, [ _('Reset unavailable: %s.').format((res && res.error) || _('no reset control')) ]), 'warning');
});
};
o = s.taboption(tab, form.Value, 'tty', _('AT control TTY'),
_('Override the auto-detected AT serial port (e.g. /dev/ttyUSB2).'));
bind(o);
/* The remedy the daemon names in its own error message when it cannot
classify a control device. Leave it on "detect" unless wwand has told
you it could not tell — a pin that contradicts a driver wwand DOES
recognise is honoured, but hardware recovery then stays disarmed for
that modem, because an AT port answers on QMI and MBIM modems too and
so proves nothing about the pin. */
o = s.taboption(tab, form.ListValue, 'protocol', _('Control protocol'),
_('Which protocol wwand uses to drive this modem. Leave on "detect" — this exists for a control device whose driver wwand cannot classify, and it will say so in the log. Pinning a protocol that contradicts a driver wwand does recognise disables hardware recovery for this modem, because an answering AT port would then prove nothing.'));
o.value('', _('detect (recommended)'));
o.value('qmi', 'QMI');
o.value('mbim', 'MBIM');
o.value('ncm', _('NCM (AT + ethernet data port)'));
o.optional = true;
bind(o);
/* The INITIAL-ATTACH bearer. The attach happens before any data session
and some networks want their own APN for it, while the data
connection uses another. Unset = the same as the interface, which is
what every deployment did before this existed. */
o = s.taboption(tab, form.Value, 'init_apn', _('Attach APN'),
_('APN for the initial network attach, when the network wants a different one from the data connection (an IMS or admin bearer). Leave empty to attach with the interface\'s own APN, which is the normal case.'));
o.placeholder = _('same as the interface');
o.optional = true;
bind(o);
o = s.taboption(tab, form.ListValue, 'init_auth', _('Attach authentication'),
_('Authentication for the attach bearer. Only used together with an attach APN.'));
o.value('', _('modem default'));
o.value('none', _('none'));
o.value('pap', 'PAP');
o.value('chap', 'CHAP');
o.value('both', _('PAP or CHAP'));
o.optional = true;
o.depends('init_apn', /.+/);
bind(o);
o = s.taboption(tab, form.Value, 'init_user', _('Attach username'),
_('Username for the attach bearer. Ignored without an attach APN — it would otherwise apply to whatever APN the modem\'s attach profile already held.'));
o.optional = true;
o.depends('init_apn', /.+/);
bind(o);
o = s.taboption(tab, form.Value, 'init_pass', _('Attach password'),
_('Password for the attach bearer.'));
o.password = true;
o.optional = true;
o.depends('init_apn', /.+/);
bind(o);
/* A headless box has no UI to render a SETUP MENU, so advertising a
phone's terminal profile invites an operator campaign to send one and
then wait for an answer that never comes. */
o = s.taboption(tab, form.ListValue, 'cat_mode', _('SIM toolkit'),
_('How SIM Application Toolkit is routed. A router has no screen to display operator menus or texts on, so a modem advertising a phone-like profile can leave the card waiting for a response nothing here will send. "disabled" stops routing toolkit to a control point. Unset leaves the modem exactly as the vendor configured it.'));
o.value('', _('modem default (do not change)'));
o.value('disabled', _('disabled'));
o.value('gobi', 'Gobi');
o.value('android', 'Android');
o.value('decoded', _('decoded'));
o.value('decoded_pullonly', _('decoded, pull-only'));
o.value('custom_raw', _('custom (raw)'));
o.value('custom_decoded', _('custom (decoded)'));
o.optional = true;
bind(o);
o = s.taboption(tab, form.ListValue, 'fcc_auth', _('FCC unlock'),
_('RF unlock for laptop-SKU modems that boot radio-locked (Lenovo/Dell/HP variants of Quectel EM1xx, Foxconn SDX55/SDX62). Default "auto" tries the known QMI unlock messages when the modem stays in low-power after going online; MBIM modems need the explicit "quectel" method. "off" disables the unlock entirely.'));
o.value('', _('auto (QMI only)'));
o.value('off', _('off'));
o.value('dms', _('QMI DMS (Quectel EM1xx)'));
o.value('foxconn', _('QMI Foxconn (SDX55, DW5821e)'));
o.value('quectel', _('MBIM Quectel radio state'));
o.optional = true;
bind(o);
o = s.taboption(tab, form.Flag, 'at2_external', _('Release secondary AT port'),
_('Reserve the modem\'s second AT port for external tools (gpsd, own scripts): wwand never opens it and runs telemetry over the control channel instead. The modem status shows which port was released.'));
o.default = '0';
bind(o);
/* Which port a modem released is a PER-ROW fact, and `description` is a
per-column field — writing it from load() made the last modem in the
status reply speak for every row, including modems that released
nothing. Remember it per section and show it beside that row's own
widget instead. */
o.load = function(section_id) {
var self = this;
return L.resolveDefault(callStatus(), {}).then(function(res) {
var m = ((res || {}).modems || {})[modemOf(section_id)];
self.at2Port = self.at2Port || {};
self.at2Port[section_id] = (m && m.at2_released) || null;
return self.super('load', [section_id]);
});
};
o.renderWidget = function(section_id, option_index, cfgvalue) {
/* NOT this.super('renderWidget', ...). These overrides sit on the
INSTANCE, and LuCI's super() resolves from the class's PARENT
prototype (luci.js:236): for a form.Flag that is CBIValue, whose
renderWidget builds a text/select widget — so the checkbox was
replaced by one, getUIElement() then found a widget without
isChecked(), and every save on the form threw
(ddimension/luci-app-wwand#3). Worse for a form.Value: its parent
CBIAbstractValue has no renderWidget at all, so super() returns
null. Two earlier fixes here blamed the wrapping and the appending;
both were wrong, and the reporter's console output — this option
MISMATCH, every unwrapped flag OK — is what finally placed it. */
var node = form.Flag.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
var port = (this.at2Port || {})[section_id];
if (!port)
return node;
/* APPEND into the widget's own node — never wrap it in a parent.
Wrapping looks harmless and is not: getUIElement() resolves with
dom.findClassInstance(), which walks UPWARDS from the id-bearing
node until it finds a bound class, and an extra parent changes
what that walk reaches. Flag.formvalue() then calls isChecked()
on something that is not a checkbox and the whole modal save
throws — every other flag on the page worked, this one did not
(ddimension/luci-app-wwand#3, measured in the browser: both
`cbid.…at2_external` and `widget.cbid.…at2_external` resolved to
an instance without isChecked, while lowpower/location/... were
fine). A code-reading argument said the wrap was safe; the
measurement said otherwise, and the measurement is right.
The hint text goes in as a one-element ARRAY, not a bare string:
dom.append() assigns a bare string via innerHTML (luci.js:1394-96)
and only an array element becomes a createTextNode (:1382-83). The
port comes from the daemon, so the escaped path is the right one. */
node.appendChild(E('div', {
'style': 'font-size:90%;margin-top:4px',
}, [ _('wwand leaves %s untouched and polls telemetry over the control channel.')
.format(port) ]));
return node;
};
/* Not a ListValue: `option mux` also takes the name of an add-on datapath
package (wwand.datapath_<name>), so the field must accept a value that
is not in the list — a fixed dropdown made those unreachable from LuCI
although the daemon supports them. The list is filled from what the
daemon reports as selectable ON THIS BOX (modes + built-ins + installed
plugins, each carrying the control protocols it serves), so an
installed rmnet_nss shows up as a normal choice and a list hardcoded
here cannot go stale behind the daemon. */
o = s.taboption(tab, form.Value, 'mux', _('Data multiplexing'),
_('Kernel datapath that carries this modem\'s data sessions. Leave on automatic unless a modem misbehaves — under automatic an installed vendor datapath that recognises this hardware is used on its own. Naming one pins it. Only datapaths this modem\'s control protocol can use are offered. What automatic settled on is shown as <em>configured → actual</em> under Datapath on the status page: a single interface on an MBIM modem lands on <em>untagged</em>, where the session rides the parent device with no 802.1q tag on every frame, and a QMI modem that turns out not to carry QMAP lands on <em>raw_ip</em>.'));
o.default = 'auto';
/* seed: the field must offer something even if the daemon is not
answering (stopped, no modem yet) — the load() below adds the rest */
o.value('auto', _('Automatic'));
o.validate = function(section_id, value) {
/* a hyphen is accepted although a datapath name carries none: it is
how 'raw-ip' gets written, and the daemon canonicalises it */
if (value == null || value === '' || /^[a-z][a-z0-9_-]*$/.test(value))
return true;
return _('A datapath name: lowercase letters, digits, underscore and hyphen.');
};
o.load = function(section_id) {
var self = this;
return L.resolveDefault(callStatus(), {}).then(function(reply) {
var list = ((reply || {}).globals || {}).datapaths || [];
/* name -> the protocols it serves, and section -> this modem's
control protocol; renderWidget below needs both to decide what
a given ROW may be offered. */
self.dpServes = self.dpServes || {};
self.dpProto = self.dpProto || {};
var mine = ((reply || {}).modems || {})[modemOf(section_id)];
self.dpProto[section_id] = mine ? mine.protocol : null;
list.forEach(function(e) {
/* tolerate the pre-1.6 shape (a plain array of names) */
if (typeof e == 'string') e = { name: e };
if (!e || !e.name) return;
self.dpServes[e.name] = e.proto || null;
uniqVal(self, e.name, dpLabel(e));
});
return self.super('load', [section_id]);
});
};
/* Filter the choices PER ROW. A LuCI option object is shared by every
section of a GridSection, so o.value() is per column and the plain
list would be the union across a QMI and an MBIM modem — offering each
of them datapaths the daemon now refuses outright. The rendering hook
is the one place that knows which section it is drawing, so the
keylist is narrowed there and restored afterwards. */
o.renderWidget = function(section_id, option_index, cfgvalue) {
var allKeys = this.keylist || [], allVals = this.vallist || [];
var proto = (this.dpProto || {})[section_id];
var serves = this.dpServes || {};
var keys = [], vals = [];
for (var i = 0; i < allKeys.length; i++) {
var p = serves[allKeys[i]];
/* unknown protocol (modem not running, or a daemon that does not
report it) shows everything — narrowing on a guess would hide
the very option someone came to set. A mode (proto null) fits
every modem, and the value already configured is always kept
so an existing config is never silently dropped from its own
dropdown. */
if (!proto || !p || p.indexOf(proto) >= 0 || allKeys[i] === cfgvalue) {
keys.push(allKeys[i]);
vals.push(allVals[i]);
}
}
this.keylist = keys;
this.vallist = vals;
try {
return form.Value.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
}
finally {
this.keylist = allKeys;
this.vallist = allVals;
}
};
bind(o);
o = s.taboption(tab, form.Value, 'dl_datagram_max_size', _('Aggregation DL datagram size'),
_('Max downlink QMAP aggregation datagram in bytes (0 = board/model default).'));
o.placeholder = '0';
o.datatype = 'uinteger';
bind(o);
o = s.taboption(tab, form.DynamicList, 'at_init', _('Extra AT init commands'),
_('Vendor AT commands sent once after the modem is detected, before registration.'));
o.placeholder = 'ATE0';
bind(o);
o = s.taboption(tab, form.Value, 'sim_slot', _('SIM slot'),
_('Physical SIM slot to activate on multi-slot / eSIM modems (0 = leave as-is). The list shows the slots the modem reports.'));
o.placeholder = '0';
o.datatype = 'uinteger';
bind(o);
o.value('0', _('0 — leave as-is'));
/* enrich the dropdown with the modem's actual slots (card / ICCID / eUICC).
Best-effort: talks to live modems; on any failure the plain field stays. */
o.load = function(section_id) {
var self = this;
/* THIS modem's slots, not every modem's. The section id is the modem
name, so there is no need to sweep the status reply — and sweeping
it was wrong twice over: the choices land on the shared column, so
one modem was offered another's slot numbers AND its ICCIDs, and
picking one saved a sim_slot that modem does not have. Slot number
is `physical` (what format.js/esim.js and modem_sim_switch_slot
use), not `slot`. */
return L.resolveDefault(callStatus(), {}).then(function(reply) {
var modem = modemOf(section_id);
if (!((reply && reply.modems) || {})[modem])
return self.super('load', [section_id]);
return L.resolveDefault(callSlots(modem), {}).then(function(r) {
var seen = {};
self.slotsOf = self.slotsOf || {};
self.slotsOf[section_id] = { '0': true };
((r && r.slots) || []).forEach(function(sl) {
/* an inferred row is not a slot the modem named, so it
is not a slot to configure — see fmt.slotEnumerated.
On such a modem the list correctly collapses to
"leave as-is", which is the only truthful choice. */
if (!fmt.slotEnumerated(sl)) return;
if (sl.physical == null || seen[sl.physical]) return;
seen[sl.physical] = true;
self.slotsOf[section_id][String(sl.physical)] = true;
/* the card's name first when it has one (daemon
modem_sim_slots `name`, from its wwand_sim): the
ICCID alone does not say which card is which
(ddimension/wwand#39). A PLAIN STRING: this
dropdown escapes a string label itself, so `%h`
showed `&#60;b&#62;`, and it stringifies an array,
commas and all — both seen on 245 with a name of
"M2M <b>test</b>" (luci-base as built for the
chateau tree, 2026-10-05). */
var lbl = _('Slot %d').format(sl.physical);
if (sl.iccid)
lbl += ' — ' + (sl.name ? String(sl.name) + ' · ' : '') + sl.iccid + (sl.is_euicc ? ' (eUICC)' : '');
else
lbl += ' — ' + _('empty');
uniqVal(self, String(sl.physical), lbl);
});
return self.super('load', [section_id]);
});
});
};
/* ...and offer each row only its own modem's slots: the choices above
land on the shared column, so without this every modem still sees
every other modem's slot numbers and ICCIDs. Unknown modem (not
running, or the status call failed) shows the full list rather than
an empty one, and the configured value is always kept. */
o.renderWidget = function(section_id, option_index, cfgvalue) {
var allKeys = this.keylist || [], allVals = this.vallist || [];
var mine = (this.slotsOf || {})[section_id];
var keys = [], vals = [];
if (!mine)
return form.Value.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
for (var i = 0; i < allKeys.length; i++)
if (mine[allKeys[i]] || allKeys[i] === cfgvalue) {
keys.push(allKeys[i]);
vals.push(allVals[i]);
}
this.keylist = keys;
this.vallist = vals;
try {
return form.Value.prototype.renderWidget.call(this, section_id, option_index, cfgvalue);
}
finally {
this.keylist = allKeys;
this.vallist = allVals;
}
};
o = s.taboption(tab, form.Value, 'pincode', _('SIM PIN'),
_('Default SIM PIN for this modem, entered on each start. Empty = unlocked SIM. Per-card overrides live in the SIMs list.'));
o.datatype = 'and(uinteger,minlength(4),maxlength(8))';
bind(o);
},
/* Radio (RAT + manual PLMN). Cell lock lives in addCellLock (same tab). */
addRadio: function(s, tab, bind) {
var o;
o = s.taboption(tab, form.ListValue, 'modes', _('Radio technology'),
_('Restrict the modem to certain radio access technologies.'));
o.value('', _('Modem default'));
o.value('all', _('All'));
o.value('lte', 'LTE');
o.value('nr5g', '5G NR');
o.value('lte,nr5g', 'LTE + 5G NR');
o.value('umts', 'UMTS');
o.value('gsm', 'GSM');
o.value('td-scdma', 'TD-SCDMA');
o.value('cdma', 'CDMA');
bind(o);
o = s.taboption(tab, form.Value, 'mcc', _('MCC'),
_('Mobile Country Code for manual network selection.'));
o.datatype = 'uinteger';
bind(o);
o = s.taboption(tab, form.Value, 'mnc', _('MNC'),
_('Mobile Network Code (requires MCC).'));
o.datatype = 'uinteger';
bind(o);
/* preferred-PLMN list restored before every radio-on (managed on the
modem status/tools page; a per-SIM list wins over this one) */
o = s.taboption(tab, form.ListValue, 'plmn_list', _('Preferred-PLMN list'),
_('Optional: a saved PLMN list (config wwand_plmnlist) the daemon restores before every radio-on. Edit lists on the modem status page.'));
o.rmempty = true;
o.value('', _('(none)'));
uci.sections('network', 'wwand_plmnlist').forEach(function(sec) {
o.value(sec['.name'], sec['.name'] + ' (' + ((sec.type == 'user') ? 'user' : (sec.type == 'fplmn') ? 'FPLMN' : 'NAS') + ')');
});
bind(o);
},
/* Cell lock fields (kept separate so the caller can slot the live cell-scan
widget between the radio options and the lock inputs). Returns the two
lock options so a scan widget can push values into them. */
addCellLock: function(s, tab, bind) {
var lock4g = s.taboption(tab, form.DynamicList, 'lock_4g', _('LTE cell lock'),
_('Lock to LTE cells, one "earfcn:pci" per entry (several = a cell list).'));
lock4g.placeholder = '1300:246';
bind(lock4g);
/* SCS IS kHz — say so where the user will actually read it. The
* one-click button in the protocol page fills this field with a 30 kHz
* default because nothing in the scan reports the SSB spacing, and the
* field is where that guess becomes visible and editable before Save.
* (It used to fill in a '1', which is not a spacing at all and the
* modem rejected the command.) Found by a full review, 2026-09-19. */
var lock5g = s.taboption(tab, form.Value, 'lock_5g', _('5G NR SA cell lock'),
_('Lock to a 5G SA cell: "pci:arfcn:scs:band" — the subcarrier spacing is in kHz (15, 30, 60 …). The "Lock this 5G cell" button fills in 30 as a default; the modem does not report the actual spacing, so check it against your band before saving.'));
lock5g.placeholder = '242:431070:30:78';
bind(lock5g);
var persist = s.taboption(tab, form.Flag, 'lock_persist', _('Persist lock in modem'),
_('Store the cell lock in modem non-volatile memory.'));
persist.default = '0';
bind(persist);
return { lock4g: lock4g, lock5g: lock5g };
},
/* Resilience / recovery / telemetry cadence. */
addResilience: function(s, tab, bind) {
var o;
o = s.taboption(tab, form.Value, 'delay', _('Modem init delay'),
_('Seconds to wait before initializing the modem.'));
o.placeholder = '0';
o.datatype = 'min(0)';
bind(o);
o = s.taboption(tab, form.Value, 'failreboot', _('Reboot after N failures'),
_('Reboot the router after this many failed connection attempts. 0 = never reboot — the hardware recovery rungs (op-mode cycle, modem reset, GPIO/repower) still run and the ladder then keeps retrying.'));
o.placeholder = '100';
o.datatype = 'uinteger';
bind(o);
/* The three hardware rungs' attempt counts (recovery_steps). The
daemon refuses a list that does not rise and keeps 8/16/24, with a
config warning; the check here only catches the shape early. */
o = s.taboption(tab, form.Value, 'recovery_steps', _('Recovery steps'),
_('Failed attempts at which the recovery ladder cycles the radio, resets the modem and pulses the reset line / repowers it, e.g. "2 6 12". One or two numbers replace only the first steps. Empty = 8 16 24. An earlier radio cycle helps after a registration reject, which the modem may only retry after a switch-off.'));
o.placeholder = '8 16 24';
o.validate = function(section_id, value) {
if (!value || /^\s*\d+([\s,]+\d+){0,2}\s*$/.test(value))
return true;
return _('One to three whole numbers, rising, e.g. "2 6 12"');
};
bind(o);
/* The one automatic hardware action on a modem that has never
answered (a wedged modem after a reboot looks exactly like that):
a pulse of the reset GPIO assigned to THIS modem, once per outage.
Without a reset GPIO on the modem it does nothing. */
o = s.taboption(tab, form.Value, 'unarmed_reset_after', _('Reset a never-answering modem after (s)'),
_('If the modem has not answered since boot or since the outage began, pulse its reset GPIO once after this many seconds. Only a modem with its own reset GPIO set is touched, never another modem on the router. 0 = never.'));
o.placeholder = '300';
o.datatype = 'uinteger';
bind(o);
o = s.taboption(tab, form.Value, 'proto_error_limit', _('Reboot after N protocol errors'),
_('Reboot after this many consecutive control-protocol errors. Gated by the failure-reboot setting above — with reboot disabled it never fires.'));
o.placeholder = '25';
o.datatype = 'uinteger';
bind(o);
/* Off by default on purpose: a parked radio is not reachable, which is
the opposite of what most routers want. */
o = s.taboption(tab, form.Flag, 'lowpower', _('Park the radio when idle'),
_('Put the modem\'s radio into low power once no interface on this modem is up — for battery and solar installs, where an idle modem still spends a couple of watts holding a registration nobody uses. Only on an administrative down, never on a brief connection loss. A parked modem is NOT reachable from the network until the interface is brought up again.'));
o.default = '0';
bind(o);
o = s.taboption(tab, form.Value, 'repower_time', _('Repower / reset duration'),
_('Seconds the modem is held de-powered during a recovery power-cycle (or held in reset, when a reset GPIO is used). Default 30 s.'));
o.placeholder = '30';
o.datatype = 'min(1)';
bind(o);
o = s.taboption(tab, form.Value, 'zero_rx_timeout', _('Zero-RX timeout'),
_('Restart the connection after this many seconds without received packets (0 = off).'));
o.placeholder = '21600';
o.datatype = 'uinteger';
bind(o);
o = s.taboption(tab, form.Value, 'tx_stall_timeout', _('TX stall timeout'),
_('Reset the modem when, with a connection up, the kernel reports its transmit queue stuck for this many seconds, i.e. the modem has stopped taking packets (the "NETDEV WATCHDOG" message in the system log). 0 only records the stalls in the status. Default 120 s.'));
o.placeholder = '120';
o.datatype = 'uinteger';
bind(o);
o = s.taboption(tab, form.Value, 'bearer_poll_count', _('Dead-bearer poll count'),
_('NCM modems only. How many consecutive status polls may list no data context — with no received traffic in between — before wwand declares the bearer dead and redials. One poll per telemetry interval, so 3 is about three minutes. Lower recovers faster from a drop the modem does not announce; the minimum is 2, because a single unparsed answer on an idle link must not be enough to drop a working connection.'));
o.placeholder = '3';
o.datatype = 'min(2)';
bind(o);
o = s.taboption(tab, form.Value, 'stats_interval', _('Telemetry interval'),
_('Seconds between throughput/signal telemetry samples while connected.'));
o.placeholder = '60';
o.datatype = 'uinteger';
bind(o);
/* Default ON in the daemon (config.uc), so the flag must default to '1'
too: a '0' default would render an unset option as off while wwand
treats it as on. */
o = s.taboption(tab, form.Flag, 'nitz_time', _('Set the clock from the network'),
_('Step the system clock to the network\'s time (NITZ) when it is more than two minutes off. Smaller differences are left to NTP. A router without a real-time clock otherwise keeps its boot-time date until an NTP server is reachable. The time zone is not changed.'));
o.default = '1';
bind(o);
/* TWO DIFFERENT PATHS, and this option was labelled as the other one.
`location` is the QMI LOC service, which is documented as broken on
Quectel (docs/backend-interface.md) and is QMI-only; `gnss` runs the
vendor AT command that actually starts the receiver and is where the
NMEA port comes from. A flag that said "Enable GPS/location" and
wrote `location` sent every Quectel owner down the path that does not
work, with no way to find the one that does — `gnss` had no UI at
all. */
o = s.taboption(tab, form.Flag, 'gnss', _('GNSS receiver'),
_('Switch the modem\'s GNSS on. With <em>wwand-gps</em> installed wwand reads the position itself: from the modem\'s NMEA port (reported as <em>gps_port</em>; the receiver is started with the vendor AT command, the path that works on Quectel and ASR modems), or — on a modem without an NMEA port — the same NMEA over the QMI location service, natively or over the MBIM passthrough.'));
o.default = '0';
bind(o);
o = s.taboption(tab, form.Flag, 'gnss_set_time', _('Set the clock from GNSS'),
_('Let wwand step the system clock from the GNSS time — only while the clock is plainly unset, so it never fights the NTP client. Off by default. For installs with no RTC where the modem is the only time source.'));
o.default = '0';
o.depends('gnss', '1');
bind(o);
o = s.taboption(tab, form.Flag, 'auto_correct_config', _('Auto-correct modem config'),
_('Let wwand reprogram mismatched modem-side settings (attach profile, etc.) when it detects them.'));
o.default = '0';
bind(o);
}
});
@@ -0,0 +1,148 @@
'use strict';
'require baseclass';
'require uci';
/* Interface → wwand_modem section resolution/migration (network-native model:
radio/SIM/hardware options live on a `config wwand_modem` section in
/etc/config/network, referenced from the interface via `option modem`; the
interface itself carries only the connection). Shared by the proto handler
(protocol/wwand.js) and the settings page (view/wwand/settings.js) — this
is correctness-critical migration code: saving must ALWAYS convert an old
inline config to new-style, so keep both consumers on this single copy.
The two former copies (proto handler / settings view) were behaviourally
identical for modemSid/ensureModemSid — the settings view merely resolved
its target interface itself before calling; bindModem() existed only in the
proto handler. This module carries the proto-handler (newer) semantics. */
function modemSid(ifaceSid) {
var ref = uci.get('network', ifaceSid, 'modem');
if (ref && uci.get('network', ref) != null)
return ref;
return null;
}
function ensureModemSid(ifaceSid) {
var sid = modemSid(ifaceSid);
if (sid)
return sid;
var base = 'wwmodem_' + ifaceSid, name = base, i = 0;
while (uci.get('network', name) != null)
name = base + (++i);
uci.add('network', 'wwand_modem', name);
uci.set('network', ifaceSid, 'modem', name);
return name;
}
/* Redirect a form option's storage to the interface's wwand_modem section.
Reads new-style (wwand_modem) or, until one exists, legacy inline; writes
new-style and clears any legacy inline copy. */
function bindModem(o) {
/* WAS THE MODEM RESOLVABLE THE FIRST TIME THIS OPTION WAS READ. A form is
drawn before it is saved, so the first read is the render — and the
render is the only moment that tells us whether the widget the user
looked at was showing the modem's value or a default.
RECORDED ONCE PER SECTION AND NEVER OVERWRITTEN. That is the whole
difference from the attempt described in remove() below: form.js:2148
calls cfgvalue again inside save(), by which time an earlier option has
written `option modem` and the lookup succeeds — so a recording that
updates on every call always ends up saying "resolved", including for
the form that never resolved it at render. */
var seenResolved = {};
o.cfgvalue = function(sid) {
var opt = this.ucioption || this.option;
var msid = modemSid(sid);
if (!(sid in seenResolved))
seenResolved[sid] = (msid != null);
return uci.get('network', msid || sid, opt);
};
o.write = function(sid, val) {
var opt = this.ucioption || this.option;
var msid = ensureModemSid(sid);
uci.set('network', msid, opt, val);
/* `device` on the interface is the daemon-managed L3 device handle (the
mux child / netdev the daemon writes back), NOT a legacy inline modem
netdev — never clear it here. Other modem options still migrate. */
if (msid != sid && opt != 'device')
uci.unset('network', sid, opt);
};
o.remove = function(sid) {
var opt = this.ucioption || this.option;
/* NEVER delete on the wwand_modem section from here.
This redirect is used by the INTERFACE form (the proto handler). That
form only REFERENCES a modem; the section belongs to the hardware and
is shared with every other interface on it. An empty field here is
almost never "the user cleared this", because the fields are blank
whenever the form did not resolve the modem — and LuCI then calls
remove() for each of them on save.
Two earlier attempts got this wrong, both by assuming when cfgvalue
runs:
- the original had no guard at all, so adding a SECOND interface on
an existing modem deleted its `path` (openwrt/packages#30185);
- the next recorded which section cfgvalue had addressed and
compared at remove() time. That cannot work: form.js:2148 calls
cfgvalue inside save(), AFTER `option modem` has been written by
an earlier option, so the recorded value resolves to the modem
section and the comparison passes. It also never runs at all for
an inactive option (form.js:2167). Reported still broken on r29
(ddimension/luci-app-wwand#7), with `reset_gpio` gone as well.
Clearing a modem-level option is the MODEMS page's job, where the
section IS the wwand_modem and `bind` is a pass-through — there
remove() reaches it directly and means what it says. What this one
still does is drop a legacy inline copy from the interface, which is
the migration half and touches nothing shared.
A CHECKBOX IS THE ONE CASE THIS CAN DECIDE, and it had to be carved
out because the blanket refusal made it impossible to switch a
modem-level flag OFF from the interface form at all: form.Flag sets
`default = disabled` in its constructor (form.js:3998-4003), so
unchecking always lands in parse()'s remove branch (form.js:4109),
and this function then did nothing. The box stayed ticked after
Save, with "no changes to apply" — reported for `gnss` by obsy
(ddimension/luci-app-wwand#11, 2026-09-22) and true of all seven
modem-bound flags.
What makes it decidable: a checkbox has no blank state. formvalue()
returns `enabled` or `disabled` and nothing else, so "the user
cleared it" and "the form never resolved the modem" cannot be
confused — the second is answered by seenResolved above, at render,
before any save can muddy it. Off is then WRITTEN rather than
removed, which the daemon reads identically (config.uc bool_opt
treats '0' and absent alike), and only for an option that is
currently set to something else, so a form that touched nothing
writes nothing.
`disabled` is the Flag test HERE, not in general: LuCI puts that
property on TextValue too (form.js:5347). It holds because every
option modemopts binds is a Flag, a plain Value or a ListValue, and
only form.Flag's constructor sets enabled/disabled
(form.js:3998-4003). Binding a TextValue through here would need
this narrowed first. Raised by Codex review, 2026-09-22. */
if (this.disabled != null && seenResolved[sid] === true) {
var msid = modemSid(sid);
var cur = msid ? uci.get('network', msid, opt) : null;
if (cur != null && cur != this.disabled)
uci.set('network', msid, opt, this.disabled);
}
if (opt != 'device')
uci.unset('network', sid, opt);
};
return o;
}
return baseclass.extend({
modemSid: modemSid,
ensureModemSid: ensureModemSid,
bindModem: bindModem
});
@@ -0,0 +1,265 @@
'use strict';
'require baseclass';
'require dom';
'require ui';
'require wwand.rpc as wrpc';
'require wwand.format as fmt';
/* Network selection (operator scan + manual/automatic), extracted from
view/wwand/settings.js. Protocol-neutral: modem_scan /
modem_set_network_selection run over QMI NAS, the MBIM passthrough, or
AT+COPS on NCM. The scan is slow (up to ~90 s), so it runs as a daemon-side
job that is polled — with a blocking-call fallback for old daemons.
render(ctx, data) returns the section node; ctx carries the pieces the
settings page owns:
ctx.notifyDeferred(modem) — "apply needs a modem restart" notification
data: { modem, settings } as assembled by the settings view. */
var callSetSelection = wrpc.setNetsel;
var callScan = wrpc.scan;
var callScanStart = wrpc.scanStart;
var callScanStatus = wrpc.scanStatus;
return baseclass.extend({
render: function(ctx, data) {
var self = this;
var s = data.settings || {};
var mode = (s.selection_mode == 'manual') ? 'manual' : 'auto';
var reg = s.registered_plmn || {};
var infoRows = [
E('div', { 'style': 'margin-bottom:3px' }, [
E('strong', {}, [ _('Current mode') + ': ' ]),
(mode == 'manual') ? _('manual') : _('automatic') ]),
];
if (reg && (reg.mcc != null || reg.name))
infoRows.push(E('div', {}, [ E('strong', {}, [ _('Registered operator') + ': ' ]),
(reg.name || _('unknown')) + ' (' + (reg.mcc != null ? reg.mcc : '?') +
'/' + fmt.fmtMnc(reg.mnc, reg.mnc_digits) + ')' ]));
var results = E('div', { 'style': 'margin-top:10px' });
/* `digits` is the MNC's width from the scan (310/030 and 310/30 are
different operators, and mnc crosses ubus as an integer). Left
undefined when unknown, which rpc.js drops from the call — a null
would reach ucode as an argument of the wrong type and be refused. */
var setSelection = function(smode, mcc, mnc, digits, label) {
return callSetSelection(data.modem, smode, (mcc != null ? +mcc : 0), (mnc != null ? +mnc : 0),
(digits == 2 || digits == 3) ? +digits : undefined)
.then(function(res) {
if (res && res.ok === false)
ui.addNotification(null, E('p', [ _('Failed: ') + (res.error || '?') ]), 'error');
else if (res && res.unchanged)
ui.addNotification(null, E('p',
_('No change — the modem already runs this selection.')), 'info');
else if (res && res.deferred)
ctx.notifyDeferred(data.modem);
else {
ui.addNotification(null, E('p', label), 'info');
window.setTimeout(function() { window.location.reload(); }, 800);
}
});
};
var STATUS_LABEL = {
current: _('current'),
forbidden: _('forbidden'),
available: _('available'),
};
/* QmiNasNetworkScanResult, as the daemon names it */
var SCAN_RESULT_LABEL = {
abort: _('the modem aborted the scan'),
radio_link_failure: _('radio link failure during the scan'),
};
var renderOps = function(ops, scanResult) {
if (!ops || !ops.length) {
/* "No operators found" is a claim about the air, and it is the
wrong one when the modem simply gave up — which it reports in
its own scan-result field (QmiNasNetworkScanResult). Seen on an
RG502QEA, 2026-09-12: 180 s and an empty list for a scan fired
mid-teardown, then a full list from the same modem minutes
later. Say which of the two happened. */
var why = scanResult && scanResult != 'success'
? (SCAN_RESULT_LABEL[scanResult] || scanResult) : null;
dom.content(results, E('em', {}, [ why
? _('No operators returned — %s.').format(why)
: _('No operators found.') ]));
return;
}
/* one row per operator; the rule (and why the MNC width is part
of the identity) lives in wwand.format.collapseScan */
var RAT_ORDER = { 'NR5G': 0, 'LTE': 1, 'TD-SCDMA': 2, 'UMTS': 3, 'GSM': 4, 'EVDO': 5, 'CDMA': 6 };
ops = fmt.collapseScan(ops);
var rows = ops.map(function(op) {
var forbidden = (op.status == 'forbidden');
var current = (op.status == 'current');
var rats = Object.keys(op._rats || {}).sort(function(a, b) {
return (RAT_ORDER[a] == null ? 9 : RAT_ORDER[a]) - (RAT_ORDER[b] == null ? 9 : RAT_ORDER[b]);
});
var act;
if (forbidden)
act = E('span', { 'style': 'color:#999' }, '—');
else
act = E('button', { 'class': 'btn cbi-button cbi-button-apply',
'click': ui.createHandlerFn(self, function() {
if (!confirm(_('Register manually to %s (%s)? The connection may briefly drop.')
.format(op.name || '?', fmt.fmtPlmn(op.mcc, op.mnc, op.mnc_digits))))
return;
return setSelection('manual', op.mcc, op.mnc, op.mnc_digits,
_('Manual network selection applied.'));
}) }, current ? _('Reselect') : _('Select'));
return E('tr', { 'class': 'tr',
'style': forbidden ? 'opacity:.5' : (current ? 'font-weight:600' : '') }, [
/* array, not a bare string: this name arrives OVER THE AIR in
the scan result, and dom.append() would put a bare string
through innerHTML (luci.js:1394-96) */
E('td', { 'class': 'td' }, [ op.name || _('(unnamed)') ]),
E('td', { 'class': 'td' }, [ fmt.fmtPlmn(op.mcc, op.mnc, op.mnc_digits) ]),
E('td', { 'class': 'td' }, [ rats.length ? rats.join(', ') : '—' ]),
E('td', { 'class': 'td' }, [
STATUS_LABEL[op.status] || op.status || '',
op.roaming ? E('span', { 'style': 'color:#da3;margin-left:4px', 'title': _('roaming') }, '✈') : '',
]),
E('td', { 'class': 'td', 'style': 'width:1%' }, act),
]);
});
dom.content(results, E('table', { 'class': 'table' }, [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, _('Operator')),
E('th', { 'class': 'th' }, _('PLMN')),
E('th', { 'class': 'th' }, _('RAT')),
E('th', { 'class': 'th' }, _('Status')),
E('th', { 'class': 'th' }, ''),
]),
].concat(rows)));
};
/* A real operator scan regularly takes MINUTES (AT+COPS=? — and QMI NAS
scans on busy bands too), far beyond the XHR/uhttpd/rpcd timeout
chain. So: start the scan as a daemon-side job and poll its status
every few seconds — every request stays fast. Falls back to the old
blocking call when the daemon predates modem_scan_start. */
var scanBtn;
var scanFail = function(msg) {
scanBtn.disabled = false;
dom.content(results, E('div', { 'class': 'wwe-banner err' },
[ E('span', {}, '✕'), E('span', {}, [ _('Scan failed: ') + msg ]) ]));
};
var scanSpinner = function(secs) {
dom.content(results, E('div', { 'class': 'wwe-banner run',
'style': 'display:flex;align-items:center;gap:9px' }, [
E('span', { 'class': 'wwe-spin' }),
E('span', {}, [ _('Scanning for operators — this can take several minutes…')
+ (secs ? ' (' + secs + 's)' : '') ]),
]));
};
/* `started` is a BROWSER timestamp, deliberately. The daemon reports its
own start time in st.started, but that is the ROUTER clock, and
subtracting it from Date.now() measures the skew between the two
machines rather than the elapsed scan — on a box whose clock has not
been set it reads days (observed: 624335s on the NR7101, 2026-09-12),
and when the router runs ahead the Math.max() floor pinned it at 0
forever. A cellular router is in exactly that state where this widget
matters: no RTC, and no NTP until the modem this page is scanning with
has brought a connection up. One clock only. */
/* A scan the daemon never finishes would otherwise keep this chain
alive for the life of the LuCI document: the view is gone, the
widget is detached, and modem_scan_status is still being asked once
a second. Checked before scheduling AND inside the callback, since
the navigation can happen in between. */
var pollScan = function(started) {
if (!results.isConnected)
return;
window.setTimeout(function() {
if (!results.isConnected)
return;
callScanStatus(data.modem).then(function(st) {
if (st && st.running) {
scanSpinner(Math.max(0, Math.round((Date.now() - started) / 1000)));
return pollScan(started);
}
if (!st || st.ok === false || st.error || st.idle)
return scanFail((st && (st.error || (st.idle ? _('scan vanished (daemon restarted?)') : '?'))) || '?');
scanBtn.disabled = false;
renderOps(st.operators || [], st.scan_result);
}).catch(function(e) {
/* one failed poll is not a failed scan (rpcd hiccup) — retry */
pollScan(started);
});
}, 3000);
};
var legacyScan = function() {
/* pre-async daemon: single blocking call with a bumped XHR timeout */
var saved = L.env.rpctimeout;
L.env.rpctimeout = 120;
var restore = function() { L.env.rpctimeout = saved; scanBtn.disabled = false; };
return callScan(data.modem).then(function(r) {
restore();
if (r && r.ok === false)
return scanFail(r.error || '?');
renderOps((r || {}).operators || [], (r || {}).scan_result);
}).catch(function(e) {
restore();
scanFail((e && e.message) || e);
});
};
/* A SCAN IS NOT A READ. It takes the radio off the network for up to
* 240 s (netsel_ops.uc SCAN_TIMEOUT_MS), so modem_scan and
* modem_scan_start moved out of the read ACL grant — which would leave
* a read-only operator looking at a button that answers "permission
* denied". hasViewPermission() returns null when the node spec is not
* known; treat that as permitted rather than disabling a working
* button on a page we cannot classify. Found by a full review,
* 2026-09-19. */
var mayScan = (L.hasViewPermission() !== false);
scanBtn = E('button', { 'class': 'btn cbi-button',
'disabled': mayScan ? null : '',
'title': mayScan ? null
: _('A network scan takes the radio off the network for minutes, so it needs write access.'),
'click': ui.createHandlerFn(self, function() {
scanBtn.disabled = true;
scanSpinner(null);
var t0 = Date.now();
return callScanStart(data.modem).then(function(r) {
if (r && r.ok === false)
return scanFail(r.error || '?');
pollScan(t0);
}).catch(function(e) {
return legacyScan();
});
}) }, _('Scan for operators'));
var autoBtn = E('button', { 'class': 'btn cbi-button', 'style': 'margin-left:6px',
'click': ui.createHandlerFn(self, function() {
return setSelection('auto', 0, 0, undefined, _('Automatic network selection enabled.'));
}) }, _('Set automatic'));
/* THE TITLE BELONGS INSIDE THE SECTION. LuCI's own form.js appends the
section title to the `.cbi-section` element itself
(form.js:2482-2490 and :2792-2816), so a theme that draws the section
as a panel draws the heading inside it. Ours sat beside the panel and
therefore floated above it, unstyled — reported by obsy comparing this
tab against the status page, which has always had it right
(ddimension/luci-app-wwand#14, 2026-09-24). */
return E('div', {}, [
E('div', { 'class': 'cbi-section' }, [
E('h3', {}, _('Network selection')),
E('div', {}, infoRows),
E('p', { 'style': 'margin:8px 0' }, E('em', {},
_('Automatic lets the modem choose the best operator. A manual scan lists the visible operators so you can force one (e.g. to prefer a partner network while roaming).'))),
E('div', {}, [ scanBtn, autoBtn ]),
results,
]),
]);
}
});
@@ -0,0 +1,136 @@
'use strict';
'require baseclass';
'require rpc';
/* Every ubus rpc.declare() used by the wwand LuCI packages, collected once so
the method/params/expect triples are not copy-pasted across the views, the
proto handler and the shared modules.
IMPORTANT — ACLs: a method added here must be granted in BOTH rpcd ACL
files, luci-app-wwand/root/usr/share/rpcd/acl.d/luci-app-wwand.json AND
luci-proto-wwand/root/usr/share/rpcd/acl.d/luci-proto-wwand.json, whenever
it is reachable from a shared module (wwand.modemopts / wwand.modemsid are
loaded by both packages) or from the proto handler. Methods used only by
the app views need only the app ACL. */
/* A write whose call ubus itself refuses — an argument the method does not
declare or with another type (INVALID_ARGUMENT), a method a newer LuCI
knows and an older daemon does not (METHOD_NOT_FOUND) — reaches rpc.js as
a bare status code in the legacy `call` result, which `expect: {}` then
replaces with an empty object (luci-base rpc.js:92-105, luci feeds
4b6dda1a). The views read `ok === false` as failure and anything else as
success, so every such refusal was reported as done. `reject` makes the
status an error, and this turns it into the answer shape they already
handle. Reads stay as they are: their callers render an empty result. */
function checked(decl) {
var call = rpc.declare(Object.assign({ reject: true }, decl));
return function() {
return call.apply(this, arguments).catch(function(e) {
return { ok: false, error: (e && e.message) ? e.message : String(e) };
});
};
}
return baseclass.extend({
/* --- status flavours -------------------------------------------------
`status` unwraps the modems map (what the views iterate);
`statusRaw` returns the whole status object (modems + contexts +
board …) — wwand.modemopts needs .board/.modems;
`contexts` unwraps the contexts map (status page connection cards). */
status: rpc.declare({ object: 'wwand', method: 'status', expect: { modems: {} } }),
/* the daemon's globals block — carries the level the PROCESS logs at, which
the configured value does not tell you once it has been overridden */
globals: rpc.declare({ object: 'wwand', method: 'status', expect: { globals: {} } }),
setLogLevel: checked({ object: 'wwand', method: 'set_log_level',
params: [ 'level' ], expect: {} }),
statusRaw: rpc.declare({ object: 'wwand', method: 'status', expect: { '': {} } }),
contexts: rpc.declare({ object: 'wwand', method: 'status', expect: { contexts: {} } }),
/* --- telemetry ------------------------------------------------------- */
signal: rpc.declare({ object: 'wwand', method: 'modem_signal', params: [ 'modem' ], expect: {} }),
cells: rpc.declare({ object: 'wwand', method: 'modem_cells', params: [ 'modem' ], expect: {} }),
datapath: rpc.declare({ object: 'wwand', method: 'modem_datapath', params: [ 'modem' ], expect: {} }),
gps: rpc.declare({ object: 'wwand', method: 'modem_gps', params: [ 'modem' ], expect: {} }),
ctxStatus: rpc.declare({ object: 'wwand', method: 'context_status', params: [ 'interface' ], expect: {} }),
/* --- hardware -------------------------------------------------------- */
probe: rpc.declare({ object: 'wwand', method: 'modem_probe', expect: {} }),
modemReset: checked({ object: 'wwand', method: 'modem_reset', params: [ 'modem' ], expect: {} }),
modemSimReinit: checked({ object: 'wwand', method: 'modem_sim_reinit', params: [ 'modem' ], expect: {} }),
modemRepower: checked({ object: 'wwand', method: 'modem_repower', params: [ 'modem' ], expect: {} }),
modemReattach: checked({ object: 'wwand', method: 'modem_reattach', params: [ 'modem' ], expect: {} }),
modemDetach: checked({ object: 'wwand', method: 'modem_detach', params: [ 'modem' ], expect: {} }),
modemAttach: checked({ object: 'wwand', method: 'modem_attach', params: [ 'modem' ], expect: {} }),
setProtocol: checked({ object: 'wwand', method: 'modem_set_protocol', params: [ 'modem', 'protocol' ], expect: {} }),
/* --- carrier configuration (MBN, over QMI PDC) ------------------------
op = list | get | set. A `set` only takes effect after a modem reset and
is reported as `pending` until then, so the UI must not claim the switch
already happened. */
carrierConfig: checked({ object: 'wwand', method: 'modem_carrier_config',
params: [ 'modem', 'op', 'id' ], expect: {} }),
/* the modem's own eUICC profile read, for cards lpac structurally cannot
enumerate (an M2M eUICC has no local ES10) */
euiccProfiles: rpc.declare({ object: 'wwand', method: 'modem_euicc_profiles',
params: [ 'modem', 'slot' ], expect: {} }),
/* --- SIM / eSIM ------------------------------------------------------ */
slots: rpc.declare({ object: 'wwand', method: 'modem_sim_slots', params: [ 'modem' ], expect: {} }),
switchSlot: checked({ object: 'wwand', method: 'modem_sim_switch_slot', params: [ 'modem', 'slot' ], expect: {} }),
pinVerify: checked({ object: 'wwand', method: 'modem_sim_pin_verify', params: [ 'modem', 'pin' ], expect: {} }),
simPuk: checked({ object: 'wwand', method: 'modem_sim_puk', params: [ 'modem', 'puk', 'new_pin' ], expect: {} }),
pinLock: checked({ object: 'wwand', method: 'modem_sim_pin_lock', params: [ 'modem', 'pin', 'enable' ], expect: {} }),
/* READ-ONLY profile list. NOT `esim` with op 'profiles': that method is in
the WRITE acl because its other ops enable, disable and delete profiles,
and rpcd grants a METHOD, never a method with certain arguments — so a
read-only operator was denied the status page on every eUICC box. */
esimProfiles: rpc.declare({ object: 'wwand', method: 'modem_esim_profiles',
params: [ 'modem', 'slot' ], expect: {} }),
esim: checked({ object: 'wwand', method: 'modem_esim',
params: [ 'modem', 'op', 'slot', 'iccid', 'activation_code', 'confirmation_code', 'auto_notify' ], expect: {} }),
/* --- settings / network selection ------------------------------------ */
getSettings: rpc.declare({ object: 'wwand', method: 'modem_get_settings', params: [ 'modem' ], expect: {} }),
setSettings: checked({ object: 'wwand', method: 'modem_set_settings', params: [ 'modem', 'settings' ], expect: {} }),
setNetsel: checked({ object: 'wwand', method: 'modem_set_network_selection',
params: [ 'modem', 'mode', 'mcc', 'mnc', 'mnc_digits' ], expect: {} }),
plmn: rpc.declare({ object: 'wwand', method: 'modem_plmn_lists', params: [ 'modem' ], expect: {} }),
plmnSet: checked({ object: 'wwand', method: 'modem_plmn_set', params: [ 'modem', 'list_type', 'entries' ], expect: {} }),
plmnRestore: checked({ object: 'wwand', method: 'modem_plmn_restore', params: [ 'modem' ], expect: {} }),
scan: checked({ object: 'wwand', method: 'modem_scan', params: [ 'modem' ], expect: {} }),
scanStart: checked({ object: 'wwand', method: 'modem_scan_start', params: [ 'modem' ], expect: {} }),
scanStatus: rpc.declare({ object: 'wwand', method: 'modem_scan_status', params: [ 'modem' ], expect: {} }),
/* --- SMS -------------------------------------------------------------- */
smsList: rpc.declare({ object: 'wwand', method: 'modem_sms_list', params: [ 'modem', 'storage' ], expect: {} }),
/* `indices` deletes a set in ONE call and answers { deleted, requested,
failed }; `index` is the single-message form and still answers { ok }.
Deliberately no "delete all": the daemon has no such primitive, because it
would delete what is in the store when the modem runs it rather than what
the operator was shown. */
smsDelete: checked({ object: 'wwand', method: 'modem_sms_delete', params: [ 'modem', 'storage', 'index', 'indices' ], expect: {} }),
/* --- migration -------------------------------------------------------- */
/* convert selected legacy proto qmi/mbim/ncm interfaces to proto wwand
in place. apply=false returns the planned changes (preview). */
migrate: checked({ object: 'wwand', method: 'migrate', params: [ 'apply', 'interfaces' ], expect: {} }),
/* All *named* GPIO lines (from the DT gpio-line-names), for the
reset/power GPIO picker. Skips the export/unexport control files and
the raw gpiochipN/gpioNNN entries. */
gpioList: rpc.declare({
object: 'file', method: 'list', params: [ 'path' ], expect: { entries: [] },
filter: function(list) {
var rv = [];
for (var i = 0; i < list.length; i++) {
var n = list[i].name;
if (n && n != 'export' && n != 'unexport' && !/^gpio(chip)?[0-9]+$/.test(n))
rv.push(n);
}
return rv.sort();
}
})
});
@@ -0,0 +1,53 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- wwand — live signal graph canvas.
Only the three QUALITY THRESHOLDS live here. The series lines are built by
wwand/graph.js, because a fixed set of <path> elements in this file capped
how many RATs a canvas could show: adding 3G and 2G to the strength graph
would have meant editing an asset to add more paths with hard-coded
colours, and the colour of an LTE line is not a property of the canvas.
The rules are drawn, not decorated: the idea is borrowed from the old
luci-proto-marcant-qmi page, which coloured them and spelled out "good
quality" / "bad quality" beside each. That is worth keeping — it tells
someone aiming an antenna what they are looking at without their having to
know what a dBm is. Their y positions come from the view, because they
belong to the scale, not to the canvas, and each canvas carries ONE
quantity with its own ladder.
THE COLOURS ARE MARKED `!important`, which is not decoration. LuCI's themes
restyle graph SVGs so the bandwidth page's grid lines follow the theme:
#view div[style] > svg line[style] { stroke: <theme text> !important }
#view div[style] > svg > text[style] { fill: <theme text> !important }
(the real declarations use the theme's own CSS variable; it cannot be
quoted here, because its name contains a double hyphen and XML comments
may not.)
(luci-theme-footstrap cascade.css:298-299; luci-theme-bootstrap has the
same rule at :2626.) Those selectors match any svg that is a direct child
of a styled div under #view — this canvas — and the rules below are not
grid lines: green means "excellent" and red means "fair", so a theme
repainting them all in the text colour deletes the information. Reported
from footstrap, where every rule and label came out black
(ddimension/wwand#14, 2026-09-11). An important declaration in a style
attribute outranks an important one from a stylesheet, so this holds
without the theme having to know about us.
What is NOT borrowed is that page's orientation. It drew the magnitude of a
negative dBm upwards, so -100 dBm sat near the TOP and -20 near the bottom:
a worse signal made a taller line. Here stronger is always higher. -->
<svg width="100%" height="100%" version="1.1" xmlns="http://www.w3.org/2000/svg">
<line id="t_good" x1="0" y1="0" x2="100%" y2="0" style="stroke:#2c8a2c !important;stroke-width:0.6;stroke-dasharray:4,3" />
<text id="t_good_l" x="6" y="0" style="fill:#2c8a2c !important; font-size:9pt; font-family:sans-serif"> </text>
<line id="t_fair" x1="0" y1="0" x2="100%" y2="0" style="stroke:#c8a415 !important;stroke-width:0.6;stroke-dasharray:4,3" />
<text id="t_fair_l" x="6" y="0" style="fill:#c8a415 !important; font-size:9pt; font-family:sans-serif"> </text>
<line id="t_poor" x1="0" y1="0" x2="100%" y2="0" style="stroke:#c0392b !important;stroke-width:0.6;stroke-dasharray:4,3" />
<text id="t_poor_l" x="6" y="0" style="fill:#c0392b !important; font-size:9pt; font-family:sans-serif"> </text>
</svg>
@@ -0,0 +1,240 @@
'use strict';
'require baseclass';
'require form';
'require uci';
'require rpc';
/* Shared per-SIM override list (config wwand_sim in /etc/config/network): a
PIN/APN bundle matched at runtime by ICCID, independent of the modem (a
wwand_sim with no `modem` applies to whichever modem holds that card). Used —
like wwand.modemopts — by both the dedicated Modems page and the interface
proto handler, so the list is defined once. */
var callInventory = rpc.declare({ object: 'wwand', method: 'sim_inventory', expect: { '': {} } });
/* An ICCID as written in the config and as read from the card can differ in
case and in the 'F' padding of an odd-length ICCID (EF ICCID is BCD,
ETSI TS 102 221 13.2), so both sides are compared normalised. */
function normIccid(v) {
return String(v || '').toUpperCase().replace(/F+$/, '');
}
/* one inventory entry, tersely: where the card sits right now */
function whereShort(c, now) {
var parts = [];
if (!c.present) {
if (c.last_seen != null && now != null) {
var d = Math.max(0, now - c.last_seen);
return _('not present, %s').format(d < 7200 ? _('%d min').format(Math.floor(d / 60))
: d < 172800 ? _('%d h').format(Math.floor(d / 3600)) : _('%d d').format(Math.floor(d / 86400)));
}
return _('not present');
}
if (c.reader)
parts.push(_('rsim %s').format(c.reader));
else if (c.modem)
parts.push(c.slot != null ? _('%s · slot %d').format(c.modem, c.slot) : c.modem);
if (c.eid)
parts.push(c.profile && c.profile.state ? _('eSIM %s').format(c.profile.state) : _('eSIM'));
if (c.active)
parts.push(_('in use'));
return parts.join(' · ') || '?';
}
/* the wwand_sim section for a card, matched as the daemon matches it:
by ICCID, or by an IMSI written into the iccid field or into `imsi`
(modem_common.uc match_sim_override); null when there is none */
function findSim(iccid, imsi) {
var want = normIccid(iccid), hit = null;
uci.sections('network', 'wwand_sim', function(sec) {
if (hit)
return;
if ((want && normIccid(sec.iccid) == want) ||
(imsi && (sec.imsi == imsi || sec.iccid == imsi)))
hit = sec['.name'];
});
return hit;
}
return baseclass.extend({
findSim: findSim,
/* the card's label (`option name` of its wwand_sim), or null — for the
places that list cards by ICCID: with several cards in a box the ICCID
alone does not say which one is which (ddimension/wwand#44, #39).
Operator-entered text: callers put it in a text node, never markup. */
simName: function(iccid, imsi) {
var sid = findSim(iccid, imsi);
return sid ? (uci.get('network', sid, 'name') || null) : null;
},
/* Open the override editor of the list `s` (addSimList's section) for
one card: its section when it has one, otherwise a new one with the
ICCID filled in — added the way the list's own Add does
(GridSection.handleAdd, form.js), so Cancel removes it again and only
Save & Apply keeps it. */
openSim: function(s, iccid, imsi) {
var sid = findSim(iccid, imsi);
if (sid)
return s.renderMoreOptionsModal(sid);
sid = s.map.data.add('network', 'wwand_sim');
s.map.data.set('network', sid, 'iccid', iccid);
s.map.addedSection = sid;
return s.renderMoreOptionsModal(sid);
},
/* add the SIM-override GridSection to the form.Map `m`.
opts.prefillIccid — prefill the ICCID on a freshly added row (the active
card, so the common "PIN for the inserted SIM" case is one click). */
addSimList: function(m, opts) {
opts = opts || {};
var s = m.section(form.GridSection, 'wwand_sim', _('SIMs (per-ICCID overrides)'),
_('Match a specific card by its ICCID and give it a PIN — and optionally its own APN/auth. Applies to whichever modem holds that card (leave Modem empty). Handy for dual-SIM or eUICC profiles with different PINs.'));
s.addremove = true;
s.anonymous = true;
s.sortable = false;
s.nodescriptions = true;
s.addbtntitle = _('Add SIM');
/* An override row usually holds the card's PIN, and nothing else in the
UI holds it — deleted, it is gone, and the card goes back to whatever
the modem section carries (or to no PIN at all). Cheap to ask. */
s.handleRemove = function(section_id /*, ev */) {
var iccid = uci.get('network', section_id, 'iccid');
if (!confirm(iccid
? _('Delete the override for SIM %s? Its PIN and APN settings are lost.').format(iccid)
: _('Delete this SIM override? Its PIN and APN settings are lost.')))
return Promise.resolve();
return form.GridSection.prototype.handleRemove.apply(this, arguments);
};
var o;
/* Where the card is right now, from the daemon's SIM inventory — status
only. One request per page; a page whose ACL lacks sim_inventory (or
an older daemon) just shows nothing here. */
var inventory = L.resolveDefault(callInventory(), {});
o = s.option(form.Value, 'iccid', _('ICCID'),
_('The card\'s ICCID (printed on the SIM / shown on the modem status page).'));
o.rmempty = false;
o.datatype = 'and(uinteger,minlength(6),maxlength(22))';
if (opts.prefillIccid)
o.default = opts.prefillIccid;
/* A label for the card. With several cards in a box the ICCID alone
does not say which one is which (ddimension/wwand#44); the name is
shown on the SIM cards page and the modem status. Display only —
wwand matches on nothing but the ICCID/IMSI, and renaming the card
in use re-dials nothing. */
o = s.option(form.Value, 'name', _('Name'),
_('A name for this card, e.g. "Work" or "Travel". Shown on the SIM cards page and the modem status.'));
o.rmempty = true;
o.datatype = 'maxlength(32)';
o = s.option(form.DummyValue, '_where', _('Now'));
o.modalonly = false;
o.textvalue = function(section_id) {
/* the daemon matches a section by ICCID, by `imsi`, or by an IMSI
written into the iccid field (modem_common.uc match_sim_override,
config.uc accepting `imsi`) — so does this */
var want = normIccid(uci.get('network', section_id, 'iccid')),
imsi = uci.get('network', section_id, 'imsi'),
span = E('span', { 'style': 'white-space:nowrap' }, [ '…' ]);
inventory.then(function(inv) {
var hits = ((inv && inv.cards) || []).filter(function(c) {
return (want && (normIccid(c.iccid) == want || c.imsi == want)) ||
(imsi && c.imsi == imsi);
});
/* a string argument becomes a text node: reader and profile
names come from outside and are never parsed as markup */
span.replaceChildren(hits.length
? hits.map(function(c) { return whereShort(c, inv.now); }).join('; ')
: E('span', { 'style': 'opacity:.6' }, [ _('not seen') ]));
});
return span;
};
o = s.option(form.Value, 'pincode', _('PIN'),
_('SIM PIN for this card. Overrides the modem\'s default PIN; wwand never retries a PIN when only one attempt is left.'));
o.datatype = 'and(uinteger,minlength(4),maxlength(8))';
o.password = true;
/* `password` masks the input field only: the grid cell is rendered from
textvalue(), which is the stored value (form.js renderTextValue), so
the list showed every card's PIN in clear. Only whether one is set. */
o.textvalue = function(section_id) {
return this.cfgvalue(section_id) ? '••••' : null;
};
o = s.option(form.Value, 'apn', _('APN'),
_('Optional APN for this card, overriding the interface/default APN.'));
o.rmempty = true;
o = s.option(form.Value, 'modem', _('Modem'),
_('Optional: bind this override to one modem (its wwand_modem name). Empty = any modem holding this card.'));
o.rmempty = true;
uci.sections('network', 'wwand_modem').forEach(function(sec) { o.value(sec['.name'], sec['.name']); });
/* modal-only detail: auth + credentials + PDP override */
o = s.option(form.ListValue, 'auth', _('Authentication type'),
_('APN authentication for this card (only needed when the operator requires a username/password).'));
o.modalonly = true;
o.default = 'none';
o.value('none', _('None'));
o.value('pap', 'PAP');
o.value('chap', 'CHAP');
o.value('both', 'PAP/CHAP');
o = s.option(form.Value, 'username', _('PAP/CHAP username'),
_('Username for the APN authentication.'));
o.modalonly = true;
o.depends('auth', 'pap'); o.depends('auth', 'chap'); o.depends('auth', 'both');
o = s.option(form.Value, 'password', _('PAP/CHAP password'),
_('Password for the APN authentication.'));
o.modalonly = true;
o.password = true;
o.depends('auth', 'pap'); o.depends('auth', 'chap'); o.depends('auth', 'both');
o = s.option(form.ListValue, 'pdp_type', _('PDP type'),
_('IP version(s) requested for this card\'s data session, overriding the interface setting.'));
o.modalonly = true;
o.value('', _('(interface default)'));
o.value('ipv4v6', _('IPv4 + IPv6'));
o.value('ipv4', _('IPv4'));
o.value('ipv6', _('IPv6'));
/* optional preferred-PLMN list for this card — the daemon restores it
before every radio-on; a per-SIM list wins over the modem's. The list
itself (and its NAS/user type) is a `config wwand_plmnlist`, managed on
the modem settings page. */
o = s.option(form.ListValue, 'plmn_list', _('Preferred-PLMN list'),
_('Optional: a saved PLMN list to restore for this SIM (type NAS or user is set on the list). Wins over the modem\'s list.'));
o.modalonly = true;
o.rmempty = true;
o.value('', _('(none)'));
uci.sections('network', 'wwand_plmnlist').forEach(function(sec) {
var t = (sec.type == 'user') ? 'user' : (sec.type == 'fplmn') ? 'FPLMN' : 'NAS';
o.value(sec['.name'], sec['.name'] + ' (' + t + ')');
});
return s;
}
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,69 @@
{
"admin/status/wwand": {
"title": "Modem",
"order": 15,
"action": {
"type": "view",
"path": "wwand/status"
},
"depends": {
"acl": [
"luci-app-wwand"
],
"ubus": {
"wwand": [
"status"
]
}
}
},
"admin/status/wwand-sims": {
"title": "SIM cards",
"order": 16,
"action": {
"type": "view",
"path": "wwand/sims"
},
"depends": {
"acl": [
"luci-app-wwand"
],
"ubus": {
"wwand": [
"sim_inventory"
]
}
}
},
"admin/network/wwand": {
"title": "Modems",
"order": 55,
"action": {
"type": "view",
"path": "wwand/modems"
},
"depends": {
"acl": [
"luci-app-wwand"
]
}
},
"admin/network/wwand-tools": {
"title": "Modem Tools",
"order": 60,
"action": {
"type": "view",
"path": "wwand/settings"
},
"depends": {
"acl": [
"luci-app-wwand"
],
"ubus": {
"wwand": [
"modem_get_settings"
]
}
}
}
}
@@ -0,0 +1,75 @@
{
"luci-app-wwand": {
"description": "Grant access to the wwand modem status page",
"read": {
"uci": [
"network"
],
"ubus": {
"wwand": [
"context_status",
"sim_inventory",
"modem_cells",
"modem_datapath",
"modem_gps",
"modem_esim_profiles",
"modem_euicc_profiles",
"modem_get_settings",
"modem_plmn_lists",
"modem_probe",
"modem_scan_status",
"modem_signal",
"modem_sim_slots",
"modem_sms_list",
"status"
]
},
"file": {
"/sys/class/gpio/": [
"list"
]
}
},
"write": {
"uci": [
"network"
],
"ubus": {
"wwand": [
"migrate",
"modem_carrier_config",
"modem_esim",
"modem_plmn_restore",
"modem_plmn_set",
"modem_attach",
"modem_detach",
"modem_reattach",
"modem_repower",
"modem_reset",
"modem_scan",
"modem_scan_start",
"modem_set_network_selection",
"modem_set_protocol",
"modem_set_settings",
"set_log_level",
"modem_sim_pin_lock",
"modem_sim_pin_verify",
"modem_sim_puk",
"modem_sim_reinit",
"modem_sim_switch_slot",
"modem_sms_delete"
]
}
}
},
"luci-app-wwand-log": {
"description": "Read the system log for the wwand log viewer on the modem status page. SEPARATE ROLE ON PURPOSE: rpcd cannot filter a method's arguments or its result, so granting `log read` grants the WHOLE system log \u2014 kernel, auth, dropbear, firewall \u2014 not just the wwand lines, which are picked out in the browser afterwards. Keeping it out of the luci-app-wwand role lets an operator be given the modem pages without the box's logs; the viewer says it cannot read them and everything else on the page still works.",
"read": {
"ubus": {
"log": [
"read"
]
}
}
}
}
@@ -0,0 +1,322 @@
#!/usr/bin/env node
/* Refuse to let a method that needs its receiver be aliased without one.
*
* format.js is a baseclass object, so a helper that composes a sibling does it
* through `this`. The views alias those helpers into locals:
*
* var fmtReg = fmt.fmtRegistration; // `this` is LOST
* var fmtSim = fmt.fmtSim.bind(fmt); // `this` survives
*
* LuCI modules are strict-mode, so the detached call gets `this === undefined`
* and the FIRST read of `this` inside throws -- not at load, but on the render
* that first reaches that branch. So it ships green: the unit tests call
* `fmt.fmtRegistration(...)` WITH a receiver and pass, while the browser column
* throws on every draw.
*
* That is not hypothetical. It happened to fmtSim, was fixed with .bind() and
* written up in a comment -- and the line DIRECTLY ABOVE that comment had the
* same defect and shipped, until a user's stack trace found it
* (openwrt/packages#37, format.js:651, 2026-09-21). A comment did not hold, so
* this does instead.
*
* node tools/check-detached-methods.js
*
* node tools/check-detached-methods.js --self-test
*
* WHAT IT DOES NOT CATCH. The receiver-dependent set is exact -- it evaluates
* format.js and asks each real exported function whether its own body reads
* `this`. The USE-SITE scan is lexical and therefore is not: a VARIABLE key
* (`fmt[name]`), an alias reached through a second object (`var f = other;
* f.fmtRegistration`), or anything built at run time all pass it. It covers
* the forms this tree actually writes, and --self-test asserts each of them
* rather than promising it here -- the first version of this file promised
* and was wrong, missing `euiccSlot` because it looked for `this.` and that
* method parks the receiver in `var self = this` (found in review,
* 2026-09-21). So: a guard against the mistake that was made, not a proof
* that the mistake is impossible. An AST pass would settle it; that is not
* worth a dependency for six files.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const RES = path.join(__dirname, '..', 'htdocs', 'luci-static', 'resources');
const FORMAT = path.join(RES, 'wwand', 'format.js');
const rel = (f) => path.relative(path.join(__dirname, '..'), f);
/* Comments and quoted strings are PROSE, not code. Stripping them is not
cosmetic: three helpers match a bare /\bthis\b/ only because their comments
use the English word, and one translated string reads "while this slot is
the active one". Left in, the checker would demand a .bind() for helpers
that do not need one -- and a checker that cries wolf gets ignored, which is
how the original defect shipped. Template literals are stripped whole; this
tree writes none that contain a `fmt.` reference (checked 2026-09-21). */
/* ONE left-to-right pass, because a chain of independent regexes is not a
lexer and this tree proves it: `const SVGNS = 'http://www.w3.org/2000/svg'`
(graph.js:177) makes a comment-first pass blank the rest of that line, real
code included. Comments, string and template literals and regex literals all
become spaces -- NEWLINES SURVIVE, so every reported line number is the line
in the file. Found in review, 2026-09-21; the version before this one had
both defects. */
function lex(src) {
const out = src.split('');
const n = src.length;
const wipe = (a, b) => { for (let k = a; k < b && k < n; k++) if (out[k] != '\n') out[k] = ' '; };
/* A `/` opens a regex only where a value may begin. Approximated by the
last significant character: after an identifier, a number, `)` or `]` it
is division. `return /x/` is misread as division -- harmless here, since
a division's operands are code either way and this tree writes none. */
const opensRegex = (c) => c == '' || !/[A-Za-z0-9_$)\]]/.test(c);
let i = 0, prev = '';
while (i < n) {
const c = src[i];
if (c == '/' && src[i + 1] == '/') {
let j = i;
while (j < n && src[j] != '\n') j++;
wipe(i, j); i = j; continue;
}
if (c == '/' && src[i + 1] == '*') {
const k = src.indexOf('*/', i + 2);
const j = k < 0 ? n : k + 2;
wipe(i, j); i = j; continue;
}
if (c == '"' || c == "'" || c == '`') {
let j = i + 1;
while (j < n) {
if (src[j] == '\\') { j += 2; continue; }
if (src[j] == c) { j++; break; }
if (src[j] == '\n' && c != '`') break; /* unterminated: stop at the line */
j++;
}
wipe(i, j); i = j; prev = 'x'; continue;
}
if (c == '/' && opensRegex(prev)) {
let j = i + 1, inClass = false;
while (j < n) {
const d = src[j];
if (d == '\\') { j += 2; continue; }
if (d == '[') inClass = true;
else if (d == ']') inClass = false;
else if (d == '/' && !inClass) { j++; break; }
else if (d == '\n') break;
j++;
}
wipe(i, j); i = j; prev = 'x'; continue;
}
if (!/\s/.test(c)) prev = c;
i++;
}
return out.join('');
}
/* ---- 1. the real exported object, evaluated the way LuCI would ---- */
const src = fs.readFileSync(FORMAT, 'utf8').replace(/^\s*'require [^']*';\s*$/gm, '');
const baseclass = { extend: (o) => o };
const _ = (s) => s;
const E = () => ({});
const ui = {};
const L = {};
if (!String.prototype.format)
String.prototype.format = function () { return String(this); };
const fmt = new Function('baseclass', '_', 'E', 'ui', 'L', src)(baseclass, _, E, ui, L);
const methods = Object.keys(fmt).filter((k) => typeof fmt[k] == 'function');
/* ANY read of `this`, not just `this.x`: euiccSlot and esimProfileList both
park it in `var self = this` first and would otherwise pass as standalone.
Codex found that; it was a real hole in the first version of this file. */
const needsReceiver = new Set(methods.filter((k) => /\bthis\b/.test(lex(fmt[k].toString()))));
/* ---- 2. every detached reference across the shipped JS ---- */
const lineOf = (text, idx) => text.slice(0, idx).split('\n').length;
/* One file's worth of detached references. Returns [{line, name, why}].
Split out so --self-test can drive it with synthetic sources: the forms
this claims to catch are asserted, not asserted-in-a-comment. */
function scan(raw) {
const out = [];
const text = lex(raw);
let m, refs = 0;
const flag = (idx, name, why) => {
if (needsReceiver.has(name))
out.push({ line: lineOf(text, idx), name, why });
};
/* (a) property reference. `\s*` spans newlines, so a call split across
lines is still a call. The next non-space character decides, and it
is a CHARACTER, not a parse: `(` is a call; `?` covers both `?.()`
and a ternary TEST (`fmt.x ? a : b` only asks whether it exists);
`.` covers .bind()/.call()/.apply() and also lets a harmless
property read like `.name` through. Each is deliberately generous:
none of them stores the bare function, which is the mistake here. */
let re = /\bfmt\.([A-Za-z_]\w*)\s*(\S?)/g;
while ((m = re.exec(text)) !== null) {
if (m[2] == '(' || m[2] == '?' || m[2] == '.')
continue;
refs++;
flag(m.index, m[1], `aliased bare -- fix: fmt.${m[1]}.bind(fmt)`);
}
/* (b) destructured out of fmt: every name in the pattern loses `this`. */
re = /\{([^}]*)\}\s*=\s*fmt\b/g;
while ((m = re.exec(text)) !== null)
for (const part of m[1].split(',')) {
const name = part.split(':')[0].trim();
if (!name)
continue;
refs++;
flag(m.index, name, `destructured out of fmt -- call it as fmt.${name}()`);
}
/* (c) computed access with a literal key, not followed by a call. The key
is a string, so lex() has already blanked it -- which is the point:
`fmt['euiccSlot']` written INSIDE a string is blanked whole and never
matches, while the real one leaves `fmt[` spaces `]` behind. The name
is then read back out of the raw source at those same offsets. */
re = /\bfmt\[(\s+)\]\s*(\S?)/g;
while ((m = re.exec(text)) !== null) {
if (m[2] == '(' || m[2] == '?' || m[2] == '.')
continue;
const at = m.index + 'fmt['.length;
const key = raw.slice(at, at + m[1].length).match(/^\s*['"]([A-Za-z_]\w*)['"]\s*$/);
if (!key)
continue;
refs++;
flag(m.index, key[1], `taken by computed key -- call it as fmt.${key[1]}()`);
}
return { out, refs };
}
/* ---- 2b. self-test: prove the scan catches what this file claims ---- */
if (process.argv.includes('--self-test')) {
/* `euiccSlot` and `fmtSim` need a receiver, `fmtBytes` does not.
[ how many findings, which name, what it is, the source ] */
const CASES = [
[ 1, 'euiccSlot', 'bare alias', 'var a = fmt.euiccSlot;' ],
[ 1, 'fmtRegistration', 'destructured', 'const { fmtRegistration } = fmt;' ],
[ 1, 'euiccSlot', 'computed key', "var a = fmt['euiccSlot'];" ],
[ 1, 'fmtSim', 'inside an array', 'var t = [ fmt.fmtSim, 1 ];' ],
[ 1, 'fmtSim', 'ternary branch', 'var t = x ? fmt.fmtSim : null;' ],
[ 1, 'fmtRegistration', 'passed as a callback', 'list.map(fmt.fmtRegistration);' ],
[ 0, null, 'plain call', 'fmt.euiccSlot(s);' ],
[ 0, null, 'call split over lines', 'fmt.fmtRegistration(\n\tmi);' ],
[ 0, null, 'optional call', 'fmt.fmtSim?.(a, b);' ],
[ 0, null, 'bound', 'var a = fmt.euiccSlot.bind(fmt);' ],
[ 0, null, 'call/apply', 'fmt.euiccSlot.call(fmt, s);' ],
[ 0, null, 'computed call', "fmt['euiccSlot'](s);" ],
[ 0, null, 'in a comment', '/* var a = fmt.euiccSlot; */' ],
[ 0, null, 'in a string', "var s = 'fmt.euiccSlot';" ],
[ 0, null, 'standalone helper', 'var a = fmt.fmtBytes;' ],
/* the four the regex chain this replaced got WRONG. Each is a real
shape: graph.js:177 holds an http:// inside a string, and any of
these could blank a live reference or invent a dead one. */
[ 0, null, 'computed key in a string',
'var s = "fmt[\'euiccSlot\']";' ],
[ 1, 'euiccSlot', '// inside a string does not eat the line',
'var s = "http://example"; var a = fmt.euiccSlot;' ],
[ 1, 'euiccSlot', '/* inside a string opens no comment',
'var s = "/*"; var a = fmt.euiccSlot;' ],
[ 1, 'euiccSlot', 'a quote inside a regex literal',
"var r = /'/; var a = fmt.euiccSlot; var s = 'x';" ],
];
let bad = 0;
for (const [ want, name, what, code ] of CASES) {
const got = scan(code).out;
if (got.length != want || (name && got[0] && got[0].name != name)) {
console.error(` self-test FAILED: ${what} -> ${got.length} finding(s)` +
`${got[0] ? ' for ' + got[0].name : ''}, wanted ${want}${name ? ' for ' + name : ''}`);
bad++;
}
}
/* and the reported line survives a multi-line block comment */
const after = scan('/* one\n two\n three\n four */\nvar x = 1;\nvar a = fmt.euiccSlot;\n');
if (after.out.length != 1 || after.out[0].line != 6) {
console.error(` self-test FAILED: line after a block comment -> ${after.out[0] && after.out[0].line}, wanted 6`);
bad++;
}
if (bad) {
console.error(`check-detached-methods --self-test: ${bad} failure(s)`);
process.exit(1);
}
console.log(`check-detached-methods --self-test: ${CASES.length + 1} cases, all correct`);
process.exit(0);
}
/* ---- 2c. the shipped tree ---- */
function walk(dir) {
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
return e.isDirectory() ? walk(p) : (e.name.endsWith('.js') ? [ p ] : []);
});
}
const problems = [];
let refs = 0;
for (const file of walk(RES)) {
if (path.resolve(file) == path.resolve(FORMAT))
continue;
const r = scan(fs.readFileSync(file, 'utf8'));
refs += r.refs;
for (const p of r.out)
problems.push({ file: rel(file), ...p });
}
/* ---- 3. verdict ---- */
if (problems.length) {
console.error('detached method that needs its receiver:\n');
for (const p of problems)
console.error(` ${p.file}:${p.line}: ${p.name}() reads \`this\`, ${p.why}`);
console.error('');
process.exit(1);
}
console.log(`check-detached-methods: ${needsReceiver.size} receiver-dependent of ` +
`${methods.length} methods, ${refs} detached references, all safe`);
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""A panel's title belongs INSIDE its .cbi-section, not beside it.
LuCI builds it that way itself: form.js appends the section title to the
`.cbi-section` element (modules/luci-base/.../form.js:2482-2490 for
TypedSection, :2792-2816 for TableSection, :4251+ for the grid), so a theme
that draws a section as a panel draws its heading inside the panel. A hand-built
panel that puts the <h3> beside the section gets a heading floating above the
box, unstyled.
That is not hypothetical: every panel on the status page had it right and every
one on the modem-tools tab had it wrong since 2026-07-23, and it took a user
comparing the two tabs to notice (ddimension/luci-app-wwand#14, 2026-09-24).
Nothing in the tree could have caught it, which is why this exists.
EVERY <h3> outside a .cbi-section fails. The first version of this tool tried to
be clever — it absolved an <h3> that had a section starting somewhere after it,
on the theory that such a one is a GROUP heading rather than a section title. The
counterproof killed that idea: putting the bug back turned the finding into an
absolution and the check stayed green, so the tool would not have caught the
thing it was written for. A heuristic that exonerates the defect is worse than no
tool. One rule, and the exceptions are named below where a reviewer can see them.
Usage: tools/check-section-titles.py
"""
# Named exceptions: a heading that genuinely titles a RUN of sibling sections and
# sub-headings rather than one panel, where conforming means restructuring the
# panel and not moving a line. Keyed by file and title text, so it survives the
# line moving; adding to this list is a decision, not a line-number refresh.
KNOWN = {
# keyed by a marker from the heading call ITSELF, not by an absent title:
# (file, None) would have exempted any dynamic-title heading in the file
# (Codex review)
('htdocs/luci-static/resources/view/wwand/settings.js', "title + ' ('"):
'plmnTable() returns a sub-block that is only ever appended INSIDE '
"renderPlmnManager's .cbi-section; the nesting is a call, not a "
'literal, so a static scan cannot see it',
}
import re, sys, pathlib
SECTION = re.compile(r"E\(\s*'div'\s*,\s*\{[^{}]*'class'\s*:\s*'cbi-section")
# <h3> AND <h4>: the first version checked only h3, and the SIM/eSIM panel's
# h4 sub-headings ("SIM PIN", "SIM overrides") sat outside their boxes on a
# release that passed it (ddimension/luci-app-wwand#14, reported on 1.6.8_p1).
H3 = re.compile(r"E\(\s*'h[34]'")
# A `/` starts a regex literal only where an OPERAND is expected. Without this
# a regex containing an apostrophe — `/'/` — opens a phantom string, blanks the
# code after it, and the tool stops seeing real headings. Codex review found
# that hole, 2026-09-24; the guard below then makes any remaining mis-lex fail
# loudly instead of passing quietly, which is the direction that matters for a
# tool whose whole job is not to miss things.
_BEFORE_REGEX = set('(,=:[!&|?{};+-*~^%<>') | {'\n'}
def strip_noise(s):
"""Blank strings, comments and regex literals so paren matching is not fooled
by them, keeping offsets identical. Returns (clean, spans) where spans are
(start, end, kind) so a caller can tell WHY a given offset was blanked."""
out = list(s)
spans = []
i, n = 0, len(s)
prev = '' # last significant character before i
while i < n:
c = s[i]
if c in "'\"`":
q, j = c, i + 1
while j < n:
if s[j] == '\\':
j += 2
continue
if s[j] == q:
break
j += 1
for k in range(i, min(j + 1, n)):
out[k] = ' '
spans.append((i, min(j + 1, n), 'string'))
i = j + 1
prev = 'x'
continue
if c == '/' and i + 1 < n and s[i + 1] == '/':
j = s.find('\n', i)
j = n if j < 0 else j
for k in range(i, j):
out[k] = ' '
spans.append((i, j, 'comment'))
i = j
continue
if c == '/' and i + 1 < n and s[i + 1] == '*':
j = s.find('*/', i + 2)
j = n if j < 0 else j + 2
for k in range(i, j):
out[k] = ' '
spans.append((i, j, 'comment'))
i = j
continue
if c == '/' and prev in _BEFORE_REGEX:
j, cls = i + 1, False
while j < n and s[j] != '\n':
if s[j] == '\\':
j += 2
continue
if s[j] == '[':
cls = True
elif s[j] == ']':
cls = False
elif s[j] == '/' and not cls:
break
j += 1
if j < n and s[j] == '/':
for k in range(i, j + 1):
out[k] = ' '
spans.append((i, j + 1, 'regex'))
i = j + 1
prev = 'x'
continue
if not c.isspace():
prev = c
i += 1
return ''.join(out), spans
def extent(clean, start):
"""offset range of the E( ... ) call beginning at `start`."""
i = clean.find('(', start)
if i < 0:
return None
depth = 0
while i < len(clean):
if clean[i] == '(':
depth += 1
elif clean[i] == ')':
depth -= 1
if depth == 0:
return (start, i)
i += 1
return None
def line_of(s, off):
return s.count('\n', 0, off) + 1
root = pathlib.Path(__file__).resolve().parent.parent
files = sorted((root / 'htdocs/luci-static/resources').rglob('*.js'))
bad, structural, unparsed, ok = [], [], [], 0
claimed = set()
for f in files:
raw = f.read_text()
clean, spans_noise = strip_noise(raw)
# the PATTERNS are matched on the raw text (they contain string literals
# that strip_noise blanks out), the paren matching on the cleaned one. A
# match whose own `E` was blanked lived inside a string or a comment and is
# not code — clean[off] tells us that, since strip_noise preserves offsets.
spans = [e for e in (extent(clean, m.start()) for m in SECTION.finditer(raw)
if clean[m.start()] == 'E') if e]
for m in H3.finditer(raw):
off = m.start()
if clean[off] != 'E':
# A COMMENTED-OUT CALL IS NOT CODE and is skipped. Anything else that
# swallowed it means the lexer above got the file wrong, and a tool
# that cannot read the file must say so rather than report success.
kind = next((k for a, b, k in spans_noise if a <= off < b), '?')
if kind != 'comment':
unparsed.append((str(f.relative_to(root)), line_of(raw, off), kind))
continue
inside = [sp for sp in spans if sp[0] < off < sp[1]]
rel = str(f.relative_to(root))
if inside:
ok += 1
continue
t = re.match(r"E\(\s*'h[34]'\s*,[^,]*,\s*\[?\s*_\(\s*'([^']*)'", raw[off:off + 200])
title = t.group(1) if t else None
tag = raw[off:off + 12].split("'")[1]
key = (rel, title)
call = raw[off:off + 120]
for (kf, kmark) in KNOWN:
if kf == rel and kmark and kmark not in (title or '') and kmark in call:
key = (kf, kmark)
# EXACTLY ONE. Keyed by file and title alone, a second accidental
# <h3>SIM</h3> in the same file would inherit the exemption — the
# false negative an allowlist is always one step away from. Codex
# review, 2026-09-24.
if key in KNOWN and key not in claimed:
claimed.add(key)
structural.append((rel, line_of(raw, off), title, tag, key))
else:
bad.append((rel, line_of(raw, off), title, tag))
for rel, ln, title, tag in bad:
print(' OUTSIDE a .cbi-section: %s:%d <%s>%s</%s>' % (rel, ln, tag, title or '?', tag))
print(' LuCI puts a section title inside the section (form.js:2482-2490).')
for rel, ln, title, tag, key in structural:
print(' known exception %s:%d <%s>%s</%s>' % (rel, ln, tag, title or '?', tag))
print(' %s' % KNOWN[key])
for rel, ln, kind in unparsed:
print(' UNPARSED %s:%d an E(\'h3\' the lexer lost inside a %s — fix strip_noise()' % (rel, ln, kind))
print('checked %d headings in %d files: %d inside a .cbi-section, %d outside, %d known, %d unparsed'
% (ok + len(bad) + len(structural) + len(unparsed), len(files), ok, len(bad),
len(structural), len(unparsed)))
sys.exit(1 if (bad or unparsed) else 0)
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Refuse to let outside text reach innerHTML.
LuCI's E() takes either E(tag, children) or E(tag, attrs, children). A child
passed as a bare STRING is assigned through innerHTML and its markup is parsed;
a child passed inside an ARRAY becomes a text node and is not. Every string
wwand renders can come from the modem, the SIM, the operator or lpac -- an SMS
body most of all, which anyone who knows the number can write -- so the rule is:
E('td', {}, [ msg.text ]) and never E('td', {}, msg.text)
A literal is fine (we wrote it). A translated literal is fine. A translated
literal with .format() is NOT: the substituted value is the outside part.
This is the checkable half of that rule. It is deliberately syntactic and errs
toward reporting: a false positive costs a pair of brackets, a false negative
costs a cross-site scripting hole in an authenticated admin session.
Usage: check-xss.py <dir>... (exit 1 if anything is flagged)
"""
import re, sys, os
def split_args(s, i):
"""Split the argument list of a call whose '(' is at s[i]. Returns
(args, end) with args as raw source slices, or (None, None) if unbalanced."""
depth, args, start, j = 0, [], i + 1, i
quote = None
while j < len(s):
c = s[j]
if quote:
if c == '\\':
j += 2
continue
if c == quote:
quote = None
elif c in '\'"`':
quote = c
elif c in '([{':
depth += 1
elif c in ')]}':
depth -= 1
if depth == 0:
args.append(s[start:j])
return args, j
elif c == ',' and depth == 1:
args.append(s[start:j])
start = j + 1
j += 1
return None, None
# Deciding this in general needs types: E('div', {}, cards) with an array of
# nodes is safe, E('td', {}, msg.text) is not, and they are the same syntax. So
# this checks only what can be PROVEN to be a string -- a .format() result or a
# concatenation with a string literal. Those are always text, always reach
# innerHTML, and are the shape nearly every real finding here had. Bare
# identifiers are left alone deliberately: flagging them drowned the real hits
# ~3:1 in node arrays, and a check nobody can keep green stops being run.
STRINGS = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"")
def mask_nested(e):
"""Blank out nested E(...) calls. Their contents are checked as their own
children; counted here they make every enclosing .map() look like a string."""
out, i = e, 0
while True:
m = re.search(r'\bE\s*\(', out[i:])
if not m:
return out
j = i + m.end() - 1
args, end = split_args(out, j)
if end is None:
return out
out = out[:j] + '\x01' * (end - j + 1) + out[end + 1:]
i = end + 1
def safe_child(expr):
e = expr.strip()
if not e or e.startswith('[') or e.startswith('{') or e.startswith('E('):
return True
# blank out string literals, then look for the two provable-string shapes
masked = STRINGS.sub(lambda m: '\x00' * len(m.group(0)), mask_nested(e))
if '.format(' in masked:
return False
if '+' in masked and '\x00' in masked:
return False # concatenation involving a literal -> a string
# `a() || b || 'fallback'` is a string in EVERY branch, but it carries no
# .format() and no +, so both tests above pass it. That shape shipped a real
# hole: a PLMN row rendered `describe(...) || e.name || '—'` as a bare child,
# and e.name is the operator name the MODEM supplied (openwrt/luci#8917).
# A literal anywhere in an || chain settles the type of the whole expression.
if '||' in masked and '\x00' in masked:
return False
return True
def check(path):
src = open(path, encoding='utf-8').read()
bad = []
for m in re.finditer(r'\bE\s*\(', src):
i = m.end() - 1
args, end = split_args(src, i)
if not args or len(args) < 2:
continue
# E(tag, attrs, child...) vs E(tag, child)
children = args[2:] if args[1].strip().startswith('{') else args[1:]
for c in children:
if not safe_child(c):
line = src.count('\n', 0, i) + 1
bad.append((line, ' '.join(c.split())[:96]))
break
return bad
# NO ARGUMENTS USED TO MEAN NO WORK, and it still printed "clean" and exited 0.
# That is the worst possible answer: it was quoted as evidence that a diff was
# safe when nothing had been read at all. Default to the package's own trees, and
# refuse rather than reassure if even those are missing.
roots = sys.argv[1:]
if not roots:
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
roots = [ d for d in (os.path.join(here, 'htdocs'), os.path.join(here, 'root'))
if os.path.isdir(d) ]
if not roots:
sys.exit('check-xss: nothing to scan — pass a directory')
rc = 0
scanned = 0
for root in roots:
for dirpath, _dirs, files in os.walk(root):
for f in sorted(files):
if not f.endswith('.js'):
continue
p = os.path.join(dirpath, f)
scanned += 1
for line, expr in check(p):
print(f'{p}:{line}: bare string child -> innerHTML: {expr}')
rc = 1
print('check-xss: %s (%d files)' % ('FLAGGED' if rc else 'clean', scanned))
sys.exit(rc)
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Regenerate htdocs/luci-static/resources/wwand/mccmnc.js.
Sources:
- https://raw.githubusercontent.com/musalbas/mcc-mnc-table/master/mcc-mnc-table.json
(ITU E.212 national assignments)
- https://en.wikipedia.org/wiki/Mobile_country_code (International operators:
MCC 901/902/991 -- the musalbas table only carries a handful of these)
Usage: gen-mccmnc.py <mcc-mnc-table.json> <mcc-wikipedia.html> > mccmnc.js
"""
import json, re, sys, html
table = json.load(open(sys.argv[1], encoding='utf-8'))
wiki = open(sys.argv[2], encoding='utf-8').read()
db = {} # mcc -> { '_c': country, mnc: name }
def put(mcc, mnc, name, country):
if not name:
return
m = db.setdefault(mcc, {})
if country and '_c' not in m:
m['_c'] = country
m.setdefault(mnc, name)
for e in table:
net = (e.get('network') or '').strip()
if e['mcc'] in ('901', '902', '991'):
continue # replaced wholesale by the Wikipedia international table
put(e['mcc'], e['mnc'], net, (e.get('country') or '').strip())
# international operators from the Wikipedia tables (brand preferred)
rows = re.findall(r'<tr>(.*?)</tr>', wiki, re.S)
strip = lambda c: html.unescape(re.sub(r'<[^>]+>', '', c)).strip()
for r in rows:
cells = re.findall(r'<t[dh][^>]*>(.*?)</t[dh]>', r, re.S)
if len(cells) < 4:
continue
mcc, mnc, brand, op = (strip(c) for c in cells[:4])
if mcc not in ('901', '902', '991') or not re.match(r'^[0-9]+$', mnc or ''):
continue
name = brand if brand else op
if brand and op and brand != op:
name = '%s (%s)' % (brand, op)
put(mcc, mnc, name, 'International')
# field knowledge: Telenor Maritime (ex MCP) is what cruise passengers see
if '12' in db.get('901', {}):
db['901']['12'] = 'Telenor Maritime / AIDA Cruises'
out = json.dumps(db, sort_keys=True, ensure_ascii=False, separators=(',', ':'))
print("'use strict';")
print("'require baseclass';")
print()
print("/* MCC/MNC -> operator-name database. GENERATED by tools/gen-mccmnc.py")
print(" from the musalbas/mcc-mnc-table dump (ITU E.212) plus the Wikipedia")
print(" international-operators table (MCC 901/902/991). Do not edit by hand")
print(" -- rerun the generator. Layout: MCC -> { _c: country, MNC: name }. */")
print('var DB = %s;' % out)
print('''
return baseclass.extend({
/* operator name for an mcc/mnc (numbers or strings; 2- and 3-digit MNC
forms are both tried) -- null when unknown */
name: function(mcc, mnc) {
var m = DB['' + mcc];
if (!m || mnc == null) return null;
var s = '' + mnc;
var two = (s.length < 2) ? ('0' + s) : s;
return m[two] || m[s] || m['0' + s] || null;
},
/* country (or 'International') for an mcc -- null when unknown */
country: function(mcc) {
var m = DB['' + mcc];
return m ? (m._c || null) : null;
},
/* "name, country" one-liner, or null */
describe: function(mcc, mnc) {
var n = this.name(mcc, mnc), c = this.country(mcc);
if (!n) return null;
return (c && c != 'International') ? (n + ', ' + c) : n;
}
});''')
+819
View File
@@ -0,0 +1,819 @@
#!/usr/bin/env node
/* Standalone checks for the PURE helpers in resources/wwand/format.js.
*
* format.js is a LuCI module: the file body ends in a top-level `return
* baseclass.extend({...})` and its `'require x'` lines are directives the LuCI
* loader consumes, not real imports. So it cannot be `require()`d — but it can
* be evaluated as a function body with the handful of globals LuCI would have
* provided. That is all this harness does; it deliberately does not fake a DOM,
* so only value-returning helpers are testable here (which is why the panel
* decisions live in format.js as pure functions rather than inline in the view).
*
* node tools/test-format.js
*/
'use strict';
const fs = require('fs');
const path = require('path');
const src = fs.readFileSync(
path.join(__dirname, '..', 'htdocs', 'luci-static', 'resources', 'wwand', 'format.js'),
'utf8');
/* drop the loader directives; keep every line number otherwise intact */
const body = src.replace(/^\s*'require [^']*';\s*$/gm, '');
/* LuCI installs String.prototype.format in cbi.js, which this repo does not
* ship — so every helper that uses it was untestable here, which is why the
* whole fmtMnc / fmtPlmn / fmtOperator family had no coverage until a review
* found a wrong MNC in all three. A STUB, deliberately: it covers only the
* directives format.js actually uses (%s, %d and %0Nd — verified by grep over
* that file, 2026-09-19) and does no HTML escaping, because nothing asserted
* here renders markup. A new directive appearing in format.js and not here
* will show up as a literal in an expected value, not as a silent pass. */
String.prototype.format = function () {
var args = arguments, i = 0;
return this.replace(/%(0(\d+))?([sd%])/g, function (m, _pad, width, conv) {
if (conv == '%')
return '%';
var v = args[i++];
if (conv == 'd') {
var out = String(Math.trunc(+v) || 0);
while (width && out.length < +width)
out = '0' + out;
return out;
}
return String(v);
});
};
const length_of = (a) => (a && a.length) || 0;
const baseclass = { extend: (o) => o };
const _ = (s) => s; /* i18n passthrough */
/* no DOM here: an element is its description, so a test can assert what
would be built (tag, attributes, children) without a browser */
const E = (tag, attr, children) =>
({ tag: tag, attr: attr || {}, children: children || [] });
const ui = {};
const fmt = new Function('baseclass', '_', 'E', 'ui', body)(baseclass, _, E, ui);
let checks = 0, failures = 0;
function eq(got, want, label) {
checks++;
/* structural where it matters: several helpers answer with a list, and
`===` on two arrays is always false — which reports a failure whose
"got" and "want" print identically and sends the reader hunting */
if (got === want ||
(got && want && typeof got == 'object' && typeof want == 'object' &&
JSON.stringify(got) === JSON.stringify(want)))
return;
failures++;
console.log(`FAIL: ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
}
/* --- signalNone: the claim must match the registration block --------------- */
eq(fmt.signalNone({ registration: 1 }), 'registered — this modem reports no signal detail',
'signalNone: registered modem is not called unregistered');
eq(fmt.signalNone({ registration: 0 }), 'no signal (modem not registered)',
'signalNone: genuinely unregistered');
eq(fmt.signalNone(null), 'no signal (modem not registered)',
'signalNone: no registration block -> the cautious claim');
/* it must agree with regShort(), which is what the Serving cell panel prints */
for (const reg of [ { registration: 1 }, { registration: 0 }, {} ]) {
const registered = (fmt.regShort(reg) === 'registered');
const claimsUnregistered = (fmt.signalNone(reg).indexOf('not registered') >= 0);
eq(claimsUnregistered, !registered,
`signalNone agrees with regShort for ${JSON.stringify(reg)}`);
}
/* --- signalSample: what the realtime graphs plot ------------------------- */
const S = (sig) => JSON.stringify(fmt.signalSample(sig));
/* ONE SERIES PER RAT, one GROUP PER QUANTITY. Slots:
rsrp = [ RSRP LTE, RSRP 5G, RSCP 3G ] · rssi = [ LTE, 3G, 2G, untagged ]
sinr = [ LTE, 5G ] · rsrq = [ LTE, 5G ] · ecio = [ 3G ]
A modem flapping between RATs must not draw one line that silently changes
which carrier it means — and on EN-DC both arrive in the SAME reply, so they
are not alternatives. */
const NONE = { rsrp: [ null, null, null ], rssi: [ null, null, null, null ],
sinr: [ null, null ], rsrq: [ null, null ], ecio: [ null ] };
const S_ = (over) => JSON.stringify(Object.assign({}, NONE, over));
eq(S({ nr5g: { rsrp: -106, snr: 160 }, lte: { rsrp: -94, snr: 240, rsrq: -9 } }),
S_({ rsrp: [ -94, -106, null ], sinr: [ 24, 16 ], rsrq: [ -9, null ] }),
'signalSample: en-dc keeps LTE and 5G apart');
/* SINR, RSRQ AND EC/IO ARE NOT ONE AXIS even though all three are dB: they
measure different things and carry different boundaries. Sharing a group had
a normal -12 dB RSRQ drawn below SINR's "poor" rule. */
eq(S({ lte: { snr: 240, rsrq: -12 }, nr5g: { snr: 160, rsrq: -11 } }),
S_({ sinr: [ 24, 16 ], rsrq: [ -12, -11 ] }),
'signalSample: sinr and rsrq are separate groups');
/* NR RSRQ ARRIVES BESIDE nr5g, NOT INSIDE IT — QMI NAS Get Signal Info puts NR
RSRP/SNR in TLV 0x17 and NR RSRQ in TLV 0x18, so the decoded reply carries a
top-level `nr5g_rsrq` (codec/schema/nas.uc:110-113). Reading only nr5g.rsrq
drew an empty 5G RSRQ line, indistinguishable from a modem that does not
report it. This is the shape an RG650E actually returns. */
eq(S({ lte: { rssi: -66, rsrq: -17, rsrp: -100, snr: 138 },
nr5g: { rsrp: -32768, snr: -32768 }, nr5g_rsrq: -11 }),
S_({ rsrp: [ -100, null, null ], rssi: [ -66, null, null, null ], sinr: [ 13.8, null ], rsrq: [ -17, -11 ] }),
'signalSample: 5G rsrq comes from the top-level nr5g_rsrq');
/* AND IT IS PLAIN dB, not tenths. libqmi prints this TLV as '%d dB' with no
scaling (qmicli-nas.c:581-584, 1.38.0) while it multiplies the neighbouring
SNR by 0.1 — so the two sit side by side in one reply in different units. A
review read the old test's -110 as evidence of a missing divide-by-ten; the
value was simply unrealistic. -110 dB RSRQ does not exist, and a test whose
numbers cannot occur invites exactly that misreading. */
eq(S({ nr5g_rsrq: -13 }), S_({ rsrq: [ null, -13 ] }),
'signalSample: nr5g_rsrq is plain dB, not tenths');
eq(S({ nr5g: { rsrp: -32768, snr: -32768 }, nr5g_rsrq: -32768 }), S_({}),
'signalSample: an unreported nr5g_rsrq stays a gap');
eq(S({ nr5g: { rsrq: -12 }, nr5g_rsrq: -110 }), S_({ rsrq: [ null, -12 ] }),
'signalSample: a nested nr5g.rsrq still takes precedence');
/* 2G AND 3G ARE RATs TOO, and a modem falling back to one is exactly the event
a graph should make visible.
RSCP AND RSSI ARE NOT ONE SERIES. Both are 3G strength in dBm, but RSCP is
the serving cell's pilot and RSSI is everything in the carrier; folding them
with `rscp ?? rssi` gave a line that changed which measure it meant the
moment a modem stopped reporting RSCP, with nothing on screen saying so. */
eq(S({ wcdma: { rscp: -95, rssi: -80, ecio: -7.5 } }),
S_({ rsrp: [ null, null, -95 ], rssi: [ null, -80, null, null ], ecio: [ -7.5 ] }),
'signalSample: 3G plots rscp and rssi as separate lines');
eq(S({ wcdma: { rssi: -80, ecio: -7.5 } }),
S_({ rsrp: [ null, null, null ], rssi: [ null, -80, null, null ], ecio: [ -7.5 ] }),
'signalSample: a 3G rssi without rscp stays in the rssi slot');
/* +CESQ calls the same measure `ecno` (atcmd_parse.uc:307) where QMI and ^HCSQ
call it `ecio` — one quantity, two spellings, and a graph that reads only one
of them goes blank depending on which parser answered */
eq(S({ wcdma: { rscp: -95, ecno: -9 } }),
S_({ rsrp: [ null, null, -95 ], ecio: [ -9 ] }),
'signalSample: ecno is the same measure as ecio');
eq(S({ gsm_rssi: -78 }), S_({ rsrp: [ null, null, null ], rssi: [ null, null, -78, null ] }),
'signalSample: 2G plots its rssi in its own slot');
/* LTE only: the 5G slot stays null, and that gap is the point — it says 5G was
not serving, which a single blended line could never show */
eq(S({ lte: { rsrp: -94, snr: 240, rsrq: -9 } }),
S_({ rsrp: [ -94, null, null ], sinr: [ 24, null ], rsrq: [ -9, null ] }),
'signalSample: lte-only leaves a gap in the 5G series');
eq(S({ nr5g: { rsrp: -88, snr: 180, rsrq: -11 } }),
S_({ rsrp: [ null, -88, null ], sinr: [ null, 18 ], rsrq: [ null, -11 ] }),
'signalSample: standalone 5G leaves a gap in the LTE series');
/* snr is TENTHS of a dB on every backend; plotting it raw draws a graph ten
times too tall, and nothing about the picture would say so */
eq(S({ lte: { snr: 135 } }), S_({ sinr: [ 13.5, null ] }),
'signalSample: snr scaled from tenths');
/* THE UNTAGGED SLOT IS ONLY FOR AN UNTAGGED VALUE. A top-level rssi is the
AT+CSQ floor a NAS 1.0 stack falls back to (HW-seen on the E182E): nothing
says which radio measured it, so it is drawn without a RAT. */
eq(S({ rssi: -101 }), S_({ rsrp: [ null, null, null ], rssi: [ null, null, null, -101 ] }),
'signalSample: an untagged rssi plots in the untagged slot');
/* But `lte.rssi` IS tagged, and must not be laundered into that slot. It was:
`sig.rssi ?? lte.rssi` meant that on a modem with no top-level value — the
RM520N-GL, HW-observed 2026-09-10 — the line labelled plainly "RSSI" was the
LTE one and never admitted it. */
eq(S({ lte: { rssi: -84 } }), S_({ rsrp: [ null, null, null ], rssi: [ -84, null, null, null ] }),
'signalSample: an lte rssi is labelled LTE, not untagged');
/* BOTH PRESENT: the tagged one wins and the untagged slot stays empty. Modems
really do report the same measurement twice — FM350-GL rssi -101 beside
lte.rssi -101, E3372 -85 beside -86 (HW-observed 2026-09-10) — and drawing
both produced a duplicate line distinguished only by claiming to have no
radio behind it. */
eq(S({ rssi: -101, lte: { rssi: -84 } }),
S_({ rsrp: [ null, null, null ], rssi: [ -84, null, null, null ] }),
'signalSample: a tagged rssi suppresses the untagged line');
eq(S({ rssi: -83, gsm_rssi: -78 }),
S_({ rsrp: [ null, null, null ], rssi: [ null, null, -78, null ] }),
'signalSample: a 2G rssi suppresses it too');
eq(S({ rssi: -83, lte: { rsrp: -95 } }),
S_({ rsrp: [ -95, null, null ], rssi: [ null, null, null, -83 ] }),
'signalSample: an rsrp is not an rssi — the untagged line still shows');
/* RSSI IS NOT ON THE RSRP CANVAS. Its ladder sits 20 dB higher and a strong
signal reaches -46 dBm, off the top of any scale drawn for RSRP — shared, it
was pinned to the ceiling and showed nothing (HW screenshot, wwand#14). The
two groups must therefore stay apart even when both are reported. */
eq(S({ lte: { rsrp: -78, rssi: -46 } }),
S_({ rsrp: [ -78, null, null ], rssi: [ -46, null, null, null ] }),
'signalSample: rsrp and rssi are separate groups, not one canvas');
/* A MISSING VALUE MUST STAY null. Zero is a legitimate dB reading and off the
top of a dBm scale; turning a gap into 0 would draw "signal lost" where the
modem merely said nothing. */
eq(S({}), S_({}), 'signalSample: nothing reported stays null, never 0');
eq(S(null), S_({}), 'signalSample: no reply at all');
eq(S({ lte: { rsrp: -32768, snr: -32768 } }), S_({}),
'signalSample: the -32768 sentinel is not a measurement');
/* 0 dB SINR is marginal but real, and must survive as a value */
eq(S({ lte: { snr: 0, rsrp: -100 } }),
S_({ rsrp: [ -100, null, null ], sinr: [ 0, null ] }),
'signalSample: 0 dB is a reading, not a gap');
/* A 3-DIGIT MNC IS NOT KNOWABLE FROM THE NUMBER. 310/030 and 310/30 are
* different operators and both are the integer 30, so padding to a fixed two
* rendered the first as the second — across the operator line, the scan table
* and the PLMN editor. The daemon sends `mnc_digits` for exactly this, from
* the QMI PCS-digit TLVs and from the quoted PLMN id of an AT scan, and
* nothing here read it. Found by a full review, 2026-09-19. */
eq(fmt.fmtMnc(30, 3), '030', 'fmtMnc: a declared 3-digit MNC keeps its leading zero');
eq(fmt.fmtMnc(30, 2), '30', 'fmtMnc: a declared 2-digit MNC does not gain one');
eq(fmt.fmtMnc(30), '30', 'fmtMnc: undeclared falls back to two');
eq(fmt.fmtMnc(6, 2), '06', 'fmtMnc: 260/06 still pads');
eq(fmt.fmtMnc(260), '260', 'fmtMnc: a value >= 100 settles its own width');
eq(fmt.fmtMnc(260, 2), '260', 'fmtMnc: ...and is never truncated to the declared two');
eq(fmt.fmtMnc(30, 9), '30', 'fmtMnc: a nonsense width is ignored, not looped on');
eq(fmt.fmtMnc(null, 3), '?', 'fmtMnc: nothing reported stays "?"');
eq(fmt.fmtPlmn(310, 30, 3), '310/030', 'fmtPlmn: the width reaches the pair');
eq(fmt.fmtPlmn(310, 30), '310/30', 'fmtPlmn: and its absence is the old behaviour');
/* the operator line takes it off the registration block, and when it falls
* back to splitting the raw id the id itself states the width */
eq(fmt.fmtOperator({ plmn: { mcc: 310, mnc: 30, mnc_digits: 3, description: 'X' } }),
'X (310/030)', 'fmtOperator: mnc_digits from the registration');
eq(fmt.fmtOperator({ plmn: { id: '310030', description: 'X' } }),
'X (310/030)', 'fmtOperator: the raw id states its own width');
eq(fmt.fmtOperator({ plmn: { id: '31030', description: 'X' } }),
'X (310/30)', 'fmtOperator: ...and the 2-digit one likewise');
/* fmtRegistration renders the same pair with the same hazard — a second
* hardcoded %02d that fmtOperator's fix did not reach. */
eq(fmt.fmtRegistration({ registration: { registration: 1,
plmn: { mcc: 310, mnc: 30, mnc_digits: 3 } } }),
'310/030', 'fmtRegistration: the MNC width reaches this line too');
eq(fmt.fmtRegistration({ registration: { registration: 1,
plmn: { mcc: 262, mnc: 1 } } }),
'262/01', 'fmtRegistration: and a 2-digit MNC still pads');
/* The reporter's own payload, verbatim from the ubus reply in
* openwrt/packages#37 (2026-09-21): a description of null is what forces the
* branch through fmtMnc, and every check above happens to take it too. Pinned
* separately anyway, because it is the shape the field produced. */
var xlsmart = { registration: { registration: 1, roaming: false,
plmn: { mcc: 510, mnc: 11, mnc_digits: 2, description: null } } };
eq(fmt.fmtRegistration(xlsmart), '510/11',
'fmtRegistration: XLSmart, the payload from #37');
/* WITNESS, not a preference: every check above passes a receiver, which is
* exactly why a view that aliased this method bare shipped a column that threw
* on every draw of it. The hazard is a property of the method, so it is
* asserted here; tools/check-detached-methods.js asserts that no caller walks
* into it.
*
* TWO assertions, because "it throws" on its own would be satisfied by a typo
* in the payload just as well: the error has to NAME the sibling it could not
* reach, and the method has to demonstrably route through whatever receiver it
* is handed. If format.js is ever made receiver-free both fail -- correctly,
* and the checker's receiver-dependent set shrinks with them. */
var bare = fmt.fmtRegistration, err = null;
try { bare(xlsmart); } catch (e) { err = e; }
eq(err instanceof TypeError && /fmtMnc/.test(String(err)), true,
'fmtRegistration: detached, it throws naming the sibling it cannot reach');
var stub = Object.create(fmt);
stub.fmtMnc = function() { return 'SENTINEL'; };
eq(fmt.fmtRegistration.call(stub, xlsmart), '510/SENTINEL',
'fmtRegistration: ...and given a receiver, the MNC goes through it');
/* --- the one eUICC rule ---------------------------------------------------
*
* Three call sites used to answer "which slot's eSIM can be read" for
* themselves, and two of them disagreed. The rule includes `active` because the
* APDU channel belongs to the active card — an eUICC in the other slot is not
* addressable without switching first. */
eq(fmt.euiccReadable({ is_euicc: true, active: true, card: 'present', physical: 1 }),
true, 'euicc: an active, present eUICC is readable');
eq(fmt.euiccReadable({ is_euicc: true, active: false, card: 'present', physical: 2 }),
false, 'euicc: ...one in the inactive slot is not');
eq(fmt.euiccReadable({ is_euicc: true, active: true, card: 'absent', physical: 1 }),
false, 'euicc: ...nor an empty slot');
eq(fmt.euiccReadable({ is_euicc: false, active: true, card: 'present', physical: 1 }),
false, 'euicc: ...nor a plain SIM');
/* 0 is not a slot: slots are 1-based everywhere the daemon builds them, and 0
* was the value that slipped through its `?? 1` and addressed a non-slot */
eq(fmt.euiccReadable({ is_euicc: true, active: true, card: 'present', physical: 0 }),
false, 'euicc: ...nor physical slot 0');
eq(fmt.euiccReadable(null), false, 'euicc: ...and no slot at all is not readable');
eq(fmt.euiccSlot([
{ is_euicc: false, active: true, card: 'present', physical: 1 },
{ is_euicc: true, active: true, card: 'present', physical: 2 },
]).physical, 2, 'euicc: the readable slot is found in a mixed list');
/* euiccProbeSlot: the SEPARATE question "where is it worth asking". A modem
that cannot enumerate slots gets an inferred record with is_euicc null (wwand
sim.uc single_slot), and euiccReadable correctly says no — which is what hid
a perfectly readable eSIM on a MeiG SLM770A behind the panel's gate. */
eq(fmt.euiccProbeSlot([ { is_euicc: null, active: true, card: 'present', physical: 1,
inferred: true } ]).physical,
1, 'probe: an inferred single slot is worth asking about');
eq(fmt.euiccProbeSlot([ { is_euicc: null, active: true, card: 'unknown', physical: 1,
inferred: true } ]).physical,
1, 'probe: ...even before the card has been read');
eq(fmt.euiccProbeSlot([ { is_euicc: false, active: true, card: 'present', physical: 1 } ]),
null, 'probe: a slot that SAID it is a plain SIM is not asked again');
eq(fmt.euiccProbeSlot([ { is_euicc: null, active: false, card: 'present', physical: 1 } ]),
null, 'probe: an inactive slot has no open APDU channel');
eq(fmt.euiccProbeSlot([ { is_euicc: null, active: true, card: 'absent', physical: 1 } ]),
null, 'probe: an empty slot has nothing to ask');
/* AND THE CASE THE OLD FALLBACK GOT WRONG, which is why it was removed: with a
real two-slot list, slot 1 is a DIFFERENT card and asking about it answers
the wrong question. Two slots is never a probe. */
eq(fmt.euiccProbeSlot([
{ is_euicc: null, active: true, card: 'present', physical: 1 },
{ is_euicc: null, active: false, card: 'present', physical: 2 },
]), null, 'probe: a real multi-slot list is never guessed at');
eq(fmt.euiccProbeSlot([
{ is_euicc: false, active: true, card: 'present', physical: 1 },
{ is_euicc: true, active: true, card: 'present', physical: 2 },
]).physical, 2, 'probe: ...and a known eUICC still wins outright');
eq(fmt.euiccProbeSlot([]), null, 'probe: no slots, nothing to ask');
/* AND THE FLAG IS THE CONTRACT. A single row that merely LOOKS inferred —
because some future producer left is_euicc out — is not one, and guessing on
shape would quietly adopt it. */
eq(fmt.euiccProbeSlot([ { is_euicc: null, active: true, card: 'present', physical: 1 } ]),
null, 'probe: an unmarked single row is not treated as an inference');
/* slotEnumerated: reading an inferred row is fine, CHOOSING with it is not —
the sim_slot dropdown and the "set as primary" button persist a number the
daemon made up. Mirror of sim.enumerated() on the daemon side. */
eq(fmt.slotEnumerated({ physical: 1, active: true, inferred: true }),
false, 'enumerated: an inferred row is not a slot to configure');
eq(fmt.slotEnumerated({ physical: 2, active: false }),
true, 'enumerated: a reported row is');
eq(fmt.slotEnumerated(null), false, 'enumerated: and no row is not either');
/* slotPinnable: may this slot be written to `option sim_slot`. The status
page's switch button has always required a card; the tools page's "Set as
primary" sat on the same rows and did not (ddimension/luci-app-wwand#12). */
eq(fmt.slotPinnable({ physical: 1, card: 'present', active: true }),
true, 'pinnable: the slot in use is the obvious thing to pin');
eq(fmt.slotPinnable({ physical: 2, card: 'present', active: false }),
true, 'pinnable: ...and so is the other one, if it holds a card');
eq(fmt.slotPinnable({ physical: 2, card: 'absent', active: false }),
false, 'pinnable: an empty slot would ask the modem to boot on nothing');
eq(fmt.slotPinnable({ physical: 2, card: 'unknown', active: false }),
false, 'pinnable: ...and an unread one is not known to hold anything');
eq(fmt.slotPinnable({ physical: 1, card: 'present', active: true, inferred: true }),
false, 'pinnable: an inferred row names no slot to pin');
eq(fmt.slotPinnable(null), false, 'pinnable: and no row, nothing to pin');
/* slotSwitchable: the third policy, previously spelled out in two renderers */
eq(fmt.slotSwitchable({ card: 'present', active: false }),
true, 'switchable: an idle slot with a card can be switched to');
eq(fmt.slotSwitchable({ card: 'present', active: true }),
false, 'switchable: ...but switching to the one you are on does nothing');
eq(fmt.slotSwitchable({ card: 'absent', active: false }),
false, 'switchable: ...and there is nothing to switch to in an empty slot');
eq(fmt.slotSwitchable(null), false, 'switchable: and no row is not a slot');
/* multisimText: what the SIM-slot panel says about the modem's shape. The slot
COUNT is the half that was missing — obsy's modem reports two slots and one
executor on a board with one card reader, and the row gave him nothing to
connect the second (empty) slot row to (ddimension/luci-app-wwand#12). */
eq(fmt.multisimText({ slots: 2, executors: 1, concurrency: 1,
mode: 'dssa', mode_min: 'dssa', exact: true }),
'2 slots \u00b7 one SIM active at a time (switching)',
'multisim: the enumerated slot count is named alongside the mode');
eq(fmt.multisimText({ slots: 2, executors: 2, concurrency: 2,
mode: 'dsda', exact: true }),
'2 slots \u00b7 both usable at once', 'multisim: ...whatever the mode');
/* `exact` qualifies the MODE, not the count: sim.uc:877 takes `slots` from the
length of the slot list either way, while :910 sets exact from whether the
executor figures came from SYS_CAPS. So the count appears in both cases and
the marker attaches to the mode. */
eq(fmt.multisimText({ slots: 2, mode: null, mode_min: 'dsds', exact: false }),
'2 slots \u00b7 at least DSDS (inferred)',
'multisim: the marker sits beside the mode, not at the end of the line');
/* one active logical slot supports no claim at all — that is a single-executor
modem and an under-observed dual-executor one alike, so the row says nothing
rather than something that reads as a measurement */
eq(fmt.multisimText({ slots: 2, mode: null, mode_min: null, exact: false }),
null, 'multisim: no mode and no floor -> no row');
eq(fmt.multisimText(null), null, 'multisim: and no report -> no row');
/* a mode the daemon grows later must not vanish from the page */
eq(fmt.multisimText({ slots: 1, mode: 'tsts', exact: true }),
'TSTS', 'multisim: an unknown mode is passed through, not dropped');
/* cardText: one word per state, because there were two — simSlotRow printed
the daemon's identifier while simSlotCard said "empty" for the same slot. */
eq(fmt.cardText('present'), 'card present', 'cardtext: a card is a card');
eq(fmt.cardText('absent'), 'empty', 'cardtext: absent reads as empty');
eq(fmt.cardText('error'), 'card error', 'cardtext: an error is not an emptiness');
eq(fmt.cardText('unknown'), 'not read',
'cardtext: ...and neither is a slot the modem would not talk about');
eq(fmt.cardText(undefined), 'not read', 'cardtext: a missing state is unread, not empty');
/* euiccConfirmed: evidence beats inference. On an inferred slot the label and
the profile list follow the answer that came back, not the null. */
var inferred = { is_euicc: null, active: true, card: 'present', physical: 1 };
eq(fmt.euiccConfirmed(inferred, [ { iccid: '8988', state: 'enabled' } ]),
true, 'confirmed: a profile list proves the inferred slot is an eUICC');
eq(fmt.euiccConfirmed(inferred, []),
true, 'confirmed: ...an empty list too — only an eUICC answers "none installed"');
eq(fmt.euiccConfirmed(inferred, null),
false, 'confirmed: ...but an unread slot is not claimed to be one');
eq(fmt.euiccConfirmed({ is_euicc: true, physical: 1 }, null),
true, 'confirmed: a modem that SAID eUICC needs no corroboration');
eq(fmt.euiccConfirmed({ is_euicc: false, physical: 1 }, [ { iccid: '1' } ]),
false, 'confirmed: ...and one that said otherwise is believed over a stray read');
eq(fmt.euiccConfirmed(null, null), false, 'confirmed: no slot, no claim');
eq(fmt.euiccSlot([ { is_euicc: true, active: false, card: 'present', physical: 2 } ]),
null, 'euicc: an unreachable eUICC yields null, not a slot to guess with');
eq(fmt.euiccSlot([]), null, 'euicc: an empty slot list yields null');
eq(fmt.euiccSlot(undefined), null, 'euicc: ...and so does a missing one');
/* --- collapsing a scan ----------------------------------------------------
*
* The identity of an operator includes the WIDTH of its MNC: 310/030 and
* 310/30 are two of them. Collapsing on the bare number merged the pair into
* one row which then inherited the other's status and technologies. */
(function () {
var out = fmt.collapseScan([
{ mcc: 310, mnc: 30, mnc_digits: 2, status: 'available', rats: [ 'LTE' ] },
{ mcc: 310, mnc: 30, mnc_digits: 3, status: 'forbidden', rats: [ 'GSM' ] },
]);
eq(out.length, 2, 'collapseScan: 310/30 and 310/030 stay two operators');
eq(out[0].status, 'available', 'collapseScan: ...each keeping its own status');
eq(out[1].status, 'forbidden', 'collapseScan: ...and the other keeping its own');
/* the collapse it IS supposed to do: one operator listed per RAT */
var same = fmt.collapseScan([
{ mcc: 262, mnc: 1, mnc_digits: 2, status: 'available', rats: [ 'LTE' ] },
{ mcc: 262, mnc: 1, mnc_digits: 2, status: 'current', rats: [ 'NR5G' ] },
]);
eq(same.length, 1, 'collapseScan: one operator listed twice becomes one row');
eq(same[0].status, 'current', 'collapseScan: ...keeping the strongest status');
eq(Object.keys(same[0]._rats).sort(), [ 'LTE', 'NR5G' ],
'collapseScan: ...and the union of its technologies');
/* a scan that reports no width at all still collapses by PLMN, rather than
splitting every entry apart on an undefined */
var nodig = fmt.collapseScan([
{ mcc: 262, mnc: 2, status: 'available', rats: [ 'LTE' ] },
{ mcc: 262, mnc: 2, status: 'available', rats: [ 'GSM' ] },
]);
eq(nodig.length, 1, 'collapseScan: entries without mnc_digits still collapse');
eq(fmt.collapseScan(undefined), [], 'collapseScan: no list is an empty list');
})();
/* --- MBIMEx data subclass / frequency range -------------------------------
*
* Both are BITMASKS, and the data subclass is the authoritative answer to a
* question the rest of the page infers: 5G on an LTE anchor (ENDC) versus
* standalone. Values from libmbim 1.32.0 mbim-enums.h:1867-1872 and :1627-1629.
*/
eq(fmt.fmtDataSubclass(1 << 0), 'ENDC', 'subclass: bit 0 is ENDC — 5G NSA');
eq(fmt.fmtDataSubclass(1 << 1), '5G NR', 'subclass: bit 1 is standalone');
eq(fmt.fmtDataSubclass((1 << 0) | (1 << 3)), 'ENDC + ELTE',
'subclass: it is a mask, so several can be set at once');
/* an unknown bit says so rather than vanishing: a modem setting one is telling
us something this table does not know yet */
eq(fmt.fmtDataSubclass(1 << 20), '0x100000', 'subclass: an unknown bit is reported as itself');
/* ...INCLUDING BESIDE A KNOWN ONE. The first version fell back to hex only when
* nothing was recognised, so 0x21 came back as a bare "ENDC" and the bit this
* table does not know vanished — the one case where silence is worst. */
eq(fmt.fmtDataSubclass(0x21), 'ENDC + 0x20',
'subclass: an unknown bit survives next to a known one');
eq(fmt.fmtDataSubclass(0), null, 'subclass: zero means the modem said nothing');
eq(fmt.fmtDataSubclass(null), null, 'subclass: ...and so does an absent field');
/* SPELLED OUT by default: "FR1" is 3GPP's name and means nothing to a reader
who has not looked it up, and the whole reason to show the row is that the
two ranges behave completely differently. */
eq(fmt.fmtFrequencyRange(1), 'FR1 (sub-6 GHz)', 'range: FR1 is resolved, not left as a code');
eq(fmt.fmtFrequencyRange(2), 'FR2 (mmWave, 24 GHz and above)', 'range: ...and so is FR2');
eq(fmt.fmtFrequencyRange(3), 'FR1 (sub-6 GHz) + FR2 (mmWave, 24 GHz and above)',
'range: aggregation can span both');
/* the bare form stays available for places with no room for the gloss */
eq(fmt.fmtFrequencyRange(1, false), 'FR1', 'range: the short form is still reachable');
eq(fmt.fmtFrequencyRange(1 << 5), '0x20', 'range: an unknown bit is reported as itself');
eq(fmt.fmtFrequencyRange(5), 'FR1 (sub-6 GHz) + 0x4',
'range: ...and survives next to a known one');
eq(fmt.fmtFrequencyRange(0), null, 'range: zero is absent, not "unknown"');
eq(fmt.fmtFrequencyRange(null), null, 'range: and so is null');
/* --- LTE Cell ID -> eNodeB / sector (luci-app-wwand#16) ------------------ */
eq(fmt.fmtEnodeb(26385411), '103068 / 3', 'enb: 0x1929C03 is eNodeB 0x1929C, cell 3');
eq(fmt.fmtEnodeb(29582339), '115556 / 3', 'enb: the Chateau RG650E on 2026-10-29, AT+QENG cell 1C36403');
eq(fmt.fmtEnodeb(255), '0 / 255', 'enb: the cell byte alone');
eq(fmt.fmtEnodeb(0x0FFFFFFF), '1048575 / 255', 'enb: the largest 28-bit ECI');
eq(fmt.fmtEnodeb(0x10000000), null, 'enb: past 28 bits is not an ECI');
eq(fmt.fmtEnodeb(0xFFFFFFFF), null, 'enb: an all-ones unknown gives no row');
eq(fmt.fmtEnodeb(0), '0 / 0', 'enb: zero is a valid ECI; unknown is all-ones, absent is null');
eq(fmt.fmtEnodeb(-1), null, 'enb: a negative number is not an ECI');
eq(fmt.fmtEnodeb(null), null, 'enb: absent stays absent');
eq(fmt.fmtEnodeb('26385411'), null, 'enb: only a number is split');
/* --- brief: short value, explanation on hover (luci-app-wwand#15) --------- */
eq(fmt.brief('no', null), 'no', 'brief: no detail = the bare text, no hover cue');
eq(fmt.brief('no', ''), 'no', 'brief: an empty detail counts as none');
eq(fmt.brief('no', 'the modem offers no UIM Remote'), {
tag: 'span',
attr: {
title: 'the modem offers no UIM Remote',
style: 'cursor:help;text-decoration:underline dotted;text-underline-offset:2px',
},
/* a TEXT child, never markup: the detail may carry daemon text */
children: [ 'no' ],
}, 'brief: a detail becomes a titled span with a text child');
/* --- sccIdle: ONE rule for the CA table and the carrier graph -------------
*
* ddimension/openwrt-repo#2: the EG060K listed PCC + SCC in the table while the
* graph said one carrier. The modem had reported the SCC as not activated; the
* graph was right, the table just did not say so.
*/
(function () {
eq(fmt.sccIdle({ role: 'SCC', state: 1 }), 'inactive', 'sccIdle: a deactivated SCC is marked');
eq(fmt.sccIdle({ role: 'SCC', state: 0 }), 'deconfigured', 'sccIdle: ...and a deconfigured one');
eq(fmt.sccIdle({ role: 'SCC', state: 2 }), null, 'sccIdle: an activated SCC is a carrier');
eq(fmt.sccIdle({ role: 'SCC' }), null, 'sccIdle: no state reported counts as in use');
eq(fmt.sccIdle({ role: 'PCC', state: 1 }), null, 'sccIdle: never the primary');
/* the graph applies the same rule: PCC + idle SCC is one carrier */
var one = fmt.carrierSample({
ca: [ { role: 'PCC', bandwidth_mhz: 20 },
{ role: 'SCC', state: 1, bandwidth_mhz: 20 } ],
});
eq(one.ca, [ 1, null ], 'sccIdle: the graph does not count what the table marks');
/* carrierSample is handed around as a formatter: it must not need `this` */
var sample = fmt.carrierSample;
eq(sample({ ca: [ { role: 'PCC' }, { role: 'SCC', state: 1 } ] }).ca, [ 1, null ],
'sccIdle: a detached carrierSample applies the same rule');
})();
/* --- carrierSample: the aggregation picture is STACKED ---------------------
*
* The second series is the TOTAL, not the 5G count on its own. Drawn
* absolutely, "LTE 2 + 5G 1" put lines at 2 and 1, which reads as the 5G leg
* being the smaller half of a link carrying 2 — rather than the third carrier
* of a link carrying 3.
*/
(function () {
/* EN-DC: two LTE carriers plus one 5G carrier on the same link */
var ca = fmt.carrierSample({
ca: [ { role: 'PCC', bandwidth_mhz: 20 },
{ role: 'SCC', state: 2, bandwidth_mhz: 10 },
{ role: 'PCC', rat: 'nr', bandwidth_mhz: 100 } ],
dsd: { nr: true },
});
eq(ca.ca, [ 2, 3 ], 'carrierSample: LTE 2, total 3 — the upper line is the sum');
eq(ca.bw, [ 30, 130 ], 'carrierSample: ...and the bandwidth stacks the same way');
/* LTE only: ONE line, not two identical ones. A total that merely repeats
the anchor says nothing and doubles the ink. */
var lte = fmt.carrierSample({
ca: [ { role: 'PCC', bandwidth_mhz: 20 } ],
});
eq(lte.ca, [ 1, null ], 'carrierSample: with no 5G leg the total line is absent');
eq(lte.bw, [ 20, null ], 'carrierSample: ...for bandwidth too');
/* A 5G band that is VISIBLE but not serving must not draw a line — the
RG502QEA reports a neighbouring NR band while parked on LTE
(format.js, HW note 2026-09-12). `dsd.nr` is the gate. */
var parked = fmt.carrierSample({
serving: { lte: { bandwidth_mhz: 10 }, nr: { bandwidth_mhz: 100 } },
dsd: { nr: false },
});
eq(parked.ca, [ 1, null ], 'carrierSample: a visible-but-not-serving 5G band draws nothing');
/* ...and when it IS serving, the serving cell supplies the floor */
var serving = fmt.carrierSample({
serving: { lte: { bandwidth_mhz: 10 }, nr: { bandwidth_mhz: 100 } },
dsd: { nr: true },
});
eq(serving.ca, [ 1, 2 ], 'carrierSample: a serving 5G leg lifts the total to 2');
/* a deconfigured SCC is not a carrier (QmiNasScellState 0/1) */
var deconf = fmt.carrierSample({
ca: [ { role: 'PCC' }, { role: 'SCC', state: 0 } ],
});
eq(deconf.ca, [ 1, null ], 'carrierSample: an unactivated SCC is still not counted');
})();
/* --- GNSS: two daemon shapes, one record -----------------------------------
*
* The daemon and this app are pinned separately in the feed, so a box can run
* either pairing. wwand <= 1.6.7_p58 passed ugps' own reply through — strings,
* with the EMPTY string for an absent value — and wwand > p58 reads the port
* itself and answers in numbers. Rendering the second with a reader written
* for the first put "ugps is not answering" and a row of [object Object] on
* the status page of a real router (NR7101, 2026-09-21); this is the seam that
* went unguarded.
*/
/* the OLD shape, as ugps actually answered (GL-X3000, 2026-09-20) */
var g_old = fmt.gnss({
ok: true, port: '/dev/ttyUSB1', receiver: true, receiver_started: true,
reader: true, fix: true, age: 3,
latitude: '52.143559', longitude: '8.964249', elevation: '103.4',
course: '', speed: '0.0', satellites: '08', HDOP: '0.5',
});
eq(g_old.legacy, true, 'gnss: the old shape is recognised as such');
eq(g_old.reading, true, 'gnss: ugps answering IS the reader running');
eq(g_old.valid, true, 'gnss: its boolean fix means there is a solution');
eq(g_old.fix_type, null, 'gnss: ...but it never said 2D or 3D, so neither do we');
eq(g_old.latitude, 52.143559, 'gnss: strings become numbers');
eq(g_old.sats_used, 8, 'gnss: "08" is eight, not a string');
eq(g_old.sats_view, null, 'gnss: ugps had no notion of satellites in VIEW');
eq(g_old.hdop, 0.5, 'gnss: its upper-case HDOP lands in the same field');
eq(g_old.pdop, null, 'gnss: it had no PDOP at all');
eq(g_old.course, null, 'gnss: an EMPTY string is absent — `+""` would be a bearing of 0');
eq(g_old.speed_knots, 0, 'gnss: ...but a real 0.0 is a real zero');
eq(g_old.speed_kmh, 0, 'gnss: knots are converted so the panel need not know which shape it got');
eq(g_old.sats, null, 'gnss: no per-satellite data existed');
/* the NEW shape, verbatim from the NR7101 (2026-09-21) */
var g_new = fmt.gnss({
ok: true, modem: 'wwmodem0', port: '/dev/ttyUSB1',
receiver_started: true, configured: true, reading: true,
valid: true, fix: '3d', quality: 1,
latitude: 52.14355698, longitude: 8.96424958, elevation: 103.3,
speed_kmh: 0, speed_knots: 0, course: null,
satellites_used: 8, satellites_in_view: 13,
satellites: [ { talker: 'GP', signal: '1', prn: 9, elevation: 22, azimuth: 95, snr: 27 },
{ talker: 'GP', signal: '1', prn: 11, elevation: 14, azimuth: 219, snr: 35 },
{ talker: 'GP', signal: '8', prn: 14, elevation: 20, azimuth: 154, snr: null } ],
hdop: 0.5, pdop: 0.8, vdop: 0.6, epoch: 1789991209, age: 1,
lines: 4196, sentences: 4196, unparsed: 1,
});
eq(g_new.legacy, false, 'gnss: the new shape is not the old one');
eq(g_new.reading, true, 'gnss: `reading`, not `reader` — this is what said "ugps is not answering"');
eq(g_new.valid, true, 'gnss: there is a solution');
eq(g_new.fix_type, '3d', 'gnss: ...and it is three-dimensional, which the old shape could not say');
eq(g_new.sats_used, 8, 'gnss: satellites in use');
eq(g_new.sats_view, 13, 'gnss: and in view, which is the number that says the antenna can see');
eq([ g_new.pdop, g_new.vdop ], [ 0.8, 0.6 ], 'gnss: PDOP and VDOP come through');
eq(length_of(g_new.sats), 3, 'gnss: the satellite list is a LIST, not a string');
eq(g_new.counters.unparsed, 1, 'gnss: the counters say how much of the stream was understood');
/* a fix type the receiver never stated is null, not "none" — a GGA-only
stream makes exactly that, and "none" would claim it said so */
/* the source travels: a modem without an NMEA port is read over QMI LOC, and
* the panel says so instead of "no NMEA port" */
var g_loc = fmt.gnss({ port: 'qmi-loc', source: 'qmi_loc', reading: true, configured: true,
loc: { state: 'running', error: null, indications: 3 } });
eq(g_loc.source, 'qmi_loc', 'gnss: the source is carried');
eq(g_loc.loc && g_loc.loc.state, 'running', 'gnss: ...with the LOC session state');
eq(fmt.gnss({ port: '/dev/ttyUSB1', reader: true, receiver: true }).source, 'nmea_port',
'gnss: the old shape only ever had a port');
eq(fmt.gnss({ port: '/dev/x', reading: true, valid: true, fix: null }).fix_type, null,
'gnss: an unstated fix type stays unstated');
eq(fmt.gnss({ port: '/dev/x', reading: true, valid: false, fix: 'none' }).fix_type, null,
'gnss: and "none" is not a TYPE either — `valid` already carries that');
/* nothing to read is an answer with a reason, not an empty panel */
var g_off = fmt.gnss({ ok: true, port: null, configured: false, reading: false,
reason: 'no_gps_port', receiver_started: false });
eq(g_off, null, 'gnss: no port, nothing reading and nobody asked — no panel at all');
/* ...but a modem that WAS asked and has no port is the case most worth saying
* out loud, and suppressing it made the no_gps_port wording unreachable.
* Raised by Codex review, 2026-09-21. */
var g_asked = fmt.gnss({ ok: true, port: null, configured: true, reading: false,
reason: 'no_gps_port', receiver_started: false });
eq(g_asked != null, true, 'gnss: `option gnss` set and no port DOES get a panel');
eq(g_asked.reason, 'no_gps_port', 'gnss: ...whose whole purpose is to say why');
eq(g_asked.port, null, 'gnss: with no port to name');
/* A value the receiver did not report must not become a zero. "12.3 km/h
* (0.0 kn)" is not a gap, it is a wrong reading. */
var g_half = fmt.gnss({ ok: true, port: '/dev/x', reading: true, valid: true,
speed_kmh: 12.3, speed_knots: null,
satellites_in_view: 13, satellites_used: null });
eq(g_half.speed_knots, null, 'gnss: an unreported knots value stays unreported');
eq(g_half.speed_kmh, 12.3, 'gnss: ...while the one that was reported stands');
eq(g_half.sats_used, null, 'gnss: and an unreported in-use count is not zero either');
eq(g_half.sats_view, 13, 'gnss: with the in-view count still there');
var g_idle = fmt.gnss({ ok: true, port: '/dev/ttyUSB1', configured: true,
reading: false, reason: 'reader_not_running',
receiver_started: true });
eq(g_idle.reading, false, 'gnss: a port with no reader still gets a panel');
eq(g_idle.reason, 'reader_not_running', 'gnss: ...and says why');
eq(fmt.gnss(null), null, 'gnss: nothing in, nothing out');
eq(fmt.gnss({ error: 'package_not_installed' }), null, 'gnss: an error is not a panel');
/* the strongest few, for a panel that cannot show thirty rows */
var top = fmt.gnssTopSats(g_new.sats, 2);
eq(top.length, 2, 'gnss: the list is trimmed');
eq(top[0].snr, 35, 'gnss: strongest first');
eq(fmt.gnssTopSats(g_new.sats, 9).length, 3, 'gnss: asking for more than there are is fine');
eq(fmt.gnssTopSats(g_new.sats, 9)[2].snr, null,
'gnss: a satellite in view but unheard sorts last, rather than as a strong one');
eq(fmt.gnssTopSats(null, 3).length, 0, 'gnss: no list, no rows');
/* ONE ROW PER SATELLITE, not per signal. The list carries an entry per band,
* so PRN 18 heard on L1 and on a second band appeared twice — "GP18 40 dB ·
* GP18 39 dB", which reads as a bug rather than as two bands (NR7101,
* 2026-09-21). The best band is what the row is about. */
var two_bands = fmt.gnssTopSats([
{ talker: 'GP', signal: '1', prn: 18, snr: 40 },
{ talker: 'GP', signal: '8', prn: 18, snr: 39 },
{ talker: 'GP', signal: '1', prn: 23, snr: 44 },
{ talker: 'GL', signal: '1', prn: 18, snr: 20 },
], 6);
eq(two_bands.length, 3, 'gnss: one row per satellite, not one per band');
eq(two_bands[0].prn, 23, 'gnss: strongest still first');
eq(two_bands[1].snr, 40, 'gnss: ...and a satellite is shown at its BEST band');
eq(two_bands[2].talker, 'GL',
'gnss: PRN 18 on another constellation is a different satellite and stays');
/* --- PLMN access technologies: ONE vocabulary -------------------------------
*
* The keys are the 3GPP names for the radio access network, and they are what
* the daemon speaks (EF_PLMNwAcT bits; sim_plmn.uc:68). They are not labels.
* The read-only SIM list rendered them by upper-casing the key, so an entry
* read "GSM UTRAN" twelve lines under an editor that labels the very same two
* flags "2G 3G" (ddimension/luci-app-wwand#10, 2026-09-21).
*/
eq(fmt.plmnRatLabels({ gsm: true, utran: true }), [ '2G', '3G' ],
'plmn rats: the words a reader uses, not GSM and UTRAN');
eq(fmt.plmnRatLabels({ eutran: true, ngran: true }), [ '4G', '5G' ],
'plmn rats: ...and not EUTRAN and NGRAN either');
/* generational order, not the order the flags happen to be written in: the
* same entry must read the same way whoever built the object */
eq(fmt.plmnRatLabels({ ngran: true, gsm: true, eutran: true }), [ '2G', '4G', '5G' ],
'plmn rats: oldest first, whatever order the flags came in');
eq(fmt.plmnRatLabels({}), [], 'plmn rats: an entry with no AcT flags carries none');
eq(fmt.plmnRatLabels(null), [], 'plmn rats: and nothing in is nothing out');
/* a flag that is present but false is not set — `e[key]` must be truthy, not
* merely defined, or every entry would claim every technology */
eq(fmt.plmnRatLabels({ gsm: true, utran: false, eutran: false, ngran: false }), [ '2G' ],
'plmn rats: a false flag is not a technology');
/* the table the editor builds its checkboxes from is the same one */
eq(fmt.PLMN_RATS.map(function(r) { return r.key; }),
[ 'gsm', 'utran', 'eutran', 'ngran' ],
'plmn rats: the editor and the list read one table');
eq(fmt.PLMN_RATS.map(function(r) { return r.label; }), [ '2G', '3G', '4G', '5G' ],
'plmn rats: ...with one set of labels');
/* --- the card's ISD-R about itself (daemon status `euicc`) --- */
eq(fmt.euiccText({ sgp32: true, ipae_supported: true, svn: '2.5.0', ipa: 'ipae' }),
'SGP.32 IoT eUICC (on SGP.22 2.5.0)', 'euicc: SGP.32 names the SGP.22 base');
eq(fmt.euiccText({ sgp32: false, svn: '2.2.2' }), 'SGP.22 2.2.2', 'euicc: an SGP.22 card');
eq(fmt.euiccText(null), null, 'euicc: nothing read, nothing said');
eq(fmt.ipaText({ sgp32: true, ipae_supported: true, ipa: 'ipae' }),
'in the card (IPAe) — the card and its eIM manage the profiles; the router has no ES10 access',
'ipa: the card runs itself');
eq(fmt.ipaText({ sgp32: true, ipae_supported: true, ipa: 'ipad' }),
'on the device (IPAd); the card could also run its own', 'ipa: device IPA on a card that has its own');
eq(fmt.ipaText({ sgp32: true, ipae_supported: false, ipa: null }), 'unknown', 'ipa: not determined');
eq(fmt.ipaText({ sgp32: false, svn: '2.5.0' }), null, 'ipa: no row for an SGP.22 card');
eq(fmt.cardHoldText({ card_hold: 61 }),
'modem reset, power cycle and reboot held for 2 min — the card is settling its own profile change',
'card hold: rounded up to whole minutes');
eq(fmt.cardHoldText({ card_hold: null }), null, 'card hold: none, no text');
console.log(`test-format: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env node
/* wwand.modemsid — the interface form's redirect onto the shared wwand_modem
* section. Evaluated the same way as test-format.js: the file is a LuCI module
* body, so it is run as a function with the globals LuCI would have given it.
*
* The rule under test is narrow and has been got wrong three times
* (openwrt/packages#30185, ddimension/luci-app-wwand#7 and #11), always in the
* same place: what remove() is allowed to do to a section it does not own.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let checks = 0, failures = 0;
function eq(got, want, label) {
checks++;
if (got === want || JSON.stringify(got) === JSON.stringify(want))
return;
failures++;
console.log(`FAIL: ${label}\n got: ${JSON.stringify(got)}\n want: ${JSON.stringify(want)}`);
}
/* a uci stand-in: package -> section -> { option: value } */
function fakeUci(state) {
return {
state: state,
get: function (conf, sid, opt) {
var s = (this.state[conf] || {})[sid];
if (s == null) return null;
return (opt == null) ? s : (s[opt] != null ? s[opt] : null);
},
set: function (conf, sid, opt, val) {
this.state[conf] = this.state[conf] || {};
this.state[conf][sid] = this.state[conf][sid] || {};
this.state[conf][sid][opt] = val;
},
unset: function (conf, sid, opt) {
var s = (this.state[conf] || {})[sid];
if (s) delete s[opt];
},
add: function (conf, type, name) {
this.state[conf] = this.state[conf] || {};
this.state[conf][name] = { '.type': type };
},
};
}
const body = fs.readFileSync(
path.join(__dirname, '..', 'htdocs', 'luci-static', 'resources', 'wwand', 'modemsid.js'), 'utf8');
const baseclass = { extend: (o) => o };
function load(uci) {
return new Function('baseclass', 'uci', body)(baseclass, uci);
}
/* a stand-in for a form.Flag bound to a modem option. `disabled` is what marks
it as a checkbox — form.Flag sets enabled/disabled in its constructor. */
function flag(option) {
return { option: option, enabled: '1', disabled: '0' };
}
function value(option) {
return { option: option };
}
/* --- the case that was broken: unchecking a modem-level flag ---------------
*
* form.Flag has default == disabled (form.js:3998-4003), so unchecking always
* reaches parse()'s remove branch (form.js:4109) rather than write(). remove()
* used to touch only the interface section, where the option does not live, so
* the box sprang back and LuCI reported "no changes to apply".
*/
{
const uci = fakeUci({ network: {
wan: { '.type': 'interface', proto: 'wwand', modem: 'modem0' },
modem0: { '.type': 'wwand_modem', gnss: '1' },
} });
const m = load(uci);
const o = m.bindModem(flag('gnss'));
eq(o.cfgvalue('wan'), '1', 'flag: reads through to the modem section');
o.remove('wan');
eq(uci.get('network', 'modem0', 'gnss'), '0',
'flag: unchecking writes the off value where the option lives');
eq(uci.get('network', 'wan', 'gnss'), null,
'flag: ...and leaves no copy on the interface');
}
/* --- ...and the case that must stay broken, deliberately -------------------
*
* Adding a SECOND interface on an existing modem: `option modem` is not set
* when the form renders, so every widget draws from its default and every flag
* looks unchecked. Acting on that cleared the first interface's settings
* (openwrt/packages#30185). The render-time reading is what tells the two
* apart, and the save-time cfgvalue call must not be able to change it.
*/
{
const uci = fakeUci({ network: {
wan2: { '.type': 'interface', proto: 'wwand' },
modem0: { '.type': 'wwand_modem', gnss: '1' },
} });
const m = load(uci);
const o = m.bindModem(flag('gnss'));
o.cfgvalue('wan2'); /* the render: no modem yet */
uci.set('network', 'wan2', 'modem', 'modem0'); /* an earlier option writes it */
o.cfgvalue('wan2'); /* form.js:2148, inside save() */
o.remove('wan2');
eq(uci.get('network', 'modem0', 'gnss'), '1',
'unresolved: a flag that was never shown resolved does not clear the modem');
}
/* --- a flag the form did not change writes nothing ------------------------- */
{
const uci = fakeUci({ network: {
wan: { '.type': 'interface', proto: 'wwand', modem: 'modem0' },
modem0: { '.type': 'wwand_modem' },
} });
const m = load(uci);
const o = m.bindModem(flag('gnss'));
o.cfgvalue('wan');
o.remove('wan');
eq(uci.get('network', 'modem0', 'gnss'), null,
'untouched: an option that was never set is not written as 0');
}
/* --- and a TEXT field keeps the blanket refusal ----------------------------
*
* A Value has a blank state and "the user cleared it" cannot be told from "the
* form had nothing to show", so nothing about it changes here.
*/
{
const uci = fakeUci({ network: {
wan: { '.type': 'interface', proto: 'wwand', modem: 'modem0' },
modem0: { '.type': 'wwand_modem', path: 'platform/soc/usb1' },
} });
const m = load(uci);
const o = m.bindModem(value('path'));
o.cfgvalue('wan');
o.remove('wan');
eq(uci.get('network', 'modem0', 'path'), 'platform/soc/usb1',
'value: a cleared text field still never deletes on the modem section');
}
/* --- write() still redirects, and still migrates a legacy inline copy ------ */
{
const uci = fakeUci({ network: {
wan: { '.type': 'interface', proto: 'wwand', modem: 'modem0', gnss: '1' },
modem0: { '.type': 'wwand_modem' },
} });
const m = load(uci);
const o = m.bindModem(flag('gnss'));
o.write('wan', '1');
eq(uci.get('network', 'modem0', 'gnss'), '1', 'write: lands on the modem section');
eq(uci.get('network', 'wan', 'gnss'), null, 'write: ...and clears the legacy inline copy');
}
/* --- `device` is the daemon's L3 handle and is never migrated or cleared --- */
{
const uci = fakeUci({ network: {
wan: { '.type': 'interface', proto: 'wwand', modem: 'modem0', device: 'wwan0_1' },
modem0: { '.type': 'wwand_modem' },
} });
const m = load(uci);
const o = m.bindModem(value('device'));
o.write('wan', 'wwan0_1');
eq(uci.get('network', 'wan', 'device'), 'wwan0_1',
'device: the interface copy survives a write');
o.remove('wan');
eq(uci.get('network', 'wan', 'device'), 'wwan0_1',
'device: ...and a remove');
}
console.log(`test-modemsid: ${checks} checks, ${failures} failures`);
process.exit(failures ? 1 : 0);
+1 -1
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI Status Pages LUCI_TITLE:=LuCI Status Pages
LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket LUCI_DEPENDS:=+luci-base +luci-app-oui +luci-mod-dashboard +libiwinfo +rpcd-mod-iwinfo +rpcd-mod-rrdns +ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-socket
PKG_RELEASE:=37 PKG_RELEASE:=38
PKG_BUILD_DEPENDS:=iwinfo PKG_BUILD_DEPENDS:=iwinfo
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0
@@ -77,17 +77,50 @@ function parseNetdev(raw) {
return stats; return stats;
} }
// The backend resolves the tracked device (Settings choice or default-route
// device). Without one, fall back to the busiest non-loopback interface
// rather than guessing names.
function getBestWAN(stats, preferred) { function getBestWAN(stats, preferred) {
for (const i of preferred) if (stats[i]) return i; for (const i of preferred) if (stats[i]) return i;
const dynamic = Object.keys(stats).find(i => let best = '', bestBytes = -1;
/^(wwan|usb|ppp|lte|qmi|modem)/.test(i) && i.includes('_')); for (const k of Object.keys(stats)) {
if (dynamic) return dynamic; if (k === 'lo') continue;
const bytes = (parseInt(stats[k].rx) || 0) + (parseInt(stats[k].tx) || 0);
if (bytes > bestBytes) { best = k; bestBytes = bytes; }
}
return best;
}
for (const i of ['pppoe-wan','lte0','usb0','wan','eth1','tun0','wg0']) // Totals for the downloaded/uploaded boxes: this month from vnStat when the
if (stats[i]) return i; // vnStat backend is selected, otherwise the since-boot kernel counters.
function getTotals(data, curr) {
const v = data.vnstat_total;
if (v && typeof v === 'object')
return { rx: parseInt(v.rx) || 0, tx: parseInt(v.tx) || 0 };
return curr;
}
return Object.keys(stats).find(k => k !== 'lo') || ''; const STATS_URL = L.url('admin/tools/get_netdev_stats');
// Normalise one backend response into the shape render()/updateContainer() use.
function toData(r, ip) {
return {
stats: (r && r.stats) || {},
ip: ip,
status: (r && r.status) || 'Disconnected',
uptime: (r && r.uptime) || 0,
cpu_pct: (r && r.cpu_pct) || 0,
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
mem_pct: (r && r.mem_pct) || 0,
mem_used: (r && r.mem_used) || 0,
mem_total: (r && r.mem_total) || 0,
disk_pct: (r && r.disk_pct) || 0,
disk_used: (r && r.disk_used) || 0,
disk_total: (r && r.disk_total) || 0,
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : [],
vnstat_total: (r && r.vnstat_total) || null
};
} }
function formatRate(bps) { function formatRate(bps) {
@@ -381,8 +414,9 @@ function updateContainer(container, data, dt) {
const rxRate = formatRate(rxSpeed * 8); const rxRate = formatRate(rxSpeed * 8);
const txRate = formatRate(txSpeed * 8); const txRate = formatRate(txSpeed * 8);
const totalRx = formatBytes(curr.rx).split(' '); const sums = getTotals(data, curr);
const totalTx = formatBytes(curr.tx).split(' '); const totalRx = formatBytes(sums.rx).split(' ');
const totalTx = formatBytes(sums.tx).split(' ');
const peakRxFmt = formatRate(peakRx * 8); const peakRxFmt = formatRate(peakRx * 8);
const peakTxFmt = formatRate(peakTx * 8); const peakTxFmt = formatRate(peakTx * 8);
@@ -457,18 +491,34 @@ function updateContainer(container, data, dt) {
return true; return true;
} }
// ─── Cached public IP (refreshed every 60 s, not on every 2 s poll) ────────── // ─── Cached public IP (refreshed every 5 min, not on every 2 s poll) ─────────
let _cachedIp = 'N/A'; // The public IP (as seen through any proxy such as Passwall) is shown once
// known; the tracked device's local address is only a fallback until then,
// so the 2 s fast polls must never overwrite it.
let _publicIp = null;
let _localIp = 'N/A';
let _lastIpFetch = 0; let _lastIpFetch = 0;
const IP_REFRESH_MS = 300000; const IP_REFRESH_MS = 300000;
const IP_RETRY_MS = 30000;
function shownIp() {
return _publicIp || _localIp;
}
function noteLocalIp(r) {
if (r && r.ip && r.ip !== 'N/A') _localIp = r.ip;
}
function maybeRefreshIp() { function maybeRefreshIp() {
const now = Date.now(); const now = Date.now();
if (now - _lastIpFetch < IP_REFRESH_MS) return; if (now - _lastIpFetch < (_publicIp ? IP_REFRESH_MS : IP_RETRY_MS)) return;
_lastIpFetch = now; _lastIpFetch = now;
// Full fetch (no ?fast=1) to get public IP; update cache on success // Full fetch (no ?fast=1) to get public IP; update cache on success
fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats', 8000) fetchWithTimeout(STATS_URL, 8000)
.then(r => { if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; }) .then(r => {
if (r && r.ip_source === 'public' && r.ip && r.ip !== 'N/A')
_publicIp = r.ip;
})
.catch(() => {}); .catch(() => {});
} }
@@ -478,29 +528,13 @@ return baseclass.extend({
load() { load() {
// Use ?fast=1 to skip slow public-IP APIs — response is near-instant // Use ?fast=1 to skip slow public-IP APIs — response is near-instant
return fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats?fast=1', 5000) return fetchWithTimeout(STATS_URL + '?fast=1', 5000)
.then(r => { .then(r => {
// Seed the cached IP from the local WAN address on first load // Show the local WAN address until the public IP arrives
if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; noteLocalIp(r);
return { return toData(r, shownIp());
stats: (r && r.stats) || {},
ip: _cachedIp,
status: (r && r.status) || 'Disconnected',
uptime: (r && r.uptime) || 0,
cpu_pct: (r && r.cpu_pct) || 0,
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
mem_pct: (r && r.mem_pct) || 0,
mem_used: (r && r.mem_used) || 0,
mem_total: (r && r.mem_total) || 0,
disk_pct: (r && r.disk_pct) || 0,
disk_used: (r && r.disk_used) || 0,
disk_total: (r && r.disk_total) || 0,
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : []
};
}) })
.catch(() => ({ stats: {}, ip: 'N/A', status: 'Disconnected', .catch(() => toData(null, 'N/A'));
uptime: 0, cpu_pct: 0, cpu_temp: null, mem_pct: 0, mem_used: 0, mem_total: 0,
disk_pct: 0, disk_used: 0, disk_total: 0, preferred: [] }));
}, },
render(data) { render(data) {
@@ -538,8 +572,9 @@ return baseclass.extend({
const rxRate = formatRate(rxSpeed * 8); const rxRate = formatRate(rxSpeed * 8);
const txRate = formatRate(txSpeed * 8); const txRate = formatRate(txSpeed * 8);
const totalRx = formatBytes(curr.rx).split(' '); const sums = getTotals(data, curr);
const totalTx = formatBytes(curr.tx).split(' '); const totalRx = formatBytes(sums.rx).split(' ');
const totalTx = formatBytes(sums.tx).split(' ');
// Build download box manually to inject trend badge // Build download box manually to inject trend badge
const dlBox = createRateBox(_('download'), rxRate.number, rxRate.unit, const dlBox = createRateBox(_('download'), rxRate.number, rxRate.unit,
@@ -573,31 +608,17 @@ return baseclass.extend({
L.Poll.add(() => { L.Poll.add(() => {
// Periodically refresh the public IP in the background (non-blocking) // Periodically refresh the public IP in the background (non-blocking)
maybeRefreshIp(); maybeRefreshIp();
return fetchWithTimeout('/cgi-bin/luci/admin/tools/get_netdev_stats?fast=1', 5000) return fetchWithTimeout(STATS_URL + '?fast=1', 5000)
.then(r => { .then(r => {
const now2 = Date.now(); const now2 = Date.now();
const dt2 = Math.max(0.1, (now2 - last_time) / 1000); const dt2 = Math.max(0.1, (now2 - last_time) / 1000);
last_time = now2; last_time = now2;
// Update cached IP from local WAN if we got a valid one // Keep the local address current as a fallback only
if (r && r.ip && r.ip !== 'N/A') _cachedIp = r.ip; noteLocalIp(r);
if (_container && _container.isConnected) { if (_container && _container.isConnected) {
updateContainer(_container, { updateContainer(_container, toData(r, shownIp()), dt2);
stats: (r && r.stats) || {},
ip: _cachedIp,
status: (r && r.status) || 'Disconnected',
uptime: (r && r.uptime) || 0,
cpu_pct: (r && r.cpu_pct) || 0,
cpu_temp: (r && r.cpu_temp) != null ? r.cpu_temp : null,
mem_pct: (r && r.mem_pct) || 0,
mem_used: (r && r.mem_used) || 0,
mem_total: (r && r.mem_total) || 0,
disk_pct: (r && r.disk_pct) || 0,
disk_used: (r && r.disk_used) || 0,
disk_total: (r && r.disk_total) || 0,
preferred: (r && r.prefer_iface) ? [r.prefer_iface] : []
}, dt2);
} }
}) })
.catch(() => {}); .catch(() => {});
@@ -1,6 +1,8 @@
module("luci.controller.netstat", package.seeall) module("luci.controller.netstat", package.seeall)
local nixio = require("nixio") local nixio = require("nixio")
local nfs = require("nixio.fs")
local uci = require("luci.model.uci").cursor() local uci = require("luci.model.uci").cursor()
local nsu = require("luci.netstat")
function index() function index()
entry({"admin", "tools", "get_netdev_stats"}, call("getNetdevStats"), nil).sysauth = false entry({"admin", "tools", "get_netdev_stats"}, call("getNetdevStats"), nil).sysauth = false
@@ -31,19 +33,7 @@ function getNetdevStats()
end end
end end
-- Quick connectivity check local status = #nsu.default_route_devices() > 0 and "Connected" or "Disconnected"
local status = "Disconnected"
for iface, data in pairs(stats) do
if iface ~= "lo" and (data.rx > 0 or data.tx > 0) then
status = "Connected"
break
end
end
-- ── IP detection ─────────────────────────────────────────────────────────
-- Strategy: try a single fast HTTP call first (2 s timeout), then fall
-- back to local ubus/ifstatus which is instant. The old code tried 11
-- commands with 4 s timeouts each – worst-case 44 s of blocking.
local function read_cmd(cmd) local function read_cmd(cmd)
local p = io.popen(cmd) local p = io.popen(cmd)
@@ -55,38 +45,11 @@ function getNetdevStats()
return line ~= "" and line or nil return line ~= "" and line or nil
end end
-- ── Preferred interface resolution ────────────────────────────────────── -- ── Tracked device ──────────────────────────────────────────────────────
-- The "Preferred Interface" setting may hold either a real netdev name -- The "Preferred Interface" setting (netdev or logical name such as
-- (e.g. "eth2", "wwan0_1") or a logical/config interface name (e.g. -- "wan"), otherwise the default-route device. Both are resolved
-- "modem", "wan") that has no matching key in /proc/net/dev. In the -- generically; no interface names are assumed.
-- latter case resolve it to its actual l3_device via ubus so traffic local prefer_iface = nsu.wan_device(stats) or ""
-- stats can be found.
local prefer_iface = ""
do
local prefer = uci:get_first("netstats", "config", "prefer") or ""
-- Only allow safe characters before using it in a shell command.
if prefer ~= "" and prefer:match("^[%w%-%._]+$") then
if stats[prefer] then
prefer_iface = prefer
else
local l3 = read_cmd(
"ubus call network.interface." .. prefer ..
" status 2>/dev/null | jsonfilter -e '@.l3_device'"
)
if l3 and stats[l3] then
prefer_iface = l3
end
end
end
-- Nothing configured (or unresolvable): use the default-route device
-- so idle interfaces named "wan"/"eth1" don't win over the real uplink.
if prefer_iface == "" then
local dev = read_cmd("ip route show default 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i==\"dev\"){print $(i+1); exit}}'")
if dev and dev:match("^[%w%-%._]+$") and stats[dev] then
prefer_iface = dev
end
end
end
local function is_valid_ip(v) local function is_valid_ip(v)
if not v then return false end if not v then return false end
@@ -100,6 +63,7 @@ function getNetdevStats()
end end
local ip = "N/A" local ip = "N/A"
local ip_source = "none"
-- Determine whether the caller wants a public IP (slow, requires network) -- Determine whether the caller wants a public IP (slow, requires network)
-- or just the fast local WAN address. The JS frontend sends ?fast=1 for -- or just the fast local WAN address. The JS frontend sends ?fast=1 for
@@ -109,30 +73,29 @@ function getNetdevStats()
-- 1. Try public-IP APIs (only when not in fast mode) -- 1. Try public-IP APIs (only when not in fast mode)
if want_public then if want_public then
local public_cmds = { local public_cmds = {
"curl -fsS --max-time 1 'https://api.ipify.org' 2>/dev/null", "curl -fsS --max-time 2 'https://api.ipify.org' 2>/dev/null",
"curl -fsS --max-time 1 'http://api.ipify.org' 2>/dev/null", "curl -fsS --max-time 2 'http://api.ipify.org' 2>/dev/null",
"uclient-fetch -qO- --timeout=1 'https://api.ipify.org' 2>/dev/null", "uclient-fetch -qO- --timeout=2 'https://api.ipify.org' 2>/dev/null",
} }
for _, cmd in ipairs(public_cmds) do for _, cmd in ipairs(public_cmds) do
local v = read_cmd(cmd) local v = read_cmd(cmd)
if is_valid_ip(v) then ip = v; break end if is_valid_ip(v) then ip = v; ip_source = "public"; break end
end end
end end
-- 2. Fall back to local WAN address (instant, no network needed) -- 2. Fall back to the local address of the tracked device
if ip == "N/A" then if ip == "N/A" and prefer_iface ~= "" then
local local_cmds = { local v = nsu.device_ipaddr(prefer_iface)
-- wan IPv4 if is_valid_ip(v) then ip = v; ip_source = "local" end
"ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@[\"ipv4-address\"][0].address'", end
"ifstatus wan 2>/dev/null | jsonfilter -e '@[\"ipv4-address\"][0].address'",
-- wan IPv6 -- ── vnStat totals (only with the vnStat backend) ────────────────────────
"ubus call network.interface.wan status 2>/dev/null | jsonfilter -e '@[\"ipv6-address\"][0].address'", -- Live rates always come from /proc/net/dev; the vnStat backend swaps the
"ubus call network.interface.wan6 status 2>/dev/null | jsonfilter -e '@[\"ipv6-address\"][0].address'", -- "downloaded/uploaded" boxes to this month's totals from its database.
} local vnstat_total = nil
for _, cmd in ipairs(local_cmds) do if prefer_iface ~= "" and
local v = read_cmd(cmd) (uci:get_first("netstats", "config", "backend") or "normal") == "vnstat" then
if is_valid_ip(v) then ip = v; break end vnstat_total = nsu.vnstat_month_total(prefer_iface)
end
end end
-- ── System uptime ───────────────────────────────────────────────────────── -- ── System uptime ─────────────────────────────────────────────────────────
@@ -196,9 +159,7 @@ function getNetdevStats()
-- ── Disk space (root filesystem) ────────────────────────────────────────── -- ── Disk space (root filesystem) ──────────────────────────────────────────
local disk_pct, disk_used_mb, disk_total_mb = 0, 0, 0 local disk_pct, disk_used_mb, disk_total_mb = 0, 0, 0
-- Use POSIX output (-P): busybox df wraps long device names onto a second local df = read_cmd("df -k / 2>/dev/null | awk 'NR==2{print $2,$3}'")
-- line without it, which breaks the NR==2 parse (e.g. LXC root device).
local df = read_cmd("df -kP / 2>/dev/null | awk 'NR==2{print $2,$3}'")
if df then if df then
local dtotal, dused = df:match("^(%d+)%s+(%d+)$") local dtotal, dused = df:match("^(%d+)%s+(%d+)$")
dtotal = tonumber(dtotal) or 0 dtotal = tonumber(dtotal) or 0
@@ -212,43 +173,9 @@ function getNetdevStats()
-- ── CPU temperature ─────────────────────────────────────────────────────── -- ── CPU temperature ───────────────────────────────────────────────────────
local cpu_temp = nil local cpu_temp = nil
-- Scan all available thermal zones and pick the highest valid reading
-- Preferred source: CPU-specific hwmon sensors (k10temp/coretemp/zenpower/ -- (avoids hardcoded zone numbers which vary per device)
-- ...). ACPI thermal zones can be absent or bogus (e.g. inside LXC for path in (nfs.glob("/sys/class/thermal/thermal_zone*/temp") or function() end) do
-- containers the host ACPI zone is not the CPU sensor), while hwmon
-- reports the real package temperature on x86.
local cpu_hwmon = { k10temp = true, coretemp = true, zenpower = true, cpu_thermal = true, soc_thermal = true }
if nixio.fs.access("/sys/class/hwmon") then
for h in nixio.fs.dir("/sys/class/hwmon") do
local base = "/sys/class/hwmon/" .. h
local nf = io.open(base .. "/name", "r")
local name = nf and nf:read("*l") or nil
if nf then nf:close() end
if name and cpu_hwmon[name] then
for entry in nixio.fs.dir(base) do
if entry:match("^temp%d+_input$") then
local sf = io.open(base .. "/" .. entry, "r")
local val = sf and sf:read("*l") or nil
if sf then sf:close() end
local t = tonumber(val)
if t and t > 0 then
if t > 1000 then t = t / 1000 end
t = math.floor(t + 0.5)
if cpu_temp == nil or t > cpu_temp then
cpu_temp = t
end
end
end
end
end
end
end
-- Fallback: scan all available thermal zones and pick the highest valid
-- reading (avoids hardcoded zone numbers which vary per device)
for zone = 0, 9 do
if cpu_temp ~= nil then break end
local path = "/sys/class/thermal/thermal_zone" .. zone .. "/temp"
local tf = io.open(path, "r") local tf = io.open(path, "r")
if tf then if tf then
local val = tf:read("*l") local val = tf:read("*l")
@@ -267,8 +194,10 @@ function getNetdevStats()
end end
luci.http.prepare_content("application/json") luci.http.prepare_content("application/json")
luci.http.write_json({ stats = stats, ip = ip, status = status, luci.http.write_json({ stats = stats, ip = ip, ip_source = ip_source,
status = status,
prefer_iface = prefer_iface, prefer_iface = prefer_iface,
vnstat_total = vnstat_total,
uptime = uptime_sec, uptime = uptime_sec,
cpu_pct = cpu_pct, cpu_pct = cpu_pct,
cpu_temp = cpu_temp, cpu_temp = cpu_temp,
+1 -1
View File
@@ -10,7 +10,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=mptcp PKG_NAME:=mptcp
PKG_VERSION:=6.1 PKG_VERSION:=6.1
PKG_RELEASE:=29 PKG_RELEASE:=30
PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org> PKG_MAINTAINER:=Ycarus (Yannick Chabanois) <ycarus@zugaina.org>
PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME) PKG_BUILD_DIR := $(BUILD_DIR)/$(PKG_NAME)
+11 -2
View File
@@ -51,10 +51,19 @@ esac
# Legacy v0.9x scheduler names carried over to a 6.x kernel: only the names # Legacy v0.9x scheduler names carried over to a 6.x kernel: only the names
# that never exist upstream are migrated (BPF schedulers are not listed in # that never exist upstream are migrated (BPF schedulers are not listed in
# available_schedulers until their object is registered, so they must not be # available_schedulers until their object is registered, so they must not be
# touched here). # touched here). BLEST and ECF have a BPF port: when its object is
# installed, the choice is kept on it instead of going back to 'default'.
if [ -f /proc/sys/net/mptcp/available_schedulers ]; then if [ -f /proc/sys/net/mptcp/available_schedulers ]; then
case "$(uci -q get network.globals.mptcp_scheduler)" in case "$(uci -q get network.globals.mptcp_scheduler)" in
blest|roundrobin|redundant|ecf) blest|ecf)
_sched="bpf_$(uci -q get network.globals.mptcp_scheduler)"
[ -f "/usr/share/bpf/scheduler/mptcp_${_sched}.o" ] || _sched='default'
uci -q batch <<-EOF >/dev/null
set network.globals.mptcp_scheduler='${_sched}'
commit network
EOF
;;
roundrobin|redundant)
uci -q batch <<-EOF >/dev/null uci -q batch <<-EOF >/dev/null
set network.globals.mptcp_scheduler='default' set network.globals.mptcp_scheduler='default'
commit network commit network
+1 -1
View File
@@ -9,7 +9,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=sqm-autorate PKG_NAME:=sqm-autorate
PKG_VERSION:=0.1 PKG_VERSION:=0.1
PKG_RELEASE:=8 PKG_RELEASE:=9
include $(INCLUDE_DIR)/package.mk include $(INCLUDE_DIR)/package.mk
+21 -3
View File
@@ -13,6 +13,8 @@
. /lib/functions/network.sh . /lib/functions/network.sh
AUTORATE_DIR="${SQM_AUTORATE_DIR:-/usr/share/sqm-autorate}"
_config_autorate() { _config_autorate() {
config_get enabled "$1" enabled config_get enabled "$1" enabled
[ "${enabled}" != "1" ] && return [ "${enabled}" != "1" ] && return
@@ -30,7 +32,12 @@ _config_autorate() {
[ "${max_upload}" == "0" ] || [ "${upload}" == "0" ] && return [ "${max_upload}" == "0" ] || [ "${upload}" == "0" ] && return
# config_get interface "$1" interface # config_get interface "$1" interface
# cp /usr/share/sqm-autorate/cake-autorate_template.sh /usr/share/sqm-autorate/cake-autorate_config.${interface}.sh # cp /usr/share/sqm-autorate/cake-autorate_template.sh /usr/share/sqm-autorate/cake-autorate_config.${interface}.sh
cp /usr/share/sqm-autorate/config_template.sh /usr/share/sqm-autorate/config.$1.sh # The instance config is a copy of the template (it reads uci at run
# time) on the overlay: write it only when it differs, not on every
# boot and sqm commit.
cmp -s "${AUTORATE_DIR}/config_template.sh" "${AUTORATE_DIR}/config.$1.sh" ||
cp "${AUTORATE_DIR}/config_template.sh" "${AUTORATE_DIR}/config.$1.sh"
AUTORATE_SECTIONS="${AUTORATE_SECTIONS} $1"
} }
_launch_autorate() { _launch_autorate() {
@@ -54,10 +61,21 @@ _launch_autorate() {
} }
start_service() { start_service() {
rm -f /usr/share/sqm-autorate/config.*.sh local f sec
AUTORATE_SECTIONS=""
config_load sqm config_load sqm
config_foreach _config_autorate queue config_foreach _config_autorate queue
[ -n "$(ls /usr/share/sqm-autorate/config.*.sh 2>/dev/null)" ] && _launch_autorate # drop the configs of queues no longer autorated
for f in "${AUTORATE_DIR}"/config.*.sh; do
[ -e "$f" ] || continue
sec="${f#"${AUTORATE_DIR}"/config.}"
sec="${sec%.sh}"
case " ${AUTORATE_SECTIONS} " in
*" ${sec} "*) ;;
*) rm -f "$f" ;;
esac
done
[ -n "$(ls "${AUTORATE_DIR}"/config.*.sh 2>/dev/null)" ] && _launch_autorate
} }
reload_service() { reload_service() {
@@ -210,7 +210,7 @@ reflector_owd_delta_ewma_delta_thr_ms=10.0 # mac increase from min delta ewma
stall_detection_thr=5 stall_detection_thr=5
connection_stall_thr_kbps=10 connection_stall_thr_kbps=10
global_ping_response_timeout_s=${dl_owd_delta_thr_ms} # timeout to set shaper rates to min on no ping response whatsoever (seconds) global_ping_response_timeout_s=10.0 # timeout to set shaper rates to min on no ping response whatsoever (seconds)
if_up_check_interval_s=10.0 # time to wait before re-checking if rx/tx bytes files exist (e.g. from boot state or sleep recovery) if_up_check_interval_s=10.0 # time to wait before re-checking if rx/tx bytes files exist (e.g. from boot state or sleep recovery)
+3 -3
View File
@@ -50,12 +50,12 @@
include $(TOPDIR)/rules.mk include $(TOPDIR)/rules.mk
PKG_NAME:=wwand PKG_NAME:=wwand
PKG_VERSION:=1.8.0_pre31 PKG_VERSION:=1.8.0_pre32
PKG_RELEASE:=84 PKG_RELEASE:=85
PKG_SOURCE_PROTO:=git PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
PKG_SOURCE_VERSION:=ae002a7e9a7a9d45b267b961e45ef1ec88fddebe PKG_SOURCE_VERSION:=4ea1d116311a0205396f3c40dffe9f012c200233
PKG_MIRROR_HASH:=skip PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only PKG_LICENSE:=GPL-2.0-only