From 2a5c5428d0d911d47cda8f8f84a8162308dd0665 Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 24 Oct 2018 12:59:43 -0700 Subject: [PATCH] Crontab installation support --- .gitignore | 1 + README.md | 117 ++++++++++++++++++++++++++++++++++++- config.go | 10 ++++ cron.go | 78 +++++++++++++++++++++++++ crontab.in/000-header.cron | 22 +++++++ music.go | 8 ++- routes.go | 37 ++++++++++++ sessions.go | 16 ++++- sonar.go | 10 ++-- utils.go | 43 ++++++++++++++ www/index.gohtml | 22 +++---- 11 files changed, 341 insertions(+), 23 deletions(-) create mode 100644 cron.go create mode 100644 crontab.in/000-header.cron create mode 100644 utils.go diff --git a/.gitignore b/.gitignore index 07129c0..ca3e61e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ media/ +crontab.in/ diff --git a/README.md b/README.md index be72a51..81df668 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,118 @@ # sonar -Raspberry Pi alarm clock server. \ No newline at end of file +Sonar is an alarm clock server designed to run on a Raspberry Pi, but it could +just as well work anywhere. + +For the alarm clock it plays a list of media files from the filesystem. By +default it will use the `mplayer` command. + +# Usage + +``` +./sonar [-listen 127.0.0.1:8000] +``` + +Sonar has no authentication system. It listens on localhost by default and you +should put a proxy like nginx in front with HTTP Basic Auth or whatever. + +It will create its config on first startup. + +# Features + +It listens on an HTTP service and shows a GUI on the homepage where you can +toggle the volume settings, start/stop the alarm clock playlist, and see/change +the scheduled alarm times. + +The clock is controlled over simple RESTful API. You just post to these +endpoints: + +* `/volume/higher`: increase volume by 5% (default) +* `/volume/lower`: lower volume by 5% +* `/volume/mute`: toggle mute status +* `/playlist/start`: start the playlist (doesn't stop automatically!) +* `/playlist/stop`: stop the playlist + +Example to start the playlist via `curl`: + +```bash +$ curl -X POST http://localhost:8000/playlist/start +``` + +# Crontab + +The schedule system installs into the user's local crontab. The cron entries +just post back to the API service, like: + +```cron +30 5 * * * curl -X POST http://127.0.0.1:8000/playlist/start +30 6 * * * curl -X POST http://127.0.0.1:8000/playlist/stop +``` + +The stop command is installed one hour after the start. + +The user's local crontab is **overwritten** by the one Sonar installs. To keep +custom crontab entries, place them into the `crontab.in/` directory. + +All custom user crontabs are concatenated together ahead of Sonar's cron entries. +The `000-header.cron` is the standard Debian cron header and tends to be installed +on top. + +# Configuration + +The config file will be in your system's native location, which is +`~/.config/sonar.json` on Linux environments. + +After running the app once, it will save its default configuration to disk. +The defaults are fine for PulseAudio setups but you may want to revise it to +be sure. + +A default config file looks like this, annotated: + +```javascript +{ + "cookieName": "session", // name of HTTP session cookie + "mediaPath": "./media", // path of media files (like .mp3) to shuffle and play + "mediaCommand": [ + // The command to actually play the media. Use %s where the filename goes. + "mplayer", + "%s" + ], + "volUpCommand": [ + "pactl", + "set-sink-volume", + "0", // Sink number, from `pactl list-sinks` + "+5%" // 5% step + ], + "volDnCommand": [ + "pactl", + "set-sink-volume", + "0", // Sink number + "-5%" + ], + "volMuteCommand": [ + "pactl", + "set-sink-mute", + "0", + "toggle" + ], + "volStatusCommand": [ + // How to get the volume status. The command + // should output just a value like: 56% + "bash", + "-c", + "pacmd dump-volumes | grep \"Sink 0\" | egrep -o '([0-9]+)%' | head -1" + ], + // scheduled alarm time by default + "hour": 6, + "minute": 30, + "days": [ + "1", "2", "3", "4", "5" + ] +} +``` + +# License + +Noah Petherbridge © 2018 + +GPLv2. diff --git a/config.go b/config.go index 8f27f7f..7ffee91 100644 --- a/config.go +++ b/config.go @@ -23,6 +23,11 @@ type Config struct { VolumeDownCommand []string `json:"volDnCommand"` VolumeMuteCommand []string `json:"volMuteCommand"` VolumeStatusCommand []string `json:"volStatusCommand"` + + // Scheduled cron tab. + Hour int `json:"hour"` + Minute int `json:"minute"` + Days []string `json:"days"` } // DefaultConfig returns the default config. @@ -32,6 +37,11 @@ func DefaultConfig() *Config { MediaPath: "./media", MediaCommand: []string{"mplayer", "%s"}, + // Default schedule. + Hour: 6, + Minute: 30, + Days: []string{"1", "2", "3", "4", "5"}, // M-F + // PulseAudio pactl commands. // pactl set-sink-volume -% // Find the sink number from: pactl list diff --git a/cron.go b/cron.go new file mode 100644 index 0000000..0512fab --- /dev/null +++ b/cron.go @@ -0,0 +1,78 @@ +package sonar + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// SetSchedule installs the crontab. +func (s *Sonar) SetSchedule(tod time.Time, days []string) error { + // Assemble our new crontab entries. + entries := []string{ + // m h dom mon dow command + fmt.Sprintf("%d %d * * %s %s", + tod.Minute(), + tod.Hour(), + strings.Join(days, ","), + "curl -X POST http://"+s.url+"/playlist/start", + ), + fmt.Sprintf("%d %d * * %s %s", + tod.Minute(), + tod.Add(1*time.Hour).Hour(), + strings.Join(days, ","), + "curl -X POST http://"+s.url+"/playlist/stop", + ), + "", + } + + // Concat all the user's custom crontab entries. + crons, err := BuildCrontab() + if err != nil { + return fmt.Errorf("SetSchedule: BuildCrontab: %s", err) + } + + // Final cron file is the user's custom crons plus the new ones. + crontab := strings.Join(append(crons, entries...), "\n") + fmt.Println(crontab) + + // Install the crontab. + cmd := exec.Command("crontab", "-") + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdin = strings.NewReader(crontab) + cmd.Stdout = &out + cmd.Stderr = &stderr + err = cmd.Run() + if err != nil { + return fmt.Errorf("SetSchedule: crontab -: %s\nSTDOUT: %s\nSTDERR: %s", err, out.String(), stderr.String()) + } + + return nil +} + +// BuildCrontab concatenates all the crontabs from crontab.in/ +func BuildCrontab() ([]string, error) { + var crontab []string + err := filepath.Walk("./crontab.in", func(path string, info os.FileInfo, err error) error { + if info.IsDir() { + return nil + } + data, err := ioutil.ReadFile(path) + if err != nil { + return fmt.Errorf("ReadAll(%s): %s", path, err) + } + lines := strings.Split(string(data), "\n") + crontab = append(crontab, lines...) + return nil + }) + if err != nil { + return nil, err + } + return crontab, nil +} diff --git a/crontab.in/000-header.cron b/crontab.in/000-header.cron new file mode 100644 index 0000000..e8b2601 --- /dev/null +++ b/crontab.in/000-header.cron @@ -0,0 +1,22 @@ +# Edit this file to introduce tasks to be run by cron. +# +# Each task to run has to be defined through a single line +# indicating with different fields when the task will be run +# and what command to run for the task +# +# To define the time you can provide concrete values for +# minute (m), hour (h), day of month (dom), month (mon), +# and day of week (dow) or use '*' in these fields (for 'any').# +# Notice that tasks will be started based on the cron's system +# daemon's notion of time and timezones. +# +# Output of the crontab jobs (including errors) is sent through +# email to the user the crontab file belongs to (unless redirected). +# +# For example, you can run a backup of all your user accounts +# at 5 a.m every week with: +# 0 5 * * 1 tar -zcf /var/backups/home.tgz /home/ +# +# For more information see the manual pages of crontab(5) and cron(8) +# +# m h dom mon dow command diff --git a/music.go b/music.go index 290aeee..ef5123d 100644 --- a/music.go +++ b/music.go @@ -3,6 +3,7 @@ package sonar import ( "errors" "fmt" + "math/rand" "os/exec" "path/filepath" "strconv" @@ -136,9 +137,10 @@ func (s *Sonar) StartPlaylist() error { // Shuffle the files. log.Debug("StartPlaylist: shuffled %d media files", len(files)) - // rand.Shuffle(len(files), func(i, j int) { - // files[i], files[j] = files[j], files[i] - // }) + for i := range files { + j := rand.Intn(i + 1) + files[i], files[j] = files[j], files[i] + } // Install the playlist and go. s.Player.Run(files) diff --git a/routes.go b/routes.go index e1fc43d..21bf12d 100644 --- a/routes.go +++ b/routes.go @@ -3,6 +3,7 @@ package sonar import ( "html/template" "net/http" + "time" "github.com/gorilla/mux" ) @@ -23,6 +24,11 @@ func (s *Sonar) Register() *mux.Router { "Flashes": flashes, "Volume": s.Volume(), "PlaylistStatus": s.Player.Status(), + + // Current schedule + "Hour": s.Config.Hour, + "Minute": s.Config.Minute, + "Days": NewSet(s.Config.Days), } templ.ExecuteTemplate(w, "index.gohtml", v) }) @@ -46,6 +52,37 @@ func (s *Sonar) Register() *mux.Router { s.FlashAndRedirect(r, w, "/", "Playlist stopped!") } }) + POST.HandleFunc("/playlist/schedule", func(w http.ResponseWriter, r *http.Request) { + var ( + timestamp = r.FormValue("time") + days = r.Form["day"] + ) + + // Validate the inputs. + dt, err := time.Parse("15:04", timestamp) + if err != nil { + s.Abort(r, w, "Invalid time stamp.") + return + } + if len(days) == 0 { + s.Abort(r, w, "Select one or more days!") + return + } + + // Save the intended schedule to our config on disk. + s.Config.Hour = dt.Hour() + s.Config.Minute = dt.Minute() + s.Config.Days = days + SaveConfig(s.Config) + + // Save the crontab to disk. + err = s.SetSchedule(dt, days) + if err != nil { + s.Abort(r, w, err.Error()) + } else { + s.FlashAndRedirect(r, w, "/", "Schedule saved!") + } + }) POST.HandleFunc("/volume/higher", func(w http.ResponseWriter, r *http.Request) { err := s.VolumeUp() if err != nil { diff --git a/sessions.go b/sessions.go index 2c9f629..90ac4f6 100644 --- a/sessions.go +++ b/sessions.go @@ -1,10 +1,20 @@ package sonar -import "net/http" +import ( + "fmt" + "net/http" +) + +// Abort with an error. +func (s *Sonar) Abort(r *http.Request, w http.ResponseWriter, message string, v ...interface{}) { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, message, v...) +} // FlashAndRedirect to flash and redirect away. -func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...string) { - s.Flash(r, w, flash, v...) +func (s *Sonar) FlashAndRedirect(r *http.Request, w http.ResponseWriter, url string, flash string, v ...interface{}) { + log.Debug("Flash: %s", fmt.Sprintf(flash, v...)) + s.Flash(r, w, fmt.Sprintf(flash, v...)) w.Header().Add("Location", url) w.WriteHeader(http.StatusFound) } diff --git a/sonar.go b/sonar.go index 40f194c..a89ea1b 100644 --- a/sonar.go +++ b/sonar.go @@ -19,6 +19,7 @@ type Sonar struct { Debug bool Player *Player Config *Config + url string n *negroni.Negroni router *mux.Router @@ -50,11 +51,10 @@ func New() *Sonar { // ListenAndServe starts the HTTP app. func (s *Sonar) ListenAndServe(address string) error { - label := address - if strings.HasPrefix(label, ":") { - label = "0.0.0.0" + label + s.url = address + if strings.HasPrefix(s.url, ":") { + s.url = "0.0.0.0" + s.url } - log.Info("Listening at http://%s", label) - + log.Info("Listening at http://%s", s.url) return http.ListenAndServe(address, s.n) } diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..d633111 --- /dev/null +++ b/utils.go @@ -0,0 +1,43 @@ +package sonar + +import "strconv" + +// Set is a lookup set of strings. +type Set map[string]interface{} + +// NewSet makes a new Set from a list of strings. +func NewSet(input []string) Set { + set := Set{} + for _, v := range input { + set[v] = nil + } + return set +} + +// In queries if the value is in the set. +func (s Set) In(v string) bool { + _, ok := s[v] + return ok +} + +// SliceToSet makes a slice into a set map. +func SliceToSet(slice []string) map[string]bool { + var result = make(map[string]bool) + for _, v := range slice { + result[v] = true + } + return result +} + +// Str2IntSlice converts a string slice to an int slice. +func Str2IntSlice(str []string) ([]int, error) { + result := make([]int, len(str)) + for i, literal := range str { + val, err := strconv.Atoi(literal) + if err != nil { + return result, err + } + result[i] = val + } + return result, nil +} diff --git a/www/index.gohtml b/www/index.gohtml index f0167fc..6d5d814 100644 --- a/www/index.gohtml +++ b/www/index.gohtml @@ -90,52 +90,52 @@ Schedule
-

- See the current schedule -

+
+ - +
+