Browse Source

Initial minimal version of pyopenadr

Stan Janssen 4 years ago
commit
19cc7cbd15
42 changed files with 1384 additions and 0 deletions
  1. 2 0
      MANIFEST.in
  2. 115 0
      README.md
  3. 0 0
      openadr.md
  4. 2 0
      pyopenadr/__init__.py
  5. 176 0
      pyopenadr/client.py
  6. 5 0
      pyopenadr/config.py
  7. 12 0
      pyopenadr/datatypes.py
  8. 33 0
      pyopenadr/errors.py
  9. 33 0
      pyopenadr/server.py
  10. 26 0
      pyopenadr/service/__init__.py
  11. 36 0
      pyopenadr/service/event_service.py
  12. 6 0
      pyopenadr/service/opt_service.py
  13. 94 0
      pyopenadr/service/poll_service.py
  14. 84 0
      pyopenadr/service/registration_service.py
  15. 76 0
      pyopenadr/service/report_service.py
  16. 51 0
      pyopenadr/service/vtn_service.py
  17. 0 0
      pyopenadr/templates/oadrCancelOpt.xml
  18. 0 0
      pyopenadr/templates/oadrCancelPartyRegistration.xml
  19. 0 0
      pyopenadr/templates/oadrCancelReport.xml
  20. 0 0
      pyopenadr/templates/oadrCanceledOpt.xml
  21. 0 0
      pyopenadr/templates/oadrCanceledPartyRegistration.xml
  22. 0 0
      pyopenadr/templates/oadrCanceledReport.xml
  23. 13 0
      pyopenadr/templates/oadrCreateOpt.xml
  24. 18 0
      pyopenadr/templates/oadrCreatePartyRegistration.xml
  25. 0 0
      pyopenadr/templates/oadrCreateReport.xml
  26. 35 0
      pyopenadr/templates/oadrCreatedEvent.xml
  27. 0 0
      pyopenadr/templates/oadrCreatedOpt.xml
  28. 44 0
      pyopenadr/templates/oadrCreatedPartyRegistration.xml
  29. 0 0
      pyopenadr/templates/oadrCreatedReport.xml
  30. 17 0
      pyopenadr/templates/oadrDistributeEvent.xml
  31. 8 0
      pyopenadr/templates/oadrPoll.xml
  32. 8 0
      pyopenadr/templates/oadrQueryRegistration.xml
  33. 149 0
      pyopenadr/templates/oadrRegisterReport.xml
  34. 17 0
      pyopenadr/templates/oadrRegisteredReport.xml
  35. 11 0
      pyopenadr/templates/oadrRequestEvent.xml
  36. 0 0
      pyopenadr/templates/oadrRequestReport.xml
  37. 8 0
      pyopenadr/templates/oadrRequestReregistration.xml
  38. 17 0
      pyopenadr/templates/oadrResponse.xml
  39. 0 0
      pyopenadr/templates/oadrUpdateReport.xml
  40. 0 0
      pyopenadr/templates/oadrUpdatedReport.xml
  41. 280 0
      pyopenadr/utils.py
  42. 8 0
      setup.py

+ 2 - 0
MANIFEST.in

