sms — Huawei router SMS reader¶
CLI and TV channel for reading SMS from a Huawei 4G/5G HiLink router's SIM
card — primarily to grab verification codes without opening the web UI.
sms watch adds push: it polls the router and forwards each new SMS
to any apprise target (Telegram, ntfy,
Discord, desktop…).
Sister tools: mi-router (Xiaomi / MiWiFi) and
reyee (Ruijie / Reyee). They stay separate — each vendor's auth
and API are unrelated — but share conventions: standalone uv-script,
~/.config/<tool>/config.toml (0600) + env overrides + TTY prompt-and-save,
and LAN requests that ignore ambient proxy env.
The CLI hits the router's XML API (/api/sms/sms-list, /api/user/login,
etc.) via huawei-lte-api,
which handles CSRF tokens, SCRAM-SHA-256 login, and session cookies. This
is far more reliable than scraping the JavaScript SPA.
Install¶
Deployed automatically by chezmoi:
~/.dotfiles/bin/sms— the CLI (uv-script; first run resolves deps into uv's cache)~/.config/television/cable/sms.toml— thetv smschannel~/.config/television/cable/sms-archive.toml— thetv sms-archivechannel~/.config/sms/config.toml.example— starter config
uv must be installed (it is, via the bootstrap script).
First-time setup¶
# Either: populate the config file
cp ~/.config/sms/config.toml.example ~/.config/sms/config.toml
chmod 600 ~/.config/sms/config.toml
$EDITOR ~/.config/sms/config.toml # set password
# Or: let sms prompt on first call and save for you
sms login-test
Env overrides (take precedence over the file):
| Variable | Purpose |
|---|---|
SMS_ROUTER_HOST |
Router IP/hostname (default 192.168.168.1) |
SMS_ROUTER_USER |
Login user (default admin) |
SMS_ROUTER_PASS |
Login password |
SMS_CACHE_TTL |
Inbox cache TTL in seconds (default 30, 0 disables) |
SMS_NOTIFY_URLS |
Space-separated apprise URLs for sms watch (overrides [watch] config) |
Commands¶
sms # latest verification code → stdout + clipboard
sms code [--no-copy] # same, explicit
sms latest [-n 5] # N most recent messages (pretty table)
sms list [--unread] [--json]
sms show INDEX [--json]
sms delete INDEX
sms refresh # force cache bust + refetch
sms login-test # verify credentials/connectivity
sms count [--json] # inbox total / max / unread (no login needed on B525s)
sms prune --keep N [--include-unread] [--yes] # delete oldest beyond N (read-only by default)
sms watch [--interval 10] [--once] [--json] [--no-notify] [--mark-read]
sms notify-test [--latest] [--dry-run] # one test push via the same targets as watch
sms archive # copy every inbox message into the local SQLite archive
sms export [--format sbr|mbox|jsonl] [-o FILE] [--refresh]
sms search [WORDS…] [--phone P] [--since 2026-10] [--codes] [--limit 20] [--json] [--key K]
sms service install|uninstall|status # systemd --user unit for `sms watch` (Linux)
Exit codes: 0 ok, 1 router error, 2 bad config/args, 3 no code found.
TV channel¶
Enter— copy message body to clipboardAlt+C— extract and copy just the verification codeAlt+R— refresh cache and reloadAlt+D— delete selected message (with confirm)Ctrl+S— cycle source: all ↔ unread only- Auto-refreshes every 5 s — leave it open while waiting for a code to arrive.
tv sms-archive is the same picker over the local archive (full history,
offline, no router login): Enter / Alt+C as above, Alt+R runs
sms archive then reloads, Ctrl+S cycles all ↔ verification codes only.
Push notifications (sms watch)¶
HiLink routers have no webhook / SMS-forwarding hook, so watch is a cheap
poll + push:
- Every
intervalseconds (default 10) it reads/api/sms/sms-count. On the B525s this endpoint answers without login, so steady-state polling holds no admin session and never kicks you out of the web UI. (If a firmware demands login, it falls back to a short login per poll.) - Only when
(LocalInbox, LocalUnread)changes — or everyfull_syncseconds as a safety net — it logs in, reads the newest 50 messages, and diffs them againstseen.jsonin the platformdirs state dir (~/.local/state/sms/on Linux,~/Library/Application Support/sms/on macOS). Messages are keyed byindex|phone|date, since indexes get reused. - Each new message → one apprise push: title
SMS from <phone>, body prefixed with[code 123456]when a verification code is detected. It is also printed on stdout (--json→ JSONL) and thesmscache is busted sosms/tv smssee it immediately.
The first run silently seeds the state from the current inbox (no flood
of old messages); --notify-existing-unread pushes the current unread ones
instead. A never-seen message older than the seed point (e.g. an old one
sliding back into the 50-message window after deletions) is ignored.
Targets (first match wins) — configure in ~/.config/sms/config.toml:
[watch]
notify_urls = ["tgram://<bot_token>/<chat_id>"] # or ntfy://topic, discord://…
# notify_tag = "sms" # …or a tag from ~/.config/apprise/apprise.yaml
SMS_NOTIFY_URLS (env) > notify_urls > notify_tag. URLs are handed to
apprise via APPRISE_URLS env, not argv, so tokens don't show in ps.
Tokens never go into chezmoi — only the .example is managed.
A failed push is never silent: it's logged to stderr and appended to
notify.log in the same state dir. (The message is still marked seen —
check that log if you suspect a miss.) Router/network errors back off
exponentially (cap 5 min) instead of exiting.
Quick test:
sms notify-test --dry-run # which services apprise would hit (sends nothing)
sms notify-test # canned test push
sms notify-test --latest # newest real SMS, formatted exactly like watch
sms watch --once --no-notify # seed state
SMS_NOTIFY_URLS='macosx://' sms watch # desktop banners, foreground
Discord webhooks work as-is: apprise accepts the native
https://discord.com/api/webhooks/<id>/<token> form (= discord://<id>/<token>).
Put it in [watch] notify_urls rather than on the command line — an
SMS_NOTIFY_URLS=… prefix lands in shell history.
Running as a service¶
On an always-on Linux box on the router's LAN:
chezmoi apply # deploys sms
$EDITOR ~/.config/sms/config.toml # password + [watch] notify_urls
sms watch --once --no-notify # sanity check + seed
sms service install # writes ~/.config/systemd/user/sms-watch.service, enable --now
journalctl --user -u sms-watch -f
sudo loginctl enable-linger "$USER" # survive logout/reboot (install warns if off)
The unit is generated on demand rather than deployed by chezmoi, so it only
exists on the host that opts in. It captures the installing shell's PATH
(for uv / apprise). It does not inherit shell proxy vars — if apprise
needs a proxy to reach Telegram etc., add it with
systemctl --user edit sms-watch (Environment=HTTPS_PROXY=…). LAN traffic
to the router always bypasses proxies (trust_env=False, same as
mi-router / reyee).
On macOS, run sms watch under tmux/pueue (or a LaunchAgent) instead;
sms service is Linux-only.
Inbox capacity / prune¶
sms count shows LocalInbox/LocalMax (the web UI's "202/209" is
unread/total, not capacity — the B525s holds 500). When the inbox fills,
the router drops new SMS, so watch pushes a one-time alert at
capacity_warn (default 95%). Free space with:
sms prune --keep 200 # oldest read messages beyond the newest 200
sms prune --keep 200 --include-unread --yes
prune archives victims before deleting (see below); --no-archive makes
it lossy. For hands-off operation let watch do it (opt-in):
[watch]
archive = true
auto_prune_keep = 300 # never let the router hold more than 300
auto_prune_include_unread = true # router "unread" is meaningless once pushed
auto_prune_keep refuses to run without archive = true or below 50 (the
watch diff window), so auto-pruning can never lose a message or delete one
before it was pushed.
Archive & export¶
There's no email-style standard for SMS (no RFC 5322 / mbox / IMAP equivalent), so the archive is a plain SQLite file and exports target the two formats with real tooling:
sms archive # idempotent: N new of M on router
sms export -o sms.xml # SMS Backup & Restore XML (default)
sms export --format mbox -o sms.mbox # one SMS = one email
sms export --format jsonl | jq … # scripts
sms export --refresh -o sms.xml # archive first, then export
sqlite3 ~/.local/share/sms/archive.sqlite 'select date, phone, content from sms order by date desc limit 5'
| Format | Read it with |
|---|---|
sbr |
Android app SMS Backup & Restore (SyncTech) — restores into the phone's SMS store; its web viewer; many converters. The de-facto SMS archive format. |
mbox |
Any mail client (Apple Mail / Thunderbird import), mutt -f, notmuch. Subject = first 70 chars; Message-ID is stable, so re-imports dedupe. |
jsonl |
jq, scripts. |
Viewing the archive¶
sms search 驗證碼 --since 2026-10 # words are AND-ed substring matches
sms search --codes --limit 5 # verification-code SMS only
sms search --phone 68005
sms search --key 558e47 # full detail (key prefix from the table)
tv sms-archive # fuzzy picker, copy body / code
For ad-hoc exploration, use the tools already on the box:
| Tool | Command | Good for |
|---|---|---|
VisiData (vd) |
vd ~/.local/share/sms/archive.sqlite |
Browse / sort / regex-filter (\|) / frequency (Shift+F) |
| sqlit | sqlit --db-type sqlite --file-path ~/.local/share/sms/archive.sqlite |
SQL in a TUI |
| sqlite3 | sqlite3 -box …/archive.sqlite "select phone, count(*) n from sms group by phone order by n desc limit 10" |
One-liners |
| datasette | uvx datasette ~/.local/share/sms/archive.sqlite |
Browser UI — keep it on localhost (the archive holds codes) |
The archive lives in the platformdirs data dir (~/.local/share/sms/archive.sqlite
on Linux, or wherever XDG_DATA_HOME points), chmod 600. Rows are keyed like
watch's seen-state (index|phone|date); the unread column tracks the
router's read flag at the last archive. Router timestamps are naive local
time and are exported assuming this machine's timezone.
Clipboard¶
sms code pipes through the existing x copy helper (see dot_dotfiles/bin/executable_x)
so clipboard works uniformly on macOS (pbcopy), Linux Wayland (wl-copy),
Linux X11 (xclip/xsel), WSL (clip.exe), and over SSH (OSC 52).
Cache¶
Inbox is cached at ~/.cache/sms/inbox.json for 30 s by default. This keeps
back-to-back sms calls from re-logging-in to the router (each fresh login
invalidates the router's existing session, which would kick the user out of
the web UI). Use sms refresh or sms --no-cache <cmd> to bypass.
Troubleshooting¶
router error: ResponseErrorLoginCsrfException— firmware wants a fresh SCRAM challenge. Runsms refreshthen retry.login failed: 108003 — user already logged in— log out of the web UI, or wait ~5 min for the router session to expire.- Unreachable — confirm you're on the router's LAN (or tunnelled via Tailscale/SSH port-forward). The API is not exposed on WAN by default.
- Web UI login fails with 108003 while
sms watchruns — the watcher only logs in briefly when the inbox changes, so retry in a few seconds; orsystemctl --user stop sms-watchwhile you use the web UI. - Sporadic
RemoteDisconnectedinsms watch— the router's httpd closes keep-alive sockets early. Polling uses one plain GET withConnection: closeand a single failure is retried silently after 2 s; only consecutive failures are logged and backed off. sms watchpushes nothing — checknotify.login the state dir and test the URL directly:apprise -b test '<url>'.- Wrong host — many HiLink routers default to
192.168.8.1; this one uses192.168.168.1. Override withSMS_ROUTER_HOST.