Upgrade to decky 2.0 plugin system

This commit is contained in:
NGnius (Graham) 2022-07-04 22:02:38 -04:00
parent a5b9216be5
commit d86d72af05
31 changed files with 5673 additions and 6 deletions

43
.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
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
# Editors
.idea
*.iml
# OS metadata
.DS_Store
Thumbs.db
# Ignore built ts files
dist/
__pycache__/
/.yalc
yalc.lock
# ignore Rust compiler files
/backend-rs/target

View file

@ -1,6 +1,6 @@
# Fantastic
![plugin_demo](./extras/ui.png)
![plugin_demo](./assets/ui.png)
Steam Deck fan controls.

BIN
assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
assets/ui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

1107
backend-rs/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

19
backend-rs/Cargo.toml Normal file
View file

@ -0,0 +1,19 @@
[package]
name = "fantastic-rs"
version = "0.3.0"
edition = "2021"
[dependencies]
usdpl-back = { version = "0.5.2", features = ["blocking"]}#, path = "../usdpl/usdpl-back"}
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# logging
log = "0.4"
simplelog = "0.12"
[profile.release]
debug = false
strip = true
lto = true
codegen-units = 4

6
backend-rs/Cross.toml Normal file
View file

@ -0,0 +1,6 @@
[build]
#xargo = true
default-target = "x86_64-unknown-linux-gnu"
[build.env]
volumes = ["/home/ngnius/Documents/git-repos",]

4
backend-rs/build.sh Executable file
View file

@ -0,0 +1,4 @@
#!/bin/bash
cross build --release
cp ./target/release/fantastic-rs ../backend

259
backend-rs/src/api.rs Normal file
View file

@ -0,0 +1,259 @@
use usdpl_back::core::serdes::Primitive;
use super::control::ControlRuntime;
use super::json::GraphPointJson;
pub const VERSION: &'static str = env!("CARGO_PKG_VERSION");
pub const NAME: &'static str = env!("CARGO_PKG_NAME");
pub fn hello(params: Vec<Primitive>) -> Vec<Primitive> {
if let Some(Primitive::String(name)) = params.get(0) {
vec![Primitive::String(format!("Hello {}", name))]
} else {
vec![]
}
}
pub fn echo(params: Vec<Primitive>) -> Vec<Primitive> {
params
}
pub fn version(_: Vec<Primitive>) -> Vec<Primitive> {
vec![VERSION.into()]
}
pub fn name(_: Vec<Primitive>) -> Vec<Primitive> {
vec![NAME.into()]
}
pub fn get_fan_rpm(_: Vec<Primitive>) -> Vec<Primitive> {
if let Some(rpm) = crate::sys::read_fan() {
log::debug!("get_fan_rpm() success: {}", rpm);
vec![rpm.into()]
} else {
log::error!("get_fan_rpm failed to read fan speed");
Vec::new()
}
}
pub fn get_temperature(_: Vec<Primitive>) -> Vec<Primitive> {
if let Some(temperature) = crate::sys::read_thermal_zone(0) {
let real_temp = temperature as f64 / 1000.0;
log::debug!("get_temperature() success: {}", real_temp);
vec![real_temp.into()]
} else {
log::error!("get_fan_rpm failed to read fan speed");
Vec::new()
}
}
pub fn set_enable_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
let runtime_state = runtime.state_clone();
move |params| {
if let Some(Primitive::Bool(enabled)) = params.get(0) {
let mut settings = match runtime_settings.write() {
Ok(x) => x,
Err(e) => {
log::error!("set_enable failed to acquire settings write lock: {}", e);
return vec![];
}
};
if settings.enable != *enabled {
settings.enable = *enabled;
let mut state = match runtime_state.write() {
Ok(x) => x,
Err(e) => {
log::error!("set_enable failed to acquire state write lock: {}", e);
return vec![];
}
};
state.dirty = true;
log::debug!("set_enable({}) success", enabled);
}
vec![(*enabled).into()]
} else {
Vec::new()
}
}
}
pub fn get_enable_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
move |_| {
let lock = match runtime_settings.read() {
Ok(x) => x,
Err(e) => {
log::error!("get_enable failed to acquire settings read lock: {}", e);
return vec![];
}
};
log::debug!("get_enable() success");
vec![lock.enable.into()]
}
}
pub fn set_interpolate_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
let runtime_state = runtime.state_clone();
move |params| {
if let Some(Primitive::Bool(enabled)) = params.get(0) {
let mut settings = match runtime_settings.write() {
Ok(x) => x,
Err(e) => {
log::error!("set_enable failed to acquire settings write lock: {}", e);
return vec![];
}
};
if settings.interpolate != *enabled {
settings.interpolate = *enabled;
let mut state = match runtime_state.write() {
Ok(x) => x,
Err(e) => {
log::error!("set_interpolate failed to acquire state write lock: {}", e);
return vec![];
}
};
state.dirty = true;
log::debug!("set_interpolate({}) success", enabled);
}
vec![(*enabled).into()]
} else {
Vec::new()
}
}
}
pub fn get_interpolate_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
move |_| {
let lock = match runtime_settings.read() {
Ok(x) => x,
Err(e) => {
log::error!("get_interpolate failed to acquire settings read lock: {}", e);
return vec![];
}
};
log::debug!("get_interpolate() success");
vec![lock.interpolate.into()]
}
}
fn curve_to_json(curve: &Vec<super::datastructs::GraphPoint>) -> serde_json::Result<String> {
let mut curve_points = Vec::<GraphPointJson>::with_capacity(curve.len());
for point in curve.iter() {
curve_points.push(point.clone().into());
}
serde_json::to_string(&curve_points)
}
pub fn get_curve_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
move |_| {
let lock = match runtime_settings.read() {
Ok(x) => x,
Err(e) => {
log::error!("get_curve failed to acquire settings read lock: {}", e);
return vec![];
}
};
let json_str = match curve_to_json(&lock.curve) {
Ok(x) => x,
Err(e) => {
log::error!("get_curve failed to serialize points: {}", e);
return vec![];
}
};
log::debug!("get_curve() success");
vec![Primitive::Json(json_str)]
}
}
pub fn add_curve_point_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
let runtime_state = runtime.state_clone();
move |params| {
if let Some(Primitive::Json(json_str)) = params.get(0) {
let mut settings = match runtime_settings.write() {
Ok(x) => x,
Err(e) => {
log::error!("add_curve_point failed to acquire settings write lock: {}", e);
return vec![];
}
};
let new_point: GraphPointJson = match serde_json::from_str(&json_str) {
Ok(x) => x,
Err(e) => {
log::error!("add_curve_point failed deserialize point json: {}", e);
return vec![];
}
};
let version = settings.version;
settings.curve.push(super::datastructs::GraphPoint::from_json(new_point, version));
settings.sort_curve();
let mut state = match runtime_state.write() {
Ok(x) => x,
Err(e) => {
log::error!("add_curve_point failed to acquire state write lock: {}", e);
return vec![];
}
};
state.dirty = true;
let json_str = match curve_to_json(&settings.curve) {
Ok(x) => x,
Err(e) => {
log::error!("add_curve_point failed to serialize points: {}", e);
return vec![];
}
};
log::debug!("add_curve_point({}) success", json_str);
vec![Primitive::Json(json_str)]
} else {
Vec::new()
}
}
}
pub fn remove_curve_point_gen(runtime: &ControlRuntime) -> impl Fn(Vec<Primitive>) -> Vec<Primitive> {
let runtime_settings = runtime.settings_clone();
let runtime_state = runtime.state_clone();
move |params| {
if let Some(Primitive::F64(index)) = params.get(0) {
let mut settings = match runtime_settings.write() {
Ok(x) => x,
Err(e) => {
log::error!("remove_curve_point failed to acquire settings write lock: {}", e);
return vec![];
}
};
let rounded = index.round();
if rounded >= 0.0 && rounded < settings.curve.len() as _ {
let index = rounded as usize;
settings.curve.swap_remove(index);
settings.sort_curve();
let mut state = match runtime_state.write() {
Ok(x) => x,
Err(e) => {
log::error!("remove_curve_point failed to acquire state write lock: {}", e);
return vec![];
}
};
state.dirty = true;
let json_str = match curve_to_json(&settings.curve) {
Ok(x) => x,
Err(e) => {
log::error!("remove_curve_point failed to serialize points: {}", e);
return vec![];
}
};
log::debug!("remove_curve_point({}) success", json_str);
vec![Primitive::Json(json_str)]
} else {
log::error!("remove_curve_point received index out of bounds: {} indexing array of length {}", index, settings.curve.len());
return vec![];
}
} else {
Vec::new()
}
}
}