@@ -0,0 +1,2 @@
+include pyopenadr/templates/*.xml
+include pyopenadr/templates/parts/*.xml

+ 115 - 0
README.md

@@ -0,0 +1,115 @@
+# PyOpenADR
+
+PyOpenADR is a Python 3 module that provides a convenient interface to OpenADR
+systems. It contains an OpenADR Client that you can use to talk to other OpenADR
+systems, and it contains an OpenADR Server (VTN) with convenient integration
+possibilities.
+
+It's easy to hook up your own pieces of functionality while having as little to do with the OpenADR protocol and intricacies as possible. If you want, everything can be coroutine and event-based, and your coroutines will be called whenever something of interest happens.
+
+If you want to get up to speed on how the basic OpenADR communication flows happen, please read the [OpenADR Basics](#openadr-basics) section.
+
+## Client (VEN)
+
+You can use the OpenADR Client (Virtual End Node) to talk to OpenADR Virtual Top Nodes.
+
+### Manual Mode
+
+If you want to use the client in manual mode, you can instantiate the OpenADRClient with just a `ven_name` and a `vtn_url`, and then call the `create_party_registration()` once, and the `poll()` method repeatedly. The OpenADR client will keep track of the VTN-assigned `ven_id` and `registration_id`. The former is used for many messages, the latter is used if you wish to 'unregister' from the VTN.
+
+The `poll()` method will return a tuple of (message type, message payload) for you to interpret. The message payload is a dict that contains native Python types as much as possible.
+
+### Auto Mode
+
+The client can handle the automatic polling and call your own functions or coroutines whenever there is an event or report that your application needs to see or needs to respond to. If you want to use automatic polling, set `auto_polling` = `True`, and also implement the `on_event` and `on_report` handlers. All handlers can be implemented as regular methods or coroutines; the coroutines having the advantage of not blocking the rest of the client (polling for example).
+
+### Handling events
+
+To link your own event handler, populate the `on_event` method in the OpenADRClient. This method or coroutine will be once for each event that comes in, event if these events are supplied in a single message from the VTN. You can decide what your response to each event is, by supplying the contents of the `oadrCreatedEvent` message. Mostly, you will want to either Opt In or Opt Out of the event by returning the strings `"optIn"` or `"optOut"`. The client will perform the neccessary requests to the VTN.
+
+### Handling reports
+
+To link your own report handler, implement the `on_report` method in the OpenADRClient. Your method or coroutine is called once for each report, supplying the contents of the report to your function.
+
+### Supplying reports
+
+If you want to supply reports to the VTN on an automated basis, implement the `next_report` method in the OpenADRClient. Your method or coroutine should supply the contents of the `oadrCreateReport` message.
+
+
+
+## Server (VTN)
+
+The Virtual Top Node is the "server" that the VENs connect to. The pyopenadr server implements most of the behavior, but you have to connect it to the backend that provides the data.
+
+## Connecting to your data sources
+
+
+### Responding to queries
+
+*Methods dealing with events*
+
+* `on_created_event(payload)` method is called whenever the VEN sends an `oadrCreatedEvent` message, probably informing you of what they intend to do with the event you gave them.
+* `on_request_event(ven_id)`: this should return the next event (if any) that you have for the VEN. If you return `None`, a blank `oadrResponse` will be returned to the VEN.
+* `on_request_report(ven_id)`: this should return then next report (if any) that you have for the VEN. If you return None, a blank oadrResponse will be returned to the VEN.
+* `on_poll(ven_id)`: this should return the next message in line, which is usually either a new `oadrReport` or a `oadrDistributeEvent` message.
+
+*Methods dealing with reports*
+
+Please read the [OpenADR Basics: reporting](#reporting) section if you are unsure of the correct message flow involved in OpenADR Reporting.
+
+* `on_create_report(payload)` method is called whenever the VEN sends an `oadrCreateReport` message, requesting you to periodically generate reports for the VEN.
+* `on_register_report(payload)` method is called whenever the VEN sends an `oadrRegisterReport` message, probably to register their reporting capabilities with you.
+* `on_update_report(payload)` method is called whenever the VEN sends an `oadrUpdateReport`, probably including an actual report from the VEN.
+
+* `on_registered_report(payload)` method is called whenever the VEN confirms your communication of reporting capabilities.
+* `on_created_report(payload)` method is called whenever the VEN sends an `oadrCreatedReport` message, probably informing you that they will be preparing periodic reports for you.
+
+You have to supply your own classes for these. Each of the backends will be explained below.
+
+### Getting meta-information on connected VENs
+
+The Server can also call methods or coroutines if a VEN comes online, or when they go offline (having missed three polling intervals, which is configurable):
+
+* `on_ven_online(ven_id)`
+* `on_ven_offline(ven_id)`
+
+## OpenADR Basics
+
+OpenADR revolves around the VEN polling for messages from the VTN.
+
+### Registration
+
+
+
+### Reporting
+
+Reporting is probably the most complicated of interactions within OpenADR. It involves the following steps:
+
+1. Party A makes its reporting capabilities known to party B using a `oadrRegisterReport` message.
+2. Party B responds with an `oadrRegisteredReport` message, optionally including an `oadrReportRequest` section that tells party A which party B is interested in.
+3. Party A reponds with an `oadrCreatedReport` message telling party B that it will periodically generate the reports.
+
+This ceremony is performed once with the VTN as party A and once with the VEN as party A.
+
+The VEN party can collect the reports it requested from the VTN using either the `oadrPoll` or `oadrRequestReport` mechanisms. The VTN will respond with an `oadrUpdateReport` message containing the actual report. The VEN should then respond with a `oadrUpdatedReport` message.
+
+The VEN should actively supply the reports to the VTN using `oadrUpdateReport` messages, to which the VTN will respond with `oadrUpdatedReport` messages.
+
+### Events
+
+OpenADR has a pretty flexible event modeling, which can nessecarily make things quite complex. The mechanism is pretty simple, and follows the following logic:
+
+1. The VEN asks the VTN if there are any Events for the VEN. This can be done using the `oadrRequestEvent` or `oadrPoll` methods.
+2. The VTN will supply an Event to the VEN using an `oadrDistributeEvent` message.
+3. The VEN can decide whether to 'opt in' or 'opt out' of the event, which is included in the `oadrCreatedEvent` message.
+
+An OpenADR Event is built up of the following properties (more or less):
+
+* A specification of when this EVent is active, and what tolerances around the activation of the event are permissible (ActivePeriod)
+* A list of Targets to which the event applies. THis can be VENs, groups, assets, geographic areas, and more.
+* A list of Signals that have a name, a type and multiple Intervals that contain start dates and some payload value.
+
+
+
+
+### Polling

+ 0 - 0
openadr.md


+ 2 - 0
pyopenadr/__init__.py

@@ -0,0 +1,2 @@
+from .client import OpenADRClient
+from .server import OpenADRServer

+ 176 - 0
pyopenadr/client.py

@@ -0,0 +1,176 @@
+#!/Users/stan/Development/ElaadNL/pyopenadr/.python/bin/python3
+
+import xmltodict
+import random
+import requests
+from jinja2 import Environment, PackageLoader, select_autoescape
+from pyopenadr.utils import parse_message, create_message, new_request_id, peek
+from http import HTTPStatus
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+import asyncio
+from asyncio import iscoroutine
+
+class OpenADRClient:
+    def __init__(self, ven_name, vtn_url):
+        self.ven_name = ven_name
+        self.vtn_url = vtn_url
+        self.ven_id = None
+        self.poll_frequency = None
+
+    def run(self):
+        """
+        Run the client in full-auto mode.
+        """
+        if not hasattr(self, 'on_event') or not hasattr(self, 'on_report'):
+            raise NotImplementedError("You must implement both the on_event and and_report functions or coroutines.")
+
+        self.create_party_registration()
+
+        # Set up automatic polling
+        self.scheduler = AsyncIOScheduler()
+        if self.poll_frequency.total_seconds() < 60:
+            cron_second = f"*/{self.poll_frequency.seconds}"
+            cron_minute = "*"
+            cron_hour = "*"
+        elif self.poll_frequency.total_seconds() < 3600:
+            cron_second = "0"
+            cron_minute = f'*/{int(self.poll_frequency.total_seconds() / 60)}'
+            cron_hour = "*"
+        elif self.poll_frequency.total_seconds() < 86400:
+            cron_second = "0"
+            cron_minute = "0"
+            cron_hour = f'*/{int(self.poll_frequency.total_seconds() / 3600)}'
+        elif self.poll_frequency.total_seconds() > 86400:
+            print("Polling with intervals of more than 24 hours is not supported.")
+            return
+
+        self.scheduler.add_job(self._poll, trigger='cron', second=cron_second, minute=cron_minute, hour=cron_hour)
+        self.scheduler.start()
+
+
+    def query_registration(self):
+        """
+        Request information about the VTN.
+        """
+        request_id = new_request_id()
+        service = 'EiRegisterParty'
+        message = create_message('oadrQueryRegistration', request_id=request_id)
+        response_type, response_payload = self._perform_request(service, message)
+        return response_type, response_payload
+
+    def create_party_registration(self, http_pull_model=True, xml_signature=False,
+                                  report_only=False, profile_name='2.0b',
+                                  transport_name='simpleHttp', transport_address=None, ven_id=None):
+        request_id = new_request_id()
+        service = 'EiRegisterParty'
+        payload = {'ven_name': self.ven_name,
+                   'http_pull_model': http_pull_model,
+                   'xml_signature': xml_signature,
+                   'report_only': report_only,
+                   'profile_name': profile_name,
+                   'transport_name': transport_name,
+                   'transport_address': transport_address}
+        if ven_id:
+            payload['ven_id'] = ven_id
+        message = create_message('oadrCreatePartyRegistration', request_id=new_request_id(), **payload)
+        response_type, response_payload = self._perform_request(service, message)
+        self.ven_id = response_payload['ven_id']
+        self.poll_frequency = response_payload['requested_oadr_poll_freq']
+        print(f"VEN is now registered with ID {self.ven_id}")
+        print(f"The polling frequency is {self.poll_frequency}")
+        return response_type, response_payload
+
+    def cancel_party_registration(self):
+        raise NotImplementedError("Cancel Registration is not yet implemented")
+
+    def request_event(self, reply_limit=1):
+        """
+        Request the next Event from the VTN, if it has any.
+        """
+        payload = {'request_id': new_request_id(),
+                   'ven_id': self.ven_id,
+                   'reply_limit': reply_limit}
+        message = create_message('oadrRequestEvent', **payload)
+        service = 'EiEvent'
+        response_type, response_payload = self._perform_request(service, message)
+        return response_type, response_payload
+
+
+    def created_event(self, request_id, event_id, opt_type, modification_number=1):
+        """
+        Inform the VTN that we created an event.
+        """
+        service = 'EiEvent'
+        payload = {'ven_id': self.ven_id,
+                   'response': {'response_code': 200,
+                                'response_description': 'OK',
+                                'request_id': request_id},
+                   'event_responses': [{'response_code': 200,
+                                        'response_description': 'OK',
+                                        'request_id': request_id,
+                                        'qualified_event_id': {'event_id': event_id,
+                                                               'modification_number': modification_number},
+                                        'opt_type': opt_type}]}
+        message = create_message('oadrCreatedEvent', **payload)
+        response_type, response_payload = self._perform_request(service, message)
+        return response_type, response_payload
+
+    def register_report(self):
+        """
+        Tell the VTN about our reporting capabilities.
+        """
+        raise NotImplementedError("Reporting is not yet implemented")
+
+    def poll(self):
+        service = 'OadrPoll'
+        message = create_message('OadrPoll', ven_id=self.ven_id)
+        response_type, response_payload = self._perform_request(service, message)
+        return response_type, response_payload
+
+    def _perform_request(self, service, message):
+        print(f"Sending {message}")
+        url = f"{self.vtn_url}/{service}"
+        r = requests.post(url,
+                          data=message)
+        if r.status_code != HTTPStatus.OK:
+            raise Exception(f"Received non-OK status in request: {r.status_code}")
+        print(r.content.decode('utf-8'))
+        return parse_message(r.content)
+
+    async def _on_event(self, message):
+        print("ON_EVENT")
+        result = self.on_event(message)
+        if iscoroutine(result):
+            result = await result
+
+        print(f"Now responding with {result}")
+        request_id = message['request_id']
+        event_id = message['events'][0]['event_descriptor']['event_id']
+        self.created_event(request_id, event_id, result)
+        return
+
+    async def _on_report(self, message):
+        result = self.on_report(message)
+        if iscoroutine(result):
+            result = await result
+        return result
+
+    async def _poll(self):
+        response_type, response_payload = self.poll()
+        if response_type == 'oadrResponse':
+            print("No events or reports available")
+            return
+
+        if response_type == 'oadrRequestReregistration':
+            result = self.create_party_registration()
+
+        if response_type == 'oadrDistributeEvent':
+            result = await self._on_event(response_payload)
+
+        elif response_type == 'oadrUpdateReport':
+            result = await self._on_report(response_payload)
+
+        else:
+            print(f"No handler implemented for message type {response_type}, ignoring.")
+        await self._poll()
+

+ 5 - 0
pyopenadr/config.py

@@ -0,0 +1,5 @@
+from os.path import abspath, dirname, join
+
+VTN_ID = "elaadvtn"
+DATABASE = "openadr"
+TEMPLATE_DIR = join(abspath(dirname(__file__)), 'templates')

+ 12 - 0
pyopenadr/datatypes.py

@@ -0,0 +1,12 @@
+def event_descriptor(event_id, modification_number, modification_date_time, priority,
+                     market_context, created_date_time, event_status, test_event, vtn_comment):
+    data = {"event_id": event_id,
+            "modification_number": modification_number,
+            "modification_date_time": modification_date_time,
+            "priority": priority,
+            "market_context": market_context,
+            "created_date_time": created_date_time,
+            "event_status": event_status,
+            "test_event": test_event,
+            "vtn_comment": vtn_comment}
+    return {key: value for key, value in data.items() if value}

+ 33 - 0
pyopenadr/errors.py

@@ -0,0 +1,33 @@
+OUT_OF_SEQUENCE  = 450
+NOT_ALLOWED      = 451
+INVALID_ID       = 452
+NOT_RECOGNIZED   = 453
+INVALID_DATA     = 454
+COMPLIANCE_ERROR = 459
+SIGNAL_NOT_SUPPORTED = 460
+REPORT_NOT_SUPPORTED = 461
+TARGET_MISMATCH = 462
+NOT_REGISTERED_OR_AUTHORIZED = 463
+DEPLOYMENT_ERROR_OTHER = 469
+
+class OpenADRError(Exception):
+    status_codes = {450: "OUT_OF_SEQUENCE",
+                    451: "NOT_ALLOWED",
+                    452: "INVALID_ID",
+                    453: "NOT_RECOGNIZED",
+                    454: "INVALID_DATA",
+                    459: "COMPLIANCE_ERROR",
+                    460: "SIGNAL_NOT_SUPPORTED",
+                    461: "REPORT_NOT_SUPPORTED",
+                    462: "TARGET_MISMATCH",
+                    463: "NOT_REGISTERED_OR_AUTHORIZED",
+                    469: "DEPLOYMENT_ERROR_OTHER"}
+
+    def __init__(self, status, description):
+        assert status in self.status_codes, f"Invalid status code {status} while raising OpenADRError"
+        super().__init__()
+        self.status = status
+        self.description = description
+
+    def __str__(self):
+        return f'Error {self.status} {self.status_codes[self.status]}: {self.description}'

+ 33 - 0
pyopenadr/server.py

@@ -0,0 +1,33 @@
+#!/Users/stan/Development/ElaadNL/pyopenadr/.python/bin/python3
+# A simple Python OpenADR VTN Server.
+
+from pyopenadr.service import api
+from pyopenadr.service import PollService, EventService, PollService, RegistrationService, ReportService
+
+class OpenADRServer:
+    def __init__(self):
+        self.api = api
+        self.__setattr__ = self.add_handler
+
+    def add_handler(self, name, func):
+        """
+        Add a handler to the OpenADRServer.
+        """
+        map = {'on_created_event': EventService,
+               'on_request_event': EventService,
+
+               'on_create_report': ReportService,
+               'on_created_report': ReportService,
+               'on_request_report': ReportService,
+               'on_update_report': ReportService,
+
+               'on_poll': PollService,
+
+               'on_query_registration': RegistrationService,
+               'on_create_party_registration': RegistrationService,
+               'on_cancel_party_registration': RegistrationService}
+        if name in map:
+            setattr(map[name], name, staticmethod(func))
+        else:
+            raise NameError(f"Unknown handler {name}. Correct handler names are: {map.keys()}")
+

+ 26 - 0
pyopenadr/service/__init__.py

@@ -0,0 +1,26 @@
+import responder
+from .. import config
+from ..utils import datetimeformat, timedeltaformat, booleanformat
+
+api = responder.API(templates_dir=config.TEMPLATE_DIR)
+api.jinja_env.filters['datetimeformat'] = datetimeformat
+api.jinja_env.filters['timedeltaformat'] = timedeltaformat
+api.jinja_env.filters['booleanformat'] = booleanformat
+
+def handler(message_type):
+    """
+    Decorator to mark a method as the handler for a specific message type.
+    """
+    def _actual_decorator(decorated_function):
+        decorated_function.__message_type__ = message_type
+        return decorated_function
+    return _actual_decorator
+
+# The classes below all register to the api
+from .vtn_service import VTNService
+from .event_service import EventService
+from .poll_service import PollService
+from .registration_service import RegistrationService
+from .report_service import ReportService
+
+# from .opt_service import OptService

+ 36 - 0
pyopenadr/service/event_service.py

@@ -0,0 +1,36 @@
+from . import api, handler, VTNService
+from datetime import datetime, timedelta, timezone
+from asyncio import iscoroutine
+
+@api.route('/OpenADR2/Simple/2.0b/EiEvent')
+class EventService(VTNService):
+
+    @handler('oadrRequestEvent')
+    async def request_event(self, payload):
+        """
+        The VEN requests us to send any events we have.
+        """
+        # TODO: hook into some backend here to retrieve the appropriate events for this VEN.
+        try:
+            result = self.on_request_event(payload['ven_id'])
+            if iscoroutine(result):
+                result = await result
+        except OpenADRError as err:
+            response_type = 'oadrResponse'
+            response_payload = {'request_id': payload['request_id'],
+                                'response_code': err.status,
+                                'response_description': err.description,
+                                'ven_id': payload['ven_id']}
+            return response_type, response_payload
+        else:
+            return result
+
+    @handler('oadrCreatedEvent')
+    async def created_event(self, payload):
+        """
+        The VEN informs us that they created an EiEvent.
+        """
+        result = self.on_created_event(payload)
+        if iscoroutine(result):
+            result = await(result)
+        return result

