def build_created_payload(self, events): ''' Assemble an XML payload to send out for events marked "response required." events -- List of tuples with the following structure: (Event ID, Modification Number, Request ID, Opt, Status) Returns: An XML Tree in a string ''' # Setup the element makers oadr = ElementMaker(namespace=self.ns_map['oadr'], nsmap=self.ns_map) pyld = ElementMaker(namespace=self.ns_map['pyld'], nsmap=self.ns_map) ei = ElementMaker(namespace=self.ns_map['ei'], nsmap=self.ns_map) def responses(events): for e_id, mod_num, requestID, opt, status in events: yield ei.eventResponse( ei.responseCode(str(status)), pyld.requestID(requestID), ei.qualifiedEventID(ei.eventID(e_id), ei.modificationNumber(str(mod_num))), ei.optType(opt)) payload = oadr.oadrCreatedEvent( pyld.eiCreatedEvent( ei.eiResponse(ei.responseCode('200'), pyld.requestID()), ei.eventResponses(*list(responses(events))), ei.venID(self.ven_id))) logger.debug("Created payload:\n%s", etree.tostring(payload, pretty_print=True)) return payload
def send_reply(self, payload, uri): ''' Send a reply back to the VTN. payload -- An lxml.etree.ElementTree object containing an OpenADR 2.0 payload uri -- The URI (of the VTN) where the response should be sent ''' resp = requests.post(uri, cert=self.ven_certs, verify=self.vtn_ca_certs, data=etree.tostring(payload), timeout=REQUEST_TIMEOUT, auth=(self.__username, self.__password) if self.__username or self.__password else None) logger.debug("EiEvent response: %s", resp.status_code)
def _update_control(self, events): ''' Called by `control_event_loop()` to determine the current signal level. This also deletes any events from the database that have expired. events -- List of lxml.etree.ElementTree objects (with OpenADR 2.0 tags) ''' signal_level, event_id, remove_events = self._calculate_current_event_status( events) if remove_events: # remove any events that we've detected have ended or been cancelled. # TODO callback for expired events?? logger.debug("Removing completed or cancelled events: %s", remove_events) self.event_handler.remove_events(remove_events) if event_id: self.event_handler.update_active_status(event_id) return signal_level
def _control_event_loop(self): ''' This is the threading loop to perform control based on current oadr events Note the current implementation simply loops based on CONTROL_LOOP_INTERVAL except when an updated event is received by a VTN. ''' while not self._exit.is_set(): try: logger.debug("Updating control states...") events = self.event_handler.get_active_events() new_signal_level = self._update_control(events) logger.debug("Highest signal level is: %f", new_signal_level) changed = self._update_signal_level(new_signal_level) if changed: logger.debug("Updated current signal level!") except Exception as ex: logger.exception("Control loop error: %s", ex) self._control_loop_signal.wait(self.control_loop_interval) self._control_loop_signal.clear( ) # in case it was triggered by a poll update logger.info("Control loop exiting.")
def poll_vtn_loop(self): ''' The threading loop which polls the VTN on an interval ''' while not self._exit.is_set(): try: self.query_vtn() except urllib.error.HTTPError as ex: # 4xx or 5xx HTTP response: logger.warning("HTTP error: %s\n%s", ex, ex.read()) except urllib.error.URLError as ex: # network error. logger.debug("Network error: %s", ex) except Exception as ex: logger.exception("Error in OADR2 poll thread: %s", ex) self._exit.wait( uniform(self.vtn_poll_interval * (1 - POLLING_JITTER), self.vtn_poll_interval * (1 + POLLING_JITTER))) logger.info("+++++++++++++++ OADR2 polling thread has exited.")
def build_error_response(self, request_id, code, description=None): ''' Assemble the XML for an error response payload. request_id -- Request ID of offending payload code -- The HTTP Error Code Status we want to use description -- An extra note on what was not acceptable Returns: An lxml.etree.Element object containing the payload ''' oadr = ElementMaker(namespace=self.ns_map['oadr'], nsmap=self.ns_map) pyld = ElementMaker(namespace=self.ns_map['pyld'], nsmap=self.ns_map) ei = ElementMaker(namespace=self.ns_map['ei'], nsmap=self.ns_map) payload = oadr.oadrCreatedEvent( pyld.eiCreatedEvent( ei.eiResponse(ei.responseCode(code), pyld.requestID(request_id)), ei.venID(self.ven_id))) logger.debug("Error payload:\n%s", etree.tostring(payload, pretty_print=True)) return payload
def query_vtn(self): ''' Query the VTN for an event. ''' if not self.vtn_base_uri: logger.warning("VTN base URI is invalid: %s", self.vtn_base_uri) return event_uri = self.vtn_base_uri + 'EiEvent' payload = self.event_handler.build_request_payload() logger.debug( f'New polling request to {event_uri}:\n' f'{etree.tostring(payload, pretty_print=True).decode("utf-8")}') try: resp = requests.post(event_uri, cert=self.ven_certs, verify=self.vtn_ca_certs, data=etree.tostring(payload), auth=(self.__username, self.__password) if self.__username or self.__password else None) except Exception as ex: logger.warning(f"Connection failed: {ex}") return reply = None try: payload = etree.fromstring(resp.content) logger.debug( f'Got Payload:\n' f'{etree.tostring(payload, pretty_print=True).decode("utf-8")}' ) reply = self.event_handler.handle_payload(payload) except Exception as ex: logger.warning(f"Connection failed: error parsing payload\n" f"{ex}: {resp.content}") # If we have a generated reply: if reply is not None: logger.debug( f'Reply to {event_uri}:\n' f'{etree.tostring(reply, pretty_print=True).decode("utf-8")}') # tell the control loop that events may have updated # (note `self.event_controller` is defined in base.BaseHandler) self.event_controller.events_updated() self.send_reply(reply, event_uri) # And send the response
def default_signal_callback(self, old_level, new_level): ''' The default callback just logs a message. ''' logger.debug(f"Signal level changed from {old_level} to {new_level}")
def _calculate_current_event_status(self, events: List[EventSchema]): ''' returns a 3-tuple of (current_signal_level, current_event_id, remove_events=[]) ''' highest_signal_val = 0 current_event = None remove_events = [] # to collect expired events now = datetime.utcnow() for evt in events: try: if evt.status is None: logger.debug(f"Ignoring event {evt.id} - no valid status") continue if evt.status.lower( ) == "cancelled" and datetime.utcnow() > evt.end: logger.debug( f"Event {evt.id}({evt.mod_number}) has been cancelled") remove_events.append(evt.id) continue if not evt.signals: logger.debug(f"Ignoring event {evt.id} - no valid signals") continue current_interval = evt.get_current_interval(now=now) if current_interval is None: if evt.end < now: logger.debug( f"Event {evt.id}({evt.mod_number}) has ended") remove_events.append(evt.id) continue elif evt.start > now: logger.debug( f"Event {evt.id}({evt.mod_number}) has not started yet." ) continue else: logger.warning( f"Error getting current interval for event {evt.id}({evt.mod_number}):" f"Signals: {evt.signals}") continue if evt.test_event: logger.debug(f"Ignoring event {evt.id} - test event") continue logger.debug( f'Control loop: Evt ID: {evt.id}({evt.mod_number}); ' f'Interval: {current_interval.index}; Current Signal: {current_interval.level}' ) if current_interval.level > highest_signal_val or not current_event: if not current_event or evt.priority > current_event.priority: highest_signal_val = current_interval.level current_event = evt except Exception as ex: logger.exception(f"Error parsing event: {evt.id}: {ex}") return highest_signal_val, current_event.id if current_event else None, remove_events
def handle_payload(self, payload): ''' Handle a payload. Puts Events into the handler's event list. payload -- An lxml.etree.Element object of oadr:oadrDistributeEvent as root node Returns: An lxml.etree.Element object; which should be used as a response payload ''' reply_events = [] all_events = [] requestID = payload.findtext('pyld:requestID', namespaces=self.ns_map) vtnID = payload.findtext('ei:vtnID', namespaces=self.ns_map) # If we got a payload from an VTN that is not in our list, # send it a 400 message and return if self.vtn_ids and (vtnID not in self.vtn_ids): logger.warning("Unexpected VTN ID: %s, expected one of %r", vtnID, self.vtn_ids) return self.build_error_response(requestID, '400', 'Unknown vtnID: %s' % vtnID) # Loop through all of the oadr:oadrEvent 's in the payload for evt in payload.iterfind('oadr:oadrEvent', namespaces=self.ns_map): response_required = evt.findtext("oadr:oadrResponseRequired", namespaces=self.ns_map) evt = evt.find('ei:eiEvent', namespaces=self.ns_map) # go to nested eiEvent new_event = EventSchema.from_xml(evt) current_signal_val = get_current_signal_value(evt, self.ns_map) logger.debug( f'------ EVENT ID: {new_event.id}({new_event.mod_number}); ' f'Status: {new_event.status}; Current Signal: {current_signal_val}' ) all_events.append(new_event.id) old_event = self.db.get_event(new_event.id) # For the events we need to reply to, make our "opts," and check the status of the event # By default, we optIn and have an "OK," status (200) opt = 'optIn' status = '200' if old_event and (old_event.mod_number > new_event.mod_number): logger.warning( f"Got a smaller modification number " f"({new_event.mod_number} < {old_event.mod_number}) for event {new_event.id}" ) status = '403' opt = 'optOut' if not self.check_target_info(new_event): logger.info( f"Opting out of event {new_event.id} - no target match") status = '403' opt = 'optOut' if new_event.id in self.optouts: logger.info( f"Opting out of event {new_event.id} - user opted out") status = '200' opt = 'optOut' if not new_event.signals: logger.info( f"Opting out of event {new_event.id} - no simple signal") opt = 'optOut' status = '403' if self.market_contexts and (new_event.market_context not in self.market_contexts): logger.info( f"Opting out of event {new_event.id}:" f"market context {new_event.market_context} does not match" ) opt = 'optOut' status = '405' if response_required == 'always': reply_events.append((new_event.id, new_event.mod_number, requestID, opt, status)) # We have a new event or an updated old one # if (old_event is None) or (e_mod_num > old_mod_num): if opt == "optIn": if old_event and (old_event.mod_number < new_event.mod_number): # Add/update the event to our list # updated_events[e_id] = evt if new_event.status == "cancelled": if new_event.status != old_event.status: new_event.cancel(random_end=True) else: new_event.cancel() self.db.update_event(new_event) if not old_event: if new_event.status == "cancelled": new_event.cancel() self.db.add_event(new_event) # Find implicitly cancelled events and get rid of them for evt in self.get_active_events(): if evt.id not in all_events: logger.debug(f'Mark event {evt.id} as cancelled') evt.cancel() self.db.update_event(evt) # If we have any in the reply_events list, build some payloads logger.debug("Replying for events %r", reply_events) reply = None if reply_events: reply = self.build_created_payload(reply_events) return reply