Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,24 @@ Flow:

You can start, pause, resume, cancel via the GUI (Advanced → Auto Calibration) or CLI (`batt calibrate start|pause|resume|cancel|status`) or HTTP API. Cancel restores original settings immediately.

#### Scheduling automatic calibration

> [!NOTE]
> This feature is CLI-only and is not available in the GUI version.

Use `batt schedule` to let the daemon trigger calibration runs on a cron cadence. Examples:

```bash
batt schedule '0 10 * * 0' # every Sunday at 10:00 (quote * so the shell doesn't expand it)
batt schedule disable # remove the schedule and stop future runs
batt schedule postpone 90m # push the next run back by 90 minutes (defaults to 1h if omitted)
batt schedule skip # skip only the upcoming run and keep the schedule
```

After setting a cron expression, the CLI prints the next few run times returned by the daemon so you can verify the schedule. `postpone` and `skip` operate on the next scheduled execution only; `disable` clears the stored cron configuration.

Before a scheduled calibration actually begins, `batt` checks that the Mac is plugged into wall power. If it is not, the scheduler posts a reminder notification (GUI) and keeps waiting for roughly five minutes. If power is still unavailable after that grace period, the pending run is skipped automatically so later schedules are not blocked.

### Preventing idle sleep

Set whether to prevent idle sleep during a charging session.
Expand Down
1 change: 1 addition & 0 deletions cmd/batt/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ Report issues: https://github.com/charlie0129/batt/issues`,
NewSetControlMagSafeLEDCommand(),
NewInstallCommand(),
NewUninstallCommand(),
NewScheduleCommand(),
gui.NewGUICommand(unixSocketPath, ""),
)

Expand Down
91 changes: 91 additions & 0 deletions cmd/batt/schedule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"
)

func NewScheduleCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "schedule [cron expression | disable | postpone <duration> | skip]",
Aliases: []string{"sch"},
Short: "Manage automatic calibration schedule",
Long: `Examples:
batt schedule '0 10 * * 0' # Note: Cron expressions containing * must be quoted!
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add some examples on cron expressions?

For example:

batt schedule 'minute hour day month weekday'
batt schedule '0 10 * * 0' (At 10:00 on Sunday)
batt schedule '0 10 1 * *' (At 10:00 on the first day of every month)
batt schedule '0 10 1 */2 *' (At 10:00 on the first day of every two months)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok

batt schedule disable
batt schedule postpone 90m
batt schedule skip`,
GroupID: gAdvanced,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Usage()
}
switch strings.ToLower(args[0]) {
case "disable":
return runScheduleDisable(cmd)
case "postpone":
return runSchedulePostpone(cmd, args[1:])
case "skip":
return runScheduleSkip(cmd)
default:
cronExpr := strings.Join(args, " ")
return runScheduleSet(cmd, cronExpr)
}
},
}
return cmd
}

func runScheduleSet(cmd *cobra.Command, cronExpr string) error {
if strings.TrimSpace(cronExpr) == "" {
return fmt.Errorf("cron expression cannot be empty")
}
nextRuns, err := apiClient.Schedule(cronExpr)
if err != nil {
return err
}
if len(nextRuns) == 0 {
cmd.Println("Calibration schedule disabled.")
return nil
}
cmd.Printf("Calibration scheduled. Next %d run(s):\n", len(nextRuns))
for _, run := range nextRuns {
cmd.Printf(" - %s\n", run.Local().Format(time.DateTime))
}
return nil
}

func runScheduleDisable(cmd *cobra.Command) error {
if _, err := apiClient.Schedule(""); err != nil {
return err
}
cmd.Println("Calibration schedule disabled.")
return nil
}

func runSchedulePostpone(cmd *cobra.Command, args []string) error {
duration := time.Hour
if len(args) > 0 {
parsed, err := time.ParseDuration(args[0])
if err != nil {
return fmt.Errorf("invalid duration %q: %w", args[0], err)
}
duration = parsed
}
if _, err := apiClient.PostponeSchedule(duration); err != nil {
return err
}
cmd.Printf("Next run postponed by %s.\n", duration)
return nil
}

func runScheduleSkip(cmd *cobra.Command) error {
if _, err := apiClient.SkipSchedule(); err != nil {
return err
}
cmd.Println("Next scheduled run skipped.")
return nil
}
22 changes: 22 additions & 0 deletions cmd/batt/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package main

import (
"fmt"
"time"

"github.com/fatih/color"
"github.com/spf13/cobra"

"github.com/charlie0129/batt/pkg/calibration"
"github.com/charlie0129/batt/pkg/client"
"github.com/charlie0129/batt/pkg/config"
"github.com/charlie0129/batt/pkg/powerinfo"
Expand Down Expand Up @@ -64,6 +66,7 @@ func fetchStatusData() (*statusData, error) {
}, nil
}

//nolint:gocyclo
func NewStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Expand Down Expand Up @@ -205,6 +208,25 @@ func NewStatusCommand() *cobra.Command {
ledStatus += " (" + bold("always off") + ")"
}
cmd.Printf(" Control MagSafe LED: %s\n", ledStatus)

cmd.Println()

tr, err := apiClient.GetTelemetry(false, true)
if err == nil {
cmd.Println(bold("Calibration status:"))
cmd.Printf(" Phase: %s\n", bold("%s", string(tr.Calibration.Phase)))
if tr.Calibration.Phase != calibration.PhaseIdle {
cmd.Printf(" Start: %s\n", bold("%s", tr.Calibration.StartedAt.Format(time.DateTime)))
}

cron := cfg.Cron()
if cron == "" {
cmd.Printf(" Schedule: %s\n", bold("disabled"))
} else {
cmd.Printf(" Schedule: %s (%s)\n", bold("%s", tr.Calibration.ScheduledAt.Format(time.DateTime)), cfg.Cron())
}
}

