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..72c3aa0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,143 @@ # 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 +``` + +# 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) + +# 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" + ] +} +``` + +## 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 +``` + +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/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 -

+
+ - +
+