217
backend-rs/src/control.rs Normal file
View file

@ -0,0 +1,217 @@
//! Fan control
use std::sync::{RwLock, Arc};
use std::thread;
use std::time::{Duration, Instant};
use super::datastructs::{Settings, State, GraphPoint};
use super::json::SettingsJson;
pub struct ControlRuntime {
settings: Arc<RwLock<Settings>>,
state: Arc<RwLock<State>>,
}
impl ControlRuntime {
pub fn new() -> Self {
let new_state = State::new();
let settings_p = settings_path(&new_state.home);
Self {
settings: Arc::new(RwLock::new(super::json::SettingsJson::open(settings_p).unwrap_or_default().into())),
state: Arc::new(RwLock::new(new_state)),
}
}
pub(crate) fn settings_clone(&self) -> Arc<RwLock<Settings>> {
self.settings.clone()
}
pub(crate) fn state_clone(&self) -> Arc<RwLock<State>> {
self.state.clone()
}
pub fn run(&self) -> thread::JoinHandle<()> {
let runtime_settings = self.settings_clone();
let runtime_state = self.state_clone();
thread::spawn(move || {
let sleep_duration = Duration::from_millis(1000);
let mut start_time = Instant::now();
loop {
if Instant::now().duration_since(start_time).as_secs_f64() * 0.95 > sleep_duration.as_secs_f64() {
// resumed from sleep; do fan re-init
log::debug!("Detected resume from sleep, overriding fan again");
{
let state = match runtime_state.read() {
Ok(x) => x,
Err(e) => {
log::error!("runtime failed to acquire state read lock: {}", e);
continue;
}
};
let settings = match runtime_settings.read() {
Ok(x) => x,
Err(e) => {
log::error!("runtime failed to acquire settings read lock: {}", e);
continue;
}
};
Self::on_set_enable(&settings, &state);
}
}
start_time = Instant::now();
{ // save to file
let state = match runtime_state.read() {
Ok(x) => x,
Err(e) => {
log::error!("runtime failed to acquire state read lock: {}", e);
continue;
}
};
if state.dirty {
// save settings to file
let settings = match runtime_settings.read() {
Ok(x) => x,
Err(e) => {
log::error!("runtime failed to acquire settings read lock: {}", e);
continue;
}
};
let settings_json: SettingsJson = settings.clone().into();
if let Err(e) = settings_json.save(settings_path(&state.home)) {
log::error!("SettingsJson.save({}) error: {}", settings_path(&state.home).display(), e);
}
Self::on_set_enable(&settings, &state);
}
}
{ // fan control
let settings = match runtime_settings.read() {
Ok(x) => x,
Err(e) => {
log::error!("runtime failed to acquire settings read lock: {}", e);
continue;
}
};
if settings.enable {
Self::do_fan_control(&settings);
}
}
thread::sleep(sleep_duration);
}
})
}
fn on_set_enable(settings: &Settings, _state: &State) {
// TODO stop/start jupiter fan control (or maybe let the UI handle that?)
if let Err(e) = crate::sys::write_fan_recalc(settings.enable) {
log::error!("runtime failed to write to fan recalculate file: {}", e);
}
}
fn do_fan_control(settings: &Settings) {
/*
curve = self.settings["curve"]
fan_ratio = 0 # unnecessary in Python, but stupid without
if len(curve) == 0:
fan_ratio = 1
else:
index = -1
temperature_ratio = (thermal_zone(0) - TEMPERATURE_MINIMUM) / (TEMPERATURE_MAXIMUM - TEMPERATURE_MINIMUM)
for i in range(len(curve)-1, -1, -1):
if curve[i]["x"] < temperature_ratio:
index = i
break
if self.settings["interpolate"]:
fan_ratio = self.interpolate_fan(self, index, temperature_ratio)
else:
fan_ratio = self.step_fan(self, index, temperature_ratio)
set_fan_target(int((fan_ratio * FAN_MAXIMUM) + FAN_MINIMUM))
*/
let fan_ratio: f64 = if let Some(thermal_zone) = crate::sys::read_thermal_zone(0) {
let temperature_ratio = (((thermal_zone as f64)/1000.0) - settings.temperature_bounds.min)
/ (settings.temperature_bounds.max - settings.temperature_bounds.min);
let mut index = None;
for i in (0..settings.curve.len()).rev() {
if settings.curve[i].x < temperature_ratio {
index = Some(i);
break;
}
}
if settings.interpolate {
Self::interpolate_fan(settings, index, temperature_ratio)
} else {
Self::step_fan(settings, index, temperature_ratio)
}
} else {
1.0
};
let fan_speed: u64 = ((fan_ratio * (settings.fan_bounds.max - settings.fan_bounds.min)) + settings.fan_bounds.min) as _;
if let Err(e) = crate::sys::write_fan_target(fan_speed) {
log::error!("runtime failed to write to fan target file: {}", e);
}
}
fn interpolate_fan(settings: &Settings, index: Option<usize>, t_ratio: f64) -> f64 {
/*
curve = self.settings["curve"]
upper_point = {"x": 1.0, "y": 0.0}
lower_point = {"x": 0.0, "y": 1.0}
if index != -1: # guaranteed to not be empty
lower_point = curve[index]
if index != len(curve) - 1:
upper_point = curve[index+1]
#logging.debug(f"lower_point: {lower_point}, upper_point: {upper_point}")
upper_y = 1-upper_point["y"]
lower_y = 1-lower_point["y"]
slope_m = (upper_y - lower_y) / (upper_point["x"] - lower_point["x"])
y_intercept_b = lower_y - (slope_m * lower_point["x"])
logging.debug(f"interpolation: y = {slope_m}x + {y_intercept_b}")
return (slope_m * temperature_ratio) + y_intercept_b
*/
let (upper, lower) = if let Some(i) = index {
(if i != settings.curve.len() - 1 {
settings.curve[i+1].clone()
} else {
GraphPoint{x: 1.0, y: 1.0}
},
settings.curve[i].clone())
} else {
(if settings.curve.is_empty() {
GraphPoint{x: 1.0, y: 1.0}
} else {
settings.curve[0].clone()
},
GraphPoint{x: 0.0, y: 0.0})
};
let slope_m = (upper.y - lower.y) / (upper.x - lower.x);
let y_intercept_b = lower.y - (slope_m * lower.x);
log::debug!("interpolation: y = {}x + {} (between {:?} and {:?})", slope_m, y_intercept_b, upper, lower);
(slope_m * t_ratio) + y_intercept_b
}
fn step_fan(settings: &Settings, index: Option<usize>, _t_ratio: f64) -> f64 {
/*
curve = self.settings["curve"]
if index != -1:
return 1 - curve[index]["y"]
else:
if len(curve) == 0:
return 1
else:
return 0.5
*/
if let Some(index) = index {
settings.curve[index].y
} else {
if settings.curve.is_empty() {
1.0
} else {
0.5
}
}
}
}
fn settings_path<P: AsRef<std::path::Path>>(home: P) -> std::path::PathBuf {
home.as_ref().join(".config/fantastic/fantastic.json")
}