return nil
},
}
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/peterneutron/powerkit-go v0.3.3
github.com/pkg/errors v0.9.1
github.com/progrium/darwinkit v0.5.1-0.20240715194340-61b9e31a12fa
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.7.0
golang.org/x/term v0.37.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/progrium/darwinkit v0.5.1-0.20240715194340-61b9e31a12fa h1:tt/xmq4xYm+9iAWwvob4Z5P/9SOyeUl3aIXlxUh1RLo=
github.com/progrium/darwinkit v0.5.1-0.20240715194340-61b9e31a12fa/go.mod h1:PxQhZuftnALLkCVaR8LaHtUOfoo4pm8qUDG+3C/sXNs=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
Expand Down
15 changes: 11 additions & 4 deletions pkg/calibration/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ const (
type Action string

const (
ActionStart Action = "Start"
ActionPause Action = "Pause"
ActionResume Action = "Resume"
ActionCancel Action = "Cancel"
ActionStart Action = "Start"
ActionPause Action = "Pause"
ActionResume Action = "Resume"
ActionCancel Action = "Cancel"
ActionSchedule Action = "Schedule"
ActionScheduleUpComing Action = "ScheduleUpcoming"
ActionScheduleDisable Action = "ScheduleDisable"
ActionScheduleSkip Action = "ScheduleSkip"
ActionSchedulePostpone Action = "SchedulePostpone"
ActionScheduleError Action = "ScheduleError"
)

// State holds runtime state persisted to disk.
Expand Down Expand Up @@ -58,4 +64,5 @@ type Status struct {
CanCancel bool `json:"canCancel"`
Message string `json:"message"`
TargetPercent int `json:"targetPercent,omitempty"`
ScheduledAt time.Time `json:"scheduledAt"`
}
23 changes: 23 additions & 0 deletions pkg/client/apis.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,29 @@ func (c *Client) CancelCalibration() (string, error) {
return c.Send("POST", "/calibration/cancel", "")
}

type scheduleResponse struct {
OK bool `json:"ok"`
NextRuns []time.Time `json:"next_runs"`
}

func (c *Client) Schedule(cronExpr string) ([]time.Time, error) {
body, err := c.Put("/schedule", strconv.Quote(cronExpr))
if err != nil {
return nil, pkgerrors.Wrapf(err, "failed to set cron expression")
}
var resp scheduleResponse
if err := json.Unmarshal([]byte(body), &resp); err != nil {
return nil, pkgerrors.Wrapf(err, "failed to read next runs")
}
return resp.NextRuns, nil
}
func (c *Client) PostponeSchedule(d time.Duration) (string, error) {
return c.Put("/schedule/postpone", strconv.Quote(d.String()))
}
func (c *Client) SkipSchedule() (string, error) {
return c.Put("/schedule/skip", "")
}

func parseBoolResponse(resp string) (bool, error) {
switch resp {
case "true":
Expand Down
2 changes: 2 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Config interface {
ControlMagSafeLED() ControlMagSafeMode
CalibrationDischargeThreshold() int
CalibrationHoldDurationMinutes() int
Cron() string

SetUpperLimit(int)
SetLowerLimit(int)
Expand All @@ -22,6 +23,7 @@ type Config interface {
SetPreventSystemSleep(bool)
SetAllowNonRootAccess(bool)
SetControlMagSafeLED(ControlMagSafeMode)
SetCron(string)

LogrusFields() logrus.Fields

Expand Down
34 changes: 32 additions & 2 deletions pkg/config/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,9 @@ type RawFileConfig struct {
LowerLimitDelta *int `json:"lowerLimitDelta,omitempty"`
ControlMagSafeLED *ControlMagSafeMode `json:"controlMagSafeLED,omitempty"`

CalibrationDischargeThreshold *int `json:"calibrationDischargeThreshold,omitempty"`
CalibrationHoldDurationMinutes *int `json:"calibrationHoldDurationMinutes,omitempty"`
CalibrationDischargeThreshold *int `json:"calibrationDischargeThreshold,omitempty"`
CalibrationHoldDurationMinutes *int `json:"calibrationHoldDurationMinutes,omitempty"`
Cron *string `json:"cron,omitempty"`
}

func NewRawFileConfigFromConfig(c Config) (*RawFileConfig, error) {
Expand All @@ -135,6 +136,7 @@ func NewRawFileConfigFromConfig(c Config) (*RawFileConfig, error) {
AllowNonRootAccess: ptr.To(c.AllowNonRootAccess()),
LowerLimitDelta: ptr.To(c.UpperLimit() - c.LowerLimit()),
ControlMagSafeLED: ptr.To(c.ControlMagSafeLED()),
Cron: ptr.To(c.Cron()),
}

return rawConfig, nil
Expand Down Expand Up @@ -399,6 +401,34 @@ func (f *File) SetControlMagSafeLED(mode ControlMagSafeMode) {
f.c.ControlMagSafeLED = ptr.To(mode)
}

func (f *File) Cron() string {
if f.c == nil {
panic("config is nil")
}

f.mu.RLock()
defer f.mu.RUnlock()

var cron string

if f.c.Cron != nil {
cron = *f.c.Cron
}

return cron
}

func (f *File) SetCron(cron string) {
if f.c == nil {
panic("config is nil")
}

f.mu.Lock()
defer f.mu.Unlock()

f.c.Cron = ptr.To(cron)
}

func (f *File) Load() error {
f.mu.Lock()
defer f.mu.Unlock()
Expand Down
Loading