diff --git a/.gitignore b/.gitignore index b250500..75a4557 100644 --- a/.gitignore +++ b/.gitignore @@ -101,4 +101,5 @@ ENV/ .mypy_cache/ .idea/ -.vscode/ \ No newline at end of file +.vscode/ +index.py \ No newline at end of file diff --git a/raincloudy/controller.py b/raincloudy/controller.py index 0f5f82f..29fb9a6 100644 --- a/raincloudy/controller.py +++ b/raincloudy/controller.py @@ -3,16 +3,14 @@ import raincloudy from raincloudy.faucet import RainCloudyFaucet -from raincloudy.const import ( - STATUS_ENDPOINT, HEADERS, HOME_ENDPOINT, SETUP_ENDPOINT) -from raincloudy.helpers import ( - generate_soup_html, find_controller_or_faucet_name) +from raincloudy.const import SETUP_ENDPOINT +from raincloudy.helpers import find_controller_or_faucet_name class RainCloudyController(): """RainCloudy Controller object.""" - def __init__(self, parent, controller_id, faucets=None): + def __init__(self, parent, controller_id, index, faucets=None): """ Initialize RainCloudy Controller object. @@ -25,14 +23,16 @@ def __init__(self, parent, controller_id, faucets=None): :return: RainCloudyController object :rtype: RainCloudyController object """ - self.attributes = None + self.attributes = {} self._parent = parent + self.home = parent.html['home'] self._controller_id = controller_id + self.index = index self._verify_parent() # faucets associated with controller - self.faucets = [] + self._faucets = [] # load assigned faucets self._assign_faucets(faucets) @@ -46,13 +46,13 @@ def _verify_parent(self): raise TypeError("Invalid parent object.") def _assign_faucets(self, faucets): - """Assign RainCloudyFaucet objects to self.faucets.""" + """Assign RainCloudyFaucet objects to self._faucets.""" if not faucets: raise TypeError("Controller does not have a faucet assigned.") - for faucet_id in faucets: - self.faucets.append( - RainCloudyFaucet(self._parent, self, faucet_id)) + for index, faucet_id in enumerate(faucets): + self._faucets.append( + RainCloudyFaucet(self._parent, self, faucet_id, index)) def __repr__(self): """Object representation.""" @@ -61,70 +61,13 @@ def __repr__(self): except AttributeError: return "<{0}: {1}>".format(self.__class__.__name__, self.id) - def post(self, ddata, url=SETUP_ENDPOINT, referer=SETUP_ENDPOINT): - """Method to update some attributes on namespace.""" - headers = HEADERS.copy() - if referer is None: - headers.pop('Referer') - else: - headers['Referer'] = referer - - # append csrftoken - if 'csrfmiddlewaretoken' not in ddata.keys(): - ddata['csrfmiddlewaretoken'] = self._parent.csrftoken - - req = self._parent.client.post(url, headers=headers, data=ddata) - if req.status_code == 200: - self.update() - - def _get_cu_and_fu_status(self): - """Submit GET request to update information.""" - # adjust headers - headers = HEADERS.copy() - headers['Accept'] = '*/*' - headers['X-Requested-With'] = 'XMLHttpRequest' - headers['X-CSRFToken'] = self._parent.csrftoken - - args = '?controller_serial=' + self.serial \ - + '&faucet_serial=' + self.faucet.serial - - req = self._parent.client.get(STATUS_ENDPOINT + args, - headers=headers) - - # token probably expired, then try again - if req.status_code == 403: - self._parent.login() - self.update() - elif req.status_code == 200: - self.attributes = req.json() - else: - req.raise_for_status() - - def _refresh_html_home(self): - """ - Function to refresh the self._parent.html['home'] object - which provides the status if zones are scheduled to - start automatically (program_toggle). - """ - req = self._parent.client.get(HOME_ENDPOINT) - if req.status_code == 403: - self._parent.login() - self.update() - elif req.status_code == 200: - self._parent.html['home'] = generate_soup_html(req.text) - else: - req.raise_for_status() - def update(self): """ - Call 2 methods to update zone attributes and html['home'] object + Call 1 method to update zone attributes """ # update zone attributes - self._get_cu_and_fu_status() - - # update self._parent.html['home'] for gathering - # auto_watering status (program_toggle tag) - self._refresh_html_home() + for faucet in self._faucets: + faucet.update() @property def serial(self): @@ -142,16 +85,18 @@ def name(self): """Return controller name.""" return \ find_controller_or_faucet_name(self._parent.html['home'], - 'controller') + 'controller', + self.index) @name.setter def name(self, value): """Set a new name to controller.""" data = { + 'select_controller': self.index, '_set_controller_name': 'Set Name', 'controller_name': value, } - self.post(data, url=SETUP_ENDPOINT, referer=SETUP_ENDPOINT) + self._parent.post(data, url=SETUP_ENDPOINT, referer=SETUP_ENDPOINT) @property def status(self): @@ -164,13 +109,10 @@ def current_time(self): return self.attributes['current_time'] @property - def faucet(self): + def faucets(self): """Show current linked faucet.""" - if hasattr(self, 'faucets'): - if len(self.faucets) > 1: - # in the future, we should support more faucets - raise TypeError("Only one faucet per account.") - return self.faucets[0] - raise AttributeError("There is no faucet assigned.") + if hasattr(self, '_faucets'): + return self._faucets + raise AttributeError("There are no faucets assigned.") # vim:sw=4:ts=4:et: diff --git a/raincloudy/core.py b/raincloudy/core.py index 44484f8..9d8879e 100644 --- a/raincloudy/core.py +++ b/raincloudy/core.py @@ -3,8 +3,10 @@ import requests import urllib3 from raincloudy.const import ( - INITIAL_DATA, HEADERS, LOGIN_ENDPOINT, LOGOUT_ENDPOINT, SETUP_ENDPOINT) -from raincloudy.helpers import generate_soup_html, serial_finder + INITIAL_DATA, HEADERS, LOGIN_ENDPOINT, LOGOUT_ENDPOINT, SETUP_ENDPOINT, + HOME_ENDPOINT) +from raincloudy.helpers import generate_soup_html, faucet_serial_finder, \ + controller_serial_finder from raincloudy.controller import RainCloudyController @@ -39,7 +41,7 @@ def __init__(self, username, password, http_proxy=None, https_proxy=None, self._password = password # initialize future attributes - self.controllers = [] + self._controllers = [] self.client = None self.is_connected = False self.html = { @@ -60,13 +62,15 @@ def __init__(self, username, password, http_proxy=None, https_proxy=None, def __repr__(self): """Object representation.""" - return "<{0}: {1}>".format(self.__class__.__name__, - self.controller.serial) + for controller in self.controllers: + return "<{0}: {1}>".format(self.__class__.__name__, + controller.serial) def login(self): """Call login.""" - self._authenticate() + return self._authenticate + @property def _authenticate(self): """Authenticate.""" # to obtain csrftoken, remove Referer from headers @@ -91,44 +95,84 @@ def _authenticate(self): if req.status_code != 302: req.raise_for_status() + home = self.client.get(url=HOME_ENDPOINT) + + self.html['home'] = generate_soup_html(home.text) + setup = self.client.get(SETUP_ENDPOINT, headers=HEADERS) # populate device list self.html['setup'] = generate_soup_html(setup.text) - # currently only one faucet is supported on the code - # we have future plans to support it - parsed_controller = serial_finder(self.html['setup']) - self.controllers.append( - RainCloudyController( - self, - parsed_controller['controller_serial'], - parsed_controller['faucet_serial'] + controller_serials = controller_serial_finder(self.html['setup']) + + for index, controller_serial in enumerate(controller_serials): + + # We need to do a form submit for other controllers to get + # faucet serials + if index > 0: + data = { + 'select_controller': index + } + self.html['setup'] = \ + generate_soup_html(self.post(data, + url=SETUP_ENDPOINT, + referer=SETUP_ENDPOINT).text) + + faucet_serials = faucet_serial_finder(self.html['setup']) + self._controllers.append( + RainCloudyController( + self, + controller_serial, + index, + faucet_serials + ) ) - ) self.is_connected = True return True @property def csrftoken(self): - '''Return current csrftoken from request session.''' + """Return current csrftoken from request session.""" if self.client: return self.client.cookies.get('csrftoken') return None def update(self): """Update controller._attributes.""" - self.controller.update() + for controller in self._controllers: + controller.update() @property - def controller(self): + def controllers(self): """Show current linked controllers.""" - if hasattr(self, 'controllers'): - if len(self.controllers) > 1: - # in the future, we should support more controllers - raise TypeError("Only one controller per account.") - return self.controllers[0] + if hasattr(self, '_controllers'): + return self._controllers raise AttributeError("There is no controller assigned.") + def update_home(self, data): + """Update home html""" + if not isinstance(data, str): + raise TypeError("Function requires string response") + self.html['home'] = generate_soup_html(data) + + def post(self, ddata, url=SETUP_ENDPOINT, referer=SETUP_ENDPOINT): + """Method to update some attributes on namespace.""" + headers = HEADERS.copy() + if referer is None: + headers.pop('Referer') + else: + headers['Referer'] = referer + + # append csrftoken + if 'csrfmiddlewaretoken' not in ddata.keys(): + ddata['csrfmiddlewaretoken'] = self.csrftoken + + req = self.client.post(url, headers=headers, data=ddata) + if not req.status_code == 200: + return None + + return req + def logout(self): """Logout.""" self.client.get(LOGOUT_ENDPOINT) @@ -137,7 +181,7 @@ def logout(self): def _cleanup(self): """Cleanup object when logging out.""" self.client = None - self.controllers = [] + self._controllers = [] self.is_connected = False # vim:sw=4:ts=4:et: diff --git a/raincloudy/faucet.py b/raincloudy/faucet.py index c44f346..5914af3 100644 --- a/raincloudy/faucet.py +++ b/raincloudy/faucet.py @@ -2,15 +2,16 @@ """RainCloud Faucet.""" from raincloudy.const import ( HOME_ENDPOINT, MANUAL_OP_DATA, MANUAL_WATERING_ALLOWED, - MAX_RAIN_DELAY_DAYS, MAX_WATERING_MINUTES) + MAX_RAIN_DELAY_DAYS, MAX_WATERING_MINUTES, HEADERS, STATUS_ENDPOINT) from raincloudy.helpers import ( - find_controller_or_faucet_name, find_zone_name) + find_controller_or_faucet_name, find_zone_name, + find_selected_controller_or_faucet_index) class RainCloudyFaucetCore(): """RainCloudyFaucetCore object.""" - def __init__(self, parent, controller, faucet_id): + def __init__(self, parent, controller, faucet_id, index): """ Initialize RainCloudy Controller object. @@ -24,9 +25,11 @@ def __init__(self, parent, controller, faucet_id): :rtype: RainCloudyFaucet object """ + self.index = index self._parent = parent self._controller = controller self._id = faucet_id + self._attributes = {} # zones associated with faucet self.zones = [] @@ -55,9 +58,9 @@ def __repr__(self): return "<{0}: {1}>".format(self.__class__.__name__, self.id) @property - def _attributes(self): - """Callback to self._controller attributes.""" - return self._controller.attributes + def attributes(self): + """Return faucet id.""" + return self._attributes @property def serial(self): @@ -80,18 +83,20 @@ def name(self): """Return faucet name.""" return \ find_controller_or_faucet_name( - self._parent.html['home'], - 'faucet') + self._controller.home, + 'faucet', + self.index + ) @name.setter def name(self, value): """Set a new name to faucet.""" data = { '_set_faucet_name': 'Set Name', - 'select_faucet': 0, + 'select_faucet': self.index, 'faucet_name': value, } - self._controller.post(data) + self._parent.post(data) @property def status(self): @@ -107,8 +112,28 @@ def battery(self): return battery.strip('%') def update(self): - """Callback self._controller.update().""" - self._controller.update() + """Submit GET request to update information.""" + # adjust headers + headers = HEADERS.copy() + headers['Accept'] = '*/*' + headers['X-Requested-With'] = 'XMLHttpRequest' + headers['X-CSRFToken'] = self._parent.csrftoken + + args = '?controller_serial=' + self._controller.serial \ + + '&faucet_serial=' + self.id + + req = self._parent.client.get(STATUS_ENDPOINT + args, + headers=headers) + + # token probably expired, then try again + if req.status_code == 403: + self._parent.login() + self.update() + elif req.status_code == 200: + self._attributes = req.json() + self._controller.attributes = self._attributes + else: + req.raise_for_status() def _find_zone_by_id(self, zone_id): """Return zone by id.""" @@ -182,16 +207,18 @@ def _set_zone_name(self, zoneid, name): # zone starts with index 0 zoneid -= 1 data = { + 'select_controller': self._controller.index, + 'select_faucet': self._faucet.index, '_set_zone_name': 'Set Name', 'select_zone': str(zoneid), 'zone_name': name, } - self._controller.post(data) + self._parent.post(data) @property def name(self): """Return zone name.""" - return find_zone_name(self._parent.html['home'], self.id) + return find_zone_name(self._controller.home, self.id) @name.setter def name(self, value): @@ -298,6 +325,7 @@ def _set_auto_watering(self, zoneid, value): ddata[attr] = 'on' except KeyError: pass + self.submit_action(ddata) return True @@ -318,7 +346,8 @@ def is_watering(self): def lookup_attr(self, attr): """Returns rain_delay_mode attributes by zone index""" - return self._attributes['rain_delay_mode'][int(self.id) - 1][attr] + return self._faucet.attributes['rain_delay_mode'][int(self.id) - 1][ + attr] def _to_dict(self): """Method to build zone dict.""" @@ -343,13 +372,17 @@ def report(self): """Return status from zone.""" return self._to_dict() + def update(self): + """Request faucet to update""" + return self._faucet.update() + def preupdate(self, force_refresh=True): """Return a dict with all current options prior submitting request.""" ddata = MANUAL_OP_DATA.copy() # force update to make sure status is accurate if force_refresh: - self.update() + self._faucet.update() # select current controller and faucet ddata['select_controller'] = \ @@ -386,6 +419,30 @@ def preupdate(self, force_refresh=True): def submit_action(self, ddata): """Post data.""" - self._controller.post(ddata, + + controller_index = self._parent.controllers.index(self._controller) + faucet_index = self._controller.faucets.index(self._faucet) + + current_controller_index = find_selected_controller_or_faucet_index( + self._parent.html['home'], 'controller') + + current_faucet_index = find_selected_controller_or_faucet_index( + self._parent.html['home'], 'faucet') + + # This is an artifact of how the web-page we're impersonating works. + # The form submit will only apply actions to _selected_ controllers + # and faucets. So if the active controller and/or faucet on the page + # isn't the faucet we're trying to submit an action for we need to + # send the response twice. The first time we send it will switch us + # to the action + if current_controller_index != controller_index or \ + current_faucet_index != faucet_index: + self._parent.post(ddata, url=HOME_ENDPOINT, referer=HOME_ENDPOINT) + + response = self._parent.post(ddata, + url=HOME_ENDPOINT, + referer=HOME_ENDPOINT) + + self._parent.update_home(response.text) diff --git a/raincloudy/helpers.py b/raincloudy/helpers.py index 18b3346..729861c 100644 --- a/raincloudy/helpers.py +++ b/raincloudy/helpers.py @@ -7,24 +7,23 @@ def generate_soup_html(data): """Return an BeautifulSoup HTML parser document.""" try: - return BeautifulSoup(data, 'html.parser') + return BeautifulSoup(data, 'html5lib') except: raise TypeError( 'Invalid data passed to BeautifulSoup') -def serial_finder(data): +def faucet_serial_finder(data): """ - Find controller serial and faucet_serial from the setup page. + Find faucet_serial from the setup page. + + :param data: text to be parsed + :type data: BeautilSoup object + :return: an array of controller serials + :raises IndexError: if controller_serial was not found on the data + """ + + try: + + controllers_element = data.find(id="id_select_controller2").find_all( + 'option') + + controller_serials = [] + + for controller_element in controllers_element: + controller_serials.append( + controller_element.text.split('-')[1].strip()) + + return controller_serials + + except (AttributeError, IndexError, ValueError): + raise RainCloudyException( + 'Could not find any valid controller serials') + + +def find_controller_or_faucet_name(data, p_type, index=0): """ Find on the HTML document the controller name. @@ -67,6 +88,7 @@ def find_controller_or_faucet_name(data, p_type): name="select_controller" onchange="submit()" >