+ 6 - 0
pyopenadr/service/opt_service.py

@@ -0,0 +1,6 @@
+from .. import config
+from . import api
+
+@api.route('/OpenADR2/Simple/2.0b/EiOpt')
+class OptService(VTNService):
+    pass

+ 94 - 0
pyopenadr/service/poll_service.py

@@ -0,0 +1,94 @@
+from . import api, handler, VTNService
+from asyncio import iscoroutine
+
+# ╔══════════════════════════════════════════════════════════════════════════╗
+# ║                             POLLING SERVICE                              ║
+# ╚══════════════════════════════════════════════════════════════════════════╝
+#
+# oadrPoll is a service independent polling mechanism used by VENs in a PULL
+# model to request pending service operations from the VTN. The VEN queries
+# the poll endpoint and the VTN re- sponds with the same message that it would
+# have “pushed” had it been a PUSH VEN. If there are multiple messages pending
+# a “push,” the VEN will continue to query the poll endpoint until there are
+# no new messages and the VTN responds with an eiResponse payload.
+#
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ The VEN can poll for any messages that we have for them. If we have no   │
+# │ (more) messages, we send a generic oadrResponse:                         │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────────────────oadrPoll()───────────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─oadrResponse() ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ If we have an Event, we expect the following:                            │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────────────────oadrPoll()───────────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ oadrCreateEvent() ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │   │───────────────────────oadrCreatedEvent()───────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─oadrResponse() ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ For Reports:                                                             │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────────────────oadrPoll()───────────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─oadrCreateReport() ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │   │───────────────────────oadrCreatedReport()──────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─oadrResponse() ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ If re-registration is neccessary:                                        │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────────────────oadrPoll()───────────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─oadrRequestReregistration()─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │   │─────────────────────────oadrResponse()─────────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ HTTP 200─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# │   │──────────────────oadrCreatePartyRegistration()─────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─oadrRequestReregistration()─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+
+@api.route('/OpenADR2/Simple/2.0b/OadrPoll')
+class PollService(VTNService):
+
+    @handler('oadrPoll')
+    async def poll(self, payload):
+        """
+        Retrieve the messages that we have for this VEN in order.
+
+        The backend get_next_message
+        """
+        result = self.on_poll(ven_id=payload['ven_id'])
+        if iscoroutine(result):
+            result = await result
+        return result

+ 84 - 0
pyopenadr/service/registration_service.py

@@ -0,0 +1,84 @@
+from . import api, handler, VTNService
+from datetime import timedelta
+from asyncio import iscoroutine
+
+# ╔══════════════════════════════════════════════════════════════════════════╗
+# ║                           REGISTRATION SERVICE                           ║
+# ╚══════════════════════════════════════════════════════════════════════════╝
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ The VEN can explore some information about the VTN:                      │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │─────────────────────oadrQueryRegistration()────────────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ oadrCreatedPartyRegistration(VTN Info)─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ The VEN can then go on and register with the VTN                         │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────oadrCreatePartyRegistration(VEN Info)────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ oadrCreatedPartyRegistration(VTN Info, registrationID)─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ The VEN can also choose to cancel the registration                       │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │──────────oadrCancelPartyRegistration(registrationID)───────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─oadrCanceledPartyRegistration()─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+
+@api.route('/OpenADR2/Simple/2.0b/EiRegisterParty')
+class RegistrationService(VTNService):
+
+    @handler('oadrQueryRegistration')
+    async def query_registration(self, payload):
+        """
+        Return the profiles we support.
+        """
+        request_id = payload['request_id']
+        response_type = "oadrCreatedPartyRegistration"
+        response_payload = {"request_id": request_id,
+                            "vtn_id": "elaadvtn",
+                            "profiles": [{"profile_name": "2.0a",
+                                          "transports": [{"transport_name": "simpleHttp"},
+                                                          {"transport_name": "xmpp"}]},
+                                         {"profile_name": "2.0b",
+                                          "transports": [{"transport_name": "simpleHttp"},
+                                                          {"transport_name": "xmpp"}]}],
+                            "requested_oadr_poll_freq": timedelta(seconds=10)}
+        return response_type, response_payload
+
+    @handler('oadrCreatePartyRegistration')
+    async def create_party_registration(self, payload):
+        """
+        Handle the registration of a VEN party.
+        """
+        result = self.on_create_party_registration(payload)
+        if iscoroutine(result):
+            result = await result
+        return result
+
+    @handler('oadrCancelPartyRegistration')
+    async def cancel_party_registration(self, payload):
+        """
+        Cancel the registration of a party.
+        """
+        result = self.on_cancel_party_registration(payload)
+        if iscoroutine(result):
+            result = await result
+        return result

+ 76 - 0
pyopenadr/service/report_service.py

@@ -0,0 +1,76 @@
+from . import api, handler, VTNService
+
+# ╔══════════════════════════════════════════════════════════════════════════╗
+# ║                              REPORT SERVICE                              ║
+# ╚══════════════════════════════════════════════════════════════════════════╝
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ The VEN can register its reporting capabilities.                         │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────oadrRegisterReport(METADATA Report)──────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─oadrRegisteredReport(optional oadrReportRequest) ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │   │                                                                 │    │
+# │   │─────────────oadrCreatedReport(if report requested)─────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ oadrResponse()─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+# ┌──────────────────────────────────────────────────────────────────────────┐
+# │ A report can also be canceled                                            │
+# │                                                                          │
+# │ ┌────┐                                                            ┌────┐ │
+# │ │VEN │                                                            │VTN │ │
+# │ └─┬──┘                                                            └─┬──┘ │
+# │   │───────────────oadrRegisterReport(METADATA Report)──────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─oadrRegisteredReport(optional oadrReportRequest) ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │   │                                                                 │    │
+# │   │─────────────oadrCreatedReport(if report requested)─────────────▶│    │
+# │   │                                                                 │    │
+# │   │◀ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ oadrResponse()─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│    │
+# │   │                                                                 │    │
+# │                                                                          │
+# └──────────────────────────────────────────────────────────────────────────┘
+
+@api.route('/OpenADR2/Simple/2.0b/EiReport')
+class ReportService(VTNService):
+
+    @handler('oadrRegisterReport')
+    async def register_report(self, payload):
+        """
+        Register a reporting type.
+        """
+        print("Called Registered Report")
+        response_type = 'oadrRegisteredReport'
+        response_payload = {"response": {"response_code": 200,
+                                         "response_description": "OK",
+                                         "request_id": payload['request_id']},
+                            "ven_id": '123'}
+        return response_type, response_payload
+
+    @handler('oadrRequestReport')
+    async def request_report(self, payload):
+        """
+        Provide the VEN with the latest report.
+        """
+        print("Called Request Report")
+
+    @handler('oadrUpdateReport')
+    async def update_report(self, payload):
+        """
+        Updates an existing report from this VEN in our database.
+        """
+        print("Called Update Report")
+
+    @handler('oadrCancelReport')
+    async def cancel_report(self, payload):
+        """
+        Cancels a previously received report from this VEN.
+        """
+        print("Called Cancel Report")

+ 51 - 0
pyopenadr/service/vtn_service.py

