No description
  • Python 80.3%
  • Shell 19.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Boffin f0c7ade460 Document PyInstaller binaries in README
Add "Standalone binaries (PyInstaller)" section to the table of
contents and body, covering:
- build_server_termux.sh / build_tray_debian.sh usage
- Termux-specific build fixes (ldd stub, clang/make/zlib bootloader
  compile)
- termux-rpc-server --start/--stop/--status
- rpc-tray --no-network/--no-battery
- rpc-tray --install-tray/--uninstall-tray and the equivalent
  install_tray_autostart.sh script
- clean usage/error output on bad flags instead of a traceback

Also updates the project structure listing to include
combined_tray.py, the two build scripts, install_tray_autostart.sh,
and stop_server.sh.
2026-08-22 10:00:27 +00:00
client feat: implement network toggles and GNOME-style tray UI 2026-04-11 11:39:09 +00:00
docs Initial commit: Added Project Structure 2026-04-11 09:53:58 +00:00
scripts Add clean CLI error handling and tray autostart script 2026-08-22 08:37:38 +00:00
server Add clean CLI error handling and tray autostart script 2026-08-22 08:37:38 +00:00
tray Add --install-tray/--uninstall-tray to rpc-tray binary 2026-08-22 09:28:09 +00:00
.gitignore Initial commit: Added Project Structure 2026-04-11 09:53:58 +00:00
LICENSE Feat: Intelligent ADB auto-discovery, Termux command fallbacks & massive documentation overhaul 2026-04-12 17:27:14 +00:00
README.md Document PyInstaller binaries in README 2026-08-22 10:00:27 +00:00
requirements.txt Initial commit: Added Project Structure 2026-04-11 09:53:58 +00:00

termux-rpc-framework

A production-grade, privacy-first RPC bridge between Termux (Android host) and proot-Debian — exposing Termux hardware APIs over a local HTTP microservice with authentication, rate limiting, caching, zero-config ADB toggle support, and GNOME-style PyQt6 system-tray widgets.

All traffic stays on-device (127.0.0.1). No cloud. No telemetry.


Table of contents

  1. Architecture
  2. Project structure
  3. How it works
  4. Quick start
  5. Standalone binaries (PyInstaller)
  6. Available RPC commands
  7. Configuration
  8. Adding a new command
  9. Security model
  10. Troubleshooting
  11. License

Architecture

┌──────────────────────────────────────────────────────┐
│  Android (Termux)                                    │
│                                                      │
│  termux_bridge_server.py  ←─ HTTP :8085              │
│  ├── Auth          (X-API-Key header)                │
│  ├── Rate limiter  (per-command cooldown)            │
│  ├── Cache         (TTL-based, auto-evict)           │
│  └── Commands                                        │
│      ├── battery.py    termux-battery-status         │
│      ├── network.py    termux-wifi-connectioninfo    │
│      │                 + ADB shell svc / cmd wifi    │
│      └── system.py     ip / ifconfig                 │
│                                                      │
│  adb_manager.py  (zero-config ADB singleton)         │
│  ├── auto-discovers device via `adb devices`         │
│  ├── port-scans localhost for wireless debug port    │
│  └── falls back to Termux commands if ADB absent     │
└─────────────────────┬────────────────────────────────┘
                      │  HTTP/JSON  (loopback only)
┌─────────────────────▼────────────────────────────────┐
│  proot-Debian                                        │
│                                                      │
│  client/api_client.py    RPCClient                   │
│  ├── fetch("wifi")       GET /api?cmd=wifi           │
│  └── toggle("data", True) POST /toggle?cmd=data      │
│                                                      │
│  tray/network_tray.py    GNOME-style network panel   │
│  ├── WiFi toggle  (connected/bars/RSSI/IP/freq/speed)│
│  ├── Mobile data toggle                              │
│  ├── Hotspot toggle                                  │
│  ├── Real internet check  (ping 8.8.8.8)             │
│  └── Tray icon reflects actual connectivity          │
│                                                      │
│  tray/battery_tray.py    Battery indicator           │
└──────────────────────────────────────────────────────┘

Project structure

