Skip to content

Commit 0b121d1

Browse files
committed
feat: add NeonPulse theme and DIMM temperature sensors
- Add NeonPulse theme with 2x2 grid layout (device/storage, CPU, GPU, memory) - Add DIMM temperature reading from spd5118 sensors (DDR5) - Add disk labels feature for custom mount point names - Add network speed display in device panel - Load sensor_options from config in theme dev command - Remove unused CPU power/voltage fields
1 parent 02403c5 commit 0b121d1

22 files changed

Lines changed: 4938 additions & 100 deletions

cmd/sensor.go

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package cmd
22

33
import (
44
"bufio"
5+
"encoding/json"
56
"fmt"
67
"os"
78
"path/filepath"
89
"sort"
910
"strings"
1011

12+
"github.com/alperen/sensorpanel/pkg/config"
1113
"github.com/alperen/sensorpanel/pkg/sensors"
1214
"github.com/spf13/cobra"
1315
)
@@ -51,15 +53,30 @@ or configured in the config file.`,
5153
RunE: runSensorOpts,
5254
}
5355

56+
var sensorReadCmd = &cobra.Command{
57+
Use: "read [sensor_id...]",
58+
Short: "Read current sensor values",
59+
Long: `Read and display current values from all or specified sensors.
60+
61+
Examples:
62+
sensorpanel sensor read # Read all sensors
63+
sensorpanel sensor read cpu # Read only CPU sensor
64+
sensorpanel sensor read cpu memory # Read CPU and memory sensors
65+
sensorpanel sensor read --json # Output as JSON`,
66+
RunE: runSensorRead,
67+
}
68+
5469
func init() {
5570
rootCmd.AddCommand(sensorCmd)
5671
sensorCmd.AddCommand(sensorListCmd)
5772
sensorCmd.AddCommand(sensorTypesCmd)
5873
sensorCmd.AddCommand(sensorCreateCmd)
5974
sensorCmd.AddCommand(sensorOptsCmd)
75+
sensorCmd.AddCommand(sensorReadCmd)
6076

6177
sensorListCmd.Flags().BoolP("available", "a", false, "Only show available sensors on this system")
6278
sensorTypesCmd.Flags().StringP("output", "o", "", "Output file path (default: stdout)")
79+
sensorReadCmd.Flags().BoolP("json", "j", false, "Output as JSON")
6380
}
6481