@@ -0,0 +1,51 @@
+from asyncio import iscoroutine
+from http import HTTPStatus
+import random
+import string
+
+from . import api
+from .. import config, errors
+from ..utils import parse_message, indent_xml
+
+class VTNService:
+    """
+    This is the default OpenADR handler. You should subclass this with your
+    specific services.
+    """
+    def __init__(self):
+        self.handlers = {}
+        for method in [getattr(self, attr) for attr in dir(self) if callable(getattr(self, attr))]:
+            if hasattr(method, '__message_type__'):
+                print(f"Adding {method.__name__} as handler for {method.__message_type__}")
+                self.handlers[method.__message_type__] = method
+
+    async def on_request(self, request, response):
+        """
+        This is the default handler that is used by python-responder. It will
+        look for a handler of the message type in one of the subclasses.
+        """
+        print()
+        print()
+        print("================================================================================")
+        print(f"             NEW REQUEST to {request.url.path}                                 ")
+        print("================================================================================")
+        content = await request.content
+        print(f"Received: {content.decode('utf-8')}")
+        message_type, message_payload = parse_message(content)
+        print(f"Interpreted message: {message_type}: {message_payload}")
+        if message_type in self.handlers:
+            handler = self.handlers[message_type]
+            result = handler(message_payload)
+            if iscoroutine(result):
+                response_type, response_payload = await result
+            else:
+                response_type, response_payload = result
+            response.html = indent_xml(api.template(f'{response_type}.xml', **response_payload))
+            print(f"Sending {response.html}")
+        else:
+            response.html = indent_xml(api.template('oadrResponse.xml',
+                                status_code=errorcodes.COMPLIANCE_ERROR,
+                                status_description=f'A message of type {message_type} should not be sent to this endpoint'))
+            print(f"Sending {response.html}")
+            response.status_code = HTTPStatus.BAD_REQUEST
+

+ 0 - 0
pyopenadr/templates/oadrCancelOpt.xml


+ 0 - 0
pyopenadr/templates/oadrCancelPartyRegistration.xml


+ 0 - 0
pyopenadr/templates/oadrCancelReport.xml


+ 0 - 0
pyopenadr/templates/oadrCanceledOpt.xml


+ 0 - 0
pyopenadr/templates/oadrCanceledPartyRegistration.xml


+ 0 - 0
pyopenadr/templates/oadrCanceledReport.xml


+ 13 - 0
pyopenadr/templates/oadrCreateOpt.xml

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+  <oadrSignedObject>
+    <oadrCreateOpt ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ response.request_id }}</requestID>
+      <ei:qualifiedEventID>
+        <ei:eventID>{{ event_id }}</ei:eventID>
+        <ei:modificationNumber>{{ modification_number }}</ei:modificationNumber>
+      </ei:qualifiedEventID>
+      {% include 'parts/eiTarget.xml' %}
+    </oadrCreatedEvent>
+  </oadrSignedObject>
+</oadrPayload>

+ 18 - 0
pyopenadr/templates/oadrCreatePartyRegistration.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07">
+  <oadrSignedObject>
+    <oadrCreatePartyRegistration ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ request_id }}</requestID>
+      <oadrProfileName>{{ profile_name }}</oadrProfileName>
+      {% if ven_id %}
+      <venID>{{ ven_id }}</venID>
+      {% endif %}
+      <oadrTransportName>{{ transport_name }}</oadrTransportName>
+      <oadrTransportAddress>{{ transport_address }}</oadrTransportAddress>
+      <oadrReportOnly>{{ report_only }}</oadrReportOnly>
+      <oadrXmlSignature>{{ xml_signature }}</oadrXmlSignature>
+      <oadrVenName>{{ ven_name }}</oadrVenName>
+      <oadrHttpPullModel>{{ http_pull_model }}</oadrHttpPullModel>
+    </oadrCreatePartyRegistration>
+  </oadrSignedObject>
+</oadrPayload>

+ 0 - 0
pyopenadr/templates/oadrCreateReport.xml


+ 35 - 0
pyopenadr/templates/oadrCreatedEvent.xml

@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+  <oadrSignedObject>
+    <oadrCreatedEvent ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <eiCreatedEvent xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">
+        <ei:eiResponse>
+          <ei:responseCode>{{ response.response_code }}</ei:responseCode>
+          <ei:responseDescription>{{ response.response_description }}</ei:responseDescription>
+          {% if response.request_id %}
+          <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ response.request_id }}</requestID>
+          {% endif %}
+        </ei:eiResponse>
+        {% if event_responses %}
+        <ei:eventResponses>
+          {% for event_response in event_responses %}
+          <ei:eventResponse>
+            <ei:responseCode>{{ event_response.response_code }}</ei:responseCode>
+            {% if event_response.response_description %}
+            <ei:responseDescription>{{ event_response.response_description }}</ei:responseDescription>
+            {% endif %}
+            <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ event_response.request_id }}</requestID>
+            <ei:qualifiedEventID>
+              <ei:eventID>{{ event_response.qualified_event_id.event_id }}</ei:eventID>
+              <ei:modificationNumber>{{ event_response.qualified_event_id.modification_number }}</ei:modificationNumber>
+            </ei:qualifiedEventID>
+            <ei:optType>{{ event_response.opt_type }}</ei:optType>
+          </ei:eventResponse>
+          {% endfor %}
+        </ei:eventResponses>
+        {% endif %}
+        <ei:venID>{{ ven_id }}</ei:venID>
+      </eiCreatedEvent>
+    </oadrCreatedEvent>
+  </oadrSignedObject>
+</oadrPayload>

+ 0 - 0
pyopenadr/templates/oadrCreatedOpt.xml


+ 44 - 0
pyopenadr/templates/oadrCreatedPartyRegistration.xml

@@ -0,0 +1,44 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+  <oadrSignedObject>
+    <oadrCreatedPartyRegistration ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <ei:eiResponse>
+        <ei:responseCode>200</ei:responseCode>
+        <ei:responseDescription>OK</ei:responseDescription>
+        <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ request_id }}</requestID>
+      </ei:eiResponse>
+      {% if registration_id %}
+      <ei:registrationID>{{ registration_id }}</ei:registrationID>
+      {% else %}
+      <ei:registrationID />
+      {% endif %}
+
+      {% if ven_id %}
+      <ei:venID>{{ ven_id }}</ei:venID>
+      {% else %}
+      <ei:venID />
+      {% endif %}
+
+      <ei:vtnID>{{ vtn_id }}</ei:vtnID>
+      <oadrProfiles>
+      {% for profile in profiles %}
+        <oadrProfile>
+          <oadrProfileName>{{ profile.profile_name }}</oadrProfileName>
+          <oadrTransports>
+            {% for transport in profile.transports %}
+            <oadrTransport>
+              <oadrTransportName>simpleHttp</oadrTransportName>
+            </oadrTransport>
+            {% endfor %}
+          </oadrTransports>
+        </oadrProfile>
+      {% endfor %}
+      </oadrProfiles>
+      {% if requested_oadr_poll_freq %}
+      <oadrRequestedOadrPollFreq>
+        <duration xmlns="urn:ietf:params:xml:ns:icalendar-2.0">{{ requested_oadr_poll_freq|timedeltaformat }}</duration>
+      </oadrRequestedOadrPollFreq>
+      {% endif %}
+    </oadrCreatedPartyRegistration>
+  </oadrSignedObject>
+</oadrPayload>

