From 9d33e212c19c168124f03ccb9d45170921d5818d Mon Sep 17 00:00:00 2001 From: Bea Lam Date: Thu, 13 Jul 2023 10:26:39 +1000 Subject: [PATCH] Add PV inverters support #346 Add solar data from devices at com.victronenergy.pvinverter.* on D-Bus and mqtt/pvinverter on MQTT. The total solar output on the Brief page now shows the combined output from PV chargers and PV inverters, and PV inverters are also listed in the solar device list that is shown when the solar overview widget is clicked. Adjust the UI to accommodate for differences between PV inverters and the PV chargers that were already supported. The main difference is that PV inverters do not have solar history data. So: - The solar gauge on the Brief page now shows runtime history from approximately the last 15 mintues, instead of from the last few days - The solar widget on the Overview page now shows PV inverter phase data instead of history data, if no PV chargers are present --- Global.qml | 1 + components/SolarMeasurements.qml | 19 ++ components/SolarYieldGauge.qml | 46 ++++- components/widgets/SolarYieldWidget.qml | 56 ++++- data/DataManager.qml | 2 + data/PvInverters.qml | 93 +++++++++ data/common/DeviceModel.qml | 22 ++ data/common/PvInverter.qml | 95 +++++++++ data/dbus/DBusDataManager.qml | 1 + data/dbus/PvInvertersImpl.qml | 25 +++ data/mock/MockDataManager.qml | 1 + data/mock/PvInvertersImpl.qml | 95 +++++++++ .../config/BriefAndOverviewPageConfig.qml | 11 +- data/mock/config/MockDataSimulator.qml | 1 + data/mqtt/MqttDataManager.qml | 1 + data/mqtt/PvInvertersImpl.qml | 24 +++ pages/BriefMonitorPanel.qml | 8 +- pages/BriefPage.qml | 2 +- pages/OverviewPage.qml | 11 +- pages/solar/PvInverterPage.qml | 136 ++++++++++++ pages/solar/SolarDeviceListPage.qml | 194 +++++++++++++----- qml.qrc | 13 +- src/enums.h | 24 +++ src/main.cpp | 2 + 24 files changed, 801 insertions(+), 82 deletions(-) create mode 100644 components/SolarMeasurements.qml create mode 100644 data/PvInverters.qml create mode 100644 data/common/PvInverter.qml create mode 100644 data/dbus/PvInvertersImpl.qml create mode 100644 data/mock/PvInvertersImpl.qml create mode 100644 data/mqtt/PvInvertersImpl.qml create mode 100644 pages/solar/PvInverterPage.qml diff --git a/Global.qml b/Global.qml index 8791dd19c..f9c03c05f 100644 --- a/Global.qml +++ b/Global.qml @@ -36,6 +36,7 @@ QtObject { property var generators property var inverters property var notifications + property var pvInverters property var relays property var solarChargers property var system diff --git a/components/SolarMeasurements.qml b/components/SolarMeasurements.qml new file mode 100644 index 000000000..8af3d1035 --- /dev/null +++ b/components/SolarMeasurements.qml @@ -0,0 +1,19 @@ +/* +** Copyright (C) 2023 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS + +// +// Provides combined solar measurement data from PV chargers and PV inverters. +// +QtObject { + id: root + + readonly property real power: isNaN(Global.solarChargers.power) && isNaN(Global.pvInverters.power) ? NaN + : (Global.solarChargers.power || 0) + (Global.pvInverters.power || 0) + + readonly property real current: isNaN(Global.solarChargers.current) && isNaN(Global.pvInverters.current) ? NaN + : (Global.solarChargers.current || 0) + (Global.pvInverters.current || 0) +} diff --git a/components/SolarYieldGauge.qml b/components/SolarYieldGauge.qml index 8fae67a66..d4528ea8e 100644 --- a/components/SolarYieldGauge.qml +++ b/components/SolarYieldGauge.qml @@ -20,10 +20,7 @@ Item { Repeater { id: gaugeRepeater - model: SolarYieldModel { - id: yieldModel - dayRange: [0, Theme.geometry.briefPage.solarHistoryGauge.maximumGaugeCount + 1] - } + model: powerSampler.sampledAverages.length + 1 delegate: ScaledArcGauge { width: Theme.geometry.briefPage.edgeGauge.width @@ -36,7 +33,38 @@ Item { direction: PathArc.Counterclockwise strokeWidth: Theme.geometry.arc.strokeWidth arcY: root.alignment & Qt.AlignVCenter ? undefined : -radius + strokeWidth/2 - value: yieldModel.maximumYield ? Utils.scaleToRange(model.yieldKwh, 0, yieldModel.maximumYield, 0, 100) : 100 + value: { + // First gauge shows the current runtime power, other gauges show historical values. + const power = model.index === 0 ? solarMeasurements.power : powerSampler.sampledAverages[model.index - 1] + return Utils.scaleToRange(power, 0, solarMeasurements.maxPower, 0, 100) + } + } + } + + // Take 30-second samples of the solar power. Every 5 minutes, take the average of these samples + // and add a new gauge bar with that value. + Timer { + id: powerSampler + + property var sampledAverages: [] + property var _activeSamples: [] + + running: true + repeat: true + interval: 30 * 1000 + onTriggered: { + _activeSamples.push(solarMeasurements.power) + if (_activeSamples.length < 10) { + return + } + const averagePower = _activeSamples.reduce((a, b) => a + b) / _activeSamples.length + let newAverages = sampledAverages + newAverages.unshift(averagePower) + if (newAverages.length >= Theme.geometry.briefPage.solarHistoryGauge.maximumGaugeCount) { + newAverages.pop() + } + _activeSamples = [] + sampledAverages = newAverages } } @@ -45,7 +73,13 @@ Item { alignment: root.alignment icon.source: "qrc:/images/solaryield.svg" - quantityLabel.dataObject: Global.solarChargers + quantityLabel.dataObject: SolarMeasurements { + id: solarMeasurements + + property real maxPower: NaN + + onPowerChanged: maxPower = isNaN(maxPower) ? power : Math.max(maxPower, power) + } } } diff --git a/components/widgets/SolarYieldWidget.qml b/components/widgets/SolarYieldWidget.qml index 5d0e22922..ec31d9656 100644 --- a/components/widgets/SolarYieldWidget.qml +++ b/components/widgets/SolarYieldWidget.qml @@ -15,28 +15,64 @@ OverviewWidget { border.width: enabled ? Theme.geometry.overviewPage.widget.border.width : 0 border.color: Theme.color.overviewPage.widget.border - quantityLabel.dataObject: Global.solarChargers + quantityLabel.dataObject: SolarMeasurements {} + // Solar yield history is only available for PV chargers, and phase data is only available for + // PV inverters. So, if there are only solar chargers, show the solar history; otherwise if + // there is a single PV inverter, show its phase data. extraContent.children: [ - SolarYieldGraph { + Loader { + readonly property int margin: sourceComponent === historyComponent + ? Theme.geometry.overviewPage.widget.solar.graph.margins + : Theme.geometry.overviewPage.widget.extraContent.bottomMargin + anchors { - horizontalCenter: parent.horizontalCenter + left: parent.left + leftMargin: margin + right: parent.right + rightMargin: margin bottom: parent.bottom - bottomMargin: Theme.geometry.overviewPage.widget.solar.graph.margins + bottomMargin: margin + } + sourceComponent: { + if (root.size >= VenusOS.OverviewWidget_Size_L) { + if (Global.pvInverters.model.count === 1 && Global.solarChargers.model.count === 0) { + return phaseComponent + } else if (Global.pvInverters.model.count === 0) { + return historyComponent + } + } + return null } - visible: root.size >= VenusOS.OverviewWidget_Size_L - width: parent.width - Theme.geometry.overviewPage.widget.solar.graph.margins*2 - height: parent.height - (2 * Theme.geometry.overviewPage.widget.solar.graph.margins) } ] + Component { + id: phaseComponent + + ThreePhaseDisplay { + model: Global.pvInverters.model.objectAt(0).phases + } + } + + Component { + id: historyComponent + + SolarYieldGraph { + height: root.extraContent.height - (2 * Theme.geometry.overviewPage.widget.solar.graph.margins) + } + } + MouseArea { anchors.fill: parent - enabled: Global.solarChargers.model.count > 0 onClicked: { - if (Global.solarChargers.model.count === 1) { + const singleDeviceOnly = (Global.solarChargers.model.count + Global.pvInverters.model.count) === 1 + if (singleDeviceOnly && Global.solarChargers.model.count === 1) { Global.pageManager.pushPage("/pages/solar/SolarChargerPage.qml", - { "solarCharger": Global.solarChargers.model.get(0).solarCharger }) + { "solarCharger": Global.solarChargers.model.objectAt(0) }) + } else if (singleDeviceOnly && Global.pvInverters.model === 1) { + Global.pageManager.pushPage("/pages/solar/PvInverterPage.qml", + { "pvInverter": Global.pvInverters.model.objectAt(0) }) } else { Global.pageManager.pushPage("/pages/solar/SolarDeviceListPage.qml", { "title": root.title }) } diff --git a/data/DataManager.qml b/data/DataManager.qml index 843d55640..dfebc4065 100644 --- a/data/DataManager.qml +++ b/data/DataManager.qml @@ -20,6 +20,7 @@ Item { && !!Global.generators && !!Global.inverters && !!Global.notifications + && !!Global.pvInverters && !!Global.relays && !!Global.solarChargers && !!Global.system @@ -80,6 +81,7 @@ Item { Generators {} Inverters {} Notifications {} + PvInverters {} Relays {} SolarChargers {} System {} diff --git a/data/PvInverters.qml b/data/PvInverters.qml new file mode 100644 index 000000000..64a1e79b4 --- /dev/null +++ b/data/PvInverters.qml @@ -0,0 +1,93 @@ +/* +** Copyright (C) 2022 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS +import "common" + +QtObject { + id: root + + property DeviceModel model: DeviceModel { + objectProperty: "pvInverter" + } + + property real energy: NaN + property real power: NaN + property real current: NaN + property real voltage: NaN + + function addInverter(inverter) { + if (model.addObject(inverter)) { + updateTotalMeasurements() + } + } + + function removeInverter(inverter) { + if (model.removeObject(inverter.serviceUid)) { + updateTotalMeasurements() + } + } + + function updateTotalMeasurements() { + const totals = model.totalValues(["energy", "power", "current", "voltage"]) + energy = totals.energy + power = totals.power + current = totals.current + voltage = totals.voltage + } + + function reset() { + model.clear() + updateTotalMeasurements() + } + + function statusCodeToText(statusCode) { + switch (statusCode) { + case VenusOS.PvInverter_StatusCode_Startup0: + //% "Startup 0" + return qsTrId("pvinverters_statusCode_startup_0") + case VenusOS.PvInverter_StatusCode_Startup1: + //% "Startup 1" + return qsTrId("pvinverters_statusCode_startup_1") + case VenusOS.PvInverter_StatusCode_Startup2: + //% "Startup 2" + return qsTrId("pvinverters_statusCode_startup_2") + case VenusOS.PvInverter_StatusCode_Startup3: + //% "Startup 3" + return qsTrId("pvinverters_statusCode_startup_3") + case VenusOS.PvInverter_StatusCode_Startup4: + //% "Startup 4" + return qsTrId("pvinverters_statusCode_startup_4") + case VenusOS.PvInverter_StatusCode_Startup5: + //% "Startup 5" + return qsTrId("pvinverters_statusCode_startup_5") + case VenusOS.PvInverter_StatusCode_Startup6: + //% "Startup 6" + return qsTrId("pvinverters_statusCode_startup_6") + case VenusOS.PvInverter_StatusCode_Running: + //% "Running" + return qsTrId("pvinverters_statusCode_running") + case VenusOS.PvInverter_StatusCode_Standby: + //% "Standby" + return qsTrId("pvinverters_statusCode_standby") + case VenusOS.PvInverter_StatusCode_BootLoading: + //% "Boot loading" + return qsTrId("pvinverters_statusCode_boot_loading") + case VenusOS.PvInverter_StatusCode_Error: + //% "Error" + return qsTrId("pvinverters_statusCode_error") + case VenusOS.PvInverter_StatusCode_RunningMPPT: + //% "Running (MPPT)" + return qsTrId("pvinverters_statusCode_running_mppt") + case VenusOS.PvInverter_StatusCode_RunningThrottled: + //% "Running (Throttled)" + return qsTrId("pvinverters_statusCode_running_throttled") + default: + return "" + } + } + + Component.onCompleted: Global.pvInverters = root +} diff --git a/data/common/DeviceModel.qml b/data/common/DeviceModel.qml index 1627f343b..05aa1d070 100644 --- a/data/common/DeviceModel.qml +++ b/data/common/DeviceModel.qml @@ -98,6 +98,28 @@ ListModel { return get(index)[objectProperty] } + function totalValues(propertyNames) { + let totals = {} + let propertyIndex + for (propertyIndex = 0; propertyIndex < propertyNames.length; ++propertyIndex) { + totals[propertyNames[propertyIndex]] = NaN + } + for (let i = 0; i < count; ++i) { + const object = objectAt(i) + for (propertyIndex = 0; propertyIndex < propertyNames.length; ++propertyIndex) { + const propertyName = propertyNames[propertyIndex] + const value = object[propertyName] + if (!isNaN(value)) { + if (isNaN(totals[propertyName])) { + totals[propertyName] = 0 + } + totals[propertyName] += value + } + } + } + return totals + } + function _refreshFirstObject() { firstObject = count === 0 ? null : objectAt(0) } diff --git a/data/common/PvInverter.qml b/data/common/PvInverter.qml new file mode 100644 index 000000000..e745fe6d8 --- /dev/null +++ b/data/common/PvInverter.qml @@ -0,0 +1,95 @@ +/* +** Copyright (C) 2023 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS +import Victron.Veutil + +Device { + id: pvInverter + + readonly property int statusCode: _statusCode.value === undefined ? -1 : _statusCode.value + readonly property int errorCode: _errorCode.value === undefined ? -1 : _errorCode.value + + readonly property real energy: _energy.value === undefined ? NaN : _energy.value + readonly property real power: _power.value === undefined ? NaN : _power.value + readonly property real current: _current.value === undefined ? NaN : _current.value + readonly property real voltage: _voltage.value === undefined ? NaN : _voltage.value + + readonly property ListModel phases: ListModel { + function setPhaseProperty(index, propertyName, value) { + if (index >= 0 && index < count) { + setProperty(index, propertyName, value === undefined ? NaN : value) + } else { + console.warn("setPhaseProperty(): bad index", index, "count is", count) + } + } + + Component.onCompleted: { + const phaseCount = 3 + for (let i = 0; i < phaseCount; ++i) { + append({ name: "L" + (i + 1), energy: NaN, power: NaN, current: NaN, voltage: NaN }) + } + _phaseObjects.model = phaseCount + } + } + + readonly property Instantiator _phaseObjects: Instantiator { + delegate: QtObject { + readonly property VeQuickItem _phaseEnergy: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/L" + (model.index + 1) + "/Energy/Forward" + onValueChanged: phases.setPhaseProperty(model.index, "energy", value) + } + readonly property VeQuickItem _phasePower: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/L" + (model.index + 1) + "/Power" + onValueChanged: phases.setPhaseProperty(model.index, "power", value) + } + readonly property VeQuickItem _phaseCurrent: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/L" + (model.index + 1) + "/Current" + onValueChanged: phases.setPhaseProperty(model.index, "current", value) + } + readonly property VeQuickItem _phaseVoltage: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/L" + (model.index + 1) + "/Voltage" + onValueChanged: phases.setPhaseProperty(model.index, "voltage", value) + } + } + } + + readonly property VeQuickItem _statusCode: VeQuickItem { + uid: pvInverter.serviceUid + "/StatusCode" + } + + readonly property VeQuickItem _errorCode: VeQuickItem { + uid: pvInverter.serviceUid + "/ErrorCode" + } + + readonly property VeQuickItem _energy: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/Energy/Forward" + onValueChanged: Qt.callLater(Global.pvInverters.updateTotalMeasurements) + } + + readonly property VeQuickItem _power: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/Power" + onValueChanged: Qt.callLater(Global.pvInverters.updateTotalMeasurements) + } + + readonly property VeQuickItem _current: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/Current" + onValueChanged: Qt.callLater(Global.pvInverters.updateTotalMeasurements) + } + + readonly property VeQuickItem _voltage: VeQuickItem { + uid: pvInverter.serviceUid + "/Ac/Voltage" + onValueChanged: Qt.callLater(Global.pvInverters.updateTotalMeasurements) + } + + property bool _valid: deviceInstance.value !== undefined + on_ValidChanged: { + if (_valid) { + Global.pvInverters.addInverter(pvInverter) + } else { + Global.pvInverters.removeInverter(pvInverter) + } + } +} diff --git a/data/dbus/DBusDataManager.qml b/data/dbus/DBusDataManager.qml index 044d1e324..2d6348443 100644 --- a/data/dbus/DBusDataManager.qml +++ b/data/dbus/DBusDataManager.qml @@ -18,6 +18,7 @@ QtObject { property var generators: GeneratorsImpl { } property var inverters: InvertersImpl { } property var notifications: NotificationsImpl {} + property var pvInverters: PvInvertersImpl { } property var relays: RelaysImpl {} property var solarChargers: SolarChargersImpl { } property var system: SystemImpl { } diff --git a/data/dbus/PvInvertersImpl.qml b/data/dbus/PvInvertersImpl.qml new file mode 100644 index 000000000..c85627994 --- /dev/null +++ b/data/dbus/PvInvertersImpl.qml @@ -0,0 +1,25 @@ +/* +** Copyright (C) 2022 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS +import Victron.Veutil +import "../common" + +QtObject { + id: root + + property Instantiator inverterObjects: Instantiator { + model: VeQItemSortTableModel { + dynamicSortFilter: true + filterRole: VeQItemTableModel.UniqueIdRole + filterRegExp: "^dbus/com\.victronenergy\.pvinverter\." + model: Global.dataServiceModel + } + + delegate: PvInverter { + serviceUid: model.uid + } + } +} diff --git a/data/mock/MockDataManager.qml b/data/mock/MockDataManager.qml index 5593792b9..1a43c8dff 100644 --- a/data/mock/MockDataManager.qml +++ b/data/mock/MockDataManager.qml @@ -29,6 +29,7 @@ QtObject { property var generators: GeneratorsImpl {} property var inverters: InvertersImpl {} property var notifications: NotificationsImpl {} + property var pvInverters: PvInvertersImpl {} property var relays: RelaysImpl {} property var solarChargers: SolarChargersImpl {} property var system: SystemImpl {} diff --git a/data/mock/PvInvertersImpl.qml b/data/mock/PvInvertersImpl.qml new file mode 100644 index 000000000..b771dba2d --- /dev/null +++ b/data/mock/PvInvertersImpl.qml @@ -0,0 +1,95 @@ +/* +** Copyright (C) 2022 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS +import "../common" + +QtObject { + id: root + + function populate() { + Global.pvInverters.reset() + + const inverterCount = 3 + for (let i = 0; i < inverterCount; ++i) { + const inverterObj = inverterComponent.createObject(root, { + name: "My PV inverter " + i + }) + _createdObjects.push(inverterObj) + + Global.pvInverters.addInverter(inverterObj) + } + } + + property int _objectId + property Component inverterComponent: Component { + MockDevice { + id: pvInverter + + readonly property string serviceUid: "com.victronenergy.pvinverter.ttyUSB" + deviceInstance.value + property int statusCode: Math.random() * VenusOS.PvInverter_StatusCode_Error + + readonly property ListModel phases: ListModel { + ListElement { name: "L1"; energy: 1.5; power: 20; current: 5; voltage: 10 } + ListElement { name: "L2"; energy: 3; power: 30; current: 10; voltage: 15 } + ListElement { name: "L3"; energy: 4.5; power: 40; current: 15; voltage: 20 } + } + + property real energy: Math.random() * 10 + property real current: Math.random() * 20 + property real power: Math.random() * 50 + property real voltage: Math.random() * 30 + + property Timer _updates: Timer { + running: Global.mockDataSimulator.timersActive + repeat: true + interval: 1000 + onTriggered: { + const phaseIndex = Math.floor(Math.random() * phases.count) + const phase = phases.get(phaseIndex) + const delta = Math.random() > 0.5 ? 1 : -1 // power may fluctuate up or down + pvInverter.power -= phase.power + phase.power = Math.max(0, phase.power + (1 + (Math.random() * 10)) * delta) + pvInverter.power += phase.power + pvInverter.energy = Math.random() * 10 + pvInverter.current = Math.random() * 20 + pvInverter.voltage = Math.random() * 30 + Global.pvInverters.updateTotalMeasurements() + } + } + + name: "PV Inverter " + deviceInstance.value + Component.onCompleted: deviceInstance.value = root._objectId++ + } + } + + property Connections mockConn: Connections { + target: Global.mockDataSimulator || null + + function onSetPvInvertersRequested(config) { + Global.pvInverters.reset() + while (_createdObjects.length > 0) { + _createdObjects.pop().destroy() + } + + if (config && config.inverters) { + for (let i = 0; i < config.inverters.length; ++i) { + const inverterObj = inverterComponent.createObject(root, { + statusCode: config.inverters[i].statusCode + }) + _createdObjects.push(inverterObj) + + Global.pvInverters.addInverter(inverterObj) + } + } + } + } + + property var _createdObjects: [] + + Component.onCompleted: { + populate() + } +} diff --git a/data/mock/config/BriefAndOverviewPageConfig.qml b/data/mock/config/BriefAndOverviewPageConfig.qml index e072559c6..9918dd5fd 100644 --- a/data/mock/config/BriefAndOverviewPageConfig.qml +++ b/data/mock/config/BriefAndOverviewPageConfig.qml @@ -110,8 +110,16 @@ QtObject { system: { state: VenusOS.System_State_FloatCharging, ac: {} }, battery: { stateOfCharge: 100, current: 0 }, evcs: { chargers: [ { status: VenusOS.Evcs_Status_Charging }, { status: VenusOS.Evcs_Status_Charging }, { status: VenusOS.Evcs_Status_Disconnected } ] }, + }, + { + name: "PV inverter", + pvInverters: { inverters: [ { statusCode: VenusOS.PvInverter_StatusCode_Running } ] }, + }, + { + name: "PV chargers + PV inverters", + pvInverters: { inverters: [ { statusCode: VenusOS.PvInverter_StatusCode_Error }, { statusCode: VenusOS.PvInverter_StatusCode_BootLoading } ] }, + solar: { chargers: [ { acPower: 123 } ] }, } - ] function configCount() { @@ -122,6 +130,7 @@ QtObject { const config = configs[configIndex] Global.mockDataSimulator.setAcInputsRequested(config.acInputs) Global.mockDataSimulator.setDcInputsRequested(config.dcInputs) + Global.mockDataSimulator.setPvInvertersRequested(config.pvInverters) Global.mockDataSimulator.setSolarChargersRequested(config.solar) Global.mockDataSimulator.setSystemRequested(config.system) Global.mockDataSimulator.setBatteryRequested(config.battery) diff --git a/data/mock/config/MockDataSimulator.qml b/data/mock/config/MockDataSimulator.qml index d9798956d..adbb4890c 100644 --- a/data/mock/config/MockDataSimulator.qml +++ b/data/mock/config/MockDataSimulator.qml @@ -17,6 +17,7 @@ QtObject { signal setDcInputsRequested(var config) signal setEnvironmentInputsRequested(var config) signal setEvChargersRequested(var config) + signal setPvInvertersRequested(var config) signal setSolarChargersRequested(var config) signal setSystemRequested(var config) signal setTanksRequested(var config) diff --git a/data/mqtt/MqttDataManager.qml b/data/mqtt/MqttDataManager.qml index 5f140376e..613aa68d7 100644 --- a/data/mqtt/MqttDataManager.qml +++ b/data/mqtt/MqttDataManager.qml @@ -18,6 +18,7 @@ QtObject { property var generators: GeneratorsImpl { } property var inverters: InvertersImpl { } property var notifications: NotificationsImpl {} + property var pvInverters: PvInvertersImpl { } property var relays: RelaysImpl {} property var solarChargers: SolarChargersImpl { } property var system: SystemImpl { } diff --git a/data/mqtt/PvInvertersImpl.qml b/data/mqtt/PvInvertersImpl.qml new file mode 100644 index 000000000..f2c8a2637 --- /dev/null +++ b/data/mqtt/PvInvertersImpl.qml @@ -0,0 +1,24 @@ + +/* +** Copyright (C) 2022 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS +import Victron.Veutil +import "../common" + +QtObject { + id: root + + property Instantiator inverterObjects: Instantiator { + model: VeQItemTableModel { + uids: ["mqtt/pvinverter"] + flags: VeQItemTableModel.AddChildren | VeQItemTableModel.AddNonLeaves | VeQItemTableModel.DontAddItem + } + + delegate: PvInverter { + serviceUid: model.uid + } + } +} diff --git a/pages/BriefMonitorPanel.qml b/pages/BriefMonitorPanel.qml index 43f705460..374887627 100644 --- a/pages/BriefMonitorPanel.qml +++ b/pages/BriefMonitorPanel.qml @@ -76,7 +76,7 @@ Column { Item { width: parent.width height: solarQuantityLabel.y + solarQuantityLabel.height - visible: Global.solarChargers.model.count > 0 + visible: Global.solarChargers.model.count || Global.pvInverters.model.count WidgetHeader { id: solarHeader @@ -90,18 +90,20 @@ Column { id: solarQuantityLabel anchors.top: solarHeader.bottom - dataObject: Global.solarChargers + dataObject: SolarMeasurements {} font.pixelSize: Theme.font.briefPage.quantityLabel.size } - SolarYieldGraph { + Loader { anchors { right: parent.right top: parent.top bottom: solarQuantityLabel.bottom bottomMargin: solarQuantityLabel.bottomPadding } + active: Global.solarChargers.model.count > 0 && Global.pvInverters.model.count === 0 width: Theme.geometry.briefPage.sidePanel.solarYield.width + sourceComponent: SolarYieldGraph {} } } diff --git a/pages/BriefPage.qml b/pages/BriefPage.qml index 6d5fc1227..09744a8b1 100644 --- a/pages/BriefPage.qml +++ b/pages/BriefPage.qml @@ -171,7 +171,7 @@ Page { leftMargin: Theme.geometry.briefPage.edgeGauge.horizontalMargin right: mainGauge.left } - active: Global.solarChargers.model.count > 0 + active: Global.solarChargers.model.count > 0 || Global.pvInverters.model.count > 0 sourceComponent: SolarYieldGauge { alignment: Qt.AlignLeft | (leftEdge.active ? Qt.AlignBottom : Qt.AlignVCenter) diff --git a/pages/OverviewPage.qml b/pages/OverviewPage.qml index 08925876e..c9e1d6304 100644 --- a/pages/OverviewPage.qml +++ b/pages/OverviewPage.qml @@ -33,6 +33,7 @@ Page { + (isNaN(Global.system.loads.dcPower) ? 0 : 1) + (Global.solarChargers.model.count === 0 ? 0 : 1) + (Global.evChargers.model.count === 0 ? 0 : 1) + + (Global.pvInverters.model.count === 0 ? 0 : 1) on_ShouldResetWidgetsChanged: Qt.callLater(_resetWidgets) Component.onCompleted: Qt.callLater(_resetWidgets) @@ -288,7 +289,7 @@ Page { } // Add solar widget - if (Global.solarChargers.model.count > 0) { + if (Global.solarChargers.model.count > 0 || Global.pvInverters.model.count > 0) { widgetCandidates.splice(_leftWidgetInsertionIndex(VenusOS.OverviewWidget_Type_Solar, widgetCandidates), 0, _createWidget(VenusOS.OverviewWidget_Type_Solar)) } @@ -584,10 +585,10 @@ Page { animateGeometry: root.isCurrentPage && Global.pageManager.animatingIdleResize animationEnabled: root.animationEnabled - // Energy always flows towards inverter/charger, never towards solar charger. + // Energy flows to Inverter/Charger if there is any MPPT AC power or PV Inverter power. animationMode: root.isCurrentPage - && !isNaN(Global.solarChargers.acPower) - && Math.abs(Global.solarChargers.acPower) > Theme.geometry.overviewPage.connector.animationPowerThreshold + && !isNaN(Global.solarChargers.acPower) || !isNaN(Global.pvInverters.power) + && Math.abs((Global.solarChargers.acPower || 0) + (Global.pvInverters.power || 0)) > Theme.geometry.overviewPage.connector.animationPowerThreshold ? VenusOS.WidgetConnector_AnimationMode_StartToEnd : VenusOS.WidgetConnector_AnimationMode_NotAnimated } @@ -604,7 +605,7 @@ Page { animateGeometry: root.isCurrentPage && Global.pageManager.animatingIdleResize animationEnabled: root.animationEnabled - // Energy always flows towards battery, never towards solar charger. + // Energy flows to battery if there is any MPPT DC power (i.e. solar is charging battery). animationMode: root.isCurrentPage && !isNaN(Global.solarChargers.dcPower) && Math.abs(Global.solarChargers.dcPower) > Theme.geometry.overviewPage.connector.animationPowerThreshold diff --git a/pages/solar/PvInverterPage.qml b/pages/solar/PvInverterPage.qml new file mode 100644 index 000000000..5ddc26e29 --- /dev/null +++ b/pages/solar/PvInverterPage.qml @@ -0,0 +1,136 @@ +/* +** Copyright (C) 2023 Victron Energy B.V. +*/ + +import QtQuick +import Victron.VenusOS + +Page { + id: root + + property var pvInverter + + title: pvInverter.name + + GradientListView { + model: ObjectModel { + ListItemBackground { + height: phaseTable.y + phaseTable.height + + QuantityTableSummary { + id: phaseSummary + + x: Theme.geometry.listItem.content.horizontalMargin + width: parent.width - Theme.geometry.listItem.content.horizontalMargin + + model: [ + { + title: root.pvInverter.statusCode >= 0 ? CommonWords.status : "", + text: Global.pvInverters.statusCodeToText(root.pvInverter.statusCode), + unit: VenusOS.Units_None, + }, + { + title: CommonWords.energy, + value: root.pvInverter.energy, + unit: VenusOS.Units_Energy_KiloWattHour + }, + { + title: CommonWords.voltage, + value: root.pvInverter.voltage, + unit: VenusOS.Units_Volt + }, + { + title: CommonWords.current_amps, + value: root.pvInverter.current, + unit: VenusOS.Units_Amp + }, + { + title: CommonWords.power_watts, + value: root.pvInverter.power, + unit: VenusOS.Units_Watt + }, + ] + } + + QuantityTable { + id: phaseTable + + anchors { + top: phaseSummary.bottom + topMargin: Theme.geometry.gradientList.spacing + } + visible: root.pvInverter.phases.count > 1 + headerVisible: false + + rowCount: root.pvInverter.phases.count + units: [ + { title: CommonWords.phase, unit: VenusOS.Units_None }, + { title: CommonWords.energy, unit: VenusOS.Units_Energy_KiloWattHour }, + { title: CommonWords.voltage, unit: VenusOS.Units_Volt }, + { title: CommonWords.current_amps, unit: VenusOS.Units_Amp }, + { title: CommonWords.power_watts, unit: VenusOS.Units_Watt } + ] + valueForModelIndex: function(phaseIndex, column) { + const phase = root.pvInverter.phases.get(phaseIndex) + const columnProperties = ["name", "energy", "voltage", "current", "power"] + return phase[columnProperties[column]] + } + } + } + + Item { + width: 1 + height: Theme.geometry.gradientList.spacing + } + + ListRadioButtonGroup { + //: PV inverter AC input/output position + //% "Position" + text: qsTrId("pvinverter_ac_position") + optionModel: [ + { + //% "AC Input 1" + display: qsTrId("pvinverter_ac_input_1"), + value: VenusOS.PvInverter_Position_ACInput + }, + { + //% "AC Input 2" + display: qsTrId("pvinverter_ac_input_2"), + value: VenusOS.PvInverter_Position_ACInput2 + }, + { + //% "AC Output" + display: qsTrId("pvinverter_ac_output"), + value: VenusOS.PvInverter_Position_ACOutput + } + ] + dataSource: root.pvInverter.serviceUid + "/Position" + } + + ListTextItem { + //% "Zero feed-in power limit" + text: qsTrId("pvinverters_power_limit") + dataSource: root.pvInverter.serviceUid + "/Ac/PowerLimit" + visible: dataValid + } + + ListTextItem { + text: CommonWords.error + secondaryText: root.pvInverter.errorCode > 0 + ? root.pvInverter.errorCode + //: Indicates there are no errors present + //% "None" + : qsTrId("pvinverters_errors_none") + secondaryLabel.color: root.pvInverter.errorCode > 0 ? Theme.color.critical : Theme.color.font.secondary + } + + ListNavigationItem { + text: CommonWords.device_info_title + onClicked: { + Global.pageManager.pushPage("/pages/settings/PageDeviceInfo.qml", + { "title": text, "bindPrefix": root.pvInverter.serviceUid }) + } + } + } + } +} diff --git a/pages/solar/SolarDeviceListPage.qml b/pages/solar/SolarDeviceListPage.qml index cad0cb0cc..ca060c22f 100644 --- a/pages/solar/SolarDeviceListPage.qml +++ b/pages/solar/SolarDeviceListPage.qml @@ -16,77 +16,165 @@ Page { return NaN } + // A list of the quantity measurements for all PV chargers, followed by all PV inverters. GradientListView { id: chargerListView - header: Row { - anchors { - right: parent.right - rightMargin: Theme.geometry.listItem.content.horizontalMargin + Theme.geometry.statusBar.button.icon.width + // If there are both PV chargers and PV inverters, the ListView headerItem will be the + // 'PV chargers' header, and one of the list delegates will be the 'PV inverters' header + // row instead of a row containing the quantity measurements. + // If there are only PV chargers or only PV inverters, only the ListView headerItem is + // required, and no additional header is needed. + readonly property int extraHeaderCount: Global.solarChargers.model.count > 0 && Global.pvInverters.model.count > 0 ? 1 : 0 + + header: listHeaderComponent + model: Global.solarChargers.model.count + Global.pvInverters.model.count + extraHeaderCount + + delegate: Loader { + width: parent ? parent.width : 0 + height: Math.max(item ? item.implicitHeight : 0, Theme.geometry.listItem.height) + sourceComponent: Global.solarChargers.model.count > 0 + && Global.pvInverters.model.count > 0 + && model.index === Global.solarChargers.model.count + ? listHeaderComponent + : contentRowComponent + + onLoaded: { + if (sourceComponent === listHeaderComponent) { + item.chargerMode = false + } } - width: Theme.geometry.solarListPage.quantityRow.width - height: implicitHeight + Theme.geometry.quantityTableSummary.verticalMargin - - Repeater { - id: titleRepeater - - model: [CommonWords.yield_today, CommonWords.voltage, CommonWords.current_amps, CommonWords.pv_power] - delegate: Label { - width: (parent.width / titleRepeater.count) * (model.index === 0 ? 1.2 : 1) // kwh column needs more space as unit name is longer - text: modelData - font.pixelSize: Theme.font.size.caption - color: Theme.color.solarListPage.header.text + + Component { + id: contentRowComponent + + ListNavigationItem { + readonly property var solarCharger: model.index < Global.solarChargers.model.count + ? Global.solarChargers.model.objectAt(model.index) + : null + readonly property var pvInverter: { + let pvInverterIndex = model.index + if (Global.solarChargers.model.count > 0) { + if (model.index <= Global.solarChargers.model.count) { + // This is a row for a charger or for the 'PV inverters' header, not for + // an inverter + return null + } + // Offset the index by the number of items above it in the list + pvInverterIndex = model.index - Global.solarChargers.model.count - chargerListView.extraHeaderCount + } + return Global.pvInverters.model.objectAt(pvInverterIndex) + } + + text: solarCharger ? solarCharger.name : pvInverter.name + primaryLabel.width: availableWidth - Theme.geometry.solarListPage.quantityRow.width - Theme.geometry.listItem.content.horizontalMargin + + onClicked: { + if (solarCharger) { + Global.pageManager.pushPage("/pages/solar/SolarChargerPage.qml", { "solarCharger": solarCharger }) + } else { + Global.pageManager.pushPage("/pages/solar/PvInverterPage.qml", { "pvInverter": pvInverter }) + } + } + + Row { + id: quantityRow + + anchors { + right: parent.right + rightMargin: Theme.geometry.listItem.content.horizontalMargin + Theme.geometry.statusBar.button.icon.width + } + width: Theme.geometry.solarListPage.quantityRow.width + height: parent.height - parent.spacing + + Repeater { + id: quantityRepeater + + model: [ + { + value: !!solarCharger ? root._yieldToday(solarCharger) : pvInverter.energy, + unit: VenusOS.Units_Energy_KiloWattHour + }, + { + value: (solarCharger || pvInverter).voltage, + unit: VenusOS.Units_Volt + }, + { + value: (solarCharger || pvInverter).current, + unit: VenusOS.Units_Amp + }, + { + value: (solarCharger || pvInverter).power, + unit: VenusOS.Units_Watt + }, + ] + + delegate: QuantityLabel { +// anchors.verticalCenter: quantityRow.verticalCenter + width: (quantityRow.width / quantityRepeater.count) * (model.index === 0 ? 1.2 : 1) + height: quantityRow.height + value: modelData.value + unit: modelData.unit + alignment: Qt.AlignLeft + font.pixelSize: Theme.font.size.body2 + valueColor: Theme.color.quantityTable.quantityValue + unitColor: Theme.color.quantityTable.quantityUnit + } + } + } } } } - model: Global.solarChargers.model - delegate: ListNavigationItem { - readonly property var solarCharger: model.solarCharger + } + + Component { + id: listHeaderComponent - text: solarCharger.name - primaryLabel.width: availableWidth - Theme.geometry.solarListPage.quantityRow.width - Theme.geometry.listItem.content.horizontalMargin + Item { + property bool chargerMode: Global.solarChargers.model.count > 0 - onClicked: Global.pageManager.pushPage("/pages/solar/SolarChargerPage.qml", { "solarCharger": solarCharger }) + width: parent.width + height: Theme.geometry.listItem.height + + Label { + id: firstTitleLabel + anchors { + left: parent.left + leftMargin: Theme.geometry.listItem.content.horizontalMargin + right: quantityRow.left + bottom: parent.bottom + bottomMargin: Theme.geometry.quantityTableSummary.verticalMargin + } + text: chargerMode + //% "PV Charger" + ? qsTrId("solardevices_pv_charger") + //% "PV Inverter" + : qsTrId("solardevices_pv_inverter") + font.pixelSize: Theme.font.size.caption + color: Theme.color.solarListPage.header.text + elide: Text.ElideRight + } Row { + id: quantityRow + anchors { + bottom: parent.bottom + bottomMargin: Theme.geometry.quantityTableSummary.verticalMargin right: parent.right rightMargin: Theme.geometry.listItem.content.horizontalMargin + Theme.geometry.statusBar.button.icon.width } width: Theme.geometry.solarListPage.quantityRow.width - height: parent.height Repeater { - id: quantityRepeater - - model: [ - { - value: root._yieldToday(solarCharger), - unit: VenusOS.Units_Energy_KiloWattHour - }, - { - value: solarCharger.voltage, - unit: VenusOS.Units_Volt - }, - { - value: solarCharger.current, - unit: VenusOS.Units_Amp - }, - { - value: solarCharger.power, - unit: VenusOS.Units_Watt - }, - ] - - delegate: QuantityLabel { - anchors.verticalCenter: parent.verticalCenter - width: (parent.width / quantityRepeater.count) * (model.index === 0 ? 1.2 : 1) - value: modelData.value - unit: modelData.unit - alignment: Qt.AlignLeft - font.pixelSize: Theme.font.size.body2 - valueColor: Theme.color.quantityTable.quantityValue - unitColor: Theme.color.quantityTable.quantityUnit + id: titleRepeater + + model: [chargerMode ? CommonWords.yield_today : CommonWords.energy, CommonWords.voltage, CommonWords.current_amps, CommonWords.power_watts] + delegate: Label { + width: (parent.width / titleRepeater.count) * (model.index === 0 ? 1.2 : 1) // kwh column needs more space as unit name is longer + text: modelData + font.pixelSize: Theme.font.size.caption + color: Theme.color.solarListPage.header.text } } } diff --git a/qml.qrc b/qml.qrc index 705eab0d2..a2f3a760e 100644 --- a/qml.qrc +++ b/qml.qrc @@ -48,13 +48,14 @@ components/SeparatorBar.qml components/ShinyProgressArc.qml components/SideGauge.qml - components/SolarYieldGauge.qml - components/SolarYieldGraph.qml - components/SolarYieldModel.qml components/SolarDetailBox.qml components/SolarHistoryChart.qml components/SolarHistoryErrorView.qml components/SolarHistoryTableView.qml + components/SolarMeasurements.qml + components/SolarYieldGauge.qml + components/SolarYieldGraph.qml + components/SolarYieldModel.qml components/SplashView.qml components/StatusBar.qml components/SwitchControlValue.qml @@ -152,6 +153,7 @@ data/Generators.qml data/Inverters.qml data/Notifications.qml + data/PvInverters.qml data/Relays.qml data/SolarChargers.qml data/System.qml @@ -171,6 +173,7 @@ data/common/EvCharger.qml data/common/Generator.qml data/common/Inverter.qml + data/common/PvInverter.qml data/common/PvMonitor.qml data/common/Relay.qml data/common/SolarCharger.qml @@ -188,6 +191,7 @@ data/dbus/GeneratorsImpl.qml data/dbus/InvertersImpl.qml data/dbus/NotificationsImpl.qml + data/dbus/PvInvertersImpl.qml data/dbus/RelaysImpl.qml data/dbus/SolarChargersImpl.qml data/dbus/SystemImpl.qml @@ -204,6 +208,7 @@ data/mock/MockDevice.qml data/mock/MockDataManager.qml data/mock/NotificationsImpl.qml + data/mock/PvInvertersImpl.qml data/mock/RelaysImpl.qml data/mock/SolarChargersImpl.qml data/mock/SystemImpl.qml @@ -224,6 +229,7 @@ data/mqtt/InvertersImpl.qml data/mqtt/MqttDataManager.qml data/mqtt/NotificationsImpl.qml + data/mqtt/PvInvertersImpl.qml data/mqtt/RelaysImpl.qml data/mqtt/SolarChargersImpl.qml data/mqtt/SystemImpl.qml @@ -402,6 +408,7 @@ pages/settings/tz/TzEuropeData.qml pages/settings/tz/TzIndianData.qml pages/settings/tz/TzPacificData.qml + pages/solar/PvInverterPage.qml pages/solar/SolarChargerAlarmsAndErrorsPage.qml pages/solar/SolarChargerNetworkedOperationPage.qml pages/solar/SolarChargerPage.qml diff --git a/src/enums.h b/src/enums.h index 9f854a8d1..2628acc2d 100644 --- a/src/enums.h +++ b/src/enums.h @@ -491,6 +491,30 @@ class Enums : public QObject }; Q_ENUM(Evcs_Position) + enum PvInverter_StatusCode { + PvInverter_StatusCode_Startup0, + PvInverter_StatusCode_Startup1, + PvInverter_StatusCode_Startup2, + PvInverter_StatusCode_Startup3, + PvInverter_StatusCode_Startup4, + PvInverter_StatusCode_Startup5, + PvInverter_StatusCode_Startup6, + PvInverter_StatusCode_Running, + PvInverter_StatusCode_Standby, + PvInverter_StatusCode_BootLoading, + PvInverter_StatusCode_Error, + PvInverter_StatusCode_RunningMPPT, + PvInverter_StatusCode_RunningThrottled + }; + Q_ENUM(PvInverter_StatusCode) + + enum PvInverter_Position { + PvInverter_Position_ACInput, + PvInverter_Position_ACOutput, + PvInverter_Position_ACInput2, + }; + Q_ENUM(PvInverter_Position) + Q_INVOKABLE QString acInputIcon(AcInputs_InputType type); Q_INVOKABLE QString dcInputIcon(DcInputs_InputType type); }; diff --git a/src/main.cpp b/src/main.cpp index 7f445f31b..4e0c9aa96 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -371,6 +371,8 @@ void registerQmlTypes() "Victron.VenusOS", 2, 0, "SolarHistoryErrorView"); qmlRegisterType(QUrl(QStringLiteral("qrc:/components/SolarHistoryTableView.qml")), "Victron.VenusOS", 2, 0, "SolarHistoryTableView"); + qmlRegisterType(QUrl(QStringLiteral("qrc:/components/SolarMeasurements.qml")), + "Victron.VenusOS", 2, 0, "SolarMeasurements"); qmlRegisterType(QUrl(QStringLiteral("qrc:/components/SolarYieldGauge.qml")), "Victron.VenusOS", 2, 0, "SolarYieldGauge"); qmlRegisterType(QUrl(QStringLiteral("qrc:/components/SolarYieldGraph.qml")),