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..647063c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PowerTools -![plugin_demo](./extras/ui.png) +![plugin_demo](./assets/ui.png) Steam Deck power tweaks for power users. 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 7d97797..598c6ab 100644 --- a/main.py +++ b/main.py @@ -5,8 +5,8 @@ import asyncio import pathlib import subprocess -VERSION = "0.7.0" -HOME_DIR = str(pathlib.Path(os.getcwd()).parent.parent.resolve()) +VERSION = "0.7.0-alpha-react" +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,13 @@ 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 + ready = False async def get_version(self) -> str: return VERSION @@ -139,6 +142,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 +169,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 +209,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): @@ -331,6 +355,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 @@ -439,7 +466,15 @@ class Plugin: self.current_gameid = None async def get_per_game_profile(self) -> bool: - return self.current_gameid is not None + return self.current_gameid 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) + return True + + async def on_game_stop(self, game_id: int) -> bool: + pt_server.http_server.unset_game(game_id) + 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..2e5dfcf --- /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.3.1" + } +} diff --git a/plugin.json b/plugin.json index 88badf1..ee4f4ee 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..fec3162 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): @@ -60,6 +60,15 @@ class Server(web.Application): def game(self) -> GameInfo: return self.current_game + def set_game(self, game_id, data): + self.current_game = GameInfo(game_id, data) + + 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 index(self, request): logging.debug("Debug index page accessed") current_game = None if self.current_game is None else self.current_game.gameid @@ -97,16 +106,13 @@ class Server(web.Application): 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 + self.current_game = None 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 + self.current_game = None return web.Response(status=204, headers={"Access-Control-Allow-Origin": "*"}) async def self_destruct(self, request): diff --git a/src/index.tsx b/src/index.tsx new file mode 100755 index 0000000..034a5fc --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,368 @@ +import { + //ButtonItem, + definePlugin, + DialogButton, + //Menu, + //MenuItem, + PanelSection, + PanelSectionRow, + //Router, + ServerAPI, + //showContextMenu, + staticClasses, + Slider, + Toggle, + //NotchLabel + gamepadDialogClasses, + joinClassNames, +} from "decky-frontend-lib"; +import { VFC, useState } from "react"; +import { FaShip } from "react-icons/fa"; + +import * as python from "./python"; + +//import logo from "../assets/logo.png"; + +// interface AddMethodArgs { +// left: number; +// right: number; +// } + +var firstTime: boolean = true; +var versionGlobal: string = "0.0.0-jank"; +var periodicHook: NodeJS.Timer | null = null; +var lastGame: string = ""; +var lifetimeHook: any = null; +var startHook: any = null; + +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] = useState(true); + const [cpusGlobal, setCPUs] = useState(8); + const [boostGlobal, setBoost] = useState(true); + + const [freqGlobal, setFreq] = useState(8); + + const [slowPPTGlobal, setSlowPPT] = useState(1); + const [fastPPTGlobal, setFastPPT] = useState(1); + + const [chargeNowGlobal, setChargeNow] = useState(40); + const [chargeFullGlobal, setChargeFull] = useState(40); + const [chargeDesignGlobal, setChargeDesign] = useState(40); + + const [persistGlobal, setPersist] = useState(false); + const [perGameProfileGlobal, setPerGameProfile] = useState(false); + const [gameGlobal, setGame] = useState("with your mom"); + + 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); + + periodicHook = setInterval(function() { + python.resolve(python.getChargeNow(), setChargeNow); + python.resolve(python.getChargeFull(), setChargeFull); + python.resolve(python.getCurrentGame(), (game: string) => { + if (lastGame != game) { + setGame(game); + lastGame = game; + reload(); + } + }); + }, 1000); + + python.resolve(python.getVersion(), (v: string) => {versionGlobal = v;}); + + //@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 + SteamClient.Apps.RegisterForGameActionStart((actionType, id) => { + //@ts-ignore + let gameInfo: any = appStore.GetAppOverviewByGameID(id); + python.execute(python.onGameStart(id, gameInfo)); + }); + } + + 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, + }); + + return { + title:
PowerTools
, + content: , + icon: , + onDismount() { + console.log("PowerTools shutting down"); + clearInterval(periodicHook!); + lifetimeHook.unregister(); + startHook.unregister(); + serverApi.routerHook.removeRoute("/decky-plugin-test"); + }, + }; +}); diff --git a/src/python.ts b/src/python.ts new file mode 100644 index 0000000..9912f65 --- /dev/null +++ b/src/python.ts @@ -0,0 +1,133 @@ +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; + console.log("Got resolved", data); + if (data.success) { + setter(data.result); + } else { + console.log("Resolve failed:", data); + } + })(); +} + +export function execute(promise: Promise) { + (async function () { + let data = await promise; + console.log("Got executed", data); + })(); +} + +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 { + return server!.callPluginMethod("on_game_start", {"game_id": gameId, "data":data}); +} + +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"] +}