diff --git a/evcharger-app/qml/WiFiPage.qml b/evcharger-app/qml/WiFiPage.qml index c3d4e7e..b9b63b4 100644 --- a/evcharger-app/qml/WiFiPage.qml +++ b/evcharger-app/qml/WiFiPage.qml @@ -103,8 +103,17 @@ NavigationPage { apiKey: "scan" } - text: qsTr("(%0) Scan Result").arg(wifiScanResult.value == null ? 0 : wifiScanResult.value.length) - onClicked: stackView.push(wiFiScanPageComponent, {wifiScanResult} ) + ApiKeyValueHelper { + id: configuredWifis + deviceConnection: theDeviceConnection + apiKey: "wifis" + } + + text: qsTr("(%0) Available networks").arg(wifiScanResult.value == null ? 0 : wifiScanResult.value.length) + onClicked: stackView.push(wiFiScanPageComponent, { + wifiScanResult: wifiScanResult, + configuredWifis: configuredWifis + }) enabled: wifiScanResult.value != null Component { diff --git a/evcharger-app/qml/WiFiScanPage.qml b/evcharger-app/qml/WiFiScanPage.qml index 48d860c..b2eb331 100644 --- a/evcharger-app/qml/WiFiScanPage.qml +++ b/evcharger-app/qml/WiFiScanPage.qml @@ -5,118 +5,410 @@ import EVChargerApp BaseNavigationPage { property ApiKeyValueHelper wifiScanResult + property ApiKeyValueHelper configuredWifis + property var selectedNetwork: null + property int selectedWifiSlot: -1 - title: qsTr("Wi-Fi Scan Result") + title: qsTr("Wi-Fi networks") - ListView { - id: listView + function configuredNetworks() { + const result = [] + const wifis = configuredWifis === null ? null : configuredWifis.value + if (wifis === null || wifis === undefined) + return result + + for (let i = 0; i < wifis.length; ++i) { + const wifi = wifis[i] + if (wifi !== null && wifi !== undefined && wifi.ssid) + result.push({slot: i, ssid: wifi.ssid, hasKey: wifi.key === true}) + } + return result + } + + function isConfigured(ssid) { + const networks = configuredNetworks() + for (let i = 0; i < networks.length; ++i) { + if (networks[i].ssid === ssid) + return true + } + return false + } + + function firstFreeWifiSlot() { + const wifis = configuredWifis === null ? null : configuredWifis.value + if (wifis === null || wifis === undefined) + return -2 + + for (let i = 0; i < wifis.length; ++i) { + if (wifis[i] === null || wifis[i] === undefined || !wifis[i].ssid) + return i + } + return -1 + } + + function availableNetworks() { + const result = [] + const scan = wifiScanResult === null ? null : wifiScanResult.value + if (scan === null || scan === undefined) + return result + + // Android presents an SSID once. Keep the strongest BSSID when the + // same network was observed through multiple access points. + for (let i = 0; i < scan.length; ++i) { + const candidate = scan[i] + if (candidate === null || candidate === undefined || !candidate.ssid) + continue + + let existing = -1 + for (let j = 0; j < result.length; ++j) { + if (result[j].ssid === candidate.ssid) { + existing = j + break + } + } + + if (existing < 0) + result.push(candidate) + else if ((candidate.rssi ?? -1000) > (result[existing].rssi ?? -1000)) + result[existing] = candidate + } + + result.sort((left, right) => (right.rssi ?? -1000) - (left.rssi ?? -1000)) + return result + } + + function securityName(encryptionType) { + switch (encryptionType) { + case 0: return qsTr("Open") + case 1: return qsTr("WEP") + case 2: return qsTr("WPA") + case 3: return qsTr("WPA2") + case 4: return qsTr("WPA/WPA2") + case 5: return qsTr("WPA2 Enterprise") + case 6: return qsTr("WPA3") + case 7: return qsTr("WPA2/WPA3") + case 8: return qsTr("WAPI") + case 9: return qsTr("OWE") + case 10: return qsTr("WPA3 Enterprise") + case 11: return qsTr("WPA3 External PSK") + case 12: return qsTr("WPA3 External PSK mixed") + default: return qsTr("Unknown security") + } + } + + function needsPassword(network) { + return network.encryptionType !== 0 && network.encryptionType !== 9 + } + + function signalBars(rssi) { + if (rssi >= -55) + return "▂▄▆█" + if (rssi >= -67) + return "▂▄▆" + if (rssi >= -75) + return "▂▄" + return "▂" + } + + function selectNetwork(network) { + if (isConfigured(network.ssid)) + return + + const slot = firstFreeWifiSlot() + if (slot === -2) { + noSlotDialog.text = qsTr("The Wi-Fi configuration has not been received yet.") + noSlotDialog.open() + return + } + if (slot < 0) { + noSlotDialog.text = qsTr("All Wi-Fi configuration slots are occupied. Delete a configured network first.") + noSlotDialog.open() + return + } + + selectedNetwork = network + selectedWifiSlot = slot + passwordInput.clear() + passwordDialog.open() + } + + function provisionSelectedNetwork() { + if (selectedNetwork === null || selectedWifiSlot < 0) + return + + const value = [] + for (let i = 0; i < selectedWifiSlot; ++i) + value.push(null) + value.push({ + ssid: selectedNetwork.ssid, + key: needsPassword(selectedNetwork) ? passwordInput.text : "" + }) + provisionWifi.setValue(value) + } + + headerItems: RowLayout { + BusyIndicator { + visible: provisionWifi.pending || deleteWifi.pending || triggerScan.pending + } + + Button { + text: qsTr("Scan again") + enabled: !triggerScan.pending + onClicked: triggerScan.sendMessage({type: "wifiScan"}) + } + } + + SetValueHelper { + id: provisionWifi + deviceConnection: theDeviceConnection + apiKey: "wifis" + } + + SetValueHelper { + id: deleteWifi + deviceConnection: theDeviceConnection + apiKey: "delw" + } + + SendMessageHelper { + id: triggerScan + deviceConnection: theDeviceConnection + } + + Flickable { Layout.fillWidth: true Layout.fillHeight: true - - model: wifiScanResult.value - spacing: 5 clip: true + contentHeight: networksColumn.implicitHeight + ScrollBar.vertical: ScrollBar {} - delegate: WhiteBox { - required property var modelData - property var theModelData: modelData + ColumnLayout { + id: networksColumn + width: parent.width - 30 + x: 15 + spacing: 5 - width: listView.width + Text { + Layout.fillWidth: true + text: qsTr("Configured networks") + font.bold: true + visible: configuredRepeater.count > 0 + } - GridLayout { - anchors.fill: parent - columns: 2 + Repeater { + id: configuredRepeater + model: configuredNetworks() - Text { - text: qsTr("SSID:") - font.bold: true - } + delegate: WhiteBox { + required property var modelData + Layout.fillWidth: true + implicitHeight: configuredRow.implicitHeight + 20 - Text { - text: modelData.ssid - } + RowLayout { + id: configuredRow + anchors.fill: parent + anchors.margins: 10 - Text { - text: qsTr("Authmode:") - font.bold: true - } + ColumnLayout { + Layout.fillWidth: true - Text { - text: { - switch (modelData.encryptionType) - { - case 0: return qsTr("OPEN") - case 1: return qsTr("WEP") - case 2: return qsTr("WPA_PSK") - case 3: return qsTr("WPA2_PSK") - case 4: return qsTr("WPA_WPA2_PSK") - case 5: return qsTr("WPA2_ENTERPRISE") - case 6: return qsTr("WPA3_PSK") - case 7: return qsTr("WPA2_WPA3_PSK") - case 8: return qsTr("WAPI_PSK") - case 9: return qsTr("OWE") - case 10: return qsTr("WPA3_ENT_192") - case 11: return qsTr("WPA3_EXT_PSK") - case 12: return qsTr("WPA3_EXT_PSK_MIXED_MODE") + Text { + Layout.fillWidth: true + text: modelData.ssid + font.bold: true + elide: Text.ElideRight + } + + Text { + text: qsTr("Saved in slot %1").arg(modelData.slot + 1) + color: "gray" + } + } + + Button { + text: qsTr("Delete") + enabled: !deleteWifi.pending + onClicked: { + selectedWifiSlot = modelData.slot + deleteDialog.networkName = modelData.ssid + deleteDialog.open() + } } - return modelData.encryptionType } } + } - function wiFiCipherToString(cipher) { - const names = ['NONE', 'WEP40', 'WEP104', 'TKIP', 'CCMP', 'TKIP_CCMP', 'AES_CMAC128', 'SMS4', 'GCMP', 'GCMP256', 'AES_GMAC128', 'AES_GMAC256', 'UNKNOWN']; + Text { + Layout.fillWidth: true + Layout.topMargin: configuredRepeater.count > 0 ? 10 : 0 + text: qsTr("Available networks") + font.bold: true + } - if (cipher >= 0 && cipher < names.length) - return names[cipher]; + Repeater { + model: availableNetworks() - return cipher; + delegate: WhiteItemDelegate { + required property var modelData + readonly property bool alreadyConfigured: isConfigured(modelData.ssid) + + Layout.fillWidth: true + implicitHeight: availableRow.implicitHeight + 20 + enabled: !alreadyConfigured && !provisionWifi.pending + onClicked: selectNetwork(modelData) + + contentItem: RowLayout { + id: availableRow + + ColumnLayout { + Layout.fillWidth: true + + Text { + Layout.fillWidth: true + text: modelData.ssid + font.bold: true + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: alreadyConfigured + ? qsTr("Saved • %1").arg(securityName(modelData.encryptionType)) + : securityName(modelData.encryptionType) + color: "gray" + elide: Text.ElideRight + } + } + + Text { + text: signalBars(modelData.rssi) + font.bold: true + Accessible.name: qsTr("Signal strength %1 dBm").arg(modelData.rssi) + } + + Text { + text: alreadyConfigured ? "✓" : ">" + } + } } + } - Text { - text: qsTr("Pairwise cipher:") - font.bold: true - } + Text { + Layout.fillWidth: true + text: qsTr("No networks found. Start another scan.") + visible: availableNetworks().length === 0 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + } - Text { - text: parent.wiFiCipherToString("f" in modelData ? modelData.f[0] : modelData.pairwiseCipher) - } + RequestStatusText { + Layout.fillWidth: true + request: provisionWifi + } - Text { - text: qsTr("Group cipher:") - font.bold: true - } - - Text { - text: parent.wiFiCipherToString("f" in modelData ? modelData.f[1] : modelData.groupCipher) - } - - Text { - text: qsTr("RSSI:") - font.bold: true - } - - Text { - text: modelData.rssi - } - - Text { - text: qsTr("Channel:") - font.bold: true - } - - Text { - text: modelData.channel - } - - Text { - text: qsTr("BSSID:") - font.bold: true - } - - Text { - text: modelData.bssid - } + RequestStatusText { + Layout.fillWidth: true + request: deleteWifi } } } + + CenteredDialog { + id: passwordDialog + title: selectedNetwork === null ? qsTr("Connect to Wi-Fi") : selectedNetwork.ssid + standardButtons: Dialog.Ok | Dialog.Cancel + modal: true + focus: true + + onOpened: { + passwordInput.forceActiveFocus() + standardButton(Dialog.Ok).enabled = !needsPassword(selectedNetwork) + } + onAccepted: provisionSelectedNetwork() + + contentItem: ColumnLayout { + Text { + Layout.fillWidth: true + text: selectedNetwork === null ? "" : securityName(selectedNetwork.encryptionType) + color: "gray" + } + + TextField { + id: passwordInput + Layout.minimumWidth: 240 + Layout.fillWidth: true + visible: selectedNetwork !== null && needsPassword(selectedNetwork) + placeholderText: qsTr("Password") + echoMode: showPassword.checked ? TextInput.Normal : TextInput.Password + maximumLength: 64 + inputMethodHints: Qt.ImhSensitiveData | Qt.ImhNoPredictiveText + onTextChanged: { + if (passwordDialog.visible) + passwordDialog.standardButton(Dialog.Ok).enabled = text.length >= 8 + } + onAccepted: { + if (text.length >= 8) + passwordDialog.accept() + } + } + + CheckBox { + id: showPassword + visible: passwordInput.visible + text: qsTr("Show password") + } + + Text { + Layout.fillWidth: true + visible: passwordInput.visible && passwordInput.text.length > 0 && passwordInput.text.length < 8 + text: qsTr("The password must contain at least 8 characters.") + color: "red" + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + visible: selectedNetwork !== null && !needsPassword(selectedNetwork) + text: qsTr("This network does not require a password.") + wrapMode: Text.Wrap + } + + Text { + Layout.fillWidth: true + text: qsTr("The network will be saved in slot %1.").arg(selectedWifiSlot + 1) + wrapMode: Text.Wrap + } + } + } + + CenteredDialog { + id: deleteDialog + property string networkName + + title: qsTr("Delete Wi-Fi network?") + standardButtons: Dialog.Yes | Dialog.No + modal: true + focus: true + onAccepted: deleteWifi.setValue(selectedWifiSlot) + + contentItem: Text { + text: qsTr("Delete %1 and all of its IP and DNS settings?").arg(deleteDialog.networkName) + wrapMode: Text.Wrap + } + } + + CenteredDialog { + id: noSlotDialog + property alias text: noSlotText.text + + title: qsTr("Cannot add Wi-Fi network") + standardButtons: Dialog.Ok + modal: true + focus: true + + contentItem: Text { + id: noSlotText + wrapMode: Text.Wrap + } + } }