Prechádzať zdrojové kódy

PROD-1221: Event processing

Blake Schneider 5 rokov pred
rodič
commit
9ac9bf59dc

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 46 - 0
__tests__/unit/xml/event/distribute-event.spec.js


+ 14 - 0
__tests__/unit/xml/event/js-requests.js

@@ -0,0 +1,14 @@
+'use strict';
+
+const requestEvent1 = {
+  _type: 'oadrRequestEvent',
+  requestId: '2233',
+  venId: '3f59d85fbdf3997dbeb1',
+  replyLimit: '2'
+};
+
+module.exports = {
+  requestEvent1,
+};
+
+

+ 542 - 0
__tests__/unit/xml/event/js-responses.js

@@ -0,0 +1,542 @@
+'use strict';
+
+const distributeEvent1 = {
+  'responseCode': '200',
+  'responseDescription': 'OK',
+  'responseRequestId': '9383fc5946cb0e14ef5a',
+  'requestId': '81dc20dfea7df7a2bb9e',
+  'vtnId': 'NANTUM_VTN',
+  'events': [
+    {
+      'eventDescriptor': {
+        'eventId': '41836407d027a0aabcb3',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-14T16:06:39.000Z',
+        'eventStatus': 'far',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-18T16:21:00.000Z',
+        'duration': 'PT60M',
+        'toleranceTolerateStartAfter': 'PT5M',
+        'notificationDuration': 'PT5M',
+        'rampUpDuration': 'PT10M',
+        'recoveryDuration': 'PT12M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT60M',
+                'uid': '1'
+              },
+            ],
+            'signalName': 'LOAD_CONTROL',
+            'signalType': 'x-loadControlCapacity',
+            'signalId': '64ba02508ab099d6eae6'
+          },
+        ],
+      },
+      'target': {
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    }
+  ]
+};
+
+const distributeEvent2 = {
+  'responseCode': '200',
+  'responseDescription': 'OK',
+  'responseRequestId': '9383fc5946cb0e14ef5a',
+  'requestId': '81dc20dfea7df7a2bb9e',
+  'vtnId': 'NANTUM_VTN',
+  'events': [
+    {
+      'eventDescriptor': {
+        'eventId': '41836407d027a0aabcb3',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-14T16:06:39.000Z',
+        'eventStatus': 'far',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-14T16:21:00.000Z',
+        'duration': 'PT60M',
+        'toleranceTolerateStartAfter': 'PT5M',
+        'notificationDuration': 'PT5M',
+        'rampUpDuration': 'PT10M',
+        'recoveryDuration': 'PT12M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT10M',
+                'uid': '1'
+              },
+              {
+                'signalPayloads': [
+                  55
+                ],
+                'duration': 'PT15M',
+                'uid': '2'
+              },
+              {
+                'signalPayloads': [
+                  60
+                ],
+                'duration': 'PT25M',
+                'uid': '3'
+              },
+              {
+                'signalPayloads': [
+                  65
+                ],
+                'duration': 'PT10M',
+                'uid': '4'
+              }
+            ],
+            'signalName': 'LOAD_CONTROL',
+            'signalType': 'x-loadControlCapacity',
+            'signalId': '64ba02508ab099d6eae6',
+            'target': {
+              'endDeviceAsset': [
+                'Energy_Management_System'
+              ]
+            },
+            'currentValue': 0
+          },
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  5.55
+                ],
+                'duration': 'PT60M',
+                'uid': '0'
+              }
+            ],
+            'signalName': 'ELECTRICITY_PRICE',
+            'signalType': 'price',
+            'signalId': 'a5d7f2c75a526386fa41',
+            'currentValue': 0
+          }
+        ],
+        'baseline': [
+          {
+            'startDate': '2020-04-14T16:50:00.000Z',
+            'duration': 'PT10M',
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT30M',
+                'uid': '1'
+              },
+              {
+                'signalPayloads': [
+                  60
+                ],
+                'duration': 'PT30M',
+                'uid': '2'
+              }
+            ],
+            'baselineId': '72233284678ff05139f4',
+            'baselineName': 'some baseline',
+            'itemBase': {
+              'type': 'currencyPerKWh',
+              'description': 'currencyPerKWh',
+              'units': 'USD',
+              'siScaleCode': 'none'
+            }
+          }
+        ]
+      },
+      'target': {
+        'groupId': [
+          'Test Target'
+        ],
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    },
+    {
+      'eventDescriptor': {
+        'eventId': 'b6c955285eb2006232ea',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-10T19:38:00.000Z',
+        'eventStatus': 'completed',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-10T19:08:00.000Z',
+        'duration': 'PT30M',
+        'toleranceTolerateStartAfter': 'PT0M',
+        'notificationDuration': 'PT0M',
+        'rampUpDuration': 'PT0M',
+        'recoveryDuration': 'PT0M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [],
+            'signalName': 'BID_LOAD',
+            'signalType': 'level',
+            'signalId': '38e550909d77bc37310d',
+            'currentValue': 0,
+            'itemBase': {
+              'type': 'powerReal',
+              'description': 'RealPower',
+              'units': 'W',
+              'siScaleCode': 'none',
+              'powerAttributes': {
+                'hertz': '60',
+                'voltage': '120',
+                'ac': 'true'
+              }
+            }
+          },
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  5.5
+                ],
+                'duration': 'PT30M',
+                'uid': '0'
+              }
+            ],
+            'signalName': 'ELECTRICITY_PRICE',
+            'signalType': 'price',
+            'signalId': '94a93415888d31b6d84e',
+            'currentValue': 5.5
+          }
+        ]
+      },
+      'target': {
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    },
+    {
+      'eventDescriptor': {
+        'eventId': '16b3c052f1b636ede15e',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-10T20:54:00.000Z',
+        'eventStatus': 'completed',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-10T20:34:00.000Z',
+        'duration': 'PT20M',
+        'toleranceTolerateStartAfter': 'PT0M',
+        'notificationDuration': 'PT0M',
+        'rampUpDuration': 'PT0M',
+        'recoveryDuration': 'PT0M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT20M',
+                'uid': '0'
+              }
+            ],
+            'signalName': 'ENERGY_PRICE',
+            'signalType': 'price',
+            'signalId': 'e6e7b114b6298cd9d055',
+            'currentValue': 50
+          }
+        ]
+      },
+      'target': {
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    }
+  ]
+};
+
+const epriEvent1 = {
+  'responseCode': '200',
+  'responseDescription': 'OK',
+  'responseRequestId': '9383fc5946cb0e14ef5a',
+  'requestId': '81dc20dfea7df7a2bb9e',
+  'vtnId': 'EPRI_VTN',
+  'events': [
+    {
+      'eventDescriptor': {
+        'eventId': '41836407d027a0aabcb3',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-14T16:06:39.000Z',
+        'eventStatus': 'far',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-14T16:21:00.000Z',
+        'duration': 'PT60M',
+        'toleranceTolerateStartAfter': 'PT5M',
+        'notificationDuration': 'PT5M',
+        'rampUpDuration': 'PT10M',
+        'recoveryDuration': 'PT12M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT10M',
+                'uid': '1'
+              },
+              {
+                'signalPayloads': [
+                  55
+                ],
+                'duration': 'PT15M',
+                'uid': '2'
+              },
+              {
+                'signalPayloads': [
+                  60
+                ],
+                'duration': 'PT25M',
+                'uid': '3'
+              },
+              {
+                'signalPayloads': [
+                  65
+                ],
+                'duration': 'PT10M',
+                'uid': '4'
+              }
+            ],
+            'signalName': 'LOAD_CONTROL',
+            'signalType': 'x-loadControlCapacity',
+            'signalId': '64ba02508ab099d6eae6',
+            'target': {
+              'endDeviceAsset': [
+                'Energy_Management_System'
+              ]
+            },
+            'currentValue': 0
+          },
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  5.55
+                ],
+                'duration': 'PT60M',
+                'uid': '0'
+              }
+            ],
+            'signalName': 'ELECTRICITY_PRICE',
+            'signalType': 'price',
+            'signalId': 'a5d7f2c75a526386fa41',
+            'currentValue': 0
+          }
+        ],
+        'baseline': [
+          {
+            'startDate': '2020-04-14T16:50:00.000Z',
+            'duration': 'PT10M',
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT30M',
+                'uid': '1'
+              },
+              {
+                'signalPayloads': [
+                  60
+                ],
+                'duration': 'PT30M',
+                'uid': '2'
+              }
+            ],
+            'baselineId': '72233284678ff05139f4',
+            'baselineName': 'some baseline',
+            'itemBase': {
+              'type': 'currencyPerKWh',
+              'description': 'currencyPerKWh',
+              'units': 'USD',
+              'siScaleCode': 'none'
+            }
+          }
+        ]
+      },
+      'target': {
+        'groupId': [
+          'Test Target'
+        ],
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    },
+    {
+      'eventDescriptor': {
+        'eventId': 'b6c955285eb2006232ea',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-10T19:38:00.000Z',
+        'eventStatus': 'completed',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-10T19:08:00.000Z',
+        'duration': 'PT30M',
+        'toleranceTolerateStartAfter': 'PT0M',
+        'notificationDuration': 'PT0M',
+        'rampUpDuration': 'PT0M',
+        'recoveryDuration': 'PT0M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [],
+            'signalName': 'BID_LOAD',
+            'signalType': 'level',
+            'signalId': '38e550909d77bc37310d',
+            'currentValue': 0,
+            'itemBase': {
+              'type': 'powerReal',
+              'description': 'RealPower',
+              'units': 'W',
+              'siScaleCode': 'none',
+              'powerAttributes': {
+                'hertz': '60',
+                'voltage': '120',
+                'ac': 'true'
+              }
+            }
+          },
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  5.5
+                ],
+                'duration': 'PT30M',
+                'uid': '0'
+              }
+            ],
+            'signalName': 'ELECTRICITY_PRICE',
+            'signalType': 'price',
+            'signalId': '94a93415888d31b6d84e',
+            'currentValue': 5.5
+          }
+        ]
+      },
+      'target': {
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    },
+    {
+      'eventDescriptor': {
+        'eventId': '16b3c052f1b636ede15e',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-10T20:54:00.000Z',
+        'eventStatus': 'completed',
+        'testEvent': false,
+        'modificationReason': '',
+        'priority': 0,
+        'vtnComment': ''
+      },
+      'activePeriod': {
+        'startDate': '2020-04-10T20:34:00.000Z',
+        'duration': 'PT20M',
+        'toleranceTolerateStartAfter': 'PT0M',
+        'notificationDuration': 'PT0M',
+        'rampUpDuration': 'PT0M',
+        'recoveryDuration': 'PT0M'
+      },
+      'signals': {
+        'event': [
+          {
+            'intervals': [
+              {
+                'signalPayloads': [
+                  50
+                ],
+                'duration': 'PT20M',
+                'uid': '0'
+              }
+            ],
+            'signalName': 'ENERGY_PRICE',
+            'signalType': 'price',
+            'signalId': 'e6e7b114b6298cd9d055',
+            'currentValue': 50
+          }
+        ]
+      },
+      'target': {
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    }
+  ]
+};
+
+module.exports = {
+  generatedEvent1: distributeEvent1,
+  generatedEvent2: distributeEvent2,
+  epriEvent1
+};

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 43 - 0
__tests__/unit/xml/event/request-event.spec.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 17 - 0
__tests__/unit/xml/event/xml-requests.js


Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 372 - 0
__tests__/unit/xml/event/xml-responses.js


