Ejemplo n.º 1
0
    def startup(self, sender, **kwargs):
        """
        Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later
        :param sender: caller
        :param kwargs: optional arguments
        :return:
        """
        self._vip_socket = self.core.socket

        #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with
        # external platforms.
        if self._setup_mode:
            if self._my_web_address is None:
                _log.error(
                    "Web bind address is needed in multiplatform setup mode")
                return
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(
                        filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json format: {0}".format(exc))
                #Delete the existing store.
                if self._ext_addresses_store:
                    self._ext_addresses_store.clear()
                    self._ext_addresses_store.async_sync()
            web_addresses = dict()
            #Read External web addresses file
            try:
                web_addresses = self._read_platform_address_file()
                try:
                    web_addresses.remove(self._my_web_address)
                except ValueError:
                    _log.debug(
                        "My web address is not in the external bind web adress list"
                    )

                op = b'web-addresses'
                self._send_to_router(op, web_addresses)
            except IOError as exc:
                _log.error("Error in reading file: {}".format(exc))
                return
            sec = random.random() * self.r + 10
            delay = utils.get_aware_utc_now() + timedelta(seconds=sec)
            grnlt = self.core.schedule(delay, self._key_collection,
                                       web_addresses)
        else:
            #Use the existing store for platform discovery information
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(
                        filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json file format: {0}".format(exc))
                for name, discovery_info in self._ext_addresses_store.items():
                    op = b'normalmode_platform_connection'
                    self._send_to_router(op, discovery_info)
Ejemplo n.º 2
0
    def configure(self, config_dict, registry_config_str):
        """
        Interface configuration callback
        :param config_dict: Driver configuration dictionary
        :param registry_config_str: Driver registry configuration dictionary
        """
        self.config_dict.update(config_dict)
        self.api_key = self.config_dict.get("API_KEY")
        self.ecobee_id = self.config_dict.get('DEVICE_ID')
        if not isinstance(self.ecobee_id, int):
            try:
                self.ecobee_id = int(self.ecobee_id)
            except ValueError:
                raise ValueError(
                    f"Ecobee driver requires Ecobee device identifier as int, got: {self.ecobee_id}"
                )
        self.cache = PersistentDict("ecobee_" + str(self.ecobee_id) + ".json",
                                    format='json')
        self.auth_config_path = AUTH_CONFIG_PATH.format(self.ecobee_id)
        self.parse_config(registry_config_str)

        # Fetch any stored configuration values to reuse
        self.authorization_stage = "UNAUTHORIZED"
        stored_auth_config = self.get_auth_config_from_store()
        # Do some minimal checks on auth
        if stored_auth_config:
            if stored_auth_config.get("AUTH_CODE"):
                self.authorization_code = stored_auth_config.get("AUTH_CODE")
                self.authorization_stage = "REQUEST_TOKENS"
                if stored_auth_config.get(
                        "ACCESS_TOKEN") and stored_auth_config.get(
                            "REFRESH_TOKEN"):
                    self.access_token = stored_auth_config.get("ACCESS_TOKEN")
                    self.refresh_token = stored_auth_config.get(
                        "REFRESH_TOKEN")
                    try:
                        self.get_thermostat_data()
                        self.authorization_stage = "AUTHORIZED"
                    except HTTPError:
                        _log.warning(
                            "Ecobee request response contained HTTP Error, authorization code may be expired. "
                            "Requesting new authorization code from Ecobee api"
                        )
                        self.authorization_stage = "UNAUTHORIZED"
        if self.authorization_stage != "AUTHORIZED":
            # if this fails, our attempt to obtain new auth code and tokens was unsuccessful and the driver is in an
            # error state
            self.update_authorization()
            self.get_thermostat_data()

        if not self.poll_greenlet_thermostats:
            self.poll_greenlet_thermostats = self.core.periodic(
                180, self.get_thermostat_data)
        _log.debug("Ecobee configuration complete.")
Ejemplo n.º 3
0
    def startup(self, sender, **kwargs):
        """
        Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later
        :param sender: caller
        :param kwargs: optional arguments
        :return:
        """
        self._vip_socket = self.core.socket

        #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with
        # external platforms.
        if self._setup_mode:
            if self._my_web_address is None:
                _log.error("Web bind address is needed in multiplatform setup mode")
                return
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json format: {0}".format(exc))
                #Delete the existing store.
                if self._ext_addresses_store:
                    self._ext_addresses_store.clear()
                    self._ext_addresses_store.async_sync()
            web_addresses = dict()
            #Read External web addresses file
            try:
                web_addresses = self._read_platform_address_file()
                try:
                    web_addresses.remove(self._my_web_address)
                except ValueError:
                    _log.debug("My web address is not in the external bind web adress list")

                op = b'web-addresses'
                self._send_to_router(op, web_addresses)
            except IOError as exc:
                _log.error("Error in reading file: {}".format(exc))
                return
            sec = random.random() * self.r + 10
            delay = utils.get_aware_utc_now() + timedelta(seconds=sec)
            grnlt = self.core.schedule(delay, self._key_collection, web_addresses)
        else:
            #Use the existing store for platform discovery information
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json file format: {0}".format(exc))
                for name, discovery_info in self._ext_addresses_store.items():
                    op = b'normalmode_platform_connection'
                    self._send_to_router(op, discovery_info)
Ejemplo n.º 4
0
    def _setup(self, sender, **kwargs):
        _log.info("Initializing configuration store service.")

        try:
            os.makedirs(self.store_path)
        except OSError as e:
            if e.errno != errno.EEXIST:
                _log.critical(
                    "Failed to create configuration store directory: " +
                    str(e))
                raise
            else:
                _log.debug("Configuration directory already exists.")

        config_store_iter = glob.iglob(
            os.path.join(self.store_path, "*" + store_ext))

        for store_path in config_store_iter:
            root, ext = os.path.splitext(store_path)
            agent_identity = os.path.basename(root)
            _log.debug("Processing store for agent {}".format(agent_identity))
            store = PersistentDict(filename=store_path,
                                   flag='c',
                                   format='json')
            parsed_configs, name_map = process_store(agent_identity, store)
            self.store[agent_identity] = {
                "configs": parsed_configs,
                "store": store,
                "name_map": name_map,
                "lock": Semaphore()
            }
Ejemplo n.º 5
0
    def get_configs(self):
        """Called by an Agent at startup to trigger initial configuration state push."""
        identity = bytes(self.vip.rpc.context.vip_message.peer)

        #We need to create store and lock if it doesn't exist in case someone tries to add
        # a configuration while we are sending the initial state.
        agent_store = self.store.get(identity)


        if agent_store is None:
            # Initialize a new store.
            store_path = os.path.join(self.store_path, identity + store_ext)
            store = PersistentDict(filename=store_path, flag='c', format='json')
            agent_store = {"configs": {}, "store": store, "name_map": {}, "lock": Semaphore()}
            self.store[identity] = agent_store

        agent_configs = agent_store["configs"]
        agent_disk_store = agent_store["store"]
        agent_store_lock = agent_store["lock"]

        with agent_store_lock:
            try:
                self.vip.rpc.call(identity, "config.initial_update", agent_configs).get(timeout=10.0)
            except errors.Unreachable:
                _log.debug("Agent {} not currently running. Configuration update not sent.".format(identity))
            except RemoteError as e:
                _log.error("Agent {} failure when performing initial update: {}".format(identity, e))
            except MethodNotFound as e:
                _log.error(
                    "Agent {} failure when performing initial update: {}".format(identity, e))

        # If the store is empty (and nothing jumped in and added to it while we were informing the agent)
        # then remove it from the global store.
        if not agent_disk_store:
            self.store.pop(identity, None)
Ejemplo n.º 6
0
    def _add_config_to_store(self, identity, config_name, raw, parsed,
                             config_type, trigger_callback=False,
                             send_update=True):
        """Adds a processed configuration to the store."""
        agent_store = self.store.get(identity)

        action = "UPDATE"

        if agent_store is None:
            #Initialize a new store.
            store_path = os.path.join(self.store_path, identity+ store_ext)
            store = PersistentDict(filename=store_path, flag='c', format='json')
            agent_store = {"configs": {}, "store": store, "name_map": {}, "lock": Semaphore()}
            self.store[identity] = agent_store

        agent_configs = agent_store["configs"]
        agent_disk_store = agent_store["store"]
        agent_store_lock = agent_store["lock"]
        agent_name_map = agent_store["name_map"]

        config_name = strip_config_name(config_name)
        config_name_lower = config_name.lower()

        if config_name_lower not in agent_name_map:
            action = "NEW"

        if check_for_recursion(config_name, parsed, agent_configs):
            raise ValueError("Recursive configuration references detected.")

        if config_name_lower in agent_name_map:
            old_config_name = agent_name_map[config_name_lower]
            del agent_configs[old_config_name]

        agent_configs[config_name] = parsed
        agent_name_map[config_name_lower] = config_name

        agent_disk_store[config_name] = {"type": config_type,
                                         "modified": format_timestamp(get_aware_utc_now()),
                                         "data": raw}

        agent_disk_store.async_sync()

        _log.debug("Agent {} config {} stored.".format(identity, config_name))

        if send_update:
            with agent_store_lock:
                try:
                    self.vip.rpc.call(identity, "config.update", action, config_name, contents=parsed, trigger_callback=trigger_callback).get(timeout=10.0)
                except errors.Unreachable:
                    _log.debug("Agent {} not currently running. Configuration update not sent.".format(identity))
                except RemoteError as e:
                    _log.error("Agent {} failure when adding/updating configuration {}: {}".format(identity, config_name, e))
                except MethodNotFound as e:
                    _log.error(
                        "Agent {} failure when adding/updating configuration {}: {}".format(identity, config_name, e))
Ejemplo n.º 7
0
 def reload_userdict(self):
     webuserpath = os.path.join(get_home(), 'web-users.json')
     self._userdict = PersistentDict(webuserpath)
Ejemplo n.º 8
0
class AuthenticateEndpoints(object):

    def __init__(self, ssl_private_key):

        self._ssl_private_key = ssl_private_key
        self._userdict = None
        self.reload_userdict()
        self._observer = Observer()
        self._observer.schedule(
            FileReloader("web-users.json", self.reload_userdict),
            get_home()
        )
        self._observer.start()

    def reload_userdict(self):
        webuserpath = os.path.join(get_home(), 'web-users.json')
        self._userdict = PersistentDict(webuserpath)

    def get_routes(self):
        """
        Returns a list of tuples with the routes for authentication.

        Tuple should have the following:

            - regular expression for calling the endpoint
            - 'callable' keyword specifying that a method is being specified
            - the method that should be used to call when the regular expression matches

        code:

            return [
                (re.compile('^/csr/request_new$'), 'callable', self._csr_request_new)
            ]

        :return:
        """
        return [
            (re.compile('^/authenticate'), 'callable', self.get_auth_token)
        ]

    def get_auth_token(self, env, data):
        """
        Creates an authentication token to be returned to the caller.  The
        response will be a text/plain encoded user

        :param env:
        :param data:
        :return:
        """
        if env.get('REQUEST_METHOD') != 'POST':
            _log.warning("Authentication must use POST request.")
            return Response('', status='401 Unauthorized')

        assert len(self._userdict) > 0, "No users in user dictionary, set the master password first!"

        if not isinstance(data, dict):
            _log.debug("data is not a dict, decoding")
            decoded = dict((k, v if len(v) > 1 else v[0])
                           for k, v in urlparse.parse_qs(data).iteritems())

            username = decoded.get('username')
            password = decoded.get('password')

        else:
            username = data.get('username')
            password = data.get('password')

        _log.debug("Username is: {}".format(username))

        error = ""
        if username is None:
            error += "Invalid username passed"
        if not password:
            error += "Invalid password passed"

        if error:
            _log.error("Invalid parameters passed: {}".format(error))
            return Response(error, status='401')

        user = self.__get_user(username, password)
        if user is None:
            _log.error("No matching user for passed username: {}".format(username))
            return Response('', status='401')

        encoded = jwt.encode(user, self._ssl_private_key, algorithm='RS256').encode('utf-8')

        return Response(encoded, '200 OK', content_type='text/plain')

    def __get_user(self, username, password):
        """
        Retrieve user from the user store based upon username/password

        The hashed_password will not be returned with the value in the user
        object.

        If there is not a username/password that match return None.

        :param username:
        :param password:
        :return:
        """
        user = self._userdict.get(username)
        if user is not None:
            hashed_pass = user.get('hashed_password')
            if hashed_pass and argon2.verify(password, hashed_pass):
                usr_cpy = user.copy()
                del usr_cpy['hashed_password']
                return usr_cpy
        return None
Ejemplo n.º 9
0
class AdminEndpoints(object):
    def __init__(self, rmq_mgmt, ssl_public_key):

        self._rmq_mgmt = rmq_mgmt
        self._ssl_public_key = ssl_public_key
        self._userdict = None
        self.reload_userdict()
        self._observer = Observer()
        self._observer.schedule(
            FileReloader("web-users.json", self.reload_userdict), get_home())
        self._observer.start()
        self._certs = Certs()

    def reload_userdict(self):
        webuserpath = os.path.join(get_home(), 'web-users.json')
        self._userdict = PersistentDict(webuserpath)

    def get_routes(self):
        """
        Returns a list of tuples with the routes for the adminstration endpoints
        available in it.

        :return:
        """
        return [(re.compile('^/admin.*'), 'callable', self.admin)]

    def admin(self, env, data):
        if len(self._userdict) == 0:
            if env.get('REQUEST_METHOD') == 'POST':
                decoded = dict((k, v if len(v) > 1 else v[0])
                               for k, v in urlparse.parse_qs(data).iteritems())
                username = decoded.get('username')
                pass1 = decoded.get('password1')
                pass2 = decoded.get('password2')
                if pass1 == pass2 and pass1 is not None:
                    _log.debug("Setting master password")
                    self.add_user(username, pass1, groups=['admin'])
                    return Response('',
                                    status='302',
                                    headers={'Location': '/admin/login.html'})

            template = template_env(env).get_template('first.html')
            return Response(template.render())

        if 'login.html' in env.get('PATH_INFO') or '/admin/' == env.get(
                'PATH_INFO'):
            template = template_env(env).get_template('login.html')
            return Response(template.render())

        return self.verify_and_dispatch(env, data)

    def verify_and_dispatch(self, env, data):
        """ Verify that the user is an admin and dispatch

        :param env: web environment
        :param data: data associated with a web form or json/xml request data
        :return: Response object.
        """
        from volttron.platform.web import get_user_claims, NotAuthorized
        try:
            claims = get_user_claims(env)
        except NotAuthorized:
            _log.error("Unauthorized user attempted to connect to {}".format(
                env.get('PATH_INFO')))
            return Response('<h1>Unauthorized User</h1>',
                            status="401 Unauthorized")

        # Make sure we have only admins for viewing this.
        if 'admin' not in claims.get('groups'):
            return Response('<h1>Unauthorized User</h1>',
                            status="401 Unauthorized")

        # Make sure we have only admins for viewing this.
        if 'admin' not in claims.get('groups'):
            return Response('<h1>Unauthorized User</h1>',
                            status="401 Unauthorized")

        path_info = env.get('PATH_INFO')
        if path_info.startswith('/admin/api/'):
            return self.__api_endpoint(path_info[len('/admin/api/'):], data)

        if path_info.endswith('html'):
            page = path_info.split('/')[-1]
            try:
                template = template_env(env).get_template(page)
            except TemplateNotFound:
                return Response("<h1>404 Not Found</h1>",
                                status="404 Not Found")

            if page == 'list_certs.html':
                html = template.render(
                    certs=self._certs.get_all_cert_subjects())
            elif page == 'pending_csrs.html':
                html = template.render(
                    csrs=self._certs.get_pending_csr_requests())
            else:
                # A template with no params.
                html = template.render()

            return Response(html)

        template = template_env(env).get_template('index.html')
        resp = template.render()
        return Response(resp)

    def __api_endpoint(self, endpoint, data):
        _log.debug("Doing admin endpoint {}".format(endpoint))
        if endpoint == 'certs':
            response = self.__cert_list_api()
        elif endpoint == 'pending_csrs':
            response = self.__pending_csrs_api()
        elif endpoint.startswith('approve_csr/'):
            response = self.__approve_csr_api(endpoint.split('/')[1])
        elif endpoint.startswith('deny_csr/'):
            response = self.__deny_csr_api(endpoint.split('/')[1])
        elif endpoint.startswith('delete_csr/'):
            response = self.__delete_csr_api(endpoint.split('/')[1])
        else:
            response = Response(
                '{"status": "Unknown endpoint {}"}'.format(endpoint),
                content_type="application/json")
        return response

    def __approve_csr_api(self, common_name):
        try:
            _log.debug("Creating cert and permissions for user: {}".format(
                common_name))
            self._certs.approve_csr(common_name)
            permissions = self._rmq_mgmt.get_default_permissions(common_name)
            self._rmq_mgmt.create_user_with_permissions(
                common_name, permissions, True)
            data = dict(status=self._certs.get_csr_status(common_name),
                        cert=self._certs.get_cert_from_csr(common_name))
        except ValueError as e:
            data = dict(status="ERROR", message=e.message)

        return Response(json.dumps(data), content_type="application/json")

    def __deny_csr_api(self, common_name):
        try:
            self._certs.deny_csr(common_name)
            data = dict(status="DENIED",
                        message="The administrator has denied the request")
        except ValueError as e:
            data = dict(status="ERROR", message=e.message)

        return Response(json.dumps(data), content_type="application/json")

    def __delete_csr_api(self, common_name):
        try:
            self._certs.delete_csr(common_name)
            data = dict(status="DELETED",
                        message="The administrator has denied the request")
        except ValueError as e:
            data = dict(status="ERROR", message=e.message)

        return Response(json.dumps(data), content_type="application/json")

    def __pending_csrs_api(self):
        csrs = [c for c in self._certs.get_pending_csr_requests()]
        return Response(json.dumps(csrs), content_type="application/json")

    def __cert_list_api(self):

        subjects = [
            dict(common_name=x.common_name)
            for x in self._certs.get_all_cert_subjects()
        ]
        return Response(json.dumps(subjects), content_type="application/json")

    def add_user(self, username, unencrypted_pw, groups=[], overwrite=False):
        if self._userdict.get(username):
            raise ValueError("Already exists!")
        if groups is None:
            groups = []
        hashed_pass = argon2.hash(unencrypted_pw)
        self._userdict[username] = dict(hashed_password=hashed_pass,
                                        groups=groups)
        self._userdict.async_sync()
Ejemplo n.º 10
0
class KeyDiscoveryAgent(Agent):
    """
    Class to get server key, instance name and vip address of external/remote platforms
    """
    def __init__(self, address, serverkey, identity, external_address_config,
                 setup_mode, bind_web_address, *args, **kwargs):
        super(KeyDiscoveryAgent, self).__init__(identity, address, **kwargs)
        self._external_address_file = external_address_config
        self._ext_addresses = dict()
        self._grnlets = dict()
        self._vip_socket = None
        self._my_web_address = bind_web_address
        self.r = random.random()
        self._setup_mode = setup_mode
        if self._setup_mode:
            _log.debug("RUNNING IN MULTI-PLATFORM SETUP MODE")

        self._store_path = os.path.join(os.environ['VOLTTRON_HOME'],
                                        'external_platform_discovery.json')
        self._ext_addresses_store = dict()
        self._ext_addresses_store_lock = Semaphore()

    @Core.receiver('onstart')
    def startup(self, sender, **kwargs):
        """
        Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later
        :param sender: caller
        :param kwargs: optional arguments
        :return:
        """
        self._vip_socket = self.core.socket

        #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with
        # external platforms.
        if self._setup_mode:
            if self._my_web_address is None:
                _log.error(
                    "Web bind address is needed in multiplatform setup mode")
                return
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(
                        filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json format: {0}".format(exc))
                #Delete the existing store.
                if self._ext_addresses_store:
                    self._ext_addresses_store.clear()
                    self._ext_addresses_store.async_sync()
            web_addresses = dict()
            #Read External web addresses file
            try:
                web_addresses = self._read_platform_address_file()
                try:
                    web_addresses.remove(self._my_web_address)
                except ValueError:
                    _log.debug(
                        "My web address is not in the external bind web adress list"
                    )

                op = b'web-addresses'
                self._send_to_router(op, web_addresses)
            except IOError as exc:
                _log.error("Error in reading file: {}".format(exc))
                return
            sec = random.random() * self.r + 10
            delay = utils.get_aware_utc_now() + timedelta(seconds=sec)
            grnlt = self.core.schedule(delay, self._key_collection,
                                       web_addresses)
        else:
            #Use the existing store for platform discovery information
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(
                        filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json file format: {0}".format(exc))
                for name, discovery_info in self._ext_addresses_store.items():
                    op = b'normalmode_platform_connection'
                    self._send_to_router(op, discovery_info)

    def _key_collection(self, web_addresses):
        """
        Collect platform discovery information (server key, instance name, vip-address) for all platforms.
        :param web_addresses: List of web addresses to get discovery info
        :return:
        """
        for web_address in web_addresses:
            if web_address not in self._my_web_address:
                self._collect_key(web_address)

    def _collect_key(self, web_address):
        """
        Try to get (server key, instance name, vip-address) of remote instance and send it to RoutingService
        to connect to the remote instance. If unsuccessful, try again later.
        :param name: instance name
        :param web_address: web address of remote instance
        :return:
        """
        platform_info = dict()

        try:
            platform_info = self._get_platform_discovery(web_address)
            with self._ext_addresses_store_lock:
                _log.debug("Platform discovery info: {}".format(platform_info))
                name = platform_info['instance-name']
                self._ext_addresses_store[name] = platform_info
                self._ext_addresses_store.async_sync()
        except KeyError as exc:
            _log.error(
                "Discovery info does not contain instance name {}".format(exc))
        except DiscoveryError:
            # If discovery error, try again later
            sec = random.random() * self.r + 30
            delay = utils.get_aware_utc_now() + timedelta(seconds=sec)
            grnlet = self.core.schedule(delay, self._collect_key, web_address)
        except ConnectionError as e:
            _log.error("HTTP connection error {}".format(e))

        #If platform discovery is successful, send the info to RoutingService
        #to establish connection with remote platform.
        if platform_info:
            op = b'setupmode_platform_connection'
            connection_settings = dict(platform_info)
            connection_settings['web-address'] = web_address
            self._send_to_router(op, connection_settings)

    def _send_to_router(self, op, platform_info):
        """
        Send the platform discovery stats to the router to establish new connection
        :param platform_info: platform discovery stats
        :return:
        """
        address = jsonapi.dumps(platform_info)

        frames = [op, address]
        try:
            self._vip_socket.send_vip(b'', 'routing_table', frames, copy=False)
        except ZMQError as ex:
            # Try sending later
            _log.error(
                "ZMQ error while sending external platform info to router: {}".
                format(ex))

    def _read_platform_address_file(self):
        """
        Read the external addresses file
        :return:
        """

        try:
            with open(self._external_address_file) as fil:
                # Use gevent FileObject to avoid blocking the thread
                data = FileObject(fil, close=False).read()
                web_addresses = jsonapi.loads(data) if data else {}
                return web_addresses
        except IOError as e:
            _log.error("Error opening file {}".format(
                self._external_address_file))
            raise
        except Exception:
            _log.exception('error loading %s', self._external_address_file)
            raise

    def _get_platform_discovery(self, web_address):
        """
        Use http discovery call to get serverkey, instance name and vip-address of remote instance
        :param web_address: web address of remote instance
        :return:
        """

        r = {}
        try:
            parsed = urlparse(web_address)

            assert parsed.scheme
            assert not parsed.path

            real_url = urljoin(web_address, "/discovery/")
            req = grequests.get(real_url)
            responses = grequests.map([req])
            responses[0].raise_for_status()
            r = responses[0].json()
            return r
        except requests.exceptions.HTTPError:
            raise DiscoveryError(
                "Invalid discovery response from {}".format(real_url))
        except requests.exceptions.Timeout:
            raise DiscoveryError("Timeout error from {}".format(real_url))
        except AttributeError as e:
            raise DiscoveryError(
                "Invalid web_address passed {}".format(web_address))
        except (ConnectionError, NewConnectionError) as e:
            raise DiscoveryError(
                "Connection to {} not available".format(real_url))
        except Exception as e:
            raise DiscoveryError("Unknown Exception: {}".format(str(e)))
Ejemplo n.º 11
0
class AuthenticateEndpoints(object):
    def __init__(self,
                 tls_private_key=None,
                 tls_public_key=None,
                 web_secret_key=None):

        self.refresh_token_timeout = 240  # minutes before token expires. TODO: Should this be a setting somewhere?
        self.access_token_timeout = 15  # minutes before token expires. TODO: Should this be a setting somewhere?
        self._tls_private_key = tls_private_key
        self._tls_public_key = tls_public_key
        self._web_secret_key = web_secret_key
        if self._tls_private_key is None and self._web_secret_key is None:
            raise ValueError(
                "Must have either ssl_private_key or web_secret_key specified!"
            )
        if self._tls_private_key is not None and self._web_secret_key is not None:
            raise ValueError(
                "Must use either ssl_private_key or web_secret_key not both!")
        self._userdict = None
        self.reload_userdict()
        self._observer = Observer()
        self._observer.schedule(
            VolttronHomeFileReloader("web-users.json", self.reload_userdict),
            get_home())
        self._observer.start()

    def reload_userdict(self):
        webuserpath = os.path.join(get_home(), 'web-users.json')
        self._userdict = PersistentDict(webuserpath)

    def get_routes(self):
        """
        Returns a list of tuples with the routes for authentication.

        Tuple should have the following:

            - regular expression for calling the endpoint
            - 'callable' keyword specifying that a method is being specified
            - the method that should be used to call when the regular expression matches

        code:

            return [
                (re.compile('^/csr/request_new$'), 'callable', self._csr_request_new)
            ]

        :return:
        """
        return [(re.compile('^/authenticate'), 'callable',
                 self.handle_authenticate)]

    def handle_authenticate(self, env, data):
        """
        Callback for /authenticate endpoint.

        Routes request based on HTTP method and returns a text/plain encoded token or error.

        :param env:
        :param data:
        :return: Response
        """
        method = env.get('REQUEST_METHOD')
        if method == 'POST':
            response = self.get_auth_tokens(env, data)
        elif method == 'PUT':
            response = self.renew_auth_token(env, data)
        elif method == 'DELETE':
            response = self.revoke_auth_token(env, data)
        else:
            error = f"/authenticate endpoint accepts only POST, PUT, or DELETE methods. Received: {method}"
            _log.warning(error)
            return Response(error,
                            status='405 Method Not Allowed',
                            content_type='text/plain')
        return response

    def get_auth_tokens(self, env, data):
        """
        Creates an authentication refresh and acccss tokens to be returned to the caller.  The
        response will be a text/plain encoded user.  Data should contain:
        {
            "username": "******",
            "password": "******"
        }
        :param env:
        :param data:
        :return:
        """

        assert len(
            self._userdict
        ) > 0, "No users in user dictionary, set the administrator password first!"

        if not isinstance(data, dict):
            _log.debug("data is not a dict, decoding")
            decoded = dict((k, v if len(v) > 1 else v[0])
                           for k, v in parse_qs(data).items())

            username = decoded.get('username')
            password = decoded.get('password')

        else:
            username = data.get('username')
            password = data.get('password')

        _log.debug("Username is: {}".format(username))

        error = ""
        if username is None:
            error += "Invalid username passed"
        if not password:
            error += "Invalid password passed"

        if error:
            _log.error("Invalid parameters passed: {}".format(error))
            return Response(error, status='401')

        user = self.__get_user(username, password)
        if user is None:
            _log.error(
                "No matching user for passed username: {}".format(username))
            return Response('', status='401')
        access_token, refresh_token = self._get_tokens(user)
        response = Response(json.dumps({
            "refresh_token": refresh_token,
            "access_token": access_token
        }),
                            content_type="application/json")
        return response

    def _get_tokens(self, claims):
        now = datetime.utcnow()
        claims['iat'] = now
        claims['nbf'] = now
        claims['exp'] = now + timedelta(minutes=self.access_token_timeout)
        claims['grant_type'] = 'access_token'
        algorithm = 'RS256' if self._tls_private_key is not None else 'HS256'
        encode_key = self._tls_private_key if algorithm == 'RS256' else self._web_secret_key
        access_token = jwt.encode(claims, encode_key, algorithm=algorithm)
        claims['exp'] = now + timedelta(minutes=self.refresh_token_timeout)
        claims['grant_type'] = 'refresh_token'
        refresh_token = jwt.encode(claims, encode_key, algorithm=algorithm)
        return access_token.decode('utf-8'), refresh_token.decode('utf8')

    def renew_auth_token(self, env, data):
        """
        Creates a new authentication access token to be returned to the caller.  The
        response will be a text/plain encoded user.  Request should contain:
            • Content Type: application/json
            • Authorization: BEARER <jwt_refresh_token>
            • Body (optional):
                {
                "current_access_token": "<jwt_access_token>"
                }

        :param env:
        :param data:
        :return:
        """
        current_access_token = data.get('current_access_token')
        from volttron.platform.web import get_bearer, get_user_claim_from_bearer, NotAuthorized
        try:
            current_refresh_token = get_bearer(env)
            claims = get_user_claim_from_bearer(
                current_refresh_token,
                web_secret_key=self._web_secret_key,
                tls_public_key=self._tls_public_key)
        except NotAuthorized:
            _log.error("Unauthorized user attempted to connect to {}".format(
                env.get('PATH_INFO')))
            return Response('Unauthorized User', status="401 Unauthorized")

        except jwt.ExpiredSignatureError:
            _log.error(
                "User attempted to connect to {} with an expired signature".
                format(env.get('PATH_INFO')))
            return Response('Unauthorized User', status="401 Unauthorized")

        if claims.get('grant_type') != 'refresh_token' or not claims.get(
                'groups'):
            return Response('Invalid refresh token.',
                            status="401 Unauthorized")
        else:
            # TODO: Consider blacklisting and reissuing refresh tokens also when used.
            new_access_token, _ = self._get_tokens(claims)
            if current_access_token:
                pass  # TODO: keep current subscriptions? blacklist old token?
            return Response(json.dumps({"access_token": new_access_token}),
                            content_type="application/json")

    def revoke_auth_token(self, env, data):
        # TODO: Blacklist old token? Immediately close websockets?
        return Response('DELETE /authenticate is not yet implemented',
                        status='501 Not Implemented',
                        content_type='text/plain')

    def __get_user(self, username, password):
        """
        Retrieve user from the user store based upon username/password

        The hashed_password will not be returned with the value in the user
        object.

        If there is not a username/password that match return None.

        :param username:
        :param password:
        :return:
        """
        user = self._userdict.get(username)
        if user is not None:
            hashed_pass = user.get('hashed_password')
            if hashed_pass and argon2.verify(password, hashed_pass):
                usr_cpy = user.copy()
                del usr_cpy['hashed_password']
                return usr_cpy
        return None
Ejemplo n.º 12
0
class KeyDiscoveryAgent(Agent):
    """
    Class to get server key, instance name and vip address of external/remote platforms
    """

    def __init__(self, address, serverkey, identity, external_address_config,
                 setup_mode, bind_web_address, *args, **kwargs):
        super(KeyDiscoveryAgent, self).__init__(identity, address, **kwargs)
        self._external_address_file = external_address_config
        self._ext_addresses = dict()
        self._grnlets = dict()
        self._vip_socket = None
        self._my_web_address = bind_web_address
        self.r = random.random()
        self._setup_mode = setup_mode
        if self._setup_mode:
            _log.debug("RUNNING IN MULTI-PLATFORM SETUP MODE")

        self._store_path = os.path.join(os.environ['VOLTTRON_HOME'],
                                        'external_platform_discovery.json')
        self._ext_addresses_store = dict()
        self._ext_addresses_store_lock = Semaphore()

    @Core.receiver('onstart')
    def startup(self, sender, **kwargs):
        """
        Try to get platform discovery info of all the remote platforms. If unsuccessful, setup events to try again later
        :param sender: caller
        :param kwargs: optional arguments
        :return:
        """
        self._vip_socket = self.core.socket

        #If in setup mode, read the external_addresses.json to get web addresses to set up authorized connection with
        # external platforms.
        if self._setup_mode:
            if self._my_web_address is None:
                _log.error("Web bind address is needed in multiplatform setup mode")
                return
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json format: {0}".format(exc))
                #Delete the existing store.
                if self._ext_addresses_store:
                    self._ext_addresses_store.clear()
                    self._ext_addresses_store.async_sync()
            web_addresses = dict()
            #Read External web addresses file
            try:
                web_addresses = self._read_platform_address_file()
                try:
                    web_addresses.remove(self._my_web_address)
                except ValueError:
                    _log.debug("My web address is not in the external bind web adress list")

                op = b'web-addresses'
                self._send_to_router(op, web_addresses)
            except IOError as exc:
                _log.error("Error in reading file: {}".format(exc))
                return
            sec = random.random() * self.r + 10
            delay = utils.get_aware_utc_now() + timedelta(seconds=sec)
            grnlt = self.core.schedule(delay, self._key_collection, web_addresses)
        else:
            #Use the existing store for platform discovery information
            with self._ext_addresses_store_lock:
                try:
                    self._ext_addresses_store = PersistentDict(filename=self._store_path, flag='c', format='json')
                except ValueError as exc:
                    _log.error("Error in json file format: {0}".format(exc))
                for name, discovery_info in self._ext_addresses_store.items():
                    op = b'normalmode_platform_connection'
                    self._send_to_router(op, discovery_info)

    def _key_collection(self, web_addresses):
        """
        Collect platform discovery information (server key, instance name, vip-address) for all platforms.
        :param web_addresses: List of web addresses to get discovery info
        :return:
        """
        for web_address in web_addresses:
            if web_address not in self._my_web_address:
                self._collect_key(web_address)

    def _collect_key(self, web_address):
        """
        Try to get (server key, instance name, vip-address) of remote instance and send it to RoutingService
        to connect to the remote instance. If unsuccessful, try again later.
        :param name: instance name
        :param web_address: web address of remote instance
        :return:
        """
        platform_info = dict()

        try:
            platform_info = self._get_platform_discovery(web_address)
            with self._ext_addresses_store_lock:
                _log.debug("Platform discovery info: {}".format(platform_info))
                name = platform_info['instance-name']
                self._ext_addresses_store[name] = platform_info
                self._ext_addresses_store.async_sync()
        except KeyError as exc:
            _log.error("Discovery info does not contain instance name {}".format(exc))
        except DiscoveryError:
            # If discovery error, try again later
            sec = random.random() * self.r + 30
            delay = utils.get_aware_utc_now() + timedelta(seconds=sec)
            grnlet = self.core.schedule(delay, self._collect_key, web_address)
        except ConnectionError as e:
            _log.error("HTTP connection error {}".format(e))

        #If platform discovery is successful, send the info to RoutingService
        #to establish connection with remote platform.
        if platform_info:
            op = b'setupmode_platform_connection'
            connection_settings = dict(platform_info)
            connection_settings['web-address'] = web_address
            self._send_to_router(op, connection_settings)

    def _send_to_router(self, op, platform_info):
        """
        Send the platform discovery stats to the router to establish new connection
        :param platform_info: platform discovery stats
        :return:
        """
        address = jsonapi.dumps(platform_info)

        frames = [op, address]
        try:
            self._vip_socket.send_vip(b'', 'routing_table', frames, copy=False)
        except ZMQError as ex:
            # Try sending later
            _log.error("ZMQ error while sending external platform info to router: {}".format(ex))

    def _read_platform_address_file(self):
        """
        Read the external addresses file
        :return:
        """

        try:
            with open(self._external_address_file) as fil:
                # Use gevent FileObject to avoid blocking the thread
                data = FileObject(fil, close=False).read()
                web_addresses = jsonapi.loads(data) if data else {}
                return web_addresses
        except IOError as e:
            _log.error("Error opening file {}".format(self._external_address_file))
            raise
        except Exception:
            _log.exception('error loading %s', self._external_address_file)
            raise

    def _get_platform_discovery(self, web_address):
        """
        Use http discovery call to get serverkey, instance name and vip-address of remote instance
        :param web_address: web address of remote instance
        :return:
        """

        r = {}
        try:
            parsed = urlparse(web_address)

            assert parsed.scheme
            assert not parsed.path

            real_url = urljoin(web_address, "/discovery/")
            req = grequests.get(real_url)
            responses = grequests.map([req])
            responses[0].raise_for_status()
            r = responses[0].json()
            return r
        except requests.exceptions.HTTPError:
            raise DiscoveryError(
                    "Invalid discovery response from {}".format(real_url)
                )
        except requests.exceptions.Timeout:
            raise DiscoveryError(
                    "Timeout error from {}".format(real_url)
                )
        except AttributeError as e:
            raise DiscoveryError(
                "Invalid web_address passed {}"
                    .format(web_address)
            )
        except (ConnectionError, NewConnectionError) as e:
            raise DiscoveryError(
                "Connection to {} not available".format(real_url)
            )
        except Exception as e:
            raise DiscoveryError(
                "Unknown Exception".format(e.message)
            )
Ejemplo n.º 13
0
class Interface(BasicRevert, BaseInterface):
    """
    Interface implementation for wrapping around the Ecobee thermostat API
    """

    def __init__(self, **kwargs):
        super(Interface, self).__init__(**kwargs)
        # Configuration value defaults
        self.config_dict = {}
        self.api_key = ""
        self.ecobee_id = -1
        # which agent is being used as the caching agent
        self.cache = None
        # Authorization tokens
        self.refresh_token = None
        self.access_token = None
        self.authorization_code = None
        self.authorization_stage = "UNAUTHORIZED"
        # Config path for storing Ecobee auth information in config store, not user facing
        self.auth_config_path = ""
        # Un-initialized data response from Driver Cache agent
        self.thermostat_data = None
        # Un-initialized greenlet for querying cache agent
        self.poll_greenlet_thermostats = None

    def configure(self, config_dict, registry_config_str):
        """
        Interface configuration callback
        :param config_dict: Driver configuration dictionary
        :param registry_config_str: Driver registry configuration dictionary
        """
        self.config_dict.update(config_dict)
        self.api_key = self.config_dict.get("API_KEY")
        self.ecobee_id = self.config_dict.get('DEVICE_ID')
        if not isinstance(self.ecobee_id, int):
            try:
                self.ecobee_id = int(self.ecobee_id)
            except ValueError:
                raise ValueError(
                    f"Ecobee driver requires Ecobee device identifier as int, got: {self.ecobee_id}")
        self.cache = PersistentDict("ecobee_" + str(self.ecobee_id) + ".json", format='json')
        self.auth_config_path = AUTH_CONFIG_PATH.format(self.ecobee_id)
        self.parse_config(registry_config_str)

        # Fetch any stored configuration values to reuse
        self.authorization_stage = "UNAUTHORIZED"
        stored_auth_config = self.get_auth_config_from_store()
        # Do some minimal checks on auth
        if stored_auth_config:
            if stored_auth_config.get("AUTH_CODE"):
                self.authorization_code = stored_auth_config.get("AUTH_CODE")
                self.authorization_stage = "REQUEST_TOKENS"
                if stored_auth_config.get("ACCESS_TOKEN") and stored_auth_config.get("REFRESH_TOKEN"):
                    self.access_token = stored_auth_config.get("ACCESS_TOKEN")
                    self.refresh_token = stored_auth_config.get("REFRESH_TOKEN")
                    try:
                        self.get_thermostat_data()
                        self.authorization_stage = "AUTHORIZED"
                    except HTTPError:
                        _log.warning("Ecobee request response contained HTTP Error, authorization code may be expired. "
                                     "Requesting new authorization code from Ecobee api")
                        self.authorization_stage = "UNAUTHORIZED"
        if self.authorization_stage != "AUTHORIZED":
            # if this fails, our attempt to obtain new auth code and tokens was unsuccessful and the driver is in an
            # error state
            self.update_authorization()
            self.get_thermostat_data()

        if not self.poll_greenlet_thermostats:
            self.poll_greenlet_thermostats = self.core.periodic(180, self.get_thermostat_data)
        _log.debug("Ecobee configuration complete.")

    def parse_config(self, config_dict):
        """
        Parse driver registry configuration and create device registers
        :param config_dict: Registry configuration in dictionary representation
        """
        first_hold = True
        _log.debug("Parsing Ecobee registry configuration.")
        if not config_dict:
            return
        # Parse configuration file for registry parameters, then add new register to the interface
        for index, regDef in enumerate(config_dict):
            point_name = regDef.get("Point Name")
            if not point_name:
                _log.warning(f"Registry configuration contained entry without a point name: {regDef}")
                continue
            read_only = regDef.get('Writable', "").lower() != 'true'
            readable = regDef.get('Readable', "").lower() == 'true'
            volttron_point_name = regDef.get('Volttron Point Name')
            if not volttron_point_name:
                volttron_point_name = point_name
            description = regDef.get('Notes', '')
            units = regDef.get('Units', None)
            default_value = regDef.get("Default Value", "").strip()
            # Truncate empty string or 0 values to None
            if not default_value:
                default_value = None
            type_name = regDef.get("Type", 'string')
            # Create an instance of the register class based on the register type
            if type_name.lower().startswith("setting"):
                register = Setting(self.ecobee_id, read_only, readable, volttron_point_name, point_name, units,
                                   description=description)
            elif type_name.lower() == "hold":
                if first_hold:
                    _log.warning("Hold registers' set_point requires dictionary value, for best practices, visit "
                                 "https://www.ecobee.com/home/developer/api/documentation/v1/functions/SetHold.shtml")
                    first_hold = False
                register = Hold(self.ecobee_id, read_only, readable, volttron_point_name, point_name, units,
                                description=description)
            else:
                _log.warning(f"Unsupported register type {type_name} in Ecobee registry configuration")
                continue
            if default_value is not None:
                self.set_default(point_name, default_value)
            # Add the register instance to our list of registers
            self.insert_register(register)

        # Each Ecobee thermostat has one Status reporting "register", one programs register and one vacation "register

        # Status is a static point which reports a list of running HVAC systems reporting to the thermostat
        status_register = Status(self.ecobee_id)
        self.insert_register(status_register)

        # Vacation can be used to manage all Vacation programs for the thermostat
        vacation_register = Vacation(self.ecobee_id)
        self.insert_register(vacation_register)

        # Add a register for listing events and resuming programs
        program_register = Program(self.ecobee_id)
        self.insert_register(program_register)

    def update_authorization(self):
        if self.authorization_stage == "UNAUTHORIZED":
            self.authorize_application()
        if self.authorization_stage == "REQUEST_TOKENS":
            self.request_tokens()
        if self.authorization_stage == "REFRESH_TOKENS":
            self.refresh_tokens()
        self.update_auth_config()

    def authorize_application(self):
        auth_url = "https://api.ecobee.com/authorize"
        params = {
            "response_type": "ecobeePin",
            "client_id": self.api_key,
            "scope": "smartWrite"
        }
        try:
            response = make_ecobee_request("GET", auth_url, params=params)
        except (ConnectionError, NewConnectionError) as re:
            _log.error(re)
            _log.warning("Error connecting to Ecobee, Could not request pin.")
            return
        for auth_item in ['code', 'ecobeePin']:
            if auth_item not in response:
                raise RuntimeError(f"Ecobee authorization response was missing required item: {auth_item}, response "
                                   "contained {response}")
        self.authorization_code = response.get('code')
        pin = response.get('ecobeePin')
        _log.warning("***********************************************************")
        _log.warning(
            f'Please authorize your Ecobee developer app with PIN code {pin}.\nGo to '
            'https://www.ecobee.com/consumerportal /index.html, click My Apps, Add application, Enter Pin and click '
            'Authorize.')
        _log.warning("***********************************************************")
        self.authorization_stage = "REQUEST_TOKENS"
        gevent.sleep(60)

    def request_tokens(self):
        """
        Request up to date Auth tokens from Ecobee using API key and authorization code
        """
        # Generate auth request and extract returned value
        _log.debug("Requesting new auth tokens from Ecobee.")
        url = 'https://api.ecobee.com/token'
        params = {
            'grant_type': 'ecobeePin',
            'code': self.authorization_code,
            'client_id': self.api_key
        }
        response = make_ecobee_request("POST", url, data=params)
        for token in ["access_token", "refresh_token"]:
            if token not in response:
                raise RuntimeError(f"Request tokens response did  not contain {token}: {response}")
        self.access_token = response.get('access_token')
        self.refresh_token = response.get('refresh_token')
        self.authorization_stage = "AUTHORIZED"

    def refresh_tokens(self):
        """
        Refresh Ecobee API authentication tokens via API endpoint - asks Ecobee to reset tokens then updates config with
        new tokens from Ecobee
        """
        _log.info('Refreshing Ecobee auth tokens.')
        url = 'https://api.ecobee.com/token'
        params = {
            'grant_type': 'refresh_token',
            'refresh_token': self.refresh_token,
            'client_id': self.api_key
        }
        # Generate auth request and extract returned value
        response = make_ecobee_request("POST", url, data=params)
        for token in 'access_token', 'refresh_token':
            if token not in response:
                raise RuntimeError(f"Ecobee response did not contain token {token}:, response was {response}")
        self.access_token = response['access_token']
        self.refresh_token = response['refresh_token']
        self.authorization_stage = "AUTHORIZED"

    def update_auth_config(self):
        """
        Update the platform driver configuration for this device with new values from auth functions
        """
        auth_config = {"AUTH_CODE": self.authorization_code,
                       "ACCESS_TOKEN": self.access_token,
                       "REFRESH_TOKEN": self.refresh_token}
        _log.debug("Updating Ecobee auth configuration with new tokens.")
        self.vip.rpc.call(CONFIGURATION_STORE, "set_config", self.auth_config_path, auth_config, trigger_callback=False,
                          send_update=False).get(timeout=3)

    def get_auth_config_from_store(self):
        """
        :return: Fetch currently stored auth configuration info from config store, returns empty dict if none is
        present
        """
        configs = self.vip.rpc.call(CONFIGURATION_STORE, "manage_list_configs", PLATFORM_DRIVER).get(timeout=3)
        if self.auth_config_path in configs:
            return jsonapi.loads(self.vip.rpc.call(
                CONFIGURATION_STORE, "manage_get", PLATFORM_DRIVER, self.auth_config_path).get(timeout=3))
        else:
            _log.warning("No Ecobee auth file found in config store")
            return {}

    def get_thermostat_data(self, refresh=False):
        """
        Collects most up to date thermostat object data for the configured Ecobee thermostat ID
        :param refresh: whether or not to force obtaining new data from the remote Ecobee API
        """
        params = {
            "json": jsonapi.dumps({
                "selection": {
                    "selectionType": "thermostats",
                    "selectionMatch": self.ecobee_id,
                    "includeSensors": True,
                    "includeRuntime": True,
                    "includeEvents": True,
                    "includeEquipmentStatus": True,
                    "includeSettings": True
                }
            })
        }
        headers = populate_thermostat_headers(self.access_token)
        self.thermostat_data = self.get_ecobee_data("GET", THERMOSTAT_URL, 180, refresh=refresh, headers=headers,
                                                    params=params)

    def get_ecobee_data(self, request_type, url, update_frequency, refresh=False, **kwargs):
        """
        Checks cache for up to date Ecobee data. If none is available for the URL, makes a request to remote Ecobee API.
        :param refresh: force Ecobee data to be obtained from the remote API rather than cache
        :param request_type: HTTP request type for request sent to remote
        :param url: URL of remote Ecobee API endpoint
        :param update_frequency: period for which cached data is considered up to date
        :param kwargs: HTTP request arguments
        :return: Up to date Ecobee data for URL
        """
        cache_data = self.get_data_cache(url, update_frequency)
        if refresh or not (isinstance(cache_data, dict) and len(cache_data)):
            try:
                response = self.get_data_remote(request_type, url, **kwargs)
            except HTTPError as he:
                self.store_remote_data(url, None)
                raise he
            self.store_remote_data(url, response)
            return response
        else:
            return cache_data

    def get_data_remote(self, request_type, url, **kwargs):
        """
        Make request to Ecobee remote API for "register" data, updating authorization tokens as necessary
        :param request_type: HTTP request type for making request
        :param url: URL corresponding to "register" data
        :param kwargs: HTTP request arguments
        :return: remote API response body
        """
        try:
            response = make_ecobee_request(request_type, url, **kwargs)
            self.authorization_stage = "AUTHORIZED"
            return response
        except HTTPError:
            _log.warning(f"HTTPError occurred while fetching data from Ecobee API url: {url}")
            # The request to the remote failed, try refreshing the tokens and trying again using the refresh token
            self.authorization_stage = "REFRESH_TOKENS"
            try:
                self.update_authorization()
            except HTTPError:
                _log.warning("HTTPError occurred while refreshing Ecobee API tokens")
                # if tokens could not be refreshed, try obtaining new tokens using the existing authorization key
                self.authorization_stage = "REQUEST_TOKENS"
                # if we fail to request new tokens, the authorization key is no longer valid, the driver will need
                # to be restarted
                self.update_authorization()
            response = make_ecobee_request(request_type, url, **kwargs)
            self.authorization_stage = "AUTHORIZED"
            return response

    def get_data_cache(self, url, update_frequency):
        """
        Fetches data from cache dict if it is up to date
        :param url: URL to use to use as lookup value in cache dict
        :param update_frequency: duration in seconds for which data in cache is considered up to date
        :return: Data stored in cache if up to date, otherwise None
        """
        url_data = self.cache.get(url)
        if url_data:
            timestamp = utils.parse_timestamp_string(url_data.get("request_timestamp"))
            if (datetime.datetime.now() - timestamp).total_seconds() < update_frequency:
                return url_data.get("request_response")
            else:
                _log.info("Cached Ecobee data out of date.")
        return None

    def store_remote_data(self, url, response):
        """
        Store response body with a timestamp for a given URL
        :param url: url to use to use as lookup value in cache dict
        :param response: request response body to store in cache
        """
        timestamp = utils.format_timestamp(datetime.datetime.now())
        self.cache.update({
            url: {
                "request_timestamp": timestamp,
                "request_response": response
            }
        })
        _log.info(f"Last Ecobee update occurred at {timestamp}")
        self.cache.sync()

    def get_point(self, point_name, **kwargs):
        """
        Return a point's most recent stored value from remote API
        :param point_name: The name of the point corresponding to a register to get the state of
        :return: register's most recent state from remote API response
        """
        # Find the named register and get its current state from the periodic Ecobee API data
        register = self.get_register_by_name(point_name)
        try:
            return register.get_state(self.thermostat_data)
        except (ValueError, KeyError, TypeError):
            self.get_thermostat_data(refresh=True)
            return register.get_state(self.thermostat_data)

    def _set_point(self, point_name, value, **kwargs):
        """
        Send request to remote API to update a point based on provided parameters
        :param point_name: Name of the point to update
        :param value: Intended update value
        :return: Updated state from remote API
        """
        # Find the correct register by name, set its state, then fetch the new state based on the register's type
        register = self.get_register_by_name(point_name)
        if register.read_only:
            raise IOError(f"Trying to write to a point configured read only: {point_name}")
        try:
            if isinstance(register, Setting) or isinstance(register, Hold):
                register.set_state(value, self.access_token)
            elif isinstance(register, Vacation) or isinstance(register, Program):
                register.set_state(value, self.access_token, **kwargs)
        except HTTPError:
            self.refresh_tokens()
            if isinstance(register, Setting) or isinstance(register, Hold):
                register.set_state(value, self.access_token)
            elif isinstance(register, Vacation) or isinstance(register, Program):
                register.set_state(value, self.access_token, **kwargs)
        self.get_thermostat_data(refresh=True)
        if register.readable:
            return register.get_state(self.thermostat_data)

    def _scrape_all(self):
        """
        Fetch point data for all configured points
        :return: dictionary of most recent data for all points configured for the driver
        """
        result = {}
        byte_registers = self.get_registers_by_type("byte", True) + self.get_registers_by_type("byte", False)
        registers = [register for register in byte_registers if register.readable]
        refresh = True
        # Add data for all holds and settings to our results
        for register in registers:
            try:
                register_data = register.get_state(self.thermostat_data)
                if isinstance(register_data, dict):
                    result.update(register_data)
                else:
                    result[register.point_name] = register_data
            except ValueError:
                if refresh is True:
                    # refresh data, but don't create a non-deterministic loop of refreshes
                    self.get_thermostat_data(refresh=refresh)
                    refresh = False
                    register_data = register.get_state(self.thermostat_data)
                    if isinstance(register_data, dict):
                        result.update(register_data)
                    else:
                        result[register.point_name] = register_data
        return result