Crontab installation support

Noah 2018-10-24 12:59:43 -07:00
parent e1fa2f35f2
commit 2a5c5428d0
11 changed files with 341 additions and 23 deletions

1
.gitignore vendored
View File

@ -1 +1,2 @@
media/
crontab.in/

117
README.md
View File

@ -1,3 +1,118 @@
# 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
```
# 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.

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

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

View File

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

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

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

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

@ -90,52 +90,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="{{ .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>