// SPDX-License-Identifier: GPL-3.0-or-later /* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * Copyright (C) 2026, ETM-PowerSync * * This file is part of nymea. * * nymea is free software: you can redistribute it and/or modify it under the * terms of the GNU General Public License as published by the Free Software * Foundation, either version 3 of the License, or (at your option) any later * version. * * nymea is distributed in the hope that it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR * A PARTICULAR PURPOSE. See the GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with * nymea. If not, see . * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ #ifndef FRONIUSRULES_H #define FRONIUSRULES_H #include #include #include #include #include "sunspecstoragelogic.h" /* Vendor specific rules for Fronius GEN24 inverters. * * Everything here is a pure decision: no Modbus, no thing, no timer. The * plugin asks these functions what to do; the answers are what the unit tests * pin down. See specs/fronius-sunspec.md. */ namespace FroniusRules { /* Polling. The manufacturer's support recommends staying above seven seconds * for third party applications. Below that, communication load can push the * inverter offline towards the manufacturer's portal, and recovery needs an * AC+DC power cycle on site. This floor is a guard in the code, not a * configurable default. */ const int MinimumUpdateIntervalSeconds = 7; const int DefaultUpdateIntervalSeconds = 10; const int DefaultTimeoutMilliSeconds = 2000; const int DefaultNumberOfRetries = 3; /* Revert timer armed with every setpoint, so that a setpoint left behind by a * stopped or crashed HEMS expires instead of holding the battery forever. * * WORKING HYPOTHESIS: the timer semantics are documented in the manual's * general chapter and illustrated on model 123, not on model 124. This must be * verified experimentally on model 124 before being frozen. If it does not * hold, fall back to 0 plus an application level watchdog in the HEMS. */ const int DefaultRevertTimeSeconds = 300; /* Default unit IDs. The inverter is fixed at 1 over TCP; the meter defaults to * 200, with 201, 202... for secondary meters. 0 means "no meter on this * connection". */ const int InverterSlaveId = 1; const int DefaultMeterSlaveId = 200; const int MaximumSlaveId = 247; /* Whether a second SunSpecConnection should be opened for the Smart Meter, and * why not when it should not. The caller logs the reason; keeping it out of * this function is what makes the decision testable without nymea. */ enum MeterConnectionDecision { MeterConnectionCreate, /* 0 is the documented way of saying "no meter behind this inverter". */ MeterConnectionDisabled, /* A second socket on the unit ID already served by the inverter would * rediscover the very same models and duplicate every child thing. */ MeterConnectionCollidesWithInverter, MeterConnectionInvalidSlaveId }; MeterConnectionDecision decideMeterConnection(int meterSlaveId, int inverterSlaveId); bool matchesManufacturer(const QStringList &manufacturers); /* Sign to apply to the raw SunSpec meter power registers (W, WphA...) to get * the nymea smartmeter convention, where positive is import and negative is * export. * * The Fronius Smart Meter already reports in that convention: negative W is * feed-in. The generic path negates, so on a Fronius the surplus came out with * the wrong sign and the arbiter refused to run loads while the site was * exporting. * * Established on hardware, not from the manufacturer's documentation. On a * Primo GEN24 3.0, a *single phase* inverter feeding phase A only, the plugin * reported phase A at +1003 W (import) while phases B and C read -33 W and * -262 W (export). Exporting on two phases carrying no source is impossible. * With the sign flipped the balance closes exactly: production 1190 W = phase A * load 187 W + phase A export 1003 W, B and C import 294 W, so the house draws * 481 W and the site exports 709 W against 708 W measured. The meter's own * energy counters agree: over the same window its import counter stayed at * zero while its export counter advanced. * * Only the instantaneous power is affected. The energy counters map correctly * already and must not be swapped. * * The generic default is left untouched: other vendors keep the negation. */ double meterPowerSign(bool froniusMeter); /* Whether a model 124 block is backed by a physically installed battery. * * A hybrid GEN24 publishes model 124 whether or not a battery is wired to it, * so the presence of the model proves nothing. Creating a storage child anyway * gives the customer a phantom battery and a permanent batteryCritical alarm: * observed on hardware, a GEN24 with no battery reported ChaSt = Off and a * state of charge of 0, and nymea's own energy engine then logged "There is no * energy storage available to consider" while the child stayed visible. * * The discriminator is deliberately a *physical* quantity. The state of charge * is refused because 0% cannot be told apart from a real but empty battery, and * the setpoint registers (WChaMax and friends) are refused because they keep * their defaults with no battery attached. * * INFÉRÉ: an installed battery carries a DC voltage at rest, so InBatV or * StorAval is expected to be strictly positive whenever one is present, and * both zero when none is. The absent side matches what the battery-less GEN24 * showed and is reproduced on the simulator. The present side could NOT be * confirmed: no GEN24 with a battery was reachable. Confirm InBatV/StorAval on * a GEN24 with a battery before this ships. See DIVERGENCES.md. */ bool hasBattery(double batteryVoltage, double availableCharge); /* Word order of the float32 points, measured on hardware. * * This used to read "INFERRED: not documented by the manufacturer, so the * SunSpec default is assumed. To be confirmed on the bench, then on hardware." * It has now been confirmed, and the assumption was wrong: what was assumed to * be "the SunSpec default" was in fact libnymea-sunspec's own default, which is * the opposite of the specification. * * A float32 spans two registers. SunSpec puts the high word first. * SunSpecDataPoint::convertToUInt32 reads registers.at(0) then .at(1) for * ByteOrderBigEndian, and the reverse for ByteOrderLittleEndian — and little * endian is the library's default. Keeping that default on a Fronius means * reading every float32 with its two words swapped. * * Measured 2026-08-07 on a Fronius Symo, read only: * meter 213, unit 200 : Hz = 4248 0000 -> 50.0000 big endian * PhVphA = 436a 8000 -> 234.5000 * inverter 113, unit 1: Hz = 4247 fc48 -> 49.9964 * Swapped, those same registers give 2.4e-41 and -4.2e+36: denormals, the * signature of a wrong interpretation rather than a measurement. * * What it costs to get this wrong is not a small offset. Reproduced on the * bench with the simulator emitting the hardware's order: a meter importing * 2.7 kW was reported as exporting 107 MW, and the arbiter switched every load * on. Correcting the SIGN of a denormal changes nothing, so meterPowerSign() * only becomes observable once this is right. * * The SolarEdge vendor already forces big endian on its own path; the generic * vendor keeps its user-settable parameter. Neither is affected. */ SunSpecDataPoint::ByteOrder byteOrder(); int clampUpdateInterval(int requestedSeconds); /* Registers the GEN24 accepts and then ignores without raising a Modbus * exception. Writing them fails invisibly, so they are refused before being * put on the wire. */ bool isWriteForbidden(const QString ®isterName); enum BatteryMode { BatteryModeNormal, BatteryModeNoDischarge, BatteryModeNoCharge, BatteryModeFrozen, BatteryModeForceCharge, BatteryModeLimit }; /* Sign convention of the manufacturer: negative is charging, positive is * discharging. chargeLimit and dischargeLimit are percentages of WChaMax and * are only used by the modes that need them. */ SunSpecStorageLogic::SetpointPlan planBatteryMode(BatteryMode mode, double chargeLimit, double dischargeLimit, quint16 revertTime); /* Model 160 repeating blocks are identified by their IDStr, never by their * index: on a three or four MPPT inverter the battery blocks are not in * position 3 and 4. */ enum BlockRole { BlockRolePvString, BlockRoleBatteryCharge, BlockRoleBatteryDischarge, BlockRoleUnknown }; BlockRole classifyBlock(const QString &inputIdString); struct ModuleReading { QString inputIdString; double dcPower; }; /* Sum of the DC power of the PV string blocks. */ double pvPower(const QList &readings); /* Discharge minus charge, so positive means discharging. DCW is a uint16 and * therefore always positive: the sign is carried by whichever block is active, * the inactive one reading zero. */ double batteryPower(const QList &readings); } #endif // FRONIUSRULES_H