// SPDX-License-Identifier: AGPL-3.0-or-later /* * Battery and power-supply state, from UPower over the DOM D-Bus bridge. * * Two entry points, both extending EventTarget so callers can use * `addEventListener`: * - `upower` : the system singleton. Whether we are on battery, the * batteries present, live add/remove. * - `PowerDevice` : one power source. A battery, but also line power, a UPS, * or a peripheral that reports its own charge. * * Reading is cheap and snapshot-based: a device pulls all its properties in one * `GetAll` round trip and serves getters from that, so a UI can render a whole * battery panel from one call. `refresh()` re-reads; `startMonitoring()` keeps * it current from `PropertiesChanged`. * * UPower reference: https://upower.freedesktop.org/docs/ */ import { PROPS_IFACE, SYSTEM, busAvailable, callMethod, enumName, getAllProperties, getProperty, hub, } from "beaver://shared/dbus/bus.js"; const UPOWER_SERVICE = "org.freedesktop.UPower"; const UPOWER_PATH = "/org/freedesktop/UPower"; const UPOWER_IFACE = "org.freedesktop.UPower"; const DEVICE_IFACE = "org.freedesktop.UPower.Device"; // --------------------------------------------------------------------------- // Enums. Values are UPower's own; see its `up-types.h`. // --------------------------------------------------------------------------- // What kind of power source a device is (UpDeviceKind). export const DeviceType = { UNKNOWN: 0, LINE_POWER: 1, BATTERY: 2, UPS: 3, MONITOR: 4, MOUSE: 5, KEYBOARD: 6, PDA: 7, PHONE: 8, MEDIA_PLAYER: 9, TABLET: 10, COMPUTER: 11, GAMING_INPUT: 12, PEN: 13, TOUCHPAD: 14, MODEM: 15, NETWORK: 16, HEADSET: 17, SPEAKERS: 18, HEADPHONES: 19, VIDEO: 20, OTHER_AUDIO: 21, REMOTE_CONTROL: 22, PRINTER: 23, SCANNER: 24, CAMERA: 25, WEARABLE: 26, TOY: 27, BLUETOOTH_GENERIC: 28, }; // Charge state (UpDeviceState). export const DeviceState = { UNKNOWN: 0, CHARGING: 1, DISCHARGING: 2, EMPTY: 3, FULLY_CHARGED: 4, PENDING_CHARGE: 5, PENDING_DISCHARGE: 6, }; // Cell chemistry (UpDeviceTechnology). export const DeviceTechnology = { UNKNOWN: 0, LITHIUM_ION: 1, LITHIUM_POLYMER: 2, LITHIUM_IRON_PHOSPHATE: 3, LEAD_ACID: 4, NICKEL_CADMIUM: 5, NICKEL_METAL_HYDRIDE: 6, }; // How urgent the charge situation is (UpDeviceLevel). export const WarningLevel = { UNKNOWN: 0, NONE: 1, DISCHARGING: 2, // UPS only LOW: 3, CRITICAL: 4, ACTION: 5, }; /** * Coarse charge level, for devices that report buckets rather than a percentage * (many Bluetooth peripherals). `NONE` means "ignore this, use percentage". */ export const BatteryLevel = { UNKNOWN: 0, NONE: 1, LOW: 3, CRITICAL: 4, NORMAL: 6, HIGH: 7, FULL: 8, }; export const deviceTypeName = (value) => enumName(DeviceType, value); export const deviceStateName = (value) => enumName(DeviceState, value); export const technologyName = (value) => enumName(DeviceTechnology, value); export const warningLevelName = (value) => enumName(WarningLevel, value); // --------------------------------------------------------------------------- // Local helpers, bound to the UPower service // --------------------------------------------------------------------------- const call = (path, iface, method, args = []) => callMethod({ destination: UPOWER_SERVICE, path, iface, method, args }); const prop = (path, iface, property) => getProperty({ destination: UPOWER_SERVICE, path, iface, property }); const allProps = (path, iface) => getAllProperties({ destination: UPOWER_SERVICE, path, iface }); // UPower reports "unknown" durations as 0 rather than omitting them, which is // indistinguishable from "zero seconds remaining" unless you know. Map it to // null so callers cannot accidentally render "0:00 remaining". function duration(seconds) { return typeof seconds === "number" && seconds > 0 ? seconds : null; } // --------------------------------------------------------------------------- // PowerDevice // --------------------------------------------------------------------------- /** * One power source. * * Getters read a cached snapshot taken by `refresh()` so they are synchronous and free. * A device that has never been refreshed reports zeroes and `UNKNOWN`. * * Events: "changed", with `detail.properties` naming what UPower said changed. */ export class PowerDevice extends EventTarget { constructor(path, properties) { super(); this._path = path; this._props = properties || {}; this._off = null; } get path() { return this._path; } /** The raw property snapshot, for anything not surfaced below. */ get properties() { return this._props; } get type() { return this._props.Type ?? DeviceType.UNKNOWN; } get vendor() { return this._props.Vendor || ""; } get model() { return this._props.Model || ""; } get serial() { return this._props.Serial || ""; } // Kernel name, e.g. "BAT0". Useful as a stable-ish label. get nativePath() { return this._props.NativePath || ""; } /** * True when this device powers the system, rather than being a peripheral * that happens to report a battery (a wireless mouse, say). This is the flag * to filter on before showing something as "the" battery. */ get isPowerSupply() { return !!this._props.PowerSupply; } get isPresent() { return !!this._props.IsPresent; } get isRechargeable() { return !!this._props.IsRechargeable; } get isBattery() { return this.type === DeviceType.BATTERY; } get isLinePower() { return this.type === DeviceType.LINE_POWER; } // Charge in percent, 0..100 get percentage() { return this._props.Percentage ?? 0; } get state() { return this._props.State ?? DeviceState.UNKNOWN; } get stateName() { return deviceStateName(this.state); } get isCharging() { return ( this.state === DeviceState.CHARGING || this.state === DeviceState.PENDING_CHARGE ); } get isDischarging() { return this.state === DeviceState.DISCHARGING; } get isFullyCharged() { return this.state === DeviceState.FULLY_CHARGED; } /** * For line power only: whether something is plugged into it. Meaningless on * batteries, where UPower leaves it false. */ get online() { return !!this._props.Online; } // Seconds until empty, or null when UPower does not know. get timeToEmpty() { return duration(this._props.TimeToEmpty); } // Seconds until full, or null when UPower does not know. get timeToFull() { return duration(this._props.TimeToFull); } /** * Seconds until the state that is actually approaching: empty while * discharging, full while charging. Null when unknown or neither applies. */ get timeRemaining() { if (this.isCharging) { return this.timeToFull; } if (this.isDischarging) { return this.timeToEmpty; } return null; } // Current charge in watt-hours. get energy() { return this._props.Energy ?? 0; } // Charge when full, in watt-hours. Shrinks as the cell ages. get energyFull() { return this._props.EnergyFull ?? 0; } // Charge when new, in watt-hours. get energyFullDesign() { return this._props.EnergyFullDesign ?? 0; } /** * Rate of change in watts: what is being drawn while discharging, or pushed * in while charging. 0 when idle or unknown. */ get energyRate() { return this._props.EnergyRate ?? 0; } get voltage() { return this._props.Voltage ?? 0; } // Degrees Celsius, 0 when the device does not report it. get temperature() { return this._props.Temperature ?? 0; } get chargeCycles() { // -1 means unknown, which is not a cycle count any caller wants to show. const cycles = this._props.ChargeCycles; return typeof cycles === "number" && cycles >= 0 ? cycles : null; } /** * Health as a percentage of design capacity. Null when unknown; UPower * reports 0 for devices that cannot measure it, which would read as a dead * battery rather than as missing data. */ get capacity() { const capacity = this._props.Capacity; return typeof capacity === "number" && capacity > 0 ? capacity : null; } get technology() { return this._props.Technology ?? DeviceTechnology.UNKNOWN; } get warningLevel() { return this._props.WarningLevel ?? WarningLevel.UNKNOWN; } /** * Coarse level for devices that report buckets instead of a percentage. * `BatteryLevel.NONE` means the percentage is the real answer. */ get batteryLevel() { return this._props.BatteryLevel ?? BatteryLevel.UNKNOWN; } // The icon name UPower suggests, from the freedesktop naming spec. get iconName() { return this._props.IconName || ""; } // When UPower last updated this device, as a Date, or null. get updated() { const seconds = this._props.UpdateTime; return typeof seconds === "number" && seconds > 0 ? new Date(seconds * 1000) : null; } // Re-read every property. One round trip. async refresh() { this._props = await allProps(this._path, DEVICE_IFACE); return this; } /** * Keep the snapshot current from `PropertiesChanged`, emitting "changed". * Idempotent; returns `this` so it can be chained onto a constructor. */ startMonitoring() { if (this._off) { return this; } this._off = hub.on( SYSTEM, PROPS_IFACE, "PropertiesChanged", this._path, (detail) => { // args = (interface_name, changed_properties, invalidated_properties) const [iface, changed] = detail.args || []; if (iface !== DEVICE_IFACE || !changed) { return; } Object.assign(this._props, changed); this.dispatchEvent( new CustomEvent("changed", { detail: { properties: Object.keys(changed) }, }), ); }, ); return this; } stopMonitoring() { if (this._off) { this._off(); this._off = null; } return this; } } // --------------------------------------------------------------------------- // UPower // --------------------------------------------------------------------------- /** * The UPower daemon. * * Events (after `startMonitoring()`): "changed" when the daemon's own * properties move (chiefly `OnBattery`), "device-added" and "device-removed" * with `detail.path`. */ export class UPower extends EventTarget { constructor() { super(); this._subs = null; } /** True when this page can reach the D-Bus bridge at all. */ get available() { return busAvailable(); } /** * Whether the system is running from a battery. * * UPower defines this over power-supply devices only, so a Bluetooth mouse * reporting a low battery never affects it. False on a machine with no * battery at all, which is the right answer: it is on mains. */ onBattery() { return prop(UPOWER_PATH, UPOWER_IFACE, "OnBattery").then( (value) => !!value, ); } // Whether the system is on external power. The inverse of `onBattery()`. async pluggedIn() { return !(await this.onBattery()); } /** * What UPower will do when the battery becomes critical, as a bare string * ("HybridSleep", "Hibernate", "PowerOff", "Ignore"). */ criticalAction() { return call(UPOWER_PATH, UPOWER_IFACE, "GetCriticalAction"); } lidIsPresent() { return prop(UPOWER_PATH, UPOWER_IFACE, "LidIsPresent").then( (value) => !!value, ); } lidIsClosed() { return prop(UPOWER_PATH, UPOWER_IFACE, "LidIsClosed").then( (value) => !!value, ); } daemonVersion() { return prop(UPOWER_PATH, UPOWER_IFACE, "DaemonVersion"); } /** Object paths of every device UPower knows about. */ devicePaths() { return call(UPOWER_PATH, UPOWER_IFACE, "EnumerateDevices").then( (paths) => paths || [], ); } /** * Every device, each already carrying a full property snapshot. Properties * are fetched in parallel, so this is one round trip plus one per device * rather than a serial walk. */ async devices() { const paths = await this.devicePaths(); const snapshots = await Promise.all( paths.map((path) => allProps(path, DEVICE_IFACE).catch(() => null)), ); return paths .map((path, index) => snapshots[index] ? new PowerDevice(path, snapshots[index]) : null, ) .filter(Boolean); } /** * The batteries that power the system, most charged last so `[0]` is the one * to worry about. Peripherals with their own cells are excluded: they are * batteries, but not this machine's. */ async batteries() { const devices = await this.devices(); return devices .filter((device) => device.isBattery && device.isPowerSupply) .sort((a, b) => a.percentage - b.percentage); } // The line-power (mains) devices, if any. async linePowerDevices() { const devices = await this.devices(); return devices.filter((device) => device.isLinePower); } /** * UPower's synthetic summary device: one aggregate battery standing for * all of them, which is what a status indicator should usually show. Null * when the daemon offers none. * * Its `Type` is BATTERY even on a desktop with no battery, so check * `isPresent` before rendering it. */ async displayDevice() { const path = await call(UPOWER_PATH, UPOWER_IFACE, "GetDisplayDevice"); if (!path || path === "/") { return null; } const device = new PowerDevice(path); await device.refresh(); return device; } // Emit daemon property changes and device add/remove. Idempotent. startMonitoring() { if (this._subs) { return this; } this._subs = [ hub.on( SYSTEM, PROPS_IFACE, "PropertiesChanged", UPOWER_PATH, (detail) => { const [iface, changed] = detail.args || []; if (iface !== UPOWER_IFACE || !changed) { return; } this.dispatchEvent( new CustomEvent("changed", { detail: { properties: Object.keys(changed), values: changed }, }), ); }, ), hub.on(SYSTEM, UPOWER_IFACE, "DeviceAdded", "", (detail) => { this.dispatchEvent( new CustomEvent("device-added", { detail: { path: detail.args?.[0] }, }), ); }), hub.on(SYSTEM, UPOWER_IFACE, "DeviceRemoved", "", (detail) => { this.dispatchEvent( new CustomEvent("device-removed", { detail: { path: detail.args?.[0] }, }), ); }), ]; return this; } stopMonitoring() { if (this._subs) { this._subs.forEach((off) => off()); this._subs = null; } return this; } } // Shared instance: UPower is a system singleton, subscriptions are global. export const upower = new UPower();