diff --git a/cmake/ModuleMock_Sources.cmake b/cmake/ModuleMock_Sources.cmake index 7cd3eb5b0..3b9ae8b7d 100644 --- a/cmake/ModuleMock_Sources.cmake +++ b/cmake/ModuleMock_Sources.cmake @@ -10,6 +10,7 @@ set(VictronMock_QML_MODULE_SOURCES data/mock/MotorDrivesImpl.qml data/mock/MockNotification.qml data/mock/NotificationsImpl.qml + data/mock/OpportunityLoadsImpl.qml data/mock/SolarInputsImpl.qml data/mock/SystemAcImpl.qml data/mock/SystemDcImpl.qml diff --git a/cmake/ModuleVenus_Sources.cmake b/cmake/ModuleVenus_Sources.cmake index 44c8387db..da967014e 100644 --- a/cmake/ModuleVenus_Sources.cmake +++ b/cmake/ModuleVenus_Sources.cmake @@ -353,6 +353,10 @@ set (VictronVenusOS_QML_MODULE_SOURCES pages/settings/NetworkSettingsPageModel.qml pages/settings/PageCanbusStatus.qml pages/settings/PageChargeCurrentLimits.qml + pages/settings/PageControllableLoads.qml + pages/settings/PageControllableLoadsAcLoad.qml + pages/settings/PageControllableLoadsBattery.qml + pages/settings/PageControllableLoadsEVCS.qml pages/settings/PageDeviceInfo.qml pages/settings/PageGenerator.qml pages/settings/PageGeneratorAcLoad.qml @@ -692,6 +696,7 @@ set(VictronVenusOS_RESOURCES images/alternator.svg images/breadcrumb_lhs.svg images/breadcrumb_rhs.svg + images/icon_arrow.svg images/icon_battery_24.svg images/icon_battery_charging_24.svg images/icon_battery_discharging_24.svg diff --git a/data/mock/MockSetup.qml b/data/mock/MockSetup.qml index b46497891..642448399 100644 --- a/data/mock/MockSetup.qml +++ b/data/mock/MockSetup.qml @@ -21,6 +21,7 @@ Item { MiscServicesImpl {} MotorDrivesImpl {} NotificationsImpl {} + OpportunityLoadsImpl {} SolarInputsImpl {} SystemAcImpl {} SystemDcImpl {} diff --git a/data/mock/OpportunityLoadsImpl.qml b/data/mock/OpportunityLoadsImpl.qml new file mode 100644 index 000000000..ef09641dd --- /dev/null +++ b/data/mock/OpportunityLoadsImpl.qml @@ -0,0 +1,71 @@ + +/* +** Copyright (C) 2026 Victron Energy B.V. +** See LICENSE.txt for license information. +*/ + +import QtQuick +import Victron.VenusOS + +Item { + id: root + + property var jsonArray: [ + { + "controllable": true, + "deviceInstance": 0, + "label": "Battery", + "serviceType": "battery", + "uniqueIdentifier": "battery" + }, + { + "controllable": true, + "deviceInstance": 56, + "serviceType": "acload", + "uniqueIdentifier": "shellyPro2PMPVHeat2_56" + }, + { + "controllable": true, + "deviceInstance": 54, + "serviceType": "acload", + "uniqueIdentifier": "shellyPro2PMPVHeat1_54" + }, + { + "controllable": true, + "deviceInstance": 55, + "serviceType": "acload", + "uniqueIdentifier": "shellyPro2PMPVHeat2_55" + }, + { + "serviceType":"evcharger", + "deviceInstance":920, + "uniqueIdentifier":"EVCS_Mock_3_Phase_920", + "controllable":true + } + ] + + VeQuickItem { + uid: BackendConnection.serviceUidForType("opportunityloads") + "/AvailableServices" + Component.onCompleted: setValue(JSON.stringify(jsonArray)) + } + + VeQuickItem { + uid: Global.systemSettings.serviceUid + "/Settings/OpportunityLoads/Mode" + Component.onCompleted: setValue(1) + } + + VeQuickItem { + uid: BackendConnection.serviceUidForType("opportunityloads") + "/ReservationBasePower" + Component.onCompleted: setValue(4000) + } + + VeQuickItem { + uid: BackendConnection.serviceUidForType("opportunityloads") + "/ReservationDecrement" + Component.onCompleted: setValue(40) + } + + VeQuickItem { + uid: BackendConnection.serviceUidForType("opportunityloads") + "/BatteryLifeSupport" + Component.onCompleted: setValue(true) + } +} diff --git a/images/icon_arrow.svg b/images/icon_arrow.svg new file mode 100644 index 000000000..85e7f5398 --- /dev/null +++ b/images/icon_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/pages/settings/PageControllableLoads.qml b/pages/settings/PageControllableLoads.qml new file mode 100644 index 000000000..0e3e6c472 --- /dev/null +++ b/pages/settings/PageControllableLoads.qml @@ -0,0 +1,203 @@ +/* +** Copyright (C) 2026 Victron Energy B.V. +** See LICENSE.txt for license information. +*/ + +import QtQuick +import Victron.VenusOS + +Page { + id: root + + property alias model: opportunityLoadsModel + property VeQuickItem loads: VeQuickItem { + uid: BackendConnection.serviceUidForType("opportunityloads") + "/AvailableServices" + invalidate: true + onValueChanged: { + const jsv = JSON.parse(value) + for (var i = 0; jsv && i < jsv.length; ++i) { + const newValue = jsv[i] + if ((opportunityLoadsModel.count < (i + 1)) || (newValue !== opportunityLoadsModel.get(i))) + opportunityLoadsModel.set(i, jsv[i]) + } + } + } + + component Arrow: ListItemButton { + icon.source: "qrc:/images/icon_arrow.svg" + flat: false + height: parent.height - 2 * Theme.geometry_opportunityLoad_margin + } + + component DevicePriorityListNavigation: ListNavigation { + id: devicePriorityDelegate + + property string serviceType: "" + property int deviceInstance: -1 + property string label: "" + property string uniqueIdentifier: "" + readonly property var devices: serviceType === "acload" ? acLoadDevices : serviceType === "evcharger" ? evcsDevices : null + readonly property Device device: devices && deviceInstance > 0 ? devices.deviceForDeviceInstance(deviceInstance) : null + readonly property string pageSource: { + switch (serviceType) { + case "battery": + return "/pages/settings/PageControllableLoadsBattery.qml" + case "acload": + return "/pages/settings/PageControllableLoadsAcLoad.qml" + case "evcharger": + return "/pages/settings/PageControllableLoadsEVCS.qml" + default: + console.warn("Controllable Loads: Invalid service type.") + return "" + } + } + + property alias text: primary.text + property var pageProperties: ({ + "title": devicePriorityDelegate.text, + "device": devicePriorityDelegate.device + }) + + onClicked: Global.pageManager.pushPage(devicePriorityDelegate.pageSource, devicePriorityDelegate.pageProperties) + interactive: serviceType !== "evcharger" // TODO: remove this once backend supports "evcs maximum charging power limit". + + Arrow { + id: upArrow + + anchors { + left: parent.left + leftMargin: Theme.geometry_opportunityLoad_margin + verticalCenter: parent.verticalCenter + } + enabled: index !== 0 + onClicked: { + root.model.move(index, index - 1, 1) + opportunityLoadsModel.writeToBackEnd() + } + } + + Arrow { + id: downArrow + + anchors { + left: upArrow.right + leftMargin: Theme.geometry_opportunityLoad_margin + verticalCenter: parent.verticalCenter + } + enabled: index !== (listView.count - 1) + rotation: 180 + onClicked: { + root.model.move(index + 1, index, 1) + opportunityLoadsModel.writeToBackEnd() + } + } + + Column { + anchors { + left: downArrow.right + leftMargin: Theme.geometry_listItem_content_horizontalMargin + verticalCenter: parent.verticalCenter + } + + Label { + id: primary + + property var device: devicePriorityDelegate.device + + // eg. "Shelly switch (shellyPro2)" + readonly property string longName: ((device?.productName ?? "") && (device?.name ?? "")) + ? (device.productName + " (" + device.name + ")") + : "" + + font.pixelSize: Theme.font_size_body2 + wrapMode: Text.Wrap + text: serviceType === "battery" + ? "Battery" + : longName + ? longName + : (device?.name ?? "") + ? device.name + : uniqueIdentifier || "" + } + } + } + + GradientListView { + model: VisibleItemModel { + ListSwitch { + dataItem.uid: BackendConnection.serviceUidForType("platform") + "/OpportunityLoads/Mode" + text: CommonWords.enabled + } + + SettingsListHeader { + //% "Devices and Priorities" + text: qsTrId("pagecontrollableloads_devices_and_priorities") + } + + SettingsColumn { + width: parent ? parent.width : 0 + preferredVisible: opportunityLoadsModel.count > 0 + + ListView { + id: listView + + model: opportunityLoadsModel + spacing: Theme.geometry_gradientList_spacing + implicitHeight: contentHeight + width: parent ? parent.width : 0 + delegate: DevicePriorityListNavigation { + serviceType: model.serviceType + deviceInstance: model.deviceInstance + uniqueIdentifier: model.uniqueIdentifier + label: model.label + } + move: Transition { + NumberAnimation { + properties: "x,y" + easing.type: Easing.InOutQuad + } + } + displaced: Transition { + NumberAnimation { + properties: "x,y" + easing.type: Easing.InOutQuad + } + } + } + } + + SettingsListHeader { + //% "Arrange the controllable devices according to their priority; the control algorithm will control them based on the currently available PV excess." + text: qsTrId("pagecontrollableloads_arrange") + font.pixelSize: Theme.font_size_tiny + width: 528 + wrapMode: Text.Wrap + } + } + } + + ListModel { + id: opportunityLoadsModel + + function writeToBackEnd() { + var newValue = [] + for (var i = 0; i < opportunityLoadsModel.count; ++i) { + newValue.push(opportunityLoadsModel.get(i)) + } + + loads.setValue(JSON.stringify(newValue)) + } + } + + FilteredDeviceModel { + id: acLoadDevices + objectName: "PageControllableLoads.acLoadDevices" + serviceTypes: ["acload"] + } + + FilteredDeviceModel { + id: evcsDevices + objectName: "PageControllableLoads.evcsDevices" + serviceTypes: ["evcharger"] + } +} diff --git a/pages/settings/PageControllableLoadsAcLoad.qml b/pages/settings/PageControllableLoadsAcLoad.qml new file mode 100644 index 000000000..3b85871ad --- /dev/null +++ b/pages/settings/PageControllableLoadsAcLoad.qml @@ -0,0 +1,37 @@ + +/* +** Copyright (C) 2026 Victron Energy B.V. +** See LICENSE.txt for license information. +*/ + +import QtQuick +import Victron.VenusOS + +Page { + id: root + + required property Device device + + GradientListView { + model: VisibleItemModel { + ListQuantityField { + unit: VenusOS.Units_Watt + //% "Expected power consumption" + text: qsTrId("pagecontrollableloads_expected_power_consumption") + dataItem.uid: device.serviceUid + "/S2/0/RmSettings/PowerSetting" + } + ListQuantityField { + unit: VenusOS.Units_Time_Second + //% "Minimum run duration when turned on" + text: qsTrId("pagecontrollableloads_minimum_run_duration") + dataItem.uid: device.serviceUid + "/S2/0/RmSettings/OffHysteresis" + } + ListQuantityField { + unit: VenusOS.Units_Time_Second + //% "Minimum rest duration when turned off" + text: qsTrId("pagecontrollableloads_minimum_rest_duration") + dataItem.uid: device.serviceUid + "/S2/0/RmSettings/OnHysteresis" + } + } + } +} diff --git a/pages/settings/PageControllableLoadsBattery.qml b/pages/settings/PageControllableLoadsBattery.qml new file mode 100644 index 000000000..b0a1153d7 --- /dev/null +++ b/pages/settings/PageControllableLoadsBattery.qml @@ -0,0 +1,49 @@ + +/* +** Copyright (C) 2026 Victron Energy B.V. +** See LICENSE.txt for license information. +*/ + +import QtQuick +import Victron.VenusOS + +Page { + id: root + + required property Device device + + GradientListView { + model: VisibleItemModel { + ListQuantityField { + unit: VenusOS.Units_Watt + //% "Reserved power for battery charging at 0% SOC" + text: qsTrId("pagecontrollableloads_battery_reserved_power_0") + dataItem.uid: BackendConnection.serviceUidForType("opportunityloads") + "/ReservationBasePower" + } + + ListQuantityField { + unit: VenusOS.Units_Watt + //% "Reduce power per percentage point of SOC by" + text: qsTrId("pagecontrollableloads_battery_reduce_power") + dataItem.uid: BackendConnection.serviceUidForType("opportunityloads") + "/ReservationDecrement" + } + + SettingsListHeader { + //% "BatteryLife compatibility" + text: qsTrId("pagecontrollableloads_battery_batterylife_compatibility") + } + + ListSwitch { + //% "Pause Opportunity Loads when Active SOC limit exceeds 85%" + text: qsTrId("page_controllableloads_battery_pause_opportunity_loads") + dataItem.uid: BackendConnection.serviceUidForType("opportunityloads") + "/BatteryLifeSupport" + } + + SettingsListHeader { + //% "This helps the BatteryLife algorithm recharge the battery to 100%." + text: qsTrId("pagecontrollableloads_battery_this_supports_the_batterylife_algorithm") + font.pixelSize: Theme.font_size_tiny + } + } + } +} diff --git a/pages/settings/PageControllableLoadsEVCS.qml b/pages/settings/PageControllableLoadsEVCS.qml new file mode 100644 index 000000000..aa6b2039a --- /dev/null +++ b/pages/settings/PageControllableLoadsEVCS.qml @@ -0,0 +1,33 @@ + +/* +** Copyright (C) 2026 Victron Energy B.V. +** See LICENSE.txt for license information. +*/ + +import QtQuick +import Victron.VenusOS + +Page { + id: root + + required property Device device + + GradientListView { + model: VisibleItemModel { + ListQuantityField { + unit: VenusOS.Units_Watt + //% "Maximum charging power limit" + text: qsTrId("pagecontrollableloads_evcs_maximum_charging_power_limit") + // dataItem.uid: TBD + } + + SettingsListHeader { + //% "Limiting the maximum charging power can improve simultaneity with other controllable devices." + text: qsTrId("pagecontrollableloads_limiting_the_maximum") + font.pixelSize: Theme.font_size_tiny + width: 528 + wrapMode: Text.Wrap + } + } + } +} diff --git a/pages/settings/PageSettingsSystem.qml b/pages/settings/PageSettingsSystem.qml index fd7575f3c..87b54b519 100644 --- a/pages/settings/PageSettingsSystem.qml +++ b/pages/settings/PageSettingsSystem.qml @@ -53,7 +53,23 @@ Page { dataItem.uid: Global.systemSettings.serviceUid + "/Settings/SystemSetup/SystemName" } - SettingsListHeader { } + SettingsListHeader {} + + SettingsListNavigation { + //% "Opportunity Loads" + text: qsTrId("pagesettingssystem_opportunity_loads") + //% "Automate controllable devices to maximize PV self-use" + secondaryText: qsTrId("pagesettingssystem_automate_controllable_devices") + secondaryLabel.text: _mode.value ? CommonWords.enabled : CommonWords.disabled + pageSource: "/pages/settings/PageControllableLoads.qml" + + VeQuickItem { + id: _mode + uid: BackendConnection.serviceUidForType("platform") + "/OpportunityLoads/Mode" + } + } + + SettingsListHeader {} SettingsListNavigation { //% "AC System" diff --git a/src/enums.h b/src/enums.h index 77b7db2a7..5ae3119cc 100644 --- a/src/enums.h +++ b/src/enums.h @@ -92,6 +92,7 @@ class Enums : public QObject Units_Time_Day, Units_Time_Hour, Units_Time_Minute, + Units_Time_Second, Units_Altitude_Metre, Units_Altitude_Foot, Units_PartsPerMillion, diff --git a/src/units.cpp b/src/units.cpp index d11b70978..2921cf6b0 100644 --- a/src/units.cpp +++ b/src/units.cpp @@ -162,6 +162,7 @@ int Units::defaultUnitPrecision(VenusOS::Enums::Units_Type unit) const case VenusOS::Enums::Units_Time_Day: // fall through case VenusOS::Enums::Units_Time_Hour: // fall through case VenusOS::Enums::Units_Time_Minute: // fall through + case VenusOS::Enums::Units_Time_Second: // fall through case VenusOS::Enums::Units_Altitude_Metre: // fall through case VenusOS::Enums::Units_Altitude_Foot: // fall through case VenusOS::Enums::Units_PartsPerMillion: // fall through @@ -242,6 +243,8 @@ QString Units::defaultUnitString(VenusOS::Enums::Units_Type unit, int formatHint return QStringLiteral("h"); case VenusOS::Enums::Units_Time_Minute: return QStringLiteral("m"); + case VenusOS::Enums::Units_Time_Second: + return QStringLiteral("s"); case VenusOS::Enums::Units_Altitude_Metre: return QStringLiteral("m"); case VenusOS::Enums::Units_Altitude_Foot: diff --git a/themes/geometry/FiveInch.json b/themes/geometry/FiveInch.json index 55a657f24..9016be12d 100644 --- a/themes/geometry/FiveInch.json +++ b/themes/geometry/FiveInch.json @@ -533,5 +533,7 @@ "geometry_boatPage_motorDriveGauges_topPadding": 25, "geometry_boatPage_motorDrive_temperaturesColumn_spacing": -5, "geometry_boatPage_motorDriveColumn_spacing": 5, - "geometry_boatPage_motordriveRow_image_width": 32 + "geometry_boatPage_motordriveRow_image_width": 32, + + "geometry_opportunityLoad_margin": 4 } diff --git a/themes/geometry/SevenInch.json b/themes/geometry/SevenInch.json index f5d3b6c3e..86e4cc2c0 100644 --- a/themes/geometry/SevenInch.json +++ b/themes/geometry/SevenInch.json @@ -533,5 +533,7 @@ "geometry_boatPage_motorDriveGauges_topPadding": 8, "geometry_boatPage_motorDrive_temperaturesColumn_spacing": -2, "geometry_boatPage_motorDriveColumn_spacing": 18, - "geometry_boatPage_motordriveRow_image_width": 32 + "geometry_boatPage_motordriveRow_image_width": 32, + + "geometry_opportunityLoad_margin": 4 }