diff --git a/gowebdav/Makefile b/gowebdav/Makefile
index 4f716faac..784d4c5fc 100644
--- a/gowebdav/Makefile
+++ b/gowebdav/Makefile
@@ -6,8 +6,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gowebdav
-PKG_VERSION:=5.16.0
-PKG_RELEASE:=22
+PKG_VERSION:=5.16.1
+PKG_RELEASE:=23
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/hacdias/webdav/tar.gz/v$(PKG_VERSION)?
diff --git a/luci-app-homeproxy/Makefile b/luci-app-homeproxy/Makefile
index 6b6daa1bb..cc476d80d 100644
--- a/luci-app-homeproxy/Makefile
+++ b/luci-app-homeproxy/Makefile
@@ -23,7 +23,7 @@ LUCI_DEPENDS:= \
PKG_NAME:=luci-app-homeproxy
PKG_VERSION:=27.919.1.14
-PKG_RELEASE:=17
+PKG_RELEASE:=18
define Package/luci-app-homeproxy/conffiles
/etc/config/homeproxy
diff --git a/luci-app-homeproxy/README.md b/luci-app-homeproxy/README.md
index 369228a13..da2c20ef6 100644
--- a/luci-app-homeproxy/README.md
+++ b/luci-app-homeproxy/README.md
@@ -1,5 +1,8 @@
+## 新增本仓库“架构重构”试验性产品luci-app-homeproxy-pro
+(https://github.com/szwjp/luci-app-homeproxy-pro)
+
# luci-app-homeproxy
**The modern ImmortalWrt proxy platform for ARM64 / AMD64**
diff --git a/luci-app-passwall/Makefile b/luci-app-passwall/Makefile
index 5a7fbd73f..083ec34b4 100644
--- a/luci-app-passwall/Makefile
+++ b/luci-app-passwall/Makefile
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.9.26
-PKG_RELEASE:=293
+PKG_RELEASE:=294
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
diff --git a/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm b/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm
index d79c18756..afb1218fb 100644
--- a/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm
+++ b/luci-app-passwall/luasrc/view/passwall/node_config/link_share_man.htm
@@ -649,6 +649,7 @@ local current_node = map:get(section)
params += opt.query("congestion_control", dom_prefix + "tuic_congestion_control");
params += opt.query("udp_relay_mode", dom_prefix + "tuic_udp_relay_mode");
params += opt.query("allowinsecure", dom_prefix + "tls_allowInsecure");
+ params += opt.query("pcs", dom_prefix + "tls_pinSHA256");
params += "#" + encodeURI(v_alias.value);
if (params[0] == "&") {
@@ -679,6 +680,7 @@ local current_node = map:get(section)
params += opt.query("alpn", dom_prefix + "alpn");
params += opt.query("sni", dom_prefix + "tls_serverName");
params += opt.query("insecure", dom_prefix + "tls_allowInsecure");
+ params += opt.query("pcs", dom_prefix + "tls_pinSHA256");
}
params += "#" + encodeURI(v_alias.value);
@@ -1705,6 +1707,7 @@ local current_node = map:get(section)
dom_prefix + 'tls_allowInsecure',
!((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0' && (queryParam.allow_insecure ?? '0') === '0')
);
+ opt.set(dom_prefix + 'tls_pinSHA256', queryParam.pcs || queryParam.pinSHA256 || '');
if (hash) {
opt.set('remarks', decodeURIComponent(hash.substr(1)));
}
@@ -1737,29 +1740,23 @@ local current_node = map:get(section)
}
queryParam.security = queryParam.security || "tls";
if (queryParam.security) {
+ opt.set(dom_prefix + 'tls', true);
if (queryParam.security == "tls") {
- opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', false);
- opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
- opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
- opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
- if (queryParam.fp && queryParam.fp.trim() != "") {
- opt.set(dom_prefix + 'utls', true);
- opt.set(dom_prefix + 'fingerprint', queryParam.fp);
- }
}
if (queryParam.security == "reality") {
- opt.set(dom_prefix + 'tls', true);
opt.set(dom_prefix + 'reality', true);
- opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
- opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
- if (queryParam.fp && queryParam.fp.trim() != "") {
- opt.set(dom_prefix + 'utls', true);
- opt.set(dom_prefix + 'fingerprint', queryParam.fp);
- }
opt.set(dom_prefix + 'reality_publicKey', queryParam.pbk || '');
opt.set(dom_prefix + 'reality_shortId', queryParam.sid || '');
}
+ opt.set(dom_prefix + 'alpn', queryParam.alpn || 'default');
+ opt.set(dom_prefix + 'tls_serverName', queryParam.sni || queryParam.peer || '');
+ if (queryParam.fp && queryParam.fp.trim() != "") {
+ opt.set(dom_prefix + 'utls', true);
+ opt.set(dom_prefix + 'fingerprint', queryParam.fp);
+ }
+ opt.set(dom_prefix + 'tls_allowInsecure', !((queryParam.allowinsecure ?? '0') === '0' && (queryParam.insecure ?? '0') === '0'));
+ opt.set(dom_prefix + 'tls_pinSHA256', queryParam.pcs || queryParam.pinSHA256 || '');
}
if (m.hash) {
opt.set('remarks', decodeURIComponent(m.hash.substr(1)));
diff --git a/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua b/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua
index 4dcbe398a..5ccf89996 100644
--- a/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua
+++ b/luci-app-passwall/root/usr/share/passwall/clash_subconverter.lua
@@ -448,6 +448,7 @@ local function encode_tuic(node)
if node["disable-sni"] then table.insert(p, "disable_sni=1") end
if node["skip-cert-verify"] then table.insert(p, "allowInsecure=1") end
if node["udp-relay-mode"] then table.insert(p, "udp_relay_mode=" .. node["udp-relay-mode"]) end
+ if node["fingerprint"] then table.insert(p, "pcs=" .. urlencode(node["fingerprint"])) end
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
@@ -483,11 +484,8 @@ local function encode_anytls(node)
if o.tls.alpn then table.insert(p, "alpn=" .. urlencode(o.tls.alpn)) end
if o.tls.fp then table.insert(p, "fp=" .. urlencode(o.tls.fp)) end
if o.tls.ech then table.insert(p, "ech=" .. urlencode(o.tls.ech)) end
- if o.tls.pcs then
- table.insert(p, "insecure=1")
- else
- table.insert(p, "insecure=" .. (o.tls.insecure and "1" or "0"))
- end
+ if o.tls.pcs then table.insert(p, "pcs=" .. urlencode(o.tls.pcs)) end
+ table.insert(p, "insecure=" .. (o.tls.insecure and "1" or "0"))
if #p > 0 then
link = link .. "?" .. table.concat(p, "&")
diff --git a/luci-app-passwall/root/usr/share/passwall/subscribe.lua b/luci-app-passwall/root/usr/share/passwall/subscribe.lua
index 204e26bf4..0c8b7fea4 100755
--- a/luci-app-passwall/root/usr/share/passwall/subscribe.lua
+++ b/luci-app-passwall/root/usr/share/passwall/subscribe.lua
@@ -1560,6 +1560,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
end
result.tls_serverName = params.sni
result.tls_disable_sni = params.disable_sni
+ result.tls_pinSHA256 = params.pcs or params.pinsha256
result.tuic_alpn = params.alpn or "h3"
result.tuic_congestion_control = params.congestion_control or "cubic"
result.tuic_udp_relay_mode = params.udp_relay_mode or "native"
@@ -1615,6 +1616,7 @@ local function processData(szType, content, add_mode, group, sub_cfg)
if params.security == "tls" or params.security == "reality" then
result.tls = "1"
result.tls_serverName = params.sni or params.peer
+ result.tls_pinSHA256 = params.pcs or params.pinsha256
result.alpn = params.alpn
if params.fp and params.fp ~= "" then
result.utls = "1"
diff --git a/luci-app-qosmate/Makefile b/luci-app-qosmate/Makefile
index 5cb5d0eb3..d423be34a 100644
--- a/luci-app-qosmate/Makefile
+++ b/luci-app-qosmate/Makefile
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-qosmate
PKG_VERSION:=1.0.14
-PKG_RELEASE:=4
+PKG_RELEASE:=5
PKG_MAINTAINER:=Markus Hütter
PKG_LICENSE:=GPL-3.0-or-later
diff --git a/luci-app-qosmate/htdocs/luci-static/resources/view/rules.js b/luci-app-qosmate/htdocs/luci-static/resources/view/rules.js
index a08c4d7a8..20965c002 100644
--- a/luci-app-qosmate/htdocs/luci-static/resources/view/rules.js
+++ b/luci-app-qosmate/htdocs/luci-static/resources/view/rules.js
@@ -253,7 +253,7 @@ return view.extend({
if (headerRow) {
var headers = headerRow.querySelectorAll('th');
headers.forEach(function(th, index) {
- if (th.textContent.includes('Activity')) {
+ if (th.textContent.includes(_('Activity'))) {
activityColumnIndex = index;
}
diff --git a/luci-app-qosmate/htdocs/luci-static/resources/view/statistics.js b/luci-app-qosmate/htdocs/luci-static/resources/view/statistics.js
index 3dbfa44bf..3583963e9 100644
--- a/luci-app-qosmate/htdocs/luci-static/resources/view/statistics.js
+++ b/luci-app-qosmate/htdocs/luci-static/resources/view/statistics.js
@@ -253,6 +253,52 @@ var TimeSeriesChart = {
}
};
+// Statistics tables and charts are rendered with translated headings, so they
+// have to be grouped into the Egress/Ingress tabs by comparing against the
+// translations as well. A plain English keyword match such as
+// title.includes('Egress') dropped every table and chart in a non-English UI.
+var qosmateEgressHeadings = [
+ 'CAKE Egress Statistics',
+ 'CAKE Statistics for Default Class (Egress)',
+ 'Egress Bytes Sent by Class',
+ 'Egress Bytes Sent by Tin',
+ 'Egress Dropped Packets by Class',
+ 'Egress Dropped Packets by Tin',
+ 'Egress Packets Sent by Class',
+ 'Egress Packets Sent by Tin',
+ 'HFSC Egress Class Statistics',
+ 'HTB Egress Class Statistics',
+ 'Hybrid Egress Class Statistics'
+];
+
+var qosmateIngressHeadings = [
+ 'CAKE Ingress Statistics',
+ 'CAKE Statistics for Default Class (Ingress)',
+ 'HFSC Ingress Class Statistics',
+ 'HTB Ingress Class Statistics',
+ 'Hybrid Ingress Class Statistics',
+ 'Ingress Bytes Sent by Class',
+ 'Ingress Bytes Sent by Tin',
+ 'Ingress Dropped Packets by Class',
+ 'Ingress Dropped Packets by Tin',
+ 'Ingress Packets Sent by Class',
+ 'Ingress Packets Sent by Tin'
+];
+
+var isEgressHeading = function(heading) {
+ for (var i = 0; i < qosmateEgressHeadings.length; i++)
+ if (heading === _(qosmateEgressHeadings[i]))
+ return true;
+ return false;
+};
+
+var isIngressHeading = function(heading) {
+ for (var i = 0; i < qosmateIngressHeadings.length; i++)
+ if (heading === _(qosmateIngressHeadings[i]))
+ return true;
+ return false;
+};
+
// Main view
return view.extend({
pollInterval: 5,
@@ -440,7 +486,7 @@ return view.extend({
// Create table for CAKE egress tins
if (egressRows.length > 0) {
result.tables.push(self.createStatsTable(
- _('CAKE Egress Statistics - eth1'),
+ _('CAKE Egress Statistics'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
egressRows
));
@@ -511,7 +557,7 @@ return view.extend({
// Create table for CAKE ingress tins
if (ingressRows.length > 0) {
result.tables.push(self.createStatsTable(
- _('CAKE Ingress Statistics - eth1'),
+ _('CAKE Ingress Statistics'),
[_('Tin'), _('Threshold'), _('Target'), _('Interval'), _('Peak Delay'), _('Avg Delay'), _('Sparse Delay'), _('Bytes'), _('Packets'), _('Dropped'), _('ECN Marked')],
ingressRows
));
@@ -1621,9 +1667,9 @@ return view.extend({
var titleElement = cakeResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
- if (title.includes('Egress')) {
+ if (isEgressHeading(title)) {
egressTable = cakeResults.tables[i];
- } else if (title.includes('Ingress')) {
+ } else if (isIngressHeading(title)) {
ingressTable = cakeResults.tables[i];
}
}
@@ -1649,9 +1695,9 @@ return view.extend({
var chartTitle = cakeResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
- if (titleText.includes('Egress')) {
+ if (isEgressHeading(titleText)) {
egressCharts.push(cakeResults.charts[i]);
- } else if (titleText.includes('Ingress')) {
+ } else if (isIngressHeading(titleText)) {
ingressCharts.push(cakeResults.charts[i]);
}
}
@@ -1701,9 +1747,9 @@ return view.extend({
var titleElement = hfscResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
- if (title.includes('Egress')) {
+ if (isEgressHeading(title)) {
egressTable = hfscResults.tables[i];
- } else if (title.includes('Ingress')) {
+ } else if (isIngressHeading(title)) {
ingressTable = hfscResults.tables[i];
}
}
@@ -1729,9 +1775,9 @@ return view.extend({
var chartTitle = hfscResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
- if (titleText.includes('Egress')) {
+ if (isEgressHeading(titleText)) {
egressCharts.push(hfscResults.charts[i]);
- } else if (titleText.includes('Ingress')) {
+ } else if (isIngressHeading(titleText)) {
ingressCharts.push(hfscResults.charts[i]);
}
}
@@ -1783,13 +1829,13 @@ return view.extend({
var titleElement = hybridResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
- if (title.includes('Hybrid Egress Class')) {
+ if (title === _('Hybrid Egress Class Statistics')) {
egressClassTable = hybridResults.tables[i];
- } else if (title.includes('Hybrid Ingress Class')) {
+ } else if (title === _('Hybrid Ingress Class Statistics')) {
ingressClassTable = hybridResults.tables[i];
- } else if (title.includes('CAKE') && title.includes('Egress')) {
+ } else if (title === _('CAKE Statistics for Default Class (Egress)')) {
cakeEgressTable = hybridResults.tables[i];
- } else if (title.includes('CAKE') && title.includes('Ingress')) {
+ } else if (title === _('CAKE Statistics for Default Class (Ingress)')) {
cakeIngressTable = hybridResults.tables[i];
}
}
@@ -1820,9 +1866,9 @@ return view.extend({
var chartTitle = hybridResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
- if (titleText.includes('Egress')) {
+ if (isEgressHeading(titleText)) {
egressCharts.push(hybridResults.charts[i]);
- } else if (titleText.includes('Ingress')) {
+ } else if (isIngressHeading(titleText)) {
ingressCharts.push(hybridResults.charts[i]);
}
}
@@ -1872,9 +1918,9 @@ return view.extend({
var titleElement = htbResults.tables[i].querySelector('h3, .table-title');
if (titleElement) {
var title = titleElement.textContent || titleElement.innerText;
- if (title.includes('HTB Egress Class')) {
+ if (title === _('HTB Egress Class Statistics')) {
egressClassTable = htbResults.tables[i];
- } else if (title.includes('HTB Ingress Class')) {
+ } else if (title === _('HTB Ingress Class Statistics')) {
ingressClassTable = htbResults.tables[i];
}
}
@@ -1898,9 +1944,9 @@ return view.extend({
var chartTitle = htbResults.charts[i].querySelector('h3');
var titleText = chartTitle ? (chartTitle.textContent || chartTitle.innerText) : '';
- if (titleText.includes('Egress')) {
+ if (isEgressHeading(titleText)) {
egressCharts.push(htbResults.charts[i]);
- } else if (titleText.includes('Ingress')) {
+ } else if (isIngressHeading(titleText)) {
ingressCharts.push(htbResults.charts[i]);
}
}
diff --git a/luci-app-qosmate/po/zh_Hans/qosmate.po b/luci-app-qosmate/po/zh_Hans/qosmate.po
index c7d3235b9..8934c84bb 100644
--- a/luci-app-qosmate/po/zh_Hans/qosmate.po
+++ b/luci-app-qosmate/po/zh_Hans/qosmate.po
@@ -1,3 +1,5 @@
+msgid ""
+msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh_CN\n"
@@ -245,7 +247,7 @@ msgstr "数据包丢失百分比"
msgid "Percentage of packet loss"
msgstr "数据包丢失百分比"
-msgid "Failed to save settings or update QoSmate service: "
+msgid "Failed to save settings or update QoSmate service:"
msgstr "保存设置或更新 QoSmate 服务失败:"
msgid "QoSmate CAKE Settings"
@@ -305,9 +307,6 @@ msgstr "PPPoE PTM"
msgid "Bridged PTM"
msgstr "桥接 PTM"
-msgid "Set the overhead"
-msgstr "设置开销"
-
msgid "Default: based on preset"
msgstr "默认值: 基于预设"
@@ -377,9 +376,6 @@ msgstr "为出口设置 ACK 过滤器。如果下载/上传比率≥15,将自
msgid "Auto"
msgstr "自动"
-msgid "Enable"
-msgstr "启用"
-
msgid "Disable"
msgstr "禁用"
@@ -410,9 +406,6 @@ msgstr "额外参数(出口)"
msgid "Set extra parameters for egress"
msgstr "设置出站的额外参数"
-msgid "Failed to save settings or update QoSmate service: "
-msgstr "保存设置或更新 QoSmate 服务失败:"
-
msgid "QoSmate Advanced Settings"
msgstr "QoSmate 高级设置"
@@ -506,18 +499,12 @@ msgstr "TCP 连接的最大段大小。此设置仅在上传或下载带宽小
msgid "Default: 536"
msgstr "默认值:536"
-msgid "Failed to save settings or update QoSmate service: "
-msgstr "保存设置或更新 QoSmate 服务失败:"
-
msgid "QoSmate Rules"
msgstr "QoSmate 规则"
msgid "Configure QoS rules for marking packets with DSCP values."
msgstr "配置 QoS 规则以标记具有 DSCP 值的数据包。"
-msgid "Rules"
-msgstr "规则"
-
msgid "General Settings"
msgstr "常规设置"
@@ -659,18 +646,9 @@ msgstr "CS0 - 最佳努力 (0)"
msgid "Enable counter"
msgstr "启用计数器"
-msgid "Enable"
-msgstr "启用"
-
-msgid "Failed to save settings or update QoSmate service: "
-msgstr "保存设置或更新 QoSmate 服务失败:"
-
msgid "Custom rules have been saved and applied."
msgstr "自定义规则已保存并应用。"
-msgid "Failed to save settings or restart QoSmate:"
-msgstr "无法保存设置或重启 QoSmate:"
-
msgid "QoSmate Custom Rules"
msgstr "QoSmate 自定义规则"
@@ -689,9 +667,6 @@ msgstr "确定要清除所有自定义规则吗?此操作无法撤销。"
msgid "Custom rules have been erased and changes applied."
msgstr "自定义规则已清除并应用更改。"
-msgid "Failed to erase custom rules or apply changes:"
-msgstr "无法清除自定义规则或应用更改:"
-
msgid "Custom nftables Rules"
msgstr "自定义 nftables 规则"
@@ -731,9 +706,6 @@ msgstr "请等待规则验证... "
msgid "Finalizing validation results, please wait..."
msgstr "正在完成验证结果,请稍候..."
-msgid "Error during validation:"
-msgstr "验证过程中出错:"
-
msgid "Example rules:"
msgstr "示例规则:"
@@ -752,10 +724,10 @@ msgstr "通过 IP、IP:端口、端口、协议或 DSCP 过滤"
msgid "Zoom:"
msgstr "缩放:"
-msgid "In: "
+msgid "In:"
msgstr "入站:"
-msgid "Out: "
+msgid "Out:"
msgstr "出站:"
msgid "Packets"
@@ -770,9 +742,6 @@ msgstr "最大 PPS"
msgid "Avg BPS"
msgstr "平均 BPS"
-msgid "Protocol"
-msgstr "协议"
-
msgid "Source"
msgstr "来源"
@@ -784,3 +753,1108 @@ msgstr "DSCP"
msgid "Bytes"
msgstr "字节"
+
+msgid "(expected IP, IPv6, CIDR, or @setname)"
+msgstr "(预期为 IP、IPv6、CIDR 或 @setname)"
+
+msgid "(use IP addresses instead)"
+msgstr "(请改用 IP 地址)"
+
+msgid "Mbps (based on your"
+msgstr " Mbps(基于您的 "
+
+msgid "Mbps upload)"
+msgstr " Mbps 上传)"
+
+msgid "%s (Backend) / %s (Frontend)"
+msgstr "%s(后端)/ %s(前端)"
+
+msgid "0 = no burst (strict), 1.0 = rate as burst, higher = more burst"
+msgstr "0 = 无突发(严格),1.0 = 以速率为突发,越高突发越多"
+
+msgid "192.168.1.100, !=192.168.1.77, 2001:db8::1"
+msgstr "192.168.1.100, !=192.168.1.77, 2001:db8::1"
+
+msgid "AF11 (10)"
+msgstr "AF11 (10)"
+
+msgid "API LIMIT"
+msgstr "API 限制"
+
+msgid "Active Range"
+msgstr "活跃范围"
+
+msgid "Activity"
+msgstr "活动"
+
+msgid "Add IPv4 address, subnet or range"
+msgstr "添加 IPv4 地址、子网或范围"
+
+msgid "Add IPv6 address, subnet or range"
+msgstr "添加 IPv6 地址、子网或范围"
+
+msgid "Add Rate Limit"
+msgstr "添加速率限制"
+
+msgid "Added for IP:"
+msgstr "已为 IP 添加:"
+
+msgid "Additional link compensation (atm, ptm, noatm). Affects overhead calculations."
+msgstr "附加链路补偿(atm、ptm、noatm)。影响开销计算。"
+
+msgid "Adjustment Factor - Decrease (%)"
+msgstr "调整系数 - 下降(%)"
+
+msgid "Adjustment Factor - Increase (%)"
+msgstr "调整系数 - 上升(%)"
+
+msgid "All rules have been erased and changes applied."
+msgstr "所有规则已清除并应用更改。"
+
+msgid "All rules have been saved and applied."
+msgstr "所有规则已保存并应用。"
+
+msgid "Apply and Reload"
+msgstr "应用并刷新"
+
+msgid "Applying Changes"
+msgstr "正在应用更改"
+
+msgid "Auto Setup Results"
+msgstr "自动设置结果"
+
+msgid "Auto setup did not finish within 5 minutes."
+msgstr "自动设置未在 5 分钟内完成。"
+
+msgid "Auto setup did not finish, last error:"
+msgstr "自动设置未完成,最后错误:"
+
+msgid "Auto setup failed:"
+msgstr "自动设置失败:"
+
+msgid "Auto-detect"
+msgstr "自动检测"
+
+msgid "Autorate"
+msgstr "自动速率"
+
+msgid "Autorate Settings"
+msgstr "自动速率设置"
+
+msgid "Autorate Status"
+msgstr "自动速率状态"
+
+msgid "Avg Delay"
+msgstr "平均延迟"
+
+msgid "Backend"
+msgstr "后端"
+
+msgid "Background"
+msgstr "后台"
+
+msgid "Bandwidth Ratio Warning"
+msgstr "带宽比例警告"
+
+msgid "Base Download Rate (kbps)"
+msgstr "基础下载速率(kbps)"
+
+msgid "Base Rate"
+msgstr "基础速率"
+
+msgid "Base Upload Rate (kbps)"
+msgstr "基础上传速率(kbps)"
+
+msgid "Baseline"
+msgstr "基线"
+
+msgid "Both Directions"
+msgstr "双向"
+
+msgid "Bulk"
+msgstr "批量"
+
+msgid "Burst Factor"
+msgstr "突发系数"
+
+msgid "CAKE - Common Applications Kept Enhanced. Configure CAKE-specific parameters in the CAKE tab."
+msgstr "CAKE - 通用应用保持增强。在 CAKE 标签页中配置 CAKE 专属参数。"
+
+msgid "CAKE Egress Statistics"
+msgstr "CAKE 出口统计"
+
+msgid "CAKE Ingress Statistics"
+msgstr "CAKE 入口统计"
+
+msgid "CAKE Statistics for Default Class (Egress)"
+msgstr "默认类的 CAKE 统计(出口)"
+
+msgid "CAKE Statistics for Default Class (Ingress)"
+msgstr "默认类的 CAKE 统计(入口)"
+
+msgid "CAKE mode active."
+msgstr "CAKE 模式已启用。"
+
+msgid "Cable DOCSIS (25B)"
+msgstr "DOCSIS 电缆 (25B)"
+
+msgid "Cancel"
+msgstr "取消"
+
+msgid "Change Channel"
+msgstr "更改频道"
+
+msgid "Change Update Channel"
+msgstr "更改更新频道"
+
+msgid "Changing Update Channel"
+msgstr "正在更改更新频道"
+
+msgid "Channel"
+msgstr "频道"
+
+msgid "Class ID"
+msgstr "类 ID"
+
+msgid "Component"
+msgstr "组件"
+
+msgid "Components are using different update channels (%s/%s). Continue with %s channel?"
+msgstr "组件使用了不同的更新频道(%s/%s)。是否继续使用 %s 频道?"
+
+msgid "Config"
+msgstr "配置"
+
+msgid "Configure link layer adaptation settings. For detailed information about different link types and overhead calculations, please check the"
+msgstr "配置链路层适配设置。有关不同链路类型和开销计算的详细信息,请查看 "
+
+msgid "Configure per-device bandwidth limits using nftables meters. Limits are applied based on the source IP of devices, making it easy to control traffic for specific devices or subnets."
+msgstr "使用 nftables 计量器为每台设备配置带宽限制。限制根据设备的源 IP 应用,方便控制特定设备或子网的流量。"
+
+msgid "Connections:"
+msgstr "连接数:"
+
+msgid "Connections: 0"
+msgstr "连接数:0"
+
+msgid "Connections: Error"
+msgstr "连接数:错误"
+
+msgid "Current Root QDisc is %s - CAKE settings are not used."
+msgstr "当前根队列规则为 %s - 不使用 CAKE 设置。"
+
+msgid "Current Root QDisc is %s - HFSC settings are not used."
+msgstr "当前根队列规则为 %s - 不使用 HFSC 设置。"
+
+msgid "Current Update Channels"
+msgstr "当前更新频道"
+
+msgid "Current limit:"
+msgstr "当前限制:"
+
+msgid "DSL ATM/ADSL (44B)"
+msgstr "DSL ATM/ADSL (44B)"
+
+msgid "Debug only"
+msgstr "仅调试"
+
+msgid "Dec. Threshold"
+msgstr "下降阈值"
+
+msgid "Decrease bandwidth when latency delta exceeds this value."
+msgstr "当延迟差超过此值时降低带宽。"
+
+msgid "Default (CAKE)"
+msgstr "默认(CAKE)"
+
+msgid "Default: 24"
+msgstr "默认值:24"
+
+msgid "Default: 30"
+msgstr "默认值:30"
+
+msgid "Default: 450"
+msgstr "默认值:450"
+
+msgid "Default: 45000"
+msgstr "默认值:45000"
+
+msgid "Default: 5"
+msgstr "默认值:5"
+
+msgid "Default: 7"
+msgstr "默认值:7"
+
+msgid "Default: 90000"
+msgstr "默认值:90000"
+
+msgid "Define groups of IP addresses that can be referenced in QoS rules using @setname"
+msgstr "定义 IP 地址组,可在 QoS 规则中通过 @setname 引用"
+
+msgid "Description"
+msgstr "描述"
+
+msgid "Downgrades TCP flows exceeding ~10 seconds worth of data transfer to CS1 (Bulk). Helps prevent large downloads from starving other traffic."
+msgstr "将传输数据量超过约 10 秒的 TCP 流降级为 CS1(批量)。有助于防止大文件下载挤占其他流量。"
+
+msgid "Downgrades the first ~500ms of TCP traffic (except CS1) to CS0 to prevent initial bursts"
+msgstr "将 TCP 流量的前约 500 毫秒(CS1 除外)降级为 CS0,以防止初始突发。"
+
+msgid "Download Bandwidth"
+msgstr "下载带宽"
+
+msgid "Download Limit (Kbit/s)"
+msgstr "下载限制 (Kbit/s)"
+
+msgid "Download Rate"
+msgstr "下载速率"
+
+msgid "Download Speed"
+msgstr "下载速度"
+
+msgid "Download shaping disabled (DOWNRATE=0)"
+msgstr "下载整形已禁用 (DOWNRATE=0)"
+
+msgid "Download:"
+msgstr "下载:"
+
+msgid "Dropped"
+msgstr "已丢弃"
+
+msgid "Drops"
+msgstr "丢包数"
+
+msgid "Dynamic"
+msgstr "动态"
+
+msgid "Dynamic Bandwidth (Autorate)"
+msgstr "动态带宽(自动速率)"
+
+msgid "Dynamically adjust bandwidth based on latency measurements. Fine-tune in Advanced tab."
+msgstr "根据延迟测量动态调整带宽。可在\"高级设置\"标签页中微调。"
+
+msgid "ECN Marked"
+msgstr "ECN 标记"
+
+msgid "Edit Rate Limit: %s"
+msgstr "编辑速率限制:%s"
+
+msgid "Egress (Upload)"
+msgstr "出口(上传)"
+
+msgid "Egress Bytes Sent by Class"
+msgstr "各类发送的出口字节数"
+
+msgid "Egress Bytes Sent by Tin"
+msgstr "各 Tin 发送的出口字节数"
+
+msgid "Egress Dropped Packets by Class"
+msgstr "各类丢弃的出口数据包"
+
+msgid "Egress Dropped Packets by Tin"
+msgstr "各 Tin 丢弃的出口数据包"
+
+msgid "Egress Only"
+msgstr "仅出口"
+
+msgid "Egress Packets Sent by Class"
+msgstr "各类发送的出口数据包"
+
+msgid "Egress Packets Sent by Tin"
+msgstr "各 Tin 发送的出口数据包"
+
+msgid "Enable Autorate"
+msgstr "启用自动速率"
+
+msgid "Enable Initial TCP Down-Prioritization"
+msgstr "启用初始 TCP 降优先级"
+
+msgid "Enable Sustained TCP Down-Prioritization"
+msgstr "启用持续 TCP 降优先级"
+
+msgid "Enable long-term history"
+msgstr "启用长期历史记录"
+
+msgid "Enable trace"
+msgstr "启用跟踪"
+
+msgid "Enabled"
+msgstr "已启用"
+
+msgid "Erase"
+msgstr "清除"
+
+msgid "Error displaying connections. System may be overloaded."
+msgstr "显示连接时出错。系统可能过载。"
+
+msgid "Error during validation:"
+msgstr "验证过程中出错:"
+
+msgid "Error loading QoS statistics. Please check if the QoSmate service is running."
+msgstr "加载 QoS 统计信息出错。请检查 QoSmate 服务是否正在运行。"
+
+msgid "Error loading initial data: %s"
+msgstr "加载初始数据出错:%s"
+
+msgid "Error: Could not find rules textareas"
+msgstr "错误:找不到规则文本区域"
+
+msgid "Ethernet (HFSC/HTB: 40B, CAKE: 40B override)"
+msgstr "以太网 (HFSC/HTB: 40B, CAKE: 40B 覆盖)"
+
+msgid "Ethernet VLAN keyword for CAKE. Ignored by other QDiscs."
+msgstr "CAKE 的以太网 VLAN 关键字。其他队列规则会忽略此设置。"
+
+msgid "Example (Full Table Rules):"
+msgstr "示例(完整表规则):"
+
+msgid "Example (Inline Rules):"
+msgstr "示例(内联规则):"
+
+msgid "Experimental"
+msgstr "实验性"
+
+msgid "Failed to apply connection limit: %s"
+msgstr "应用连接限制失败:%s"
+
+msgid "Failed to change update channel"
+msgstr "更改更新频道失败"
+
+msgid "Failed to erase custom rules or apply changes:"
+msgstr "清除自定义规则或应用更改失败:"
+
+msgid "Failed to restart QoSmate:"
+msgstr "重启 QoSmate 失败:"
+
+msgid "Failed to save settings or restart QoSmate:"
+msgstr "保存设置或重启 QoSmate 失败:"
+
+msgid "Failed to start QoSmate:"
+msgstr "启动 QoSmate 失败:"
+
+msgid "Failed to stop QoSmate:"
+msgstr "停止 QoSmate 失败:"
+
+msgid "Failed to update QoSmate"
+msgstr "更新 QoSmate 失败"
+
+msgid "Failed to update settings or restart QoSmate:"
+msgstr "更新设置或重启 QoSmate 失败:"
+
+msgid "Family"
+msgstr "协议族"
+
+msgid "Fast Non-Realtime"
+msgstr "快速非实时"
+
+msgid "Filter by: IP IP:Port Port Protocol DSCP"
+msgstr "过滤条件:IP、IP:端口、端口、协议、DSCP"
+
+msgid "Finalizing Validation"
+msgstr "正在完成验证"
+
+msgid "Fine-tune the dynamic bandwidth adjustment algorithm. Enable Autorate in the Settings tab."
+msgstr "微调动态带宽调整算法。请在\"设置\"标签页中启用自动速率。"
+
+msgid "For detailed setup instructions and advanced configuration options, please check the"
+msgstr "有关详细设置说明和高级配置选项,请查看 "
+
+msgid "For ranges above 60 minutes, recent data (last 60 min) is high-resolution, while older points are aggregated and less precise. \"Latency (max)\" highlights spikes that average latency may hide."
+msgstr "对于超过 60 分钟的范围,最近数据(最后 60 分钟)为高分辨率,而较早的数据点会被聚合且精度较低。\"延迟(最大值)\"会突出显示平均延迟可能掩盖的尖峰。"
+
+msgid "Frontend"
+msgstr "前端"
+
+msgid "Game Queue Discipline:"
+msgstr "游戏队列规则:"
+
+msgid "Gaming Rules"
+msgstr "游戏规则"
+
+msgid "General"
+msgstr "常规"
+
+msgid "General Information"
+msgstr "基本信息"
+
+msgid "HFSC - Hierarchical Fair Service Curve. Configure realtime traffic settings in the HFSC tab."
+msgstr "HFSC - 分层公平服务曲线。在 HFSC 标签页中配置实时流量设置。"
+
+msgid "HFSC Egress Class Statistics"
+msgstr "HFSC 出口类统计"
+
+msgid "HFSC Ingress Class Statistics"
+msgstr "HFSC 入口类统计"
+
+msgid "HFSC mode active."
+msgstr "HFSC 模式已启用。"
+
+msgid "HTB (Experimental)"
+msgstr "HTB(实验性)"
+
+msgid "HTB - Hierarchical Token Bucket. Simple 3-tier priority system with pre-configured settings - no additional qdisc configuration required."
+msgstr "HTB - 分层令牌桶。简单的 3 级优先级系统,带预配置设置 - 无需额外配置队列规则。"
+
+msgid "HTB Egress Class Statistics"
+msgstr "HTB 出口类统计"
+
+msgid "HTB Ingress Class Statistics"
+msgstr "HTB 入口类统计"
+
+msgid "High Priority (Realtime)"
+msgstr "高优先级(实时)"
+
+msgid "How often to measure latency and adjust bandwidth."
+msgstr "测量延迟并调整带宽的频率。"
+
+msgid "Hybrid"
+msgstr "混合"
+
+msgid "Hybrid - HFSC as shaper, Game Qdisc for realtime traffic, CAKE for default traffic and fq_codel for bulk traffic. Configure realtime class settings in HFSC tab and default class settings in CAKE tab."
+msgstr "混合 - 使用 HFSC 作为整形器,游戏队列规则用于实时流量,CAKE 用于默认流量,fq_codel 用于批量流量。在 HFSC 标签页中配置实时类设置,在 CAKE 标签页中配置默认类设置。"
+
+msgid "Hybrid Egress Class Statistics"
+msgstr "混合出口类统计"
+
+msgid "Hybrid Ingress Class Statistics"
+msgstr "混合入口类统计"
+
+msgid "Hybrid Note:"
+msgstr "混合模式说明:"
+
+msgid "Hybrid mode - these settings control default traffic class (normal priority)."
+msgstr "混合模式 - 这些设置控制默认流量类(普通优先级)。"
+
+msgid "Hybrid mode - these settings control realtime traffic (high priority class)."
+msgstr "混合模式 - 这些设置控制实时流量(高优先级类)。"
+
+msgid "ICMPv6"
+msgstr "ICMPv6"
+
+msgid "IP Sets"
+msgstr "IP 集合"
+
+msgid "IP address, range, @setname or ::suffix/::mask"
+msgstr "IP 地址、范围、@setname 或 ::suffix/::mask"
+
+msgid "IPv4"
+msgstr "IPv4"
+
+msgid "IPv4 Addresses"
+msgstr "IPv4 地址"
+
+msgid "IPv6"
+msgstr "IPv6"
+
+msgid "IPv6 Addresses"
+msgstr "IPv6 地址"
+
+msgid "Inc. Threshold"
+msgstr "上升阈值"
+
+msgid "Increase bandwidth when latency delta is below this value."
+msgstr "当延迟差低于此值时提高带宽。"
+
+msgid "Ingress (Download)"
+msgstr "入口(下载)"
+
+msgid "Ingress Bytes Sent by Class"
+msgstr "各类发送的入口字节数"
+
+msgid "Ingress Bytes Sent by Tin"
+msgstr "各 Tin 发送的入口字节数"
+
+msgid "Ingress Dropped Packets by Class"
+msgstr "各类丢弃的入口数据包"
+
+msgid "Ingress Dropped Packets by Tin"
+msgstr "各 Tin 丢弃的入口数据包"
+
+msgid "Ingress Only"
+msgstr "仅入口"
+
+msgid "Ingress Packets Sent by Class"
+msgstr "各类发送的入口数据包"
+
+msgid "Ingress Packets Sent by Tin"
+msgstr "各 Tin 发送的入口数据包"
+
+msgid "Inline Extra Rules"
+msgstr "内联额外规则"
+
+msgid "Interval"
+msgstr "间隔"
+
+msgid "Invalid IP address or CIDR format:"
+msgstr "无效的 IP 地址或 CIDR 格式:"
+
+msgid "Invalid IP range:"
+msgstr "无效的 IP 范围:"
+
+msgid "Invalid IPv6 suffix format. Use ::suffix/::mask with valid IPv6 segments (e.g. ::1234:5678/::ffff:ffff)"
+msgstr "无效的 IPv6 后缀格式。请使用 ::suffix/::mask 格式并提供有效的 IPv6 段(例如 ::1234:5678/::ffff:ffff)"
+
+msgid "Invalid decimal format"
+msgstr "无效的小数格式"
+
+msgid "Invalid protocol: %s"
+msgstr "无效的协议:%s"
+
+msgid "Invalid rates for HFSC. Please set non-zero values manually."
+msgstr "HFSC 速率无效。请手动设置非零值。"
+
+msgid "Invalid set name format. Must start with @ followed by letters, numbers, or underscore"
+msgstr "无效的集合名称格式。必须以 @ 开头,后跟字母、数字或下划线"
+
+msgid "Invalid set name format:"
+msgstr "无效的集合名称格式:"
+
+msgid "Invalid target format:"
+msgstr "无效的目标格式:"
+
+msgid "Large download/upload difference detected ("
+msgstr "检测到较大的下载/上传差异("
+
+msgid "Latency"
+msgstr "延迟"
+
+msgid "Latency (max)"
+msgstr "延迟(最大值)"
+
+msgid "Latency Decrease Threshold (ms)"
+msgstr "延迟下降阈值(毫秒)"
+
+msgid "Latency Increase Threshold (ms)"
+msgstr "延迟上升阈值(毫秒)"
+
+msgid "Latency:"
+msgstr "延迟:"
+
+msgid "Leave empty for auto-calculation (100% of configured download rate)."
+msgstr "留空以自动计算(已配置下载速率的 100%)。"
+
+msgid "Leave empty for auto-calculation (100% of configured upload rate)."
+msgstr "留空以自动计算(已配置上传速率的 100%)。"
+
+msgid "Leave empty for auto-calculation (105% of configured download rate)."
+msgstr "留空以自动计算(已配置下载速率的 105%)。"
+
+msgid "Leave empty for auto-calculation (105% of configured upload rate)."
+msgstr "留空以自动计算(已配置上传速率的 105%)。"
+
+msgid "Leave empty for auto-calculation (25% of configured download rate)."
+msgstr "留空以自动计算(已配置下载速率的 25%)。"
+
+msgid "Leave empty for auto-calculation (25% of configured upload rate)."
+msgstr "留空以自动计算(已配置上传速率的 25%)。"
+
+msgid "Limited to %d connections. Some connections may not be shown."
+msgstr "限制显示 %d 个连接。部分连接可能不会显示。"
+
+msgid "Limits"
+msgstr "限制"
+
+msgid "Link Layer Settings"
+msgstr "链路层设置"
+
+msgid "Loading health check status..."
+msgstr "正在加载健康检查状态..."
+
+msgid "Log All Rate Changes"
+msgstr "记录所有速率变化"
+
+msgid "Log every rate change to system log (for debugging). May spam logs."
+msgstr "将每次速率变化记录到系统日志(用于调试)。可能会产生大量日志。"
+
+msgid "Low Priority"
+msgstr "低优先级"
+
+msgid "MAC addresses not supported:"
+msgstr "不支持 MAC 地址:"
+
+msgid "Manual Overhead (bytes)"
+msgstr "手动开销(字节)"
+
+msgid "Max"
+msgstr "最大"
+
+msgid "Max Connections:"
+msgstr "最大连接数:"
+
+msgid "Max Download Rate (kbps)"
+msgstr "最大下载速率(kbps)"
+
+msgid "Max Upload Rate (kbps)"
+msgstr "最大上传速率(kbps)"
+
+msgid "Maximum Segment Size for TCP connections. This setting is only active when the upload or download bandwidth is less than 3000 kbit/s. Leave empty to use the default value. Valid range: 536-1500"
+msgstr "TCP 连接的最大段大小。此设置仅在上传或下载带宽小于 3000 kbit/s 时生效。留空将使用默认值。有效范围:536-1500"
+
+msgid "Measurement Interval (ms)"
+msgstr "测量间隔(毫秒)"
+
+msgid "Min"
+msgstr "最小"
+
+msgid "Min Download Rate (kbps)"
+msgstr "最小下载速率(kbps)"
+
+msgid "Min Upload Rate (kbps)"
+msgstr "最小上传速率(kbps)"
+
+msgid "Minimum packet unit size. Used primarily by CAKE, ignored by other QDiscs."
+msgstr "最小数据包单元大小。主要由 CAKE 使用,其他队列规则会忽略此设置。"
+
+msgid "Minimum wait time after a rate decrease before next adjustment."
+msgstr "降低速率后到下次调整前的最小等待时间。"
+
+msgid "Minimum wait time after a rate increase before next adjustment."
+msgstr "提高速率后到下次调整前的最小等待时间。"
+
+msgid "Multi-Queue CAKE (cake_mq)"
+msgstr "多队列 CAKE (cake_mq)"
+
+msgid "Multiply current rate by this percentage when decreasing (85 = 15% decrease)."
+msgstr "降低时将当前速率乘以此百分比(85 = 降低 15%)。"
+
+msgid "Multiply current rate by this percentage when increasing (102 = 2% increase)."
+msgstr "上升时将当前速率乘以此百分比(102 = 提升 2%)。"
+
+msgid "Must be a non-negative integer or empty"
+msgstr "必须为非负整数或留空"
+
+msgid "Must be a number"
+msgstr "必须为数字"
+
+msgid "Must be an integer or empty"
+msgstr "必须为整数或留空"
+
+msgid "Must be between 0 and 100000000 Kbit/s"
+msgstr "必须在 0 到 100000000 Kbit/s 之间"
+
+msgid "Must be between 0.0 and 10.0 (0 = no burst)"
+msgstr "必须在 0.0 到 10.0 之间(0 = 无突发)"
+
+msgid "Must be between 536 and 1500"
+msgstr "必须在 536 到 1500 之间"
+
+msgid "NETEM Direction"
+msgstr "NETEM 方向"
+
+msgid "No IFB device is created. TCP bulk detection is off because its thresholds derive from the download rate, CAKE ingress options have no effect, and incoming packets carry no DSCP."
+msgstr "未创建 IFB 设备。TCP 批量检测已关闭,因为其阈值来自下载速率;CAKE 入口选项无效,且入站数据包不带 DSCP。"
+
+msgid "No QoS statistics available. Make sure QoSmate is running."
+msgstr "没有可用的 QoS 统计信息。请确保 QoSmate 正在运行。"
+
+msgid "No data available"
+msgstr "暂无数据"
+
+msgid "No egress qdisc is created, so traffic classification only affects the download queue."
+msgstr "未创建出口队列规则,因此流量分类仅影响下载队列。"
+
+msgid "No hourly history available yet. Data is consolidated once per hour."
+msgstr "暂无小时历史数据。数据每小时整合一次。"
+
+msgid "No shaping active: both rates are 0"
+msgstr "未启用整形:上下行速率均为 0"
+
+msgid "Normal"
+msgstr "普通"
+
+msgid "Not available"
+msgstr "不可用"
+
+msgid "Not available in Hybrid mode (cake_mq requires root qdisc)"
+msgstr "混合模式下不可用(cake_mq 需要作为根队列规则)"
+
+msgid "Not detected"
+msgstr "未检测到"
+
+msgid "Not set"
+msgstr "未设置"
+
+msgid "Not used with %s"
+msgstr "不适用于 %s"
+
+msgid "Note:"
+msgstr "注意:"
+
+msgid "Note: Router-based speed tests may underestimate actual speeds. For best results, consider running tests from a LAN device and manually entering the values. These results serve as a starting point."
+msgstr "注意:基于路由器的测速可能低估实际速度。为获得最佳结果,建议从局域网设备运行测速并手动输入数值。这些结果仅作为起点。"
+
+msgid "Note: The \"snapshot\" channel might not be available in the current repository configuration."
+msgstr "注意:在当前仓库配置中,\"snapshot\" 频道可能不可用。"
+
+msgid "Off (default)"
+msgstr "关闭(默认)"
+
+msgid "Only digits and decimal separators allowed"
+msgstr "仅允许数字和小数分隔符"
+
+msgid "Only nftables DSCP marking is applied, no queues are created."
+msgstr "仅应用 nftables DSCP 标记,不创建队列。"
+
+msgid "Overlimits"
+msgstr "超限数"
+
+msgid "Override automatic overhead. For HFSC/HTB: Ethernet=40, DOCSIS=25, ATM=44. CAKE will add this to its preset."
+msgstr "覆盖自动开销。对于 HFSC/HTB:以太网=40、DOCSIS=25、ATM=44。CAKE 会将此值叠加到其预设上。"
+
+msgid "Packages"
+msgstr "软件包"
+
+msgid "Pareto"
+msgstr "Pareto"
+
+msgid "Pareto Normal"
+msgstr "Pareto 正态"
+
+msgid "Pause"
+msgstr "暂停"
+
+msgid "Peak Delay"
+msgstr "峰值延迟"
+
+msgid "Ping Reflectors"
+msgstr "Ping 反射器"
+
+msgid "Please wait while QoSmate is being updated..."
+msgstr "请稍候,QoSmate 正在更新..."
+
+msgid "Please wait while the auto setup is in progress..."
+msgstr "请稍候,自动设置正在进行中..."
+
+msgid "Please wait while the changes are being applied..."
+msgstr "请稍候,正在应用更改..."
+
+msgid "Please wait while the update channel is being changed..."
+msgstr "请稍候,正在更改更新频道..."
+
+msgid "Polling Interval:"
+msgstr "轮询间隔:"
+
+msgid "Priority"
+msgstr "优先级"
+
+msgid "Priority Queue Type (Egress):"
+msgstr "优先级队列类型(出口):"
+
+msgid "Priority Queue Type (Ingress):"
+msgstr "优先级队列类型(入口):"
+
+msgid "QoS Configuration"
+msgstr "QoS 配置"
+
+msgid "QoSmate IP Sets"
+msgstr "QoSmate IP 集合"
+
+msgid "QoSmate Rate Limits"
+msgstr "QoSmate 速率限制"
+
+msgid "QoSmate Update"
+msgstr "QoSmate 更新"
+
+msgid "QoSmate restarted"
+msgstr "QoSmate 已重启"
+
+msgid "QoSmate updated successfully."
+msgstr "QoSmate 更新成功。"
+
+msgid "Queue Type"
+msgstr "队列类型"
+
+msgid "Rate Limit"
+msgstr "速率限制"
+
+msgid "Rate Limits"
+msgstr "速率限制"
+
+msgid "Realtime Gaming"
+msgstr "实时游戏"
+
+msgid "Refractory Period - Decrease (s)"
+msgstr "不应期 - 下降(秒)"
+
+msgid "Refractory Period - Increase (s)"
+msgstr "不应期 - 上升(秒)"
+
+msgid "Refresh Interval"
+msgstr "刷新间隔"
+
+msgid "Release (stable)"
+msgstr "Release(稳定版)"
+
+msgid "Resume"
+msgstr "继续"
+
+msgid "Root Queueing Discipline:"
+msgstr "根队列规则:"
+
+msgid "Router-based speed tests may underestimate actual speeds. These results serve as a starting point and may require manual adjustment for optimal performance."
+msgstr "基于路由器的测速可能低估实际速度。这些结果仅作为起点,可能需要手动调整以获得最佳性能。"
+
+msgid "Rule already exists for IP:"
+msgstr "IP 已存在规则:"
+
+msgid "Rules validation failed. Please check the validation result below."
+msgstr "规则验证失败。请查看下方的验证结果。"
+
+msgid "Rules validation successful."
+msgstr "规则验证成功。"
+
+msgid "Running Auto Setup"
+msgstr "正在运行自动设置"
+
+msgid "Select components to update:"
+msgstr "选择要更新的组件:"
+
+msgid "Select the update channel for QoSmate:"
+msgstr "选择 QoSmate 的更新频道:"
+
+msgid "Select which direction to apply the NETEM delay/jitter settings"
+msgstr "选择应用 NETEM 延迟/抖动设置的方向"
+
+msgid "Select your connection type for overhead calculation. Used by all QDiscs (HTB, HFSC, Hybrid, CAKE)."
+msgstr "选择用于开销计算的连接类型。适用于所有队列规则(HTB、HFSC、混合、CAKE)。"
+
+msgid "Service"
+msgstr "服务"
+
+msgid "Service Status & Control"
+msgstr "服务状态与控制"
+
+msgid "Service not running - restart to apply"
+msgstr "服务未运行 - 重启以应用"
+
+msgid "Set ACK_FILTER_EGRESS explicitly in the CAKE tab: \"auto\" cannot decide without a download rate."
+msgstr "请在 CAKE 标签页中显式设置 ACK_FILTER_EGRESS:没有下载速率时,\"auto\" 无法自动判断。"
+
+msgid "Set Mode"
+msgstr "设置模式"
+
+msgid "Set Name"
+msgstr "集合名称"
+
+msgid "Set name is required"
+msgstr "集合名称为必填项"
+
+msgid "Set name must contain only letters, numbers, and underscore"
+msgstr "集合名称只能包含字母、数字和下划线"
+
+msgid "Set the download rate in kbps. 0 disables ingress shaping (no IFB device is created)."
+msgstr "以 kbps 为单位设置下载速率。设为 0 将禁用入口整形(不创建 IFB 设备)。"
+
+msgid "Set the upload rate in kbps. 0 disables egress shaping."
+msgstr "以 kbps 为单位设置上传速率。设为 0 将禁用出口整形。"
+
+msgid "Shaping Direction"
+msgstr "整形方向"
+
+msgid "Shaping:"
+msgstr "整形:"
+
+msgid "Show Examples"
+msgstr "显示示例"
+
+msgid "Showing %s of data (requested %s). History grows over time."
+msgstr "显示 %s 的数据(请求 %s)。历史数据会随时间增长。"
+
+msgid "Snapshot (latest from main)"
+msgstr "Snapshot(主分支最新版)"
+
+msgid "Software Flow Offloading Status"
+msgstr "软件流量卸载状态"
+
+msgid "Software Flow Offloading active - some limitations apply"
+msgstr "软件流量卸载已启用 - 存在一些限制"
+
+msgid "Space-separated list of IP addresses for latency measurement."
+msgstr "以空格分隔的 IP 地址列表,用于延迟测量。"
+
+msgid "Sparse Delay"
+msgstr "稀疏延迟"
+
+msgid "Speed Test Results"
+msgstr "测速结果"
+
+msgid "Statements only – run inside chain dscptag at hook $NFT_HOOK / priority $NFT_PRIORITY. Do not start with 'table' or 'chain'. Included only if validation passes."
+msgstr "仅限语句 – 在钩子 $NFT_HOOK / 优先级 $NFT_PRIORITY 的 dscptag 链内运行。不要以 'table' 或 'chain' 开头。只有验证通过才会包含。"
+
+msgid "Static"
+msgstr "静态"
+
+msgid "Statistics"
+msgstr "统计信息"
+
+msgid "Target"
+msgstr "目标"
+
+msgid "Target Devices"
+msgstr "目标设备"
+
+msgid "Threshold"
+msgstr "阈值"
+
+msgid "Throughput"
+msgstr "吞吐量"
+
+msgid "Time Range"
+msgstr "时间范围"
+
+msgid "Timeout"
+msgstr "超时"
+
+msgid "Timeout must be in a valid format (e.g., \"10s\", \"1h\", \"2h12m10s\")"
+msgstr "超时格式无效(例如 \"10s\"、\"1h\"、\"2h12m10s\")"
+
+msgid "Tin"
+msgstr "Tin"
+
+msgid "To change the update channel, go to the Advanced tab"
+msgstr "要更改更新频道,请前往\"高级设置\"标签页"
+
+msgid "To override: Advanced Settings → BWMAXRATIO"
+msgstr "覆盖方法:高级设置 → BWMAXRATIO"
+
+msgid "Traffic FROM device. 0 = unlimited"
+msgstr "来自设备的流量。0 = 不限制"
+
+msgid "Traffic TO device. 0 = unlimited"
+msgstr "发送到设备的流量。0 = 不限制"
+
+msgid "UPDATE AVAILABLE"
+msgstr "有可用更新"
+
+msgid "Unlimited"
+msgstr "不限制"
+
+msgid "Update Channel"
+msgstr "更新频道"
+
+msgid "Update Now"
+msgstr "立即更新"
+
+msgid "Update QoSmate"
+msgstr "更新 QoSmate"
+
+msgid "Update QoSmate Components"
+msgstr "更新 QoSmate 组件"
+
+msgid "Update Settings"
+msgstr "更新设置"
+
+msgid "Update channel change encountered issues: %s"
+msgstr "更改更新频道遇到问题:%s"
+
+msgid "Update channel changed to %s. Reloading page..."
+msgstr "更新频道已更改为 %s。正在刷新页面..."
+
+msgid "Update process encountered issues: %s"
+msgstr "更新过程遇到问题:%s"
+
+msgid "Updating QoSmate"
+msgstr "正在更新 QoSmate"
+
+msgid "Updating QoSmate. Please wait..."
+msgstr "正在更新 QoSmate。请稍候..."
+
+msgid "Upload Bandwidth"
+msgstr "上传带宽"
+
+msgid "Upload Limit (Kbit/s)"
+msgstr "上传限制 (Kbit/s)"
+
+msgid "Upload Rate"
+msgstr "上传速率"
+
+msgid "Upload Speed"
+msgstr "上传速度"
+
+msgid "Upload shaping disabled (UPRATE=0)"
+msgstr "上传整形已禁用 (UPRATE=0)"
+
+msgid "Upload:"
+msgstr "上传:"
+
+msgid "Use cake_mq for multi-core CPU scaling. Only effective in pure CAKE mode (not hybrid). Requires kernel support (OpenWrt 25.12+)."
+msgstr "使用 cake_mq 实现多核 CPU 扩展。仅在纯 CAKE 模式(非混合)下有效。需要内核支持(OpenWrt 25.12+)。"
+
+msgid "Uses Realtime (1:11) & Bulk (1:15) classes with their dscp values from HFSC. All other traffic is handled by a single CAKE class (1:13)."
+msgstr "使用 HFSC 中的 Realtime (1:11) 和 Bulk (1:15) 类及其 DSCP 值。其他所有流量由单一 CAKE 类 (1:13) 处理。"
+
+msgid "Version & Updates"
+msgstr "版本与更新"
+
+msgid "WAN Interface:"
+msgstr "WAN 接口:"
+
+msgid "Your download speed has been limited to prevent connection issues."
+msgstr "为避免连接问题,您的下载速度已被限制。"
+
+msgid "[CAKE] Conservative (48B + ATM)"
+msgstr "[CAKE] 保守 (48B + ATM)"
+
+msgid "[CAKE] Ethernet (38B native)"
+msgstr "[CAKE] 以太网 (38B 原生)"
+
+msgid "[CAKE] Raw (No overhead)"
+msgstr "[CAKE] 原始(无开销)"
+
+msgid "cake_mq active"
+msgstr "cake_mq 已启用"
+
+msgid "cake_mq is active"
+msgstr "cake_mq 已启用"
+
+msgid "cake_mq not available"
+msgstr "cake_mq 不可用"
+
+msgid "cake_mq not available on this system, using standard cake"
+msgstr "此系统不支持 cake_mq,正在使用标准 cake"
+
+msgid "cake_mq off"
+msgstr "cake_mq 已关闭"
+
+msgid "days"
+msgstr "天"
+
+msgid "disabled in both directions (both rates are 0)"
+msgstr "双向均已禁用(上下行速率均为 0)"
+
+msgid "e.g., 1h"
+msgstr "例如:1h"
+
+msgid "e.g., Guest Network Limit"
+msgstr "例如:访客网络限制"
+
+msgid "egress only (DOWNRATE=0, no ingress statistics)"
+msgstr "仅出口(DOWNRATE=0,无入口统计)"
+
+msgid "forward"
+msgstr "转发"
+
+msgid "hours"
+msgstr "小时"
+
+msgid "ingress only (UPRATE=0, no egress statistics)"
+msgstr "仅入口(UPRATE=0,无出口统计)"
+
+msgid "load"
+msgstr "负载"
+
+msgid "minutes"
+msgstr "分钟"
+
+msgid "no activity"
+msgstr "无活动"
+
+msgid "postrouting"
+msgstr "路由后"
+
+msgid "seconds"
+msgstr "秒"
+
+msgid "✓ Static rules work ✗ Dynamic rules may not work"
+msgstr "✓ 静态规则有效 ✗ 动态规则可能无效"
+
diff --git a/luci-theme-footstrap/styles/pages/20-overview.css b/luci-theme-footstrap/styles/pages/20-overview.css
index bdc95c941..959a8d863 100644
--- a/luci-theme-footstrap/styles/pages/20-overview.css
+++ b/luci-theme-footstrap/styles/pages/20-overview.css
@@ -161,9 +161,11 @@
text-align: end !important;
}
- /* meter rows — any stock .table row with a .cbi-progressbar. docs/css.md above. */
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)):has(.cbi-progressbar) { display: block; }
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) {
+ /* meter rows — a key/value-shaped .table row with a .cbi-progressbar (same shape test as
+ * :218 below and 30-tables.css's key/value rule); a data table's own bar stays untouched.
+ * docs/css.md above. */
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))):has(.cbi-progressbar) { display: block; }
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--fs-space-1) var(--fs-space-2-5);
@@ -172,8 +174,8 @@
border: 0;
position: relative;
}
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td {
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar):last-child { padding-bottom: var(--fs-space-0-5); }
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td {
display: block;
/* beats base's .td[width="33%"] with no flag: that sets a width ATTRIBUTE, a later layer wins */
width: auto;
@@ -182,7 +184,7 @@
}
/* label — undoes key/value nowrap: a mount point is one unbreakable line past the card.
* padding-inline-end is the gutter for the out-of-flow value (::after over the bar's edge). */
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
grid-column: 1; grid-row: 1;
align-self: center;
min-width: 0;
@@ -191,32 +193,40 @@
font-size: var(--fs-type);
}
+ /* value rides the label's own line at desktop (::after floats over the row's top edge) —
+ * opt out of the bare-meter default reserve. <=560px below restores it (own line, no room). */
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
+ margin-block-start: 0;
+ }
+
/* narrow card: the value takes its own line, the reserve above cannot fit it — docs/css.md
* above has the 218px/190px measurement. */
@container fs-view (max-width: 560px) {
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:first-child {
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:first-child {
padding-inline-end: 0;
}
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .cbi-progressbar {
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .cbi-progressbar {
margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
}
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr:has(.cbi-progressbar) .td:nth-child(2) {
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr:has(.cbi-progressbar) .td:nth-child(2) {
grid-column: 1 / -1; grid-row: 2;
}
- /* per-row dividers dropped, keyed on table SHAPE (no header row) not on the three named cards —
- * docs/css.md above. A DATA table (.tr.table-titles present) keeps its dividers. */
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td,
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .th,
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .tr { border-bottom: 0; }
+ /* per-row dividers dropped, keyed on table SHAPE (no .th, no 3rd .td — same test as
+ * 30-tables.css's key/value rule, docs/css.md "The key/value discriminator") not on the
+ * three named cards. attendedsysupgrade's 11_upgrades.js (a stock header row) keeps its
+ * dividers under this test. */
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td,
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .th,
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .tr { border-bottom: 0; }
/* own rule, not a 4th line above: .cbi-value carries no :has(), and a selector list is not
* forgiving below the browser floor (npm run css-floor) */
:where(#view)[data-page="admin-status-overview"] .cbi-section .cbi-value { border-bottom: 0; }
/* key/value name is the QUIET half: --fs-dim at normal weight, matching what the System table's
- * first column already resolved to. Data tables excluded by the same header-row test above. */
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .td:first-child {
+ * first column already resolved to. Data tables excluded by the same shape test above. */
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .td:first-child {
font-weight: var(--fs-weight-normal);
color: var(--fs-dim);
}
@@ -240,16 +250,19 @@
/* progressbar's attr(title) was --fs-type-xs/--fs-dim beside a now-demoted label; give Memory's
* bars something at full strength. Overview only — on Software the value sits beside its own
* field label. */
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.tr.table-titles)) .cbi-progressbar::after {
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:not(:has(.th)):not(:has(.td:nth-child(3))) .cbi-progressbar::after {
font-size: var(--fs-type);
color: var(--fs-text);
}
- /* row height in DATA tables (DHCP leases, station list): theme/30-tables.css pads for a page
- * that IS a table, here it's a card read at a glance — 8 leases measured 740px against a 440px
- * System card. One step down the space ladder, only here. */
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .td,
- :where(#view)[data-page="admin-status-overview"] .cbi-section .table:has(.tr.table-titles) .th {
+ /* row height in DATA tables (DHCP leases, station list, attendedsysupgrade's 11_upgrades.js):
+ * theme/30-tables.css pads for a page that IS a table, here it's a card read at a glance — 8
+ * leases measured 740px against a 440px System card. Positive test is the complement of the
+ * key/value shape above (:has(.th) or a 3rd .td), not .tr.table-titles — a stock header .th
+ * emitted through initFromMarkup never carries that class. One step down the space ladder,
+ * only here. */
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .td,
+ :where(#view)[data-page="admin-status-overview"] .cbi-section .table:is(:has(.th), :has(.td:nth-child(3))) .th {
padding-block: var(--fs-space-1-5);
}
diff --git a/luci-theme-footstrap/styles/pages/30-software.css b/luci-theme-footstrap/styles/pages/30-software.css
index 134e89090..bfe01b060 100644
--- a/luci-theme-footstrap/styles/pages/30-software.css
+++ b/luci-theme-footstrap/styles/pages/30-software.css
@@ -31,4 +31,9 @@
:where(#view)[data-page="admin-system-package-manager"] label:has(+ .cbi-progressbar) {
display: block; margin: 0; font-size: var(--fs-type); font-weight: var(--fs-weight); color: var(--fs-dim);
}
+ /* value rides the "Disk space:" label's line at both widths — one unconditional opt-out of
+ * the bare-meter reserve; theme/90-responsive.css's phone rule only adds margin-bottom. */
+ :where(#view)[data-page="admin-system-package-manager"] label + .cbi-progressbar {
+ margin-block-start: 0;
+ }
}
diff --git a/luci-theme-footstrap/styles/theme/25-progressbar.css b/luci-theme-footstrap/styles/theme/25-progressbar.css
index 80c76486e..d46843b79 100644
--- a/luci-theme-footstrap/styles/theme/25-progressbar.css
+++ b/luci-theme-footstrap/styles/theme/25-progressbar.css
@@ -1,6 +1,10 @@
@layer theme {
/* One thin meter for every .cbi-progressbar: track + accent fill, title value floated above
- * the right edge instead of stuffed inside a fat pill. */
+ * the right edge instead of stuffed inside a fat pill. Default reserves that value's own line
+ * in flow (bootstrap's margin:1.4em model) — an abspos value with zero reserve spilled 5-6px
+ * UP across the divider of the row above, off the overview page (key/value .table, no header
+ * row). Contexts that put the value BESIDE the bar or on a neighbour's line opt out with
+ * margin-block-start: 0. */
.cbi-progressbar {
position: relative;
height: 10px;
@@ -10,6 +14,7 @@
overflow: visible;
min-width: 120px;
margin: 0;
+ margin-block-start: calc(var(--fs-type-xs) * var(--fs-leading));
}
.cbi-progressbar > div {
height: 100%;
@@ -39,6 +44,7 @@
.cbi-value-field > .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
+ margin-block-start: 0;
/* @endmirror */
--fs-meter-value: calc(var(--fs-space-10) * 2);
}
@@ -51,6 +57,7 @@
.cbi-section .table:has(.tr.table-titles) .td .cbi-progressbar {
/* @mirror meter/beside */
margin-inline-end: var(--fs-meter-value);
+ margin-block-start: 0;
/* @endmirror */
--fs-meter-value: var(--fs-space-10);
min-width: 60px; /* narrower and it stops reading as a meter; the column gives up the rest */
diff --git a/luci-theme-footstrap/styles/theme/30-tables.css b/luci-theme-footstrap/styles/theme/30-tables.css
index 19d92ee60..188be040e 100644
--- a/luci-theme-footstrap/styles/theme/30-tables.css
+++ b/luci-theme-footstrap/styles/theme/30-tables.css
@@ -151,34 +151,36 @@
}
/* --- KEY/VALUE: "no id" is not enough — GridSection also renders no-id .cbi-section-table,
- * excluded along with .fs-dt and the legend's inline table-layout. */
- .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child {
+ * excluded along with .fs-dt and the legend's inline table-layout. Shape, not a class, says
+ * data table: any .th or a 3rd .td rules a table out — a real key/value row is exactly 2 .td
+ * and no .th. docs/css.md, "The key/value discriminator". */
+ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child {
color: var(--fs-dim); font-weight: var(--fs-weight); width: 40%; white-space: nowrap;
}
- .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:not(:first-child) {
+ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:not(:first-child) {
font-family: var(--fs-font-mono); color: var(--fs-text); text-align: end;
}
/* below ~560px the 40%/nowrap column starves the value — stacked below (560, not the config
* table's 960px). :first-child:first-child doubles the pseudo-class ON PURPOSE, to outrank. */
@container fs-content (max-width: 560px) {
/* the row's own .td/.th padding above spent a FULL padding per half; moved to the ROW */
- .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr {
+ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr {
display: block;
padding: var(--fs-space-1) 0 var(--fs-space-1-5);
border: 0;
border-bottom: var(--fs-hairline);
}
- .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .tr:last-child {
+ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .tr:last-child {
border-bottom: 0;
}
- .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:first-child:first-child {
+ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:first-child:first-child {
display: block; width: auto; white-space: normal;
padding: 0; border: 0;
font-size: var(--fs-type-xs); font-weight: var(--fs-eyebrow-weight);
letter-spacing: var(--fs-eyebrow-tracking); color: var(--fs-eyebrow-color);
text-transform: uppercase;
}
- .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.tr.table-titles)):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
+ .cbi-section .table:not([id]):not(.cbi-section-table):not(.fs-dt):not(:has(.th)):not(:has(.td:nth-child(3))):not([style*="table-layout"]) .td:last-child:last-child:not(:first-child) {
display: block; width: auto; text-align: start;
padding: 0; border: 0;
}
diff --git a/wwand-ipa/Makefile b/wwand-ipa/Makefile
index 80749f6c5..ffa5be8a2 100644
--- a/wwand-ipa/Makefile
+++ b/wwand-ipa/Makefile
@@ -13,12 +13,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand-ipa
-PKG_VERSION:=0.1.0
-PKG_RELEASE:=1
+PKG_VERSION:=0.1.0_p1
+PKG_RELEASE:=2
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand-ipa.git
-PKG_SOURCE_VERSION:=fc0fad051213f23d10864f26ebc7205ae5c0a451
+PKG_SOURCE_VERSION:=f84625427503041472ba10f32df4969091a86f17
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only
@@ -34,21 +34,24 @@ define Package/wwand-ipa
CATEGORY:=Network
SUBMENU:=WWAN
TITLE:=eSIM fleet management (SGP.32 eIM) for wwand
- # wwand-esim: its bridge carries the assistant's APDUs; wwand-ipad: the
- # assistant itself (onomondo-ipa with the stdio card backend)
+ # wwand-esim: its bridge carries the assistant's APDUs and runs direct
+ # downloads; wwand-ipad: the assistant itself (ipad, SGP.32 v1.3)
DEPENDS:=+wwand-esim +wwand-ipad
PKGARCH:=all
endef
define Package/wwand-ipa/description
- Lets an eIM manage the modem's eSIM: wwand polls it (after the connection
- comes up, then every ipa_interval seconds) with the onomondo-ipa assistant,
- relays the assistant's card traffic over the modem's APDU channel, and after
- a profile change resets the SIM (or the modem) and waits for the session on
- the new profile before the result is reported. Runs against an SGP.22
- consumer eUICC (the assistant's IoT eUICC emulation); the eIM must accept
- that mode. Enable per modem with `wwandctl ipa [modem] eim `. While
- enabled, manual profile changes on that card are refused unless forced.
+ Lets an eIM manage the modem's eSIM (GSMA SGP.32): wwand polls it (after
+ the connection comes up, then every ipa_interval seconds) with the ipad
+ assistant, relays the assistant's card traffic over the modem's APDU
+ channel, resets the SIM (or the modem) after a profile change and waits for
+ the session on the new profile, runs direct downloads through lpac, and
+ writes the enabled profile's APN into a wwand_sim section for its ICCID.
+ Drives an IoT eUICC, or an SGP.22 consumer eUICC through ipad's emulation,
+ whose results are signed with a device key the eIM imports
+ (`wwandctl ipa export`). Enable per modem with `wwandctl ipa [modem] eim
+ `. While enabled, manual profile changes on that card are refused
+ unless forced.
endef
define Package/luci-app-wwand-ipa
diff --git a/wwand-ipad/Makefile b/wwand-ipad/Makefile
index c7191dc70..77fa5b692 100644
--- a/wwand-ipad/Makefile
+++ b/wwand-ipad/Makefile
@@ -1,154 +1,86 @@
-# onomondo-ipa, the GSMA SGP.32 IoT Profile Assistant, built for wwand-ipa.
+# ipad, the GSMA SGP.32 v1.3 IoT Profile Assistant, built for wwand-ipa.
#
-# The assistant polls an eIM (the fleet server) for eUICC packages and runs
-# them on the card. Upstream reaches the card through PC/SC; patch 100 adds a
-# card backend that speaks lpac's stdio APDU protocol instead, so wwand's
-# esim_bridge relays its APDUs over the modem's own channel (QMI UIM / MBIM
-# UICC / AT) exactly as it does for lpac, and adds -H: a profile change is
-# handed to the host, which resets the SIM and waits for the new session. The
-# patch carries a test for the card backend (tests/scard_stdio). The package
-# build does not run it (BUILD_TESTING=OFF, target ipa only); on a host:
-# cmake -S . -B build -DIPA_SCARD_STDIO=ON && cmake --build build && ctest --test-dir build
-# The -H wiring in main.c has no test of its own: exercising it takes an eIM
-# that sends a profile change.
+# It polls an eIM (the fleet server) for eUICC packages and runs them on the
+# card, which it reaches through lpac's stdio APDU protocol: wwand's
+# esim_bridge relays those APDUs over the modem's own channel (QMI UIM / MBIM
+# UICC / AT), exactly as for lpac. An IoT eUICC is driven as it is; an SGP.22
+# consumer eUICC through ipad's emulation of the SGP.32 functions, with its
+# results signed by a device key the eIM imports (`wwandctl ipa export`).
#
-# TLS: the same minimal static wolfSSL + libcurl bundle as wwand-lpac, for the
-# same reason (OpenWrt's mbedtls lacks curves the GSMA PKI uses, and
-# libcurl+OpenSSL is ~6.4 MB). The eIM is reached over HTTPS with the system
-# CA bundle unless the eIM configuration says otherwise.
-#
-# Upstream implements SGP.32 v1.0; wwand always runs it in IoT eUICC emulation
-# (-E), i.e. against an SGP.22 consumer eUICC. See wwand's src-ucode/ipa.uc.
+# mbedTLS 3.6 LTS is built in statically: RSP needs brainpoolP256r1, which
+# OpenWrt's shared mbedtls does not enable by default. The project's tests
+# (unit, a scripted eIM, the real binary over the stdio protocol) are not run
+# by the package build; see the ipad README.
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand-ipad
-PKG_VERSION:=0.0.1_git20260901
-PKG_RELEASE:=1
+PKG_VERSION:=0.1.0
+PKG_RELEASE:=2
PKG_SOURCE_PROTO:=git
-PKG_SOURCE_URL:=https://github.com/onomondo/onomondo-ipa.git
-PKG_SOURCE_VERSION:=6aaeb38a569e648c8305aa5bd4d300cc1ea6822a
+PKG_SOURCE_URL:=https://github.com/ddimension/ipad.git
+PKG_SOURCE_VERSION:=fbb175f4d272f0db45b3bc006392f172cd248aa5
PKG_MIRROR_HASH:=skip
-PKG_LICENSE:=AGPL-3.0-only curl GPL-3.0-or-later
+PKG_LICENSE:=GPL-2.0-only Apache-2.0
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=
-# bundled, statically-linked dependencies (keep in step with wwand-lpac)
-WOLFSSL_VERSION:=5.9.2-stable
-CURL_VERSION:=8.21.0
+MBEDTLS_VERSION:=3.6.7
PKG_BUILD_PARALLEL:=1
-
-# wolfSSL's SP math carries inline assembly that MIPS16 cannot encode
-PKG_BUILD_FLAGS:=no-mips16
+CMAKE_BINARY_SUBDIR:=openwrt-build
include $(INCLUDE_DIR)/package.mk
+include $(INCLUDE_DIR)/cmake.mk
-define Download/wolfssl
- FILE:=wolfssl-$(WOLFSSL_VERSION).tar.gz
- URL:=https://github.com/wolfSSL/wolfssl/archive/v$(WOLFSSL_VERSION)
+# the release tarball; its SHA-256 is the one mbedtls-3.6.7-sha256sum.txt on
+# the release page publishes
+define Download/mbedtls
+ FILE:=mbedtls-$(MBEDTLS_VERSION).tar.bz2
+ URL:=https://github.com/Mbed-TLS/mbedtls/releases/download/mbedtls-$(MBEDTLS_VERSION)
HASH:=skip
endef
-$(eval $(call Download,wolfssl))
+$(eval $(call Download,mbedtls))
-define Download/curl
- FILE:=curl-$(CURL_VERSION).tar.xz
- URL:=https://github.com/curl/curl/releases/download/curl-8_21_0
- HASH:=skip
+define Build/Prepare
+ $(call Build/Prepare/Default)
+ $(TAR) -C $(PKG_BUILD_DIR) -xjf $(DL_DIR)/mbedtls-$(MBEDTLS_VERSION).tar.bz2
endef
-$(eval $(call Download,curl))
+
+CMAKE_OPTIONS += \
+ -DIPAD_MBEDTLS_DIR=$(PKG_BUILD_DIR)/mbedtls-$(MBEDTLS_VERSION) \
+ -DIPAD_WERROR=OFF \
+ -DBUILD_TESTING=OFF
+
+TARGET_CFLAGS += -ffunction-sections -fdata-sections
+TARGET_LDFLAGS += -Wl,--gc-sections
define Package/wwand-ipad
SECTION:=net
CATEGORY:=Network
SUBMENU:=WWAN
- TITLE:=SGP.32 IoT Profile Assistant (onomondo-ipa) for wwand-ipa
- URL:=https://github.com/onomondo/onomondo-ipa
- # the eIM is an HTTPS server with an ordinary certificate chain
+ TITLE:=SGP.32 v1.3 IoT Profile Assistant (ipad) for wwand-ipa
+ URL:=https://github.com/ddimension/ipad
+ # the eIM is an HTTPS server; without a pin in its configuration the
+ # system CAs decide
DEPENDS:=+ca-bundle
endef
define Package/wwand-ipad/description
- onomondo-ipa, the GSMA SGP.32 IoT Profile Assistant, built with a card
- backend that reaches the eUICC through wwand (lpac's stdio APDU protocol)
- instead of PC/SC, and with a minimal static wolfSSL + libcurl linked in.
- Installed as /usr/lib/wwand/ipad; wwand-ipa runs it. Implements SGP.32 v1.0
- and is run in IoT eUICC emulation against an SGP.22 consumer eUICC.
-endef
-
-PREFIX:=$(PKG_BUILD_DIR)/.bundle
-WOLFSSL_DIR:=$(PKG_BUILD_DIR)/wolfssl-$(WOLFSSL_VERSION)
-CURL_DIR:=$(PKG_BUILD_DIR)/curl-$(CURL_VERSION)
-IPA_BUILD:=$(PKG_BUILD_DIR)/.ipa-build
-
-LTO_CFLAGS:=-flto=auto -ffat-lto-objects
-BUNDLE_AR:=AR="$(TARGET_CROSS)gcc-ar" RANLIB="$(TARGET_CROSS)gcc-ranlib"
-
-# unpack + patch the assistant, then the bundled wolfSSL + curl beside it
-define Build/Prepare
- $(call Build/Prepare/Default)
- $(TAR) -C $(PKG_BUILD_DIR) -xzf $(DL_DIR)/wolfssl-$(WOLFSSL_VERSION).tar.gz
- $(TAR) -C $(PKG_BUILD_DIR) -xJf $(DL_DIR)/curl-$(CURL_VERSION).tar.xz
-endef
-
-Build/Configure:=
-
-define Build/Compile
- # 1) minimal static wolfSSL (the wwand-lpac recipe)
- ( cd $(WOLFSSL_DIR) && ./autogen.sh )
- ( cd $(WOLFSSL_DIR) && ./configure --host=$(GNU_TARGET_NAME) --prefix=$(PREFIX) \
- --enable-static --disable-shared \
- --enable-curl --enable-tls13 --enable-brainpool --enable-ecccustcurves \
- --enable-curve25519 --disable-dtls --disable-oldtls \
- --disable-examples --disable-crypttests \
- CC="$(TARGET_CC)" $(BUNDLE_AR) \
- CFLAGS="$(TARGET_CFLAGS) $(LTO_CFLAGS) -ffunction-sections -fdata-sections -DWOLFSSL_NO_ASN_STRICT" )
- +$(MAKE) -C $(WOLFSSL_DIR)
- $(MAKE) -C $(WOLFSSL_DIR) install
-
- # 2) minimal static libcurl (HTTP/HTTPS only) against it. HTTP stays: an
- # eIM configuration may name a plain-HTTP lab eIM (the assistant's -S).
- ( cd $(CURL_DIR) && \
- PKG_CONFIG_PATH="$(PREFIX)/lib/pkgconfig" PKG_CONFIG_LIBDIR="$(PREFIX)/lib/pkgconfig" \
- ./configure --host=$(GNU_TARGET_NAME) --prefix=$(PREFIX) --with-wolfssl=$(PREFIX) \
- --enable-static --disable-shared \
- --disable-ftp --disable-file --disable-ldap --disable-ldaps --disable-rtsp \
- --disable-dict --disable-telnet --disable-tftp --disable-pop3 --disable-imap \
- --disable-smtp --disable-gopher --disable-mqtt --disable-smb --disable-ntlm \
- --disable-manual --disable-unix-sockets --disable-progress-meter \
- --without-libpsl --without-zlib --without-nghttp2 --without-brotli \
- --without-zstd --without-libidn2 --without-librtmp --without-ca-path \
- --with-ca-bundle=/etc/ssl/certs/ca-certificates.crt \
- CC="$(TARGET_CC)" $(BUNDLE_AR) \
- CFLAGS="$(TARGET_CFLAGS) $(LTO_CFLAGS) -ffunction-sections -fdata-sections -DSIZEOF_LONG_LONG=8" \
- CPPFLAGS="-I$(PREFIX)/include" LDFLAGS="-L$(PREFIX)/lib" LIBS="-lm" )
- +$(MAKE) -C $(CURL_DIR)
- $(MAKE) -C $(CURL_DIR) install
-
- # 3) the assistant with the stdio card backend, statically against both.
- # AR/RANLIB go in as full paths: CMake took the bare name as a path relative
- # to its build directory and wrote `.ipa-build/aarch64-openwrt-linux-musl-gcc-ar`
- # into libasn's link.txt, which failed with "Error running link command: no
- # such file or directory" (SDK snapshot, aarch64, 2026-09-26).
- rm -rf $(IPA_BUILD)
- mkdir -p $(IPA_BUILD)
- ( cd $(IPA_BUILD) && cmake $(PKG_BUILD_DIR) \
- -DCMAKE_SYSTEM_NAME=Linux -DCMAKE_SYSTEM_PROCESSOR=$(ARCH) \
- -DCMAKE_C_COMPILER="$(TARGET_CROSS)gcc" \
- -DCMAKE_AR="$(TOOLCHAIN_DIR)/bin/$(TARGET_CROSS)gcc-ar" \
- -DCMAKE_RANLIB="$(TOOLCHAIN_DIR)/bin/$(TARGET_CROSS)gcc-ranlib" \
- -DCMAKE_C_FLAGS="$(TARGET_CFLAGS) $(LTO_CFLAGS) -ffunction-sections -fdata-sections -I$(PREFIX)/include" \
- -DCMAKE_EXE_LINKER_FLAGS="$(TARGET_LDFLAGS) $(LTO_CFLAGS) -Wl,--gc-sections -L$(PREFIX)/lib" \
- -DCMAKE_C_STANDARD_LIBRARIES="-L$(PREFIX)/lib -lwolfssl -lm" \
- -DIPA_SCARD_STDIO=ON -DBUILD_TESTING=OFF )
- +$(MAKE) -C $(IPA_BUILD) ipa
+ ipad, a GSMA SGP.32 v1.3 IoT Profile Assistant: eIM packages (every PSMO
+ and eCO), profile rollback, IPA/eUICC data, direct and indirect profile
+ download, notifications and the connectivity parameters of the enabled
+ profile. Drives an IoT eUICC, or an SGP.22 consumer eUICC through an
+ emulation that signs its results with a device key the eIM imports.
+ Reaches the card through wwand (lpac's stdio APDU protocol); mbedTLS
+ linked in statically. Installed as /usr/lib/wwand/ipad; wwand-ipa runs it.
endef
define Package/wwand-ipad/install
$(INSTALL_DIR) $(1)/usr/lib/wwand
- $(INSTALL_BIN) $(IPA_BUILD)/src/ipa/ipa $(1)/usr/lib/wwand/ipad
+ $(INSTALL_BIN) $(PKG_BUILD_DIR)/$(CMAKE_BINARY_SUBDIR)/ipad $(1)/usr/lib/wwand/ipad
endef
$(eval $(call BuildPackage,wwand-ipad))
diff --git a/wwand-ipad/patches/100-scard-stdio.patch b/wwand-ipad/patches/100-scard-stdio.patch
deleted file mode 100644
index 36c2e958d..000000000
--- a/wwand-ipad/patches/100-scard-stdio.patch
+++ /dev/null
@@ -1,833 +0,0 @@
-diff --git a/include/onomondo/ipa/scard_stdio.h b/include/onomondo/ipa/scard_stdio.h
-new file mode 100644
-index 0000000..fa9ec54
---- /dev/null
-+++ b/include/onomondo/ipa/scard_stdio.h
-@@ -0,0 +1,13 @@
-+/*
-+ * Copyright (c) 2026 André Valentin
-+ *
-+ * SPDX-License-Identifier: AGPL-3.0-only
-+ */
-+
-+#pragma once
-+
-+/*! Hand a profile change to the host and wait until it has restored the data
-+ * connection, then re-open the card channel the host's SIM reset closed.
-+ * \returns 1 when the host reports the connection back, 0 when it gave up
-+ * waiting, negative when the host is gone. */
-+int ipa_scard_stdio_await_online(void);
-diff --git a/src/ipa/CMakeLists.txt b/src/ipa/CMakeLists.txt
-index 2cf4a2b..b4ee0e5 100644
---- a/src/ipa/CMakeLists.txt
-+++ b/src/ipa/CMakeLists.txt
-@@ -5,7 +5,15 @@ add_subdirectory(libasn)
- add_subdirectory(libipa)
-
- add_library(http STATIC http.c)
--add_library(scard STATIC scard.c)
-+option(IPA_SCARD_STDIO "Reach the eUICC through a host process (lpac stdio protocol) instead of PC/SC" OFF)
-+if (IPA_SCARD_STDIO)
-+ add_library(scard STATIC scard_stdio.c)
-+ target_compile_definitions(scard PUBLIC IPA_SCARD_STDIO)
-+ set(IPA_SCARD_LIBS "")
-+else()
-+ add_library(scard STATIC scard.c)
-+ set(IPA_SCARD_LIBS pcsclite)
-+endif()
-
- target_include_directories(http PUBLIC ${CMAKE_SOURCE_DIR}/include)
- target_include_directories(scard PUBLIC ${CMAKE_SOURCE_DIR}/include)
-@@ -16,7 +24,7 @@ set_property(TARGET ipa PROPERTY C_STANDARD 99)
- target_compile_options(ipa PRIVATE -Wall)
-
- target_include_directories(ipa PUBLIC ${CMAKE_SOURCE_DIR}/include)
--target_link_libraries(ipa libipa http scard pcsclite curl)
-+target_link_libraries(ipa libipa http scard ${IPA_SCARD_LIBS} curl)
-
- if (M32)
- set_target_properties(http PROPERTIES COMPILE_FLAGS "-m32" LINK_FLAGS "-m32")
-diff --git a/src/ipa/main.c b/src/ipa/main.c
-index d4316f2..5dfa703 100644
---- a/src/ipa/main.c
-+++ b/src/ipa/main.c
-@@ -17,6 +17,9 @@
- #include
- #include
- #include
-+#ifdef IPA_SCARD_STDIO
-+#include
-+#endif
-
- #define DEFAULT_READER_NUMBER 0
- #define DEFAULT_CHANNEL_NUMBER 1
-@@ -58,6 +61,9 @@ static void print_help(void)
- printf(" -I .................. disable SSL certificate verification (insecure)\n");
- printf(" -E .................. emulate IoT eUICC (compatibility mode to use consumer eUICCs)\n");
- printf(" -1 .................. force the IPAd to process only one eUICC package (debug, use with caution)\n");
-+#ifdef IPA_SCARD_STDIO
-+ printf(" -H .................. hand profile changes to the host and wait for it to restore connectivity\n");
-+#endif
- }
-
- struct ipa_buf *load_ber_from_file(char *dir, char *file)
-@@ -134,6 +140,22 @@ static void sig_usr1(int signum)
- running = false;
- }
-
-+#ifdef IPA_SCARD_STDIO
-+/* The host ends a run it has given up on with SIGTERM. Without a handler that
-+ * kills the process before ipa_free_ctx() hands back the nvstate, and in IoT
-+ * eUICC emulation the nvstate IS the eIM configuration and the replay counter.
-+ * No SA_RESTART: a read blocked on the host must return, so the card layer
-+ * fails the current exchange and main() reaches the save below. */
-+static void install_sigterm(void)
-+{
-+ struct sigaction sa = { 0 };
-+
-+ sa.sa_handler = sig_usr1;
-+ sigemptyset(&sa.sa_mask);
-+ sigaction(SIGTERM, &sa, NULL);
-+}
-+#endif
-+
- int main(int argc, char **argv)
- {
- struct ipa_config cfg = { 0 };
-@@ -146,8 +168,12 @@ int main(int argc, char **argv)
- struct ipa_buf *nvstate_load = NULL;
- struct ipa_buf *nvstate_save = NULL;
- bool getopt_one_euicc_pkg_only = false;
-+ bool getopt_host_online = false;
-
- signal(SIGUSR1, sig_usr1);
-+#ifdef IPA_SCARD_STDIO
-+ install_sigterm();
-+#endif
-
- printf("IPAd!\n");
-
-@@ -159,7 +185,7 @@ int main(int argc, char **argv)
-
- /* Overwrite configuration values with user defined parameters */
- while (1) {
-- opt = getopt(argc, argv, "ht:e:r:c:f:mn:C:SIEy:a1");
-+ opt = getopt(argc, argv, "ht:e:r:c:f:mn:C:SIEy:a1H");
- if (opt == -1)
- break;
-
-@@ -210,6 +236,9 @@ int main(int argc, char **argv)
- case '1':
- getopt_one_euicc_pkg_only = true;
- break;
-+ case 'H':
-+ getopt_host_online = true;
-+ break;
- default:
- printf("unhandled option: %c!\n", opt);
- break;
-@@ -284,6 +313,19 @@ int main(int argc, char **argv)
- case IPA_POLL_AGAIN_WHEN_ONLINE:
- /* ipa_poll asks us to wait with the next poll cycle until we have a stable IP
- * connection. In this example we assume that IP connectivity is always available. */
-+#ifdef IPA_SCARD_STDIO
-+ /* On a router it is not: the modem keeps the old profile until the host resets
-+ * the SIM, and the data session has to come back on the new one. The process
-+ * stays alive meanwhile, because the pending result (and a rollback, should the
-+ * eIM stay unreachable) lives in the context, not in the nvstate. Whatever the
-+ * host answers, the next ipa_poll is the one that decides: it reports to the
-+ * eIM, or rolls the profile back when it cannot. */
-+ if (getopt_host_online && ipa_scard_stdio_await_online() < 0) {
-+ IPA_LOGP(SMAIN, LERROR, "host went away during a profile change\n");
-+ rc = -EIO;
-+ goto leave;
-+ }
-+#endif
- IPA_LOGP(SMAIN, LINFO, "poll cycle continues normally (profile change)\n");
- rc = 0;
- break;
-diff --git a/src/ipa/scard_stdio.c b/src/ipa/scard_stdio.c
-new file mode 100644
-index 0000000..5b58991
---- /dev/null
-+++ b/src/ipa/scard_stdio.c
-@@ -0,0 +1,513 @@
-+/*
-+ * Copyright (c) 2026 André Valentin
-+ *
-+ * SPDX-License-Identifier: AGPL-3.0-only
-+ *
-+ * Smartcard backend for onomondo-ipa that reaches the eUICC through a HOST
-+ * PROCESS instead of PC/SC: every card operation is one JSON line on stdout,
-+ * and the answer is one JSON line on stdin. The wire format is lpac's stdio
-+ * APDU driver (lpac driver/apdu/stdio.c, v2.3.0) byte for byte, so the host
-+ * side that already bridges lpac -- wwand's esim_bridge, which answers from
-+ * the modem's own APDU channel (QMI UIM / MBIM UICC / AT) -- bridges this too:
-+ *
-+ * -> {"type":"apdu","payload":{"func":"logic_channel_open","param":""}}
-+ * <- {"type":"apdu","payload":{"ecode":}}
-+ * -> {"type":"apdu","payload":{"func":"transmit","param":""}}
-+ * <- {"type":"apdu","payload":{"ecode":0,"data":""}}
-+ *
-+ * The two sides do not speak the same card dialect, and this file is where
-+ * that is reconciled:
-+ *
-+ * - libipa talks raw T=0 (libipa/euicc.c): TERMINAL CAPABILITY on the basic
-+ * channel, MANAGE CHANNEL to open one, SELECT ISD-R on it and then STORE
-+ * DATA / GET RESPONSE with the channel number in CLA. A modem's APDU
-+ * service does not let the host do any of that directly: QMI UIM opens a
-+ * logical channel BY AID (OPEN_LOGICAL_CHANNEL, which selects it in the
-+ * same step) and addresses the channel by id. So MANAGE CHANNEL is
-+ * answered here, the channel is really opened when the SELECT names the
-+ * AID, and CLA is rewritten to the channel the modem handed out.
-+ * - The modem completes GET RESPONSE itself and returns the whole answer with
-+ * 9000 (the QMI UIM long-response path in wwand's sim.uc reassembles even
-+ * the chunked ones). libipa insists on seeing 61xx after the last STORE
-+ * DATA block and fetching with GET RESPONSE, and it rejects a block whose
-+ * length differs from what 61xx announced (euicc.c recv_es10x_block). So a
-+ * response with data is held here and handed out again in 61xx/GET
-+ * RESPONSE portions of exactly the announced size.
-+ *
-+ * Logging goes to stderr (libipa's log.c already writes there), so stdout
-+ * carries only the protocol. A host may still merge the two, as wwand does
-+ * (2>&1, into its syslog): every log line starts with the space-padded
-+ * subsystem ("%8s %8s ", log.c:69), never with the '{' a protocol line starts
-+ * with, so the two stay apart.
-+ */
-+
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+
-+/* ES10 answers (a profile list, certificates) reach several kB; one line
-+ * carries the hex of the largest plus the JSON around it */
-+#define RX_MAX_LEN 32768
-+#define LINE_MAX_LEN (2 * RX_MAX_LEN + 128)
-+#define AID_MAX_LEN 16
-+
-+struct scard_ctx {
-+ /* the AID libipa selected, kept so the channel can be re-opened after
-+ * the host has reset the card (see ipa_scard_stdio_await_online) */
-+ uint8_t aid[AID_MAX_LEN];
-+ size_t aid_len;
-+
-+ /* the channel the modem handed out; -1 while none is open */
-+ int channel;
-+
-+ /* a card answer being handed out in GET RESPONSE portions */
-+ uint8_t *pending;
-+ size_t pending_len;
-+ size_t pending_off;
-+};
-+
-+/* One process drives one card, and the connectivity hook below is called from
-+ * main.c, which only holds libipa's opaque context. */
-+static struct scard_ctx *g_ctx;
-+
-+/* Set once a read from the host failed (EOF, or a SIGTERM interrupting it).
-+ * Every later exchange fails at once instead of blocking again: the shutdown
-+ * path still closes the channel and disconnects, and waiting on a host that
-+ * has gone would keep main() from ever saving the nvstate. */
-+static bool host_gone;
-+
-+static const char hexdigits[] = "0123456789ABCDEF";
-+
-+static char *to_hex(const uint8_t *data, size_t len)
-+{
-+ char *s = malloc(len * 2 + 1);
-+ size_t i;
-+
-+ assert(s);
-+ for (i = 0; i < len; i++) {
-+ s[i * 2] = hexdigits[data[i] >> 4];
-+ s[i * 2 + 1] = hexdigits[data[i] & 0x0F];
-+ }
-+ s[len * 2] = '\0';
-+ return s;
-+}
-+
-+static int hexval(char c)
-+{
-+ if (c >= '0' && c <= '9')
-+ return c - '0';
-+ if (c >= 'a' && c <= 'f')
-+ return c - 'a' + 10;
-+ if (c >= 'A' && c <= 'F')
-+ return c - 'A' + 10;
-+ return -1;
-+}
-+
-+/* Read one line from stdin. Returns false on EOF: the host is gone, and every
-+ * caller turns that into a card error rather than waiting forever. */
-+static bool read_line(char *buf, size_t size)
-+{
-+ size_t n;
-+
-+ if (host_gone)
-+ return false;
-+
-+ if (!fgets(buf, size, stdin)) {
-+ host_gone = true;
-+ return false;
-+ }
-+
-+ n = strlen(buf);
-+
-+ /* A line longer than the buffer: the rest would be read as the NEXT
-+ * answer and every later exchange would be off by one. Drain it and
-+ * report the round as failed instead. */
-+ if (n == size - 1 && buf[n - 1] != '\n') {
-+ int c;
-+
-+ while ((c = getchar()) != EOF && c != '\n')
-+ ;
-+ buf[0] = '\0';
-+ IPA_LOGP(SSCARD, LERROR, "stdio: answer longer than %zu bytes dropped\n", size);
-+ return true;
-+ }
-+ while (n && (buf[n - 1] == '\n' || buf[n - 1] == '\r'))
-+ buf[--n] = '\0';
-+
-+ return true;
-+}
-+
-+/* The host's answers are produced by wwand (a fixed sprintf) or lpac-compatible
-+ * bridges, so a field lookup is enough; a full JSON parser would be the only
-+ * dependency this binary had besides libcurl. */
-+static const char *field(const char *line, const char *name)
-+{
-+ char key[32];
-+ const char *p;
-+
-+ snprintf(key, sizeof(key), "\"%s\"", name);
-+ p = strstr(line, key);
-+ if (!p)
-+ return NULL;
-+ p += strlen(key);
-+ while (*p == ' ' || *p == ':')
-+ p++;
-+ return p;
-+}
-+
-+/* One request/answer round. `data` receives the decoded "data" field (may be
-+ * empty); returns the ecode, or -1 on a protocol failure. */
-+static int roundtrip(const char *func, const uint8_t *param, size_t param_len, struct ipa_buf *data)
-+{
-+ static char line[LINE_MAX_LEN];
-+ char *hex = param_len ? to_hex(param, param_len) : NULL;
-+ const char *p;
-+ int ecode;
-+
-+ if (host_gone) {
-+ free(hex);
-+ return -1;
-+ }
-+
-+ fflush(stdout);
-+ printf("{\"type\":\"apdu\",\"payload\":{\"func\":\"%s\",\"param\":%s%s%s}}\n",
-+ func, hex ? "\"" : "", hex ? hex : "null", hex ? "\"" : "");
-+ fflush(stdout);
-+ free(hex);
-+
-+ if (!read_line(line, sizeof(line))) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: host closed the channel during %s\n", func);
-+ return -1;
-+ }
-+
-+ p = field(line, "ecode");
-+ if (!p) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: malformed answer to %s: %s\n", func, line);
-+ return -1;
-+ }
-+ ecode = atoi(p);
-+
-+ if (data) {
-+ data->len = 0;
-+ p = field(line, "data");
-+ if (p && *p == '"') {
-+ p++;
-+ while (hexval(p[0]) >= 0 && hexval(p[1]) >= 0) {
-+ if (data->len >= data->data_len) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: answer to %s exceeds %zu bytes\n", func,
-+ data->data_len);
-+ return -1;
-+ }
-+ data->data[data->len++] = (hexval(p[0]) << 4) | hexval(p[1]);
-+ p += 2;
-+ }
-+ }
-+ }
-+
-+ return ecode;
-+}
-+
-+/* CLA with the channel number in it (ISO/IEC 7816-4 5.4.1). Channels 0-3 sit
-+ * in bits 1-2 of the first interindustry coding, 4-19 need the further
-+ * coding: bit 7 set and channel-4 in bits 1-4. Bit 8 (the proprietary class
-+ * STORE DATA uses) is kept either way. libipa sends no secure messaging, so
-+ * the SM bits are left clear. lpac writes the low nibble instead
-+ * (euicc/interface.c, v2.3.0), which is the same for 0-3 and wrong from 4 on;
-+ * a modem is free to hand out a channel above 3. */
-+static uint8_t cla_for_channel(uint8_t cla, int channel)
-+{
-+ if (channel <= 3)
-+ return (cla & 0x80) | (uint8_t) channel;
-+ return (cla & 0x80) | 0x40 | (uint8_t) (channel - 4);
-+}
-+
-+static void drop_pending(struct scard_ctx *ctx)
-+{
-+ free(ctx->pending);
-+ ctx->pending = NULL;
-+ ctx->pending_len = 0;
-+ ctx->pending_off = 0;
-+}
-+
-+static void put_sw(struct ipa_buf *res, uint16_t sw)
-+{
-+ res->data[res->len++] = sw >> 8;
-+ res->data[res->len++] = sw & 0xFF;
-+}
-+
-+/* 61xx for what is still held: xx is the size of the next portion, 00 meaning
-+ * 256 (ISO/IEC 7816-4 and ETSI TS 102 221 10.1.6, which libipa follows). */
-+static uint16_t pending_sw(const struct scard_ctx *ctx)
-+{
-+ size_t left = ctx->pending_len - ctx->pending_off;
-+
-+ if (!left)
-+ return 0x9000;
-+ return 0x6100 | (left >= 256 ? 0 : (uint8_t) left);
-+}
-+
-+static void close_channel(struct scard_ctx *ctx)
-+{
-+ uint8_t ch;
-+
-+ if (ctx->channel < 0)
-+ return;
-+ ch = (uint8_t) ctx->channel;
-+ roundtrip("logic_channel_close", &ch, 1, NULL);
-+ ctx->channel = -1;
-+}
-+
-+static int open_channel(struct scard_ctx *ctx)
-+{
-+ int ch = roundtrip("logic_channel_open", ctx->aid, ctx->aid_len, NULL);
-+
-+ /* channel 0 is the basic channel, never one the modem hands out for an
-+ * AID; lpac treats it as a failure the same way */
-+ if (ch <= 0) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: could not open a logical channel to %s (ecode %d)\n",
-+ ipa_hexdump(ctx->aid, ctx->aid_len), ch);
-+ ctx->channel = -1;
-+ return -EIO;
-+ }
-+
-+ ctx->channel = ch;
-+ IPA_LOGP(SSCARD, LINFO, "stdio: logical channel %d open to %s\n", ch, ipa_hexdump(ctx->aid, ctx->aid_len));
-+ return 0;
-+}
-+
-+void *ipa_scard_init(unsigned int reader_num)
-+{
-+ struct scard_ctx *ctx;
-+
-+ (void)reader_num; /* the host decides which modem and slot this is */
-+
-+ if (roundtrip("connect", NULL, 0, NULL) != 0) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: the host refused the connection\n");
-+ return NULL;
-+ }
-+
-+ ctx = IPA_ALLOC_ZERO(struct scard_ctx);
-+ assert(ctx);
-+ ctx->channel = -1;
-+ g_ctx = ctx;
-+ return ctx;
-+}
-+
-+int ipa_scard_transceive(void *scard_ctx, struct ipa_buf *res, const struct ipa_buf *req)
-+{
-+ struct scard_ctx *ctx = scard_ctx;
-+ struct ipa_buf *rx = NULL;
-+ uint8_t *tx = NULL;
-+ uint8_t cla, ins, p1;
-+ uint16_t sw;
-+ size_t n;
-+ int rc = 0;
-+
-+ assert(ctx);
-+ assert(res);
-+ assert(req);
-+ res->len = 0;
-+
-+ if (req->len < 4)
-+ return -EINVAL;
-+
-+ cla = req->data[0];
-+ ins = req->data[1];
-+ p1 = req->data[2];
-+
-+ IPA_LOGP(SSCARD, LDEBUG, "stdio TX: %s\n", ipa_buf_hexdump(req));
-+
-+ /* TERMINAL CAPABILITY belongs to the terminal, which is the modem: it
-+ * has sent its own to the card at power-up, and it is not something a
-+ * host can say on the modem's behalf through a logical channel. lpac
-+ * does not send it either. */
-+ if ((cla & 0xF0) == 0x80 && ins == 0xAA) {
-+ put_sw(res, 0x9000);
-+ goto out;
-+ }
-+
-+ /* MANAGE CHANNEL: the modem opens channels by AID, so opening waits for
-+ * the SELECT that names one; closing closes the real channel. */
-+ if (ins == 0x70) {
-+ if (p1 & 0x80)
-+ close_channel(ctx);
-+ put_sw(res, 0x9000);
-+ goto out;
-+ }
-+
-+ /* SELECT by DF name: this is where the channel is really opened. Any
-+ * channel still open is closed first so a re-select does not leak one
-+ * per poll. The select response is not handed on: libipa checks only
-+ * for 61xx here (euicc.c select_isd_r) and never fetches it. */
-+ if (ins == 0xA4 && p1 == 0x04 && req->len >= 5) {
-+ size_t lc = req->data[4];
-+
-+ if (lc == 0 || lc > AID_MAX_LEN || req->len < 5 + lc) {
-+ put_sw(res, 0x6A82);
-+ goto out;
-+ }
-+ close_channel(ctx);
-+ drop_pending(ctx);
-+ memcpy(ctx->aid, req->data + 5, lc);
-+ ctx->aid_len = lc;
-+ put_sw(res, open_channel(ctx) == 0 ? 0x6100 : 0x6A82);
-+ goto out;
-+ }
-+
-+ /* GET RESPONSE for an answer held here: hand out exactly the announced
-+ * portion (Le 00 = 256), then 61xx for the rest or 9000. */
-+ if (ins == 0xC0 && ctx->pending) {
-+ size_t le = req->len >= 5 ? req->data[4] : 0;
-+ size_t left = ctx->pending_len - ctx->pending_off;
-+
-+ if (le == 0)
-+ le = 256;
-+ n = le < left ? le : left;
-+ if (n + 2 > res->data_len)
-+ n = res->data_len - 2;
-+ memcpy(res->data, ctx->pending + ctx->pending_off, n);
-+ res->len = n;
-+ ctx->pending_off += n;
-+ sw = pending_sw(ctx);
-+ if (sw == 0x9000)
-+ drop_pending(ctx);
-+ put_sw(res, sw);
-+ goto out;
-+ }
-+
-+ if (ctx->channel < 0) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: APDU for a card channel that is not open\n");
-+ return -EIO;
-+ }
-+
-+ /* the channel number in CLA is the modem's, not the one libipa chose */
-+ tx = malloc(req->len);
-+ assert(tx);
-+ memcpy(tx, req->data, req->len);
-+ tx[0] = cla_for_channel(tx[0], ctx->channel);
-+
-+ drop_pending(ctx);
-+ rx = ipa_buf_alloc(RX_MAX_LEN);
-+ assert(rx);
-+
-+ if (roundtrip("transmit", tx, req->len, rx) != 0 || rx->len < 2) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: transmit failed\n");
-+ rc = -EIO;
-+ goto free;
-+ }
-+
-+ sw = (rx->data[rx->len - 2] << 8) | rx->data[rx->len - 1];
-+ n = rx->len - 2;
-+
-+ /* An answer with data and a normal ending is held and announced as 61xx.
-+ * 91xx is a normal ending too (a proactive command is pending, which the
-+ * modem fetches itself); libipa knows only 9000/61xx and would abort. */
-+ if (n && (sw == 0x9000 || (sw & 0xFF00) == 0x9100)) {
-+ ctx->pending = malloc(n);
-+ assert(ctx->pending);
-+ memcpy(ctx->pending, rx->data, n);
-+ ctx->pending_len = n;
-+ ctx->pending_off = 0;
-+ put_sw(res, pending_sw(ctx));
-+ goto free;
-+ }
-+
-+ if ((sw & 0xFF00) == 0x9100)
-+ sw = 0x9000;
-+
-+ /* anything else goes to libipa as the card said it, clipped to fit */
-+ if (n + 2 > res->data_len)
-+ n = res->data_len - 2;
-+ memcpy(res->data, rx->data, n);
-+ res->len = n;
-+ put_sw(res, sw);
-+
-+free:
-+ free(tx);
-+ ipa_buf_free(rx);
-+ if (rc < 0)
-+ return rc;
-+out:
-+ IPA_LOGP(SSCARD, LDEBUG, "stdio RX: %s\n", ipa_buf_hexdump(res));
-+ return 0;
-+}
-+
-+int ipa_scard_reset(void *scard_ctx)
-+{
-+ /* a card reset is the host's to do (it owns the modem and knows what a
-+ * reset costs the data session); from here it is a re-open */
-+ struct scard_ctx *ctx = scard_ctx;
-+
-+ assert(ctx);
-+ drop_pending(ctx);
-+ close_channel(ctx);
-+ return ctx->aid_len ? open_channel(ctx) : 0;
-+}
-+
-+int ipa_scard_atr(void *scard_ctx, struct ipa_buf *atr)
-+{
-+ /* the modem does not hand the ATR to the host over this channel */
-+ (void)scard_ctx;
-+ atr->len = 0;
-+ return -EIO;
-+}
-+
-+int ipa_scard_free(void *scard_ctx)
-+{
-+ struct scard_ctx *ctx = scard_ctx;
-+
-+ if (!ctx)
-+ return 0;
-+
-+ drop_pending(ctx);
-+ close_channel(ctx);
-+ roundtrip("disconnect", NULL, 0, NULL);
-+ if (g_ctx == ctx)
-+ g_ctx = NULL;
-+ IPA_FREE(ctx);
-+ return 0;
-+}
-+
-+int ipa_scard_stdio_await_online(void)
-+{
-+ static char line[LINE_MAX_LEN];
-+ const char *p;
-+ int online;
-+
-+ /* The profile just changed. The host has to make the modem use it (a SIM
-+ * power cycle: without REFRESH the modem keeps running the old profile)
-+ * and wait until the data session is back; only then can the result reach
-+ * the eIM. That power cycle closes every logical channel, so ours is
-+ * re-opened here before libipa talks to the card again. */
-+ fflush(stdout);
-+ printf("{\"type\":\"event\",\"payload\":{\"event\":\"profile_changed\"}}\n");
-+ fflush(stdout);
-+
-+ if (!read_line(line, sizeof(line))) {
-+ IPA_LOGP(SSCARD, LERROR, "stdio: host closed the channel while restoring connectivity\n");
-+ return -1;
-+ }
-+
-+ p = field(line, "online");
-+ online = p && strncmp(p, "true", 4) == 0;
-+ IPA_LOGP(SSCARD, LINFO, "stdio: host reports the connection %s\n", online ? "back" : "NOT back");
-+
-+ if (g_ctx) {
-+ /* no close first: the card reset already closed it, and the modem
-+ * may have handed that id to someone else since */
-+ drop_pending(g_ctx);
-+ if (g_ctx->aid_len && open_channel(g_ctx) < 0)
-+ return -1;
-+ }
-+
-+ return online;
-+}
-diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
-index a3e0ef8..de33415 100644
---- a/tests/CMakeLists.txt
-+++ b/tests/CMakeLists.txt
-@@ -6,3 +6,6 @@ enable_testing()
- add_subdirectory(activation_code)
- add_subdirectory(utils)
- add_subdirectory(bpp_segments)
-+if (IPA_SCARD_STDIO)
-+ add_subdirectory(scard_stdio)
-+endif()
-diff --git a/tests/scard_stdio/CMakeLists.txt b/tests/scard_stdio/CMakeLists.txt
-new file mode 100644
-index 0000000..639a9c7
---- /dev/null
-+++ b/tests/scard_stdio/CMakeLists.txt
-@@ -0,0 +1,11 @@
-+# Copyright (c) 2026 André Valentin
-+# SPDX-License-Identifier: AGPL-3.0-only
-+
-+add_executable(scard_stdio_test scard_stdio_test.c)
-+set_property(TARGET scard_stdio_test PROPERTY C_STANDARD 99)
-+target_compile_options(scard_stdio_test PRIVATE -Wall)
-+target_include_directories(scard_stdio_test PRIVATE ${PROJECT_SOURCE_DIR}/include)
-+target_link_libraries(scard_stdio_test scard libipa)
-+
-+add_test(NAME scard_stdio_test
-+ COMMAND sh -c "$ < ${CMAKE_CURRENT_SOURCE_DIR}/scard_stdio_test.in > scard_stdio_test.out && cmp scard_stdio_test.out ${CMAKE_CURRENT_SOURCE_DIR}/scard_stdio_test.ok")
-diff --git a/tests/scard_stdio/scard_stdio_test.c b/tests/scard_stdio/scard_stdio_test.c
-new file mode 100644
-index 0000000..831320d
---- /dev/null
-+++ b/tests/scard_stdio/scard_stdio_test.c
-@@ -0,0 +1,95 @@
-+/*
-+ * Copyright (c) 2026 André Valentin
-+ *
-+ * SPDX-License-Identifier: AGPL-3.0-only
-+ *
-+ * The stdio card backend against a scripted host: stdin carries the host's
-+ * answers (scard_stdio_test.in), stdout the requests, which the test compares
-+ * with scard_stdio_test.ok. The card answers the way a modem's APDU service
-+ * does -- the whole answer at once with 9000 -- and the checks here are what
-+ * libipa relies on seeing instead (libipa/euicc.c).
-+ */
-+
-+#include
-+#include
-+#include
-+#include
-+#include
-+#include
-+
-+static void *card;
-+
-+/* send one C-APDU (hex) and return the R-APDU as a buffer */
-+static struct ipa_buf *xfer(const char *capdu_hex)
-+{
-+ struct ipa_buf *req = ipa_buf_alloc(300);
-+ struct ipa_buf *res = ipa_buf_alloc(258);
-+
-+ req->len = ipa_binary_from_hexstr(req->data, req->data_len, capdu_hex);
-+ assert(ipa_scard_transceive(card, res, req) == 0);
-+ ipa_buf_free(req);
-+ return res;
-+}
-+
-+static unsigned sw_of(const struct ipa_buf *r)
-+{
-+ assert(r->len >= 2);
-+ return (r->data[r->len - 2] << 8) | r->data[r->len - 1];
-+}
-+
-+static void expect_sw(const char *capdu_hex, unsigned sw, size_t data_len)
-+{
-+ struct ipa_buf *r = xfer(capdu_hex);
-+
-+ if (sw_of(r) != sw || r->len - 2 != data_len) {
-+ fprintf(stderr, "%s: got sw %04X with %zu bytes, want %04X with %zu\n", capdu_hex, sw_of(r),
-+ r->len - 2, sw, data_len);
-+ assert(0);
-+ }
-+ ipa_buf_free(r);
-+}
-+
-+int main(void)
-+{
-+ card = ipa_scard_init(0);
-+ assert(card);
-+
-+ /* TERMINAL CAPABILITY and MANAGE CHANNEL never reach the host */
-+ expect_sw("80AA000005A903830107", 0x9000, 0);
-+ expect_sw("0070000100", 0x9000, 0);
-+
-+ /* SELECT ISD-R opens the real channel (the host hands out 3); libipa
-+ * accepts only 61xx here */
-+ expect_sw("01A4040410A0000005591010FFFFFFFF8900000100", 0x6100, 0);
-+
-+ /* A 300-byte answer with 9000 is announced as 61 00 and handed out as
-+ * 256 + 44, each portion exactly the size announced. CLA goes out with
-+ * the host's channel (83), not libipa's (81). */
-+ expect_sw("81E2910003BF2200", 0x6100, 0);
-+ expect_sw("01C0000000", 0x612C, 256);
-+ expect_sw("01C000002C", 0x9000, 44);
-+
-+ /* The host resets the card for a profile change: the channel it closed
-+ * is re-opened (the host now hands out 2) and later APDUs use it */
-+ assert(ipa_scard_stdio_await_online() == 1);
-+ expect_sw("81E2910003BF2B00", 0x6A88, 0);
-+
-+ /* 91xx is a normal ending too: held and announced like 9000 */
-+ expect_sw("81E2910003BF2D00", 0x6102, 0);
-+ expect_sw("01C0000002", 0x9000, 2);
-+
-+ /* the host reports it could not get the connection back, and re-opens
-+ * on channel 4: from there CLA takes the further coding, 0x40 | ch-4
-+ * (ISO/IEC 7816-4 5.4.1), so STORE DATA goes out as C0, not 84 */
-+ assert(ipa_scard_stdio_await_online() == 0);
-+ expect_sw("81E2910003BF2B00", 0x9000, 0);
-+
-+ assert(ipa_scard_free(card) == 0);
-+
-+ /* the host is gone: every exchange fails at once instead of blocking */
-+ card = ipa_scard_init(0);
-+ assert(card == NULL);
-+
-+ fprintf(stderr, "scard_stdio_test: ok\n");
-+ return 0;
-+}
-diff --git a/tests/scard_stdio/scard_stdio_test.in b/tests/scard_stdio/scard_stdio_test.in
-new file mode 100644
-index 0000000..50d2f24
---- /dev/null
-+++ b/tests/scard_stdio/scard_stdio_test.in
-@@ -0,0 +1,12 @@
-+{"type":"apdu","payload":{"ecode":0}}
-+{"type":"apdu","payload":{"ecode":3}}
-+{"type":"apdu","payload":{"ecode":0,"data":"A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A5A59000"}}
-+{"type":"event","payload":{"online":true}}
-+{"type":"apdu","payload":{"ecode":2}}
-+{"type":"apdu","payload":{"ecode":0,"data":"6A88"}}
-+{"type":"apdu","payload":{"ecode":0,"data":"AABB9110"}}
-+{"type":"event","payload":{"online":false}}
-+{"type":"apdu","payload":{"ecode":4}}
-+{"type":"apdu","payload":{"ecode":0,"data":"9000"}}
-+{"type":"apdu","payload":{"ecode":0}}
-+{"type":"apdu","payload":{"ecode":0}}
-diff --git a/tests/scard_stdio/scard_stdio_test.ok b/tests/scard_stdio/scard_stdio_test.ok
-new file mode 100644
-index 0000000..2e653d9
---- /dev/null
-+++ b/tests/scard_stdio/scard_stdio_test.ok
-@@ -0,0 +1,13 @@
-+{"type":"apdu","payload":{"func":"connect","param":null}}
-+{"type":"apdu","payload":{"func":"logic_channel_open","param":"A0000005591010FFFFFFFF8900000100"}}
-+{"type":"apdu","payload":{"func":"transmit","param":"83E2910003BF2200"}}
-+{"type":"event","payload":{"event":"profile_changed"}}
-+{"type":"apdu","payload":{"func":"logic_channel_open","param":"A0000005591010FFFFFFFF8900000100"}}
-+{"type":"apdu","payload":{"func":"transmit","param":"82E2910003BF2B00"}}
-+{"type":"apdu","payload":{"func":"transmit","param":"82E2910003BF2D00"}}
-+{"type":"event","payload":{"event":"profile_changed"}}
-+{"type":"apdu","payload":{"func":"logic_channel_open","param":"A0000005591010FFFFFFFF8900000100"}}
-+{"type":"apdu","payload":{"func":"transmit","param":"C0E2910003BF2B00"}}
-+{"type":"apdu","payload":{"func":"logic_channel_close","param":"04"}}
-+{"type":"apdu","payload":{"func":"disconnect","param":null}}
-+{"type":"apdu","payload":{"func":"connect","param":null}}
diff --git a/wwand/Makefile b/wwand/Makefile
index cb3f74138..105800a34 100644
--- a/wwand/Makefile
+++ b/wwand/Makefile
@@ -50,12 +50,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=wwand
-PKG_VERSION:=1.6.8_p23
-PKG_RELEASE:=57
+PKG_VERSION:=1.6.8_p25
+PKG_RELEASE:=58
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://github.com/ddimension/wwand.git
-PKG_SOURCE_VERSION:=cf8d5cf1cc5767d58824a02c61de4d9d2ff58cf6
+PKG_SOURCE_VERSION:=cd55f6dd5c0b81d47f7dd6f8a07ab69b58bd1061
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-2.0-only