+ 0 - 0
pyopenadr/templates/oadrCreatedReport.xml


+ 17 - 0
pyopenadr/templates/oadrDistributeEvent.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+	<oadrSignedObject>
+		<oadrDistributeEvent ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+			<ei:eiResponse>
+				<ei:responseCode>{{ response.response_code }}</ei:responseCode>
+				<ei:responseDescription>{{ response.response_description }}</ei:responseDescription>
+				<requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ response.request_id }}</requestID>
+			</ei:eiResponse>
+			<requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ request_id }}</requestID>
+			<ei:vtnID>{{ vtn_id }}</ei:vtnID>
+            {% for event in events %}
+			    {% include 'parts/eiEvent.xml' %}
+            {% endfor %}
+	</oadrDistributeEvent>
+</oadrSignedObject>
+</oadrPayload>

+ 8 - 0
pyopenadr/templates/oadrPoll.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07">
+  <oadrSignedObject>
+    <oadrPoll ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <ei:venID>{{ ven_id }}</ei:venID>
+    </oadrPoll>
+  </oadrSignedObject>
+</oadrPayload>

+ 8 - 0
pyopenadr/templates/oadrQueryRegistration.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+  <oadrSignedObject>
+    <oadrQueryRegistration ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ request_id }}</requestID>
+    </oadrQueryRegistration>
+  </oadrSignedObject>
+</oadrPayload>

+ 149 - 0
pyopenadr/templates/oadrRegisterReport.xml

@@ -0,0 +1,149 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07">
+  <oadrSignedObject>
+    <oadrRegisterReport ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ request_id }}</requestID>
+      <ei:venID>{{ ven_id }}</ei:venID>
+{% for report in reports %}
+      <oadrReport>
+        <duration xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
+          <duration>{{ report.duration }}</duration>
+        </duration>
+    {% for rd report.description %}
+        <oadrReportDescription>
+          <ei:rID>{{ rd.status }}</ei:rID>
+          <ei:reportDataSource>
+            <ei:resourceID>{{ rd.resource_id }}</ei:resourceID>
+          </ei:reportDataSource>
+          <ei:reportType>{{ rd.report_type }}</ei:reportType>
+          <ei:readingType>{{ rd.reading_type }}</ei:readingType>
+          <marketContext xmlns="http://docs.oasis-open.org/ns/emix/2011/06">{{ rd.market_context }}</marketContext>
+          <oadrSamplingRate>
+            <oadrMinPeriod>{{ rd.sampling_rate.min_period }}</oadrMinPeriod>
+            <oadrMaxPeriod>{{ rd.sampling_rate.max_period }}</oadrMaxPeriod>
+            <oadrOnChange>{{ rd.sampling_rate.on_change }}</oadrOnChange>
+          </oadrSamplingRate>
+        </oadrReportDescription>
+    {% endfor %}
+        <ei:reportRequestID>{{ report.report_request_id }}</ei:reportRequestID>
+        <ei:reportSpecifierID>{{ report.report_specifier_id }}</ei:reportSpecifierID>
+        <ei:reportName>{{ report.report_name }}</ei:reportName>
+        <ei:createdDateTime>{{ report.created_date_time }}</ei:createdDateTime>
+      </oadrReport>
+{% endfor %}
+
+
+
+
+
+      <oadrReport>
+        <duration xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
+          <duration>{{ duration }}</duration>
+        </duration>
+{% for report_description in reports %}
+        <oadrReportDescription>
+          <ei:rID>{{ report_description.r_id }}</ei:rID>
+          <ei:reportDataSource>
+            <ei:resourceID>{report_description.report_data_source.resource_id</ei:resourceID>
+          </ei:reportDataSource>
+          <ei:reportType>report_description.report_type</ei:reportType>
+          <energyReal xmlns="http://docs.oasis-open.org/ns/emix/2011/06/power">
+            <itemDescription>{{report_description.energy_real.item_descirption}}</itemDescription>
+            <itemUnits>{{ report_description.energy_real.item_units }}</itemUnits>
+            <siScaleCode xmlns="http://docs.oasis-open.org/ns/emix/2011/06/siscale">{{ report_descirption.energy_real.si_scale_code }}</siScaleCode>
+          </energyReal>
+          <ei:readingType>Direct Read</ei:readingType>
+          <marketContext xmlns="http://docs.oasis-open.org/ns/emix/2011/06">http://MarketContext1</marketContext>
+          <oadrSamplingRate>
+            <oadrMinPeriod>PT1M</oadrMinPeriod>
+            <oadrMaxPeriod>PT1M</oadrMaxPeriod>
+            <oadrOnChange>false</oadrOnChange>
+          </oadrSamplingRate>
+        </oadrReportDescription>
+{%
+        <oadrReportDescription>
+          <ei:rID>resource1_power</ei:rID>
+          <ei:reportDataSource>
+            <ei:resourceID>resource1</ei:resourceID>
+          </ei:reportDataSource>
+          <ei:reportType>usage</ei:reportType>
+          <powerReal xmlns="http://docs.oasis-open.org/ns/emix/2011/06/power">
+            <itemDescription>RealPower</itemDescription>
+            <itemUnits>W</itemUnits>
+            <siScaleCode xmlns="http://docs.oasis-open.org/ns/emix/2011/06/siscale">n</siScaleCode>
+            <powerAttributes>
+              <hertz>60</hertz>
+              <voltage>110</voltage>
+              <ac>false</ac>
+            </powerAttributes>
+          </powerReal>
+          <ei:readingType>Direct Read</ei:readingType>
+          <marketContext xmlns="http://docs.oasis-open.org/ns/emix/2011/06">http://MarketContext1</marketContext>
+          <oadrSamplingRate>
+            <oadrMinPeriod>PT1M</oadrMinPeriod>
+            <oadrMaxPeriod>PT1M</oadrMaxPeriod>
+            <oadrOnChange>false</oadrOnChange>
+          </oadrSamplingRate>
+        </oadrReportDescription>
+        <ei:reportRequestID>0</ei:reportRequestID>
+        <ei:reportSpecifierID>789ed6cd4e_telemetry_usage</ei:reportSpecifierID>
+        <ei:reportName>METADATA_TELEMETRY_USAGE</ei:reportName>
+        <ei:createdDateTime>2019-06-04T10:59:35.6533057Z</ei:createdDateTime>
+      </oadrReport>
+      <oadrReport>
+        <duration xmlns="urn:ietf:params:xml:ns:icalendar-2.0">
+          <duration>PT2H</duration>
+        </duration>
+        <oadrReportDescription>
+          <ei:rID>resource1_energy</ei:rID>
+          <ei:reportDataSource>
+            <ei:resourceID>resource1</ei:resourceID>
+          </ei:reportDataSource>
+          <ei:reportType>usage</ei:reportType>
+          <energyReal xmlns="http://docs.oasis-open.org/ns/emix/2011/06/power">
+            <itemDescription>RealEnergy</itemDescription>
+            <itemUnits>Wh</itemUnits>
+            <siScaleCode xmlns="http://docs.oasis-open.org/ns/emix/2011/06/siscale">n</siScaleCode>
+          </energyReal>
+          <ei:readingType>Direct Read</ei:readingType>
+          <marketContext xmlns="http://docs.oasis-open.org/ns/emix/2011/06">http://MarketContext1</marketContext>
+          <oadrSamplingRate>
+            <oadrMinPeriod>PT1M</oadrMinPeriod>
+            <oadrMaxPeriod>PT1M</oadrMaxPeriod>
+            <oadrOnChange>false</oadrOnChange>
+          </oadrSamplingRate>
+        </oadrReportDescription>
+{% endfor %}
+        <oadrReportDescription>
+          <ei:rID>resource1_power</ei:rID>
+          <ei:reportDataSource>
+            <ei:resourceID>resource1</ei:resourceID>
+          </ei:reportDataSource>
+          <ei:reportType>usage</ei:reportType>
+          <powerReal xmlns="http://docs.oasis-open.org/ns/emix/2011/06/power">
+            <itemDescription>RealPower</itemDescription>
+            <itemUnits>W</itemUnits>
+            <siScaleCode xmlns="http://docs.oasis-open.org/ns/emix/2011/06/siscale">n</siScaleCode>
+            <powerAttributes>
+              <hertz>60</hertz>
+              <voltage>110</voltage>
+              <ac>false</ac>
+            </powerAttributes>
+          </powerReal>
+          <ei:readingType>Direct Read</ei:readingType>
+          <marketContext xmlns="http://docs.oasis-open.org/ns/emix/2011/06">http://MarketContext1</marketContext>
+          <oadrSamplingRate>
+            <oadrMinPeriod>PT1M</oadrMinPeriod>
+            <oadrMaxPeriod>PT1M</oadrMaxPeriod>
+            <oadrOnChange>false</oadrOnChange>
+          </oadrSamplingRate>
+        </oadrReportDescription>
+        <ei:reportRequestID>0</ei:reportRequestID>
+        <ei:reportSpecifierID>789ed6cd4e_history_usage</ei:reportSpecifierID>
+        <ei:reportName>METADATA_HISTORY_USAGE</ei:reportName>
+        <ei:createdDateTime>2019-06-04T10:59:35.6533057Z</ei:createdDateTime>
+      </oadrReport>
+      <ei:venID>95a3c2f9068725320753</ei:venID>
+    </oadrRegisterReport>
+  </oadrSignedObject>
+</oadrPayload>

+ 17 - 0
pyopenadr/templates/oadrRegisteredReport.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07">
+  <oadrSignedObject>
+    <oadrRegisteredReport ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <ei:eiResponse>
+        <ei:responseCode>{{ response.response_code }}</ei:responseCode>
+        <ei:responseDescription>{{ response.response_description }}</ei:responseDescription>
+        <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ response.request_id }}</requestID>
+      </ei:eiResponse>
+    {% for report_request in report_requests %}
+      <oadrReportRequest>
+      </oadrReportRequest>
+    {% endfor %}
+      <ei:venID>{{ ven_id }}</ei:venID>
+    </oadrRegisteredReport>
+  </oadrSignedObject>
+</oadrPayload>

