Files

828 lines
26 KiB
JavaScript

/**
* Driver for handling thresholds in Lenovo ThinkPad series for models since 2011
*
* Based on information from https://linrunner.de/tlp/faq/battery.html
*
* Original sources: https://github.com/linrunner/TLP/blob/main/bat.d/05-thinkpad
*/
'use strict';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
/*
* On some models the start threshold 0 is not allowed, instead 95 is used (Ex: E14 Gen 3)
* See issues:
* #8: https://gitlab.com/marcosdalvarez/thinkpad-battery-threshold-extension/-/issues/8
* #20: https://gitlab.com/marcosdalvarez/thinkpad-battery-threshold-extension/-/issues/20
*/
const ALTERNATIVE_DISABLED_START = /(Think[pP]ad) (E14 Gen 3|P14s Gen 2a)/;
// Driver constants
const BASE_PATH = '/sys/class/power_supply';
const START_FILE_OLD = 'charge_start_threshold'; // kernel 4.17 and newer
const END_FILE_OLD = 'charge_stop_threshold'; // kernel 4.17 and newer
const START_FILE_NEW = 'charge_control_start_threshold'; // kernel 5.9 and newer
const END_FILE_NEW = 'charge_control_end_threshold'; // kernel 5.9 and newer
/**
* Read file contents
*
* @param {string} path Path of file
* @returns {string} File contents
*/
const readFile = function (path) {
try {
const f = Gio.File.new_for_path(path);
const [, contents,] = f.load_contents(null);
const decoder = new TextDecoder('utf-8');
return decoder.decode(contents);
} catch (e) {
return null;
}
}
/**
* Read integer value from file
*
* @param {string} path Path of file
* @returns {number|null} Return a integer or null
*/
const readFileInt = function (path) {
try {
const v = readFile(path);
if (v) {
return parseInt(v);
} else {
return null;
}
} catch (e) {
return null;
}
}
/**
* Test file/directory exists
*
* @param {string} path File/directory path
* @returns {boolean}
*/
const fileExists = function (path) {
try {
const f = Gio.File.new_for_path(path);
return f.query_exists(null);
} catch (e) {
return false;
}
}
/**
* Environment object
*/
const Environment = GObject.registerClass({
GTypeName: 'Environment',
}, class Environment extends GObject.Object {
get productVersion() {
if (this._productVersion === undefined) {
const tmp = readFile('/sys/class/dmi/id/product_version');
if (tmp) {
// Remove non-alphanumeric characters
const sanitize = /([^ A-Za-z0-9])+/g;
this._productVersion = tmp.replace(sanitize, '');
} else {
this._productVersion = null;
}
}
return this._productVersion;
}
get kernelRelease() {
if (this._kernelRelease === undefined) {
try {
let proc = Gio.Subprocess.new(
['uname', '-r'],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
const [ok, stdout, stderr] = proc.communicate_utf8(null, null);
proc = null;
this._kernelRelease = stdout.trim();
} catch (e) {
logError(e);
}
}
return this._kernelRelease;
}
get kernelMajorVersion() {
if (this._kernelMajorVersion === undefined) {
[this._kernelMajorVersion, this._kernelMinorVersion] = this.kernelRelease.split('.', 2);
}
return this._kernelMajorVersion;
}
get kernelMinorVersion() {
if (this._kernelMinorVersion === undefined) {
[this._kernelMajorVersion, this._kernelMinorVersion] = this.kernelRelease.split('.', 2);
}
return this._kernelMinorVersion;
}
checkMinKernelVersion(major, minor) {
return (
this.kernelMajorVersion > major ||
(this.kernelMajorVersion === major && this.kernelMinorVersion >= minor)
);
}
});
/**
* Execute a command and generate events based on its result
*
* @param {string} command Command to execute
* @param {boolean} asRoot True to run with elevated permissions
*/
const Runnable = GObject.registerClass({
GTypeName: 'Runnable',
Signals: {
'command-completed': {
param_types: [GObject.TYPE_JSOBJECT /* error */]
}
}
}, class Runnable extends GObject.Object {
constructor(command, asRoot = false) {
super();
if (!command) {
throw Error('The command cannot be an empty string');
}
this._command = command;
this._asRoot = asRoot;
}
run() {
const argv = ['sh', '-c', this._command];
if (this._asRoot) {
argv.unshift('pkexec');
}
try {
const [, pid] = GLib.spawn_async(null, argv, null, GLib.SpawnFlags.SEARCH_PATH | GLib.SpawnFlags.DO_NOT_REAP_CHILD, null);
GLib.child_watch_add(GLib.PRIORITY_DEFAULT_IDLE, pid, (pid, status) => {
try {
GLib.spawn_check_exit_status(status);
this.emit('command-completed', null);
} catch (e) {
if (e.code == 126) {
// Cancelled
} else {
logError(e);
this.emit('command-completed', e);
}
}
GLib.spawn_close_pid(pid);
});
} catch (e) {
logError(e);
this.emit('command-completed', e);
}
}
});
/**
* ThinkPad battery object
*/
export const ThinkPadBattery = GObject.registerClass({
GTypeName: 'ThinkPadBattery',
Properties: {
'environment': GObject.ParamSpec.object(
'environment',
'Environment',
'Environment',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
Environment.$gtype
),
'name': GObject.ParamSpec.string(
'name',
'Name',
'Battery name',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
null
),
'auth-required': GObject.ParamSpec.boolean(
'auth-required',
'Auth required',
'Authorization is required to write the values',
GObject.ParamFlags.READABLE,
false
),
'is-active': GObject.ParamSpec.boolean(
'is-active',
'Is active',
'Indicates if the thresholds are active or not',
GObject.ParamFlags.READABLE,
false
),
'is-available': GObject.ParamSpec.boolean(
'is-available',
'Is available',
'Indicates if the battery are available or not',
GObject.ParamFlags.READABLE,
false
),
'pending-changes': GObject.ParamSpec.boolean(
'pending-changes',
'Pending changes',
'Indicates if the current values do not match the configured values',
GObject.ParamFlags.READABLE,
false
),
'start-value': GObject.ParamSpec.int(
'start-value',
'Start value',
'Current start value',
GObject.ParamFlags.READABLE,
0, 100,
0
),
'end-value': GObject.ParamSpec.int(
'end-value',
'End value',
'Current end value',
GObject.ParamFlags.READABLE,
0, 100,
100
),
'settings': GObject.ParamSpec.object(
'settings',
'Settings',
'Settings',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
Gio.Settings.$gtype
),
},
Signals: {
'enable-completed': {
param_types: [GObject.TYPE_JSOBJECT /* error */]
},
'disable-completed': {
param_types: [GObject.TYPE_JSOBJECT /* error */]
},
},
}, class ThinkPadBattery extends GObject.Object {
constructor(constructProperties = {}) {
super(constructProperties);
if (!this.name) {
throw Error('Battery name not defined');
}
// Battery directory
this._baseDirectoryPath = `${BASE_PATH}/${this.name}`;
this._baseDirectory = Gio.File.new_for_path(this._baseDirectoryPath);
// Set paths
if (this.environment.checkMinKernelVersion(5, 9)) { // kernel 5.9 and newer
this._startFilePath = `${BASE_PATH}/${this.name}/${START_FILE_NEW}`;
this._endFilePath = `${BASE_PATH}/${this.name}/${END_FILE_NEW}`;
} else if (this.environment.checkMinKernelVersion(4, 17)) { // kernel 4.17 and newer
this._startFilePath = `${BASE_PATH}/${this.name}/${START_FILE_OLD}`;
this._endFilePath = `${BASE_PATH}/${this.name}/${END_FILE_OLD}`;
} else { // Unsupported kernel
throw Error(`Unsupported kernel version (${this.environment.kernelRelease}). A kernel version greater than or equal to 4.17 is required.`);
}
// Activate the directory monitor
try {
this._baseMonitor = this._baseDirectory.monitor_directory(Gio.FileMonitorFlags.NONE, null);
this._monitorId = this._baseMonitor.connect(
'changed', (obj, file, otherFile, eventType) => {
const filePath = file.get_path();
switch (eventType) {
case Gio.FileMonitorEvent.CHANGES_DONE_HINT:
case Gio.FileMonitorEvent.CREATED:
case Gio.FileMonitorEvent.DELETED:
switch (filePath) {
case this._startFilePath:
this.startValue = readFileInt(this._startFilePath);
break;
case this._endFilePath:
this.endValue = readFileInt(this._endFilePath);
break;
case this._baseDirectoryPath:
this.startValue = readFileInt(this._startFilePath);
this.endValue = readFileInt(this._endFilePath);
break;
default:
break;
}
break;
case Gio.FileMonitorEvent.ATTRIBUTE_CHANGED:
this.authRequired = this._checkAuthRequired();
break;
default:
break;
}
}
);
} catch (e) {
logError(e, this.name);
}
this._notifyHandlerId = this.connect('notify', () => this._updateStatuses());
this._settingsHandlerId = this.settings.connect('changed', () => this._updateStatuses());
// Load initial values
this.startValue = readFileInt(this._startFilePath);
this.endValue = readFileInt(this._endFilePath);
this.authRequired = this._checkAuthRequired();
}
/**
* Update the statuses
*/
_updateStatuses() {
// Check if is available
this.isAvailable = this.startValue !== null || this.endValue !== null;
// Check if thresholds is active
const disabledStartValue = this.settings.get_int(`disabled-start-${this.name.toLowerCase()}-value`);
if (disabledStartValue === -1) {
// Use model detection
if (this.environment.productVersion.search(ALTERNATIVE_DISABLED_START) >= 0) {
this.isActive = (this.startValue !== null && this.startValue !== 95) || (this.endValue !== null && this.endValue !== 100);
} else {
this.isActive = (this.startValue !== null && this.startValue !== 0) || (this.endValue !== null && this.endValue !== 100);
}
} else {
// Use user settings
this.isActive = (this.startValue !== null && this.startValue !== disabledStartValue) || (this.endValue !== null && this.endValue !== 100);
}
const startSetting = this.settings.get_int(`start-${this.name.toLowerCase()}`);
const endSetting = this.settings.get_int(`end-${this.name.toLowerCase()}`);
if (this.isActive) {
this.pendingChanges = (this.startValue !== null && this.startValue !== startSetting) || (this.endValue !== null && this.endValue !== endSetting);
} else {
this.pendingChanges = false;
}
}
/**
* Check if authentication is required to apply the changes
*
* @returns {boolean} Returns true if authentication is required to apply the changes
*/
_checkAuthRequired() {
try {
const f = Gio.File.new_for_path(this._startFilePath);
const info = f.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
if (!info.get_attribute_boolean('access::can-write')) {
return true;
}
} catch (e) {
// Ignored
}
try {
const f = Gio.File.new_for_path(this._endFilePath);
const info = f.query_info('access::*', Gio.FileQueryInfoFlags.NONE, null);
if (!info.get_attribute_boolean('access::can-write')) {
return true;
}
} catch (e) {
// Ignored
}
return false;
}
get authRequired() {
return this._authRequired;
}
set authRequired(value) {
if (this.authRequired !== value) {
this._authRequired = value;
this.notify('auth-required');
}
}
get isActive() {
return this._isActive;
}
set isActive(value) {
if (this.isActive !== value) {
this._isActive = value;
this.notify('is-active');
}
}
get isAvailable() {
return this._isAvailable;
}
set isAvailable(value) {
if (this.isAvailable !== value) {
this._isAvailable = value;
this.notify('is-available');
}
}
get startValue() {
return this._startValue;
}
set startValue(value) {
if (this.startValue !== value) {
this._startValue = value;
this.notify('start-value');
}
}
get endValue() {
return this._endValue;
}
set endValue(value) {
if (this.endValue !== value) {
this._endValue = value;
this.notify('end-value');
}
}
get pendingChanges() {
return this._pendingChanges;
}
set pendingChanges(value) {
if (this.pendingChanges !== value) {
this._pendingChanges = value;
this.notify('pending-changes');
}
}
/**
* Returns the command to set the thresholds.
* This function does not run the command, it just returns the string corresponding to the command.
* Passed values are not validated by the function, it is your responsibility to validate them first.
*
* @param {number} start Start value
* @param {number} end End value
* @returns {string|null} Command to modify the thresholds
*/
_getSetterCommand(start, end) {
if (!this.isAvailable) {
return null;
}
if (!Number.isInteger(start) || !Number.isInteger(end)) {
throw TypeError(`Thresholds (${start}/${end}) on battery (${this.name}) are not integer`);
}
if (start < 0 || end > 100 || start >= end) {
throw RangeError(`Thresholds (${start}/${end}) on battery (${this.name}) out of range`);
}
// Commands
const setStart = `echo ${start.toString()} > ${this._startFilePath}`;
const setEnd = `echo ${end.toString()} > ${this._endFilePath}`;
let oldStart = this.startValue;
let oldEnd = this.endValue;
if ((oldStart === start || oldStart === null) && (oldEnd === end || oldEnd === null)) {
// Same thresholds
return null;
}
if (oldStart >= oldEnd) {
// Invalid threshold reading, happens on ThinkPad E/L series
oldStart = null;
oldEnd = null;
}
let command = null;
if (fileExists(this._startFilePath) && fileExists(this._endFilePath)) {
if (oldStart === start) { // Same start, apply only stop
command = setEnd;
} else if (oldEnd === end) { // Same stop, apply only start
command = setStart;
} else {
// Determine sequence
let startStopSequence = true;
if (oldEnd != null && start > oldEnd) {
startStopSequence = false;
}
if (startStopSequence) {
command = setStart + ' && ' + setEnd;
} else {
command = setEnd + ' && ' + setStart;
}
}
} else if (fileExists(this._startFilePath)) { // Only start available
command = setStart;
} else if (fileExists(this._endFilePath)) { // Only stop available
command = setEnd;
}
return command;
}
/**
* Get the command string to enable the configured thresholds
*
* @returns {string|null} Enable thresholds command string
*/
get enableCommand() {
const startSetting = this.settings.get_int(`start-${this.name.toLowerCase()}`);
const endSetting = this.settings.get_int(`end-${this.name.toLowerCase()}`);
return this._getSetterCommand(startSetting, endSetting);
}
/**
* Get the command string to disable the thresholds
*
* @returns {string|null} Enable thresholds command string
*/
get disableCommand() {
return this._getSetterCommand(0, 100);
}
/**
* Enable the configured thresholds
*/
enable() {
const command = this.enableCommand;
if (!command) {
return;
}
const runnable = new Runnable(command, this.authRequired);
runnable.connect('command-completed', (obj, error) => {
this.emit('enable-completed', error);
})
runnable.run();
}
/**
* Disable thresholds
*/
disable() {
const command = this.disableCommand;
if (!command) {
return;
}
const runnable = new Runnable(command, this.authRequired);
runnable.connect('command-completed', (obj, error) => {
this.emit('disable-completed', error);
})
runnable.run();
}
/**
* Toggle thresholds status
*/
toggle() {
this.isActive ? this.disable() : this.enable();
}
destroy() {
if (this._notifyHandlerId) {
this.disconnect(this._notifyHandlerId)
}
if (this._settingsHandlerId) {
this.settings.disconnect(this._settingsHandlerId)
}
this._baseMonitor.cancel();
this._baseMonitor = null;
this._baseDirectory = null;
}
});
export const ThinkPad = GObject.registerClass({
GTypeName: 'ThinkPad',
Properties: {
'environment': GObject.ParamSpec.object(
'environment',
'Environment',
'Environment',
GObject.ParamFlags.READABLE,
Environment.$gtype
),
'is-active': GObject.ParamSpec.boolean(
'is-active',
'Is active',
'Indicates if the thresholds are active or not',
GObject.ParamFlags.READABLE,
false
),
'is-available': GObject.ParamSpec.boolean(
'is-available',
'Is available',
'Indicates if the battery are available or not',
GObject.ParamFlags.READABLE,
false
),
'pending-changes': GObject.ParamSpec.boolean(
'pending-changes',
'Pending changes',
'Indicates if the current values do not match the configured values',
GObject.ParamFlags.READABLE,
false
),
'batteries': GObject.ParamSpec.jsobject(
'batteries',
'Batteries',
'Batteries object',
GObject.ParamFlags.READWRITE,
[]
),
'settings': GObject.ParamSpec.object(
'settings',
'Settings',
'Settings',
GObject.ParamFlags.READWRITE | GObject.ParamFlags.CONSTRUCT_ONLY,
Gio.Settings.$gtype
),
},
Signals: {
'enable-battery-completed': {
param_types: [GObject.TYPE_OBJECT /* battery */, GObject.TYPE_JSOBJECT /* error */]
},
'disable-battery-completed': {
param_types: [GObject.TYPE_OBJECT /* battery */, GObject.TYPE_JSOBJECT /* error */]
},
'enable-all-completed': {
param_types: [GObject.TYPE_JSOBJECT /* error */]
},
'disable-all-completed': {
param_types: [GObject.TYPE_JSOBJECT /* error */]
},
},
}, class ThinkPad extends GObject.Object {
constructor(constructProperties = {}) {
super(constructProperties);
if (!this.environment.checkMinKernelVersion(4, 17)) {
logError(new Error(`Kernel ${this.environment.kernelRelease} not supported`));
this.batteries = [];
return;
}
// Signals handlers IDs
this._batteriesId = {};
// Define batteries
this.batteries = [
new ThinkPadBattery({
'environment': this.environment,
'name': 'BAT0',
'settings': this.settings
}),
new ThinkPadBattery({
'environment': this.environment,
'name': 'BAT1',
'settings': this.settings
}),
];
// Connect the signals from the batteries to update the flags and notify commands
this.batteries.forEach(battery => {
const notifyHandlerId = battery.connect('notify', () => this._updateStatuses());
const enableCompletedId = battery.connect(
'enable-completed', (bat, error) => {
this.emit('enable-battery-completed', bat, error);
}
);
const disableCompletedId = battery.connect(
'disable-completed', (bat, error) => {
this.emit('disable-battery-completed', bat, error);
}
);
this._batteriesId[battery.name] = [notifyHandlerId, enableCompletedId, disableCompletedId];
});
// Load initial values
this._updateStatuses();
}
get environment() {
if (this._environment === undefined) {
this._environment = new Environment();
}
return this._environment;
}
get isAvailable() {
return this._isAvailable;
}
set isAvailable(value) {
if (this.isAvailable !== value) {
this._isAvailable = value;
this.notify('is-available');
}
}
get isActive() {
return this._isActive;
}
set isActive(value) {
if (this.isActive !== value) {
this._isActive = value;
this.notify('is-active');
}
}
get pendingChanges() {
return this._pendingChanges;
}
set pendingChanges(value) {
if (this.pendingChanges !== value) {
this._pendingChanges = value;
this.notify('pending-changes');
}
}
/**
* Update driver status
*/
_updateStatuses() {
this.isAvailable = this.batteries.some(bat => {
return bat.isAvailable;
});
this.isActive = this.batteries.some(bat => {
return bat.isActive && bat.isAvailable;
});
this.pendingChanges = this.batteries.some(bat => {
return bat.pendingChanges;
});
}
/**
* Enable all configured thresholds
*/
enableAll() {
let command = '';
let authRequired = false;
this.batteries.forEach(battery => {
const batteryCommand = battery.enableCommand;
if (batteryCommand) {
command = `${command}${command ? ' && ' : ''}${batteryCommand}`;
authRequired = authRequired || battery.authRequired;
}
});
if (!command) {
return;
}
const runnable = new Runnable(command, authRequired);
runnable.connect('command-completed', (obj, error) => {
this.emit('enable-all-completed', error);
})
runnable.run();
}
/**
* Disable all thresholds
*/
disableAll() {
let command = '';
let authRequired = false;
this.batteries.forEach(battery => {
const batteryCommand = battery.disableCommand;
if (batteryCommand) {
command = `${command}${command ? ' && ' : ''}${batteryCommand}`;
authRequired = authRequired || battery.authRequired;
}
});
if (!command) {
return;
}
const runnable = new Runnable(command, authRequired);
runnable.connect('command-completed', (obj, error) => {
this.emit('disable-all-completed', error);
})
runnable.run();
}
destroy() {
if (this._batteriesId) {
this.batteries.forEach(battery => {
const ids = this._batteriesId[battery.name];
ids.forEach(id => {
battery.disconnect(id);
});
battery.destroy()
});
}
}
});