Prepare public OneXPlayer Super X control release
This commit is contained in:
parent
f75d8444eb
commit
7c44b9fb4e
22 changed files with 3470 additions and 226 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
280
LICENSE
Normal 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
180
README.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
968
gnome-extension/extension.js
Normal file
968
gnome-extension/extension.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
6
gnome-extension/metadata.json
Normal file
6
gnome-extension/metadata.json
Normal 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
81
install.sh
Executable 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
14
local/README.md
Normal 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
163
oxp-battery-ec-probe.c
Normal 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
281
oxp-battery-probe.py
Normal 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
75
oxp-fan-control.conf
Normal 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
430
oxp-fan-control.py
Normal 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
15
oxp-fan-control.service
Normal 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
237
oxp-fan-profile.py
Normal 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
15
oxp-fan-profile.rules
Normal 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
139
oxp-rgb
Normal 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
173
oxp-rgb-hid.py
Normal 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
2
oxp-rgb-hid.rules
Normal 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"
|
||||
430
oxp-sensors.c
430
oxp-sensors.c
|
|
@ -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
1
oxp-sensors.conf
Normal file
|
|
@ -0,0 +1 @@
|
|||
oxp-sensors
|
||||
109
oxp-tdp
Normal file
109
oxp-tdp
Normal 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
70
uninstall.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue