piframe-go/ui/config.go

597 lines
19 KiB
Go

package ui
import (
"bytes"
"fmt"
"log"
"math"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"github.com/gdamore/tcell"
"github.com/guillermo/go.procmeminfo"
"github.com/knadh/koanf"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/confmap"
"github.com/rivo/tview"
"git.kemonine.info/PiFrame/config"
"git.kemonine.info/PiFrame/utils"
"git.kemonine.info/PiFrame/wifi"
)
const (
CMD_SYSTEMCTL = "/usr/bin/systemctl"
CMD_FINDMNT = "/usr/bin/findmnt"
SYNCTHING_FOLDER_SKIP = ".stfolder"
)
const (
PAGE_MAIN_UI = "PAGE_MAIN_UI"
PAGE_SAVE_EXIT = "PAGE_SAVE_EXIT"
PAGE_EXIT = "PAGE_EXIT"
PAGE_REBOOT = "PAGE_REBOOT"
PAGE_POWEROFF = "PAGE_POWEROFF"
)
// Bullshit to remove an element from a slice
func remove(s []string, index int) []string {
return append(s[:index], s[index+1:]...)
}
// Housekeeping
var app *tview.Application
var main *tview.Flex
var menu *tview.List
// Function to reset the main input area that's used all over the place
func resetMain() {
main.Clear()
app.SetFocus(menu)
}
func ConfigGui(pfconfig *koanf.Koanf) {
// Memory info for status panel
meminfo := &procmeminfo.MemInfo{}
err := meminfo.Update()
if err != nil {
log.Fatalf("Error getting memory info : %s", err)
}
// Network interfaces for status panel
ifaces, err := net.Interfaces()
if err != nil {
log.Fatalf("Error getting netork interfaces : %s", err)
return
}
// Disk use
findmntOut, err := exec.Command(CMD_FINDMNT, "-n", "-l",
"-o", "TARGET,USE%",
"-t", "ext4,exfat,vfat,btrfs,zfs,xfs").Output()
if err != nil {
log.Fatalf("Error getting disk use : %s", err)
}
filesystems := strings.Split(strings.Trim(string(findmntOut), "\n"), "\n")
// GPU Temp
gpuTemp := fmt.Sprintf("%.2f'C", utils.GetGPUTemp())
// CPU Temp
cpuTemp := fmt.Sprintf("%.2f'C", utils.GetCPUTemp())
// Get list of all folders that can be used as albums
var albums []string
err = filepath.Walk(pfconfig.String(config.CONFIG_KEY_ALBUMS_ROOT), func(path string, fi os.FileInfo, err error) error {
if err != nil {
return err
}
if fi.IsDir() {
if strings.Contains(path, SYNCTHING_FOLDER_SKIP) {
return nil
}
albumName := strings.TrimPrefix(path, pfconfig.String(config.CONFIG_KEY_ALBUMS_ROOT))
if albumName == "" {
albumName = "Main Folder"
}
albums = append(albums, albumName)
}
return nil
})
if err != nil {
log.Fatalf("Error getting list of albums : %s", err)
}
// Run config UI when slideshow stops
app = tview.NewApplication()
// Header
headerTitle := tview.NewTextView().
SetText("PiFrame").
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.ColorAqua)
headerSubTitle := tview.NewTextView().
SetText("Management Utility").
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.ColorSilver)
header := tview.NewFlex().
SetDirection(tview.FlexRow)
header.AddItem(headerTitle, 0, 1, false).
AddItem(headerSubTitle, 0, 1, false)
// Footer fields (Left Column)
saveExitButton := tview.NewButton("Save & Exit").
SetBackgroundColorActivated(tcell.ColorGray)
saveExitButton.SetLabelColor(tcell.ColorBlack).
SetBorder(true).
SetBorderColor(tcell.ColorBlack).
SetBackgroundColor(tcell.ColorGreen).
SetRect(0, 0, 22, 3)
exitButton := tview.NewButton("Exit").
SetBackgroundColorActivated(tcell.ColorGray)
exitButton.SetLabelColor(tcell.ColorBlack).
SetBorder(true).
SetBorderColor(tcell.ColorBlack).
SetBackgroundColor(tcell.ColorYellow).
SetRect(0, 0, 22, 3)
rebootButton := tview.NewButton("Reboot").
SetBackgroundColorActivated(tcell.ColorGray)
rebootButton.SetLabelColor(tcell.ColorBlack).
SetBorder(true).
SetBorderColor(tcell.ColorBlack).
SetBackgroundColor(tcell.ColorFuchsia).
SetRect(0, 0, 22, 3)
powerOffButton := tview.NewButton("Power Off").
SetBackgroundColorActivated(tcell.ColorGray)
powerOffButton.SetLabelColor(tcell.ColorBlack).
SetBorder(true).
SetBorderColor(tcell.ColorBlack).
SetBackgroundColor(tcell.ColorRed).
SetRect(0, 0, 22, 3)
// Footer
footer := tview.NewFlex()
footer.AddItem(saveExitButton, 0, 1, false).
AddItem(exitButton, 0, 1, false).
AddItem(rebootButton, 0, 1, false).
AddItem(powerOffButton, 0, 1, false)
// Setup menu
menu = tview.NewList()
menu.SetBorder(true).
SetTitle("Menu").
SetTitleColor(tcell.ColorAqua)
menu.AddItem("Select Albums", "", '1', nil)
menu.AddItem("Intervals", "", '2', nil)
menu.AddItem("WiFi", "", '3', nil)
menu.AddItem("HDMI On/Off", "", '5', nil)
menu.AddItem("Advanced", "", '6', nil)
// Setup base var for main column so the menu setup is easier to manage
main = tview.NewFlex().
SetDirection(tview.FlexRow)
// Setup main panel (Center column)
main.SetTitle("").
SetBorder(true).
SetTitleColor(tcell.ColorAqua)
// Select Albums Form
selectAlbumsForm := tview.NewForm()
configSelectedAlbums := pfconfig.Strings(config.CONFIG_KEY_ALBUMS_SELECTED)
albumCheckboxes := []*tview.Checkbox{}
for _, album := range albums {
albumSelected := false
for _, configSelectedAlbum := range configSelectedAlbums {
if configSelectedAlbum == "/" {
configSelectedAlbum = "Main Folder"
}
if album == configSelectedAlbum {
albumSelected = true
}
}
albumCheckbox := tview.NewCheckbox()
albumCheckbox.SetLabel(album)
albumCheckbox.SetChecked(albumSelected)
albumCheckboxes = append(albumCheckboxes, albumCheckbox)
selectAlbumsForm.AddFormItem(albumCheckbox)
}
selectAlbumsForm.AddButton("Apply", resetMain)
selectAlbumsForm.AddButton("Cancel", resetMain)
// Slide Interval Form
intervalsForm := tview.NewForm()
configSlideInterval := pfconfig.String(config.CONFIG_KEY_SLIDESHOW_INTERVAL)
configRestartInterval := pfconfig.String(config.CONFIG_KEY_SLIDESHOW_RESTART_INTERVAL)
intervalsForm.AddInputField("Slide", configSlideInterval, 0, nil, func(value string) {
configSlideInterval = value
})
intervalsForm.AddInputField("Restart/Reshuffle", configRestartInterval, 0, nil, func(value string) {
configRestartInterval = value
})
intervalsForm.AddButton("Apply", resetMain)
intervalsForm.AddButton("Cancel", resetMain)
// WiFi Config Form
wifiConfigForm := tview.NewForm()
wifiConfigAccessPoint := ""
wifiConfigPassword := ""
wifiConfigForm.AddInputField("Access Point", "", 0, nil, func(value string) {
wifiConfigAccessPoint = value
})
wifiConfigForm.AddPasswordField("Password", "", 0, '*', func(value string) {
wifiConfigPassword = value
})
wifiConfigForm.AddButton("Apply", func() {
// Cleanup old wifi configs and apply new one
nmWifi := wifi.New(wifiConfigAccessPoint, wifiConfigPassword)
nmWifi.ApplyConfig()
resetMain()
})
wifiConfigForm.AddButton("Cancel", resetMain)
// HDMI On/Off Form
hdmiForm := tview.NewForm()
configHDMIOff := pfconfig.String(config.CONFIG_KEY_HDMI_OFF)
configHDMIOn := pfconfig.String(config.CONFIG_KEY_HDMI_ON)
hdmiForm.AddInputField("HDMI Off Schedule", configHDMIOff, 0, nil, func(value string) {
configHDMIOff = value
})
hdmiForm.AddInputField("HDMI On Schedule", configHDMIOn, 0, nil, func(value string) {
configHDMIOn = value
})
hdmiForm.AddButton("Apply", resetMain)
hdmiForm.AddButton("Cancel", resetMain)
// Advanced config form
advancedForm := tview.NewForm()
advancedForm.AddButton("Go Back", resetMain)
// Setup menu selection handler
menu.SetSelectedFunc(func(index int, title string, desc string, shortcut rune) {
if title == "Select Albums" {
main.SetTitle("Select Albums")
main.Clear()
main.AddItem(selectAlbumsForm, 0, 1, true)
app.SetFocus(selectAlbumsForm)
}
if title == "WiFi" {
main.SetTitle("Configure WiFi")
main.Clear()
main.AddItem(wifiConfigForm, 0, 1, true)
app.SetFocus(wifiConfigForm)
}
if title == "Intervals" {
main.SetTitle("Configure Intervals")
main.Clear()
main.AddItem(intervalsForm, 0, 1, true)
main.AddItem(tview.NewTextView().SetText("Intervals are a number + letter\n\nUse\ns for seconds\nm for minutes\nh for hours"), 0, 1, false)
app.SetFocus(intervalsForm)
}
if title == "HDMI On/Off" {
main.SetTitle("Configure HDMI On/Off")
main.Clear()
main.AddItem(hdmiForm, 0, 1, true)
main.AddItem(tview.NewTextView().SetText("These values are date+time combos\n\nPlease see https://www.freedesktop.org/software/systemd/man/systemd.time.html for details\n\nONLY adjust the times (24h format) if unsure of what to change"), 0, 1, false)
app.SetFocus(hdmiForm)
}
if title == "Advanced" {
main.SetTitle("Advanced Configuration")
main.Clear()
main.AddItem(tview.NewTextView().SetText("For advanced configuration edit /etc/default/pf.toml via the command line. There are a few config values that are DANGEROUS to adjust and not exposed through this ui."), 0, 1, false)
main.AddItem(advancedForm, 0, 1, false)
app.SetFocus(advancedForm)
}
})
// Side bar fields
sideBarCPUTempTitle := tview.NewTextView().
SetText("CPU Temperature").
SetTextColor(tcell.ColorYellow)
sideBarCPUTemp := tview.NewTextView().
SetText(fmt.Sprintf(" %s", cpuTemp))
sideBarGPUTempTitle := tview.NewTextView().
SetText("GPU Temperature").
SetTextColor(tcell.ColorYellow)
sideBarGPUTemp := tview.NewTextView().
SetText(fmt.Sprintf(" %s", gpuTemp))
sideBarMemoryTitle := tview.NewTextView().
SetText("Memory Use (Mb)").
SetTextColor(tcell.ColorYellow)
divisor := math.Pow(1024.0, 2.0)
sideBarMemoryStats := tview.NewTextView().
SetText(fmt.Sprintf(" %.1f / %.1f",
float64(meminfo.Used())/divisor,
float64(meminfo.Total())/divisor))
sideBarSwapTitle := tview.NewTextView().
SetText("Swap Use").
SetTextColor(tcell.ColorYellow)
sideBarSwapStats := tview.NewTextView().
SetText(fmt.Sprintf(" %d%%", meminfo.Swap()))
sideBarFilesystemTitle := tview.NewTextView().
SetText("Disk Use").
SetTextColor(tcell.ColorYellow)
var sideBarFilesystems []*tview.TextView
for _, i := range filesystems {
filesystemAsTextView := tview.NewTextView().
SetText(fmt.Sprintf(" %s", i))
sideBarFilesystems = append(sideBarFilesystems, filesystemAsTextView)
}
sideBarIPAddressesTitle := tview.NewTextView().
SetText("IP Addresses").
SetTextColor(tcell.ColorYellow)
var sideBarIPAddresses []*tview.TextView
for _, i := range ifaces {
addrs, err := i.Addrs()
if err != nil {
log.Printf("Error getting interface addresses : %s", err)
continue
}
for _, a := range addrs {
ipAsTextView := tview.NewTextView().
SetText(fmt.Sprintf(" %v : %s", i.Name, a.String()))
sideBarIPAddresses = append(sideBarIPAddresses, ipAsTextView)
}
}
// Setup side bar (Right column)
sideBar := tview.NewFlex().
SetDirection(tview.FlexRow)
sideBar.SetTitle("System Info").
SetBorder(true).
SetTitleColor(tcell.ColorAqua)
sideBar.AddItem(sideBarCPUTempTitle, 1, 1, false)
sideBar.AddItem(sideBarCPUTemp, 1, 1, false)
sideBar.AddItem(sideBarGPUTempTitle, 1, 1, false)
sideBar.AddItem(sideBarGPUTemp, 1, 1, false)
sideBar.AddItem(sideBarMemoryTitle, 1, 1, false)
sideBar.AddItem(sideBarMemoryStats, 1, 1, false)
sideBar.AddItem(sideBarSwapTitle, 1, 1, false)
sideBar.AddItem(sideBarSwapStats, 1, 1, false)
sideBar.AddItem(sideBarFilesystemTitle, 1, 1, false)
for _, filesystemAsTextView := range sideBarFilesystems {
sideBar.AddItem(filesystemAsTextView, 1, 1, false)
}
sideBar.AddItem(sideBarIPAddressesTitle, 1, 1, false)
for _, ipAsTextView := range sideBarIPAddresses {
sideBar.AddItem(ipAsTextView, 1, 1, false)
}
// Pages
pages := tview.NewPages()
// Main UI
mainUI := tview.NewGrid().
SetRows(2, 0, 4).
SetColumns(25, 50, 50).
SetBorders(true).
AddItem(header, 0, 0, 1, 3, 0, 0, false).
AddItem(footer, 2, 0, 1, 3, 0, 0, false)
mainUI.AddItem(menu, 1, 0, 1, 1, 0, 100, true).
AddItem(main, 1, 1, 1, 1, 0, 100, false).
AddItem(sideBar, 1, 2, 1, 1, 0, 100, false)
pages.AddPage(PAGE_MAIN_UI, mainUI, true, true)
// Button modals
saveExitModal := tview.NewModal().
SetText("Are you sure you want to [red]SAVE [red]& [red]EXIT?").
AddButtons([]string{"Yes", "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonLabel == "Yes" {
// Deal with selected albums
for _, checkbox := range albumCheckboxes {
checkboxLabel := checkbox.GetLabel()
// Undo beautification of checkbox label
if checkboxLabel == "Main Folder" {
checkboxLabel = "/"
}
// Track whether or not we need to add OR remove from config
selected := checkbox.IsChecked()
inConfig := false
configIndex := 0
// Walk the existing config values and bail if/when we find the album
for configIndex = range configSelectedAlbums {
if checkboxLabel == configSelectedAlbums[configIndex] {
inConfig = true
break
}
}
// We need to add to the config
if selected && !inConfig {
configSelectedAlbums = append(configSelectedAlbums, checkboxLabel)
}
// We need to remove from the config
if !selected && inConfig {
configSelectedAlbums = remove(configSelectedAlbums, configIndex)
}
}
// Apply HDMI configuration (on/off)
var hdmiForDisk bytes.Buffer
valueForTemplate := utils.SystemdTimer{OnCalendar: configHDMIOn}
screenOn, err := template.New("screenon.systemd.timer").Parse(utils.SCREEN_ON_DOT_TIMER)
if err != nil {
log.Fatalf("Error setting up screen on systemd timer : %s", err)
}
err = screenOn.Execute(&hdmiForDisk, valueForTemplate)
if err != nil {
log.Fatalf("Error setting up screen on systemd timer : %s", err)
}
utils.WriteFile(utils.SCREEN_ON_TIMER_PATH, utils.SCREEN_ON_TIMER_PATH+".tmp", hdmiForDisk.Bytes())
valueForTemplate.OnCalendar = configHDMIOff
hdmiForDisk.Truncate(0)
screenOff, err := template.New("screenoff.systemd.timer").Parse(utils.SCREEN_OFF_DOT_TIMER)
if err != nil {
log.Fatalf("Error setting up screen off systemd timer : %s", err)
}
err = screenOff.Execute(&hdmiForDisk, valueForTemplate)
if err != nil {
log.Fatalf("Error setting up screen off systemd timer : %s", err)
}
utils.WriteFile(utils.SCREEN_OFF_TIMER_PATH, utils.SCREEN_OFF_TIMER_PATH+".tmp", hdmiForDisk.Bytes())
// Reload systemd units after applying HDMI config
utils.SystemdDaemonReload()
// Apply configuration updates to main config manager prior to saving
pfconfig.Load(confmap.Provider(map[string]interface{}{
config.CONFIG_KEY_SLIDESHOW_INTERVAL: configSlideInterval,
config.CONFIG_KEY_SLIDESHOW_RESTART_INTERVAL: configRestartInterval,
config.CONFIG_KEY_HDMI_ON: configHDMIOn,
config.CONFIG_KEY_HDMI_OFF: configHDMIOff,
config.CONFIG_KEY_ALBUMS_SELECTED: configSelectedAlbums,
}, "."), nil)
// Save configuration
parser := toml.Parser()
configForDisk, err := pfconfig.Marshal(parser)
if err != nil {
log.Fatalf("Error preparing config for disk write : %s", err)
}
utils.WriteFile(config.CONFIG_FILE_PATH, config.CONFIG_FILE_PATH_TMP, configForDisk)
app.Stop()
os.Exit(0)
}
pages.SwitchToPage(PAGE_MAIN_UI)
})
pages.AddPage(PAGE_SAVE_EXIT, saveExitModal, true, false)
saveExitButton.SetSelectedFunc(func() {
pages.ShowPage(PAGE_SAVE_EXIT)
})
exitModal := tview.NewModal().
SetText("Are you sure you want to [red]EXIT?").
AddButtons([]string{"Yes", "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonLabel == "Yes" {
app.Stop()
}
pages.SwitchToPage(PAGE_MAIN_UI)
})
pages.AddPage(PAGE_EXIT, exitModal, true, false)
exitButton.SetSelectedFunc(func() {
pages.ShowPage(PAGE_EXIT)
})
rebootModal := tview.NewModal().
SetText("Are you sure you want to [red]REBOOT?").
AddButtons([]string{"Yes", "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonLabel == "Yes" {
err := exec.Command(CMD_SYSTEMCTL, "reboot").Run()
if err != nil {
log.Fatalf("Could not reboot : %s ", err)
}
}
pages.SwitchToPage(PAGE_MAIN_UI)
})
pages.AddPage(PAGE_REBOOT, rebootModal, true, false)
rebootButton.SetSelectedFunc(func() {
pages.ShowPage(PAGE_REBOOT)
})
powerOffModal := tview.NewModal().
SetText("Are you sure you want to [red]POWER [red]OFF?").
AddButtons([]string{"Yes", "Cancel"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
if buttonLabel == "Yes" {
err := exec.Command(CMD_SYSTEMCTL, "poweroff").Run()
if err != nil {
log.Fatalf("Could not power off : %s ", err)
}
}
pages.SwitchToPage(PAGE_MAIN_UI)
})
pages.AddPage(PAGE_POWEROFF, powerOffModal, true, false)
powerOffButton.SetSelectedFunc(func() {
pages.ShowPage(PAGE_POWEROFF)
})
// Setup tracking of which are of the UI can/has focus
primitivesThatCanFocus := []tview.Primitive{menu, saveExitButton, exitButton, rebootButton, powerOffButton}
currentFocus := 0
// Setup basic switching between main menu and buttons for the UI
app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
// Don't process input if we aren't on an existing element that can focus
canContinue := false
focusedPrimitive := app.GetFocus()
// Override some of the default behavior so up/dn move between fields in forms
// Per API GetFocusedItemIndex on a form will be -1 if a form item isn't currently focused
// We use this as a bit of a cheat to figure out if we're inside of a form that needs better nav options for users (ie. tab doesn't exist on a remote)
albumField, albumButton := selectAlbumsForm.GetFocusedItemIndex()
intervalField, intervalButton := intervalsForm.GetFocusedItemIndex()
wifiField, wifiButton := wifiConfigForm.GetFocusedItemIndex()
hdmiField, hdmiButton := hdmiForm.GetFocusedItemIndex()
advancedField, advancedButton := advancedForm.GetFocusedItemIndex()
if wifiField != -1 || wifiButton != -1 ||
albumField != -1 || albumButton != -1 ||
intervalField != -1 || intervalButton != -1 ||
hdmiField != -1 || hdmiButton != -1 ||
advancedField != -1 || advancedButton != -1 {
switch event.Key() {
case tcell.KeyUp:
return tcell.NewEventKey(tcell.KeyBacktab, 0, event.Modifiers())
case tcell.KeyDown:
return tcell.NewEventKey(tcell.KeyTab, 0, event.Modifiers())
}
}
// Standard override for the screen primitives we are using (ie. don't take over control in modals)
for _, primitive := range primitivesThatCanFocus {
if primitive == focusedPrimitive {
canContinue = true
break
}
}
// Bail if we shouldn't be affecting user input
if !canContinue {
return event
}
// Add various forms of nav that make sense for a TUI (common stuff folks are used to)
currentFocusChanged := false
switch event.Key() {
case tcell.KeyRight, tcell.KeyTab:
currentFocus += 1
if currentFocus >= len(primitivesThatCanFocus) {
currentFocus = 0
}
currentFocusChanged = true
case tcell.KeyLeft, tcell.KeyBacktab:
currentFocus -= 1
if currentFocus < 0 {
currentFocus = len(primitivesThatCanFocus) - 1
}
currentFocusChanged = true
}
// Update the focus based on navigation
if currentFocusChanged {
app.SetFocus(primitivesThatCanFocus[currentFocus])
return nil
}
// Pass through event so the main UI can process stuff properly
return event
})
// Show UI and panic if there are any errors
if err := app.SetRoot(pages, true).SetFocus(primitivesThatCanFocus[currentFocus]).EnableMouse(false).Run(); err != nil {
log.Fatalf("Failed to run UI : %s", err)
}
}