🎨 Sync 2026-10-12 21:43:19

This commit is contained in:
github-actions[bot] committed 2026-10-12 21:43:19 +08:00
1 parent 3a82c347ab
commit bfe30ffbef
51 files changed
+8523 -149

No files matched your search

+2 -2
View File
@@ -5,13 +5,13 @@ PKG_SHORT_SHA:=bc76c13
PKG_COMMIT_DATE:=2026.10.11
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
PKG_RELEASE:=10
PKG_RELEASE:=11
PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=29c93e5739479dc14c0dea398465f3bc17026944
PKG_SOURCE_VERSION:=fac50d4bfa20e397dc765616578d92522b208e3a
PKG_HASH:=skip
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
+119
View File
@@ -0,0 +1,119 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=ksmbd-tools
PKG_VERSION:=3.5.7
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://github.com/cifsd-team/ksmbd-tools/releases/download/$(PKG_VERSION)
PKG_HASH:=skip
PKG_LICENSE:=GPL-2.0-or-later
PKG_LICENSE_FILES:=COPYING
PKG_BUILD_DEPENDS:=glib2
PKG_BUILD_FLAGS:=gc-sections
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/nls.mk
include $(INCLUDE_DIR)/meson.mk
define Package/ksmbd-tools/Default
SECTION:=net
CATEGORY:=Network
SUBMENU:=Filesystem
TITLE:=Kernel SMB
URL:=https://github.com/cifsd-team/ksmbd-tools
DEPENDS:= $(ICONV_DEPENDS) $(INTL_DEPENDS)
endef
define Package/ksmbd-tools/Default/description
Userspace tools to manage the SMB kernel fileserver (ksmbd.ko).
The config file location is /etc/ksmbd/ksmbd.conf
endef
define Package/ksmbd-server
$(call Package/ksmbd-tools/Default)
TITLE+= server
DEPENDS+= +(LINUX_5_4||LINUX_5_10||LINUX_6_6):kmod-fs-cifsd +!(LINUX_5_4||LINUX_5_10||LINUX_6_6):kmod-fs-ksmbd +libnl-core +libnl-genl +umdns +flock
endef
define Package/ksmbd-server/description
installs: ksmbd.mountd
This provides the basic fileserver service and is the minimum needed to serve 'guest only' file shares or use a existing user database file ksmbdpwd.db.
endef
define Package/ksmbd-server/config
select PACKAGE_wsdd2
endef
define Package/ksmbd-avahi-service
$(call Package/ksmbd-tools/Default)
TITLE+= (Avahi service)
DEPENDS:= +avahi-daemon
endef
define Package/ksmbd-avahi-service/description
installs: smb.service
This package contains the service definition for announcing the
Ksmbd (smb/445) Daemon service via mDNS/DNS-SD.
endef
define Package/ksmbd-hotplug
$(call Package/ksmbd-tools/Default)
TITLE+= hotplug script for auto sharing
DEPENDS:=+blockd
endef
MESON_ARGS += \
-Db_lto=true \
-Dkrb5=disabled
TARGET_LDFLAGS += -Wl,--as-needed
define Package/ksmbd-server/install
$(INSTALL_DIR) $(1)/usr/libexec
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/libexec/ksmbd.tools $(1)/usr/libexec/
$(INSTALL_DIR) $(1)/usr/sbin
$(CP) $(PKG_INSTALL_DIR)/usr/sbin/ksmbd.addshare $(1)/usr/sbin/
$(CP) $(PKG_INSTALL_DIR)/usr/sbin/ksmbd.adduser $(1)/usr/sbin/
$(CP) $(PKG_INSTALL_DIR)/usr/sbin/ksmbd.control $(1)/usr/sbin/
$(CP) $(PKG_INSTALL_DIR)/usr/sbin/ksmbd.mountd $(1)/usr/sbin/
$(INSTALL_DIR) $(1)/etc/config $(1)/etc/ksmbd $(1)/etc/init.d
$(INSTALL_CONF) ./files/ksmbd.config $(1)/etc/config/ksmbd
$(INSTALL_DATA) ./files/ksmbd.conf.template $(1)/etc/ksmbd/
$(INSTALL_BIN) ./files/ksmbd.init $(1)/etc/init.d/ksmbd
$(INSTALL_DIR) $(1)/etc/sysctl.d
$(INSTALL_CONF) ./files/ksmbd.sysctl $(1)/etc/sysctl.d/90-ksmbd.conf
$(INSTALL_DATA) ./files/shares.sh $(1)/etc/ksmbd/
# copy examples until we have a wiki page
$(INSTALL_DATA) ./files/ksmbd.config.example $(1)/etc/ksmbd/
endef
define Package/ksmbd-avahi-service/install
$(INSTALL_DIR) $(1)/etc/avahi/services
$(INSTALL_DATA) ./files/smb.service $(1)/etc/avahi/services/
endef
define Package/ksmbd-hotplug/install
$(INSTALL_DIR) $(1)/etc/hotplug.d/mount
$(INSTALL_CONF) ./files/ksmbd.hotplug $(1)/etc/hotplug.d/mount/60-ksmbd
endef
define Package/ksmbd-server/conffiles
/etc/config/ksmbd
/etc/ksmbd/ksmbd.conf.template
/etc/ksmbd/ksmbd.conf
/etc/ksmbd/ksmbdpwd.db
/etc/sysctl.d/90-ksmbd.conf
endef
define Package/ksmbd-avahi-service/conffiles
/etc/avahi/services/smb.service
endef
$(eval $(call BuildPackage,ksmbd-server))
$(eval $(call BuildPackage,ksmbd-avahi-service))
$(eval $(call BuildPackage,ksmbd-hotplug))
+15
View File
@@ -0,0 +1,15 @@
[global]
netbios name = |NAME|
server string = |DESCRIPTION|
workgroup = |WORKGROUP|
interfaces = |INTERFACES|
bind interfaces only = yes
ipc timeout = 20
deadtime = 15
map to guest = Bad User
server max protocol = SMB3_11
smb2 max read = 1MB
smb2 max write = 1MB
smb2 max trans = 1MB
cache read buffers = no
cache trans buffers = no
+9
View File
@@ -0,0 +1,9 @@
config globals
option enabled '0'
option 'workgroup' 'WORKGROUP'
option 'description' 'Ksmbd on OpenWrt'
option interface 'lan'
option allow_legacy_protocols '1'
option allow_guest_ipc '1'
option homes '0'
option autoshare '1'
+11
View File
@@ -0,0 +1,11 @@
config globals
option 'description' 'Ksmbd on OpenWrt'
config share
option name 'testshare'
option path '/tmp'
option read_only 'no'
option guest_ok 'yes'
option create_mask '0666'
option dir_mask '0777'
option force_root '1'
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
. /lib/functions.sh
case "$ACTION" in add|remove) ;; *) exit 0;; esac
/etc/init.d/ksmbd enabled || exit 0
enabled=0
autoshare=1
ksmbd_settings() {
config_get_bool enabled "$1" enabled 0
config_get_bool autoshare "$1" autoshare 1
}
config_load ksmbd
config_foreach ksmbd_settings globals
[ "$enabled" -eq 1 ] && [ "$autoshare" -eq 1 ] || exit 0
# Synchronize automatic UCI shares and rebuild from current mounts.
/etc/init.d/ksmbd reload
+211
View File
@@ -0,0 +1,211 @@
#!/bin/sh /etc/rc.common
START=98
STOP=05
USE_PROCD=1
SMBD_IFACE=""
KSMBD_ENABLED=0
KSMBD_HOMES=0
KSMBD_AUTOSHARE=1
config_get_sane()
{
config_get "$@"
set -- "$(echo "$1" | tr -d '<>[]{};%?=#\n')"
}
smb_header()
{
config_get_bool KSMBD_ENABLED "$1" enabled 0
config_get_bool KSMBD_HOMES "$1" homes 0
config_get_bool KSMBD_AUTOSHARE "$1" autoshare 1
config_get_sane SMBD_IFACE "$1" interface "lan"
# resolve interfaces
interfaces=$(
. /lib/functions/network.sh
for net in $SMBD_IFACE; do
network_is_up "$net" || continue
network_get_device device "$net"
printf "%s " "${device:-$net}"
done
)
config_get hostname "$1" name
case "$hostname" in
''|*[!a-zA-Z0-9_.-]*)
hostname="$(sed 's/\..*//' /proc/sys/kernel/hostname | tr -cd 'a-zA-Z0-9_-')"
;;
esac
config_get_sane workgroup "$1" workgroup "WORKGROUP"
config_get_sane description "$1" description "Ksmbd on OpenWrt"
config_get_bool ALLOW_LEGACY_PROTOCOLS "$1" allow_legacy_protocols 1
config_get_bool ALLOW_GUEST_IPC "$1" allow_guest_ipc 1
sed -e "s#|NAME|#$hostname#g" \
-e "s#|WORKGROUP|#$workgroup#g" \
-e "s#|DESCRIPTION|#$description#g" \
-e "s#|INTERFACES|#$interfaces#g" \
/etc/ksmbd/ksmbd.conf.template > /var/etc/ksmbd/ksmbd.conf
{
printf "\n######### Dynamic written config options #########\n"
if [ "$ALLOW_LEGACY_PROTOCOLS" -eq 1 ]; then
logger -p daemon.info -t 'ksmbd' "Legacy Protocols allowed, don't use this option for secure environments!"
printf "\tserver min protocol = NT1\n"
printf "\tserver signing = disabled\n"
fi
printf "\n[IPC$]\n\tguest ok = %s\n" "$( [ "$ALLOW_GUEST_IPC" -eq 1 ] && echo yes || echo no )"
} >> /var/etc/ksmbd/ksmbd.conf
[ -e /etc/ksmbd/ksmbd.conf ] || ln -nsf /var/etc/ksmbd/ksmbd.conf /etc/ksmbd/ksmbd.conf
if [ ! -L /etc/ksmbd/ksmbd.conf ]; then
logger -p daemon.warn -t 'ksmbd' "Local custom /etc/ksmbd/ksmbd.conf file detected, all UCI/Luci config settings are ignored!"
fi
}
smb_add_share()
{
config_get_sane name "$1" name
config_get_sane path "$1" path
config_get_sane comment "$1" comment
config_get_sane users "$1" users
config_get_sane create_mask "$1" create_mask 0666
config_get_sane dir_mask "$1" dir_mask 0777
config_get_sane browseable "$1" browseable yes
config_get_sane read_only "$1" read_only no
config_get_sane writeable "$1" writeable
config_get_sane guest_ok "$1" guest_ok yes
config_get_bool force_root "$1" force_root 1
config_get_sane write_list "$1" write_list
config_get_sane read_list "$1" read_list
config_get_sane hide_dot_files "$1" hide_dot_files yes
config_get_sane veto_files "$1" veto_files
config_get_sane inherit_owner "$1" inherit_owner
config_get_sane force_create_mode "$1" force_create_mode
config_get_sane force_directory_mode "$1" force_directory_mode
[ -z "$name" ] || [ -z "$path" ] && return
{
printf "\n[%s]\n\tpath = %s\n" "$name" "$path"
[ -n "$comment" ] && printf "\tcomment = %s\n" "$comment"
if [ "$force_root" -eq 1 ]; then
printf "\tforce user = %s\n" "root"
printf "\tforce group = %s\n" "root"
fi
[ -n "$users" ] && printf "\tvalid users = %s\n" "$users"
[ -n "$create_mask" ] && printf "\tcreate mask = %s\n" "$create_mask"
[ -n "$dir_mask" ] && printf "\tdirectory mask = %s\n" "$dir_mask"
[ -n "$force_create_mode" ] && printf "\tforce create mode = %s\n" "$force_create_mode"
[ -n "$force_directory_mode" ] && printf "\tforce directory mode = %s\n" "$force_directory_mode"
[ -n "$browseable" ] && printf "\tbrowseable = %s\n" "$browseable"
[ -n "$read_only" ] && printf "\tread only = %s\n" "$read_only"
[ -n "$writeable" ] && printf "\twriteable = %s\n" "$writeable"
[ -n "$guest_ok" ] && printf "\tguest ok = %s\n" "$guest_ok"
[ -n "$inherit_owner" ] && printf "\tinherit owner = %s\n" "$inherit_owner"
[ -n "$write_list" ] && printf "\twrite list = %s\n" "$write_list"
[ -n "$read_list" ] && printf "\tread list = %s\n" "$read_list"
[ -n "$hide_dot_files" ] && printf "\thide dot files = %s\n" "$hide_dot_files"
[ -n "$veto_files" ] && printf "\tveto files = %s\n" "$veto_files"
} >> /var/etc/ksmbd/ksmbd.conf
}
init_config()
{
flock -x 9 || return 1
mkdir -p /var/etc/ksmbd
config_load ksmbd
config_foreach smb_header globals
. /etc/ksmbd/shares.sh
ksmbd_sync_mounts || return 1
config_load ksmbd
config_foreach smb_add_share share
[ "$KSMBD_HOMES" -eq 1 ] && ksmbd_add_homes
return 0
} 9>/var/lock/ksmbd-config.lock
service_triggers()
{
# PROCD_RELOAD_DELAY=1000
procd_add_reload_trigger "dhcp" "system" "ksmbd"
for i in $SMBD_IFACE; do
procd_add_reload_interface_trigger "$i"
done
}
kill_server()
{
if [ -e /sys/module/ksmbd ]; then
if [ -e /sys/class/ksmbd-control/kill_server ]; then
logger -p daemon.info -t 'ksmbd' "triggering kill_server"
echo hard > /sys/class/ksmbd-control/kill_server
fi
fi
}
start_service()
{
init_config || return 1
if [ "$KSMBD_ENABLED" -ne 1 ]; then
stop_service
return 0
fi
if [ ! -e /etc/ksmbd/ksmbd.conf ]; then
logger -p daemon.error -t 'ksmbd' "missing config /etc/ksmbd/ksmbd.conf!"
exit 1
fi
# NOTE: We don't do a soft-reload via signal, since [global] ksmbd.conf setting changes will be ignored, so always reset hard.
[ -e /sys/module/ksmbd ] && stop_service
[ ! -e /sys/module/ksmbd ] && modprobe ksmbd 2> /dev/null
if [ ! -e /sys/module/ksmbd ]; then
logger -p daemon.error -t 'ksmbd' "modprobe of ksmbd module failed, can\'t start ksmbd!"
exit 1
fi
logger -p daemon.notice -t 'ksmbd' "Starting Ksmbd userspace service."
procd_open_instance
procd_add_mdns "smb" "tcp" "445" "daemon=ksmbd"
procd_set_param command /usr/sbin/ksmbd.mountd --n
procd_set_param file /etc/ksmbd/ksmbd.conf
procd_set_param limits nofile=16384
procd_close_instance
}
stop_service()
{
logger -p daemon.notice -t 'ksmbd' "Stopping Ksmbd userspace service."
killall ksmbd.mountd > /dev/null 2>&1
[ -e /sys/module/ksmbd ] && rmmod ksmbd > /dev/null 2>&1
# kill server if we cant rmmod
[ -e /sys/module/ksmbd ] && kill_server
# next try
[ -e /sys/module/ksmbd ] && rmmod ksmbd > /dev/null 2>&1
if [ -e /sys/module/ksmbd ]; then
logger -p daemon.error -t 'ksmbd' "module still loaded after kill_server?"
fi
[ -f /tmp/ksmbd.lock ] && rm /tmp/ksmbd.lock
}
# reload_service() {
# restart "$@"
# }
+5
View File
@@ -0,0 +1,5 @@
# Bound buffered-write backlog to smooth sustained uploads on flash storage.
# These VM limits apply system-wide, including writers other than ksmbd.
# /etc/sysctl.conf is loaded afterwards and can override these values.
vm.dirty_background_bytes=4194304
vm.dirty_bytes=16777216
+127
View File
@@ -0,0 +1,127 @@
#!/bin/sh
# Home shares remain private runtime entries; mount shares are stored in UCI.
ksmbd_share_exists()
{
KSMBD_SHARE_NAME="$1" KSMBD_SHARE_PATH="$2" awk '
/^\[/ { name = substr($0, 2, length($0)-2); if (tolower(name) == tolower(ENVIRON["KSMBD_SHARE_NAME"])) found=1 }
/^[ \t]*path[ \t]*=/ { path=$0; sub(/^[ \t]*path[ \t]*=[ \t]*/, "", path); if (path == ENVIRON["KSMBD_SHARE_PATH"]) found=1 }
END { exit !found }' /var/etc/ksmbd/ksmbd.conf
}
ksmbd_add_homes()
{
local user password uid gid gecos home shell
[ -r /etc/ksmbd/ksmbdpwd.db ] || return 0
while IFS=: read -r user password uid gid gecos home shell; do
case "$user" in ''|*[!a-zA-Z0-9_.-]*) continue;; esac
case "$home" in /|/var|/tmp|/dev|/proc|/sys|'') continue;; /*) ;; *) continue;; esac
[ -d "$home" ] || continue
# Private homes are only generated for registered SMB users.
awk -F: -v user="$user" '$1 == user { found=1 } END { exit !found }' /etc/ksmbd/ksmbdpwd.db || continue
ksmbd_share_exists "$user" "$home" && continue
{
printf '\n[%s]\n\tpath = %s\n' "$user" "$home"
printf '\tcomment = Home Directories\n\tvalid users = %s\n' "$user"
printf '\tbrowseable = no\n\tguest ok = no\n\tread only = no\n'
printf '\tcreate mask = 0750\n\tdirectory mask = 0750\n'
} >> /var/etc/ksmbd/ksmbd.conf
done < /etc/passwd
}
# Use a private delta directory so commits do not include pending LuCI edits.
ksmbd_mount_uci()
{
uci ${UCI_CONFIG_DIR:+-c "$UCI_CONFIG_DIR"} -t "$ksmbd_delta" -q "$@"
}
ksmbd_prune_mount()
{
local section="$1" path auto_path fingerprint
config_get path "$section" path
config_get auto_path "$section" auto_path
[ -n "$auto_path" ] || return 0
if [ "$path" != "$auto_path" ]; then
# Editing the path converts an automatic share into a manual share.
ksmbd_mount_uci delete "ksmbd.$section.auto_path"
elif [ "$KSMBD_AUTOSHARE" -ne 1 ] || ! grep -Fxq "$path" "$ksmbd_mounts"; then
ksmbd_mount_uci delete "ksmbd.$section"
else
# Only remove identical generated entries; differing permissions are
# intentional configuration and must not be silently discarded.
fingerprint="$(ksmbd_mount_uci show "ksmbd.$section" |
sed '1d; s/^ksmbd\.[^.]*\.//' | LC_ALL=C sort | md5sum | cut -d ' ' -f 1)"
if grep -Fxq "$fingerprint" "$ksmbd_delta/seen"; then
ksmbd_mount_uci delete "ksmbd.$section"
else
printf '%s\n' "$fingerprint" >> "$ksmbd_delta/seen"
fi
fi
return 0
}
ksmbd_find_mount()
{
local section="$1" share_name share_path
config_get share_name "$section" name
config_get share_path "$section" path
if [ "$share_path" = "$target" ] ||
[ "$(printf '%s' "$share_name" | tr 'A-Z' 'a-z')" = "$(printf '%s' "$name" | tr 'A-Z' 'a-z')" ]; then
ksmbd_found=1
fi
}
ksmbd_scan_mounts()
{
local device target fstype options rest name
while read -r device target fstype options rest; do
case "$device" in /dev/sd*|/dev/hd*|/dev/mmcblk*|/dev/nvme*|/dev/md*) ;; *) continue;; esac
# Firmware images and other read-only mounts are not automatic shares.
case "$fstype" in squashfs|erofs|cramfs|romfs|iso9660) continue;; esac
case ",$options," in *,ro,*) continue;; esac
target="$(printf '%b' "$target")"
case "$target" in /|/rom|/overlay|/boot|/boot/*|/tmp|/tmp/*|/dev|/dev/*|/proc|/proc/*|/sys|/sys/*) continue;; esac
case "$target" in *'
'*|*' '*) continue;; esac
[ -d "$target" ] || continue
name="${target##*/}"
case "$name" in ''|*'['*|*']'*|*';'*|*'%'*) name="${device##*/}";; esac
printf '%s\t%s\tno\n' "$target" "$name"
done < /proc/mounts
}
ksmbd_sync_mounts()
(
local ksmbd_delta ksmbd_mounts target name readonly section ksmbd_found
flock -x 8 || return 1
# The caller's snapshot can predate another hotplug worker's commit.
config_load ksmbd
ksmbd_delta="$(mktemp -d /tmp/ksmbd-uci.XXXXXX)" || return 1
trap 'rm -rf "$ksmbd_delta"' EXIT
: > "$ksmbd_delta/seen"
ksmbd_mounts="$ksmbd_delta/paths"
ksmbd_scan_mounts > "$ksmbd_delta/mounts"
cut -f1 "$ksmbd_delta/mounts" > "$ksmbd_mounts"
config_foreach ksmbd_prune_mount share
if [ -n "$(ksmbd_mount_uci changes ksmbd)" ]; then
ksmbd_mount_uci commit ksmbd || return 1
fi
[ "$KSMBD_AUTOSHARE" -eq 1 ] || return 0
config_load ksmbd
while IFS="$(printf '\t')" read -r target name readonly; do
ksmbd_found=0
config_foreach ksmbd_find_mount share
[ "$ksmbd_found" -eq 0 ] || continue
section="$(ksmbd_mount_uci add ksmbd share)" || return 1
ksmbd_mount_uci set "ksmbd.$section.name=$name"
ksmbd_mount_uci set "ksmbd.$section.path=$target"
ksmbd_mount_uci set "ksmbd.$section.auto_path=$target"
ksmbd_mount_uci set "ksmbd.$section.browseable=yes"
ksmbd_mount_uci set "ksmbd.$section.guest_ok=yes"
ksmbd_mount_uci set "ksmbd.$section.read_only=$readonly"
ksmbd_mount_uci set "ksmbd.$section.force_root=1"
ksmbd_mount_uci set "ksmbd.$section.create_mask=0666"
ksmbd_mount_uci set "ksmbd.$section.dir_mask=0777"
ksmbd_mount_uci commit ksmbd || return 1
config_load ksmbd
done < "$ksmbd_delta/mounts"
) 8>/var/lock/ksmbd-autoshare.lock
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" standalone='no'?>
<!DOCTYPE service-group SYSTEM "avahi-service.dtd">
<service-group>
<name replace-wildcards="yes">%h</name>
<service>
<type>_smb._tcp</type>
<port>445</port>
</service>
</service-group>
+14
View File
@@ -0,0 +1,14 @@
Subject: [PATCH] Link glib statically for OpenWrt
Signed-off-by: coolsnowwolf <coolsnowwolf@gmail.com>
--- a/meson.build
+++ b/meson.build
@@ -21,6 +21,7 @@
glib_dep = dependency(
'glib-2.0',
version: '>= 2.58',
+ static: true,
)
libnl_dep = dependency(
'libnl-genl-3.0',
+1 -1
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-clashoo
PKG_VERSION:=1.31.0
PKG_RELEASE:=7
PKG_RELEASE:=8
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
@@ -2,9 +2,18 @@
'use strict';
import { access, popen, glob, readfile, writefile, stat, lstat, unlink, open, mkdtemp, rename, rmdir } from 'fs';
import { access, popen, glob, readfile, writefile, stat, lstat, unlink, open, rename, rmdir } from 'fs';
import { cursor } from 'uci';
// 兼容 ucode < 2026-10-10:原生缺少 mkdtemp,改用 mktemp -d
function mkdtemp(tpl) {
let p = popen("mktemp -d '" + tpl + "' 2>/dev/null");
if (!p) return null;
let r = trim(p.read('all') || '');
p.close();
return r || null;
}
const BINARY_PATHS = [
'/usr/bin/mihomo-stable',
'/usr/bin/mihomo',
@@ -1219,6 +1228,10 @@ if (!t) t = 'template';
return '_merged_' + s + '__' + t + '.yaml';
}
function is_generated_yaml(path) {
return system("grep -q '^x-clashoo:' " + shell_quote(path) + " 2>/dev/null") == 0;
}
function template_search_dirs() {
return [TEMPLATE_DIR, '/usr/share/clashoo/config/template'];
}
@@ -1228,7 +1241,7 @@ name = ensure_yaml_name(name || '');
if (!name) return '';
for (let dir in template_search_dirs()) {
let p = dir + '/' + name;
if (access(p, 'r')) return p;
if (access(p, 'r') && !is_generated_yaml(p)) return p;
}
return '';
}
@@ -2907,6 +2920,7 @@ for (let dir in template_search_dirs()) {
for (let f in (glob(dir + '/*.yaml') || [])) {
let name = replace(f, dir + '/', '');
if (index(name, '_merged_') == 0 || seen[name]) continue;
if (is_generated_yaml(f)) continue;
seen[name] = 1;
let fi = stat(f);
push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null });
@@ -2914,6 +2928,7 @@ push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size
for (let f in (glob(dir + '/*.yml') || [])) {
let name = replace(f, dir + '/', '');
if (index(name, '_merged_') == 0 || seen[name]) continue;
if (is_generated_yaml(f)) continue;
seen[name] = 1;
let fi = stat(f);
push(files, { name, mtime: fi ? ('' + fi.mtime) : null, size: fi ? (int(fi.size / 1024) + ' KB') : null });
@@ -3477,13 +3492,22 @@ if (!tpl_src)
return { success: false, error: 'invalid_template', message: '模板来源不能为空' };
let pairs = [];
let names = {};
try {
let parsed = json(pairs_raw || '[]');
if (type(parsed) == 'array') {
if (type(parsed) != 'array')
return { success: false, error: 'invalid_pairs', message: '订阅参数格式错误' };
for (let p in parsed) {
if (p && p.name && p.url && match(p.url, /^https?:\/\//))
push(pairs, { name: trim(p.name), url: trim(p.url) });
}
if (!p || type(p.name) != 'string' || type(p.url) != 'string')
return { success: false, error: 'invalid_pairs', message: '订阅名称和链接不能为空' };
let name = trim(p.name);
let url = trim(p.url);
if (!name || !match(url, /^https?:\/\//))
return { success: false, error: 'invalid_pairs', message: '订阅名称不能为空,链接必须以 http:// 或 https:// 开头' };
if (names[name])
return { success: false, error: 'duplicate_provider_name', message: '订阅名称不能重复' };
names[name] = 1;
push(pairs, { name, url });
}
} catch (err) {
return { success: false, error: 'invalid_pairs', message: '订阅参数格式错误' };
@@ -3507,11 +3531,7 @@ if (system(dl_cmd) != 0 || !access(tmp_tpl, 'r'))
return { success: false, error: 'download_failed', message: '远程模板下载失败' };
} else {
let safe = safe_name(tpl_src);
let local_path = null;
let search_dirs = template_search_dirs();
for (let i = 0; i < length(search_dirs); i++) {
if (access(search_dirs[i] + '/' + safe, 'r')) { local_path = search_dirs[i] + '/' + safe; break; }
}
let local_path = find_template_path(safe);
if (!local_path)
return { success: false, error: 'template_not_found', message: '本地模板文件不存在' };
if (system('cp -f ' + shell_quote(local_path) + ' ' + shell_quote(tmp_tpl)) != 0)
@@ -3528,17 +3548,27 @@ return { success: false, error: 'missing_yq', message: '缺少 yq,无法注入
system("cp -f " + shell_quote(tmp_tpl) + " " + shell_quote(exploded));
}
/* 模板必须有 proxy-providers 段 */
if (system("yq -e 'has(\"proxy-providers\")' " + shell_quote(exploded) + " >/dev/null 2>&1") != 0) {
system("rm -f " + shell_quote(exploded));
return { success: false, error: 'no_proxy_providers', message: '模板缺少 proxy-providers 段' };
}
if (system("yq -e '(.\"proxy-providers\" | tag) == \"!!map\" and (.\"proxy-providers\" | length) == 1 and (.\"proxy-providers\" | to_entries[0].value | tag) == \"!!map\" and (.\"proxy-providers\" | to_entries[0].value.type) == \"http\"' " + shell_quote(exploded) + " >/dev/null 2>&1") != 0) {
system("rm -f " + shell_quote(exploded));
return { success: false, error: 'invalid_proxy_providers', message: '面板模板只支持一个 type: http 的样板 provider;多 provider 配置请直接上传使用' };
}
/* pairs 写到临时 JSON 文件 */
writefile(tmp_pairs, sprintf('%.J', pairs));
let tpl_label = match(tpl_src, /^https?:\/\//) ? tpl_src : replace(tpl_src, /^.*\//, "");
let tpl_expr = length(pairs) > 1 ? '($entry.value | del(.path))' : '$entry.value';
let yq_expr = '(load("' + tmp_pairs + '")) as $pairs | '
+ '(.["proxy-providers"] | keys) as $oldkeys | '
+ '($oldkeys | to_entries | map(.key as $i | .value as $k | {"key": $k, "value": ($pairs[$i].name // $k)}) | from_entries) as $ren | '
+ '($oldkeys | to_entries | map(select(.key < ($pairs | length)) | .key as $i | .value as $k | {"key": $k, "value": $pairs[$i].url}) | from_entries) as $reurl | '
+ '.["proxy-providers"] |= with_entries(.key as $old | .key = ($ren[$old] // $old) | .value.url = ($reurl[$old] // .value.url)) | '
+ '(.["proxy-groups"][] | select(has("use")) | .use) |= map($ren[.] // .) | '
+ '(.["proxy-providers"] | to_entries[0]) as $entry | '
+ tpl_expr + ' as $tpl | '
+ '.["proxy-providers"] = ($pairs | map({"key": .name, "value": ($tpl + {"url": .url})}) | from_entries) | '
+ '(.["proxy-groups"][] | select(has("use")) | .use) |= (map(. as $name | ([$pairs[].name] | select($name == $entry.key)) // [$name]) | flatten) | '
+ '."x-clashoo" = {"template": strenv(TPL_LABEL)}';
let inject_cmd = "TPL_LABEL=" + shell_quote(tpl_label)
@@ -3584,7 +3614,7 @@ system('rm -f ' + shell_quote(tmp_tpl));
}
}
if (_core_bin) {
let _ret = system(_core_bin + " -t -d /etc/clashoo -f " + shell_quote(out_path) + " 2>/tmp/clashoo_tpl_verify.txt");
let _ret = system(_core_bin + " -t -d /etc/clashoo -f " + shell_quote(out_path) + " >/dev/null 2>/tmp/clashoo_tpl_verify.txt");
if (_ret != 0) {
let _err = trim(readfile('/tmp/clashoo_tpl_verify.txt') || '配置校验失败');
system('rm -f ' + shell_quote(out_path) + ' /tmp/clashoo_tpl_verify.txt 2>/dev/null');
+2 -2
View File
@@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-diskman
PKG_VERSION:=0.2.13
PKG_RELEASE:=2
PKG_RELEASE:=3
PKG_MAINTAINER:=lisaac <lisaac.cn@gmail.com>
PKG_LICENSE:=AGPL-3.0
@@ -24,7 +24,7 @@ define Package/luci-app-diskman/config
config PACKAGE_$(PKG_NAME)_INCLUDE_btrfs_progs
depends on PACKAGE_$(PKG_NAME)
bool "Include btrfs-progs"
default y
default n
config PACKAGE_$(PKG_NAME)_INCLUDE_lsblk
depends on PACKAGE_$(PKG_NAME)
@@ -43,7 +43,7 @@ d:option(DummyValue, "temp", translate("Temp"))
d:option(DummyValue, "p_table", translate("Partition Table"))
d:option(DummyValue, "sata_ver", translate("SATA Version"))
-- d:option(DummyValue, "rota_rate", translate("Rotation Rate"))
d:option(DummyValue, "health_status", translate("Health") .. "<br/>" .. translate("Status"))
d:option(DummyValue, "health_status", translate("Health") .. " / " .. translate("Status"))
-- d:option(DummyValue, "status", translate("Status"))
-- local btn_eject = d:option(Button, "_eject")
@@ -25,6 +25,25 @@ m.reset = false
local disk_info = dm.get_disk_info(dev, true)
local format_cmd = dm.get_format_cmd()
local function is_firmware_partition(part)
local name = part.partition_name or ""
return name:match("^0:") or name:find("rootfs", 1, true)
end
local first_partition_start
for _, part in ipairs(disk_info.partitions) do
local start = tonumber(part.sec_start)
if part.number > 0 and start then
first_partition_start = math.min(first_partition_start or start, start)
end
end
local function is_leading_free_space(part)
-- Bootloader data may live in the gap before the first real partition.
return part.number == -1 and first_partition_start and
tonumber(part.sec_end) < first_partition_start
end
s = m:section(Table, {disk_info}, translate("Device Info"))
-- s:option(DummyValue, "key")
-- s:option(DummyValue, "value")
@@ -118,14 +137,15 @@ end
-- partitions info
if not disk_info.p_table:match("Raid") then
s_partition_table = m:section(Table, disk_info.partitions, translate("Partitions Info"), translate("Default 2048 sector alignment, support +size{b,k,m,g,t} in End Sector"))
s_partition_table = m:section(Table, disk_info.partitions, translate("Partitions Info"), translate("Default 1 MiB alignment, support +size{b,k,m,g,t} in End Sector"))
-- s_partition_table:option(DummyValue, "number", translate("Number"))
s_partition_table:option(DummyValue, "name", translate("Name"))
s_partition_table:option(DummyValue, "partition_name", translate("Partition Name"))
local val_sec_start = s_partition_table:option(Value, "sec_start", translate("Start Sector"))
val_sec_start.render = function(self, section, scope)
-- could create new partition
if disk_info.partitions[section].number == -1 and disk_info.partitions[section].size > 1 * 1024 * 1024 then
if disk_info.partitions[section].number == -1 and not is_leading_free_space(disk_info.partitions[section]) and disk_info.partitions[section].size > 1 * 1024 * 1024 then
self.template = "cbi/value"
Value.render(self, section, scope)
else
@@ -136,7 +156,7 @@ if not disk_info.p_table:match("Raid") then
local val_sec_end = s_partition_table:option(Value, "sec_end", translate("End Sector"))
val_sec_end.render = function(self, section, scope)
-- could create new partition
if disk_info.partitions[section].number == -1 and disk_info.partitions[section].size > 1 * 1024 * 1024 then
if disk_info.partitions[section].number == -1 and not is_leading_free_space(disk_info.partitions[section]) and disk_info.partitions[section].size > 1 * 1024 * 1024 then
self.template = "cbi/value"
Value.render(self, section, scope)
else
@@ -192,8 +212,9 @@ if not disk_info.p_table:match("Raid") then
disk_info.partitions[section]._fs = value
end
val_fs.render = function(self, section, scope)
-- use listvalue when partition not mounted
if disk_info.partitions[section].mount_point == "-" and disk_info.partitions[section].number ~= -1 and disk_info.partitions[section].type ~= "extended" then
local part = disk_info.partitions[section]
-- Keep firmware partitions read-only in the filesystem column.
if not is_firmware_partition(part) and part.mount_point == "-" and part.number ~= -1 and part.type ~= "extended" then
self.template = "diskman/cbi/format_button"
self.inputstyle = "reset"
self.inputtitle = disk_info.partitions[section].fs == "raw" and translate("Format") or disk_info.partitions[section].fs
@@ -261,6 +282,12 @@ if not disk_info.p_table:match("Raid") then
btn_action.forcewrite = true
btn_action.template = "diskman/cbi/disabled_button"
btn_action.render = function(self, section, scope)
if is_firmware_partition(disk_info.partitions[section]) or is_leading_free_space(disk_info.partitions[section]) then
self.template = "cbi/dvalue"
DummyValue.render(self, section, scope)
return
end
self.template = "diskman/cbi/disabled_button"
-- if partition is mounted or the size < 1mb, then disable the add action
if disk_info.partitions[section].mount_point ~= "-" or (disk_info.partitions[section].type ~= "extended" and disk_info.partitions[section].number == -1 and disk_info.partitions[section].size <= 1 * 1024 * 1024) then
self.view_disabled = true
@@ -282,16 +309,17 @@ if not disk_info.p_table:match("Raid") then
Button.render(self, section, scope)
end
btn_action.write = function(self, section, value)
if is_firmware_partition(disk_info.partitions[section]) or is_leading_free_space(disk_info.partitions[section]) then return end
if value == translate("New") then
local start_sec = disk_info.partitions[section]._sec_start and tonumber(disk_info.partitions[section]._sec_start) or tonumber(disk_info.partitions[section].sec_start)
local end_sec = disk_info.partitions[section]._sec_end
local logic_sec = tonumber(disk_info.logic_sec)
local align = math.max(math.ceil(tonumber(disk_info.phy_sec) / logic_sec), math.ceil(1048576 / logic_sec))
if start_sec then
-- for sector alignment
local align = tonumber(disk_info.phy_sec) / tonumber(disk_info.logic_sec)
align = (align < 2048) and 2048
if start_sec < 2048 then
start_sec = "2048" .. "s"
if start_sec < align then
start_sec = tostring(align) .. "s"
elseif math.fmod( start_sec, align ) ~= 0 then
start_sec = tostring(start_sec + align - math.fmod( start_sec, align )) .. "s"
else
@@ -306,14 +334,14 @@ if not disk_info.p_table:match("Raid") then
if tonumber(end_size) and end_unit then
local unit ={
B=1,
S=512,
S=tonumber(disk_info.logic_sec),
K=1024,
M=1048576,
G=1073741824,
T=1099511627776
}
end_unit = end_unit:upper()
end_sec = tostring(tonumber(end_size) * unit[end_unit] / unit["S"] + tonumber(start_sec:sub(1,-2)) - 1 ) .. "s"
end_sec = tostring(math.ceil(tonumber(end_size) * unit[end_unit] / logic_sec) + tonumber(start_sec:sub(1,-2)) - 1) .. "s"
elseif tonumber(end_sec) then
end_sec = end_sec .. "s"
else
@@ -325,8 +353,8 @@ if not disk_info.p_table:match("Raid") then
if disk_info.p_table == "MBR" and disk_info["extended_partition_index"] then
if tonumber(disk_info.partitions[disk_info["extended_partition_index"]].sec_start) <= tonumber(start_sec:sub(1,-2)) and tonumber(disk_info.partitions[disk_info["extended_partition_index"]].sec_end) >= tonumber(end_sec:sub(1,-2)) then
part_type = "logical"
if tonumber(start_sec:sub(1,-2)) - tonumber(disk_info.partitions[section].sec_start) < 2048 then
start_sec = tonumber(start_sec:sub(1,-2)) + 2048
if tonumber(start_sec:sub(1,-2)) - tonumber(disk_info.partitions[section].sec_start) < align then
start_sec = tonumber(start_sec:sub(1,-2)) + align
start_sec = start_sec .."s"
end
end
@@ -363,4 +391,4 @@ if not disk_info.p_table:match("Raid") then
end
end
return m
return m
+46 -9
View File
@@ -9,6 +9,7 @@ local ver = require "luci.version"
local CMD = {"parted", "mdadm", "blkid", "smartctl", "df", "btrfs", "lsblk"}
local d = {command ={}}
local SYSFS_SECTOR_SIZE = 512
for _, cmd in ipairs(CMD) do
local command = luci.sys.exec("/usr/bin/which " .. cmd)
d.command[cmd] = command:match("^.+"..cmd) or nil
@@ -33,7 +34,42 @@ function byte_format(byte)
end
end
local function get_emmc_health(device)
if not device:match("^mmcblk%d+$") then return end
local path = "/sys/class/block/" .. device .. "/device/"
if not (nixio.fs.readfile(path .. "type") or ""):match("^MMC%s*$") then return end
local translate = require("luci.i18n").translate
local info = {}
local lifetime
-- Use the more worn of the supported A/B estimates; zero means undefined.
for value in (nixio.fs.readfile(path .. "life_time") or ""):gmatch("%S+") do
local estimate = tonumber(value)
if estimate and estimate >= 1 and estimate <= 11 then
lifetime = math.max(lifetime or 0, estimate)
end
end
if lifetime then
if lifetime == 11 then
info.health = translate("Estimated lifetime exceeded")
else
-- Report estimated remaining life as a range, not an exact percentage.
info.health = string.format("%d-%d%%", 100 - lifetime * 10, 110 - lifetime * 10)
end
end
local eol = tonumber(nixio.fs.readfile(path .. "pre_eol_info") or "")
if eol == 1 then
info.status = translate("Normal")
elseif eol == 2 then
info.status = translate("Warning")
elseif eol == 3 then
info.status = translate("Urgent")
end
return info
end
local get_smart_info = function(device)
local emmc_info = get_emmc_health(device)
if emmc_info then return emmc_info end
local section
local smart_info = {}
for _, line in ipairs(luci.util.execl(d.command.smartctl .. " -H -A -i -n standby -f brief /dev/" .. device)) do
@@ -188,6 +224,10 @@ local get_parted_info = function(device)
elseif device:match("mmcblk") or device:match("md") or device:match("nvme") then
partition_temp["name"] = device.."p"..partition_temp["number"]
end
-- Read the kernel partition name: older parted output does not escape colons.
local uevent = partition_temp["name"] ~= "-" and
nixio.fs.readfile("/sys/class/block/" .. partition_temp["name"] .. "/uevent") or ""
partition_temp["partition_name"] = (uevent or ""):match("\nPARTNAME=([^\n]+)") or "-"
if partition_temp["number"] > 0 and partition_temp["fs"] == "" and d.command.lsblk then
partition_temp["fs"] = (luci.util.exec(d.command.lsblk .. " /dev/" .. partition_temp["name"] .. " -no fstype") or ""):match("([^%s]+)") or ""
end
@@ -230,7 +270,7 @@ local get_parted_info = function(device)
elseif (p["number"] <= 4) and (p["number"] > 0) then
local s = nixio.fs.readfile("/sys/block/"..device.."/"..p["name"].."/size")
if s then
local real_size_sec = tonumber(s) * tonumber(disk_temp.logic_sec)
local real_size_sec = tonumber(s) * SYSFS_SECTOR_SIZE
-- if size not equal, it's an extended
if real_size_sec ~= p["size"] then
disk_temp["extended_partition_index"] = i
@@ -313,7 +353,7 @@ d.get_disk_info = function(device, wakeup)
-- if only weakup == true, weakup the disk and check parted_info.
if smart_info.status ~= "STANDBY" or wakeup or (smart_info["p_table"] and not smart_info["p_table"]:match("Raid")) or device:match("^md") then
disk_info = get_parted_info(device)
disk_info["sec_size"] = disk_info["logic_sec"] .. "/" .. disk_info["phy_sec"]
disk_info["sec_size"] = tostring(disk_info["logic_sec"] or "-") .. "/" .. tostring(disk_info["phy_sec"] or "-")
disk_info["size_formated"] = byte_format(tonumber(disk_info["size"]))
-- if status is standby, after get part info, the disk is weakuped, then get smart_info again for more informations
if smart_info.status ~= "ACTIVE" then smart_info = get_smart_info(device) end
@@ -362,11 +402,9 @@ d.list_raid_devices = function()
end
local size = tonumber(fs.readfile(string.format("/sys/class/block/%s/size", mdpath)))
local ss = tonumber(fs.readfile(string.format("/sys/class/block/%s/queue/logical_block_size", mdpath)))
device_info["path"] = "/dev/"..mdpath
device_info["size"] = size*ss
device_info["size_formated"] = byte_format(size*ss)
device_info["size"] = size * SYSFS_SECTOR_SIZE
device_info["size_formated"] = byte_format(device_info["size"])
device_info["active"] = active:upper()
device_info["level"] = level
device_info["members"] = members
@@ -418,19 +456,18 @@ d.list_devices = function()
local device_info = {}
local device = "/dev/" .. bname
local size = tonumber(fs.readfile(string.format("/sys/class/block/%s/size", bname)) or "0")
local ss = tonumber(fs.readfile(string.format("/sys/class/block/%s/queue/logical_block_size", bname)) or "0")
local model = fs.readfile(string.format("/sys/class/block/%s/device/model", bname))
local partitions = {}
for part in nixio.fs.glob("/sys/block/" .. bname .."/" .. bname .. "*") do
local pname = nixio.fs.basename(part)
local psize = byte_format(tonumber(nixio.fs.readfile(part .. "/size"))*ss)
local psize = byte_format(tonumber(nixio.fs.readfile(part .. "/size")) * SYSFS_SECTOR_SIZE)
local mount_point = get_mount_point(pname)
if mount_point then device_info["inuse"] = true end
table.insert(partitions, {name = pname, size_formated = psize, inuse = mount_point})
end
device_info["path"] = device
device_info["size_formated"] = byte_format(size*ss)
device_info["size_formated"] = byte_format(size * SYSFS_SECTOR_SIZE)
device_info["model"] = model
device_info["partitions"] = partitions
-- true or false
@@ -1,7 +1,66 @@
<style>
.diskman-table { table-layout: auto; width: 100%; }
.diskman-table > tbody > tr > th,
.diskman-table > tbody > tr > td {
padding-left: 6px;
padding-right: 6px;
white-space: nowrap;
overflow-wrap: normal;
width: auto;
flex: 0 0 auto;
}
.diskman-table > tbody > tr > [data-name="path"],
.diskman-table > tbody > tr > [data-name="model"] {
width: auto;
flex: 1 1 0;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
}
.diskman-table > tbody > tr > .cbi-section-actions {
width: 6em;
min-width: 6em;
flex: 0 0 6em;
}
</style>
<script type="text/javascript">
window.onload = function () {
//disk partition info
let p_colors = ["#c0c0ff", "#fbbd00", "#e97c30", "#a0e0a0", "#e0c0ff"]
function renderPartitions(item, disk_info) {
if (!disk_info.partitions || !disk_info.partitions.length) return
let isTable = item.nodeName == "TR"
if (isTable) item.closest("table").classList.add("diskman-table")
let row = document.createElement(isTable ? "tr" : "div")
let cell = document.createElement(isTable ? "td" : "div")
if (isTable) cell.colSpan = item.children.length
else {
row.className = "tr cbi-section-table-row cbi-rowstyle-1"
cell.className = "td"
}
cell.style.cssText = "padding:0; border:0;"
let container = document.createElement("div")
container.style.cssText = "position:relative; height:1.8em; overflow:hidden;"
let bar = document.createElement("div")
// Keep partition labels out of the table's intrinsic width calculation.
bar.style.cssText = "position:absolute; inset:0; display:flex;"
disk_info.partitions.forEach((part, index) => {
let segment = document.createElement("div")
let label = (part.name != '-' && part.name || '') + ' ' + (part.fs != 'Free Space' && part.fs || '') + ' ' + part.size_formated + ' ' + (part.useage != '-' && part.useage || '')
let share = disk_info.size > 0 ? part.size / disk_info.size * 100 : 0
// Flex normalizes the weights even when there are many small partitions.
segment.style.cssText = "min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:center; color:#525F7F;"
segment.style.flex = Math.max(share || 0, 8) + " 1 0%"
segment.style.backgroundColor = p_colors[index % p_colors.length]
segment.title = label
segment.textContent = label
bar.appendChild(segment)
})
container.appendChild(bar)
cell.appendChild(container)
row.appendChild(cell)
item.parentNode.insertBefore(row, item.nextSibling)
}
let lines = document.querySelectorAll('[id^=cbi-disk-]')
lines.forEach((item) => {
let dev = item.id.match(/cbi-disk-(.*)/)[1]
@@ -19,7 +78,7 @@
} else if (cell.getAttribute("data-name") == "sata_ver" || cell.childNodes[1] && cell.childNodes[1].id.match(/sata_ver/)) {
cell.innerText = disk_info.sata_ver || "-"
} else if (cell.getAttribute("data-name") == "health_status" || cell.childNodes[1] && cell.childNodes[1].id.match(/health_status/)) {
cell.innerText = (disk_info.health || "-") + "\n" + (disk_info.status || "-")
cell.innerText = (disk_info.health || "-") + " / " + (disk_info.status || "-")
} else if (cell.getAttribute("data-name") == "health" || cell.childNodes[1] && cell.childNodes[1].id.match(/health/)) {
cell.innerText = disk_info.health || "-"
} else if (cell.getAttribute("data-name") == "status" || cell.childNodes[1] && cell.childNodes[1].id.match(/status/)) {
@@ -27,44 +86,7 @@
}
}
})
// handle partitons info
if (disk_info.partitions && disk_info.partitions.length > 0) {
let partitons_div
if (item.nodeName == "TR") {
partitons_div = '<tr width="100%" style="white-space:nowrap;"><td style="margin:0px; padding:0px; border:0px; white-space:nowrap;" colspan="15">'
} else if (item.nodeName == "DIV") {
partitons_div = '<div class="tr cbi-section-table-row cbi-rowstyle-1"><div style="white-space:nowrap; position:absolute; width:100%">'
}
let expand = 0
let need_expand = 0
disk_info.partitions.forEach((part) => {
let p = part.size / disk_info.size * 100
if (p <= 8) {
expand += 8
need_expand += p
part.part_percent = 8
}
})
let n = 0
disk_info.partitions.forEach((part) => {
let p = part.size / disk_info.size * 100
if (p > 8) {
part.part_percent = p * (100 - expand) / (100 - need_expand)
}
let part_percent = part.part_percent + '%'
let p_color = p_colors[n++]
if (n > 4) { n = 0 }
let inline_txt = (part.name != '-' && part.name || '') + ' ' + (part.fs != 'Free Space' && part.fs || '') + ' ' + part.size_formated + ' ' + (part.useage != '-' && part.useage || '')
let partiton_div = '<div title="' + inline_txt + '" style="color: #525F7F; display:inline-block; text-align:center;background-color:' + p_color + '; width:' + part_percent + '">' + inline_txt + '</div>'
partitons_div += partiton_div
})
if (item.nodeName == "TR") {
partitons_div += '</td></tr>'
} else if (item.nodeName == "DIV") {
partitons_div += '</div><div>&nbsp</div></div>'
}
item.insertAdjacentHTML('afterend', partitons_div);
}
renderPartitions(item, disk_info)
})
})
//raid table
@@ -82,28 +104,7 @@
}
}
})
// handle partitons info
let partitons_div
if (item.nodeName == "TR") {
partitons_div = '<tr width="100%" style="white-space:nowrap;"><td style="margin:0px; padding:0px; border:0px; white-space:nowrap;" colspan="15">'
} else if (item.nodeName == "DIV") {
partitons_div = '<div class="tr cbi-section-table-row cbi-rowstyle-1"><div style="white-space:nowrap; position:absolute; width:100%">'
}
let n = 0
disk_info.partitions.forEach((part) => {
let part_percent = part.size / disk_info.size * 100 + '%'
let p_color = p_colors[n++]
if (n > 4) { n = 0 }
let inline_txt = (part.name != '-' && part.name || '') + ' ' + (part.fs != 'Free Space' && part.fs || '') + ' ' + part.size_formated + ' ' + (part.useage != '-' && part.useage || '')
let partiton_div = '<div title="' + inline_txt + '" style="display:inline-block; text-align:center;background-color:' + p_color + '; width:' + part_percent + '">' + inline_txt + '</div>'
partitons_div += partiton_div
})
if (item.nodeName == "TR") {
partitons_div += '</td></tr>'
} else if (item.nodeName == "DIV") {
partitons_div += '</div><div>&nbsp</div></div>'
}
item.insertAdjacentHTML('afterend', partitons_div);
renderPartitions(item, disk_info)
})
})
}
+2 -8
View File
@@ -85,9 +85,9 @@ msgid "Data"
msgstr "Data"
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/partition.lua:121
msgid "Default 2048 sector alignment, support +size{b,k,m,g,t} in End Sector"
msgid "Default 1 MiB alignment, support +size{b,k,m,g,t} in End Sector"
msgstr ""
"Sektory wyrównywane są do wielokrotności 2048, sektor końcowy można podać "
"Domyślne wyrównanie wynosi 1 MiB, sektor końcowy można podać "
"jako rozmiar w postaci +size{b,k,m,g,t}"
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/btrfs.lua:114
@@ -506,9 +506,3 @@ msgstr ""
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/partition.lua:99
msgid "is in use! please unmount it first!"
msgstr "aktualnie jest w użyciu, proszę najpierw odmontować!"
#~ msgid "Partition NOT found!"
#~ msgstr "Nie znaleziono partycji!"
#~ msgid "Filesystem NOT support!"
#~ msgstr "System plików nie jest obsługiwany!"
+16 -1
View File
@@ -74,7 +74,7 @@ msgid "Data"
msgstr ""
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/partition.lua:121
msgid "Default 2048 sector alignment, support +size{b,k,m,g,t} in End Sector"
msgid "Default 1 MiB alignment, support +size{b,k,m,g,t} in End Sector"
msgstr ""
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/btrfs.lua:114
@@ -488,3 +488,18 @@ msgstr ""
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/partition.lua:99
msgid "is in use! please unmount it first!"
msgstr ""
msgid "Partition Name"
msgstr ""
msgid "Estimated lifetime exceeded"
msgstr ""
msgid "Normal"
msgstr ""
msgid "Warning"
msgstr ""
msgid "Urgent"
msgstr ""
+15 -6
View File
@@ -85,9 +85,9 @@ msgid "Data"
msgstr "数据"
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/partition.lua:121
msgid "Default 2048 sector alignment, support +size{b,k,m,g,t} in End Sector"
msgid "Default 1 MiB alignment, support +size{b,k,m,g,t} in End Sector"
msgstr ""
"默认2048扇区对齐,【中止扇区】支持 +容量{b,k,m,g,t} 格式,例:+500m +10g +1t"
"默认按 1 MiB 对齐,【终止扇区】支持 +容量{b,k,m,g,t} 格式,例:+500m +10g +1t"
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/btrfs.lua:114
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/btrfs.lua:118
@@ -504,8 +504,17 @@ msgstr ""
msgid "is in use! please unmount it first!"
msgstr "正在被使用!请先卸载!"
#~ msgid "Partition NOT found!"
#~ msgstr "分区未找到!"
msgid "Partition Name"
msgstr "分区名"
#~ msgid "Filesystem NOT support!"
#~ msgstr "文件系统不支持!"
msgid "Estimated lifetime exceeded"
msgstr "已超过预估寿命"
msgid "Normal"
msgstr "正常"
msgid "Warning"
msgstr "预警"
msgid "Urgent"
msgstr "紧急"
+15 -6
View File
@@ -85,9 +85,9 @@ msgid "Data"
msgstr "數據"
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/partition.lua:121
msgid "Default 2048 sector alignment, support +size{b,k,m,g,t} in End Sector"
msgid "Default 1 MiB alignment, support +size{b,k,m,g,t} in End Sector"
msgstr ""
"默認 2048 磁區對齊,【結束磁區】支持 +容量{b,k,m,g,t} 格式,例:+500m +10g "
"默認按 1 MiB 對齊,【結束磁區】支持 +容量{b,k,m,g,t} 格式,例:+500m +10g "
"+1t"
#: applications/luci-app-diskman/luasrc/model/cbi/diskman/btrfs.lua:114
@@ -505,8 +505,17 @@ msgstr ""
msgid "is in use! please unmount it first!"
msgstr "正在被使用!請先卸載!"
#~ msgid "Partition NOT found!"
#~ msgstr "分割區未找到!"
msgid "Partition Name"
msgstr "分割區名稱"
#~ msgid "Filesystem NOT support!"
#~ msgstr "文件系統不支持!"
msgid "Estimated lifetime exceeded"
msgstr "已超過預估壽命"
msgid "Normal"
msgstr "正常"
msgid "Warning"
msgstr "預警"
msgid "Urgent"
msgstr "緊急"
+49
View File
@@ -0,0 +1,49 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-modem-manager-next
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_LICENSE:=MIT
PKG_MAINTAINER:=Dante <dante@seckv.com>
LUCI_TITLE:=LuCI support for Cellular Modem Management (Next-Gen)
LUCI_DESCRIPTION:=Provides a modern LuCI Web management interface for 4G/5G cellular modems.
PKG_DESCRIPTION:=Provides a modern LuCI Web management interface for 4G/5G cellular modems.
LUCI_DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci \
+kmod-usb-net +kmod-usb-serial +kmod-usb-serial-option \
+kmod-usb-net-cdc-ncm +kmod-usb-net-cdc-mbim +kmod-usb-net-cdc-ether +kmod-usb-net-qmi-wwan \
+umbim +uqmi +sms-tool
LUCI_PKGARCH:=all
include $(TOPDIR)/feeds/luci/luci.mk
define Package/$(PKG_NAME)
$(call Package/luci/webtemplate)
TITLE:=$(LUCI_TITLE)
DEPENDS:=$(LUCI_DEPENDS)
PROVIDES:=luci-app-modem-manager
endef
define Package/$(PKG_NAME)/description
Provides a modern LuCI Web management interface for 4G/5G cellular modems.
endef
define Package/$(PKG_NAME)/conffiles
/etc/config/modem-manager
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] || {
chmod 0755 /etc/init.d/modem-manager 2>/dev/null || true
chmod 0755 /etc/hotplug.d/usb/20-modem-hotplug 2>/dev/null || true
chmod 0755 /usr/libexec/modem-at-executor /usr/libexec/modem-detect /usr/libexec/modem-collector 2>/dev/null || true
/etc/init.d/rpcd restart
/etc/init.d/modem-manager enable
/etc/init.d/modem-manager restart
}
exit 0
endef
# call BuildPackage - OpenWrt buildroot signature
+147
View File
@@ -0,0 +1,147 @@
# luci-app-modem-manager-next
现代化、轻量级、高可靠的 OpenWrt 蜂窝模组管理器(Next-Gen),专为 4G / 5G 移动宽带模组设计。
---
## 1. 架构设计与背景
在传统 OpenWrt 路由器环境中,官方及社区方案通常面临两大难题:
1. **重型依赖与功能受限**:基于 D-Bus 和桌面级 ModemManager (`mmcli`) 的方案占用内存大、启动慢,且对移远(Quectel)、广和通(Fibocom)、中兴(ZTE)等主流 5G/4G 模组的私有特性(锁频段、5G SA/NSA 模式切换、私有驱动切换、载波聚合查询等)支持不足,甚至默认禁止 AT 命令直接交互;
2. **串口竞争与死锁风险**:AT 串口为单工互斥字符设备。若前端定时轮询信号指标,极易与用户 AT 命令交互或模组底层注册产生冲突,引发端口死锁、数据乱码或缓冲区溢出。
`luci-app-modem-manager-next` 采用**“物理 Tab 独立解耦架构 + ucode 原生 RPC + 串口排他并发调度网关 + 异步双缓冲状态采集引擎”**,全面解决上述痛点。
---
## 2. 系统核心拓扑与执行流程
```text
【内核层与热插拔】 【前端 LuCI2 界面】
+-----------------------------+ +-----------------------------+
| USB 模组物理插拔 / 模式切换 | | 前端 Overview 页面 (5秒轮询)|
+-----------------------------+ +-----------------------------+
| |
内核 UEvent 触发 ubus call (非阻塞)
v v
+-----------------------------+ +-----------------------------+
| /etc/hotplug.d/usb/ | | rpcd ucode 插件 (luci.modem)|
| 20-modem-hotplug 脚本 | | 直接秒级读取内存缓存 JSON |
+-----------------------------+ +-----------------------------+
| ^
异步触发 | 毫秒级零延迟读取
v |
+-----------------------------+ +-----------------------------+
| modem-detect (快速探测) | ── 更新设备缓存 ──> | /tmp/run/modem-manager/ |
+-----------------------------+ | devices.json & status.json |
+-----------------------------+
^
| 原子写入最新指标
+-----------------------------------------------------------+ |
| 【后台单向采集守护器 (modem-collector)】 | |
| | ──────+
| 1. 定时触发采集周期 (默认 5 秒) |
| 2. 检查终端活跃标记 (/var/lock/modem_terminal_active.lock) |
| ├─ [活跃中] -> 主动休眠避让,绝不碰串口 |
| └─ [空闲中] -> 带 flock 单线程安全读取 AT 状态指标 |
+-----------------------------------------------------------+
▲
│ 标记终端操作已介入 (保持避让窗口 10 秒)
+-----------------------------------------------------------+
| 【用户在 AT 终端页执行指令 (Terminal Tab)】 |
| 独占拥有最高优先级串口控制权,绝无后台轮询争抢导致死锁 |
+-----------------------------------------------------------+
```
---
## 3. 核心机制实现
### 3.1 模块变动触发扫描机制 (Hotplug-driven Scan)
- 监听脚本:`/etc/hotplug.d/usb/20-modem-hotplug`;
- 行为:当模组发生物理插入、拔出,或在配置页面执行模式切换导致 USB 端点重新枚举时,内核 UEvent 异步唤醒脚本,触发 `/usr/libexec/modem-detect --cache` 重新建立设备拓扑清单,原子更新 `/tmp/run/modem-manager/devices.json`,并通过 ubus 广播事件通知前端视图。
### 3.2 初始状态扫描探测机制 (Boot & Startup Init)
- 引导期预热:在 `/etc/init.d/modem-manager` 启动引导期,后台异步执行一次 `modem-detect --cache`;
- 前端极速首屏:前端 RPC `get_devices` 优先读取内存缓存,实现 0 毫秒首屏呈现,彻底免除用户打开页面时的等待开销。
### 3.3 异步状态双缓冲与 AT 终端互斥主动避让 (Async Decoupled Polling & Active Yielding)
- **单一生产者**:后台常驻守护进程 `/usr/libexec/modem-collector` 作为与串口交互的唯一常规生产者,单线程采集 CSQ、CPIN、COPS、QENG、QTEMP 并原子更新 `/tmp/run/modem-manager/status.json`;
- **终端最高优先级**:用户通过控制台或快捷按钮发送指令(`send_at`)时,立即触碰激活 `/var/lock/modem_terminal_active.lock`;
- **主动退避**:后台采集器若检测到终端在过去 10 秒内有操作,立即放弃当前轮询并主动休眠,将串口控制权完全让渡给用户;
- **前端彻底解耦**:前端 Overview 页面的 5 秒轮询仅读取内存缓存文件,调用耗时为 `0.00s`,零串口开销,杜绝死锁与缓冲区阻塞。
### 3.4 纯 POSIX 原生串口执行器 (modem-at-executor)
- 零外部依赖:摆脱对 `chat`、`stty` 或专用工具的依赖,100% 兼容裁剪版 BusyBox;
- 互斥安全:基于 `flock -n -x 9` 非阻塞轮询实现文件级排他锁,防止多进程竞争;
- 超时保护与动态自适应:基于管道和时间窗口读取,普通指令 3 秒超时,长耗时指令(如 `AT+COPS=?` 全频段搜网)自适应动态提升至 60 秒超时,防止误截断。
### 3.5 AT 终端容错性与交互易用性增强
- **中英文标点智能转换**:自动将全角/中文引号(`“”`)、逗号(`,`)、问号(`?`)、冒号(`:`)、等号(`=`)、加号(`+`)智能清洗为标准半角 ASCII 字符,防止模组因多字节编码报错;
- **智能推测与模糊建议 ("Did you mean?")**:当指令执行失败时,利用编辑距离算法计算高频知识库,自动推测用户意图(例如误输 `AT+CPIM?` 时提示推测 `AT+CPIN?`,支持一键填入);
- **行内实时自动补全 (Autocomplete)**:打字时自动匹配候选指令及中文功能简介,支持 Esc 快速关闭;
- **CME / CMS 错误码中文语义化**:内置 3GPP TS 27.007 错误码字典,将晦涩的数字代码(如 `+CME ERROR: 10/11/13/14`)自动解析为可读中文释义;
- **高危破坏性指令拦截预警**:拦截 `AT&F`、`AT+RESTORE` 等可能导致出厂重置的高危操作,弹窗二次确认;
- **耗时测量与日志导出**:毫秒级精度测量下发耗时,支持自定义收藏夹管理与一键导出排障日志文件。
### 3.6 主控制串口用户接管与持久化机制 (User-Configured AT Port Override)
- **设置入口**:在【设置 (Settings)】页面中提供专属“串口与通信参数配置”卡片;
- **候选端口与自定义路径**:下拉列表枚举该模组暴露的所有物理串口,默认选中国际推荐口(标有 `[自动推荐]`),并支持选择 `[自定义设备路径]` 手动输入专用软链接(如 `/dev/modem_at` 或 `/dev/ttyACM0`);
- **配置与运行态全链路接管**:保存后写入 UCI `/etc/config/modem-manager` 的 `global.port`,后台采集器 `modem-collector`、前台总览页 `Overview` 与 RPC 接口均以用户指定端口作为最高优先级,彻底消除端口序号漂移和多串口设备误判风险。
### 3.7 物联网卡零干扰熔断机制与安全短信模块 (Safe SMS with IoT Guard)
- **默认全局熔断关闭**:考虑到部分物联网卡(IoT SIM)、企业遥测专用卡或定向纯流量卡对 SMS 业务存在特殊风控限制,若模组自动轮询或读取短信可能导致异常扣费、被运营商封卡或阻塞 AT 通道,系统在 UCI 中默认设置 `global.sms_enable=0`;
- **零串口开销**:在短信模块处于关闭状态时,后台采集守护器和 RPC 接口绝对不向模组发送任何短信读取、容量查询或配置指令;
- **显式激活与收发管理**:用户在【短信 (SMS)】页面可一键安全启用,支持 SIM 卡短信容量占用进度查看、收件箱列表阅读、UCS2/UTF-8 字符自适应解码、写信发送(带文本长度统计与手机号校验)及一键清空所有短信。
---
## 4. 目录与文件布局
```text
luci-app-modem-manager-next/
├── Makefile # 软件包构建与权限配置
├── README.md # 项目架构与设计文档
├── root/
│ ├── etc/
│ │ ├── config/modem-manager # UCI 配置文件 (含 sms_enable)
│ │ ├── hotplug.d/usb/20-modem-hotplug # USB 热插拔事件感知脚本
│ │ ├── init.d/modem-manager # 系统守护服务脚本
│ │ └── uci-defaults/99_modem-manager-init # 初始化脚本
│ └── usr/
│ ├── libexec/
│ │ ├── modem-at-executor # 纯 POSIX 串口排他安全执行器 (含 SMS 发送)
│ │ ├── modem-collector # 后台异步状态采集与避让守护器
│ │ └── modem-detect # sysfs 设备枚举与关联扫描工具
│ └── share/
│ ├── modem-manager/templates/ # 厂商预置模版库
│ │ ├── quectel.json # 移远模组规则 (RM500Q, RM500U, RG500U 等)
│ │ ├── fibocom.json # 广和通模组规则 (FM650, FG650, NL668 等)
│ │ ├── zte.json # 中兴微模组规则
│ │ └── generic.json # 通用 3GPP 模组规则
│ ├── luci/menu.d/
│ │ └── luci-app-modem-manager.json # 菜单路由定义 (含 SMS Tab)
│ └── rpcd/
│ ├── acl.d/luci-app-modem-manager.json # RPC 鉴权规则
│ └── ucode/luci.modem # ucode RPC 插件 (含 SMS 接口)
├── htdocs/luci-static/resources/view/modem/
│ ├── overview.js # 【总览】:信号仪表盘、基站详情、SIM状态
│ ├── settings.js # 【设置】:驱动协议切换、频段锁定、APN
│ ├── sms.js # 【短信】:物联网卡安全防护、短信收发展示
│ ├── templates.js # 【模版】:厂商模版查看与参数管理
│ ├── terminal.js # 【终端】:高级交互控制台与快捷预设指令
│ └── log.js # 【日志】:AT 交互与系统运行日志
└── po/
├── templates/modem.pot # 翻译模板
└── zh_Hans/modem.po # 中文简体翻译 (无 Emoji、无中英混杂)
```
---
## 5. 验证与兼容性
本软件包已在真实硬件环境中完成验证:
- **测试环境**:ImmortalWrt 25.12 (MediaTek Filogic, aarch64)
- **测试模组**:移远通信 RM500U-CNV (USB ID: `2c7c:0900`)
- **驱动协议**:`cdc_ncm` (`usb0`)
- **网络制式**:中国电信 5G 独立组网 (NR5G-SA, N78 频段)
@@ -0,0 +1,96 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require ui';
'require dom';
const callGetLog = rpc.declare({
object: 'luci.modem',
method: 'get_log',
expect: { log: '' }
});
const callClearLog = rpc.declare({
object: 'luci.modem',
method: 'clear_log'
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return L.resolveDefault(callGetLog(), '');
},
handleClearLog: function() {
const self = this;
callClearLog().then(function() {
const logArea = document.getElementById('modem_log_content');
if (logArea) logArea.value = '';
ui.addNotification(null, E('p', {}, _('Log buffer cleared successfully.')), 'info');
});
},
updateLog: function() {
return callGetLog().then(function(res) {
const logArea = document.getElementById('modem_log_content');
if (logArea) {
let logText = (typeof res === 'string') ? res : ((res && res.log) ? res.log : '');
logArea.value = logText || _('No log entries recorded yet.');
logArea.scrollTop = logArea.scrollHeight;
}
}).catch(function() {});
},
render: function(logData) {
const self = this;
let initialLog = (typeof logData === 'string') ? logData : ((logData && logData.log) ? logData.log : '');
if (!initialLog) initialLog = _('No log entries recorded yet.');
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('System Log')),
E('div', { 'class': 'cbi-map-descr' }, _('View background AT command traces, hardware detection events, and auto-reconnect activities.'))
]);
const logSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', { 'style': 'display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box;' }, [
E('span', {}, _('Modem Manager Logs')),
E('span', { 'style': 'display: inline-flex; gap: 8px; align-items: center; margin-left: auto;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reload',
'value': _('Refresh'),
'style': 'font-size: 11px;',
'click': ui.createHandlerFn(self, 'updateLog')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reset',
'value': _('Clear Log'),
'style': 'font-size: 11px;',
'click': ui.createHandlerFn(self, 'handleClearLog')
})
])
]),
E('div', { 'class': 'cbi-section-node' }, [
E('textarea', {
'id': 'modem_log_content',
'class': 'cbi-input-textarea',
'style': 'width: 100%; min-height: 420px; font-family: monospace; font-size: 12px; background: #fafafa; border: 1px solid #ccc; box-sizing: border-box; padding: 10px;',
'readonly': 'readonly',
'wrap': 'off'
}, [ initialLog ])
])
]);
container.appendChild(logSection);
poll.add(function() {
return self.updateLog();
}, 5);
return container;
}
});
@@ -0,0 +1,430 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require ui';
'require dom';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callGetStatus = rpc.declare({
object: 'luci.modem',
method: 'get_status',
params: [ 'port', 'primary_net' ]
});
const callRefreshStatus = rpc.declare({
object: 'luci.modem',
method: 'refresh_status',
params: [ 'port', 'primary_net' ]
});
const callDeviceAction = rpc.declare({
object: 'luci.modem',
method: 'device_action',
params: [ 'port', 'action' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
selectedNet: null,
devicesList: [],
load: function() {
return L.resolveDefault(callGetDevices(), []);
},
renderProgressBar: function(percent, label, color) {
const bg = color || '#2bc1a4';
return E('div', { 'style': 'width: 220px; background: #eee; border-radius: 3px; height: 16px; position: relative; display: inline-block; vertical-align: middle; margin-right: 10px; overflow: hidden;' }, [
E('div', {
'style': 'height: 100%; width: ' + Math.min(100, Math.max(0, percent)) + '%; background: ' + bg + '; transition: width 0.3s;'
}),
E('span', {
'style': 'position: absolute; width: 100%; top: 0; left: 0; text-align: center; font-size: 11px; line-height: 16px; color: #333; font-weight: bold;'
}, label)
]);
},
handleAction: function(action) {
const self = this;
if (!self.selectedPort) {
ui.addNotification(null, E('p', {}, _('Please select a valid modem port first.')), 'error');
return;
}
ui.showModal(_('Executing Action'), [
E('p', { 'class': 'spinning' }, _('Sending action to cellular module, please wait...'))
]);
callDeviceAction(self.selectedPort, action).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Action executed successfully.')), 'info');
self.forceRefresh();
} else {
ui.addNotification(null, E('p', {}, _('Action failed: ') + (res ? res.output : _('Unknown error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, err.message), 'error');
});
},
forceRefresh: function() {
const self = this;
if (!self.selectedPort) return;
return callRefreshStatus(self.selectedPort, self.selectedNet).then(function(res) {
self.applyStatusDOM(res);
});
},
updateStatus: function() {
const self = this;
if (!self.selectedPort)
return Promise.resolve();
return callGetStatus(self.selectedPort, self.selectedNet).then(function(res) {
self.applyStatusDOM(res);
}).catch(function(e) {});
},
formatBytes: function(bytes) {
if (bytes === null || bytes === undefined || isNaN(bytes) || bytes < 0) return '0 B';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const k = 1024;
const i = Math.floor(Math.log(bytes) / Math.log(k));
const val = (bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 2);
return val + ' ' + units[i];
},
formatSpeed: function(bytesPerSec) {
if (bytesPerSec === null || bytesPerSec === undefined || isNaN(bytesPerSec) || bytesPerSec < 0) return '0 B/s';
return this.formatBytes(bytesPerSec) + '/s';
},
formatUptime: function(sec) {
if (!sec || sec <= 0) return '-';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
let str = (h < 10 ? '0' : '') + h + ':' + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
if (d > 0) return d + ' ' + _('days') + ' ' + str;
return str;
},
applyStatusDOM: function(status) {
const self = this;
if (!status) return;
const csq = status.csq || {};
const cell = status.cell || {};
const net = status.network || {};
const tTemp = document.getElementById('modem_temp_val');
if (tTemp) dom.content(tTemp, (status.temperature !== null && status.temperature !== undefined) ? (status.temperature + ' °C') : _('Not Supported'));
const tOp = document.getElementById('modem_operator_val');
if (tOp) dom.content(tOp, status.operator || _('Searching'));
const tRat = document.getElementById('modem_rat_val');
if (tRat) dom.content(tRat, status.act || _('Unknown'));
const tBand = document.getElementById('modem_band_val');
if (tBand) dom.content(tBand, cell.band || _('None'));
const tFw = document.getElementById('modem_firmware_val');
if (tFw && status.firmware) dom.content(tFw, status.firmware);
const tImei = document.getElementById('modem_imei_val');
if (tImei && status.imei) dom.content(tImei, status.imei);
const tSim = document.getElementById('modem_sim_val');
if (tSim) {
let sText = _('Normal');
let sColor = 'green';
if (status.sim_status === 'PIN_LOCKED') { sText = _('PIN Locked'); sColor = 'orange'; }
else if (status.sim_status === 'PUK_LOCKED') { sText = _('PUK Locked'); sColor = 'red'; }
else if (status.sim_status === 'NOT_INSERTED') { sText = _('Not Detected'); sColor = 'red'; }
dom.content(tSim, E('span', { 'style': 'color: ' + sColor + '; font-weight: bold;' }, sText));
}
const sigCsq = document.getElementById('modem_sig_csq');
if (sigCsq) {
dom.content(sigCsq, [
self.renderProgressBar(csq.percent || 0, (csq.csq || 0) + ' / 31 (' + (csq.percent || 0) + '%)', '#2bc1a4'),
E('span', {}, (csq.rssi_dbm ? csq.rssi_dbm + ' dBm' : ''))
]);
}
const sigRsrp = document.getElementById('modem_sig_rsrp');
if (sigRsrp) {
let rsrpVal = (cell.rsrp !== null && cell.rsrp !== undefined && cell.rsrp < 0 && cell.rsrp >= -145) ? cell.rsrp : null;
let rsrpPercent = rsrpVal ? Math.max(0, Math.min(100, Math.floor((rsrpVal + 140) * (100 / 96)))) : 0;
let qualityText = '';
if (rsrpVal !== null) {
if (rsrpVal >= -85) qualityText = _('Excellent');
else if (rsrpVal >= -100) qualityText = _('Good');
else qualityText = _('Weak');
}
dom.content(sigRsrp, [
self.renderProgressBar(rsrpPercent, rsrpVal ? (rsrpVal + ' dBm') : _('Calculating'), '#4895ef'),
E('span', {}, qualityText)
]);
}
const sigSinr = document.getElementById('modem_sig_sinr');
if (sigSinr) {
let sinrVal = (cell.sinr !== null && cell.sinr !== undefined) ? cell.sinr : null;
let sinrPercent = sinrVal ? Math.max(0, Math.min(100, Math.floor((sinrVal + 10) * 2.5))) : 0;
dom.content(sigSinr, [
self.renderProgressBar(sinrPercent, sinrVal ? (sinrVal + ' dB') : _('Calculating'), '#4cc9f0')
]);
}
const cellPci = document.getElementById('modem_cell_pci');
if (cellPci) dom.content(cellPci, cell.pci || '-');
const cellId = document.getElementById('modem_cell_id');
if (cellId) dom.content(cellId, cell.cell_id || '-');
const cellTac = document.getElementById('modem_cell_tac');
if (cellTac) dom.content(cellTac, cell.tac || '-');
const cellArfcn = document.getElementById('modem_cell_arfcn');
if (cellArfcn) dom.content(cellArfcn, cell.earfcn || '-');
const netStatus = document.getElementById('modem_net_status');
if (netStatus) {
let isConn = !!net.connected;
let netText = isConn ? _('Connected') : _('Disconnected');
let netColor = isConn ? '#4caf50' : '#f44336';
let uptimeStr = (isConn && net.uptime) ? self.formatUptime(net.uptime) : '';
dom.content(netStatus, [
E('span', {
'style': 'background-color: ' + netColor + '; color: #fff; padding: 2px 8px; border-radius: 3px; font-weight: bold; font-size: 11px; margin-right: 8px;'
}, netText),
uptimeStr ? E('span', { 'style': 'color: #555; font-size: 12px;' }, _('Uptime: ') + uptimeStr) : ''
]);
}
const netPhys = document.getElementById('modem_net_phys');
if (netPhys) {
let devName = net.device || self.selectedNet || _('Not Registered');
dom.content(netPhys, E('span', { 'style': 'font-weight: bold; font-family: monospace; color: #0366d6;' }, devName));
}
const netIface = document.getElementById('modem_net_iface');
if (netIface) {
let ifaceName = net.interface || _('Unmanaged (Config in OpenWrt Interfaces)');
let protoStr = net.proto ? ' [' + net.proto.toUpperCase() + ']' : '';
dom.content(netIface, ifaceName + protoStr);
}
const netIp4 = document.getElementById('modem_net_ip4');
if (netIp4) dom.content(netIp4, (net.ip4 && net.ip4.length > 0) ? net.ip4.join(', ') : '-');
const netIp6 = document.getElementById('modem_net_ip6');
if (netIp6) dom.content(netIp6, (net.ip6 && net.ip6.length > 0) ? net.ip6.join(', ') : '-');
const netTraffic = document.getElementById('modem_net_traffic');
if (netTraffic) {
const rxFormatted = self.formatBytes(net.rx_bytes || 0);
const txFormatted = self.formatBytes(net.tx_bytes || 0);
const totalFormatted = self.formatBytes((net.rx_bytes || 0) + (net.tx_bytes || 0));
const rxPackets = net.rx_packets || 0;
const txPackets = net.tx_packets || 0;
dom.content(netTraffic, [
E('div', { 'style': 'display: flex; gap: 24px; align-items: center; white-space: nowrap;' }, [
E('span', {}, [
E('strong', {}, _('Download') + ': '),
rxFormatted + (rxPackets > 0 ? ' / ' + rxPackets + ' ' + _('Packets') : '')
]),
E('span', {}, [
E('strong', {}, _('Upload') + ': '),
txFormatted + (txPackets > 0 ? ' / ' + txPackets + ' ' + _('Packets') : '')
]),
E('span', { 'style': 'color: #333;' }, [
E('strong', {}, _('Total Traffic') + ': '),
totalFormatted
])
])
]);
}
},
render: function(devicesData) {
const self = this;
self.devicesList = Array.isArray(devicesData) ? devicesData : ((devicesData && Array.isArray(devicesData.devices)) ? devicesData.devices : []);
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('Overview')),
E('div', { 'class': 'cbi-map-descr' }, _('Real-time overview of cellular modem hardware, network registration, signal metrics, and data connection.'))
]);
if (self.devicesList.length === 0) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 20px; padding: 20px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 10px 0; color: #d32f2f; font-size: 16px;' }, _('No Active Cellular Modem Detected')),
E('p', { 'style': 'margin: 0 0 15px 0; line-height: 1.6; color: #555;' }, _('No active 4G or 5G cellular modem detected on the USB bus. Please verify hardware connection, power supply, or driver status.')),
E('div', {}, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('Rescan Devices'),
'click': function() {
location.reload();
}
})
])
]));
return container;
}
self.selectedPort = self.devicesList[0].primary_port;
self.selectedNet = self.devicesList[0].primary_net;
const firstDev = self.devicesList[0] || {};
const hwSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Hardware Information')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '16%' }, _('Manufacturer')),
E('td', { 'class': 'td left', 'width': '34%' }, firstDev.vendor || '-'),
E('td', { 'class': 'td left', 'width': '16%' }, _('Model')),
E('td', { 'class': 'td left', 'width': '34%' }, firstDev.model || '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Firmware Version')),
E('td', { 'class': 'td left', 'id': 'modem_firmware_val' }, '-'),
E('td', { 'class': 'td left' }, _('IMEI')),
E('td', { 'class': 'td left', 'id': 'modem_imei_val' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('USB Identifiers')),
E('td', { 'class': 'td left' }, (firstDev.vid && firstDev.pid) ? (firstDev.vid + ':' + firstDev.pid) : '-'),
E('td', { 'class': 'td left' }, _('Core Temperature')),
E('td', { 'class': 'td left', 'id': 'modem_temp_val' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Driver Binding')),
E('td', { 'class': 'td left' }, firstDev.driver || _('Option Driver')),
E('td', { 'class': 'td left' }, _('SIM Status')),
E('td', { 'class': 'td left', 'id': 'modem_sim_val' }, '-')
])
])
]);
container.appendChild(hwSection);
const sigSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Cellular Network and Signal Metrics')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '16%' }, _('Operator')),
E('td', { 'class': 'td left', 'width': '34%', 'id': 'modem_operator_val' }, '-'),
E('td', { 'class': 'td left', 'width': '16%' }, _('Access Technology')),
E('td', { 'class': 'td left', 'width': '34%', 'id': 'modem_rat_val' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Active Band')),
E('td', { 'class': 'td left', 'id': 'modem_band_val' }, '-'),
E('td', { 'class': 'td left' }, _('Physical Cell ID')),
E('td', { 'class': 'td left', 'id': 'modem_cell_pci' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Cell ID')),
E('td', { 'class': 'td left', 'id': 'modem_cell_id' }, '-'),
E('td', { 'class': 'td left' }, _('Tracking Area Code')),
E('td', { 'class': 'td left', 'id': 'modem_cell_tac' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Carrier Channel')),
E('td', { 'class': 'td left', 'id': 'modem_cell_arfcn' }, '-'),
E('td', { 'class': 'td left' }, _('Signal Strength CSQ')),
E('td', { 'class': 'td left', 'id': 'modem_sig_csq' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Signal Power RSRP')),
E('td', { 'class': 'td left', 'id': 'modem_sig_rsrp' }, '-'),
E('td', { 'class': 'td left' }, _('Signal Quality SINR')),
E('td', { 'class': 'td left', 'id': 'modem_sig_sinr' }, '-')
])
])
]);
container.appendChild(sigSection);
const connSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Network Interface Connection')),
E('table', { 'class': 'table cbi-section-table' }, [
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'width': '16%' }, _('Connection State')),
E('td', { 'class': 'td left', 'width': '34%', 'id': 'modem_net_status' }, '-'),
E('td', { 'class': 'td left', 'width': '16%' }, _('Physical & Logical Interface')),
E('td', { 'class': 'td left', 'width': '34%' }, [
E('div', {}, [
E('span', { 'style': 'color: #333; font-weight: bold; margin-right: 4px;' }, _('Physical:')),
E('span', { 'id': 'modem_net_phys' }, '-'),
E('span', { 'style': 'color: #888; margin: 0 8px;' }, '|'),
E('span', { 'style': 'color: #333; font-weight: bold; margin-right: 4px;' }, _('Logical:')),
E('span', { 'id': 'modem_net_iface' }, '-')
])
])
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Modem IPv4 Address')),
E('td', { 'class': 'td left', 'id': 'modem_net_ip4' }, '-'),
E('td', { 'class': 'td left' }, _('Modem IPv6 Address')),
E('td', { 'class': 'td left', 'id': 'modem_net_ip6' }, '-')
]),
E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left' }, _('Traffic Statistics')),
E('td', { 'class': 'td left', 'colspan': '3', 'id': 'modem_net_traffic' }, '-')
])
]),
E('div', { 'style': 'margin-top: 15px; display: flex; gap: 10px;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reload',
'value': _('Refresh'),
'click': ui.createHandlerFn(self, 'forceRefresh')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reset',
'value': _('Restart Modem'),
'click': ui.createHandlerFn(self, 'handleAction', 'reboot')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('Toggle Airplane Mode'),
'click': ui.createHandlerFn(self, 'handleAction', 'airplane_on')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reload',
'value': _('Reset RF Radio'),
'click': ui.createHandlerFn(self, 'handleAction', 'reset_radio')
})
])
]);
container.appendChild(connSection);
poll.add(function() {
return self.updateStatus();
}, 5);
setTimeout(function() {
self.updateStatus();
}, 200);
return container;
}
});
@@ -0,0 +1,565 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callGetConfig = rpc.declare({
object: 'luci.modem',
method: 'get_config',
expect: { port: '', baud: '115200' }
});
const callSaveConfig = rpc.declare({
object: 'luci.modem',
method: 'save_config',
params: [ 'port', 'baud' ]
});
const callSetMode = rpc.declare({
object: 'luci.modem',
method: 'set_mode',
params: [ 'port', 'mode' ]
});
const callSetPreferences = rpc.declare({
object: 'luci.modem',
method: 'set_preferences',
params: [ 'port', 'rat', 'bands_lte', 'bands_nr5g' ]
});
const callSetApn = rpc.declare({
object: 'luci.modem',
method: 'set_apn',
params: [ 'port', 'apn', 'pdp_type' ]
});
const callGetApn = rpc.declare({
object: 'luci.modem',
method: 'get_apn',
params: [ 'port' ]
});
const callGetCellLock = rpc.declare({
object: 'luci.modem',
method: 'get_cell_lock',
params: [ 'port' ]
});
const callSetCellLock = rpc.declare({
object: 'luci.modem',
method: 'set_cell_lock',
params: [ 'port', 'mode', 'earfcn', 'pci' ]
});
const callGetStatus = rpc.declare({
object: 'luci.modem',
method: 'get_status',
params: [ 'port', 'primary_net' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
selectedNet: null,
devicesList: [],
configData: {},
cellLockData: {},
apnData: {},
load: function() {
const self = this;
return Promise.all([
L.resolveDefault(callGetDevices(), []),
L.resolveDefault(callGetConfig(), {})
]).then(function(results) {
const devs = results[0];
const cfg = results[1] || {};
const list = Array.isArray(devs) ? devs : ((devs && Array.isArray(devs.devices)) ? devs.devices : []);
const port = cfg.port || (list.length > 0 ? list[0].primary_port : null);
if (port) {
return Promise.all([
L.resolveDefault(callGetCellLock(port), {}),
L.resolveDefault(callGetApn(port), {})
]).then(function(extraResults) {
return [ devs, cfg, extraResults[0] || {}, extraResults[1] || {} ];
});
}
return [ devs, cfg, {}, {} ];
});
},
handleApplySettings: function() {
const self = this;
const portSelect = document.getElementById('modem_port_select');
const customPortInput = document.getElementById('modem_custom_port');
let targetPort = (portSelect && portSelect.value === 'custom') ? (customPortInput ? customPortInput.value.trim() : '') : (portSelect ? portSelect.value : '');
if (!targetPort) {
targetPort = self.selectedPort;
}
if (!targetPort) {
ui.addNotification(null, E('p', {}, _('Please specify or select an active AT communication port.')), 'error');
return;
}
const baudVal = document.getElementById('modem_baud_select').value || '115200';
const modeEl = document.querySelector('input[name="modem_mode"]:checked');
const modeVal = modeEl ? modeEl.value : 'ncm';
const ratEl = document.getElementById('modem_rat_select');
const ratVal = ratEl ? ratEl.value : 'AUTO';
const apnEl = document.getElementById('modem_apn');
const apnVal = apnEl ? apnEl.value.trim() : 'cmiot';
const pdpEl = document.getElementById('modem_pdp');
const pdpVal = pdpEl ? pdpEl.value : 'IPV4V6';
const lteEls = document.querySelectorAll('.modem-band-lte:checked');
const nrEls = document.querySelectorAll('.modem-band-nr:checked');
const bandsLte = [];
const bandsNr = [];
for (let i = 0; i < lteEls.length; i++) bandsLte.push(lteEls[i].value);
for (let i = 0; i < nrEls.length; i++) bandsNr.push(nrEls[i].value);
const cellModeEl = document.getElementById('modem_cell_lock_mode');
const cellLockMode = cellModeEl ? cellModeEl.value : 'none';
const cellEarfcnEl = document.getElementById('modem_cell_lock_earfcn');
const cellLockEarfcn = cellEarfcnEl ? cellEarfcnEl.value.trim() : '';
const cellPciEl = document.getElementById('modem_cell_lock_pci');
const cellLockPci = cellPciEl ? cellPciEl.value.trim() : '';
if (cellLockMode !== 'none') {
if (!cellLockEarfcn || !cellLockPci) {
ui.addNotification(null, E('p', {}, _('Both carrier frequency and physical cell ID are required for cell locking.')), 'error');
return;
}
if (!/^[0-9]+$/.test(cellLockEarfcn) || !/^[0-9]+$/.test(cellLockPci)) {
ui.addNotification(null, E('p', {}, _('Carrier frequency and physical cell ID must be valid positive integers.')), 'error');
return;
}
}
ui.showModal(_('Applying Configuration'), [
E('p', { 'class': 'spinning' }, _('Saving port settings and sending configuration commands, please wait...'))
]);
callSaveConfig(targetPort, baudVal)
.then(function() {
return callSetMode(targetPort, modeVal);
})
.then(function() {
return callSetPreferences(targetPort, ratVal, bandsLte, bandsNr);
})
.then(function() {
return callSetApn(targetPort, apnVal, pdpVal);
})
.then(function() {
return callSetCellLock(targetPort, cellLockMode, cellLockEarfcn, cellLockPci);
})
.then(function(lockRes) {
ui.hideModal();
if (lockRes && lockRes.success === false) {
ui.addNotification(null, E('p', {}, _('Settings saved, but cell lock failed: ') + (lockRes.error || lockRes.output || '')), 'warning');
} else {
ui.addNotification(null, E('p', {}, _('Modem settings successfully applied.')), 'info');
}
})
.catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to apply settings: ') + err.message), 'error');
});
},
render: function(loadData) {
const self = this;
const devicesData = loadData ? loadData[0] : null;
self.configData = loadData ? loadData[1] : {};
self.cellLockData = loadData ? loadData[2] : {};
self.apnData = loadData ? loadData[3] : {};
self.devicesList = Array.isArray(devicesData) ? devicesData : ((devicesData && Array.isArray(devicesData.devices)) ? devicesData.devices : []);
if (self.devicesList.length > 0) {
self.selectedPort = self.configData.port || self.devicesList[0].primary_port;
self.selectedNet = self.devicesList[0].primary_net;
}
const deviceOptions = [];
self.devicesList.forEach(function(dev, idx) {
let label = dev.vendor + ' ' + dev.model + ' (' + (dev.primary_port || _('No Port')) + ')';
const optAttrs = { value: dev.primary_port, 'data-net': dev.primary_net };
if (idx === 0) optAttrs.selected = '';
deviceOptions.push(E('option', optAttrs, label));
});
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('Settings')),
E('div', { 'class': 'cbi-map-descr' }, _('Configure driver protocols, lock preferred access technologies, customize APN credentials, and manage cellular network interfaces.'))
]);
if (self.devicesList.length === 0) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 15px; padding: 15px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 8px 0; color: #d32f2f;' }, _('No Cellular Modem Detected')),
E('p', { 'style': 'margin: 0; color: #555;' }, _('No active cellular modem detected. You can still pre-configure serial port path or refresh after connecting.'))
]));
}
// Target Modem Selector
const selectorNode = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Target Modem Selection')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Target Modem')),
E('div', { 'class': 'cbi-value-field' }, [
deviceOptions.length > 0 ? E('select', {
'id': 'modem_device_select',
'class': 'cbi-input-select',
'style': 'min-width: 320px;',
'change': function(ev) {
self.selectedPort = ev.target.value;
let opt = ev.target.options[ev.target.selectedIndex];
self.selectedNet = opt ? opt.getAttribute('data-net') : null;
}
}, deviceOptions) : E('span', { 'style': 'color: red;' }, _('No cellular modems detected.')),
E('div', { 'class': 'cbi-value-description' }, _('Select the active cellular module to configure.'))
])
])
])
]);
container.appendChild(selectorNode);
// Serial Port Customization Section
const dev = self.devicesList[0] || {};
const availablePorts = dev.ports || [];
const activeConfigPort = self.configData.port || dev.primary_port || '';
const portSelectOptions = [];
let matchedInList = false;
availablePorts.forEach(function(p) {
let isSelected = (p === activeConfigPort);
if (isSelected) matchedInList = true;
let isAuto = (p === dev.primary_port);
let optLabel = p + (isAuto ? ' [' + _('Auto Recommended') + ']' : '');
const optAttrs = { value: p };
if (isSelected) optAttrs.selected = '';
portSelectOptions.push(E('option', optAttrs, optLabel));
});
const customOptAttrs = { value: 'custom' };
if (!matchedInList && activeConfigPort !== '') customOptAttrs.selected = '';
portSelectOptions.push(E('option', customOptAttrs, _('Manual Custom Path')));
const portSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Serial Port and Communication Configuration')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Primary AT Port')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'modem_port_select',
'class': 'cbi-input-select',
'style': 'min-width: 280px;',
'change': function(ev) {
const customWrap = document.getElementById('modem_custom_port_wrap');
if (customWrap) {
customWrap.style.display = (ev.target.value === 'custom') ? 'block' : 'none';
}
}
}, portSelectOptions),
E('div', {
'id': 'modem_custom_port_wrap',
'style': 'margin-top: 8px; display: ' + ((!matchedInList && activeConfigPort !== '') ? 'block' : 'none') + ';'
}, [
E('input', {
'type': 'text',
'id': 'modem_custom_port',
'class': 'cbi-input-text',
'style': 'min-width: 280px;',
'placeholder': '/dev/ttyUSB2 or /dev/modem_at',
'value': (!matchedInList ? activeConfigPort : '')
}),
E('span', { 'style': 'margin-left: 10px; color: #888; font-size: 12px;' }, _('Specify custom node path'))
]),
E('div', { 'class': 'cbi-value-description' }, _('Select or enter the dedicated AT control port for background status polling and control commands.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Serial Baudrate')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'modem_baud_select', 'class': 'cbi-input-select', 'style': 'min-width: 150px;' },
['9600', '38400', '57600', '115200', '230400', '460800', '921600'].map(function(b) {
const opt = { value: b };
if (b === (self.configData.baud || '115200')) opt.selected = '';
return E('option', opt, b);
})
),
E('div', { 'class': 'cbi-value-description' }, _('Standard cellular modem AT serial baudrate (Default: 115200).'))
])
])
])
]);
container.appendChild(portSection);
// Protocol Mode Switching Section
const modeSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Driver Protocol and Operation Mode')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Driver Mode')),
E('div', { 'class': 'cbi-value-field' }, [
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'ncm', 'checked': 'checked' }), ' ',
_('NCM Mode')
]),
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'mbim' }), ' ',
_('MBIM Mode')
]),
E('label', { 'style': 'margin-right: 20px;' }, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'qmi' }), ' ',
_('QMI Mode')
]),
E('label', {}, [
E('input', { 'type': 'radio', 'name': 'modem_mode', 'value': 'ecm' }), ' ',
_('ECM Mode')
]),
E('div', { 'class': 'cbi-value-description' }, _('Switching the operation mode requires restarting the modem to take effect.'))
])
])
])
]);
container.appendChild(modeSection);
// Cell Locking Section
const isCellLocked = !!(self.cellLockData && self.cellLockData.locked);
const cellLockType = isCellLocked ? (self.cellLockData.mode || 'nr5g') : 'none';
const cellLockSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Base Station Cell Locking')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Cell Lock Mode')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', {
'id': 'modem_cell_lock_mode',
'class': 'cbi-input-select',
'style': 'min-width: 240px;',
'change': function(ev) {
const val = ev.target.value;
const inputsWrap = document.getElementById('modem_cell_lock_inputs');
if (inputsWrap) {
inputsWrap.style.display = (val === 'none') ? 'none' : 'block';
}
}
}, [
E('option', { 'value': 'none', 'selected': (cellLockType === 'none' ? '' : null) }, _('Automatic Selection')),
E('option', { 'value': 'nr5g', 'selected': (cellLockType === 'nr5g' ? '' : null) }, _('Lock 5G NR Cell')),
E('option', { 'value': 'lte', 'selected': (cellLockType === 'lte' ? '' : null) }, _('Lock 4G LTE Cell'))
]),
E('div', { 'class': 'cbi-value-description' }, _('Lock cellular module to a specific base station cell ID and carrier frequency.'))
])
]),
E('div', { 'id': 'modem_cell_lock_inputs', 'style': 'display: ' + (isCellLocked ? 'block' : 'none') + ';' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Carrier Frequency')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modem_cell_lock_earfcn',
'class': 'cbi-input-text',
'style': 'min-width: 240px;',
'placeholder': _('e.g. 627264 or 1850'),
'value': (self.cellLockData.earfcn || '')
}),
E('div', { 'class': 'cbi-value-description' }, _('Target carrier ARFCN or EARFCN.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Physical Cell ID')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modem_cell_lock_pci',
'class': 'cbi-input-text',
'style': 'min-width: 240px;',
'placeholder': _('e.g. 468'),
'value': (self.cellLockData.pci || '')
}),
E('div', { 'class': 'cbi-value-description' }, _('Target base station physical cell identifier PCI.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Lock Actions')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('Autofill Current Serving Cell'),
'click': function() {
if (!self.selectedPort) return;
ui.showIndicator('modem_query', _('Querying serving cell...'));
callGetStatus(self.selectedPort, self.selectedNet).then(function(st) {
ui.hideIndicator('modem_query');
if (st && st.cell && st.cell.earfcn && st.cell.pci) {
const cell = st.cell;
const earfcnInput = document.getElementById('modem_cell_lock_earfcn');
const pciInput = document.getElementById('modem_cell_lock_pci');
const modeSelect = document.getElementById('modem_cell_lock_mode');
if (earfcnInput) earfcnInput.value = cell.earfcn;
if (pciInput) pciInput.value = cell.pci;
if (modeSelect) {
modeSelect.value = cell.is_5g ? 'nr5g' : 'lte';
const inputsWrap = document.getElementById('modem_cell_lock_inputs');
if (inputsWrap) inputsWrap.style.display = 'block';
}
ui.addNotification(null, E('p', {}, _('Current cell values populated.')), 'info');
} else {
ui.addNotification(null, E('p', {}, _('No active serving cell details detected.')), 'warning');
}
}).catch(function(e) {
ui.hideIndicator('modem_query');
ui.addNotification(null, E('p', {}, e.message), 'error');
});
}
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-reset',
'style': 'margin-left: 10px;',
'value': _('Clear Lock Immediately'),
'click': function() {
if (!self.selectedPort) return;
ui.showModal(_('Clearing Cell Lock'), [
E('p', { 'class': 'spinning' }, _('Sending unlocking commands...'))
]);
callSetCellLock(self.selectedPort, 'none').then(function(res) {
ui.hideModal();
const modeSelect = document.getElementById('modem_cell_lock_mode');
if (modeSelect) modeSelect.value = 'none';
const inputsWrap = document.getElementById('modem_cell_lock_inputs');
if (inputsWrap) inputsWrap.style.display = 'none';
ui.addNotification(null, E('p', {}, _('Cell lock cleared successfully.')), 'info');
}).catch(function(e) {
ui.hideModal();
ui.addNotification(null, E('p', {}, e.message), 'error');
});
}
})
])
])
])
])
]);
container.appendChild(cellLockSection);
// Network RAT and Band Locking Section
const ratSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Network Access Technology and Band Locking')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Access Technology Preference')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'modem_rat_select', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'AUTO', 'selected': 'selected' }, _('Automatic Mode')),
E('option', { 'value': 'NR5G' }, _('5G Standalone Only')),
E('option', { 'value': 'NR5G:LTE' }, _('5G and 4G Hybrid')),
E('option', { 'value': 'LTE' }, _('4G LTE Only'))
]),
E('div', { 'class': 'cbi-value-description' }, _('Select priority radio access technology for network registration.'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('4G LTE Bands')),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 10px; padding-top: 5px;' },
['B1', 'B3', 'B5', 'B8', 'B34', 'B38', 'B39', 'B40', 'B41'].map(function(band) {
return E('label', {
'style': 'display: inline-flex; align-items: center; gap: 4px; cursor: pointer; min-width: 60px;'
}, [
E('input', { 'type': 'checkbox', 'class': 'cbi-input-checkbox modem-band-lte', 'value': band, 'checked': 'checked' }),
E('span', {}, band)
]);
})
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('5G NR Bands')),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 10px; padding-top: 5px;' },
['N1', 'N28', 'N41', 'N77', 'N78', 'N79'].map(function(band) {
return E('label', {
'style': 'display: inline-flex; align-items: center; gap: 4px; cursor: pointer; min-width: 60px;'
}, [
E('input', { 'type': 'checkbox', 'class': 'cbi-input-checkbox modem-band-nr', 'value': band, 'checked': 'checked' }),
E('span', {}, band)
]);
})
)
])
])
])
]);
container.appendChild(ratSection);
// APN Section
const activeApn = (self.apnData && self.apnData.apn) ? self.apnData.apn : '';
const activePdp = (self.apnData && self.apnData.pdp_type) ? self.apnData.pdp_type : 'IPV4V6';
const apnSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Access Point Name (APN) Settings')),
E('div', { 'class': 'cbi-section-node' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('APN Name')),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'modem_apn',
'class': 'cbi-input-text',
'placeholder': _('e.g. ctnet, cmiot, uninet'),
'value': activeApn
}),
E('div', { 'class': 'cbi-value-description' }, _('Cellular carrier APN profile name (e.g. cmiot, cbnet, uninet).'))
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('PDP Protocol Type')),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'modem_pdp', 'class': 'cbi-input-select' }, [
E('option', { 'value': 'IPV4V6', 'selected': (activePdp === 'IPV4V6' ? 'selected' : null) }, _('Dual Stack IPv4 and IPv6')),
E('option', { 'value': 'IP', 'selected': (activePdp === 'IP' ? 'selected' : null) }, _('IPv4 Only')),
E('option', { 'value': 'IPV6', 'selected': (activePdp === 'IPV6' ? 'selected' : null) }, _('IPv6 Only'))
])
])
])
])
]);
container.appendChild(apnSection);
// Submit Section
const btnSection = E('div', { 'class': 'cbi-page-actions' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-save',
'value': _('Save and Apply'),
'click': ui.createHandlerFn(self, 'handleApplySettings')
})
]);
container.appendChild(btnSection);
return container;
}
});
@@ -0,0 +1,503 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callGetConfig = rpc.declare({
object: 'luci.modem',
method: 'get_config',
expect: { port: '', baud: '115200' }
});
const callGetSmsStatus = rpc.declare({
object: 'luci.modem',
method: 'get_sms_status',
params: [ 'port' ]
});
const callSetSmsEnable = rpc.declare({
object: 'luci.modem',
method: 'set_sms_enable',
params: [ 'enabled', 'port' ]
});
const callGetSmsList = rpc.declare({
object: 'luci.modem',
method: 'get_sms_list',
params: [ 'port' ]
});
const callSendSms = rpc.declare({
object: 'luci.modem',
method: 'send_sms',
params: [ 'port', 'recipient', 'message' ]
});
const callDeleteSms = rpc.declare({
object: 'luci.modem',
method: 'delete_sms',
params: [ 'port', 'index' ]
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
devicesList: [],
smsEnabled: false,
smsCapacity: { used: 0, total: 0 },
smsList: [],
load: function() {
const self = this;
return Promise.all([
L.resolveDefault(callGetDevices(), []),
L.resolveDefault(callGetConfig(), {})
]).then(function(results) {
const devicesData = results[0];
const configData = results[1] || {};
let list = [];
if (Array.isArray(devicesData)) {
list = devicesData;
} else if (devicesData && Array.isArray(devicesData.devices)) {
list = devicesData.devices;
}
self.devicesList = list;
if (self.devicesList.length > 0) {
self.selectedPort = configData.port || self.devicesList[0].primary_port;
} else {
self.selectedPort = null;
}
if (!self.selectedPort) {
self.smsEnabled = false;
self.smsCapacity = { used: 0, total: 0 };
self.smsList = [];
return Promise.resolve();
}
return callGetSmsStatus(self.selectedPort).then(function(statusRes) {
self.smsEnabled = !!(statusRes && statusRes.enabled);
self.smsCapacity = {
used: statusRes?.used || 0,
total: statusRes?.total || 0
};
if (self.smsEnabled) {
return callGetSmsList(self.selectedPort).then(function(listRes) {
self.smsList = (listRes && listRes.messages) ? listRes.messages : [];
}).catch(function() {
self.smsList = [];
});
} else {
self.smsList = [];
return Promise.resolve();
}
}).catch(function() {
self.smsEnabled = false;
self.smsCapacity = { used: 0, total: 0 };
self.smsList = [];
return Promise.resolve();
});
}).catch(function() {
self.devicesList = [];
self.selectedPort = null;
self.smsEnabled = false;
self.smsCapacity = { used: 0, total: 0 };
self.smsList = [];
return Promise.resolve();
});
},
renderSmsState: function() {
const self = this;
const mainArea = document.getElementById('sms_main_content');
if (!mainArea) return;
while (mainArea.firstChild) {
mainArea.removeChild(mainArea.firstChild);
}
if (!self.smsEnabled) {
// Disabled state: safety notice card
const disabledCard = E('div', {
'style': 'background: #fff; border: 1px solid #e1e4e8; border-radius: 6px; padding: 30px; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-top: 15px;'
}, [
E('div', { 'style': 'font-size: 18px; font-weight: bold; color: #333; margin-bottom: 12px;' }, _('SMS Feature is Currently Disabled')),
E('div', {
'style': 'max-width: 680px; margin: 0 auto 24px auto; color: #555; line-height: 1.6; font-size: 14px;'
}, _('To prevent IoT SIM cards, enterprise telemetry cards, or data-only SIM cards from unexpected billing, network carrier account suspensions, or serial AT buffer deadlocks, the SMS module is disabled by default. In this mode, no background polling or SMS commands will ever touch the cellular modem.')),
E('div', { 'style': 'display: flex; justify-content: center; gap: 15px;' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'style': 'min-width: 180px; font-weight: bold;',
'click': function() {
self.handleToggleSms(true);
}
}, _('Enable SMS Feature'))
])
]);
mainArea.appendChild(disabledCard);
return;
}
// Enabled state
const toolbar = E('div', {
'style': 'display: flex; justify-content: space-between; align-items: center; background: #fff; border: 1px solid #e1e4e8; border-radius: 6px; padding: 15px 20px; margin-top: 15px; box-shadow: 0 1px 3px rgba(0,0,0,0.05);'
}, [
E('div', { 'style': 'display: flex; align-items: center; gap: 20px;' }, [
E('div', {}, [
E('span', { 'style': 'color: #333; font-size: 13px;' }, _('SIM Card Storage Capacity: ')),
E('strong', { 'style': 'color: #333; font-size: 14px;' },
self.smsCapacity.total > 0 ? (self.smsCapacity.used + ' / ' + self.smsCapacity.total + ' ' + _('Messages')) : (self.smsList.length + ' ' + _('Messages')))
]),
E('span', {
'style': 'background: #e6ffed; color: #22863a; border: 1px solid #bef5cb; padding: 2px 8px; border-radius: 12px; font-size: 12px;'
}, _('Module Active'))
]),
E('div', { 'style': 'display: flex; gap: 10px;' }, [
E('button', {
'class': 'btn cbi-button cbi-button-save',
'click': function() {
self.handleOpenSendModal();
}
}, _('Compose Message')),
E('button', {
'class': 'btn cbi-button cbi-button-action',
'click': function() {
self.handleRefreshList();
}
}, _('Refresh List')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'click': function() {
self.handleClearAll();
}
}, _('Clear All')),
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'style': 'color: #d73a49;',
'click': function() {
self.handleToggleSms(false);
}
}, _('Disable Feature'))
])
]);
mainArea.appendChild(toolbar);
// SMS Message List
const listContainer = E('div', { 'style': 'margin-top: 15px;' });
if (!self.smsList || self.smsList.length === 0) {
listContainer.appendChild(E('div', {
'style': 'background: #fff; border: 1px dashed #d1d5db; border-radius: 6px; padding: 40px; text-align: center; color: #888;'
}, _('Inbox is empty. No messages stored on SIM card.')));
} else {
self.smsList.forEach(function(item) {
const isUnread = (item.status && item.status.indexOf('UNREAD') !== -1);
const card = E('div', {
'style': 'background: #fff; border: 1px solid #e1e4e8; border-left: 4px solid ' + (isUnread ? '#0366d6' : '#28a745') + '; border-radius: 4px; padding: 15px 20px; margin-bottom: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.04);'
}, [
E('div', { 'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;' }, [
E('div', { 'style': 'display: flex; align-items: center; gap: 10px;' }, [
E('strong', { 'style': 'font-size: 15px; color: #24292e;' }, item.sender || _('Unknown Sender')),
E('span', {
'style': 'font-size: 11px; padding: 1px 6px; border-radius: 10px; background: ' + (isUnread ? '#dbedff; color: #0366d6;' : '#f1f8ff; color: #586069;')
}, isUnread ? _('Unread') : _('Read')),
E('span', { 'style': 'font-size: 11px; color: #999;' }, '#' + item.index)
]),
E('div', { 'style': 'color: #888; font-size: 12px;' }, item.time || '')
]),
E('div', {
'style': 'color: #333; font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; margin: 10px 0; background: #fafbfc; padding: 10px 12px; border-radius: 4px;'
}, item.content || _('[Empty Message Body]')),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px;' }, [
E('button', {
'class': 'btn cbi-button cbi-button-action',
'style': 'font-size: 12px;',
'click': function() {
self.handleOpenSendModal(item.sender);
}
}, _('Reply')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'style': 'font-size: 12px;',
'click': function() {
self.handleDeleteSingle(item.index);
}
}, _('Delete'))
])
]);
listContainer.appendChild(card);
});
}
mainArea.appendChild(listContainer);
},
handleToggleSms: function(enable) {
const self = this;
const actionTitle = enable ? _('Enable SMS Feature') : _('Disable SMS Feature');
const actionText = enable ?
_('Enabling SMS allows sending, receiving, and reading messages. Ensure your SIM card plan supports SMS traffic.') :
_('Disabling SMS stops all SMS-related interactions and returns the cellular modem to pure data safety mode.');
ui.showModal(actionTitle, [
E('p', {}, actionText),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button ' + (enable ? 'cbi-button-save' : 'cbi-button-reset'),
'click': function() {
ui.showModal(actionTitle, [
E('p', { 'class': 'spinning' }, _('Updating configuration, please wait...'))
]);
callSetSmsEnable(!!enable, self.selectedPort).then(function(res) {
self.smsEnabled = !!(res && (res.enabled || res.success));
if (self.smsEnabled) {
return Promise.all([
callGetSmsStatus(self.selectedPort),
callGetSmsList(self.selectedPort)
]).then(function(datas) {
const st = datas[0] || {};
const ls = datas[1] || {};
self.smsCapacity = { used: st.used || 0, total: st.total || 0 };
self.smsList = (ls && Array.isArray(ls.messages)) ? ls.messages : [];
ui.hideModal();
self.renderSmsState();
});
} else {
self.smsList = [];
self.smsCapacity = { used: 0, total: 0 };
ui.hideModal();
self.renderSmsState();
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to toggle SMS state: ') + err.message), 'error');
});
}
}, _('Confirm'))
])
]);
},
handleRefreshList: function() {
const self = this;
if (!self.selectedPort) {
self.renderSmsState();
return;
}
ui.showModal(_('Refreshing Messages'), [
E('p', { 'class': 'spinning' }, _('Reading SMS inbox from SIM card, please wait...'))
]);
Promise.all([
callGetSmsStatus(self.selectedPort),
callGetSmsList(self.selectedPort)
]).then(function(results) {
const st = results[0] || {};
const ls = results[1] || {};
self.smsCapacity = { used: st.used || 0, total: st.total || 0 };
self.smsList = (ls && Array.isArray(ls.messages)) ? ls.messages : [];
ui.hideModal();
self.renderSmsState();
ui.addNotification(null, E('p', {}, _('SMS list refreshed successfully.')), 'info');
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to refresh SMS list: ') + err.message), 'error');
});
},
handleOpenSendModal: function(prefillRecipient) {
const self = this;
const recipientInput = E('input', {
'type': 'text',
'class': 'cbi-input-text',
'style': 'width: 100%; box-sizing: border-box; padding: 7px 10px; font-family: monospace;',
'placeholder': _('Target phone number, e.g. 10086 or +8613800000000'),
'value': prefillRecipient || ''
});
const charCountEl = E('span', { 'style': 'color: #888; font-size: 12px;' }, '0 ' + _('characters'));
const messageInput = E('textarea', {
'class': 'cbi-input-textarea',
'style': 'width: 100%; height: 130px; box-sizing: border-box; padding: 8px 10px; font-family: sans-serif; resize: vertical;',
'placeholder': _('Enter SMS message content...'),
'input': function(ev) {
charCountEl.textContent = ev.target.value.length + ' ' + _('characters');
}
});
const formContainer = E('div', {
'style': 'min-width: 520px; max-width: 100%; box-sizing: border-box; padding: 4px 0;'
}, [
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('label', {
'style': 'display: block; font-weight: bold; margin-bottom: 6px; color: #333; font-size: 13px;'
}, _('Recipient Phone Number')),
recipientInput
]),
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', {
'style': 'display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;'
}, [
E('label', {
'style': 'display: block; font-weight: bold; color: #333; font-size: 13px;'
}, _('Message Content')),
charCountEl
]),
messageInput
]),
E('div', {
'style': 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; padding-top: 15px; border-top: 1px solid #e1e4e8;'
}, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button cbi-button-save',
'click': function() {
const to = recipientInput.value.trim();
const text = messageInput.value.trim();
if (!to) {
ui.addNotification(null, E('p', {}, _('Please specify a valid recipient phone number.')), 'error');
return;
}
if (!text) {
ui.addNotification(null, E('p', {}, _('Message content cannot be empty.')), 'error');
return;
}
ui.showModal(_('Sending Message'), [
E('p', { 'class': 'spinning' }, _('Transmitting SMS message over cellular radio, please wait...'))
]);
callSendSms(self.selectedPort, to, text).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Message transmitted successfully.')), 'info');
self.handleRefreshList();
} else {
ui.addNotification(null, E('p', {}, _('Failed to transmit message: ') + (res?.output || _('Unknown cellular error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to transmit message: ') + err.message), 'error');
});
}
}, _('Send'))
])
]);
ui.showModal(_('Compose Message'), [ formContainer ]);
},
handleDeleteSingle: function(index) {
const self = this;
ui.showModal(_('Delete Message'), [
E('p', {}, _('Are you sure you want to delete message #') + index + '?'),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'click': function() {
ui.showModal(_('Deleting Message'), [
E('p', { 'class': 'spinning' }, _('Deleting message, please wait...'))
]);
callDeleteSms(self.selectedPort, index).then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('Message deleted successfully.')), 'info');
self.handleRefreshList();
} else {
ui.addNotification(null, E('p', {}, _('Failed to delete message: ') + (res?.output || _('Unknown error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to delete message: ') + err.message), 'error');
});
}
}, _('Delete'))
])
]);
},
handleClearAll: function() {
const self = this;
ui.showModal(_('Clear All Messages'), [
E('p', {}, _('Are you sure you want to clear all SMS messages stored on the SIM card? This action cannot be undone.')),
E('div', { 'class': 'cbi-page-actions' }, [
E('button', {
'class': 'btn cbi-button cbi-button-neutral',
'click': ui.hideModal
}, _('Cancel')),
E('button', {
'class': 'btn cbi-button cbi-button-reset',
'click': function() {
ui.showModal(_('Clearing Messages'), [
E('p', { 'class': 'spinning' }, _('Clearing all SMS messages, please wait...'))
]);
callDeleteSms(self.selectedPort, 'all').then(function(res) {
ui.hideModal();
if (res && res.success) {
ui.addNotification(null, E('p', {}, _('All SMS messages cleared successfully.')), 'info');
self.handleRefreshList();
} else {
ui.addNotification(null, E('p', {}, _('Failed to clear messages: ') + (res?.output || _('Unknown error'))), 'error');
}
}).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', {}, _('Failed to clear messages: ') + err.message), 'error');
});
}
}, _('Confirm Clear'))
])
]);
},
render: function() {
const self = this;
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('SMS Management')),
E('div', { 'class': 'cbi-map-descr' }, _('Read, compose, and manage cellular SMS messages stored on the SIM card.'))
]);
if (self.devicesList.length === 0) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 15px; padding: 15px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 8px 0; color: #d32f2f;' }, _('No Cellular Modem Detected')),
E('p', { 'style': 'margin: 0; color: #555;' }, _('No active cellular modem detected. Unable to manage SMS messages. Please check physical connection.'))
]));
return container;
}
// Dynamic SMS content container
const mainArea = E('div', { 'id': 'sms_main_content' });
container.appendChild(mainArea);
// Initial render after DOM is mounted
setTimeout(function() {
self.renderSmsState();
}, 0);
return container;
}
});
@@ -0,0 +1,109 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetTemplates = rpc.declare({
object: 'luci.modem',
method: 'get_templates',
expect: { templates: [] }
});
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
load: function() {
return L.resolveDefault(callGetTemplates(), []);
},
showTemplateDetail: function(tpl) {
ui.showModal(tpl.name || _('Template Detail'), [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Template ID')),
E('div', { 'class': 'cbi-value-field' }, tpl.id || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Target Vendor')),
E('div', { 'class': 'cbi-value-field' }, tpl.vendor || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('USB VID')),
E('div', { 'class': 'cbi-value-field' }, tpl.vid || '-')
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Mode Switch Command')),
E('div', { 'class': 'cbi-value-field' }, (tpl.mode_switch && tpl.mode_switch.cmd) ? tpl.mode_switch.cmd : _('Not Defined'))
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, _('Raw Configuration')),
E('div', { 'class': 'cbi-value-field' }, [
E('pre', {
'style': 'background: #f7f7f7; padding: 10px; border: 1px solid #ddd; max-height: 250px; overflow-y: auto; font-size: 12px; font-family: monospace;'
}, JSON.stringify(tpl, null, 2))
])
]),
E('div', { 'class': 'right', 'style': 'margin-top: 15px;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Close'),
'click': ui.hideModal
})
])
]);
},
render: function(tplData) {
const self = this;
const templates = Array.isArray(tplData) ? tplData : ((tplData && Array.isArray(tplData.templates)) ? tplData.templates : []);
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('Configuration Templates')),
E('div', { 'class': 'cbi-map-descr' }, _('Manage vendor preset command profiles, private AT mappings, band lock masks, and driver switching templates.'))
]);
const rows = [];
templates.forEach(function(tpl) {
rows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td left', 'style': 'font-weight: bold;' }, tpl.name || tpl.id),
E('td', { 'class': 'td left' }, tpl.vendor || '-'),
E('td', { 'class': 'td left' }, tpl.vid || '*'),
E('td', { 'class': 'td left' }, (tpl.mode_switch && tpl.mode_switch.cmd) ? tpl.mode_switch.cmd : '-'),
E('td', { 'class': 'td right' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'value': _('View Details'),
'click': ui.createHandlerFn(self, 'showTemplateDetail', tpl)
})
])
]));
});
if (rows.length === 0) {
rows.push(E('tr', { 'class': 'tr' }, [
E('td', { 'class': 'td center', 'colspan': '5' }, _('No templates installed.'))
]));
}
const tableRows = [
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th left' }, _('Template Name')),
E('th', { 'class': 'th left' }, _('Vendor')),
E('th', { 'class': 'th left' }, _('Vendor ID')),
E('th', { 'class': 'th left' }, _('Mode Switching Command')),
E('th', { 'class': 'th right' }, _('Actions'))
])
].concat(rows);
const listSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', {}, _('Preset Template Library')),
E('table', { 'class': 'table cbi-section-table' }, tableRows)
]);
container.appendChild(listSection);
return container;
}
});
@@ -0,0 +1,643 @@
'use strict';
'require view';
'require rpc';
'require ui';
const callGetDevices = rpc.declare({
object: 'luci.modem',
method: 'get_devices',
expect: { devices: [] }
});
const callSendAt = rpc.declare({
object: 'luci.modem',
method: 'send_at',
params: [ 'port', 'cmd', 'timeout' ]
});
// Standard 3GPP and Vendor AT Command Knowledge Base
const AT_DICTIONARY = [
{ cmd: 'ATI', desc: _('Query module manufacturer, model, and firmware version') },
{ cmd: 'AT+CPIN?', desc: _('Query SIM card lock and ready status') },
{ cmd: 'AT+CSQ', desc: _('Query received signal strength and bit error rate') },
{ cmd: 'AT+CCID', desc: _('Query SIM card ICCID number') },
{ cmd: 'AT+CFUN?', desc: _('Query current RF radio state (0:Min, 1:Full, 4:Airplane)') },
{ cmd: 'AT+CFUN=1', desc: _('Enable full RF function (Turn off airplane mode)') },
{ cmd: 'AT+CFUN=4', desc: _('Turn on airplane mode') },
{ cmd: 'AT+CFUN=1,1', desc: _('Reboot module processor and RF reset') },
{ cmd: 'AT+COPS?', desc: _('Query registered cellular operator and RAT') },
{ cmd: 'AT+COPS=?', desc: _('Scan all available cellular networks in air (Long timeout)') },
{ cmd: 'AT+CREG?', desc: _('Query 2G/3G network registration status') },
{ cmd: 'AT+CEREG?', desc: _('Query 4G LTE network registration status') },
{ cmd: 'AT+C5GREG?', desc: _('Query 5G NR network registration status') },
{ cmd: 'AT+CGDCONT?', desc: _('Query configured PDP contexts and APN profiles') },
{ cmd: 'AT+CGDCONT=1,"IPV4V6","cmiot"', desc: _('Configure PDP context 1 with APN') },
{ cmd: 'AT+QENG="servingcell"', desc: _('Quectel: Query current serving cell details (5G SA/LTE, PCI, TAC, Band, RSRP, SINR)') },
{ cmd: 'AT+QCAINFO', desc: _('Quectel: Query carrier aggregation (CA) state and secondary cells') },
{ cmd: 'AT+QTEMP', desc: _('Quectel: Query CPU and RF chip temperatures') },
{ cmd: 'AT+QNWINFO', desc: _('Quectel: Query current network band and channel info') },
{ cmd: 'AT+GTCCINFO?', desc: _('Fibocom: Query current cell info and radio metrics') },
{ cmd: 'AT+GTCAINFO?', desc: _('Fibocom: Query carrier aggregation info') },
{ cmd: 'AT+GTRNDIS?', desc: _('Fibocom: Query RNDIS network connection status') }
];
// 3GPP TS 27.007 CME / CMS Error Descriptions
const CME_ERRORS = {
'0': _('Phone failure'),
'1': _('No connection to phone'),
'2': _('Phone-adaptor link reserved'),
'3': _('Operation not allowed'),
'4': _('Operation not supported'),
'5': _('PH-SIM PIN required'),
'10': _('SIM card not inserted or not detected'),
'11': _('SIM PIN required (Please enter PIN to unlock)'),
'12': _('SIM PUK required (Please enter PUK to unlock)'),
'13': _('SIM card failure or communication defect'),
'14': _('SIM card busy, please retry later'),
'15': _('SIM wrong password'),
'16': _('Incorrect PIN entered'),
'17': _('PIN2 required'),
'18': _('PUK2 required'),
'20': _('Memory storage full'),
'21': _('Invalid index or memory entry not found'),
'22': _('Not found'),
'24': _('Text string too long'),
'25': _('Invalid characters in text string'),
'26': _('Dial string too long'),
'27': _('Invalid characters in dial string'),
'30': _('No cellular network service available'),
'31': _('Network timeout'),
'32': _('Network not allowed emergency calls only'),
'100': _('Unknown error or unsupported extended command')
};
// Chinese & Full-width punctuation replacement mapping
function sanitizeAtPunctuation(str) {
if (!str) return '';
let res = str;
// Replace full-width quotes
res = res.replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
// Replace full-width punctuation
res = res.replace(/,/g, ',')
.replace(/?/g, '?')
.replace(/:/g, ':')
.replace(/;/g, ';')
.replace(/=/g, '=')
.replace(/+/g, '+')
.replace(/-/g, '-')
.replace(/(/g, '(')
.replace(/)/g, ')')
.replace(/\u3000/g, ' '); // full-width space
// Ensure uppercase prefix for 'at'
if (/^at([+=?&]|\s|$)/i.test(res)) {
res = 'AT' + res.slice(2);
}
return res;
}
// Levenshtein distance for fuzzy suggestion
function levenshteinDistance(s1, s2) {
const a = s1.toUpperCase();
const b = s2.toUpperCase();
const matrix = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
);
}
}
}
return matrix[b.length][a.length];
}
function findBestSuggestion(inputCmd) {
if (!inputCmd || inputCmd.length < 3) return null;
const clean = inputCmd.trim().toUpperCase();
let bestMatch = null;
let lowestDist = 999;
for (let i = 0; i < AT_DICTIONARY.length; i++) {
const target = AT_DICTIONARY[i].cmd.toUpperCase();
const baseTarget = target.split('=')[0]; // base command prefix
const baseClean = clean.split('=')[0];
const dist = levenshteinDistance(baseClean, baseTarget);
if (dist > 0 && dist <= 2 && dist < lowestDist) {
lowestDist = dist;
bestMatch = AT_DICTIONARY[i];
}
}
return bestMatch;
}
return view.extend({
handleSaveApply: null,
handleSave: null,
handleReset: null,
selectedPort: null,
devicesList: [],
cmdHistory: [],
historyIndex: -1,
favorites: [],
load: function() {
return L.resolveDefault(callGetDevices(), []);
},
loadFavorites: function() {
try {
const saved = localStorage.getItem('modem_at_favorites');
if (saved) this.favorites = JSON.parse(saved);
} catch(e) {}
if (!Array.isArray(this.favorites) || this.favorites.length === 0) {
this.favorites = [
{ cmd: 'ATI', label: _('Module Info') },
{ cmd: 'AT+CSQ', label: _('Signal CSQ') },
{ cmd: 'AT+CPIN?', label: _('SIM Status') },
{ cmd: 'AT+COPS?', label: _('Operator') },
{ cmd: 'AT+QENG="servingcell"', label: _('Serving Cell') }
];
}
},
saveFavorites: function() {
try {
localStorage.setItem('modem_at_favorites', JSON.stringify(this.favorites));
} catch(e) {}
},
appendLog: function(text, type, durationMs) {
const out = document.getElementById('terminal_output');
if (!out) return;
const d = new Date();
const timeStr = d.toTimeString().split(' ')[0] + '.' + String(d.getMilliseconds()).padStart(3, '0');
const line = document.createElement('div');
line.style.whiteSpace = 'pre-wrap';
line.style.wordBreak = 'break-all';
line.style.fontFamily = 'monospace';
line.style.lineHeight = '1.4em';
line.style.marginBottom = '4px';
if (type === 'cmd') {
line.style.color = '#cc7832';
line.style.fontWeight = 'bold';
line.textContent = timeStr + ' >>> ' + text;
} else if (type === 'error') {
line.style.color = '#ff6b68';
line.textContent = timeStr + ' <<< ' + text;
} else if (type === 'meta') {
line.style.color = '#808080';
line.style.fontSize = '11px';
line.textContent = text;
} else {
line.style.color = '#a9b7c6';
line.textContent = timeStr + ' <<< ' + text;
}
if (durationMs !== undefined && durationMs !== null) {
const durSpan = document.createElement('span');
durSpan.style.float = 'right';
durSpan.style.color = '#629755';
durSpan.style.fontSize = '11px';
durSpan.textContent = '[' + _('Duration: ') + durationMs + ' ms]';
line.appendChild(durSpan);
}
out.appendChild(line);
// Parse CME / CMS Error Code Annotation
const mCme = text.match(/\+(CME|CMS) ERROR:\s*([0-9]+)/);
if (mCme) {
const errCode = mCme[2];
const explanation = CME_ERRORS[errCode];
if (explanation) {
const expLine = document.createElement('div');
expLine.style.color = '#e5c07b';
expLine.style.background = 'rgba(229, 192, 123, 0.1)';
expLine.style.padding = '2px 6px';
expLine.style.margin = '2px 0';
expLine.style.borderRadius = '3px';
expLine.style.fontSize = '12px';
expLine.textContent = '[' + _('Error Explanation') + '] ' + mCme[0] + ' -> ' + explanation;
out.appendChild(expLine);
}
}
out.scrollTop = out.scrollHeight;
},
executeCommand: function(cmdStr) {
const self = this;
const inputEl = document.getElementById('terminal_input');
let raw = cmdStr || (inputEl ? inputEl.value : '');
// Auto sanitize Chinese/Full-width punctuation
const cleanCmd = sanitizeAtPunctuation(raw).trim();
if (!cleanCmd) return;
if (!self.selectedPort) {
ui.addNotification(null, E('p', {}, _('No active communication port configured. Please configure an AT control port in Settings first.')), 'error');
return;
}
// Security Guard: warn on dangerous destructive reset commands
if (/^AT&F|^AT\+RESTORE/i.test(cleanCmd)) {
if (!confirm(_('Warning: This command may perform a factory reset on the cellular modem. Are you sure to execute?'))) {
return;
}
}
self.appendLog(cleanCmd, 'cmd');
if (self.cmdHistory.length === 0 || self.cmdHistory[self.cmdHistory.length - 1] !== cleanCmd) {
self.cmdHistory.push(cleanCmd);
}
self.historyIndex = self.cmdHistory.length;
if (inputEl) {
inputEl.value = '';
self.hideAutocomplete();
}
const btn = document.getElementById('terminal_send_btn');
if (btn) btn.disabled = true;
// Calculate client-side timestamp fallback
const tStart = Date.now();
callSendAt(self.selectedPort, cleanCmd, 10).then(function(res) {
if (btn) btn.disabled = false;
const dur = (res && res.duration_ms) ? res.duration_ms : (Date.now() - tStart);
if (res && res.output) {
self.appendLog(res.output, res.success ? 'resp' : 'error', dur);
// Suggest command if failed
if (!res.success) {
const sugg = findBestSuggestion(cleanCmd);
if (sugg) {
self.renderSuggestionNotice(sugg);
}
}
} else {
self.appendLog(_('No response from modem (Timeout or device busy).'), 'error', dur);
}
}).catch(function(err) {
if (btn) btn.disabled = false;
self.appendLog(_('Execution failed: ') + err.message, 'error');
});
},
renderSuggestionNotice: function(sugg) {
const self = this;
const out = document.getElementById('terminal_output');
if (!out) return;
const line = document.createElement('div');
line.style.color = '#61afef';
line.style.background = 'rgba(97, 175, 239, 0.1)';
line.style.padding = '3px 8px';
line.style.margin = '4px 0';
line.style.borderRadius = '3px';
line.style.fontSize = '12px';
const textSpan = document.createElement('span');
textSpan.textContent = _('Did you mean: ') + sugg.cmd + ' - ' + sugg.desc + '? ';
const fillBtn = document.createElement('a');
fillBtn.href = '#';
fillBtn.style.color = '#98c379';
fillBtn.style.marginLeft = '8px';
fillBtn.style.textDecoration = 'underline';
fillBtn.textContent = _('[Fill in]');
fillBtn.onclick = function(e) {
e.preventDefault();
const inputEl = document.getElementById('terminal_input');
if (inputEl) {
inputEl.value = sugg.cmd;
inputEl.focus();
}
};
line.appendChild(textSpan);
line.appendChild(fillBtn);
out.appendChild(line);
out.scrollTop = out.scrollHeight;
},
showAutocomplete: function(query) {
const self = this;
const box = document.getElementById('terminal_autocomplete_box');
if (!box) return;
const clean = query.trim().toUpperCase();
if (!clean || clean.length < 2) {
box.style.display = 'none';
return;
}
const matches = AT_DICTIONARY.filter(function(item) {
return item.cmd.toUpperCase().indexOf(clean) !== -1;
}).slice(0, 6);
if (matches.length === 0) {
box.style.display = 'none';
return;
}
box.innerHTML = '';
matches.forEach(function(m) {
const itemEl = document.createElement('div');
itemEl.style.padding = '5px 10px';
itemEl.style.cursor = 'pointer';
itemEl.style.fontSize = '12px';
itemEl.style.borderBottom = '1px solid #eee';
itemEl.style.display = 'flex';
itemEl.style.justifyContent = 'space-between';
const cmdSpan = document.createElement('b');
cmdSpan.style.fontFamily = 'monospace';
cmdSpan.textContent = m.cmd;
const descSpan = document.createElement('span');
descSpan.style.color = '#888';
descSpan.style.marginLeft = '10px';
descSpan.textContent = m.desc;
itemEl.appendChild(cmdSpan);
itemEl.appendChild(descSpan);
itemEl.onmouseenter = function() { itemEl.style.background = '#f0f4f8'; };
itemEl.onmouseleave = function() { itemEl.style.background = '#fff'; };
itemEl.onclick = function() {
const inputEl = document.getElementById('terminal_input');
if (inputEl) {
inputEl.value = m.cmd;
inputEl.focus();
}
self.hideAutocomplete();
};
box.appendChild(itemEl);
});
box.style.display = 'block';
},
hideAutocomplete: function() {
const box = document.getElementById('terminal_autocomplete_box');
if (box) box.style.display = 'none';
},
refreshFavoritesUI: function() {
const self = this;
const favContainer = document.getElementById('terminal_favorites_list');
if (!favContainer) return;
favContainer.innerHTML = '';
self.favorites.forEach(function(f, idx) {
const btn = E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right: 6px; margin-bottom: 6px; font-family: monospace; font-size: 12px;',
'value': f.label || f.cmd,
'click': function() {
const inputEl = document.getElementById('terminal_input');
if (inputEl) inputEl.value = f.cmd;
self.executeCommand(f.cmd);
}
});
favContainer.appendChild(btn);
});
},
addCurrentToFavorites: function() {
const self = this;
const inputEl = document.getElementById('terminal_input');
const cmd = inputEl ? inputEl.value.trim() : '';
if (!cmd) {
ui.addNotification(null, E('p', {}, _('Please enter a command to favorite.')), 'warning');
return;
}
const label = prompt(_('Enter a friendly name for this shortcut:'), cmd);
if (label !== null) {
self.favorites.push({ cmd: cmd, label: label.trim() || cmd });
self.saveFavorites();
self.refreshFavoritesUI();
ui.addNotification(null, E('p', {}, _('Command added to favorites.')), 'info');
}
},
exportLogFile: function() {
const out = document.getElementById('terminal_output');
if (!out) return;
const text = out.innerText;
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
const a = document.createElement('a');
const d = new Date().toISOString().replace(/[:.]/g, '-');
a.download = 'modem_at_log_' + d + '.txt';
a.href = URL.createObjectURL(blob);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
},
render: function(devicesData) {
const self = this;
self.devicesList = Array.isArray(devicesData) ? devicesData : ((devicesData && Array.isArray(devicesData.devices)) ? devicesData.devices : []);
self.loadFavorites();
if (self.devicesList.length > 0) {
self.selectedPort = self.devicesList[0].primary_port || (self.devicesList[0].ports ? self.devicesList[0].ports[0] : null);
} else {
self.selectedPort = null;
}
const container = E('div', { 'class': 'cbi-map' }, [
E('h2', {}, _('Cellular Modem Manager') + ' - ' + _('AT Command Terminal')),
E('div', { 'class': 'cbi-map-descr' }, _('Direct interactive terminal for sending AT commands with intelligent auto-punctuation correction, fuzzy suggestions, error translation, and custom favorites.'))
]);
if (!self.selectedPort) {
container.appendChild(E('div', { 'class': 'alert-message warning', 'style': 'margin-top: 15px; padding: 15px; border-left: 5px solid #ff9800; background: #fff8e1;' }, [
E('h4', { 'style': 'margin: 0 0 8px 0; color: #d32f2f;' }, _('No Serial Ports Available')),
E('p', { 'style': 'margin: 0; color: #555;' }, _('No ttyUSB or ttyACM serial communication ports detected. Please verify hardware connection and driver state.'))
]));
}
// Port Status Bar (Display Only)
const activePortLabel = self.selectedPort ? (self.selectedPort + ' @ 115200') : _('No Port Available');
const portStatusBar = E('div', {
'style': 'margin-top: 15px; margin-bottom: 15px; display: flex; align-items: center; justify-content: space-between; background: #f4f6f8; border: 1px solid #e1e4e8; border-radius: 4px; padding: 8px 16px;'
}, [
E('span', {}, [
E('strong', { 'style': 'color: #333;' }, _('Communication Port: ')),
E('span', { 'style': 'color: #0366d6; font-weight: bold; font-family: monospace; font-size: 13px;' }, activePortLabel)
]),
E('span', { 'style': 'color: #666; font-size: 12px;' }, _('Bound to configured primary AT control port'))
]);
container.appendChild(portStatusBar);
// Favorites and Presets Section
const presetsSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', { 'style': 'display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box;' }, [
E('span', {}, _('Command Presets and Favorites')),
E('span', { 'style': 'display: inline-flex; gap: 8px; align-items: center; margin-left: auto;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'style': 'font-size: 11px;',
'value': _('+ Add Current to Favorites'),
'click': ui.createHandlerFn(self, 'addCurrentToFavorites')
})
])
]),
E('div', { 'class': 'cbi-section-node', 'style': 'padding: 10px;' }, [
E('div', { 'style': 'margin-bottom: 8px;' }, [
E('b', { 'style': 'display: inline-block; width: 110px;' }, _('Favorites:')),
E('span', { 'id': 'terminal_favorites_list' })
]),
E('div', {}, [
E('b', { 'style': 'display: inline-block; width: 110px;' }, _('Advanced:')),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right: 6px; margin-bottom: 6px; font-family: monospace; font-size: 12px;',
'value': 'AT+COPS=? - ' + _('Scan Networks'),
'click': function() { self.executeCommand('AT+COPS=?'); }
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button cbi-button-action',
'style': 'margin-right: 6px; margin-bottom: 6px; font-family: monospace; font-size: 12px;',
'value': 'AT+CGDCONT? - ' + _('Query APN'),
'click': function() { self.executeCommand('AT+CGDCONT?'); }
})
])
])
]);
container.appendChild(presetsSection);
// Autocomplete Dropdown Box
const autocompleteBox = E('div', {
'id': 'terminal_autocomplete_box',
'style': 'display: none; position: absolute; background: #fff; border: 1px solid #ccc; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 1000; max-height: 200px; overflow-y: auto; width: 550px;'
});
// Command Input Bar (Positioned ABOVE Output Box)
const inputSection = E('fieldset', { 'class': 'cbi-section', 'style': 'margin-bottom: 15px; position: relative;' }, [
E('legend', {}, _('Command Execution')),
E('div', { 'class': 'cbi-section-node', 'style': 'padding: 10px;' }, [
E('div', { 'style': 'display: flex; gap: 10px; align-items: center;' }, [
E('input', {
'type': 'text',
'id': 'terminal_input',
'class': 'cbi-input-text',
'style': 'flex: 1; font-family: monospace; font-size: 14px; padding: 6px 10px;',
'placeholder': _('Enter AT command (e.g. AT+CSQ)... Chinese punctuation auto-converted'),
'input': function(ev) {
self.showAutocomplete(ev.target.value);
},
'keydown': function(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
self.executeCommand();
} else if (ev.key === 'Escape') {
self.hideAutocomplete();
} else if (ev.key === 'ArrowUp') {
if (self.cmdHistory.length > 0 && self.historyIndex > 0) {
self.historyIndex--;
ev.target.value = self.cmdHistory[self.historyIndex];
}
} else if (ev.key === 'ArrowDown') {
if (self.historyIndex < self.cmdHistory.length - 1) {
self.historyIndex++;
ev.target.value = self.cmdHistory[self.historyIndex];
} else {
self.historyIndex = self.cmdHistory.length;
ev.target.value = '';
}
}
}
}),
E('input', {
'type': 'button',
'id': 'terminal_send_btn',
'class': 'btn cbi-button cbi-button-action',
'value': _('Send Command'),
'click': function() {
self.executeCommand();
}
})
]),
autocompleteBox
])
]);
container.appendChild(inputSection);
// Terminal Output Box (Positioned BELOW Input Bar)
const outputSection = E('fieldset', { 'class': 'cbi-section' }, [
E('legend', { 'style': 'display: flex; justify-content: space-between; align-items: center; width: 100%; box-sizing: border-box;' }, [
E('span', {}, _('Interactive Console Output')),
E('span', { 'style': 'display: inline-flex; gap: 8px; align-items: center; margin-left: auto;' }, [
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Clear Output'),
'style': 'font-size: 11px;',
'click': function() {
const out = document.getElementById('terminal_output');
if (out) out.innerHTML = '';
}
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Export Log'),
'style': 'font-size: 11px;',
'click': ui.createHandlerFn(self, 'exportLogFile')
}),
E('input', {
'type': 'button',
'class': 'btn cbi-button',
'value': _('Copy All'),
'style': 'font-size: 11px;',
'click': function() {
const out = document.getElementById('terminal_output');
if (out && navigator.clipboard) {
navigator.clipboard.writeText(out.innerText);
ui.addNotification(null, E('p', {}, _('Terminal output copied to clipboard.')), 'info');
}
}
})
])
]),
E('div', {
'id': 'terminal_output',
'style': 'background: #2b2b2b; color: #a9b7c6; font-family: monospace; font-size: 13px; min-height: 280px; max-height: 480px; overflow-y: auto; padding: 12px; border-radius: 4px; box-sizing: border-box; white-space: pre-wrap;'
}, [
E('div', { 'style': 'color: #808080;' }, _('System ready. Full-width punctuation will be automatically converted to standard ASCII.'))
])
]);
container.appendChild(outputSection);
setTimeout(function() {
self.refreshFavoritesUI();
}, 100);
return container;
}
});
@@ -0,0 +1,614 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8\n"
msgid "Modem Manager"
msgstr ""
msgid "Cellular Modem Manager"
msgstr ""
msgid "Overview"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Templates"
msgstr ""
msgid "Configuration Templates"
msgstr ""
msgid "AT Terminal"
msgstr ""
msgid "AT Command Terminal"
msgstr ""
msgid "Log"
msgstr ""
msgid "System Log"
msgstr ""
msgid "Modem Manager Logs"
msgstr ""
msgid "Active Modem Selection"
msgstr ""
msgid "Target Modem Selection"
msgstr ""
msgid "Current Device:"
msgstr ""
msgid "Active Device:"
msgstr ""
msgid "Refresh"
msgstr ""
msgid "No cellular modems detected on USB bus."
msgstr ""
msgid "No cellular modems detected."
msgstr ""
msgid "No serial communication ports detected."
msgstr ""
msgid "Hardware Information"
msgstr ""
msgid "Manufacturer"
msgstr ""
msgid "Model"
msgstr ""
msgid "USB Identifiers"
msgstr ""
msgid "Core Temperature"
msgstr ""
msgid "Driver Binding"
msgstr ""
msgid "Option Driver"
msgstr ""
msgid "SIM Status"
msgstr ""
msgid "Querying..."
msgstr ""
msgid "Not Supported"
msgstr ""
msgid "Normal"
msgstr ""
msgid "PIN Locked"
msgstr ""
msgid "PUK Locked"
msgstr ""
msgid "Not Detected"
msgstr ""
msgid "Cellular Network and Signal Metrics"
msgstr ""
msgid "Operator"
msgstr ""
msgid "Access Technology"
msgstr ""
msgid "Active Band"
msgstr ""
msgid "Physical Cell ID"
msgstr ""
msgid "Cell ID"
msgstr ""
msgid "Tracking Area Code"
msgstr ""
msgid "Carrier Channel"
msgstr ""
msgid "Signal Strength (CSQ)"
msgstr ""
msgid "Signal Power (RSRP)"
msgstr ""
msgid "Signal Quality (SINR)"
msgstr ""
msgid "Excellent"
msgstr ""
msgid "Good"
msgstr ""
msgid "Weak"
msgstr ""
msgid "Searching"
msgstr ""
msgid "Calculating"
msgstr ""
msgid "Network Interface Connection"
msgstr ""
msgid "Connection State"
msgstr ""
msgid "Connected"
msgstr ""
msgid "Disconnected"
msgstr ""
msgid "Assigned IPv4"
msgstr ""
msgid "Traffic Statistics"
msgstr ""
msgid "Downlink: "
msgstr ""
msgid " / Uplink: "
msgstr ""
msgid "Restart Modem"
msgstr ""
msgid "Toggle Airplane Mode"
msgstr ""
msgid "Reset RF Radio"
msgstr ""
msgid "Executing Action"
msgstr ""
msgid "Sending action to cellular module, please wait..."
msgstr ""
msgid "Action executed successfully."
msgstr ""
msgid "Action failed: "
msgstr ""
msgid "Driver Protocol and Operation Mode"
msgstr ""
msgid "Driver Mode"
msgstr ""
msgid "MBIM Mode"
msgstr ""
msgid "NCM Mode"
msgstr ""
msgid "QMI Mode"
msgstr ""
msgid "ECM Mode"
msgstr ""
msgid "RNDIS Mode"
msgstr ""
msgid "Switching the operation mode requires restarting the modem to take effect."
msgstr ""
msgid "Sync OpenWrt Network"
msgstr ""
msgid "Automatically update or create corresponding logical network interface in OpenWrt"
msgstr ""
msgid "Interface Name"
msgstr ""
msgid "The OpenWrt logical interface name to associate with this modem."
msgstr ""
msgid "Network Access Technology and Band Locking"
msgstr ""
msgid "Access Technology Preference"
msgstr ""
msgid "Automatic Mode"
msgstr ""
msgid "5G Standalone Only"
msgstr ""
msgid "5G and 4G Hybrid"
msgstr ""
msgid "4G LTE Only"
msgstr ""
msgid "Select priority radio access technology for network registration."
msgstr ""
msgid "4G LTE Bands"
msgstr ""
msgid "5G NR Bands"
msgstr ""
msgid "Access Point Name (APN) Settings"
msgstr ""
msgid "APN Name"
msgstr ""
msgid "Cellular carrier APN profile name (e.g. cmiot, cbnet, uninet)."
msgstr ""
msgid "PDP Protocol Type"
msgstr ""
msgid "Dual Stack IPv4 and IPv6"
msgstr ""
msgid "IPv4 Only"
msgstr ""
msgid "IPv6 Only"
msgstr ""
msgid "Save and Apply"
msgstr ""
msgid "Applying Configuration"
msgstr ""
msgid "Sending configuration commands to modem, please wait..."
msgstr ""
msgid "Modem settings successfully applied."
msgstr ""
msgid "Failed to apply settings: "
msgstr ""
msgid "Preset Template Library"
msgstr ""
msgid "Template Name"
msgstr ""
msgid "Vendor"
msgstr ""
msgid "Vendor ID"
msgstr ""
msgid "Mode Switching Command"
msgstr ""
msgid "Actions"
msgstr ""
msgid "View Details"
msgstr ""
msgid "Template Detail"
msgstr ""
msgid "Template ID"
msgstr ""
msgid "Target Vendor"
msgstr ""
msgid "USB VID"
msgstr ""
msgid "Mode Switch Command"
msgstr ""
msgid "Raw Configuration"
msgstr ""
msgid "Close"
msgstr ""
msgid "Serial Port Selection"
msgstr ""
msgid "Communication Port:"
msgstr ""
msgid "Baudrate: 115200 (Auto Locked)"
msgstr ""
msgid "Quick AT Command Presets"
msgstr ""
msgid "General Info:"
msgstr ""
msgid "Network State:"
msgstr ""
msgid "Quectel Extra:"
msgstr ""
msgid "Fibocom Extra:"
msgstr ""
msgid "Interactive Console Output"
msgstr ""
msgid "Clear Output"
msgstr ""
msgid "Copy All"
msgstr ""
msgid "Terminal output copied to clipboard."
msgstr ""
msgid "System ready. Enter an AT command and press Enter or Send to execute."
msgstr ""
msgid "Enter AT command (e.g. AT+CSQ)..."
msgstr ""
msgid "Send Command"
msgstr ""
msgid "Clear Log"
msgstr ""
msgid "Log buffer cleared successfully."
msgstr ""
msgid "No log entries recorded yet."
msgstr ""
msgid "No Active Cellular Modem Detected"
msgstr ""
msgid "No active 4G or 5G cellular modem detected on the USB bus. Please verify hardware connection, power supply, or driver status."
msgstr ""
msgid "Rescan Devices"
msgstr ""
msgid "No Serial Ports Available"
msgstr ""
msgid "No ttyUSB or ttyACM serial communication ports detected. Please verify hardware connection and driver state."
msgstr ""
msgid "No Cellular Modem Detected"
msgstr ""
msgid "No active cellular modem detected. Unable to manage SMS messages. Please check physical connection."
msgstr ""
msgid "No active cellular modem detected. You can still pre-configure serial port path or refresh after connecting."
msgstr ""
msgid "Signal Strength CSQ"
msgstr ""
msgid "Signal Power RSRP"
msgstr ""
msgid "Signal Quality SINR"
msgstr ""
msgid "Configure PDP context 1 with APN"
msgstr ""
msgid "Configure driver protocols, lock preferred access technologies, customize APN credentials, and manage cellular network interfaces."
msgstr ""
msgid "Dial string too long"
msgstr ""
msgid "Direct interactive terminal for sending AT commands with intelligent auto-punctuation correction, fuzzy suggestions, error translation, and custom favorites."
msgstr ""
msgid "Enable full RF function (Turn off airplane mode)"
msgstr ""
msgid "Execution failed: "
msgstr ""
msgid "Fibocom: Query RNDIS network connection status"
msgstr ""
msgid "Fibocom: Query carrier aggregation info"
msgstr ""
msgid "Fibocom: Query current cell info and radio metrics"
msgstr ""
msgid "Incorrect PIN entered"
msgstr ""
msgid "Invalid characters in dial string"
msgstr ""
msgid "Invalid characters in text string"
msgstr ""
msgid "Invalid index or memory entry not found"
msgstr ""
msgid "Manage vendor preset command profiles, private AT mappings, band lock masks, and driver switching templates."
msgstr ""
msgid "Memory storage full"
msgstr ""
msgid "Network not allowed emergency calls only"
msgstr ""
msgid "Network timeout"
msgstr ""
msgid "No Net"
msgstr ""
msgid "No Port"
msgstr ""
msgid "No cellular network service available"
msgstr ""
msgid "No connection to phone"
msgstr ""
msgid "No templates installed."
msgstr ""
msgid "None"
msgstr ""
msgid "Not Defined"
msgstr ""
msgid "Not found"
msgstr ""
msgid "Operation not allowed"
msgstr ""
msgid "Operation not supported"
msgstr ""
msgid "PH-SIM PIN required"
msgstr ""
msgid "PIN2 required"
msgstr ""
msgid "PUK2 required"
msgstr ""
msgid "Phone failure"
msgstr ""
msgid "Phone-adaptor link reserved"
msgstr ""
msgid "Please select a communication port first."
msgstr ""
msgid "Please select a valid modem port first."
msgstr ""
msgid "Quectel: Query CPU and RF chip temperatures"
msgstr ""
msgid "Quectel: Query carrier aggregation (CA) state and secondary cells"
msgstr ""
msgid "Quectel: Query current network band and channel info"
msgstr ""
msgid "Quectel: Query current serving cell details (5G SA/LTE, PCI, TAC, Band, RSRP, SINR)"
msgstr ""
msgid "Query 2G/3G network registration status"
msgstr ""
msgid "Query 4G LTE network registration status"
msgstr ""
msgid "Query 5G NR network registration status"
msgstr ""
msgid "Query SIM card ICCID number"
msgstr ""
msgid "Query SIM card lock and ready status"
msgstr ""
msgid "Query configured PDP contexts and APN profiles"
msgstr ""
msgid "Query current RF radio state (0:Min, 1:Full, 4:Airplane)"
msgstr ""
msgid "Query module manufacturer, model, and firmware version"
msgstr ""
msgid "Query received signal strength and bit error rate"
msgstr ""
msgid "Query registered cellular operator and RAT"
msgstr ""
msgid "Real-time overview of cellular modem hardware, network registration, signal metrics, and data connection."
msgstr ""
msgid "Reboot module processor and RF reset"
msgstr ""
msgid "SIM PIN required (Please enter PIN to unlock)"
msgstr ""
msgid "SIM PUK required (Please enter PUK to unlock)"
msgstr ""
msgid "SIM card busy, please retry later"
msgstr ""
msgid "SIM card failure or communication defect"
msgstr ""
msgid "SIM card not inserted or not detected"
msgstr ""
msgid "SIM wrong password"
msgstr ""
msgid "Scan all available cellular networks in air (Long timeout)"
msgstr ""
msgid "Text string too long"
msgstr ""
msgid "Turn on airplane mode"
msgstr ""
msgid "Unknown"
msgstr ""
msgid "Unknown error or unsupported extended command"
msgstr ""
msgid "Uplink: "
msgstr ""
msgid "View background AT command traces, hardware detection events, and auto-reconnect activities."
msgstr ""
msgid "Signal CSQ"
msgstr ""
@@ -0,0 +1,614 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8\n"
msgid "Modem Manager"
msgstr ""
msgid "Cellular Modem Manager"
msgstr ""
msgid "Overview"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Templates"
msgstr ""
msgid "Configuration Templates"
msgstr ""
msgid "AT Terminal"
msgstr ""
msgid "AT Command Terminal"
msgstr ""
msgid "Log"
msgstr ""
msgid "System Log"
msgstr ""
msgid "Modem Manager Logs"
msgstr ""
msgid "Active Modem Selection"
msgstr ""
msgid "Target Modem Selection"
msgstr ""
msgid "Current Device:"
msgstr ""
msgid "Active Device:"
msgstr ""
msgid "Refresh"
msgstr ""
msgid "No cellular modems detected on USB bus."
msgstr ""
msgid "No cellular modems detected."
msgstr ""
msgid "No serial communication ports detected."
msgstr ""
msgid "Hardware Information"
msgstr ""
msgid "Manufacturer"
msgstr ""
msgid "Model"
msgstr ""
msgid "USB Identifiers"
msgstr ""
msgid "Core Temperature"
msgstr ""
msgid "Driver Binding"
msgstr ""
msgid "Option Driver"
msgstr ""
msgid "SIM Status"
msgstr ""
msgid "Querying..."
msgstr ""
msgid "Not Supported"
msgstr ""
msgid "Normal"
msgstr ""
msgid "PIN Locked"
msgstr ""
msgid "PUK Locked"
msgstr ""
msgid "Not Detected"
msgstr ""
msgid "Cellular Network and Signal Metrics"
msgstr ""
msgid "Operator"
msgstr ""
msgid "Access Technology"
msgstr ""
msgid "Active Band"
msgstr ""
msgid "Physical Cell ID"
msgstr ""
msgid "Cell ID"
msgstr ""
msgid "Tracking Area Code"
msgstr ""
msgid "Carrier Channel"
msgstr ""
msgid "Signal Strength (CSQ)"
msgstr ""
msgid "Signal Power (RSRP)"
msgstr ""
msgid "Signal Quality (SINR)"
msgstr ""
msgid "Excellent"
msgstr ""
msgid "Good"
msgstr ""
msgid "Weak"
msgstr ""
msgid "Searching"
msgstr ""
msgid "Calculating"
msgstr ""
msgid "Network Interface Connection"
msgstr ""
msgid "Connection State"
msgstr ""
msgid "Connected"
msgstr ""
msgid "Disconnected"
msgstr ""
msgid "Assigned IPv4"
msgstr ""
msgid "Traffic Statistics"
msgstr ""
msgid "Downlink: "
msgstr ""
msgid " / Uplink: "
msgstr ""
msgid "Restart Modem"
msgstr ""
msgid "Toggle Airplane Mode"
msgstr ""
msgid "Reset RF Radio"
msgstr ""
msgid "Executing Action"
msgstr ""
msgid "Sending action to cellular module, please wait..."
msgstr ""
msgid "Action executed successfully."
msgstr ""
msgid "Action failed: "
msgstr ""
msgid "Driver Protocol and Operation Mode"
msgstr ""
msgid "Driver Mode"
msgstr ""
msgid "MBIM Mode"
msgstr ""
msgid "NCM Mode"
msgstr ""
msgid "QMI Mode"
msgstr ""
msgid "ECM Mode"
msgstr ""
msgid "RNDIS Mode"
msgstr ""
msgid "Switching the operation mode requires restarting the modem to take effect."
msgstr ""
msgid "Sync OpenWrt Network"
msgstr ""
msgid "Automatically update or create corresponding logical network interface in OpenWrt"
msgstr ""
msgid "Interface Name"
msgstr ""
msgid "The OpenWrt logical interface name to associate with this modem."
msgstr ""
msgid "Network Access Technology and Band Locking"
msgstr ""
msgid "Access Technology Preference"
msgstr ""
msgid "Automatic Mode"
msgstr ""
msgid "5G Standalone Only"
msgstr ""
msgid "5G and 4G Hybrid"
msgstr ""
msgid "4G LTE Only"
msgstr ""
msgid "Select priority radio access technology for network registration."
msgstr ""
msgid "4G LTE Bands"
msgstr ""
msgid "5G NR Bands"
msgstr ""
msgid "Access Point Name (APN) Settings"
msgstr ""
msgid "APN Name"
msgstr ""
msgid "Cellular carrier APN profile name (e.g. cmiot, cbnet, uninet)."
msgstr ""
msgid "PDP Protocol Type"
msgstr ""
msgid "Dual Stack IPv4 and IPv6"
msgstr ""
msgid "IPv4 Only"
msgstr ""
msgid "IPv6 Only"
msgstr ""
msgid "Save and Apply"
msgstr ""
msgid "Applying Configuration"
msgstr ""
msgid "Sending configuration commands to modem, please wait..."
msgstr ""
msgid "Modem settings successfully applied."
msgstr ""
msgid "Failed to apply settings: "
msgstr ""
msgid "Preset Template Library"
msgstr ""
msgid "Template Name"
msgstr ""
msgid "Vendor"
msgstr ""
msgid "Vendor ID"
msgstr ""
msgid "Mode Switching Command"
msgstr ""
msgid "Actions"
msgstr ""
msgid "View Details"
msgstr ""
msgid "Template Detail"
msgstr ""
msgid "Template ID"
msgstr ""
msgid "Target Vendor"
msgstr ""
msgid "USB VID"
msgstr ""
msgid "Mode Switch Command"
msgstr ""
msgid "Raw Configuration"
msgstr ""
msgid "Close"
msgstr ""
msgid "Serial Port Selection"
msgstr ""
msgid "Communication Port:"
msgstr ""
msgid "Baudrate: 115200 (Auto Locked)"
msgstr ""
msgid "Quick AT Command Presets"
msgstr ""
msgid "General Info:"
msgstr ""
msgid "Network State:"
msgstr ""
msgid "Quectel Extra:"
msgstr ""
msgid "Fibocom Extra:"
msgstr ""
msgid "Interactive Console Output"
msgstr ""
msgid "Clear Output"
msgstr ""
msgid "Copy All"
msgstr ""
msgid "Terminal output copied to clipboard."
msgstr ""
msgid "System ready. Enter an AT command and press Enter or Send to execute."
msgstr ""
msgid "Enter AT command (e.g. AT+CSQ)..."
msgstr ""
msgid "Send Command"
msgstr ""
msgid "Clear Log"
msgstr ""
msgid "Log buffer cleared successfully."
msgstr ""
msgid "No log entries recorded yet."
msgstr ""
msgid "No Active Cellular Modem Detected"
msgstr ""
msgid "No active 4G or 5G cellular modem detected on the USB bus. Please verify hardware connection, power supply, or driver status."
msgstr ""
msgid "Rescan Devices"
msgstr ""
msgid "No Serial Ports Available"
msgstr ""
msgid "No ttyUSB or ttyACM serial communication ports detected. Please verify hardware connection and driver state."
msgstr ""
msgid "No Cellular Modem Detected"
msgstr ""
msgid "No active cellular modem detected. Unable to manage SMS messages. Please check physical connection."
msgstr ""
msgid "No active cellular modem detected. You can still pre-configure serial port path or refresh after connecting."
msgstr ""
msgid "Signal Strength CSQ"
msgstr ""
msgid "Signal Power RSRP"
msgstr ""
msgid "Signal Quality SINR"
msgstr ""
msgid "Configure PDP context 1 with APN"
msgstr ""
msgid "Configure driver protocols, lock preferred access technologies, customize APN credentials, and manage cellular network interfaces."
msgstr ""
msgid "Dial string too long"
msgstr ""
msgid "Direct interactive terminal for sending AT commands with intelligent auto-punctuation correction, fuzzy suggestions, error translation, and custom favorites."
msgstr ""
msgid "Enable full RF function (Turn off airplane mode)"
msgstr ""
msgid "Execution failed: "
msgstr ""
msgid "Fibocom: Query RNDIS network connection status"
msgstr ""
msgid "Fibocom: Query carrier aggregation info"
msgstr ""
msgid "Fibocom: Query current cell info and radio metrics"
msgstr ""
msgid "Incorrect PIN entered"
msgstr ""
msgid "Invalid characters in dial string"
msgstr ""
msgid "Invalid characters in text string"
msgstr ""
msgid "Invalid index or memory entry not found"
msgstr ""
msgid "Manage vendor preset command profiles, private AT mappings, band lock masks, and driver switching templates."
msgstr ""
msgid "Memory storage full"
msgstr ""
msgid "Network not allowed emergency calls only"
msgstr ""
msgid "Network timeout"
msgstr ""
msgid "No Net"
msgstr ""
msgid "No Port"
msgstr ""
msgid "No cellular network service available"
msgstr ""
msgid "No connection to phone"
msgstr ""
msgid "No templates installed."
msgstr ""
msgid "None"
msgstr ""
msgid "Not Defined"
msgstr ""
msgid "Not found"
msgstr ""
msgid "Operation not allowed"
msgstr ""
msgid "Operation not supported"
msgstr ""
msgid "PH-SIM PIN required"
msgstr ""
msgid "PIN2 required"
msgstr ""
msgid "PUK2 required"
msgstr ""
msgid "Phone failure"
msgstr ""
msgid "Phone-adaptor link reserved"
msgstr ""
msgid "Please select a communication port first."
msgstr ""
msgid "Please select a valid modem port first."
msgstr ""
msgid "Quectel: Query CPU and RF chip temperatures"
msgstr ""
msgid "Quectel: Query carrier aggregation (CA) state and secondary cells"
msgstr ""
msgid "Quectel: Query current network band and channel info"
msgstr ""
msgid "Quectel: Query current serving cell details (5G SA/LTE, PCI, TAC, Band, RSRP, SINR)"
msgstr ""
msgid "Query 2G/3G network registration status"
msgstr ""
msgid "Query 4G LTE network registration status"
msgstr ""
msgid "Query 5G NR network registration status"
msgstr ""
msgid "Query SIM card ICCID number"
msgstr ""
msgid "Query SIM card lock and ready status"
msgstr ""
msgid "Query configured PDP contexts and APN profiles"
msgstr ""
msgid "Query current RF radio state (0:Min, 1:Full, 4:Airplane)"
msgstr ""
msgid "Query module manufacturer, model, and firmware version"
msgstr ""
msgid "Query received signal strength and bit error rate"
msgstr ""
msgid "Query registered cellular operator and RAT"
msgstr ""
msgid "Real-time overview of cellular modem hardware, network registration, signal metrics, and data connection."
msgstr ""
msgid "Reboot module processor and RF reset"
msgstr ""
msgid "SIM PIN required (Please enter PIN to unlock)"
msgstr ""
msgid "SIM PUK required (Please enter PUK to unlock)"
msgstr ""
msgid "SIM card busy, please retry later"
msgstr ""
msgid "SIM card failure or communication defect"
msgstr ""
msgid "SIM card not inserted or not detected"
msgstr ""
msgid "SIM wrong password"
msgstr ""
msgid "Scan all available cellular networks in air (Long timeout)"
msgstr ""
msgid "Text string too long"
msgstr ""
msgid "Turn on airplane mode"
msgstr ""
msgid "Unknown"
msgstr ""
msgid "Unknown error or unsupported extended command"
msgstr ""
msgid "Uplink: "
msgstr ""
msgid "View background AT command traces, hardware detection events, and auto-reconnect activities."
msgstr ""
msgid "Signal CSQ"
msgstr ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
config global 'global'
option enabled '1'
option check_interval '15'
option auto_reconnect '1'
option target_host '223.5.5.5'
option default_interface 'modem_wan'
option log_level 'info'
option sms_enable '0'
config template_custom
option enabled '0'
@@ -0,0 +1,18 @@
#!/bin/sh
# USB Hotplug Event Handler for Cellular Modems
# Dante <dante@seckv.com>
[ "$DEVTYPE" = "usb_device" ] || exit 0
case "$ACTION" in
add|bind|remove)
# Delay slightly to allow sysfs device tree and tty nodes to settle
(
sleep 1
/usr/libexec/modem-detect --cache >/dev/null 2>&1
which ubus >/dev/null 2>&1 && ubus send modem.device.change "{\"action\":\"$ACTION\",\"dev\":\"$DEVICENAME\"}" 2>/dev/null || true
) &
;;
esac
exit 0
+44
View File
@@ -0,0 +1,44 @@
#!/bin/sh /etc/rc.common
# Cellular Modem Manager Daemon
# Dante <dante@seckv.com>
USE_PROCD=1
START=95
STOP=10
PROG="/usr/libexec/modem-collector"
start_service() {
local enabled check_interval
config_load modem-manager
config_get_bool enabled global enabled 1
config_get check_interval global check_interval 15
[ "$enabled" -eq 1 ] || return 0
mkdir -p /tmp/run/modem-manager /var/log /var/lock
touch /var/log/modem-manager.log
# Warm up devices discovery cache at startup
/usr/libexec/modem-detect --cache >/dev/null 2>&1 &
procd_open_instance
procd_set_param command "$PROG"
procd_set_param respawn 3600 5 0
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
rm -f /var/lock/modem_terminal_active.lock 2>/dev/null || true
}
reload_service() {
stop
start
}
service_triggers() {
procd_add_reload_trigger "modem-manager"
}
@@ -0,0 +1,6 @@
#!/bin/sh
chmod 0755 /usr/libexec/modem-at-executor /usr/libexec/modem-detect /usr/libexec/modem-collector 2>/dev/null || true
chmod 0755 /etc/init.d/modem-manager /etc/hotplug.d/usb/20-modem-hotplug 2>/dev/null || true
exit 0
@@ -0,0 +1,164 @@
#!/bin/sh
# Modem AT Command Safe Executor (Zero-dependency, Dynamic Timeout)
# Dante <dante@seckv.com>
DEVICE=""
COMMAND=""
TIMEOUT=3
BAUDRATE=115200
SMS_TO=""
SMS_MSG=""
while [ $# -gt 0 ]; do
case "$1" in
-d) DEVICE="$2"; shift 2 ;;
-c) COMMAND="$2"; shift 2 ;;
-t) TIMEOUT="$2"; shift 2 ;;
-b) BAUDRATE="$2"; shift 2 ;;
--sms-to) SMS_TO="$2"; shift 2 ;;
--sms-msg) SMS_MSG="$2"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$DEVICE" ]; then
echo "Usage: $0 -d <device> [-c <command>] [--sms-to <number> --sms-msg <text>]" >&2
exit 1
fi
if [ -z "$COMMAND" ] && [ -z "$SMS_TO" ]; then
echo "Usage: Either -c <command> or --sms-to <number> must be specified" >&2
exit 1
fi
case "$DEVICE" in
/dev/ttyUSB*|/dev/ttyACM*) ;;
*)
echo "ERROR: Invalid device path $DEVICE" >&2
exit 2
;;
esac
if [ ! -c "$DEVICE" ]; then
echo "ERROR: Device $DEVICE not found" >&2
exit 3
fi
# Dynamic Timeout auto-detection: long-running commands like AT+COPS=? or scan
if [ -n "$SMS_TO" ]; then
[ "$TIMEOUT" -lt 15 ] && TIMEOUT=15
else
case "$COMMAND" in
*COPS=\?*|*COPS\?*|*QSCAN*|*GTCCINFO*|*SCAN*)
[ "$TIMEOUT" -lt 60 ] && TIMEOUT=60
;;
esac
fi
LOCKFILE="/var/lock/modem_$(basename "$DEVICE").lock"
mkdir -p /var/lock
# Acquire exclusive file lock compatible with Busybox flock (-n -x FD)
exec 9>"$LOCKFILE"
locked=0
waited=0
while [ $waited -le "$TIMEOUT" ]; do
if flock -n -x 9 2>/dev/null; then
locked=1
break
fi
sleep 1
waited=$((waited + 1))
done
if [ $locked -eq 0 ]; then
echo "ERROR: Device $DEVICE busy, lock timed out" >&2
exit 4
fi
# Fast path: Prioritize native sms_tool engine if installed (zero echo loop, robust termios)
if command -v sms_tool >/dev/null 2>&1; then
if [ -n "$SMS_TO" ]; then
OUT=$(sms_tool -d "$DEVICE" -b "$BAUDRATE" send "$SMS_TO" "$SMS_MSG" 2>&1)
RC=$?
else
OUT=$(sms_tool -d "$DEVICE" -b "$BAUDRATE" at "$COMMAND" 2>&1)
RC=$?
fi
flock -u 9 2>/dev/null || true
CLEAN_OUT=$(echo "$OUT" | tr -d "\r")
if [ $RC -eq 0 ] && ! echo "$CLEAN_OUT" | grep -qE "ERROR|\+CME ERROR|\+CMS ERROR"; then
if [ -z "$CLEAN_OUT" ]; then
echo "OK"
else
echo "$CLEAN_OUT"
case "$CLEAN_OUT" in
*OK*) ;;
*) echo "OK" ;;
esac
fi
exit 0
else
echo "$CLEAN_OUT"
exit ${RC:-1}
fi
fi
command -v stty >/dev/null 2>&1 && stty -F "$DEVICE" "$BAUDRATE" raw -echo -echoe -echok min 0 time 2 2>/dev/null || true
OUT_FILE="/tmp/at_out_$$.tmp"
rm -f "$OUT_FILE"
(
exec 3<>"$DEVICE"
# Drain stale residual buffer
dd if="$DEVICE" of=/dev/null bs=512 count=1 2>/dev/null || true
cat <&3 > "$OUT_FILE" &
CAT_PID=$!
if [ -n "$SMS_TO" ]; then
# Text mode SMS sending
printf "AT+CMGF=1\r\n" >&3
sleep 1
printf "AT+CMGS=\"%s\"\r\n" "$SMS_TO" >&3
sleep 1
printf "%s\x1A" "$SMS_MSG" >&3
elapsed=0
while [ $elapsed -lt "$TIMEOUT" ]; do
sleep 1
elapsed=$((elapsed + 1))
if [ -f "$OUT_FILE" ]; then
if grep -q "\+CMGS:" "$OUT_FILE" 2>/dev/null && grep -q "OK" "$OUT_FILE" 2>/dev/null; then
break
elif grep -qE "\+CMS ERROR|\+CME ERROR|ERROR" "$OUT_FILE" 2>/dev/null; then
break
fi
fi
done
else
printf "%s\r\n" "$COMMAND" >&3
elapsed=0
while [ $elapsed -lt "$TIMEOUT" ]; do
sleep 1
elapsed=$((elapsed + 1))
if [ -f "$OUT_FILE" ] && grep -qE "OK|ERROR|\+CME ERROR|\+CMS ERROR" "$OUT_FILE" 2>/dev/null; then
break
fi
done
fi
kill -9 "$CAT_PID" 2>/dev/null || true
exec 3>&-
)
flock -u 9 2>/dev/null || true
if [ -f "$OUT_FILE" ]; then
cat "$OUT_FILE" | tr -d "\r"
rm -f "$OUT_FILE"
fi
exit 0
@@ -0,0 +1,60 @@
#!/bin/sh
# Background Status Collector with Active Terminal Yielding
# Dante <dante@seckv.com>
RUN_DIR="/tmp/run/modem-manager"
CACHE_DEVICES="$RUN_DIR/devices.json"
CACHE_STATUS="$RUN_DIR/status.json"
TERMINAL_LOCK="/var/lock/modem_terminal_active.lock"
YIELD_WINDOW=10
mkdir -p "$RUN_DIR" /var/lock
is_terminal_active() {
if [ -f "$TERMINAL_LOCK" ]; then
local lock_mtime=$(date -r "$TERMINAL_LOCK" +%s 2>/dev/null || echo 0)
local now=$(date +%s)
local diff=$((now - lock_mtime))
if [ "$diff" -ge 0 ] && [ "$diff" -le "$YIELD_WINDOW" ]; then
return 0
fi
fi
return 1
}
collect_single() {
if is_terminal_active; then
return 0
fi
[ -f "$CACHE_DEVICES" ] || /usr/libexec/modem-detect --cache >/dev/null 2>&1
# Check user manually configured port from UCI first
local user_port=$(uci -q get modem-manager.global.port || uci -q get modem-manager.default.port)
ucode -e '
import * as fs from "fs";
let m = loadfile("/usr/share/rpcd/ucode/luci.modem")();
let plugin = m["luci.modem"];
let devs_data = plugin.get_devices.call();
let dev = (devs_data?.devices && length(devs_data.devices) > 0) ? devs_data.devices[0] : null;
let user_port = "'"$user_port"'";
let target_port = (user_port && user_port != "") ? user_port : (dev?.primary_port || null);
if (target_port) {
let status = plugin.collect_raw_status.call({ args: { port: target_port, primary_net: dev?.primary_net || "usb0" } });
if (status) {
let tmp = "/tmp/run/modem-manager/status.json.tmp";
fs.writefile(tmp, sprintf("%J\n", status));
fs.rename(tmp, "/tmp/run/modem-manager/status.json");
}
}
' 2>/dev/null || true
}
while true; do
collect_single
INTERVAL=$(uci -q get modem-manager.global.check_interval || echo 15)
[ "$INTERVAL" -ge 5 ] 2>/dev/null || INTERVAL=15
sleep "$INTERVAL"
done
+189
View File
@@ -0,0 +1,189 @@
#!/bin/sh
# Cellular Modem Auto-Detector for OpenWrt
# Outputs discovered modems in JSON format and updates /tmp/run/modem-manager/devices.json
RUN_DIR="/tmp/run/modem-manager"
CACHE_FILE="$RUN_DIR/devices.json"
KNOWN_VENDORS="2c7c 2cb7 1e0e 12d1 19d2 1bbb 05c6 413c 0bdb 1199 2977 1508 1bc7 0e8d 1782 0489 1fac 2dee 33f8 3731 2df3 20a6"
get_sys_attr() {
local path="$1"
if [ -f "$path" ]; then
tr -d '\r\n' < "$path" 2>/dev/null
fi
}
detect_all() {
echo "{"
echo ' "devices": ['
local FIRST=1
for dev in /sys/bus/usb/devices/*; do
[ -d "$dev" ] || continue
[ -f "$dev/idVendor" ] || continue
[ -f "$dev/idProduct" ] || continue
local VID=$(get_sys_attr "$dev/idVendor")
local PID=$(get_sys_attr "$dev/idProduct")
local DEVCLASS=$(get_sys_attr "$dev/bDeviceClass")
case "$VID" in
1d6b|2109|1d50) continue ;;
esac
[ "$DEVCLASS" = "09" ] && continue
local IS_MODEM=0
for kv in $KNOWN_VENDORS; do
if [ "$VID" = "$kv" ]; then
IS_MODEM=1
break
fi
done
local DEVNAME=$(basename "$dev")
# Direct sysfs tree discovery for serial ports under this USB device
local TTYS=""
for t in $(find "$dev" -maxdepth 5 \( -name "ttyUSB*" -o -name "ttyACM*" \) 2>/dev/null); do
local b=$(basename "$t")
case "$b" in
ttyUSB*|ttyACM*)
if [ -e "/dev/$b" ]; then
case " $TTYS " in
*" /dev/$b "*) ;;
*) TTYS="$TTYS /dev/$b" ;;
esac
fi
;;
esac
done
# Fallback tty search via /sys/class/tty
if [ -z "$TTYS" ]; then
for t in /sys/class/tty/ttyUSB* /sys/class/tty/ttyACM*; do
[ -e "$t" ] || continue
local t_target=$(readlink "$t" 2>/dev/null || readlink -f "$t" 2>/dev/null)
case "$t_target" in
*"$DEVNAME"*)
local b=$(basename "$t")
[ -e "/dev/$b" ] && case " $TTYS " in *" /dev/$b "*) ;; *) TTYS="$TTYS /dev/$b" ;; esac
;;
esac
done
fi
TTYS=$(echo "$TTYS" | sed 's/^ *//')
# Network interfaces discovery under this USB device
local NETS=""
for n in $(find "$dev" -maxdepth 5 -name "net" 2>/dev/null); do
[ -d "$n" ] || continue
for iface in "$n"/*; do
[ -d "$iface" ] || continue
local b=$(basename "$iface")
case "$b" in
lo|br-*|eth*|lan*|wan*|sfp*) ;;
*) case " $NETS " in *" $b "*) ;; *) NETS="$NETS $b" ;; esac ;;
esac
done
done
# Fallback net search via /sys/class/net
if [ -z "$NETS" ]; then
for n in /sys/class/net/*; do
[ -d "$n" ] || continue
local n_target=$(readlink "$n" 2>/dev/null || readlink -f "$n" 2>/dev/null)
case "$n_target" in
*"$DEVNAME"*)
local b=$(basename "$n")
case "$b" in
lo|br-*|eth*|lan*|wan*|sfp*) ;;
*) case " $NETS " in *" $b "*) ;; *) NETS="$NETS $b" ;; esac ;;
esac
;;
esac
done
fi
NETS=$(echo "$NETS" | sed 's/^ *//')
if [ -n "$TTYS" ] || [ -n "$NETS" ] || [ $IS_MODEM -eq 1 ]; then
IS_MODEM=1
fi
[ $IS_MODEM -eq 1 ] || continue
local MFG=$(get_sys_attr "$dev/manufacturer")
local PROD=$(get_sys_attr "$dev/product")
local SER=$(get_sys_attr "$dev/serial")
local PRIMARY_TTY=""
for t in $TTYS; do
case "$t" in
*ttyUSB2|*ttyACM0)
PRIMARY_TTY="$t"
break
;;
esac
done
[ -z "$PRIMARY_TTY" ] && PRIMARY_TTY=$(echo "$TTYS" | awk '{print $1}')
local PRIMARY_NET=$(echo "$NETS" | awk '{print $1}')
local DRIVER="option"
for drv in $(find "$dev" -name "driver" 2>/dev/null); do
local drv_target=$(basename "$(readlink -f "$drv" 2>/dev/null)")
case "$drv_target" in
cdc_mbim|cdc_ncm|qmi_wwan|cdc_ether|rndis_host)
DRIVER="$drv_target"
break
;;
esac
done
[ $FIRST -eq 1 ] || echo ","
FIRST=0
local TTY_JSON=""
for t in $TTYS; do
[ -n "$TTY_JSON" ] && TTY_JSON="$TTY_JSON, "
TTY_JSON="$TTY_JSON\"$t\""
done
local NET_JSON=""
for n in $NETS; do
[ -n "$NET_JSON" ] && NET_JSON="$NET_JSON, "
NET_JSON="$NET_JSON\"$n\""
done
cat <<EOF
{
"id": "$DEVNAME",
"vid": "$VID",
"pid": "$PID",
"vendor": "${MFG:-Generic}",
"model": "${PROD:-Modem}",
"serial": "${SER}",
"driver": "$DRIVER",
"primary_port": "$PRIMARY_TTY",
"primary_net": "$PRIMARY_NET",
"ports": [ $TTY_JSON ],
"nets": [ $NET_JSON ]
}
EOF
done
echo " ]"
echo "}"
}
mkdir -p "$RUN_DIR"
OUTPUT=$(detect_all)
# Atomic update cache
echo "$OUTPUT" > "${CACHE_FILE}.tmp" && mv -f "${CACHE_FILE}.tmp" "$CACHE_FILE"
# Output to stdout if not purely --cache mode
if [ "$1" != "--cache" ]; then
echo "$OUTPUT"
fi
exit 0
@@ -0,0 +1,60 @@
{
"admin/network/modem": {
"title": "Modem Manager",
"order": 65,
"action": {
"type": "firstchild"
},
"depends": {
"acl": [ "luci-app-modem-manager" ]
}
},
"admin/network/modem/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "modem/overview"
}
},
"admin/network/modem/settings": {
"title": "Settings",
"order": 20,
"action": {
"type": "view",
"path": "modem/settings"
}
},
"admin/network/modem/sms": {
"title": "SMS",
"order": 30,
"action": {
"type": "view",
"path": "modem/sms"
}
},
"admin/network/modem/templates": {
"title": "Templates",
"order": 35,
"action": {
"type": "view",
"path": "modem/templates"
}
},
"admin/network/modem/terminal": {
"title": "AT Terminal",
"order": 40,
"action": {
"type": "view",
"path": "modem/terminal"
}
},
"admin/network/modem/log": {
"title": "Log",
"order": 50,
"action": {
"type": "view",
"path": "modem/log"
}
}
}
@@ -0,0 +1,40 @@
{
"id": "fibocom-5g",
"name": "广和通 4G/5G 全系模组",
"vendor": "Fibocom",
"vid": "2cb7",
"default_baud": 115200,
"commands": {
"info": "ATI",
"cpin": "AT+CPIN?",
"csq": "AT+CSQ",
"operator": "AT+COPS?",
"network_reg": "AT+CEREG?;+C5GREG?",
"serving_cell": "AT+GTCCINFO?",
"ca_info": "AT+GTCAINFO?",
"reboot": "AT+CFUN=1,1",
"airplane_on": "AT+CFUN=4",
"airplane_off": "AT+CFUN=1"
},
"mode_switch": {
"cmd": "AT+GTUSBMODE={mode}",
"modes": {
"mbim": "1",
"ncm": "2",
"ecm": "3",
"rndis": "4"
}
},
"rat_preference": {
"cmd": "AT+GTRAT={rat}",
"options": {
"AUTO": "0",
"LTE_ONLY": "2",
"NR5G_ONLY": "3"
}
},
"supported_bands": {
"lte": [ "B1", "B3", "B5", "B8", "B34", "B38", "B39", "B40", "B41" ],
"nr5g": [ "N1", "N28", "N41", "N78", "N79" ]
}
}
@@ -0,0 +1,29 @@
{
"id": "generic-3gpp",
"name": "标准 3GPP 通用模组",
"vendor": "Generic",
"vid": "*",
"default_baud": 115200,
"commands": {
"info": "ATI",
"cpin": "AT+CPIN?",
"csq": "AT+CSQ",
"operator": "AT+COPS?",
"network_reg": "AT+CREG?;+CEREG?",
"reboot": "AT+CFUN=1,1",
"airplane_on": "AT+CFUN=4",
"airplane_off": "AT+CFUN=1"
},
"mode_switch": {
"cmd": "",
"modes": {}
},
"rat_preference": {
"cmd": "",
"options": {}
},
"supported_bands": {
"lte": [ "B1", "B3", "B5", "B8", "B38", "B39", "B40", "B41" ],
"nr5g": []
}
}
@@ -0,0 +1,43 @@
{
"id": "quectel-5g",
"name": "移远通信 4G/5G 全系模组",
"vendor": "Quectel",
"vid": "2c7c",
"default_baud": 115200,
"commands": {
"info": "ATI",
"cpin": "AT+CPIN?",
"csq": "AT+CSQ",
"operator": "AT+COPS?",
"network_reg": "AT+CEREG=2;+CEREG?;+C5GREG=2;+C5GREG?",
"serving_cell": "AT+QENG=\"servingcell\"",
"ca_info": "AT+QCAINFO",
"temp": "AT+QTEMP",
"reboot": "AT+CFUN=1,1",
"airplane_on": "AT+CFUN=4",
"airplane_off": "AT+CFUN=1"
},
"mode_switch": {
"cmd": "AT+QCFG=\"usbnet\",{mode}",
"modes": {
"qmi": "0",
"ecm": "1",
"mbim": "2",
"rndis": "3",
"ncm": "5"
}
},
"rat_preference": {
"cmd": "AT+QNWPREFCFG=\"mode_pref\",{rat}",
"options": {
"AUTO": "AUTO",
"NR5G_ONLY": "NR5G",
"LTE_ONLY": "LTE",
"NR5G_LTE": "NR5G:LTE"
}
},
"supported_bands": {
"lte": [ "B1", "B3", "B5", "B8", "B34", "B38", "B39", "B40", "B41" ],
"nr5g": [ "N1", "N28", "N41", "N77", "N78", "N79" ]
}
}
@@ -0,0 +1,35 @@
{
"id": "zte-generic",
"name": "中兴微 4G/5G 模组",
"vendor": "ZTE",
"vid": "19d2",
"default_baud": 115200,
"commands": {
"info": "ATI",
"cpin": "AT+CPIN?",
"csq": "AT+CSQ",
"operator": "AT+COPS?",
"network_reg": "AT+CEREG?",
"reboot": "AT+CFUN=1,1",
"airplane_on": "AT+CFUN=4",
"airplane_off": "AT+CFUN=1"
},
"mode_switch": {
"cmd": "AT+ZMODE={mode}",
"modes": {
"ncm": "1",
"rndis": "2"
}
},
"rat_preference": {
"cmd": "AT+ZSNT={rat}",
"options": {
"AUTO": "0,0,0",
"LTE_ONLY": "6,0,0"
}
},
"supported_bands": {
"lte": [ "B1", "B3", "B5", "B8", "B34", "B38", "B39", "B40", "B41" ],
"nr5g": [ "N1", "N28", "N41", "N78" ]
}
}
@@ -0,0 +1,56 @@
{
"luci-app-modem-manager": {
"description": "Grant access to modem manager RPC methods",
"read": {
"ubus": {
"luci.modem": [
"get_devices",
"get_status",
"get_templates",
"get_config",
"get_log",
"get_sms_status",
"get_sms_list",
"get_cell_lock",
"get_apn"
]
},
"uci": [
"modem-manager",
"network"
],
"file": {
"/tmp/run/modem-manager/devices.json": [ "read" ],
"/tmp/run/modem-manager/status.json": [ "read" ]
}
},
"write": {
"ubus": {
"luci.modem": [
"send_at",
"set_mode",
"set_preferences",
"set_apn",
"set_cell_lock",
"device_action",
"refresh_status",
"save_template",
"save_config",
"clear_log",
"set_sms_enable",
"send_sms",
"delete_sms"
]
},
"uci": [
"modem-manager",
"network"
],
"file": {
"/etc/config/modem-manager": [ "read", "write" ],
"/var/log/modem-manager.log": [ "read", "write" ],
"/tmp/run/modem-manager/*": [ "read", "write" ]
}
}
}
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-passwall
PKG_VERSION:=26.10.4
PKG_RELEASE:=330
PKG_RELEASE:=331
PKG_PO_VERSION:=$(PKG_VERSION)
PKG_CONFIG_DEPENDS:= \
@@ -1,18 +1,61 @@
#!/bin/sh
# dhcp.leases to hosts
# DHCP leases and static hosts to hosts
. /lib/functions.sh
. /usr/share/passwall/utils.sh
LOCK_FILE=${LOCK_PATH}/${CONFIG}_lease2hosts.lock
LEASE_FILE=$(uci -q get dhcp.@dnsmasq[0].leasefile || echo "/tmp/dhcp.leases")
DNSMASQ_LEASE_FILE=$(uci -q get 'dhcp.@dnsmasq[0].leasefile')
DNSMASQ_LEASE_FILE=${DNSMASQ_LEASE_FILE:-/tmp/dhcp.leases}
ODHCPD_LEASE_FILE=$(uci -q get dhcp.odhcpd.leasefile)
ODHCPD_LEASE_FILE=${ODHCPD_LEASE_FILE:-/tmp/odhcpd.leases}
HOSTS_FILE="$TMP_PATH2/dhcp-hosts"
TMP_FILE="/tmp/dhcp-hosts.tmp"
exec 99>"$LOCK_FILE"
flock -n 99
if [ "$?" != 0 ]; then
exit 0
fi
exec 9>"$LOCK_FILE"
flock -n 9 || exit 0
read_static_host() {
local name ip
config_get name "$1" name
config_get ip "$1" ip
[ -n "$name" ] && [ "$name" != "*" ] && [ -n "$ip" ] && [ "$ip" != "ignore" ] || return
printf '%s %s\n' "$ip" "$name"
}
# Static DHCP configuration only needs to be read once at startup.
STATIC_HOSTS=$(
config_load dhcp
config_foreach read_static_host host
)
read_odhcpd_leases() {
[ -f "$ODHCPD_LEASE_FILE" ] || return
awk -v now="$(date +%s)" '
function valid_name(name) {
return name ~ /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/
}
# State records: # interface DUID IAID hostname expiry assigned length address/prefix ...
$1 == "#" {
if (NF < 9 || !valid_name($5) || ($6 != -1 && $6 <= now))
next
# Only host addresses, never delegated IPv6 prefixes.
if (!(($4 == "ipv4" && $8 == 32) || ($4 != "ipv4" && $8 == 128)))
next
for (i = 9; i <= NF; i++) {
split($i, addr, "/")
print addr[1] " " $5
}
next
}
# Older odhcpd versions also include hosts-format lines, with optional aliases.
NF >= 2 && $1 ~ /^[0-9a-fA-F:.]+$/ {
for (i = 2; i <= NF && $i !~ /^#/; i++)
if (valid_name($i))
print $1 " " $i
}
' "$ODHCPD_LEASE_FILE"
}
reload_dnsmasq_pids() {
local pidfile pid
@@ -28,22 +71,26 @@ reload_dnsmasq_pids() {
while true; do
if [ -f "$LEASE_FILE" ]; then
awk 'NF >= 4 && $4 != "*" {print $3" "$4}' "$LEASE_FILE" | sort > "$TMP_FILE"
if [ -s "$TMP_FILE" ]; then
if [ ! -f "$HOSTS_FILE" ] || ! cmp -s "$TMP_FILE" "$HOSTS_FILE"; then
mv "$TMP_FILE" "$HOSTS_FILE"
reload_dnsmasq_pids
else
rm -f "$TMP_FILE"
fi
{
[ -n "$STATIC_HOSTS" ] && printf '%s\n' "$STATIC_HOSTS"
if [ -f "$DNSMASQ_LEASE_FILE" ]; then
awk 'NF >= 4 && $4 != "*" {print $3" "$4}' "$DNSMASQ_LEASE_FILE"
fi
read_odhcpd_leases
} | sort -u > "$TMP_FILE"
if [ -s "$TMP_FILE" ]; then
if [ ! -f "$HOSTS_FILE" ] || ! cmp -s "$TMP_FILE" "$HOSTS_FILE"; then
mv "$TMP_FILE" "$HOSTS_FILE"
reload_dnsmasq_pids
else
if [ -s "$HOSTS_FILE" ]; then
: > "$HOSTS_FILE"
reload_dnsmasq_pids
fi
rm -f "$TMP_FILE"
fi
else
if [ -s "$HOSTS_FILE" ]; then
: > "$HOSTS_FILE"
reload_dnsmasq_pids
fi
rm -f "$TMP_FILE"
fi
sleep 60