+ 11 - 0
pyopenadr/templates/oadrRequestEvent.xml

@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07">
+  <oadrSignedObject>
+    <oadrRequestEvent ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <eiRequestEvent xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">
+        <requestID>{{ request_id }}</requestID>
+        <ei:venID>{{ ven_id }}</ei:venID>
+      </eiRequestEvent>
+    </oadrRequestEvent>
+  </oadrSignedObject>
+</oadrPayload>

+ 0 - 0
pyopenadr/templates/oadrRequestReport.xml


+ 8 - 0
pyopenadr/templates/oadrRequestReregistration.xml

@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+  <oadrSignedObject>
+    <oadrRequestReregistration ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <ei:venID>{{ ven_id }}</ei:venID>
+    </oadrRequestReregistration>
+  </oadrSignedObject>
+</oadrPayload>

+ 17 - 0
pyopenadr/templates/oadrResponse.xml

@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<oadrPayload xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns="http://openadr.org/oadr-2.0b/2012/07" xsi:schemaLocation="http://openadr.org/oadr-2.0b/2012/07 oadr_20b.xsd">
+  <oadrSignedObject>
+    <oadrResponse ei:schemaVersion="2.0b" xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">
+      <ei:eiResponse>
+        <ei:responseCode>{{ response_code }}</ei:responseCode>
+        <ei:responseDescription>{{ response_description }}</ei:responseDescription>
+        {% if request_id %}
+        <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads">{{ request_id }}</requestID>
+        {% else %}
+        <requestID xmlns="http://docs.oasis-open.org/ns/energyinterop/201110/payloads" />
+        {% endif %}
+      </ei:eiResponse>
+      <ei:venID>{{ ven_id }}</ei:venID>
+    </oadrResponse>
+  </oadrSignedObject>
+</oadrPayload>

+ 0 - 0
pyopenadr/templates/oadrUpdateReport.xml


+ 0 - 0
pyopenadr/templates/oadrUpdatedReport.xml


+ 280 - 0
pyopenadr/utils.py