View file

@ -0,0 +1,211 @@
use std::default::Default;
use std::convert::{Into, From};
use std::path::PathBuf;
use super::json::{SettingsJson, GraphPointJson, BoundsJson};
#[derive(Debug, Clone)]
pub struct Settings {
pub version: u64,
pub enable: bool,
pub interpolate: bool,
pub curve: Vec<GraphPoint>,
pub fan_bounds: Bounds<f64>,
pub temperature_bounds: Bounds<f64>,
}
impl Settings {
pub fn sort_curve(&mut self) {
self.curve.sort_by(|a, b| a.x.total_cmp(&b.x))
}
}
impl From<SettingsJson> for Settings {
fn from(mut other: SettingsJson) -> Self {
match other.version {
0 => Self {
version: 1,
enable: other.enable,
interpolate: other.interpolate,
curve: other.curve.drain(..).map(|x| GraphPoint::from_json(x, other.version)).collect(),
fan_bounds: Bounds {
min: 1.0,
max: 7000.0,
},
temperature_bounds: Bounds {
min: 0.0,
max: 100.0,
},
},
1 => Self {
version: 1,
enable: other.enable,
interpolate: other.interpolate,
curve: other.curve.drain(..).map(|x| GraphPoint::from_json(x, other.version)).collect(),
fan_bounds: other.fan_bounds.map(|x| Bounds::<f64>::from_json(x, other.version)).unwrap_or(Bounds {
min: 1.0,
max: 7000.0,
}),
temperature_bounds: other.temperature_bounds.map(|x| Bounds::<f64>::from_json(x, other.version)).unwrap_or(Bounds {
min: 0.0,
max: 100.0,
}),
},
_ => Self {
version: 1,
enable: other.enable,
interpolate: other.interpolate,
curve: other.curve.drain(..).map(|x| GraphPoint::from_json(x, other.version)).collect(),
fan_bounds: Bounds {
min: 1.0,
max: 7000.0,
},
temperature_bounds: Bounds {
min: 0.0,
max: 100.0,
},
}
}
}
}
impl Into<SettingsJson> for Settings {
#[inline]
fn into(mut self) -> SettingsJson {
SettingsJson {
version: self.version,
enable: self.enable,
interpolate: self.interpolate,
curve: self.curve.drain(..).map(|x| x.into()).collect(),
fan_bounds: Some(self.fan_bounds.into()),
temperature_bounds: Some(self.temperature_bounds.into()),
}
}
}
#[derive(Debug, Clone)]
pub struct GraphPoint {
pub x: f64,
pub y: f64,
}
impl GraphPoint {
#[inline]
pub fn from_json(other: GraphPointJson, version: u64) -> Self {
match version {
0 => Self {
x: other.x,
y: 1.0 - other.y, // use bottom left as origin, instead of whacky old way of top left
},
1 => Self {
x: other.x,
y: other.y,
},
_ => Self {
x: other.x,
y: other.y,
}
}
}
}
impl Into<GraphPointJson> for GraphPoint {
#[inline]
fn into(self) -> GraphPointJson {
GraphPointJson {
x: self.x,
y: self.y,
}
}
}
#[derive(Debug, Clone)]
pub struct Bounds<T: core::fmt::Debug + Clone> {
pub min: T,
pub max: T,
}
/*impl Bounds<usize> {
#[inline]
pub fn from_json(other: BoundsJson, version: u64) -> Self {
match version {
0 => Self {
min: other.min as _,
max: other.max as _,
},
1 => Self {
min: other.min as _,
max: other.max as _,
},
_ => Self {
min: other.min as _,
max: other.max as _,
}
}
}
}*/
impl Bounds<f64> {
#[inline]
pub fn from_json(other: BoundsJson, version: u64) -> Self {
match version {
0 => Self {
min: other.min,
max: other.max,
},
1 => Self {
min: other.min,
max: other.max,
},
_ => Self {
min: other.min,
max: other.max,
}
}
}
}
/*impl Into<BoundsJson> for Bounds<usize> {
#[inline]
fn into(self) -> BoundsJson {
BoundsJson {
min: self.min as _,
max: self.max as _,
}
}
}*/
impl Into<BoundsJson> for Bounds<f64> {
#[inline]
fn into(self) -> BoundsJson {
BoundsJson {
min: self.min,
max: self.max,
}
}
}
#[derive(Debug)]
pub struct State {
pub home: PathBuf,
pub dirty: bool,
}
impl State {
pub fn new() -> Self {
let def = Self::default();
Self {
home: usdpl_back::api::dirs::home().unwrap_or(def.home),
dirty: true,
}
}
}
impl Default for State {
fn default() -> Self {
Self {
home: "/home/deck".into(),
dirty: true,
}
}
}

