diff --git a/server/services/sunspec/lib/sunspec.connect.js b/server/services/sunspec/lib/sunspec.connect.js index 0fb6abe313..7381f2f39e 100644 --- a/server/services/sunspec/lib/sunspec.connect.js +++ b/server/services/sunspec/lib/sunspec.connect.js @@ -1,9 +1,7 @@ -const os = require('os'); -const { CONFIGURATION, DEFAULT, SCAN_OPTIONS } = require('./sunspec.constants'); +const { DEFAULT } = require('./sunspec.constants'); const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants'); const { ModbusClient } = require('./utils/sunspec.ModbusClient'); const logger = require('../../../utils/logger'); -const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); /** * @description Initialize service with dependencies and connect to devices. @@ -13,54 +11,9 @@ const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); async function connect() { logger.debug(`SunSpec: Connecting...`); - const rawIPMasks = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_IP_MASKS, this.serviceId); - if (rawIPMasks !== null) { - const loadedIPMasks = JSON.parse(rawIPMasks); - this.ipMasks = []; - loadedIPMasks.forEach((option) => { - const mask = { ...option, networkInterface: false }; - this.ipMasks.push(mask); - }); - } else { - throw new ServiceNotConfiguredError(); - } + this.sunspecIps = await this.scan(); - // Complete masks with network interfaces - const networkInterfaces = os.networkInterfaces(); - Object.keys(networkInterfaces).forEach((interfaceName) => { - const interfaces = networkInterfaces[interfaceName]; - - interfaces.forEach((interfaceDetails) => { - const { family, cidr: mask, internal } = interfaceDetails; - - // Filter on IP family - if (SCAN_OPTIONS.IP_FAMILY.includes(family) && !internal) { - const boundMask = this.ipMasks.find((currentMask) => currentMask.mask === mask); - // Add not already bound masks - if (!boundMask) { - // Check subnet mask - const subnetMask = mask.split('/')[1]; - // Default disable for large IP ranges (minimum value /24 to enable interface) - const enabled = Number.parseInt(subnetMask, 10) >= 24; - const networkInterfaceMask = { - mask, - name: interfaceName, - networkInterface: true, - enabled, - }; - this.ipMasks.push(networkInterfaceMask); - } else { - // Force override with real information - boundMask.name = interfaceName; - boundMask.networkInterface = true; - } - } - }); - }); - - const sunspecIps = await this.scan(); - - const promises = [...sunspecIps].map(async (ip) => { + const promises = [...this.sunspecIps].map(async (ip) => { const modbus = new ModbusClient(this.modbusClient); try { await modbus.connect(ip, DEFAULT.MODBUS_PORT); diff --git a/server/services/sunspec/lib/sunspec.getStatus.js b/server/services/sunspec/lib/sunspec.getStatus.js index 94e74feb09..98113286d3 100644 --- a/server/services/sunspec/lib/sunspec.getStatus.js +++ b/server/services/sunspec/lib/sunspec.getStatus.js @@ -11,6 +11,7 @@ function getStatus() { return { connected: this.connected, + sunspecIps: this.sunspecIps, }; } diff --git a/server/services/sunspec/lib/sunspec.scan.js b/server/services/sunspec/lib/sunspec.scan.js index 648d41e83a..9614867be7 100644 --- a/server/services/sunspec/lib/sunspec.scan.js +++ b/server/services/sunspec/lib/sunspec.scan.js @@ -1,6 +1,8 @@ +const os = require('os'); const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants'); const logger = require('../../../utils/logger'); -const { SCAN_OPTIONS } = require('./sunspec.constants'); +const { CONFIGURATION, SCAN_OPTIONS, DEFAULT } = require('./sunspec.constants'); +const { ServiceNotConfiguredError } = require('../../../utils/coreErrors'); /** * @description Scan for network devices. @@ -15,6 +17,51 @@ async function scan() { } logger.info(`Sunspec starts scanning devices...`); + const rawIPMasks = await this.gladys.variable.getValue(CONFIGURATION.SUNSPEC_IP_MASKS, this.serviceId); + if (rawIPMasks !== null) { + const loadedIPMasks = JSON.parse(rawIPMasks); + this.ipMasks = []; + loadedIPMasks.forEach((option) => { + const mask = { ...option, networkInterface: false }; + this.ipMasks.push(mask); + }); + } else { + throw new ServiceNotConfiguredError(); + } + + // Complete masks with network interfaces + const networkInterfaces = os.networkInterfaces(); + Object.keys(networkInterfaces).forEach((interfaceName) => { + const interfaces = networkInterfaces[interfaceName]; + + interfaces.forEach((interfaceDetails) => { + const { family, cidr: mask, internal } = interfaceDetails; + + // Filter on IP family + if (SCAN_OPTIONS.IP_FAMILY.includes(family) && !internal) { + const boundMask = this.ipMasks.find((currentMask) => currentMask.mask === mask); + // Add not already bound masks + if (!boundMask) { + // Check subnet mask + const subnetMask = mask.split('/')[1]; + // Default disable for large IP ranges (minimum value /24 to enable interface) + const enabled = Number.parseInt(subnetMask, 10) >= 24; + const networkInterfaceMask = { + mask, + name: interfaceName, + networkInterface: true, + enabled, + }; + this.ipMasks.push(networkInterfaceMask); + } else { + // Force override with real information + boundMask.name = interfaceName; + boundMask.networkInterface = true; + } + } + }); + }); + this.scanning = true; this.eventManager.emit(EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, @@ -24,14 +71,17 @@ async function scan() { }); const enabledMasks = this.ipMasks.filter((mask) => mask.enabled).map(({ mask }) => mask); - this.scanner = new this.ScannerClass(enabledMasks, '-p502'); + this.scanner = new this.ScannerClass(enabledMasks, `-p${DEFAULT.MODBUS_PORT}`); this.scanner.scanTimeout = SCAN_OPTIONS.SCAN_TIMEOUT; const scanDone = (discoveredDevices, success) => { this.scanning = false; this.eventManager.emit(EVENTS.WEBSOCKET.SEND_ALL, { type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, - payload: { scanning: false, success }, + payload: { + scanning: false, + success, + }, }); }; diff --git a/server/services/sunspec/lib/utils/sunspec.ModbusClient.js b/server/services/sunspec/lib/utils/sunspec.ModbusClient.js index 16b7101430..f962d61c43 100644 --- a/server/services/sunspec/lib/utils/sunspec.ModbusClient.js +++ b/server/services/sunspec/lib/utils/sunspec.ModbusClient.js @@ -3,16 +3,16 @@ const { REGISTER, DEFAULT, MODEL } = require('../sunspec.constants'); const { trimString } = require('./sunspec.utils'); class ModbusClient { - constructor(modbusClient) { - this.modbusClient = modbusClient; + constructor(modbusClientApi) { + this.modbusClientApi = modbusClientApi; this.models = {}; } async connect(sunspecHost, sunspecPort) { try { - await this.modbusClient.connectTCP(sunspecHost, { port: sunspecPort, timeout: 10000 }); + await this.modbusClientApi.connectTCP(sunspecHost, { port: sunspecPort, timeout: 10000 }); logger.info(`SunSpec service connected`); - // this.modbusClient.setID(UNIT_ID.SID); + // this.modbusClientApi.setID(UNIT_ID.SID); const sid = await this.readRegisterAsInt32(REGISTER.SID); if (sid !== DEFAULT.SUNSPEC_MODBUS_MAP) { logger.error(`Invalid SID received. Expected ${DEFAULT.SUNSPEC_MODBUS_MAP} but got ${sid}`); @@ -31,6 +31,7 @@ class ModbusClient { let nextModelLength; let registerId = REGISTER.MODEL_ID + 1; registerId += (await this.readRegisterAsInt16(registerId)) + 1; + // eslint-disable-next-line no-constant-condition while (true) { // eslint-disable-next-line no-await-in-loop nextModel = await this.readRegisterAsInt16(registerId); @@ -53,7 +54,7 @@ class ModbusClient { close() { return new Promise((resolve, reject) => { - this.modbusClient.close(() => { + this.modbusClientApi.close(() => { logger.info('SunSpec service disconnected'); resolve(); }); @@ -61,10 +62,10 @@ class ModbusClient { } getValueModel() { - if (this.models[MODEL.INVERTER_1_PHASE] !== null) { + if (this.models[MODEL.INVERTER_1_PHASE]) { return MODEL.INVERTER_1_PHASE; } - if (this.models[MODEL.INVERTER_3_PHASE] !== null) { + if (this.models[MODEL.INVERTER_3_PHASE]) { return MODEL.INVERTER_3_PHASE; } return MODEL.INVERTER_SPLIT_PHASE; @@ -77,7 +78,7 @@ class ModbusClient { async readRegister(registerId, registerLength) { return new Promise((resolve, reject) => { - this.modbusClient.readHoldingRegisters(registerId - 1, registerLength, (err, data) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, registerLength, (err, data) => { if (err) { reject(err); } else { @@ -89,7 +90,7 @@ class ModbusClient { async readRegisterAsString(registerId, registerLength) { return new Promise((resolve, reject) => { - this.modbusClient.readHoldingRegisters(registerId - 1, registerLength, (err, data) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, registerLength, (err, data) => { if (err) { reject(err); } else { @@ -102,7 +103,7 @@ class ModbusClient { async readRegisterAsInt16(registerId) { return new Promise((resolve, reject) => { - this.modbusClient.readHoldingRegisters(registerId - 1, 1, (err, data) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, 1, (err, data) => { if (err) { reject(err); } else { @@ -115,7 +116,7 @@ class ModbusClient { async readRegisterAsInt32(registerId) { return new Promise((resolve, reject) => { - this.modbusClient.readHoldingRegisters(registerId - 1, 2, (err, data) => { + this.modbusClientApi.readHoldingRegisters(registerId - 1, 2, (err, data) => { if (err) { reject(err); } else { diff --git a/server/test/services/sunspec/lib/sunspec.connect.test.js b/server/test/services/sunspec/lib/sunspec.connect.test.js index 9622998426..bcf5118add 100644 --- a/server/test/services/sunspec/lib/sunspec.connect.test.js +++ b/server/test/services/sunspec/lib/sunspec.connect.test.js @@ -17,7 +17,6 @@ const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { './sunspec.scanDevices': { scanDevices: scanDevicesMock }, }); -const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; @@ -44,19 +43,6 @@ describe('SunSpec connect', () => { sinon.reset(); }); - it('should not connect - not configured', async () => { - gladys.variable.getValue = fake.resolves(null); - - try { - await sunSpecManager.connect(); - assert.fail(); - } catch (error) { - expect(error).to.be.an.instanceof(ServiceNotConfiguredError); - } - - expect(sunSpecManager.connected).eql(false); - }); - it('should connect', async () => { gladys.variable.getValue = fake.resolves('[{"ip":"192.168.1.0/24"}]'); diff --git a/server/test/services/sunspec/lib/sunspec.getStatus.test.js b/server/test/services/sunspec/lib/sunspec.getStatus.test.js index f2aca1c6cb..1bef2cf40e 100644 --- a/server/test/services/sunspec/lib/sunspec.getStatus.test.js +++ b/server/test/services/sunspec/lib/sunspec.getStatus.test.js @@ -23,17 +23,21 @@ describe('SunSpec getStatus', () => { it('should connected', async () => { sunSpecManager.connected = true; + sunSpecManager.sunspecIps = ['192.168.1.xx']; const status = await sunSpecManager.getStatus(); expect(status).to.deep.equals({ connected: true, + sunspecIps: ['192.168.1.xx'], }); }); it('should not connected', async () => { sunSpecManager.connected = false; + sunSpecManager.sunspecIps = ['192.168.1.xx']; const status = await sunSpecManager.getStatus(); expect(status).to.deep.equals({ connected: false, + sunspecIps: ['192.168.1.xx'], }); }); }); diff --git a/server/test/services/sunspec/lib/sunspec.scan.test.js b/server/test/services/sunspec/lib/sunspec.scan.test.js new file mode 100644 index 0000000000..8d077d3088 --- /dev/null +++ b/server/test/services/sunspec/lib/sunspec.scan.test.js @@ -0,0 +1,72 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const proxyquire = require('proxyquire'); +const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants'); +const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors'); + +const { fake, assert } = sinon; +const ModbusTCPMock = require('./utils/ModbusTCPMock.test'); +const ScannerClassMock = require('./utils/ScannerClassMock.test'); + +const SERVICE_ID = 'faea9c35-759a-44d5-bcc9-2af1de37b8b4'; + +const SunSpecManager = proxyquire('../../../../services/sunspec/lib', { + ModbusTCP: { ModbusTCP: ModbusTCPMock }, + ScannerClass: { ScannerClass: ScannerClassMock }, +}); + +describe('SunSpec scan', () => { + // PREPARE + let gladys; + let sunSpecManager; + + beforeEach(() => { + gladys = { + event: { + emit: fake.resolves(null), + }, + variable: { + getValue: fake.resolves('[{"ip":"192.168.1.0/24"}]'), + }, + }; + sunSpecManager = new SunSpecManager(gladys, ModbusTCPMock, ScannerClassMock, SERVICE_ID); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('should not scan - not configured', async () => { + try { + await sunSpecManager.scan(); + assert.fail(); + } catch (error) { + expect(error).to.be.an.instanceof(ServiceNotConfiguredError); + } + }); + + it('should not scan - already scanning', async () => { + sunSpecManager.scanning = true; + await sunSpecManager.scan(); + assert.notCalled(gladys.variable.getValue); + }); + + it('should find Sunspec device', async () => { + await sunSpecManager.scan(); + assert.callCount(sunSpecManager.eventManager.emit, 3); + assert.calledWithExactly(sunSpecManager.eventManager.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, + payload: { + scanning: true, + }, + }); + assert.calledWithExactly(sunSpecManager.eventManager.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.SUNSPEC.SCANNING, + payload: { + scanning: false, + success: true, + }, + }); + }); +}); diff --git a/server/test/services/sunspec/lib/utils/ScannerClassMock.test.js b/server/test/services/sunspec/lib/utils/ScannerClassMock.test.js new file mode 100644 index 0000000000..c4bf7b690c --- /dev/null +++ b/server/test/services/sunspec/lib/utils/ScannerClassMock.test.js @@ -0,0 +1,13 @@ +class ScannerClassMock { + // eslint-disable-next-line class-methods-use-this + on(step, cb) { + cb([ + { + ip: '192.168.1.xx', + openPorts: [502], + }, + ]); + } +} + +module.exports = ScannerClassMock; diff --git a/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js b/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js index a1c1b5632c..6f163e7b40 100644 --- a/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js +++ b/server/test/services/sunspec/lib/utils/sunspec.ModbusClient.test.js @@ -4,6 +4,7 @@ const { expect } = require('chai'); const { fake, assert, stub } = sinon; const { ModbusClient } = require('../../../../../services/sunspec/lib/utils/sunspec.ModbusClient'); +const { MODEL } = require('../../../../../services/sunspec/lib/sunspec.constants'); describe('SunSpec ModbusClient', () => { let modbusClientApi; @@ -11,6 +12,7 @@ describe('SunSpec ModbusClient', () => { beforeEach(() => { modbusClientApi = { connectTCP: fake.returns(true), + close: fake.returns(true), readHoldingRegisters: fake.returns(true), }; }); @@ -19,6 +21,29 @@ describe('SunSpec ModbusClient', () => { sinon.reset(); }); + it('should not connect - wrong SID', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .returns(0x00); + await client.connect('host', 502); + assert.calledOnce(modbusClientApi.connectTCP); + expect(client.models).to.deep.equal({}); + }); + + it('should not connect - missing model', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .returns(0x53756e53); + client.readRegisterAsInt16 = stub() + .onCall(0) // MODEL_ID + .returns(2); + await client.connect('host', 502); + assert.calledOnce(modbusClientApi.connectTCP); + expect(client.models).to.deep.equal({}); + }); + it('should connect', async () => { const client = new ModbusClient(modbusClientApi); client.readRegisterAsInt32 = stub() @@ -50,4 +75,65 @@ describe('SunSpec ModbusClient', () => { }, }); }); + + it('should not close', async () => { + const client = new ModbusClient(modbusClientApi); + client.readRegisterAsInt32 = stub() + .onFirstCall() + .returns(0x00); + await client.close(); + assert.calledOnce(modbusClientApi.close); + }); + + it('should readModel', async () => { + const client = new ModbusClient(modbusClientApi); + modbusClientApi.readHoldingRegisters = stub().yields(null, { + buffer: Buffer.from([0, 1]), + }); + client.models = { + 101: { + registerStart: 0, + registerLength: 10, + }, + }; + const res = await client.readModel(101); + expect(res).to.be.instanceof(Buffer); + expect(res).to.deep.equal(Buffer.from([0, 1])); + }); + + it('should readRegister', async () => { + const client = new ModbusClient(modbusClientApi); + modbusClientApi.readHoldingRegisters = stub().yields(null, { + buffer: Buffer.from([0, 1]), + }); + const res = await client.readRegister(1, 4); + expect(res).to.be.instanceof(Buffer); + expect(res).to.deep.equal(Buffer.from([0, 1])); + }); + + it('should readRegisterAsString', async () => { + const client = new ModbusClient(modbusClientApi); + modbusClientApi.readHoldingRegisters = stub().yields(null, { + buffer: Buffer.from('1234'), + }); + const res = await client.readRegisterAsString(1, 4); + expect(res).to.eq('1234'); + }); + + it('should getValueModel 1-phase', async () => { + const client = new ModbusClient(modbusClientApi); + client.models[MODEL.INVERTER_1_PHASE] = {}; + expect(client.getValueModel()).to.eq(101); + }); + + it('should getValueModel split-phase', async () => { + const client = new ModbusClient(modbusClientApi); + expect(client.getValueModel()).to.eq(102); + }); + + it('should getValueModel 3-phase', async () => { + const client = new ModbusClient(modbusClientApi); + client.models[MODEL.INVERTER_3_PHASE] = {}; + expect(client.getValueModel()).to.eq(103); + }); });