src

Go monorepo.
Log | Files | Refs

commit 005551137577929740796b239f33928b61245bd4
parent 0017e12f942c741362ee5305930055a0de8fd494
Author: dwrz <dwrz@dwrz.net>
Date:   Fri,  2 Dec 2022 16:20:48 +0000

Add statusbar

Diffstat:
Acmd/statusbar/main.go | 117+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/cpu/cpu.go | 33+++++++++++++++++++++++++++++++++
Apkg/statusbar/datetime/datetime.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/disk/disk.go | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/eth/eth.go | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/light/light.go | 30++++++++++++++++++++++++++++++
Apkg/statusbar/memory/memory.go | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/mic/mic.go | 32++++++++++++++++++++++++++++++++
Apkg/statusbar/power/power.go | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/statusbar.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/temp/temp.go | 45+++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/volume/volume.go | 43+++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/wlan/wlan.go | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkg/statusbar/wwan/wwan.go | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 970 insertions(+), 0 deletions(-)

diff --git a/cmd/statusbar/main.go b/cmd/statusbar/main.go @@ -0,0 +1,117 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "code.dwrz.net/src/pkg/log" + "code.dwrz.net/src/pkg/statusbar" + "code.dwrz.net/src/pkg/statusbar/cpu" + "code.dwrz.net/src/pkg/statusbar/datetime" + "code.dwrz.net/src/pkg/statusbar/disk" + "code.dwrz.net/src/pkg/statusbar/eth" + "code.dwrz.net/src/pkg/statusbar/light" + "code.dwrz.net/src/pkg/statusbar/memory" + "code.dwrz.net/src/pkg/statusbar/mic" + "code.dwrz.net/src/pkg/statusbar/power" + "code.dwrz.net/src/pkg/statusbar/temp" + "code.dwrz.net/src/pkg/statusbar/volume" + "code.dwrz.net/src/pkg/statusbar/wlan" + "code.dwrz.net/src/pkg/statusbar/wwan" +) + +var once = flag.Bool("once", false, "print output once") + +// TODO: block labels; rendered in case of error. +func main() { + flag.Parse() + + var l = log.New(os.Stderr) + + // Setup workspace and log file. + cdir, err := os.UserCacheDir() + if err != nil { + l.Error.Fatalf( + "failed to determine user cache directory: %v", err, + ) + } + wdir := filepath.Join(cdir, "statusbar") + + if err := os.MkdirAll(wdir, os.ModeDir|0700); err != nil { + l.Error.Fatalf("failed to create tmp dir: %v", err) + } + + flog, err := os.Create(wdir + "/log") + if err != nil { + l.Error.Fatalf("failed to create log file: %v", err) + } + defer flog.Close() + + // Prepare the datetime format. + now := time.Now() + yearEnd := time.Date( + now.Year()+1, 1, 1, 0, 0, 0, 0, time.UTC, + ).AddDate(0, 0, -1) + + dfmt := fmt.Sprintf( + "+%%Y-%%m-%%d %%u/7 %%W/52 %%j/%d %%H:%%M %%Z", + yearEnd.YearDay(), + ) + + var bar = statusbar.New(statusbar.Parameters{ + Blocks: []statusbar.Block{ + cpu.New(), + temp.New(), + memory.New(), + disk.New("/", "/home"), + eth.New("eth0"), + wlan.New(), + wwan.New("wwan0"), + power.New(power.Path), + light.New(), + volume.New(), + mic.New(), + datetime.New(datetime.Parameters{ + Format: "+%d %H:%M %Z", + Label: "NYC", + Timezone: "America/New_York", + }), + datetime.New(datetime.Parameters{ + Format: "+%d %H:%M %Z", + Label: "Napoli", + Timezone: "Europe/Rome", + }), + datetime.New(datetime.Parameters{ + Format: "+%d %H:%M %Z", + Label: "上海", + Timezone: "Asia/Shanghai", + }), + datetime.New(datetime.Parameters{ + Format: dfmt, + Timezone: "UTC", + }), + }, + Log: log.New(flog), + Separator: "┃", + }) + + // Initial print. + fmt.Println(bar.Render()) + if *once { + os.Exit(0) + } + + // Main loop. + ticker := time.NewTicker(1 * time.Second) + for { + select { + case <-ticker.C: + // now := time.Now() + fmt.Println(bar.Render()) + // fmt.Println(time.Since(now)) + } + } +} diff --git a/pkg/statusbar/cpu/cpu.go b/pkg/statusbar/cpu/cpu.go @@ -0,0 +1,33 @@ +package cpu + +import ( + "fmt" + "os" + "runtime" + "strings" +) + +const path = "/proc/loadavg" + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "cpu" +} + +func (b *Block) Render() (string, error) { + out, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read %s: %v", path, err) + } + + if fields := strings.Fields(string(out)); len(fields) >= 1 { + return fmt.Sprintf(" %s/%d", fields[0], runtime.NumCPU()), nil + } else { + return fmt.Sprintf(" /%d", runtime.NumCPU()), nil + } +} diff --git a/pkg/statusbar/datetime/datetime.go b/pkg/statusbar/datetime/datetime.go @@ -0,0 +1,53 @@ +package datetime + +import ( + "fmt" + "os/exec" + "strings" +) + +type Block struct { + format string + label string + timezone string +} + +type Parameters struct { + Format string + Label string + Timezone string +} + +func New(p Parameters) *Block { + if p.Timezone == "" { + p.Timezone = "UTC" + } + + return &Block{ + format: p.Format, + label: p.Label, + timezone: p.Timezone, + } +} + +func (b *Block) Name() string { + return "datetime" +} + +func (b *Block) Render() (string, error) { + cmd := exec.Command("date", b.format) + cmd.Env = append(cmd.Env, fmt.Sprintf("TZ=%s", b.timezone)) + + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to exec: %v", err) + } + + if b.label != "" { + return fmt.Sprintf( + "%s %s", b.label, strings.TrimSpace(string(out)), + ), nil + } else { + return strings.TrimSpace(string(out)), nil + } +} diff --git a/pkg/statusbar/disk/disk.go b/pkg/statusbar/disk/disk.go @@ -0,0 +1,60 @@ +package disk + +import ( + "fmt" + "os/exec" + "strings" +) + +type Block struct { + mounts []string +} + +func New(mounts ...string) *Block { + return &Block{mounts: mounts} +} + +func (b *Block) Name() string { + return "disk" +} + +func (b *Block) Render() (string, error) { + out, err := exec.Command("df", "-h").Output() + if err != nil { + return "", fmt.Errorf("exec df failed: %v", err) + } + + var mounts = map[string]string{} + for i, l := range strings.Split(string(out), "\n") { + if i == 0 || len(l) == 0 { + continue + } + + fields := strings.Fields(l) + if len(fields) < 6 { + continue + } + + var icon rune = '' + switch fields[5] { + case "/": + icon = '/' + case "/home": + icon = '' + } + + mounts[fields[5]] = fmt.Sprintf("%c %s", icon, fields[4]) + } + + var output strings.Builder + for i, m := range b.mounts { + if s, exists := mounts[m]; exists { + output.WriteString(s) + if i < len(b.mounts)-1 { + output.WriteRune(' ') + } + } + } + + return output.String(), nil +} diff --git a/pkg/statusbar/eth/eth.go b/pkg/statusbar/eth/eth.go @@ -0,0 +1,70 @@ +package eth + +import ( + "fmt" + "net" +) + +type Block struct { + iface string +} + +func New(iface string) *Block { + return &Block{iface: iface} +} + +func (b *Block) Name() string { + return "eth" +} + +func (b *Block) Render() (string, error) { + iface, err := net.InterfaceByName(b.iface) + if err != nil { + if err.Error() == "route ip+net: no such network interface" { + return fmt.Sprintf(" "), nil + } + return "", fmt.Errorf( + "failed to get interface %s: %v", b.iface, err, + ) + } + + var ip4, ip6 string + addrs, err := iface.Addrs() + if err != nil { + return "", fmt.Errorf("failed to get addresses: %v", err) + } + + for _, addr := range addrs { + a := addr.String() + if isIPv4(a) { + ip4 = a + continue + } else { + ip6 = a + } + } + + switch { + case ip4 == "" && ip6 == "": + return fmt.Sprintf(" "), nil + case ip4 != "" && ip6 == "": + return fmt.Sprintf(" %s", ip4), nil + case ip4 == "" && ip6 != "": + return fmt.Sprintf(" %s", ip6), nil + default: + return fmt.Sprintf(" %s %s", ip4, ip6), nil + } +} + +func isIPv4(s string) bool { + for i := 0; i < len(s); i++ { + switch s[i] { + case '.': + return true + case ':': + return false + } + } + + return false +} diff --git a/pkg/statusbar/light/light.go b/pkg/statusbar/light/light.go @@ -0,0 +1,30 @@ +package light + +import ( + "fmt" + "os/exec" + "strings" +) + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "light" +} + +func (b *Block) Render() (string, error) { + out, err := exec.Command("brightnessctl", "-m").Output() + if err != nil { + return "", fmt.Errorf("failed to exec: %v", err) + } + + if fields := strings.Split(string(out), ","); len(fields) >= 4 { + return fmt.Sprintf(" %s", fields[3]), nil + } else { + return " ", nil + } +} diff --git a/pkg/statusbar/memory/memory.go b/pkg/statusbar/memory/memory.go @@ -0,0 +1,73 @@ +package memory + +import ( + "fmt" + "os/exec" + "strconv" + "strings" +) + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "memory" +} + +func (b *Block) Render() (string, error) { + var output strings.Builder + + out, err := exec.Command("free", "-m").Output() + if err != nil { + return "", fmt.Errorf("failed to exec: %v", err) + } + + for _, l := range strings.Split(string(out), "\n") { + if len(l) == 0 { + continue + } + + fields := strings.Fields(l) + if len(fields) < 3 { + continue + } + + switch fields[0] { + case "Mem:": + total, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return "", fmt.Errorf( + "failed to parse total memory: %v", err, + ) + } + used, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + return "", fmt.Errorf( + "failed to parse used memory: %v", err, + ) + } + + fmt.Fprintf(&output, " %.0f%% ", (used/total)*100) + case "Swap:": + total, err := strconv.ParseFloat(fields[1], 64) + if err != nil { + return "", fmt.Errorf( + "failed to parse total memory: %v", err, + ) + } + used, err := strconv.ParseFloat(fields[2], 64) + if err != nil { + return "", fmt.Errorf( + "failed to parse used memory: %v", err, + ) + } + + fmt.Fprintf(&output, " %.0f%%", (used/total)*100) + } + } + + return output.String(), nil +} diff --git a/pkg/statusbar/mic/mic.go b/pkg/statusbar/mic/mic.go @@ -0,0 +1,32 @@ +package mic + +import ( + "fmt" + "os/exec" + "strings" +) + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "mic" +} + +func (b *Block) Render() (string, error) { + out, err := exec.Command( + "pactl", "get-source-mute", "@DEFAULT_SOURCE@", + ).Output() + if err != nil { + return "", fmt.Errorf("exec pactl failed: %v", err) + } + + if strings.Contains(string(out), "yes") { + return fmt.Sprintf(""), nil + } + + return fmt.Sprintf(" OPEN"), nil +} diff --git a/pkg/statusbar/power/power.go b/pkg/statusbar/power/power.go @@ -0,0 +1,165 @@ +package power + +import ( + "bufio" + "fmt" + "math" + "os" + "sort" + "strconv" + "strings" +) + +// https://www.kernel.org/doc/html/latest/power/power_supply_class.html +const Path = "/sys/class/power_supply" + +type Block struct { + path string +} + +func New(path string) *Block { + return &Block{path: path} +} + +func (b *Block) Name() string { + return "power" +} + +func (b *Block) Render() (string, error) { + // Get the power supplies. + var supplies []string + + files, err := os.ReadDir(b.path) + if err != nil { + return "", fmt.Errorf("failed to read dir %s: %v", b.path, err) + } + for _, f := range files { + name := f.Name() + + if name == "AC" || strings.Contains(name, "BAT") { + supplies = append(supplies, f.Name()) + } + } + sort.Slice(supplies, func(i, j int) bool { + return supplies[i] < supplies[j] + }) + + var sections = []string{} + for _, s := range supplies { + path := fmt.Sprintf("%s/%s/uevent", b.path, s) + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf( + "failed to open %s: %v", path, err, + ) + } + defer f.Close() + + switch { + case s == "AC": + sections = append(sections, outputAC(f)) + case strings.Contains(s, "BAT"): + sections = append(sections, outputBAT(f)) + } + } + + var output strings.Builder + for i, s := range sections { + if i > 0 { + output.WriteRune(' ') + output.WriteRune(' ') + } + output.WriteString(s) + } + + return output.String(), nil +} + +func outputAC(f *os.File) string { + var scanner = bufio.NewScanner(f) + for scanner.Scan() { + k, v, found := strings.Cut(scanner.Text(), "=") + if !found { + continue + } + if k == "POWER_SUPPLY_ONLINE" { + online, err := strconv.ParseBool(v) + if err != nil { + break + } + if online { + return "" + } else { + return "" + } + } + } + + return "" +} + +func outputBAT(f *os.File) string { + // Compile the stats. + var ( + stats = map[string]string{} + scanner = bufio.NewScanner(f) + ) + for scanner.Scan() { + k, v, found := strings.Cut(scanner.Text(), "=") + if !found { + continue + } + // Exit early if the battery is not present. + if k == "POWER_SUPPLY_PRESENT" && v == "0" { + return "" + } + + stats[k] = v + } + + // Assemble output. + var icon rune + switch stats["POWER_SUPPLY_STATUS"] { + case "Charging": + icon = '' + case "Discharging": + icon = '' + case "Full": + icon = '' + default: + icon = '' + } + + // Get capacity. + var capacity string + if s, exists := stats["POWER_SUPPLY_CAPACITY"]; exists { + capacity = s + "%" + } + + // Get remaining. + pn, _ := strconv.ParseFloat(stats["POWER_SUPPLY_POWER_NOW"], 64) + en, _ := strconv.ParseFloat(stats["POWER_SUPPLY_ENERGY_NOW"], 64) + if r := remaining(en, pn); r != "" { + return fmt.Sprintf("%c %s %s", icon, capacity, r) + } + + return fmt.Sprintf("%c %s", icon, capacity) +} + +func remaining(energy, power float64) string { + if energy == 0 || power == 0 { + return "" + } + + // Calculate the remaining hours. + var hours = energy / power + if hours == 0 { + return "" + } + + return fmt.Sprintf( + "%d:%02d", + int(hours), + int((hours-math.Floor(hours))*60), + ) +} diff --git a/pkg/statusbar/statusbar.go b/pkg/statusbar/statusbar.go @@ -0,0 +1,78 @@ +package statusbar + +import ( + "fmt" + "strings" + "sync" + + "code.dwrz.net/src/pkg/log" +) + +type Block interface { + Name() string + Render() (string, error) +} + +type Parameters struct { + Blocks []Block + Log *log.Logger + Separator string +} + +type StatusBar struct { + b *strings.Builder + blocks []Block + l *log.Logger + sep string +} + +func (s *StatusBar) Render() string { + defer s.b.Reset() + + fmt.Fprintf(s.b, "%s ", s.sep) + + var ( + outputs = make([]string, len(s.blocks)) + wg sync.WaitGroup + ) + + wg.Add(len(s.blocks)) + + for i, b := range s.blocks { + go func(i int, b Block) { + defer wg.Done() + + text, err := b.Render() + if err != nil { + s.l.Error.Printf( + "failed to render %s: %v", + b.Name(), err, + ) + outputs[i] = "" + } else { + outputs[i] = text + } + }(i, b) + } + + wg.Wait() + + for i, o := range outputs { + s.b.WriteString(o) + + if i < len(outputs)-1 { + fmt.Fprintf(s.b, " %s ", s.sep) + } + } + + return s.b.String() +} + +func New(p Parameters) *StatusBar { + return &StatusBar{ + b: &strings.Builder{}, + blocks: p.Blocks, + l: p.Log, + sep: p.Separator, + } +} diff --git a/pkg/statusbar/temp/temp.go b/pkg/statusbar/temp/temp.go @@ -0,0 +1,45 @@ +package temp + +import ( + "encoding/json" + "fmt" + "os/exec" +) + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "temp" +} + +func (b *Block) Render() (string, error) { + out, err := exec.Command("sensors", "-j").Output() + if err != nil { + return "", fmt.Errorf("exec sensors failed: %v", err) + } + + var sensors = struct { + Thinkpad struct { + CPU struct { + Temp float64 `json:"temp1_input"` + } `json:"CPU"` + } `json:"thinkpad-isa-0000"` + NVME struct { + Composite struct { + Temp float64 `json:"temp1_input"` + } `json:"Composite"` + } `json:"nvme-pci-0400"` + }{} + if err := json.Unmarshal(out, &sensors); err != nil { + return "", fmt.Errorf("failed to json unmarshal: %v", err) + } + + return fmt.Sprintf( + " %.0f℃  %.0f℃", + sensors.Thinkpad.CPU.Temp, sensors.NVME.Composite.Temp, + ), nil +} diff --git a/pkg/statusbar/volume/volume.go b/pkg/statusbar/volume/volume.go @@ -0,0 +1,43 @@ +package volume + +import ( + "fmt" + "os/exec" + "strings" +) + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "volume" +} + +func (b *Block) Render() (string, error) { + out, err := exec.Command( + "pactl", "get-sink-mute", "@DEFAULT_SINK@", + ).Output() + if err != nil { + return "", fmt.Errorf("exec pactl failed: %v", err) + } + + if strings.Contains(string(out), "yes") { + return fmt.Sprintf(""), nil + } + + out, err = exec.Command( + "pactl", "get-sink-volume", "@DEFAULT_SINK@", + ).Output() + if err != nil { + return "", fmt.Errorf("exec pactl failed: %v", err) + } + + if fields := strings.Fields(string(out)); len(fields) < 5 { + return fmt.Sprintf(" "), nil + } else { + return fmt.Sprintf(" %s", fields[4]), nil + } +} diff --git a/pkg/statusbar/wlan/wlan.go b/pkg/statusbar/wlan/wlan.go @@ -0,0 +1,55 @@ +package wlan + +import ( + "bufio" + "bytes" + "fmt" + "os/exec" + "strings" +) + +type Block struct{} + +func New() *Block { + return &Block{} +} + +func (b *Block) Name() string { + return "wlan" +} + +// TODO: signal strength icon. +func (b *Block) Render() (string, error) { + out, err := exec.Command("iwctl", "station", "wlan0", "show").Output() + if err != nil { + return "", fmt.Errorf("exec iwctl failed: %v", err) + } + + var ( + scanner = bufio.NewScanner(bytes.NewReader(out)) + + state, network, ip, rssi string + ) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 2 { + continue + } + + switch { + case fields[0] == "State": + state = fields[1] + case fields[0] == "Connected": + network = fields[2] + case fields[0] == "IPv4": + ip = fields[2] + case fields[0] == "RSSI": + rssi = fields[1] + fields[2] + } + } + + if state == "disconnected" { + return " ", nil + } + return fmt.Sprintf(" %s %s %s", network, ip, rssi), nil +} diff --git a/pkg/statusbar/wwan/wwan.go b/pkg/statusbar/wwan/wwan.go @@ -0,0 +1,116 @@ +package wwan + +import ( + "bufio" + "bytes" + "fmt" + "net" + "os/exec" + "path" + "strings" +) + +type Block struct { + iface string +} + +func New(iface string) *Block { + return &Block{iface: iface} +} + +func (b *Block) Name() string { + return "wwan" +} + +// TODO: signal strength icon. +// TODO: get IP address (net.Interfaces). +func (b *Block) Render() (string, error) { + out, err := exec.Command("mmcli", "--list-modems").Output() + if err != nil { + return "", fmt.Errorf("exec mmcli failed: %v", err) + } + + fields := strings.Fields(string(out)) + if len(fields) == 0 { + return "", fmt.Errorf("unexpected output: %v", err) + } + modem := path.Base(fields[0]) + + out, err = exec.Command( + "mmcli", "-m", modem, "--output-keyvalue", + ).Output() + if err != nil { + return "", fmt.Errorf("exec mmcli failed: %v", err) + } + + var ( + scanner = bufio.NewScanner(bytes.NewReader(out)) + + state, signal string + ) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 3 { + continue + } + + switch { + case fields[0] == "modem.generic.state": + state = fields[2] + case fields[0] == "modem.generic.signal-quality.value": + signal = fields[2] + } + } + + if state == "disabled" { + return " ", nil + } + + // Get the IP address. + iface, err := net.InterfaceByName(b.iface) + if err != nil { + return "", fmt.Errorf( + "failed to get interface %s: %v", b.iface, err, + ) + } + + var ip4, ip6 string + addrs, err := iface.Addrs() + if err != nil { + return "", fmt.Errorf("failed to get addresses: %v", err) + } + + for _, addr := range addrs { + a := addr.String() + if isIPv4(a) { + ip4 = a + continue + } else { + ip6 = a + } + } + + switch { + case ip4 == "" && ip6 == "": + return fmt.Sprintf(" %s%%", signal), nil + case ip4 != "" && ip6 == "": + return fmt.Sprintf(" %s %s%%", ip4, signal), nil + case ip4 == "" && ip6 != "": + return fmt.Sprintf(" %s %s%%", ip6, signal), nil + default: + return fmt.Sprintf(" %s %s %s%%", ip4, ip6, signal), nil + } +} + +func isIPv4(s string) bool { + for i := 0; i < len(s); i++ { + switch s[i] { + case '.': + return true + case ':': + return false + } + } + + return false +}