diff --git a/extension.js b/extension.js index 58d0aa8..ddcc03b 100644 --- a/extension.js +++ b/extension.js @@ -23,6 +23,7 @@ const UiExtension = Me.imports.ui; const Bluetooth = Me.imports.bluetooth; const Utils = Me.imports.utils; const Settings = Me.imports.settings.Settings; +const BatteryProvider = Me.imports.power.UPowerBatteryProvider; class BluetoothQuickConnect { @@ -32,7 +33,8 @@ class BluetoothQuickConnect { this._menu = bluetooth._item.menu; this._proxy = bluetooth._proxy; this._controller = new Bluetooth.BluetoothController(); - this._settings = settings + this._settings = settings; + this._battery_provider = new BatteryProvider(this._logger); this._items = {}; } @@ -50,7 +52,7 @@ class BluetoothQuickConnect { this._connectSignal(this._menu, 'open-state-changed', (menu, isOpen) => { this._logger.info(`Menu toggled: ${isOpen}`); if (isOpen) - this._disconnectIdleMonitor() + this._disconnectIdleMonitor(); else this._connectIdleMonitor(); @@ -82,40 +84,53 @@ class BluetoothQuickConnect { this._connectSignal(this._controller, 'device-inserted', (ctrl, device) => { this._logger.info(`Device inserted event: ${device.name}`); - this._addMenuItem(device); + if (device.isPaired) { + this._addMenuItem(device); + } else { + this._logger.info(`Device ${device.name} not paired, ignoring`); + } }); + this._connectSignal(this._controller, 'device-changed', (ctrl, device) => { this._logger.info(`Device changed event: ${device.name}`); if (device.isDefault) this._refresh(); - else + else if (device.isPaired) this._syncMenuItem(device); + else + this._logger.info(`Skipping change event for unpaired device ${device.name}`); }); + this._connectSignal(this._controller, 'device-deleted', () => { this._logger.info(`Device deleted event`); this._refresh(); }); this._connectSignal(Main.sessionMode, 'updated', () => { - this._refresh() + this._refresh(); }); } _syncMenuItem(device) { this._logger.info(`Synchronizing device menu item: ${device.name}`); let item = this._items[device.mac] || this._addMenuItem(device); + item.sync(device); } _addMenuItem(device) { - this._logger.info(`Adding device menu item: ${device.name}`); + this._logger.info(`Adding device menu item: ${device.name} ${device.mac}`); + let menuItem = new UiExtension.PopupBluetoothDeviceMenuItem( device, + this._battery_provider, + this._logger, { showRefreshButton: this._settings.isShowRefreshButtonEnabled(), closeMenuOnAction: !this._settings.isKeepMenuOnToggleEnabled() } ); + this._items[device.mac] = menuItem; this._menu.addMenuItem(menuItem, 1); @@ -161,12 +176,17 @@ class BluetoothQuickConnect { _addDevicesToMenu() { this._controller.getDevices().forEach((device) => { - this._addMenuItem(device); + if (device.isPaired) { + let item = this._addMenuItem(device); + } else { + this._logger.info(`skipping adding device ${device.name}`); + } }); } _removeDevicesFromMenu() { Object.values(this._items).forEach((item) => { + item.disconnectSignals(); item.destroy(); }); @@ -184,7 +204,6 @@ class BluetoothQuickConnect { Utils.addSignalsHelperMethods(BluetoothQuickConnect.prototype); - let bluetoothQuickConnect = null; function init() {} diff --git a/power.js b/power.js new file mode 100644 index 0000000..2d82ad1 --- /dev/null +++ b/power.js @@ -0,0 +1,37 @@ +const ExtensionUtils = imports.misc.extensionUtils; +const UPower = imports.gi.UPowerGlib; +const Me = ExtensionUtils.getCurrentExtension(); +const Utils = Me.imports.utils; + +class UPowerBatteryProvider { + constructor(logger) { + this._upower_client = UPower.Client.new(); + this._logger = logger; + } + + locateBatteryDevice(device) { + // upower has no field in the devices that indicate that a battery is + // from a bluetooth device, so we must try and find by the provided mac. + // Problem is, the native_path field has macs in all kinds of forms ... + let _mac_addrs = [ + device.mac.toUpperCase(), + device.mac.replace(/:/g, "_").toUpperCase(), + ]; + + let _battery_devices = this._upower_client.get_devices(); + let _bateries = _battery_devices.filter(bat => { + let _native_path = bat.native_path.toUpperCase(); + return _mac_addrs.some(mac => _native_path.includes(mac)); + }); + + if (_bateries.length > 1) { + this._logger.warn(`device ${device.name} matched more than one UPower device by native_path`); + _bateries = []; + } + + let _battery_native_path = _bateries.map(bat => bat.native_path)[0] || "NOT-FOUND"; + this._logger.info(`battery: ${_battery_native_path}`); + + return _bateries; + } +} diff --git a/ui.js b/ui.js index 23bf2f1..a08ba3d 100644 --- a/ui.js +++ b/ui.js @@ -18,16 +18,24 @@ const Clutter = imports.gi.Clutter; const GObject = imports.gi.GObject; const St = imports.gi.St; +const GLib = imports.gi.GLib; const PopupMenu = imports.ui.popupMenu; const Config = imports.misc.config; var PopupBluetoothDeviceMenuItem = GObject.registerClass( class PopupSwitchWithButtonMenuItem extends PopupMenu.PopupSwitchMenuItem { - _init(device, params) { + _init(device, batteryProvider, logger, params) { let label = device.name || '(unknown)'; super._init(label, device.isConnected, {}); + this._logger = logger; + this._device = device; + this._optBatDevice = []; + this._batteryProvider = batteryProvider; + this._batteryDeviceChangeSignal = null; + this._batteryDeviceLocateTimeout = null; + this._showRefreshButton = params.showRefreshButton; this._closeMenuOnAction = params.closeMenuOnAction; @@ -43,27 +51,100 @@ var PopupBluetoothDeviceMenuItem = GObject.registerClass( this.add(this._statusBin, { expand: false }); } + this._batteryInfo = new BatteryInfoWidget({visible: false}); + this.insert_child_at_index(this._batteryInfo, this.get_n_children() - 1); + this.insert_child_at_index(this._refreshButton, this.get_n_children() - 1); this.add_child(this._pendingLabel); this.sync(device); } + _tryLocateBatteryWithTimeout(count = 10) { + + let device = this._device; + + this._logger.info(`looking up battery info for ${device.name}`); + + this._batteryDeviceLocateTimeout = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + 1000, + () => { + this._logger.info(`Looking up battery info for ${device.name}`); + let optBat = this._batteryProvider.locateBatteryDevice(device); + + if (optBat.length) { + this._batteryFound(optBat); + this._batteryDeviceLocateTimeout = null; + } else if (count) { + // try again + this._tryLocateBatteryWithTimeout(count - 1); + } else { + this._logger.info(`Did not find a battery for device ${device.name}`); + this._batteryDeviceLocateTimeout = null; + } + }); + } + + _batteryFound(optBatDevice) { + this._optBatDevice = optBatDevice; + + for (const bat of this._optBatDevice) { + this._batteryInfo.show(); + this._batteryInfo.setPercentage(bat.percentage); + + this._batteryDeviceChangeSignal = bat.connect("notify", (_dev, pspec) => { + if (pspec.name == 'percentage') { + this._logger.info(`${_dev.native_path} notified ${pspec.name}, percentage is ${_dev.percentage}`); + this._batteryInfo.setPercentage(bat.percentage); + } + }); + } + } + + disconnectSignals() { + this._optBatDevice.map(bat => bat.disconnect(this._batteryDeviceChangeSignal)); + this._batteryDeviceChangeSignal = null; + + if (this._batteryDeviceLocateTimeout != null) { + GLib.Source.remove(this._batteryDeviceLocateTimeout); + this._batteryDeviceLocateTimeout = null; + } + } + sync(device) { + this.disconnectSignals(); + + this._optBatDevice = []; + this._batteryInfo.visible = false; + this._device = device; + this._syncSwitch(device); this.visible = device.isPaired; if (this._showRefreshButton && device.isConnected) this._refreshButton.show(); else this._refreshButton.hide(); + + this._updateDeviceInfo(); + + if (device.isConnected) + this._tryLocateBatteryWithTimeout(); + } _syncSwitch(device) { - if (this._isOldGnome()) - return this._switch.setToggleState(device.isConnected); + if (this._isOldGnome()) { + this._switch.setToggleState(device.isConnected); + } else { + this._switch.state = device.isConnected; + } + } - this._switch.state = device.isConnected; + _updateDeviceInfo() { + this._logger.info(`updating label for ${this._device.name} ${this._optBatDevice.map(bat => bat.percentage)}`); + this.label.text = this._device.name || "unknown";; } _buildRefreshButton() { @@ -99,7 +180,8 @@ var PopupBluetoothDeviceMenuItem = GObject.registerClass( button.connect('clicked', () => { this._enablePending(); this._device.reconnect(() => { - this._disablePending() + this._disablePending(); + this._updateDeviceInfo(); }); if (this._closeMenuOnAction) @@ -118,14 +200,16 @@ var PopupBluetoothDeviceMenuItem = GObject.registerClass( _connectToggledEvent() { this.connect('toggled', (item, state) => { - if (state) + if (state) { this._device.connect(() => { - this._disablePending() + this._disablePending(); + this._updateDeviceInfo(); }); - else + } else { this._device.disconnect(() => { - this._disablePending() + this._disablePending(); }); + } }); } @@ -169,3 +253,37 @@ var PopupBluetoothDeviceMenuItem = GObject.registerClass( } } ); + +var BatteryInfoWidget = GObject.registerClass( + class BatteryInfoWidget extends St.BoxLayout { + _init(params) { + super._init(params); + this._icon = new St.Icon({ style_class: 'popup-menu-icon' }); + this.add_child(this._icon); + this._icon.icon_name = null; + + // dirty trick: instantiate the label with text 100%, so we can set + // the natural width of the label in case monospace has no effect + this._label = new St.Label({ y_expand: false, style_class: 'monospace', text: '100%' }); + this._label.natural_width = this._label.width; + this._label.text = ""; + + this._label.x_expand = false; + this._label.x_align = Clutter.ActorAlign.LEFT; + this.add_child(this._label); + } + + setPercentage(value) { + if (value == null) { + this._label.text = ''; + this._icon.icon_name = 'battery-missing-symbolic'; + } else { + this._label.text = '%d%%'.format(value); + + let fillLevel = 10 * Math.floor(value / 10); + let iconName = 'battery-level-%d-symbolic'.format(fillLevel); + this._icon.icon_name = iconName; + } + } + } +); diff --git a/utils.js b/utils.js index 9bb550e..073be9b 100644 --- a/utils.js +++ b/utils.js @@ -44,6 +44,10 @@ var Logger = class Logger { log(`[bluetooth-quick-connect] ${message}`); } + + warn(message) { + log(`[bluetooth-quick-connect WARNING] ${message}`); + } }; function addSignalsHelperMethods(prototype) {