Преглед изворни кода

PROD-1221: oadrPoll/oadrCreatedEvent

Blake Schneider пре 5 година
родитељ
комит
39daae2d76
35 измењених фајлова са 3153 додато и 169 уклоњено
  1. 148 0
      __tests__/integration/end-to-end.spec.js
  2. 0 54
      __tests__/integration/ven-registration.spec.js
  3. 30 0
      __tests__/unit/modules/nantum-responses.js
  4. 80 0
      __tests__/unit/processes/event.spec.js
  5. 0 26
      __tests__/unit/processes/registration.spec.js
  6. 41 0
      __tests__/unit/xml/event/created-event.spec.js
  7. 20 1
      __tests__/unit/xml/event/js-requests.js
  8. 66 5
      __tests__/unit/xml/event/js-responses.js
  9. 1 1
      __tests__/unit/xml/event/request-event.spec.js
  10. 28 0
      __tests__/unit/xml/event/xml-requests.js
  11. 16 0
      __tests__/unit/xml/poll/js-requests.js
  12. 13 0
      __tests__/unit/xml/poll/js-responses.js
  13. 41 0
      __tests__/unit/xml/poll/oadr-poll.spec.js
  14. 41 0
      __tests__/unit/xml/poll/oadr-response.spec.js
  15. 13 0
      __tests__/unit/xml/poll/xml-requests.js
  16. 18 0
      __tests__/unit/xml/poll/xml-responses.js
  17. 74 0
      client/ven.js
  18. 2 2
      docker-compose.yml
  19. 70 0
      modules/nantum.js
  20. 1554 0
      package-lock.json
  21. 5 2
      package.json
  22. 294 0
      processes/event.js
  23. 65 71
      processes/registration.js
  24. 63 0
      server/controllers/event.js
  25. 58 0
      server/controllers/poll.js
  26. 10 0
      server/routes/event.js
  27. 2 0
      server/routes/index.js
  28. 10 0
      server/routes/poll.js
  29. 182 0
      xml/event/created-event.js
  30. 4 3
      xml/event/distribute-event.js
  31. 6 1
      xml/event/index.js
  32. 11 3
      xml/parser.js
  33. 28 0
      xml/poll/index.js
  34. 43 0
      xml/poll/oadr-poll.js
  35. 116 0
      xml/poll/oadr-response.js

+ 148 - 0
__tests__/integration/end-to-end.spec.js

@@ -0,0 +1,148 @@
+'use strict';
+
+const { readFileSync } = require('fs');
+const path = require('path');
+const { expect } = require('chai');
+const sinon = require('sinon');
+
+const { Ven } = require('../../client/ven');
+const app = require('../../server');
+const { sequelize, Ven: VenDb } = require('../../db');
+const { port } = require('../../config');
+
+describe('VEN registration', function() {
+
+  describe('successful registration and event retrieval', async function() {
+
+    let clock;
+
+    after(async () => {
+      clock.restore();
+    });
+
+    let ven;
+
+    before(async () => {
+      clock = sinon.useFakeTimers(new Date('2020-04-26T01:00:00.000Z').getTime());
+      await sequelize.sync();
+      await VenDb.destroy({ truncate : true});
+      await app.start();
+      const clientCrtPem = readFileSync(path.join(__dirname, 'integration-client.crt'), 'utf-8');
+      ven = new Ven(`http://127.0.0.1:${port}`, clientCrtPem, 'aabbccddeeff', '17:32:59:FD:0E:B5:99:31:27:9C', 'ven.js1');
+    });
+
+    it('should successfully return a vtnId from queryRegistration', async () => {
+      const queryResponse = await ven.queryRegistration();
+      expect(queryResponse.vtnId).to.be.a('string');
+    });
+
+    it('should successfully register and receive a vtnId and registrationId', async () => {
+      const registrationResponse = await ven.register();
+      expect(registrationResponse.vtnId).to.be.a('string');
+      expect(registrationResponse.registrationId).to.be.a('string');
+    });
+
+    it('should successfully return a registrationId and venId from queryRegistration', async () => {
+      const queryResponse = await ven.queryRegistration();
+      expect(queryResponse.vtnId).to.be.a('string');
+      expect(queryResponse.registrationId).to.be.a('string');
+      expect(queryResponse.venId).to.be.a('string');
+    });
+
+    it('should return an event from requestEvents', async () => {
+      const eventResponse = await ven.requestEvents();
+      expect(eventResponse.events.length).to.eql(1);
+      expect(eventResponse.vtnId).to.be.a('string');
+    });
+
+    it('should successfully cancel that registration', async () => {
+      const cancelResponse = await ven.cancelRegistration();
+      expect(cancelResponse.registrationId).to.be.a('string');
+      expect(cancelResponse.venId).to.be.a('string');
+    });
+
+    after(async () => {
+      await app.stop();
+    });
+
+  });
+
+  describe('successful poll', async function() {
+    let ven;
+
+    let clock;
+
+    after(async () => {
+      clock.restore();
+    });
+
+    before(async () => {
+      clock = sinon.useFakeTimers(new Date('2020-04-26T01:00:00.000Z').getTime());
+      await sequelize.sync();
+      await VenDb.destroy({ truncate : true});
+      await app.start();
+      const clientCrtPem = readFileSync(path.join(__dirname, 'integration-client.crt'), 'utf-8');
+      ven = new Ven(`http://127.0.0.1:${port}`, clientCrtPem, 'aabbccddeeff', '17:32:59:FD:0E:B5:99:31:27:9C', 'ven.js1');
+      await ven.register();
+    });
+
+    it('should successfully poll for events', async () => {
+      const pollResponse = await ven.poll();
+      expect(pollResponse._type).to.eql('oadrDistributeEvent');
+      expect(pollResponse.events.length).to.eql(1);
+    });
+
+    after(async () => {
+      await app.stop();
+    });
+
+  });
+
+  describe('successful optIn', async function() {
+
+    let clock;
+
+    after(async () => {
+      clock.restore();
+    });
+
+    let ven, events, pollResponse;
+
+    before(async () => {
+      clock = sinon.useFakeTimers(new Date('2020-04-26T01:00:00.000Z').getTime());
+      await sequelize.sync();
+      await VenDb.destroy({ truncate : true});
+      await app.start();
+      const clientCrtPem = readFileSync(path.join(__dirname, 'integration-client.crt'), 'utf-8');
+      ven = new Ven(`http://127.0.0.1:${port}`, clientCrtPem, 'aabbccddeeff', '17:32:59:FD:0E:B5:99:31:27:9C', 'ven.js1');
+      await ven.register();
+      pollResponse = await ven.poll();
+      events = pollResponse.events;
+    });
+
+    it('should successfully poll for events', async () => {
+      expect(pollResponse._type).to.eql('oadrDistributeEvent');
+      expect(pollResponse.events.length).to.eql(1);
+    });
+
+    it('should return same events if not opted', async () => {
+      const pollResponse = await ven.poll();
+      expect(pollResponse._type).to.eql('oadrDistributeEvent');
+      expect(pollResponse.events.length).to.eql(1);
+    });
+
+    it('should return no events if opted', async () => {
+      const eventId = events[0].eventDescriptor.eventId;
+      const modificationNumber = events[0].eventDescriptor.modificationNumber;
+      await ven.opt('optIn', eventId, modificationNumber);
+
+      const pollResponse = await ven.poll();
+      expect(pollResponse._type).to.eql('oadrResponse');
+    });
+
+    after(async () => {
+      await app.stop();
+    });
+
+  });
+});