6582
func runSensorList(cmd *cobra.Command, args []string) error {
@@ -489,6 +506,191 @@ func sensorToCamelCase(s string) string {
489506
return result
490507
}
491508

509+
func runSensorRead(cmd *cobra.Command, args []string) error {
510+
jsonOutput, _ := cmd.Flags().GetBool("json")
511+
512+
// Load config from file to get sensor options
513+
cfg, err := config.Load()
514+
if err != nil {
515+
// Fall back to default config if no config file
516+
cfg = &config.Config{}
517+
}
518+
519+
// Create sensor config with options from config file
520+
sensorConfig := sensors.DefaultConfig()
521+
if cfg.SensorOptions != nil {
522+
sensorConfig.Options = make(map[string]interface{})
523+
for k, v := range cfg.SensorOptions {
524+
sensorConfig.Options[k] = v
525+
}
526+
}
527+
528+
collector := sensors.NewCollector(sensorConfig)
529+
530+
// Collect data
531+
var data map[string]interface{}
532+
533+
if len(args) == 0 {
534+
// Collect all sensors
535+
data = collector.CollectAll()
536+
} else {
537+
// Collect specified sensors
538+
data = make(map[string]interface{})
539+
for _, id := range args {
540+
if sensorData, ok := collector.CollectByID(id); ok {
541+
data[id] = sensorData
542+
} else {
543+
fmt.Fprintf(os.Stderr, "Warning: sensor '%s' not available\n", id)
544+
}
545+
}
546+
}
547+
548+
if len(data) == 0 {
549+
fmt.Println("No sensor data available.")
550+
return nil
551+
}
552+
553+
// Output as JSON
554+
if jsonOutput {
555+
jsonBytes, err := jsonMarshalIndent(data)
556+
if err != nil {
557+
return fmt.Errorf("failed to marshal JSON: %w", err)
558+
}
559+
fmt.Println(string(jsonBytes))
560+
return nil
561+
}
562+
563+
// Pretty print output
564+
registry := sensors.GlobalRegistry()
565+
566+
// Group sensors by category for display
567+
categories := make(map[string][]string)
568+
for sensorID := range data {
569+
if p, ok := registry.Get(sensorID); ok {
570+
cat := p.Meta().Category
571+
categories[cat] = append(categories[cat], sensorID)
572+
}
573+
}
574+
575+
// Sort categories
576+
catNames := make([]string, 0, len(categories))
577+
for cat := range categories {
578+
catNames = append(catNames, cat)
579+
}
580+
sort.Strings(catNames)
581+
582+
for _, cat := range catNames {
583+
fmt.Printf("\n[%s]\n", strings.ToUpper(cat))
584+
585+
sensorIDs := categories[cat]
586+
sort.Strings(sensorIDs)
587+
588+
for _, sensorID := range sensorIDs {
589+
sensorData := data[sensorID]
590+
p, _ := registry.Get(sensorID)
591+
meta := p.Meta()
592+
593+
fmt.Printf(" %s\n", meta.Name)
594+
595+
// Handle map data (arrays like disk, network)
596+
if meta.IsArray {
597+
if mapData, ok := sensorData.(map[string]interface{}); ok {
598+
// Array sensors store items in "_items" key
599+
if items, ok := mapData["_items"].([]map[string]interface{}); ok {
600+
for _, item := range items {
601+
// Use the array key field as the header
602+
keyValue := ""
603+
if v, ok := item[meta.ArrayKey]; ok {
604+
keyValue = fmt.Sprintf("%v", v)
605+
}
606+
fmt.Printf(" [%s]\n", keyValue)
607+
printSensorFields(meta.Fields, item, " ")
608+
}
609+
} else if items, ok := mapData["_items"].([]interface{}); ok {
610+
// Handle case where items are []interface{}
611+
for _, item := range items {
612+
if itemMap, ok := item.(map[string]interface{}); ok {
613+
keyValue := ""
614+
if v, ok := itemMap[meta.ArrayKey]; ok {
615+
keyValue = fmt.Sprintf("%v", v)
616+
}
617+
fmt.Printf(" [%s]\n", keyValue)
618+
printSensorFields(meta.Fields, itemMap, " ")
619+
}
620+
}
621+
}
622+
}
623+
} else {
624+
// Single sensor data
625+
if mapData, ok := sensorData.(map[string]interface{}); ok {
626+
printSensorFields(meta.Fields, mapData, " ")
627+
}
628+
}
629+
}
630+
}
631+
632+
fmt.Println()
633+
return nil
634+
}
635+
636+
func printSensorFields(fields []sensors.FieldDef, data map[string]interface{}, indent string) {
637+
for _, field := range fields {
638+
value, ok := data[field.JSONName]
639+
if !ok {
640+
continue
641+
}
642+
643+
// Format value based on type and unit
644+
var formatted string
645+
switch v := value.(type) {
646+
case float64:
647+
if field.Unit == "%" {
648+
formatted = fmt.Sprintf("%.1f%%", v)
649+
} else if field.Unit == "°C" {
650+
formatted = fmt.Sprintf("%.1f°C", v)
651+
} else if field.Unit == "W" {
652+
formatted = fmt.Sprintf("%.2f W", v)
653+
} else if field.Unit == "V" {
654+
formatted = fmt.Sprintf("%.3f V", v)
655+
} else if field.Unit == "MHz" {
656+
formatted = fmt.Sprintf("%.0f MHz", v)
657+
} else if field.Unit == "RPM" {
658+
formatted = fmt.Sprintf("%.0f RPM", v)
659+
} else if field.Unit == "MB" {
660+
if v >= 1024 {
661+
formatted = fmt.Sprintf("%.1f GB", v/1024)
662+
} else {
663+
formatted = fmt.Sprintf("%.0f MB", v)
664+
}
665+
} else if field.Unit == "GB" {
666+
formatted = fmt.Sprintf("%.1f GB", v)
667+
} else if field.Unit == "B/s" {
668+
formatted = sensors.FormatBytesPerSec(v)
669+
} else if field.Unit == "bytes" {
670+
formatted = sensors.FormatBytes(v)
671+
} else {
672+
formatted = fmt.Sprintf("%.2f", v)
673+
}
674+
case string:
675+
formatted = v
676+
case bool:
677+
if v {
678+
formatted = "yes"
679+
} else {
680+
formatted = "no"
681+
}
682+
default:
683+
formatted = fmt.Sprintf("%v", v)
684+
}
685+
686+
fmt.Printf("%s%-16s %s\n", indent, field.Name+":", formatted)
687+
}
688+
}
689+
690+
func jsonMarshalIndent(v interface{}) ([]byte, error) {
691+
return json.MarshalIndent(v, "", " ")
692+
}
693+
492694
func runSensorOpts(cmd *cobra.Command, args []string) error {
493695
registry := sensors.GlobalRegistry()
494696
options := registry.AllOptions()

cmd/theme.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -261,10 +261,20 @@ Press Ctrl+C to stop all servers.`,
261261
fmt.Printf("Starting development server for theme: %s\n", themeName)
262262
fmt.Printf("Theme path: %s\n\n", t.Path)
263263

264-
// Parse sensor options from --opt flags
264+
// Load sensor options from config file first
265265
var sensorOptions map[string]interface{}
266-
if len(themeDevOpts) > 0 {
266+
if cfg, err := config.Load(); err == nil && cfg.SensorOptions != nil {
267267
sensorOptions = make(map[string]interface{})
268+
for k, v := range cfg.SensorOptions {
269+
sensorOptions[k] = v
270+
}
271+
}
272+
273+
// Override with --opt flags if provided
274+
if len(themeDevOpts) > 0 {
275+
if sensorOptions == nil {
276+
sensorOptions = make(map[string]interface{})
277+
}
268278
for _, opt := range themeDevOpts {
269279
key, value, ok := strings.Cut(opt, "=")
270280
if !ok {

pkg/sensors/cpu_linux.go

Lines changed: 0 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,6 @@ func (p *CPUProvider) Meta() SensorMeta {
3333
{Name: "Temperature", JSONName: "temperature", TSName: "temperature", Type: FieldTypeOptionalNumber, Unit: "°C", Description: "CPU temperature"},
3434
{Name: "Frequency", JSONName: "frequency", TSName: "frequency", Type: FieldTypeOptionalNumber, Unit: "MHz", Description: "CPU frequency"},
3535
{Name: "Cores", JSONName: "cores", TSName: "cores", Type: FieldTypeNumber, Unit: "", Description: "Number of CPU cores"},
36-
{Name: "Power", JSONName: "power", TSName: "power", Type: FieldTypeOptionalNumber, Unit: "W", Description: "CPU package power"},
37-
{Name: "Voltage", JSONName: "voltage", TSName: "voltage", Type: FieldTypeOptionalNumber, Unit: "V", Description: "CPU core voltage"},
3836
},
3937
}
4038
}
@@ -69,16 +67,6 @@ func (p *CPUProvider) Collect(state *CollectorState) map[string]interface{} {
6967
// Get core count
7068
result["cores"] = runtime.NumCPU()
7169

72-
// Get power (from RAPL or hwmon)
73-
if power := p.collectPower(); power != nil {
74-
result["power"] = *power
75-
}
76-
77-
// Get voltage (from hwmon)
78-
if voltage := p.collectVoltage(); voltage != nil {
79-
result["voltage"] = *voltage
80-
}
81-
8270
return result
8371
}
8472

@@ -254,89 +242,3 @@ func (p *CPUProvider) collectName() string {
254242

255243
return "Unknown CPU"
256244
}
257-
258-
func (p *CPUProvider) collectPower() *float64 {
259-
// Try RAPL (Running Average Power Limit) for Intel/AMD
260-
raplPaths, _ := filepath.Glob("/sys/class/powercap/intel-rapl:0/energy_uj")
261-
for _, raplPath := range raplPaths {
262-
// RAPL gives cumulative energy, we'd need to track delta
263-
// For now, try hwmon power sensors instead
264-
_ = raplPath
265-
}
266-
267-
// Try hwmon power sensors (zenpower, k10temp with power)
268-
hwmonPaths, _ := filepath.Glob("/sys/class/hwmon/hwmon*/name")
269-
for _, namePath := range hwmonPaths {
270-
nameBytes, err := os.ReadFile(namePath)
271-
if err != nil {
272-
continue
273-
}
274-
name := strings.TrimSpace(string(nameBytes))
275-
276-
// Look for CPU power sensors
277-
if strings.Contains(name, "zenpower") || strings.Contains(name, "k10temp") {
278-
dir := filepath.Dir(namePath)
279-
powerFiles, _ := filepath.Glob(filepath.Join(dir, "power*_input"))
280-
for _, powerFile := range powerFiles {
281-
data, err := os.ReadFile(powerFile)
282-
if err != nil {
283-
continue
284-
}
285-
microW, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
286-
if err != nil {
287-
continue
288-
}
289-
power := float64(microW) / 1000000.0
290-
return &power
291-
}
292-
}
293-
}
294-
295-
return nil
296-
}
297-
298-
func (p *CPUProvider) collectVoltage() *float64 {
299-
// Try hwmon voltage sensors
300-
hwmonPaths, _ := filepath.Glob("/sys/class/hwmon/hwmon*/name")
301-
for _, namePath := range hwmonPaths {
302-
nameBytes, err := os.ReadFile(namePath)
303-
if err != nil {
304-
continue
305-
}
306-
name := strings.TrimSpace(string(nameBytes))
307-
308-
// Look for CPU voltage sensors (zenpower, nct6687, etc.)
309-
if strings.Contains(name, "zenpower") || strings.Contains(name, "nct6") {
310-
dir := filepath.Dir(namePath)
311-
312-
// Try labeled voltage inputs first
313-
inLabels, _ := filepath.Glob(filepath.Join(dir, "in*_label"))
314-
for _, labelPath := range inLabels {
315-
labelBytes, err := os.ReadFile(labelPath)
316-
if err != nil {
317-
continue
318-
}
319-
label := strings.TrimSpace(string(labelBytes))
320-
321-
// Look for CPU core voltage labels
322-
if strings.Contains(strings.ToLower(label), "vcore") ||
323-
strings.Contains(strings.ToLower(label), "cpu") ||
324-
strings.Contains(strings.ToLower(label), "svi2_core") {
325-
inputPath := strings.Replace(labelPath, "_label", "_input", 1)
326-
data, err := os.ReadFile(inputPath)
327-
if err != nil {
328-
continue
329-
}
330-
milliV, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
331-
if err != nil {
332-
continue
333-
}
334-
voltage := float64(milliV) / 1000.0
335-
return &voltage
336-
}
337-
}
338-
}
339-
}
340-
341-
return nil
342-
}

0 commit comments

Comments
 (0)