Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ strip = true
product-name = "SensorView"
identifier = "com.sensorview.desktop"
category = "Utility"
icons = ["assets/icon.ico", "assets/32x32.png"]
# Windows: ship the LibreHardwareMonitor sidecar next to the app
# (published by `dotnet publish` in CI before packaging).
[package.metadata.packager.windows]
resources = ["sidecar/publish/sensorview-bridge.exe"]
icons = ["assets/icon.png", "assets/icon.ico", "assets/32x32.png"]
# Ship the LibreHardwareMonitor sidecar next to the app. `resources` is a
# top-level field; the glob resolves to nothing on non-Windows runners (where
# the .NET win-x64 sidecar isn't published), so it's harmless there.
resources = ["sidecar/publish/*.exe"]
Binary file added app/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
91 changes: 87 additions & 4 deletions app/sidecar/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// Full sensor coverage (Super-I/O, MSR, SMBus) requires administrator rights;
// without them LibreHardwareMonitor silently exposes the subset it can reach.

using System.Security.Principal;
using System.Text.Json;
using LibreHardwareMonitor.Hardware;

Expand All @@ -27,23 +28,105 @@

computer.Open();

// First line: diagnostics meta so the Rust app can explain zero sensors
// (driver blocked / not elevated). ring0_report is the ring0 slice of LHM's
// own report, which names the exact WinRing0 open/install failure.
var isElevated = false;
try
{
using var identity = WindowsIdentity.GetCurrent();
isElevated = new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator);
}
catch { /* ignore */ }

// Flush the tree on Ctrl+C / kill so the driver handle is released cleanly.
AppDomain.CurrentDomain.ProcessExit += (_, _) => computer.Close();
Console.CancelKeyPress += (_, _) => { computer.Close(); Environment.Exit(0); };

var visitor = new UpdateVisitor();
var json = new JsonSerializerOptions { WriteIndented = false };
var stdout = Console.Out;

// Use the raw stdout stream so a broken pipe (parent gone) surfaces as an
// IOException we can act on, instead of being swallowed by Console.Out.
using var stdout = Console.OpenStandardOutput();
using var writer = new StreamWriter(stdout) { AutoFlush = false };

// First line: diagnostics meta so the Rust app can explain zero sensors
// (driver blocked / not elevated). ring0_report is the ring0 slice of LHM's
// own report, which names the exact WinRing0 open/install failure.
var ring0Report = ExtractRing0(computer.GetReport());
var lhmVersion = typeof(Computer).Assembly.GetName().Version?.ToString() ?? "?";
writer.WriteLine(JsonSerializer.Serialize(new Dictionary<string, object?>
{
["meta"] = new Dictionary<string, object?>
{
["lhm_version"] = lhmVersion,
["is_elevated"] = isElevated,
["ring0_report"] = ring0Report,
},
}));
writer.Flush();

var visitor = new UpdateVisitor();

// Watch the parent (Rust app). If it dies, exit promptly so we never orphan —
// an elevated orphan would leak CPU and hold the driver handle.
var parentId = Environment.GetEnvironmentVariable("SENSORVIEW_PARENT_PID");
System.Diagnostics.Process? parent = null;
if (int.TryParse(parentId, out var pid))
{
try { parent = System.Diagnostics.Process.GetProcessById(pid); } catch { }
}

while (true)
{
if (parent is { HasExited: true })
{
break;
}
computer.Accept(visitor);
var tree = computer.Hardware.Select(MapHardware).ToList();
stdout.WriteLine(JsonSerializer.Serialize(tree, json));
stdout.Flush();
try
{
writer.WriteLine(JsonSerializer.Serialize(tree, json));
writer.Flush(); // throws if the parent closed the read end of the pipe
}
catch (IOException)
{
break; // parent gone → exit
}
Thread.Sleep(1000);
}

computer.Close();

// Pull the "Ring0" section out of LHM's full text report — it records whether
// the kernel driver opened, and any install/blocklist error.
static string ExtractRing0(string report)
{
var lines = report.Replace("\r\n", "\n").Split('\n');
var kept = new List<string>();
var capturing = false;
foreach (var line in lines)
{
if (line.StartsWith("Ring0", StringComparison.OrdinalIgnoreCase)
|| line.Contains("WinRing0")
|| line.Contains("Kernel Driver"))
{
capturing = true;
}
else if (capturing && line.Length > 0 && !char.IsWhiteSpace(line[0]) && line.Contains("Report"))
{
capturing = false;
}
if (capturing)
{
kept.Add(line);
}
}
var text = string.Join("\n", kept).Trim();
return text.Length == 0 ? "(no ring0 section in report)" : text;
}

