A Go daemon that replaces the MF935 router's native polling model with a single batched poller and a WebSocket event stream. Instead of 10–30 HTTP requests per second from multiple clients, one goroutine holds the device connection and fans out only what changed.
Includes a web dashboard accessible from any device on the same network.
MF935 device (192.168.0.1)
│
│ 1 batched GET every 2s
▼
┌─────────────────────────────────┐
│ Go Middleware │
│ │
│ ┌──────────┐ ┌─────────────┐ │
│ │ Poller │→ │ Diff engine │ │
│ └──────────┘ └──────┬──────┘ │
│ │ events │
│ ┌──────▼──────┐ │
│ │ WS Server │ │
│ └─────────────┘ │
└───────────────────┬─────────────┘
│
┌─────────┴──────────┐
│ │
ws://host:9000/stream http://host:9000
(React Native app) (Web dashboard)
mf935-telemetry/
├── cmd/
│ └── server/
│ ├── main.go ← entry point, wiring
│ ├── ui.go ← static file server + /api/login handler
│ └── config.go ← config.json reader
├── internal/
│ ├── auth/
│ │ └── session.go ← login, AD token computation
│ ├── client/
│ │ └── router.go ← HTTP client for goform endpoints
│ ├── diff/
│ │ └── diff.go ← state comparison → events
│ ├── events/
│ │ └── events.go ← event type definitions and constants
│ ├── poller/
│ │ └── poller.go ← single poll goroutine
│ ├── state/
│ │ └── model.go ← DeviceState struct
│ └── ws/
│ └── server.go ← WebSocket hub and broadcaster
├── static/
│ ├── index.html ← dashboard UI
│ ├── css/
│ │ └── style.css ← MTN-themed styles
│ └── js/
│ ├── app.js ← UI updates, event wiring
│ ├── auth.js ← login modal, cookie, re-auth
│ ├── events.js ← event constants and payload types
│ └── socket.js ← WebSocket connection manager
├── config.json ← optional, gitignored
├── go.mod
├── go.sum
└── README.md
- Go 1.21+
- Network access to the MF935 device at
192.168.0.1 - Router admin password
git clone https://github.com/invalid8/mf935-telemetry
cd mf935-telemetry
go mod tidy
go build ./cmd/server/./serverOn startup the server prints:
server: dashboard → http://192.168.x.x:9000
server: ws stream → ws://192.168.x.x:9000/stream
Open the dashboard URL from any device on your network.
Create config.json in the project root:
{
"password": "your_router_password"
}The server reads this on startup, authenticates immediately, and starts polling. No modal shown.
config.json is gitignored — never committed.
Without config.json, the dashboard loads with a login modal. Enter your router password, click connect.
- Password is sent plaintext over the local network to
POST /api/login - Go hashes it (
SHA256(SHA256(password) + LD).toUpperCase()) before it touches the device - On success, a cookie is set — subsequent page loads re-authenticate silently without showing the modal
- Cookie expires after 30 days
Connect to:
ws://localhost:9000/stream
On connect, the server immediately replays the latest snapshot event with full device state. After that, only changed fields are emitted as typed events.
All messages use this envelope:
{
"event": "<event_type>",
"payload": { ... },
"at": "2026-05-05T20:39:36Z"
}Sent immediately on client connect. Full device state.
{
"event": "snapshot",
"payload": {
"ppp_status": "ppp_connected",
"network_type": "LTE",
"network_provider": "MTN Nigeria",
"wan_ipaddr": "10.100.237.95",
"simcard_roam": "Home",
"dial_mode": "auto_dial",
"modem_main_state": "modem_init_complete",
"signalbar": "3",
"rssi": "-106",
"lte_rsrp": "-106",
"rscp": "",
"battery_vol_percent": "79",
"battery_charging": "1",
"battery_pers": "3",
"SSID1": "My WiFi",
"wifi_cur_state": "1",
"sta_count": "2",
"station_mac": "34:cf:f6:98:e3:1a;4e:1a:05:7c:d0:cc",
"realtime_rx_thrpt": "59704",
"realtime_tx_thrpt": "2596",
"realtime_rx_bytes": "1074694135",
"realtime_tx_bytes": "195213116",
"realtime_time": "12628",
"monthly_rx_bytes": "24835509607",
"monthly_tx_bytes": "2210054890",
"sms_unread_num": "9",
"data_volume_limit_switch": "0",
"wan_lte_ca": "",
"new_version_state": "version_idle",
"current_upgrade_state": "fota_idle",
"loginfo": "ok",
"last_updated": "2026-05-05T20:39:36Z"
},
"at": "2026-05-05T20:39:36Z"
}Fired when signal bars, RSSI, LTE RSRP, or network type changes.
{
"event": "signal_changed",
"payload": {
"signalbar": "3",
"rssi": "-106",
"lte_rsrp": "-106",
"network_type": "LTE"
},
"at": "..."
}Fired when ppp_status transitions to ppp_connected.
{
"event": "connection_gained",
"payload": {
"ppp_status": "ppp_connected",
"network_provider": "MTN Nigeria",
"wan_ipaddr": "10.100.237.95",
"network_type": "LTE"
},
"at": "..."
}Fired when ppp_status transitions away from ppp_connected.
{
"event": "connection_lost",
"payload": {
"ppp_status": "ppp_disconnected",
"network_provider": "MTN Nigeria",
"wan_ipaddr": "",
"network_type": "LTE"
},
"at": "..."
}Fired when provider or WAN IP changes without a full connect/disconnect.
{
"event": "network_changed",
"payload": {
"ppp_status": "ppp_connected",
"network_provider": "MTN Nigeria",
"wan_ipaddr": "10.100.237.95",
"network_type": "LTE"
},
"at": "..."
}Fired when battery percentage, charging state, or bars change.
{
"event": "battery_changed",
"payload": {
"battery_vol_percent": "79",
"battery_charging": "1",
"battery_pers": "3",
"battery_value": ""
},
"at": "..."
}Fired when connected WiFi client count or MACs change.
{
"event": "clients_changed",
"payload": {
"sta_count": "2",
"m_sta_count": "0",
"station_mac": "34:cf:f6:98:e3:1a;4e:1a:05:7c:d0:cc"
},
"at": "..."
}station_mac is semicolon-separated. Split on ";" and discard the trailing empty string.
Fired when sms_unread_num increases.
{
"event": "sms_received",
"payload": { "sms_unread_num": "9" },
"at": "..."
}Fired when the SMS database changes — new, deleted, or read. Use this as the trigger to refetch the inbox.
{
"event": "sms_db_changed",
"payload": { "sms_db_change": "1" },
"at": "..."
}Fired every poll cycle when rx or tx throughput changes. Expect this frequently.
{
"event": "throughput_updated",
"payload": {
"realtime_rx_thrpt": "59704",
"realtime_tx_thrpt": "2596",
"realtime_rx_bytes": "1074694135",
"realtime_tx_bytes": "195213116",
"realtime_time": "12628"
},
"at": "..."
}Throughput values are in bytes/s. Session bytes and time reset when the connection drops and reconnects.
Fired when data volume limit settings change.
{
"event": "data_limit_changed",
"payload": {
"data_volume_limit_switch": "1",
"data_volume_limit_size": "50",
"data_volume_limit_unit": "GB",
"data_volume_alert_percent": "80"
},
"at": "..."
}Fired when new_version_state changes.
{
"event": "ota_available",
"payload": {
"new_version_state": "version_has_new_software",
"current_upgrade_state": "fota_idle"
},
"at": "..."
}# Install websocat
curl -Lo websocat https://github.com/vi/websocat/releases/download/v1.13.0/websocat.x86_64-unknown-linux-musl
chmod +x websocat
# Connect
./websocat ws://localhost:9000/stream| Behaviour | Detail |
|---|---|
network_provider = "62130" |
Raw PLMN — map to operator name in app |
| WiFi passwords | Returned as Base64 (WPAPSK1_encode) — decode before displaying |
sms_unread_num |
Use directly |
SMS tag = "1" |
Means unread |
station_mac |
Semicolon-delimited, trailing semicolon produces empty last element |
crypto.subtle |
Unavailable on plain HTTP non-localhost — all hashing is done server-side |