78
backend-rs/src/json.rs Normal file
View file

@ -0,0 +1,78 @@
use std::default::Default;
use std::fmt::Display;
use serde::{Serialize, Deserialize};
//use super::datastructs::{Settings, GraphPoint};
#[derive(Serialize, Deserialize)]
pub struct SettingsJson {
pub version: u64,
pub enable: bool,
pub interpolate: bool,
pub curve: Vec<GraphPointJson>,
pub fan_bounds: Option<BoundsJson>,
pub temperature_bounds: Option<BoundsJson>,
}
#[derive(Serialize, Deserialize)]
pub struct GraphPointJson {
pub x: f64,
pub y: f64,
}
#[derive(Serialize, Deserialize)]
pub struct BoundsJson {
pub min: f64,
pub max: f64,
}
impl Default for SettingsJson {
fn default() -> Self {
Self {
version: 1,
enable: false,
interpolate: true,
curve: Vec::new(),
fan_bounds: Some(BoundsJson {
min: 0.0,
max: 7000.0,
}),
temperature_bounds: Some(BoundsJson {
min: 0.0,
max: 100.0,
})
}
}
}
impl SettingsJson {
pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), JsonError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(JsonError::Io)?;
}
let mut file = std::fs::File::create(path).map_err(JsonError::Io)?;
serde_json::to_writer_pretty(&mut file, &self).map_err(JsonError::Serde)
}
pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, JsonError> {
let mut file = std::fs::File::open(path).map_err(JsonError::Io)?;
serde_json::from_reader(&mut file).map_err(JsonError::Serde)
}
}
#[derive(Debug)]
pub enum JsonError {
Serde(serde_json::Error),
Io(std::io::Error),
}
impl Display for JsonError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Serde(e) => (e as &dyn Display).fmt(f),
Self::Io(e) => (e as &dyn Display).fmt(f),
}
}
}