termux-rpc-framework/
├── server/                          # Runs inside Termux
│   ├── termux_bridge_server.py      # HTTP entry point, request router
│   ├── config.py                    # All tuneable knobs in one place
│   ├── commands/
│   │   ├── __init__.py              # GET_COMMANDS / POST_COMMANDS registries
│   │   ├── battery.py
│   │   ├── network.py               # Read + toggle (ADB-aware)
│   │   └── system.py
│   └── utils/
│       ├── adb_manager.py           # Zero-config ADB singleton
│       ├── auth.py                  # API key validation
│       ├── rate_limit.py            # Thread-safe limiter + TTL cache
│       └── logger.py
│
├── client/                          # Runs inside proot-Debian
│   ├── api_client.py                # RPCClient — fetch() + toggle()
│   ├── debian_wrapper.py            # Interactive CLI
│   └── utils/retry.py
│
├── tray/                            # PyQt6 system-tray widgets
│   ├── base_tray.py                 # Shared polling engine + thread safety
│   ├── battery_tray.py
│   ├── network_tray.py              # Full GNOME-style network panel
│   └── combined_tray.py             # Single-process launcher (both trays, one binary)
│
├── scripts/
│   ├── install.sh                   # Dependency installer (Termux + Debian)
│   ├── setup.sh                     # venv setup + autostart .desktop gen (source mode)
│   ├── start_server.sh
│   ├── start_client.sh
│   ├── stop_server.sh
│   ├── run-battery-tray.sh
│   ├── run-network-tray.sh
│   ├── build_server_termux.sh       # PyInstaller onefile build — Termux
│   ├── build_tray_debian.sh         # PyInstaller onefile build — proot-Debian
│   └── install_tray_autostart.sh    # Registers the compiled rpc-tray binary as autostart
│
├── docs/
│   └── architecture.md
├── requirements.txt
└── README.md

How it works

Server (Termux)

termux_bridge_server.py runs a ThreadingHTTPServer on 127.0.0.1:8085. Every request passes through four layers before a command executes:

Request
  │
  ▼
1. Auth check       — X-API-Key header must match config.AUTH_TOKEN
  │
  ▼
2. Rate limit       — same command can't be called more often than
  │                   RATE_LIMIT_SEC; returns HTTP 429 if exceeded
  ▼
3. Cache lookup     — GET commands are cached for CACHE_TTL seconds;
  │                   cache is evicted lazily on every access
  ▼
4. Command dispatch — GET_COMMANDS or POST_COMMANDS dict lookup;
                      builds a shell command list or raises _DirectToggle

GET commands return the raw stdout of a Termux API tool, auto-detected as JSON or plain text. Output is truncated at MAX_OUTPUT_SIZE bytes.

POST commands (toggles) raise _DirectToggle(adb_cmd, fallback_cmd) which the router catches and routes through _run_toggle().


Client (proot-Debian)

RPCClient in client/api_client.py wraps all HTTP logic:

client = RPCClient()

# Read (GET)
data = client.fetch("wifi")          # → dict from server JSON
data = client.fetch("ping", host="cloudflare.com")

# Toggle (POST)
client.toggle("wifi",    state=True)
client.toggle("data",    state=False)
client.toggle("hotspot", state=True)

Failed requests are retried up to DEFAULT_RETRIES times with a 1-second delay. All errors surface as structured dicts ({"status": "error", ...}), never as exceptions, so tray widgets never crash on network blips.


Toggle pipeline — ADB + fallback

Android restricts svc wifi, svc data, and hotspot commands to callers with elevated permissions. Termux alone doesn't have them. The framework solves this with a two-tier approach — no configuration required:

toggle("wifi", True)
        │
        ▼
_DirectToggle raised with:
  adb_cmd      = "svc wifi enable"
  fallback_cmd = ["termux-wifi-enable", "true"]
        │
        ▼
_run_toggle() called
        │
        ├─ ADB discovery (lazy, once per session):
        │   1. `adb devices` — is a ready device already listed?
        │   2. `ip route get 8.8.8.8` → local IP
        │      scan ports 37000–37019 + 5555 with `adb connect`
        │      verify device appears in `adb devices` after connect
        │   3. discovery result cached; retried only on "device offline"
        │
        ├─ ADB available?
        │   YES → `adb -s <target> shell svc wifi enable`  ✓
        │
        └─ ADB unavailable / shell failed?
            → subprocess.run(["termux-wifi-enable", "true"])
              (best-effort; works for WiFi, limited for data/hotspot)

One-time pairing is all that's needed — do it once from Termux:

adb pair <ip>:<pairing-port>

After that, discovery reconnects automatically across reboots.


Network tray — connectivity detection

A key UX problem on Android: supplicant_state == COMPLETED means WiFi is associated, but mobile data may be the actual internet path. Showing a ✕ icon when mobile data is the active link is wrong.

The network tray resolves this with two independent checks:

1. Real internet probe — ping -c 1 -W 3 8.8.8.8 runs in the fetch thread on every poll cycle. This reflects actual reachability regardless of which interface carries traffic.

2. WiFi state — termux-wifi-connectioninfo gives supplicant_state, rssi, ip, frequency_mhz, link_speed_mbps.

Icon selection logic:

internet_ok=True  + supplicant_state=COMPLETED  → WiFi arc icon (signal bars)
internet_ok=True  + WiFi not associated         → Mobile data arrows icon
internet_ok=False                               → ✕ red cross (no internet)

