pax_global_header00006660000000000000000000000064144270037550014521gustar00rootroot0000000000000052 comment=9298d17d10401d3352fd8d7055fd12d2ecaeae7c amelchio-eternalegypt-9298d17/000077500000000000000000000000001442700375500162745ustar00rootroot00000000000000amelchio-eternalegypt-9298d17/.github/000077500000000000000000000000001442700375500176345ustar00rootroot00000000000000amelchio-eternalegypt-9298d17/.github/workflows/000077500000000000000000000000001442700375500216715ustar00rootroot00000000000000amelchio-eternalegypt-9298d17/.github/workflows/pypi.yml000066400000000000000000000012551442700375500234000ustar00rootroot00000000000000name: 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 amelchio-eternalegypt-9298d17/LICENSE.txt000066400000000000000000000020711442700375500201170ustar00rootroot00000000000000The 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. amelchio-eternalegypt-9298d17/MANIFEST.in000066400000000000000000000001041442700375500200250ustar00rootroot00000000000000include *.txt setup.cfg recursive-include *.txt *.py prune examples amelchio-eternalegypt-9298d17/README.md000066400000000000000000000012241442700375500175520ustar00rootroot00000000000000# 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) amelchio-eternalegypt-9298d17/eternalegypt/000077500000000000000000000000001442700375500207775ustar00rootroot00000000000000amelchio-eternalegypt-9298d17/eternalegypt/__init__.py000066400000000000000000000000471442700375500231110ustar00rootroot00000000000000from .eternalegypt import Modem, Error amelchio-eternalegypt-9298d17/eternalegypt/eternalegypt.py000066400000000000000000000260331442700375500240600ustar00rootroot00000000000000"""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 async_timeout 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 async_timeout.timeout(timeout): return await function(self, *args, **kwargs) except (asyncio.TimeoutError, ClientError, Error): pass _LOGGER.debug("autologin") try: async with async_timeout.timeout(timeout): await self.login() return await function(self, *args, **kwargs) except (asyncio.TimeoutError, ClientError, Error): raise Error(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.""" # Work around missing https://github.com/aio-libs/aiohttp/pull/3576 try: await self._login(password) except (asyncio.TimeoutError, ClientError, Error): await self._login(password) async def _login(self, password=None): """Create a session with the modem.""" if password is None: password = self.password else: self.password = password try: async with async_timeout.timeout(TIMEOUT): url = self._url('model.json') async with self.websession.get(url) as response: data = json.loads(await response.text()) self.token = data.get('session', {}).get('secToken') if self.token is None: _LOGGER.error("No token found during login") raise Error() _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): raise Error("Could not login") @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: data = json.loads(await response.text()) 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() 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 amelchio-eternalegypt-9298d17/examples/000077500000000000000000000000001442700375500201125ustar00rootroot00000000000000amelchio-eternalegypt-9298d17/examples/auto_connect.py000077500000000000000000000015011442700375500231450ustar00rootroot00000000000000#!/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 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_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.get_event_loop().run_until_complete(set_autoconnect_mode(sys.argv[3])) amelchio-eternalegypt-9298d17/examples/connect_lte.py000077500000000000000000000014041442700375500227630ustar00rootroot00000000000000#!/usr/bin/env python3 """Example file for eternalegypt library.""" import sys import asyncio import aiohttp import logging import eternalegypt async def connect(): """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.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.get_event_loop().run_until_complete(connect()) amelchio-eternalegypt-9298d17/examples/factory_reset.py000077500000000000000000000014171442700375500233430ustar00rootroot00000000000000#!/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.get_event_loop().run_until_complete(reconnect()) amelchio-eternalegypt-9298d17/examples/failover.py000077500000000000000000000014701442700375500223000ustar00rootroot00000000000000#!/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.get_event_loop().run_until_complete(set_failover_mode(sys.argv[3])) amelchio-eternalegypt-9298d17/examples/inbox.py000077500000000000000000000014471442700375500216140ustar00rootroot00000000000000#!/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.get_event_loop().run_until_complete(get_information()) amelchio-eternalegypt-9298d17/examples/reconnect.py000077500000000000000000000016761442700375500224610ustar00rootroot00000000000000#!/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.get_event_loop().run_until_complete(reconnect()) amelchio-eternalegypt-9298d17/examples/sms.py000077500000000000000000000014151442700375500212720ustar00rootroot00000000000000#!/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.get_event_loop().run_until_complete(send_message()) amelchio-eternalegypt-9298d17/examples/status.py000077500000000000000000000037551442700375500220240ustar00rootroot00000000000000#!/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.get_event_loop().run_until_complete(get_information()) amelchio-eternalegypt-9298d17/setup.cfg000066400000000000000000000000501442700375500201100ustar00rootroot00000000000000[metadata] description-file = README.md amelchio-eternalegypt-9298d17/setup.py000066400000000000000000000011201442700375500200000ustar00rootroot00000000000000#!/usr/bin/env python3 from setuptools import setup setup( name="eternalegypt", packages=["eternalegypt"], version="0.0.16", install_requires=["aiohttp>=3.0.1","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", ], )