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