@@ -0,0 +1,280 @@
+from asyncio import iscoroutine
+import xmltodict
+from jinja2 import Environment, PackageLoader, select_autoescape
+from datetime import datetime, timedelta, timezone
+import random
+import string
+from collections import OrderedDict
+import itertools
+import re
+
+DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
+DATETIME_FORMAT_NO_MICROSECONDS = "%Y-%m-%dT%H:%M:%SZ"
+
+def indent_xml(message):
+    """
+    Indents the XML in a nice way.
+    """
+    INDENT_SIZE = 2
+    lines = [line.strip() for line in message.split("\n") if line.strip() != ""]
+    indent = 0
+    for i, line in enumerate(lines):
+        if i == 0:
+            continue
+        if re.search(r'^</[^>]+>$', line):
+            indent = indent - INDENT_SIZE
+        lines[i] = " " * indent + line
+        if not (re.search(r'</[^>]+>$', line) or line.endswith("/>")):
+            indent = indent + INDENT_SIZE
+    return "\n".join(lines)
+
+def normalize_dict(ordered_dict):
+    """
+    Convert the OrderedDict to a regular dict, snake_case the key names, and promote uniform lists.
+    """
+    def normalize_key(key):
+        if key.startswith('oadr'):
+            key = key[4:]
+        elif key.startswith('ei'):
+            key = key[2:]
+        key = re.sub(r'([a-z])([A-Z])', r'\1_\2', key)
+        if '-' in key:
+            key = key.replace('-', '_')
+        return key.lower()
+
+    def parse_datetime(value):
+        """
+        Parse an ISO8601 datetime
+        """
+        matches = re.match(r'(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.?(\d{1,6})?\d*Z', value)
+        if matches:
+            year, month, day, hour, minute, second, microsecond = (int(value) for value in matches.groups())
+            return datetime(year, month, day, hour, minute, second, microsecond=microsecond, tzinfo=timezone.utc)
+        else:
+            print(f"{value} did not match format")
+            return value
+
+    def parse_duration(value):
+        """
+        Parse a RFC5545 duration.
+        """
+        matches = re.match(r'P(\d+(?:D|W))?T(\d+H)?(\d+M)?(\d+S)?', value)
+        if not matches:
+            return False
+        days = hours = minutes = seconds = 0
+        _days, _hours, _minutes, _seconds = matches.groups()
+        if _days:
+            if _days.endswith("D"):
+                days = int(_days[:-1])
+            elif _days.endswith("W"):
+                days = int(_days[:-1]) * 7
+        if _hours:
+            hours = int(_hours[:-1])
+        if _minutes:
+            minutes = int(_minutes[:-1])
+        if _seconds:
+            seconds = int(_seconds[:-1])
+        return timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)
+
+    def parse_int(value):
+        matches = re.match(r'^[\d-]+$', value)
+        if not matches:
+            return False
+        else:
+            return int(value)
+
+    def parse_float(value):
+        matches = re.match(r'^[\d.-]+$', value)
+        if not matches:
+            return False
+        else:
+            return float(value)
+
+    def parse_boolean(value):
+        if value == 'true':
+            return True
+        else:
+            return False
+
+    d = {}
+    for key, value in ordered_dict.items():
+        # Interpret values from the dict
+        if key.startswith("@"):
+            continue
+        key = normalize_key(key)
+        if isinstance(value, OrderedDict):
+            d[key] = normalize_dict(value)
+        elif isinstance(value, list):
+            d[key] = []
+            for item in value:
+                if isinstance(item, OrderedDict):
+                    dict_item = normalize_dict(item)
+                    d[key].append(normalize_dict(dict_item))
+                else:
+                    d[key].append(item)
+        elif key in ("duration", "startafter") and isinstance(value, str):
+            d[key] = parse_duration(value) or value
+        elif "date_time" in key and isinstance(value, str):
+            d[key] = parse_datetime(value)
+        elif value in ('true', 'false'):
+            d[key] = parse_boolean(value)
+        elif isinstance(value, str):
+            d[key] = parse_int(value) or parse_float(value) or value
+        else:
+            d[key] = value
+
+        # Do our best to make the dictionary structure as pythonic as possible
+        if key.startswith("x_ei_"):
+            d[key[5:]] = d.pop(key)
+            key = key[5:]
+
+        # Group all targets as a list of dicts under the key "target"
+        if key == "target":
+            targets = d.pop("target")
+            new_targets = []
+            for key in targets:
+                if isinstance(targets[key], list):
+                    new_targets.extend([{key: value} for value in targets[key]])
+                else:
+                    new_targets.append({key: targets[key]})
+            d["targets"] = new_targets
+
+        # Group all events al a list of dicts under the key "events"
+        elif key == "event" and isinstance(d[key], list):
+            events = d.pop("event")
+            new_events = []
+            for event in events:
+                new_event = event['event']
+                new_event['response_required'] = event['response_required']
+                new_events.append(new_event)
+            d["events"] = new_events
+
+        # If there's only one event, also put it into a list
+        elif key == "event" and isinstance(d[key], dict) and "event" in d[key]:
+            d["events"] = [d.pop('event')['event']]
+
+        elif key in ("request_event", "created_event") and isinstance(d[key], dict):
+            d = d[key]
+
+        # Durations are encapsulated in their own object, remove this nesting
+        elif isinstance(d[key], dict) and "duration" in d[key] and len(d[key]) == 1:
+            d[key] = d[key]["duration"]
+
+        # In general, remove all double nesting
+        elif isinstance(d[key], dict) and key in d[key] and len(d[key]) == 1:
+            d[key] = d[key][key]
+
+        # In general, remove the double nesting of lists of items
+        elif isinstance(d[key], dict) and key[:-1] in d[key] and len(d[key]) == 1:
+            if isinstance(d[key][key[:-1]], list):
+                d[key] = d[key][key[:-1]]
+            else:
+                d[key] = [d[key][key[:-1]]]
+
+        # Payload values are wrapped in an object according to their type. We don't need that information.
+        elif key in ("signal_payload", "current_value"):
+            value = d[key]
+            while True:
+                if isinstance(value, dict):
+                    value = list(value.values())[0]
+                else:
+                    break
+            d[key] = value
+
+        # Promote the 'text' item
+        elif isinstance(d[key], dict) and "text" in d[key] and len(d[key]) == 1:
+            d[key] = d[key]["text"]
+
+        # Remove all empty dicts
+        elif isinstance(d[key], dict) and len(d[key]) == 0:
+            d.pop(key)
+    return d
+
+def parse_message(data):
+    """
+    Parse a message and distill its usable parts. Returns a message type and payload.
+    """
+    message_dict = xmltodict.parse(data, process_namespaces=True, namespaces=NAMESPACES)
+    message_type, message_payload = message_dict['oadrPayload']['oadrSignedObject'].popitem()
+    return message_type, normalize_dict(message_payload)
+
+def create_message(message_type, **message_payload):
+    for key, value in message_payload.items():
+        if isinstance(value, bool):
+            message_payload[key] = str(value).lower()
+        if isinstance(value, datetime):
+            message_payload[key] = value.strftime("%Y-%m-%dT%H:%M:%S%z")
+    template = TEMPLATES.get_template(f'{message_type}.xml')
+    return indent_xml(template.render(**message_payload))
+
+def new_request_id(*args, **kwargs):
+    return ''.join(random.choice(string.hexdigits) for _ in range(10)).lower()
+
+def generate_id(*args, **kwargs):
+    return new_request_id()
+
+def peek(iterable):
+    """
+    Peek into an iterable.
+    """
+    try:
+        first = next(iterable)
+    except StopIteration:
+        return None
+    else:
+        return itertools.chain([first], iterable)
+
+def datetimeformat(value, format=DATETIME_FORMAT):
+    if not isinstance(value, datetime):
+        return value
+    return value.strftime(format)
+
+def timedeltaformat(value):
+    """
+    Format a timedelta to a RFC5545 Duration.
+    """
+    if not isinstance(value, timedelta):
+        return value
+    days = value.days
+    hours, seconds = divmod(value.seconds, 3600)
+    minutes, seconds = divmod(seconds, 60)
+    formatted = "P"
+    if days:
+        formatted += f"{days}D"
+    if hours or minutes or seconds:
+        formatted += f"T"
+    if hours:
+        formatted += f"{hours}H"
+    if minutes:
+        formatted += f"{minutes}M"
+    if seconds:
+        formatted += f"{seconds}S"
+    return formatted
+
+def booleanformat(value):
+    """
+    Format a boolean value
+    """
+    if isinstance(value, bool):
+        if value == True:
+            return "true"
+        elif value == False:
+            return "false"
+    else:
+        return value
+
+
+TEMPLATES = Environment(loader=PackageLoader('pyopenadr', 'templates'))
+
+NAMESPACES = {
+    'http://docs.oasis-open.org/ns/energyinterop/201110': None,
+    'http://openadr.org/oadr-2.0b/2012/07': None,
+    'urn:ietf:params:xml:ns:icalendar-2.0': None,
+    'http://docs.oasis-open.org/ns/energyinterop/201110/payloads': None,
+    'http://docs.oasis-open.org/ns/emix/2011/06': None,
+    'urn:ietf:params:xml:ns:icalendar-2.0:stream': None
+}
+
+TEMPLATES.filters['datetimeformat'] = datetimeformat
+TEMPLATES.filters['timedeltaformat'] = timedeltaformat
+TEMPLATES.filters['booleanformat'] = booleanformat

+ 8 - 0
setup.py

@@ -0,0 +1,8 @@
+from setuptools import setup
+
+setup(name="pyopenadr",
+      version="0.1.2",
+      description="Python library for dealing with OpenADR",
+      packages=['pyopenadr', 'pyopenadr.service'],
+      include_package_data=True,
+      install_requires=['xmltodict', 'responder', 'apscheduler'])