+ 3 - 3
package.json

@@ -7,9 +7,9 @@
     "start": "node index.js",
     "test": "npm run unit",
     "unit": "NODE_ENV=test _mocha $(find __tests__ -name \"*.spec.js\")",
-    "lint": "eslint client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js && prettier --check client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js",
-    "fixlint": "eslint --fix client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js",
-    "fixprettier": "prettier --write client/** config/** db/** modules/** processes/** server/** xml/** __tests__/** *.js"
+    "lint": "eslint client/**/*.js config/**/*.js db/**/*.js modules/**/*.js processes/**/*.js server/**/*.js xml/**/*.js __tests__/**/*.js && prettier --check client/**/*.js config/**/*.js db/**/*.js modules/**/*.js processes/**/*.js server/**/*.js xml/**/*.js __tests__/**/*.js",
+    "fixlint": "eslint --fix client/**/*.js config/**/*.js db/**/*.js modules/**/*.js processes/**/*.js server/**/*.js xml/**/*.js __tests__/**/*.js",
+    "fixprettier": "prettier --write client/**/*.js config/**/*.js db/**/*.js modules/**/*.js processes/**/*.js server/**/*.js xml/**/*.js __tests__/***.js"
   },
   "repository": {
     "type": "git",

+ 25 - 8
server/controllers/register-party.js

@@ -2,8 +2,12 @@
 
 const logger = require('../../logger');
 const { parse } = require('../../xml/register-party');
-const { serialize: serializeCreatedPartyRegistration } = require('../../xml/register-party/created-party-registration');
-const { serialize: serializeCanceledPartyRegistration } = require('../../xml/register-party/canceled-party-registration');
+const {
+  serialize: serializeCreatedPartyRegistration,
+} = require('../../xml/register-party/created-party-registration');
+const {
+  serialize: serializeCanceledPartyRegistration,
+} = require('../../xml/register-party/canceled-party-registration');
 
 const {
   cancelParty,
@@ -20,18 +24,30 @@ exports.postController = async (req, res) => {
   try {
     parsedRequest = await parse(xmlRequest);
     let response;
-    switch(parsedRequest._type) {
+    switch (parsedRequest._type) {
       case 'oadrCreatePartyRegistration':
         serialize = serializeCreatedPartyRegistration;
-        response = await registerParty(parsedRequest, req.clientCertificateCn, req.clientCertificateFingerprint);
+        response = await registerParty(
+          parsedRequest,
+          req.clientCertificateCn,
+          req.clientCertificateFingerprint,
+        );
         break;
       case 'oadrCancelPartyRegistration':
         serialize = serializeCanceledPartyRegistration;
-        response = await cancelParty(parsedRequest, req.clientCertificateCn, req.clientCertificateFingerprint);
+        response = await cancelParty(
+          parsedRequest,
+          req.clientCertificateCn,
+          req.clientCertificateFingerprint,
+        );
         break;
       case 'oadrQueryRegistration':
         serialize = serializeCreatedPartyRegistration;
-        response = await query(parsedRequest, req.clientCertificateCn, req.clientCertificateFingerprint);
+        response = await query(
+          parsedRequest,
+          req.clientCertificateCn,
+          req.clientCertificateFingerprint,
+        );
         break;
       default:
         throw new Error(`Unknown _type: ${parsedRequest._type}`);
@@ -39,11 +55,12 @@ exports.postController = async (req, res) => {
     xmlResponse = serialize(response);
   } catch (e) {
     logger.warn('Error occurred processing', parsedRequest, e);
-    const responseRequestId = (parsedRequest != null) ? parsedRequest.requestId : '';
+    const responseRequestId =
+      parsedRequest != null ? parsedRequest.requestId : '';
     xmlResponse = serialize({
       responseCode: e.responseCode || '454',
       responseDescription: e.message || 'Unknown error',
-      responseRequestId: responseRequestId || ''
+      responseRequestId: responseRequestId || '',
     });
   }
   res.set('Content-Type', 'application/xml');

+ 10 - 3
server/middleware/certificate-parser.js

@@ -1,9 +1,14 @@
 'use strict';
 
-const { calculatePartialFingerprintOfEscapedPemCertificate } = require('../../modules/certificate');
+const {
+  calculatePartialFingerprintOfEscapedPemCertificate,
+} = require('../../modules/certificate');
 
 module.exports = async (req, res, next) => {
-  if (req.headers['ssl_client_s_dn_cn'] && req.headers['ssl_client_s_dn_cn'] !== 'no_client_cert') {
+  if (
+    req.headers['ssl_client_s_dn_cn'] &&
+    req.headers['ssl_client_s_dn_cn'] !== 'no_client_cert'
+  ) {
     req.clientCertificateCn = req.headers['ssl_client_s_dn_cn'];
   } else {
     const err = new Error('Unauthorized');
@@ -13,7 +18,9 @@ module.exports = async (req, res, next) => {
 
   if (req.headers['ssl_client_certificate']) {
     const pemCertificateEscaped = req.headers['ssl_client_certificate'];
-    const fingerprint = calculatePartialFingerprintOfEscapedPemCertificate(pemCertificateEscaped);
+    const fingerprint = calculatePartialFingerprintOfEscapedPemCertificate(
+      pemCertificateEscaped,
+    );
     req.clientCertificateFingerprint = fingerprint;
   } else {
     const err = new Error('Unauthorized');

+ 989 - 0
xml/event/distribute-event.js

@@ -0,0 +1,989 @@
+'use strict';
+
+const {
+  parseXML,
+  childAttr,
+  required,
+  boolean,
+  dateTime,
+  duration,
+  number,
+} = require('../parser');
+const { create, fragment } = require('xmlbuilder2');
+
+const xsiNs = 'http://www.w3.org/2001/XMLSchema-instance';
+const oadrPayloadNs = 'http://www.w3.org/2000/09/xmldsig#';
+const oadrNs = 'http://openadr.org/oadr-2.0b/2012/07';
+const emixNs = 'http://docs.oasis-open.org/ns/emix/2011/06';
+const powerNs = 'http://docs.oasis-open.org/ns/emix/2011/06/power';
+const energyInteropNs = 'http://docs.oasis-open.org/ns/energyinterop/201110';
+const energyInteropPayloadsNs =
+  'http://docs.oasis-open.org/ns/energyinterop/201110/payloads';
+const calendarNs = 'urn:ietf:params:xml:ns:icalendar-2.0';
+const calendarStreamNs = 'urn:ietf:params:xml:ns:icalendar-2.0:stream';
+const siScaleNs = 'http://docs.oasis-open.org/ns/emix/2011/06/siscale';
+
+const eiTargetMappings = [
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'endDeviceAsset',
+    xmlChildElement: 'mrid',
+    json: 'endDeviceAsset',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'aggregatedPnode',
+    xmlChildElement: 'node',
+    json: 'aggregatedPnode',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'meterAsset',
+    xmlChildElement: 'mrid',
+    json: 'meterAsset',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'pnode',
+    xmlChildElement: 'node',
+    json: 'pNode',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'serviceDeliveryPoint',
+    xmlChildElement: 'node',
+    json: 'serviceDeliveryPoint',
+  },
+  {
+    xmlNs: 'ei',
+    xmlNsUri: energyInteropNs,
+    xmlElement: 'groupID',
+    json: 'groupId',
+  },
+  {
+    xmlNs: 'ei',
+    xmlNsUri: energyInteropNs,
+    xmlElement: 'groupName',
+    json: 'groupName',
+  },
+  {
+    xmlNs: 'ei',
+    xmlNsUri: energyInteropNs,
+    xmlElement: 'resourceID',
+    json: 'resourceId',
+  },
+  {
+    xmlNs: 'ei',
+    xmlNsUri: energyInteropNs,
+    xmlElement: 'venID',
+    json: 'venId',
+  },
+  {
+    xmlNs: 'ei',
+    xmlNsUri: energyInteropNs,
+    xmlElement: 'partyID',
+    json: 'partyId',
+  },
+];
+
+const itemBaseMappings = [
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'powerReal',
+    additionalAttributes: [
+      {
+        xmlElement: 'powerAttributes',
+        json: 'powerAttributes',
+      },
+    ],
+    json: 'powerReal',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'powerReactive',
+    additionalAttributes: [
+      {
+        xmlElement: 'powerAttributes',
+        json: 'powerAttributes',
+      },
+    ],
+    json: 'powerReactive',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'powerApparent',
+    additionalAttributes: [
+      {
+        xmlElement: 'powerAttributes',
+        json: 'powerAttributes',
+      },
+    ],
+    json: 'powerApparent',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'voltage',
+    json: 'voltage',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'energyApparent',
+    json: 'energyApparent',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'energyReactive',
+    json: 'energyReactive',
+  },
+  {
+    xmlNs: 'power',
+    xmlNsUri: powerNs,
+    xmlElement: 'energyReal',
+    json: 'energyReal',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'currencyPerKWh',
+    json: 'currencyPerKWh',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'currencyPerKW',
+    json: 'currencyPerKW',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'currencyPerThm',
+    json: 'currencyPerThm',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'currency',
+    json: 'currency',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'current',
+    json: 'current',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'frequency',
+    json: 'frequency',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'Therm',
+    json: 'therm',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'temperature',
+    json: 'temperature',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'pulseCount',
+    json: 'pulseCount',
+  },
+  {
+    xmlNs: 'oadr',
+    xmlNsUri: oadrNs,
+    xmlElement: 'customUnit',
+    json: 'customUnit',
+  },
+];
+
+function parseItemBase(x) {
+  for (const itemBaseMapping of itemBaseMappings) {
+    const { json, xmlElement, additionalAttributes } = itemBaseMapping;
+    const itemBaseList = x[xmlElement];
+    if (itemBaseList) {
+      const itemBase = itemBaseList[0]['$$'];
+      const result = {
+        type: json,
+        description: required(
+          childAttr(itemBase, 'itemDescription'),
+          'itemDescription',
+        ),
+        units: required(childAttr(itemBase, 'itemUnits'), 'itemUnits'),
+        siScaleCode: required(
+          childAttr(itemBase, 'siScaleCode'),
+          'siScaleCode',
+        ),
+      };
+      for (const additionalMapping of additionalAttributes || []) {
+        const unNamespacedAdditionalAttribute = additionalMapping.xmlElement;
+        const attributesList = itemBase[unNamespacedAdditionalAttribute];
+        if (attributesList) {
+          const xmlAttributes = attributesList[0]['$$'];
+          const attributes = {};
+          Object.keys(xmlAttributes).forEach(key => {
+            attributes[key] = required(childAttr(xmlAttributes, key), key);
+          });
+          result[additionalMapping.json] = attributes;
+        }
+      }
+      return result;
+    }
+  }
+}
+
+function serializeItemBase(x) {
+  for (const itemBaseMapping of itemBaseMappings) {
+    const {
+      xmlNs,
+      xmlNsUri,
+      json,
+      xmlElement,
+      additionalAttributes,
+    } = itemBaseMapping;
+    if (x.type === json) {
+      const result = fragment();
+      const innerResult = result.ele(xmlNsUri, `${xmlNs}:${xmlElement}`);
+      innerResult.ele(xmlNsUri, xmlNs + ':itemDescription').txt(x.description);
+      innerResult.ele(xmlNsUri, xmlNs + ':itemUnits').txt(x.units);
+      innerResult.ele(siScaleNs, 'scale:siScaleCode').txt(x.siScaleCode);
+      if (additionalAttributes) {
+        additionalAttributes.forEach(additionalAttribute => {
+          const additionalResult = innerResult.ele(
+            xmlNsUri,
+            `${xmlNs}:${additionalAttribute.xmlElement}`,
+          );
+          const additionalJson = x[additionalAttribute.json];
+          Object.keys(additionalJson).forEach(key => {
+            additionalResult
+              .ele(xmlNsUri, `${xmlNs}:${key}`)
+              .txt(additionalJson[key]);
+          });
+        });
+      }
+      return result;
+    }
+  }
+}
+
+function parseEiResponse(response) {
+  return {
+    code: required(childAttr(response, 'responseCode'), 'responseCode'),
+    description: childAttr(response, 'responseDescription'),
+    requestId: required(childAttr(response, 'requestID'), 'requestID'),
+  };
+}
+
+function parseEventDescriptor(eventDescriptor) {
+  const result = {
+    eventId: required(childAttr(eventDescriptor, 'eventID'), 'eventID'),
+    modificationNumber: required(
+      number(childAttr(eventDescriptor, 'modificationNumber')),
+      'modificationNumber',
+    ),
+    marketContext: childAttr(
+      eventDescriptor.eiMarketContext[0]['$$'],
+      'marketContext',
+    ),
+    createdDateTime: required(
+      childAttr(eventDescriptor, 'createdDateTime'),
+      'createdDateTime',
+    ),
+    eventStatus: required(
+      childAttr(eventDescriptor, 'eventStatus'),
+      'eventStatus',
+    ),
+  };
+
+  const testEvent = boolean(childAttr(eventDescriptor, 'testEvent'));
+  if (testEvent != null) {
+    result.testEvent = testEvent;
+  }
+
+  const modificationDateTime = childAttr(
+    eventDescriptor,
+    'modificationDateTime',
+  );
+  if (modificationDateTime != null)
+    result.modificationDateTime = modificationDateTime;
+
+  const modificationReason = childAttr(eventDescriptor, 'modificationReason');
+  if (modificationReason != null)
+    result.modificationReason = modificationReason;
+
+  const priority = number(childAttr(eventDescriptor, 'priority'));
+  if (priority != null) result.priority = priority;
+
+  const vtnComment = childAttr(eventDescriptor, 'vtnComment');
+  if (vtnComment != null) result.vtnComment = vtnComment;
+
+  return result;
+}
+
+function serializeEventDescriptor(eventDescriptor) {
+  const result = fragment();
+
+  const eventDescriptorResult = result.ele(
+    energyInteropNs,
+    'ei:eventDescriptor',
+  );
+
+  eventDescriptorResult
+    .ele(energyInteropNs, 'ei:eventID')
+    .txt(eventDescriptor.eventId)
+    .up()
+    .ele(energyInteropNs, 'ei:modificationNumber')
+    .txt(eventDescriptor.modificationNumber)
+    .up()
+    .ele(energyInteropNs, 'ei:eiMarketContext')
+    .ele(emixNs, 'emix:marketContext')
+    .txt(eventDescriptor.marketContext)
+    .up()
+    .up()
+    .ele(energyInteropNs, 'ei:createdDateTime')
+    .txt(eventDescriptor.createdDateTime)
+    .up()
+    .ele(energyInteropNs, 'ei:eventStatus')
+    .txt(eventDescriptor.eventStatus);
+
+  if (eventDescriptor.testEvent != null) {
+    eventDescriptorResult
+      .ele(energyInteropNs, 'ei:testEvent')
+      .txt(eventDescriptor.testEvent);
+  }
+
+  if (eventDescriptor.modificationDateTime != null) {
+    eventDescriptorResult
+      .ele(energyInteropNs, 'ei:modificationDateTime')
+      .txt(eventDescriptor.modificationDateTime);
+  }
+
+  if (eventDescriptor.modificationReason != null) {
+    eventDescriptorResult
+      .ele(energyInteropNs, 'ei:modificationReason')
+      .txt(eventDescriptor.modificationReason);
+  }
+
+  if (eventDescriptor.priority != null) {
+    eventDescriptorResult
+      .ele(energyInteropNs, 'ei:priority')
+      .txt(eventDescriptor.priority);
+  }
+
+  if (eventDescriptor.vtnComment != null) {
+    eventDescriptorResult
+      .ele(energyInteropNs, 'ei:vtnComment')
+      .txt(eventDescriptor.vtnComment);
+  }
+
+  return result;
+}
+
+function parseToleranceTolerateStartAfter(tolerance) {
+  if (tolerance) {
+    const tolerate = childAttr(tolerance['$$'], 'tolerate');
+    if (tolerate) {
+      return duration(tolerate, 'startafter');
+    }
+  }
+}
+
+function parsePayloadFloat(payloadFloatInput) {
+  const payloadFloat = required(
+    childAttr(payloadFloatInput, 'payloadFloat'),
+    'payloadFloat',
+  )['$$'];
+  return required(number(childAttr(payloadFloat, 'value')), 'value');
+}
+
+function serializePayloadFloat(payloadFloat) {
+  const result = fragment();
+  result
+    .ele(energyInteropNs, 'ei:payloadFloat')
+    .ele(energyInteropNs, 'ei:value')
+    .txt(payloadFloat);
+  return result;
+}
+
+function serializeSignalPayload(signalPayload) {
+  const result = fragment();
+  result
+    .ele(energyInteropNs, 'ei:signalPayload')
+    .import(serializePayloadFloat(signalPayload));
+  return result;
+}
+
+function parseSignalPayloads(signalPayloads) {
+  return signalPayloads.map(x => parsePayloadFloat(x['$$']));
+}
+
+function serializeSignalPayloads(signalPayloads) {
+  return signalPayloads.map(x => serializeSignalPayload(x));
+}
+
+function parseEventSignalInterval(eventSignalInterval) {
+  const result = {
+    signalPayloads: parseSignalPayloads(eventSignalInterval.signalPayload),
+  };
+
+  const durationValue = duration(
+    childAttr(eventSignalInterval, 'duration'),
+    'duration',
+  );
+  if (durationValue != null) result.duration = durationValue;
+
+  const dtStartValue = dateTime(
+    childAttr(eventSignalInterval, 'dtstart'),
+    'date-time',
+  );
+  if (dtStartValue != null) {
+    result.startDate = dtStartValue;
+  }
+
+  const uidHolder = childAttr(eventSignalInterval, 'uid');
+  if (uidHolder != null) {
+    result.uid = required(childAttr(uidHolder['$$'], 'text'));
+  }
+
+  return result;
+}
+
+function serializeEventSignalInterval(eventSignalInterval) {
+  const result = fragment();
+  const interval = result.ele(energyInteropNs, 'ei:interval');
+
+  if (eventSignalInterval.duration) {
+    interval
+      .ele(calendarNs, 'cal:duration')
+      .import(serializeDuration(eventSignalInterval.duration));
+  }
+
+  if (eventSignalInterval.startDate) {
+    interval
+      .ele(calendarNs, 'cal:dtstart')
+      .import(serializeDateTime(eventSignalInterval.startDate));
+  }
+
+  if (eventSignalInterval.uid) {
+    interval
+      .ele(calendarNs, 'cal:uid')
+      .ele(calendarNs, 'cal:text')
+      .txt(eventSignalInterval.uid);
+  }
+
+  serializeSignalPayloads(eventSignalInterval.signalPayloads).forEach(payload =>
+    interval.import(payload),
+  );
+  return result;
+}
+
+function parseEventSignalIntervals(eventSignalIntervals) {
+  if (!eventSignalIntervals) {
+    return [];
+  }
+  return eventSignalIntervals['interval'].map(x =>
+    parseEventSignalInterval(x['$$']),
+  );
+}
+
+function serializeEventSignalIntervals(eventSignalIntervals) {
+  return eventSignalIntervals.map(x => serializeEventSignalInterval(x));
+}
+
+function parseEiTarget(eiTarget) {
+  const result = {};
+  for (const eiTargetMapping of eiTargetMappings) {
+    const unNamespacedAttribute = eiTargetMapping.xmlElement;
+    if (eiTarget[unNamespacedAttribute]) {
+      const eiTargetValue = eiTarget[unNamespacedAttribute];
+      let newValues;
+      if (eiTargetMapping.xmlChildElement) {
+        const unNamespacedChildAttribute = eiTargetMapping.xmlChildElement;
+        newValues = eiTargetValue[0]['$$'][unNamespacedChildAttribute];
+      } else {
+        newValues = eiTargetValue;
+      }
+      const existing = result[eiTargetMapping.json] || [];
+      result[eiTargetMapping.json] = [...existing, ...newValues];
+    }
+  }
+  return result;
+}
+
+function serializeEiTarget(eiTarget) {
+  const result = fragment();
+  const targetElement = result.ele(energyInteropNs, 'ei:eiTarget');
+
+  for (const eiTargetMapping of eiTargetMappings) {
+    if (eiTarget[eiTargetMapping.json]) {
+      eiTarget[eiTargetMapping.json].forEach(target => {
+        const {
+          xmlNs,
+          xmlNsUri,
+          xmlElement,
+          xmlChildElement,
+        } = eiTargetMapping;
+        if (xmlChildElement) {
+          targetElement
+            .ele(xmlNsUri, `${xmlNs}:${xmlElement}`)
+            .ele(xmlNsUri, `${xmlNs}:${xmlChildElement}`)
+            .txt(target);
+        } else {
+          targetElement.ele(xmlNsUri, `${xmlNs}:${xmlElement}`).txt(target);
+        }
+      });
+    }
+  }
+
+  return result;
+}
+
+function parseEventSignal(eventSignal) {
+  const result = {
+    intervals: parseEventSignalIntervals(eventSignal['intervals'][0]['$$']),
+    signalName: required(childAttr(eventSignal, 'signalName'), 'signalName'),
+    signalType: required(childAttr(eventSignal, 'signalType'), 'signalType'),
+    signalId: required(childAttr(eventSignal, 'signalID'), 'signalID'),
+  };
+
+  const eiTarget = childAttr(eventSignal, 'eiTarget');
+  if (eiTarget != null) {
+    result.target = parseEiTarget(eiTarget['$$']);
+  }
+
+  const currentValue = childAttr(eventSignal, 'currentValue');
+  if (currentValue != null)
+    result.currentValue = parsePayloadFloat(currentValue['$$']);
+
+  const itemBase = parseItemBase(eventSignal);
+  if (itemBase != null) result.itemBase = itemBase;
+
+  return result;
+}
+
+function serializeEventSignal(eventSignal) {
+  const result = fragment();
+  const eiEventSignal = result.ele(energyInteropNs, 'ei:eiEventSignal');
+
+  const intervals = eiEventSignal.ele(calendarStreamNs, 'strm:intervals');
+
+  serializeEventSignalIntervals(eventSignal.intervals).forEach(interval =>
+    intervals.import(interval),
+  );
+
+  eiEventSignal.ele(energyInteropNs, 'ei:signalID').txt(eventSignal.signalId);
+  eiEventSignal
+    .ele(energyInteropNs, 'ei:signalName')
+    .txt(eventSignal.signalName);
+  eiEventSignal
+    .ele(energyInteropNs, 'ei:signalType')
+    .txt(eventSignal.signalType);
+
+  if (eventSignal.target) {
+    eiEventSignal.import(serializeEiTarget(eventSignal.target));
+  }
+
+  if (eventSignal.currentValue != null) {
+    eiEventSignal
+      .ele(energyInteropNs, 'ei:currentValue')
+      .import(serializePayloadFloat(eventSignal.currentValue));
+  }
+
+  if (eventSignal.itemBase != null) {
+    eiEventSignal.import(serializeItemBase(eventSignal.itemBase));
+  }
+
+  return result;
+}
+
+function parseEventBaseline(eventSignal) {
+  const result = {
+    startDate: required(
+      dateTime(childAttr(eventSignal, 'dtstart'), 'date-time'),
+      'dtstart',
+    ),
+    duration: required(
+      duration(childAttr(eventSignal, 'duration'), 'duration'),
+      'duration',
+    ),
+    intervals: parseEventSignalIntervals(eventSignal['intervals'][0]['$$']),
+    baselineId: required(childAttr(eventSignal, 'baselineID'), 'baselineID'),
+    baselineName: required(
+      childAttr(eventSignal, 'baselineName'),
+      'baselineName',
+    ),
+  };
+
+  const itemBase = parseItemBase(eventSignal);
+  if (itemBase != null) result.itemBase = itemBase;
+
+  return result;
+}
+
+function serializeEventBaseline(eventBaseline) {
+  const result = fragment();
+  const eiEventBaseline = result.ele(energyInteropNs, 'ei:eiEventBaseline');
+  eiEventBaseline
+    .ele(energyInteropNs, 'ei:baselineID')
+    .txt(eventBaseline.baselineId);
+  eiEventBaseline
+    .ele(energyInteropNs, 'ei:baselineName')
+    .txt(eventBaseline.baselineName);
+
+  if (eventBaseline.duration) {
+    eiEventBaseline
+      .ele(calendarNs, 'cal:duration')
+      .import(serializeDuration(eventBaseline.duration));
+  }
+
+  if (eventBaseline.startDate) {
+    eiEventBaseline
+      .ele(calendarNs, 'cal:dtstart')
+      .import(serializeDateTime(eventBaseline.startDate));
+  }
+
+  const intervals = eiEventBaseline.ele(calendarStreamNs, 'strm:intervals');
+  serializeEventSignalIntervals(eventBaseline.intervals).forEach(interval =>
+    intervals.import(interval),
+  );
+
+  if (eventBaseline.itemBase != null) {
+    eiEventBaseline.import(serializeItemBase(eventBaseline.itemBase));
+  }
+
+  return result;
+}
+
+function parseEiEventSignals(eiEventSignals) {
+  const wrappedEventSignals = eiEventSignals.eiEventSignal;
+  const wrappedBaselines = eiEventSignals.eiEventBaseline;
+
+  const result = {};
+
+  if (wrappedEventSignals) {
+    result.event = wrappedEventSignals.map(x => parseEventSignal(x['$$']));
+  }
+
+  if (wrappedBaselines) {
+    result.baseline = wrappedBaselines.map(x => parseEventBaseline(x['$$']));
+  }
+
+  return result;
+}
+
+function serializeEiEventSignals(eiEventSignals) {
+  const eventSignals = eiEventSignals.event.map(x => serializeEventSignal(x));
+  const eventBaselines = eiEventSignals.baseline
+    ? eiEventSignals.baseline.map(x => serializeEventBaseline(x))
+    : [];
+
+  return [...eventSignals, ...eventBaselines];
+}
+
+function parseEiActivePeriod(activePeriod) {
+  const properties = activePeriod['properties'][0]['$$'];
+  const result = {
+    startDate: required(
+      dateTime(childAttr(properties, 'dtstart'), 'date-time'),
+      'dtstart',
+    ),
+    duration: required(
+      duration(childAttr(properties, 'duration'), 'duration'),
+      'duration',
+    ),
+  };
+
+  const toleranceTolerateStartAfter = parseToleranceTolerateStartAfter(
+    childAttr(properties, 'tolerance'),
+  );
+  if (toleranceTolerateStartAfter != null) {
+    result.toleranceTolerateStartAfter = toleranceTolerateStartAfter;
+  }
+
+  const notificationDuration = duration(
+    childAttr(properties, 'x-eiNotification'),
+    'duration',
+  );
+  if (notificationDuration != null) {
+    result.notificationDuration = notificationDuration;
+  }
+
+  const rampUpDuration = duration(
+    childAttr(properties, 'x-eiRampUp'),
+    'duration',
+  );
+  if (rampUpDuration != null) {
+    result.rampUpDuration = rampUpDuration;
+  }
+
+  const recoveryDuration = duration(
+    childAttr(properties, 'x-eiRecovery'),
+    'duration',
+  );
+  if (recoveryDuration != null) {
+    result.recoveryDuration = recoveryDuration;
+  }
+
+  return result;
+}
+
+function serializeDateTime(dateTime) {
+  return fragment()
+    .ele(calendarNs, 'cal:date-time')
+    .txt(dateTime)
+    .up();
+}
+
+function serializeToleranceTolerateStartAfter(duration) {
+  const result = fragment();
+  result
+    .ele(calendarNs, 'cal:tolerance')
+    .ele(calendarNs, 'cal:tolerate')
+    .ele(calendarNs, 'cal:startafter')
+    .txt(duration);
+  return result;
+}
+
+function serializeActivePeriod(activePeriod) {
+  const result = fragment();
+
+  const activePeriodResult = result.ele(energyInteropNs, 'ei:eiActivePeriod');
+  const properties = activePeriodResult.ele(calendarNs, 'cal:properties');
+  const components = activePeriodResult.ele(calendarNs, 'cal:components');
+  components.att(xsiNs, 'xsi:nil', 'true');
+
+  properties
+    .ele(calendarNs, 'cal:dtstart')
+    .import(serializeDateTime(activePeriod.startDate))
+    .up()
+    .ele(calendarNs, 'cal:duration')
+    .import(serializeDuration(activePeriod.duration))
+    .up();
+
+  if (activePeriod.toleranceTolerateStartAfter) {
+    properties.import(
+      serializeToleranceTolerateStartAfter(
+        activePeriod.toleranceTolerateStartAfter,
+      ),
+    );
+  }
+
+  if (activePeriod.notificationDuration) {
+    properties
+      .ele(energyInteropNs, 'ei:x-eiNotification')
+      .import(serializeDuration(activePeriod.notificationDuration));
+  }
+
+  if (activePeriod.rampUpDuration) {
+    properties
+      .ele(energyInteropNs, 'ei:x-eiRampUp')
+      .import(serializeDuration(activePeriod.rampUpDuration));
+  }
+
+  if (activePeriod.recoveryDuration) {
+    properties
+      .ele(energyInteropNs, 'ei:x-eiRecovery')
+      .import(serializeDuration(activePeriod.recoveryDuration));
+  }
+
+  return result;
+}
+
+function parseEiEvent(eiEvent) {
+  return {
+    eventDescriptor: parseEventDescriptor(eiEvent.eventDescriptor[0]['$$']),
+    activePeriod: parseEiActivePeriod(eiEvent.eiActivePeriod[0]['$$']),
+    signals: parseEiEventSignals(eiEvent.eiEventSignals[0]['$$']),
+    target: parseEiTarget(eiEvent.eiTarget[0]['$$']),
+  };
+}
+
+function serializeEiEvent(eiEvent) {
+  const result = fragment();
+
+  const eiEventResult = result.ele(energyInteropNs, 'ei:eiEvent');
+
+  eiEventResult.import(serializeEventDescriptor(eiEvent.eventDescriptor));
+  eiEventResult.import(serializeActivePeriod(eiEvent.activePeriod));
+
+  const eiEventSignals = eiEventResult.ele(
+    energyInteropNs,
+    'ei:eiEventSignals',
+  );
+
+  serializeEiEventSignals(eiEvent.signals).forEach(signal =>
+    eiEventSignals.import(signal),
+  );
+
+  if (eiEvent.target) {
+    eiEventResult.import(serializeEiTarget(eiEvent.target));
+  }
+
+  return result;
+}
+
+function parseEvents(eventList) {
+  const events = [];
+  for (const wrappedEvent of eventList) {
+    const event = wrappedEvent['$$'];
+    const parsedEiEvent = parseEiEvent(event.eiEvent[0]['$$']);
+    parsedEiEvent.responseRequired = childAttr(event, 'oadrResponseRequired');
+    events.push(parsedEiEvent);
+  }
+  return events;
+}
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'][
+      'oadrDistributeEvent'
+    ][0]['$$'];
+
+  const { code, description, requestId: responseRequestId } = parseEiResponse(
+    o['eiResponse'][0]['$$'],
+  );
+
+  const result = {
+    responseCode: code,
+    responseDescription: description,
+    responseRequestId: responseRequestId,
+  };
+
+  if (code < 200 || code >= 300) {
+    return result;
+  }
+
+  const payloadRequestId = childAttr(o, 'requestID');
+  if (payloadRequestId != null) result.requestId = payloadRequestId;
+
+  const vtnId = childAttr(o, 'vtnID');
+  if (vtnId != null) result.vtnId = vtnId;
+
+  const events = parseEvents(o['oadrEvent']);
+
+  result.events = events;
+
+  return result;
+}
+
+function serializeEiResponse(code, description, requestId) {
+  const descriptionFrag =
+    description != null
+      ? fragment()
+        .ele(energyInteropNs, 'ei:responseDescription')
+        .txt(description)
+      : fragment();
+  return fragment()
+    .ele(energyInteropNs, 'ei:responseCode')
+    .txt(code)
+    .up()
+    .import(descriptionFrag)
+    .ele(energyInteropPayloadsNs, 'pyld:requestID')
+    .txt(requestId)
+    .up();
+}
+
+function serializeDuration(duration) {
+  return duration != null
+    ? fragment()
+      .ele(calendarNs, 'cal:duration')
+      .txt(duration)
+    : fragment();
+}
+
+function validate(obj) {
+  if (!obj.responseCode) {
+    throw new Error('Missing responseCode');
+  }
+  if (!obj.responseRequestId) {
+    throw new Error('Missing responseRequestId');
+  }
+}
+
+function serializeOadrEvent(event) {
+  const result = fragment();
+  const oadrEvent = result.ele(oadrNs, 'oadr2b:oadrEvent');
+  oadrEvent.import(serializeEiEvent(event));
+  oadrEvent
+    .ele(oadrNs, 'oadr2b:oadrResponseRequired')
+    .txt(event.responseRequired);
+  return result;
+}
+
+function serializeEvents(events) {
+  const result = fragment();
+  events.forEach(event => {
+    result.import(serializeOadrEvent(event));
+  });
+
+  return result;
+}
+
+function serialize(obj) {
+  validate(obj);
+
+  const vtnId =
+    obj.vtnId != null
+      ? fragment()
+        .ele(energyInteropNs, 'ei:vtnID')
+        .txt(obj.vtnId)
+      : fragment();
+
+  const requestId =
+    obj.requestId != null
+      ? fragment()
+        .ele(oadrPayloadNs, 'pyld:requestID')
+        .txt(obj.requestId)
+      : fragment();
+
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+      pyld: energyInteropPayloadsNs,
+      cal: calendarNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrDistributeEvent')
+    .att('@ei', 'ei:schemaVersion', '2.0b')
+    .ele('@ei', 'ei:eiResponse')
+    .import(
+      serializeEiResponse(
+        obj.responseCode,
+        obj.responseDescription,
+        obj.responseRequestId,
+      ),
+    )
+    .up()
+    .import(vtnId)
+    .import(requestId)
+    .import(serializeEvents(obj.events))
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};

+ 20 - 0
xml/event/index.js

@@ -0,0 +1,20 @@
+'use strict';
+
+const { parseXML } = require('../parser');
+
+const { parse: parseRequestEvent } = require('./request-event');
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o = json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'];
+
+  if (o['oadrRequestEvent2']) {
+    return await parseRequestEvent(input);
+  }
+
+  throw new Error(`Unexpected payload type: ${Object.keys(o)}`);
+}
+
+module.exports = {
+  parse,
+};

+ 71 - 0
xml/event/request-event.js

@@ -0,0 +1,71 @@
+'use strict';
+
+const { parseXML, childAttr, required } = require('../parser');
+const { create, fragment } = require('xmlbuilder2');
+
+const oadrPayloadNs = 'http://www.w3.org/2000/09/xmldsig#';
+const oadrNs = 'http://openadr.org/oadr-2.0b/2012/07';
+const energyInteropNs = 'http://docs.oasis-open.org/ns/energyinterop/201110';
+const energyInteropPayloadsNs =
+  'http://docs.oasis-open.org/ns/energyinterop/201110/payloads';
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'][
+      'oadrRequestEvent'
+    ][0]['$$']['eiRequestEvent'][0]['$$'];
+
+  const result = {
+    _type: 'oadrRequestEvent',
+    requestId: required(childAttr(o, 'requestID'), 'requestID'),
+    venId: required(childAttr(o, 'venID'), 'venID'),
+  };
+
+  const replyLimit = childAttr(o, 'replyLimit');
+  if (replyLimit != null) result.replyLimit = replyLimit;
+
+  return result;
+}
+
+function serializeEiRequestEvent(requestId, venId, replyLimit) {
+  const replyLimitFrag =
+    replyLimit != null
+      ? fragment()
+        .ele(energyInteropPayloadsNs, 'pyld:replyLimit')
+        .txt(replyLimit)
+      : fragment();
+
+  return fragment()
+    .ele(energyInteropPayloadsNs, 'pyld:requestID')
+    .txt(requestId)
+    .up()
+    .ele(energyInteropNs, 'ei:venID')
+    .txt(venId)
+    .up()
+    .import(replyLimitFrag);
+}
+
+function serialize(obj) {
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+      pyld: energyInteropPayloadsNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrRequestEvent')
+    .att(energyInteropNs, 'ei:schemaVersion', '2.0b')
+    .ele('@pyld', 'pyld:eiRequestEvent')
+    .import(serializeEiRequestEvent(obj.requestId, obj.venId, obj.replyLimit))
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};

+ 36 - 0
xml/parser.js

@@ -45,6 +45,39 @@ function boolean(input, errorCode = 452) {
   throw error;
 }
 
+function dateTime(input, key, errorCode = 452) {
+  if (input) {
+    if (input['$$']) {
+      return childAttr(input['$$'], key, errorCode);
+    }
+    throw new Error(`unknown dateTime: ${input}`);
+  }
+}
+
+function duration(input, key, errorCode = 452) {
+  if (input) {
+    if (input['$$']) {
+      return childAttr(input['$$'], key, errorCode);
+    }
+    throw new Error(`unknown duration: ${input}`);
+  }
+}
+
+function number(input, errorCode = 452) {
+  const trimmed = (input || '').toLowerCase().trim();
+  const parsed = Number(trimmed);
+
+  if (trimmed === '') {
+    return undefined;
+  } else if (isNaN(parsed)) {
+    const error = new Error(`Not a number: ${input}`);
+    error.errorCode = errorCode;
+    throw error;
+  } else {
+    return parsed;
+  }
+}
+
 function required(input, key, errorCode = 452) {
   if (input == null) {
     const error = new Error(`Missing required value: ${key}`);
@@ -57,6 +90,9 @@ function required(input, key, errorCode = 452) {
 module.exports = {
   parseXML,
   childAttr,
+  dateTime,
+  duration,
   boolean,
   required,
+  number,
 };

+ 1 - 1
xml/register-party/canceled-party-registration.js

@@ -94,7 +94,7 @@ function serialize(obj) {
       ns: oadrPayloadNs,
       oadr2b: oadrNs,
       ei: energyInteropNs,
-      pyld: energyInteropPayloadsNs
+      pyld: energyInteropPayloadsNs,
     },
   })
     .ele('@oadr2b', 'oadr2b:oadrPayload')

+ 7 - 3
xml/register-party/index.js

@@ -2,9 +2,13 @@
 
 const { parseXML } = require('../parser');
 
-const { parse: parseCreatePartyRegistration } = require('./create-party-registration');
+const {
+  parse: parseCreatePartyRegistration,
+} = require('./create-party-registration');
 const { parse: parseQueryRegistration } = require('./query-registration');
-const { parse: parseCancelPartyRegistration } = require('./cancel-party-registration');
+const {
+  parse: parseCancelPartyRegistration,
+} = require('./cancel-party-registration');
 
 async function parse(input) {
   const json = await parseXML(input);
@@ -26,5 +30,5 @@ async function parse(input) {
 }
 
 module.exports = {
-  parse
+  parse,
 };

+ 2 - 2
xml/register-party/query-registration.js

@@ -18,7 +18,7 @@ async function parse(input) {
 
   return {
     _type: 'oadrQueryRegistration',
-    requestId: required(childAttr(o, 'requestID'), 'requestID')
+    requestId: required(childAttr(o, 'requestID'), 'requestID'),
   };
 }
 
@@ -28,7 +28,7 @@ function serialize(obj) {
       ns: oadrPayloadNs,
       oadr2b: oadrNs,
       ei: energyInteropNs,
-      pyld: energyInteropPayloadsNs
+      pyld: energyInteropPayloadsNs,
     },
   })
     .ele('@oadr2b', 'oadr2b:oadrPayload')