diff --git a/connect_box/__init__.py b/connect_box/__init__.py index 0bcd72f..2ef01e0 100644 --- a/connect_box/__init__.py +++ b/connect_box/__init__.py @@ -1,7 +1,10 @@ """A Python Client to get data from UPC Connect Boxes.""" import asyncio from collections import OrderedDict +from hashlib import sha256 +from http.cookies import BaseCookie import logging +import re from typing import Dict, List, Optional import aiohttp @@ -53,13 +56,20 @@ class ConnectBox: """A class for handling the data retrieval from an UPC Connect Box.""" def __init__( - self, session: aiohttp.ClientSession, password: str, host: str = "192.168.0.1" + self, + session: aiohttp.ClientSession, + password: str, + host: str = "192.168.0.1", + username: str = "admin", + use_token: bool = True, + encrypt_password: bool = False, ): """Initialize the connection.""" self._session: aiohttp.ClientSession = session - self.token: Optional[str] = None self.host: str = host + self.username: str = username self.password: str = password + self.use_token: bool = use_token self.headers: Dict[str, str] = { HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", REFERER: f"http://{self.host}/index.html", @@ -84,9 +94,16 @@ def __init__( self.cm_systeminfo: Optional[CmSystemInfo] = None self.global_settings: Optional[GlobalSettings] = None + # Optionally encrypt_password the password if requested + if password and encrypt_password: + self.password = sha256(self.password.encode("utf-8")).hexdigest() + + # Allow setting cookies for IP addresses + self._session.cookie_jar._unsafe = True + async def async_get_devices(self): """Scan for new devices and return a list with found device IDs.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.devices = [] @@ -138,12 +155,11 @@ async def async_get_devices(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read device from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_downstream(self): """Get the current downstream cable modem state.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.ds_channels = [] @@ -168,12 +184,11 @@ async def async_get_downstream(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read downstream channels from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_upstream(self): """Get the current upstream cable modem state.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.us_channels = [] @@ -189,23 +204,20 @@ async def async_get_upstream(self): upstream.find("srate").text, upstream.find("usid").text, upstream.find("mod").text, - upstream.find("ustype").text, int(upstream.find("t1Timeouts").text), int(upstream.find("t2Timeouts").text), int(upstream.find("t3Timeouts").text), int(upstream.find("t4Timeouts").text), - upstream.find("channeltype").text, int(upstream.find("messageType").text), ) ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read upstream channels from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_ipv6_filtering(self) -> None: """Get the current ipv6 filter (and filters time) rules.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.ipv6_filters = [] @@ -240,8 +252,7 @@ async def async_get_ipv6_filtering(self) -> None: except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read IPv6 filter rules from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def _async_get_ipv6_filter_states(self) -> FilterStatesList: """Get enable/disable states of IPv6 filter instances.""" @@ -255,7 +266,7 @@ async def _async_get_ipv6_filter_states(self) -> FilterStatesList: async def _async_update_ipv6_filter_states(self, filter_states: FilterStatesList): """Update enable/disable states of IPv6 filters while not affecting any other settings.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() val_enabled = "*".join([str(fs.enabled) for fs in filter_states.entries]) @@ -310,7 +321,7 @@ async def async_toggle_ipv6_filter(self, idd: int) -> Optional[bool]: async def async_get_lanstatus(self): """Access information related to the router gateway""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.lanstatus = None @@ -325,12 +336,11 @@ async def async_get_lanstatus(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read lanstatus from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_wanstatus(self): """Access information related to the WAN port.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.wanstatus = None @@ -342,12 +352,11 @@ async def async_get_wanstatus(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read wanstatus from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_cm_system_info(self): """Access information related to the cable modem.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.cm_systeminfo = None @@ -361,12 +370,11 @@ async def async_get_cm_system_info(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read cm system info from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_cmstatus_and_service_flows(self): """Get various status information.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.cmstatus = None @@ -381,7 +389,6 @@ async def async_get_cmstatus_and_service_flows(self): cmComment=xml_root.find("cm_comment").text, cmDocsisMode=xml_root.find("cm_docsis_mode").text, cmNetworkAccess=xml_root.find("cm_network_access").text, - numberOfCpes=int(xml_root.find("NumberOfCpes").text), firmwareFilename=xml_root.find("FileName").text, dMaxCpes=int(xml_root.find("dMaxCpes").text), bpiEnable=int(xml_root.find("bpiEnable").text), @@ -410,12 +417,11 @@ async def async_get_cmstatus_and_service_flows(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read cmstatus from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_temperature(self): """Get temperature information (in degrees Celsius).""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.temperature = None @@ -430,12 +436,11 @@ async def async_get_temperature(self): ) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read temperature from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_get_eventlog(self): """Get network-related eventlog data.""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() self.eventlog = [] @@ -453,31 +458,31 @@ async def async_get_eventlog(self): self.eventlog.append(log_event) except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read eventlog from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() + self.eventlog.sort(key=(lambda e: e.evEpoch)) async def async_reboot_device(self) -> None: """Request that the device reboot""" - if self.token is None: + if not self._has_token(): await self.async_initialize_token() await self._async_ws_set_function(CMD_REBOOT, params={}) # At this point the device must be restarting, so the token becomes invalid - self.token = None + self._clear_token() async def async_close_session(self) -> None: """Logout and close session.""" - if not self.token: + if not self._has_token(): return await self._async_ws_set_function(CMD_LOGOUT, {}) - self.token = None + self._clear_token() async def async_get_global_settings(self) -> None: """Access the global settings, reduced information is available if not logged in before.""" - if self.token is None: + if not self._has_token(): # Loging in is not required for this method await self._async_initialize_valid_token() @@ -497,8 +502,7 @@ async def async_get_global_settings(self) -> None: self.global_settings = global_settings except (element_tree.ParseError, TypeError): _LOGGER.warning("Can't read global settings from %s", self.host) - self.token = None - raise exceptions.ConnectBoxNoDataAvailable() from None + raise exceptions.ConnectBoxNoDataAvailable() async def async_initialize_token(self) -> None: """Get the token first.""" @@ -506,16 +510,15 @@ async def async_initialize_token(self) -> None: await self._async_do_login_with_password(CMD_LOGIN) async def _async_initialize_valid_token(self) -> None: - """Initialize self.token to a known good value""" + """Initialize the session token""" try: # Get first the token async with self._session.get( - f"http://{self.host}/common_page/login.html", + f"http://{self.host}/index.html", headers=self.headers, timeout=10, ) as response: await response.text() - #self.token = response.cookies["sessionToken"].value except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Can not load login page from %s: %s", self.host, err) @@ -523,21 +526,42 @@ async def _async_initialize_valid_token(self) -> None: async def _async_do_login_with_password(self, function: int) -> None: """Get token with password.""" + params = OrderedDict( + [ + ("Username", "NULL" if self.use_token else self.username), + ("Password", self.password), + ] + ) + try: async with await self._session.post( f"http://{self.host}/xml/setter.xml", - data=f"token={self.token}&fun={function}&Username=NULL&Password={self.password}", + data=self._payload(function, params), headers=self.headers, allow_redirects=False, timeout=10, ) as response: - await response.text() + html = await response.text() if response.status != 200: _LOGGER.warning("Login error with code %d", response.status) - self.token = None + self._clear_token() + raise exceptions.ConnectBoxLoginError() + + sid_matcher = re.search("SID=(\d*)", html) + if sid_matcher: + self._session.cookie_jar.update_cookies( + {"SID": sid_matcher.group(1)} + ) + + if not sid_matcher and not self.use_token: + _LOGGER.error( + "Could not find an SID in the login response, " + "which is mandatory for non-token based authentication. " + "Response is: %s", + html, + ) raise exceptions.ConnectBoxLoginError() - self.token = response.cookies["sessionToken"].value except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Can not login to %s: %s", self.host, err) @@ -546,29 +570,23 @@ async def _async_do_login_with_password(self, function: int) -> None: async def _async_ws_get_function(self, function: int) -> Optional[str]: """Execute a command on UPC firmware webservice.""" try: - # The 'token' parameter has to be first and 'fun' second. Otherwise - # the firmware will return an error async with await self._session.post( f"http://{self.host}/xml/getter.xml", - data=f"token={self.token}&fun={function}", + data=self._payload(function), headers=self.headers, allow_redirects=False, timeout=10, ) as response: - # If there is an error if response.status != 200: _LOGGER.debug("Receive HTTP code %d", response.status) - self.token = None + self._clear_token() raise exceptions.ConnectBoxError() - # Load data, store token for next request - self.token = response.cookies["sessionToken"].value return await response.text() except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Error received on %s: %s", function, err) - self.token = None raise exceptions.ConnectBoxConnectionError() @@ -582,34 +600,45 @@ async def _async_ws_set_function( params(dict): key/value pairs to be passed to the function """ try: - # The 'token' parameter has to be first and 'fun' second. Otherwise - # the firmware will return an error - params_str = "".join([f"&{key}={value}" for (key, value) in params.items()]) - async with await self._session.post( f"http://{self.host}/xml/setter.xml", - data=f"token={self.token}&fun={function}{params_str}", - # data=params, + data=self._payload(function, params), headers=self.headers, allow_redirects=False, timeout=10, ) as response: - # If there is an error if response.status != 200: _LOGGER.debug("Receive HTTP code %d", response.status) - self.token = None - print(response.status) - print(response.content) + self._clear_token() raise exceptions.ConnectBoxError() - # Load data, store token for next request - self.token = response.cookies["sessionToken"].value await response.text() return True except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error("Error received on %s: %s", function, err) - self.token = None raise exceptions.ConnectBoxConnectionError() + + def _domain_cookies(self) -> BaseCookie[str]: + return self._session.cookie_jar.filter_cookies("http://" + self.host) + + def _has_token(self) -> bool: + return "sessionToken" in self._domain_cookies() + + def _clear_token(self): + self._session.cookie_jar.clear() + + def _payload(self, function: int, data: OrderedDict = {}) -> str: + payload = OrderedDict() + + # The 'token' parameter has to be first and 'fun' second. + # Otherwise the firmware will return an error + if self.use_token: + payload["token"] = self._domain_cookies()["sessionToken"].value + + payload["fun"] = function + payload.update(data) + + return "".join([f"&{key}={value}" for (key, value) in payload.items()]) diff --git a/connect_box/data.py b/connect_box/data.py index ad39f6a..64a2698 100644 --- a/connect_box/data.py +++ b/connect_box/data.py @@ -47,12 +47,10 @@ class UpstreamChannel: symbolRate: str = attr.ib() id: str = attr.ib() modulation: str = attr.ib() - type: str = attr.ib() t1Timeouts: int = attr.ib() t2Timeouts: int = attr.ib() t3Timeouts: int = attr.ib() t4Timeouts: int = attr.ib() - channelType: str = attr.ib() messageType: int = attr.ib() @@ -108,9 +106,6 @@ class CmStatus: cmNetworkAccess: str = attr.ib() firmwareFilename: str = attr.ib() - # number of IP addresses to assign via DHCP - numberOfCpes: int = attr.ib() - # ??? dMaxCpes: int = attr.ib() bpiEnable: int = attr.ib() diff --git a/example.py b/example.py index 4da6bc5..d995474 100644 --- a/example.py +++ b/example.py @@ -11,7 +11,7 @@ async def main(): """Sample code to retrieve the data from an UPC Connect Box.""" async with aiohttp.ClientSession() as session: - client = ConnectBox(session, PASSWORD) + client = ConnectBox(session, PASSWORD, use_token=False, encrypt_password=False) # Print details about the downstream channel connectivity await client.async_get_downstream()