pax_global_header00006660000000000000000000000064150411503210014502gustar00rootroot0000000000000052 comment=8f89e9923bed2d2d7f64656a8600922b44d7b43d eternalegypt-0.0.17/000077500000000000000000000000001504115032100142725ustar00rootroot00000000000000eternalegypt-0.0.17/.github/000077500000000000000000000000001504115032100156325ustar00rootroot00000000000000eternalegypt-0.0.17/.github/workflows/000077500000000000000000000000001504115032100176675ustar00rootroot00000000000000eternalegypt-0.0.17/.github/workflows/pypi.yml000066400000000000000000000012551504115032100213760ustar00rootroot00000000000000name: Upload Python Package on: release: types: [published] jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} verify_metadata: false eternalegypt-0.0.17/LICENSE.txt000066400000000000000000000020711504115032100161150ustar00rootroot00000000000000The MIT License (MIT) Copyright 2018 Anders Melchiorsen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. eternalegypt-0.0.17/MANIFEST.in000066400000000000000000000001041504115032100160230ustar00rootroot00000000000000include *.txt setup.cfg recursive-include *.txt *.py prune examples eternalegypt-0.0.17/README.md000066400000000000000000000012241504115032100155500ustar00rootroot00000000000000# Eternal Egypt This library piggybacks on the web interface of Netgear LTE modems to provide a simple async Python 3 API. Currently tested devices: * [LB1120](https://www.netgear.com/home/mobile-wifi/lte-modems/lb1120/) (firmware M18Q2_v12.09.163431) * [LB2120](https://www.netgear.com/au/home/mobile-wifi/lte-modems/lb2120/) (firmware M18QW_v07.05.170721) * [LM1200](https://www.netgear.com/home/mobile-wifi/lte-modems/lm1200/) (firmware NTG9X07C_20.06.09.00) * [MR1100 (Nighthawk M1)](https://www.netgear.com/home/mobile-wifi/hotspots/mr1100/) (firmware NTG9X50C_12.06.08.00) * [AirCard 800S (Optus)](https://www.netgear.com/support/product/ac800s_optus) eternalegypt-0.0.17/eternalegypt/000077500000000000000000000000001504115032100167755ustar00rootroot00000000000000eternalegypt-0.0.17/eternalegypt/__init__.py000066400000000000000000000000471504115032100211070ustar00rootroot00000000000000from .eternalegypt import Modem, Error eternalegypt-0.0.17/eternalegypt/eternalegypt.py000066400000000000000000000265211504115032100220600ustar00rootroot00000000000000"""Library for interfacing with Netgear LTE modems.""" import logging import re import json from functools import wraps from datetime import datetime import asyncio from aiohttp.client_exceptions import ClientError import attr TIMEOUT = 3 _LOGGER = logging.getLogger(__name__) class Error(Exception): """Base class for all exceptions.""" @attr.s class SMS: """An SMS message.""" id = attr.ib() timestamp = attr.ib() unread = attr.ib() sender = attr.ib() message = attr.ib() @attr.s class Information: """Various information from the modem.""" serial_number = attr.ib(default=None) usage = attr.ib(default=None) upstream = attr.ib(default=None) wire_connected = attr.ib(default=None) mobile_connected = attr.ib(default=None) connection_text = attr.ib(default=None) connection_type = attr.ib(default=None) current_nw_service_type = attr.ib(default=None) current_ps_service_type = attr.ib(default=None) register_network_display = attr.ib(default=None) roaming = attr.ib(default=None) radio_quality = attr.ib(default=None) rx_level = attr.ib(default=None) tx_level = attr.ib(default=None) current_band = attr.ib(default=None) cell_id = attr.ib(default=None) sms = attr.ib(factory=list) items = attr.ib(factory=dict) def autologin(function, timeout=TIMEOUT): """Decorator that will try to login and redo an action before failing.""" @wraps(function) async def wrapper(self, *args, **kwargs): """Wrap a function with timeout.""" if self.websession is None: _LOGGER.debug("Already logged out") return try: async with asyncio.timeout(timeout): return await function(self, *args, **kwargs) except (asyncio.TimeoutError, ClientError, Error) as ex: _LOGGER.debug("Operation failed (%s), attempting autologin", ex) try: async with asyncio.timeout(timeout): await self.login() return await function(self, *args, **kwargs) except (asyncio.TimeoutError, ClientError, Error) as ex: raise Error(f"Autologin failed ({ex}) for {str(function)}") return wrapper @attr.s class LB2120: """Class for Netgear LB2120 interface.""" hostname = attr.ib() websession = attr.ib() password = attr.ib(default=None) token = attr.ib(default=None) listeners = attr.ib(init=False, factory=list) max_sms_id = attr.ib(init=False, default=None) @property def _baseurl(self): return "http://{}/".format(self.hostname) def _url(self, path): """Build a complete URL for the device.""" return self._baseurl + path async def add_sms_listener(self, listener): """Add a listener for new SMS.""" self.listeners.append(listener) async def logout(self): """Cleanup resources.""" self.websession = None self.token = None async def login(self, password=None): """Create a session with the modem.""" if password is None: password = self.password else: self.password = password self.token = None self.websession.cookie_jar.clear(lambda cookie: cookie['domain'] == self.hostname) try: async with asyncio.timeout(TIMEOUT): url = self._url('model.json') async with self.websession.get(url) as response: try: data = json.loads(await response.text()) self.token = data.get('session', {}).get('secToken') except json.decoder.JSONDecodeError as ex: pass if self.token is None: raise Error("No token found during login") _LOGGER.debug("Token: %s", self.token) url = self._url('Forms/config') data = { 'session.password': password, 'token': self.token } async with self.websession.post(url, data=data) as response: _LOGGER.debug("Got cookie with status %d", response.status) except (asyncio.TimeoutError, ClientError, Error) as ex: raise Error(f"Could not login ({ex})") @autologin async def sms(self, phone, message): """Send a message.""" _LOGGER.debug("Send to %s via %s len=%d", phone, self._baseurl, len(message)) url = self._url('Forms/smsSendMsg') data = { 'sms.sendMsg.receiver': phone, 'sms.sendMsg.text': message, 'sms.sendMsg.clientId': __name__, 'action': 'send', 'token': self.token } async with self.websession.post(url, data=data) as response: _LOGGER.debug("Sent message with status %d", response.status) def _config_call(self, key, value): """Set a configuration key to a certain value.""" url = self._url('Forms/config') data = { key: value, 'err_redirect': '/error.json', 'ok_redirect': '/success.json', 'token': self.token } return self.websession.post(url, data=data) @autologin async def disconnect_lte(self): """Do an LTE disconnect.""" async with self._config_call('wwan.connect', 'Disconnect') as response: _LOGGER.debug("Disconnected LTE with status %d", response.status) @autologin async def connect_lte(self): """Do an LTE reconnect.""" async with self._config_call('wwan.connect', 'DefaultProfile') as response: _LOGGER.debug("Connected to LTE with status %d", response.status) @autologin async def delete_sms(self, sms_id): """Delete a message.""" async with self._config_call('sms.deleteId', sms_id) as response: _LOGGER.debug("Delete %d with status %d", sms_id, response.status) @autologin async def set_failover_mode(self, mode): """Set failover mode.""" modes = { 'auto': 'Auto', 'wire': 'WAN', 'mobile': 'LTE', } if mode not in modes.keys(): _LOGGER.error("Invalid mode %s not %s", mode, "/".join(modes.keys())) return async with self._config_call('failover.mode', modes[mode]) as response: _LOGGER.debug("Set mode to %s", mode) @autologin async def set_autoconnect_mode(self, mode): """Set autoconnect mode.""" modes = { 'never': 'Never', 'home': 'HomeNetwork', 'always': 'Always', } if mode not in modes.keys(): _LOGGER.error("Invalid mode %s not %s", mode, "/".join(modes.keys())) return async with self._config_call('wwan.autoconnect', modes[mode]) as response: _LOGGER.debug("Set mode to %s", mode) @autologin async def router_restart(self): """Do a device restart.""" async with self._config_call('general.shutdown', 'restart') as response: _LOGGER.debug("Router restart %d", response.status) @autologin async def factory_reset(self): """Do a factory reset.""" async with self._config_call('general.factoryReset', 1) as response: _LOGGER.debug("Factory reset %d", response.status) def _build_information(self, data): """Read the bits we need from returned data.""" result = Information() result.serial_number = data['general']['FSN'] result.usage = data['wwan']['dataUsage']['generic']['dataTransferred'] if 'failover' in data: result.upstream = data['failover'].get('backhaul') result.wire_connected = data['failover'].get('wanConnected') result.mobile_connected = (data['wwan']['connection'] == 'Connected') result.connection_text = data['wwan']['connectionText'] result.connection_type = data['wwan']['connectionType'] result.current_nw_service_type = data['wwan']['currentNWserviceType'] result.current_ps_service_type = data['wwan']['currentPSserviceType'] result.register_network_display = data['wwan']['registerNetworkDisplay'] result.roaming = data['wwan']['roaming'] result.radio_quality = data['wwanadv']['radioQuality'] result.rx_level = data['wwanadv']['rxLevel'] result.tx_level = data['wwanadv']['txLevel'] result.current_band = data['wwanadv']['curBand'] result.cell_id = data['wwanadv']['cellId'] mdy_models = ('MR1100') for msg in [m for m in data['sms']['msgs'] if 'text' in m]: # {'id': '6', 'rxTime': '11/03/18 08:18:11 PM', 'text': 'tak tik', # 'sender': '555-987-654', 'read': False} try: if ('model' in data['general'] and data['general']['model'] in mdy_models): dt = datetime.strptime(msg['rxTime'], '%m/%d/%y %I:%M:%S %p') else: dt = datetime.strptime(msg['rxTime'], '%d/%m/%y %I:%M:%S %p') except ValueError: dt = None element = SMS(int(msg['id']), dt, not msg['read'], msg['sender'], msg['text']) result.sms.append(element) result.sms.sort(key=lambda sms: sms.id) result.items = { key: value for key, value in flatten(data).items() if key not in ('webd.adminpassword', 'session.sectoken', 'wifi.guest.passphrase', 'wifi.passphrase') } return result @autologin async def information(self): """Return the current information.""" url = self._url('model.json') async with self.websession.get(url) as response: try: text = await response.text() except TimeoutError as ex: _LOGGER.debug("Timeout while reading information (%s)", ex) raise Error(ex) try: data = json.loads(text) except json.decoder.JSONDecodeError as ex: _LOGGER.debug("Failed to decode response (%s): %s", ex, text) raise Error(ex) try: result = self._build_information(data) _LOGGER.debug("Did read information: %s", data) except KeyError as ex: _LOGGER.debug("Failed to read information (%s): %s", ex, data) raise Error(ex) self._sms_events(result) return result def _sms_events(self, information): """Send events for each new SMS.""" if not self.listeners: return if self.max_sms_id is not None: new_sms = (s for s in information.sms if s.id > self.max_sms_id) for sms in new_sms: for listener in self.listeners: listener(sms) if information.sms: self.max_sms_id = max(s.id for s in information.sms) else: self.max_sms_id = 0 class Modem(LB2120): """Class for any modem.""" def flatten(obj, path=""): """Flatten nested dicts into hierarchical keys.""" result = {} if isinstance(obj, dict): for key, item in obj.items(): result.update(flatten(item, path=(path + "." if path else "") + key.lower())) elif isinstance(obj, (str, int, float, bool)): result[path] = obj return result eternalegypt-0.0.17/examples/000077500000000000000000000000001504115032100161105ustar00rootroot00000000000000eternalegypt-0.0.17/examples/auto_connect.py000077500000000000000000000014631504115032100211520ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import logging import eternalegypt async def set_autoconnect_mode(mode): """Example of setting the autoconnect mode.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) try: modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) await modem.set_autoconnect_mode(mode) await modem.logout() except eternalegypt.Error: print("Could not login") await websession.close() if len(sys.argv) != 4: print("{}: ".format(sys.argv[0])) else: asyncio.run(set_autoconnect_mode(sys.argv[3])) eternalegypt-0.0.17/examples/connect_lte.py000077500000000000000000000013361504115032100207650ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import logging import eternalegypt async def connect(): """Example of doing an LTE reconnect..""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) try: modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) await modem.connect_lte() await modem.logout() except eternalegypt.Error: print("Could not login") await websession.close() if len(sys.argv) != 3: print("{}: ".format(sys.argv[0])) else: asyncio.run(connect()) eternalegypt-0.0.17/examples/factory_reset.py000077500000000000000000000013571504115032100213440ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import eternalegypt import logging logging.basicConfig(level=logging.DEBUG) async def reconnect(): """Example of disconnecting and reconnecting.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) print("Factory reset") await modem.factory_reset() print("Closing down") await modem.logout() await websession.close() if len(sys.argv) != 3: print("{}: ".format(sys.argv[0])) else: asyncio.run(reconnect()) eternalegypt-0.0.17/examples/failover.py000077500000000000000000000014301504115032100202720ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import logging import eternalegypt async def set_failover_mode(mode): """Example of printing the current upstream.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) try: modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) await modem.set_failover_mode(mode) await modem.logout() except eternalegypt.Error: print("Could not login") await websession.close() if len(sys.argv) != 4: print("{}: ".format(sys.argv[0])) else: asyncio.run(set_failover_mode(sys.argv[3])) eternalegypt-0.0.17/examples/inbox.py000077500000000000000000000014071504115032100176060ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import logging import pprint import eternalegypt logging.basicConfig(level=logging.DEBUG) async def get_information(): """Example of printing the inbox.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) result = await modem.information() for sms in result.sms: pprint.pprint(sms) await modem.logout() await websession.close() if len(sys.argv) != 3: print("{}: ".format(sys.argv[0])) else: asyncio.run(get_information()) eternalegypt-0.0.17/examples/reconnect.py000077500000000000000000000016361504115032100204530ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import eternalegypt import logging logging.basicConfig(level=logging.DEBUG) async def reconnect(): """Example of disconnecting and reconnecting.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) print("Disconnecting") await modem.disconnect_lte() print("Waiting 5 seconds") await asyncio.sleep(5) print("Connecting") await modem.connect_lte() print("Waiting 5 seconds") await asyncio.sleep(5) print("Closing down") await modem.logout() await websession.close() if len(sys.argv) != 3: print("{}: ".format(sys.argv[0])) else: asyncio.run(reconnect()) eternalegypt-0.0.17/examples/sms.py000077500000000000000000000013551504115032100172730ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for Eternal Egypt library.""" import sys import asyncio import aiohttp import logging import eternalegypt logging.basicConfig(level=logging.DEBUG) async def send_message(): """Example of sending a message.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) await modem.sms(phone=sys.argv[3], message=sys.argv[4]) await modem.logout() await websession.close() if len(sys.argv) != 5: print("{}: ".format( sys.argv[0])) else: asyncio.run(send_message()) eternalegypt-0.0.17/examples/sms_forward.py000077500000000000000000000021411504115032100210110ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for Eternal Egypt library.""" import sys import asyncio import aiohttp import eternalegypt async def wait_for_messages(): jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) def forward_sms(sms): if sms.sender == sys.argv[3] and ": " in sms.message: phone, message = sms.message.split(": ", 1) asyncio.create_task(modem.sms(phone=phone, message=message)) else: asyncio.create_task(modem.sms(phone=sys.argv[3], message=f"{sms.sender}: {sms.message}")) await modem.add_sms_listener(forward_sms) try: while True: await modem.information() # sends new sms objects to listener await asyncio.sleep(5) finally: await modem.logout() await websession.close() if len(sys.argv) != 4: print("{}: ".format(sys.argv[0])) else: asyncio.run(wait_for_messages()) eternalegypt-0.0.17/examples/status.py000077500000000000000000000037151504115032100200160ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import eternalegypt async def get_information(): """Example of printing the current upstream.""" jar = aiohttp.CookieJar(unsafe=True) websession = aiohttp.ClientSession(cookie_jar=jar) try: modem = eternalegypt.Modem(hostname=sys.argv[1], websession=websession) await modem.login(password=sys.argv[2]) result = await modem.information() if len(sys.argv) == 3: print("serial_number: {}".format(result.serial_number)) print("usage: {}".format(result.usage)) print("upstream: {}".format(result.upstream)) print("wire_connected: {}".format(result.wire_connected)) print("mobile_connected: {}".format(result.mobile_connected)) print("connection_text: {}".format(result.connection_text)) print("connection_type: {}".format(result.connection_type)) print("current_nw_service_type: {}".format(result.current_nw_service_type)) print("current_ps_service_type: {}".format(result.current_ps_service_type)) print("register_network_display: {}".format(result.register_network_display)) print("roaming: {}".format(result.roaming)) print("radio_quality: {}".format(result.radio_quality)) print("rx_level: {}".format(result.rx_level)) print("tx_level: {}".format(result.tx_level)) print("current_band: {}".format(result.current_band)) print("cell_id: {}".format(result.cell_id)) else: key = sys.argv[3] print("{}: {}".format(key, result.items.get(key))) await modem.logout() except eternalegypt.Error: print("Could not login") await websession.close() if len(sys.argv) not in (3, 4): print("{}: [key]".format(sys.argv[0])) else: asyncio.run(get_information()) eternalegypt-0.0.17/setup.cfg000066400000000000000000000000501504115032100161060ustar00rootroot00000000000000[metadata] description-file = README.md eternalegypt-0.0.17/setup.py000066400000000000000000000011201504115032100157760ustar00rootroot00000000000000#!/usr/bin/env python3 from setuptools import setup setup( name="eternalegypt", packages=["eternalegypt"], version="0.0.17", install_requires=["aiohttp>=3.5.0","attrs"], description="Netgear LTE modem API", author="Anders Melchiorsen", author_email="amelchio@nogoto.net", url="https://github.com/amelchio/eternalegypt", license="MIT", keywords=["netgear,lte,lb1120,lb2120"], classifiers=[ "License :: OSI Approved :: MIT License", "Intended Audience :: Developers", "Programming Language :: Python :: 3 :: Only", ], )