2024-07-08 22:46:35 +02:00

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;