41
backend-rs/src/main.rs Normal file
View file

@ -0,0 +1,41 @@
mod api;
mod control;
mod datastructs;
mod json;
mod sys;
use simplelog::{WriteLogger, LevelFilter};
use usdpl_back::Instance;
const PORT: u16 = 44444;
fn main() -> Result<(), ()> {
WriteLogger::init(
LevelFilter::Debug,
Default::default(),
std::fs::File::create("/tmp/fantastic.log").unwrap()
).unwrap();
log::info!("Starting back-end ({} v{})", api::NAME, api::VERSION);
println!("Starting back-end ({} v{})", api::NAME, api::VERSION);
let runtime = control::ControlRuntime::new();
runtime.run();
Instance::new(PORT)
.register("echo", api::echo)
.register("hello", api::hello)
.register("version", api::version)
.register("name", api::name)
.register("get_fan_rpm", api::get_fan_rpm)
.register("get_temperature", api::get_temperature)
.register("set_enable", api::set_enable_gen(&runtime))
.register("get_enable", api::get_enable_gen(&runtime))
.register("set_interpolate", api::set_interpolate_gen(&runtime))
.register("get_interpolate", api::get_interpolate_gen(&runtime))
.register("get_curve", api::get_curve_gen(&runtime))
.register("add_curve_point", api::add_curve_point_gen(&runtime))
.register("remove_curve_point", api::remove_curve_point_gen(&runtime))
.run_blocking()
//Ok(())
//println!("Hello, world!");
}

17
backend-rs/src/sys.rs Normal file
View file

@ -0,0 +1,17 @@
use usdpl_back::api::files::*;
pub fn read_fan() -> Option<u64> {
read_single("/sys/class/hwmon/hwmon5/fan1_input").ok()
}
pub fn read_thermal_zone(index: u8) -> Option<u64> {
read_single(format!("/sys/class/thermal/thermal_zone{}/temp", index)).ok()
}
pub fn write_fan_recalc(enabled: bool) -> Result<(), std::io::Error> {
write_single("/sys/class/hwmon/hwmon5/recalculate", enabled as u8)
}
pub fn write_fan_target(rpm: u64) -> Result<(), std::io::Error> {
write_single("/sys/class/hwmon/hwmon5/fan1_target", rpm)
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

1
fantastic.json Normal file
View file

@ -0,0 +1 @@
{"version": 0, "enable": true, "interpolate": true, "curve": [{"x": 0.2740740740740741, "y": 0.815}, {"x": 0.725925925925926, "y": 0.665}, {"x": 0.9074074074074074, "y": 0.08}]}

View file

@ -10,7 +10,7 @@ HOME_DIR = str(pathlib.Path(os.getcwd()).parent.parent.resolve())
import logging
logging.basicConfig(
filename = "/tmp/fantastic.log",
filename = "/tmp/fantastic.old.log",
format = '%(asctime)s %(levelname)s %(message)s',
filemode = 'w',
force = True)
@ -35,6 +35,8 @@ DEFAULT_DATA = {
"curve": [], # items are {x: int (distance from left), y: int (distance from top, NOT bottom)}
}
logging.debug(f"CWD: {os.getcwd()}")
class Plugin:
settings = None
is_changed = False

2582
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

46
package.json Normal file
View file

@ -0,0 +1,46 @@
{
"name": "decky-plugin-template",
"version": "0.0.1",
"description": "A template to quickly create decky plugins from scratch, based on TypeScript and webpack",
"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/SteamDeckHomebrew/decky-plugin-template.git"
},
"keywords": [
"decky",
"plugin",
"plugin-template",
"steam-deck",
"deck"
],
"author": "Jonas Dellinger <jonas@dellinger.dev>",
"license": "GPL-2.0-or-later",
"bugs": {
"url": "https://github.com/SteamDeckHomebrew/decky-plugin-template/issues"
},
"homepage": "https://github.com/SteamDeckHomebrew/decky-plugin-template#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",
"usdpl-front": "file:./src/usdpl"
}
}

View file

@ -1,9 +1,7 @@
{
"name": "Fantastic",
"name": "Fantastic_React",
"author": "NGnius",
"main_view_html": "main_view.html",
"tile_view_html": "",
"flags": ["root", "_debug"],
"flags": ["root", "debug"],
"publish": {
"discord_id": "106537989684887552",
"description": "Fan controls",

37
rollup.config.js Normal file
View file

@ -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',
},
});

74
src/backend.ts Normal file
View file

