Prepare public OneXPlayer Super X control release

This commit is contained in:
ps1x 2026-03-29 18:59:25 +03:00
parent f75d8444eb
commit 7c44b9fb4e
22 changed files with 3470 additions and 226 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Kernel module build output
*.o
*.ko
*.mod
*.mod.c
*.mod.o
Module.symvers
modules.order
.*.cmd
.tmp/
# Python cache
__pycache__/
*.py[cod]
# Local/generated version stamp
VERSION
# Logs and dumps
*.log
acpidump*.txt
# Local reverse-engineering and private artifacts
/local/*
!/local/README.md

280
LICENSE Normal file
View file

@ -0,0 +1,280 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS

180
README.md
View file

@ -1,68 +1,158 @@
# Platform driver for OneXPlayer boards
# onexplayer-superxcontrol
This driver provides functinoality to control the fan in the OneXPlayer mini
AMD variant. Intel boards are not yet supported until I can figure out EC
registers and values.
Linux control stack for **OneXPlayer Super X** with:
Supported devices include:
- DKMS kernel module
- fan control service
- GNOME Shell extension
- RGB control
- TDP control
- battery charge-limit backend
- AOK ZOE A1
- OneXPlayer AMD
- OneXPlayer mini AMD
- OneXPlayer mini AMD PRO
If you are searching for **OneXPlayer Super X Linux fan control**, **OneXPlayer
Super X RGB control**, **OneXPlayer Super X TDP control**, or a **GNOME
extension for OneXPlayer Super X**, this repository is intended for that use
case.
## Build
If you only want to build and test the module (you need headers for your
kernel):
## WARNING
**THIS SOFTWARE CAN CHANGE LOW-LEVEL HARDWARE BEHAVIOR. IT MAY CAUSE SYSTEM
INSTABILITY, OVERHEATING, DATA LOSS, OR PERMANENT HARDWARE DAMAGE.**
**YOU USE THIS PROJECT ENTIRELY AT YOUR OWN RISK. I ACCEPT NO RESPONSIBILITY
OR LIABILITY FOR ANY DAMAGE, FAILURE, OR LOSS CAUSED BY ITS USE, MISUSE, OR
MODIFICATION.**
**ANYTHING ABOVE 75W TDP IS STRICTLY AT YOUR OWN RISK AND SHOULD ONLY BE USED
WITH ADEQUATE THERMAL HEADROOM AND, IN PRACTICE, A WATER COOLER.**
Public repository scope:
- DKMS kernel module for the `oxp-sensors` platform driver
- fan-control daemon and profile manager
- GNOME Shell extension
- client helpers for RGB, TDP, and battery controls used by the extension
Local reverse-engineering artifacts, dumps, and experimental tools are kept
under `local/` and are excluded from Git.
## Features
- `oxp-sensors` DKMS module for fan and EC-backed controls
- systemd fan-control daemon with switchable profiles
- GNOME Shell top-bar plugin for daily use
- RGB preset and color control helpers
- TDP presets through `ryzenadj`
- battery status and charge-limit backend used by the plugin
## Compatibility
This repository is focused on **OneXPlayer Super X**.
Other OneXPlayer, AOKZOE, AYANEO, mini, or older board variants are not the
target of this public tree.
## Upstream Base
This project is based on the original `oxp-sensors` work:
- <https://gitlab.com/Samsagax/oxp-sensors>
I could not find a working way to contact Joaquín Ignacio Aramendía
(`@Samsagax`) directly. If the upstream author wants this repository or any
part of it to be removed from public distribution, they are welcome to open an
issue in this repository.
## Included Components
- `oxp-sensors.c`: kernel driver
- `oxp-fan-control.py`: fan-control daemon
- `oxp-fan-profile.py`: profile CLI used by the daemon and the plugin
- `gnome-extension/`: GNOME Shell top-bar plugin
- `oxp-rgb`, `oxp-rgb-hid.py`: RGB control helpers
- `oxp-tdp`: TDP helper based on `ryzenadj`
- `oxp-battery-probe.py`, `oxp-battery-ec-probe.c`: battery status / charge-limit backend for the plugin
## Build DKMS Module
To build the kernel module for the running kernel:
```shell
$ git clone https://gitlab.com/Samsagax/oxp-platform-dkms.git
$ cd oxp-platform-dkms
$ make
make
```
Then insert the module and check `sensors` and `dmesg` if appropriate:
```shell
# insmod oxp-sensors.ko
$ sensors
```
## Install
You'll need appropriate headers for your kernel and `dkms` package from your
distribution.
To install through DKMS:
```shell
$ git clone https://gitlab.com/Samsagax/oxp-platform-dkms.git
$ cd oxp-platform-dkms
$ make
# make dkms
make dkms
```
## Usage
## Install On Linux
Insert the module with `insmod`. Then look for a `hwmon` device with name
`oxpec`, i.e.:
Install the client tools, fan-control service, and GNOME extension:
`$ cat /sys/class/hwmon/hwmon?/name`
```shell
./install.sh
```
### Reading fan RPM
## Uninstall
`sensors` will show the fan RPM as read from the EC. You can also read the
file `fan1_input` to get the fan RPM.
Remove the installed userland components with:
### Controlling the fan
```shell
./uninstall.sh
```
***Warning: controlling the fan without an accurate reading of the CPU, GPU,
and Battery temperature can cause irreversible damage to the device. Use at
your own risk!***
Full removal including `/etc/oxp-fan-control.conf` and user plugin state:
To enable manual control of the fan (assuming `hwmon5` is our driver, look for
`oxpec` in the `name` file):
```shell
./uninstall.sh --purge
```
`# echo 1 > /sys/class/hwmon/hwmon5/pwm1_enable`
## Fan Control Profiles
Then input values in the range `[0-255]` to the pwm:
The fan daemon uses `/etc/oxp-fan-control.conf` and supports named profiles.
Stock profiles are:
`# echo 100 > /sys/class/hwmon/hwmon5/pwm1`
- `silent`
- `balanced`
- `watercool`
- `aggressive`
Useful commands:
```shell
/usr/local/bin/oxp-fan-profile list
/usr/local/bin/oxp-fan-profile current
sudo /usr/local/bin/oxp-fan-profile set balanced
```
## GNOME Shell Extension
The GNOME Shell extension adds a top-bar menu for:
- fan profile switching
- RGB presets and custom color
- TDP presets
- battery charge-limit / bypass controls when the backend is available
Enable it after install:
```shell
gnome-extensions enable oxp-fan-profiles@ps1x
```
## RGB And TDP Commands
Examples:
```shell
/usr/local/bin/oxp-rgb rainbow
/usr/local/bin/oxp-rgb red
sudo /usr/local/bin/oxp-tdp 15
sudo /usr/local/bin/oxp-tdp 20
```
## License
This repository is distributed under **GPL-2.0-or-later**. See `LICENSE`.

View file

@ -1,7 +1,7 @@
MAKE="make TARGET=${kernelver}"
CLEAN="make clean"
PACKAGE_NAME="oxp-sensors"
PACKAGE_VERSION="to be filled by make dkms"
PACKAGE_VERSION="v0.9.0.gf75d844"
BUILT_MODULE_NAME[0]="oxp-sensors"
DEST_MODULE_LOCATION[0]="/kernel/drivers/hwmon"
AUTOINSTALL="yes"

View file

@ -0,0 +1,968 @@
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import St from 'gi://St';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
const PROFILE_HELPER = '/usr/local/bin/oxp-fan-profile';
const RGB_HELPER = '/usr/local/bin/oxp-rgb';
const RGB_HID_HELPER = '/usr/local/bin/oxp-rgb-hid';
const TDP_HELPER = '/usr/local/bin/oxp-tdp';
const BATTERY_HELPER = '/usr/local/bin/oxp-battery-probe';
const BATTERY_EC_HELPER = '/usr/local/bin/oxp-battery-ec-probe';
const CONFIG_PATH = GLib.build_filenamev([GLib.get_user_config_dir(), 'oxp-control.json']);
const DEFAULT_COLOR = [244, 67, 54];
const DEFAULT_NOTIFICATION_COLOR = [255, 215, 0];
const NOTIFICATION_FLASH_PATTERN_MS = 500;
const DEFAULT_TDP_WATTS = 15;
const SAFE_BATTERY_TDP_WATTS = 65;
const MAX_BATTERY_TDP_WATTS = 80;
const TDP_WARNING_WATTS = 80;
const TDP_ACTIONS = [8, 10, 12, 15, 18, 20, 25, 28, 35, 45, 65, 80, 90, 100, 120];
const CHARGE_LIMIT_ACTIONS = [50, 60, 70, 75, 80, 85, 90, 95, 100];
const BYPASS_ACTIONS = [
{label: 'Off', value: 'off'},
{label: 'Mode 1', value: 'mode1'},
{label: 'Mode 2', value: 'mode2'},
];
const QUICK_COLORS = [
{label: 'Pick Color…', kind: 'picker'},
{label: 'Last Custom', kind: 'last-custom'},
{label: 'Warm', preset: 'warm'},
{label: 'Red', preset: 'red'},
{label: 'Green', preset: 'green'},
{label: 'Blue', preset: 'blue'},
{label: 'Off', preset: 'off'},
];
const BRIGHTNESS_ACTIONS = [
{label: 'Dim', preset: 'dim'},
{label: 'Mid', preset: 'mid'},
{label: 'Bright', preset: 'bright'},
];
const EFFECT_ACTIONS = [
{label: 'Rainbow', preset: 'rainbow'},
{label: 'Rainbow Flow', preset: 'rainbow-flow'},
{label: 'Rainbow Breath', preset: 'rainbow-breath-random'},
{label: 'Rainbow Cycle', preset: 'rainbow-cycle'},
{label: 'Whole Rainbow', preset: 'whole-rainbow-cycle'},
{label: 'Flame Cycle', preset: 'flame-cycle'},
{label: 'Cyberpunk', preset: 'cyberpunk'},
];
const MONO_ACTIONS = [
{label: 'Green Breath', preset: 'green-breath'},
{label: 'Cyan Breath', preset: 'cyan-breath'},
{label: 'Pink Breath', preset: 'pink-breath'},
{label: 'White Breath', preset: 'white-breath-slow'},
{label: 'Red Monster', preset: 'red-monster'},
{label: 'Green Monster', preset: 'green-monster'},
{label: 'Blue Monster', preset: 'blue-monster'},
];
const TEXT_DECODER = new TextDecoder('utf-8');
function rgbToHex(red, green, blue) {
return '#' + [red, green, blue].map(value => value.toString(16).padStart(2, '0')).join('');
}
function parseColorOutput(text) {
const trimmed = text.trim();
let match = trimmed.match(/^#?([0-9a-fA-F]{6})$/);
if (match) {
const hex = match[1];
return [
parseInt(hex.slice(0, 2), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(4, 6), 16),
];
}
match = trimmed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (match) {
return [parseInt(match[1], 10), parseInt(match[2], 10), parseInt(match[3], 10)];
}
return null;
}
function formatEnergyWh(rawValue) {
const parsed = Number.parseInt(rawValue, 10);
if (Number.isNaN(parsed)) {
return 'n/a';
}
return `${(parsed / 1000000).toFixed(2)} Wh`;
}
function sanitizeRgb(rgb, fallback) {
if (!Array.isArray(rgb) || rgb.length !== 3) {
return fallback;
}
return rgb.map((value, index) => {
const base = fallback[index];
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
return base;
}
return Math.max(0, Math.min(255, parsed));
});
}
function decodeBytes(bytes) {
if (typeof bytes === 'string') {
return bytes;
}
return TEXT_DECODER.decode(bytes);
}
function defaultRgbState() {
return {
kind: 'preset',
preset: 'warm',
label: 'Warm',
};
}
function rgbStateLabel(state) {
if (!state) {
return 'none';
}
if (state.kind === 'custom') {
return state.label || rgbToHex(...state.rgb);
}
return state.label || state.preset || 'unknown';
}
const OXPFanProfilesButton = GObject.registerClass(
class OXPFanProfilesButton extends PanelMenu.Button {
_init() {
super._init(0.0, 'OXP Control');
this._icon = new St.Icon({
icon_name: 'weather-windy-symbolic',
style_class: 'system-status-icon',
y_align: Clutter.ActorAlign.CENTER,
});
this.add_child(this._icon);
this._config = this._loadConfig();
this._batteryEcLoaded = false;
this._batteryBackendAvailable = null;
this._currentFanProfile = null;
this._notificationFlashTimeoutId = null;
this._notificationFlashStepIds = [];
this._notificationSourceSignals = new Map();
this._messageTraySignals = [];
this._upowerProxy = null;
this._upowerSignalId = 0;
this.menu.connect('open-state-changed', (_menu, open) => {
if (open) {
this._refreshBatteryInfo(true);
}
});
this._summaryBox = new St.BoxLayout({
vertical: true,
style: 'padding: 8px 12px; spacing: 4px;',
});
this._statusItem = new St.Label({
text: 'Loading…',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
this._tdpStateItem = new St.Label({
text: '',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
this._rgbStateItem = new St.Label({
text: '',
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});
this._summaryBox.add_child(this._statusItem);
this._summaryBox.add_child(this._tdpStateItem);
this._summaryBox.add_child(this._rgbStateItem);
this.menu.box.add_child(this._summaryBox);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._profilesMenu = new PopupMenu.PopupSubMenuMenuItem('Fan Profiles');
this.menu.addMenuItem(this._profilesMenu);
this._tdpMenu = new PopupMenu.PopupSubMenuMenuItem('TDP');
this.menu.addMenuItem(this._tdpMenu);
this._batteryMenu = new PopupMenu.PopupSubMenuMenuItem('Battery');
this.menu.addMenuItem(this._batteryMenu);
this._batteryHealthItem = new PopupMenu.PopupMenuItem('Health: …', {
reactive: false,
can_focus: false,
});
this._batteryMenu.menu.addMenuItem(this._batteryHealthItem);
this._batteryEnergyItem = new PopupMenu.PopupMenuItem('Energy: …', {
reactive: false,
can_focus: false,
});
this._batteryMenu.menu.addMenuItem(this._batteryEnergyItem);
this._batteryLimitItem = new PopupMenu.PopupMenuItem('Charge Limit: …', {
reactive: false,
can_focus: false,
});
this._batteryMenu.menu.addMenuItem(this._batteryLimitItem);
this._batteryModeItem = new PopupMenu.PopupMenuItem('Power Mode: …', {
reactive: false,
can_focus: false,
});
this._batteryMenu.menu.addMenuItem(this._batteryModeItem);
this._batteryChargeMenu = new PopupMenu.PopupSubMenuMenuItem('Charge Limit');
this.menu.addMenuItem(this._batteryChargeMenu);
this._batteryBypassMenu = new PopupMenu.PopupSubMenuMenuItem('Bypass Mode');
this.menu.addMenuItem(this._batteryBypassMenu);
this._colorsMenu = new PopupMenu.PopupSubMenuMenuItem('Colors');
this.menu.addMenuItem(this._colorsMenu);
this._quickColorsSection = this._colorsMenu.menu;
this._effectsMenu = new PopupMenu.PopupSubMenuMenuItem('Effects');
this.menu.addMenuItem(this._effectsMenu);
this._effectsSection = this._effectsMenu.menu;
this._monoMenu = new PopupMenu.PopupSubMenuMenuItem('Monochrome');
this.menu.addMenuItem(this._monoMenu);
this._monoSection = this._monoMenu.menu;
this._notificationMenu = new PopupMenu.PopupSubMenuMenuItem('Notifications');
this.menu.addMenuItem(this._notificationMenu);
this._notificationToggle = new PopupMenu.PopupSwitchMenuItem(
'Flash On Notifications',
this._config.notificationFlashEnabled
);
this._notificationToggle.connect('toggled', (_item, state) => {
this._config.notificationFlashEnabled = state;
this._saveConfig();
this._updateInfoRows();
});
this._notificationMenu.menu.addMenuItem(this._notificationToggle);
this._notificationColorItem = new PopupMenu.PopupMenuItem('Notification Color…');
this._notificationColorItem.connect('activate', () => this._openNotificationColorPicker());
this._notificationMenu.menu.addMenuItem(this._notificationColorItem);
this._testFlashItem = new PopupMenu.PopupMenuItem('Test Notification Flash');
this._testFlashItem.connect('activate', () => this._flashNotificationColor());
this._notificationMenu.menu.addMenuItem(this._testFlashItem);
this._buildTdpMenu();
this._buildBatteryMenus();
this._buildRgbMenu();
this._connectNotificationSignals();
this._connectPowerSignals();
this._refresh();
this._updateInfoRows();
this._timeoutId = GLib.timeout_add_seconds(
GLib.PRIORITY_DEFAULT,
10,
() => {
this._refresh();
return GLib.SOURCE_CONTINUE;
}
);
}
_loadConfig() {
const fallback = {
lastColor: DEFAULT_COLOR,
notificationColor: DEFAULT_NOTIFICATION_COLOR,
notificationFlashEnabled: false,
currentRgbState: defaultRgbState(),
currentTdpWatts: DEFAULT_TDP_WATTS,
};
try {
const [ok, contents] = GLib.file_get_contents(CONFIG_PATH);
if (!ok) {
return fallback;
}
const parsed = JSON.parse(decodeBytes(contents));
return {
lastColor: sanitizeRgb(parsed.lastColor, DEFAULT_COLOR),
notificationColor: sanitizeRgb(parsed.notificationColor, DEFAULT_NOTIFICATION_COLOR),
notificationFlashEnabled: Boolean(parsed.notificationFlashEnabled),
currentRgbState: parsed.currentRgbState || defaultRgbState(),
currentTdpWatts: Number.parseInt(parsed.currentTdpWatts, 10) || DEFAULT_TDP_WATTS,
};
} catch (error) {
return fallback;
}
}
_saveConfig() {
try {
GLib.mkdir_with_parents(GLib.get_user_config_dir(), 0o755);
GLib.file_set_contents(CONFIG_PATH, JSON.stringify(this._config, null, 2) + '\n');
} catch (error) {
log(`OXP config save error: ${error}`);
}
}
_updateInfoRows() {
const rgbLabel = rgbStateLabel(this._config.currentRgbState);
const tdpLabel = `${this._config.currentTdpWatts || DEFAULT_TDP_WATTS}W`;
const fanLabel = this._currentFanProfile || '…';
this._statusItem.text = `Fan: ${fanLabel}`;
this._rgbStateItem.text = `RGB: ${rgbLabel}`;
if (this._tdpStateItem) {
this._tdpStateItem.text = `TDP: ${tdpLabel}`;
}
}
_runStatus() {
try {
const proc = Gio.Subprocess.new(
[PROFILE_HELPER, 'status'],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
const [, stdout, stderr] = proc.communicate_utf8(null, null);
if (proc.get_exit_status() !== 0) {
throw new Error(stderr.trim() || 'profile helper failed');
}
return JSON.parse(stdout);
} catch (error) {
log(`OXP control status error: ${error}`);
return null;
}
}
_readTextFile(path) {
try {
const [ok, bytes] = GLib.file_get_contents(path);
if (!ok) {
return null;
}
return decodeBytes(bytes).trim();
} catch (_error) {
return null;
}
}
_readSysfsBatteryInfo() {
const base = '/sys/class/power_supply/BAT0';
const energyFull = this._readTextFile(`${base}/energy_full`);
const energyFullDesign = this._readTextFile(`${base}/energy_full_design`);
const capacity = this._readTextFile(`${base}/capacity`);
let healthPercent = null;
if (energyFull && energyFullDesign) {
const full = Number.parseInt(energyFull, 10);
const design = Number.parseInt(energyFullDesign, 10);
if (!Number.isNaN(full) && !Number.isNaN(design) && design > 0) {
healthPercent = (full * 100.0 / design).toFixed(2);
}
}
return {
energyFull,
energyFullDesign,
capacity,
healthPercent,
};
}
_readPowerSourceInfo() {
if (this._upowerProxy) {
try {
const onBattery = this._upowerProxy.get_cached_property('OnBattery')?.unpack();
if (typeof onBattery === 'boolean') {
return {onAc: !onBattery, onBattery};
}
} catch (_error) {
}
}
const acOnline = this._readTextFile('/sys/class/power_supply/ACAD/online');
if (acOnline === '1') {
return {onAc: true, onBattery: false};
}
if (acOnline === '0') {
return {onAc: false, onBattery: true};
}
const batteryStatus = this._readTextFile('/sys/class/power_supply/BAT0/status');
if (batteryStatus === 'Discharging') {
return {onAc: false, onBattery: true};
}
return {onAc: false, onBattery: false};
}
_connectPowerSignals() {
try {
this._upowerProxy = Gio.DBusProxy.new_for_bus_sync(
Gio.BusType.SYSTEM,
Gio.DBusProxyFlags.NONE,
null,
'org.freedesktop.UPower',
'/org/freedesktop/UPower',
'org.freedesktop.UPower',
null
);
this._upowerSignalId = this._upowerProxy.connect('g-properties-changed', () => {
const power = this._enforceBatteryTdpSafety();
this._buildTdpMenu();
if (power.onBattery) {
this._statusItem.text = `On battery: capped at ${MAX_BATTERY_TDP_WATTS}W`;
}
});
} catch (error) {
log(`OXP UPower signal error: ${error}`);
}
}
_runBatteryProbe() {
try {
const proc = Gio.Subprocess.new(
[BATTERY_EC_HELPER],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
const [, stdout, stderr] = proc.communicate_utf8(null, null);
if (proc.get_exit_status() !== 0) {
throw new Error(stderr.trim() || 'battery helper failed');
}
return JSON.parse(stdout);
} catch (error) {
log(`OXP battery probe error: ${error}`);
return null;
}
}
_refreshBatteryInfo(withEc = false) {
const sysfs = this._readSysfsBatteryInfo();
const health = sysfs.healthPercent ?? 'n/a';
const energyFull = formatEnergyWh(sysfs.energyFull);
const energyDesign = formatEnergyWh(sysfs.energyFullDesign);
this._batteryHealthItem.label.text = `Health: ${health}%`;
this._batteryEnergyItem.label.text = `Energy: ${energyFull} / ${energyDesign}`;
if (!withEc) {
if (!this._batteryEcLoaded) {
this._batteryLimitItem.label.text = 'Charge Limit: …';
this._batteryModeItem.label.text = 'Power Mode: …';
}
return;
}
const payload = this._runBatteryProbe();
if (!payload) {
this._batteryBackendAvailable = false;
this._batteryLimitItem.label.text = 'Charge Limit: backend unavailable';
this._batteryModeItem.label.text = 'Power Mode: backend unavailable';
this._buildBatteryMenus();
return;
}
const chargeLimit = payload.charge_limit_percent;
const powerMode = payload.power_supply_mode?.name || payload.power_supply_mode?.value;
this._batteryEcLoaded = true;
this._batteryBackendAvailable = true;
this._batteryHealthItem.label.text = `Health: ${health}%`;
this._batteryEnergyItem.label.text = `Energy: ${energyFull} / ${energyDesign}`;
this._batteryLimitItem.label.text = `Charge Limit: ${chargeLimit ?? 'n/a'}%`;
this._batteryModeItem.label.text = `Power Mode: ${powerMode ?? 'n/a'}`;
this._buildBatteryMenus();
}
_spawn(argv, message) {
try {
Gio.Subprocess.new(argv, Gio.SubprocessFlags.NONE);
this._statusItem.text = message;
} catch (error) {
log(`OXP control command error: ${error}`);
this._statusItem.text = `Command failed: ${message}`;
}
}
_runChecked(argv) {
try {
const proc = Gio.Subprocess.new(
argv,
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
const [, stdout, stderr] = proc.communicate_utf8(null, null);
return {
ok: proc.get_exit_status() === 0,
stdout: stdout?.trim() || '',
stderr: stderr?.trim() || '',
};
} catch (error) {
return {
ok: false,
stdout: '',
stderr: `${error}`,
};
}
}
_runLater(delayMs, callback) {
return GLib.timeout_add(GLib.PRIORITY_DEFAULT, delayMs, () => {
callback();
return GLib.SOURCE_REMOVE;
});
}
_applyCustomColor(rgb, saveAsCurrent = true, statusLabel = null) {
const hex = rgbToHex(...rgb);
if (saveAsCurrent) {
this._config.lastColor = rgb;
this._config.currentRgbState = {
kind: 'custom',
rgb,
label: hex,
};
this._saveConfig();
this._updateInfoRows();
}
this._spawn(
[RGB_HID_HELPER, 'static', String(rgb[0]), String(rgb[1]), String(rgb[2])],
statusLabel || `RGB color ${hex}`
);
this._runLater(180, () => {
this._spawn([RGB_HID_HELPER, 'mode', 'alt-static'], statusLabel || `RGB color ${hex}`);
});
}
_applyRgbState(state, statusLabel = null, save = true) {
if (!state) {
return;
}
if (state.kind === 'custom') {
this._applyCustomColor(sanitizeRgb(state.rgb, DEFAULT_COLOR), save, statusLabel);
return;
}
this._spawn([RGB_HELPER, state.preset], statusLabel || `RGB: ${rgbStateLabel(state)}`);
if (save) {
this._config.currentRgbState = {
kind: 'preset',
preset: state.preset,
label: state.label || state.preset,
};
this._saveConfig();
this._updateInfoRows();
}
}
_setProfile(profileName) {
this._spawn(['pkexec', PROFILE_HELPER, 'set', profileName], `Switching to ${profileName}`);
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => {
this._refresh();
return GLib.SOURCE_REMOVE;
});
}
_setTdp(watts) {
const power = this._readPowerSourceInfo();
if (power.onBattery && watts > MAX_BATTERY_TDP_WATTS) {
this._statusItem.text = `Battery mode limit: ${MAX_BATTERY_TDP_WATTS}W max`;
return;
}
this._spawn(['pkexec', TDP_HELPER, String(watts)], `TDP ${watts}W`);
this._config.currentTdpWatts = watts;
this._saveConfig();
this._buildTdpMenu();
this._updateInfoRows();
}
_enforceBatteryTdpSafety() {
const power = this._readPowerSourceInfo();
const onBattery = power.onBattery;
if (this._lastOnBattery === undefined) {
this._lastOnBattery = onBattery;
}
if (onBattery && !this._lastOnBattery && this._config.currentTdpWatts > SAFE_BATTERY_TDP_WATTS) {
this._statusItem.text = `On battery: falling back to ${SAFE_BATTERY_TDP_WATTS}W`;
this._setTdp(SAFE_BATTERY_TDP_WATTS);
}
if (onBattery && this._config.currentTdpWatts > MAX_BATTERY_TDP_WATTS) {
this._statusItem.text = `Battery mode limit: forcing ${SAFE_BATTERY_TDP_WATTS}W`;
this._setTdp(SAFE_BATTERY_TDP_WATTS);
}
this._lastOnBattery = onBattery;
return power;
}
_setChargeLimit(percent) {
const result = this._runChecked([BATTERY_EC_HELPER, 'set-charge-limit', String(percent)]);
if (!result.ok) {
this._statusItem.text = `Charge limit failed: ${result.stderr || 'backend unavailable'}`;
this._refreshBatteryInfo(true);
return;
}
this._statusItem.text = `Charge limit ${percent}%`;
this._refreshBatteryInfo(true);
}
_setBypassMode(mode, label) {
const result = this._runChecked([BATTERY_EC_HELPER, 'set-bypass-mode', mode]);
if (!result.ok) {
this._statusItem.text = `Bypass failed: ${result.stderr || 'backend unavailable'}`;
this._refreshBatteryInfo(true);
return;
}
this._statusItem.text = `Bypass ${label}`;
this._refreshBatteryInfo(true);
}
_confirmHighTdp(watts) {
try {
const proc = Gio.Subprocess.new(
[
'/usr/bin/zenity',
'--question',
'--width=420',
'--title=High TDP Warning',
`--text=Applying ${watts} W requires water cooling. Continue?`,
],
Gio.SubprocessFlags.NONE
);
proc.wait_check_async(null, (self, res) => {
try {
if (self.wait_check_finish(res)) {
this._setTdp(watts);
}
} catch (_error) {
}
});
} catch (error) {
log(`OXP TDP warning dialog error: ${error}`);
this._statusItem.text = 'Unable to show TDP warning';
}
}
_applyPreset(action) {
this._applyRgbState(
{
kind: 'preset',
preset: action.preset,
label: action.label,
},
`RGB: ${action.label}`,
true
);
}
_pickColor(initialColor, title, onColor) {
try {
const proc = Gio.Subprocess.new(
[
'/usr/bin/zenity',
'--color-selection',
'--show-palette',
`--color=${initialColor}`,
`--title=${title}`,
],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
);
proc.communicate_utf8_async(null, null, (self, res) => {
try {
const [, stdout] = self.communicate_utf8_finish(res);
if (self.get_exit_status() !== 0) {
return;
}
const rgb = parseColorOutput(stdout);
if (!rgb) {
this._statusItem.text = 'Color picker returned an unknown format';
return;
}
onColor(rgb);
} catch (error) {
log(`OXP color picker error: ${error}`);
this._statusItem.text = 'Color picker failed';
}
});
} catch (error) {
log(`OXP color picker launch error: ${error}`);
this._statusItem.text = 'Unable to launch color picker';
}
}
_openMainColorPicker() {
const initialColor = rgbToHex(...this._config.lastColor);
this._pickColor(initialColor, 'Choose OneXPlayer Super X RGB Color', rgb => {
this._applyCustomColor(rgb, true);
});
}
_openNotificationColorPicker() {
const initialColor = rgbToHex(...this._config.notificationColor);
this._pickColor(initialColor, 'Choose Notification Flash Color', rgb => {
this._config.notificationColor = rgb;
this._saveConfig();
this._updateInfoRows();
this._statusItem.text = `Notify color ${rgbToHex(...rgb)}`;
});
}
_flashNotificationColor() {
const previousState = this._config.currentRgbState;
const flashHex = rgbToHex(...this._config.notificationColor);
if (this._notificationFlashTimeoutId) {
GLib.Source.remove(this._notificationFlashTimeoutId);
this._notificationFlashTimeoutId = null;
}
for (const id of this._notificationFlashStepIds) {
GLib.Source.remove(id);
}
this._notificationFlashStepIds = [];
const offState = {
kind: 'preset',
preset: 'off',
label: 'Off',
};
const flashState = {
kind: 'custom',
rgb: this._config.notificationColor,
label: flashHex,
};
const steps = [
{delay: 0, state: offState},
{delay: NOTIFICATION_FLASH_PATTERN_MS, state: flashState},
{delay: NOTIFICATION_FLASH_PATTERN_MS * 2, state: offState},
{delay: NOTIFICATION_FLASH_PATTERN_MS * 3, state: flashState},
];
for (const step of steps) {
const id = this._runLater(step.delay, () => {
this._applyRgbState(step.state, `Notify flash ${flashHex}`, false);
});
this._notificationFlashStepIds.push(id);
}
this._notificationFlashTimeoutId = this._runLater(NOTIFICATION_FLASH_PATTERN_MS * 4 + 140, () => {
this._notificationFlashTimeoutId = null;
this._notificationFlashStepIds = [];
this._applyRgbState(previousState, `RGB: ${rgbStateLabel(previousState)}`, false);
this._updateInfoRows();
});
}
_onNotificationAdded(_source, notification) {
if (!notification || !this._config.notificationFlashEnabled) {
return;
}
if (notification.urgency === 0 && notification.resident) {
return;
}
this._flashNotificationColor();
}
_connectNotificationSource(source) {
if (!source || this._notificationSourceSignals.has(source)) {
return;
}
const signalId = source.connect('notification-added', this._onNotificationAdded.bind(this));
this._notificationSourceSignals.set(source, signalId);
source.connect('destroy', () => {
if (this._notificationSourceSignals.has(source)) {
this._notificationSourceSignals.delete(source);
}
});
}
_connectNotificationSignals() {
if (Main.messageTray && Main.messageTray.getSources) {
for (const source of Main.messageTray.getSources()) {
this._connectNotificationSource(source);
}
}
if (Main.messageTray) {
this._messageTraySignals.push(
Main.messageTray.connect('source-added', (_tray, source) => {
this._connectNotificationSource(source);
})
);
}
}
_buildActionSection(section, actions) {
section.removeAll();
for (const action of actions) {
const item = new PopupMenu.PopupMenuItem(action.label);
item.connect('activate', () => {
if (action.kind === 'picker') {
this._openMainColorPicker();
return;
}
if (action.kind === 'last-custom') {
this._applyCustomColor(this._config.lastColor, true);
return;
}
this._applyPreset(action);
});
section.addMenuItem(item);
}
}
_buildRgbMenu() {
this._buildActionSection(this._quickColorsSection, QUICK_COLORS);
this._quickColorsSection.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
for (const action of BRIGHTNESS_ACTIONS) {
const item = new PopupMenu.PopupMenuItem(action.label);
item.connect('activate', () => this._applyPreset(action));
this._quickColorsSection.addMenuItem(item);
}
this._buildActionSection(this._effectsSection, EFFECT_ACTIONS);
this._buildActionSection(this._monoSection, MONO_ACTIONS);
}
_buildBatteryMenus() {
this._batteryChargeMenu.menu.removeAll();
this._batteryBypassMenu.menu.removeAll();
if (this._batteryBackendAvailable === false) {
this._batteryChargeMenu.label.text = 'Charge Limit (unavailable)';
this._batteryBypassMenu.label.text = 'Bypass Mode (unavailable)';
const chargeInfo = new PopupMenu.PopupMenuItem('Battery write backend unavailable', {
reactive: false,
can_focus: false,
});
const bypassInfo = new PopupMenu.PopupMenuItem('Battery write backend unavailable', {
reactive: false,
can_focus: false,
});
this._batteryChargeMenu.menu.addMenuItem(chargeInfo);
this._batteryBypassMenu.menu.addMenuItem(bypassInfo);
return;
}
this._batteryChargeMenu.label.text = 'Charge Limit';
for (const percent of CHARGE_LIMIT_ACTIONS) {
const item = new PopupMenu.PopupMenuItem(`${percent}%`);
item.connect('activate', () => this._setChargeLimit(percent));
this._batteryChargeMenu.menu.addMenuItem(item);
}
this._batteryBypassMenu.label.text = 'Bypass Mode';
for (const action of BYPASS_ACTIONS) {
const item = new PopupMenu.PopupMenuItem(action.label);
item.connect('activate', () => this._setBypassMode(action.value, action.label));
this._batteryBypassMenu.menu.addMenuItem(item);
}
}
_buildTdpMenu() {
const power = this._readPowerSourceInfo();
this._tdpMenu.menu.removeAll();
for (const watts of TDP_ACTIONS) {
const batteryBlocked = power.onBattery && watts > MAX_BATTERY_TDP_WATTS;
const suffix = batteryBlocked ? ' (AC only)' : '';
const item = new PopupMenu.PopupMenuItem(`${watts} W${suffix}`);
if (watts === this._config.currentTdpWatts) {
item.setOrnament(PopupMenu.Ornament.DOT);
}
item.connect('activate', () => {
if (batteryBlocked) {
this._statusItem.text = `Battery mode limit: ${MAX_BATTERY_TDP_WATTS}W max`;
return;
}
if (watts > TDP_WARNING_WATTS) {
this._confirmHighTdp(watts);
return;
}
this._setTdp(watts);
});
this._tdpMenu.menu.addMenuItem(item);
}
}
_refresh() {
const status = this._runStatus();
this._enforceBatteryTdpSafety();
this._profilesMenu.menu.removeAll();
this._buildTdpMenu();
this._refreshBatteryInfo(false);
if (!status || !status.profiles || status.profiles.length === 0) {
this._currentFanProfile = null;
this._updateInfoRows();
return;
}
this._currentFanProfile = status.current;
this._updateInfoRows();
for (const profile of status.profiles) {
const item = new PopupMenu.PopupMenuItem(profile);
if (profile === status.current) {
item.setOrnament(PopupMenu.Ornament.DOT);
}
item.connect('activate', () => this._setProfile(profile));
this._profilesMenu.menu.addMenuItem(item);
}
}
destroy() {
if (this._notificationFlashTimeoutId) {
GLib.Source.remove(this._notificationFlashTimeoutId);
this._notificationFlashTimeoutId = null;
}
for (const id of this._notificationFlashStepIds) {
GLib.Source.remove(id);
}
this._notificationFlashStepIds = [];
if (this._timeoutId) {
GLib.Source.remove(this._timeoutId);
this._timeoutId = null;
}
for (const [source, signalId] of this._notificationSourceSignals.entries()) {
if (signalId) {
source.disconnect(signalId);
}
}
this._notificationSourceSignals.clear();
if (this._upowerProxy && this._upowerSignalId) {
this._upowerProxy.disconnect(this._upowerSignalId);
this._upowerSignalId = 0;
}
this._upowerProxy = null;
if (Main.messageTray) {
for (const signalId of this._messageTraySignals) {
Main.messageTray.disconnect(signalId);
}
}
this._messageTraySignals = [];
super.destroy();
}
});
export default class OXPFanProfilesExtension extends Extension {
enable() {
this._button = new OXPFanProfilesButton();
Main.panel.addToStatusArea('oxp-fan-profiles', this._button, 0, 'right');
}
disable() {
if (this._button) {
this._button.destroy();
this._button = null;
}
}
}

View file

@ -0,0 +1,6 @@
{
"uuid": "oxp-fan-profiles@ps1x",
"name": "OXP Control",
"description": "Top-bar fan and RGB control for OneXPlayer Super X",
"shell-version": ["45", "46", "47", "48", "49"]
}

81
install.sh Executable file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_USER="${SUDO_USER:-$USER}"
TARGET_GROUP="$(id -gn "$TARGET_USER")"
EXTENSION_UUID="oxp-fan-profiles@ps1x"
EXTENSION_DIR="$(eval echo "~$TARGET_USER")/.local/share/gnome-shell/extensions/$EXTENSION_UUID"
echo "Installing OXP fan control daemon..."
sudo install -Dm755 "$REPO_DIR/oxp-fan-control.py" /usr/local/bin/oxp-fan-control.py
sudo install -Dm755 "$REPO_DIR/oxp-fan-profile.py" /usr/local/bin/oxp-fan-profile
sudo install -Dm755 "$REPO_DIR/oxp-rgb-hid.py" /usr/local/bin/oxp-rgb-hid
sudo install -Dm755 "$REPO_DIR/oxp-rgb" /usr/local/bin/oxp-rgb
sudo install -Dm755 "$REPO_DIR/oxp-tdp" /usr/local/bin/oxp-tdp
sudo install -Dm755 "$REPO_DIR/oxp-battery-probe.py" /usr/local/bin/oxp-battery-probe
gcc -O2 -Wall -Wextra -o /tmp/oxp-battery-ec-probe "$REPO_DIR/oxp-battery-ec-probe.c"
sudo install -Dm755 /tmp/oxp-battery-ec-probe /usr/local/bin/oxp-battery-ec-probe
sudo chmod 4755 /usr/local/bin/oxp-battery-ec-probe
sudo install -Dm644 "$REPO_DIR/oxp-fan-control.service" /etc/systemd/system/oxp-fan-control.service
sudo install -Dm644 "$REPO_DIR/oxp-sensors.conf" /etc/modules-load.d/oxp-sensors.conf
sudo install -Dm644 "$REPO_DIR/oxp-fan-profile.rules" /etc/polkit-1/rules.d/49-oxp-fan-profile.rules
sudo install -Dm644 "$REPO_DIR/oxp-rgb-hid.rules" /etc/udev/rules.d/99-oxp-rgb-hid.rules
if ! sudo test -f /etc/oxp-fan-control.conf; then
echo "Installing fresh profile config..."
sudo install -Dm644 "$REPO_DIR/oxp-fan-control.conf" /etc/oxp-fan-control.conf
elif ! sudo grep -q '^\[profile ' /etc/oxp-fan-control.conf; then
echo "Migrating legacy /etc/oxp-fan-control.conf to named profiles..."
sudo /usr/local/bin/oxp-fan-profile migrate-legacy
fi
echo "Installing GNOME extension for $TARGET_USER..."
sudo install -d -m755 -o "$TARGET_USER" -g "$TARGET_GROUP" "$EXTENSION_DIR"
sudo install -m644 -o "$TARGET_USER" -g "$TARGET_GROUP" \
"$REPO_DIR/gnome-extension/metadata.json" \
"$EXTENSION_DIR/metadata.json"
sudo install -m644 -o "$TARGET_USER" -g "$TARGET_GROUP" \
"$REPO_DIR/gnome-extension/extension.js" \
"$EXTENSION_DIR/extension.js"
sudo systemctl daemon-reload
sudo udevadm control --reload-rules
sudo udevadm trigger --attr-match=idVendor=1a2c --attr-match=idProduct=b001 || true
sudo modprobe oxp-sensors || true
sudo systemctl enable --now oxp-fan-control.service
if command -v gnome-extensions >/dev/null 2>&1; then
sudo -u "$TARGET_USER" gnome-extensions enable "$EXTENSION_UUID" 2>/dev/null || true
fi
echo
echo "Installed profiles:"
/usr/local/bin/oxp-fan-profile list || true
echo
echo "Current profile:"
/usr/local/bin/oxp-fan-profile current || true
echo
echo "Enable the GNOME extension with:"
echo " gnome-extensions enable $EXTENSION_UUID"
echo
echo "Use the experimental RGB HID helper with:"
echo " /usr/local/bin/oxp-rgb-hid mode rainbow"
echo " /usr/local/bin/oxp-rgb-hid static 255 0 0"
echo
echo "Use the preset RGB wrapper with:"
echo " /usr/local/bin/oxp-rgb rainbow"
echo " /usr/local/bin/oxp-rgb green"
echo
echo "Set TDP presets with:"
echo " sudo /usr/local/bin/oxp-tdp 15"
echo " sudo /usr/local/bin/oxp-tdp 20"
echo
echo "Probe the battery EC backend with:"
echo " sudo /usr/local/bin/oxp-battery-probe --json"
echo " /usr/local/bin/oxp-battery-ec-probe"
echo
echo "Then restart GNOME Shell or log out/in if the menu does not appear immediately."
echo
echo "Local active users get direct access to the RGB HID interface through udev."
echo "Local active wheel users can switch fan profiles from the GNOME menu without an extra password prompt."

14
local/README.md Normal file
View file

@ -0,0 +1,14 @@
# Local artifacts
This directory is intentionally excluded from Git.
Use it for machine-specific or non-public artifacts, for example:
- extracted OneXConsole files and databases
- firmware dumps and ACPI dumps
- decompiled application bundles
- temporary experiment outputs and logs
Expected paths used by helper scripts:
- `local/OneXConsole_linux_reverse/user/gamerzone/core.db`

163
oxp-battery-ec-probe.c Normal file
View file

@ -0,0 +1,163 @@
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#define EC_BASE 0xFE800400UL
#define EC_SIZE 0x100UL
#define OFFS_CHARGE_LIMIT 0xA3
#define OFFS_BYPASS_MODE 0xA4
#define OFFS_FORCE_CHARGE_MIN 0xA5
#define OFFS_POWER_SUPPLY_MODE 0xFE
static uint32_t read_u8(const uint8_t *data, size_t offset) {
return data[offset];
}
static int write_u8(uint8_t *data, size_t offset, uint8_t value) {
data[offset] = value;
return 0;
}
static const char *power_supply_mode_name(uint32_t mode) {
switch (mode) {
case 1:
return "OnlyBattery";
case 2:
return "OnlyTypec100";
case 3:
return "BatteryTypec100";
case 4:
return "DCIn";
case 5:
return "BatteryDCIn";
case 8:
return "Typec65";
case 9:
return "BatteryTypec65";
case 500:
return "BatteryLow";
case 501:
return "BatteryOverheat";
case 1000:
return "Unknown";
default:
return "unmapped";
}
}
static int parse_charge_limit(const char *text, uint8_t *value) {
char *end = NULL;
long parsed = strtol(text, &end, 10);
static const int allowed[] = {50, 60, 70, 75, 80, 85, 90, 95, 100};
size_t i;
if (!text || *text == '\0' || (end && *end != '\0')) {
return -1;
}
for (i = 0; i < sizeof(allowed) / sizeof(allowed[0]); i++) {
if (parsed == allowed[i]) {
*value = (uint8_t)parsed;
return 0;
}
}
return -1;
}
static int parse_bypass_mode(const char *text, uint8_t *value) {
if (strcmp(text, "off") == 0 || strcmp(text, "0") == 0) {
*value = 0;
return 0;
}
if (strcmp(text, "mode1") == 0 || strcmp(text, "1") == 0) {
*value = 1;
return 0;
}
if (strcmp(text, "mode2") == 0 || strcmp(text, "3") == 0) {
*value = 3;
return 0;
}
return -1;
}
int main(int argc, char **argv) {
long page_size = sysconf(_SC_PAGE_SIZE);
int write_mode = 0;
uint8_t write_value = 0;
size_t write_offset = 0;
if (page_size <= 0) {
fprintf(stderr, "failed to get page size\n");
return 1;
}
if (argc == 3 && strcmp(argv[1], "set-charge-limit") == 0) {
if (parse_charge_limit(argv[2], &write_value) != 0) {
fprintf(stderr, "invalid charge limit\n");
return 2;
}
write_mode = 1;
write_offset = OFFS_CHARGE_LIMIT;
} else if (argc == 3 && strcmp(argv[1], "set-bypass-mode") == 0) {
if (parse_bypass_mode(argv[2], &write_value) != 0) {
fprintf(stderr, "invalid bypass mode\n");
return 2;
}
write_mode = 1;
write_offset = OFFS_BYPASS_MODE;
} else if (argc != 1 && !(argc == 2 && strcmp(argv[1], "get") == 0)) {
fprintf(stderr, "usage: %s [get|set-charge-limit <percent>|set-bypass-mode <off|mode1|mode2>]\n", argv[0]);
return 2;
}
off_t page_base = (off_t)(EC_BASE & ~((unsigned long)page_size - 1UL));
off_t page_offset = (off_t)(EC_BASE - (unsigned long)page_base);
size_t map_size = (size_t)page_offset + EC_SIZE;
int fd = open("/dev/mem", write_mode ? (O_RDWR | O_SYNC) : (O_RDONLY | O_SYNC));
if (fd < 0) {
fprintf(stderr, "open /dev/mem failed: %s\n", strerror(errno));
return 1;
}
void *map = mmap(NULL, map_size, write_mode ? (PROT_READ | PROT_WRITE) : PROT_READ, MAP_SHARED, fd, page_base);
if (map == MAP_FAILED) {
fprintf(stderr, "mmap failed: %s\n", strerror(errno));
close(fd);
return 1;
}
uint8_t *data = (uint8_t *)map + page_offset;
if (write_mode) {
write_u8(data, write_offset, write_value);
msync(map, map_size, MS_SYNC);
}
uint32_t charge_limit = read_u8(data, OFFS_CHARGE_LIMIT);
uint32_t bypass_mode = read_u8(data, OFFS_BYPASS_MODE);
uint32_t force_charge_min = read_u8(data, OFFS_FORCE_CHARGE_MIN);
uint32_t power_supply_mode = read_u8(data, OFFS_POWER_SUPPLY_MODE);
printf("{\n");
printf(" \"charge_limit_percent\": %u,\n", charge_limit);
printf(" \"bypass_power_mode\": %u,\n", bypass_mode);
printf(" \"force_charge_min\": %u,\n", force_charge_min);
printf(" \"write_applied\": %s,\n", write_mode ? "true" : "false");
printf(" \"power_supply_mode\": {\n");
printf(" \"value\": %u,\n", power_supply_mode);
printf(" \"name\": \"%s\"\n", power_supply_mode_name(power_supply_mode));
printf(" }\n");
printf("}\n");
munmap(map, map_size);
close(fd);
return 0;
}

281
oxp-battery-probe.py Normal file
View file

@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Read confirmed battery-related fields from the OneXPlayer Super X EC memory region.
This helper is intentionally read-only. It does not write EC/WMI state.
"""
import argparse
import json
import mmap
import os
import sys
from pathlib import Path
EC_BASE = 0xFE800400
EC_SIZE = 0x100
FIELDS = (
("CHGR", 0x67, 2, "candidate charge-limit / charge-control field"),
("TBAT", 0x6A, 1, "battery temperature raw"),
("B1DC", 0x84, 2, "design capacity"),
("B1FV", 0x86, 2, "present voltage in mV"),
("B1FC", 0x88, 2, "full charge capacity"),
("B1ST", 0x8C, 1, "ACPI battery status bitmask"),
("B1CR", 0x8D, 2, "present charge/discharge rate"),
("B1RC", 0x8F, 2, "remaining capacity"),
("B1VT", 0x91, 2, "battery terminal voltage raw"),
("BPCN", 0x93, 1, "battery percentage"),
("CHLT", 0xA3, 1, "confirmed battery charge limit percent"),
("BPWM", 0xA4, 1, "confirmed bypass power mode value"),
("FCMN", 0xA5, 1, "confirmed force-charge-min threshold"),
("BALT", 0xEF, 1, "candidate bypass/battery-alternate mode"),
("PSMD", 0xFE, 1, "confirmed power supply mode enum"),
)
BATTERY_STATUS_FLAGS = {
0x01: "discharging",
0x02: "charging",
0x04: "critical",
}
POWER_SUPPLY_MODE = {
1: "OnlyBattery",
2: "OnlyTypec100",
3: "BatteryTypec100",
4: "DCIn",
5: "BatteryDCIn",
8: "Typec65",
9: "BatteryTypec65",
500: "BatteryLow",
501: "BatteryOverheat",
1000: "Unknown",
}
def parse_args():
parser = argparse.ArgumentParser(
description="Read confirmed OneXPlayer Super X battery fields from EC system-memory region",
)
parser.add_argument("--json", action="store_true", help="Print JSON instead of text")
parser.add_argument(
"--dump",
action="store_true",
help="Include a full 0x100-byte hex dump of the EC battery region",
)
return parser.parse_args()
def read_bat0():
battery = Path("/sys/class/power_supply/BAT0")
if not battery.exists():
return None
values = {}
for name in (
"status",
"capacity",
"voltage_now",
"energy_full",
"energy_full_design",
"energy_now",
"power_now",
):
path = battery / name
if path.exists():
values[name] = path.read_text(encoding="utf-8").strip()
return values
def decode_battery_status(value):
active = [name for bit, name in BATTERY_STATUS_FLAGS.items() if value & bit]
return active or ["idle"]
def decode_power_supply_mode(value):
name = POWER_SUPPLY_MODE.get(value)
return [name] if name else ["unmapped"]
def read_region():
page_size = os.sysconf("SC_PAGE_SIZE")
page_base = EC_BASE & ~(page_size - 1)
page_offset = EC_BASE - page_base
fd = os.open("/dev/mem", os.O_RDONLY | os.O_SYNC)
try:
region = mmap.mmap(
fd,
page_offset + EC_SIZE,
flags=mmap.MAP_SHARED,
prot=mmap.PROT_READ,
offset=page_base,
)
data = bytes(region[page_offset : page_offset + EC_SIZE])
finally:
os.close(fd)
return data
def build_payload(include_dump: bool):
raw = read_region()
fields = []
values = {}
sysfs_bat0 = read_bat0()
for name, offset, size, description in FIELDS:
chunk = raw[offset : offset + size]
value = int.from_bytes(chunk, "little")
item = {
"name": name,
"offset": f"0x{offset:02X}",
"address": f"0x{EC_BASE + offset:08X}",
"size": size,
"hex": chunk.hex(),
"value": value,
"description": description,
}
if name == "B1ST":
item["decoded"] = decode_battery_status(value)
elif name == "PSMD":
item["decoded"] = decode_power_supply_mode(value)
fields.append(item)
values[name] = value
payload = {
"backend": {
"type": "ec-system-memory",
"path": "/dev/mem",
"base": f"0x{EC_BASE:08X}",
"size": f"0x{EC_SIZE:02X}",
"write_support": False,
},
"sysfs_bat0": sysfs_bat0,
"linux_battery_metrics": {
"energy_full": (
int(sysfs_bat0["energy_full"]) if sysfs_bat0 and sysfs_bat0.get("energy_full") else None
),
"energy_full_design": (
int(sysfs_bat0["energy_full_design"])
if sysfs_bat0 and sysfs_bat0.get("energy_full_design")
else None
),
"health_percent": (
round(
int(sysfs_bat0["energy_full"]) * 100.0 / int(sysfs_bat0["energy_full_design"]),
2,
)
if sysfs_bat0
and sysfs_bat0.get("energy_full")
and sysfs_bat0.get("energy_full_design")
and int(sysfs_bat0["energy_full_design"]) > 0
else None
),
},
"confirmed_matches": {
"capacity_percent": {
"ec_field": "BPCN",
"ec_value": values["BPCN"],
"sysfs_value": sysfs_bat0.get("capacity") if sysfs_bat0 else None,
},
"voltage_mv": {
"ec_field": "B1FV",
"ec_value": values["B1FV"],
"sysfs_value": (
int(sysfs_bat0["voltage_now"]) // 1000
if sysfs_bat0 and sysfs_bat0.get("voltage_now")
else None
),
},
"status_bits": {
"ec_field": "B1ST",
"ec_value": values["B1ST"],
"decoded": decode_battery_status(values["B1ST"]),
"sysfs_value": sysfs_bat0.get("status") if sysfs_bat0 else None,
},
"power_supply_mode": {
"ec_field": "PSMD",
"ec_value": values["PSMD"],
"decoded": decode_power_supply_mode(values["PSMD"]),
},
},
"confirmed_control_fields": {
"charge_limit_percent": {"field": "CHLT", "value": values["CHLT"]},
"bypass_power_mode": {"field": "BPWM", "value": values["BPWM"]},
"force_charge_min": {"field": "FCMN", "value": values["FCMN"]},
"power_supply_mode": {
"field": "PSMD",
"value": values["PSMD"],
"decoded": decode_power_supply_mode(values["PSMD"]),
},
},
"candidate_control_fields": {
"charge_limit_or_charge_control": {"field": "CHGR", "value": values["CHGR"]},
"bypass_or_alt_mode": {"field": "BALT", "value": values["BALT"]},
},
"fields": fields,
}
if include_dump:
payload["dump_hex"] = raw.hex()
return payload
def print_text(payload):
backend = payload["backend"]
print(f"backend: {backend['type']} via {backend['path']} @ {backend['base']}")
sysfs_bat0 = payload.get("sysfs_bat0") or {}
if sysfs_bat0:
print("sysfs BAT0:")
for key, value in sysfs_bat0.items():
print(f" {key}: {value}")
linux_metrics = payload.get("linux_battery_metrics") or {}
if linux_metrics:
print("linux battery metrics:")
for key, value in linux_metrics.items():
print(f" {key}: {value}")
print("confirmed matches:")
for key, value in payload["confirmed_matches"].items():
print(f" {key}: {value}")
print("confirmed control fields:")
for key, value in payload["confirmed_control_fields"].items():
print(f" {key}: {value}")
print("candidate control fields:")
for key, value in payload["candidate_control_fields"].items():
print(f" {key}: {value}")
print("fields:")
for field in payload["fields"]:
suffix = f", decoded={field['decoded']}" if "decoded" in field else ""
print(
f" {field['name']} {field['offset']} {field['hex']} = {field['value']}"
f" ({field['description']}{suffix})"
)
def main():
args = parse_args()
try:
payload = build_payload(args.dump)
except PermissionError:
print("Error: reading /dev/mem requires root", file=sys.stderr)
sys.exit(1)
except OSError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print_text(payload)
if __name__ == "__main__":
main()

75
oxp-fan-control.conf Normal file
View file

@ -0,0 +1,75 @@
[general]
active_profile = balanced
[profile silent]
# Quiet everyday profile
target_temp = 72
min_temp = 50
max_temp = 82
interval = 1
kp = 4.0
ki = 0.15
kd = 4.0
max_pwm_change = 5
min_pwm = 40
50 = 0
58 = 45
65 = 90
72 = 140
78 = 200
82 = 255
[profile balanced]
# Default mixed-use profile
target_temp = 78
min_temp = 55
max_temp = 88
interval = 1
kp = 4.0
ki = 0.15
kd = 4.0
max_pwm_change = 5
min_pwm = 40
55 = 0
62 = 50
70 = 100
78 = 150
84 = 210
88 = 255
[profile watercool]
# Delayed fan start for external water cooling
target_temp = 85
min_temp = 65
max_temp = 95
interval = 1
kp = 4.0
ki = 0.15
kd = 4.0
max_pwm_change = 5
min_pwm = 40
65 = 0
75 = 60
82 = 100
88 = 150
92 = 200
95 = 255
[profile aggressive]
# Fast ramp for sustained high load or warm ambient temperatures
target_temp = 65
min_temp = 50
max_temp = 85
interval = 1
kp = 6.0
ki = 0.2
kd = 3.0
max_pwm_change = 15
min_pwm = 40
50 = 40
55 = 120
60 = 150
65 = 180
70 = 210
75 = 240
80 = 255

430
oxp-fan-control.py Normal file
View file

@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""
OXP fan control daemon with named profile support.
"""
import configparser
import logging
import signal
import sys
import time
from collections import deque
from pathlib import Path
DEFAULT_CONFIG = """[general]
active_profile = balanced
[profile silent]
target_temp = 72
min_temp = 50
max_temp = 82
interval = 1
kp = 4.0
ki = 0.15
kd = 4.0
max_pwm_change = 5
min_pwm = 40
50 = 0
58 = 45
65 = 90
72 = 140
78 = 200
82 = 255
[profile balanced]
target_temp = 78
min_temp = 55
max_temp = 88
interval = 1
kp = 4.0
ki = 0.15
kd = 4.0
max_pwm_change = 5
min_pwm = 40
55 = 0
62 = 50
70 = 100
78 = 150
84 = 210
88 = 255
[profile watercool]
target_temp = 85
min_temp = 65
max_temp = 95
interval = 1
kp = 4.0
ki = 0.15
kd = 4.0
max_pwm_change = 5
min_pwm = 40
65 = 0
75 = 60
82 = 100
88 = 150
92 = 200
95 = 255
[profile aggressive]
target_temp = 65
min_temp = 50
max_temp = 85
interval = 1
kp = 6.0
ki = 0.2
kd = 3.0
max_pwm_change = 15
min_pwm = 40
50 = 40
55 = 120
60 = 150
65 = 180
70 = 210
75 = 240
80 = 255
"""
CONFIG_PATH = Path("/etc/oxp-fan-control.conf")
HWMON_PATH = None
PROFILE_PREFIX = "profile "
PARAM_DEFAULTS = {
"target_temp": 78.0,
"min_temp": 55.0,
"max_temp": 88.0,
"interval": 1.0,
"kp": 4.0,
"ki": 0.15,
"kd": 4.0,
"max_pwm_change": 5,
"min_pwm": 40,
}
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("oxp-fan")
class PIDController:
def __init__(self, kp, ki, kd, target, min_output=0, max_output=255):
self.kp = kp
self.ki = ki
self.kd = kd
self.target = target
self.min_output = min_output
self.max_output = max_output
self.integral = 0
self.last_error = 0
self.last_time = time.time()
def reset(self):
self.integral = 0
self.last_error = 0
self.last_time = time.time()
def compute(self, current_value):
now = time.time()
dt = max(now - self.last_time, 0.1)
error = current_value - self.target
p_term = self.kp * error
self.integral += error * dt
self.integral = max(-100, min(100, self.integral))
i_term = self.ki * self.integral
d_term = 0
if dt > 0:
d_value = (current_value - (current_value - error)) / dt
d_term = -self.kd * d_value
output = p_term + i_term + d_term
output = max(self.min_output, min(self.max_output, output))
self.last_error = error
self.last_time = now
return output
def find_hwmon():
for hwmon_dir in Path("/sys/class/hwmon").glob("hwmon*"):
name_file = hwmon_dir / "name"
if name_file.exists() and name_file.read_text().strip() == "oxpec":
return hwmon_dir
return None
def read_temp():
for hwmon_dir in Path("/sys/class/hwmon").glob("hwmon*"):
try:
for label_file in hwmon_dir.glob("temp*_label"):
label = label_file.read_text().strip()
if label in ["Tctl", "Tdie", "CPU", "Core"]:
temp_file = label_file.with_name(label_file.name.replace("_label", "_input"))
if temp_file.exists():
return int(temp_file.read_text().strip()) / 1000.0
temp_file = hwmon_dir / "temp1_input"
if temp_file.exists():
temp = int(temp_file.read_text().strip())
if temp > 0:
return temp / 1000.0
except (ValueError, IOError):
continue
return None
def set_fan_speed(pwm_value):
if not HWMON_PATH:
return False
try:
(HWMON_PATH / "pwm1_enable").write_text("1")
(HWMON_PATH / "pwm1").write_text(str(int(pwm_value)))
return True
except IOError as exc:
logger.error("Error setting fan speed: %s", exc)
return False
def create_default_config():
if CONFIG_PATH.exists():
return
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(DEFAULT_CONFIG)
logger.info("Created default config at %s", CONFIG_PATH)
def load_config():
config = configparser.ConfigParser()
config.read_string(DEFAULT_CONFIG)
if CONFIG_PATH.exists():
config.read(CONFIG_PATH)
return config
def list_profiles(config):
profiles = []
for section in config.sections():
if section.startswith(PROFILE_PREFIX):
profiles.append(section[len(PROFILE_PREFIX):])
return profiles
def get_profile_section(config, profile_name):
if profile_name == "legacy" and config.has_section("fan"):
return "fan"
section = f"{PROFILE_PREFIX}{profile_name}"
if config.has_section(section):
return section
return None
def get_active_profile_name(config):
profiles = list_profiles(config)
if profiles:
requested = config.get("general", "active_profile", fallback=profiles[0]).strip()
if requested in profiles:
return requested
return profiles[0]
if config.has_section("fan"):
return "legacy"
raise RuntimeError("No usable fan profile found in config")
def parse_fan_curve(config, section):
curve = {}
for key, value in config.items(section):
if key.isdigit():
curve[int(key)] = int(value)
return dict(sorted(curve.items()))
def get_base_pwm(temp, curve, min_temp):
if temp < min_temp or not curve:
return 0
thresholds = sorted(curve.keys())
if temp <= thresholds[0]:
return curve[thresholds[0]]
if temp >= thresholds[-1]:
return curve[thresholds[-1]]
for index in range(len(thresholds) - 1):
left = thresholds[index]
right = thresholds[index + 1]
if left <= temp <= right:
left_pwm = curve[left]
right_pwm = curve[right]
return left_pwm + (right_pwm - left_pwm) * (temp - left) / (right - left)
return 0
def load_runtime_settings():
config = load_config()
profile_name = get_active_profile_name(config)
section = get_profile_section(config, profile_name)
curve = parse_fan_curve(config, section)
settings = {
"profile_name": profile_name,
"target_temp": config.getfloat(section, "target_temp", fallback=PARAM_DEFAULTS["target_temp"]),
"min_temp": config.getfloat(section, "min_temp", fallback=PARAM_DEFAULTS["min_temp"]),
"max_temp": config.getfloat(section, "max_temp", fallback=PARAM_DEFAULTS["max_temp"]),
"interval": config.getfloat(section, "interval", fallback=PARAM_DEFAULTS["interval"]),
"kp": config.getfloat(section, "kp", fallback=PARAM_DEFAULTS["kp"]),
"ki": config.getfloat(section, "ki", fallback=PARAM_DEFAULTS["ki"]),
"kd": config.getfloat(section, "kd", fallback=PARAM_DEFAULTS["kd"]),
"max_pwm_change": config.getint(section, "max_pwm_change", fallback=PARAM_DEFAULTS["max_pwm_change"]),
"min_pwm": config.getint(section, "min_pwm", fallback=PARAM_DEFAULTS["min_pwm"]),
"curve": curve,
}
return settings
def main():
global HWMON_PATH
running = True
def signal_handler(signum, frame):
nonlocal running
running = False
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
HWMON_PATH = find_hwmon()
if not HWMON_PATH:
logger.error("Could not find oxp-sensors hwmon device.")
sys.exit(1)
logger.info("Found OXP hwmon at: %s", HWMON_PATH)
create_default_config()
current_mtime = None
current_profile = None
settings = None
pid = None
temp_history = deque(maxlen=3)
last_pwm = 0
fan_off = True
manual_off_applied = False
logger.info("Starting fan control daemon...")
while running:
try:
config_mtime = CONFIG_PATH.stat().st_mtime if CONFIG_PATH.exists() else None
if settings is None or config_mtime != current_mtime:
settings = load_runtime_settings()
current_mtime = config_mtime
temp_history.clear()
last_pwm = 0
fan_off = True
manual_off_applied = False
pid = PIDController(
settings["kp"],
settings["ki"],
settings["kd"],
settings["target_temp"],
0,
255,
)
if current_profile != settings["profile_name"]:
logger.info(
"Loaded fan profile '%s' (target=%sC min=%sC max=%sC)",
settings["profile_name"],
settings["target_temp"],
settings["min_temp"],
settings["max_temp"],
)
else:
logger.info("Reloaded fan profile '%s'", settings["profile_name"])
current_profile = settings["profile_name"]
temp = read_temp()
if temp is None:
logger.warning("Could not read temperature.")
time.sleep(settings["interval"])
continue
temp_history.append(temp)
smoothed_temp = sum(temp_history) / len(temp_history)
if smoothed_temp >= settings["max_temp"]:
target_pwm = 255
fan_off = False
logger.warning(
"EMERGENCY COOLING [%s]: Temp %.1fC >= %.1fC",
current_profile,
smoothed_temp,
settings["max_temp"],
)
else:
base_pwm = get_base_pwm(smoothed_temp, settings["curve"], settings["min_temp"])
if base_pwm == 0 and not fan_off and smoothed_temp >= (settings["min_temp"] - 3.0):
base_pwm = settings["min_pwm"]
if base_pwm == 0:
if not manual_off_applied:
set_fan_speed(0)
manual_off_applied = True
if not fan_off:
fan_off = True
pid.reset()
last_pwm = 0
logger.info(
"[%s] Temp: %.1fC -> Fan: OFF",
current_profile,
smoothed_temp,
)
time.sleep(settings["interval"])
continue
is_starting = fan_off
fan_off = False
manual_off_applied = False
pid_adjustment = pid.compute(smoothed_temp)
target_pwm = base_pwm + pid_adjustment
if settings["max_pwm_change"] > 0 and not is_starting:
pwm_diff = target_pwm - last_pwm
if abs(pwm_diff) > settings["max_pwm_change"]:
step = settings["max_pwm_change"] if pwm_diff > 0 else -settings["max_pwm_change"]
target_pwm = last_pwm + step
if target_pwm < settings["min_pwm"]:
target_pwm = settings["min_pwm"]
target_pwm = max(0, min(255, target_pwm))
if int(target_pwm) != int(last_pwm):
if set_fan_speed(target_pwm):
logger.info(
"[%s] Temp: %.1fC -> PWM: %d",
current_profile,
smoothed_temp,
int(target_pwm),
)
last_pwm = target_pwm
time.sleep(settings["interval"])
except Exception as exc:
logger.error("Error in main loop: %s", exc)
time.sleep(settings["interval"] if settings else 1.0)
logger.info("Restoring automatic fan control...")
try:
(HWMON_PATH / "pwm1_enable").write_text("0")
except IOError:
pass
logger.info("Fan control stopped.")
if __name__ == "__main__":
main()

15
oxp-fan-control.service Normal file
View file

@ -0,0 +1,15 @@
[Unit]
Description=OXP SuperX Fan Control Daemon
After=systemd-modules-load.service
Wants=systemd-modules-load.service
[Service]
Type=simple
ExecStartPre=/usr/sbin/modprobe oxp-sensors
ExecStartPre=/bin/sh -c 'for i in $(seq 1 20); do for d in /sys/class/hwmon/hwmon*; do [ -r "$d/name" ] || continue; [ "$(cat "$d/name")" = "oxpec" ] && exit 0; done; sleep 0.5; done; echo "oxpec hwmon device not found after loading oxp-sensors" >&2; exit 1'
ExecStart=/usr/local/bin/oxp-fan-control.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target

237
oxp-fan-profile.py Normal file
View file

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""
Manage OXP fan profiles stored in /etc/oxp-fan-control.conf.
"""
import argparse
import configparser
import json
import os
import sys
import tempfile
from pathlib import Path
CONFIG_PATH = Path("/etc/oxp-fan-control.conf")
PROFILE_PREFIX = "profile "
PROFILE_ORDER = ["silent", "balanced", "watercool", "aggressive"]
STOCK_PROFILES = {
"silent": {
"target_temp": "72",
"min_temp": "50",
"max_temp": "82",
"interval": "1",
"kp": "4.0",
"ki": "0.15",
"kd": "4.0",
"max_pwm_change": "5",
"min_pwm": "40",
"50": "0",
"58": "45",
"65": "90",
"72": "140",
"78": "200",
"82": "255",
},
"balanced": {
"target_temp": "78",
"min_temp": "55",
"max_temp": "88",
"interval": "1",
"kp": "4.0",
"ki": "0.15",
"kd": "4.0",
"max_pwm_change": "5",
"min_pwm": "40",
"55": "0",
"62": "50",
"70": "100",
"78": "150",
"84": "210",
"88": "255",
},
"watercool": {
"target_temp": "85",
"min_temp": "65",
"max_temp": "95",
"interval": "1",
"kp": "4.0",
"ki": "0.15",
"kd": "4.0",
"max_pwm_change": "5",
"min_pwm": "40",
"65": "0",
"75": "60",
"82": "100",
"88": "150",
"92": "200",
"95": "255",
},
"aggressive": {
"target_temp": "65",
"min_temp": "50",
"max_temp": "85",
"interval": "1",
"kp": "6.0",
"ki": "0.2",
"kd": "3.0",
"max_pwm_change": "15",
"min_pwm": "40",
"50": "40",
"55": "120",
"60": "150",
"65": "180",
"70": "210",
"75": "240",
"80": "255",
},
}
def load_config():
config = configparser.ConfigParser()
if CONFIG_PATH.exists():
config.read(CONFIG_PATH)
return config
def profile_sections(config):
return [section for section in config.sections() if section.startswith(PROFILE_PREFIX)]
def list_profiles(config):
profiles = [section[len(PROFILE_PREFIX):] for section in profile_sections(config)]
if not profiles and config.has_section("fan"):
return ["legacy"]
return profiles
def current_profile(config):
profiles = list_profiles(config)
if not profiles:
raise RuntimeError("No fan profiles configured")
selected = config.get("general", "active_profile", fallback=profiles[0]).strip()
if selected in profiles:
return selected
return profiles[0]
def write_config(config):
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", delete=False, dir=CONFIG_PATH.parent, encoding="utf-8") as handle:
config.write(handle)
temp_path = Path(handle.name)
os.replace(temp_path, CONFIG_PATH)
CONFIG_PATH.chmod(0o644)
def ensure_root():
if os.geteuid() != 0:
raise PermissionError("This command must run as root to modify /etc/oxp-fan-control.conf")
def make_stock_config(active_profile="balanced"):
config = configparser.ConfigParser()
config["general"] = {"active_profile": active_profile}
for name in PROFILE_ORDER:
config[f"{PROFILE_PREFIX}{name}"] = STOCK_PROFILES[name]
return config
def merge_legacy_profile(config):
if not config.has_section("fan"):
raise RuntimeError("No legacy [fan] section found to migrate")
migrated = make_stock_config(active_profile="legacy")
legacy_values = {}
for key, value in config.items("fan"):
legacy_values[key] = value
migrated[f"{PROFILE_PREFIX}legacy"] = legacy_values
return migrated
def cmd_list(args):
config = load_config()
profiles = list_profiles(config)
if args.json:
print(json.dumps({"profiles": profiles}))
return
for profile in profiles:
print(profile)
def cmd_current(args):
config = load_config()
current = current_profile(config)
if args.json:
print(json.dumps({"current": current}))
return
print(current)
def cmd_status(args):
config = load_config()
payload = {
"current": current_profile(config),
"profiles": list_profiles(config),
}
print(json.dumps(payload))
def cmd_set(args):
ensure_root()
config = load_config()
profiles = list_profiles(config)
if args.profile not in profiles:
raise RuntimeError(f"Unknown profile '{args.profile}'. Available: {', '.join(profiles)}")
if not config.has_section("general"):
config.add_section("general")
config.set("general", "active_profile", args.profile)
write_config(config)
print(f"Active profile set to {args.profile}")
def cmd_migrate_legacy(args):
ensure_root()
config = load_config()
migrated = merge_legacy_profile(config)
write_config(migrated)
print("Migrated legacy [fan] config to named profiles; active profile is 'legacy'")
def build_parser():
parser = argparse.ArgumentParser(description="Manage OXP fan profiles")
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser("list", help="List available profiles")
list_parser.add_argument("--json", action="store_true", help="Output JSON")
list_parser.set_defaults(func=cmd_list)
current_parser = subparsers.add_parser("current", help="Show active profile")
current_parser.add_argument("--json", action="store_true", help="Output JSON")
current_parser.set_defaults(func=cmd_current)
status_parser = subparsers.add_parser("status", help="Show active profile and all profile names")
status_parser.set_defaults(func=cmd_status)
set_parser = subparsers.add_parser("set", help="Set active profile")
set_parser.add_argument("profile", help="Profile name to activate")
set_parser.set_defaults(func=cmd_set)
migrate_parser = subparsers.add_parser("migrate-legacy", help="Convert a legacy [fan] config into profile sections")
migrate_parser.set_defaults(func=cmd_migrate_legacy)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
try:
args.func(args)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

15
oxp-fan-profile.rules Normal file
View file

@ -0,0 +1,15 @@
polkit.addRule(function(action, subject) {
if (action.id !== "org.freedesktop.policykit.exec") {
return polkit.Result.NOT_HANDLED;
}
if (action.lookup("program") !== "/usr/local/bin/oxp-fan-profile") {
return polkit.Result.NOT_HANDLED;
}
if (!subject.local || !subject.active || !subject.isInGroup("wheel")) {
return polkit.Result.NOT_HANDLED;
}
return polkit.Result.YES;
});

139
oxp-rgb Normal file
View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Convenience wrapper around oxp-rgb-hid for confirmed RGB sequences.
"""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
RGB_HID_HELPER = Path("/usr/local/bin/oxp-rgb-hid")
LOCAL_RGB_HID_HELPER = Path(__file__).with_name("oxp-rgb-hid.py")
PRESETS = {
"off": [
["level", "off"],
],
"dim": [
["level", "1"],
],
"mid": [
["level", "2"],
],
"bright": [
["level", "3"],
],
"rainbow": [
["mode", "rainbow"],
["mode", "enable"],
],
"red": [
["static", "255", "0", "0"],
["mode", "alt-static"],
],
"green": [
["static", "0", "255", "0"],
["mode", "alt-static"],
],
"blue": [
["static", "0", "0", "255"],
["mode", "alt-static"],
],
"warm": [
["static", "244", "67", "54"],
["mode", "alt-static"],
],
"rainbow-flow": [
["preset", "0x01"],
],
"rainbow-breath-random": [
["preset", "0x02"],
],
"rainbow-cycle": [
["preset", "0x03"],
],
"slow-color-runner": [
["preset", "0x04"],
],
"slow-color-breath": [
["preset", "0x05"],
],
"slow-color-breath-2": [
["preset", "0x06"],
],
"slow-color-breath-3": [
["preset", "0x07"],
],
"flame-cycle": [
["preset", "0x08"],
],
"cyberpunk": [
["preset", "0x09"],
],
"teal-green-drops": [
["preset", "0x0a"],
],
"pink-red-drops": [
["preset", "0x0b"],
],
"whole-rainbow-cycle": [
["preset", "0x0c"],
],
"red-monster": [
["preset", "0x0d"],
],
"green-monster": [
["preset", "0x0e"],
],
"blue-monster": [
["preset", "0x0f"],
],
"green-breath": [
["preset", "0x10"],
],
"cyan-breath": [
["preset", "0x11"],
],
"pink-breath": [
["preset", "0x12"],
],
"white-breath-slow": [
["preset", "0x13"],
],
"red-solid": [
["preset", "0x14"],
],
}
def parse_args():
parser = argparse.ArgumentParser(description="Convenience RGB presets for OneXPlayer Super X")
parser.add_argument("preset", choices=sorted(PRESETS))
parser.add_argument("--dry-run", action="store_true")
return parser.parse_args()
def helper_path() -> Path:
if LOCAL_RGB_HID_HELPER.exists():
return LOCAL_RGB_HID_HELPER
if RGB_HID_HELPER.exists():
return RGB_HID_HELPER
raise FileNotFoundError("oxp-rgb-hid helper not found")
def main():
args = parse_args()
helper = helper_path()
for step in PRESETS[args.preset]:
cmd = [sys.executable, str(helper), *step]
if args.dry_run:
cmd.append("--dry-run")
subprocess.run(cmd, check=True)
if __name__ == "__main__":
main()

173
oxp-rgb-hid.py Normal file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""
Experimental RGB control for the OneXPlayer Super X vendor HID interface.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
DEFAULT_VENDOR_ID = 0x1A2C
DEFAULT_PRODUCT_ID = 0xB001
DEFAULT_INTERFACE = 0
PACKET_SIZE = 64
BRIGHTNESS_LEVELS = {
"off": [0x07, 0xFF, 0xFD, 0x00, 0x05, 0x01],
"1": [0x07, 0xFF, 0xFD, 0x01, 0x05, 0x01],
"2": [0x07, 0xFF, 0xFD, 0x01, 0x05, 0x03],
"3": [0x07, 0xFF, 0xFD, 0x01, 0x05, 0x04],
}
SAFE_PRESET_IDS = set(range(0x00, 0x17))
CAPTURED_PRESET_IDS = {0x0D, 0x03, 0x0B, 0x05, 0x07, 0x09, 0x0C, 0x02, 0x08}
def parse_int(value: str) -> int:
return int(value, 0)
def read_attr(path: Path, name: str) -> str | None:
attr = path / name
if not attr.exists():
return None
return attr.read_text(encoding="utf-8").strip()
def find_hidraw_path(vendor_id: int, product_id: int, interface: int) -> Path:
for hidraw in sorted(Path("/sys/class/hidraw").glob("hidraw*")):
device = (hidraw / "device").resolve()
usb_interface = device.parent
usb_device = usb_interface.parent
if read_attr(usb_device, "idVendor") != f"{vendor_id:04x}":
continue
if read_attr(usb_device, "idProduct") != f"{product_id:04x}":
continue
if read_attr(usb_interface, "bInterfaceNumber") != f"{interface:02x}":
continue
return Path("/dev") / hidraw.name
raise RuntimeError(
f"hidraw device not found for vendor=0x{vendor_id:04x} "
f"product=0x{product_id:04x} interface={interface}"
)
def pad_packet(prefix: list[int]) -> list[int]:
if len(prefix) > PACKET_SIZE:
raise ValueError(f"packet too long: {len(prefix)}")
return prefix + [0] * (PACKET_SIZE - len(prefix))
def build_static_packet(red: int, green: int, blue: int) -> list[int]:
for value in (red, green, blue):
if value < 0 or value > 255:
raise ValueError("RGB values must be in range 0-255")
payload = [0x07, 0xFF, 0xFE]
payload.extend([red, green, blue] * 20)
payload.append(0x00)
return payload
def build_mode_packet(mode: str) -> list[int]:
if mode == "static":
return pad_packet([0x07, 0xFF, 0xFD, 0x00, 0x05, 0x04])
if mode == "enable":
return pad_packet([0x07, 0xFF, 0x05, 0x00])
if mode == "rainbow":
return pad_packet([0x07, 0xFF, 0xFD, 0x01, 0x05, 0x04])
if mode == "alt-static":
return pad_packet([0x07, 0xFF, 0xFD, 0x01, 0x05, 0x04])
raise ValueError(f"unsupported mode: {mode}")
def build_level_packet(level: str) -> list[int]:
try:
return pad_packet(BRIGHTNESS_LEVELS[level])
except KeyError as exc:
raise ValueError(f"unsupported level: {level}") from exc
def build_preset_packet(preset_id: int) -> list[int]:
if preset_id not in SAFE_PRESET_IDS:
raise ValueError(
f"unsupported preset id 0x{preset_id:02x}; "
f"safe range: 0x00..0x16; "
f"captured ids: {', '.join(f'0x{value:02x}' for value in sorted(CAPTURED_PRESET_IDS))}"
)
return pad_packet([0x07, 0xFF, preset_id, 0x00, 0x00])
def build_parser():
parser = argparse.ArgumentParser(description="Experimental OneXPlayer Super X RGB HID helper")
parser.add_argument("--vendor-id", type=parse_int, default=DEFAULT_VENDOR_ID)
parser.add_argument("--product-id", type=parse_int, default=DEFAULT_PRODUCT_ID)
parser.add_argument("--interface", type=int, default=DEFAULT_INTERFACE)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--json", action="store_true")
subparsers = parser.add_subparsers(dest="cmd", required=True)
static = subparsers.add_parser("static", help="Send a static RGB color packet")
static.add_argument("--dry-run", action="store_true")
static.add_argument("--json", action="store_true")
static.add_argument("red", type=int)
static.add_argument("green", type=int)
static.add_argument("blue", type=int)
mode = subparsers.add_parser("mode", help="Send a captured mode packet")
mode.add_argument("--dry-run", action="store_true")
mode.add_argument("--json", action="store_true")
mode.add_argument("mode", choices=["static", "enable", "rainbow", "alt-static"])
level = subparsers.add_parser("level", help="Send a captured brightness/on-off packet")
level.add_argument("--dry-run", action="store_true")
level.add_argument("--json", action="store_true")
level.add_argument("level", choices=["off", "1", "2", "3"])
preset = subparsers.add_parser("preset", help="Send a captured preset-id packet")
preset.add_argument("--dry-run", action="store_true")
preset.add_argument("--json", action="store_true")
preset.add_argument("preset_id", type=parse_int)
return parser
def main():
args = build_parser().parse_args()
if args.cmd == "static":
packet = build_static_packet(args.red, args.green, args.blue)
action = {"cmd": "static", "rgb": [args.red, args.green, args.blue]}
elif args.cmd == "mode":
packet = build_mode_packet(args.mode)
action = {"cmd": "mode", "mode": args.mode}
elif args.cmd == "level":
packet = build_level_packet(args.level)
action = {"cmd": "level", "level": args.level}
elif args.cmd == "preset":
packet = build_preset_packet(args.preset_id)
action = {"cmd": "preset", "preset_id": args.preset_id}
else:
raise RuntimeError(f"unsupported command: {args.cmd}")
result = {
**action,
"interface": args.interface,
"packet": packet,
}
if args.dry_run:
result["status"] = "dry-run"
print(json.dumps(result, indent=2) if args.json else result)
return
hidraw_path = find_hidraw_path(args.vendor_id, args.product_id, args.interface)
with hidraw_path.open("r+b", buffering=0) as device:
written = device.write(bytes(packet))
result["status"] = "ok"
result["written"] = written
result["path"] = str(hidraw_path)
print(json.dumps(result, indent=2) if args.json else result)
if __name__ == "__main__":
main()

2
oxp-rgb-hid.rules Normal file
View file

@ -0,0 +1,2 @@
SUBSYSTEM=="usb", ATTR{idVendor}=="1a2c", ATTR{idProduct}=="b001", MODE="0666", TAG+="uaccess"
SUBSYSTEM=="hidraw", ENV{ID_VENDOR_ID}=="1a2c", ENV{ID_MODEL_ID}=="b001", ENV{ID_USB_INTERFACE_NUM}=="00", MODE="0666", TAG+="uaccess"

View file

@ -1,18 +1,7 @@
// SPDX-License-Identifier: GPL-2.0+
/*
* Platform driver for OneXPlayer, AOK ZOE, and Aya Neo Handhelds that expose
* fan reading and control via hwmon sysfs.
*
* Old OXP boards have the same DMI strings and they are told apart by
* the boot cpu vendor (Intel/AMD). Currently only AMD boards are
* supported but the code is made to be simple to add other handheld
* boards in the future.
* Fan control is provided via pwm interface in the range [0-255].
* Old AMD boards use [0-100] as range in the EC, the written value is
* scaled to accommodate for that. Newer boards like the mini PRO and
* AOK ZOE are not scaled but have the same EC layout.
*
* Copyright (C) 2022 Joaquín I. Aramendía <samsagax@gmail.com>
* Platform driver for OneXPlayer Super X systems that expose fan reading and
* fan control through EC-backed hwmon sysfs nodes.
*/
#include <linux/acpi.h>
@ -21,6 +10,7 @@
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/platform_device.h>
#include <linux/processor.h>
@ -39,19 +29,6 @@ static bool unlock_global_acpi_lock(void)
return ACPI_SUCCESS(acpi_release_global_lock(oxp_mutex));
}
enum oxp_board {
aok_zoe_a1 = 1,
aya_neo_2,
aya_neo_air,
aya_neo_air_pro,
aya_neo_geek,
oxp_mini_amd,
oxp_mini_amd_a07,
oxp_mini_amd_pro,
};
static enum oxp_board board;
/* Fan reading and PWM */
#define OXP_SENSOR_FAN_REG 0x76 /* Fan reading is 2 registers long */
#define OXP_SENSOR_PWM_ENABLE_REG 0x4A /* PWM enable is 1 register long */
@ -69,69 +46,41 @@ static enum oxp_board board;
#define OXP_TURBO_TAKE_VAL 0x40
#define OXP_TURBO_RETURN_VAL 0x00
#define OXP_LED_ENABLE_VAL 0x01
#define OXP_LED_DISABLE_VAL 0x00
#define OXP_LED_MODE_STATIC 0x00
#define OXP_LED_MODE_BREATHING 0x01
#define OXP_LED_MODE_RAINBOW 0x02
static int led_enable_reg = -1;
static int led_mode_reg = -1;
static int led_brightness_reg = -1;
static int led_red_reg = -1;
static int led_green_reg = -1;
static int led_blue_reg = -1;
module_param(led_enable_reg, int, 0644);
MODULE_PARM_DESC(led_enable_reg, "EC register for LED enable");
module_param(led_mode_reg, int, 0644);
MODULE_PARM_DESC(led_mode_reg, "EC register for LED mode");
module_param(led_brightness_reg, int, 0644);
MODULE_PARM_DESC(led_brightness_reg, "EC register for LED brightness");
module_param(led_red_reg, int, 0644);
MODULE_PARM_DESC(led_red_reg, "EC register for LED red channel");
module_param(led_green_reg, int, 0644);
MODULE_PARM_DESC(led_green_reg, "EC register for LED green channel");
module_param(led_blue_reg, int, 0644);
MODULE_PARM_DESC(led_blue_reg, "EC register for LED blue channel");
static bool led_regs_valid;
static const struct dmi_system_id dmi_table[] = {
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "AOKZOE"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "AOKZOE A1 AR07"),
},
.driver_data = (void *)aok_zoe_a1,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "AOKZOE"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "AOKZOE A1 Pro"),
},
.driver_data = (void *)aok_zoe_a1,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "AYANEO"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "AYANEO 2"),
},
.driver_data = (void *)aya_neo_2,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "AYANEO"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "AIR"),
},
.driver_data = (void *)aya_neo_air,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "AYANEO"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "AIR Pro"),
},
.driver_data = (void *)aya_neo_air_pro,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "AYANEO"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "GEEK"),
},
.driver_data = (void *)aya_neo_geek,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "ONE-NETBOOK"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "ONE XPLAYER"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "ONEXPLAYER SUPER X"),
},
.driver_data = (void *)oxp_mini_amd,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "ONE-NETBOOK"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "ONEXPLAYER mini A07"),
},
.driver_data = (void *)oxp_mini_amd_a07,
},
{
.matches = {
DMI_MATCH(DMI_BOARD_VENDOR, "ONE-NETBOOK"),
DMI_EXACT_MATCH(DMI_BOARD_NAME, "ONEXPLAYER Mini Pro"),
},
.driver_data = (void *)oxp_mini_amd_pro,
},
{},
};
@ -176,47 +125,25 @@ static int write_to_ec(u8 reg, u8 value)
return ret;
}
static bool led_map_complete(void)
{
return led_enable_reg >= 0 && led_enable_reg <= 0xFF &&
led_mode_reg >= 0 && led_mode_reg <= 0xFF &&
led_brightness_reg >= 0 && led_brightness_reg <= 0xFF &&
led_red_reg >= 0 && led_red_reg <= 0xFF &&
led_green_reg >= 0 && led_green_reg <= 0xFF &&
led_blue_reg >= 0 && led_blue_reg <= 0xFF;
}
/* Turbo button toggle functions */
static int tt_toggle_enable(void)
{
u8 reg;
u8 val;
switch (board) {
case oxp_mini_amd_a07:
reg = OXP_OLD_TURBO_SWITCH_REG;
val = OXP_OLD_TURBO_TAKE_VAL;
break;
case oxp_mini_amd_pro:
case aok_zoe_a1:
reg = OXP_TURBO_SWITCH_REG;
val = OXP_TURBO_TAKE_VAL;
break;
default:
return -EINVAL;
}
return write_to_ec(reg, val);
return write_to_ec(OXP_TURBO_SWITCH_REG, OXP_TURBO_TAKE_VAL);
}
static int tt_toggle_disable(void)
{
u8 reg;
u8 val;
switch (board) {
case oxp_mini_amd_a07:
reg = OXP_OLD_TURBO_SWITCH_REG;
val = OXP_OLD_TURBO_RETURN_VAL;
break;
case oxp_mini_amd_pro:
case aok_zoe_a1:
reg = OXP_TURBO_SWITCH_REG;
val = OXP_TURBO_RETURN_VAL;
break;
default:
return -EINVAL;
}
return write_to_ec(reg, val);
return write_to_ec(OXP_TURBO_SWITCH_REG, OXP_TURBO_RETURN_VAL);
}
/* Callbacks for turbo toggle attribute */
@ -246,22 +173,9 @@ static ssize_t tt_toggle_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
int retval;
u8 reg;
long val;
switch (board) {
case oxp_mini_amd_a07:
reg = OXP_OLD_TURBO_SWITCH_REG;
break;
case oxp_mini_amd_pro:
case aok_zoe_a1:
reg = OXP_TURBO_SWITCH_REG;
break;
default:
return -EINVAL;
}
retval = read_from_ec(reg, 1, &val);
retval = read_from_ec(OXP_TURBO_SWITCH_REG, 1, &val);
if (retval)
return retval;
@ -270,6 +184,201 @@ static ssize_t tt_toggle_show(struct device *dev,
static DEVICE_ATTR_RW(tt_toggle);
/* LED control functions */
static int led_enable(void)
{
if (!led_regs_valid)
return -EOPNOTSUPP;
return write_to_ec((u8)led_enable_reg, OXP_LED_ENABLE_VAL);
}
static int led_disable(void)
{
if (!led_regs_valid)
return -EOPNOTSUPP;
return write_to_ec((u8)led_enable_reg, OXP_LED_DISABLE_VAL);
}
static int led_set_mode(long mode)
{
if (!led_regs_valid)
return -EOPNOTSUPP;
if (mode < OXP_LED_MODE_STATIC || mode > OXP_LED_MODE_RAINBOW)
return -EINVAL;
return write_to_ec((u8)led_mode_reg, (u8)mode);
}
static int led_set_brightness(long brightness)
{
if (!led_regs_valid)
return -EOPNOTSUPP;
if (brightness < 0 || brightness > 255)
return -EINVAL;
return write_to_ec((u8)led_brightness_reg, (u8)brightness);
}
static int led_set_color(u8 red, u8 green, u8 blue)
{
int ret;
if (!led_regs_valid)
return -EOPNOTSUPP;
ret = write_to_ec((u8)led_red_reg, red);
if (ret)
return ret;
ret = write_to_ec((u8)led_green_reg, green);
if (ret)
return ret;
return write_to_ec((u8)led_blue_reg, blue);
}
/* LED sysfs callbacks */
static ssize_t led_enable_store(struct device *dev,
struct device_attribute *attr, const char *buf,
size_t count)
{
int rval;
bool value;
rval = kstrtobool(buf, &value);
if (rval)
return rval;
if (value)
rval = led_enable();
else
rval = led_disable();
if (rval)
return rval;
return count;
}
static ssize_t led_enable_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
long val;
int retval;
if (!led_regs_valid)
return -EOPNOTSUPP;
retval = read_from_ec((u8)led_enable_reg, 1, &val);
if (retval)
return retval;
return sysfs_emit(buf, "%d\n", !!val);
}
static ssize_t led_mode_store(struct device *dev,
struct device_attribute *attr, const char *buf,
size_t count)
{
int rval;
long value;
rval = kstrtol(buf, 0, &value);
if (rval)
return rval;
rval = led_set_mode(value);
if (rval)
return rval;
return count;
}
static ssize_t led_mode_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
long val;
int retval;
if (!led_regs_valid)
return -EOPNOTSUPP;
retval = read_from_ec((u8)led_mode_reg, 1, &val);
if (retval)
return retval;
return sysfs_emit(buf, "%ld\n", val);
}
static ssize_t led_brightness_store(struct device *dev,
struct device_attribute *attr, const char *buf,
size_t count)
{
int rval;
long value;
rval = kstrtol(buf, 0, &value);
if (rval)
return rval;
rval = led_set_brightness(value);
if (rval)
return rval;
return count;
}
static ssize_t led_brightness_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
long val;
int retval;
if (!led_regs_valid)
return -EOPNOTSUPP;
retval = read_from_ec((u8)led_brightness_reg, 1, &val);
if (retval)
return retval;
return sysfs_emit(buf, "%ld\n", val);
}
static ssize_t led_color_store(struct device *dev,
struct device_attribute *attr, const char *buf,
size_t count)
{
int rval;
u8 red, green, blue;
rval = sscanf(buf, "%hhu %hhu %hhu", &red, &green, &blue);
if (rval != 3)
return -EINVAL;
rval = led_set_color(red, green, blue);
if (rval)
return rval;
return count;
}
static ssize_t led_color_show(struct device *dev,
struct device_attribute *attr, char *buf)
{
long red, green, blue;
int retval;
if (!led_regs_valid)
return -EOPNOTSUPP;
retval = read_from_ec((u8)led_red_reg, 1, &red);
if (retval)
return retval;
retval = read_from_ec((u8)led_green_reg, 1, &green);
if (retval)
return retval;
retval = read_from_ec((u8)led_blue_reg, 1, &blue);
if (retval)
return retval;
return sysfs_emit(buf, "%ld %ld %ld\n", red, green, blue);
}
static DEVICE_ATTR_RW(led_enable);
static DEVICE_ATTR_RW(led_mode);
static DEVICE_ATTR_RW(led_brightness);
static DEVICE_ATTR_RW(led_color);
/* PWM enable/disable functions */
static int oxp_pwm_enable(void)
{
@ -315,20 +424,6 @@ static int oxp_platform_read(struct device *dev, enum hwmon_sensor_types type,
ret = read_from_ec(OXP_SENSOR_PWM_REG, 1, val);
if (ret)
return ret;
switch (board) {
case aya_neo_2:
case aya_neo_air:
case aya_neo_air_pro:
case aya_neo_geek:
case oxp_mini_amd:
case oxp_mini_amd_a07:
*val = (*val * 255) / 100;
break;
case oxp_mini_amd_pro:
case aok_zoe_a1:
default:
break;
}
return 0;
case hwmon_pwm_enable:
return read_from_ec(OXP_SENSOR_PWM_ENABLE_REG, 1, val);
@ -357,20 +452,6 @@ static int oxp_platform_write(struct device *dev, enum hwmon_sensor_types type,
case hwmon_pwm_input:
if (val < 0 || val > 255)
return -EINVAL;
switch (board) {
case aya_neo_2:
case aya_neo_air:
case aya_neo_air_pro:
case aya_neo_geek:
case oxp_mini_amd:
case oxp_mini_amd_a07:
val = (val * 100) / 255;
break;
case aok_zoe_a1:
case oxp_mini_amd_pro:
default:
break;
}
return write_to_ec(OXP_SENSOR_PWM_REG, val);
default:
break;
@ -393,6 +474,10 @@ static const struct hwmon_channel_info * const oxp_platform_sensors[] = {
static struct attribute *oxp_ec_attrs[] = {
&dev_attr_tt_toggle.attr,
&dev_attr_led_enable.attr,
&dev_attr_led_mode.attr,
&dev_attr_led_brightness.attr,
&dev_attr_led_color.attr,
NULL
};
@ -412,34 +497,19 @@ static const struct hwmon_chip_info oxp_ec_chip_info = {
/* Initialization logic */
static int oxp_platform_probe(struct platform_device *pdev)
{
const struct dmi_system_id *dmi_entry;
struct device *dev = &pdev->dev;
struct device *hwdev;
int ret;
/*
* Have to check for AMD processor here because DMI strings are the
* same between Intel and AMD boards, the only way to tell them apart
* is the CPU.
* Intel boards seem to have different EC registers and values to
* read/write.
*/
dmi_entry = dmi_first_match(dmi_table);
if (!dmi_entry || boot_cpu_data.x86_vendor != X86_VENDOR_AMD)
if (!dmi_first_match(dmi_table) || boot_cpu_data.x86_vendor != X86_VENDOR_AMD)
return -ENODEV;
board = (enum oxp_board)(unsigned long)dmi_entry->driver_data;
led_regs_valid = led_map_complete();
switch (board) {
case aok_zoe_a1:
case oxp_mini_amd_a07:
case oxp_mini_amd_pro:
ret = devm_device_add_groups(dev, oxp_ec_groups);
if (led_regs_valid) {
ret = devm_device_add_group(dev, &oxp_ec_group);
if (ret)
return ret;
break;
default:
break;
}
hwdev = devm_hwmon_device_register_with_info(dev, "oxpec", NULL,
@ -478,5 +548,5 @@ module_init(oxp_platform_init);
module_exit(oxp_platform_exit);
MODULE_AUTHOR("Joaquín Ignacio Aramendía <samsagax@gmail.com>");
MODULE_DESCRIPTION("Platform driver that handles EC sensors of OneXPlayer devices");
MODULE_DESCRIPTION("Platform driver that handles EC sensors of OneXPlayer Super X");
MODULE_LICENSE("GPL");

1
oxp-sensors.conf Normal file
View file

@ -0,0 +1 @@
oxp-sensors

109
oxp-tdp Normal file
View file

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""
Set OneXPlayer Super X TDP limits through ryzenadj.
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
MIN_WATTS = 3
MAX_WATTS = 120
MAX_BATTERY_WATTS = 80
LOCAL_RYZENADJ = Path("/usr/local/bin/ryzenadj")
AC_ONLINE_PATH = Path("/sys/class/power_supply/ACAD/online")
BATTERY_STATUS_PATH = Path("/sys/class/power_supply/BAT0/status")
def parse_args():
parser = argparse.ArgumentParser(description="Set TDP through ryzenadj")
parser.add_argument("watts", type=int)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--json", action="store_true")
return parser.parse_args()
def ryzenadj_path() -> str:
if LOCAL_RYZENADJ.exists():
return str(LOCAL_RYZENADJ)
found = shutil.which("ryzenadj")
if found:
return found
raise FileNotFoundError("ryzenadj not found")
def emit(payload, as_json: bool):
if as_json:
print(json.dumps(payload, indent=2))
else:
print(payload)
def power_source() -> str:
try:
online = AC_ONLINE_PATH.read_text(encoding="utf-8").strip()
if online == "1":
return "ac"
if online == "0":
return "battery"
except OSError:
pass
try:
status = BATTERY_STATUS_PATH.read_text(encoding="utf-8").strip()
if status == "Discharging":
return "battery"
except OSError:
pass
return "unknown"
def main():
args = parse_args()
if args.watts < MIN_WATTS or args.watts > MAX_WATTS:
raise ValueError(f"watts must be in range {MIN_WATTS}-{MAX_WATTS}")
source = power_source()
if source == "battery" and args.watts > MAX_BATTERY_WATTS:
raise ValueError(
f"battery mode limit is {MAX_BATTERY_WATTS}W, requested {args.watts}W"
)
ryzenadj = ryzenadj_path()
milliwatts = args.watts * 1000
cmd = [
ryzenadj,
"--stapm-limit",
str(milliwatts),
"--fast-limit",
str(milliwatts),
"--slow-limit",
str(milliwatts),
]
payload = {
"watts": args.watts,
"milliwatts": milliwatts,
"power_source": source,
"backend": ryzenadj,
"command": cmd,
}
if args.dry_run:
payload["status"] = "dry-run"
emit(payload, args.json)
return
subprocess.run(cmd, check=True)
payload["status"] = "applied"
emit(payload, args.json)
if __name__ == "__main__":
try:
main()
except (FileNotFoundError, subprocess.CalledProcessError, ValueError) as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)

70
uninstall.sh Executable file
View file

@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
TARGET_USER="${SUDO_USER:-$USER}"
TARGET_HOME="$(eval echo "~$TARGET_USER")"
TARGET_GROUP="$(id -gn "$TARGET_USER")"
EXTENSION_UUID="oxp-fan-profiles@ps1x"
EXTENSION_DIR="$TARGET_HOME/.local/share/gnome-shell/extensions/$EXTENSION_UUID"
PURGE=0
if [[ "${1:-}" == "--purge" ]]; then
PURGE=1
elif [[ $# -gt 0 ]]; then
echo "Usage: $0 [--purge]" >&2
exit 1
fi
echo "Stopping OXP fan service..."
sudo systemctl disable --now oxp-fan-control.service 2>/dev/null || true
echo "Removing installed binaries..."
for path in \
/usr/local/bin/oxp-fan-control.py \
/usr/local/bin/oxp-fan-profile \
/usr/local/bin/oxp-rgb-hid \
/usr/local/bin/oxp-rgb \
/usr/local/bin/oxp-tdp \
/usr/local/bin/oxp-battery-probe \
/usr/local/bin/oxp-battery-ec-probe
do
sudo rm -f "$path"
done
echo "Removing service and rules..."
for path in \
/etc/systemd/system/oxp-fan-control.service \
/etc/modules-load.d/oxp-sensors.conf \
/etc/polkit-1/rules.d/49-oxp-fan-profile.rules \
/etc/udev/rules.d/99-oxp-rgb-hid.rules
do
sudo rm -f "$path"
done
echo "Removing GNOME extension..."
if [[ -d "$EXTENSION_DIR" ]]; then
sudo rm -rf "$EXTENSION_DIR"
fi
if [[ $PURGE -eq 1 ]]; then
echo "Purging local configuration..."
sudo rm -f /etc/oxp-fan-control.conf
sudo rm -f "$TARGET_HOME/.config/oxp-control.json"
fi
sudo systemctl daemon-reload
sudo udevadm control --reload-rules || true
sudo udevadm trigger --attr-match=idVendor=1a2c --attr-match=idProduct=b001 || true
sudo modprobe -r oxp-sensors 2>/dev/null || true
if command -v gnome-extensions >/dev/null 2>&1; then
sudo -u "$TARGET_USER" gnome-extensions disable "$EXTENSION_UUID" 2>/dev/null || true
fi
echo
echo "Uninstall complete."
if [[ $PURGE -eq 0 ]]; then
echo "User config and /etc/oxp-fan-control.conf were kept."
else
echo "User config and /etc/oxp-fan-control.conf were removed."
fi