Add detailed Wi‑Fi and Bluetooth panel improvements

- Introduce expanded info panels for connected Wi‑Fi networks and Bluetooth devices.
- Display device details like IP, gateway, link speed, signal strength, and pairing status.
- Add pairing and unpairing functionality with enhanced device deduplication.
- Update translations to include new labels and messages for Wi‑Fi and Bluetooth.
- Refactor services to support feature-rich info retrieval and device handling.
This commit is contained in:
danny
2025-12-16 21:46:09 +01:00
parent 07050928bd
commit 2fe5421e44
6 changed files with 368 additions and 8 deletions
+32 -2
View File
@@ -453,10 +453,17 @@
"disconnect": "Disconnect",
"enable-message": "Enable Bluetooth to see available devices.",
"known-devices": "Known devices",
"paired-devices": "Paired devices",
"pairing": "Pairing...",
"pairing-mode": "Make sure your device is in pairing mode.",
"scanning": "Scanning for devices...",
"title": "Bluetooth"
"title": "Bluetooth",
"pair": "Pair",
"unpair": "Unpair",
"info": "Info",
"device-address": "Device address",
"paired": "Paired",
"trusted": "Trusted"
}
},
"calendar": {
@@ -516,7 +523,9 @@
"cancel": "Cancel",
"check-settings": "Check Settings for details",
"close": "Close",
"save": "Save"
"save": "Save",
"yes": "Yes",
"no": "No"
},
"context-menu": {
"activate-app": "Activate {app}",
@@ -2618,6 +2627,27 @@
"wallpaper-selector": "Wallpaper selector",
"widget-settings": "Widget settings"
},
"wifi": {
"panel": {
"title": "WiFi",
"connect": "Connect",
"disconnect": "Disconnect",
"password": "Password",
"enter-password": "Enter WiFi password",
"connected": "Connected",
"disconnecting": "Disconnecting…",
"forgetting": "Forgetting…",
"saved": "Saved",
"forget-network": "Forget this network",
"forget": "Forget",
"info": "Info",
"security": "Security",
"internet-connected": "Internet connected",
"internet-limited": "No internet",
"link-speed": "Link speed",
"gateway": "Gateway"
}
},
"wallpaper": {
"configure-directory": "Configure your wallpaper directory with images.",
"fill-modes": {
@@ -14,6 +14,8 @@ NBox {
property string label: ""
property string tooltipText: ""
property var model: {}
// Per-list expanded details (by device key)
property string expandedDeviceKey: ""
Layout.fillWidth: true
Layout.preferredHeight: column.implicitHeight + Style.marginM * 2
@@ -46,6 +48,7 @@ NBox {
readonly property bool canConnect: BluetoothService.canConnect(modelData)
readonly property bool canDisconnect: BluetoothService.canDisconnect(modelData)
readonly property bool canPair: BluetoothService.canPair(modelData)
readonly property bool isBusy: BluetoothService.isDeviceBusy(modelData)
function getContentColor(defaultColor = Color.mOnSurface) {
@@ -146,7 +149,7 @@ NBox {
NButton {
id: button
visible: (modelData.state !== BluetoothDeviceState.Connecting)
enabled: (canConnect || canDisconnect) && !isBusy
enabled: (canConnect || canDisconnect || canPair) && !isBusy
outlined: !button.hovered
fontSize: Style.fontSizeXS
fontWeight: Style.fontWeightMedium
@@ -167,6 +170,9 @@ NBox {
if (modelData.connected) {
return I18n.tr("bluetooth.panel.disconnect");
}
if (device.canPair) {
return I18n.tr("bluetooth.panel.pair");
}
return I18n.tr("bluetooth.panel.connect");
}
icon: (isBusy ? "busy" : null)
@@ -174,13 +180,89 @@ NBox {
if (modelData.connected) {
BluetoothService.disconnectDevice(modelData);
} else {
BluetoothService.connectDeviceWithTrust(modelData);
if (device.canPair) {
BluetoothService.pairDevice(modelData);
} else {
BluetoothService.connectDeviceWithTrust(modelData);
}
}
}
onRightClicked: {
BluetoothService.forgetDevice(modelData);
}
}
// Extra actions
RowLayout {
spacing: Style.marginXS
// Unpair for saved devices when not connected
NIconButton {
visible: (modelData.paired || modelData.trusted) && !modelData.connected && !isBusy && !modelData.blocked
icon: "trash"
tooltipText: I18n.tr("bluetooth.panel.unpair")
baseSize: Style.baseWidgetSize * 0.8
onClicked: BluetoothService.unpairDevice(modelData)
}
// Info for connected device
NIconButton {
visible: modelData.connected
icon: "info-circle"
tooltipText: I18n.tr("bluetooth.panel.info")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
const key = BluetoothService.deviceKey(modelData);
root.expandedDeviceKey = (root.expandedDeviceKey === key) ? "" : key;
}
}
}
}
// Expanded info section
Rectangle {
visible: root.expandedDeviceKey === BluetoothService.deviceKey(modelData)
Layout.fillWidth: true
height: infoColumn.implicitHeight + Style.marginS * 2
radius: Style.radiusS
color: Color.mSurfaceVariant
border.width: Style.borderS
border.color: Color.mOutline
ColumnLayout {
id: infoColumn
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginXS
RowLayout {
spacing: Style.marginS
NIcon { icon: BluetoothService.getSignalIcon(modelData); pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: BluetoothService.getSignalStrength(modelData); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
NText { visible: modelData.signalStrength > 0; text: (modelData.signalStrength || 0) + "%"; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
}
RowLayout {
spacing: Style.marginS
NIcon { icon: "hash"; pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: I18n.tr("bluetooth.panel.device-address") + ": "; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: modelData.address || "-"; pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
RowLayout {
spacing: Style.marginS
NIcon { icon: "shield-check"; pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: I18n.tr("bluetooth.panel.paired") + ": " + (modelData.paired ? I18n.tr("common.yes") : I18n.tr("common.no")); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
NText { text: "•"; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: I18n.tr("bluetooth.panel.trusted") + ": " + (modelData.trusted ? I18n.tr("common.yes") : I18n.tr("common.no")); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
RowLayout {
visible: modelData.batteryAvailable
spacing: Style.marginS
NIcon { icon: "battery"; pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: BluetoothService.getBattery(modelData); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
}
}
}
}
+6 -3
View File
@@ -151,6 +151,7 @@ SmartPanel {
if (!BluetoothService.adapter || !Bluetooth.devices)
return [];
var filtered = Bluetooth.devices.values.filter(dev => dev && !dev.blocked && dev.connected);
filtered = BluetoothService.dedupeDevices(filtered);
return BluetoothService.sortDevices(filtered);
}
model: items
@@ -158,14 +159,15 @@ SmartPanel {
Layout.fillWidth: true
}
// Known devices
// Paired devices
BluetoothDevicesList {
label: I18n.tr("bluetooth.panel.known-devices")
label: I18n.tr("bluetooth.panel.paired-devices")
tooltipText: I18n.tr("tooltips.connect-disconnect-devices")
property var items: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return [];
var filtered = Bluetooth.devices.values.filter(dev => dev && !dev.blocked && !dev.connected && (dev.paired || dev.trusted));
filtered = BluetoothService.dedupeDevices(filtered);
return BluetoothService.sortDevices(filtered);
}
model: items
@@ -173,13 +175,14 @@ SmartPanel {
Layout.fillWidth: true
}
// Available devices
// Available devices (for pairing)
BluetoothDevicesList {
label: I18n.tr("bluetooth.panel.available-devices")
property var items: {
if (!BluetoothService.adapter || !Bluetooth.devices)
return [];
var filtered = Bluetooth.devices.values.filter(dev => dev && !dev.blocked && !dev.paired && !dev.trusted);
filtered = BluetoothService.dedupeDevices(filtered);
return BluetoothService.sortDevices(filtered);
}
model: items
+72
View File
@@ -13,6 +13,8 @@ NBox {
property var model: []
property string passwordSsid: ""
property string expandedSsid: ""
// Currently expanded info panel for a connected SSID
property string infoSsid: ""
signal passwordRequested(string ssid)
signal passwordSubmitted(string ssid, string password)
@@ -203,6 +205,22 @@ NBox {
size: Style.baseWidgetSize * 0.5
}
// Info toggle for connected network
NIconButton {
visible: modelData.connected && NetworkService.disconnectingFrom !== modelData.ssid
icon: "info-circle"
tooltipText: I18n.tr("wifi.panel.info")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
if (root.infoSsid === modelData.ssid) {
root.infoSsid = "";
} else {
root.infoSsid = modelData.ssid;
NetworkService.refreshActiveWifiDetails();
}
}
}
NIconButton {
visible: (modelData.existing || modelData.cached) && !modelData.connected && NetworkService.connectingTo !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid
icon: "trash"
@@ -243,6 +261,60 @@ NBox {
}
}
// Connection info details
Rectangle {
visible: root.infoSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid
Layout.fillWidth: true
color: Color.mSurfaceVariant
radius: Style.radiusS
border.width: Style.borderS
border.color: Color.mOutline
height: infoColumn.implicitHeight + Style.marginS * 2
ColumnLayout {
id: infoColumn
anchors.fill: parent
anchors.margins: Style.marginS
spacing: Style.marginXS
RowLayout {
spacing: Style.marginS
NIcon { icon: NetworkService.signalIcon(modelData.signal, modelData.connected); pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: I18n.tr("system.signal-strength", {"signal": modelData.signal}); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
RowLayout {
spacing: Style.marginS
NIcon { icon: "lock"; pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: I18n.tr("wifi.panel.security") + ": "; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: NetworkService.isSecured(modelData.security) ? modelData.security : "Open"; pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
RowLayout {
spacing: Style.marginS
NIcon { icon: NetworkService.internetConnectivity ? "world" : "world-off"; pointSize: Style.fontSizeM; color: NetworkService.internetConnectivity ? Color.mOnSurface : Color.mError }
NText { text: NetworkService.internetConnectivity ? I18n.tr("wifi.panel.internet-connected") : I18n.tr("wifi.panel.internet-limited"); pointSize: Style.fontSizeXS; color: NetworkService.internetConnectivity ? Color.mOnSurface : Color.mError }
}
RowLayout {
spacing: Style.marginS
NIcon { icon: "activity"; pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: I18n.tr("wifi.panel.link-speed") + ": "; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: (NetworkService.activeWifiDetails.rate || "-"); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
RowLayout {
spacing: Style.marginS
NIcon { icon: "router"; pointSize: Style.fontSizeM; color: Color.mOnSurface }
NText { text: "IPv4: "; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: (NetworkService.activeWifiDetails.ipv4 || "-"); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
NText { text: "•"; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: I18n.tr("wifi.panel.gateway") + ": "; pointSize: Style.fontSizeXS; color: Color.mOnSurfaceVariant }
NText { text: (NetworkService.activeWifiDetails.gateway4 || "-"); pointSize: Style.fontSizeXS; color: Color.mOnSurface }
}
}
}
// Password input
Rectangle {
visible: root.passwordSsid === modelData.ssid && NetworkService.disconnectingFrom !== modelData.ssid && NetworkService.forgettingNetwork !== modelData.ssid
+70 -1
View File
@@ -152,7 +152,8 @@ Singleton {
Example: once your headphones are paired, you dont need to type a PIN every time.
Hence, instead of !device.paired, should be device.connected
*/
return !device.connected && !device.pairing && !device.blocked;
// Only allow connect if device is already paired or trusted
return !device.connected && (device.paired || device.trusted) && !device.pairing && !device.blocked;
}
function canDisconnect(device) {
@@ -226,6 +227,74 @@ Singleton {
return device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting;
}
// Return a stable unique key for a device (prefer MAC address)
function deviceKey(device) {
if (!device)
return "";
if (device.address && device.address.length > 0)
return device.address.toUpperCase();
if (device.nativePath && device.nativePath.length > 0)
return device.nativePath;
if (device.devicePath && device.devicePath.length > 0)
return device.devicePath;
return (device.name || device.deviceName || "") + "|" + (device.icon || "");
}
// Deduplicate a list of devices using the stable key
function dedupeDevices(devList) {
if (!devList || devList.length === 0)
return [];
const seen = ({});
const out = [];
for (let i = 0; i < devList.length; ++i) {
const d = devList[i];
if (!d)
continue;
const key = deviceKey(d);
if (key && !seen[key]) {
seen[key] = true;
out.push(d);
}
}
return out;
}
// Separate capability helpers
function canPair(device) {
if (!device)
return false;
return !device.connected && !device.paired && !device.trusted && !device.pairing && !device.blocked;
}
// Pairing and unpairing helpers
function pairDevice(device) {
if (!device)
return;
try {
if (typeof device.pair === 'function') {
device.pair();
} else {
// Fallback: trust and connect (most stacks will pair during connect)
device.trusted = true;
device.connect();
}
} catch (e) {
Logger.w("Bluetooth", "pairDevice failed", e);
// Fallback to connect if pair not supported
try {
device.trusted = true;
device.connect();
} catch (e2) {
Logger.w("Bluetooth", "pairDevice connect fallback failed", e2);
}
}
}
function unpairDevice(device) {
// Alias to forgetDevice for clarity in UI
forgetDevice(device);
}
function connectDeviceWithTrust(device) {
if (!device) {
return;
+104
View File
@@ -23,6 +23,10 @@ Singleton {
property bool ignoreScanResults: false
property bool scanPending: false
// Active WiFi connection details (for info panel)
property var activeWifiDetails: ({})
property string activeWifiIf: ""
// Persistent cache
property string cacheFile: Settings.cacheDir + "network.json"
readonly property string cachedLastConnected: cacheAdapter.lastConnected
@@ -79,6 +83,13 @@ Singleton {
onTriggered: cacheFileView.writeAdapter()
}
// Refresh details for the currently active WiFi link
function refreshActiveWifiDetails() {
activeWifiDetails = ({})
activeWifiIf = ""
wifiDeviceListProcess.running = true;
}
function saveCache() {
saveDebounce.restart();
}
@@ -256,6 +267,99 @@ Singleton {
}
}
// Discover connected WiFi interface
Process {
id: wifiDeviceListProcess
running: false
command: ["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"]
stdout: StdioCollector {
onStreamFinished: {
let ifname = "";
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].trim().split(":");
if (parts.length >= 3) {
const dev = parts[0];
const type = parts[1];
const state = parts[2];
if (type === "wifi" && state === "connected") {
ifname = dev;
break;
}
}
}
root.activeWifiIf = ifname;
if (ifname) {
wifiDeviceShowProcess.ifname = ifname;
wifiDeviceShowProcess.running = true;
}
}
}
}
// Fetch IPv4 and gateway for the interface
Process {
id: wifiDeviceShowProcess
property string ifname: ""
running: false
command: ["nmcli", "-t", "-f", "IP4.ADDRESS,IP4.GATEWAY", "device", "show", ifname]
stdout: StdioCollector {
onStreamFinished: {
const details = root.activeWifiDetails || ({});
let ipv4 = "";
let gw4 = "";
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.substring(0, idx);
const val = line.substring(idx + 1);
if (key.startsWith("IP4.ADDRESS")) {
ipv4 = val.split("/")[0];
} else if (key === "IP4.GATEWAY") {
gw4 = val;
}
}
details.ipv4 = ipv4;
details.gateway4 = gw4;
root.activeWifiDetails = details;
// Try to get link rate (best effort)
wifiIwLinkProcess.ifname = wifiDeviceShowProcess.ifname;
wifiIwLinkProcess.running = true;
}
}
}
// Optional: query WiFi bitrate via iw if available
Process {
id: wifiIwLinkProcess
property string ifname: ""
running: false
command: ["sh", "-c", "iw dev '" + ifname + "' link 2>/dev/null || true"]
stdout: StdioCollector {
onStreamFinished: {
const details = root.activeWifiDetails || ({});
let rate = "";
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.toLowerCase().startsWith("tx bitrate:")) {
rate = line.substring(11).trim();
break;
}
}
details.rate = rate;
root.activeWifiDetails = details;
}
}
}
// Only check the state of the actual interface
// and update our setting to be in sync.
Process {