Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MQTT inverter support #74

Merged
merged 6 commits into from
Jan 18, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading