diff --git a/config.schema.json b/config.schema.json index df9e164..4d76964 100644 --- a/config.schema.json +++ b/config.schema.json @@ -464,6 +464,39 @@ "description": "MQTT meter configuration" } ] + }, + "publish": { + "type": "object", + "properties": { + "mqtt": { + "type": "object", + "properties": { + "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 publish limits" + } + }, + "required": [ + "host", + "topic" + ], + "additionalProperties": false + } + }, + "additionalProperties": false, + "description": "Publish active control limits" } }, "required": [ diff --git a/config/config.example.json b/config/config.example.json index 903c27a..ecfc11d 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -1,9 +1,5 @@ { "limiters": { - "sep2": { - "host": "https://sep2-test.energyq.com.au", - "dcapUri": "/api/v2/dcap" - } }, "inverters": [ { diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index d6fd107..def4401 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -37,6 +37,7 @@ export default withMermaid( }, { text: 'Site meter', link: '/configuration/meter' }, { text: 'Limiters', link: '/configuration/limiters' }, + { text: 'Publish', link: '/configuration/publish' }, ], }, { diff --git a/docs/configuration/limiters.md b/docs/configuration/limiters.md index 75b2125..b125c16 100644 --- a/docs/configuration/limiters.md +++ b/docs/configuration/limiters.md @@ -35,7 +35,7 @@ To use the CSIP-AUS limiter, add following property to `config.json` "limiters": { "sep2": { "host": "https://sep2-test.energyq.com.au", // (string) required: the SEP2 server host - "dcapUri": "/api/v2/dcap" // (string) required: the device capability discovery URI + "dcapUri": "/api/v2/dcap", // (string) required: the device capability discovery URI "nmi": "1234567890" // (string) optional: for utilities that require in-band registration, the NMI of the site } } @@ -53,8 +53,8 @@ To set fixed limits (such as for fixed export limits), add the following propert "fixed": { "connect": true, // (true/false) optional: whether the inverters should be connected to the grid "exportLimitWatts": 5000, // (number) optional: the maximum export limit in watts - "generationLimitWatts": 10000 // (number) optional: the maximum generation limit in watts - "importLimitWatts": 5000 // (number) optional: the maximum import limit in watts (not currently used) + "generationLimitWatts": 10000, // (number) optional: the maximum generation limit in watts + "importLimitWatts": 5000, // (number) optional: the maximum import limit in watts (not currently used) "loadLimitWatts": 10000 // (number) optional: the maximum load limit in watts (not currently used) } } diff --git a/docs/configuration/publish.md b/docs/configuration/publish.md new file mode 100644 index 0000000..371ee46 --- /dev/null +++ b/docs/configuration/publish.md @@ -0,0 +1,105 @@ +# Publish + +Optionally configure the active limits to be published to an external system. + +[[toc]] + +## MQTT + +Write active limits to a MQTT topic. + +To configure a MQTT output, add the following property to `config.json` + +```js +{ + "publish": { + "mqtt": { + "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": "limits" // (string) required: the MQTT topic to write + } + } + ... +} +``` + +The MQTT topic will contain a JSON message that meets the following Zod schema + +```ts +const inverterControlTypesSchema = z.union([ + z.literal("fixed"), + z.literal("mqtt"), + z.literal("sep2"), + z.literal("twoWayTariff"), + z.literal("negativeFeedIn") +]) + +const activeInverterControlLimitSchema = z.object({ + opModEnergize: z.union([ + z.object({ + value: z.boolean(), + source: inverterControlTypesSchema + }), + z.undefined() + ]), + opModConnect: z.union([ + z.object({ + value: z.boolean(), + source: inverterControlTypesSchema + }), + z.undefined() + ]), + opModGenLimW: z.union([ + z.object({ + value: z.number(), + source: inverterControlTypesSchema + }), + z.undefined() + ]), + opModExpLimW: z.union([ + z.object({ + value: z.number(), + source: inverterControlTypesSchema + }), + z.undefined() + ]), + opModImpLimW: z.union([ + z.object({ + value: z.number(), + source: inverterControlTypesSchema + }), + z.undefined() + ]), + opModLoadLimW: z.union([ + z.object({ + value: z.number(), + source: inverterControlTypesSchema + }), + z.undefined() + ]) +}) +``` + +For example + +```js +{ + "opModEnergize": { + "source": "sep2", + "value": true + }, + "opModConnect": { + "source": "sep2", + "value": true + }, + "opModExpLimW": { + "source": "sep2", + "value": 1500 + }, + "opModImpLimW": { + "source": "sep2", + "value": 1500 + } +} +``` \ No newline at end of file diff --git a/src/coordinator/helpers/inverterController.ts b/src/coordinator/helpers/inverterController.ts index 2c6ce3b..557aca7 100644 --- a/src/coordinator/helpers/inverterController.ts +++ b/src/coordinator/helpers/inverterController.ts @@ -22,6 +22,7 @@ import { CappedArrayStack } from '../../helpers/cappedArrayStack.js'; import { timeWeightedAverage } from '../../helpers/timeWeightedAverage.js'; import { differenceInSeconds } from 'date-fns'; import { type ControlsModel } from '../../connections/sunspec/models/controls.js'; +import { Publish } from './publish.js'; export type SupportedControlTypes = Extract< ControlType, @@ -69,6 +70,7 @@ const defaultValues = { } as const satisfies Record; export class InverterController { + private activeLimitOutput: Publish; private cachedDerSample = new CappedArrayStack({ limit: 100 }); private cachedSiteSample = new CappedArrayStack({ limit: 100 }); private logger: Logger; @@ -105,6 +107,7 @@ export class InverterController { inverterConfiguration: InverterConfiguration, ) => Promise; }) { + this.activeLimitOutput = new Publish({ config }); this.secondsToSample = config.inverterControl.sampleSeconds; this.controlFrequencyMinimumSeconds = config.inverterControl.controlFrequencyMinimumSeconds; @@ -172,6 +175,10 @@ export class InverterController { writeActiveControlLimit({ limit: activeInverterControlLimit }); + this.activeLimitOutput.onActiveInverterControlLimit({ + limit: activeInverterControlLimit, + }); + this.controlLimitsCache = { controlLimitsByLimiter, activeInverterControlLimit, diff --git a/src/coordinator/helpers/publish.ts b/src/coordinator/helpers/publish.ts new file mode 100644 index 0000000..08a7893 --- /dev/null +++ b/src/coordinator/helpers/publish.ts @@ -0,0 +1,29 @@ +import mqtt from 'mqtt'; +import { type Config } from '../../helpers/config.js'; +import { type ActiveInverterControlLimit } from './inverterController.js'; + +export class Publish { + private mqttClient: mqtt.MqttClient | undefined; + + constructor({ config }: { config: Config }) { + if (config.publish?.mqtt) { + this.mqttClient = mqtt.connect(config.publish.mqtt.host, { + username: config.publish.mqtt.username, + password: config.publish.mqtt.password, + }); + } + } + + onActiveInverterControlLimit({ + limit, + }: { + limit: ActiveInverterControlLimit; + }) { + if (this.mqttClient) { + this.mqttClient.publish( + 'inverterControlLimit', + JSON.stringify(limit), + ); + } + } +} diff --git a/src/helpers/config.ts b/src/helpers/config.ts index c24cf91..4875421 100644 --- a/src/helpers/config.ts +++ b/src/helpers/config.ts @@ -251,6 +251,29 @@ A longer time will smooth out load changes but may result in overshoot.`, }) .describe('MQTT meter configuration'), ]), + publish: z + .object({ + mqtt: z + .object({ + 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 publish limits'), + }) + .optional(), + }) + .describe('Publish active control limits') + .optional(), }); export type Config = z.infer; diff --git a/src/meters/mqtt/index.ts b/src/meters/mqtt/index.ts index 5058877..055eb13 100644 --- a/src/meters/mqtt/index.ts +++ b/src/meters/mqtt/index.ts @@ -1,12 +1,12 @@ import mqtt from 'mqtt'; import { type Config } from '../../helpers/config.js'; import { SiteSamplePollerBase } from '../siteSamplePollerBase.js'; -import { type SiteSample } from '../siteSample.js'; +import { type SiteSampleData, type SiteSample } from '../siteSample.js'; import { siteSampleDataSchema } from '../siteSample.js'; export class MqttSiteSamplePoller extends SiteSamplePollerBase { private client: mqtt.MqttClient; - private cachedMessage: SiteSample | null = null; + private cachedMessage: SiteSampleData | null = null; constructor({ mqttConfig, @@ -37,7 +37,7 @@ export class MqttSiteSamplePoller extends SiteSamplePollerBase { return; } - this.cachedMessage = { date: new Date(), ...result.data }; + this.cachedMessage = result.data; }); void this.startPolling(); @@ -49,7 +49,7 @@ export class MqttSiteSamplePoller extends SiteSamplePollerBase { throw new Error('No site sample data on MQTT'); } - return this.cachedMessage; + return { date: new Date(), ...this.cachedMessage }; } override onDestroy() {