Last active
March 1, 2024 15:53
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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