diff --git a/config.schema.json b/config.schema.json index fa51f1b5..e8799b69 100644 --- a/config.schema.json +++ b/config.schema.json @@ -262,6 +262,27 @@ ], "additionalProperties": false, "description": "SMA inverter configuration" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "growatt" + }, + "connection": { + "$ref": "#/definitions/config/properties/inverters/items/anyOf/0/properties/connection" + }, + "unitId": { + "$ref": "#/definitions/config/properties/inverters/items/anyOf/0/properties/unitId" + } + }, + "required": [ + "type", + "connection" + ], + "additionalProperties": false, + "description": "Growatt inverter configuration" } ] }, @@ -349,6 +370,27 @@ "additionalProperties": false, "description": "SMA meter configuration" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "growatt" + }, + "connection": { + "$ref": "#/definitions/config/properties/inverters/items/anyOf/0/properties/connection" + }, + "unitId": { + "$ref": "#/definitions/config/properties/inverters/items/anyOf/0/properties/unitId" + } + }, + "required": [ + "type", + "connection" + ], + "additionalProperties": false, + "description": "Growatt meter configuration" + }, { "type": "object", "properties": { diff --git a/src/connections/modbus/connection/growatt.ts b/src/connections/modbus/connection/growatt.ts new file mode 100644 index 00000000..2b56029b --- /dev/null +++ b/src/connections/modbus/connection/growatt.ts @@ -0,0 +1,81 @@ +import { type ModbusSchema } from '../../../helpers/config.js'; +import { getModbusConnection } from '../connections.js'; +import { type GrowattInverterModels } from '../models/growatt/inverter.js'; +import { GrowattInveter1Model } from '../models/growatt/inverter.js'; +import { type GrowattInverterControl } from '../models/growatt/inverterControl.js'; +import { GrowattInverterControl1Model } from '../models/growatt/inverterControl.js'; +import { type GrowattMeterModels } from '../models/growatt/meter.js'; +import { + GrowattMeter1Model, + GrowattMeter2Model, +} from '../models/growatt/meter.js'; +import { type ModbusConnection } from './base.js'; +import { type Logger } from 'pino'; + +export class GrowattConnection { + protected readonly modbusConnection: ModbusConnection; + protected readonly unitId: number; + private logger: Logger; + + constructor({ connection, unitId }: ModbusSchema) { + this.modbusConnection = getModbusConnection(connection); + this.unitId = unitId; + this.logger = this.modbusConnection.logger.child({ + module: 'GrowattConnection', + unitId, + }); + } + + async getInverterModel(): Promise { + const model1 = await GrowattInveter1Model.read({ + modbusConnection: this.modbusConnection, + address: { + start: 0, + length: 3, + }, + unitId: this.unitId, + }); + + return model1; + } + + async getMeterModel(): Promise { + const model1 = await GrowattMeter1Model.read({ + modbusConnection: this.modbusConnection, + address: { + start: 37, + length: 10, + }, + unitId: this.unitId, + }); + + const model2 = await GrowattMeter2Model.read({ + modbusConnection: this.modbusConnection, + address: { + start: 1015, + length: 24, + }, + unitId: this.unitId, + }); + + const data = { ...model1, ...model2 }; + + return data; + } + + async writeInverterControlModel(values: GrowattInverterControl) { + return await GrowattInverterControl1Model.write({ + modbusConnection: this.modbusConnection, + address: { + start: 3, + length: 1, + }, + unitId: this.unitId, + values, + }); + } + + public onDestroy(): void { + this.modbusConnection.close(); + } +} diff --git a/src/connections/modbus/modbusModelFactory.ts b/src/connections/modbus/modbusModelFactory.ts index 698dd819..369a5d99 100644 --- a/src/connections/modbus/modbusModelFactory.ts +++ b/src/connections/modbus/modbusModelFactory.ts @@ -1,7 +1,10 @@ import { writeLatency } from '../../helpers/influxdb.js'; import { objectEntriesWithType } from '../../helpers/object.js'; import { type ModelAddress } from '../sunspec/connection/base.js'; -import { type ModbusConnection } from './connection/base.js'; +import { + type ModbusConnection, + type ModbusRegisterType, +} from './connection/base.js'; export type Mapping< // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -22,8 +25,13 @@ export function modbusModelFactory< // eslint-disable-next-line @typescript-eslint/no-explicit-any Model extends Record, WriteableKeys extends keyof Model = never, ->(config: { +>({ + name, + type = 'holding', + mapping, +}: { name: string; + type?: ModbusRegisterType; mapping: Mapping; }): { read(params: { @@ -42,7 +50,7 @@ export function modbusModelFactory< read: async ({ modbusConnection, address, unitId }) => { const logger = modbusConnection.logger.child({ module: 'modbusModelFactory', - model: config.name, + model: name, type: 'read', }); @@ -51,7 +59,7 @@ export function modbusModelFactory< await modbusConnection.connect(); const registers = await modbusConnection.readRegisters({ - type: 'holding', + type, unitId, start: address.start, length: address.length, @@ -77,7 +85,7 @@ export function modbusModelFactory< } })(), unitId: unitId.toString(), - model: config.name, + model: name, addressStart: address.start.toString(), addressLength: address.length.toString(), }, @@ -90,13 +98,13 @@ export function modbusModelFactory< return convertReadRegisters({ registers: registers.data, - mapping: config.mapping, + mapping, }); }, write: async ({ modbusConnection, address, unitId, values }) => { const logger = modbusConnection.logger.child({ module: 'modbusModelFactory', - model: config.name, + model: name, type: 'write', }); @@ -104,14 +112,14 @@ export function modbusModelFactory< const registerValues = convertWriteRegisters({ values, - mapping: config.mapping, + mapping, length: address.length, }); await modbusConnection.connect(); await modbusConnection.writeRegisters({ - type: 'holding', + type, unitId, start: address.start, data: registerValues, @@ -137,7 +145,7 @@ export function modbusModelFactory< } })(), unitId: unitId.toString(), - model: config.name, + model: name, addressStart: address.start.toString(), addressLength: address.length.toString(), }, diff --git a/src/connections/modbus/models/growatt/inverter.ts b/src/connections/modbus/models/growatt/inverter.ts new file mode 100644 index 00000000..1d793065 --- /dev/null +++ b/src/connections/modbus/models/growatt/inverter.ts @@ -0,0 +1,32 @@ +import { + registersToUint16, + registersToUint32, +} from '../../helpers/converters.js'; +import { modbusModelFactory } from '../../modbusModelFactory.js'; + +export type GrowattInverterModels = GrowattInverter1; + +type GrowattInverter1 = { + // Inverter run state + // 0:waiting, 1:normal, 3:fault + InverterStatus: number; + // Input power + Ppv: number; +}; + +export const GrowattInveter1Model = modbusModelFactory({ + name: 'GrowattInveter1Model', + type: 'input', + mapping: { + InverterStatus: { + start: 0, + end: 1, + readConverter: registersToUint16, + }, + Ppv: { + start: 1, + end: 3, + readConverter: (value) => registersToUint32(value, -1), + }, + }, +}); diff --git a/src/connections/modbus/models/growatt/inverterControl.ts b/src/connections/modbus/models/growatt/inverterControl.ts new file mode 100644 index 00000000..6c4113b9 --- /dev/null +++ b/src/connections/modbus/models/growatt/inverterControl.ts @@ -0,0 +1,29 @@ +import { + registersToUint16, + uint16ToRegisters, +} from '../../helpers/converters.js'; +import { modbusModelFactory } from '../../modbusModelFactory.js'; + +export type GrowattInverterControl = GrowattInverterControl1; + +export type GrowattInverterControl1 = { + // Inverter Max output active power percent + // 0-100 or 255 + // 255: power is not belimited + ActivePRate: number; +}; + +export const GrowattInverterControl1Model = modbusModelFactory< + GrowattInverterControl1, + keyof GrowattInverterControl1 +>({ + name: 'GrowattInverterControl1Model', + mapping: { + ActivePRate: { + start: 0, + end: 1, + readConverter: registersToUint16, + writeConverter: uint16ToRegisters, + }, + }, +}); diff --git a/src/connections/modbus/models/growatt/meter.ts b/src/connections/modbus/models/growatt/meter.ts new file mode 100644 index 00000000..74dc42ca --- /dev/null +++ b/src/connections/modbus/models/growatt/meter.ts @@ -0,0 +1,111 @@ +import { + registersToUint16, + registersToUint32, +} from '../../helpers/converters.js'; +import { modbusModelFactory } from '../../modbusModelFactory.js'; + +export type GrowattMeterModels = GrowattMeter1 & GrowattMeter2; + +type GrowattMeter1 = { + // Grid frequency + Fac: number; + // Three/single phase grid voltage + Vac1: number; + // Three/single phase grid voltage + Vac2: number; + // Three/single phase grid voltage + Vac3: number; +}; + +type GrowattMeter2 = { + // AC power to user + PactouserR: number; + // AC power to user + PactouserS: number; + // AC power to user + PactouserT: number; + // AC power to user + PactouserTotal: number; + // AC power to grid + PactogridR: number; + // AC power to grid + PactogridS: number; + // AC power to grid + PactogridT: number; + // AC power to grid + PactogridTotal: number; +}; + +export const GrowattMeter1Model = modbusModelFactory({ + name: 'GrowattMeter1Model', + type: 'input', + mapping: { + Fac: { + start: 0, + end: 1, + readConverter: (value) => registersToUint16(value, -2), + }, + Vac1: { + start: 1, + end: 2, + readConverter: (value) => registersToUint16(value, -1), + }, + Vac2: { + start: 2, + end: 3, + readConverter: (value) => registersToUint16(value, -1), + }, + Vac3: { + start: 3, + end: 4, + readConverter: (value) => registersToUint16(value, -1), + }, + }, +}); + +export const GrowattMeter2Model = modbusModelFactory({ + name: 'GrowattMeter2Model', + type: 'input', + mapping: { + PactouserR: { + start: 0, + end: 2, + readConverter: (value) => registersToUint32(value, -1), + }, + PactouserS: { + start: 2, + end: 4, + readConverter: (value) => registersToUint32(value, -1), + }, + PactouserT: { + start: 4, + end: 6, + readConverter: (value) => registersToUint32(value, -1), + }, + PactouserTotal: { + start: 6, + end: 8, + readConverter: (value) => registersToUint32(value, -1), + }, + PactogridR: { + start: 8, + end: 10, + readConverter: (value) => registersToUint32(value, -1), + }, + PactogridS: { + start: 10, + end: 12, + readConverter: (value) => registersToUint32(value, -1), + }, + PactogridT: { + start: 12, + end: 14, + readConverter: (value) => registersToUint32(value, -1), + }, + PactogridTotal: { + start: 14, + end: 16, + readConverter: (value) => registersToUint32(value, -1), + }, + }, +}); diff --git a/src/coordinator/helpers/inverterSample.ts b/src/coordinator/helpers/inverterSample.ts index a1237825..51de8ae5 100644 --- a/src/coordinator/helpers/inverterSample.ts +++ b/src/coordinator/helpers/inverterSample.ts @@ -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 { GrowattInverterDataPoller } from '../../inverter/growatt/index.js'; export class InvertersPoller extends EventEmitter<{ data: [DerSample]; @@ -46,6 +47,13 @@ export class InvertersPoller extends EventEmitter<{ inverterIndex: index, }).on('data', inverterOnData); } + case 'growatt': { + return new GrowattInverterDataPoller({ + growattInverterConfig: inverterConfig, + applyControl: config.inverterControl.enabled, + inverterIndex: index, + }).on('data', inverterOnData); + } } }, ); diff --git a/src/coordinator/helpers/siteSample.ts b/src/coordinator/helpers/siteSample.ts index d70bfa94..822e3053 100644 --- a/src/coordinator/helpers/siteSample.ts +++ b/src/coordinator/helpers/siteSample.ts @@ -5,6 +5,7 @@ import { SunSpecMeterSiteSamplePoller } from '../../meters/sunspec/index.js'; import { type SiteSamplePollerBase } from '../../meters/siteSamplePollerBase.js'; import { type InvertersPoller } from './inverterSample.js'; import { SmaMeterSiteSamplePoller } from '../../meters/sma/index.js'; +import { GrowattMeterSiteSamplePoller } from '../../meters/growatt/index.js'; export function getSiteSamplePollerInstance({ config, @@ -35,5 +36,10 @@ export function getSiteSamplePollerInstance({ smaMeterConfig: config.meter, }); } + case 'growatt': { + return new GrowattMeterSiteSamplePoller({ + growattMeterConfig: config.meter, + }); + } } } diff --git a/src/helpers/config.ts b/src/helpers/config.ts index 8019f4a8..79ac9c9d 100644 --- a/src/helpers/config.ts +++ b/src/helpers/config.ts @@ -156,6 +156,12 @@ export const configSchema = z.object({ }) .merge(modbusSchema) .describe('SMA inverter configuration'), + z + .object({ + type: z.literal('growatt'), + }) + .merge(modbusSchema) + .describe('Growatt inverter configuration'), ]), ) .describe('Inverter configuration'), @@ -196,6 +202,12 @@ A longer time will smooth out load changes but may result in overshoot.`, }) .merge(modbusSchema) .describe('SMA meter configuration'), + z + .object({ + type: z.literal('growatt'), + }) + .merge(modbusSchema) + .describe('Growatt meter configuration'), z .object({ type: z.literal('powerwall2'), diff --git a/src/inverter/growatt/index.ts b/src/inverter/growatt/index.ts new file mode 100644 index 00000000..b3c6efcf --- /dev/null +++ b/src/inverter/growatt/index.ts @@ -0,0 +1,141 @@ +import { type InverterData } from '../inverterData.js'; +import { ConnectStatusValue } from '../../sep2/models/connectStatus.js'; +import { OperationalModeStatusValue } from '../../sep2/models/operationModeStatus.js'; +import { InverterDataPollerBase } from '../inverterDataPollerBase.js'; +import { type InverterConfiguration } from '../../coordinator/helpers/inverterController.js'; +import { type Config } from '../../helpers/config.js'; +import { writeLatency } from '../../helpers/influxdb.js'; +import { type GrowattInverterModels } from '../../connections/modbus/models/growatt/inverter.js'; +import { GrowattConnection } from '../../connections/modbus/connection/growatt.js'; +import { DERTyp } from '../../connections/sunspec/models/nameplate.js'; + +export class GrowattInverterDataPoller extends InverterDataPollerBase { + private growattConnection: GrowattConnection; + + constructor({ + growattInverterConfig, + inverterIndex, + applyControl, + }: { + growattInverterConfig: Extract< + Config['inverters'][number], + { type: 'growatt' } + >; + inverterIndex: number; + applyControl: boolean; + }) { + super({ + name: 'GrowattInverterDataPoller', + pollingIntervalMs: 200, + applyControl, + inverterIndex, + }); + + this.growattConnection = new GrowattConnection(growattInverterConfig); + + void this.startPolling(); + } + + override async getInverterData(): Promise { + const start = performance.now(); + + const inverterModel = await this.growattConnection.getInverterModel(); + + writeLatency({ + field: 'GrowattInverterDataPoller', + duration: performance.now() - start, + tags: { + inverterIndex: this.inverterIndex.toString(), + model: 'inverter', + }, + }); + + const models: InverterModels = { + inverter: inverterModel, + }; + + const end = performance.now(); + const duration = end - start; + + this.logger.trace({ duration, models }, 'Got inverter data'); + + const inverterData = generateInverterData(models); + + return inverterData; + } + + override onDestroy(): void { + this.growattConnection.onDestroy(); + } + + override async onControl( + inverterConfiguration: InverterConfiguration, + ): Promise { + const targetPowerRatio = (() => { + switch (inverterConfiguration.type) { + case 'disconnect': + return 0; + case 'limit': + return ( + (inverterConfiguration.targetSolarWatts / + inverterConfiguration.invertersCount - + 250) / + 6000 + ); + } + })(); + + // clamp between 0 and 100 + // no decimal points, round down + const ActivePRate = Math.max( + Math.min(Math.floor(targetPowerRatio * 100), 100), + 0, + ); + + await this.growattConnection.writeInverterControlModel({ + ActivePRate, + }); + } +} + +type InverterModels = { + inverter: GrowattInverterModels; +}; + +export function generateInverterData({ + inverter, +}: InverterModels): InverterData { + return { + date: new Date(), + inverter: { + realPower: inverter.Ppv, + reactivePower: 0, + voltagePhaseA: 0, + voltagePhaseB: null, + voltagePhaseC: null, + frequency: 0, + }, + nameplate: { + type: DERTyp.PV, + maxW: 0, + maxVA: 0, + maxVar: 0, + }, + settings: { + maxW: 0, + maxVA: 0, + maxVar: 0, + }, + status: generateInverterDataStatus(), + }; +} + +export function generateInverterDataStatus(): InverterData['status'] { + return { + operationalModeStatus: OperationalModeStatusValue.OperationalMode, + genConnectStatus: + ConnectStatusValue.Available | + ConnectStatusValue.Connected | + ConnectStatusValue.Operating, + }; +} diff --git a/src/meters/growatt/index.ts b/src/meters/growatt/index.ts new file mode 100644 index 00000000..3aaebf72 --- /dev/null +++ b/src/meters/growatt/index.ts @@ -0,0 +1,72 @@ +import { type SiteSample } from '../siteSample.js'; +import { SiteSamplePollerBase } from '../siteSamplePollerBase.js'; +import { type Config } from '../../helpers/config.js'; +import { type GrowattMeterModels } from '../../connections/modbus/models/growatt/meter.js'; +import { GrowattConnection } from '../../connections/modbus/connection/growatt.js'; + +type GrowattMeterConfig = Extract; + +export class GrowattMeterSiteSamplePoller extends SiteSamplePollerBase { + private growattConnection: GrowattConnection; + + constructor({ + growattMeterConfig, + }: { + growattMeterConfig: GrowattMeterConfig; + }) { + super({ name: 'growattMeter', pollingIntervalMs: 200 }); + + this.growattConnection = new GrowattConnection(growattMeterConfig); + + void this.startPolling(); + } + + override async getSiteSample(): Promise { + const start = performance.now(); + + const meterModel = await this.growattConnection.getMeterModel(); + + const end = performance.now(); + const duration = end - start; + + this.logger.trace({ duration, meterModel }, 'polled meter data'); + + const siteSample = generateSiteSample({ + meter: meterModel, + }); + + return siteSample; + } + + override onDestroy() { + this.growattConnection.onDestroy(); + } +} + +function generateSiteSample({ + meter, +}: { + meter: GrowattMeterModels; +}): SiteSample { + return { + date: new Date(), + realPower: { + type: 'noPhase', + net: + meter.PactogridTotal > 0 + ? -meter.PactogridTotal + : meter.PactouserTotal, + }, + reactivePower: { + type: 'noPhase', + net: 0, + }, + voltage: { + type: 'perPhase', + phaseA: meter.Vac1, + phaseB: meter.Vac2, + phaseC: meter.Vac3, + }, + frequency: meter.Fac, + }; +}