@ -0,0 +1,74 @@
import {init_usdpl, target, init_embedded, call_backend} from "usdpl-front";
const USDPL_PORT: number = 44444;
// Utility
export function resolve(promise: Promise<any>, setter: any) {
(async function () {
let data = await promise;
if (data != null) {
console.debug("Got resolved", data);
setter(data);
} else {
console.warn("Resolve failed:", data);
}
})();
}
export function execute(promise: Promise<any[]>) {
(async function () {
let data = await promise;
console.debug("Got executed", data);
})();
}
export async function initBackend() {
// init usdpl
await init_embedded();
init_usdpl(USDPL_PORT);
console.log("USDPL started for framework: " + target());
//setReady(true);
}
// Back-end functions
export async function setEnabled(value: boolean): Promise<boolean> {
return (await call_backend("set_enable", [value]))[0];
}
export async function getEnabled(): Promise<boolean> {
return (await call_backend("get_enable", []))[0];
}
export async function setInterpolate(value: boolean): Promise<boolean> {
return (await call_backend("set_interpolate", [value]))[0];
}
export async function getInterpolate(): Promise<boolean> {
return (await call_backend("get_interpolate", []))[0];
}
export async function getVersion(): Promise<string> {
return (await call_backend("version", []))[0];
}
export async function getCurve(): Promise<{"x": number, "y": number}[]> {
return (await call_backend("get_curve", []))[0];
}
export async function addCurvePoint(point: {"x": number, "y": number}): Promise<{"x": number, "y": number}[]> {
return (await call_backend("add_curve_point", [point]))[0];
}
export async function removeCurvePoint(index: number): Promise<{"x": number, "y": number}[]> {
return (await call_backend("remove_curve_point", [index]))[0];
}
export async function getFanRpm(): Promise<number> {
return (await call_backend("get_fan_rpm", []))[0];
}
export async function getTemperature(): Promise<number> {
return (await call_backend("get_temperature", []))[0];
}

39
src/canvas.tsx Normal file
View file

@ -0,0 +1,39 @@
// from https://medium.com/@pdx.lucasm/canvas-with-react-js-32e133c05258
//import React from 'react';
import { useRef, useEffect } from 'react';
export const Canvas = (props: any) => {
const { draw, options, ...rest } = props;
//const { context, ...moreConfig } = options;
const canvasRef = useCanvas(draw);
return <canvas ref={canvasRef} {...rest}/>;
}
export const useCanvas = (draw: (ctx: any, count: number) => void) => {
const canvasRef: any = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
const context = canvas!.getContext('2d');
let frameCount = 0;
let animationFrameId: number;
const render = () => {
frameCount++;
draw(context, frameCount);
animationFrameId = window.requestAnimationFrame(render);
}
render();
return () => {
window.cancelAnimationFrame(animationFrameId);
}
}, [draw]);
return canvasRef;
}

287
src/index.tsx Executable file
View file

