diff --git a/.gitignore b/.gitignore index e33609d..d6fed4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,41 @@ -*.png +lib-cov +*.seed +*.log +*.csv +*.dat +*.out +*.pid +*.gz +*.swp + +pids +logs +results +tmp + +# Coverage reports +coverage + +# API keys and secrets +.env + +# Dependency directory +node_modules +bower_components +package-lock.json + +# Editors +.idea +*.iml + +# OS metadata +.DS_Store +Thumbs.db + +# Ignore built ts files +dist/ + +__pycache__/ + +/.yalc +yalc.lock diff --git a/README.md b/README.md index 3d18495..da82bbc 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # PowerTools -![plugin_demo](./extras/ui.png) +![plugin_demo](./assets/ui.png) Steam Deck power tweaks for power users. -This is generated from the template plugin for the [SteamOS Plugin Loader](https://github.com/SteamDeckHomebrew/PluginLoader). +This is generated from the template plugin for the [Decky Plugin Loader](https://github.com/SteamDeckHomebrew/decky-loader). You will need that installed for this plugin to work. ## What does it do? @@ -12,7 +12,6 @@ You will need that installed for this plugin to work. - Enable & disable CPU threads & SMT - Set CPU max frequency and toggle boost - Set some GPU power parameters (fastPPT & slowPPT) -- Set the fan RPM (unsupported on SteamOS beta) - Display supplementary battery info - Keep settings between restarts (stored in `~/.config/powertools/.json`) @@ -52,6 +51,8 @@ Get the entry limits for those two commands with `cat /sys/class/hwmon/hwmon4/po ### Set Fan speed +NOTE: PowerTools no longer supports this, since [Fantastic](https://github.com/NGnius/Fantastic) does it much better. + Enable automatic control: `echo 0 > /sys/class/hwmon/hwmon5/recalculate` enables automatic fan control. Disable automatic control: `echo 1 > /sys/class/hwmon/hwmon5/recalculate` disables automatic (temperature-based) fan control and starts using the set fan target instead. @@ -73,6 +74,8 @@ Get the design battery capacity: `cat /sys/class/hwmon/hwmon2/device/charge_full Get whether the deck is plugged in: `cat /sys/class/hwmon/hwmon5/curr1_input` gives the charger current in mA. +NOTE: 7.7 is the voltage of the battery -- it's not just a magic number. + ### Steam Deck kernel patches This is how I figured out how the fan stuff works. @@ -81,22 +84,26 @@ https://lkml.org/lkml/2022/2/5/391 ### Game launch detection -The biggest limitation right now is it can't detect a game closing -- only opening -- and only after PowerTools is looked at at least once (per SteamOS restart). - -From a plugin, this can be accomplished by running some front-end Javascript. - -```javascript -await execute_in_tab("SP", false, - `SteamClient.Apps.RegisterForGameActionStart((actionType, data) => { - console.log("start game", appStore.GetAppOverviewByGameID(data)); - });` -); +```typescript +//@ts-ignore +let lifetimeHook = SteamClient.GameSessions.RegisterForAppLifetimeNotifications((update) => { + if (update.bRunning) { + console.log("AppID " + update.unAppID.toString() + " is now running"); + } else { + console.log("AppID " + update.unAppID.toString() + " is no longer running"); + // game exit code here + // NOTE: custom games always have 0 as AppID, so AppID is bad to use as ID + } +}); +//@ts-ignore +let startHook = SteamClient.Apps.RegisterForGameActionStart((actionType, id) => { + //@ts-ignore + let gameInfo: any = appStore.GetAppOverviewByGameID(id); + // game start code here + // NOTE: GameID (variable: id) is always unique, even for custom games, so it's better to use than AppID +}); ``` -In PowerTools, the callback (the part surrounded by `{` and `}`, containing `console.log(...)`) sends a message to a local HTTP server to notify the PowerTools back-end that a game has been launched. - -If you go to `http://127.0.0.1:5030` on your Steam Deck with PowerTools >=0.6.0, you can see some info about the last game you launched. - ## License This is licensed under GNU GPLv3. diff --git a/extras/icon.svg b/assets/icon.svg similarity index 100% rename from extras/icon.svg rename to assets/icon.svg diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..94c719c Binary files /dev/null and b/assets/logo.png differ diff --git a/assets/thumbnail.png b/assets/thumbnail.png new file mode 100644 index 0000000..b22ecbe Binary files /dev/null and b/assets/thumbnail.png differ diff --git a/assets/ui.png b/assets/ui.png new file mode 100644 index 0000000..5adb07f Binary files /dev/null and b/assets/ui.png differ diff --git a/extras/ui.png b/extras/ui.png deleted file mode 100644 index d319477..0000000 Binary files a/extras/ui.png and /dev/null differ diff --git a/main.py b/main.py index f35b04f..df431b0 100644 --- a/main.py +++ b/main.py @@ -5,8 +5,8 @@ import asyncio import pathlib import subprocess -VERSION = "0.6.0" -HOME_DIR = str(pathlib.Path(os.getcwd()).parent.parent.resolve()) +VERSION = "0.7.0" +HOME_DIR = "/home/deck" DEFAULT_SETTINGS_LOCATION = HOME_DIR + "/.config/powertools/default_settings.json" LOG_LOCATION = "/tmp/powertools.log" FANTASTIC_INSTALL_DIR = HOME_DIR + "/homebrew/plugins/Fantastic" @@ -127,10 +127,14 @@ class Plugin: CPU_COUNT = 8 FAN_SPEEDS = [0, 1000, 2000, 3000, 4000, 5000, 6000] + gpu_power_values = [[-1, -1, -1], [1000000, 15000000, 29000000], [0, 15000000, 30000000]] + auto_fan = True persistent = True modified_settings = False current_gameid = None + old_gameid = None + ready = False async def get_version(self) -> str: return VERSION @@ -139,6 +143,7 @@ class Plugin: # call from main_view.html with setCPUs(count, smt) async def set_cpus(self, count, smt=True): + logging.info(f"set_cpus({count}, {smt})") self.modified_settings = True cpu_count = len(self.cpus) self.smt = smt @@ -165,9 +170,11 @@ class Plugin: for cpu in self.cpus: if(cpu.status()): online_count += 1 + logging.info(f"get_cpus() -> {online_count}") return online_count async def get_smt(self) -> bool: + logging.info(f"get_smt() -> {self.smt}") return self.smt async def set_boost(self, enabled: bool) -> bool: @@ -203,6 +210,24 @@ class Plugin: async def get_gpu_power(self, power_number: int) -> int: return read_gpu_ppt(power_number) + async def set_gpu_power_index(self, index: int, power_number: int) -> bool: + if index < 3 and index >= 0: + self.modified_settings = True + old_value = read_gpu_ppt(power_number) + if old_value not in self.gpu_power_values[power_number]: + self.gpu_power_values[power_number][1] = old_value + write_gpu_ppt(power_number, self.gpu_power_values[power_number][index]) + return True + return False + + async def get_gpu_power_index(self, power_number: int) -> int: + value = read_gpu_ppt(power_number) + if value not in self.gpu_power_values[power_number]: + #self.gpu_power_values[power_number][1] = value + return 1 + else: + return self.gpu_power_values[power_number].index(value) + # Fan stuff async def set_fan_tick(self, tick: int): @@ -304,26 +329,7 @@ class Plugin: if self.modified_settings and self.persistent: self.save_settings(self) self.modified_settings = False - if self.persistent: - # per-game profiles - current_game = pt_server.http_server.game() - old_gameid = self.current_gameid - if current_game is not None and current_game.has_settings(): - self.current_gameid = current_game.gameid - if old_gameid != self.current_gameid: - logging.info(f"Applying custom settings for {current_game.name()} {current_game.appid()}") - # new game; apply settings - settings = current_game.load_settings() - if settings is not None: - self.apply_settings(self, settings) - else: - self.current_gameid = None - if old_gameid != self.current_gameid: - logging.info("Reapplying default settings; game without custom settings found") - # game without custom settings; apply defaults - settings = read_json(DEFAULT_SETTINGS_LOCATION) - self.apply_settings(self, settings) - logging.debug(f"gameid update: {old_gameid} -> {self.current_gameid}") + #self.reload_current_settings(self) await asyncio.sleep(1) await pt_server.shutdown() @@ -331,6 +337,9 @@ class Plugin: # called from main_view::onViewReady async def on_ready(self): delta = time.time() - startup_time + if self.ready: + logging.info(f"Front-end init called again {delta}s after startup") + return logging.info(f"Front-end initialised {delta}s after startup") # persistence @@ -373,6 +382,29 @@ class Plugin: settings["auto"] = self.auto_fan return settings + def reload_current_settings(self): + logging.debug(f"gameid update: {self.old_gameid} -> {self.current_gameid}") + if self.persistent: + # per-game profiles + current_game = pt_server.http_server.game() + self.old_gameid = self.current_gameid + if current_game is not None and current_game.has_settings(): + self.current_gameid = current_game.gameid + if self.old_gameid != self.current_gameid: + logging.info(f"Applying custom settings for {current_game.name()} {current_game.appid()}") + # new game; apply settings + settings = current_game.load_settings() + if settings is not None: + self.apply_settings(self, settings) + else: + self.current_gameid = None + if self.old_gameid != None: + logging.info("Reapplying default settings; game without custom settings found") + self.old_gameid = None + # game without custom settings; apply defaults + settings = read_json(DEFAULT_SETTINGS_LOCATION) + self.apply_settings(self, settings) + def save_settings(self): settings = self.current_settings(self) logging.debug(f"Saving settings to file: {settings}") @@ -439,7 +471,18 @@ class Plugin: self.current_gameid = None async def get_per_game_profile(self) -> bool: - return self.current_gameid is not None + current_game = pt_server.http_server.game() + return current_game is not None and current_game.has_settings() + + async def on_game_start(self, game_id: int, data) -> bool: + pt_server.http_server.set_game(game_id, data) + self.reload_current_settings(self) + return True + + async def on_game_stop(self, game_id: int) -> bool: + pt_server.http_server.unset_game(game_id) + self.reload_current_settings(self) + return True diff --git a/main_view.html b/main_view.html deleted file mode 100644 index 9b23608..0000000 --- a/main_view.html +++ /dev/null @@ -1,746 +0,0 @@ - - - - - - - - - - - -
-
- -
-
- - - - -
-
-
-
-
-
- CPU SMT -
-
-
-
-
-
-
-
-
Enables odd-numbered CPUs
-
-
- - - -
-
-
-
Threads
-
-
-
-
-
-
-
-
-
-
-
-
1
-
-
-
-
2
-
-
-
-
3
-
-
-
-
4
-
-
-
-
5
-
-
-
-
6
-
-
-
-
7
-
-
-
-
8
-
-
-
-
-
- -
- - -
-
-
-
-
- CPU Boost -
-
-
-
-
-
-
-
-
Allows the CPU to go above max frequency
-
-
- - - -
-
-
-
Max Frequency
-
-
-
-
-
-
-
-
-
-
-
-
-
1.7GHz
-
-
-
-
2.4GHz
-
-
-
-
2.8GHz
-
-
-
-
-
- WARNING: This will change the CPU governor. -
-
-
-
- - - -
- - -
-
-
-
GPU SlowPPT Power
-
-
-
-
-
-
-
-
-
-
-
-
-
0
-
-
-
-
Auto
-
-
-
-
Max
-
-
-
-
-
-
- - -
-
-
-
GPU FastPPT Power
-
-
-
-
-
-
-
-
-
-
-
-
-
0
-
-
-
-
Auto
-
-
-
-
Max
-
-
-
-
-
-
-
- - -
-
- -
-
-
Fan RPM
-
- -
-
-
-
-
-
-
-
-
-
-
-
0
-
-
-
-
1K
-
-
-
-
2K
-
-
-
-
3K
-
-
-
-
4K
-
-
-
-
5K
-
-
-
-
6K
-
-
-
-
Auto
-
-
-
-
-
- WARNING: This can cause component overheating. -
-
-
-
- - -
-
-
Battery
-
-
-
-
-
-
Now (Charge)
-
-
:'( (|-_-|)
-
-
-
-
-
-
-
-
Max (Design)
-
-
9000+ (420%)
-
-
-
-
-
-
-
-
-
-
-
- Persistent -
-
-
-
-
-
-
-
-
Restores settings after an app or OS restart
-
-
-
-
-
-
- Use per-game profile -
-
-
-
-
-
-
-
-
-
-
-
-
-
Now Playing
-
-
the bongos
-
-
-
-
-
-
-
-
PowerTools
-
-
v0.42.0
-
-
-
-
-
-
- - diff --git a/package.json b/package.json new file mode 100644 index 0000000..54e7c7d --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "PowerTools", + "version": "0.7.0", + "description": "Power tweaks for power users", + "scripts": { + "build": "shx rm -rf dist && rollup -c", + "watch": "rollup -c -w", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/NGnius/PowerTools.git" + }, + "keywords": [ + "plugin", + "utility", + "power-management", + "steam-deck", + "deck" + ], + "author": "NGnius (Graham) ", + "license": "GPL-3.0", + "bugs": { + "url": "https://github.com/NGnius/PowerTools/issues" + }, + "homepage": "https://github.com/NGnius/PowerTools#readme", + "devDependencies": { + "@rollup/plugin-commonjs": "^21.1.0", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.2.1", + "@rollup/plugin-replace": "^4.0.0", + "@rollup/plugin-typescript": "^8.3.2", + "@types/react": "16.14.0", + "@types/webpack": "^5.28.0", + "rollup": "^2.70.2", + "rollup-plugin-import-assets": "^1.1.1", + "shx": "^0.3.4", + "tslib": "^2.4.0", + "typescript": "^4.6.4" + }, + "dependencies": { + "decky-frontend-lib": "*", + "react-icons": "^4.4.0" + } +} diff --git a/plugin.json b/plugin.json index 6189f5c..6a2b283 100644 --- a/plugin.json +++ b/plugin.json @@ -1,8 +1,6 @@ { - "name": "Power Tools", + "name": "PowerTools", "author": "NGnius", - "main_view_html": "main_view.html", - "tile_view_html": "", "flags": ["root", "_debug"], "publish": { "discord_id": "106537989684887552", diff --git a/rollup.config.js b/rollup.config.js new file mode 100644 index 0000000..8717908 --- /dev/null +++ b/rollup.config.js @@ -0,0 +1,37 @@ +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; +import typescript from '@rollup/plugin-typescript'; +import { defineConfig } from 'rollup'; +import importAssets from 'rollup-plugin-import-assets'; + +import { name } from "./plugin.json"; + +export default defineConfig({ + input: './src/index.tsx', + plugins: [ + commonjs(), + nodeResolve(), + typescript(), + json(), + replace({ + preventAssignment: false, + 'process.env.NODE_ENV': JSON.stringify('production'), + }), + importAssets({ + publicPath: `http://127.0.0.1:1337/plugins/${name}/` + }) + ], + context: 'window', + external: ['react', 'react-dom'], + output: { + file: 'dist/index.js', + globals: { + react: 'SP_REACT', + 'react-dom': 'SP_REACTDOM', + }, + format: 'iife', + exports: 'default', + }, +}); diff --git a/server.py b/server.py index 3e2e799..067ae3c 100644 --- a/server.py +++ b/server.py @@ -7,7 +7,7 @@ import pathlib from aiohttp import web import aiohttp -HOME_DIR = str(pathlib.Path(os.getcwd()).parent.parent.resolve()) +HOME_DIR = "/home/deck" SETTINGS_DIR = HOME_DIR + "/.config/powertools" if not os.path.exists(SETTINGS_DIR): @@ -42,99 +42,30 @@ class GameInfo: return os.path.exists(self.settings_path()) -class Server(web.Application): +class Server: def __init__(self, version): super().__init__() self.version = version self.current_game = None - self.add_routes([ - web.get("/", lambda req: self.index(req)), - web.post("/on_game_start/{game_id}", lambda req: self.on_game_start(req)), - web.post("/on_game_exit/{game_id}", lambda req: self.on_game_exit(req)), - web.post("/on_game_exit_null", lambda req: self.on_game_exit_null(req)), - web.get("/self_destruct", lambda req: self.self_destruct(req)) - ]) logging.debug("Server init complete") def game(self) -> GameInfo: return self.current_game - async def index(self, request): - logging.debug("Debug index page accessed") - current_game = None if self.current_game is None else self.current_game.gameid - game_info = None if self.current_game is None else self.current_game.game_info - settings_info = None if self.current_game is None else self.current_game.load_settings() - return web.json_response({ - "name": "PowerTools", - "version": self.version, - "latest_game_id": current_game, - "game_info": game_info, - "settings": settings_info - }, headers={"Access-Control-Allow-Origin": "*"}) - - async def on_game_start(self, request): - game_id = request.match_info["game_id"] - data = await request.text() - logging.debug(f"on_game_start {game_id} body:\n{data}") - try: - game_id = int(game_id) - data = json.loads(data) - except: - return web.Response(text="WTF", status=400) + def set_game(self, game_id, data): self.current_game = GameInfo(game_id, data) - if self.current_game.has_settings(): - self.last_recognised_game = self.current_game - return web.Response(status=204, headers={"Access-Control-Allow-Origin": "*"}) - async def on_game_exit(self, request): - # ignored for now - game_id = request.match_info["game_id"] - data = await request.text() - logging.debug(f"on_game_exit {game_id}") - try: - game_id = int(game_id) - except ValueError: - return web.Response(text="WTF", status=400) - if self.current_game.gameid == game_id: - pass - #self.current_game = None - # TODO change settings to default - return web.Response(status=204, headers={"Access-Control-Allow-Origin": "*"}) - - async def on_game_exit_null(self, request): - # ignored for now - logging.info(f"on_game_exit_null") - #self.current_game = None - # TODO change settings to default - return web.Response(status=204, headers={"Access-Control-Allow-Origin": "*"}) - - async def self_destruct(self, request): - logging.warning("Geodude self-destructed") - await shutdown() - # unreachable \/ \/ \/ - return web.Response(status=204, headers={"Access-Control-Allow-Origin": "*"}) + def unset_game(self, game_id): + if self.current_game is None: + return + if game_id is None or self.current_game.gameid == game_id: + self.current_game = None async def start(version): - global http_runner, http_server, http_site - # make sure old server has shutdown - try: - async with aiohttp.ClientSession() as session: - async with session.get('http://127.0.0.1:5030/self_destruct') as response: - await response.text() - except: - pass + global http_server http_server = Server(version) - http_runner = web.AppRunner(http_server) - await http_runner.setup() - site = web.TCPSite(http_runner, '127.0.0.1', 5030) - await site.start() async def shutdown(): # never really called - global http_runner, http_server, http_site - if http_runner is not None: - await http_runner.cleanup() - http_runner = None - http_site.stop() - http_site = None + global http_server http_server = None diff --git a/src/index.tsx b/src/index.tsx new file mode 100755 index 0000000..7c1f33f --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,452 @@ +import { + //ButtonItem, + definePlugin, + DialogButton, + //Menu, + //MenuItem, + PanelSection, + PanelSectionRow, + //Router, + ServerAPI, + //showContextMenu, + staticClasses, + SliderField, + ToggleField, + //NotchLabel + gamepadDialogClasses, + joinClassNames, +} from "decky-frontend-lib"; +import { VFC, useState } from "react"; +import { GiDrill } from "react-icons/gi"; + +import * as python from "./python"; + +//import logo from "../assets/logo.png"; + +// interface AddMethodArgs { +// left: number; +// right: number; +// } + +var firstTime: boolean = true; +var versionGlobalHolder: string = "0.0.0-jank"; +var periodicHook: NodeJS.Timer | null = null; +var lastGame: string = ""; +var lifetimeHook: any = null; +var startHook: any = null; + +var smt_backup: boolean = true; +var cpus_backup: number = 8; +var boost_backup: boolean = true; +var freq_backup: number = 8; +var slowPPT_backup: number = 1; +var fastPPT_backup: number = 1; +var chargeNow_backup: number = 5200000; +var chargeFull_backup: number = 5200000; +var chargeDesign_backup: number = 5200000; +var persistent_backup: boolean = false; +var perGameProfile_backup: boolean = false; + +var reload = function(){}; + +const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => { + // const [result, setResult] = useState(); + + // const onClick = async () => { + // const result = await serverAPI.callPluginMethod( + // "add", + // { + // left: 2, + // right: 2, + // } + // ); + // if (result.success) { + // setResult(result.result); + // } + // }; + + python.setServer(serverAPI); + + const [smtGlobal, setSMT_internal] = useState(smt_backup); + const setSMT = (value: boolean) => { + smt_backup = value; + setSMT_internal(value); + }; + + const [cpusGlobal, setCPUs_internal] = useState(cpus_backup); + const setCPUs = (value: number) => { + cpus_backup = value; + setCPUs_internal(value); + }; + + const [boostGlobal, setBoost_internal] = useState(boost_backup); + const setBoost = (value: boolean) => { + boost_backup = value; + setBoost_internal(value); + }; + + const [freqGlobal, setFreq_internal] = useState(freq_backup); + const setFreq = (value: number) => { + freq_backup = value; + setFreq_internal(value); + }; + + const [slowPPTGlobal, setSlowPPT_internal] = useState(slowPPT_backup); + const setSlowPPT = (value: number) => { + slowPPT_backup = value; + setSlowPPT_internal(value); + }; + + const [fastPPTGlobal, setFastPPT_internal] = useState(fastPPT_backup); + const setFastPPT = (value: number) => { + fastPPT_backup = value; + setFastPPT_internal(value); + }; + + const [chargeNowGlobal, setChargeNow_internal] = useState(chargeNow_backup); + const setChargeNow = (value: number) => { + chargeNow_backup = value; + setChargeNow_internal(value); + }; + + const [chargeFullGlobal, setChargeFull_internal] = useState(chargeFull_backup); + const setChargeFull = (value: number) => { + chargeFull_backup = value; + setChargeFull_internal(value); + }; + + const [chargeDesignGlobal, setChargeDesign_internal] = useState(chargeDesign_backup); + const setChargeDesign = (value: number) => { + chargeDesign_backup = value; + setChargeDesign_internal(value); + }; + + const [persistGlobal, setPersist_internal] = useState(persistent_backup); + const setPersist = (value: boolean) => { + persistent_backup = value; + setPersist_internal(value); + }; + + const [perGameProfileGlobal, setPerGameProfile_internal] = useState(perGameProfile_backup); + const setPerGameProfile = (value: boolean) => { + perGameProfile_backup = value; + setPerGameProfile_internal(value); + }; + + const [gameGlobal, setGame_internal] = useState(lastGame); + const setGame = (value: string) => { + lastGame = value; + setGame_internal(value); + }; + + const [versionGlobal, setVersion_internal] = useState(versionGlobalHolder); + const setVersion = (value: string) => { + versionGlobalHolder = value; + setVersion_internal(value); + }; + + reload = function () { + python.execute(python.onViewReady()); + + python.resolve(python.getSMT(), setSMT); + python.resolve(python.getCPUs(), setCPUs); + python.resolve(python.getCPUBoost(), setBoost); + python.resolve(python.getMaxBoost(), setFreq); + + python.resolve(python.getGPUPowerI(1), setSlowPPT); + python.resolve(python.getGPUPowerI(2), setFastPPT); + + python.resolve(python.getPersistent(), setPersist); + python.resolve(python.getPerGameProfile(), setPerGameProfile); + }; + + + if (firstTime) { + firstTime = false; + + reload(); // technically it's just load, not reload ;) + + python.resolve(python.getChargeNow(), setChargeNow); + python.resolve(python.getChargeFull(), setChargeFull); + python.resolve(python.getChargeDesign(), setChargeDesign); + + python.resolve(python.getCurrentGame(), setGame); + + python.resolve(python.getVersion(), setVersion); + } + + if (periodicHook != null) { + clearInterval(periodicHook); + periodicHook = null; + } + + periodicHook = setInterval(function() { + python.resolve(python.getChargeFull(), setChargeFull); + python.resolve(python.getChargeNow(), setChargeNow); + python.resolve(python.getCurrentGame(), (game: string) => { + if (lastGame != game) { + setGame(game); + lastGame = game; + reload(); + } + }); + }, 1000); + + const FieldWithSeparator = joinClassNames(gamepadDialogClasses.Field, gamepadDialogClasses.WithBottomSeparatorStandard); + + return ( + + {/* CPU */} +
+ CPU +
+ + { + console.log("SMT is now " + smt.toString()); + python.execute(python.setCPUs(cpusGlobal, smt)); + python.resolve(python.getCPUs(), setCPUs); + python.resolve(python.getSMT(), setSMT); + }} + /> + + + { + console.log("CPU slider is now " + cpus.toString()); + if (cpus != cpusGlobal) { + python.execute(python.setCPUs(cpus, smtGlobal)); + python.resolve(python.getCPUs(), setCPUs); + } + }} + /> + + + { + console.log("Boost is now " + boost.toString()); + python.execute(python.setCPUBoost(boost)); + python.resolve(python.getCPUBoost(), setBoost); + }} + /> + + + { + console.log("CPU slider is now " + freq.toString()); + if (freq != freqGlobal) { + python.execute(python.setMaxBoost(freq)); + python.resolve(python.getMaxBoost(), setFreq); + } + }} + /> + + {/* GPU */} +
+ GPU +
+ + {/* index: 1 */} + { + console.log("SlowPPT is now " + ppt.toString()); + if (ppt != slowPPTGlobal) { + python.execute(python.setGPUPowerI(ppt, 1)); + python.resolve(python.getGPUPowerI(1), setSlowPPT); + } + }} + /> + + + {/* index: 2 */} + { + console.log("FastPPT is now " + ppt.toString()); + if (ppt != fastPPTGlobal) { + python.execute(python.setGPUPowerI(ppt, 2)); + python.resolve(python.getGPUPowerI(2), setFastPPT); + } + }} + /> + + {/* Battery */} +
+ Battery +
+ +
+
+
+ Now (Charge) +
+
+ {(7.7 * chargeNowGlobal / 1000000).toFixed(1).toString() + " Wh (" + (100 * chargeNowGlobal / chargeFullGlobal).toFixed(1).toString() + "%)"} +
+
+
+
+ +
+
+
+ Max (Design) +
+
+ {(7.7 * chargeFullGlobal / 1000000).toFixed(1).toString() + " Wh (" + (100 * chargeFullGlobal / chargeDesignGlobal).toFixed(1).toString() + "%)"} +
+
+
+
+ {/* Persistence */} + + { + console.log("Persist is now " + persist.toString()); + python.execute(python.setPersistent(persist)); + python.resolve(python.getPersistent(), setPersist); + }} + /> + + + { + console.log("Per game profile is now " + p.toString()); + python.execute(python.setPerGameProfile(p)); + python.resolve(python.getPerGameProfile(), setPerGameProfile); + reload(); + }} + /> + + +
+
+
+ Now Playing +
+
+ {gameGlobal} +
+
+
+
+ {/* Version */} +
+ Debug +
+ +
+
+
+ PowerTools +
+
+ v{versionGlobal} +
+
+
+
+
+ ); +}; + +const DeckyPluginRouterTest: VFC = () => { + return ( +
+ Hello World! + {}}> + Go to Store + +
+ ); +}; + +export default definePlugin((serverApi: ServerAPI) => { + serverApi.routerHook.addRoute("/decky-plugin-test", DeckyPluginRouterTest, { + exact: true, + }); + + //@ts-ignore + lifetimeHook = SteamClient.GameSessions.RegisterForAppLifetimeNotifications((update) => { + if (update.bRunning) { + console.log("AppID " + update.unAppID.toString() + " is now running"); + } else { + console.log("AppID " + update.unAppID.toString() + " is no longer running"); + python.execute(python.onGameStop(null)); + } + }); + //@ts-ignore + startHook = SteamClient.Apps.RegisterForGameActionStart((actionType, id) => { + //@ts-ignore + let gameInfo: any = appStore.GetAppOverviewByGameID(id); + python.execute(python.onGameStart(id, gameInfo)); + }); + + console.log("Registered PowerTools callbacks, hello!"); + + return { + title:
PowerTools
, + content: , + icon: , + onDismount() { + console.log("PowerTools shutting down"); + clearInterval(periodicHook!); + periodicHook = null; + lifetimeHook!.unregister(); + startHook!.unregister(); + serverApi.routerHook.removeRoute("/decky-plugin-test"); + firstTime = true; + lastGame = ""; + console.log("Unregistered PowerTools callbacks, goodbye."); + }, + }; +}); diff --git a/src/python.ts b/src/python.ts new file mode 100644 index 0000000..f764856 --- /dev/null +++ b/src/python.ts @@ -0,0 +1,139 @@ +import { ServerAPI } from "decky-frontend-lib"; + +var server: ServerAPI | undefined = undefined; + +//import { useEffect } from "react"; + +export function resolve(promise: Promise, setter: any) { + (async function () { + let data = await promise; + if (data.success) { + console.debug("Got resolved", data, "promise", promise); + setter(data.result); + } else { + console.warn("Resolve failed:", data, "promise", promise); + } + })(); +} + +export function execute(promise: Promise) { + (async function () { + let data = await promise; + if (data.success) { + console.debug("Got executed", data, "promise", promise); + } else { + console.warn("Execute failed:", data, "promise", promise); + } + + })(); +} + +export function setServer(s: ServerAPI) { + server = s; +} + +// Python functions +export function getVersion(): Promise { + return server!.callPluginMethod("get_version", {}); +} + +export function onViewReady(): Promise { + return server!.callPluginMethod("on_ready", {}); +} + +export function setCPUs(value: number, smt: boolean): Promise { + return server!.callPluginMethod("set_cpus", {"count":value, "smt": smt}); +} + +export function getCPUs(): Promise { + return server!.callPluginMethod("get_cpus", {}); +} + +export function getSMT(): Promise { + return server!.callPluginMethod("get_smt", {}); +} + +export function setCPUBoost(value: boolean): Promise { + return server!.callPluginMethod("set_boost", {"enabled": value}); +} + +export function getCPUBoost(): Promise { + return server!.callPluginMethod("get_boost", {}); +} + +export function setMaxBoost(index: number): Promise { + return server!.callPluginMethod("set_max_boost", {"index": index}); +} + +export function getMaxBoost(): Promise { + return server!.callPluginMethod("get_max_boost", {}); +} + +export function setGPUPower(value: number, index: number): Promise { + return server!.callPluginMethod("set_gpu_power", {"value": value, "power_number": index}); +} + +export function getGPUPower(index: number): Promise { + return server!.callPluginMethod("get_gpu_power", {"power_number": index}); +} + +export function setGPUPowerI(value: number, index: number): Promise { + return server!.callPluginMethod("set_gpu_power_index", {"index": value, "power_number": index}); +} + +export function getGPUPowerI(index: number): Promise { + return server!.callPluginMethod("get_gpu_power_index", {"power_number": index}); +} + +export function setFanTick(tick: number): Promise { + return server!.callPluginMethod("set_fan_tick", {"tick": tick}); +} + +export function getFanTick(): Promise { + return server!.callPluginMethod("get_fan_tick", {}); +} + +export function getFantastic(): Promise { + return server!.callPluginMethod("fantastic_installed", {}); +} + +export function getChargeNow(): Promise { + return server!.callPluginMethod("get_charge_now", {}); +} + +export function getChargeFull(): Promise { + return server!.callPluginMethod("get_charge_full", {}); +} + +export function getChargeDesign(): Promise { + return server!.callPluginMethod("get_charge_design", {}); +} + +export function setPersistent(value: boolean): Promise { + return server!.callPluginMethod("set_persistent", {"enabled": value}); +} + +export function getPersistent(): Promise { + return server!.callPluginMethod("get_persistent", {}); +} + +export function setPerGameProfile(value: boolean): Promise { + return server!.callPluginMethod("set_per_game_profile", {"enabled": value}); +} + +export function getPerGameProfile(): Promise { + return server!.callPluginMethod("get_per_game_profile", {}); +} + +export function getCurrentGame(): Promise { + return server!.callPluginMethod("get_current_game", {}); +} + +export function onGameStart(gameId: number, data: any): Promise { + const data2 = {appid: data.appid, display_name: data.display_name, gameid: gameId}; // Issue #17 + return server!.callPluginMethod("on_game_start", {"game_id": gameId, "data":data2}); +} + +export function onGameStop(gameId: number | null): Promise { + return server!.callPluginMethod("on_game_stop", {"game_id": gameId}); +} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..dfc0472 --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,14 @@ +declare module "*.svg" { + const content: string; + export default content; +} + +declare module "*.png" { + const content: string; + export default content; +} + +declare module "*.jpg" { + const content: string; + export default content; +} diff --git a/tile_view.html b/tile_view.html deleted file mode 100644 index e69de29..0000000 diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..13b0c35 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "dist", + "module": "ESNext", + "target": "ES2020", + "jsx": "react", + "jsxFactory": "window.SP_REACT.createElement", + "declaration": false, + "moduleResolution": "node", + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "strict": true, + "suppressImplicitAnyIndexErrors": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules"] +}