static Dictionary<string, object?> MapHardware(IHardware hw)
{
return new Dictionary<string, object?>
Expand Down
155 changes: 155 additions & 0 deletions app/src/logging.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
//! CSV logging — HWiNFO's "Start Logging". One column per sensor, one row per
//! poll tick. Owned and written by the poll thread so UI never blocks on IO.

use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::PathBuf;

use crate::model::Hardware;

pub struct CsvLogger {
writer: BufWriter<File>,
/// Sensor identifiers in column order (fixed at start; new sensors ignored).
columns: Vec<String>,
path: PathBuf,
rows: u64,
}

impl CsvLogger {
/// Create a logger writing to the user's Documents folder (falling back to
/// Desktop, then the temp dir), header from the current tree.
pub fn start(tree: &[Hardware]) -> Result<Self, String> {
Self::start_in(&log_dir(), tree)
}

/// Create a logger writing into `dir` (created if missing). Used by tests.
pub fn start_in(dir: &std::path::Path, tree: &[Hardware]) -> Result<Self, String> {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = dir.join(format!("SensorView_log_{stamp}.csv"));
let file = File::create(&path).map_err(|e| e.to_string())?;
let mut writer = BufWriter::new(file);
// UTF-8 BOM so Excel renders °C / µ correctly.
writer.write_all(&[0xEF, 0xBB, 0xBF]).map_err(|e| e.to_string())?;

let mut columns = Vec::new();
let mut header = String::from("Time");
collect(tree, &mut |s| {
columns.push(s.identifier.clone());
let unit = s.sensor_type.unit();
let label = if unit.is_empty() {
s.name.clone()
} else {
format!("{} [{}]", s.name, unit)
};
header.push(',');
header.push_str(&csv_escape(&label));
});
writeln!(writer, "{header}").map_err(|e| e.to_string())?;
writer.flush().map_err(|e| e.to_string())?;

Ok(Self { writer, columns, path, rows: 0 })
}

/// Append one row for the given tree snapshot.
pub fn log(&mut self, tree: &[Hardware]) {
// Index current values by identifier.
let mut values = std::collections::HashMap::new();
collect(tree, &mut |s| {
values.insert(s.identifier.clone(), s.value);
});

let secs = self.rows; // relative seconds at 1 Hz; good enough for a log
let mut line = format!("{secs}");
for id in &self.columns {
line.push(',');
if let Some(Some(v)) = values.get(id) {
line.push_str(&format!("{v:.3}"));
}
}
if writeln!(self.writer, "{line}").is_ok() {
let _ = self.writer.flush();
self.rows += 1;
}
}

pub fn path(&self) -> &PathBuf {
&self.path
}

pub fn rows(&self) -> u64 {
self.rows
}
}

/// A writable directory for logs: Documents → Desktop → temp dir.
fn log_dir() -> PathBuf {
dirs::document_dir()
.or_else(dirs::desktop_dir)
.filter(|d| d.exists() || std::fs::create_dir_all(d).is_ok())
.unwrap_or_else(std::env::temp_dir)
}

/// Visit every sensor in the tree in stable (depth-first) order.
fn collect(tree: &[Hardware], f: &mut impl FnMut(&crate::model::Sensor)) {
for hw in tree {
for s in &hw.sensors {
f(s);
}
collect(&hw.sub_hardware, f);
}
}

fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Hardware, HardwareType, Sensor, SensorType};

fn tree(v: f32) -> Vec<Hardware> {
vec![Hardware {
identifier: "/cpu/0".into(),
name: "CPU".into(),
hardware_type: HardwareType::Cpu,
sensors: vec![Sensor {
identifier: "/cpu/0/temperature/0".into(),
name: "Core, Max".into(), // comma → must be quoted in header
sensor_type: SensorType::Temperature,
index: 0,
value: Some(v),
min: None, max: None, avg: None,
}],
sub_hardware: vec![],
}]
}

#[test]
fn writes_header_and_rows() {
// Use the temp dir so the test is CI-safe (no ~/Documents on runners).
let dir = std::env::temp_dir().join("sensorview_test_logs");
let mut logger = CsvLogger::start_in(&dir, &tree(40.0)).expect("start logger");
logger.log(&tree(41.0));
logger.log(&tree(42.5));
let path = logger.path().clone();
assert_eq!(logger.rows(), 2);
drop(logger); // flush + close

let text = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 3); // header + 2 rows
assert!(lines[0].contains("\"Core, Max [°C]\""));
assert!(lines[1].ends_with("41.000"));
assert!(lines[2].ends_with("42.500"));
let _ = std::fs::remove_file(&path);
}
}
59 changes: 51 additions & 8 deletions app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// Hide the console window on Windows release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

mod logging;
mod model;
mod poll;
mod report;
Expand Down Expand Up @@ -35,6 +36,9 @@ fn main() -> eframe::Result {
settings: Arc::new(RwLock::new(app_settings.clone())),
sysinfo: sysinfo::spawn_query(),
windows: Arc::new(WindowFlags::default()),
graphs: Arc::new(RwLock::new(std::collections::BTreeSet::new())),
logger: Arc::new(Mutex::new(None)),
elevated: sysinfo::is_elevated(),
started: Instant::now(),
};

Expand All @@ -47,10 +51,28 @@ fn main() -> eframe::Result {
.windows
.sensors
.store(app_settings.show_sensors_on_startup, Ordering::Relaxed);
// Dev/testing affordance: open the Settings dialog immediately.
// Dev/testing affordances (env-gated, harmless in normal use).
if std::env::var("SENSORVIEW_SHOW_SETTINGS").is_ok() {
shared.windows.settings.store(true, Ordering::Relaxed);
}
// Prime one snapshot so graph/logging dev-hooks have sensors to attach to.
if std::env::var("SENSORVIEW_OPEN_GRAPH").is_ok() || std::env::var("SENSORVIEW_START_LOGGING").is_ok() {
let tree = shared.monitor.lock().map(|mut m| m.poll()).unwrap_or_default();
if let Ok(needle) = std::env::var("SENSORVIEW_OPEN_GRAPH") {
if let Some(id) = first_sensor_matching(&tree, &needle) {
shared.windows.sensors.store(true, Ordering::Relaxed);
if let Ok(mut g) = shared.graphs.write() {
g.insert(id);
}
}
}
if std::env::var("SENSORVIEW_START_LOGGING").is_ok() {
if let Ok(l) = logging::CsvLogger::start(&tree) {
*shared.logger.lock().unwrap() = Some(l);
shared.windows.sensors.store(true, Ordering::Relaxed);
}
}
}

let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
Expand Down Expand Up @@ -101,22 +123,43 @@ impl eframe::App for SensorViewApp {
}
}

/// Background thread: poll once per interval and wake the UI to repaint.
/// Background thread: poll once per interval, feed the CSV logger, and wake the
/// UI to repaint. The logger lives here so file IO never blocks the UI thread.
fn spawn_poll_thread(ctx: egui::Context, shared: Shared, interval: Duration) {
std::thread::spawn(move || loop {
{
match shared.monitor.lock() {
Ok(mut m) => {
m.poll();
}
Err(_) => break, // poisoned; nothing sensible to do
let tree = match shared.monitor.lock() {
Ok(mut m) => m.poll(),
Err(_) => break, // poisoned; nothing sensible to do
};
if let Ok(mut logger) = shared.logger.lock() {
if let Some(l) = logger.as_mut() {
l.log(&tree);
}
}
ctx.request_repaint();
std::thread::sleep(interval);
});
}

/// First sensor identifier whose name contains `needle` (case-insensitive).
fn first_sensor_matching(tree: &[model::Hardware], needle: &str) -> Option<String> {
let needle = needle.to_lowercase();
fn walk(tree: &[model::Hardware], needle: &str) -> Option<String> {
for hw in tree {
for s in &hw.sensors {
if s.name.to_lowercase().contains(needle) {
return Some(s.identifier.clone());
}
}
if let Some(f) = walk(&hw.sub_hardware, needle) {
return Some(f);
}
}
None
}
walk(tree, &needle)
}

/// Window icon (32×32 PNG baked into the binary).
fn load_icon() -> egui::IconData {
let bytes = include_bytes!("../assets/32x32.png");
Expand Down
4 changes: 4 additions & 0 deletions app/src/poll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ impl Monitor {
self.source.name()
}

pub fn diagnostics(&self) -> crate::source::Diagnostics {
self.source.diagnostics()
}

/// Poll the source once and fold the readings into the running statistics.
/// Returns the freshly enriched tree (also cached as `latest`).
pub fn poll(&mut self) -> Vec<Hardware> {
Expand Down
Loading