Compare commits

..

3 Commits

Author SHA1 Message Date
f4eccec652 Add single-file HTML/JS client 2026-02-25 19:39:39 +02:00
621815ed0f Add callbacks, crescendo sound and better configs 2026-02-04 20:55:03 +02:00
cdcdf2c644 Expensive bee 2026-02-03 16:49:05 +02:00
12 changed files with 1625 additions and 75 deletions

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"woke/db"
)
@@ -12,13 +13,14 @@ type Notifier func()
// Server provides a REST API for alarm CRUD.
type Server struct {
store *db.Store
mux *http.ServeMux
notify Notifier
store *db.Store
mux *http.ServeMux
notify Notifier
password string
}
func New(store *db.Store, notify Notifier) *Server {
s := &Server{store: store, notify: notify}
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)
@@ -30,7 +32,36 @@ func New(store *db.Store, notify Notifier) *Server {
}
func (s *Server) Handler() http.Handler {
return s.mux
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 {

192
api/client.go Normal file
View 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
}

View File

@@ -201,18 +201,42 @@ type Settings struct {
ColorClock 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 {
return Settings{
SnoozeMinutes: 5,
TimeoutMinutes: 5,
DefaultSound: "default",
BlinkOnMs: 1000,
BlinkOffMs: 500,
ColorClock: "#00FF88",
ColorAlarm: "#FF4444",
ShowSeconds: true,
SnoozeMinutes: 5,
TimeoutMinutes: 5,
DefaultSound: "default",
BlinkOnMs: 1000,
BlinkOffMs: 500,
ColorClock: "#00FF88",
ColorAlarm: "#FF4444",
ShowSeconds: true,
ServerURL: "",
ServerPassword: "",
APIPassword: "",
PollSeconds: 5,
CrescendoEnabled: false,
CrescendoStartPct: 20,
CrescendoEndPct: 100,
CrescendoDurationS: 60,
CallbackScript: "",
}
}
@@ -226,19 +250,37 @@ func (s *Store) LoadSettings() Settings {
cfg.ColorClock = s.GetSetting("color_clock", cfg.ColorClock)
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
}
func (s *Store) SaveSettings(cfg Settings) error {
pairs := map[string]string{
"snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes),
"timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes),
"default_sound": cfg.DefaultSound,
"blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs),
"blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs),
"color_clock": cfg.ColorClock,
"color_alarm": cfg.ColorAlarm,
"show_seconds": fmt.Sprintf("%t", cfg.ShowSeconds),
"snooze_minutes": fmt.Sprintf("%d", cfg.SnoozeMinutes),
"timeout_minutes": fmt.Sprintf("%d", cfg.TimeoutMinutes),
"default_sound": cfg.DefaultSound,
"blink_on_ms": fmt.Sprintf("%d", cfg.BlinkOnMs),
"blink_off_ms": fmt.Sprintf("%d", cfg.BlinkOffMs),
"color_clock": cfg.ColorClock,
"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 {
if err := s.SetSetting(k, v); err != nil {

13
db/interface.go Normal file
View 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

File diff suppressed because it is too large Load Diff

52
main.go
View File

@@ -16,9 +16,19 @@ import (
const helpText = `woke - TUI alarm clock with REST API
Usage:
woke Start the TUI (API server runs on :9119)
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:
@@ -48,6 +58,9 @@ API endpoints (http://localhost:9119):
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
@@ -64,6 +77,7 @@ func main() {
os.Exit(0)
}
// Always open local store for settings
store, err := db.Open()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open database: %v\n", err)
@@ -71,23 +85,37 @@ func main() {
}
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()
sched := scheduler.New(store)
sched := scheduler.New(alarmStore, clientMode)
sched.Start()
defer sched.Stop()
model := ui.NewModel(store, sched, pl)
model := ui.NewModel(store, alarmStore, sched, pl, clientMode)
p := tea.NewProgram(model, tea.WithAltScreen())
// Start HTTP API server — notify TUI on mutations via p.Send
srv := api.New(store, 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)
}
}()
// 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 {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)

View File

@@ -1,17 +1,28 @@
package player
import (
"fmt"
"os"
"os/exec"
"sync"
"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.
type Player struct {
mu sync.Mutex
stopCh chan struct{}
doneCh chan struct{}
mu sync.Mutex
stopCh chan struct{}
doneCh chan struct{}
crescStopCh chan struct{}
crescDoneCh chan struct{}
}
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.
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.mu.Lock()
@@ -69,6 +85,19 @@ func (p *Player) PlayLoop(path string) {
doneCh := p.doneCh
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)
if resolved == "" {
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.
func (p *Player) Stop() {
p.mu.Lock()
stopCh := p.stopCh
doneCh := p.doneCh
crescStopCh := p.crescStopCh
crescDoneCh := p.crescDoneCh
p.stopCh = nil
p.doneCh = nil
p.crescStopCh = nil
p.crescDoneCh = nil
p.mu.Unlock()
// Stop crescendo
if crescStopCh != nil {
close(crescStopCh)
}
if crescDoneCh != nil {
<-crescDoneCh
}
// Stop audio
if stopCh != nil {
close(stopCh)
}

View File

@@ -15,19 +15,21 @@ type AlarmEvent struct {
// Scheduler checks alarms every second and fires events when they trigger.
type Scheduler struct {
store *db.Store
events chan AlarmEvent
stop chan struct{}
mu sync.Mutex
parser cron.Parser
store db.AlarmStore
events chan AlarmEvent
stop chan struct{}
mu sync.Mutex
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{
store: store,
events: make(chan AlarmEvent, 10),
stop: make(chan struct{}),
parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow),
store: store,
clientMode: clientMode,
events: make(chan AlarmEvent, 10),
stop: make(chan struct{}),
parser: cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow),
}
}
@@ -90,11 +92,14 @@ func (s *Scheduler) check(now time.Time, firedThisMinute map[int]string) {
if s.shouldTrigger(a, now) {
firedThisMinute[a.ID] = currentMinute
_ = s.store.MarkTriggered(a.ID)
// Disable one-shot alarms after triggering
if a.Trigger == "once" {
_ = s.store.ToggleAlarm(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
if a.Trigger == "once" {
_ = s.store.ToggleAlarm(a.ID)
}
}
select {

View File

@@ -20,6 +20,15 @@ const (
cfgColorClock
cfgColorAlarm
cfgShowSeconds
cfgServerURL
cfgServerPassword
cfgAPIPassword
cfgPollSeconds
cfgCrescendoEnabled
cfgCrescendoStartPct
cfgCrescendoEndPct
cfgCrescendoDurationS
cfgCallbackScript
cfgFieldCount
)
@@ -43,6 +52,19 @@ func newConfigModel(cfg db.Settings) *configModel {
} 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
}
@@ -82,11 +104,12 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
case " ":
// Toggle boolean fields
if c.active == cfgShowSeconds {
if c.fields[cfgShowSeconds] == "true" {
c.fields[cfgShowSeconds] = "false"
if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
field := &c.fields[c.active]
if *field == "true" {
*field = "false"
} else {
c.fields[cfgShowSeconds] = "true"
*field = "true"
}
return *m, nil
}
@@ -98,7 +121,7 @@ func (c *configModel) HandleKey(msg tea.KeyMsg, m *Model) (tea.Model, tea.Cmd) {
default:
if len(key) == 1 {
// Don't allow free typing on boolean fields
if c.active == cfgShowSeconds {
if c.active == cfgShowSeconds || c.active == cfgCrescendoEnabled {
return *m, nil
}
c.fields[c.active] += key
@@ -145,6 +168,30 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
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{
SnoozeMinutes: snooze,
TimeoutMinutes: timeout,
@@ -158,6 +205,17 @@ func (c *configModel) save(m *Model) (tea.Model, tea.Cmd) {
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 == "" {
cfg.DefaultSound = "default"
}
@@ -185,6 +243,15 @@ func (c *configModel) View() string {
"Clock color:",
"Alarm color:",
"Show seconds:",
"Server URL:",
"Server pass:",
"API password:",
"Poll (sec):",
"Enabled:",
"Start %:",
"End %:",
"Duration (s):",
"Script path:",
}
hints := [cfgFieldCount]string{
@@ -196,12 +263,35 @@ func (c *configModel) View() string {
"hex e.g. #00FF88",
"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
lines = append(lines, TitleStyle.Render("Settings"), "")
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])
value := c.fields[i]

View File

@@ -135,13 +135,13 @@ func (f *formModel) save(m *Model) (tea.Model, tea.Cmd) {
if f.editing != nil {
alarm.ID = f.editing.ID
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)
return *m, nil
}
m.statusMsg = "Alarm updated"
} 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)
return *m, nil
}

View File

@@ -2,6 +2,7 @@ package ui
import (
"fmt"
"os/exec"
"strings"
"time"
"woke/db"
@@ -29,12 +30,15 @@ type alarmsLoadedMsg []db.Alarm
type snoozeFireMsg db.Alarm
type autoTimeoutMsg struct{}
type AlarmsChangedMsg struct{}
type pollTickMsg struct{}
// Model is the main bubbletea model.
type Model struct {
store *db.Store
scheduler *scheduler.Scheduler
player *player.Player
store *db.Store // For settings (always local)
alarmStore db.AlarmStore // For alarm CRUD (local or remote)
scheduler *scheduler.Scheduler
player *player.Player
clientMode bool
// State
alarms []db.Alarm
@@ -63,13 +67,15 @@ type Model struct {
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{
store: store,
scheduler: sched,
player: pl,
now: time.Now(),
settings: store.LoadSettings(),
store: store,
alarmStore: alarmStore,
scheduler: sched,
player: pl,
clientMode: clientMode,
now: time.Now(),
settings: store.LoadSettings(),
}
m.applySettings()
return m
@@ -107,11 +113,15 @@ func (m *Model) applySettings() {
}
func (m Model) Init() tea.Cmd {
return tea.Batch(
cmds := []tea.Cmd{
tick(),
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 {
@@ -127,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 {
alarms, err := store.ListAlarms()
if err != nil {
@@ -137,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) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@@ -154,6 +170,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.refreshAlarms()
return m, nil
case pollTickMsg:
m.refreshAlarms()
return m, pollTick(m.settings.PollSeconds)
case tickMsg:
m.now = time.Time(msg)
if m.firingAlarm != nil {
@@ -181,6 +201,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case autoTimeoutMsg:
if m.firingAlarm != nil {
m.player.Stop()
m.runCallback("timeout", m.firingAlarm.Name)
m.statusMsg = fmt.Sprintf("Alarm auto-dismissed after %d minutes", m.settings.TimeoutMinutes)
m.firingAlarm = nil
m.snoozeCount = 0
@@ -213,12 +234,14 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case "enter", " ", "d":
m.player.Stop()
alarm := m.firingAlarm
m.runCallback("dismiss", alarm.Name)
m.firingAlarm = nil
m.snoozeCount = 0
m.statusMsg = fmt.Sprintf("Alarm '%s' dismissed", alarm.Name)
case "s":
m.player.Stop()
alarm := *m.firingAlarm
m.runCallback("snooze", alarm.Name)
dur := m.snoozeDuration()
m.firingAlarm = nil
m.snoozeUntil = time.Now().Add(dur)
@@ -244,7 +267,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key {
case "y", "Y":
if m.cursor < len(m.alarms) {
_ = m.store.DeleteAlarm(m.alarms[m.cursor].ID)
_ = m.alarmStore.DeleteAlarm(m.alarms[m.cursor].ID)
m.refreshAlarms()
if m.cursor >= len(m.alarms) && m.cursor > 0 {
m.cursor--
@@ -280,7 +303,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case " ":
// Toggle enabled
if m.cursor < len(m.alarms) {
_ = m.store.ToggleAlarm(m.alarms[m.cursor].ID)
_ = m.alarmStore.ToggleAlarm(m.alarms[m.cursor].ID)
m.refreshAlarms()
}
case "a":
@@ -323,7 +346,30 @@ func (m *Model) startFiring(alarm *db.Alarm) {
if sound == "default" || sound == "" {
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 {
@@ -341,7 +387,7 @@ func autoTimeoutCmd(dur time.Duration) tea.Cmd {
}
func (m *Model) refreshAlarms() {
alarms, err := m.store.ListAlarms()
alarms, err := m.alarmStore.ListAlarms()
if err == nil {
m.alarms = alarms
}
@@ -372,6 +418,17 @@ func (m Model) View() string {
dateLine = lipgloss.PlaceHorizontal(m.width, lipgloss.Center, 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
if !m.snoozeUntil.IsZero() && m.now.Before(m.snoozeUntil) {
snoozeText := fmt.Sprintf("[Snoozing %s until %s]", m.snoozeName, m.snoozeUntil.Format("15:04:05"))

BIN
woke

Binary file not shown.