diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0aa54a5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "3rdparty/qmsgpack"] + path = 3rdparty/qmsgpack + url = ../../0xFEEDC0DE64/qmsgpack.git +[submodule "3rdparty/QtZeroConf"] + path = 3rdparty/QtZeroConf + url = ../../0xFEEDC0DE64/QtZeroConf.git diff --git a/3rdparty/CMakeLists.txt b/3rdparty/CMakeLists.txt new file mode 100644 index 0000000..f3cff6a --- /dev/null +++ b/3rdparty/CMakeLists.txt @@ -0,0 +1,2 @@ +add_subdirectory(qmsgpack) +add_subdirectory(QtZeroConf) diff --git a/3rdparty/QtZeroConf b/3rdparty/QtZeroConf new file mode 160000 index 0000000..2fbb52b --- /dev/null +++ b/3rdparty/QtZeroConf @@ -0,0 +1 @@ +Subproject commit 2fbb52b70820a933ca954fdc3befaebc32072e3b diff --git a/3rdparty/qmsgpack b/3rdparty/qmsgpack new file mode 160000 index 0000000..0117422 --- /dev/null +++ b/3rdparty/qmsgpack @@ -0,0 +1 @@ +Subproject commit 01174223398cd6d7ab5017051711b306fed3eecf diff --git a/AboutPage.qml b/AboutPage.qml new file mode 100644 index 0000000..ea8ad82 --- /dev/null +++ b/AboutPage.qml @@ -0,0 +1,23 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("About") + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Firmware") + component: "FirmwarePage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Hardware information") + component: "HardwareInformationPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Licenses") + component: "LicensesPage.qml" + } +} diff --git a/AccessPage.qml b/AccessPage.qml new file mode 100644 index 0000000..dde07c1 --- /dev/null +++ b/AccessPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Access") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/AddDeviceScreen.qml b/AddDeviceScreen.qml new file mode 100644 index 0000000..6f1806b --- /dev/null +++ b/AddDeviceScreen.qml @@ -0,0 +1,115 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("Setup or add device") + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: localLayout.implicitHeight + + color: "white" + radius: 5 + + ColumnLayout { + id: localLayout + + anchors.fill: parent + + Text { + text: qsTr("Add via local connection or AP:") + } + + RowLayout { + Layout.fillWidth: true + + TextField { + id: manualUrl + + Layout.fillWidth: true + + placeholderText: qsTr("Local url") + text: "ws://10.128.250.181/ws" + // validator: RegularExpressionValidator { regularExpression: /^wss?://.*/ } + onAccepted: connectLocalButton.clicked() + } + + Button { + id: connectLocalButton + text: qsTr("Connect") + onClicked: deviceSelected(manualUrl.text, "") + } + } + } + } + + Item { + Layout.preferredHeight: 50 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: cloudLayout.implicitHeight + + color: "white" + radius: 5 + + ColumnLayout { + id: cloudLayout + + anchors.fill: parent + spacing: 5 + + Text { + text: qsTr("Add via cloud:") + } + + RowLayout { + Layout.fillWidth: true + + TextField { + id: cloudSerial + + Layout.fillWidth: true + + placeholderText: qsTr("Cloud Serial") + text: "000000" + onTextChanged: { + const serial = Number(text); + for (let i = 0; i < cloudUrlsModel.count; ++i) { + const entry = cloudUrlsModel.get(i); + if (serial >= entry.minSerial && serial <= entry.maxSerial) { + cloudType.currentIndex = i; + break; + } + } + } + inputMethodHints: Qt.ImhDigitsOnly | Qt.ImhFormattedNumbersOnly + validator: RegularExpressionValidator { regularExpression: /^[0-9]{6,}$/ } + onAccepted: connectCloudButton.clicked() + } + + ComboBox { + id: cloudType + + textRole: "text" + valueRole: "url" + + model: cloudUrlsModel + } + + Button { + id: connectCloudButton + text: qsTr("Connect") + onClicked: deviceSelected(cloudType.currentValue + cloudSerial.text, "") + } + } + } + } + + Item { + Layout.fillHeight: true + } +} diff --git a/ApiKeyValueItem.qml b/ApiKeyValueItem.qml new file mode 100644 index 0000000..c91dc8e --- /dev/null +++ b/ApiKeyValueItem.qml @@ -0,0 +1,18 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +Text { + property alias apiKey: apiKeyValueHelper.apiKey + property alias exists: apiKeyValueHelper.exists + + Layout.fillWidth: true + + ApiKeyValueHelper { + id: apiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + } + + text: apiKeyValueHelper.value +} diff --git a/ApiSettingsPage.qml b/ApiSettingsPage.qml new file mode 100644 index 0000000..eb2a5a1 --- /dev/null +++ b/ApiSettingsPage.qml @@ -0,0 +1,181 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("API Settings") + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: localApiLayout.implicitHeight + visible: localApi.exists + + ColumnLayout { + id: localApiLayout + anchors.fill: parent + + GeneralOnOffSwitch { + id: localApi + apiKey: "hai" + text: qsTr("Allow access to local HTTP-Api v2") + } + + Button { + text: qsTr("API documentation") + } + + RowLayout { + Layout.fillWidth: true + + SendMessageHelper { + id: apiToken + deviceConnection: mainScreen.deviceConnection + } + + Button { + text: qsTr("Generate token") + onClicked: apiToken.sendMessage({ type: "generateApiToken" }) + } + + BusyIndicator { + visible: apiToken.pending + } + + Text { + Layout.fillWidth: true + + visible: apiToken.response + text: { + if (!apiToken.response) + return "" + if (apiToken.response.success) + return qsTr("OK! token: %0").arg(apiToken.response.token) + else + return apiToken.response.message + } + wrapMode: Text.Wrap + } + } + + RowLayout { + Layout.fillWidth: true + + ApiKeyValueHelper { + id: tokenSetup + deviceConnection: mainScreen.deviceConnection + apiKey: "hatv" + } + + visible: tokenSetup.value + + SendMessageHelper { + id: abortFirmwareUpdate + deviceConnection: mainScreen.deviceConnection + } + + Button { + text: qsTr("Clear API token") + + onClicked: abortFirmwareUpdate.sendMessage({ type: "clearApiToken" }) + } + + BusyIndicator { + visible: abortFirmwareUpdate.pending + } + + RequestStatusText { + Layout.fillWidth: true + request: abortFirmwareUpdate + } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: cloudApiLayout.implicitHeight + visible: cloudApi.exists + + ColumnLayout { + id: cloudApiLayout + anchors.fill: parent + + GeneralOnOffSwitch { + id: cloudApi + apiKey: "cae" + text: qsTr("Enable cloud API") + } + + ApiKeyValueHelper { + id: cloudApiKey + deviceConnection: mainScreen.deviceConnection + apiKey: "cak" + } + + Text { + visible: cloudApiKey.exists + text: qsTr("API key: %0").arg(cloudApiKey.value) + } + + Button { + text: qsTr("API documentation") + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: gridApiLayout.implicitHeight + visible: gridApi.exists + + ColumnLayout { + id: gridApiLayout + anchors.fill: parent + + GeneralOnOffSwitch { + Layout.fillWidth: true + id: gridApi + apiKey: "gme" + text: qsTr("Enable grid API") + } + + ApiKeyValueHelper { + id: gridApiKey + deviceConnection: mainScreen.deviceConnection + apiKey: "gmk" + } + + Text { + Layout.fillWidth: true + visible: gridApiKey.exists + text: qsTr("API key: %0").arg(gridApiKey.value) + } + + Button { + text: qsTr("API documentation") + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: legacyApiLayout.implicitHeight + visible: legacyApi.exists + + ColumnLayout { + id: legacyApiLayout + anchors.fill: parent + + GeneralOnOffSwitch { + id: legacyApi + apiKey: "hla" + text: qsTr("Allow access to legacy HTTP-Api v1") + } + + Button { + text: qsTr("API documentation") + } + } + } +} diff --git a/AppInstance.qml b/AppInstance.qml new file mode 100644 index 0000000..c7a4ac8 --- /dev/null +++ b/AppInstance.qml @@ -0,0 +1,35 @@ +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Layouts +import EVChargerApp + +Loader { + id: loader + + sourceComponent: deviceList + + function backPressed() { + return loader.item.backPressed() + } + + Component { + id: deviceList + + DeviceListScreen { + //onDeviceSelected: (url, password) => loader.setSource("DeviceScreen.qml", { url, password }) + onDeviceSelected: function(url, password) { + loader.sourceComponent = deviceScreen + loader.item.url = url; + loader.item.password = password; + } + } + } + + Component { + id: deviceScreen + + DeviceScreen { + onClose: loader.sourceComponent = deviceList + } + } +} diff --git a/AppSettingsPage.qml b/AppSettingsPage.qml new file mode 100644 index 0000000..8a879bd --- /dev/null +++ b/AppSettingsPage.qml @@ -0,0 +1,60 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("App Settings") + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: gridLayout.implicitHeight + color: "white" + radius: 5 + + GridLayout { + id: gridLayout + columns: 2 + + Label { + text: qsTr("Number of app instances:") + font.bold: true + } + + SpinBox { + value: theSettings.numberOfAppInstances + onValueModified: theSettings.numberOfAppInstances = value + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: gridLayout2.implicitHeight + color: "white" + radius: 5 + + GridLayout { + id: gridLayout2 + columns: 2 + + Label { + text: qsTr("solalaweb key:") + font.bold: true + } + + Button { + text: qsTr("Select...") + } + + Label { + text: qsTr("solalaweb cert:") + font.bold: true + } + + Button { + text: qsTr("Select...") + } + } + } +} diff --git a/BaseNavigationPage.qml b/BaseNavigationPage.qml new file mode 100644 index 0000000..2e80e97 --- /dev/null +++ b/BaseNavigationPage.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Item { + Layout.fillWidth: true + + property alias title: titleText.text + default property alias data: columnLayout.data + property alias headerItems: headerLayout.data + + ColumnLayout { + id: columnLayout + + anchors.fill: parent + + RowLayout { + id: headerLayout + + Layout.fillWidth: true + + Button { + text: qsTr("Back") + visible: stackView.depth > 1 + onClicked: stackView.pop() + } + + Text { + Layout.fillWidth: true + + id: titleText + + font.pixelSize: 30 + font.bold: true + wrapMode: Text.Wrap + } + } + } +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..7ec9514 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,158 @@ +cmake_minimum_required(VERSION 3.16) +project(evcharger-app LANGUAGES CXX) +set(CMAKE_AUTOMOC ON) +#set(CMAKE_CXX_STANDARD 23) +#set(CMAKE_CXX_STANDARD_REQUIRED ON) +#set(CMAKE_CXX_EXTENSIONS ON) +add_compile_options(-std=c++2b) + +find_program(CCACHE_FOUND ccache) +if(CCACHE_FOUND) + set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ccache) + set_property(GLOBAL PROPERTY RULE_LAUNCH_LINK ccache) +endif(CCACHE_FOUND) + +add_definitions(-DQT_GUI_LIB) +add_subdirectory(3rdparty) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Quick WebSockets LinguistTools) + +qt_standard_project_setup(REQUIRES 6.6 I18N_TRANSLATED_LANGUAGES de) + +qt_add_executable(evcharger-app WIN32 MACOSX_BUNDLE +apikeyexistancehelper.cpp +apikeyexistancehelper.h +apikeyvaluehelper.cpp +apikeyvaluehelper.h +appsettings.cpp +appsettings.h +deviceconnection.cpp +deviceconnection.h +devicesmodel.cpp +devicesmodel.h +main.cpp +sendmessagehelper.cpp +sendmessagehelper.h +) + +qt6_add_translations(evcharger-app + RESOURCE_PREFIX /EVChargerApp/i18n + TS_FILE_BASE qml + TS_FILE_DIR i18n +) + +set_property(TARGET evcharger-app APPEND PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/android) + +if (ANDROID) + include(${ANDROID_SDK_ROOT}/android_openssl/CMakeLists.txt) + set_target_properties(evcharger-app PROPERTIES QT_ANDROID_EXTRA_LIBS "${ANDROID_EXTRA_LIBS}") +endif() + +qt_add_qml_module(evcharger-app + URI EVChargerApp + VERSION 1.0 + QML_FILES + AboutPage.qml + AccessPage.qml + AddDeviceScreen.qml + ApiKeyValueItem.qml + ApiSettingsPage.qml + AppInstance.qml + AppSettingsPage.qml + BaseNavigationPage.qml + CablePage.qml + CarPage.qml + ChargerTabPage.qml + ChargingConfigurationPage.qml + ChargingSpeedPage.qml + CloudUrlsModel.qml + CloudPage.qml + ConnectingScreen.qml + ConnectionPage.qml + ControllerPage.qml + ControllerTabPage.qml + CurrentLevelsPage.qml + DailyTripPage.qml + DateAndTimePage.qml + DeviceListScreen.qml + DeviceScreen.qml + DisplaySettingsPage.qml + EcoTabPage.qml + EthernetPage.qml + EVChargerApp.qml + FirmwarePage.qml + FlexibleEnergyTariffPage.qml + GeneralOnOffSwitch.qml + GeneralPage.qml + GridPage.qml + GroundCheckPage.qml + HardwareInformationPage.qml + HotspotPage.qml + InformationsTabPage.qml + KwhLimitPage.qml + LedPage.qml + LicensesPage.qml + LoadBalancingPage.qml + MainScreen.qml + NamePage.qml + NavigationItem.qml + NavigationPage.qml + NotificationsPage.qml + OcppPage.qml + PasswordPage.qml + PvSurplusPage.qml + RebootPage.qml + RequestStatusText.qml + SchedulerPage.qml + SecurityPage.qml + SensorsConfigurationPage.qml + SettingsTabPage.qml + SwitchLanguagePage.qml + VerticalTabButton.qml + WiFiErrorsPage.qml + WiFiOnOffSwitch.qml + WiFiPage.qml + RESOURCES + icons/Charger.svg + icons/ChargerV3.svg + icons/ChargerV4.svg + icons/Controller.svg + icons/Alarm.svg + icons/EcoModeFilled.svg + icons/Charts.svg + images/controller.png + images/geminiFlex.png + images/homeFix.png + images/homePlus.png + images/wattpilot.png + images/phoenix.png + images/geminiFix.png + material-icons/add.svg + material-icons/grid_guides.svg + material-icons/settings.svg + ui-icons/MaterialIcons-Regular.ttf +) + +target_link_libraries(evcharger-app PUBLIC + Qt6::Core + Qt6::Gui + Qt6::Quick + Qt6::WebSockets + qmsgpack + QtZeroConf +) + +install(TARGETS evcharger-app + BUNDLE DESTINATION . + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} +) + +qt_generate_deploy_qml_app_script( + TARGET evcharger-app + OUTPUT_SCRIPT deploy_script + MACOS_BUNDLE_POST_BUILD + NO_UNSUPPORTED_PLATFORM_ERROR + DEPLOY_USER_QML_MODULES_ON_UNSUPPORTED_PLATFORM +) +install(SCRIPT ${deploy_script}) diff --git a/CablePage.qml b/CablePage.qml new file mode 100644 index 0000000..f1f9d1f --- /dev/null +++ b/CablePage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Cable") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/CarPage.qml b/CarPage.qml new file mode 100644 index 0000000..662e943 --- /dev/null +++ b/CarPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Car") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/ChargerTabPage.qml b/ChargerTabPage.qml new file mode 100644 index 0000000..cd82d2c --- /dev/null +++ b/ChargerTabPage.qml @@ -0,0 +1,157 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +Page { + function backPressed() { + return false + } + + header: ToolBar { + id: toolBar + + background: Rectangle { + color: "lightblue" + } + + RowLayout { + anchors.fill: parent + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + ApiKeyValueHelper { + id: friendlyName + deviceConnection: mainScreen.deviceConnection + apiKey: "fna" + } + + text: friendlyName.value + color: "white" + verticalAlignment: Text.AlignVCenter + } + + Button { + Layout.fillHeight: true + + text: qsTr("Devices") + onClicked: loader.close() + } + } + } + + Flickable { + id: flickable + anchors.fill: parent + contentHeight: columnLayout.implicitHeight + clip: true + + ColumnLayout { + id: columnLayout + width: flickable.width + spacing: 5 + + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + + Text { + Layout.fillWidth: true + + text: qsTr("No car connected") + font.pixelSize: 20 + font.bold: true + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + + text: qsTr("Connect the cable to charge your car") + wrapMode: Text.Wrap + } + } + + Image { + Layout.preferredWidth: parent.width / 4 + Layout.preferredHeight: paintedHeight + + fillMode: Image.PreserveAspectFit + + ApiKeyValueHelper { + id: devicetype + deviceConnection: mainScreen.deviceConnection + apiKey: "typ" + } + + ApiKeyValueHelper { + id: isgo + deviceConnection: mainScreen.deviceConnection + apiKey: "isgo" + } + + source: { + if (devicetype.value == 'go-eCharger_V5' || + devicetype.value == 'go-eCharger_V4') + { + if (isgo.value) + return "images/geminiFlex.png" + else + return "images/geminiFix.png" + } else if (devicetype.value == 'wattpilot_V2') { + return "images/wattpilot.png" + } else if (devicetype.value == 'go-eCharger' || + devicetype.value == 'wattpilot') { + return "images/homeFix.png" + } else if (devicetype.value == 'go-eCharger_Phoenix') { + return "images/phoenix.png" + } + + return "material-icons/grid_guides.svg" + } + } + } + + Button { + Layout.fillWidth: true + + text: qsTr("Start") + } + + ButtonGroup { + buttons: column.children + } + + RowLayout { + id: column + Layout.fillWidth: true + + Button { + Layout.fillWidth: true + checked: true + checkable: true + text: qsTr("Eco") + display: AbstractButton.TextUnderIcon + } + + Button { + Layout.fillWidth: true + checkable: true + text: qsTr("Basic") + display: AbstractButton.TextUnderIcon + } + + Button { + Layout.fillWidth: true + checkable: true + text: qsTr("Daily trip") + display: AbstractButton.TextUnderIcon + } + } + } + } +} diff --git a/ChargingConfigurationPage.qml b/ChargingConfigurationPage.qml new file mode 100644 index 0000000..0f2d624 --- /dev/null +++ b/ChargingConfigurationPage.qml @@ -0,0 +1,53 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Charging configuration") + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Charging Speed") + component: "ChargingSpeedPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("kWh Limit") + component: "KwhLimitPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Daily Trip") + component: "DailyTripPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Flexible energy tariff") + component: "FlexibleEnergyTariffPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("PV Surplus") + component: "PvSurplusPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Load Balancing") + component: "LoadBalancingPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Scheduler") + component: "SchedulerPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Current Levels") + component: "CurrentLevelsPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Car") + component: "CarPage.qml" + } +} diff --git a/ChargingSpeedPage.qml b/ChargingSpeedPage.qml new file mode 100644 index 0000000..7a22441 --- /dev/null +++ b/ChargingSpeedPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Charging Speed") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/CloudPage.qml b/CloudPage.qml new file mode 100644 index 0000000..7bc9ad2 --- /dev/null +++ b/CloudPage.qml @@ -0,0 +1,81 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("Cloud") + + GeneralOnOffSwitch { + Layout.fillWidth: true + apiKey: "cwe" + text: qsTr("Enable cloud connection") + } + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: qsTr("Features like flexible energy tariffs, time sync and app connection are unavilable when \"Enable cloud connection\" is disabled") + } + + Rectangle { + color: "white" + radius: 5 + Layout.fillWidth: true + Layout.preferredHeight: gridLayout.implicitHeight + + GridLayout { + id: gridLayout + anchors.fill: parent + columns: 2 + + Label { + text: qsTr("Trying to connect:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "cws" + } + + Label { + text: qsTr("Is connected:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "cwsc" + } + + // TODO cwsca + + Label { + text: qsTr("Hello received:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "chr" + } + + Label { + text: qsTr("Queue size cloud:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "qsc" + } + + Label { + text: qsTr("Last error:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "cle" + wrapMode: Text.Wrap + } + } + } +} diff --git a/CloudUrlsModel.qml b/CloudUrlsModel.qml new file mode 100644 index 0000000..9d5a485 --- /dev/null +++ b/CloudUrlsModel.qml @@ -0,0 +1,42 @@ +import QtQuick + +ListModel { + ListElement { + text: qsTr("V2") + url: "wss://app.v2.go-e.io/" + manufacturer: "go-e" + deviceType: "has-no-mdns" + minSerial: 0 + maxSerial: 49999 + } + ListElement { + text: qsTr("Wattpilot") + url: "wss://app.wattpilot.io/app/" + manufacturer: "fronius" + deviceType: "wattpilot" + minSerial: 10000000 + maxSerial: 99999999 + } + ListElement { + text: qsTr("go-eCharger") + url: "wss://app.v3.go-e.io/" + manufacturer: "go-e" + deviceType: "go-eCharger" + minSerial: 50000 + maxSerial: 499999 + } + ListElement { + text: qsTr("go-eController") + url: "wss://app.controller.go-e.io/" + manufacturer: "go-e" + deviceType: "controller" + minSerial: 900000 + maxSerial: 999999 + } + ListElement { + text: qsTr("Solalaweb") + url: "wss://solalaweb.com/" + manufacturer: "go-e" + deviceType: "has-no-mdns" + } +} diff --git a/ConnectingScreen.qml b/ConnectingScreen.qml new file mode 100644 index 0000000..ccb893e --- /dev/null +++ b/ConnectingScreen.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ColumnLayout { + signal connected + + required property DeviceConnection deviceConnection + + function backPressed() { + close() + return true + } + + Connections { + target: deviceConnection + onFullStatusReceived: connected() + } + + Text { + text: qsTr("Trying to reach device...") + } + + BusyIndicator { + Layout.fillWidth: true + + running: true + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + + model: collectedMessages + clip: true + + delegate: Label { + text: message + } + + onCountChanged: positionViewAtEnd() + } + + Button { + text: qsTr("Cancel") + + onClicked: close() + } +} diff --git a/ConnectionPage.qml b/ConnectionPage.qml new file mode 100644 index 0000000..75470c9 --- /dev/null +++ b/ConnectionPage.qml @@ -0,0 +1,49 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("Connection") + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Wi-Fi") + component: "WiFiPage.qml" + visible: wifiStaApiKeyValueHelper.exists + } + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Hotspot") + component: "HotspotPage.qml" + visible: wifiApApiKeyValueHelper.exists + } + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Ethernet") + component: "EthernetPage.qml" + visible: ethernetApiKeyValueHelper.exists + } + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("OCPP") + component: "OcppPage.qml" + visible: ocppApiKeyValueHelper.exists + } + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Cloud") + component: "CloudPage.qml" + visible: cloudApiKeyValueHelper.exists + } + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("API Settings") + component: "ApiSettingsPage.qml" + } +} diff --git a/ControllerPage.qml b/ControllerPage.qml new file mode 100644 index 0000000..48f97de --- /dev/null +++ b/ControllerPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Controller") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/ControllerTabPage.qml b/ControllerTabPage.qml new file mode 100644 index 0000000..0618a17 --- /dev/null +++ b/ControllerTabPage.qml @@ -0,0 +1,123 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +Page { + function backPressed() { + return false + } + + header: ToolBar { + id: toolBar + + background: Rectangle { + color: "lightblue" + } + + RowLayout { + anchors.fill: parent + + Label { + Layout.fillWidth: true + Layout.fillHeight: true + + ApiKeyValueHelper { + id: friendlyName + deviceConnection: mainScreen.deviceConnection + apiKey: "fna" + } + + text: friendlyName.value + color: "white" + verticalAlignment: Text.AlignVCenter + } + + Button { + Layout.fillHeight: true + + text: qsTr("Devices") + onClicked: loader.close() + } + } + } + + Flickable { + anchors.fill: parent + + ColumnLayout { + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + Text { + //Layout.fillWidth: true + width: 100 + + text: qsTr("Connected") + font.pixelSize: 20 + font.bold: true + wrapMode: Text.Wrap + } + } + + Image { + Layout.fillHeight: true + // width: 200 + // height: 200 + Layout.preferredWidth: 100 + fillMode: Image.PreserveAspectFit + + source: "images/controller.png" + } + } + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + + text: "Controller TODO" + } + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + + text: "Controller TODO" + } + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + + text: "Controller TODO" + } + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + + text: "Controller TODO" + } + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + + text: "Controller TODO" + } + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + + text: "Controller TODO" + } + } + } +} diff --git a/CurrentLevelsPage.qml b/CurrentLevelsPage.qml new file mode 100644 index 0000000..a14f8f5 --- /dev/null +++ b/CurrentLevelsPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Current Levels") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/DailyTripPage.qml b/DailyTripPage.qml new file mode 100644 index 0000000..65664ec --- /dev/null +++ b/DailyTripPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Daily Trip") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/DateAndTimePage.qml b/DateAndTimePage.qml new file mode 100644 index 0000000..c631905 --- /dev/null +++ b/DateAndTimePage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Date and time") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/DeviceListScreen.qml b/DeviceListScreen.qml new file mode 100644 index 0000000..5be7782 --- /dev/null +++ b/DeviceListScreen.qml @@ -0,0 +1,337 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +StackView { + id: stackView + + signal deviceSelected(url: string, password: string) + + function backPressed() { + if (depth > 1) { + pop() + return true + } + return false + } + + // pushEnter: Transition { + // PropertyAnimation { + // property: "opacity" + // from: 0 + // to:1 + // duration: 200 + // } + // } + // pushExit: Transition { + // PropertyAnimation { + // property: "opacity" + // from: 1 + // to:0 + // duration: 200 + // } + // } + // popEnter: Transition { + // PropertyAnimation { + // property: "opacity" + // from: 0 + // to:1 + // duration: 200 + // } + // } + // popExit: Transition { + // PropertyAnimation { + // property: "opacity" + // from: 1 + // to:0 + // duration: 200 + // } + // } + + initialItem: BaseNavigationPage { + id: page + title: qsTr("Device list") + + CloudUrlsModel { + id: cloudUrlsModel + } + + DevicesModel { + id: devicesModel + + settings: theSettings + + Component.onCompleted: start() + } + + headerItems: [ + Button { + text: qsTr("App Settings") + icon.source: "material-icons/settings.svg" + display: AbstractButton.IconOnly + + onClicked: stackView.push(appSettingsPage) + + Component { + id: appSettingsPage + + AppSettingsPage { + } + } + } + ] + + ListView { + id: listView + model: devicesModel + + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: 10 + contentX: -15 + + ScrollBar.vertical: ScrollBar { + interactive: false + } + + footer: Item { + height: 75 + } + + section.property: "saved" + section.criteria: ViewSection.FullString + section.delegate: Component { + id: sectionHeading + Text { + width: ListView.view.width - 30 + height: implicitHeight + 50 + bottomPadding: 10 + + verticalAlignment: Text.AlignBottom + wrapMode: Text.Wrap + + required property bool section + + text: section ? qsTr("My devices") : qsTr("Found devices") + font.bold: true + font.pixelSize: 20 + } + } + + delegate: SwipeDelegate { + id: delegate + checkable: true + width: ListView.view.width - 30 + + required property int index + + required property string name + required property string serial + required property string manufacturer + required property string deviceType + required property string friendlyName + required property string password + required property bool saved + required property string hostName + required property string ip + + Component.onCompleted: { + background.color = "white" + background.radius = 5 + } + + swipe.enabled: saved + + swipe.right: Label { + id: deleteLabel + text: qsTr("Delete") + color: "white" + verticalAlignment: Label.AlignVCenter + padding: 12 + height: parent.height + anchors.right: parent.right + + SwipeDelegate.onClicked: { + listView.model.removeRow(delegate.index) + swipe.close() + delegate.checked = false; + } + + background: Rectangle { + color: deleteLabel.SwipeDelegate.pressed ? Qt.darker("tomato", 1.1) : "tomato" + } + } + + contentItem: ColumnLayout { + RowLayout { + Image { + height: parent.height + //Layout.fillHeight: true + source: { + if (delegate.deviceType == 'go-eCharger_V5' || + delegate.deviceType == 'go-eCharger_V4' || + delegate.deviceType == 'wattpilot_V2') + { + return "icons/ChargerV4.svg" + } else if (delegate.deviceType == 'go-eCharger' || + delegate.deviceType == 'wattpilot') { + return "icons/ChargerV3.svg" + } else if (delegate.deviceType == 'go-eCharger_Phoenix') { + return "icons/Charger.svg" + } else if (delegate.deviceType.includes('controller')) { + return "icons/Controller.svg" + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + Label { + Layout.fillWidth: true + text: delegate.friendlyName + font.bold: true + elide: Text.ElideRight + } + + Label { + Layout.fillWidth: true + text: qsTr("Serial Number %0").arg(delegate.serial); + } + } + } + + GridLayout { + id: grid + visible: false + + columns: 2 + rowSpacing: 10 + columnSpacing: 10 + + Label { + text: qsTr("Manufacturer:") + Layout.leftMargin: 60 + } + + Label { + text: delegate.manufacturer + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + } + + Label { + text: qsTr("Device Type:") + Layout.leftMargin: 60 + } + + Label { + text: delegate.deviceType + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + } + + Label { + text: qsTr("Host Name:") + Layout.leftMargin: 60 + } + + Label { + text: delegate.hostName + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + } + + Label { + text: qsTr("Ip:") + Layout.leftMargin: 60 + } + + Label { + text: delegate.ip + font.bold: true + elide: Text.ElideRight + Layout.fillWidth: true + } + + Button { + enabled: delegate.ip != "" + + text: qsTr("Connect local") + onClicked: deviceSelected("ws://" + delegate.ip + "/ws", delegate.password) + } + + Button { + property var cloudUrl: { + for (let i = 0; i < cloudUrlsModel.count; ++i) { + const entry = cloudUrlsModel.get(i); + if (delegate.manufacturer === entry.manufacturer && + delegate.deviceType.includes(entry.deviceType)) { + return entry.url; + } + } + return null; + } + + enabled: cloudUrl !== null + + text: qsTr("Connect cloud") + onClicked: deviceSelected(cloudUrl + delegate.serial, delegate.password) + } + } + } + + states: [ + State { + name: "expanded" + when: delegate.checked + + PropertyChanges { + // TODO: When Qt Design Studio supports generalized grouped properties, change to: + // grid.visible: true + // qmllint disable Quick.property-changes-parsed + target: grid + visible: true + } + } + ] + } + } + + Button { + parent: page + anchors { + right: parent.right + rightMargin: 10 + bottom: parent.bottom + bottomMargin: 25 + } + + text: qsTr("Add or setup device") + icon.source: "material-icons/add.svg" + + font.pointSize: 12 + font.bold: true + + highlighted: true + Material.accent: Material.Blue + + display: listView.contentY < 20 ? AbstractButton.TextBesideIcon : AbstractButton.IconOnly + + onClicked: stackView.push(addDeviceScreen) + + Component { + id: addDeviceScreen + + AddDeviceScreen { + } + } + } + } +} diff --git a/DeviceScreen.qml b/DeviceScreen.qml new file mode 100644 index 0000000..25c6fa0 --- /dev/null +++ b/DeviceScreen.qml @@ -0,0 +1,131 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +Loader { + id: loader + + signal close + + property alias url: theDeviceConnection.url + property alias password: theDeviceConnection.password + + function backPressed() { + return item.backPressed() + } + + ListModel { + id: collectedMessages + + function push(message) { + if (collectedMessages.count >= 15) + collectedMessages.remove(0); + collectedMessages.append({message}); + } + } + + anchors.fill: parent + + DeviceConnection { + id: theDeviceConnection + + settings: theSettings + + onLogMessage: (message) => collectedMessages.push(message) + + onAuthRequired: { + passwordError.visible = false; + passwordDialog.open(); + } + onAuthError: function(message) { + passwordError.visible = true; + passwordError.text = message; + passwordDialog.open(); + } + onAuthImpossible: authImpossibleDialog.open() + } + + sourceComponent: Component { + ConnectingScreen { + deviceConnection: theDeviceConnection + + anchors.fill: parent + + onConnected: loader.sourceComponent = mainScreen; + } + } + + Component { + id: mainScreen + + MainScreen { + deviceConnection: theDeviceConnection + } + } + + Dialog { + id: passwordDialog + + x: parent.width / 2 - width / 2 + y: parent.height / 2 - height / 2 + + title: qsTr("Password required") + standardButtons: Dialog.Ok | Dialog.Cancel + focus: true + modal: true + + onAccepted: theDeviceConnection.sendAuth(passwordInput.text) + onRejected: loader.close() + + contentItem: GridLayout { + property int minimumInputSize: 120 + + id: grid + + columns: 2 + + Text { + id: passwordError + + Layout.columnSpan: 2 + color: "red" + } + + Label { + text: qsTr("Password:") + Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline + } + + TextField { + id: passwordInput + focus: true + Layout.fillWidth: true + Layout.minimumWidth: grid.minimumInputSize + Layout.alignment: Qt.AlignLeft | Qt.AlignBaseline + placeholderText: qsTr("Password") + echoMode: TextInput.PasswordEchoOnEdit + onAccepted: passwordDialog.accept(); + } + } + } + + Dialog { + id: authImpossibleDialog + + x: parent.width / 2 - width / 2 + y: parent.height / 2 - height / 2 + + title: qsTr("Authentication impossible!") + standardButtons: Dialog.Ok | Dialog.Cancel + focus: true + modal: true + + onAccepted: loader.close() + onRejected: loader.close() + + contentItem: Text { + text: qsTr("To use this password remotely a password has to be setup first. This can be done over the AccessPoint."); + } + } +} diff --git a/DisplaySettingsPage.qml b/DisplaySettingsPage.qml new file mode 100644 index 0000000..fe6b870 --- /dev/null +++ b/DisplaySettingsPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Display settings") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/EVChargerApp.qml b/EVChargerApp.qml new file mode 100644 index 0000000..0a59101 --- /dev/null +++ b/EVChargerApp.qml @@ -0,0 +1,67 @@ +import QtQuick +import QtQuick.Controls.Material +import QtQuick.Layouts +import EVChargerApp + +ApplicationWindow { + id: window + + width: 320 + height: 480 + visible: true + title: qsTr("EVcharger App") + color: "#F3F2F7" + + AppSettings { + id: theSettings + } + + FontLoader { + id: materialIcons + source: "ui-icons/MaterialIcons-Regular.ttf" + } + + SwipeView { + id: view + + anchors.fill: parent + + currentIndex: indicator.currentIndex + + Repeater { + model: theSettings.numberOfAppInstances + + AppInstance { + width: view.width + } + } + } + + PageIndicator { + id: indicator + + count: view.count + currentIndex: view.currentIndex + + anchors.top: view.top + anchors.horizontalCenter: parent.horizontalCenter + } + + function backPressed() { + if (view.currentItem.backPressed()) + return true + + if (view.currentIndex > 0) { + view.currentIndex = 0 + view.currentIndex = Qt.binding(() => indicator.currentIndex) + return true + } + + return false + } + + onClosing: (close) => { + if (backPressed()) + close.accepted = false + } +} diff --git a/EcoTabPage.qml b/EcoTabPage.qml new file mode 100644 index 0000000..d6da9fb --- /dev/null +++ b/EcoTabPage.qml @@ -0,0 +1,7 @@ +import QtQuick + +Item { + function backPressed() { + return false + } +} diff --git a/EthernetPage.qml b/EthernetPage.qml new file mode 100644 index 0000000..c1d0af9 --- /dev/null +++ b/EthernetPage.qml @@ -0,0 +1,17 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Ethernet") + + GeneralOnOffSwitch { + apiKey: "ee" + } + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/FirmwarePage.qml b/FirmwarePage.qml new file mode 100644 index 0000000..33c924c --- /dev/null +++ b/FirmwarePage.qml @@ -0,0 +1,247 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("Firmware") + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: gridLayout.implicitHeight + + color: "white" + radius: 5 + + GridLayout { + id: gridLayout + + anchors.fill: parent + columns: 2 + + Label { + text: qsTr("Running version:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "fwv" + id: runningVersion + } + + Label { + text: qsTr("Details:") + font.bold: true + } + + Text { + Layout.fillWidth: true + + ApiKeyValueHelper { + id: runningVersionDetails + deviceConnection: mainScreen.deviceConnection + apiKey: "apd" + } + + text: JSON.stringify(runningVersionDetails.value, null, 4) + } + + Label { + text: qsTr("Recommended version:") + font.bold: true + } + + ApiKeyValueItem { + id: recommendedVersion + apiKey: "onv" + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: columnLayout.implicitHeight + + color: "white" + radius: 5 + + ColumnLayout { + id: columnLayout + anchors.fill: parent + + RowLayout { + Layout.fillWidth: true + + Label { + text: qsTr("Update:") + } + + ComboBox { + id: firmwareSelector + + ApiKeyValueHelper { + id: availableUrls + deviceConnection: mainScreen.deviceConnection + apiKey: "ocu" + } + + Layout.fillWidth: true + model: availableUrls.value + + delegate: ItemDelegate { + text: modelData + background: Rectangle { + color: recommendedVersion.value == modelData ? "lightgreen" : modelData.includes(runningVersion.value) ? "lightyellow" : "white" + } + width: parent.width + } + } + + SendMessageHelper { + id: startFirmwareUpdate + deviceConnection: mainScreen.deviceConnection + } + + Button { + text: qsTr("Start") + + onClicked: startFirmwareUpdate.sendMessage({ + type: "otaCloud", + firmware: firmwareSelector.currentValue + }) + } + + BusyIndicator { + visible: startFirmwareUpdate.pending + } + + RequestStatusText { + request: startFirmwareUpdate + } + } + + RowLayout { + Layout.fillWidth: true + + SendMessageHelper { + id: abortFirmwareUpdate + deviceConnection: mainScreen.deviceConnection + } + + Button { + text: qsTr("Abort") + + onClicked: abortFirmwareUpdate.sendMessage({ + type: "abortFwUpdate" + }) + } + + BusyIndicator { + visible: abortFirmwareUpdate.pending + } + + RequestStatusText { + request: abortFirmwareUpdate + } + } + + RowLayout { + Layout.fillWidth: true + + SendMessageHelper { + id: switchAppPartition + deviceConnection: mainScreen.deviceConnection + } + + Button { + text: qsTr("Switch partition") + + onClicked: switchAppPartition.sendMessage({ + type: "switchAppPartition" + }) + } + + BusyIndicator { + visible: switchAppPartition.pending + } + + RequestStatusText { + request: switchAppPartition + } + } + + GridLayout { + Layout.fillWidth: true + columns: 2 + + Label { + text: qsTr("Update status:") + font.bold: true + } + + Text { + Layout.fillWidth: true + + ApiKeyValueHelper { + id: updateStatus + deviceConnection: mainScreen.deviceConnection + apiKey: "ocs" + } + + text: { + switch (updateStatus.value) + { + case 0: return 'Idle' + case 1: return 'Updating' + case 2: return 'Failed' + case 3: return 'Succeeded' + case 4: return 'NotReady' + case 5: return 'Verifying' + default: return updateStatus.value + } + } + } + + Label { + text: qsTr("Update progress:") + font.bold: true + } + + ColumnLayout { + Text { + Layout.fillWidth: true + + ApiKeyValueHelper { + id: updateProgress + deviceConnection: mainScreen.deviceConnection + apiKey: "ocp" + } + + ApiKeyValueHelper { + id: updateLength + deviceConnection: mainScreen.deviceConnection + apiKey: "ocl" + } + + text: updateProgress.value + ' / ' + updateLength.value + } + + ProgressBar { + from: 0 + to: updateLength.value ? updateLength.value : 0 + value: updateProgress.value + } + } + + Label { + text: qsTr("Update message:") + font.bold: true + } + + ApiKeyValueItem { + apiKey: "ocm" + } + } + } + } +} diff --git a/FlexibleEnergyTariffPage.qml b/FlexibleEnergyTariffPage.qml new file mode 100644 index 0000000..401c1c0 --- /dev/null +++ b/FlexibleEnergyTariffPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Flexible energy tariff") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/GeneralOnOffSwitch.qml b/GeneralOnOffSwitch.qml new file mode 100644 index 0000000..cecc956 --- /dev/null +++ b/GeneralOnOffSwitch.qml @@ -0,0 +1,50 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +CheckDelegate { + id: checkDelegate + + required property string apiKey + property alias exists: valueHelper.exists + + Layout.fillWidth: true + + Component.onCompleted: { + background.color = "white" + background.radius = 5 + } + + ApiKeyValueHelper { + id: valueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: checkDelegate.apiKey + } + + SendMessageHelper { + id: valueChanger + deviceConnection: mainScreen.deviceConnection + onResponseChanged: checkDelegate.checked = Qt.binding(function(){ return valueHelper.value; }) + } + + checked: valueHelper.value + text: valueHelper.value ? qsTr("On") : qsTr("Off") + wrapMode: Text.Wrap + + onClicked: { + valueChanger.sendMessage({ + type: "setValue", + key: checkDelegate.apiKey, + value: checked + }) + } + + BusyIndicator { + visible: valueChanger.pending + } + + RequestStatusText { + request: valueChanger + } +} diff --git a/GeneralPage.qml b/GeneralPage.qml new file mode 100644 index 0000000..cb48029 --- /dev/null +++ b/GeneralPage.qml @@ -0,0 +1,51 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("General") + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Name") + component: "NamePage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Switch Language") + component: "SwitchLanguagePage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Notifications") + component: "NotificationsPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Date and time") + component: "DateAndTimePage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("LED") + component: "LedPage.qml" + visible: ledApiKeyValueHelper.exists + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Controller") + component: "ControllerPage.qml" + visible: controllerApiKeyValueHelper.exists + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Display settings") + component: "DisplaySettingsPage.qml" + visible: displayApiKeyValueHelper.exists + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Reboot") + component: "RebootPage.qml" + } +} diff --git a/GridPage.qml b/GridPage.qml new file mode 100644 index 0000000..17e140a --- /dev/null +++ b/GridPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Grid") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/GroundCheckPage.qml b/GroundCheckPage.qml new file mode 100644 index 0000000..d40ffc0 --- /dev/null +++ b/GroundCheckPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Ground check") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/HardwareInformationPage.qml b/HardwareInformationPage.qml new file mode 100644 index 0000000..0877a50 --- /dev/null +++ b/HardwareInformationPage.qml @@ -0,0 +1,58 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("Hardware information") + + Rectangle { + color: "white" + radius: 5 + Layout.fillWidth: true + Layout.preferredHeight: layout.implicitHeight + + GridLayout { + id: layout + columns: 2 + anchors.fill: parent + + Label { + text: qsTr("Serial Number:") + } + + ApiKeyValueItem { + Layout.fillWidth: true + apiKey: "sse" + } + + Label { + visible: variant.exists + text: qsTr("Variant:") + } + + ApiKeyValueItem { + id: variant + visible: exists + Layout.fillWidth: true + apiKey: "var" + } + + Label { + visible: rssi.exists + text: qsTr("RSSI:") + } + + ApiKeyValueItem { + id: rssi + visible: exists + Layout.fillWidth: true + apiKey: "rssi" + } + } + } + + Item { + Layout.fillHeight: true + } +} diff --git a/HotspotPage.qml b/HotspotPage.qml new file mode 100644 index 0000000..1956827 --- /dev/null +++ b/HotspotPage.qml @@ -0,0 +1,22 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Hotspot") + + GeneralOnOffSwitch { + apiKey: "wae" + } + + GeneralOnOffSwitch { + apiKey: "wda" + text: qsTr("Disable AP when online") + } + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/InformationsTabPage.qml b/InformationsTabPage.qml new file mode 100644 index 0000000..d6da9fb --- /dev/null +++ b/InformationsTabPage.qml @@ -0,0 +1,7 @@ +import QtQuick + +Item { + function backPressed() { + return false + } +} diff --git a/KwhLimitPage.qml b/KwhLimitPage.qml new file mode 100644 index 0000000..c00260f --- /dev/null +++ b/KwhLimitPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("kWh Limit") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/LedPage.qml b/LedPage.qml new file mode 100644 index 0000000..a3c5a69 --- /dev/null +++ b/LedPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("LED") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/LicensesPage.qml b/LicensesPage.qml new file mode 100644 index 0000000..e7298fb --- /dev/null +++ b/LicensesPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Licenses") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/LoadBalancingPage.qml b/LoadBalancingPage.qml new file mode 100644 index 0000000..eb40aed --- /dev/null +++ b/LoadBalancingPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Load Balancing") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/MainScreen.qml b/MainScreen.qml new file mode 100644 index 0000000..d70e30e --- /dev/null +++ b/MainScreen.qml @@ -0,0 +1,211 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +ColumnLayout { + id: mainScreen + + required property DeviceConnection deviceConnection + + function backPressed() { + if (stackLayout.currentItem.item.backPressed()) + return true + if (stackLayout.currentIndex != 0) { + stackLayout.currentIndex = 0 + stackLayout.currentIndex = Qt.binding(() => tabBar.currentIndex) + return true + } + loader.close() + return true + } + + ApiKeyValueHelper { + id: rebootTime + deviceConnection: mainScreen.deviceConnection + apiKey: "rbt" + } + + function formatDuration(duration) { + duration = Math.floor(duration) + + const wasNegative = duration < 0; + if (wasNegative) + duration = -duration; + + const milliseconds = duration%1000; + duration-=milliseconds; + duration/=1000; + + const seconds = duration%60; + duration-=seconds; + duration/=60; + + const minutes = duration%60; + duration-=minutes; + duration/=60; + + const hours = duration%24; + + return (wasNegative ? qsTr('%0 ago') : qsTr('in %0')) + .arg( + (hours < 10 ? '0' : '') + hours + ':' + + (minutes < 10 ? '0' : '') + minutes + ':' + + (seconds < 10 ? '0' : '') + seconds + '.' + + (milliseconds < 100 ? '0' : '') + (milliseconds < 10 ? '0' : '') + milliseconds) + ; + } + + ApiKeyValueHelper { + id: carApiKeyHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "car" + } + + ApiKeyValueHelper { + id: controllerApiKeyHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "ccp" + } + + ListModel { + id: modelBoth + + ListElement { + text: qsTr("Charger") + icon: "icons/ChargerV4.svg" + source: "ChargerTabPage.qml" + } + ListElement { + text: qsTr("Eco") + icon: "icons/EcoModeFilled.svg" + source: "EcoTabPage.qml" + } + ListElement { + text: qsTr("Controller") + icon: "icons/Controller.svg" + source: "ControllerTabPage.qml" + } + ListElement { + text: qsTr("Infromations") + icon: "icons/Charts.svg" + source: "InformationsTabPage.qml" + } + ListElement { + text: qsTr("Settings") + icon: "material-icons/settings.svg" + source: "SettingsTabPage.qml" + } + } + + ListModel { + id: modelCharger + + ListElement { + text: qsTr("Charger") + icon: "icons/ChargerV4.svg" + source: "ChargerTabPage.qml" + } + ListElement { + text: qsTr("Eco") + icon: "icons/EcoModeFilled.svg" + source: "EcoTabPage.qml" + } + ListElement { + text: qsTr("Infromations") + icon: "icons/Charts.svg" + source: "InformationsTabPage.qml" + } + ListElement { + text: qsTr("Settings") + icon: "material-icons/settings.svg" + source: "SettingsTabPage.qml" + } + } + + ListModel { + id: modelController + + ListElement { + text: qsTr("Controller") + icon: "icons/Controller.svg" + source: "ControllerTabPage.qml" + } + ListElement { + text: qsTr("Infromations") + icon: "icons/Charts.svg" + source: "InformationsTabPage.qml" + } + ListElement { + text: qsTr("Settings") + icon: "material-icons/settings.svg" + source: "SettingsTabPage.qml" + } + } + + ListModel { + id: modelNone + + ListElement { + text: qsTr("Infromations") + icon: "icons/Charts.svg" + source: "InformationsTabPage.qml" + } + ListElement { + text: qsTr("Settings") + icon: "material-icons/settings.svg" + source: "SettingsTabPage.qml" + } + } + + property ListModel theModel: { + if (carApiKeyHelper.exists) { + if (controllerApiKeyHelper.exists) { + return modelBoth + } else { + return modelCharger + } + } else { + if (controllerApiKeyHelper.exists) { + return modelController + } else { + return modelNone + } + } + } + + StackLayout { + id: stackLayout + Layout.fillWidth: true + Layout.fillHeight: true + + currentIndex: tabBar.currentIndex + property Item currentItem: children[currentIndex] + + Repeater { + model: theModel + + delegate: Loader { + source: model.source + } + } + } + + TabBar { + id: tabBar + Layout.fillWidth: true + + currentIndex: stackLayout.currentIndex + contentHeight: 56 + + Repeater { + model: theModel + + delegate: VerticalTabButton { + text: model.text + //width: tabBar.width / tabBar.count + icon.source: model.icon + } + } + } +} diff --git a/NamePage.qml b/NamePage.qml new file mode 100644 index 0000000..7a320fc --- /dev/null +++ b/NamePage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Name") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/NavigationItem.qml b/NavigationItem.qml new file mode 100644 index 0000000..f09c9ce --- /dev/null +++ b/NavigationItem.qml @@ -0,0 +1,75 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +ItemDelegate { + id: navigationItem + + property alias iconSource: icon.source + property alias title: titleText.text + property string description + property string component + + Layout.fillWidth: true + //width: parent.width + + implicitWidth: row.implicitWidth + implicitHeight: Math.max(row.implicitHeight, 50) + + // color: "white" + // radius: 5 + + // MouseArea { + // anchors.fill: parent + + // onClicked: stackView.push(navigationItem.component) + // } + + // background: Rectangle { + // color: "white" + // radius: 5 + // } + + Component.onCompleted: { + background.radius = 5 + background.color = 'white' + } + + onClicked: stackView.push(navigationItem.component) + + RowLayout { + id: row + + anchors.fill: parent + + Image { + id: icon + Layout.preferredWidth: 32 + Layout.preferredHeight: 32 + } + + ColumnLayout { + Layout.fillWidth: true + + Text { + id: titleText + + Layout.fillWidth: true + + wrapMode: Text.Wrap + font.bold: true + } + + Text { + Layout.fillWidth: true + wrapMode: Text.Wrap + text: navigationItem.description + visible: text != "" + } + } + + Text { + text: ">" + } + } +} diff --git a/NavigationPage.qml b/NavigationPage.qml new file mode 100644 index 0000000..9a9daf4 --- /dev/null +++ b/NavigationPage.qml @@ -0,0 +1,21 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +BaseNavigationPage { + default property alias data: columnLayout.data + + Flickable { + Layout.fillWidth: true + Layout.fillHeight: true + contentHeight: columnLayout.height + clip: true + + ColumnLayout { + id: columnLayout + width: parent.width - 10 + x: 5 + spacing: 5 + } + } +} diff --git a/NotificationsPage.qml b/NotificationsPage.qml new file mode 100644 index 0000000..1fb25a4 --- /dev/null +++ b/NotificationsPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Notifications") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/OcppPage.qml b/OcppPage.qml new file mode 100644 index 0000000..0a68a1f --- /dev/null +++ b/OcppPage.qml @@ -0,0 +1,17 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("OCPP") + + GeneralOnOffSwitch { + apiKey: "ocppe" + } + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/PasswordPage.qml b/PasswordPage.qml new file mode 100644 index 0000000..458d8f5 --- /dev/null +++ b/PasswordPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Password") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/PvSurplusPage.qml b/PvSurplusPage.qml new file mode 100644 index 0000000..223523c --- /dev/null +++ b/PvSurplusPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("PV Surplus") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecc3f15 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# evcharger-app + +This app aims to be functional first and pretty second. + +The app that is shipped with the products was made in opposite order and has a few problems now. + +In contrast to the official go-e app this app has: +* Actual support to connect over cloud to the device +* Actual support to connect locally to the device +* No silly "device type does not match" +* No silly assumptions on what feature set the device has, it looks on the existance of api keys +* It shows the problems that the device is reporting like wifi error log, ocpp error log, cloud error log +* It shows any network communication problems like invalid websocket response received +* API Key Browser +* Support for all future go-e compatible products as it does not hardcode any device types (Sadly it also works with Wattpilots) +* If you enter the incorrect password it will tell you and lock up +* You can also select 06:00 in the scheduler +* You can also select any color you like in all color pickers + +Lots of settings pages are still missing but this is a side project after work time and doesnt get much attention from Daniel's side. + +I just need an app that just works when I stand in fron of my charger, I can't continue using solalaweb for all basic settings every day. + +go-e Support is welcome to ship this app to customers that are facing WiFI, OCPP or any other issues. diff --git a/RebootPage.qml b/RebootPage.qml new file mode 100644 index 0000000..404ea7d --- /dev/null +++ b/RebootPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Reboot") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/RequestStatusText.qml b/RequestStatusText.qml new file mode 100644 index 0000000..2db4326 --- /dev/null +++ b/RequestStatusText.qml @@ -0,0 +1,24 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +Text { + required property SendMessageHelper request + + wrapMode: Text.Wrap + + visible: !request.pending && request.response != undefined + + text: { + if (request.response == undefined) + return "" + if (request.response.type == "response") { + if (request.response.success) + return "OK" + if ('message' in request.response) + return request.response.message + } + return JSON.stringify(request.response) + } +} diff --git a/SchedulerPage.qml b/SchedulerPage.qml new file mode 100644 index 0000000..47d2815 --- /dev/null +++ b/SchedulerPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Scheduler") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/SecurityPage.qml b/SecurityPage.qml new file mode 100644 index 0000000..260731f --- /dev/null +++ b/SecurityPage.qml @@ -0,0 +1,33 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Security") + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Cable") + component: "CablePage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Access") + component: "AccessPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Password") + component: "PasswordPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Grid") + component: "GridPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Ground check") + component: "GroundCheckPage.qml" + } +} diff --git a/SensorsConfigurationPage.qml b/SensorsConfigurationPage.qml new file mode 100644 index 0000000..3fe250b --- /dev/null +++ b/SensorsConfigurationPage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Sensors Configuration") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/SettingsTabPage.qml b/SettingsTabPage.qml new file mode 100644 index 0000000..a1d675e --- /dev/null +++ b/SettingsTabPage.qml @@ -0,0 +1,137 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +StackView { + id: stackView + + function backPressed() { + if (depth > 1) { + pop() + return true + } + + return false + } + + initialItem: NavigationPage { + title: qsTr("Settings") + + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Charging configuration") + description: [ + qsTr("Charging Speed"), + qsTr("kWh Limit"), + qsTr("Daily Trip"), + qsTr("Flexible energy tariff"), + qsTr("PV Surplus") + ].join(" • ") + component: "ChargingConfigurationPage.qml" + visible: carApiKeyHelper.exists + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Security") + description: [ + qsTr("Cable"), + qsTr("Access"), + qsTr("Password"), + qsTr("Grid"), + qsTr("Ground check") + ].join(" • ") + component: "SecurityPage.qml" + visible: carApiKeyHelper.exists + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Sensors Configuration") + description: [ + qsTr("Sensors"), + qsTr("Categories") + ].join(" • ") + component: "SensorsConfigurationPage.qml" + visible: controllerApiKeyHelper.exists + } + NavigationItem { + ApiKeyValueHelper { + id: wifiStaApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "wen" + } + ApiKeyValueHelper { + id: wifiApApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "wae" + } + ApiKeyValueHelper { + id: ethernetApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "ee" + } + ApiKeyValueHelper { + id: ocppApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "ocppe" + } + ApiKeyValueHelper { + id: cloudApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "cwe" + } + + iconSource: "material-icons/grid_guides.svg" + title: qsTr("Connection") + description: [ + wifiStaApiKeyValueHelper.exists ? qsTr("Wi-Fi") : null, + wifiApApiKeyValueHelper.exists ? qsTr("Hotspot") : null, + ethernetApiKeyValueHelper.exists ? qsTr("Ethernet") : null, + ocppApiKeyValueHelper.exists ? qsTr("OCPP") : null, + cloudApiKeyValueHelper.exists ? qsTr("Cloud") : null, + qsTr("API Settings") + ].filter(Boolean).join(" • ") + component: "ConnectionPage.qml" + } + NavigationItem { + ApiKeyValueHelper { + id: ledApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "lbr" + } + ApiKeyValueHelper { + id: controllerApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "ccd" + } + ApiKeyValueHelper { + id: displayApiKeyValueHelper + deviceConnection: mainScreen.deviceConnection + apiKey: "ccd" + } + + iconSource: "material-icons/grid_guides.svg" + title: qsTr("General") + description: [ + qsTr("Name"), + qsTr("Switch Language"), + qsTr("Notifications"), + qsTr("Date and time"), + ledApiKeyValueHelper.exists ? qsTr("LED") : null, + controllerApiKeyValueHelper.exists ? qsTr("Controller") : null, + displayApiKeyValueHelper.exists ? qsTr("Display settings") : null + ].filter(Boolean).join(" • ") + component: "GeneralPage.qml" + } + NavigationItem { + iconSource: "material-icons/grid_guides.svg" + title: qsTr("About") + description: [ + qsTr("Firmware"), + qsTr("Hardware information"), + qsTr("Licenses") + ].join(" • ") + component: "AboutPage.qml" + } + } +} diff --git a/SwitchLanguagePage.qml b/SwitchLanguagePage.qml new file mode 100644 index 0000000..7dfa5d6 --- /dev/null +++ b/SwitchLanguagePage.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +NavigationPage { + title: qsTr("Switch Language") + + Text { + text: "TODO" + + Layout.fillHeight: true + } +} diff --git a/VerticalTabButton.qml b/VerticalTabButton.qml new file mode 100644 index 0000000..4056eca --- /dev/null +++ b/VerticalTabButton.qml @@ -0,0 +1,42 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +TabButton { + id: control + + contentItem: Item { + anchors.fill: parent + ColumnLayout { + anchors.fill: parent + //anchors.horizontalCenter: parent.horizontalCenter + spacing: 5 + Text { + Layout.fillWidth: true + text: control.text + font: control.font + horizontalAlignment: Text.AlignHCenter + } + RowLayout { + Layout.fillHeight: true + + Item { + Layout.fillWidth: true + } + + Image { + source: control.icon.source + + fillMode: Image.PreserveAspectFit + + width: 32 + height: 32 + } + + Item { + Layout.fillWidth: true + } + } + } + } +} diff --git a/WiFiErrorsPage.qml b/WiFiErrorsPage.qml new file mode 100644 index 0000000..5569e26 --- /dev/null +++ b/WiFiErrorsPage.qml @@ -0,0 +1,86 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +BaseNavigationPage { + property ApiKeyValueHelper wifiErrorLog + + title: qsTr("Wi-Fi Errors") + + // Item { + // id: theAboveList + // // component exists only to complete the example supplied in the question + // } + + ListView { + id: listView + Layout.fillWidth: true + Layout.fillHeight: trues + + model: wifiErrorLog.value + spacing: 5 + clip: true + verticalLayoutDirection: ListView.BottomToTop + + // anchors + // { + // left: parent.left + // top: theAboveList.bottom + // right: parent.right + // bottom: parent.bottom + // } + // header: Item {} + // onContentHeightChanged: { + // if (contentHeight < height) { + // headerItem.height += (height - contentHeight) + // } + // currentIndex = count-1 + // positionViewAtEnd() + // } + + delegate: Rectangle { + required property var modelData + property var theModelData: modelData + + color: "white" + radius: 5 + + width: listView.width + height: gridLayout.implicitHeight + + property var properties: [ qsTr("Timestamp:"), qsTr("SSID:"), qsTr("BSSID:"), qsTr("Reason:") ] + + GridLayout { + id: gridLayout + anchors.fill: parent + + Repeater { + model: properties + + Text { + required property int index + required property string modelData + + text: modelData + Layout.row: index + Layout.column: 0 + } + } + Repeater { + model: properties + + Text { + Layout.fillWidth: true + + required property int index + + text: index == 0 ? formatDuration((theModelData[index] > 5.184e+9 ? theModelData[index] / 1000 : theModelData[index]) - rebootTime.value) : theModelData[index] + Layout.row: index + Layout.column: 1 + } + } + } + } + } +} diff --git a/WiFiOnOffSwitch.qml b/WiFiOnOffSwitch.qml new file mode 100644 index 0000000..1c32b30 --- /dev/null +++ b/WiFiOnOffSwitch.qml @@ -0,0 +1,78 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +CheckDelegate { + id: checkDelegate + + Layout.fillWidth: true + + Component.onCompleted: { + background.color = "white" + background.radius = 5 + } + + ApiKeyValueHelper { + id: staEnabled + deviceConnection: mainScreen.deviceConnection + apiKey: "wen" + } + + SendMessageHelper { + id: staEnabledChanger + deviceConnection: mainScreen.deviceConnection + } + + checked: staEnabled.value + text: staEnabled.value ? qsTr("On") : qsTr("Off") + + onClicked: { + if (checked) + staEnabledChanger.sendMessage({ + type: "setValue", + key: "wen", + value: checked + }) + else { + checked = true + disableStaDialog.open() + } + } + + BusyIndicator { + visible: staEnabledChanger.pending + } + + RequestStatusText { + request: staEnabledChanger + } + + Dialog { + id: disableStaDialog + + x: window.width / 2 - width / 2 + y: window.height / 2 - height / 2 + width: Math.min(implicitWidth, window.width - 20) + + title: qsTr("Do you really want to disable Wi-Fi?") + standardButtons: Dialog.Ok | Dialog.Cancel + focus: true + modal: true + + onAccepted: { + checkDelegate.checked = false + staEnabledChanger.sendMessage({ + type: "setValue", + key: "wen", + value: false + }) + } + onRejected: checkDelegate.checked = Qt.binding(function() { return staEnabled.value }) + + contentItem: Text { + text: qsTr("This action could make your device unreachable from your local homenetwork or the cloud!"); + wrapMode: Text.Wrap + } + } +} diff --git a/WiFiPage.qml b/WiFiPage.qml new file mode 100644 index 0000000..e3aab35 --- /dev/null +++ b/WiFiPage.qml @@ -0,0 +1,64 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import EVChargerApp + +NavigationPage { + title: qsTr("Wi-Fi") + + WiFiOnOffSwitch { + + } + + GridLayout { + columns: 2 + + Label { + text: qsTr("Status:") + } + + Text { + Layout.fillWidth: true + + ApiKeyValueHelper { + id: staStatus + deviceConnection: mainScreen.deviceConnection + apiKey: "wst" + } + + text: { + switch (staStatus.value) + { + case 0: return 'IDLE_STATUS' + case 1: return 'NO_SSID_AVAIL' + case 2: return 'SCAN_COMPLETED' + case 3: return 'CONNECTED' + case 4: return 'CONNECT_FAILED' + case 5: return 'CONNECTION_LOST' + case 6: return 'DISCONNECTED' + case 7: return 'CONNECTING' + case 8: return 'DISCONNECTING' + case 9: return 'NO_SHIELD' + case 10: return 'WAITING_FOR_IP' + default: return staStatus.value + } + } + } + } + + Button { + ApiKeyValueHelper { + id: wifiErrorLog + deviceConnection: mainScreen.deviceConnection + apiKey: "wsl" + } + + text: qsTr("(%0) Wi-Fi Errors").arg(wifiErrorLog.value == null ? 0 : wifiErrorLog.value.length) + onClicked: stackView.push("WiFiErrorsPage.qml", {wifiErrorLog} ) + enabled: wifiErrorLog.value != null + } + + Item { + Layout.fillHeight: true + } +} diff --git a/android/AndroidManifest.xml b/android/AndroidManifest.xml new file mode 100644 index 0000000..f28c7cc --- /dev/null +++ b/android/AndroidManifest.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..4a93923 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,84 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:8.4.0' + } +} + +repositories { + google() + mavenCentral() +} + +apply plugin: qtGradlePluginType + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation 'androidx.core:core:1.13.1' +} + +android { + /******************************************************* + * The following variables: + * - androidBuildToolsVersion, + * - androidCompileSdkVersion + * - qtAndroidDir - holds the path to qt android files + * needed to build any Qt application + * on Android. + * - qtGradlePluginType - whether to build an app or a library + * + * are defined in gradle.properties file. This file is + * updated by QtCreator and androiddeployqt tools. + * Changing them manually might break the compilation! + *******************************************************/ + + namespace androidPackageName + compileSdkVersion androidCompileSdkVersion + buildToolsVersion androidBuildToolsVersion + ndkVersion androidNdkVersion + + // Extract native libraries from the APK + packagingOptions.jniLibs.useLegacyPackaging true + + sourceSets { + main { + manifest.srcFile 'AndroidManifest.xml' + java.srcDirs = [qtAndroidDir + '/src', 'src', 'java'] + aidl.srcDirs = [qtAndroidDir + '/src', 'src', 'aidl'] + res.srcDirs = [qtAndroidDir + '/res', 'res'] + resources.srcDirs = ['resources'] + renderscript.srcDirs = ['src'] + assets.srcDirs = ['assets'] + jniLibs.srcDirs = ['libs'] + } + } + + tasks.withType(JavaCompile) { + options.incremental = true + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + abortOnError false + } + + // Do not compress Qt binary resources file + aaptOptions { + noCompress 'rcc' + } + + defaultConfig { + resConfig "en" + minSdkVersion qtMinSdkVersion + targetSdkVersion qtTargetSdkVersion + ndk.abiFilters = qtTargetAbiList.split(",") + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..4fe1674 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,18 @@ +# Project-wide Gradle settings. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2500m -XX:MaxMetaspaceSize=768m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 + +# Enable building projects in parallel +org.gradle.parallel=true + +# Gradle caching allows reusing the build artifacts from a previous +# build with the same inputs. However, over time, the cache size will +# grow. Uncomment the following line to enable it. +#org.gradle.caching=true +#org.gradle.configuration-cache=true + +# Allow AndroidX usage +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7f93135 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..c22a517 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..3624bce --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/res/drawable-hdpi/icon.png b/android/res/drawable-hdpi/icon.png new file mode 100644 index 0000000..b3edd23 Binary files /dev/null and b/android/res/drawable-hdpi/icon.png differ diff --git a/android/res/drawable-hdpi/logo.png b/android/res/drawable-hdpi/logo.png new file mode 100644 index 0000000..d975027 Binary files /dev/null and b/android/res/drawable-hdpi/logo.png differ diff --git a/android/res/drawable-hdpi/logo_land.png b/android/res/drawable-hdpi/logo_land.png new file mode 100644 index 0000000..63b5a32 Binary files /dev/null and b/android/res/drawable-hdpi/logo_land.png differ diff --git a/android/res/drawable-hdpi/logo_port.png b/android/res/drawable-hdpi/logo_port.png new file mode 100644 index 0000000..d975027 Binary files /dev/null and b/android/res/drawable-hdpi/logo_port.png differ diff --git a/android/res/drawable-ldpi/icon.png b/android/res/drawable-ldpi/icon.png new file mode 100644 index 0000000..495d467 Binary files /dev/null and b/android/res/drawable-ldpi/icon.png differ diff --git a/android/res/drawable-ldpi/logo.png b/android/res/drawable-ldpi/logo.png new file mode 100644 index 0000000..86d06bc Binary files /dev/null and b/android/res/drawable-ldpi/logo.png differ diff --git a/android/res/drawable-ldpi/logo_land.png b/android/res/drawable-ldpi/logo_land.png new file mode 100644 index 0000000..c17c1ee Binary files /dev/null and b/android/res/drawable-ldpi/logo_land.png differ diff --git a/android/res/drawable-ldpi/logo_port.png b/android/res/drawable-ldpi/logo_port.png new file mode 100644 index 0000000..86d06bc Binary files /dev/null and b/android/res/drawable-ldpi/logo_port.png differ diff --git a/android/res/drawable-mdpi/icon.png b/android/res/drawable-mdpi/icon.png new file mode 100644 index 0000000..74fbe19 Binary files /dev/null and b/android/res/drawable-mdpi/icon.png differ diff --git a/android/res/drawable-mdpi/logo.png b/android/res/drawable-mdpi/logo.png new file mode 100644 index 0000000..e84e63d Binary files /dev/null and b/android/res/drawable-mdpi/logo.png differ diff --git a/android/res/drawable-mdpi/logo_land.png b/android/res/drawable-mdpi/logo_land.png new file mode 100644 index 0000000..2a0dd25 Binary files /dev/null and b/android/res/drawable-mdpi/logo_land.png differ diff --git a/android/res/drawable-mdpi/logo_port.png b/android/res/drawable-mdpi/logo_port.png new file mode 100644 index 0000000..e84e63d Binary files /dev/null and b/android/res/drawable-mdpi/logo_port.png differ diff --git a/android/res/drawable-xhdpi/icon.png b/android/res/drawable-xhdpi/icon.png new file mode 100644 index 0000000..08ff127 Binary files /dev/null and b/android/res/drawable-xhdpi/icon.png differ diff --git a/android/res/drawable-xhdpi/logo.png b/android/res/drawable-xhdpi/logo.png new file mode 100644 index 0000000..ef7084c Binary files /dev/null and b/android/res/drawable-xhdpi/logo.png differ diff --git a/android/res/drawable-xhdpi/logo_land.png b/android/res/drawable-xhdpi/logo_land.png new file mode 100644 index 0000000..0094994 Binary files /dev/null and b/android/res/drawable-xhdpi/logo_land.png differ diff --git a/android/res/drawable-xhdpi/logo_port.png b/android/res/drawable-xhdpi/logo_port.png new file mode 100644 index 0000000..ef7084c Binary files /dev/null and b/android/res/drawable-xhdpi/logo_port.png differ diff --git a/android/res/drawable-xxhdpi/icon.png b/android/res/drawable-xxhdpi/icon.png new file mode 100644 index 0000000..1bde2cc Binary files /dev/null and b/android/res/drawable-xxhdpi/icon.png differ diff --git a/android/res/drawable-xxhdpi/logo.png b/android/res/drawable-xxhdpi/logo.png new file mode 100644 index 0000000..92f68c4 Binary files /dev/null and b/android/res/drawable-xxhdpi/logo.png differ diff --git a/android/res/drawable-xxhdpi/logo_land.png b/android/res/drawable-xxhdpi/logo_land.png new file mode 100644 index 0000000..d4575b0 Binary files /dev/null and b/android/res/drawable-xxhdpi/logo_land.png differ diff --git a/android/res/drawable-xxhdpi/logo_port.png b/android/res/drawable-xxhdpi/logo_port.png new file mode 100644 index 0000000..92f68c4 Binary files /dev/null and b/android/res/drawable-xxhdpi/logo_port.png differ diff --git a/android/res/drawable-xxxhdpi/icon.png b/android/res/drawable-xxxhdpi/icon.png new file mode 100644 index 0000000..d12bb4f Binary files /dev/null and b/android/res/drawable-xxxhdpi/icon.png differ diff --git a/android/res/drawable-xxxhdpi/logo.png b/android/res/drawable-xxxhdpi/logo.png new file mode 100644 index 0000000..022fe20 Binary files /dev/null and b/android/res/drawable-xxxhdpi/logo.png differ diff --git a/android/res/drawable-xxxhdpi/logo_land.png b/android/res/drawable-xxxhdpi/logo_land.png new file mode 100644 index 0000000..507fe18 Binary files /dev/null and b/android/res/drawable-xxxhdpi/logo_land.png differ diff --git a/android/res/drawable-xxxhdpi/logo_port.png b/android/res/drawable-xxxhdpi/logo_port.png new file mode 100644 index 0000000..022fe20 Binary files /dev/null and b/android/res/drawable-xxxhdpi/logo_port.png differ diff --git a/android/res/drawable/splashscreen.xml b/android/res/drawable/splashscreen.xml new file mode 100644 index 0000000..22571a8 --- /dev/null +++ b/android/res/drawable/splashscreen.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android/res/drawable/splashscreen_land.xml b/android/res/drawable/splashscreen_land.xml new file mode 100644 index 0000000..4111621 --- /dev/null +++ b/android/res/drawable/splashscreen_land.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android/res/drawable/splashscreen_port.xml b/android/res/drawable/splashscreen_port.xml new file mode 100644 index 0000000..92b71de --- /dev/null +++ b/android/res/drawable/splashscreen_port.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/android/res/values-land/splashscreentheme.xml b/android/res/values-land/splashscreentheme.xml new file mode 100644 index 0000000..800b923 --- /dev/null +++ b/android/res/values-land/splashscreentheme.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/res/values-port/splashscreentheme.xml b/android/res/values-port/splashscreentheme.xml new file mode 100644 index 0000000..a20302e --- /dev/null +++ b/android/res/values-port/splashscreentheme.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/res/values/libs.xml b/android/res/values/libs.xml new file mode 100644 index 0000000..fe63866 --- /dev/null +++ b/android/res/values/libs.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/res/values/splashscreentheme.xml b/android/res/values/splashscreentheme.xml new file mode 100644 index 0000000..53b3673 --- /dev/null +++ b/android/res/values/splashscreentheme.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/res/xml/qtprovider_paths.xml b/android/res/xml/qtprovider_paths.xml new file mode 100644 index 0000000..ae5b4b6 --- /dev/null +++ b/android/res/xml/qtprovider_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/src/QZeroConfNsdManager.java b/android/src/QZeroConfNsdManager.java new file mode 120000 index 0000000..337cc26 --- /dev/null +++ b/android/src/QZeroConfNsdManager.java @@ -0,0 +1 @@ +../../3rdparty/QtZeroConf/QZeroConfNsdManager.java \ No newline at end of file diff --git a/apikeyexistancehelper.cpp b/apikeyexistancehelper.cpp new file mode 100644 index 0000000..d63e012 --- /dev/null +++ b/apikeyexistancehelper.cpp @@ -0,0 +1,53 @@ +#include "apikeyexistancehelper.h" + +void ApiKeyExistanceHelper::setDeviceConnection(DeviceConnection *deviceConnection) +{ + if (m_deviceConnection == deviceConnection) + return; + + if (m_deviceConnection) + { + disconnect(m_deviceConnection, &DeviceConnection::fullStatusReceived, + this, &ApiKeyExistanceHelper::fullStatusReceived); + } + + emit deviceConnectionChanged(m_deviceConnection = deviceConnection); + + if (m_deviceConnection) + { + connect(m_deviceConnection, &DeviceConnection::fullStatusReceived, + this, &ApiKeyExistanceHelper::fullStatusReceived); + + fullStatusReceived(); + } + else if (m_exists) + emit existsChanged(m_exists = false); +} + +void ApiKeyExistanceHelper::setApiKey(const QString &apiKey) +{ + if (m_apiKey == apiKey) + return; + + emit apiKeyChanged(m_apiKey = apiKey); + + if (m_deviceConnection) + fullStatusReceived(); + else if (m_exists) + emit existsChanged(m_exists = false); +} + +void ApiKeyExistanceHelper::fullStatusReceived() +{ + if (!m_deviceConnection) + { + if (m_exists) + emit existsChanged(m_exists = false); + return; + } + + const auto exists = m_deviceConnection->getFullStatus().contains(m_apiKey); + + if (exists != m_exists) + emit existsChanged(m_exists = exists); +} diff --git a/apikeyexistancehelper.h b/apikeyexistancehelper.h new file mode 100644 index 0000000..9fbf6a2 --- /dev/null +++ b/apikeyexistancehelper.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include + +#include "deviceconnection.h" + +class ApiKeyExistanceHelper : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(DeviceConnection* deviceConnection READ deviceConnection WRITE setDeviceConnection NOTIFY deviceConnectionChanged FINAL) + Q_PROPERTY(QString apiKey READ apiKey WRITE setApiKey NOTIFY apiKeyChanged FINAL) + Q_PROPERTY(bool exists READ exists NOTIFY existsChanged STORED false FINAL) + +public: + DeviceConnection *deviceConnection() { return m_deviceConnection; } + const DeviceConnection *deviceConnection() const { return m_deviceConnection; } + void setDeviceConnection(DeviceConnection *deviceConnection); + + const QString &apiKey() const { return m_apiKey; } + void setApiKey(const QString &apiKey); + + bool exists() { return m_exists; } + +private slots: + void fullStatusReceived(); + +signals: + void deviceConnectionChanged(DeviceConnection *deviceConnection); + void apiKeyChanged(const QString &apiKey); + void existsChanged(bool exists); + +private: + DeviceConnection *m_deviceConnection{}; + QString m_apiKey; + bool m_exists{}; +}; diff --git a/apikeyvaluehelper.cpp b/apikeyvaluehelper.cpp new file mode 100644 index 0000000..32eea05 --- /dev/null +++ b/apikeyvaluehelper.cpp @@ -0,0 +1,79 @@ +#include "apikeyvaluehelper.h" + +void ApiKeyValueHelper::setDeviceConnection(DeviceConnection *deviceConnection) +{ + if (m_deviceConnection == deviceConnection) + return; + + if (m_deviceConnection) + { + disconnect(m_deviceConnection, &DeviceConnection::fullStatusReceived, + this, &ApiKeyValueHelper::fullStatusReceived); + disconnect(m_deviceConnection, &DeviceConnection::valueChanged, + this, &ApiKeyValueHelper::valueChangedSlot); + } + + emit deviceConnectionChanged(m_deviceConnection = deviceConnection); + + if (m_deviceConnection) + { + connect(m_deviceConnection, &DeviceConnection::fullStatusReceived, + this, &ApiKeyValueHelper::fullStatusReceived); + connect(m_deviceConnection, &DeviceConnection::valueChanged, + this, &ApiKeyValueHelper::valueChangedSlot); + } + + fullStatusReceived(); +} + +void ApiKeyValueHelper::setApiKey(const QString &apiKey) +{ + if (m_apiKey == apiKey) + return; + + emit apiKeyChanged(m_apiKey = apiKey); + + fullStatusReceived(); +} + +void ApiKeyValueHelper::fullStatusReceived() +{ + if (!m_deviceConnection) + { + if (m_value.isValid()) + emit valueChanged(m_value = {}); + if (m_exists) + emit existsChanged(m_exists = false); + return; + } + + const auto &fullStatus = m_deviceConnection->getFullStatus(); + const auto iter = fullStatus.find(m_apiKey); + + QVariant value; + bool exists{}; + + if (iter != std::cend(fullStatus)) + { + value = *iter; + exists = true; + } + + if (m_value != value) + emit valueChanged(m_value = value); + + if (m_exists != exists) + emit existsChanged(m_exists = exists); +} + +void ApiKeyValueHelper::valueChangedSlot(const QString &key, const QVariant &value) +{ + if (m_apiKey != key) + return; + + if (m_value != value) + emit valueChanged(m_value = value); + + if (!m_exists) + emit existsChanged(m_exists = true); +} diff --git a/apikeyvaluehelper.h b/apikeyvaluehelper.h new file mode 100644 index 0000000..2c70bff --- /dev/null +++ b/apikeyvaluehelper.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +#include "deviceconnection.h" + +class ApiKeyValueHelper : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(DeviceConnection* deviceConnection READ deviceConnection WRITE setDeviceConnection NOTIFY deviceConnectionChanged FINAL) + Q_PROPERTY(QString apiKey READ apiKey WRITE setApiKey NOTIFY apiKeyChanged FINAL) + Q_PROPERTY(QVariant value READ value NOTIFY valueChanged STORED false FINAL) + Q_PROPERTY(bool exists READ exists NOTIFY existsChanged STORED false FINAL) + +public: + DeviceConnection *deviceConnection() { return m_deviceConnection; } + const DeviceConnection *deviceConnection() const { return m_deviceConnection; } + void setDeviceConnection(DeviceConnection *deviceConnection); + + const QString &apiKey() const { return m_apiKey; } + void setApiKey(const QString &apiKey); + + const QVariant &value() { return m_value; } + bool exists() const { return m_exists; } + +private slots: + void fullStatusReceived(); + void valueChangedSlot(const QString &key, const QVariant &value); + +signals: + void deviceConnectionChanged(DeviceConnection *deviceConnection); + void apiKeyChanged(const QString &apiKey); + void valueChanged(const QVariant &variant); + void existsChanged(bool exists); + +private: + DeviceConnection *m_deviceConnection{}; + QString m_apiKey; + QVariant m_value; + bool m_exists{}; +}; diff --git a/appsettings.cpp b/appsettings.cpp new file mode 100644 index 0000000..323acf6 --- /dev/null +++ b/appsettings.cpp @@ -0,0 +1,92 @@ +#include "appsettings.h" + +#include + +std::vector AppSettings::getSavedDevices() +{ + std::vector savedDevices; + + int size = beginReadArray("devices"); + for (int i = 0; i < size; ++i) + { + setArrayIndex(i); + savedDevices.emplace_back(SavedDevice { + .serial = value("serial").toString(), + .manufacturer = value("manufacturer").toString(), + .deviceType = value("deviceType").toString(), + .friendlyName = value("friendlyName").toString(), + .password = value("password").toString(), + }); + } + endArray(); + + return savedDevices; +} + +void AppSettings::saveSavedDevices(const std::vector &savedDevices) +{ + beginWriteArray("devices"); + for (qsizetype i = 0; i < savedDevices.size(); ++i) { + setArrayIndex(i); + setValue("serial", savedDevices.at(i).serial); + setValue("manufacturer", savedDevices.at(i).manufacturer); + setValue("deviceType", savedDevices.at(i).deviceType); + setValue("friendlyName", savedDevices.at(i).friendlyName); + setValue("password", savedDevices.at(i).password); + } + endArray(); +} + +void AppSettings::refreshSavedDevice(SavedDevice &&savedDevice) +{ + auto savedDevices = getSavedDevices(); + + if (auto iter = std::find_if(std::begin(savedDevices), std::end(savedDevices), + [&serial=savedDevice.serial](const SavedDevice &savedDevice){ return savedDevice.serial == serial; }); + iter != std::end(savedDevices)) + { + if (savedDevice.password.isEmpty()) + savedDevice.password = std::move(iter->password); + savedDevices.erase(iter); + } + + savedDevices.emplace(std::begin(savedDevices), std::move(savedDevice)); + + saveSavedDevices(savedDevices); +} + +void AppSettings::removeSavedDevice(const QString &serial) +{ + auto savedDevices = getSavedDevices(); + + if (auto iter = std::find_if(std::begin(savedDevices), std::end(savedDevices), + [&serial](const SavedDevice &savedDevice){ return savedDevice.serial == serial; }); + iter != std::end(savedDevices)) + { + savedDevices.erase(iter); + } + + saveSavedDevices(savedDevices); +} + +int AppSettings::numberOfAppInstances() const +{ + if (!m_numberOfAppInstances) + { + bool ok{}; + int numberOfAppInstances = value("numberOfAppInstances", 1).toInt(&ok); + if (!ok) + numberOfAppInstances = 1; + m_numberOfAppInstances = numberOfAppInstances; + return numberOfAppInstances; + } + + return *m_numberOfAppInstances; +} + +void AppSettings::setNumberOfAppInstances(int numberOfAppInstances) +{ + setValue("numberOfAppInstances", numberOfAppInstances); + m_numberOfAppInstances = numberOfAppInstances; + emit numberOfAppInstancesChanged(numberOfAppInstances); +} diff --git a/appsettings.h b/appsettings.h new file mode 100644 index 0000000..e7e22d8 --- /dev/null +++ b/appsettings.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include + +struct SavedDevice +{ + QString serial; + QString manufacturer; + QString deviceType; + QString friendlyName; + QString password; +}; + +class AppSettings : public QSettings +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(int numberOfAppInstances READ numberOfAppInstances WRITE setNumberOfAppInstances NOTIFY numberOfAppInstancesChanged FINAL) + +public: + std::vector getSavedDevices(); + void saveSavedDevices(const std::vector &savedDevices); + void refreshSavedDevice(SavedDevice &&savedDevice); + void removeSavedDevice(const QString &serial); + + int numberOfAppInstances() const; + void setNumberOfAppInstances(int numberOfAppInstances); + +signals: + void numberOfAppInstancesChanged(int numberOfAppInstances); + +private: + mutable std::optional m_numberOfAppInstances; +}; diff --git a/deviceconnection.cpp b/deviceconnection.cpp new file mode 100644 index 0000000..f8c0520 --- /dev/null +++ b/deviceconnection.cpp @@ -0,0 +1,543 @@ +#include "deviceconnection.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "msgpack.h" + +namespace { +template +auto enumToString(const QEnum value) +{ + return QMetaEnum::fromType().valueToKey(std::to_underlying(value)); +} +} + +DeviceConnection::DeviceConnection(QObject *parent) : + QObject{parent} +{ + connect(&m_websocket, &QWebSocket::connected, + this, &DeviceConnection::connected); + connect(&m_websocket, &QWebSocket::disconnected, + this, &DeviceConnection::disconnected); + connect(&m_websocket, &QWebSocket::stateChanged, + this, &DeviceConnection::stateChanged); + connect(&m_websocket, &QWebSocket::textMessageReceived, + this, &DeviceConnection::textMessageReceived); + connect(&m_websocket, &QWebSocket::binaryMessageReceived, + this, &DeviceConnection::binaryMessageReceived); + connect(&m_websocket, &QWebSocket::errorOccurred, + this, &DeviceConnection::errorOccurred); + + connect(&m_websocket, &QWebSocket::peerVerifyError, + this, &DeviceConnection::peerVerifyError); + connect(&m_websocket, &QWebSocket::sslErrors, + this, &DeviceConnection::sslErrors); + // connect(&m_websocket, &QWebSocket::preSharedKeyAuthenticationRequired, + // this, &DeviceConnection::preSharedKeyAuthenticationRequired); + connect(&m_websocket, &QWebSocket::alertSent, + this, &DeviceConnection::alertSent); + connect(&m_websocket, &QWebSocket::alertReceived, + this, &DeviceConnection::alertReceived); + connect(&m_websocket, &QWebSocket::handshakeInterruptedOnError, + this, &DeviceConnection::handshakeInterruptedOnError); +} + +void DeviceConnection::setUrl(const QString &url) +{ + if (m_url == url) + return; + + emit urlChanged(m_url = url); + + emit logMessage(tr("Connecting to %0").arg(m_url)); + m_websocket.open(QUrl{m_url}); +} + +void DeviceConnection::setPassword(const QString &password) +{ + if (m_password == password) + return; + + emit passwordChanged(m_password = password); +} + +void DeviceConnection::messageReceived(const QVariant &variant) +{ + if (variant.type() != QMetaType::QVariantMap) + { + emit logMessage(tr("Received something that is not a json object!")); + return; + } + + const auto &map = variant.toMap(); + auto typeIter = map.find("type"); + if (typeIter == std::cend(map)) + { + emit logMessage(tr("Received something that does not contain a type!")); + return; + } + + const auto &typeVariant = *typeIter; + if (typeVariant.type() != QMetaType::QString) + { + emit logMessage(tr("Received something with a non-string type!")); + return; + } + + const auto &type = typeVariant.toString(); + bool omitLog{}; + + if (type == "hello") + { + { + auto iter = map.find("serial"); + if (iter == std::cend(map)) + { + emit logMessage(tr("Received hello without serial!")); + return; + } + + const auto &serialVariant = *iter; + if (serialVariant.type() != QMetaType::QString) + { + emit logMessage(tr("Received hello with a non-string serial!")); + return; + } + + m_serial = serialVariant.toString(); + } + + if (auto iter = map.find("secured"); iter != std::cend(map)) + { + const auto &securedVariant = *iter; + if (securedVariant.type() != QMetaType::Bool) + { + emit logMessage(tr("Received hello with a non-bool secured!")); + return; + } + + m_secured = securedVariant.toBool(); + } + else + m_secured = false; + + { + auto manufacturerIter = map.find("manufacturer"); + if (manufacturerIter == std::cend(map)) + { + emit logMessage(tr("Received hello without manufacturer!")); + return; + } + + const auto &manufacturerVariant = *manufacturerIter; + if (manufacturerVariant.type() != QMetaType::QString) + { + emit logMessage(tr("Received hello with a non-string manufacturer!")); + return; + } + + m_manufacturer = manufacturerVariant.toString(); + } + + { + auto devicetypeIter = map.find("devicetype"); + if (devicetypeIter == std::cend(map)) + { + emit logMessage(tr("Received hello without devicetype!")); + return; + } + + const auto &deviceTypeVariant = *devicetypeIter; + if (deviceTypeVariant.type() != QMetaType::QString) + { + emit logMessage(tr("Received hello with a non-string devicetype!")); + return; + } + + m_deviceType = deviceTypeVariant.toString(); + } + + { + auto friendlyNameIter = map.find("friendly_name"); + if (friendlyNameIter == std::cend(map)) + { + emit logMessage(tr("Received hello without friendly_name!")); + return; + } + + const auto &friendlyNameVariant = *friendlyNameIter; + if (friendlyNameVariant.type() != QMetaType::QString) + { + emit logMessage(tr("Received hello with a non-string friendly_name!")); + return; + } + + m_friendlyName = friendlyNameVariant.toString(); + } + + if (m_settings) + m_settings->refreshSavedDevice(SavedDevice { + .serial = m_serial, + .manufacturer = m_manufacturer, + .deviceType = m_deviceType, + .friendlyName = m_friendlyName + }); + } + else if (type == "authRequired") + { + { + auto iter = map.find("token1"); + if (iter == std::cend(map)) + { + emit logMessage(tr("Received authRequired without token1!")); + return; + } + + const auto &token1Variant = *iter; + if (token1Variant.type() != QMetaType::QString) + { + emit logMessage(tr("Received authRequired with a non-string token1!")); + return; + } + + m_token1 = token1Variant.toString(); + } + + { + auto iter = map.find("token2"); + if (iter == std::cend(map)) + { + emit logMessage(tr("Received authRequired without token2!")); + return; + } + + const auto &token2Variant = *iter; + if (token2Variant.type() != QMetaType::QString) + { + emit logMessage(tr("Received authRequired with a non-string token2!")); + return; + } + + m_token2 = token2Variant.toString(); + } + + if (m_password.isEmpty()) + emit authRequired(); + else + sendAuthInternal(); + } + else if (type == "fullStatus") + { + omitLog = true; + + bool partial{}; + + if (auto iter = map.find("partial"); iter != std::cend(map)) + { + const auto &partialVariant = *iter; + if (partialVariant.type() != QMetaType::Bool) + { + emit logMessage(tr("Received fullStatus with a non-bool partial!")); + return; + } + + partial = partialVariant.toBool(); + } + + auto iter = map.find("status"); + if (iter == std::cend(map)) + { + emit logMessage(tr("Received fullStatus without a status!")); + return; + } + + const auto &statusVariant = *iter; + if (statusVariant.type() != QMetaType::QVariantMap) + { + emit logMessage(tr("Received fullStatus with a non-object status!")); + return; + } + + const auto &status = statusVariant.toMap(); + for (auto iter = std::cbegin(status); iter != std::cend(status); iter++) + m_fullStatus[iter.key()] = iter.value(); + + emit logMessage(partial ? tr("Received partial fullStatus") : tr("Received last fullStatus")); + + if (!partial) + emit fullStatusReceived(); + } + else if (type == "deltaStatus") + { + omitLog = true; + + auto iter = map.find("status"); + if (iter == std::cend(map)) + { + emit logMessage(tr("Received deltaStatus without a status!")); + return; + } + + const auto &statusVariant = *iter; + if (statusVariant.type() != QMetaType::QVariantMap) + { + emit logMessage(tr("Received deltaStatus with a non-object status!")); + return; + } + + const auto &status = statusVariant.toMap(); + for (auto iter = std::cbegin(status); iter != std::cend(status); iter++) + { + m_fullStatus[iter.key()] = iter.value(); + emit valueChanged(iter.key(), iter.value()); + if (iter.key() == "fna") + { + const auto &friendlyNameVariant = iter.value(); + if (friendlyNameVariant.type() == QMetaType::QString) + { + const auto &friendlyName = friendlyNameVariant.toString(); + if (m_friendlyName != friendlyName) + { + m_friendlyName = friendlyName; + if (m_settings) + m_settings->refreshSavedDevice(SavedDevice { + .serial = m_serial, + .manufacturer = m_manufacturer, + .deviceType = m_deviceType, + .friendlyName = m_friendlyName, + .password = m_password + }); + } + } + } + } + } + + if (!omitLog) + { + emit logMessage(tr("Received message type %0 %1").arg(type).arg(QJsonDocument::fromVariant(variant).toJson())); + } + + if (auto iter = map.find("requestId"); iter != std::cend(map)) + { + const auto &requestIdVariant = *iter; + if (requestIdVariant.type() != QMetaType::QString) + { + emit logMessage(tr("Received message with a non-string requestId!")); + return; + } + + const auto &requestId = requestIdVariant.toString(); + + emit responseReceived(requestId, map); + + if (const auto callbackIter = m_callbacks.find(requestId); callbackIter != std::end(m_callbacks)) + { + if (*callbackIter) + (*callbackIter)(type, map); + m_callbacks.erase(callbackIter); + } + } +} + +void DeviceConnection::sendMessage(QVariantMap variant, callback_t &&callback) +{ + if (callback) + { + auto requestId = generateRequestId(); + variant["requestId"] = requestId; + m_callbacks[requestId] = std::move(callback); + } + + auto message = QJsonDocument::fromVariant(variant).toJson(QJsonDocument::Compact); + + if (const auto iter = variant.find("type"); m_secured && (iter == std::cend(variant) || *iter != "auth")) + { + if (m_password.isEmpty()) + { + qCritical() << "would have needed secure wrapper but we dont have a password!"; + } + else + { + QString requestId; + if (const auto iter = variant.find("requestId"); iter != std::cend(variant)) + requestId = iter->toString(); + + variant = QVariantMap { + { "type", "securedMsg" }, + { "data", message }, + { "hmac", QMessageAuthenticationCode::hash(message, m_password.toUtf8(), QCryptographicHash::Sha256).toHex() } + }; + if (!requestId.isEmpty()) + variant["requestId"] = requestId; + message = QJsonDocument::fromVariant(variant).toJson(QJsonDocument::Compact); + } + } + + //TODO verify that we actually sent the message + m_websocket.sendTextMessage(message); + emit logMessage(tr("Sending %0").arg(message)); +} + +QString DeviceConnection::generateRequestId() +{ + return QString("request-%0").arg(m_requestId++); +} + +void DeviceConnection::sendMessage(const QVariantMap &variant) +{ + sendMessage(variant, {}); +} + +void DeviceConnection::sendAuth(const QString &password) +{ + const QString data = password; + const QString salt = m_serial; + constexpr auto iterations = 100000; + constexpr auto keylen = 64; + + m_password = QPasswordDigestor::deriveKeyPbkdf2(QCryptographicHash::Sha512, data.toUtf8(), salt.toUtf8(), iterations, keylen).toBase64(); + + if (m_password.size() > 32) + m_password.resize(32); + + if (m_websocket.state() != QAbstractSocket::ConnectedState) + return; + + sendAuthInternal(); +} + +void DeviceConnection::connected() +{ + emit logMessage(tr("Connected!")); +} + +void DeviceConnection::disconnected() +{ + emit logMessage(tr("Disconnected!")); + emit logMessage(tr("Reconnecting to %0").arg(m_url)); + m_websocket.open(QUrl{m_url}); +} + +void DeviceConnection::stateChanged(QAbstractSocket::SocketState state) +{ + emit logMessage(tr("state changed: %0").arg(enumToString(state))); +} + +void DeviceConnection::textMessageReceived(const QString &message) +{ + QJsonParseError error; + QJsonDocument document = QJsonDocument::fromJson(message.toUtf8(), &error); + if (error.error != QJsonParseError::NoError) + { + emit logMessage(tr("could not parse received json: %0 at offset %1").arg(error.errorString()).arg(error.offset)); + return; + } + + messageReceived(document.toVariant()); +} + +void DeviceConnection::binaryMessageReceived(QByteArray message) +{ + const auto type = message.at(0); + switch (type) + { + case 0: // Type msgpack + message.removeFirst(); + messageReceived(MsgPack::unpack(message)); + break; + default: + emit logMessage(tr("unknown binary message type %0 received").arg(int(type))); + break; + } +} + +void DeviceConnection::errorOccurred(QAbstractSocket::SocketError error) +{ + emit logMessage(tr("error occured: %0").arg(enumToString(error))); +} + +void DeviceConnection::peerVerifyError(const QSslError &error) +{ + emit logMessage(tr("ssl peer verify error")); +} + +void DeviceConnection::sslErrors(const QList &errors) +{ + emit logMessage(tr("ssl errors")); +} + +// void DeviceConnection::preSharedKeyAuthenticationRequired(QSslPreSharedKeyAuthenticator *authenticator) +// { + +// } + +void DeviceConnection::alertSent(QSsl::AlertLevel level, QSsl::AlertType type, const QString &description) +{ + emit logMessage(tr("ssl alert sent level=%0 type=%1 description=%2").arg(enumToString(level)).arg(enumToString(type)).arg(description)); +} + +void DeviceConnection::alertReceived(QSsl::AlertLevel level, QSsl::AlertType type, const QString &description) +{ + emit logMessage(tr("ssl alert received level=%0 type=%1 description=%2").arg(enumToString(level)).arg(enumToString(type)).arg(description)); +} + +void DeviceConnection::handshakeInterruptedOnError(const QSslError &error) +{ + emit logMessage(tr("ssl handshake interrupted on error")); +} + +void DeviceConnection::sendAuthInternal() +{ + auto hash2 = QCryptographicHash::hash(m_token1.toUtf8() + m_password.toUtf8(), QCryptographicHash::Sha256).toHex().toLower(); + + const QString token3 = "hatschi"; + + auto hash3 = QCryptographicHash::hash(token3.toUtf8() + m_token2.toUtf8() + hash2, QCryptographicHash::Sha256).toHex().toLower(); + + sendMessage({ + { "type", "auth" }, + { "token3", token3 }, + { "hash", hash3 }, + { "requestId", "auth-request" } + }, [this](const QString &type, const QVariantMap &message){ + authResponse(type, message); + }); +} + +void DeviceConnection::authResponse(const QString &type, const QVariantMap &message) +{ + if (type == "authError") + { + m_password.clear(); + emit authError(message.value("message").toString()); + } + else if (type == "authImpossible") + { + emit authImpossible(); + } + else if (type == "authSuccess") + { + if (m_settings) + m_settings->refreshSavedDevice(SavedDevice { + .serial = m_serial, + .manufacturer = m_manufacturer, + .deviceType = m_deviceType, + .friendlyName = m_friendlyName, + .password = m_password + }); + + emit authSuccess(); + } + else + { + emit authError(tr("unknown response type %0").arg(type)); + } +} diff --git a/deviceconnection.h b/deviceconnection.h new file mode 100644 index 0000000..e6c7d3c --- /dev/null +++ b/deviceconnection.h @@ -0,0 +1,105 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "appsettings.h" + +class DeviceConnection : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(AppSettings* settings READ settings WRITE setSettings NOTIFY settingsChanged FINAL) + Q_PROPERTY(QString url READ url WRITE setUrl NOTIFY urlChanged FINAL) + Q_PROPERTY(QString password READ password WRITE setPassword NOTIFY passwordChanged FINAL) + + using callback_t = std::function; + +public: + explicit DeviceConnection(QObject *parent = nullptr); + + AppSettings *settings() { return m_settings; } + const AppSettings *settings() const { return m_settings; } + void setSettings(AppSettings *settings) { if (m_settings == settings) return; emit settingsChanged(m_settings = settings); } + + QString url() const { return m_url; } + void setUrl(const QString &url); + + QString password() const { return m_password; } + void setPassword(const QString &password); + + void messageReceived(const QVariant &variant); + + void sendMessage(QVariantMap variant, callback_t &&callback); + + const QVariantMap &getFullStatus() { return m_fullStatus; } + + QString generateRequestId(); + +public slots: + void sendMessage(const QVariantMap &variant); + void sendAuth(const QString &password); + +signals: + void settingsChanged(AppSettings *settings); + void urlChanged(const QString &url); + void passwordChanged(const QString &password); + + void logMessage(const QString &message); + + void responseReceived(const QString &requestId, const QVariantMap &message); + + void authRequired(); + void authError(const QString &message); + void authImpossible(); + void authSuccess(); + + void fullStatusReceived(); + void valueChanged(const QString &key, const QVariant &value); + +private slots: + void connected(); + void disconnected(); + void stateChanged(QAbstractSocket::SocketState state); + void textMessageReceived(const QString &message); + void binaryMessageReceived(QByteArray message); + void errorOccurred(QAbstractSocket::SocketError error); + + void peerVerifyError(const QSslError &error); + void sslErrors(const QList &errors); + // void preSharedKeyAuthenticationRequired(QSslPreSharedKeyAuthenticator *authenticator); + void alertSent(QSsl::AlertLevel level, QSsl::AlertType type, const QString &description); + void alertReceived(QSsl::AlertLevel level, QSsl::AlertType type, const QString &description); + void handshakeInterruptedOnError(const QSslError &error); + +private: + void sendAuthInternal(); + void authResponse(const QString &type, const QVariantMap &message); + + AppSettings *m_settings{}; + + QString m_url; + + QWebSocket m_websocket; + + int m_requestId{}; + + QString m_serial; + bool m_secured{}; + QString m_manufacturer; + QString m_deviceType; + QString m_friendlyName; + + QString m_token1; + QString m_token2; + + QString m_password; + + QHash m_callbacks; + + QVariantMap m_fullStatus; +}; diff --git a/devicesmodel.cpp b/devicesmodel.cpp new file mode 100644 index 0000000..7222bf9 --- /dev/null +++ b/devicesmodel.cpp @@ -0,0 +1,292 @@ +#include "devicesmodel.h" + +enum { + SerialRole = Qt::UserRole, + ManufacturerRole, + DeviceTypeRole, + FriendlyNameRole, + PasswordRole, + SavedRole, + HostNameRole, + IpRole +}; + +DevicesModel::DevicesModel(QObject *parent) : + QAbstractListModel{parent} +{ + connect(&m_zeroConf, &QZeroConf::error, this, &DevicesModel::error); + connect(&m_zeroConf, &QZeroConf::serviceAdded, this, &DevicesModel::serviceAdded); + connect(&m_zeroConf, &QZeroConf::serviceUpdated, this, &DevicesModel::serviceUpdated); + connect(&m_zeroConf, &QZeroConf::serviceRemoved, this, &DevicesModel::serviceRemoved); +} + +void DevicesModel::start() +{ + qDebug() << "start() called"; + m_zeroConf.startBrowser("_http._tcp"); +} + +void DevicesModel::setSettings(AppSettings *settings) +{ + if (m_settings == settings) + return; + + m_settings = settings; + + beginResetModel(); + + if (m_settings) + for (auto &device : m_settings->getSavedDevices()) + m_foundDevices.emplace_back(FoundDevice{std::move(device), true}); + + endResetModel(); +} + +int DevicesModel::rowCount(const QModelIndex &parent) const +{ + return m_foundDevices.size(); +} + +QVariant DevicesModel::data(const QModelIndex &index, int role) const +{ + if (index.row() < 0 || index.row() >= m_foundDevices.size()) + { + qWarning() << "invalid row" << index.row(); + return {}; + } + + const auto &foundDevice = m_foundDevices.at(index.row()); + + switch (role) + { + case Qt::DisplayRole: return tr("Device %0").arg(foundDevice.serial); + case SerialRole: return foundDevice.serial; + case ManufacturerRole: return foundDevice.manufacturer; + case DeviceTypeRole: return foundDevice.deviceType; + case FriendlyNameRole: return foundDevice.friendlyName; + case PasswordRole: return foundDevice.password; + case SavedRole: return foundDevice.saved; + case HostNameRole: return foundDevice.hostName; + case IpRole: return foundDevice.ip.toString(); + } + + return {}; +} + +QMap DevicesModel::itemData(const QModelIndex &index) const +{ + if (index.row() < 0 || index.row() >= m_foundDevices.size()) + { + qWarning() << "invalid row" << index.row(); + return {}; + } + + const auto &foundDevice = m_foundDevices.at(index.row()); + + return { + { Qt::DisplayRole, tr("Device %0").arg(foundDevice.serial) }, + { SerialRole,foundDevice.serial }, + { ManufacturerRole, foundDevice.manufacturer }, + { DeviceTypeRole, foundDevice.deviceType }, + { FriendlyNameRole, foundDevice.friendlyName }, + { PasswordRole, foundDevice.password }, + { SavedRole, foundDevice.saved }, + { HostNameRole, foundDevice.hostName }, + { IpRole, foundDevice.ip.toString() } + }; +} + +QHash DevicesModel::roleNames() const +{ + return { + { Qt::DisplayRole, "name" }, + { SerialRole, "serial" }, + { ManufacturerRole, "manufacturer" }, + { DeviceTypeRole, "deviceType" }, + { FriendlyNameRole, "friendlyName" }, + { PasswordRole, "password" }, + { SavedRole, "saved" }, + { HostNameRole, "hostName" }, + { IpRole, "ip" } + }; +} + +bool DevicesModel::removeRows(int row, int count, const QModelIndex &parent) +{ + if (row < 0 || row >= m_foundDevices.size()) + { + qWarning() << "invalid row" << row; + return false; + } + + if (count != 1) + { + qWarning() << "only count=1 is supported!" << count; + return false; + } + + auto iter = std::next(std::begin(m_foundDevices), row); + + if (!iter->saved) + { + qWarning() << "row is not saved" << row << iter->serial; + return false; + } + + if (m_settings) + m_settings->removeSavedDevice(iter->serial); + + if (iter->ip.isNull()) + { + beginRemoveRows({}, row, row); + m_foundDevices.erase(iter); + endRemoveRows(); + } + else + { + FoundDevice device = std::move(*iter); + device.saved = false; + device.password.clear(); + + beginRemoveRows({}, row, row); + m_foundDevices.erase(iter); + endRemoveRows(); + + beginInsertRows({}, m_foundDevices.size(), m_foundDevices.size()); + m_foundDevices.emplace_back(std::move(device)); + endInsertRows(); + } + + return true; +} + +void DevicesModel::error(QZeroConf::error_t error) +{ + qDebug() << "error()" << error; +} + +void DevicesModel::serviceAdded(QZeroConfService service) +{ + qDebug() << "serviceAdded()" << service->host(); + + auto txt = service->txt(); + + auto serialIter = txt.find("serial"); + if (serialIter == txt.end()) + { + qWarning() << "serial missing" << txt; + return; + } + auto serial = *serialIter; + + auto manufacturerIter = txt.find("manufacturer"); + if (manufacturerIter == txt.end()) + { + qWarning() << "manufacturer missing" << txt; + return; + } + auto manufacturer = *manufacturerIter; + + auto deviceTypeIter = txt.find("devicetype"); + if (deviceTypeIter == txt.end()) + { + qWarning() << "devicetype missing" << txt; + return; + } + auto deviceType = *deviceTypeIter; + + auto friendlyNameIter = txt.find("friendly_name"); + if (friendlyNameIter == txt.end()) + { + qWarning() << "friendly_name missing" << txt; + return; + } + auto friendlyName = *friendlyNameIter; + + const auto iter = std::find_if(std::begin(m_foundDevices), std::end(m_foundDevices), [&serial](const FoundDevice &foundDevice){ + return foundDevice.serial == serial; + }); + + if (iter == std::end(m_foundDevices)) + { + beginInsertRows({}, m_foundDevices.size(), m_foundDevices.size()); + m_foundDevices.emplace_back(FoundDevice { + /*.serial{ */ std::move(serial) /*}*/, + /*.manufacturer{ */ std::move(manufacturer) /*}*/, + /*.deviceType{ */ std::move(deviceType) /*}*/, + /*.friendlyName{ */ std::move(friendlyName) /*}*/, + /*.password{ */ {} /*}*/, + /*.saved{ */ false /*}*/, + /*.hostName{ */ service->host() /*}*/, + /*.ip{ */ service->ip() /*}*/ + }); + endInsertRows(); + } + else + { + iter->manufacturer = std::move(manufacturer); + iter->deviceType = std::move(deviceType); + iter->friendlyName = std::move(friendlyName); + iter->hostName = service->host(); + iter->ip = service->ip(); + + const auto index = createIndex(std::distance(std::begin(m_foundDevices), iter), 0); + emit dataChanged(index, index, { + ManufacturerRole, + DeviceTypeRole, + FriendlyNameRole, + HostNameRole, + IpRole + }); + } +} + +void DevicesModel::serviceUpdated(QZeroConfService service) +{ + qDebug() << "serviceUpdated()" << service->host(); + + // TODO +} + +void DevicesModel::serviceRemoved(QZeroConfService service) +{ + qDebug() << "serviceRemoved()" << service->host(); + + const auto &txt = service->txt(); + + auto serialIter = txt.find("serial"); + if (serialIter == txt.constEnd()) + { + qWarning() << "serial missing" << txt; + return; + } + auto serial = *serialIter; + + const auto iter = std::find_if(std::begin(m_foundDevices), std::end(m_foundDevices), [&serial](const FoundDevice &foundDevice){ + return foundDevice.serial == serial; + }); + + if (iter == std::end(m_foundDevices)) + { + qWarning() << "serial not found!" << serial; + return; + } + + if (!iter->saved) + { + const auto row = std::distance(std::begin(m_foundDevices), iter); + beginRemoveRows({}, row, row); + m_foundDevices.erase(iter); + endRemoveRows(); + } + else + { + iter->hostName.clear(); + iter->ip = {}; + const auto index = createIndex(std::distance(std::begin(m_foundDevices), iter), 0); + emit dataChanged(index, index, { + HostNameRole, + IpRole + }); + } +} diff --git a/devicesmodel.h b/devicesmodel.h new file mode 100644 index 0000000..2421dad --- /dev/null +++ b/devicesmodel.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include + +#include + +#include + +#include "appsettings.h" + +class DevicesModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(AppSettings* settings READ settings WRITE setSettings NOTIFY settingsChanged FINAL) + +public: + explicit DevicesModel(QObject *parent = nullptr); + Q_INVOKABLE void start(); + + AppSettings *settings() { return m_settings; } + const AppSettings *settings() const { return m_settings; } + void setSettings(AppSettings *settings); + + int rowCount(const QModelIndex &parent) const override; + QVariant data(const QModelIndex &index, int role) const override; + QMap itemData(const QModelIndex &index) const override; + QHash roleNames() const override; + + bool removeRows(int row, int count, const QModelIndex &parent) override; + +signals: + void settingsChanged(AppSettings *settings); + +private slots: + void error(QZeroConf::error_t error); + void serviceAdded(QZeroConfService service); + void serviceUpdated(QZeroConfService service); + void serviceRemoved(QZeroConfService service); + +private: + AppSettings *m_settings{}; + + struct FoundDevice : public SavedDevice + { + bool saved{}; + QString hostName; + QHostAddress ip; + }; + + std::vector m_foundDevices; + + QZeroConf m_zeroConf; +}; diff --git a/i18n/qml_de.ts b/i18n/qml_de.ts new file mode 100644 index 0000000..db0ad06 --- /dev/null +++ b/i18n/qml_de.ts @@ -0,0 +1,1707 @@ + + + + + AboutPage + + + + About + Über + + + + + Firmware + + + + + + Hardware information + Hardwareinformationen + + + + + Licenses + Lizenzen + + + + AccessPage + + + + Access + Zugangskontrolle + + + + AddDeviceScreen + + + + Setup or add device + Gerät hinzufügen oder einrichten + + + + + Add via local connection or AP: + Über lokale Verbindung oder AP einrichten: + + + + + Local url + Lokale url + + + + + + + Connect + Verbinden + + + + + Add via cloud: + Über Cloud einrichten: + + + + + Cloud Serial + Cloud Seriennr. + + + Back + Zurück + + + + ApiSettingsPage + + + + API Settings + API Einstellungen + + + Enable cloud connection + Cloud Verbindung aktivieren + + + Features like flexible energy tariffs, time sync and app connection are unavilable when "Enable cloud connection" is disabled + Wenn "Cloud Verbindung aktivieren" deaktiviert ist, sind Funktionen wie flexible Energietarife, Zeitsynchronisierung und App-Verbindung über das Internet nicht möglich. + + + + + Allow access to local HTTP-Api v2 + Zugriff auf die lokale HTTP-API v2 zulassen + + + + + + + + + + + API documentation + API-Dokumentation + + + + + Generate token + + + + + + OK! token: %0 + + + + + + Clear API token + + + + + + Enable cloud API + Cloud HTTP API aktivieren + + + + + + + API key: %0 + + + + + + Enable grid API + Netzbetreiber-API aktiviert + + + + + Allow access to legacy HTTP-Api v1 + Zugriff auf lokale HTTP-API v1 zulassen + + + + AppSettingsPage + + + + App Settings + + + + + + Number of app instances: + + + + + + solalaweb key: + + + + + + + + Select... + + + + + + solalaweb cert: + + + + + BaseNavigationPage + + + + Back + Zurück + + + + CablePage + + + + Cable + Kabel + + + + CarPage + + + + Car + Auto + + + + ChargerTabPage + + + + Devices + Geräte + + + + + No car connected + Kein Auto angeschlossen + + + + + Connect the cable to charge your car + Stecke das Kabel ein, um dein Auto aufzuladen + + + + + Start + + + + + + Eco + + + + + + Basic + + + + + + Daily trip + + + + + ChargingConfigurationPage + + + + Charging configuration + Konfiguration des Ladevorgangs + + + + + Charging Speed + Ladegeschwindigkeit + + + + + kWh Limit + + + + + + Daily Trip + + + + + + Flexible energy tariff + Flexibler Energietarif + + + + + PV Surplus + PV-Überschuss + + + + + Load Balancing + Lastmanagement + + + + + Scheduler + Ladetimer + + + + + Current Levels + Strompegel + + + + + Car + Auto + + + + ChargingSpeedPage + + + + Charging Speed + Ladegeschwindigkeit + + + + CloudPage + + + + Cloud + + + + + + Enable cloud connection + Cloud Verbindung aktivieren + + + + + Features like flexible energy tariffs, time sync and app connection are unavilable when "Enable cloud connection" is disabled + Wenn "Cloud Verbindung aktivieren" deaktiviert ist, sind Funktionen wie flexible Energietarife, Zeitsynchronisierung und App-Verbindung über das Internet nicht möglich. + + + + + Trying to connect: + + + + + + Is connected: + + + + + + Hello received: + + + + + + Queue size cloud: + + + + + + Last error: + + + + + CloudUrlsModel + + + + V2 + + + + + + Wattpilot + + + + + + go-eCharger + + + + + + go-eController + + + + + + Solalaweb + + + + + ConnectingScreen + + + + Trying to reach device... + Versuche Gerät zu erreichen... + + + + + Cancel + Abbrechen + + + + ConnectionPage + + + + Connection + Verbindung + + + + + Wi-Fi + WLAN + + + + + Hotspot + + + + + + Ethernet + + + + + + OCPP + + + + + + Cloud + + + + + + API Settings + API Einstellungen + + + + ControllerPage + + + + Controller + + + + + ControllerTabPage + + + + Devices + Geräte + + + + + Connected + Verbunden + + + + CurrentLevelsPage + + + + Current Levels + Strompegel + + + + DailyTripPage + + + + Daily Trip + + + + + DateAndTimePage + + + + Date and time + Datum und Uhrzeit + + + + DeviceConnection + + + Connecting to %0 + Verbinde zu %0 + + + + Received something that is not a json object! + + + + + Received something that does not contain a type! + + + + + Received something with a non-string type! + + + + + Received hello without serial! + + + + + Received hello with a non-string serial! + + + + + Received hello with a non-bool secured! + + + + + Received hello without manufacturer! + + + + + Received hello with a non-string manufacturer! + + + + + Received hello without friendly_name! + + + + + Received hello with a non-string friendly_name! + + + + + Received hello without devicetype! + + + + + Received hello with a non-string devicetype! + + + + + Received authRequired without token1! + + + + + Received authRequired with a non-string token1! + + + + + Received authRequired without token2! + + + + + Received authRequired with a non-string token2! + + + + + Received fullStatus with a non-bool partial! + + + + + Received fullStatus without a status! + + + + + Received fullStatus with a non-object status! + + + + + Received partial fullStatus + + + + + Received last fullStatus + + + + + Received deltaStatus without a status! + + + + + Received deltaStatus with a non-object status! + + + + + Received message type %0 %1 + + + + + Received message with a non-string requestId! + + + + + Sending %0 + + + + + Connected! + Verbunden! + + + + Disconnected! + Verbindung verloren! + + + + Reconnecting to %0 + Verbinde wieder zu %0 + + + + state changed: %0 + + + + + could not parse received json: %0 at offset %1 + + + + + unknown binary message type %0 received + + + + + error occured: %0 + + + + + ssl peer verify error + + + + + ssl errors + + + + + ssl alert sent level=%0 type=%1 description=%2 + + + + + ssl alert received level=%0 type=%1 description=%2 + + + + + ssl handshake interrupted on error + + + + + unknown response type %0 + + + + + DeviceListScreen + + Please select your go-e device + Bitte wählen Sie Ihr go-e Gerät + + + Local url + Lokale url + + + Connect + Verbinden + + + Cloud Serial + Cloud Seriennr. + + + + + Device list + Geräteliste + + + + + App Settings + + + + + + My devices + Meine Geräte + + + + + Found devices + Im Netzwerk verfügbare Geräte + + + + + Delete + Löschen + + + + + Serial Number %0 + Seriennummer %0 + + + + + Add or setup device + Gerät hinzufügen oder einrichten + + + Add device + Gerät hinzufügen + + + Locally found devices: + Lokal gefundene Geräte: + + + Serial: + Seriennr: + + + My devices: + Meine Geräte: + + + + + Manufacturer: + Hersteller: + + + + + Device Type: + Gerätetyp: + + + Friendly Name: + Anzeigename: + + + + + Host Name: + + + + + + Ip: + + + + + + Connect local + Lokal verbinden + + + + + Connect cloud + Über cloud verbinden + + + Settings + Einstellungen + + + + DeviceScreen + + + + Password required + Passwort erforderlich + + + + + Password: + Passwort: + + + + + Password + Passwort + + + + + Authentication impossible! + Authentifizierung unmöglich! + + + + + To use this password remotely a password has to be setup first. This can be done over the AccessPoint. + Um dieses Gerät aus der Ferne nutzen zu können, müssen Sie erst ein Passwort einrichten. Dies kann über den AccessPoint gemacht werden. + + + + DevicesModel + + + + Device %0 + Gerät %0 + + + + DisplaySettingsPage + + + + Display settings + Display + + + + EthernetPage + + + + Ethernet + + + + + FirmwarePage + + + + Firmware + + + + + + Update: + + + + + + Start + + + + + + Abort + Abbrechen + + + + + Switch partition + Partition tauschen + + + + + Update status: + + + + + + Update progress: + Update Fortschritt: + + + + + Update message: + Update Meldung: + + + + + Recommended version: + Empfohlene Version: + + + + + Running version: + Ausgeführte Version: + + + + + Details: + + + + + FlexibleEnergyTariffPage + + + + Flexible energy tariff + Flexibler Energietarif + + + + FoundDevicesModel + + Device %0 + Gerät %0 + + + + GeneralOnOffSwitch + + + + On + An + + + + + Off + Aus + + + + GeneralPage + + + + General + Allgemein + + + + + Name + + + + + + Switch Language + Sprache ändern + + + + + Notifications + Benachrichtigungen + + + + + Date and time + Datum und Uhrzeit + + + + + LED + + + + + + Controller + + + + + + Display settings + Display + + + + + Reboot + Neustart + + + + EVChargerApp + + + + go-e App + + + + + GridPage + + + + Grid + Netz + + + + GroundCheckPage + + + + Ground check + Erdungsprüfung + + + + HardwareInformationPage + + + + Hardware information + Hardwareinformationen + + + + + Serial Number: + Seriennummer: + + + + + Variant: + Variante: + + + + + RSSI: + + + + + HotspotPage + + + + Hotspot + + + + + + Disable AP when online + AP ausschalten wenn online + + + + KwhLimitPage + + + + kWh Limit + + + + + LedPage + + + + LED + + + + + LicensesPage + + + + Licenses + Lizenzen + + + + LoadBalancingPage + + + + Load Balancing + Lastmanagement + + + + MainScreen + + + + %0 ago + vor %0 + + + + + in %0 + + + + + + + + Charger + + + + + + + + Eco + + + + + + + + Controller + + + + + + + + + + + + Infromations + Informationen + + + + + + + + + + + Settings + Einstellungen + + + + NamePage + + + + Name + + + + + NavigationPage + + Back + Zurück + + + + NotificationsPage + + + + Notifications + Benachrichtigungen + + + + OcppPage + + + + OCPP + + + + + PasswordPage + + + + Password + Passwort + + + + PvSurplusPage + + + + PV Surplus + PV-Überschuss + + + + RebootPage + + + + Reboot + Neustart + + + + SavedDevicesModel + + Device %0 + Gerät %0 + + + + SchedulerPage + + + + Scheduler + Ladetimer + + + + SecurityPage + + + + Security + Sicherheit + + + + + Cable + Kabel + + + + + Access + Zugangskontrolle + + + + + Password + Passwort + + + + + Grid + Netz + + + + + Ground check + Erdungsprüfung + + + + SendMessageHelper + + + No device connection set! + + + + + SensorsConfigurationPage + + + + Sensors Configuration + Sensorkonfiguration + + + + SettingsTabPage + + + + Settings + Einstellungen + + + + + Charging configuration + Konfiguration des Ladevorgangs + + + + + Charging Speed + Ladegeschwindigkeit + + + + + kWh Limit + + + + + + Daily Trip + + + + + + Flexible energy tariff + Flexibler Energietarif + + + + + PV Surplus + PV-Überschuss + + + + + Cable + Kabel + + + + + Access + Zugangskontrolle + + + + + Password + Passwort + + + + + Grid + Netz + + + + + Ground check + Erdungsprüfung + + + + + Sensors + Sensoren + + + + + Categories + Kategorien + + + + + Wi-Fi + WLAN + + + + + Hotspot + + + + + + Ethernet + + + + + + OCPP + + + + + + Cloud + + + + + + API Settings + API Einstellungen + + + + + Name + + + + + + Switch Language + Sprache ändern + + + + + Notifications + Benachrichtigungen + + + + + Date and time + Datum und Uhrzeit + + + + + LED + + + + + + Controller + + + + + + Display settings + Display + + + + + Firmware + + + + + + Hardware information + Hardwareinformationen + + + + + Licenses + Lizenzen + + + Charging Speed • kWh Limit • Daily Trip • Flexible energy tariff • PV Surplus + Ladegeschwindigkeit • kWh Limit • Daily Trip • Flexibler Energietraif • PV Überschussladen + + + + + Security + Sicherheit + + + Cable • Access • Password • Grid • Ground check + Kabel • Zugangskontrolle • Passwort • Netz • Erdungsprüfung + + + + + Sensors Configuration + Sensorkonfiguration + + + Sensors • Categories + Sensoren • Kategorien + + + + + Connection + Verbindung + + + Wi-Fi • Hotspot • OCPP • API Settings + WLAN • Hotspot • OCPP • API Einstellungen + + + + + General + Allgemein + + + Name • Switch Language • Notifications • Date and time • LED • Controller + Name • Sprache ändern • Benachrichtigungen • Datum und Uhrzeit • LED • Controller + + + + + About + Über + + + Firmware • Hardware information • Licenses + Firmware • Hardwareinformationen • Lizenzen + + + + SwitchLanguagePage + + + + Switch Language + Sprache ändern + + + + WiFiErrorsPage + + + + Wi-Fi Errors + WLAN Probleme + + + + + Timestamp: + Zeitpunkt: + + + + + SSID: + + + + + + BSSID: + + + + + + Reason: + Grund: + + + + WiFiOnOffSwitch + + + + On + An + + + + + Off + Aus + + + + + Do you really want to disable Wi-Fi? + Möchten Sie wirklich WLAN ausschalten? + + + + + This action could make your device unreachable from your local homenetwork or the cloud! + Diese Aktion könnte das Gerät im Heimnetzwerk und aus der Cloud unerreichbar machen! + + + + WiFiPage + + + + Wi-Fi + WLAN + + + + + Status: + + + + + + (%0) Wi-Fi Errors + (%0) WLAN Probleme + + + diff --git a/i18n/qml_en.ts b/i18n/qml_en.ts new file mode 100644 index 0000000..6401616 --- /dev/null +++ b/i18n/qml_en.ts @@ -0,0 +1,4 @@ + + + + diff --git a/icons/Alarm.svg b/icons/Alarm.svg new file mode 100644 index 0000000..4f3f992 --- /dev/null +++ b/icons/Alarm.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/Charger.svg b/icons/Charger.svg new file mode 100644 index 0000000..57d8b1a --- /dev/null +++ b/icons/Charger.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/ChargerV3.svg b/icons/ChargerV3.svg new file mode 100644 index 0000000..f49c9a0 --- /dev/null +++ b/icons/ChargerV3.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/ChargerV4.svg b/icons/ChargerV4.svg new file mode 100644 index 0000000..927993a --- /dev/null +++ b/icons/ChargerV4.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/Charts.svg b/icons/Charts.svg new file mode 100644 index 0000000..24c5d38 --- /dev/null +++ b/icons/Charts.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/Controller.svg b/icons/Controller.svg new file mode 100644 index 0000000..958e811 --- /dev/null +++ b/icons/Controller.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/EcoModeFilled.svg b/icons/EcoModeFilled.svg new file mode 100644 index 0000000..8c03df3 --- /dev/null +++ b/icons/EcoModeFilled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/controller.png b/images/controller.png new file mode 100644 index 0000000..2386c4a Binary files /dev/null and b/images/controller.png differ diff --git a/images/geminiFix.png b/images/geminiFix.png new file mode 100644 index 0000000..e550844 Binary files /dev/null and b/images/geminiFix.png differ diff --git a/images/geminiFlex.png b/images/geminiFlex.png new file mode 100644 index 0000000..8b895d7 Binary files /dev/null and b/images/geminiFlex.png differ diff --git a/images/homeFix.png b/images/homeFix.png new file mode 100644 index 0000000..278e6cc Binary files /dev/null and b/images/homeFix.png differ diff --git a/images/homePlus.png b/images/homePlus.png new file mode 100644 index 0000000..7db46bd Binary files /dev/null and b/images/homePlus.png differ diff --git a/images/phoenix.png b/images/phoenix.png new file mode 100644 index 0000000..10da0b2 Binary files /dev/null and b/images/phoenix.png differ diff --git a/images/wattpilot.png b/images/wattpilot.png new file mode 100644 index 0000000..8ecaeac Binary files /dev/null and b/images/wattpilot.png differ diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..8985c96 --- /dev/null +++ b/main.cpp @@ -0,0 +1,38 @@ +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + qSetMessagePattern(QStringLiteral("%{time dd.MM.yyyy HH:mm:ss.zzz} " + "[" + "%{if-debug}D%{endif}" + "%{if-info}I%{endif}" + "%{if-warning}W%{endif}" + "%{if-critical}C%{endif}" + "%{if-fatal}F%{endif}" + "] " + "%{function}(): " + "%{message}")); + + QCoreApplication::setOrganizationName("feedc0de"); + QCoreApplication::setOrganizationDomain("brunner.ninja"); + QCoreApplication::setApplicationName("evcharger-app"); + + QGuiApplication app(argc, argv); + + QTranslator translator; + if (!translator.load(QLocale(), "qml", "_", ":/EVChargerApp/i18n/")) + qWarning("could not load translations!"); + app.installTranslator(&translator); + + QQmlApplicationEngine engine; + QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, + [](const QUrl &url){ qFatal("object creation failed: %s", qPrintable(url.toString())); + }); + + engine.loadFromModule("EVChargerApp", "EVChargerApp"); + + return app.exec(); +} diff --git a/material-icons/add.svg b/material-icons/add.svg new file mode 100644 index 0000000..6a0e78b --- /dev/null +++ b/material-icons/add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/material-icons/grid_guides.svg b/material-icons/grid_guides.svg new file mode 100644 index 0000000..cd82f05 --- /dev/null +++ b/material-icons/grid_guides.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/material-icons/settings.svg b/material-icons/settings.svg new file mode 100644 index 0000000..63cebb1 --- /dev/null +++ b/material-icons/settings.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/qmldir b/qmldir new file mode 100644 index 0000000..43d5f78 --- /dev/null +++ b/qmldir @@ -0,0 +1,62 @@ +module EVChargerApp + +EVChargerApp 1.0 AboutPage.qml +EVChargerApp 1.0 AccessPage.qml +EVChargerApp 1.0 AddDeviceScreen.qml +EVChargerApp 1.0 ApiKeyValueItem.qml +EVChargerApp 1.0 ApiSettingsPage.qml +EVChargerApp 1.0 AppInstance.qml +EVChargerApp 1.0 AppSettingsPage.qml +EVChargerApp 1.0 BaseNavigationPage.qml +EVChargerApp 1.0 CablePage.qml +EVChargerApp 1.0 CarPage.qml +EVChargerApp 1.0 ChargerTabPage.qml +EVChargerApp 1.0 ChargingConfigurationPage.qml +EVChargerApp 1.0 ChargingSpeedPage.qml +EVChargerApp 1.0 CloudPage.qml +EVChargerApp 1.0 CloudUrlsModel.qml +EVChargerApp 1.0 ConnectingScreen.qml +EVChargerApp 1.0 ConnectionPage.qml +EVChargerApp 1.0 ControllerPage.qml +EVChargerApp 1.0 ControllerTabPage.qml +EVChargerApp 1.0 CurrentLevelsPage.qml +EVChargerApp 1.0 DailyTripPage.qml +EVChargerApp 1.0 DateAndTimePage.qml +EVChargerApp 1.0 DeviceListScreen.qml +EVChargerApp 1.0 DeviceScreen.qml +EVChargerApp 1.0 DisplaySettingsPage.qml +EVChargerApp 1.0 EcoTabPage.qml +EVChargerApp 1.0 EthernetPage.qml +EVChargerApp 1.0 EVChargerApp.qml +EVChargerApp 1.0 FirmwarePage.qml +EVChargerApp 1.0 FlexibleEnergyTariffPage.qml +EVChargerApp 1.0 GeneralOnOffSwitch.qml +EVChargerApp 1.0 GeneralPage.qml +EVChargerApp 1.0 GridPage.qml +EVChargerApp 1.0 GroundCheckPage.qml +EVChargerApp 1.0 HardwareInformationPage.qml +EVChargerApp 1.0 HotspotPage.qml +EVChargerApp 1.0 InformationsTabPage.qml +EVChargerApp 1.0 KwhLimitPage.qml +EVChargerApp 1.0 LedPage.qml +EVChargerApp 1.0 LicensesPage.qml +EVChargerApp 1.0 LoadBalancingPage.qml +EVChargerApp 1.0 MainScreen.qml +EVChargerApp 1.0 NamePage.qml +EVChargerApp 1.0 NavigationItem.qml +EVChargerApp 1.0 NavigationPage.qml +EVChargerApp 1.0 NotificationsPage.qml +EVChargerApp 1.0 OcppPage.qml +EVChargerApp 1.0 PasswordPage.qml +EVChargerApp 1.0 PvSurplusPage.qml +EVChargerApp 1.0 RebootPage.qml +EVChargerApp 1.0 RequestStatusText.qml +EVChargerApp 1.0 SchedulerPage.qml +EVChargerApp 1.0 SecurityPage.qml +EVChargerApp 1.0 SensorsConfigurationPage.qml +EVChargerApp 1.0 SettingsTabPage.qml +EVChargerApp 1.0 SwitchLanguagePage.qml +EVChargerApp 1.0 VerticalTabButton.qml +EVChargerApp 1.0 WiFiErrorsPage.qml +EVChargerApp 1.0 WiFiOnOffSwitch.qml +EVChargerApp 1.0 WiFiPage.qml diff --git a/sendmessagehelper.cpp b/sendmessagehelper.cpp new file mode 100644 index 0000000..0b89f5b --- /dev/null +++ b/sendmessagehelper.cpp @@ -0,0 +1,74 @@ +#include "sendmessagehelper.h" + +#include + +void SendMessageHelper::setDeviceConnection(DeviceConnection *deviceConnection) +{ + if (m_deviceConnection == deviceConnection) + return; + + if (m_deviceConnection) + { + disconnect(m_deviceConnection, &DeviceConnection::responseReceived, + this, &SendMessageHelper::responseReceived); + } + + emit deviceConnectionChanged(m_deviceConnection = deviceConnection); + + if (m_deviceConnection) + { + connect(m_deviceConnection, &DeviceConnection::responseReceived, + this, &SendMessageHelper::responseReceived); + } + + if (!m_requestId.isEmpty()) + { + m_requestId.clear(); + emit requestIdChanged(m_requestId); + } + + if (m_pending) + emit pendingChanged(m_pending = false); + + if (!m_response.isNull()) + emit responseChanged(m_response = {}); +} + +void SendMessageHelper::sendMessage(QVariantMap message) +{ + if (!m_deviceConnection) + { + qWarning() << "No device connection set!"; + qmlEngine(this)->throwError(tr("No device connection set!")); + } + + emit requestIdChanged(m_requestId = m_deviceConnection->generateRequestId()); + + if (!m_pending) + emit pendingChanged(m_pending = true); + + message["requestId"] = m_requestId; + + m_deviceConnection->sendMessage(message); +} + +void SendMessageHelper::responseReceived(const QString &requestId, const QVariantMap &message) +{ + if (m_requestId.isEmpty()) + return; + + if (m_requestId != requestId) + return; + + if (!m_requestId.isEmpty()) + { + m_requestId.clear(); + emit requestIdChanged(m_requestId); + } + + if (m_pending) + emit pendingChanged(m_pending = false); + + emit responseChanged(m_response = message); +} + diff --git a/sendmessagehelper.h b/sendmessagehelper.h new file mode 100644 index 0000000..547b554 --- /dev/null +++ b/sendmessagehelper.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include "deviceconnection.h" + +class SendMessageHelper : public QObject +{ + Q_OBJECT + QML_ELEMENT + Q_PROPERTY(DeviceConnection* deviceConnection READ deviceConnection WRITE setDeviceConnection NOTIFY deviceConnectionChanged FINAL) + Q_PROPERTY(QString requestId READ requestId NOTIFY requestIdChanged STORED false FINAL) + Q_PROPERTY(bool pending READ pending NOTIFY pendingChanged STORED false FINAL) + Q_PROPERTY(QVariant response READ response NOTIFY responseChanged FINAL) + +public: + DeviceConnection *deviceConnection() { return m_deviceConnection; } + const DeviceConnection *deviceConnection() const { return m_deviceConnection; } + void setDeviceConnection(DeviceConnection *deviceConnection); + + const QString &requestId() const { return m_requestId; } + + bool pending() const { return m_pending; } + + const QVariant &response() const { return m_response; } + + Q_INVOKABLE void sendMessage(QVariantMap message); + +signals: + void deviceConnectionChanged(DeviceConnection *deviceConnection); + void requestIdChanged(const QString &requestId); + void pendingChanged(bool pending); + void responseChanged(const QVariant &response); + +private slots: + void responseReceived(const QString &requestId, const QVariantMap &message); + +private: + DeviceConnection *m_deviceConnection{}; + QString m_requestId; + bool m_pending{}; + QVariant m_response; +}; diff --git a/ui-icons/MaterialIcons-Regular.ttf b/ui-icons/MaterialIcons-Regular.ttf new file mode 100644 index 0000000..48c69b5 Binary files /dev/null and b/ui-icons/MaterialIcons-Regular.ttf differ diff --git a/ui-icons/material-design-icons.LICENSE b/ui-icons/material-design-icons.LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/ui-icons/material-design-icons.LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file