434 lines
12 KiB
JavaScript
Executable File
434 lines
12 KiB
JavaScript
Executable File
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
|
|
import * as Components from '../components/index.js';
|
|
import Plugin from '../plugin.js';
|
|
|
|
|
|
export const Metadata = {
|
|
label: _('Battery'),
|
|
description: _('Exchange battery information'),
|
|
id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Battery',
|
|
incomingCapabilities: [
|
|
'kdeconnect.battery',
|
|
'kdeconnect.battery.request',
|
|
],
|
|
outgoingCapabilities: [
|
|
'kdeconnect.battery',
|
|
'kdeconnect.battery.request',
|
|
],
|
|
actions: {},
|
|
};
|
|
|
|
|
|
/**
|
|
* Battery Plugin
|
|
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/battery
|
|
*/
|
|
const BatteryPlugin = GObject.registerClass({
|
|
GTypeName: 'GSConnectBatteryPlugin',
|
|
}, class BatteryPlugin extends Plugin {
|
|
|
|
_init(device) {
|
|
super._init(device, 'battery');
|
|
|
|
// Setup Cache; defaults are 90 minute charge, 1 day discharge
|
|
this._chargeState = [54, 0, -1];
|
|
this._dischargeState = [864, 0, -1];
|
|
this._thresholdLevel = 25;
|
|
|
|
this.cacheProperties([
|
|
'_chargeState',
|
|
'_dischargeState',
|
|
'_thresholdLevel',
|
|
]);
|
|
|
|
// Export battery state as GAction
|
|
this.__state = new Gio.SimpleAction({
|
|
name: 'battery',
|
|
parameter_type: new GLib.VariantType('(bsii)'),
|
|
state: this.state,
|
|
});
|
|
this.device.add_action(this.__state);
|
|
|
|
// Local Battery (UPower)
|
|
this._upower = null;
|
|
this._sendStatisticsId = this.settings.connect(
|
|
'changed::send-statistics',
|
|
this._onSendStatisticsChanged.bind(this)
|
|
);
|
|
this._onSendStatisticsChanged(this.settings);
|
|
}
|
|
|
|
get charging() {
|
|
if (this._charging === undefined)
|
|
this._charging = false;
|
|
|
|
return this._charging;
|
|
}
|
|
|
|
get icon_name() {
|
|
let icon;
|
|
|
|
if (this.level === -1)
|
|
return 'battery-missing-symbolic';
|
|
else if (this.level === 100)
|
|
return 'battery-full-charged-symbolic';
|
|
else if (this.level < 3)
|
|
icon = 'battery-empty';
|
|
else if (this.level < 10)
|
|
icon = 'battery-caution';
|
|
else if (this.level < 30)
|
|
icon = 'battery-low';
|
|
else if (this.level < 60)
|
|
icon = 'battery-good';
|
|
else if (this.level >= 60)
|
|
icon = 'battery-full';
|
|
|
|
if (this.charging)
|
|
return `${icon}-charging-symbolic`;
|
|
|
|
return `${icon}-symbolic`;
|
|
}
|
|
|
|
get level() {
|
|
// This is what KDE Connect returns if the remote battery plugin is
|
|
// disabled or still being loaded
|
|
if (this._level === undefined)
|
|
this._level = -1;
|
|
|
|
return this._level;
|
|
}
|
|
|
|
get time() {
|
|
if (this._time === undefined)
|
|
this._time = 0;
|
|
|
|
return this._time;
|
|
}
|
|
|
|
get state() {
|
|
return new GLib.Variant(
|
|
'(bsii)',
|
|
[this.charging, this.icon_name, this.level, this.time]
|
|
);
|
|
}
|
|
|
|
cacheLoaded() {
|
|
this._initEstimate();
|
|
this._sendState();
|
|
}
|
|
|
|
clearCache() {
|
|
this._chargeState = [54, 0, -1];
|
|
this._dischargeState = [864, 0, -1];
|
|
this._thresholdLevel = 25;
|
|
this._initEstimate();
|
|
}
|
|
|
|
connected() {
|
|
super.connected();
|
|
|
|
this._requestState();
|
|
this._sendState();
|
|
}
|
|
|
|
handlePacket(packet) {
|
|
switch (packet.type) {
|
|
case 'kdeconnect.battery':
|
|
this._receiveState(packet);
|
|
break;
|
|
|
|
case 'kdeconnect.battery.request':
|
|
this._sendState();
|
|
break;
|
|
}
|
|
}
|
|
|
|
_onSendStatisticsChanged() {
|
|
if (this.settings.get_boolean('send-statistics'))
|
|
this._monitorState();
|
|
else
|
|
this._unmonitorState();
|
|
}
|
|
|
|
/**
|
|
* Recalculate and update the estimated time remaining, but not the rate.
|
|
*/
|
|
_initEstimate() {
|
|
let rate, level;
|
|
|
|
// elision of [rate, time, level]
|
|
if (this.charging)
|
|
[rate,, level] = this._chargeState;
|
|
else
|
|
[rate,, level] = this._dischargeState;
|
|
|
|
if (!Number.isFinite(rate) || rate < 1)
|
|
rate = this.charging ? 864 : 90;
|
|
|
|
if (!Number.isFinite(level) || level < 0)
|
|
level = this.level;
|
|
|
|
// Update the time remaining
|
|
if (rate && this.charging)
|
|
this._time = Math.floor(rate * (100 - level));
|
|
else if (rate && !this.charging)
|
|
this._time = Math.floor(rate * level);
|
|
|
|
this.__state.state = this.state;
|
|
}
|
|
|
|
/**
|
|
* Recalculate the (dis)charge rate and update the estimated time remaining.
|
|
*/
|
|
_updateEstimate() {
|
|
let rate, time, level;
|
|
const newTime = Math.floor(Date.now() / 1000);
|
|
const newLevel = this.level;
|
|
|
|
// Load the state; ensure we have sane values for calculation
|
|
if (this.charging)
|
|
[rate, time, level] = this._chargeState;
|
|
else
|
|
[rate, time, level] = this._dischargeState;
|
|
|
|
if (!Number.isFinite(rate) || rate < 1)
|
|
rate = this.charging ? 54 : 864;
|
|
|
|
if (!Number.isFinite(time) || time <= 0)
|
|
time = newTime;
|
|
|
|
if (!Number.isFinite(level) || level < 0)
|
|
level = newLevel;
|
|
|
|
// Update the rate; use a weighted average to account for missed changes
|
|
// NOTE: (rate = seconds/percent)
|
|
const ldiff = this.charging ? newLevel - level : level - newLevel;
|
|
const tdiff = newTime - time;
|
|
const newRate = tdiff / ldiff;
|
|
|
|
if (newRate && Number.isFinite(newRate))
|
|
rate = Math.floor((rate * 0.4) + (newRate * 0.6));
|
|
|
|
// Store the state for the next recalculation
|
|
if (this.charging)
|
|
this._chargeState = [rate, newTime, newLevel];
|
|
else
|
|
this._dischargeState = [rate, newTime, newLevel];
|
|
|
|
// Update the time remaining
|
|
if (rate && this.charging)
|
|
this._time = Math.floor(rate * (100 - newLevel));
|
|
else if (rate && !this.charging)
|
|
this._time = Math.floor(rate * newLevel);
|
|
|
|
this.__state.state = this.state;
|
|
}
|
|
|
|
/**
|
|
* Notify the user the remote battery is full.
|
|
*/
|
|
_fullBatteryNotification() {
|
|
if (!this.settings.get_boolean('full-battery-notification'))
|
|
return;
|
|
|
|
// Offer the option to ring the device, if available
|
|
let buttons = [];
|
|
|
|
if (this.device.get_action_enabled('ring')) {
|
|
buttons = [{
|
|
label: _('Ring'),
|
|
action: 'ring',
|
|
parameter: null,
|
|
}];
|
|
}
|
|
|
|
this.device.showNotification({
|
|
id: 'battery|full',
|
|
// TRANSLATORS: eg. Google Pixel: Battery is full
|
|
title: _('%s: Battery is full').format(this.device.name),
|
|
// TRANSLATORS: when the battery is fully charged
|
|
body: _('Fully Charged'),
|
|
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
|
|
buttons: buttons,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notify the user the remote battery is at custom charge level.
|
|
*/
|
|
_customBatteryNotification() {
|
|
if (!this.settings.get_boolean('custom-battery-notification'))
|
|
return;
|
|
|
|
// Offer the option to ring the device, if available
|
|
let buttons = [];
|
|
|
|
if (this.device.get_action_enabled('ring')) {
|
|
buttons = [{
|
|
label: _('Ring'),
|
|
action: 'ring',
|
|
parameter: null,
|
|
}];
|
|
}
|
|
|
|
this.device.showNotification({
|
|
id: 'battery|custom',
|
|
// TRANSLATORS: eg. Google Pixel: Battery has reached custom charge level
|
|
title: _('%s: Battery has reached custom charge level').format(this.device.name),
|
|
// TRANSLATORS: when the battery has reached custom charge level
|
|
body: _('%d%% Charged').format(this.level),
|
|
icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
|
|
buttons: buttons,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Notify the user the remote battery is low.
|
|
*/
|
|
_lowBatteryNotification() {
|
|
if (!this.settings.get_boolean('low-battery-notification'))
|
|
return;
|
|
|
|
// Offer the option to ring the device, if available
|
|
let buttons = [];
|
|
|
|
if (this.device.get_action_enabled('ring')) {
|
|
buttons = [{
|
|
label: _('Ring'),
|
|
action: 'ring',
|
|
parameter: null,
|
|
}];
|
|
}
|
|
|
|
this.device.showNotification({
|
|
id: 'battery|low',
|
|
// TRANSLATORS: eg. Google Pixel: Battery is low
|
|
title: _('%s: Battery is low').format(this.device.name),
|
|
// TRANSLATORS: eg. 15% remaining
|
|
body: _('%d%% remaining').format(this.level),
|
|
icon: Gio.ThemedIcon.new('battery-caution-symbolic'),
|
|
buttons: buttons,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle a remote battery update.
|
|
*
|
|
* @param {Core.Packet} packet - A kdeconnect.battery packet
|
|
*/
|
|
_receiveState(packet) {
|
|
// Charging state changed
|
|
this._charging = packet.body.isCharging;
|
|
|
|
// Level changed
|
|
if (this._level !== packet.body.currentCharge) {
|
|
this._level = packet.body.currentCharge;
|
|
|
|
// If the level is above the threshold hide the notification
|
|
if (this._level > this._thresholdLevel)
|
|
this.device.hideNotification('battery|low');
|
|
|
|
// The level just changed to/from custom level while charging
|
|
if ((this._level === this.settings.get_uint('custom-battery-notification-value')) && this._charging)
|
|
this._customBatteryNotification();
|
|
else
|
|
this.device.hideNotification('battery|custom');
|
|
|
|
// The level just changed to/from full
|
|
if (this._level === 100)
|
|
this._fullBatteryNotification();
|
|
else
|
|
this.device.hideNotification('battery|full');
|
|
}
|
|
|
|
// Device considers the level low
|
|
if (packet.body.thresholdEvent > 0) {
|
|
this._lowBatteryNotification();
|
|
this._thresholdLevel = this.level;
|
|
}
|
|
|
|
this._updateEstimate();
|
|
}
|
|
|
|
/**
|
|
* Request the remote battery's current state
|
|
*/
|
|
_requestState() {
|
|
this.device.sendPacket({
|
|
type: 'kdeconnect.battery.request',
|
|
body: {request: true},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Report the local battery's current state
|
|
*/
|
|
_sendState() {
|
|
if (this._upower === null || !this._upower.is_present)
|
|
return;
|
|
|
|
this.device.sendPacket({
|
|
type: 'kdeconnect.battery',
|
|
body: {
|
|
currentCharge: this._upower.level,
|
|
isCharging: this._upower.charging,
|
|
thresholdEvent: this._upower.threshold,
|
|
},
|
|
});
|
|
}
|
|
|
|
/*
|
|
* UPower monitoring methods
|
|
*/
|
|
_monitorState() {
|
|
try {
|
|
// Currently only true if the remote device is a desktop (rare)
|
|
const incoming = this.device.settings.get_strv('incoming-capabilities');
|
|
|
|
if (!incoming.includes('kdeconnect.battery'))
|
|
return;
|
|
|
|
this._upower = Components.acquire('upower');
|
|
|
|
this._upowerId = this._upower.connect(
|
|
'changed',
|
|
this._sendState.bind(this)
|
|
);
|
|
|
|
this._sendState();
|
|
} catch (e) {
|
|
logError(e, this.device.name);
|
|
this._unmonitorState();
|
|
}
|
|
}
|
|
|
|
_unmonitorState() {
|
|
try {
|
|
if (this._upower === null)
|
|
return;
|
|
|
|
this._upower.disconnect(this._upowerId);
|
|
this._upower = Components.release('upower');
|
|
} catch (e) {
|
|
logError(e, this.device.name);
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
this.device.remove_action('battery');
|
|
this.settings.disconnect(this._sendStatisticsId);
|
|
this._unmonitorState();
|
|
|
|
super.destroy();
|
|
}
|
|
});
|
|
|
|
export default BatteryPlugin;
|