battery.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. import Gio from 'gi://Gio';
  5. import GLib from 'gi://GLib';
  6. import GObject from 'gi://GObject';
  7. import * as Components from '../components/index.js';
  8. import Plugin from '../plugin.js';
  9. export const Metadata = {
  10. label: _('Battery'),
  11. description: _('Exchange battery information'),
  12. id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Battery',
  13. incomingCapabilities: [
  14. 'kdeconnect.battery',
  15. 'kdeconnect.battery.request',
  16. ],
  17. outgoingCapabilities: [
  18. 'kdeconnect.battery',
  19. 'kdeconnect.battery.request',
  20. ],
  21. actions: {},
  22. };
  23. /**
  24. * Battery Plugin
  25. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/battery
  26. */
  27. const BatteryPlugin = GObject.registerClass({
  28. GTypeName: 'GSConnectBatteryPlugin',
  29. }, class BatteryPlugin extends Plugin {
  30. _init(device) {
  31. super._init(device, 'battery');
  32. // Setup Cache; defaults are 90 minute charge, 1 day discharge
  33. this._chargeState = [54, 0, -1];
  34. this._dischargeState = [864, 0, -1];
  35. this._thresholdLevel = 25;
  36. this.cacheProperties([
  37. '_chargeState',
  38. '_dischargeState',
  39. '_thresholdLevel',
  40. ]);
  41. // Export battery state as GAction
  42. this.__state = new Gio.SimpleAction({
  43. name: 'battery',
  44. parameter_type: new GLib.VariantType('(bsii)'),
  45. state: this.state,
  46. });
  47. this.device.add_action(this.__state);
  48. // Local Battery (UPower)
  49. this._upower = null;
  50. this._sendStatisticsId = this.settings.connect(
  51. 'changed::send-statistics',
  52. this._onSendStatisticsChanged.bind(this)
  53. );
  54. this._onSendStatisticsChanged(this.settings);
  55. }
  56. get charging() {
  57. if (this._charging === undefined)
  58. this._charging = false;
  59. return this._charging;
  60. }
  61. get icon_name() {
  62. let icon;
  63. if (this.level === -1)
  64. return 'battery-missing-symbolic';
  65. else if (this.level === 100)
  66. return 'battery-full-charged-symbolic';
  67. else if (this.level < 3)
  68. icon = 'battery-empty';
  69. else if (this.level < 10)
  70. icon = 'battery-caution';
  71. else if (this.level < 30)
  72. icon = 'battery-low';
  73. else if (this.level < 60)
  74. icon = 'battery-good';
  75. else if (this.level >= 60)
  76. icon = 'battery-full';
  77. if (this.charging)
  78. return `${icon}-charging-symbolic`;
  79. return `${icon}-symbolic`;
  80. }
  81. get level() {
  82. // This is what KDE Connect returns if the remote battery plugin is
  83. // disabled or still being loaded
  84. if (this._level === undefined)
  85. this._level = -1;
  86. return this._level;
  87. }
  88. get time() {
  89. if (this._time === undefined)
  90. this._time = 0;
  91. return this._time;
  92. }
  93. get state() {
  94. return new GLib.Variant(
  95. '(bsii)',
  96. [this.charging, this.icon_name, this.level, this.time]
  97. );
  98. }
  99. cacheLoaded() {
  100. this._initEstimate();
  101. this._sendState();
  102. }
  103. clearCache() {
  104. this._chargeState = [54, 0, -1];
  105. this._dischargeState = [864, 0, -1];
  106. this._thresholdLevel = 25;
  107. this._initEstimate();
  108. }
  109. connected() {
  110. super.connected();
  111. this._requestState();
  112. this._sendState();
  113. }
  114. handlePacket(packet) {
  115. switch (packet.type) {
  116. case 'kdeconnect.battery':
  117. this._receiveState(packet);
  118. break;
  119. case 'kdeconnect.battery.request':
  120. this._sendState();
  121. break;
  122. }
  123. }
  124. _onSendStatisticsChanged() {
  125. if (this.settings.get_boolean('send-statistics'))
  126. this._monitorState();
  127. else
  128. this._unmonitorState();
  129. }
  130. /**
  131. * Recalculate and update the estimated time remaining, but not the rate.
  132. */
  133. _initEstimate() {
  134. let rate, level;
  135. // elision of [rate, time, level]
  136. if (this.charging)
  137. [rate,, level] = this._chargeState;
  138. else
  139. [rate,, level] = this._dischargeState;
  140. if (!Number.isFinite(rate) || rate < 1)
  141. rate = this.charging ? 864 : 90;
  142. if (!Number.isFinite(level) || level < 0)
  143. level = this.level;
  144. // Update the time remaining
  145. if (rate && this.charging)
  146. this._time = Math.floor(rate * (100 - level));
  147. else if (rate && !this.charging)
  148. this._time = Math.floor(rate * level);
  149. this.__state.state = this.state;
  150. }
  151. /**
  152. * Recalculate the (dis)charge rate and update the estimated time remaining.
  153. */
  154. _updateEstimate() {
  155. let rate, time, level;
  156. const newTime = Math.floor(Date.now() / 1000);
  157. const newLevel = this.level;
  158. // Load the state; ensure we have sane values for calculation
  159. if (this.charging)
  160. [rate, time, level] = this._chargeState;
  161. else
  162. [rate, time, level] = this._dischargeState;
  163. if (!Number.isFinite(rate) || rate < 1)
  164. rate = this.charging ? 54 : 864;
  165. if (!Number.isFinite(time) || time <= 0)
  166. time = newTime;
  167. if (!Number.isFinite(level) || level < 0)
  168. level = newLevel;
  169. // Update the rate; use a weighted average to account for missed changes
  170. // NOTE: (rate = seconds/percent)
  171. const ldiff = this.charging ? newLevel - level : level - newLevel;
  172. const tdiff = newTime - time;
  173. const newRate = tdiff / ldiff;
  174. if (newRate && Number.isFinite(newRate))
  175. rate = Math.floor((rate * 0.4) + (newRate * 0.6));
  176. // Store the state for the next recalculation
  177. if (this.charging)
  178. this._chargeState = [rate, newTime, newLevel];
  179. else
  180. this._dischargeState = [rate, newTime, newLevel];
  181. // Update the time remaining
  182. if (rate && this.charging)
  183. this._time = Math.floor(rate * (100 - newLevel));
  184. else if (rate && !this.charging)
  185. this._time = Math.floor(rate * newLevel);
  186. this.__state.state = this.state;
  187. }
  188. /**
  189. * Notify the user the remote battery is full.
  190. */
  191. _fullBatteryNotification() {
  192. if (!this.settings.get_boolean('full-battery-notification'))
  193. return;
  194. // Offer the option to ring the device, if available
  195. let buttons = [];
  196. if (this.device.get_action_enabled('ring')) {
  197. buttons = [{
  198. label: _('Ring'),
  199. action: 'ring',
  200. parameter: null,
  201. }];
  202. }
  203. this.device.showNotification({
  204. id: 'battery|full',
  205. // TRANSLATORS: eg. Google Pixel: Battery is full
  206. title: _('%s: Battery is full').format(this.device.name),
  207. // TRANSLATORS: when the battery is fully charged
  208. body: _('Fully Charged'),
  209. icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
  210. buttons: buttons,
  211. });
  212. }
  213. /**
  214. * Notify the user the remote battery is at custom charge level.
  215. */
  216. _customBatteryNotification() {
  217. if (!this.settings.get_boolean('custom-battery-notification'))
  218. return;
  219. // Offer the option to ring the device, if available
  220. let buttons = [];
  221. if (this.device.get_action_enabled('ring')) {
  222. buttons = [{
  223. label: _('Ring'),
  224. action: 'ring',
  225. parameter: null,
  226. }];
  227. }
  228. this.device.showNotification({
  229. id: 'battery|custom',
  230. // TRANSLATORS: eg. Google Pixel: Battery has reached custom charge level
  231. title: _('%s: Battery has reached custom charge level').format(this.device.name),
  232. // TRANSLATORS: when the battery has reached custom charge level
  233. body: _('%d%% Charged').format(this.level),
  234. icon: Gio.ThemedIcon.new('battery-full-charged-symbolic'),
  235. buttons: buttons,
  236. });
  237. }
  238. /**
  239. * Notify the user the remote battery is low.
  240. */
  241. _lowBatteryNotification() {
  242. if (!this.settings.get_boolean('low-battery-notification'))
  243. return;
  244. // Offer the option to ring the device, if available
  245. let buttons = [];
  246. if (this.device.get_action_enabled('ring')) {
  247. buttons = [{
  248. label: _('Ring'),
  249. action: 'ring',
  250. parameter: null,
  251. }];
  252. }
  253. this.device.showNotification({
  254. id: 'battery|low',
  255. // TRANSLATORS: eg. Google Pixel: Battery is low
  256. title: _('%s: Battery is low').format(this.device.name),
  257. // TRANSLATORS: eg. 15% remaining
  258. body: _('%d%% remaining').format(this.level),
  259. icon: Gio.ThemedIcon.new('battery-caution-symbolic'),
  260. buttons: buttons,
  261. });
  262. }
  263. /**
  264. * Handle a remote battery update.
  265. *
  266. * @param {Core.Packet} packet - A kdeconnect.battery packet
  267. */
  268. _receiveState(packet) {
  269. // Charging state changed
  270. this._charging = packet.body.isCharging;
  271. // Level changed
  272. if (this._level !== packet.body.currentCharge) {
  273. this._level = packet.body.currentCharge;
  274. // If the level is above the threshold hide the notification
  275. if (this._level > this._thresholdLevel)
  276. this.device.hideNotification('battery|low');
  277. // The level just changed to/from custom level while charging
  278. if ((this._level === this.settings.get_uint('custom-battery-notification-value')) && this._charging)
  279. this._customBatteryNotification();
  280. else
  281. this.device.hideNotification('battery|custom');
  282. // The level just changed to/from full
  283. if (this._level === 100)
  284. this._fullBatteryNotification();
  285. else
  286. this.device.hideNotification('battery|full');
  287. }
  288. // Device considers the level low
  289. if (packet.body.thresholdEvent > 0) {
  290. this._lowBatteryNotification();
  291. this._thresholdLevel = this.level;
  292. }
  293. this._updateEstimate();
  294. }
  295. /**
  296. * Request the remote battery's current state
  297. */
  298. _requestState() {
  299. this.device.sendPacket({
  300. type: 'kdeconnect.battery.request',
  301. body: {request: true},
  302. });
  303. }
  304. /**
  305. * Report the local battery's current state
  306. */
  307. _sendState() {
  308. if (this._upower === null || !this._upower.is_present)
  309. return;
  310. this.device.sendPacket({
  311. type: 'kdeconnect.battery',
  312. body: {
  313. currentCharge: this._upower.level,
  314. isCharging: this._upower.charging,
  315. thresholdEvent: this._upower.threshold,
  316. },
  317. });
  318. }
  319. /*
  320. * UPower monitoring methods
  321. */
  322. _monitorState() {
  323. try {
  324. // Currently only true if the remote device is a desktop (rare)
  325. const incoming = this.device.settings.get_strv('incoming-capabilities');
  326. if (!incoming.includes('kdeconnect.battery'))
  327. return;
  328. this._upower = Components.acquire('upower');
  329. this._upowerId = this._upower.connect(
  330. 'changed',
  331. this._sendState.bind(this)
  332. );
  333. this._sendState();
  334. } catch (e) {
  335. logError(e, this.device.name);
  336. this._unmonitorState();
  337. }
  338. }
  339. _unmonitorState() {
  340. try {
  341. if (this._upower === null)
  342. return;
  343. this._upower.disconnect(this._upowerId);
  344. this._upower = Components.release('upower');
  345. } catch (e) {
  346. logError(e, this.device.name);
  347. }
  348. }
  349. destroy() {
  350. this.device.remove_action('battery');
  351. this.settings.disconnect(this._sendStatisticsId);
  352. this._unmonitorState();
  353. super.destroy();
  354. }
  355. });
  356. export default BatteryPlugin;