From 3e8b817796e6ae155624856bd324fb562f2d410b Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Wed, 24 Oct 2018 10:12:43 -0700 Subject: [PATCH] Crontab installation support --- .gitignore | 4 + Makefile | 20 +++++ README.md | 169 ++++++++++++++++++++++++++++++++++++- cmd/sonar/main.go | 13 +++ config.go | 11 +++ cron.go | 78 +++++++++++++++++ crontab.in/000-header.cron | 22 +++++ etc/supervisor.conf | 6 ++ music.go | 47 ++++++++--- routes.go | 45 ++++++++++ screenshot1.png | Bin 0 -> 15402 bytes screenshot2.png | Bin 0 -> 18333 bytes sessions.go | 16 +++- sonar.go | 13 +-- utils.go | 43 ++++++++++ www/index.gohtml | 28 +++--- 16 files changed, 478 insertions(+), 37 deletions(-) create mode 100644 cron.go create mode 100644 crontab.in/000-header.cron create mode 100644 etc/supervisor.conf create mode 100644 screenshot1.png create mode 100644 screenshot2.png create mode 100644 utils.go diff --git a/.gitignore b/.gitignore index 07129c0..4333075 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ +bin/ +dist/ +sonar.pi/ media/ +crontab.in/ diff --git a/Makefile b/Makefile index ead75e3..c4ed3b2 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,26 @@ build: gofmt -w . go build $(LDFLAGS) -i -o bin/sonar cmd/sonar/main.go +# `make dist` makes a distribution bundle. +.PHONY: dist +dist: + rm -rf dist + mkdir dist + cp -r crontab.in www dist/ + go build $(LDFLAGS) -i -o dist/sonar cmd/sonar/main.go + cd dist + tar -czvf sonar-$(VERSION).tar.gz * + mv *.tar.gz ../ + +# `make pi` makes a distribution for the Raspberry Pi. +.PHONY: pi +pi: + rm -rf sonar.pi + mkdir sonar.pi + cp -r crontab.in www sonar.pi/ + GOOS=linux GOARCH=arm go build $(LDFLAGS) -i -o sonar.pi/sonar cmd/sonar/main.go + zip -r sonar-pi.zip sonar.pi + # `make run` to run it in debug mode. .PHONY: run run: diff --git a/README.md b/README.md index be72a51..239b38a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,170 @@ # 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 +``` + +# Screenshots + +![screenshot1.png](screenshot1.png)![screenshot2.png](screenshot2.png) + +# How It Works + +The config file specifies the shell commands to run to launch your media player, +volume changing commands, etc. + +When the playlist starts, the Go app shuffles the files in your media folder +and feeds them one by one to your media player command (`mplayer` by default). +To stop the playlist, it kills the current mplayer task and stops. + +When you save a schedule for the alarm clock, it will create and install a +crontab entry for the user running the app. The cron entry hits the API server +to start the playlist at the desired time, and then, an hour later, it stops +it the same way. + +## 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. + +# Installation + +## Supervisor + +There's an example supervisor config in the `etc/` folder. + +Add it to supervisor and put nginx in front with Basic Auth. + +# Makefile + +* `make setup` to fetch dependencies. +* `make build` to build the binary to `bin/` +* `make dist` to build a distribution for your current setup +* `make run` to run it in debug mode +* `make watch` to run it in debug mode, auto-reloading (sometimes flaky control over mplayer tho!) +* `make pi` to build a zipped distribution for Raspberry Pi. + See [Cross Compile for Raspberry Pi](#cross-compile-for-raspberry-pi) + +# 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" + ] +} +``` + +## Cross Compile for Raspberry Pi + +Use the `make pi` command to build a distribution for Raspberry Pi. + +If you get permission errors when trying to download the standard library for +ARM64, make and chown the folders as a workaround: + +```bash +sudo mkdir /usr/lib/golang/pkg/linux_arm +sudo chown kirsle:kirsle /usr/lib/golang/pkg/linux_arm +make pi +rsync -av sonar.pi 192.168.0.102: +``` + +It outputs a `sonar-pi.zip` that you can scp over and run. + +# License + +Noah Petherbridge © 2018 + +GPLv2. diff --git a/cmd/sonar/main.go b/cmd/sonar/main.go index 404794f..09ab552 100644 --- a/cmd/sonar/main.go +++ b/cmd/sonar/main.go @@ -2,25 +2,38 @@ package main import ( "flag" + "fmt" "math/rand" + "os" "time" "git.kirsle.net/apps/sonar" "github.com/kirsle/golog" ) +// Build hash. +var Build = "N/A" + var debug bool var listen string +var version bool func init() { rand.Seed(time.Now().UnixNano()) flag.StringVar(&listen, "listen", "127.0.0.1:8000", "Interface to listen on, default localhost only") flag.BoolVar(&debug, "debug", false, "Debug level logging") + flag.BoolVar(&version, "version", false, "Version number") + flag.BoolVar(&version, "v", false, "Version number (alias)") } func main() { flag.Parse() + if version { + fmt.Printf("sonar v%s build %s\n", sonar.Version, Build) + os.Exit(0) + } + if debug { log := golog.GetLogger("sonar") log.Config.Level = golog.DebugLevel diff --git a/config.go b/config.go index 8f27f7f..94592e7 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 @@ -65,6 +75,7 @@ func LoadConfig() *Config { } defer fh.Close() + log.Info("Reading config from %s", filepath) var c *Config decoder := json.NewDecoder(fh) decoder.Decode(&c) 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/etc/supervisor.conf b/etc/supervisor.conf new file mode 100644 index 0000000..7bc2a23 --- /dev/null +++ b/etc/supervisor.conf @@ -0,0 +1,6 @@ +[program:sonar] +command = /home/kirsle/sonar.pi/sonar +directory = /home/kirsle/sonar.pi +user = kirsle + +# vim:ft=dosini diff --git a/music.go b/music.go index 290aeee..3a02113 100644 --- a/music.go +++ b/music.go @@ -3,10 +3,12 @@ package sonar import ( "errors" "fmt" + "math/rand" "os/exec" "path/filepath" "strconv" "strings" + "time" ) // Player manages playing music one track at a time. @@ -16,6 +18,26 @@ type Player struct { cmd *exec.Cmd } +// Watch the running playlist to advance the next track. +func (s *Sonar) Watch() { + var p = s.Player + for { + if p.Running() { + if err := p.cmd.Wait(); err != nil { + log.Error("Player.Watch: p.cmd.Wait: %s", err) + } + p.cmd = nil + + // Next track? + log.Info("Track ended, play next") + if err := s.PlayNext(); err != nil { + log.Info("EOF: %s", err) + } + } + time.Sleep(1 * time.Second) + } +} + // Run the playlist. func (p *Player) Run(playlist []string) { p.playlist = playlist @@ -34,15 +56,17 @@ func (p *Player) Stop() error { } p.cmd = nil + p.playlist = []string{} p.index = 0 return nil } // Next returns the next song. func (p *Player) Next() (string, bool) { - p.index++ if p.index < len(p.playlist) { - return p.playlist[p.index], true + next := p.playlist[p.index] + p.index++ + return next, true } return "", false } @@ -56,23 +80,18 @@ func (p *Player) Status() string { var ( index = p.index length = len(p.playlist) - pct float64 ) - if length > 0 { - pct = float64(index / length) - } parts = append(parts, fmt.Sprintf( - "Track %d of %d (%.2f%%)", + "Track %d of %d", index, length, - pct, )) // Append current track. - if index < len(p.playlist) { + if index <= len(p.playlist) { parts = append(parts, fmt.Sprintf( "Now playing: %s", - p.playlist[index], + p.playlist[index-1], )) } } else { @@ -136,9 +155,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) @@ -176,5 +196,6 @@ func (s *Sonar) PlayNext() error { if !ok { return errors.New("end of playlist") } + s.Player.Stop() return s.Play(filename) } diff --git a/routes.go b/routes.go index e1fc43d..e98c93e 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,45 @@ func (s *Sonar) Register() *mux.Router { s.FlashAndRedirect(r, w, "/", "Playlist stopped!") } }) + POST.HandleFunc("/playlist/next", func(w http.ResponseWriter, r *http.Request) { + err := s.PlayNext() + if err != nil { + s.FlashAndRedirect(r, w, "/", "Error: "+err.Error()) + } else { + s.FlashAndRedirect(r, w, "/", "Playlist advanced!") + } + }) + 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/screenshot1.png b/screenshot1.png new file mode 100644 index 0000000000000000000000000000000000000000..dfc8c19ef7204408cd2330a8dfc709a0f98302f9 GIT binary patch literal 15402 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 K8rerJ@BA;rUz&~p literal 0 HcmV?d00001 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..5531c92 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 @@ -45,16 +46,18 @@ func New() *Sonar { ) s.n.UseHandler(router) + // Watch the player to advance to the next track. + go s.Watch() + return s } // 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..e990d15 100644 --- a/www/index.gohtml +++ b/www/index.gohtml @@ -1,7 +1,7 @@ - time.caskir.net + Sonar @@ -19,8 +19,6 @@
-

time.caskir.net

- {{ range .Flashes }}
{{ . }}
{{ end }} @@ -60,7 +58,7 @@
- Alan Watts Alarm Clock + Alarm Clock Playlist

@@ -90,52 +88,52 @@ Schedule

-

- See the current schedule -

+
+ - +
+