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")),