@ -0,0 +1,287 @@
import {
definePlugin,
DialogButton,
PanelSection,
PanelSectionRow,
ServerAPI,
ToggleField,
staticClasses,
gamepadDialogClasses,
joinClassNames,
} from "decky-frontend-lib";
import { VFC, useState } from "react";
import { FaFan } from "react-icons/fa";
import * as backend from "./backend";
import {Canvas} from "./canvas";
const POINT_SIZE = 32;
var periodicHook: any = null;
const Content: VFC<{ serverAPI: ServerAPI }> = ({serverAPI}) => {
// const [result, setResult] = useState<number | undefined>();
// const onClick = async () => {
// const result = await serverAPI.callPluginMethod<AddMethodArgs, number>(
// "add",
// {
// left: 2,
// right: 2,
// }
// );
// if (result.success) {
// setResult(result.result);
// }
// };
const [enabledGlobal, setEnableInternal] = useState<boolean>(false);
const [interpolGlobal, setInterpol] = useState<boolean>(false);
const [serverApiGlobal, setServerApi] = useState<ServerAPI>(serverAPI);
const [firstTime, setFirstTime] = useState<boolean>(true);
const [usdplReady, setUsdplReady] = useState<boolean>(false);
const [curveGlobal, setCurve] = useState<{x: number, y: number}[]>([]);
const [temperatureGlobal, setTemperature] = useState<number>(-273.15);
const [fanRpmGlobal, setFanRpm] = useState<number>(-1337);
function setEnable(enable: boolean) {
setEnableInternal(enable);
//@ts-ignore
SteamClient.System.SetBetaFanControl(!enable);
}
function onClickCanvas(e: any) {
//console.log("canvas click", e);
const realEvent: any = e.nativeEvent;
//console.log("Canvas click @ (" + realEvent.layerX.toString() + ", " + realEvent.layerY.toString() + ")");
const target: any = e.currentTarget;
//console.log("Target dimensions " + target.width.toString() + "x" + target.height.toString());
const clickX = realEvent.layerX;
const clickY = realEvent.layerY;
for (let i = 0; i < curveGlobal.length; i++) {
const curvePoint = curveGlobal[i];
const pointX = curvePoint.x * target.width;
const pointY = (1 - curvePoint.y) * target.height;
if (
pointX + POINT_SIZE > clickX
&& pointX - POINT_SIZE < clickX
&& pointY + POINT_SIZE > clickY
&& pointY - POINT_SIZE < clickY
) {
//console.log("Clicked on point " + i.toString());
backend.resolve(backend.removeCurvePoint(i), setCurve);
return;
}
}
//console.log("Adding new point");
backend.resolve(backend.addCurvePoint({x: clickX / target.width, y: 1 - (clickY / target.height)}), setCurve);
}
function drawCanvas(ctx: any, frameCount: number): void {
const width: number = ctx.canvas.width;
const height: number = ctx.canvas.height;
ctx.strokeStyle = "#1a9fff";
ctx.fillStyle = "#1a9fff";
ctx.lineWidth = 2;
ctx.lineJoin = "round";
//ctx.beginPath();
ctx.clearRect(0, 0, width, height);
/*ctx.arc(75, 75, 50, 0, Math.PI * 2, true); // Outer circle
ctx.moveTo(110, 75);
ctx.arc(75, 75, 35, 0, Math.PI, false); // Mouth (clockwise)
ctx.moveTo(65, 65);
ctx.arc(60, 65, 5, 0, Math.PI * 2, true); // Left eye
ctx.moveTo(95, 65);
ctx.arc(90, 65, 5, 0, Math.PI * 2, true); // Right eye*/
//ctx.beginPath();
//ctx.moveTo(0, height);
if (interpolGlobal) {
ctx.beginPath();
ctx.moveTo(0, height);
for (let i = 0; i < curveGlobal.length; i++) {
const canvasHeight = (1 - curveGlobal[i].y) * height;
const canvasWidth = curveGlobal[i].x * width;
ctx.lineTo(canvasWidth, canvasHeight);
ctx.moveTo(canvasWidth, canvasHeight);
ctx.arc(canvasWidth, canvasHeight, 8, 0, Math.PI * 2);
ctx.moveTo(canvasWidth, canvasHeight);
}
ctx.lineTo(width, 0);
//ctx.moveTo(width, 0);
ctx.stroke();
ctx.fill();
} else {
ctx.beginPath();
ctx.moveTo(0, height);
for (let i = 0; i < curveGlobal.length - 1; i++) {
const canvasHeight = (1 - curveGlobal[i].y) * height;
const canvasWidth = curveGlobal[i].x * width;
const canvasHeight2 = (1 - curveGlobal[i+1].y) * height;
const canvasWidth2 = curveGlobal[i+1].x * width;
//ctx.lineTo(canvasWidth, canvasHeight);
ctx.moveTo(canvasWidth, canvasHeight);
ctx.arc(canvasWidth, canvasHeight, 8, 0, Math.PI * 2);
ctx.moveTo(canvasWidth, canvasHeight);
ctx.lineTo(canvasWidth2, canvasHeight);
ctx.moveTo(canvasWidth2, canvasHeight);
ctx.lineTo(canvasWidth2, canvasHeight2);
}
if (curveGlobal.length != 0) {
const i = curveGlobal.length - 1;
const canvasHeight = (1 - curveGlobal[i].y) * height;
const canvasWidth = curveGlobal[i].x * width;
//ctx.lineTo(width, 0);
ctx.moveTo(canvasWidth, canvasHeight);
ctx.arc(canvasWidth, canvasHeight, 8, 0, Math.PI * 2);
ctx.moveTo(canvasWidth, canvasHeight);
ctx.lineTo(width, canvasHeight);
//ctx.moveTo(width, canvasHeight);
//ctx.lineTo(width, 0);
const canvasHeight2 = (1 - curveGlobal[0].y) * height;
const canvasWidth2 = curveGlobal[0].x * width;
ctx.moveTo(canvasWidth2, canvasHeight2);
ctx.lineTo(canvasWidth2, height);
}
//ctx.moveTo(width, 0);
ctx.stroke();
ctx.fill();
}
//console.debug("Drew canvas with " + curveGlobal.length.toString() + " points; " + width.toString() + "x" + height.toString());
//ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
//ctx.fillStyle = '#000000';
//ctx.beginPath();
//ctx.arc(50, 100, 20*Math.sin(frameCount*0.05)**2, 0, 2*Math.PI);
//ctx.fill();
}
if (firstTime) {
setFirstTime(false);
setServerApi(serverAPI);
(async function(){
await backend.initBackend();
setUsdplReady(true);
backend.resolve(backend.getEnabled(), setEnable);
backend.resolve(backend.getInterpolate(), setInterpol);
backend.resolve(backend.getCurve(), setCurve);
backend.resolve(backend.getTemperature(), setTemperature);
backend.resolve(backend.getFanRpm(), setFanRpm);
})();
periodicHook = setInterval(function() {
backend.resolve(backend.getTemperature(), setTemperature);
backend.resolve(backend.getFanRpm(), setFanRpm);
}, 1000);
}
if (!usdplReady) {
return (
<PanelSection>
</PanelSection>
);
}
const FieldWithSeparator = joinClassNames(gamepadDialogClasses.Field, gamepadDialogClasses.WithBottomSeparatorStandard);
// TODO handle clicking on fan curve nodes
return (
<PanelSection>
<PanelSectionRow>
<div className={FieldWithSeparator}>
<div className={gamepadDialogClasses.FieldLabelRow}>
<div className={gamepadDialogClasses.FieldLabel}>
Current Fan Speed
</div>
<div className={gamepadDialogClasses.FieldChildren}>
{fanRpmGlobal.toFixed(0) + " RPM"}
</div>
</div>
</div>
</PanelSectionRow>
<PanelSectionRow>
<div className={FieldWithSeparator}>
<div className={gamepadDialogClasses.FieldLabelRow}>
<div className={gamepadDialogClasses.FieldLabel}>
Current Temperature
</div>
<div className={gamepadDialogClasses.FieldChildren}>
{temperatureGlobal.toFixed(1) + " °C"}
</div>
</div>
</div>
</PanelSectionRow>
<PanelSectionRow>
<ToggleField
label="Custom Fan Curve"
description="Overrides SteamOS fan curve"
checked={enabledGlobal}
onChange={(value: boolean) => {
backend.resolve(backend.setEnabled(value), setEnable);
}}
/>
</PanelSectionRow>
{ enabledGlobal &&
<div className={staticClasses.PanelSectionTitle}>
Fan
</div>
}
{ enabledGlobal &&
<PanelSectionRow>
<Canvas draw={drawCanvas} width={268} height={200} style={{
"width": "268px",
"height": "200px",
"padding":"0px",
"border":"1px solid #1a9fff",
//"position":"relative",
"background-color":"#1a1f2c",
"border-radius":"4px",
//"margin":"auto",
}} onClick={(e: any) => onClickCanvas(e)}/>
</PanelSectionRow>
}
{ enabledGlobal &&
<PanelSectionRow>
<ToggleField
label="Linear Interpolation"
description="Pretends a straight line connects points"
checked={interpolGlobal}
onChange={(value: boolean) => {
backend.resolve(backend.setInterpolate(value), setInterpol);
}}
/>
</PanelSectionRow>
}
</PanelSection>
);
};
const DeckyPluginRouterTest: VFC = () => {
return (
<div style={{ marginTop: "50px", color: "white" }}>
Hello World!
<DialogButton onClick={() => {}}>
Go to Store
</DialogButton>
</div>
);
};
export default definePlugin((serverApi: ServerAPI) => {
serverApi.routerHook.addRoute("/decky-plugin-test", DeckyPluginRouterTest, {
exact: true,
});
return {
title: <div className={staticClasses.Title}>Fantastic</div>,
content: <Content serverAPI={serverApi} />,
icon: <FaFan />,
onDismount() {
clearInterval(periodicHook!);
serverApi.routerHook.removeRoute("/decky-plugin-test");
},
};
});