SSID caveat: Android 8+ requires ACCESS_FINE_LOCATION to expose the real SSID. Without it, termux-wifi-connectioninfo returns "<unknown ssid>". The tray handles this gracefully — connection is determined from supplicant_state alone; the IP address is shown in place of the SSID when the name is unavailable.


Tray threading model

PyQt6 forbids all UI operations off the main thread. Every tray widget follows this pattern via BaseTray and FetchThread:

Main thread                         Worker thread (FetchThread)
     │                                       │
     │  poll() called (timer / user)         │
     │                                       │
     ├──── FetchThread(fetch_data).start() ──►│
     │     reference held in _active set     │  fetch_data() runs here
     │                                       │  (HTTP, ping, ADB shell)
     │◄────── done signal emitted ───────────┤
     │                                       │
     ├── on_data(result) on main thread      │  finished → _cleanup()
     │   (safe to touch all Qt widgets)      │  removes from _active set
     │                                       │  deleteLater() called

FetchThread._active is a class-level set that holds a strong reference to every in-flight thread. Without it, Python's GC destroys the QThread object before Qt finishes with it, causing QThread: Destroyed while thread is still running → Abort.


Quick start

Prerequisites

Termux side:

pkg install python termux-api adb
# Install Termux:API app from F-Droid (same source as Termux)

proot-Debian side:

apt-get install python3 python3-venv python3-pip

1. Clone / copy the project

# Inside Termux
git clone https://github.com/dev-boffin-io/termux-rpc-framework
cd termux-rpc-framework

2. Set up (proot-Debian side)

bash scripts/setup.sh
# Creates .venv, installs PyQt6, writes autostart .desktop files

3. Pair ADB once (Termux, one-time only)

Enable Wireless Debugging in Android Developer Options, tap Pair device with pairing code, then:

# Inside Termux — use the pairing port shown on screen
adb pair <device-ip>:<pairing-port>
# Enter the 6-digit code when prompted

After pairing, the framework auto-discovers the connection on every toggle. No further ADB setup needed.

4. Start the server (Termux)

bash scripts/start_server.sh
# or: python -m server.termux_bridge_server

5. Run tray widgets (proot-Debian)

bash scripts/run-network-tray.sh &
bash scripts/run-battery-tray.sh &

Or start them at login via the autostart entries written by setup.sh.


Standalone binaries (PyInstaller)

Both sides can also be packaged as single-file binaries — no Python, no venv, no pip install needed to run them afterward (building still needs Python once, on-device, since PyInstaller compiles natively for the machine it runs on).

Server binary (Termux)

bash scripts/build_server_termux.sh