+ 0 - 54
__tests__/integration/ven-registration.spec.js

@@ -1,54 +0,0 @@
-'use strict';
-
-const { readFileSync } = require('fs');
-const path = require('path');
-const { expect } = require('chai');
-
-const { Ven } = require('../../client/ven');
-const app = require('../../server');
-const { sequelize } = require('../../db');
-const { port } = require('../../config');
-
-describe('VEN registration', function() {
-
-  describe('successful self-registration', async function() {
-
-    let ven;
-
-    before(async () => {
-      await sequelize.sync();
-      await app.start();
-      const clientCrtPem = readFileSync(path.join(__dirname, 'integration-client.crt'), 'utf-8');
-      ven = new Ven(`http://127.0.0.1:${port}`, clientCrtPem, 'aabbccddeeff', '17:32:59:FD:0E:B5:99:31:27:9C', 'ven.js1');
-    });
-
-    it('should successfully return a vtnId from queryRegistration', async () => {
-      const queryResponse = await ven.queryRegistration();
-      expect(queryResponse.vtnId).to.be.a('string');
-    });
-
-    it('should successfully register and receive a vtnId and registrationId', async () => {
-      const registrationResponse = await ven.register();
-      expect(registrationResponse.vtnId).to.be.a('string');
-      expect(registrationResponse.registrationId).to.be.a('string');
-    });
-
-    it('should successfully return a registrationId and venId from queryRegistration', async () => {
-      const queryResponse = await ven.queryRegistration();
-      expect(queryResponse.vtnId).to.be.a('string');
-      expect(queryResponse.registrationId).to.be.a('string');
-      expect(queryResponse.venId).to.be.a('string');
-    });
-
-    it('should successfully cancel that registration', async () => {
-      const cancelResponse = await ven.cancelRegistration();
-      expect(cancelResponse.registrationId).to.be.a('string');
-      expect(cancelResponse.venId).to.be.a('string');
-    });
-
-    after(async () => {
-      await app.stop();
-    });
-
-  });
-});

+ 30 - 0
__tests__/unit/modules/nantum-responses.js

@@ -0,0 +1,30 @@
+'use strict';
+
+const sampleEvent1 = {
+  event_identifier: 'a2fa542eca8d4e829ff5c0f0c8e68710',
+  client_id: 'D8:1D:4B:20:5A:65:4C:50:32:FA',
+  test_event: false,
+  event_mod_number: 0,
+  offLine: false,
+  dr_mode_data: {
+    operation_mode_value: 'NORMAL',
+    // event_status: 'NEAR',
+    // currentTime: 'xxxxx',
+  },
+  dr_event_data: {
+    notification_time: '2020-04-25T22:50:00.000Z',
+    start_time: '2020-04-26T23:00:00.000Z',
+    end_time: '2020-04-26T23:55:00.000Z',
+    event_instance: [{
+      event_type_id: 'LOAD_AMOUNT',
+      event_info_values: [
+        {value: 41, timeOffset: 0},
+        {value: 42, timeOffset: 10}
+      ]
+    }]
+  }
+};
+
+module.exports = {
+  sampleEvent1
+};

+ 80 - 0
__tests__/unit/processes/event.spec.js

@@ -0,0 +1,80 @@
+'use strict';
+
+const { expect } = require('chai');
+const { v4 } = require('uuid');
+const sinon = require('sinon');
+const rewire = require('rewire');
+
+const {
+  requestEvent1,
+  createdEvent1
+} = require('../xml/event/js-requests');
+
+const {
+  oadrPoll1,
+} = require('../xml/poll/js-requests');
+
+const { generatedFromNantumEvent1 } = require('../xml/event/js-responses');
+const {sampleEvent1 } = require('../modules/nantum-responses');
+
+describe('Event', function() {
+  let clock;
+  let sandbox, rewired;
+  let fetchEventStub;
+
+  after(async () => {
+    clock.restore();
+  });
+
+  before(async () => {
+    clock = sinon.useFakeTimers(new Date('2020-04-26T01:00:00.000Z').getTime());
+    sandbox = sinon.createSandbox();
+
+    let registration = {};
+
+    fetchEventStub = sandbox.stub().resolves(sampleEvent1);
+    rewired = rewire('../../../processes/event.js');
+    rewired.__set__({
+      nantum: {
+        fetchRegistration: () => Promise.resolve(registration),
+        fetchEvent: fetchEventStub,
+        updateRegistration: async newRegistration => {
+          registration = newRegistration;
+        }
+      },
+    });
+  });
+
+  describe('retrieveEvents', function() {
+
+    it('translates nantum event to oadr event', async () => {
+      const venId = requestEvent1.venId;
+      const commonName = v4().replace(/-/g, '').substring(0, 12);
+
+      const events = await rewired.retrieveEvents(requestEvent1, commonName, venId);
+      expect(events).to.eql(generatedFromNantumEvent1);
+    });
+  });
+
+  describe('poll and updateOptType', function() {
+    it('should return the same event on subsequent polls if it has not been opted', async () => {
+      const venId = oadrPoll1.venId;
+      const commonName = v4().replace(/-/g, '').substring(0, 12);
+      const pollResponse1 = await rewired.pollForEvents(oadrPoll1, commonName, venId);
+      expect(pollResponse1.events.length).to.eql(1);
+      const pollResponse2 = await rewired.pollForEvents(oadrPoll1, commonName, venId);
+      expect(pollResponse2.events.length).to.eql(1);
+    });
+
+    it('should return not return an opted event in subsequent poll response', async () => {
+      const venId = oadrPoll1.venId;
+      const commonName = v4().replace(/-/g, '').substring(0, 12);
+      const pollResponse1 = await rewired.pollForEvents(oadrPoll1, commonName, venId);
+      expect(pollResponse1.events.length).to.eql(1);
+
+      await rewired.updateOptType(createdEvent1, commonName, venId);
+      const pollResponse2 = await rewired.pollForEvents(oadrPoll1, commonName, venId);
+      expect(pollResponse2).to.be.undefined;
+    });
+  });
+});

+ 0 - 26
__tests__/unit/processes/registration.spec.js

@@ -91,32 +91,6 @@ describe('VEN registration', function() {
       expect(exception).is.an('error');
       expect(exception.message).to.eql('Client certificate CN mismatch');
     });
-
-    it('rejects registration with existing common name but different venId', async () => {
-      const requestId = v4().replace(/-/g, '');
-      const venId2 = v4().replace(/-/g, '').substring(0, 20).toUpperCase().match(/.{2}/g).join(':');
-
-      const request = {
-        requestId: requestId,
-        venId: venId2,
-        oadrProfileName: '2.0b',
-        oadrTransportName: 'simplehttp',
-        oadrReportOnly: false,
-        oadrXmlSignature: false,
-        oadrVenName: `VEN ${commonName}`,
-        oadrHttpPullModel: true
-      };
-
-      let exception;
-      try {
-        await registerParty(request, commonName, venId2);
-      } catch (e) {
-        exception = e;
-      }
-
-      expect(exception).is.an('error');
-      expect(exception.message).to.eql('Ven already exists with that CN');
-    });
   });
 
   describe('query', function() {

Разлика између датотеке није приказан због своје велике величине
+ 41 - 0
__tests__/unit/xml/event/created-event.spec.js


+ 20 - 1
__tests__/unit/xml/event/js-requests.js

@@ -3,11 +3,30 @@
 const requestEvent1 = {
   _type: 'oadrRequestEvent',
   requestId: '2233',
-  venId: '3f59d85fbdf3997dbeb1',
+  venId: 'D8:1D:4B:20:5A:65:4C:50:32:FA',
   replyLimit: '2'
 };
 
+const createdEvent1 = {
+  '_type': 'oadrCreatedEvent',
+  'responseCode': '200',
+  'responseDescription': 'OK',
+  'responseRequestId': '336f7e47b92eefe985ec',
+  'venId': 'D8:1D:4B:20:5A:65:4C:50:32:FA',
+  'eventResponses': [
+    {
+      'responseCode': '200',
+      'responseDescription': 'OK',
+      'responseRequestId': '336f7e47b92eefe985ec',
+      'optType': 'optIn',
+      'eventId': 'a2fa542eca8d4e829ff5c0f0c8e68710',
+      'modificationNumber': 0
+    }
+  ]
+};
+
 module.exports = {
+  createdEvent1,
   requestEvent1,
 };
 

+ 66 - 5
__tests__/unit/xml/event/js-responses.js

@@ -1,6 +1,7 @@
 'use strict';
 
-const distributeEvent1 = {
+const generatedEvent1 = {
+  '_type': 'oadrDistributeEvent',
   'responseCode': '200',
   'responseDescription': 'OK',
   'responseRequestId': '9383fc5946cb0e14ef5a',
@@ -55,7 +56,8 @@ const distributeEvent1 = {
   ]
 };
 
-const distributeEvent2 = {
+const generatedEvent2 = {
+  '_type': 'oadrDistributeEvent',
   'responseCode': '200',
   'responseDescription': 'OK',
   'responseRequestId': '9383fc5946cb0e14ef5a',
@@ -296,6 +298,7 @@ const distributeEvent2 = {
 };
 
 const epriEvent1 = {
+  _type: 'oadrDistributeEvent',
   'responseCode': '200',
   'responseDescription': 'OK',
   'responseRequestId': '9383fc5946cb0e14ef5a',
@@ -535,8 +538,66 @@ const epriEvent1 = {
   ]
 };
 
+const generatedFromNantumEvent1 = {
+  'responseCode': '200',
+  'responseDescription': 'OK',
+  'responseRequestId': '2233',
+  'requestId': '2233',
+  'vtnId': 'TEST_VTN',
+  'events': [
+    {
+      'eventDescriptor': {
+        'eventId': 'a2fa542eca8d4e829ff5c0f0c8e68710',
+        'modificationNumber': 0,
+        'marketContext': 'http://MarketContext1',
+        'createdDateTime': '2020-04-14T16:06:39.000Z',
+        'eventStatus': 'far',
+        'testEvent': false,
+        'priority': 0
+      },
+      'activePeriod': {
+        'duration': 'PT3300S',
+        'notificationDuration': 'PT87000S',
+        'startDate': '2020-04-26T23:00:00.000Z'
+      },
+      'signals': {
+        'event': [
+          {
+            'signalName': 'LOAD_AMOUNT',
+            'signalId': '112233445566',
+            'signalType': 'level',
+            'intervals': [
+              {
+                'signalPayloads': [
+                  41
+                ],
+                'duration': 'PT10S',
+                'uid': '1'
+              },
+              {
+                'signalPayloads': [
+                  42
+                ],
+                'duration': 'PT3290S',
+                'uid': '2'
+              }
+            ]
+          }
+        ]
+      },
+      'target': {
+        'venId': [
+          'D8:1D:4B:20:5A:65:4C:50:32:FA'
+        ]
+      },
+      'responseRequired': 'always'
+    }
+  ]
+}
+
 module.exports = {
-  generatedEvent1: distributeEvent1,
-  generatedEvent2: distributeEvent2,
-  epriEvent1
+  generatedEvent1,
+  generatedEvent2,
+  generatedFromNantumEvent1,
+  epriEvent1,
 };

Разлика између датотеке није приказан због своје велике величине
+ 1 - 1
__tests__/unit/xml/event/request-event.spec.js


Разлика између датотеке није приказан због своје велике величине
+ 28 - 0
__tests__/unit/xml/event/xml-requests.js


+ 16 - 0
__tests__/unit/xml/poll/js-requests.js

@@ -0,0 +1,16 @@
+'use strict';
+
+const oadrPoll1 = {
+  '_type': 'oadrPoll',
+  'venId': 'D8:1D:4B:20:5A:65:4C:50:32:FA'
+};
+
+const oadrResponse1 = {
+  '_type': 'oadrResponse',
+  'venId': 'D8:1D:4B:20:5A:65:4C:50:32:FA'
+};
+
+module.exports = {
+  oadrPoll1,
+  oadrResponse1
+};

+ 13 - 0
__tests__/unit/xml/poll/js-responses.js

@@ -0,0 +1,13 @@
+'use strict';
+
+const oadrResponse1 = {
+  '_type': 'oadrResponse',
+  'responseCode': '200',
+  'responseDescription': 'OK',
+  'responseRequestId': '',
+  'venId': 'aa'
+};
+
+module.exports = {
+  oadrResponse1
+};

+ 41 - 0
__tests__/unit/xml/poll/oadr-poll.spec.js

@@ -0,0 +1,41 @@
+'use strict';
+
+const { expect } = require('chai');
+
+const { parse, serialize } = require('../../../../xml/poll/oadr-poll');
+const { oadrPoll1Xml } = require('./xml-requests');
+const { oadrPoll1 } = require('./js-requests');
+
+describe('Poll', function() {
+  describe('parse', function() {
+
+    let parsedResponse;
+
+    before(async () => {
+      parsedResponse = await parse(oadrPoll1Xml);
+    });
+
+    it ('successfully parses valid message', function() {
+      expect(parsedResponse.venId).to.eql('D8:1D:4B:20:5A:65:4C:50:32:FA');
+    });
+
+    it ('successfully parses serialized value', async function() {
+      const serialized = serialize(oadrPoll1);
+      const parsedResponse = await parse(serialized);
+      expect(parsedResponse).to.eql(oadrPoll1);
+    });
+  });
+
+  describe('serialize', function() {
+
+    let serializedResponse;
+
+    before(async () => {
+      serializedResponse = await serialize(oadrPoll1);
+    });
+
+    it ('successfully serializes valid message', function() {
+      expect(serializedResponse).to.eql('<oadr2b:oadrPayload xmlns:oadr2b="http://openadr.org/oadr-2.0b/2012/07"><oadr2b:oadrSignedObject><oadr2b:oadrPoll xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110" ei:schemaVersion="2.0b"><ei:venID xmlns:ei="http://docs.oasis-open.org/ns/energyinterop/201110">D8:1D:4B:20:5A:65:4C:50:32:FA</ei:venID></oadr2b:oadrPoll></oadr2b:oadrSignedObject></oadr2b:oadrPayload>');
+    });
+  });
+});

Разлика између датотеке није приказан због своје велике величине
+ 41 - 0
__tests__/unit/xml/poll/oadr-response.spec.js


Разлика између датотеке није приказан због своје велике величине
+ 13 - 0
__tests__/unit/xml/poll/xml-requests.js


Разлика између датотеке није приказан због своје велике величине
+ 18 - 0
__tests__/unit/xml/poll/xml-responses.js


+ 74 - 0
client/ven.js

@@ -13,6 +13,16 @@ const {
 } = require('../xml/register-party/cancel-party-registration');
 
 const {
+  serialize: serializeRequestEvent,
+} = require('../xml/event/request-event');
+
+const { serialize: serializeOadrPoll } = require('../xml/poll/oadr-poll');
+
+const {
+  serialize: serializeCreatedEvent,
+} = require('../xml/event/created-event');
+
+const {
   parse: parseCreatedPartyRegistration,
 } = require('../xml/register-party/created-party-registration');
 
@@ -20,6 +30,14 @@ const {
   parse: parseCanceledPartyRegistration,
 } = require('../xml/register-party/canceled-party-registration');
 
+const {
+  parse: parseDistributeEvent,
+} = require('../xml/event/distribute-event');
+
+const { parse: parsePollResponse } = require('../xml/poll');
+
+const { parse: parseOadrResponse } = require('../xml/poll/oadr-response');
+
 const axios = require('axios');
 const { escape } = require('querystring');
 
@@ -102,6 +120,62 @@ class Ven {
     return createdResponse;
   }
 
+  async requestEvents() {
+    const message = {
+      requestId: '2233',
+      venId: this.venId,
+    };
+
+    const response = await this.makeRequest(
+      'EiEvent',
+      message,
+      serializeRequestEvent,
+      parseDistributeEvent,
+    );
+
+    return response;
+  }
+
+  async poll() {
+    const message = {
+      venId: this.venId,
+    };
+
+    const response = await this.makeRequest(
+      'OadrPoll',
+      message,
+      serializeOadrPoll,
+      parsePollResponse,
+    );
+
+    return response;
+  }
+
+  async opt(optType, eventId, modificationNumber) {
+    const message = {
+      responseCode: '200',
+      responseDescription: 'OK',
+      responseRequestId: '',
+      venId: this.venId,
+      eventResponses: [
+        {
+          responseCode: '200',
+          responseDescription: 'OK',
+          responseRequestId: '',
+          optType: optType,
+          eventId: eventId,
+          modificationNumber: modificationNumber,
+        },
+      ],
+    };
+    return await this.makeRequest(
+      'EiEvent',
+      message,
+      serializeCreatedEvent,
+      parseOadrResponse,
+    );
+  }
+
   async makeRequest(service, message, serializer, parser) {
     const xml = serializer(message);
 

+ 2 - 2
docker-compose.yml

@@ -20,8 +20,8 @@ services:
     container_name: nantum-vtn-db
     expose:
       - 5432
-#    ports:
-#      - 55432:5432
+    ports:
+      - 55432:5432
     image: postgres:9.5
     volumes:
       - postgres_data:/var/lib/postgresql/data

+ 70 - 0
modules/nantum.js

@@ -0,0 +1,70 @@
+'use strict';
+
+const { Ven } = require('../db');
+
+async function fetchEvent(venId) {
+  return {
+    event_identifier: '112233eca8d4e829ff5c0f0c8e68710',
+    client_id: venId,
+    test_event: false,
+    event_mod_number: 2,
+    offLine: false,
+    dr_mode_data: {
+      operation_mode_value: 'NORMAL',
+      // currentTime: 'xxxxx', //TODO: find reasonable value
+    },
+    dr_event_data: {
+      notification_time: '2020-04-22T00:00:00.000Z',
+      start_time: '2020-04-22T06:00:00.000Z',
+      end_time: '2020-04-22T06:55:00.000Z',
+      event_instance: [
+        {
+          event_type_id: 'LOAD_DISPATCH',
+          event_info_values: [
+            { value: 41, timeOffset: 0},
+            { value: 42, timeOffset: 5 * 60},
+            { value: 43, timeOffset: 10 * 60},
+          ],
+        },
+      ],
+    },
+  };
+}
+
+async function fetchRegistration(venId) {
+  const venRecord = await Ven.findOne({
+    where: { ven_id: venId },
+  });
+  if (venRecord) return venRecord.data.registration;
+}
+
+async function updateRegistration(registration) {
+  if (registration.ven_id == null) {
+    throw new Error('Registration is missing ven_id');
+  }
+  if (registration.common_name == null) {
+    throw new Error('Registration is missing common_name');
+  }
+  let venRecord = await Ven.findOne({
+    where: { ven_id: registration.ven_id },
+  });
+
+  if (venRecord) {
+    const newData = venRecord.data || {};
+    newData.registration = registration;
+    venRecord.set('data', newData); // setting `data` directly on object doesn't trigger change detection
+  } else {
+    venRecord = new Ven();
+    venRecord.ven_id = registration.ven_id;
+    venRecord.common_name = registration.common_name;
+    const newData = { registration: registration };
+    venRecord.set('data', newData);
+  }
+  await venRecord.save();
+}
+
+module.exports = {
+  fetchEvent,
+  fetchRegistration,
+  updateRegistration,
+};

Разлика између датотеке није приказан због своје велике величине
+ 1554 - 0
package-lock.json


+ 5 - 2
package.json

@@ -6,7 +6,7 @@
   "scripts": {
     "start": "node index.js",
     "test": "npm run unit",
-    "unit": "NODE_ENV=test _mocha $(find __tests__ -name \"*.spec.js\")",
+    "unit": "NODE_ENV=test _mocha --exit $(find __tests__ -name \"*.spec.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"
@@ -32,7 +32,10 @@
   "devDependencies": {
     "chai": "^4.2.0",
     "mocha": "^7.1.1",
-    "prettier": "^1.19.1"
+    "nyc": "^15.0.1",
+    "prettier": "^1.19.1",
+    "rewire": "^5.0.0",
+    "sinon": "^9.0.2"
   },
   "keywords": [],
   "author": "PD Data-Acquisition",

+ 294 - 0
processes/event.js

@@ -0,0 +1,294 @@
+'use strict';
+
+const logger = require('../logger');
+const nantum = require('../modules/nantum');
+const { vtnId } = require('../config');
+
+function calculateEventStatus(notificationTime, startTime, endTime) {
+  const nowMillis = new Date().getTime();
+  if (nowMillis < new Date(startTime).getTime()) {
+    return 'far';
+  }
+  if (nowMillis > new Date(endTime).getTime()) {
+    return 'completed';
+  }
+
+  return 'active';
+}
+
+function calculateDurationSeconds(startTime, endTime) {
+  return Math.round(
+    (new Date(endTime).getTime() - new Date(startTime).getTime()) / 1000,
+  );
+}
+
+function calculateDuration(startTime, endTime) {
+  return `PT${calculateDurationSeconds(startTime, endTime)}S`;
+}
+
+function calculateNotificationDuration(notificationTime, startTime) {
+  if (!notificationTime) {
+    return 'PT0S';
+  }
+  return calculateDuration(notificationTime, startTime);
+}
+
+function calculateEventIntervals(eventInfoValues, eventDurationSeconds) {
+  //TODO: this is likely incorrect. Get more details on the event_info_value data model.
+
+  let result = [];
+
+  for (let i = 0; i < eventInfoValues.length; i++) {
+    const eventInfoValue = eventInfoValues[i];
+    const nextOffset =
+      i === eventInfoValues.length - 1
+        ? eventDurationSeconds - eventInfoValue.timeOffset
+        : eventInfoValues[i + 1].timeOffset;
+    result.push({
+      signalPayloads: [eventInfoValue.value],
+      duration: `PT${nextOffset}S`,
+      uid: `${i + 1}`,
+    });
+  }
+  return result;
+}
+
+function calculateEventSignals(eventInstances, eventDurationSeconds) {
+  return eventInstances.map(eventInstance => {
+    return {
+      signalName: eventInstance.event_type_id,
+      signalId: '112233445566',
+      signalType: 'level',
+      intervals: calculateEventIntervals(
+        eventInstance.event_info_values,
+        eventDurationSeconds,
+      ),
+    };
+  });
+}
+
+function convertToOadrEvents(nantumEvent) {
+  if (!nantumEvent.dr_event_data) {
+    // no event
+    return [];
+  }
+  const nowMillis = new Date().getTime();
+  if (
+    nowMillis < new Date(nantumEvent.dr_event_data.notification_time).getTime()
+  ) {
+    return []; // not in the notification period yet
+  }
+
+  return [
+    {
+      eventDescriptor: {
+        eventId: nantumEvent.event_identifier,
+        modificationNumber: nantumEvent.event_mod_number,
+        marketContext: 'http://MarketContext1',
+        createdDateTime: '2020-04-14T16:06:39.000Z',
+        eventStatus: calculateEventStatus(
+          nantumEvent.dr_event_data.notification_time,
+          nantumEvent.dr_event_data.start_time,
+          nantumEvent.dr_event_data.end_time,
+        ),
+        testEvent: nantumEvent.test_event,
+        priority: 0,
+      },
+      activePeriod: {
+        startDate: nantumEvent.dr_event_data.start_time,
+        duration: calculateDuration(
+          nantumEvent.dr_event_data.start_time,
+          nantumEvent.dr_event_data.end_time,
+        ),
+        notificationDuration: calculateNotificationDuration(
+          nantumEvent.dr_event_data.notification_time,
+          nantumEvent.dr_event_data.start_time,
+        ),
+      },
+      signals: {
+        event: calculateEventSignals(
+          nantumEvent.dr_event_data.event_instance,
+          calculateDurationSeconds(
+            nantumEvent.dr_event_data.start_time,
+            nantumEvent.dr_event_data.end_time,
+          ),
+        ),
+      },
+      target: {
+        venId: [nantumEvent.client_id],
+      },
+      responseRequired: 'always',
+    },
+  ];
+}
+
+async function retrieveEvents(
+  obj,
+  clientCertificateCn,
+  clientCertificateFingerprint,
+) {
+  logger.info(
+    'retrieveEvents',
+    obj,
+    clientCertificateCn,
+    clientCertificateFingerprint,
+  );
+
+  const requestVenId = obj.venId;
+
+  if (!requestVenId) {
+    const error = new Error('No VenID in request');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (requestVenId !== clientCertificateFingerprint) {
+    // as per certification item #512, venId MUST be case-sensitive
+    const error = new Error('VenID does not match certificate');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (!clientCertificateCn) {
+    const error = new Error('Could not determine CN from client certificate');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  const event = await nantum.fetchEvent(requestVenId);
+
+  return {
+    responseCode: '200',
+    responseDescription: 'OK',
+    responseRequestId: obj.requestId || '',
+    requestId: obj.requestId || '',
+    vtnId: vtnId,
+    events: convertToOadrEvents(event),
+  };
+}
+
+async function updateOptType(
+  oadrCreatedEvent,
+  clientCertificateCn,
+  clientCertificateFingerprint,
+) {
+  logger.info(
+    'updateOptType',
+    oadrCreatedEvent,
+    clientCertificateCn,
+    clientCertificateFingerprint,
+  );
+
+  const requestVenId = oadrCreatedEvent.venId;
+
+  if (!requestVenId) {
+    const error = new Error('No VenID in request');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (requestVenId !== clientCertificateFingerprint) {
+    // as per certification item #512, venId MUST be case-sensitive
+    const error = new Error('VenID does not match certificate');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  const nantumRegistration = await nantum.fetchRegistration(requestVenId);
+
+  const opted = nantumRegistration.opted || [];
+
+  //TODO: more validation: VEN may opt into an event that doesn't exist. VEN may opt into an old version of an event
+  // (modificationNumber doesn't match). May opt into a completed event. Indicate error(s) to client.
+
+  for (const eventResponse of oadrCreatedEvent.eventResponses) {
+    // remove existing opts for this eventId
+    nantumRegistration.opted = [
+      ...opted.filter(optedItem => optedItem.eventId !== eventResponse.eventId),
+    ];
+    nantumRegistration.opted.push({
+      eventId: eventResponse.eventId,
+      modificationNumber: eventResponse.modificationNumber,
+      optType: eventResponse.optType,
+    });
+    await nantum.updateRegistration(nantumRegistration);
+  }
+
+  return {
+    responseCode: '200',
+    responseDescription: 'OK',
+    venId: clientCertificateFingerprint,
+  };
+}
+
+async function filterOutAcknowledgedEvents(venId, events) {
+  const nantumRegistration = await nantum.fetchRegistration(venId);
+  const opted = nantumRegistration.opted || [];
+  return events.filter(
+    event =>
+      opted.filter(
+        optedItem =>
+          optedItem.eventId === event.eventDescriptor.eventId &&
+          optedItem.modificationNumber ===
+            event.eventDescriptor.modificationNumber,
+      ).length === 0,
+  );
+}
+
+async function pollForEvents(
+  obj,
+  clientCertificateCn,
+  clientCertificateFingerprint,
+) {
+  logger.info(
+    'pollForEvents',
+    obj,
+    clientCertificateCn,
+    clientCertificateFingerprint,
+  );
+
+  const requestVenId = obj.venId;
+
+  if (!requestVenId) {
+    const error = new Error('No VenID in request');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (requestVenId !== clientCertificateFingerprint) {
+    // as per certification item #512, venId MUST be case-sensitive
+    const error = new Error('VenID does not match certificate');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  if (!clientCertificateCn) {
+    const error = new Error('Could not determine CN from client certificate');
+    error.responseCode = 452;
+    throw error;
+  }
+
+  const event = await nantum.fetchEvent(requestVenId);
+  const filteredEvents = await filterOutAcknowledgedEvents(
+    requestVenId,
+    convertToOadrEvents(event),
+  );
+
+  if (filteredEvents.length > 0) {
+    return {
+      responseCode: '200',
+      responseDescription: 'OK',
+      responseRequestId: '', // required field, but empty is allowed as per spec
+      requestId: '',
+      vtnId: vtnId,
+      events: filteredEvents,
+    };
+  }
+  return undefined;
+}
+
+module.exports = {
+  pollForEvents,
+  retrieveEvents,
+  updateOptType,
+};

+ 65 - 71
processes/registration.js

@@ -1,9 +1,9 @@
 'use strict';
 
-const { Ven } = require('../db');
 const { v4 } = require('uuid');
 const { vtnId } = require('../config');
 const logger = require('../logger');
+const nantum = require('../modules/nantum');
 
 async function registerParty(
   obj,
@@ -38,86 +38,57 @@ async function registerParty(
     throw error;
   }
 
-  let registrationId, venId;
+  let nantumRegistration = await nantum.fetchRegistration(requestVenId);
 
-  const existingDbRecordByVenId = await Ven.findOne({
-    where: { ven_id: requestVenId },
-  });
-  const existingDbRecordByCommonName = await Ven.findOne({
-    where: { common_name: clientCertificateCn },
-  });
-
-  if (existingDbRecordByVenId) {
-    if (existingDbRecordByVenId.common_name !== clientCertificateCn) {
+  if (nantumRegistration) {
+    if (nantumRegistration.common_name !== clientCertificateCn) {
       const error = new Error('Client certificate CN mismatch');
       error.responseCode = 452;
       throw error;
     }
-    registrationId = existingDbRecordByVenId.data.registrationId;
-    venId = existingDbRecordByVenId.ven_id;
-  } else if (existingDbRecordByCommonName) {
-    const error = new Error('Ven already exists with that CN');
-    error.responseCode = 452;
-    throw error;
+    if (nantumRegistration.registration_id == null) {
+      const registrationId = v4().replace(/-/g, '');
+      nantumRegistration.registration_id = registrationId;
+      await nantum.updateRegistration(nantumRegistration);
+    }
   } else {
-    registrationId = v4().replace(/-/g, '');
-    venId = requestVenId;
-    const newVen = new Ven();
-    newVen.common_name = clientCertificateCn;
-    newVen.ven_id = requestVenId;
-    newVen.data = {
-      registrationId: registrationId,
+    const registrationId = v4().replace(/-/g, '');
+    nantumRegistration = {
+      common_name: clientCertificateCn,
+      ven_id: requestVenId,
+      registration_id: registrationId,
     };
-    await newVen.save();
+    await nantum.updateRegistration(nantumRegistration);
   }
 
-  return {
-    responseRequestId: obj.requestId || '',
-    responseCode: '200',
-    responseDescription: 'OK',
-    registrationId: registrationId,
-    venId: venId,
-    vtnId: vtnId,
-    pollFreqDuration: 'PT10S',
-  };
+  return nantumRegistrationToOadrRegistrationCreated(
+    obj.requestId,
+    nantumRegistration,
+  );
 }
 
 async function query(obj, clientCertificateCn, clientCertificateFingerprint) {
   logger.info('query', obj, clientCertificateCn, clientCertificateFingerprint);
 
-  let registrationId, venId;
-
-  const existingDbRecordByVenId = await Ven.findOne({
-    where: { ven_id: clientCertificateFingerprint },
-  });
+  const requestVenId = clientCertificateFingerprint;
 
-  const existingDbRecordByCommonName = await Ven.findOne({
-    where: { common_name: clientCertificateCn },
-  });
+  let nantumRegistration = await nantum.fetchRegistration(requestVenId);
 
-  if (existingDbRecordByVenId) {
-    if (existingDbRecordByVenId.common_name !== clientCertificateCn) {
+  if (nantumRegistration) {
+    if (nantumRegistration.common_name !== clientCertificateCn) {
       const error = new Error('Client certificate CN mismatch');
       error.responseCode = 452;
       throw error;
     }
-    registrationId = existingDbRecordByVenId.data.registrationId;
-    venId = existingDbRecordByVenId.ven_id;
-  } else if (existingDbRecordByCommonName) {
-    const error = new Error('Ven already exists with that CN');
-    error.responseCode = 452;
-    throw error;
+  } else {
+    // response payload should not contain ven_id or registration_id
+    nantumRegistration = {};
   }
 
-  return {
-    responseRequestId: obj.requestId || '',
-    responseCode: '200',
-    responseDescription: 'OK',
-    registrationId: registrationId,
-    venId: venId,
-    vtnId: vtnId,
-    pollFreqDuration: 'PT10S',
-  };
+  return nantumRegistrationToOadrRegistrationCreated(
+    obj.requestId,
+    nantumRegistration,
+  );
 }
 
 async function cancelParty(
@@ -156,30 +127,53 @@ async function cancelParty(
     throw error;
   }
 
-  const existingDbRecordByVenId = await Ven.findOne({
-    where: { ven_id: venId },
-  });
+  let nantumRegistration = await nantum.fetchRegistration(requestVenId);
+  let cancelledRegistrationId;
 
-  if (existingDbRecordByVenId == null) {
-    const error = new Error('No current registration for VenID');
-    error.responseCode = 452;
-    throw error;
+  if (nantumRegistration) {
+    if (nantumRegistration.common_name !== clientCertificateCn) {
+      const error = new Error('Client certificate CN mismatch');
+      error.responseCode = 452;
+      throw error;
+    }
+
+    cancelledRegistrationId = nantumRegistration.registration_id;
+
+    // clear all registration data
+    nantumRegistration = {
+      ven_id: requestVenId,
+      common_name: clientCertificateCn,
+    };
+    await nantum.updateRegistration(nantumRegistration);
   }
 
-  if (existingDbRecordByVenId.data.registrationId !== registrationId) {
-    const error = new Error('Incorrect registrationID for VenID');
+  if (cancelledRegistrationId == null) {
+    const error = new Error('No current registration for VenID');
     error.responseCode = 452;
     throw error;
   }
 
-  await existingDbRecordByVenId.destroy();
-
   return {
     responseRequestId: obj.requestId || '',
     responseCode: '200',
     responseDescription: 'OK',
-    registrationId: registrationId,
     venId: venId,
+    registrationId: cancelledRegistrationId,
+  };
+}
+
+function nantumRegistrationToOadrRegistrationCreated(
+  requestId,
+  nantumRegistration,
+) {
+  return {
+    responseRequestId: requestId || '',
+    responseCode: '200',
+    responseDescription: 'OK',
+    registrationId: nantumRegistration.registration_id,
+    venId: nantumRegistration.ven_id,
+    vtnId: vtnId,
+    pollFreqDuration: 'PT10S',
   };
 }
 

+ 63 - 0
server/controllers/event.js

@@ -0,0 +1,63 @@
+'use strict';
+
+const logger = require('../../logger');
+const { parse } = require('../../xml/event');
+const {
+  serialize: serializeDistributeEvent,
+} = require('../../xml/event/distribute-event');
+
+const {
+  serialize: serializeOadrResponse,
+} = require('../../xml/poll/oadr-response');
+
+const { retrieveEvents, updateOptType } = require('../../processes/event');
+
+exports.postController = async (req, res) => {
+  const xmlRequest = req.body;
+  let parsedRequest;
+  let xmlResponse;
+  let serialize = serializeOadrResponse;
+
+  try {
+    parsedRequest = await parse(xmlRequest);
+    let response;
+    switch (parsedRequest._type) {
+      case 'oadrRequestEvent':
+        serialize = serializeDistributeEvent;
+        response = await retrieveEvents(
+          parsedRequest,
+          req.clientCertificateCn,
+          req.clientCertificateFingerprint,
+        );
+        break;
+      case 'oadrCreatedEvent':
+        serialize = serializeOadrResponse;
+        response = await updateOptType(
+          parsedRequest,
+          req.clientCertificateCn,
+          req.clientCertificateFingerprint,
+        );
+        break;
+      default:
+        throw new Error(`Unknown _type: ${parsedRequest._type}`);
+    }
+    xmlResponse = serialize(response);
+  } catch (e) {
+    logger.warn('Error occurred processing', parsedRequest || xmlRequest, e);
+    const responseRequestId =
+      parsedRequest != null ? parsedRequest.requestId : '';
+    xmlResponse = serialize({
+      responseCode: e.responseCode || '454',
+      responseDescription: e.message || 'Unknown error',
+      responseRequestId: responseRequestId || '',
+    });
+  }
+  res.set('Content-Type', 'application/xml');
+  res.send(xmlResponse);
+  res.end();
+};
+
+exports.postErrorHandler = (error, next) => {
+  logger.warn('Error in EiEvent', { error });
+  next(error);
+};

+ 58 - 0
server/controllers/poll.js

@@ -0,0 +1,58 @@
+'use strict';
+
+const logger = require('../../logger');
+const { parse } = require('../../xml/poll');
+const { pollForEvents } = require('../../processes/event');
+
+const {
+  serialize: serializeOadrResponse,
+} = require('../../xml/poll/oadr-response');
+
+const {
+  serialize: serializeDistributeEvent,
+} = require('../../xml/event/distribute-event');
+
+exports.postController = async (req, res) => {
+  const xmlRequest = req.body;
+  let parsedRequest, jsonResponse, xmlResponse;
+  let serialize = serializeOadrResponse;
+
+  try {
+    parsedRequest = await parse(xmlRequest);
+    jsonResponse = await pollForEvents(
+      parsedRequest,
+      req.clientCertificateCn,
+      req.clientCertificateFingerprint,
+    );
+    if (jsonResponse) {
+      serialize = serializeDistributeEvent;
+    } else {
+      serialize = serializeOadrResponse;
+      jsonResponse = {
+        responseCode: '200',
+        responseDescription: 'OK',
+        responseRequestId: parsedRequest.requestId || '',
+        venId: req.clientCertificateFingerprint,
+      };
+    }
+
+    xmlResponse = serialize(jsonResponse);
+  } catch (e) {
+    logger.warn('Error occurred processing', parsedRequest, e);
+    const responseRequestId =
+      parsedRequest != null ? parsedRequest.requestId : '';
+    xmlResponse = serialize({
+      responseCode: e.responseCode || '454',
+      responseDescription: e.message || 'Unknown error',
+      responseRequestId: responseRequestId || '',
+    });
+  }
+  res.set('Content-Type', 'application/xml');
+  res.send(xmlResponse);
+  res.end();
+};
+
+exports.postErrorHandler = (error, next) => {
+  logger.warn('Error in OadrPoll', { error });
+  next(error);
+};

+ 10 - 0
server/routes/event.js

@@ -0,0 +1,10 @@
+'use strict';
+
+const router = require('express').Router();
+
+const asyncHandler = require('../middleware/async-handler');
+const { postController, postErrorHandler } = require('../controllers/event');
+
+router.post('/', asyncHandler(postController, postErrorHandler));
+
+module.exports = router;

+ 2 - 0
server/routes/index.js

@@ -5,5 +5,7 @@ const router = require('express').Router();
 const oadrPrefix = '/OpenADR2/Simple/2.0b';
 
 router.use(`${oadrPrefix}/EiRegisterParty`, require('./register-party'));
+router.use(`${oadrPrefix}/EiEvent`, require('./event'));
+router.use(`${oadrPrefix}/OadrPoll`, require('./poll'));
 
 module.exports = router;

+ 10 - 0
server/routes/poll.js

@@ -0,0 +1,10 @@
+'use strict';
+
+const router = require('express').Router();
+
+const asyncHandler = require('../middleware/async-handler');
+const { postController, postErrorHandler } = require('../controllers/poll');
+
+router.post('/', asyncHandler(postController, postErrorHandler));
+
+module.exports = router;

+ 182 - 0
xml/event/created-event.js

@@ -0,0 +1,182 @@
+'use strict';
+
+const { parseXML, childAttr, required, number } = 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';
+
+function parseEiResponse(response) {
+  return {
+    code: required(childAttr(response, 'responseCode'), 'responseCode'),
+    description: childAttr(response, 'responseDescription'),
+    requestId: required(childAttr(response, 'requestID'), 'requestID'),
+  };
+}
+
+function parseEventResponse(eventResponse) {
+  return {
+    responseCode: required(
+      childAttr(eventResponse, 'responseCode'),
+      'responseCode',
+    ),
+    responseDescription: childAttr(eventResponse, 'responseDescription'),
+    responseRequestId: required(
+      childAttr(eventResponse, 'requestID'),
+      'requestID',
+    ),
+    optType: required(childAttr(eventResponse, 'optType'), 'optType'),
+    eventId: required(
+      childAttr(eventResponse['qualifiedEventID'][0]['$$'], 'eventID'),
+      'qualifiedEventID.eventID',
+    ),
+    modificationNumber: required(
+      number(
+        childAttr(
+          eventResponse['qualifiedEventID'][0]['$$'],
+          'modificationNumber',
+        ),
+      ),
+      'qualifiedEventID.modificationNumber',
+    ),
+  };
+}
+
+function parseEventResponses(eventResponses) {
+  const unwrappedEventResponses = eventResponses['$$']['eventResponse'];
+  return unwrappedEventResponses.map(x => parseEventResponse(x['$$']));
+}
+
+function parseEiCreatedEvent(eiCreatedEvent) {
+  const { code, description, requestId } = parseEiResponse(
+    eiCreatedEvent['eiResponse'][0]['$$'],
+  );
+
+  const result = {
+    _type: 'oadrCreatedEvent',
+    responseCode: code,
+    responseDescription: description,
+    responseRequestId: requestId,
+  };
+
+  const venId = childAttr(eiCreatedEvent, 'venID');
+  if (venId != null) result.venId = venId;
+
+  const eventResponses = childAttr(eiCreatedEvent, 'eventResponses');
+  if (eventResponses != null)
+    result.eventResponses = parseEventResponses(eventResponses);
+
+  return result;
+}
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'][
+      'oadrCreatedEvent'
+    ][0]['$$'];
+
+  return parseEiCreatedEvent(o['eiCreatedEvent'][0]['$$']);
+}
+
+function serializeEiResponse(eiCreatedEvent) {
+  const descriptionFrag =
+    eiCreatedEvent.responseDescription != null
+      ? fragment()
+        .ele(energyInteropNs, 'ei:responseDescription')
+        .txt(eiCreatedEvent.responseDescription)
+      : fragment();
+
+  return fragment()
+    .ele(energyInteropNs, 'ei:eiResponse')
+    .ele(energyInteropNs, 'ei:responseCode')
+    .txt(eiCreatedEvent.responseCode)
+    .up()
+    .import(descriptionFrag)
+    .ele(energyInteropPayloadsNs, 'pyld:requestID')
+    .txt(eiCreatedEvent.responseRequestId)
+    .up()
+    .up();
+}
+
+function serializeEventResponse(eventResponse) {
+  const descriptionFrag =
+    eventResponse.responseDescription != null
+      ? fragment()
+        .ele(energyInteropNs, 'ei:responseDescription')
+        .txt(eventResponse.responseDescription)
+      : fragment();
+
+  return fragment()
+    .ele(energyInteropNs, 'ei:eventResponse')
+    .ele(energyInteropNs, 'ei:responseCode')
+    .txt(eventResponse.responseCode)
+    .up()
+    .import(descriptionFrag)
+    .ele(energyInteropPayloadsNs, 'pyld:requestID')
+    .txt(eventResponse.responseRequestId)
+    .up()
+    .ele(energyInteropNs, 'ei:optType')
+    .txt(eventResponse.optType)
+    .up()
+    .ele(energyInteropNs, 'ei:qualifiedEventID')
+    .ele(energyInteropNs, 'ei:eventID')
+    .txt(eventResponse.eventId)
+    .up()
+    .ele(energyInteropNs, 'ei:modificationNumber')
+    .txt(eventResponse.modificationNumber)
+    .up()
+    .up();
+}
+
+function serializeEventResponses(eventResponses) {
+  const result = fragment();
+  const responsesHolder = result.ele(energyInteropNs, 'ei:eventResponses');
+
+  eventResponses.forEach(x =>
+    responsesHolder.import(serializeEventResponse(x)),
+  );
+  return result;
+}
+
+function serializeEiCreatedEvent(eiCreatedEvent) {
+  const result = fragment();
+  const eiCreatedEventHolder = result.ele(oadrPayloadNs, 'pyld:eiCreatedEvent');
+  eiCreatedEventHolder.import(serializeEiResponse(eiCreatedEvent));
+  eiCreatedEventHolder.import(
+    serializeEventResponses(eiCreatedEvent.eventResponses),
+  );
+
+  if (eiCreatedEvent.venId != null) {
+    eiCreatedEventHolder
+      .ele(energyInteropNs, 'ei:venID')
+      .txt(eiCreatedEvent.venId);
+  }
+
+  return result;
+}
+
+function serialize(obj) {
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+      pyld: energyInteropPayloadsNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrCreatedEvent')
+    .import(serializeEiCreatedEvent(obj))
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};

+ 4 - 3
xml/event/distribute-event.js

@@ -862,6 +862,7 @@ async function parse(input) {
   );
 
   const result = {
+    _type: 'oadrDistributeEvent',
     responseCode: code,
     responseDescription: description,
     responseRequestId: responseRequestId,
@@ -877,7 +878,7 @@ async function parse(input) {
   const vtnId = childAttr(o, 'vtnID');
   if (vtnId != null) result.vtnId = vtnId;
 
-  const events = parseEvents(o['oadrEvent']);
+  const events = parseEvents(o['oadrEvent'] || []);
 
   result.events = events;
 
@@ -910,10 +911,10 @@ function serializeDuration(duration) {
 }
 
 function validate(obj) {
-  if (!obj.responseCode) {
+  if (obj.responseCode == null) {
     throw new Error('Missing responseCode');
   }
-  if (!obj.responseRequestId) {
+  if (obj.responseRequestId == null) {
     throw new Error('Missing responseRequestId');
   }
 }

+ 6 - 1
xml/event/index.js

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

+ 11 - 3
xml/parser.js

@@ -22,11 +22,19 @@ function childAttr(obj, key, errorCode = 452) {
     throw error;
   }
 
-  if (value[0]['$'] && value[0]['_']) {
-    return value[0]['_'];
+  if (value[0]['$$']) {
+    // there is a child element
+    return value[0];
   }
 
-  return value[0];
+  if (value[0]['$']) {
+    if (value[0]['_']) {
+      return value[0]['_']; // namespaced text node
+    }
+    return ''; // namespaced, no text between tags
+  }
+
+  return value[0]; // un-namespaced text node
 }
 
 function boolean(input, errorCode = 452) {

+ 28 - 0
xml/poll/index.js

@@ -0,0 +1,28 @@
+'use strict';
+
+const { parseXML } = require('../parser');
+
+const { parse: parseOadrPoll } = require('./oadr-poll');
+const { parse: parseOadrResponse } = require('./oadr-response');
+const {
+  parse: parseOadrDistributeEvent,
+} = require('../event/distribute-event');
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o = json['oadrPayload']['$$']['oadrSignedObject'][0]['$$'];
+
+  if (o['oadrPoll']) {
+    return await parseOadrPoll(input);
+  } else if (o['oadrResponse']) {
+    return await parseOadrResponse(input);
+  } else if (o['oadrDistributeEvent']) {
+    return await parseOadrDistributeEvent(input);
+  }
+
+  throw new Error(`Unexpected payload type: ${Object.keys(o)}`);
+}
+
+module.exports = {
+  parse,
+};

+ 43 - 0
xml/poll/oadr-poll.js

@@ -0,0 +1,43 @@
+'use strict';
+
+const { parseXML, childAttr, required } = require('../parser');
+const { create } = 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';
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$']['oadrPoll'][0]['$$'];
+
+  return {
+    _type: 'oadrPoll',
+    venId: required(childAttr(o, 'venID'), 'venID'),
+  };
+}
+
+function serialize(obj) {
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrPoll')
+    .att(energyInteropNs, 'ei:schemaVersion', '2.0b')
+    .ele(energyInteropNs, 'ei:venID')
+    .txt(obj.venId)
+    .up()
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};

+ 116 - 0
xml/poll/oadr-response.js

@@ -0,0 +1,116 @@
+'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';
+
+function parseEiResponse(response) {
+  const result = {
+    code: required(childAttr(response, 'responseCode'), 'responseCode'),
+    description: childAttr(response, 'responseDescription'),
+  };
+
+  const requestId = childAttr(response, 'requestID');
+  if (requestId != null) {
+    result.requestId = requestId;
+  }
+
+  return result;
+}
+
+async function parse(input) {
+  const json = await parseXML(input);
+  const o =
+    json['oadrPayload']['$$']['oadrSignedObject'][0]['$$']['oadrResponse'][0][
+      '$$'
+    ];
+
+  const { code, description, requestId } = parseEiResponse(
+    o['eiResponse'][0]['$$'],
+  );
+
+  const result = {
+    _type: 'oadrResponse',
+    responseCode: code,
+    responseDescription: description,
+    responseRequestId: requestId,
+  };
+
+  if (code < 200 || code >= 300) {
+    return result;
+  }
+
+  const venId = childAttr(o, 'venID');
+  if (venId != null) result.venId = venId;
+
+  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 validate(obj) {
+  if (!obj.responseCode) {
+    throw new Error('Missing responseCode');
+  }
+}
+
+function serialize(obj) {
+  validate(obj);
+
+  const venId =
+    obj.venId != null
+      ? fragment()
+        .ele(energyInteropNs, 'ei:venID')
+        .txt(obj.venId)
+      : fragment();
+
+  const doc = create({
+    namespaceAlias: {
+      ns: oadrPayloadNs,
+      oadr2b: oadrNs,
+      ei: energyInteropNs,
+      pyld: energyInteropPayloadsNs,
+    },
+  })
+    .ele('@oadr2b', 'oadr2b:oadrPayload')
+    .ele('oadr2b:oadrSignedObject')
+    .ele('oadr2b:oadrResponse')
+    .att('@ei', 'ei:schemaVersion', '2.0b')
+    .ele('@ei', 'ei:eiResponse')
+    .import(
+      serializeEiResponse(
+        obj.responseCode,
+        obj.responseDescription,
+        obj.responseRequestId,
+      ),
+    )
+    .up()
+    .import(venId)
+    .doc();
+  return doc.end({ headless: true, prettyPrint: false });
+}
+
+module.exports = {
+  parse,
+  serialize,
+};