14
src/types.d.ts vendored Normal file
View file

@ -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;
}

21
src/usdpl/package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "usdpl-front",
"collaborators": [
"NGnius (Graham) <ngniusness@gmail.com>"
],
"description": "Universal Steam Deck Plugin Library front-end designed for WASM",
"version": "0.4.0",
"license": "GPL-3.0-only",
"repository": {
"type": "git",
"url": "https://github.com/NGnius/usdpl-rs"
},
"files": [
"usdpl_front_bg.wasm",
"usdpl_front.js",
"usdpl_front.d.ts"
],
"module": "usdpl_front.js",
"types": "usdpl_front.d.ts",
"sideEffects": false
}

51
src/usdpl/usdpl_front.d.ts vendored Normal file
View file

@ -0,0 +1,51 @@
/* tslint:disable */
/* eslint-disable */
/**
* Initialize the front-end library
* @param {number} port
*/
export function init_usdpl(port: number): void;
/**
* Get the targeted plugin framework, or "any" if unknown
* @returns {string}
*/
export function target(): string;
/**
* Call a function on the back-end.
* Returns null (None) if this fails for any reason.
* @param {string} name
* @param {any[]} parameters
* @returns {Promise<any>}
*/
export function call_backend(name: string, parameters: any[]): Promise<any>;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly init_usdpl: (a: number) => void;
readonly target: (a: number) => void;
readonly call_backend: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_malloc: (a: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number) => number;
readonly __wbindgen_export_2: WebAssembly.Table;
readonly _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h2bd47cee569ae4c6: (a: number, b: number, c: number) => void;
readonly __wbindgen_add_to_stack_pointer: (a: number) => number;
readonly __wbindgen_free: (a: number, b: number) => void;
readonly __wbindgen_exn_store: (a: number) => void;
readonly wasm_bindgen__convert__closures__invoke2_mut__hfe1195d34914cc54: (a: number, b: number, c: number, d: number) => void;
}
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {InitInput | Promise<InitInput>} module_or_path
*
* @returns {Promise<InitOutput>}
*/
export default function init (module_or_path?: InitInput | Promise<InitInput>): Promise<InitOutput>;
// USDPL customization
export function init_embedded();

476
src/usdpl/usdpl_front.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

14
src/usdpl/usdpl_front_bg.wasm.d.ts vendored Normal file
View file

@ -0,0 +1,14 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export function init_usdpl(a: number): void;
export function target(a: number): void;
export function call_backend(a: number, b: number, c: number, d: number): number;
export function __wbindgen_malloc(a: number): number;
export function __wbindgen_realloc(a: number, b: number, c: number): number;
export const __wbindgen_export_2: WebAssembly.Table;
export function _dyn_core__ops__function__FnMut__A____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__h2bd47cee569ae4c6(a: number, b: number, c: number): void;
export function __wbindgen_add_to_stack_pointer(a: number): number;
export function __wbindgen_free(a: number, b: number): void;
export function __wbindgen_exn_store(a: number): void;
export function wasm_bindgen__convert__closures__invoke2_mut__hfe1195d34914cc54(a: number, b: number, c: number, d: number): void;

23
tsconfig.json Normal file
View file

@ -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"]
}