Skip to content

Commit

Permalink
Merge pull request #74 from longzheng/inverters-mqtt
Browse files Browse the repository at this point in the history
MQTT inverter support
  • Loading branch information
longzheng authored Jan 18, 2025
2 parents f202280 + a7954d3 commit d85308e
Show file tree
Hide file tree
Showing 10 changed files with 358 additions and 39 deletions.
37 changes: 37 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,43 @@
],
"additionalProperties": false,
"description": "SMA inverter configuration"
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mqtt"
},
"host": {
"type": "string",
"description": "The host of the MQTT broker, including \"mqtt://\""
},
"username": {
"type": "string",
"description": "The username for the MQTT broker"
},
"password": {
"type": "string",
"description": "The password for the MQTT broker"
},
"topic": {
"type": "string",
"description": "The topic to pull inverter readings from"
},
"pollingIntervalMs": {
"type": "number",
"description": "The minimum number of seconds between polling, subject to the latency of the polling loop.",
"default": 200
}
},
"required": [
"type",
"host",
"topic"
],
"additionalProperties": false,
"description": "MQTT inverter configuration"
}
]
},
Expand Down
125 changes: 125 additions & 0 deletions docs/configuration/inverters.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,128 @@ For SunSpec over RTU, you need to modify the `connection`
### Fronius

To enable SunSpec/Modbus on Fronius inverters, you'll need to access the inverter's local web UI and [enable the Modbus TCP option](https://github.com/longzheng/open-dynamic-export/wiki/Fronius-SunSpec-Modbus-configuration).

## MQTT

> [!WARNING]
> The MQTT inverter configuration does not support control. It is designed for systems which will monitor the API or "publish" active limit output to apply inverter control externally.
A MQTT topic can be read to get the inveter measurements.

To configure a MQTT inverter connection, add the following property to `config.json`

```js
{
"inverters": [
{
"type": "mqtt", // (string) required: the type of inverter
"host": "mqtt://192.168.1.2", // (string) required: the MQTT broker host
"username": "user", // (string) optional: the MQTT broker username
"password": "password", // (string) optional: the MQTT broker password
"topic": "inverters/1" // (string) required: the MQTT topic to read
"pollingIntervalMs": // (number) optional: the polling interval in milliseconds, default 200
}
]
...
}
```

The MQTT topic must contain a JSON message that meets the following schema

```js
z.object({
inverter: z.object({
/**
* Positive values = inverter export (produce) power
*
* Negative values = inverter import (consume) power
*
* Value is total (net across all phases) measurement
*/
realPower: z.number(),
/**
* Positive values = inverter export (produce) power
*
* Negative values = inverter import (consume) power
*
* Value is total (net across all phases) measurement
*/
reactivePower: z.number(),
// Voltage of phase A (null if not available)
voltagePhaseA: z.number().nullable(),
// Voltage of phase B (null if not available)
voltagePhaseB: z.number().nullable(),
// Voltage of phase C (null if not available)
voltagePhaseC: z.number().nullable(),
frequency: z.number(),
}),
nameplate: z.object({
/**
* Type of DER device Enumeration
*
* PV = 4,
* PV_STOR = 82,
*/
type: z.nativeEnum(DERTyp),
// Maximum active power output in W
maxW: z.number(),
// Maximum apparent power output in VA
maxVA: z.number(),
// Maximum reactive power output in var
maxVar: z.number(),
}),
settings: z.object({
// Currently set active power output in W
maxW: z.number(),
// Currently set apparent power output in VA
maxVA: z.number().nullable(),
// Currently set reactive power output in var
maxVar: z.number().nullable(),
}),
status: z.object({
// DER OperationalModeStatus value:
// 0 - Not applicable / Unknown
// 1 - Off
// 2 - Operational mode
// 3 - Test mode
operationalModeStatus: z.nativeEnum(OperationalModeStatusValue),
// DER ConnectStatus value (bitmap):
// 0 - Connected
// 1 - Available
// 2 - Operating
// 3 - Test
// 4 - Fault / Error
genConnectStatus: connectStatusValueSchema,
}),
})
```

For example

```json
{
"inverter": {
"realPower": 4500,
"reactivePower": 1500,
"voltagePhaseA": 230.5,
"voltagePhaseB": null,
"voltagePhaseC": null,
"frequency": 50.1
},
"nameplate": {
"type": 4,
"maxW": 5000,
"maxVA": 5000,
"maxVar": 5000
},
"settings": {
"maxW": 5000,
"maxVA": 5000,
"maxVar": 5000
},
"status": {
"operationalModeStatus": 2,
"genConnectStatus": 7
}
}
```
8 changes: 8 additions & 0 deletions src/coordinator/helpers/inverterSample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { SunSpecInverterDataPoller } from '../../inverter/sunspec/index.js';
import { type InverterConfiguration } from './inverterController.js';
import { type Logger } from 'pino';
import { SmaInverterDataPoller } from '../../inverter/sma/index.js';
import { MqttInverterDataPoller } from '../../inverter/mqtt/index.js';

export class InvertersPoller extends EventEmitter<{
data: [DerSample];
Expand Down Expand Up @@ -46,6 +47,13 @@ export class InvertersPoller extends EventEmitter<{
inverterIndex: index,
}).on('data', inverterOnData);
}
case 'mqtt': {
return new MqttInverterDataPoller({
mqttConfig: inverterConfig,
applyControl: config.inverterControl.enabled,
inverterIndex: index,
}).on('data', inverterOnData);
}
}
},
);
Expand Down
30 changes: 30 additions & 0 deletions src/helpers/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,36 @@ export const configSchema = z.object({
})
.merge(modbusSchema)
.describe('SMA inverter configuration'),
z
.object({
type: z.literal('mqtt'),
host: z
.string()
.describe(
'The host of the MQTT broker, including "mqtt://"',
),
username: z
.string()
.optional()
.describe('The username for the MQTT broker'),
password: z
.string()
.optional()
.describe('The password for the MQTT broker'),
topic: z
.string()
.describe(
'The topic to pull inverter readings from',
),
pollingIntervalMs: z
.number()
.optional()
.describe(
'The minimum number of seconds between polling, subject to the latency of the polling loop.',
)
.default(200),
})
.describe('MQTT inverter configuration'),
]),
)
.describe('Inverter configuration'),
Expand Down
70 changes: 41 additions & 29 deletions src/inverter/inverterData.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,42 @@
import { type DERTyp } from '../connections/sunspec/models/nameplate.js';
import { type ConnectStatusValue } from '../sep2/models/connectStatus.js';
import { type OperationalModeStatusValue } from '../sep2/models/operationModeStatus.js';
import { z } from 'zod';
import { DERTyp } from '../connections/sunspec/models/nameplate.js';
import { connectStatusValueSchema } from '../sep2/models/connectStatus.js';
import { OperationalModeStatusValue } from '../sep2/models/operationModeStatus.js';
import { type SampleBase } from '../coordinator/helpers/sampleBase.js';

export type InverterData = {
date: Date;
inverter: {
realPower: number;
reactivePower: number;
voltagePhaseA: number | null;
voltagePhaseB: number | null;
voltagePhaseC: number | null;
frequency: number;
};
nameplate: {
type: DERTyp;
maxW: number;
maxVA: number;
maxVar: number;
};
settings: {
maxW: number;
maxVA: number | null;
maxVar: number | null;
};
status: {
operationalModeStatus: OperationalModeStatusValue;
genConnectStatus: ConnectStatusValue;
};
};
export const inverterDataSchema = z.object({
inverter: z.object({
/**
* Positive values = inverter export (produce) power
*
* Negative values = inverter import (consume) power
*
* Value is total (net across all phases) measurement
*/
realPower: z.number(),
reactivePower: z.number(),
voltagePhaseA: z.number().nullable(),
voltagePhaseB: z.number().nullable(),
voltagePhaseC: z.number().nullable(),
frequency: z.number(),
}),
nameplate: z.object({
type: z.nativeEnum(DERTyp),
maxW: z.number(),
maxVA: z.number(),
maxVar: z.number(),
}),
settings: z.object({
maxW: z.number(),
maxVA: z.number().nullable(),
maxVar: z.number().nullable(),
}),
status: z.object({
operationalModeStatus: z.nativeEnum(OperationalModeStatusValue),
genConnectStatus: connectStatusValueSchema,
}),
});

export type InverterDataBase = z.infer<typeof inverterDataSchema>;

export type InverterData = SampleBase & InverterDataBase;
77 changes: 77 additions & 0 deletions src/inverter/mqtt/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {
type InverterDataBase,
inverterDataSchema,
type InverterData,
} from '../inverterData.js';
import { InverterDataPollerBase } from '../inverterDataPollerBase.js';
import { type Config } from '../../helpers/config.js';
import mqtt from 'mqtt';

export class MqttInverterDataPoller extends InverterDataPollerBase {
private client: mqtt.MqttClient;
private cachedMessage: InverterDataBase | null = null;

constructor({
mqttConfig,
inverterIndex,
applyControl,
}: {
mqttConfig: Extract<Config['inverters'][number], { type: 'mqtt' }>;
inverterIndex: number;
applyControl: boolean;
}) {
super({
name: 'MqttInverterDataPoller',
pollingIntervalMs: mqttConfig.pollingIntervalMs,
applyControl,
inverterIndex,
});

this.client = mqtt.connect(mqttConfig.host, {
username: mqttConfig.username,
password: mqttConfig.password,
});

this.client.on('connect', () => {
this.client.subscribe(mqttConfig.topic);
});

this.client.on('message', (_topic, message) => {
const data = message.toString();

const result = inverterDataSchema.safeParse(JSON.parse(data));

if (!result.success) {
this.logger.error({
message: `Invalid MQTT message. Error: ${result.error.message}`,
data,
});
return;
}

this.cachedMessage = result.data;
});

void this.startPolling();
}

// eslint-disable-next-line @typescript-eslint/require-await
override async getInverterData(): Promise<InverterData> {
if (!this.cachedMessage) {
throw new Error('No inverter data on MQTT');
}

return { date: new Date(), ...this.cachedMessage };
}

override onDestroy(): void {
this.client.end();
}

// eslint-disable-next-line @typescript-eslint/require-await
override async onControl(): Promise<void> {
if (this.applyControl) {
throw new Error('Unable to control MQTT inverter');
}
}
}
4 changes: 2 additions & 2 deletions src/meters/siteSample.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import { z } from 'zod';
// aligns with the CSIP-AUS requirements for site sample
export const siteSampleDataSchema = z.object({
/**
* Positive values = site import power
* Positive values = site import (consume) power
*
* Negative values = site export power
* Negative values = site export (produce) power
*/
realPower: z.union([
perPhaseNetMeasurementSchema,
Expand Down
Loading

0 comments on commit d85308e

Please sign in to comment.