// SPDX-License-Identifier: MIT OR Apache-2.0 // SPDX-FileCopyrightText: Copyright (c) 2025 Markus Zehnder //! AOOSTAR-X json configuration file format. //! //! Derived from the available Monitor3.json file in AOOSTAR-X v1.3.4. //! Likely not fully compatible with files created with the original editor. use anyhow::Context; use image::{Rgb, Rgba}; use imageproc::definitions::HasWhite; use log::{info, warn}; use regex::Regex; use serde::de::Visitor; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_repr::{Deserialize_repr, Serialize_repr}; use std::collections::HashMap; use std::io::BufReader; use std::num::ParseIntError; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::{fmt, fs}; pub fn load_cfg>(path: P) -> anyhow::Result { let path = path.as_ref(); let file = fs::File::open(path).with_context(|| format!("Failed to load config {path:?}"))?; let reader = BufReader::new(file); let config: MonitorConfig = serde_json::from_reader(reader)?; for active in config.active_panels.clone() { if active == 0 || active > config.panels.len() as u32 { warn!("Ignoring invalid active panel {active}"); continue; } let panel = &config.panels[active as usize - 1]; println!( "Panel {active}: {}", panel.img.as_deref().unwrap_or_default() ); for sensor in &panel.sensor { println!( " {}: {} {} {}", sensor.label, sensor .name .as_deref() .or(sensor.item_name.as_deref()) .unwrap_or_default(), sensor.value.as_deref().unwrap_or_default(), sensor.unit.as_deref().unwrap_or_default() ); } } Ok(config) } /// Load a custom panel configuration. /// /// The distributed panel ZIP file must be extracted and contain: /// - `panel.json` configuration file /// - `img` subdirectory containing the referenced images in panel.json /// - `fonts` subdirectory containing the referenced fonts in panel.json /// /// # Arguments /// /// * `path`: directory path of the extracted custom panel. /// /// returns: Result pub fn load_custom_panel>(path: P) -> anyhow::Result { let path = path.as_ref(); let panel_file = path.join("panel.json"); info!("Loading custom panel {panel_file:?}"); let file = fs::File::open(&panel_file) .with_context(|| format!("Failed to load custom panel {panel_file:?}"))?; let reader = BufReader::new(file); let mut panel: Panel = serde_json::from_reader(reader)?; // adjust font and image file paths let img_path = fs::canonicalize(path.join("img"))?; let font_path = fs::canonicalize(path.join("fonts"))?; if let Some(img) = &panel.img && !Path::new(img).is_absolute() { panel.img = Some(img_path.join(img).display().to_string()); } for sensor in panel.sensor.iter_mut() { if let Some(pic) = &sensor.pic && !Path::new(pic).is_absolute() { sensor.pic = Some(img_path.join(pic).display().to_string()); } if let Some(font_family) = &sensor.font_family && !font_family.is_empty() && !Path::new(&font_family).is_absolute() { sensor.font_family = Some(font_path.join(font_family).display().to_string()); } } Ok(panel) } /// AOOSTAR-X monitor json configuration file #[derive(Debug, Serialize, Deserialize)] pub struct MonitorConfig { // _Not used_ // pub credentials: Option, /// Configuration settings. pub setup: Setup, /// Panels: 1-based index into `panels` #[serde(rename = "mianban")] pub active_panels: Vec, /// Custom panels / DIY "Do It Yourself", #[serde(rename = "diy")] pub panels: Vec, /// Internal index of the currently active panel. 1-based! #[serde(skip)] active_panel_idx: Option, /// Internal sensor label mapping #[serde(skip)] sensor_mapping: Option>, /// Internal sensor filter #[serde(skip)] pub sensor_filter: Option>, } impl MonitorConfig { pub fn get_next_active_panel(&mut self) -> Option<&Panel> { let mut active_panel_idx = self.active_panel_idx.unwrap_or(0) + 1; if active_panel_idx > self.panels.len() { active_panel_idx = 1; } for (index, active) in self .active_panels .iter() .filter(|&active| *active > 0) .enumerate() { if *active > self.panels.len() as u32 { warn!("Ignoring invalid active panel {active}"); continue; } if index + 1 == active_panel_idx { self.active_panel_idx = Some(active_panel_idx); return Some(&self.panels[*active as usize - 1]); } } None } /// Adds a custom panel to the application and maps sensor labels if applicable. /// /// The panel is marked active and will be returned with [get_next_active_panel] when it is its turn. /// /// # Arguments /// /// * `panel` - the Panel to include in the active panels. pub fn include_custom_panel(&mut self, mut panel: Panel) { if let Some(mapping) = &self.sensor_mapping { panel.map_sensor_labels(mapping); } self.panels.push(panel); self.active_panels.push(self.panels.len() as u32); } /// Apply a sensor label mapping on the included panels. /// /// The mapping will also be applied on any custom panel added in the future with [include_custom_panel]. /// /// **Attention**: this method may only be called once at startup. /// Dynamically changing mappings are not supported, and the original sensor labels are not preserved. pub fn set_sensor_mapping(&mut self, mapping: HashMap) { for panel in self.panels.iter_mut() { panel.map_sensor_labels(&mapping); } self.sensor_mapping = Some(mapping); } } /// Web-app user login /// /// Not used, part of AOOSTAR-X json configuration file. #[derive(Debug, Serialize, Deserialize)] pub struct Credentials { pub username: String, pub password: String, } /// Configuration settings. /// /// Note: Trimmed down object to include only required fields for `asterctl`. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Setup { /// Switch time between panels in seconds, interpreted as float and converted to milliseconds. Default: 5 pub switch_time: Option, // existed as "30" string /// Panel redraw interval in seconds. Default: 1 pub refresh: f32, /* // The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl` /// Default: true pub off_display: bool, /// Selection of default panels based on theme / control_params / control_disk_temp ? pub theme: i32, /// ? Default: true pub control_params: bool, /// ? Default: true pub control_disk_temp: bool, /// Default: false pub custom_panel: bool, /// Language index. Default: 0 pub language: Language, /// Operation mode: performance, power saving, etc. pub operation_mode: Option, /// Operation type 1 or 2 (?). Default: 1 #[serde(rename = "type")] pub operation_type: Option, /// Default: 300 pub disk_update: i32, /// Home Assistant URL #[serde(deserialize_with = "empty_string_as_none")] #[serde(rename = "ha_url")] pub ha_url: Option, // "" in JSON ⇒ Option /// Home Assistant long-lived access token #[serde(deserialize_with = "empty_string_as_none")] #[serde(rename = "ha_token")] pub ha_token: Option, // "" in JSON ⇒ Option */ } /// Language setting. /// /// Not used, part of AOOSTAR-X json configuration file. #[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)] #[repr(u8)] #[allow(dead_code)] pub enum Language { Chinese = 0, English = 1, Japanese = 2, } /// Not used, part of AOOSTAR-X json configuration file. #[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)] #[repr(i16)] #[allow(dead_code)] pub enum OperationMode { None = -1, HighPerformance = 0, Intelligent = 1, PowerSaving = 2, Custom30W = 3, Custom20W = 4, Custom10W = 5, } #[derive(Debug, Copy, Clone, Serialize_repr, Deserialize_repr, PartialEq)] #[repr(u8)] pub enum SensorDirection { /// Also used for clockwise in circular/arc progress & rotating pointer/dial indicator LeftToRight = 1, /// Also used for counter-clockwise in circular/arc & rotating pointer/dial progress indicator RightToLeft = 2, TopToBottom = 3, BottomToTop = 4, } /// Custom DIY panel definition #[derive(Debug, Serialize, Deserialize)] pub struct Panel { /// Custom panel id pub id: Option, /// Custom panel name pub name: Option, /* // The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl` /// TODO pub checked: Option, /// TODO panel type: 5 = built-in? 6 = custom ? #[serde(rename = "type")] pub panel_type: i32, */ /// Background image filename pub img: Option, /// Sensors pub sensor: Vec, } impl Panel { pub fn friendly_name(&self) -> String { self.name .clone() .or_else(|| self.id.clone()) .or_else(|| { if let Some(img_file) = &self.img { let img_file = PathBuf::from(img_file); img_file .file_stem() .map(|s| s.to_string_lossy().to_string()) } else { None } }) .unwrap_or_else(|| "panel".into()) } fn map_sensor_labels(&mut self, mapping: &HashMap) { for sensor in self.sensor.iter_mut() { if let Some(new_label) = mapping.get(&sensor.label) { sensor.label = new_label.clone(); } } } } /// One Data Display Unit #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Sensor { /// Sensor mode: text, fan, progress, pointer pub mode: SensorMode, /// Sensor type, _not used_. /// - 1 Time / Date Labels /// - 2 Windows-specific system info /// - 3 Hardware value /// - 4 AIDA64 sensor /// - 5 HA sensor /// - 6 http fetch from url /// - 7 system info ? /// - 8 lm-sensor ? #[serde(rename = "type")] pub sensor_type: Option, /// Label name for internal panels. pub name: Option, /// Label name for custom panels. pub item_name: Option, /// Label identifier, also used as data source identifier. pub label: String, /// Sensor value. Ignored: value is used from a sensor source #[serde(deserialize_with = "empty_string_as_none")] pub value: Option, // "" or numbers, so Option /// Image for progress, fan and pointer indicators pub min_value: Option, /// Image for progress, fan and pointer indicators pub max_value: Option, /// Optional unit text to print after the value #[serde(deserialize_with = "empty_string_as_none")] pub unit: Option, /// Rounded x-position. Custom panel coordinates are stored as float! #[serde(deserialize_with = "f32_as_rounded_i32")] pub x: i32, /// Rounded y-position. Custom panel coordinates are stored as float! #[serde(deserialize_with = "f32_as_rounded_i32")] pub y: i32, /// Used for pointer type pub width: Option, /// Used for pointer type pub height: Option, /// Sensor graphic orientation pub direction: Option, /// Font name matching font filename without file extension. pub font_family: Option, /// TODO font size unit: points or pixels? pub font_size: Option, /// Font color in `#RRGGBB` notation, or -1 if not set. #ffffff = white, #ff0000 = red pub font_color: Option, /// _Not (yet) used_ pub font_weight: Option, pub text_align: Option, /// Number of integer places for the sensor value. // -1 ≈ unset ⇒ Option #[serde(deserialize_with = "option_none_if_minus_one")] pub integer_digits: Option, /// Number of decimal places for the sensor value. // -1 ≈ unset ⇒ Option #[serde(deserialize_with = "option_none_if_minus_one")] pub decimal_digits: Option, /// Image for progress, fan and pointer indicators #[serde(deserialize_with = "empty_string_as_none")] pub pic: Option, /// Used for fan & pointer sensors pub min_angle: Option, /// Used for fan & pointer sensors pub max_angle: Option, /// Pivot x #[serde(rename = "xz_x")] pub xz_x: Option, /// Pivot y #[serde(rename = "xz_y")] pub xz_y: Option, /* // The following fields of the AOOSTAR-X json configuration file are NOT used in `asterctl` /// _Not (yet) used_ pub text_direction: i32, // layout direction /// For type = 6 pub url: Option, /// For type = 6 pub data: Option, /// For type = 6 pub interval: Option, */ } /// Sensor element type. Name is based on AOOSTAR-X web configuration #[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, Eq, Hash, PartialEq)] #[repr(u8)] pub enum SensorMode { /// Text element Text = 1, /// Circular/arc progress indicator Fan = 2, /// Horizontal or vertical progress indicator Progress = 3, /// Rotating pointer/dial indicator Pointer = 4, } #[derive(Debug, Serialize, Deserialize)] pub enum TimeDateLabel { #[serde(rename = "DATE_year")] Year, #[serde(rename = "DATE_month")] Month, #[serde(rename = "DATE_day")] Day, #[serde(rename = "DATE_hour")] Hour, #[serde(rename = "DATE_minute")] Minute, #[serde(rename = "DATE_second")] Second, #[serde(rename = "DATE_m_d_h_m_1")] MDHM1, #[serde(rename = "DATE_m_d_h_m_2")] MDHM2, #[serde(rename = "DATE_m_d_1")] MD1, #[serde(rename = "DATE_m_d_2")] MD2, #[serde(rename = "DATE_y_m_d_1")] YMD1, #[serde(rename = "DATE_y_m_d_2")] YMD2, #[serde(rename = "DATE_y_m_d_3")] YMD3, #[serde(rename = "DATE_y_m_d_4")] YMD4, #[serde(rename = "DATE_h_m_s_1")] HMS1, #[serde(rename = "DATE_h_m_s_2")] HMS2, #[serde(rename = "DATE_h_m_s_3")] HMS3, #[serde(rename = "DATE_h_m_1")] HM1, #[serde(rename = "DATE_h_m_2")] HM2, #[serde(rename = "DATE_h_m_3")] HM3, } #[derive(Debug, Copy, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum FontWeight { #[default] Normal, Bold, } #[derive(Debug, Copy, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TextAlign { #[default] Left, Center, Right, } fn option_none_if_minus_one<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { match Option::::deserialize(deserializer)? { Some(-1) | None => Ok(None), Some(other) => Ok(Some(other)), } } /// Special font color type since it is represented either as numeric -1 or as a string :-( /// /// A good serde programming exercise... #[derive(Debug, Clone, Copy)] pub struct FontColor(Rgb); impl Default for FontColor { fn default() -> Self { FontColor(Rgb::white()) } } impl Deref for FontColor { type Target = Rgb; fn deref(&self) -> &Self::Target { &self.0 } } impl TryFrom<&str> for FontColor { type Error = ParseIntError; fn try_from(value: &str) -> Result { if value.len() != 7 || !value.starts_with('#') { warn!("Invalid font color: {value}"); Ok(FontColor::default()) } else { Ok(FontColor(Rgb([ u8::from_str_radix(&value[1..3], 16)?, u8::from_str_radix(&value[3..5], 16)?, u8::from_str_radix(&value[5..7], 16)?, ]))) } } } impl From> for FontColor { fn from(value: Rgb) -> Self { FontColor(value) } } impl From for Rgb { fn from(val: FontColor) -> Self { val.0 } } impl From for Rgba { fn from(val: FontColor) -> Self { Rgba([val.0[0], val.0[1], val.0[2], 255]) } } impl Serialize for FontColor { fn serialize(&self, serializer: S) -> Result where S: Serializer, { format!("#{:02x}{:02x}{:02x}", self.0[0], self.0[1], self.0[2]).serialize(serializer) } } impl<'de> Deserialize<'de> for FontColor { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { struct MyVisitor; impl<'de> Visitor<'de> for MyVisitor { type Value = FontColor; fn expecting(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { fmt.write_str("integer or string") } fn visit_i64(self, val: i64) -> Result where E: serde::de::Error, { match val { -1 => Ok(FontColor::default()), _ => Err(E::custom("invalid integer value, expected -1")), } } fn visit_str(self, val: &str) -> Result where E: serde::de::Error, { if val.trim().is_empty() { return Ok(FontColor::default()); } match val.parse::() { Ok(val) => self.visit_i32(val), Err(_) => val .try_into() .map_err(|e| E::custom(format!("invalid font color value: {e}"))), } } } deserializer.deserialize_any(MyVisitor) } } fn empty_string_as_none<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { let option = Option::::deserialize(deserializer)?; Ok(option.and_then(|s| if s.trim().is_empty() { None } else { Some(s) })) } fn f32_as_rounded_i32<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, { let rounded = f32::deserialize(deserializer).map(f32::round)?; Ok(rounded as i32) }