Handles Termux/Android-specific quirks automatically: installs clang + make + zlib (Termux ships no prebuilt PyInstaller bootloader, so pip compiles one from source on first install) and stubs a fake ldd (bionic libc has none at all, which otherwise crashes PyInstaller's musl-detection code). Output: dist/termux-rpc-server.

./dist/termux-rpc-server            # foreground — Ctrl+C to stop
./dist/termux-rpc-server --start    # daemonize (background) and return
./dist/termux-rpc-server --stop     # stop a --start'ed instance
./dist/termux-rpc-server --status   # report whether it's running

--start/--stop track the process via a PID file at ~/.termux-rpc-server.pid (not derived from the binary's own path, since a PyInstaller onefile binary re-extracts to a new temp dir every launch).

Tray binary (proot-Debian)

bash scripts/build_tray_debian.sh

Requires system PyQt6 (apt, not pip) and builds from tray/combined_tray.py, which runs the network and battery trays together in one process. Output: dist/rpc-tray.

./dist/rpc-tray                     # both trays (default)
./dist/rpc-tray --no-network        # battery tray only
./dist/rpc-tray --no-battery        # network tray only

Autostart, either from the binary itself or via a standalone script — both read/write the same ~/.config/autostart/termux-rpc-tray.desktop:

./dist/rpc-tray --install-tray                  # register self, then exit
./dist/rpc-tray --install-tray --no-network     # ...with a flag baked into Exec=
./dist/rpc-tray --uninstall-tray                # remove the entry

# or, equivalently:
bash scripts/install_tray_autostart.sh
bash scripts/install_tray_autostart.sh --no-network
bash scripts/install_tray_autostart.sh --remove

--install-tray only works from the compiled binary — in dev mode (python -m tray.combined_tray) sys.executable points at python, not rpc-tray, so it refuses rather than writing a broken autostart entry.

Both binaries fail on a bad flag with a plain usage: + error: message (exit code 2) rather than a Python traceback.


Available RPC commands

GET commands — client.fetch(cmd)

Command Termux API Returns Description
battery termux-battery-status JSON Level, status, plugged, health
wifi termux-wifi-connectioninfo JSON SSID, IP, RSSI, freq, link speed, supplicant state
wifi_scan termux-wifi-scaninfo JSON array Nearby networks with signal info
ip ip addr / ifconfig text Network interface list
ping ping -c 4 <host> text Latency to any hostname (validated)

POST commands — client.toggle(cmd, state)

Command ADB shell command Fallback
wifi svc wifi enable|disable termux-wifi-enable true|false
data svc data enable|disable svc data enable|disable
hotspot cmd wifi set-softap-enabled ... svc wifi hotspot enable|disable

Configuration

All server settings live in server/config.py. No environment variables, no external config files.

Key Default Description
HOST "127.0.0.1" Bind address — keep loopback for security
PORT 8085 Server port
AUTH_TOKEN "termux_secret_99" API key — change this before use
MAX_OUTPUT_SIZE 50000 Max bytes of command output kept (rest truncated)
CACHE_TTL 5 Seconds a GET response stays cached
RATE_LIMIT_SEC 1 Minimum seconds between identical GET calls
CMD_TIMEOUT 10 subprocess / ADB shell timeout in seconds
LOG_FILE "bridge.log" Log output path (relative to project root)

If AUTH_TOKEN is changed in config.py, update it in client/api_client.py (AUTH_TOKEN constant) to match.


Adding a new command

Read command (GET)

1. Write a handler in the relevant server/commands/ module:

# server/commands/system.py
def cmd_storage(params: dict) -> list[str]:
    """Free space on internal storage."""
    return ["termux-storage-get", "/sdcard"]

2. Register it in server/commands/__init__.py:

from server.commands.system import cmd_storage
GET_COMMANDS["storage"] = cmd_storage

3. Call it from the client:

client.fetch("storage")

Toggle command (POST)

# server/commands/network.py
def toggle_bluetooth(body: dict) -> list[str]:
    action = "enable" if body.get("state") else "disable"
    raise _DirectToggle(
        adb_cmd      = f"svc bluetooth {action}",
        fallback_cmd = ["am", "broadcast", "-a",
                        f"android.bluetooth.adapter.action.{'REQUEST_ENABLE' if body.get('state') else 'REQUEST_DISABLE'}"],
    )

Register in POST_COMMANDS, then call client.toggle("bluetooth", True).


Security model

  • Loopback only — server binds to 127.0.0.1; not reachable from the network or other devices.
  • API key auth — every request must carry X-API-Key: <token>. Requests without a valid key receive HTTP 401 immediately.
  • Input validation — ping host is validated against ^[a-zA-Z0-9.-]+$ before being passed to subprocess; no shell injection possible.
  • Output truncation — command stdout is capped at MAX_OUTPUT_SIZE bytes to prevent memory exhaustion from runaway commands.
  • Rate limiting — per-command cooldown prevents flooding the Termux API tools, which can be slow on first call.
  • ADB scope — ADB is used only to run svc and cmd wifi toggle commands. It does not expose a general shell to clients; the bridge controls exactly which commands reach ADB.
  • No persistent storage of secrets — the API token lives only in config.py on-device; it is never logged or transmitted off-device.

Troubleshooting

Panel shows "Checking…" and toggles stay off

The bridge server is not reachable from proot-Debian. Verify:

# Inside Termux — is the server running?
curl -s -H "X-API-Key: termux_secret_99" http://127.0.0.1:8085/health

# Does wifi data come back?
curl -s -H "X-API-Key: termux_secret_99" "http://127.0.0.1:8085/api?cmd=wifi" \
  | python3 -m json.tool

If health returns but wifi doesn't, check that termux-api is installed and Termux:API app is running.

WiFi shows as connected but SSID is <unknown ssid>

Android 8+ hides SSID without ACCESS_FINE_LOCATION. The tray handles this gracefully (shows IP instead of SSID). To get the real SSID, grant location permission to Termux:API in Android Settings → Apps → Termux:API → Permissions.

Toggles do nothing / return "ADB unavailable"

ADB has not been paired yet, or wireless debugging is off. Steps:

  1. Enable Developer options → Wireless debugging on the device.
  2. Pair once from Termux: adb pair <ip>:<pairing-port>
  3. After pairing, the framework auto-discovers on the next toggle attempt.

If ADB pairing was done but still fails:

# Inside Termux
adb devices           # should list the device as "device"
adb shell svc wifi enable   # test direct ADB shell access

QThread: Destroyed while thread is still running → Abort

Caused by an older version of the tray code. The current version uses FetchThread._active to hold strong references. Make sure you are running the latest code.

Server port conflict

Change PORT in server/config.py and update SERVER_URL in client/api_client.py accordingly.


License

MIT — see LICENSE.