diff --git a/.gitignore b/.gitignore index 5561ef5..370412b 100644 --- a/.gitignore +++ b/.gitignore @@ -106,4 +106,6 @@ dist # Stats stats.html -.old/ \ No newline at end of file +.old/ + +run-local.ts diff --git a/README.md b/README.md index be2805f..25e5a09 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Panel indicator, Dropdown menu, Quick settings fan control](images/screens.png) # ThinkPad Thermal GNOME Shell Extension -Extension that displays thermal and fan status on ThinkPads +Extension that displays device info, thermals and fan status on ThinkPads ## Requirements - [thinkpad-acpi](https://www.kernel.org/doc/Documentation/laptops/thinkpad-acpi.txt), check contents of `/proc/acpi/ibm/thermal` and `/proc/acpi/ibm/fan` @@ -31,6 +31,41 @@ You can clone this repo and build the extension manually with `yarn build:packag - Run `./run-log.sh` and check the generated logs `hw.log, err.log` - Clone the repo, install dependencies, `yarn dev` and `./run-nested-shell.sh` +### Unsupported firmwares +Generated `hw.log` contains a message like the one below, see [quirks mode](#Quirks-mode) +``` +$ hw.log +... +thinkpad_acpi: ThinkPad firmware release *fw_str* doesn't match the known patterns +thinkpad_acpi: please report this to ibm-acpi-devel@lists.sourceforge.net +thinkpad_acpi: ThinkPad ACPI Extras v0.26 +... +``` + + +### Quirks mode +Can be enabled via extension settings. +- Disables fan control via the extension +- Derived readings for CPU/GPU via lm-sensors if those are not available via [thinkpad_acpi driver 0.26](https://github.com/torvalds/linux/blob/master/drivers/platform/x86/thinkpad_acpi.c) ie. `/proc/acpi/ibm/thermal` is missing and/or firmware is not supported. + +**Readings pipeline:** +- CPU: thinkpad-isa-\*.CPU > k10-temp-\*.Tctl > avg coretemp-isa-\* > 0 +- GPU: thinkpad-isa-\*.GPU > amdgpu-\*.edge > -128 +- FAN: avg thinkpad-isa-\*.fanX > 0 + +### Level 0 +This extension does not provide this option out of the box. Thinkpad-acpi docs regarding [fan levels](https://github.com/torvalds/linux/blob/master/Documentation/admin-guide/laptops/thinkpad-acpi.rst#fan-levels) contain a warning about setting the level to 0 ie. turning the fan off. + +``` +WARNING WARNING WARNING: do not leave the fan disabled unless you are +monitoring all of the temperature sensor readings and you are ready +to enable it if necessary to avoid overheating. +``` + +If you really want to have the option available, you can modify the `../extension.js` and change `DISABLED_LEVELS = [0, 'disengaged']` to `DISABLED_LEVELS = ['disengaged']`. + +**Note:** Level `disengaged` is an alias for `full-speed` + ## Todo - [x] thinkpad-acpi @@ -39,4 +74,5 @@ You can clone this repo and build the extension manually with `yarn build:packag - [x] lsblk - [x] Fan speed control - [x] Settings dialog + - [x] Quirks mode - [ ] Multilang diff --git a/resources/prefs.xml b/resources/prefs.xml index 10b9e92..1628361 100644 --- a/resources/prefs.xml +++ b/resources/prefs.xml @@ -35,6 +35,21 @@ + + + + + Enable quirks mode + Use when thinkpad_acpi is not providing the readings via the driver ie. unsupported firmware release. Disables fan control, derives cpu/gpu values if those are provided by lmsensors via coretemp, k10temp, amdgpu modules/drivers. + field_quirks_mode + + + center + true + + + + diff --git a/resources/schemas/gschemas.compiled b/resources/schemas/gschemas.compiled index e7bf2f4..d639a13 100644 Binary files a/resources/schemas/gschemas.compiled and b/resources/schemas/gschemas.compiled differ diff --git a/resources/schemas/org.gnome.shell.extensions.thinkpadthermal.gschema.xml b/resources/schemas/org.gnome.shell.extensions.thinkpadthermal.gschema.xml index 0357e13..d8bb4f1 100644 --- a/resources/schemas/org.gnome.shell.extensions.thinkpadthermal.gschema.xml +++ b/resources/schemas/org.gnome.shell.extensions.thinkpadthermal.gschema.xml @@ -9,6 +9,12 @@ Interval in seconds between temperature checks + + false + Enable quirks mode + Use when thinkpad_acpi is not providing the readings via the driver ie. unsupported firmware release. Disables fan control, derives cpu/gpu values if those are provided by lmsensors via coretemp, k10temp, amdgpu modules/drivers. + + diff --git a/run-log.sh b/run-log.sh index b611db1..e0e49af 100644 --- a/run-log.sh +++ b/run-log.sh @@ -12,6 +12,11 @@ dmi=( { echo -e "\n========== $(date) ==========" + # driver + journalctl -k -b | grep thinkpad_ + echo "==========" + ls /proc/acpi/ibm -l + # info echo -e "\n=== dmi ===" for file in "${dmi[@]}" do diff --git a/src/Console.ts b/src/Console.ts index e2cdab4..700642d 100644 --- a/src/Console.ts +++ b/src/Console.ts @@ -16,13 +16,39 @@ export default class ConsoleUtil extends GObject.Object { private _command: string[] - constructor(...args: string[]) { + protected config = {} as ThinkPadThermal.Config + protected setConfig(next = {}) { + this.config = { + ...this.config, + ...next, + } + } + protected data: unknown + protected setData(next = {}) { + this.data = { + ...(this.data || {}), + ...next, + } + } + + constructor( + cmd: string, + ...args: (string | ThinkPadThermal.Config | undefined)[] + ) { super() - assert(!!args[0], 'Util not defined') - assert(!!GLib.find_program_in_path(args[0]), `Util ${args[0]} not found`) + assert(!!cmd, 'Util not defined') + assert(!!GLib.find_program_in_path(cmd), `Util ${cmd} not found`) - this._command = ConsoleUtil.args(args.join(' ')) + let rest = args + const last = rest[rest.length - 1] + + if (typeof last === 'object') { + this.setConfig(last) + rest = args.slice(0, args.length - 1) + } + + this._command = ConsoleUtil.args([cmd, ...rest].join(' ')) if ( this.available && @@ -65,6 +91,10 @@ export default class ConsoleUtil extends GObject.Object { ) } catch (e) { logError(e) + if (/no such file or directory/i.test(e as string)) { + console.log('Disabling utility', this._command) + this._command = [] + } } } @@ -94,4 +124,9 @@ export default class ConsoleUtil extends GObject.Object { static revs(n: number) { return `${n} RPM` } + static average(values: number[]) { + return values.length + ? Math.ceil(values.reduce((acc, curr) => acc + curr, 0) / values.length) + : 0 + } } diff --git a/src/Dmi.ts b/src/Dmi.ts index 42e010a..c1af38a 100644 --- a/src/Dmi.ts +++ b/src/Dmi.ts @@ -31,22 +31,22 @@ export default class DmiUtil extends ConsoleUtil { 'sys_vendor', ] as const - private data: { - [K in (typeof DmiUtil.TAGS)[number]]: string - } + protected override data = {} as ThinkPadThermal.DmiData private parse(str: string) { - const values = str.split('\n') + const values = str.trim().split('\n') - this.data = DmiUtil.TAGS.reduce((acc, curr, i) => { - acc[curr] = (values[i] ?? '') - .replace(/\(\s+/g, '(') - .replace(/\s+\)/g, ')') - .trim() - return acc - }, {}) as typeof this.data + this.setData( + DmiUtil.TAGS.reduce((acc, curr, i) => { + acc[curr] = (values[i] ?? '') + .replace(/\(\s+/g, '(') + .replace(/\s+\)/g, ')') + .trim() + return acc + }, {}) as typeof this.data + ) - this.emit('updated', this.dmi) + this.emit('updated', { dmi: this.dmi }) } get dmi() { diff --git a/src/IbmAcpi.ts b/src/IbmAcpi.ts index 590d871..fec6046 100644 --- a/src/IbmAcpi.ts +++ b/src/IbmAcpi.ts @@ -1,52 +1,14 @@ import GObject from 'gi://GObject' import ConsoleUtil from './Console.js' -import microdiff, { type DifferenceChange } from './vendor/microdiff.js' export default class IbmAcpiUtil extends ConsoleUtil { static { GObject.registerClass( { - Properties: { - cpu: GObject.ParamSpec.string( - 'cpu', - 'CPU temperature', - 'Current CPU temperature', - GObject.ParamFlags.READABLE, - '...' - ), - gpu: GObject.ParamSpec.string( - 'gpu', - 'GPU temperature', - 'Current GPU temperature', - GObject.ParamFlags.READABLE, - '...' - ), - speed: GObject.ParamSpec.string( - 'speed', - 'Fan speed', - 'Current fan speed', - GObject.ParamFlags.READABLE, - '...' - ), - status: GObject.ParamSpec.string( - 'status', - 'Fan status', - 'Current fan status', - GObject.ParamFlags.READABLE, - '...' - ), - level: GObject.ParamSpec.string( - 'level', - 'Fan level', - 'Current fan level', - GObject.ParamFlags.READABLE, - '...' - ), - }, Signals: { updated: { - param_types: [GObject.TYPE_JSOBJECT, GObject.TYPE_JSOBJECT], + param_types: [GObject.TYPE_JSOBJECT], }, }, }, @@ -54,7 +16,23 @@ export default class IbmAcpiUtil extends ConsoleUtil { ) } - private data: ThinkPadThermal.IbmAcpiData = { + private static NOTIFY = [ + 'cpu', + 'gpu', + 'speed', + 'level', + 'status', + 'levels', + 'hasDedicatedGpu', + 'isControllable', + ] + private static CHECKS = [-128, 0] + private static DISABLED_LEVELS: (string | number)[] = [0, 'disengaged'] + + public static isValidSensor = (v: number): boolean => + IbmAcpiUtil.CHECKS.every((check) => check !== v) + + protected override data: ThinkPadThermal.IbmAcpiData = { cpu: 0, gpu: 0, status: 'disabled', @@ -62,12 +40,10 @@ export default class IbmAcpiUtil extends ConsoleUtil { level: 'auto', levels: [], } - private prev: ThinkPadThermal.IbmAcpiData | object = {} - private config: ThinkPadThermal.Config + private prev = {} as ThinkPadThermal.IbmAcpiData - constructor(config: ThinkPadThermal.Config) { - super('cat', '/proc/acpi/ibm/thermal', '/proc/acpi/ibm/fan') - this.update(config) + constructor(config?: ThinkPadThermal.Config) { + super('cat', '/proc/acpi/ibm/thermal', '/proc/acpi/ibm/fan', config) } // temperatures: 43 50 0 0 0 0 0 0 @@ -129,43 +105,22 @@ export default class IbmAcpiUtil extends ConsoleUtil { } async update(config?: object) { - if (config) { - this.config = { - ...(this.config || {}), - ...config, - } - } - - if (config && Object.keys(this.prev).length > 0) { - for (const k of IbmAcpiUtil.NOTIFY) this.notify(k) - const diffs = IbmAcpiUtil.NOTIFY.map( - (k) => - ({ - type: 'CHANGE', - path: [k], - value: this[k], - oldValue: '*', - }) as DifferenceChange - ) - this.emit('updated', IbmAcpiUtil.NOTIFY, diffs) - return - } - - try { - this.data = await super.execute(this.parse.bind(this)) - - const diff = microdiff(this.prev, this.data) - this.prev = this.data - - if (diff.length === 0) return + if (!this.available) return - const keys = diff - .flatMap(({ path }) => path as string[]) - .filter(IbmAcpiUtil.isNotifiable) + if (config) this.setConfig(config) - for (const k of keys) this.notify(k as string) + this.prev = this.data - this.emit('updated', keys, diff) + try { + this.setData(await super.execute(this.parse.bind(this))) + + this.emit( + 'updated', + IbmAcpiUtil.NOTIFY.reduce((acc, key) => { + acc[key] = this[key] + return acc + }, {}) + ) } catch (e) { logError(e as Error) } @@ -181,16 +136,6 @@ export default class IbmAcpiUtil extends ConsoleUtil { ) } - private static NOTIFY = ['cpu', 'gpu', 'speed', 'level', 'status'] - private static CHECKS = [-128, 0] - private static DISABLED_LEVELS = [0, 'disengaged'] - - private static isNotifiable = (key: string): boolean => - IbmAcpiUtil.NOTIFY.includes(key) - - private static isValidSensor = (v: number): boolean => - IbmAcpiUtil.CHECKS.every((check) => check !== v) - get cpu() { return ConsoleUtil.temperature( this.data.cpu, diff --git a/src/Lsblk.ts b/src/Lsblk.ts index edb6131..4810bf1 100644 --- a/src/Lsblk.ts +++ b/src/Lsblk.ts @@ -6,34 +6,31 @@ class LsnvmeUtil extends ConsoleUtil { GObject.registerClass(LsnvmeUtil) } - private data: { - [key: string]: string - } = {} + protected override data: ThinkPadThermal.ValueReadings = {} constructor() { super('ls', '-l', '/dev/disk/by-path') } private static IS = { - NVME: /nvme/i, - PART: /part/i, + NVME: /^(?=.*nvme)(?!.*-part).*/i, } private parse(str: string) { - this.data = str - .split('\n') - .filter((l) => !LsnvmeUtil.IS.PART.test(l)) + const ids = str + .split('\n') // .filter((l) => LsnvmeUtil.IS.NVME.test(l)) - .map((l) => l.slice(l.indexOf('pci-'))) - .map((l) => l.replace(/(\.\.\/)/gim, '').split('->') as [string, string]) - .map(([a, b]): [string, string] => [ - b.trim(), - ['nvme', 'pci', a.slice(9, 14).replace(/[:.]/gim, '')].join('-'), - ]) - .reduce((acc, [path, name]) => { - acc[path] = name - return acc - }, {}) + .map((l) => l.slice(l.indexOf('pci-')).split('->') as [string, string]) + .map( + ([a, b]) => + [ + b.slice(b.indexOf('nvme')).trim(), + ['nvme-pci', a.slice(9, 14).replace(/[:.]/gim, '')].join('-'), + ] as [string, string] + ) + for (const [path, id] of ids) { + this.data[path] = id + } } update() { @@ -52,9 +49,7 @@ export default class LsblkUtil extends ConsoleUtil { private _lsnvme = new LsnvmeUtil() - private data: { - [key: string]: string - } = {} + protected override data: ThinkPadThermal.ValueReadings = {} constructor() { super('lsblk', '-o', 'HCTL,MODEL,NAME,TRAN', '-dnJ') @@ -62,8 +57,8 @@ export default class LsblkUtil extends ConsoleUtil { private parse(str: string) { const { blockdevices } = JSON.parse(str) - this.data = blockdevices.reduce( - (acc: object, { hctl, model, name, tran }) => { + this.setData( + blockdevices.reduce((acc: object, { hctl, model, name, tran }) => { if (hctl) { const key = [ 'drivetemp', @@ -81,8 +76,7 @@ export default class LsblkUtil extends ConsoleUtil { } return acc - }, - {} + }, {}) ) } @@ -92,8 +86,8 @@ export default class LsblkUtil extends ConsoleUtil { name(key: string): string { if (!this.data[key]) { - this.update() this._lsnvme.update() + this.update() } return this.data[key] ?? key } diff --git a/src/Lscpu.ts b/src/Lscpu.ts index 63efc18..2751061 100644 --- a/src/Lscpu.ts +++ b/src/Lscpu.ts @@ -5,14 +5,14 @@ export default class LscpuUtil extends ConsoleUtil { static { GObject.registerClass(LscpuUtil) } - private _data = {} + protected override data = {} constructor() { super('lscpu', '-e=MODELNAME,SOCKET', '-J') } private extractModel(modelName: string): string { - if (modelName.toLowerCase().includes('intel')) { + if (/intel/i.test(modelName)) { return ( // @ts-ignore modelName @@ -24,13 +24,15 @@ export default class LscpuUtil extends ConsoleUtil { ) } - if (modelName.toLowerCase().includes('amd')) { + if (/amd/i.test(modelName)) { return ( // @ts-ignore modelName .split('with')[0] .split(/\s+\d+-Core/)[0] .split(/\s+[A-Za-z]+-Core/)[0] + .split('w/')[0] + .replace('AMD', 'AMD®') .trim() || 'AMD CPU' ) } @@ -40,16 +42,18 @@ export default class LscpuUtil extends ConsoleUtil { private parse(str: string) { const { cpus } = JSON.parse(str) as ThinkPadThermal.LscpuEntries - this._data = Object.values(cpus).reduce>( - (acc, curr) => { - let key = curr.socket.toString().padStart(4, '0') - key = `coretemp-isa-${key}` + this.setData( + Object.values(cpus).reduce>((acc, curr) => { + const key = /intel/i.test(curr.modelname) + ? `coretemp-isa-${curr.socket.toString().padStart(4, '0')}` + : 'k10temp' // 'k10temp-pci-00c3' + acc[key] = this.extractModel(curr.modelname) return acc - }, - {} + }, {}) ) - return this._data + + return this.data } update() { @@ -57,6 +61,10 @@ export default class LscpuUtil extends ConsoleUtil { } name(key: string): string { - return this._data[key] ?? key + return ( + this.data[key] ?? // + this.data[key.slice(0, key.indexOf('-'))] ?? + key + ) } } diff --git a/src/Sensors.ts b/src/Sensors.ts index 7a50c33..34a0048 100644 --- a/src/Sensors.ts +++ b/src/Sensors.ts @@ -3,6 +3,7 @@ import GObject from 'gi://GObject' import ConsoleUtil from './Console.js' import LsblkUtil from './Lsblk.js' import LscpuUtil from './Lscpu.js' +import IbmAcpiUtil from './IbmAcpi.js' export default class SensorsUtil extends ConsoleUtil { static { @@ -17,11 +18,12 @@ export default class SensorsUtil extends ConsoleUtil { SensorsUtil ) } - private static NOTIFY = ['cpu', 'hdd', 'fan', 'other'] + private static NOTIFY = ['cpus', 'hdds', 'fans', 'other'] private static IS = { INPUT: /_input$/, FANS: /^fan/i, - CPU: /^coretemp/i, + CPU: /^(coretemp|k10temp)/i, + GPU: /^amdgpu/i, DRIVETEMP: /^drivetemp/i, NVME: /^nvme/i, TPISA: /^thinkpad-isa/i, @@ -31,16 +33,15 @@ export default class SensorsUtil extends ConsoleUtil { private _lscpu: LscpuUtil private _lsblk: LsblkUtil - private data: object = {} - private config: ThinkPadThermal.Config + protected override data: object = {} + + protected prev: { cpu: number; gpu?: number } constructor(config?: ThinkPadThermal.Config) { - super('sensors', '-A', '-j') + super('sensors', '-A', '-j', config) this._lscpu = new LscpuUtil() this._lsblk = new LsblkUtil() - - this.update(config) } private parse(str: string | object) { @@ -49,7 +50,7 @@ export default class SensorsUtil extends ConsoleUtil { if (keys.length === 0) return obj - if (keys.length === 1) + if (keys.length === 1 && keys[0] !== 'Tctl') return this.parse(Object.values(obj)[0] as string | object) const input = keys.find((k) => SensorsUtil.IS.INPUT.test(k)) @@ -62,40 +63,112 @@ export default class SensorsUtil extends ConsoleUtil { } async update(config?: ThinkPadThermal.Config) { - this.config = { - ...this.config, - ...config, - } + if (!this.available) return - try { - this.data = await super.execute(this.parse.bind(this)) - const obj = SensorsUtil.NOTIFY.reduce((acc, key) => { - acc[key] = this[key] - return acc - }, {}) + this.setConfig(config) - this.emit('updated', obj) + try { + this.setData(await super.execute(this.parse.bind(this))) + + this.emit( + 'updated', + SensorsUtil.NOTIFY.reduce((acc, key) => { + acc[key] = this[key] + return acc + }, {}) + ) } catch (error) { logError(error) } } - private select( - f: FilterFn, - r: ReduceFn, - key?: string - ) { - return Object.keys(key ? this.data[key] : this.data) - .filter(f) // - .reduce(r, {}) + isGpuDetected(): this is { prev: { gpu: number } } { + return ( + typeof this.prev?.gpu === 'number' && + IbmAcpiUtil.isValidSensor(this.prev.gpu) + ) + } + + private select = + ( + f: RegExp | RegExp[] | FilterFn, + r: ReduceFn, + i: I = {} as I + ) => + (key?: string | RegExp): I => { + const source = + key instanceof RegExp + ? Object.keys(this.data).find((k) => key.test(k)) + : key + + const data = source ? this.data[source] : this.data + const keys = Object.keys(data) as T[] + + let fn: FilterFn + if (typeof f === 'function') { + fn = f + } else { + const test = Array.isArray(f) ? f : [f] + fn = (k) => test.some((exp) => exp.test(String(k))) + } + + return keys + .filter(fn) // + .reduce((acc, k) => r(acc, k, data[k]), i) + } + + get quirks() { + const cpu = this.select( + [SensorsUtil.IS.TPISA, SensorsUtil.IS.CPU], + (acc, _, data) => + Number.parseInt( + (data.CPU || data.Tctl) ?? ConsoleUtil.average(Object.values(data)) + ) || acc, + 0 + )() + + let gpu = this.select( + [SensorsUtil.IS.TPISA, SensorsUtil.IS.GPU], + (acc, _, data) => Number.parseInt(data.GPU ?? data.edge) || acc, + -128 + )() + + const speed = this.select( + SensorsUtil.IS.FANS, + (acc, _, value) => acc.concat(value), + [] + )(SensorsUtil.IS.TPISA) + + const hasDedicatedGpu = this.isGpuDetected() + // GPU Fallback, flaky sensor, {} -> -128 + if (gpu <= 0 && hasDedicatedGpu) { + gpu = this.prev.gpu + } + + this.prev = { cpu, gpu } + + return { + cpu: ConsoleUtil.temperature( + Math.round(cpu), + this.config.temperatureUnit + ), + gpu: ConsoleUtil.temperature( + Math.round(gpu), + this.config.temperatureUnit + ), + speed: ConsoleUtil.revs(ConsoleUtil.average(speed)), + hasDedicatedGpu, + status: 'disabled', + isControllable: false, + } } - get cpu() { + get cpus() { return this.select( - (k) => SensorsUtil.IS.CPU.test(k), - (acc, k) => { - const name = this._lscpu.name(k) - const value = { ...this.data[k] } + SensorsUtil.IS.CPU, // + (acc, k, data) => { + const name = this._lscpu.name(k) ?? k + const value = { ...(data as object) } for (const key of Object.keys(value)) { value[key] = ConsoleUtil.temperature( @@ -108,15 +181,15 @@ export default class SensorsUtil extends ConsoleUtil { return acc } - ) + )() } - get hdd() { + get hdds() { return this.select( - (k) => SensorsUtil.IS.DRIVETEMP.test(k) || SensorsUtil.IS.NVME.test(k), - (acc, k) => { + [SensorsUtil.IS.DRIVETEMP, SensorsUtil.IS.NVME], + (acc, k, data) => { const name = this._lsblk.name(k) - let value = this.data[k] + let value = data if (typeof value === 'object') { value = Math.max(...(Object.values(value) as number[])) @@ -126,45 +199,36 @@ export default class SensorsUtil extends ConsoleUtil { return acc } - ) + )() } - get bat() { + get bats() { return this.select( - (k) => SensorsUtil.IS.BATTERIES.test(k), - (acc, k) => { - acc[k] = this.data[k] + SensorsUtil.IS.BATTERIES, // + (acc, k, value) => { + acc[k] = value return acc } - ) + )() } - get fan() { - const key = Object.keys(this.data).find((k) => - SensorsUtil.IS.TPISA.test(k) - ) as string - + get fans() { return this.select( - (k) => SensorsUtil.IS.FANS.test(k), - (acc, k) => { - acc[k] = ConsoleUtil.revs(this.data[key][k]) + SensorsUtil.IS.FANS, // + (acc, k, value) => { + acc[k] = ConsoleUtil.revs(value) return acc - }, - key - ) + } + )(SensorsUtil.IS.TPISA) } get other() { return this.select( - (k) => - Object.keys(SensorsUtil.IS).every( - (check) => !SensorsUtil.IS[check].test(k) - ), - (acc, k) => { - const value = this.data[k] + (k) => !Object.values(SensorsUtil.IS).some((exp) => exp.test(k)), + (acc, k, value) => { acc[k] = ConsoleUtil.temperature(value, this.config.temperatureUnit) return acc } - ) + )() } } diff --git a/src/ThermalButton.ts b/src/ThermalButton.ts index 8fd91bc..8199fec 100644 --- a/src/ThermalButton.ts +++ b/src/ThermalButton.ts @@ -1,5 +1,5 @@ import type Gio from 'gi://Gio' -import type IbmAcpiUtil from './IbmAcpi.js' +import type ThermalData from './ThermalData.js' import GObject from 'gi://GObject' import St from 'gi://St' @@ -10,7 +10,7 @@ class ThermalButton extends PanelMenu.Button { static { GObject.registerClass(ThermalButton) } - private _data: IbmAcpiUtil + private _data: ThermalData private layout: St.BoxLayout = new St.BoxLayout({ style_class: 'layout', }) @@ -18,7 +18,7 @@ class ThermalButton extends PanelMenu.Button { constructor( align: number, name: string, - data: IbmAcpiUtil, + data: ThermalData, settings: Gio.Settings ) { super(align, name) @@ -29,10 +29,10 @@ class ThermalButton extends PanelMenu.Button { this.addIndicator('cpu')() - this.addIndicator('gpu')((el, next) => { - if (next.hasDedicatedGpu) { + this.addIndicator('gpu')((el, { hasDedicatedGpu, gpu }) => { + if (hasDedicatedGpu) { el.show() - el.update(next.gpu) + el.update(gpu) } else { el.hide() } @@ -64,7 +64,7 @@ class ThermalButton extends PanelMenu.Button { key: K, icon?: string ) { - return (handler?: (child: ButtonSection, next: IbmAcpiUtil) => void) => { + return (handler?: (child: ButtonSection, next: ThermalData) => void) => { const text = this._data[key] const child = new ButtonSection(key, text, icon) diff --git a/src/ThermalData.ts b/src/ThermalData.ts index 963bd31..622f6aa 100644 --- a/src/ThermalData.ts +++ b/src/ThermalData.ts @@ -1,36 +1,116 @@ import type Gio from 'gi://Gio' +import GObject from 'gi://GObject' import GLib from 'gi://GLib' import SensorsUtil from './Sensors.js' import IbmAcpiUtil from './IbmAcpi.js' import DmiUtil from './Dmi.js' -class ThermalData { +import microdiff from './vendor/microdiff.js' + +class ThermalData extends GObject.Object { + static { + GObject.registerClass( + { + Properties: { + cpu: GObject.ParamSpec.string( + 'cpu', + 'CPU temperature', + 'Current CPU temperature', + GObject.ParamFlags.READABLE, + '...' + ), + gpu: GObject.ParamSpec.string( + 'gpu', + 'GPU temperature', + 'Current GPU temperature', + GObject.ParamFlags.READABLE, + '...' + ), + speed: GObject.ParamSpec.string( + 'speed', + 'Fan speed', + 'Current fan speed', + GObject.ParamFlags.READABLE, + '...' + ), + status: GObject.ParamSpec.string( + 'status', + 'Fan status', + 'Current fan status', + GObject.ParamFlags.READABLE, + '...' + ), + level: GObject.ParamSpec.string( + 'level', + 'Fan level', + 'Current fan level', + GObject.ParamFlags.READABLE, + '...' + ), + levels: GObject.ParamSpec.jsobject( + 'levels', + 'Fan levels', + 'Supported fan levels', + GObject.ParamFlags.READABLE + ), + }, + Signals: { + updated: { + param_types: [ + GObject.TYPE_JSOBJECT, + GObject.TYPE_JSOBJECT, + GObject.TYPE_JSOBJECT, + ], + }, + }, + }, + ThermalData + ) + } + private _interval: number | null private config: ThinkPadThermal.Config + private data: Partial = {} + private prev: Partial = {} + acpi: IbmAcpiUtil dmi: DmiUtil sensors: SensorsUtil constructor(settings: Gio.Settings) { + super() + this.config = { checkInterval: settings.get_int('check-interval'), - temperatureUnit: settings.get_string('temperature-unit'), + temperatureUnit: settings.get_string( + 'temperature-unit' + ) as ThinkPadThermal.Unit, + quirksMode: settings.get_boolean('quirks-mode'), } this.dmi = new DmiUtil() this.acpi = new IbmAcpiUtil(this.config) this.sensors = new SensorsUtil(this.config) + this.acpi.connect('updated', this.sync) + this.dmi.connect('updated', this.sync) + this.sensors.connect('updated', this.sync) + settings.connect('changed::check-interval', () => { this.config.checkInterval = settings.get_int('check-interval') this.startInterval() }) settings.connect('changed::temperature-unit', () => { - this.config.temperatureUnit = settings.get_string('temperature-unit') - this.acpi.update(this.config) - this.sensors.update(this.config) + this.config.temperatureUnit = settings.get_string( + 'temperature-unit' + ) as ThinkPadThermal.Unit + this.startInterval() + }) + settings.connect('changed::quirks-mode', () => { + this.config.quirksMode = settings.get_boolean('quirks-mode') + this.startInterval() }) this.startInterval() @@ -39,18 +119,50 @@ class ThermalData { private startInterval() { if (this._interval) GLib.source_remove(this._interval) + const fetch = () => { + this.acpi.update(this.config) + this.sensors.update(this.config) + // + return GLib.SOURCE_CONTINUE + } + + fetch() + this._interval = GLib.timeout_add_seconds( GLib.PRIORITY_DEFAULT, this.config.checkInterval, - this.fetchData.bind(this) + fetch.bind(this) ) } - private fetchData() { - this.acpi.update() - this.sensors.update() - // - return GLib.SOURCE_CONTINUE + private static NOTIFY = ['cpu', 'gpu', 'speed', 'level', 'status'] + private static isNotifiable = (s: Set) => + ThermalData.NOTIFY.filter((k) => s.has(k)) + + private setData(next: object) { + this.data = { + ...this.data, + ...next, + } + } + + private sync = (_: object, updated: object) => { + this.setData(updated) + + if (this.config.quirksMode) this.setData(this.sensors.quirks) + + const diff = microdiff(this.prev, this.data, { cyclesFix: false }) + + if (diff.length === 0) return + + this.prev = this.data + + const keys = new Set(diff.flatMap(({ path }) => path as string[])) + const notify = ThermalData.isNotifiable(keys) + + for (const k of notify) this.notify(k as string) + + this.emit('updated', this.data, diff, keys) } destroy() { @@ -59,6 +171,47 @@ class ThermalData { this._interval = null } } + + get cpu() { + return this.data.cpu ?? '...' + } + get gpu() { + return this.data.gpu ?? '...' + } + get speed() { + return this.data.speed ?? '...' + } + get status() { + return this.data.status ?? 'initializing' + } + get level() { + return this.data.level ?? 'auto' + } + set level(next) { + this.acpi.setLevel(next) + } + get levels() { + return this.data.levels ?? [] + } + get hasDedicatedGpu() { + return this.data.hasDedicatedGpu ?? false + } + get isControllable() { + return this.data.isControllable ?? false + } + + get cpus() { + return this.data.cpus ?? {} + } + get hdds() { + return this.data.hdds ?? {} + } + get fans() { + return this.data.fans ?? {} + } + get other() { + return this.data.other ?? {} + } } export default ThermalData diff --git a/src/ThermalPopup.ts b/src/ThermalPopup.ts index bb41c26..deb3494 100644 --- a/src/ThermalPopup.ts +++ b/src/ThermalPopup.ts @@ -1,7 +1,4 @@ import type ThermalData from './ThermalData.js' -import type DmiUtil from './Dmi.js' -import type IbmAcpiUtil from './IbmAcpi.js' -import type SensorsUtil from './Sensors.js' import St from 'gi://St' import { PopupMenu } from 'resource:///org/gnome/shell/ui/popupMenu.js' @@ -16,30 +13,30 @@ import { } from './ThermalUI.js' class Dmi extends PopupSection { - constructor(data: DmiUtil) { + constructor(data: ThermalData) { super('Device info', data, false) this.addMenuItem(new Groups('dmi', 'thinkpad')) } } class Sensors extends PopupSection { - constructor(data: SensorsUtil) { + constructor(data: ThermalData) { super('Sensors', data, false) - this.addMenuItem(new Groups('cpu')) - this.addMenuItem(new Group('hdd', 'Disks', data.hdd)) + this.addMenuItem(new Groups('cpus')) + this.addMenuItem(new Group('hdds', 'Disks', data.hdds)) this.addMenuItem(new Group('other', 'Thermal', data.other, 'sensor')) - this.addMenuItem(new Group('fan', 'Cooling', data.fan)) + this.addMenuItem(new Group('fans', 'Cooling', data.fans)) } } class Acpi extends PopupSection { - constructor(data: IbmAcpiUtil) { + constructor(data: ThermalData) { super('ACPI', data) this.addMenuItem(new Item('cpu', 'CPU', data.cpu, 'cpu')) this.addMenuItem(new Item('gpu', 'GPU', data.gpu, 'gpu')) - data.connect('notify::gpu', (next: IbmAcpiUtil) => { + data.connect('notify::gpu', (next: ThermalData) => { if (!next.hasDedicatedGpu) { this.item('gpu')?.hide() } else { @@ -50,7 +47,7 @@ class Acpi extends PopupSection { } class FanControl extends PopupSection { - constructor(data: IbmAcpiUtil) { + constructor(data: ThermalData) { super('Fan control', data) this.addMenuItem(new Item('status', 'Status', data.status)) @@ -62,21 +59,17 @@ class FanControl extends PopupSection { export default class ThermalPopup extends PopupMenu { _dd: QuickDropdown | null - constructor( - align: number, - actor: St.Widget, - { acpi, dmi, sensors }: ThermalData - ) { + constructor(align: number, actor: St.Widget, data: ThermalData) { super(actor, align, St.Side.TOP) this.actor.add_style_class_name('tpt-popup') - this.addMenuItem(new Dmi(dmi)) - this.addMenuItem(new Sensors(sensors)) - this.addMenuItem(new Acpi(acpi)) - this.addMenuItem(new FanControl(acpi)) + this.addMenuItem(new Dmi(data)) + this.addMenuItem(new Sensors(data)) + this.addMenuItem(new Acpi(data)) + this.addMenuItem(new FanControl(data)) - acpi.connect('notify::status', (data: IbmAcpiUtil) => { + data.connect('notify::status', (data: ThermalData) => { // if (!data.isControllable) { this._dd?.destroy() @@ -91,14 +84,16 @@ export default class ThermalPopup extends PopupMenu { 'fan', data.levels, data.level, - (next) => data.setLevel(next) + (next) => { + data.level = next + } ) Main.panel.statusArea.quickSettings // .addExternalIndicator(this._dd) }) - acpi.connect('notify::level', (data: IbmAcpiUtil) => { + data.connect('notify::level', (data: ThermalData) => { this._dd?.status( data.level, 'ThinkPad Fan Control', diff --git a/src/ThermalUI.ts b/src/ThermalUI.ts index c19e0e3..a63808d 100644 --- a/src/ThermalUI.ts +++ b/src/ThermalUI.ts @@ -16,8 +16,8 @@ import { SystemIndicator, } from 'resource:///org/gnome/shell/ui/quickSettings.js' -import microdiff from './vendor/microdiff.js' import { ME } from './extension.js' +import type ThermalData from './ThermalData.js' export class ButtonSection extends St.BoxLayout { static { @@ -89,9 +89,15 @@ export class Icon extends St.Icon { static createIcon(filename: string) { if (!ME) return null - return new Gio.FileIcon({ - file: ME.dir.resolve_relative_path(`icons/${filename}-symbolic.svg`), - }) + let file = ME.dir.resolve_relative_path(`icons/${filename}-symbolic.svg`) + + if (!file.query_exists(null)) { + file = ME.dir.resolve_relative_path( + `icons/${filename.slice(0, filename.length - 1)}-symbolic.svg` + ) + } + + return new Gio.FileIcon({ file }) } } @@ -314,7 +320,7 @@ export class Groups extends PopupMenuSection { } private CHANGE(path: (string | number)[], value: string) { const key = path[0] as string - this.element(key).update([ + this.element(key)?.update([ { type: 'CHANGE', path: path.slice(1), @@ -325,7 +331,7 @@ export class Groups extends PopupMenuSection { } private REMOVE(path: (string | number)[]) { const key = path[0] as string - this.element(key).destroy() + this.element(key)?.destroy() } update(diffs: ThinkPadThermal.Diffs) { @@ -338,18 +344,18 @@ export class Groups extends PopupMenuSection { export class PopupSection extends PopupMenuSection { name = 'Section' - constructor(name: string, data: ThinkPadThermal.Util, createTitle = true) { + constructor(name: string, data: ThermalData, createTitle = true) { super() this.name = name this.actor.y_expand = false if (createTitle) this.addMenuItem(new Title(name)) - data.connect('updated', (next) => this.sync(next)) + data.connect('updated', this.sync.bind(this)) } private get elements() { - return (this._getMenuItems() as ThinkPadThermal.PrevElement[]) // + return (this._getMenuItems() as ThinkPadThermal.Element[]) // .filter((e) => e.key) } @@ -357,29 +363,46 @@ export class PopupSection extends PopupMenuSection { return this.elements.find((e) => e.key === key) } - private sync(data: object) { + private sync( + _: object, + data: object, + diff: ThinkPadThermal.Diffs, + keys: Set + ) { for (const el of this.elements) { - const { key, prev } = el - const value = data[key] - - if (prev === value) continue - - if ( - typeof value === 'object' || - el instanceof Group || - el instanceof Groups - ) { - const diffs = microdiff(prev ?? {}, value) - + if (!keys.has(el.key)) continue + + const diffs = diff + .filter((d) => d.path.includes(el.key)) + .reduce((acc, curr) => { + if ( + curr.path.length === 1 && + curr.type === 'CREATE' && + typeof curr.value === 'object' + ) { + const innerDiffs = Object.keys(curr.value).map((k) => ({ + type: curr.type, + path: [k], + value: curr.value[k], + })) as ThinkPadThermal.Diffs + return acc.concat(innerDiffs) + } + + return acc.concat([ + { + ...curr, + path: curr.path.filter((k) => k !== el.key), + }, + ]) + }, [] as ThinkPadThermal.Diffs) + + if (el instanceof Group || el instanceof Groups) { if (diffs.length === 0) continue - - el.prev = value if ('update' in el) el.update(diffs) continue } - el.prev = value - el.value = value + el.value = data[el.key] } } } diff --git a/src/extension.ts b/src/extension.ts index 20679f8..e6203b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -23,16 +23,23 @@ export default class ThinkPadThermal extends Extension { this._indicator = new ThermalButton( 0.5, 'ThinkPad Thermal', - this._data.acpi, + this._data, this._settings ) this._indicator.setMenu(new ThermalPopup(0.5, this._indicator, this._data)) - Main.panel.addToStatusArea(this.uuid, this._indicator, this._position, this._area) + Main.panel.addToStatusArea( + this.uuid, + this._indicator, + this._position, + this._area + ) this._settings.connect('changed', (_, change) => { if (change.startsWith('position-')) this.reposition() }) + + if (this._settings.get_boolean('position-enable')) this.ensurePosition(6) } get _position() { @@ -46,7 +53,8 @@ export default class ThinkPadThermal extends Extension { : 'right' } get _box() { - return Main.panel.get_child_at_index(['left', 'center', 'right'].indexOf(this._area)) + const i = ['left', 'center', 'right'].indexOf(this._area) + return Main.panel.get_child_at_index(i) } private reposition() { if (!this._settings.get_boolean('position-enable')) return @@ -58,6 +66,11 @@ export default class ThinkPadThermal extends Extension { this._box ) } + private ensurePosition(n: number) { + if (n === 0) return + this.reposition() + setTimeout(() => this.ensurePosition(n - 1), 500) + } override disable() { this._indicator?.destroy() diff --git a/src/prefs.ts b/src/prefs.ts index d3d0a8b..7a41f93 100644 --- a/src/prefs.ts +++ b/src/prefs.ts @@ -21,6 +21,12 @@ export default class ThinkpadThermalPreferences extends ExtensionPreferences { 'value', Gio.SettingsBindFlags.DEFAULT ) + settings.bind( + 'quirks-mode', + builder.get_object('field_quirks_mode'), + 'active', + Gio.SettingsBindFlags.DEFAULT + ) settings.bind( 'temperature-unit', builder.get_object('field_unit'), diff --git a/src/types.d.ts b/src/types.d.ts index 4015178..ce93bdb 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -10,7 +10,7 @@ type TupleOf = R['length'] extends N declare global { type FilterFn = (value: T, index: number, array: T[]) => boolean - type ReduceFn = (accumulator: U, value: T) => U + type ReduceFn = (accumulator: U, value: T, data) => U type SizedArray = TupleOf namespace ThinkPadThermal { @@ -19,20 +19,45 @@ declare global { type Unit = 'celsius' | 'fahrenheit' type Config = { - temperatureUnit: TemperatureUnit + temperatureUnit: Unit checkInterval: number fanSpeedUnit?: string + quirksMode: boolean } - type IbmAcpiData = { - cpu: number - gpu: number + type ValueReadings = { + [k: string]: string + } + + type DmiData = { + [K in (typeof DmiUtil.TAGS)[number]]: string + } + + type IbmAcpiData = { + cpu: V + gpu: V status: 'initializing' | 'enabled' | 'disabled' - speed: number + speed: V level: 'auto' | 'disengaged' | 'full-speed' | string levels: string[] } + type SensorsData = { + cpus: { + [name: string]: ValueReadings + } + hdds: ValueReadings + // bats: object + fans: ValueReadings + other: ValueReadings + } + + type ThermalData = IbmAcpiData & + SensorsData & { + hasDedicatedGpu: boolean + isControllable: boolean + } + type LscpuEntries = { cpus: { socket: number