Skip to content

Instantly share code, notes, and snippets.

@s-zeid
Last active March 1, 2024 15:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save s-zeid/a1d6d1f01a08524ffded0a2d78ad2d4a to your computer and use it in GitHub Desktop.
Save s-zeid/a1d6d1f01a08524ffded0a2d78ad2d4a to your computer and use it in GitHub Desktop.
Gets information from Franklin Wireless cellular hotspots. Also allows rebooting the hotspot.
#!/usr/bin/env python3
"""Gets information from Franklin Wireless cellular hotspots.
Also allows rebooting the hotspot (`-r`/`--reboot`).
Example script to store login information (`frk-status.local.py` in the same
directory as this script; `.py` may be omitted):
```python
#!/usr/bin/env python3
# Be sure to use a strong, unique password if you are storing it in plaintext.
hostname = "192.168.0.1"
password = "admin"
try:
import os, re, subprocess, sys
prog = re.sub(r"\.local(\.py)?$", "", os.path.basename(__file__))
process = subprocess.Popen(
[prog, "--password-stdin", hostname] + sys.argv[1:],
stdin=subprocess.PIPE,
)
process.stdin.write(password.encode("utf-8"))
process.stdin.close()
sys.exit(process.wait())
except KeyboardInterrupt:
pass
```
"""
import argparse
import getpass
import json
import os
import shutil
import sys
import requests # type: ignore # apt/dnf: python3-requests; apk: py3-requests; pip3: requests
class FranklinHotspot:
def __init__(self, hostname: str, password: str):
self.hostname = hostname
self.password = password
self._session = requests.Session()
self._login()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.logout()
def _error_from_response(self, response: dict, message: str):
return RuntimeError(f"{message}: {response.get('result', '')}: {response.get('msg', '')}")
def _to_json(self, dict: dict) -> str:
return json.dumps(dict, separators=(',', ':')) + "\n"
def _login(self):
self._session.headers.update({
"Content-Type": "application/json; charset=UTF-8",
})
# get session cookie
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/general_monitor.cgi",
data=self._to_json({"command": "load"}),
).json()
if not rj.get("data", {}).get("login_available"):
raise RuntimeError("login not available")
# log in
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/login.cgi",
data=self._to_json({"command": "log_in", "params": {"password": self.password}}),
).json()
if rj.get("result", "") != "S_LOGIN":
raise self._error_from_response(rj, "could not login")
def logout(self):
try:
r = self._session.post(
f"http://{self.hostname}/cgi-bin/login.cgi",
data=self._to_json({"command": "log_out"}),
)
except requests.exceptions.RequestException:
pass
def get_monitor_info(self) -> dict[str, str|int]:
# get monitor info
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/general_monitor.cgi",
data=self._to_json({"command": "load"}),
).json()
if rj.get("result", "") != "S_OK":
raise self._error_from_response(rj, "could not get monitor JSON")
return rj.get("data", rj)
def get_about_info(self, show_wifi_password: bool = False) -> dict[str, str]:
# get about info
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/about.index.cgi",
data=self._to_json({"command": "load", "params": None}),
).json()
if rj.get("result", "") != "S_OK":
raise self._error_from_response(rj, "could not get about page JSON")
data = rj.get("data", rj)
if "wifi_password" in data and not show_wifi_password:
data["wifi_password"] = "[HIDDEN]"
return data
def get_debug_info(self) -> dict[str, str]:
# get debug info
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/about.debug-lte_engineering.cgi",
data=self._to_json({"command": "load", "params": None}),
).json()
if rj.get("result", "") != "S_OK":
raise self._error_from_response(rj, "could not get debug JSON")
return rj.get("data", rj)
def get_other_info(self) -> dict[str, str|int|None]:
result: dict[str, str|int|None] = dict(
time_connected=-1,
data_used="",
usage_result=-1,
usage_type="",
size_total="",
size_used="",
percent_remain="",
days_remain=None, # int
next_plan_begin=None, # str
apn="",
apn_auth=-1,
pdn_type="",
)
# get usage info
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/home.index.cgi",
data=self._to_json({"command": "load", "params": None}),
).json()
if rj.get("result", "") != "S_OK":
raise self._error_from_response(rj, "could not get home JSON")
data = rj.get("data", rj)
result |= {key: value for key, value in data.items() if key in (
"time_connected",
"data_used",
"usage_result",
"usage_type",
"size_total",
"size_used",
"percent_remain",
"days_remain",
"next_plan_begin",
)}
# get APN info
PDN_TYPES = {
"0": "IPv4",
"2": "IPv6",
"3": "IPv4v6",
}
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/settings.mobile_network-apn.cgi",
data=self._to_json({"command": "load", "params": None}),
).json()
if rj.get("result", "") != "S_OK":
raise self._error_from_response(rj, "could not get APN JSON")
data = rj.get("data", rj)
for apn in data.get("apns", []):
if apn.get("active", "").lower() == "true":
result["apn"] = apn.get("apn", "")
result["apn_auth"] = apn.get("auth", "")
pdn_type = str(apn.get("pdn_type", ""))
result["pdn_type"] = PDN_TYPES.get(pdn_type, pdn_type)
break
return result
def get_all_info(self) -> dict[str, str|int|bool|None]:
return {
**self.get_about_info(),
**self.get_monitor_info(),
**self.get_debug_info(),
**self.get_other_info(),
}
def reboot(self):
login_area = "itadmin"
login_attempts = 2
rj = {}
for n in range(1, login_attempts + 1):
rj = self._session.post(
f"http://{self.hostname}/cgi-bin/{login_area}.login.cgi",
data = self._to_json({
"command": "check_hidden_password",
"params": {"hidden_password": ""},
}),
).json()
if rj.get("result", "") == "E_REBOOT":
break
elif rj.get("result", "") != "E_HIDDEN_PASSWORD":
raise self._error_from_response(rj, f"login attempt #{n} did not fail as expected")
else:
raise self._error_from_response(rj, f"hotspot did not reboot after {login_attempts} failed {login_area} login attempts")
MONITOR_FIELDS = dict(
battery_state="Battery state",
battery_level="*Battery level",
battery_per="Battery percentage",
_m0="",
network_connected="*Network connected",
network_technology="*Network technology",
roam="Roaming",
roam_enable="*Roaming enable",
roam_guard="*Roaming guard",
roam_popup="*Roaming popup",
signal_strength="Signal strength",
sim_detect="SIM detected",
imei_lock_code="*IMEI lock code",
imei_lock_txt="*IMEI lock text",
_m1="",
msg_cnt="Message count",
msg_cnt_apps="*Message count apps",
msg_full="Messages full",
msg_full_apps="*Messages full apps",
msg_full_nv="*Messages full NV",
msg_full_uim="*Messages full UIM",
_m2="",
client_count="*Client count",
login_available="Login available",
login_default_pw="*Login default password",
login_status="*Login status",
refresh_banner="*Refresh banner",
sms_refresh_required="*SMS refresh required",
update_postpone="Update postponed",
update_ready="Update ready",
)
ABOUT_FIELDS = dict(
power_state="Power state",
battery_charge_level="Battery charge level",
battery_status="Battery status",
current_voltage="Current voltage",
_a0="",
model="Model",
bootloader_version="Bootloader version",
firmware_version="Firmware version",
hardware_revision="Hardware revision",
software_version="Software version",
web_app_version="Web app version",
build_date="Build date",
_a2="",
iccid="ICCID",
imei="IMEI",
imsi="IMSI",
meid="MEID",
_a3="",
msid="MSID",
my_number="My number",
lte_apn_ni="LTE APN NI",
prl_id="PRL ID",
prl_id_show="Show PRL ID",
mac_address="MAC address",
ip_address="IP address",
_a4="",
manager="Manager hostname",
wifi_name="WiFi name",
broadcast_network_name="Broadcast network name",
encryption="Encryption",
wifi_password="*WiFi password",
max_wifi_devices="Max WiFi devices",
wifi_devices="WiFi devices",
_a5="",
lifetime_transferred="Lifetime transferred",
)
DEBUG_FIELDS = dict(
power_mode="Power mode",
srv_state="Service state",
connection="Connection",
_d0="",
plmn_name="PLMN name",
plmn_search="*PLMN search",
plmn_selected="*PLMN selected",
mcc="MCC",
mnc="MNC",
roam="Roaming",
_d1="",
supported_technology="*Supported technology",
technology="Technology",
band="Band",
band_width="Band width",
cell_global_id="Cell global ID",
physical_cell_id="*Physical cell ID",
dl_earfcn="*DL channel",
ul_earfcn="*UL channel",
rrc_state="*RRC state",
pdn_type="*PDN type",
_d2="*",
rsrp="*RSRP",
rsrq="*RSRQ",
rssi="*RSSI",
sinr="*SINR",
snr="*SNR",
tx_power="*Transmit power",
_d3="",
imei="IMEI",
imsi="IMSI",
ipv4_address="IPv4",
ipv6_address="IPv6",
_d4="*",
last_error="*Last error code",
)
OTHER_FIELDS = dict(
time_connected="Time connected",
data_used="Connection data used",
_o0="",
usage_result="Usage available",
usage_type="Usage type",
size_total="Usage total",
size_used="Usage size",
percent_remain="Percentage remaining",
days_remain="Days remaining",
next_plan_begin="Next plan begins",
_o1="",
apn="APN",
apn_auth="*APN authenticated",
pdn_type="PDN type",
)
ALL_FIELDS = {
**MONITOR_FIELDS,
"__0": "",
**ABOUT_FIELDS,
"__1": "",
**{key: name for key, name in DEBUG_FIELDS.items() if key != "pdn_type"},
"__2": "",
**OTHER_FIELDS,
}
DEFAULT_FIELDS = dict(
power_mode=" ",
battery_status=" ",
battery_charge_level=" ",
_0=" ",
srv_state=" ",
connection=" ",
time_connected=" ",
signal_strength=" ",
technology=" ",
roam=" ",
roam_enable="*",
sim_detect=" ",
imei_lock_code="*",
_1=" ",
plmn_name=" ",
mcc=" ",
mnc=" ",
band=" ",
band_width="*",
cell_global_id="*",
apn=" ",
pdn_type=" ",
_2="*",
rsrp="*",
rsrq="*",
rssi="*",
sinr="*",
snr="*",
tx_power="*",
_3=" ",
iccid=" ",
imei=" ",
meid="*",
imsi=" ",
msid="*",
my_number=" ",
mac_address=" ",
ipv4_address=" ",
ipv6_address=" ",
_4=" ",
usage_result="*",
usage_type=" ",
size_total=" ",
size_used=" ",
percent_remain=" ",
days_remain=" ",
next_plan_begin=" ",
lifetime_transferred=" ",
data_used=" ",
_5=" ",
msg_cnt=" ",
msg_full=" ",
_6=" ",
login_available="*",
build_date=" ",
update_postpone=" ",
update_ready=" ",
)
# copy field names to DEFAULT_FIELDS
DEFAULT_FIELDS = {key: name.strip() + fields.get(key, "") for fields, (key, name) in zip(
[ALL_FIELDS] * len(DEFAULT_FIELDS),
DEFAULT_FIELDS.items(),
)}
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(
description=__doc__.strip().splitlines()[0].strip(),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument("hostname",
help="the hostname or IP address of the hotspot")
p.add_argument("--password-stdin", action="store_true",
help="read the password from standard input")
p.add_argument("--columns", choices=("yes", "no", "always"), default="yes",
help="whether to break the output into columns if necessary"
" (ignored for JSON output)")
p.add_argument("--json", "-j", action="store_true",
help="print JSON data as received from the hotspot")
p.add_argument("--verbose", "-v", action="store_true",
help="show more info")
g = p.add_argument_group("commands")
m = g.add_mutually_exclusive_group()
m.add_argument("--monitor", "-m", action="store_true",
help="get monitor info only")
m.add_argument("--about", "-a", action="store_true",
help="get about page info")
m.add_argument("--debug", "-d", action="store_true",
help="get debug info only")
m.add_argument("--other", "-o", action="store_true",
help="get other info only")
m.add_argument("--all", "-A", action="store_true",
help="get all info")
m.add_argument("--reboot", "-r", action="store_true",
help="reboot the hotspot")
try:
options = p.parse_args(argv[1:])
except SystemExit as exc:
return int(exc.code) if exc.code is not None else 127
hostname = options.hostname
if options.password_stdin:
password = input()
else:
password = getpass.getpass("Hotspot admin password: ")
info = {}
dump_json = options.json
verbose = options.verbose
try:
with FranklinHotspot(hostname, password) as hotspot:
if options.reboot:
hotspot.reboot()
return 0
if options.all:
info = hotspot.get_all_info()
verbose = True
if options.monitor:
info = hotspot.get_monitor_info()
elif options.about:
info = hotspot.get_about_info(show_wifi_password=False)
elif options.debug:
info = hotspot.get_debug_info()
elif options.other:
info = hotspot.get_other_info()
else:
info = hotspot.get_all_info()
except (RuntimeError, requests.exceptions.RequestException) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
if dump_json:
print(json.dumps(info, indent=2))
else:
if options.all:
fields = hotspot.ALL_FIELDS
elif options.monitor:
fields = hotspot.MONITOR_FIELDS
elif options.about:
fields = hotspot.ABOUT_FIELDS
elif options.debug:
fields = hotspot.DEBUG_FIELDS
elif options.other:
fields = hotspot.OTHER_FIELDS
else:
fields = hotspot.DEFAULT_FIELDS
paragraphs: list[list[str]] = [[]]
for key, name in fields.items():
if verbose or not name.startswith("*"):
if key.startswith("_"):
paragraphs[-1] += [""]
paragraphs += [[]]
elif key in info:
value = info.get(key, "")
if isinstance(value, str):
value = repr(value)[1:-1]
if value is None:
if not verbose:
continue
value = "(null)"
paragraphs[-1] += [f"{name.lstrip('*')}: {value}"]
columns: list[list[str]] = [[]]
max_column_height = 24 if fields is hotspot.DEFAULT_FIELDS and verbose else 16
for paragraph in paragraphs:
if len(columns[-1]) >= max_column_height and len(columns) < 2:
columns += [[]]
for line in paragraph:
columns[-1] += [line]
lines = format_columns(
columns,
always=options.columns == "always",
never=options.columns == "no" or options.all,
)
while lines[-1].strip() == "":
lines.pop()
for line in lines:
print(line)
return 0
def format_columns(
columns: list[list[str]],
*,
gap: int = 4,
max_width: int = 0,
always: bool = False,
never: bool = False,
) -> list[str]:
if not len(columns):
return []
max_column_width = max_width
column_widths = [max([len(cell) + gap for cell in column]) for column in columns]
column_widths[-1] -= gap
total_cells = sum([len(column) for column in columns])
widest_column = max(column_widths)
terminal_size = shutil.get_terminal_size()
notatty = not sys.stdout.isatty()
if any([
not always and any([
notatty,
terminal_size.lines - 4 >= total_cells, # tall enough to combine all columns
terminal_size.columns < sum(column_widths), # terminal not wide enough for data
terminal_size.columns < max_column_width,
]),
any([
never,
max_column_width and widest_column > max_column_width,
]),
]):
columns = [sum(columns, [])]
result = []
for i_line in range(max([len(column) for column in columns])):
line = ""
for i_column in range(len(columns)):
cell = columns[i_column][i_line] if i_line < len(columns[i_column]) else ""
padding = column_widths[i_column] if i_column + 1 < len(columns) else 0
line += f"{cell:<{padding}}"
result += [line]
return result
if __name__ == "__main__":
try:
sys.exit(main(sys.argv))
except KeyboardInterrupt:
pass
#!/usr/bin/env python3
# Be sure to use a strong, unique password if you are storing it in plaintext.
hostname = "192.168.0.1"
password = "admin"
try:
import os, re, subprocess, sys
prog = re.sub(r"\.local(\.py)?$", "", os.path.basename(__file__))
process = subprocess.Popen(
[prog, "--password-stdin", hostname] + sys.argv[1:],
stdin=subprocess.PIPE,
)
process.stdin.write(password.encode("utf-8"))
process.stdin.close()
sys.exit(process.wait())
except KeyboardInterrupt:
pass
Power mode: Online ICCID: 8901260111111111119F
Battery status: Charging IMEI: 111111111111119
Battery charge level: 1% IMSI: 310260111111111
My number: 12125550123
Service state: In Service MAC address: 00:00:5E:00:53:00
Connection: Connected IPv4: 192.0.2.1
Time connected: 86400 IPv6: 2001:db8::1
Signal strength: 5
Technology: LTE Usage type: device
Roaming: 0 Usage total: Unlimited
SIM detected: 1 Usage size: 128.32 MB
Percentage remaining: 100
PLMN name: T-Mobile Lifetime transferred: 256.64 GB
MCC: 310 Connection data used: 64.16 MB
MNC: 260
Band: Band 4 Message count: 4
APN: fast.t-mobile.com Messages full: 0
PDN type: IPv4
Build date: Jul 15 2021\n
Update postponed: 0
Update ready: 0
# vim: set ft=python :
SAMPLE_MONITOR_DATA = {
"battery_level": 0,
"battery_per": 1,
"battery_state": 3,
"client_count": 1,
"imei_lock_code": "1",
"imei_lock_txt": "1",
"login_available": 1,
"login_default_pw": 0,
"login_status": 1,
"msg_cnt": "4",
"msg_cnt_apps": "0",
"msg_full": "0",
"msg_full_apps": "0",
"msg_full_nv": "0",
"msg_full_uim": "0",
"network_connected": 3,
"network_technology": "LTE",
"refresh_banner": 0,
"roam": 0,
"roam_enable": 1,
"roam_guard": "0",
"roam_popup": "0",
"signal_strength": 5,
"sim_detect": "1",
"sms_refresh_required": "0",
"update_postpone": 0,
"update_ready": 0
}
SAMPLE_ABOUT_DATA = {
"battery_charge_level": "1%",
"battery_status": "Charging",
"bootloader_version": "RT41021.FR.B2669",
"broadcast_network_name": "Show",
"build_date": "Jul 15 2021\n ",
"current_voltage": "0.060V",
"encryption": "WPA2 AES",
"firmware_version": "RT41021.FR.M2669",
"hardware_revision": "P1",
"iccid": "8901260111111111119F",
"imei": "111111111111119",
"imsi": "310260111111111",
"ip_address": "192.0.2.1",
"lifetime_transferred": "256.64 GB",
"lte_apn_ni": "",
"mac_address": "00:00:5E:00:53:00",
"manager": "mobile.hotspot",
"max_wifi_devices": "10",
"meid": "11111111111111",
"model": "Franklin RT410",
"msid": "12125550123",
"my_number": "12125550123",
"power_state": "Online",
"prl_id": "",
"prl_id_show": "0",
"software_version": "RT41021.FR.2669",
"web_app_version": "RT41021.FR.A2669",
"wifi_devices": "1",
"wifi_name": "SSID",
"wifi_password": "PASSWORD" # replaced with `[HIDDEN]` in `get_about_data()`
}
SAMPLE_DEBUG_DATA = {
"band": "Band 4",
"band_width": "10MHz",
"cell_global_id": "000(00000000)",
"connection": "Connected",
"dl_earfcn": "00000",
"imei": "111111111111119",
"imsi": "310260111111111",
"ipv4_address": "192.0.2.1",
"ipv6_address": "2001:db8::1",
"last_error": " ",
"mcc": "310",
"mnc": "260",
"pdn_type": " ",
"physical_cell_id": " ",
"plmn_name": "T-Mobile",
"plmn_search": " ",
"plmn_selected": "310260",
"power_mode": "Online",
"roam": "0",
"rrc_state": " ",
"rsrp": "-100",
"rsrq": "-20",
"rssi": "-60",
"sinr": " ",
"snr": "-10.0",
"srv_state": "In Service",
"supported_technology": "LTE+UMTS",
"technology": "LTE",
"tx_power": "16.0",
"ul_earfcn": "000000"
}
SAMPLE_OTHER_DATA = { # assembled by `get_other_data()`
"time_connected": 86400,
"data_used": "64.16 MB",
"usage_result": 1,
"usage_type": "device",
"size_total": "Unlimited",
"size_used": "128.32 MB",
"percent_remain": "100",
"days_remain": None,
"next_plan_begin": None,
"apn": "fast.t-mobile.com",
"apn_auth": 0,
"pdn_type": "IPv4v6" # converted from int
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment