From 98047546df1af8cd556cbf38dc22a948de4b2bc1 Mon Sep 17 00:00:00 2001 From: Stephan van Rooij Date: Sat, 30 May 2020 17:32:57 +0200 Subject: [PATCH] feat: Support for SunSpec inverter readings Fixes #7 --- package-lock.json | 32 ++++++++++++++++++++++++++++++ package.json | 1 + src/config.ts | 18 ++++++++++++++++- src/dsmr-message.ts | 16 +++++++++++++++ src/index.ts | 5 ++++- src/output/mqtt-output.ts | 12 ++++++++++++ src/output/web-server.ts | 17 ++++++++++++++++ src/output/wwwroot/index.html | 37 ++++++++++++++++++++++++++++------- src/output/wwwroot/loader.js | 7 +++++++ src/p1-reader-events.ts | 2 ++ src/p1-reader.ts | 35 ++++++++++++++++++++++++++------- 11 files changed, 166 insertions(+), 16 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4ed3b0e..c5b2eaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1267,6 +1267,14 @@ "type-detect": "4.0.8" } }, + "@svrooij/sunspec": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@svrooij/sunspec/-/sunspec-0.9.0.tgz", + "integrity": "sha512-Vguz5u5jNWAaYSwg4rINzxWnUG//KUXEqIiBOuQgcJmBJWxskLjxrWhzM5dWP/xlG+q8cu7NcwwpwrPHIxJccA==", + "requires": { + "modbus-serial": "^7.8.1" + } + }, "@svrooij/tcp-server": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@svrooij/tcp-server/-/tcp-server-1.0.1.tgz", @@ -6740,6 +6748,30 @@ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.2.tgz", "integrity": "sha512-ejdnDQcR75gwknmMw/tx02AuRs8jCtqFoFqDZMjiNxsu85sRIJVXDKHuLYvUUPRBUtV2FpSZa9bL1BUa3BdR2g==" }, + "modbus-serial": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/modbus-serial/-/modbus-serial-7.8.1.tgz", + "integrity": "sha512-w7dyTcIbEyza3GAem7cf7wQLcef+s8jSw4Z086dl5kXexPKN0q/sHTjliUO2H4Pdr4jgZjKTzMvkJJUCJwU0xQ==", + "requires": { + "debug": "^4.1.1", + "serialport": "^8.0.6" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", diff --git a/package.json b/package.json index 901c9f7..5a8a249 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "homepage": "https://github.com/svrooij/smartmeter2mqtt#readme", "dependencies": { "@serialport/parser-readline": "^8.0.6", + "@svrooij/sunspec": "^0.9.0", "@svrooij/tcp-server": "^1.0.1", "crc": "^3.8.0", "express": "^4.17.1", diff --git a/src/config.ts b/src/config.ts index d2874ce..8dd8e02 100644 --- a/src/config.ts +++ b/src/config.ts @@ -18,6 +18,11 @@ export interface MqttConfig { url: string; } +export interface SunspecConfig { + host: string; + port: number; +} + export interface OutputConfig { debug: boolean; jsonSocket?: number; @@ -32,7 +37,7 @@ export interface Config { socket?: string; outputs: OutputConfig; - + solar?: SunspecConfig; } export class ConfigLoader { @@ -64,6 +69,9 @@ export class ConfigLoader { .conflicts('port', 'socket') .describe('debug', 'Enable debug output') .boolean('debug') + .describe('sunspec-modbus', 'IP of solar inverter with modbus TCP enabled') + .describe('sunspec-modbus-port', 'modbus TCP port') + .number('sunspec-modbus-port') .number('web-server') .number('tcp-server') .number('raw-tcp-server') @@ -75,6 +83,7 @@ export class ConfigLoader { 'post-interval': 300, 'mqtt-topic': 'smartmeter', 'mqtt-discovery-prefix': 'homeassistant', + 'sunspec-modbus-port': 502, }) .wrap(80) .version() @@ -121,6 +130,13 @@ export class ConfigLoader { config.outputs.webserver = args['web-server']; } + if (args['sunspec-modbus']) { + config.solar = { + host: args['sunspec-modbus'], + port: args['sunspec-modbus-port'], + } as SunspecConfig; + } + return config; } } diff --git a/src/dsmr-message.ts b/src/dsmr-message.ts index db64351..a9f3de1 100644 --- a/src/dsmr-message.ts +++ b/src/dsmr-message.ts @@ -1,5 +1,6 @@ import GasValue from './gas-value'; + /** * Properties in this base class are used by some outputs. * By defining them here we set the type instead of all possible types. @@ -91,6 +92,21 @@ interface DsmrMessageBase { * @memberof DsmrMessageBase */ totalT2Use?: number; + + /** + * Number of watts your solar panels are producing. + * + * @type {number} + * @memberof DsmrMessageBase + */ + solarProduction?: number; + /** + * This is the solar production - calculated usage. Should show how much you are actually using. + * + * @type {number} + * @memberof DsmrMessageBase + */ + houseUsage?: number; } /** diff --git a/src/index.ts b/src/index.ts index 5266773..b0195d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,7 +25,7 @@ class Smartmeter { console.log('----------------------------------------'); } - start(): void { + public async start(): Promise { if (this.config.serialPort && this.config.serialPort.length > 0) { console.log('- Read serial port %s', this.config.serialPort); this.reader.startWithSerialPort(this.config.serialPort); @@ -41,6 +41,9 @@ class Smartmeter { console.warn('Port or socket required'); process.exit(2); } + if (this.config.solar) { + await this.reader.enableSubspec(this.config.solar.host, this.config.solar.port); + } this.startOutputs(); } diff --git a/src/output/mqtt-output.ts b/src/output/mqtt-output.ts index 2f8b0cd..44b97b6 100644 --- a/src/output/mqtt-output.ts +++ b/src/output/mqtt-output.ts @@ -1,10 +1,12 @@ import mqtt, { MqttClient, IClientOptions } from 'mqtt'; +import { SunspecResult } from '@svrooij/sunspec/lib/sunspec-result'; import Output from './output'; import P1ReaderEvents from '../p1-reader-events'; import { MqttConfig } from '../config'; import P1Reader from '../p1-reader'; import DsmrMessage from '../dsmr-message'; + export default class MqttOutput extends Output { private mqtt?: MqttClient; @@ -19,6 +21,7 @@ export default class MqttOutput extends Output { this.mqtt.on('connect', () => { this.mqtt?.publish(`${this.config.prefix}/connected`, '2', { qos: 0, retain: true }); }); + p1Reader.on(P1ReaderEvents.ParsedResult, (data) => { this.publishData(data); if (this.config.discovery && !this.discoverySend) { @@ -26,9 +29,14 @@ export default class MqttOutput extends Output { this.discoverySend = true; } }); + p1Reader.on(P1ReaderEvents.UsageChanged, (data) => { this.publishUsage(data); }); + + p1Reader.on(P1ReaderEvents.SolarResult, (data) => { + this.publishSolar(data); + }); } async close(): Promise { @@ -73,6 +81,10 @@ export default class MqttOutput extends Output { } } + private publishSolar(data: SunspecResult): void { + this.sendToMqtt('solar', data); + } + private sendToMqtt(topicSuffix: string, data: any): void { this.mqtt?.publish(this.getTopic(topicSuffix), JSON.stringify(data), { qos: 0, retain: true }); } diff --git a/src/output/web-server.ts b/src/output/web-server.ts index 562b6c1..2da4e84 100644 --- a/src/output/web-server.ts +++ b/src/output/web-server.ts @@ -1,6 +1,7 @@ import http, { Server } from 'http'; import WebSocket from 'ws'; import path from 'path'; +import { SunspecResult } from '@svrooij/sunspec/lib/sunspec-result'; import Output from './output'; import P1ReaderEvents from '../p1-reader-events'; import P1Reader from '../p1-reader'; @@ -8,12 +9,14 @@ import DsmrMessage from '../dsmr-message'; import express = require('express'); + export default class WebServer extends Output { private server?: Server; private wsServer?: WebSocket.Server; private lastReading?: DsmrMessage; + private lastSolarReading?: SunspecResult; private checkTimeout?: NodeJS.Timeout; @@ -27,6 +30,11 @@ export default class WebServer extends Output { p1Reader.on(P1ReaderEvents.ParsedResult, (data) => { this.setReading(data); }); + + p1Reader.on(P1ReaderEvents.SolarResult, (data) => { + this.lastSolarReading = data; + }); + if (this.startServer === true) { this.startWebserver(); } @@ -51,6 +59,7 @@ export default class WebServer extends Output { }); } app.get('/api/reading', (req, res) => this.getReading(req, res)); + app.get('/api/solar', (req, res) => this.getSolarReading(req, res)); app.use(express.static(path.join(__dirname, 'wwwroot'), { index: 'index.html' })); this.server.listen(this.port); this.checkTimeout = setInterval(() => { this.checkSockets(); }, 10000); @@ -73,6 +82,14 @@ export default class WebServer extends Output { } } + private getSolarReading(req: any, res: any): void { + if (this.lastSolarReading) { + res.json(this.lastSolarReading); + } else { + res.status(400).json({ err: 'No reading just yet!' }); + } + } + private setReading(newReading: DsmrMessage): void { this.lastReading = newReading; this.broadcastMessage(newReading); diff --git a/src/output/wwwroot/index.html b/src/output/wwwroot/index.html index f09d766..f1bef74 100644 --- a/src/output/wwwroot/index.html +++ b/src/output/wwwroot/index.html @@ -20,16 +20,38 @@ menu
+
+
+
+
+

wb_sunny Solar production

+

watt

+
+
+
+
+
+
+

house House usage

+

watt

+
+
+
+ +
+
@@ -70,14 +92,15 @@

wb_sunny -
-
- filter_drama Gas -

m3

-
+
+
+ filter_drama Gas +

m3

+

+
diff --git a/src/output/wwwroot/loader.js b/src/output/wwwroot/loader.js index 81b4605..4b2442f 100644 --- a/src/output/wwwroot/loader.js +++ b/src/output/wwwroot/loader.js @@ -81,4 +81,11 @@ function updateData(data) { let gas = data.gas.totalUse; gas = Math.round(gas * 100.0) / 100.0; $('.totalGas').text(gas); + + if(data.houseUsage) { + // Load solar + $('.houseUsage').text(data.houseUsage); + $('.solarProduction').text(Math.round(data.solarProduction)); + $('.solar').removeClass('hide'); + } } diff --git a/src/p1-reader-events.ts b/src/p1-reader-events.ts index 2c93043..7271196 100644 --- a/src/p1-reader-events.ts +++ b/src/p1-reader-events.ts @@ -13,4 +13,6 @@ export default class P1ReaderEvents { /** Usage change is emitted after the parsed result. It keeps the last result to compare. */ static get UsageChanged(): string { return 'usageChanged'; } + + static get SolarResult(): string { return 'solar'; } } diff --git a/src/p1-reader.ts b/src/p1-reader.ts index b3009de..336fc6d 100644 --- a/src/p1-reader.ts +++ b/src/p1-reader.ts @@ -1,6 +1,7 @@ import SerialPort from 'serialport'; import { Socket } from 'net'; import { EventEmitter } from 'events'; +import { SunspecReader } from '@svrooij/sunspec'; import P1Parser from './p1-parser'; import P1ReaderEvents from './p1-reader-events'; import DsmrMessage from './dsmr-message'; @@ -24,6 +25,9 @@ export default class P1Reader extends EventEmitter { private parser?: P1Parser; + // Inverter stuff + private sunspecReader?: SunspecReader; + constructor() { super(); this.usage = 0; @@ -31,7 +35,7 @@ export default class P1Reader extends EventEmitter { this.parsing = false; } - startWithSerialPort(path: string, baudRate = 115200): void { + public startWithSerialPort(path: string, baudRate = 115200): void { if (this.reading) throw new Error('Already reading'); this.serialPort = new SerialPort(path, { baudRate }); this.serialParser = new SerialPort.parsers.Readline({ delimiter: '\r\n' }); @@ -43,7 +47,7 @@ export default class P1Reader extends EventEmitter { this.reading = true; } - startWithSocket(host: string, port: number): void { + public startWithSocket(host: string, port: number): void { this.socket = new Socket(); this.socket.connect(port, host); this.socket.setEncoding('ascii'); @@ -60,14 +64,19 @@ export default class P1Reader extends EventEmitter { }); } - startParsing(): void { + public startParsing(): void { if (this.parsing) return; this.parser = new P1Parser(); this.on(P1ReaderEvents.Line, (line) => { this.parseLine(line.trim()); }); this.parsing = true; } - parseLine(line: string): void { + public async enableSubspec(host: string, port: number): Promise { + this.sunspecReader = new SunspecReader(host, port); + this.sunspecReader.readInverterInfo(); + } + + private parseLine(line: string): void { if (P1Parser.isStart(line)) { this.parser = new P1Parser(); this.parser.addLine(line); @@ -76,7 +85,7 @@ export default class P1Reader extends EventEmitter { } } - handleEnd(): void { + private async handleEnd(): Promise { if (this.parser === undefined) { throw new Error('Parser not running'); } @@ -88,7 +97,16 @@ export default class P1Reader extends EventEmitter { this.emit(P1ReaderEvents.ErrorMessage, 'CRC failed'); return; } - result.calculatedUsage = Math.round(((result.currentUsage || 0.0) - (result.currentDelivery || 0.0)) * 1000); + const solar = this.sunspecReader ? await this.sunspecReader.readData() : undefined; + if (solar) { + result.calculatedUsage = Math.round(((result.currentUsage || 0.0) - (result.currentDelivery || 0.0)) * 1000); + result.solarProduction = solar.acPower; + result.houseUsage = Math.round((solar.acPower ?? 0) + result.calculatedUsage); + this.emit(P1ReaderEvents.SolarResult, solar); + } else { + result.calculatedUsage = Math.round(((result.currentUsage || 0.0) - (result.currentDelivery || 0.0)) * 1000); + } + this.lastResult = result; this.emit(P1ReaderEvents.ParsedResult, this.lastResult); @@ -104,7 +122,10 @@ export default class P1Reader extends EventEmitter { } } - close(): Promise { + public close(): Promise { + if (this.sunspecReader) { + this.sunspecReader = undefined; + } return new Promise((resolve) => { this.reading = false; if (this.serialPort) {