Compare commits
5 Commits
d1307a75b9
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f4eccec652 | |||
| 621815ed0f | |||
| cdcdf2c644 | |||
| d3f29e3927 | |||
| b309ccd9cd |
280
api/api.go
Normal file
280
api/api.go
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"woke/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notifier is called after mutating operations to notify the TUI.
|
||||||
|
type Notifier func()
|
||||||
|
|
||||||
|
// Server provides a REST API for alarm CRUD.
|
||||||
|
type Server struct {
|
||||||
|
store *db.Store
|
||||||
|
mux *http.ServeMux
|
||||||
|
notify Notifier
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(store *db.Store, password string, notify Notifier) *Server {
|
||||||
|
s := &Server{store: store, notify: notify, password: password}
|
||||||
|
s.mux = http.NewServeMux()
|
||||||
|
s.mux.HandleFunc("GET /api/alarms", s.listAlarms)
|
||||||
|
s.mux.HandleFunc("GET /api/alarms/{id}", s.getAlarm)
|
||||||
|
s.mux.HandleFunc("POST /api/alarms", s.createAlarm)
|
||||||
|
s.mux.HandleFunc("PUT /api/alarms/{id}", s.updateAlarm)
|
||||||
|
s.mux.HandleFunc("DELETE /api/alarms/{id}", s.deleteAlarm)
|
||||||
|
s.mux.HandleFunc("PATCH /api/alarms/{id}/toggle", s.toggleAlarm)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
var handler http.Handler = s.mux
|
||||||
|
|
||||||
|
// Wrap with auth middleware if password is set
|
||||||
|
if s.password != "" {
|
||||||
|
authHandler := handler
|
||||||
|
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(auth, "Bearer ") || auth[7:] != s.password {
|
||||||
|
writeError(w, http.StatusUnauthorized, "invalid or missing authorization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authHandler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap with CORS middleware (allow all origins)
|
||||||
|
corsHandler := handler
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
|
||||||
|
// Handle preflight requests
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
corsHandler.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type alarmRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
SoundPath string `json:"sound_path"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type alarmResponse struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
Trigger string `json:"trigger"`
|
||||||
|
SoundPath string `json:"sound_path"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
LastTriggered *string `json:"last_triggered"`
|
||||||
|
SnoozeCount int `json:"snooze_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func toResponse(a db.Alarm) alarmResponse {
|
||||||
|
r := alarmResponse{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
Description: a.Description,
|
||||||
|
Time: a.Time,
|
||||||
|
Trigger: a.Trigger,
|
||||||
|
SoundPath: a.SoundPath,
|
||||||
|
Enabled: a.Enabled,
|
||||||
|
CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
UpdatedAt: a.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||||
|
SnoozeCount: a.SnoozeCount,
|
||||||
|
}
|
||||||
|
if a.LastTriggered != nil {
|
||||||
|
t := a.LastTriggered.Format("2006-01-02T15:04:05Z")
|
||||||
|
r.LastTriggered = &t
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listAlarms(w http.ResponseWriter, r *http.Request) {
|
||||||
|
alarms, err := s.store.ListAlarms()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to list alarms")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := make([]alarmResponse, len(alarms))
|
||||||
|
for i, a := range alarms {
|
||||||
|
resp[i] = toResponse(a)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getAlarm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alarm, err := s.store.GetAlarm(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "alarm not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, toResponse(alarm))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createAlarm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req alarmRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Trigger == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "trigger is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
alarm := db.Alarm{
|
||||||
|
Name: req.Name,
|
||||||
|
Description: req.Description,
|
||||||
|
Time: req.Time,
|
||||||
|
Trigger: req.Trigger,
|
||||||
|
SoundPath: req.SoundPath,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if alarm.SoundPath == "" {
|
||||||
|
alarm.SoundPath = "default"
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
alarm.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := s.store.CreateAlarm(alarm)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to create alarm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.store.GetAlarm(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to read created alarm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify()
|
||||||
|
writeJSON(w, http.StatusCreated, toResponse(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateAlarm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.store.GetAlarm(id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "alarm not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req alarmRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name != "" {
|
||||||
|
existing.Name = req.Name
|
||||||
|
}
|
||||||
|
if req.Description != "" {
|
||||||
|
existing.Description = req.Description
|
||||||
|
}
|
||||||
|
if req.Time != "" {
|
||||||
|
existing.Time = req.Time
|
||||||
|
}
|
||||||
|
if req.Trigger != "" {
|
||||||
|
existing.Trigger = req.Trigger
|
||||||
|
}
|
||||||
|
if req.SoundPath != "" {
|
||||||
|
existing.SoundPath = req.SoundPath
|
||||||
|
}
|
||||||
|
if req.Enabled != nil {
|
||||||
|
existing.Enabled = *req.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.UpdateAlarm(existing); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to update alarm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, _ := s.store.GetAlarm(id)
|
||||||
|
s.notify()
|
||||||
|
writeJSON(w, http.StatusOK, toResponse(updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) deleteAlarm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.GetAlarm(id); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "alarm not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.DeleteAlarm(id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to delete alarm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.notify()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) toggleAlarm(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := strconv.Atoi(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.store.GetAlarm(id); err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "alarm not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.ToggleAlarm(id); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "failed to toggle alarm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toggled, _ := s.store.GetAlarm(id)
|
||||||
|
s.notify()
|
||||||
|
writeJSON(w, http.StatusOK, toResponse(toggled))
|
||||||
|
}
|
||||||
192
api/client.go
Normal file
192
api/client.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
"woke/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client implements db.AlarmStore via HTTP calls to a remote woke server.
|
||||||
|
type Client struct {
|
||||||
|
baseURL string
|
||||||
|
password string
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClient(baseURL, password string) *Client {
|
||||||
|
return &Client{
|
||||||
|
baseURL: baseURL,
|
||||||
|
password: password,
|
||||||
|
http: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) do(method, path string, body any) (*http.Response, error) {
|
||||||
|
var reqBody *bytes.Buffer
|
||||||
|
if body != nil {
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reqBody = bytes.NewBuffer(data)
|
||||||
|
} else {
|
||||||
|
reqBody = &bytes.Buffer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, c.baseURL+path, reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if c.password != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+c.password)
|
||||||
|
}
|
||||||
|
return c.http.Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ListAlarms() ([]db.Alarm, error) {
|
||||||
|
resp, err := c.do("GET", "/api/alarms", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var alarms []alarmResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&alarms); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]db.Alarm, len(alarms))
|
||||||
|
for i, a := range alarms {
|
||||||
|
result[i] = a.toAlarm()
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetAlarm(id int) (db.Alarm, error) {
|
||||||
|
resp, err := c.do("GET", fmt.Sprintf("/api/alarms/%d", id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return db.Alarm{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return db.Alarm{}, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var a alarmResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&a); err != nil {
|
||||||
|
return db.Alarm{}, err
|
||||||
|
}
|
||||||
|
return a.toAlarm(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) CreateAlarm(a db.Alarm) (int, error) {
|
||||||
|
req := alarmRequest{
|
||||||
|
Name: a.Name,
|
||||||
|
Description: a.Description,
|
||||||
|
Time: a.Time,
|
||||||
|
Trigger: a.Trigger,
|
||||||
|
SoundPath: a.SoundPath,
|
||||||
|
Enabled: &a.Enabled,
|
||||||
|
}
|
||||||
|
resp, err := c.do("POST", "/api/alarms", req)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusCreated {
|
||||||
|
return 0, fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var created alarmResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&created); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return created.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) UpdateAlarm(a db.Alarm) error {
|
||||||
|
req := alarmRequest{
|
||||||
|
Name: a.Name,
|
||||||
|
Description: a.Description,
|
||||||
|
Time: a.Time,
|
||||||
|
Trigger: a.Trigger,
|
||||||
|
SoundPath: a.SoundPath,
|
||||||
|
Enabled: &a.Enabled,
|
||||||
|
}
|
||||||
|
resp, err := c.do("PUT", fmt.Sprintf("/api/alarms/%d", a.ID), req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DeleteAlarm(id int) error {
|
||||||
|
resp, err := c.do("DELETE", fmt.Sprintf("/api/alarms/%d", id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNoContent {
|
||||||
|
return fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ToggleAlarm(id int) error {
|
||||||
|
resp, err := c.do("PATCH", fmt.Sprintf("/api/alarms/%d/toggle", id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("server returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) MarkTriggered(id int) error {
|
||||||
|
// Client mode doesn't mark triggered — server handles this
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a alarmResponse) toAlarm() db.Alarm {
|
||||||
|
alarm := db.Alarm{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
Description: a.Description,
|
||||||
|
Time: a.Time,
|
||||||
|
Trigger: a.Trigger,
|
||||||
|
SoundPath: a.SoundPath,
|
||||||
|
Enabled: a.Enabled,
|
||||||
|
SnoozeCount: a.SnoozeCount,
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05Z", a.CreatedAt); err == nil {
|
||||||
|
alarm.CreatedAt = t
|
||||||
|
}
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05Z", a.UpdatedAt); err == nil {
|
||||||
|
alarm.UpdatedAt = t
|
||||||
|
}
|
||||||
|
if a.LastTriggered != nil {
|
||||||
|
if t, err := time.Parse("2006-01-02T15:04:05Z", *a.LastTriggered); err == nil {
|
||||||
|
alarm.LastTriggered = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alarm
|
||||||
|
}
|
||||||
46
db/db.go
46
db/db.go
@@ -200,6 +200,22 @@ type Settings struct {
|
|||||||
BlinkOffMs int
|
BlinkOffMs int
|
||||||
ColorClock string
|
ColorClock string
|
||||||
ColorAlarm string
|
ColorAlarm string
|
||||||
|
ShowSeconds bool
|
||||||
|
|
||||||
|
// Client/server mode
|
||||||
|
ServerURL string // If set, run as client connecting to this URL
|
||||||
|
ServerPassword string // Password for authenticating to remote server
|
||||||
|
APIPassword string // Password required for incoming API requests (host mode)
|
||||||
|
PollSeconds int // How often to poll server in client mode
|
||||||
|
|
||||||
|
// Crescendo volume control (PulseAudio)
|
||||||
|
CrescendoEnabled bool // Whether to gradually raise volume
|
||||||
|
CrescendoStartPct int // Starting volume percentage (0-100)
|
||||||
|
CrescendoEndPct int // Ending volume percentage (0-100)
|
||||||
|
CrescendoDurationS int // Seconds to ramp from start to end
|
||||||
|
|
||||||
|
// Callback script
|
||||||
|
CallbackScript string // Path to script called on alarm events (start, dismiss, snooze)
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultSettings() Settings {
|
func DefaultSettings() Settings {
|
||||||
@@ -211,6 +227,16 @@ func DefaultSettings() Settings {
|
|||||||
BlinkOffMs: 500,
|
BlinkOffMs: 500,
|
||||||
ColorClock: "#00FF88",
|
ColorClock: "#00FF88",
|
||||||
ColorAlarm: "#FF4444",
|
ColorAlarm: "#FF4444",
|
||||||
|
ShowSeconds: true,
|
||||||
|
ServerURL: "",
|
||||||
|
ServerPassword: "",
|
||||||
|
APIPassword: "",
|
||||||
|
PollSeconds: 5,
|
||||||
|
CrescendoEnabled: false,
|
||||||
|
CrescendoStartPct: 20,
|
||||||
|
CrescendoEndPct: 100,
|
||||||
|
CrescendoDurationS: 60,
|
||||||
|
CallbackScript: "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,6 +249,16 @@ func (s *Store) LoadSettings() Settings {
|
|||||||
cfg.BlinkOffMs = s.getSettingInt("blink_off_ms", cfg.BlinkOffMs)
|
cfg.BlinkOffMs = s.getSettingInt("blink_off_ms", cfg.BlinkOffMs)
|
||||||
cfg.ColorClock = s.GetSetting("color_clock", cfg.ColorClock)
|
cfg.ColorClock = s.GetSetting("color_clock", cfg.ColorClock)
|
||||||
cfg.ColorAlarm = s.GetSetting("color_alarm", cfg.ColorAlarm)
|
cfg.ColorAlarm = s.GetSetting("color_alarm", cfg.ColorAlarm)
|
||||||
|
cfg.ShowSeconds = s.GetSetting("show_seconds", "true") == "true"
|
||||||
|
cfg.ServerURL = s.GetSetting("server_url", cfg.ServerURL)
|
||||||
|
cfg.ServerPassword = s.GetSetting("server_password", cfg.ServerPassword)
|
||||||
|
cfg.APIPassword = s.GetSetting("api_password", cfg.APIPassword)
|
||||||
|
cfg.PollSeconds = s.getSettingInt("poll_seconds", cfg.PollSeconds)
|
||||||
|
cfg.CrescendoEnabled = s.GetSetting("crescendo_enabled", "false") == "true"
|
||||||
|
cfg.CrescendoStartPct = s.getSettingInt("crescendo_start_pct", cfg.CrescendoStartPct)
|
||||||
|
cfg.CrescendoEndPct = s.getSettingInt("crescendo_end_pct", cfg.CrescendoEndPct)
|
||||||
|
cfg.CrescendoDurationS = s.getSettingInt("crescendo_duration_s", cfg.CrescendoDurationS)
|
||||||
|
cfg.CallbackScript = s.GetSetting("callback_script", cfg.CallbackScript)
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +271,16 @@ func (s *Store) SaveSettings(cfg Settings) error {
|
|||||||
"blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs),
|
"blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs),
|
||||||
"color_clock": cfg.ColorClock,
|
"color_clock": cfg.ColorClock,
|
||||||
"color_alarm": cfg.ColorAlarm,
|
"color_alarm": cfg.ColorAlarm,
|
||||||
|
"show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds),
|
||||||
|
"server_url": cfg.ServerURL,
|
||||||
|
"server_password": cfg.ServerPassword,
|
||||||
|
"api_password": cfg.APIPassword,
|
||||||
|
"poll_seconds": fmt.Sprintf("%d", cfg.PollSeconds),
|
||||||
|
"crescendo_enabled": fmt.Sprintf("%t", cfg.CrescendoEnabled),
|
||||||
|
"crescendo_start_pct": fmt.Sprintf("%d", cfg.CrescendoStartPct),
|
||||||
|
"crescendo_end_pct": fmt.Sprintf("%d", cfg.CrescendoEndPct),
|
||||||
|
"crescendo_duration_s": fmt.Sprintf("%d", cfg.CrescendoDurationS),
|
||||||
|
"callback_script": cfg.CallbackScript,
|
||||||
}
|
}
|
||||||
for k, v := range pairs {
|
for k, v := range pairs {
|
||||||
if err := s.SetSetting(k, v); err != nil {
|
if err := s.SetSetting(k, v); err != nil {
|
||||||
|
|||||||
13
db/interface.go
Normal file
13
db/interface.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
// AlarmStore defines the interface for alarm CRUD operations.
|
||||||
|
// Implemented by *Store (local SQLite) and api.Client (remote HTTP).
|
||||||
|
type AlarmStore interface {
|
||||||
|
ListAlarms() ([]Alarm, error)
|
||||||
|
GetAlarm(id int) (Alarm, error)
|
||||||
|
CreateAlarm(a Alarm) (int, error)
|
||||||
|
UpdateAlarm(a Alarm) error
|
||||||
|
DeleteAlarm(id int) error
|
||||||
|
ToggleAlarm(id int) error
|
||||||
|
MarkTriggered(id int) error
|
||||||
|
}
|
||||||
1004
index.html
Normal file
1004
index.html
Normal file
File diff suppressed because it is too large
Load Diff
94
main.go
94
main.go
@@ -2,7 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"woke/api"
|
||||||
"woke/db"
|
"woke/db"
|
||||||
"woke/player"
|
"woke/player"
|
||||||
"woke/scheduler"
|
"woke/scheduler"
|
||||||
@@ -11,7 +13,71 @@ import (
|
|||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const helpText = `woke - TUI alarm clock with REST API
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
woke Start the TUI (API server runs on :9119 in host mode)
|
||||||
|
woke --help Show this help
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
Host mode (default): Uses local SQLite, exposes REST API on :9119
|
||||||
|
Client mode: Connects to remote woke server, syncs alarms, fires locally
|
||||||
|
|
||||||
|
Configure mode in Settings (press 'c'):
|
||||||
|
- Server URL: empty = host mode, or http://host:9119 for client mode
|
||||||
|
- Server pass: password to authenticate with remote server
|
||||||
|
- API password: password required for incoming API requests (host mode)
|
||||||
|
- Poll seconds: how often client polls server for alarm updates
|
||||||
|
|
||||||
|
API endpoints (http://localhost:9119):
|
||||||
|
|
||||||
|
List alarms:
|
||||||
|
curl http://localhost:9119/api/alarms
|
||||||
|
|
||||||
|
Get alarm:
|
||||||
|
curl http://localhost:9119/api/alarms/1
|
||||||
|
|
||||||
|
Create alarm (one-shot):
|
||||||
|
curl -X POST http://localhost:9119/api/alarms \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name": "Standup", "time": "09:00", "trigger": "once"}'
|
||||||
|
|
||||||
|
Create alarm (cron, weekdays at 7:30):
|
||||||
|
curl -X POST http://localhost:9119/api/alarms \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name": "Morning", "time": "07:30", "trigger": "30 7 * * 1-5"}'
|
||||||
|
|
||||||
|
Update alarm:
|
||||||
|
curl -X PUT http://localhost:9119/api/alarms/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name": "New name", "time": "08:00"}'
|
||||||
|
|
||||||
|
Toggle alarm on/off:
|
||||||
|
curl -X PATCH http://localhost:9119/api/alarms/1/toggle
|
||||||
|
|
||||||
|
Delete alarm:
|
||||||
|
curl -X DELETE http://localhost:9119/api/alarms/1
|
||||||
|
|
||||||
|
With authentication (if API password is set):
|
||||||
|
curl -H 'Authorization: Bearer yourpassword' http://localhost:9119/api/alarms
|
||||||
|
|
||||||
|
TUI keybindings:
|
||||||
|
j/k Navigate alarms
|
||||||
|
a Add alarm
|
||||||
|
e Edit alarm
|
||||||
|
d Delete alarm
|
||||||
|
space Toggle enabled
|
||||||
|
c Settings
|
||||||
|
q Quit
|
||||||
|
`
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
if len(os.Args) > 1 && (os.Args[1] == "--help" || os.Args[1] == "-h") {
|
||||||
|
fmt.Print(helpText)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always open local store for settings
|
||||||
store, err := db.Open()
|
store, err := db.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err)
|
||||||
@@ -19,14 +85,38 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
|
settings := store.LoadSettings()
|
||||||
|
clientMode := settings.ServerURL != ""
|
||||||
|
|
||||||
|
// Determine alarm store: local DB or remote API
|
||||||
|
var alarmStore db.AlarmStore
|
||||||
|
if clientMode {
|
||||||
|
alarmStore = api.NewClient(settings.ServerURL, settings.ServerPassword)
|
||||||
|
fmt.Fprintf(os.Stderr, "Client mode: connecting to %s\n", settings.ServerURL)
|
||||||
|
} else {
|
||||||
|
alarmStore = store
|
||||||
|
}
|
||||||
|
|
||||||
pl := player.New()
|
pl := player.New()
|
||||||
sched := scheduler.New(store)
|
sched := scheduler.New(alarmStore, clientMode)
|
||||||
sched.Start()
|
sched.Start()
|
||||||
defer sched.Stop()
|
defer sched.Stop()
|
||||||
|
|
||||||
model := ui.NewModel(store, sched, pl)
|
model := ui.NewModel(store, alarmStore, sched, pl, clientMode)
|
||||||
p := tea.NewProgram(model, tea.WithAltScreen())
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
||||||
|
|
||||||
|
// Start HTTP API server only in host mode
|
||||||
|
if !clientMode {
|
||||||
|
srv := api.New(store, settings.APIPassword, func() { p.Send(ui.AlarmsChangedMsg{}) })
|
||||||
|
go func() {
|
||||||
|
addr := ":9119"
|
||||||
|
fmt.Fprintf(os.Stderr, "API server listening on %s\n", addr)
|
||||||
|
if err := http.ListenAndServe(addr, srv.Handler()); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "API server error: %v\n", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
package player
|
package player
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// CrescendoConfig holds volume ramp settings.
|
||||||
|
type CrescendoConfig struct {
|
||||||
|
Enabled bool
|
||||||
|
StartPct int // 0-100
|
||||||
|
EndPct int // 0-100
|
||||||
|
DurationS int // seconds to ramp
|
||||||
|
}
|
||||||
|
|
||||||
// Player handles alarm sound playback by shelling out to system audio tools.
|
// Player handles alarm sound playback by shelling out to system audio tools.
|
||||||
type Player struct {
|
type Player struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
crescStopCh chan struct{}
|
||||||
|
crescDoneCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Player {
|
func New() *Player {
|
||||||
@@ -60,6 +71,11 @@ func findPlayer() (string, func(string) []string) {
|
|||||||
|
|
||||||
// PlayLoop starts playing a sound file in a loop until Stop() is called.
|
// PlayLoop starts playing a sound file in a loop until Stop() is called.
|
||||||
func (p *Player) PlayLoop(path string) {
|
func (p *Player) PlayLoop(path string) {
|
||||||
|
p.PlayLoopWithCrescendo(path, CrescendoConfig{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlayLoopWithCrescendo starts playing with optional volume ramp.
|
||||||
|
func (p *Player) PlayLoopWithCrescendo(path string, cresc CrescendoConfig) {
|
||||||
p.Stop() // kill any previous playback and wait for it to finish
|
p.Stop() // kill any previous playback and wait for it to finish
|
||||||
|
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
@@ -69,6 +85,19 @@ func (p *Player) PlayLoop(path string) {
|
|||||||
doneCh := p.doneCh
|
doneCh := p.doneCh
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Start crescendo if enabled and pactl is available
|
||||||
|
if cresc.Enabled && cresc.DurationS > 0 {
|
||||||
|
if _, err := exec.LookPath("pactl"); err == nil {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.crescStopCh = make(chan struct{})
|
||||||
|
p.crescDoneCh = make(chan struct{})
|
||||||
|
crescStopCh := p.crescStopCh
|
||||||
|
crescDoneCh := p.crescDoneCh
|
||||||
|
p.mu.Unlock()
|
||||||
|
go p.crescendoLoop(cresc, crescStopCh, crescDoneCh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolved := resolveSound(path)
|
resolved := resolveSound(path)
|
||||||
if resolved == "" {
|
if resolved == "" {
|
||||||
go p.bellLoop(stopCh, doneCh)
|
go p.bellLoop(stopCh, doneCh)
|
||||||
@@ -146,15 +175,74 @@ func (p *Player) bellLoop(stopCh, doneCh chan struct{}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// crescendoLoop gradually raises PulseAudio volume from StartPct to EndPct over DurationS seconds.
|
||||||
|
func (p *Player) crescendoLoop(cfg CrescendoConfig, stopCh, doneCh chan struct{}) {
|
||||||
|
defer close(doneCh)
|
||||||
|
|
||||||
|
startPct := cfg.StartPct
|
||||||
|
endPct := cfg.EndPct
|
||||||
|
if startPct < 0 {
|
||||||
|
startPct = 0
|
||||||
|
}
|
||||||
|
if endPct > 150 { // allow some overdrive but not ridiculous
|
||||||
|
endPct = 150
|
||||||
|
}
|
||||||
|
if startPct >= endPct {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial volume
|
||||||
|
setVolume(startPct)
|
||||||
|
|
||||||
|
steps := cfg.DurationS
|
||||||
|
if steps < 1 {
|
||||||
|
steps = 1
|
||||||
|
}
|
||||||
|
stepSize := float64(endPct-startPct) / float64(steps)
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
currentPct := float64(startPct)
|
||||||
|
for i := 0; i < steps; i++ {
|
||||||
|
select {
|
||||||
|
case <-stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
currentPct += stepSize
|
||||||
|
setVolume(int(currentPct))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure we hit the target
|
||||||
|
setVolume(endPct)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setVolume sets the default PulseAudio sink volume to the given percentage.
|
||||||
|
func setVolume(pct int) {
|
||||||
|
_ = exec.Command("pactl", "set-sink-volume", "@DEFAULT_SINK@", fmt.Sprintf("%d%%", pct)).Run()
|
||||||
|
}
|
||||||
|
|
||||||
// Stop stops any currently playing sound and waits for the playback goroutine to exit.
|
// Stop stops any currently playing sound and waits for the playback goroutine to exit.
|
||||||
func (p *Player) Stop() {
|
func (p *Player) Stop() {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
stopCh := p.stopCh
|
stopCh := p.stopCh
|
||||||
doneCh := p.doneCh
|
doneCh := p.doneCh
|
||||||
|
crescStopCh := p.crescStopCh
|
||||||
|
crescDoneCh := p.crescDoneCh
|
||||||
p.stopCh = nil
|
p.stopCh = nil
|
||||||
p.doneCh = nil
|
p.doneCh = nil
|
||||||
|
p.crescStopCh = nil
|
||||||
|
p.crescDoneCh = nil
|
||||||
p.mu.Unlock()
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Stop crescendo
|
||||||
|
if crescStopCh != nil {
|
||||||
|
close(crescStopCh)
|
||||||
|
}
|
||||||
|
if crescDoneCh != nil {
|
||||||
|
<-crescDoneCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop audio
|
||||||
if stopCh != nil {
|
if stopCh != nil {
|
||||||
close(stopCh)
|
close(stopCh)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,18 @@ type AlarmEvent struct {
|
|||||||
|
|
||||||
// Scheduler checks alarms every second and fires events when they trigger.
|
// Scheduler checks alarms every second and fires events when they trigger.
|
||||||
type Scheduler struct {
|
type Scheduler struct {
|
||||||
store *db.Store
|
store db.AlarmStore
|
||||||
events chan AlarmEvent
|
events chan AlarmEvent
|
||||||
stop chan struct{}
|
stop chan struct{}
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
parser cron.Parser
|
parser cron.Parser
|
||||||
|
clientMode bool // If true, skip MarkTriggered/ToggleAlarm (server handles it)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(store *db.Store) *Scheduler {
|
func New(store db.AlarmStore, clientMode bool) *Scheduler {
|
||||||
return &Scheduler{
|
return &Scheduler{
|
||||||
store: store,
|
store: store,
|
||||||
|
clientMode: clientMode,
|
||||||
events: make(chan AlarmEvent, 10),
|
events: make(chan AlarmEvent, 10),
|
||||||
stop: make(chan struct{}),
|
stop: make(chan struct{}),
|
||||||
parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow),
|
parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow),
|
||||||
@@ -90,12 +92,15 @@ func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) {
|
|||||||
|
|
||||||
if s.shouldTrigger(a, now) {
|
if s.shouldTrigger(a, now) {
|
||||||
firedThisMinute[a.ID] = currentMinute
|
firedThisMinute[a.ID] = currentMinute
|
||||||
_ = s.store.MarkTriggered(a.ID)
|
|
||||||
|
|
||||||
|
// In host mode, update the DB; in client mode, server handles this
|
||||||
|
if !s.clientMode {
|
||||||
|
_ = s.store.MarkTriggered(a.ID)
|
||||||
// Disable one-shot alarms after triggering
|
// Disable one-shot alarms after triggering
|
||||||
if a.Trigger == "once" {
|
if a.Trigger == "once" {
|
||||||
_ = s.store.ToggleAlarm(a.ID)
|
_ = s.store.ToggleAlarm(a.ID)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case s.events <- AlarmEvent{Alarm: a}:
|
case s.events <- AlarmEvent{Alarm: a}:
|
||||||
|
|||||||
@@ -132,12 +132,17 @@ var bigColonBlink = []string{
|
|||||||
|
|
||||||
// RenderBigClock renders the current time as massive ASCII block digits.
|
// RenderBigClock renders the current time as massive ASCII block digits.
|
||||||
// Format: HH:MM:SS in 24h. blinkOnMs/blinkOffMs control the colon blink cycle.
|
// Format: HH:MM:SS in 24h. blinkOnMs/blinkOffMs control the colon blink cycle.
|
||||||
func RenderBigClock(t time.Time, blinkOnMs, blinkOffMs int) string {
|
func RenderBigClock(t time.Time, blinkOnMs, blinkOffMs int, showSeconds bool) string {
|
||||||
h := t.Hour()
|
h := t.Hour()
|
||||||
m := t.Minute()
|
m := t.Minute()
|
||||||
s := t.Second()
|
s := t.Second()
|
||||||
|
|
||||||
timeStr := fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
var timeStr string
|
||||||
|
if showSeconds {
|
||||||
|
timeStr = fmt.Sprintf("%02d:%02d:%02d", h, m, s)
|
||||||
|
} else {
|
||||||
|
timeStr = fmt.Sprintf("%02d:%02d", h, m)
|
||||||
|
}
|
||||||
|
|
||||||
cycle := int64(blinkOnMs + blinkOffMs)
|
cycle := int64(blinkOnMs + blinkOffMs)
|
||||||
colonVisible := t.UnixMilli()%cycle < int64(blinkOnMs)
|
colonVisible := t.UnixMilli()%cycle < int64(blinkOnMs)
|
||||||
|
|||||||
120
ui/config.go
120
ui/config.go
@@ -19,6 +19,16 @@ const (
|
|||||||
cfgBlinkOffMs
|
cfgBlinkOffMs
|
||||||
cfgColorClock
|
cfgColorClock
|
||||||
cfgColorAlarm
|
cfgColorAlarm
|
||||||
|
cfgShowSeconds
|
||||||
|
cfgServerURL
|
||||||
|
cfgServerPassword
|
||||||
|
cfgAPIPassword
|
||||||
|
cfgPollSeconds
|
||||||
|
cfgCrescendoEnabled
|
||||||
|
cfgCrescendoStartPct
|
||||||
|
cfgCrescendoEndPct
|
||||||
|
cfgCrescendoDurationS
|
||||||
|
cfgCallbackScript
|
||||||
cfgFieldCount
|
cfgFieldCount
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,6 +47,24 @@ func newConfigModel(cfg db.Settings) *configModel {
|
|||||||
c.fields[cfgBlinkOffMs] = strconv.Itoa(cfg.BlinkOffMs)
|
c.fields[cfgBlinkOffMs] = strconv.Itoa(cfg.BlinkOffMs)
|
||||||
c.fields[cfgColorClock] = cfg.ColorClock
|
c.fields[cfgColorClock] = cfg.ColorClock
|
||||||
c.fields[cfgColorAlarm] = cfg.ColorAlarm
|
c.fields[cfgColorAlarm] = cfg.ColorAlarm
|
||||||
|
if cfg.ShowSeconds {
|
||||||
|
c.fields[cfgShowSeconds] = "true"
|
||||||
|
} else {
|
||||||
|
c.fields[cfgShowSeconds] = "false"
|
||||||
|
}
|
||||||
|
c.fields[cfgServerURL] = cfg.ServerURL
|
||||||
|
c.fields[cfgServerPassword] = cfg.ServerPassword
|
||||||
|
c.fields[cfgAPIPassword] = cfg.APIPassword
|
||||||
|
c.fields[cfgPollSeconds] = strconv.Itoa(cfg.PollSeconds)
|
||||||
|
if cfg.CrescendoEnabled {
|
||||||
|
c.fields[cfgCrescendoEnabled] = "true"
|
||||||
|
} else {
|
||||||
|
c.fields[cfgCrescendoEnabled] = "false"
|
||||||
|
}
|
||||||
|
c.fields[cfgCrescendoStartPct] = strconv.Itoa(cfg.CrescendoStartPct)
|
||||||
|
c.fields[cfgCrescendoEndPct] = strconv.Itoa(cfg.CrescendoEndPct)
|
||||||
|
c.fields[cfgCrescendoDurationS] = strconv.Itoa(cfg.CrescendoDurationS)
|
||||||
|
c.fields[cfgCallbackScript] = cfg.CallbackScript
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,8 +102,28 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
|
|||||||
c.err = ""
|
c.err = ""
|
||||||
return *m, nil
|
return *m, nil
|
||||||
|
|
||||||
|
case " ":
|
||||||
|
// Toggle boolean fields
|
||||||
|
if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
|
||||||
|
field := &c.fields[c.active]
|
||||||
|
if *field == "true" {
|
||||||
|
*field = "false"
|
||||||
|
} else {
|
||||||
|
*field = "true"
|
||||||
|
}
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
// Space as literal for other fields
|
||||||
|
c.fields[c.active] += " "
|
||||||
|
c.err = ""
|
||||||
|
return *m, nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if len(key) == 1 {
|
if len(key) == 1 {
|
||||||
|
// Don't allow free typing on boolean fields
|
||||||
|
if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
c.fields[c.active] += key
|
c.fields[c.active] += key
|
||||||
c.err = ""
|
c.err = ""
|
||||||
}
|
}
|
||||||
@@ -120,6 +168,30 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
|
|||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pollSeconds, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgPollSeconds]))
|
||||||
|
if err != nil || pollSeconds < 1 || pollSeconds > 60 {
|
||||||
|
c.err = "Poll seconds must be 1-60"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
crescStartPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoStartPct]))
|
||||||
|
if err != nil || crescStartPct < 0 || crescStartPct > 100 {
|
||||||
|
c.err = "Crescendo start must be 0-100%"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
crescEndPct, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoEndPct]))
|
||||||
|
if err != nil || crescEndPct < 0 || crescEndPct > 150 {
|
||||||
|
c.err = "Crescendo end must be 0-150%"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
crescDuration, err := strconv.Atoi(strings.TrimSpace(c.fields[cfgCrescendoDurationS]))
|
||||||
|
if err != nil || crescDuration < 1 || crescDuration > 300 {
|
||||||
|
c.err = "Crescendo duration must be 1-300 seconds"
|
||||||
|
return *m, nil
|
||||||
|
}
|
||||||
|
|
||||||
cfg := db.Settings{
|
cfg := db.Settings{
|
||||||
SnoozeMinutes: snooze,
|
SnoozeMinutes: snooze,
|
||||||
TimeoutMinutes: timeout,
|
TimeoutMinutes: timeout,
|
||||||
@@ -130,6 +202,20 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
|
|||||||
ColorAlarm: colorAlarm,
|
ColorAlarm: colorAlarm,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSec := strings.TrimSpace(c.fields[cfgShowSeconds])
|
||||||
|
cfg.ShowSeconds = showSec == "true"
|
||||||
|
|
||||||
|
cfg.ServerURL = strings.TrimSpace(c.fields[cfgServerURL])
|
||||||
|
cfg.ServerPassword = strings.TrimSpace(c.fields[cfgServerPassword])
|
||||||
|
cfg.APIPassword = strings.TrimSpace(c.fields[cfgAPIPassword])
|
||||||
|
cfg.PollSeconds = pollSeconds
|
||||||
|
|
||||||
|
cfg.CrescendoEnabled = strings.TrimSpace(c.fields[cfgCrescendoEnabled]) == "true"
|
||||||
|
cfg.CrescendoStartPct = crescStartPct
|
||||||
|
cfg.CrescendoEndPct = crescEndPct
|
||||||
|
cfg.CrescendoDurationS = crescDuration
|
||||||
|
cfg.CallbackScript = strings.TrimSpace(c.fields[cfgCallbackScript])
|
||||||
|
|
||||||
if cfg.DefaultSound == "" {
|
if cfg.DefaultSound == "" {
|
||||||
cfg.DefaultSound = "default"
|
cfg.DefaultSound = "default"
|
||||||
}
|
}
|
||||||
@@ -156,6 +242,16 @@ func (c *configModel) View() string {
|
|||||||
"Blink off (ms):",
|
"Blink off (ms):",
|
||||||
"Clock color:",
|
"Clock color:",
|
||||||
"Alarm color:",
|
"Alarm color:",
|
||||||
|
"Show seconds:",
|
||||||
|
"Server URL:",
|
||||||
|
"Server pass:",
|
||||||
|
"API password:",
|
||||||
|
"Poll (sec):",
|
||||||
|
"Enabled:",
|
||||||
|
"Start %:",
|
||||||
|
"End %:",
|
||||||
|
"Duration (s):",
|
||||||
|
"Script path:",
|
||||||
}
|
}
|
||||||
|
|
||||||
hints := [cfgFieldCount]string{
|
hints := [cfgFieldCount]string{
|
||||||
@@ -166,12 +262,36 @@ func (c *configModel) View() string {
|
|||||||
"100-5000",
|
"100-5000",
|
||||||
"hex e.g. #00FF88",
|
"hex e.g. #00FF88",
|
||||||
"hex e.g. #FF4444",
|
"hex e.g. #FF4444",
|
||||||
|
"space to toggle",
|
||||||
|
"empty=local, or http://host:9119",
|
||||||
|
"password to auth with server",
|
||||||
|
"password for incoming API requests",
|
||||||
|
"1-60, client poll frequency",
|
||||||
|
"space to toggle",
|
||||||
|
"0-100, starting volume",
|
||||||
|
"0-150, ending volume (>100 = overdrive)",
|
||||||
|
"1-300, ramp duration in seconds",
|
||||||
|
"called with: start|dismiss|snooze|timeout <name>",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section headers: field index -> section name
|
||||||
|
sections := map[cfgField]string{
|
||||||
|
cfgSnoozeMinutes: "─── Alarm ───",
|
||||||
|
cfgBlinkOnMs: "─── Display ───",
|
||||||
|
cfgServerURL: "─── Network ───",
|
||||||
|
cfgCrescendoEnabled: "─── Crescendo (pactl) ───",
|
||||||
|
cfgCallbackScript: "─── Hooks ───",
|
||||||
}
|
}
|
||||||
|
|
||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, TitleStyle.Render("Settings"), "")
|
lines = append(lines, TitleStyle.Render("Settings"), "")
|
||||||
|
|
||||||
for i := cfgField(0); i < cfgFieldCount; i++ {
|
for i := cfgField(0); i < cfgFieldCount; i++ {
|
||||||
|
// Insert section header if this field starts a new section
|
||||||
|
if section, ok := sections[i]; ok {
|
||||||
|
lines = append(lines, "", DividerStyle.Render(section))
|
||||||
|
}
|
||||||
|
|
||||||
labelStr := fmt.Sprintf("%15s", labels[i])
|
labelStr := fmt.Sprintf("%15s", labels[i])
|
||||||
value := c.fields[i]
|
value := c.fields[i]
|
||||||
|
|
||||||
|
|||||||
@@ -135,13 +135,13 @@ func (f *formModel) save(m *Model) (tea.Model, tea.Cmd) {
|
|||||||
if f.editing != nil {
|
if f.editing != nil {
|
||||||
alarm.ID = f.editing.ID
|
alarm.ID = f.editing.ID
|
||||||
alarm.Enabled = f.editing.Enabled
|
alarm.Enabled = f.editing.Enabled
|
||||||
if err := m.store.UpdateAlarm(alarm); err != nil {
|
if err := m.alarmStore.UpdateAlarm(alarm); err != nil {
|
||||||
f.err = fmt.Sprintf("Save failed: %v", err)
|
f.err = fmt.Sprintf("Save failed: %v", err)
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
m.statusMsg = "Alarm updated"
|
m.statusMsg = "Alarm updated"
|
||||||
} else {
|
} else {
|
||||||
if _, err := m.store.CreateAlarm(alarm); err != nil {
|
if _, err := m.alarmStore.CreateAlarm(alarm); err != nil {
|
||||||
f.err = fmt.Sprintf("Save failed: %v", err)
|
f.err = fmt.Sprintf("Save failed: %v", err)
|
||||||
return *m, nil
|
return *m, nil
|
||||||
}
|
}
|
||||||
|
|||||||
84
ui/model.go
84
ui/model.go
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"woke/db"
|
"woke/db"
|
||||||
@@ -28,12 +29,16 @@ type alarmFiredMsg scheduler.AlarmEvent
|
|||||||
type alarmsLoadedMsg []db.Alarm
|
type alarmsLoadedMsg []db.Alarm
|
||||||
type snoozeFireMsg db.Alarm
|
type snoozeFireMsg db.Alarm
|
||||||
type autoTimeoutMsg struct{}
|
type autoTimeoutMsg struct{}
|
||||||
|
type AlarmsChangedMsg struct{}
|
||||||
|
type pollTickMsg struct{}
|
||||||
|
|
||||||
// Model is the main bubbletea model.
|
// Model is the main bubbletea model.
|
||||||
type Model struct {
|
type Model struct {
|
||||||
store *db.Store
|
store *db.Store // For settings (always local)
|
||||||
|
alarmStore db.AlarmStore // For alarm CRUD (local or remote)
|
||||||
scheduler *scheduler.Scheduler
|
scheduler *scheduler.Scheduler
|
||||||
player *player.Player
|
player *player.Player
|
||||||
|
clientMode bool
|
||||||
|
|
||||||
// State
|
// State
|
||||||
alarms []db.Alarm
|
alarms []db.Alarm
|
||||||
@@ -62,11 +67,13 @@ type Model struct {
|
|||||||
statusMsg string
|
statusMsg string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModel(store *db.Store, sched *scheduler.Scheduler, pl *player.Player) Model {
|
func NewModel(store *db.Store, alarmStore db.AlarmStore, sched *scheduler.Scheduler, pl *player.Player, clientMode bool) Model {
|
||||||
m := Model{
|
m := Model{
|
||||||
store: store,
|
store: store,
|
||||||
|
alarmStore: alarmStore,
|
||||||
scheduler: sched,
|
scheduler: sched,
|
||||||
player: pl,
|
player: pl,
|
||||||
|
clientMode: clientMode,
|
||||||
now: time.Now(),
|
now: time.Now(),
|
||||||
settings: store.LoadSettings(),
|
settings: store.LoadSettings(),
|
||||||
}
|
}
|
||||||
@@ -106,11 +113,15 @@ func (m *Model) applySettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
cmds := []tea.Cmd{
|
||||||
tick(),
|
tick(),
|
||||||
listenForAlarms(m.scheduler),
|
listenForAlarms(m.scheduler),
|
||||||
loadAlarmsCmd(m.store),
|
loadAlarmsCmd(m.alarmStore),
|
||||||
)
|
}
|
||||||
|
if m.clientMode {
|
||||||
|
cmds = append(cmds, pollTick(m.settings.PollSeconds))
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tick() tea.Cmd {
|
func tick() tea.Cmd {
|
||||||
@@ -126,7 +137,7 @@ func listenForAlarms(sched *scheduler.Scheduler) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadAlarmsCmd(store *db.Store) tea.Cmd {
|
func loadAlarmsCmd(store db.AlarmStore) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
alarms, err := store.ListAlarms()
|
alarms, err := store.ListAlarms()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,6 +147,12 @@ func loadAlarmsCmd(store *db.Store) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pollTick(seconds int) tea.Cmd {
|
||||||
|
return tea.Tick(time.Duration(seconds)*time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return pollTickMsg{}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
@@ -149,6 +166,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case AlarmsChangedMsg:
|
||||||
|
m.refreshAlarms()
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case pollTickMsg:
|
||||||
|
m.refreshAlarms()
|
||||||
|
return m, pollTick(m.settings.PollSeconds)
|
||||||
|
|
||||||
case tickMsg:
|
case tickMsg:
|
||||||
m.now = time.Time(msg)
|
m.now = time.Time(msg)
|
||||||
if m.firingAlarm != nil {
|
if m.firingAlarm != nil {
|
||||||
@@ -176,6 +201,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case autoTimeoutMsg:
|
case autoTimeoutMsg:
|
||||||
if m.firingAlarm != nil {
|
if m.firingAlarm != nil {
|
||||||
m.player.Stop()
|
m.player.Stop()
|
||||||
|
m.runCallback("timeout", m.firingAlarm.Name)
|
||||||
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
|
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
|
||||||
m.firingAlarm = nil
|
m.firingAlarm = nil
|
||||||
m.snoozeCount = 0
|
m.snoozeCount = 0
|
||||||
@@ -208,12 +234,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "enter", " ", "d":
|
case "enter", " ", "d":
|
||||||
m.player.Stop()
|
m.player.Stop()
|
||||||
alarm := m.firingAlarm
|
alarm := m.firingAlarm
|
||||||
|
m.runCallback("dismiss", alarm.Name)
|
||||||
m.firingAlarm = nil
|
m.firingAlarm = nil
|
||||||
m.snoozeCount = 0
|
m.snoozeCount = 0
|
||||||
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
|
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
|
||||||
case "s":
|
case "s":
|
||||||
m.player.Stop()
|
m.player.Stop()
|
||||||
alarm := *m.firingAlarm
|
alarm := *m.firingAlarm
|
||||||
|
m.runCallback("snooze", alarm.Name)
|
||||||
dur := m.snoozeDuration()
|
dur := m.snoozeDuration()
|
||||||
m.firingAlarm = nil
|
m.firingAlarm = nil
|
||||||
m.snoozeUntil = time.Now().Add(dur)
|
m.snoozeUntil = time.Now().Add(dur)
|
||||||
@@ -239,7 +267,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
switch key {
|
switch key {
|
||||||
case "y", "Y":
|
case "y", "Y":
|
||||||
if m.cursor < len(m.alarms) {
|
if m.cursor < len(m.alarms) {
|
||||||
_ = m.store.DeleteAlarm(m.alarms[m.cursor].ID)
|
_ = m.alarmStore.DeleteAlarm(m.alarms[m.cursor].ID)
|
||||||
m.refreshAlarms()
|
m.refreshAlarms()
|
||||||
if m.cursor >= len(m.alarms) && m.cursor > 0 {
|
if m.cursor >= len(m.alarms) && m.cursor > 0 {
|
||||||
m.cursor--
|
m.cursor--
|
||||||
@@ -275,7 +303,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case " ":
|
case " ":
|
||||||
// Toggle enabled
|
// Toggle enabled
|
||||||
if m.cursor < len(m.alarms) {
|
if m.cursor < len(m.alarms) {
|
||||||
_ = m.store.ToggleAlarm(m.alarms[m.cursor].ID)
|
_ = m.alarmStore.ToggleAlarm(m.alarms[m.cursor].ID)
|
||||||
m.refreshAlarms()
|
m.refreshAlarms()
|
||||||
}
|
}
|
||||||
case "a":
|
case "a":
|
||||||
@@ -318,7 +346,30 @@ func (m *Model) startFiring(alarm *db.Alarm) {
|
|||||||
if sound == "default" || sound == "" {
|
if sound == "default" || sound == "" {
|
||||||
sound = m.settings.DefaultSound
|
sound = m.settings.DefaultSound
|
||||||
}
|
}
|
||||||
m.player.PlayLoop(sound)
|
|
||||||
|
// Use crescendo if enabled
|
||||||
|
cresc := player.CrescendoConfig{
|
||||||
|
Enabled: m.settings.CrescendoEnabled,
|
||||||
|
StartPct: m.settings.CrescendoStartPct,
|
||||||
|
EndPct: m.settings.CrescendoEndPct,
|
||||||
|
DurationS: m.settings.CrescendoDurationS,
|
||||||
|
}
|
||||||
|
m.player.PlayLoopWithCrescendo(sound, cresc)
|
||||||
|
|
||||||
|
// Fire callback
|
||||||
|
m.runCallback("start", alarm.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCallback executes the configured callback script with the event type and alarm name.
|
||||||
|
func (m *Model) runCallback(event, alarmName string) {
|
||||||
|
script := m.settings.CallbackScript
|
||||||
|
if script == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Run in background, don't block
|
||||||
|
go func() {
|
||||||
|
_ = exec.Command(script, event, alarmName).Run()
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd {
|
func snoozeCmd(alarm db.Alarm, after time.Duration) tea.Cmd {
|
||||||
@@ -336,7 +387,7 @@ func autoTimeoutCmd(dur time.Duration) tea.Cmd {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) refreshAlarms() {
|
func (m *Model) refreshAlarms() {
|
||||||
alarms, err := m.store.ListAlarms()
|
alarms, err := m.alarmStore.ListAlarms()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
m.alarms = alarms
|
m.alarms = alarms
|
||||||
}
|
}
|
||||||
@@ -350,7 +401,7 @@ func (m Model) View() string {
|
|||||||
var sections []string
|
var sections []string
|
||||||
|
|
||||||
// Big clock
|
// Big clock
|
||||||
clockStr := RenderBigClock(m.now, m.settings.BlinkOnMs, m.settings.BlinkOffMs)
|
clockStr := RenderBigClock(m.now, m.settings.BlinkOnMs, m.settings.BlinkOffMs, m.settings.ShowSeconds)
|
||||||
if m.firingAlarm != nil && m.firingBlink {
|
if m.firingAlarm != nil && m.firingBlink {
|
||||||
clockStr = ClockAlarmStyle.Render(clockStr)
|
clockStr = ClockAlarmStyle.Render(clockStr)
|
||||||
} else {
|
} else {
|
||||||
@@ -367,6 +418,17 @@ func (m Model) View() string {
|
|||||||
dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine)
|
dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, dateLine)
|
||||||
sections = append(sections, dateLine)
|
sections = append(sections, dateLine)
|
||||||
|
|
||||||
|
// Connection status
|
||||||
|
var connStatus string
|
||||||
|
if m.clientMode {
|
||||||
|
connStatus = fmt.Sprintf("[Client: %s]", m.settings.ServerURL)
|
||||||
|
} else {
|
||||||
|
connStatus = "[Local]"
|
||||||
|
}
|
||||||
|
connLine := HelpStyle.Render(connStatus)
|
||||||
|
connLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, connLine)
|
||||||
|
sections = append(sections, connLine)
|
||||||
|
|
||||||
// Snooze indicator
|
// Snooze indicator
|
||||||
if !m.snoozeUntil.IsZero() && m.now.Before(m.snoozeUntil) {
|
if !m.snoozeUntil.IsZero() && m.now.Before(m.snoozeUntil) {
|
||||||
snoozeText := fmt.Sprintf("[Snoozing %s until %s]", m.snoozeName, m.snoozeUntil.Format("15:04:05"))
|
snoozeText := fmt.Sprintf("[Snoozing %s until %s]", m.snoozeName, m.snoozeUntil.Format("15:04:05"))
|
||||||
|
|||||||
Reference in New Issue
Block a user