Skip to content

Commit

Permalink
Add Custom Fan Control (#16)
Browse files Browse the repository at this point in the history
* fan slice

* save fan profiles to backend

* add default fan curve

* add acpi_call check

* add custom fan panel

* add toggle for enabling/disabling fan curves

* add per game toggle for fan control

* add per game fan curve toggle

* update currentGameId selector

* fix bug in fanSlice

* refactor currentDisplayName into uiSlice

* add fancurvesliders

* add labels + show/hide fan curve toggle

* add legion_space fan speed funcs

* have backend set fan profile

* add default value for 0C

* remove sudo from legion_space

* remove 0C default value

* add set_full_fan_speed

* formatting fixes

* update README
  • Loading branch information
aarron-lee authored Dec 24, 2023
1 parent f137916 commit 26cbdc2
Show file tree
Hide file tree
Showing 15 changed files with 584 additions and 9 deletions.
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Decky Plugin that replicates some of the Legion Space remapping functionality.

![color-picker image](./images/color-picker.png)

![fan-control image](./images/fan-control.png)

![remap buttons image](./images/remap-buttons.png)

## Functionality
Expand Down Expand Up @@ -91,11 +93,38 @@ sudo systemctl restart plugin_loader.service
sudo systemctl reboot
```

## Experimental Features

### Custom Fan Curves

### WARNING: If you don't properly cool your device, it can go into thermal shutdown! Make sure you set proper fan curves to keep your device cool!

This method must be manually enabled. Once enabled, will use Lenovo's bios WMI functions to set fan curves.

Note that this requires the `acpi_call` module, if your Linux distro doesn't have it pre-installed, it'll have to be manually installed.

### Setup Instructions:

1. run `sudo modprobe acpi_call` in terminal, if this errors out, you need to install `acpi_call`
2. install latest LegionGoRemapper: `curl -L https://github.com/aarron-lee/LegionGoRemapper/raw/main/install.sh | sh`
3. edit the `$HOME/homebrew/settings/LegionGoRemapper/settings.json` file. add `"forceEnableCustomFanCurves": true` to the json

The end result in the `settings.json` file should look something like this:

```
{
"forceEnableCustomFanCurves": true,
...otherStuffAlreadyHere
}
```

4. reboot

## Attribution

Special thanks to [antheas](https://github.com/antheas) for [reverse engineering and documenting the HID protocols](https://github.com/antheas/hwinfo/tree/master/devices/legion_go/peripherals) for the Legion Go Controllers.
Special thanks to [antheas](https://github.com/antheas) for [reverse engineering and documenting the HID protocols](https://github.com/antheas/hwinfo/tree/master/devices/legion_go) for the Legion Go Controllers, etc.

Also special thanks to [corando98](https://github.com/corando98) for writing + testing the backend functions for talking to the HID devices, as well as contributing to the RGB light management code on the frontend.
Also special thanks to [corando98](https://github.com/corando98) for writing + testing the backend functions for talking to the HID devices, investigating fan curves, as well as contributing to the RGB light management code on the frontend.

Icon and controller button SVG files generated from PromptFont using FontForge.

Expand Down
Binary file modified images/color-picker.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/fan-control.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified images/remap-buttons.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
38 changes: 37 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import decky_plugin
import legion_configurator
import legion_space
import controller_enums
import rgb
import controllers
Expand All @@ -30,7 +31,17 @@ async def _main(self):
decky_plugin.logger.info("Hello World!")

async def get_settings(self):
return settings.get_settings()
results = settings.get_settings()

try:
if settings.supports_custom_fan_curves():
results['supportsCustomFanCurves'] = True
else:
results['supportsCustomFanCurves'] = False
except Exception as e:
decky_plugin.logger.error(e)

return results

async def save_rgb_per_game_profiles_enabled(self, enabled: bool):
return settings.set_setting('rgbPerGameProfilesEnabled', enabled)
Expand Down Expand Up @@ -65,6 +76,31 @@ async def save_rgb_settings(self, rgbProfiles, currentGameId):
rgb.sync_rgb_settings(currentGameId)
return result

async def save_fan_settings(self, fanInfo, currentGameId):
fanProfiles = fanInfo.get('fanProfiles', {})
fanPerGameProfilesEnabled = fanInfo.get('fanPerGameProfilesEnabled', False)
customFanCurvesEnabled = fanInfo.get('customFanCurvesEnabled', False)

settings.set_setting('fanPerGameProfilesEnabled', fanPerGameProfilesEnabled)
settings.set_setting('customFanCurvesEnabled', customFanCurvesEnabled)
settings.set_all_fan_profiles(fanProfiles)

try:
active_fan_profile = fanProfiles.get('default')

if customFanCurvesEnabled and settings.supports_custom_fan_curves():
if fanPerGameProfilesEnabled:
fan_profile = fanProfiles.get(currentGameId)
if fan_profile:
active_fan_profile = fan_profile
active_fan_curve = active_fan_profile.values()

legion_space.set_fan_curve(active_fan_curve)
return True
except Exception as e:
decky_plugin.logger(f'save_fan_settings error {e}')
return False

# sync state in settings.json to actual controller RGB hardware
async def sync_rgb_settings(self, currentGameId):
return rgb.sync_rgb_settings(currentGameId)
Expand Down
27 changes: 27 additions & 0 deletions py_modules/controller_settings.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import subprocess
from settings import SettingsManager

settings_directory = os.environ["DECKY_PLUGIN_SETTINGS_DIR"]
Expand Down Expand Up @@ -87,3 +88,29 @@ def set_all_controller_profiles(controller_profiles):

setting_file.settings['controller'] = profiles
setting_file.commit()

def set_all_fan_profiles(fan_profiles):
settings = get_settings()
if not settings.get('fan'):
settings['fan'] = {}

profiles = settings['fan']
deep_merge(fan_profiles, profiles)
setting_file.settings['fan'] = profiles
setting_file.commit()

def modprobe_acpi_call():
os.system("modprobe acpi_call")
result = subprocess.run(["modprobe", "acpi_call"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)

if result.stderr:
return False
return True

def supports_custom_fan_curves():
try:
if modprobe_acpi_call() and get_settings().get("forceEnableCustomFanCurves"):
return True
return False
except Exception as e:
return False
65 changes: 65 additions & 0 deletions py_modules/legion_space.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import subprocess
import decky_plugin

# all credit goes to corando98
# source: https://github.com/corando98/LLG_Dev_scripts/blob/main/LegionSpace.py

def execute_acpi_command(command):
"""
Executes an ACPI command and returns the output.
Uses subprocess for robust command execution.
Args:
command (str): The ACPI command to be executed.
Returns:
str: The output from the ACPI command execution.
"""
try:
result = subprocess.run(command, shell=True, check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
decky_plugin.logger.error(f"Error executing command: {e.stderr}")
return None

def set_fan_curve(fan_table):
"""
Sets a new fan curve based on the provided fan table array.
The fan table should contain fan speed values that correspond to different temperature thresholds.
Args:
fan_table (list): An array of fan speeds to set the fan curve.
Returns:
str: The output from setting the new fan curve.
"""
# Assuming Fan ID and Sensor ID are both 0 (as they are ignored)
fan_id_sensor_id = '0x00, 0x00'

# Assuming the temperature array length and values are ignored but required
temp_array_length = '0x0A, 0x00, 0x00, 0x00' # Length 10 in hex
temp_values = ', '.join([f'0x{temp:02x}, 0x00' for temp in range(0, 101, 10)]) + ', 0x00'

# Fan speed values in uint16 format with null termination
fan_speed_values = ', '.join([f'0x{speed:02x}, 0x00' for speed in fan_table]) + ', 0x00'

# Constructing the full command
command = f"echo '\\_SB.GZFD.WMAB 0 0x06 {{{fan_id_sensor_id}, {temp_array_length}, {fan_speed_values}, {temp_array_length}, {temp_values}}}' | tee /proc/acpi/call; cat /proc/acpi/call"
return execute_acpi_command(command)

# FFSS Full speed mode set on /off
# echo '\_SB.GZFD.WMAE 0 0x12 0x0104020000' | sudo tee /proc/acpi/call; sudo cat /proc/acpi/call
# echo '\_SB.GZFD.WMAE 0 0x12 0x0004020000' | sudo tee /proc/acpi/call; sudo cat /proc/acpi/call
def set_full_fan_speed(enable):
"""
Enable or disable full fan speed mode.
Args:
enable (bool): True to enable, False to disable.
Returns:
str: The result of the operation.
"""
status = '0x01' if enable else '0x00'
command = f"echo '\\_SB.GZFD.WMAE 0 0x12 {status}04020000' | tee /proc/acpi/call; cat /proc/acpi/call"
return execute_acpi_command(command)
2 changes: 2 additions & 0 deletions src/backend/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const getServerApi = () => {
return serverApi;
};

export const extractDisplayName = () => `${Router.MainRunningApp?.display_name || 'default'}`

export const extractCurrentGameId = () =>
`${Router.MainRunningApp?.appid || 'default'}`;

Expand Down
53 changes: 53 additions & 0 deletions src/components/fan/FanCurveSliders.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useDispatch, useSelector } from 'react-redux';
import { fanSlice, selectActiveFanCurve } from '../../redux-modules/fanSlice';
import { PanelSection, PanelSectionRow, SliderField } from 'decky-frontend-lib';
import { VFC } from 'react';

type Props = {
showSliders: boolean;
};

const FanCurveSliders: VFC<Props> = ({ showSliders }) => {
const activeFanCurve = useSelector(selectActiveFanCurve);
const dispatch = useDispatch();

const updateFanCurveValue = (temp: string, fanSpeed: number) => {
return dispatch(fanSlice.actions.updateFanCurve({ temp, fanSpeed }));
};

const sliders = Object.entries(activeFanCurve).map(
([temp, fanSpeed], idx) => {
return (
<PanelSectionRow>
<SliderField
label={`${temp} \u2103`}
value={fanSpeed}
showValue
valueSuffix="%"
step={5}
min={0}
max={100}
validValues="range"
bottomSeparator="none"
key={idx}
onChange={(newSpeed) => {
return updateFanCurveValue(temp, newSpeed);
}}
/>
</PanelSectionRow>
);
}
);

return (
<>
{showSliders && (
<PanelSection title={'Temp (\u2103) | Fan Speed (%)'}>
{sliders}
</PanelSection>
)}
</>
);
};

export default FanCurveSliders;
88 changes: 88 additions & 0 deletions src/components/fan/FanPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
ButtonItem,
PanelSection,
PanelSectionRow,
ToggleField
} from 'decky-frontend-lib';
import {
useCustomFanCurvesEnabled,
useFanPerGameProfilesEnabled,
useSupportsCustomFanCurves
} from '../../hooks/fan';
import { capitalize } from 'lodash';
import { useSelector } from 'react-redux';
import { useState } from 'react';
import { selectCurrentGameDisplayName } from '../../redux-modules/uiSlice';
import FanCurveSliders from './FanCurveSliders';
import { IoMdArrowDropdown, IoMdArrowDropup } from 'react-icons/io';

const useTitle = (fanPerGameProfilesEnabled: boolean) => {
const currentDisplayName = useSelector(selectCurrentGameDisplayName);

if (!fanPerGameProfilesEnabled) {
return 'Fan Control';
}

const title = `Fan Control - ${capitalize(currentDisplayName)}`;

return title;
};

const FanPanel = () => {
const supportsFanCurves = useSupportsCustomFanCurves();
const [showSliders, setShowSliders] = useState(false);

const { customFanCurvesEnabled, setCustomFanCurvesEnabled } =
useCustomFanCurvesEnabled();
const { fanPerGameProfilesEnabled, setFanPerGameProfilesEnabled } =
useFanPerGameProfilesEnabled();
const title = useTitle(fanPerGameProfilesEnabled);

if (!supportsFanCurves) {
return null;
}

return (
<>
<PanelSection title={title}>
<PanelSectionRow>
<ToggleField
label={'Enable Custom Fan Curves'}
checked={customFanCurvesEnabled}
onChange={setCustomFanCurvesEnabled}
/>
</PanelSectionRow>
{customFanCurvesEnabled && (
<>
<PanelSectionRow>
<ToggleField
label={'Enable Per Game Fan Curves'}
checked={fanPerGameProfilesEnabled}
onChange={setFanPerGameProfilesEnabled}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
bottomSeparator={showSliders ? 'none' : 'thick'}
style={{
width: '100%',
height: '20px',
display: 'flex', // Set the display to flex
justifyContent: 'center', // Center align horizontally
alignItems: 'center' // Center align vertically
}}
onClick={() => setShowSliders(!showSliders)}
>
{showSliders ? <IoMdArrowDropup /> : <IoMdArrowDropdown />}
</ButtonItem>
</PanelSectionRow>
</>
)}
</PanelSection>
{customFanCurvesEnabled && <FanCurveSliders showSliders={showSliders} />}
</>
);
};

export default FanPanel;
Loading

0 comments on commit 26cbdc2

Please sign in to comment.