Crontab installation support

This commit is contained in:
Noah 2018-10-24 10:12:43 -07:00
parent e1fa2f35f2
commit 3e8b817796
16 changed files with 478 additions and 37 deletions

4
.gitignore vendored
View File

@ -1 +1,5 @@
bin/
dist/
sonar.pi/
media/
crontab.in/

View File

@ -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:

169
README.md
View File

@ -1,3 +1,170 @@
# sonar
Raspberry Pi alarm clock server.
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.

View File

@ -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

View File

@ -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 <SINK> -<INT>%
// 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)

78
cron.go Normal file
View File

@ -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
}

View File

@ -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

6
etc/supervisor.conf Normal file
View File

@ -0,0 +1,6 @@
[program:sonar]
command = /home/kirsle/sonar.pi/sonar
directory = /home/kirsle/sonar.pi
user = kirsle
# vim:ft=dosini

View File

@ -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)
}

View File

@ -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 {

BIN
screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -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)
}

View File

@ -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)
}

43
utils.go Normal file
View File

@ -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
}

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>time.caskir.net</title>
<title>Sonar</title>
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<meta http-equiv="content-type" value="text/html; encoding=UTF-8">
<link rel="stylesheet" href="/css/bootstrap.min.css">
@ -19,8 +19,6 @@
<div class="container">
<div class="row">
<div class="col">
<h1>time.caskir.net</h1>
{{ range .Flashes }}
<div class="alert alert-info">{{ . }}</div>
{{ end }}
@ -60,7 +58,7 @@
<div class="col">
<div class="card mb-4">
<div class="card-header">
Alan Watts Alarm Clock
Alarm Clock Playlist
</div>
<div class="card-body">
<p>
@ -90,52 +88,52 @@
Schedule
</div>
<div class="card-body">
<p>
<a href="/crontab.txt">See the current schedule</a>
</p>
<form method="POST" action="/playlist/schedule">
<label for="time">Time:</label>
<input type="time" name="time" class="form-control mb-2" value="05:30">
<input type="time" name="time" class="form-control mb-2" value="{{ printf "%02d" .Hour }}:{{ .Minute }}">
<label>Days of week:</label>
<ul class="list-unstyled">
<li>
<label>
<input type="checkbox" name="day" value="0"> Sunday
<input type="checkbox" name="day" value="0"{{ if .Days.In "0" }} checked{{ end }}> Sunday
</label>
</li>
<li>
<label>
<input type="checkbox" name="day" value="1" checked> Monday
<input type="checkbox" name="day" value="1"{{ if .Days.In "1" }} checked{{ end }}> Monday
</label>
</li>
<li>
<label>
<input type="checkbox" name="day" value="2" checked> Tuesday
<input type="checkbox" name="day" value="2"{{ if .Days.In "2" }} checked{{ end }}> Tuesday
</label>
</li>
<li>
<label>
<input type="checkbox" name="day" value="3" checked> Wednesday
<input type="checkbox" name="day" value="3"{{ if .Days.In "3" }} checked{{ end }}> Wednesday
</label>
</li>
<li>
<label>
<input type="checkbox" name="day" value="4" checked> Thursday
<input type="checkbox" name="day" value="4"{{ if .Days.In "4" }} checked{{ end }}> Thursday
</label>
</li>
<li>
<label>
<input type="checkbox" name="day" value="5" checked> Friday
<input type="checkbox" name="day" value="5"{{ if .Days.In "5" }} checked{{ end }}> Friday
</label>
</li>
<li>
<label>
<input type="checkbox" name="day" value="6"> Saturday
<input type="checkbox" name="day" value="6"{{ if .Days.In "6" }} checked{{ end }}> Saturday
</label>
</li>
</ul>
<button name="action" value="schedule" class="form-control btn btn-primary">Set Schedule</button>
</form>
</div>
</div>
</div>