mirror of
https://github.com/Xevion/go-ha.git
synced 2025-12-11 00:07:24 -06:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 440c5afac2 | |||
| 258bea962a | |||
| bbba55574f | |||
| 79f810b1f8 | |||
| 292879a8a9 |
27
app.go
27
app.go
@@ -42,16 +42,16 @@ type App struct {
|
||||
|
||||
type Item types.Item
|
||||
|
||||
func (mi Item) Compare(other queue.Item) int {
|
||||
if mi.Priority > other.(Item).Priority {
|
||||
func (i Item) Compare(other queue.Item) int {
|
||||
if i.Priority > other.(Item).Priority {
|
||||
return 1
|
||||
} else if mi.Priority == other.(Item).Priority {
|
||||
} else if i.Priority == other.(Item).Priority {
|
||||
return 0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// validateHomeZone verifies that the home zone entity exists and has latitude/longitude
|
||||
// validateHomeZone verifies that the home zone entity exists and has latitude/longitude.
|
||||
func validateHomeZone(state State, entityID string) error {
|
||||
entity, err := state.Get(entityID)
|
||||
if err != nil {
|
||||
@@ -75,10 +75,7 @@ func validateHomeZone(state State, entityID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
/*
|
||||
NewApp establishes the websocket connection and returns an object
|
||||
you can use to register schedules and listeners.
|
||||
*/
|
||||
// NewApp establishes the WebSocket connection and returns an object you can use to register schedules and listeners.
|
||||
func NewApp(request types.NewAppRequest) (*App, error) {
|
||||
if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" {
|
||||
slog.Error("URL and HAAuthToken are required arguments in NewAppRequest")
|
||||
@@ -138,11 +135,9 @@ func (app *App) Cleanup() {
|
||||
}
|
||||
}
|
||||
|
||||
// Close performs a clean shutdown of the application.
|
||||
// It cancels the context, closes the websocket connection,
|
||||
// and ensures all background processes are properly terminated.
|
||||
// Close performs a clean shutdown of the application. It cancels the context, closes the WebSocket connection, and ensures all background processes are properly terminated.
|
||||
func (app *App) Close() error {
|
||||
// Close websocket connection if it exists
|
||||
// Close WebSocket connection if it exists
|
||||
if app.conn != nil {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
err := app.conn.Conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), deadline)
|
||||
@@ -151,15 +146,15 @@ func (app *App) Close() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Close the websocket connection
|
||||
// Close the WebSocket connection
|
||||
err = app.conn.Conn.Close()
|
||||
if err != nil {
|
||||
slog.Warn("Error closing websocket connection", "error", err)
|
||||
slog.Warn("Error closing WebSocket connection", "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a short time for the websocket connection to close
|
||||
// Wait a short time for the WebSocket connection to close
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Cancel context to signal all goroutines to stop
|
||||
@@ -338,7 +333,7 @@ func (app *App) Start() {
|
||||
select {
|
||||
case msg, ok := <-elChan:
|
||||
if !ok {
|
||||
slog.Info("Websocket channel closed, stopping main loop")
|
||||
slog.Info("WebSocket channel closed, stopping main loop")
|
||||
return
|
||||
}
|
||||
if app.entityListenersId == msg.Id {
|
||||
|
||||
@@ -133,11 +133,11 @@ func TestAppWithNilFields(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppWithWebsocketConnection(t *testing.T) {
|
||||
// Test app with websocket connection (mocked)
|
||||
// Test app with WebSocket connection (mocked)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
ctxCancel: func() {},
|
||||
conn: nil, // In real test, this would be a mock websocket
|
||||
conn: nil, // In real test, this would be a mock WebSocket
|
||||
}
|
||||
|
||||
// Test that Close() handles nil connection gracefully
|
||||
|
||||
@@ -67,7 +67,7 @@ func toCamelCase(s string) string {
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// validateHomeZone verifies that the home zone entity exists and is valid
|
||||
// validateHomeZone verifies that the home zone entity exists and is valid.
|
||||
func validateHomeZone(state ha.State, entityID string) error {
|
||||
entity, err := state.Get(entityID)
|
||||
if err != nil {
|
||||
@@ -93,7 +93,7 @@ func validateHomeZone(state ha.State, entityID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generate creates the entities.go file with constants for all Home Assistant entities
|
||||
// generate creates the entities.go file with constants for all Home Assistant entities.
|
||||
func generate(config Config) error {
|
||||
if config.HomeZoneEntityId == "" {
|
||||
config.HomeZoneEntityId = "zone.home"
|
||||
|
||||
@@ -67,8 +67,6 @@ type msgState struct {
|
||||
Attributes map[string]any `json:"attributes"`
|
||||
}
|
||||
|
||||
/* Methods */
|
||||
|
||||
func NewEntityListener() elBuilder1 {
|
||||
return elBuilder1{EntityListener{
|
||||
lastRan: carbon.Now().StartOfCentury(),
|
||||
@@ -154,10 +152,8 @@ func (b elBuilder3) RunOnStartup() elBuilder3 {
|
||||
return b
|
||||
}
|
||||
|
||||
/*
|
||||
Enable this listener only when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// EnabledWhen enables this listener only when the current state of {entityId} matches {state}.
|
||||
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
func (b elBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool) elBuilder3 {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
|
||||
@@ -171,10 +167,8 @@ func (b elBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool)
|
||||
return b
|
||||
}
|
||||
|
||||
/*
|
||||
Disable this listener when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// DisabledWhen disables this listener when the current state of {entityId} matches {state}.
|
||||
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
func (b elBuilder3) DisabledWhen(entityId, state string, runOnNetworkError bool) elBuilder3 {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
|
||||
|
||||
@@ -34,8 +34,6 @@ type EventData struct {
|
||||
RawEventJSON []byte
|
||||
}
|
||||
|
||||
/* Methods */
|
||||
|
||||
func NewEventListener() eventListenerBuilder1 {
|
||||
return eventListenerBuilder1{EventListener{
|
||||
lastRan: carbon.Now().StartOfCentury(),
|
||||
@@ -96,10 +94,8 @@ func (b eventListenerBuilder3) ExceptionRange(start, end time.Time) eventListene
|
||||
return b
|
||||
}
|
||||
|
||||
/*
|
||||
Enable this listener only when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// EnabledWhen enables this listener only when the current state of {entityId} matches {state}.
|
||||
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
func (b eventListenerBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool) eventListenerBuilder3 {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in eventListener EnabledWhen entityId='%s' state='%s' runOnNetworkError='%t'", entityId, state, runOnNetworkError))
|
||||
@@ -113,10 +109,8 @@ func (b eventListenerBuilder3) EnabledWhen(entityId, state string, runOnNetworkE
|
||||
return b
|
||||
}
|
||||
|
||||
/*
|
||||
Disable this listener when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// DisabledWhen disables this listener when the current state of {entityId} matches {state}.
|
||||
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
|
||||
func (b eventListenerBuilder3) DisabledWhen(entityId, state string, runOnNetworkError bool) eventListenerBuilder3 {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in eventListener EnabledWhen entityId='%s' state='%s' runOnNetworkError='%t'", entityId, state, runOnNetworkError))
|
||||
@@ -140,7 +134,6 @@ type BaseEventMsg struct {
|
||||
} `json:"event"`
|
||||
}
|
||||
|
||||
/* Functions */
|
||||
func callEventListeners(app *App, msg connect.ChannelMessage) {
|
||||
baseEventMsg := BaseEventMsg{}
|
||||
_ = json.Unmarshal(msg.Raw, &baseEventMsg)
|
||||
|
||||
1
go.mod
1
go.mod
@@ -18,6 +18,7 @@ require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/kr/pretty v0.1.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
)
|
||||
|
||||
2
go.sum
2
go.sum
@@ -17,6 +17,8 @@ github.com/nathan-osman/go-sunrise v1.1.0/go.mod h1:RcWqhT+5ShCZDev79GuWLayetpJp
|
||||
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
|
||||
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
// Package websocket is used to interact with the Home Assistant
|
||||
// websocket API. All HA interaction is done via websocket
|
||||
// except for cases explicitly called out in http package
|
||||
// documentation.
|
||||
package connect
|
||||
|
||||
import (
|
||||
@@ -20,13 +16,13 @@ import (
|
||||
|
||||
var ErrInvalidToken = errors.New("invalid authentication token")
|
||||
|
||||
// HAConnection is a wrapper around a websocket connection that provides a mutex for thread safety.
|
||||
// HAConnection is a wrapper around a WebSocket connection that provides a mutex for thread safety.
|
||||
type HAConnection struct {
|
||||
Conn *websocket.Conn // Note: this is not thread safe except for Close() and WriteControl()
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// WriteMessage writes a message to the websocket connection.
|
||||
// WriteMessage writes a message to the WebSocket connection.
|
||||
func (w *HAConnection) WriteMessage(msg any) error {
|
||||
w.mutex.Lock()
|
||||
defer w.mutex.Unlock()
|
||||
@@ -34,7 +30,7 @@ func (w *HAConnection) WriteMessage(msg any) error {
|
||||
return w.Conn.WriteJSON(msg)
|
||||
}
|
||||
|
||||
// ReadMessageRaw reads a raw message from the websocket connection.
|
||||
// ReadMessageRaw reads a raw message from the WebSocket connection.
|
||||
func ReadMessageRaw(conn *websocket.Conn) ([]byte, error) {
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
@@ -43,7 +39,7 @@ func ReadMessageRaw(conn *websocket.Conn) ([]byte, error) {
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// ReadMessage reads a message from the websocket connection and unmarshals it into the given type.
|
||||
// ReadMessage reads a message from the WebSocket connection and unmarshals it into the given type.
|
||||
func ReadMessage[T any](conn *websocket.Conn) (T, error) {
|
||||
var result T
|
||||
_, msg, err := conn.ReadMessage()
|
||||
@@ -59,14 +55,14 @@ func ReadMessage[T any](conn *websocket.Conn) (T, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ConnectionFromUri creates a new websocket connection from the given base URL and authentication token.
|
||||
// ConnectionFromUri creates a new WebSocket connection from the given base URL and authentication token.
|
||||
func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.Context, context.CancelFunc, error) {
|
||||
// Build the websocket URL
|
||||
// Build the WebSocket URL
|
||||
urlWebsockets := *baseUrl
|
||||
urlWebsockets.Path = "/api/websocket"
|
||||
scheme, err := internal.GetEquivalentWebsocketScheme(baseUrl.Scheme)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to build websocket URL: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("failed to build WebSocket URL: %w", err)
|
||||
}
|
||||
urlWebsockets.Scheme = scheme
|
||||
|
||||
@@ -74,11 +70,11 @@ func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.C
|
||||
connCtx, connCtxCancel := context.WithTimeout(context.Background(), time.Second*3)
|
||||
defer connCtxCancel() // Always cancel the connection context when we're done
|
||||
|
||||
// Init websocket connection
|
||||
// Init WebSocket connection
|
||||
dialer := websocket.DefaultDialer
|
||||
conn, _, err := dialer.DialContext(connCtx, urlWebsockets.String(), nil)
|
||||
if err != nil {
|
||||
slog.Error("Failed to connect to websocket. Check URI\n", "url", urlWebsockets)
|
||||
slog.Error("Failed to connect to WebSocket. Check URI\n", "url", urlWebsockets)
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
@@ -87,7 +83,7 @@ func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.C
|
||||
MsgType string `json:"type"`
|
||||
}](conn)
|
||||
if err != nil {
|
||||
slog.Error("Unknown error creating websocket client\n")
|
||||
slog.Error("Unknown error creating WebSocket client\n")
|
||||
return nil, nil, nil, err
|
||||
} else if msg.MsgType != "auth_required" {
|
||||
slog.Error("Expected auth_required message, got", "msgType", msg.MsgType)
|
||||
@@ -97,7 +93,7 @@ func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.C
|
||||
// Send auth message
|
||||
err = SendAuthMessage(conn, connCtx, token)
|
||||
if err != nil {
|
||||
slog.Error("Unknown error creating websocket client\n")
|
||||
slog.Error("Unknown error creating WebSocket client\n")
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
@@ -114,7 +110,7 @@ func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.C
|
||||
return &HAConnection{Conn: conn}, appCtx, appCtxCancel, nil
|
||||
}
|
||||
|
||||
// SendAuthMessage sends an auth message to the websocket connection.
|
||||
// SendAuthMessage sends an auth message to the WebSocket connection.
|
||||
func SendAuthMessage(conn *websocket.Conn, ctx context.Context, token string) error {
|
||||
type AuthMessage struct {
|
||||
MsgType string `json:"type"`
|
||||
@@ -174,7 +170,7 @@ func SubscribeToEventType(eventType string, conn *HAConnection, ctx context.Cont
|
||||
err := conn.WriteMessage(e)
|
||||
// TODO: Handle errors better
|
||||
if err != nil {
|
||||
wrappedErr := fmt.Errorf("error writing to websocket: %w", err)
|
||||
wrappedErr := fmt.Errorf("error writing to WebSocket: %w", err)
|
||||
slog.Error(wrappedErr.Error())
|
||||
panic(wrappedErr)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// BaseMessage is the base message type for all messages sent by the websocket server.
|
||||
// BaseMessage is the base message type for all messages sent by the WebSocket server.
|
||||
type BaseMessage struct {
|
||||
Type string `json:"type"`
|
||||
Id int64 `json:"id"`
|
||||
@@ -21,14 +21,14 @@ type ChannelMessage struct {
|
||||
Raw []byte
|
||||
}
|
||||
|
||||
// ListenWebsocket reads messages from the websocket connection and sends them to the channel.
|
||||
// ListenWebsocket reads messages from the WebSocket connection and sends them to the channel.
|
||||
// It will close the channel if it encounters an error, or if the channel is full, and return.
|
||||
// It ignores errors in deserialization.
|
||||
func ListenWebsocket(conn *websocket.Conn, c chan ChannelMessage) {
|
||||
for {
|
||||
raw, err := ReadMessageRaw(conn)
|
||||
if err != nil {
|
||||
slog.Error("Error reading from websocket", "err", err)
|
||||
slog.Error("Error reading from WebSocket", "err", err)
|
||||
close(c)
|
||||
break
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func ListenWebsocket(conn *websocket.Conn, c chan ChannelMessage) {
|
||||
// Message sent successfully
|
||||
default:
|
||||
// Channel is full or closed, break out of loop
|
||||
slog.Warn("Websocket message channel is full or closed, stopping listener",
|
||||
slog.Warn("WebSocket message channel is full or closed, stopping listener",
|
||||
"channel_capacity", cap(c),
|
||||
"channel_length", len(c))
|
||||
close(c)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// http is used to interact with the home assistant
|
||||
// REST API. Currently only used to retrieve state for
|
||||
// a single entity_id
|
||||
package internal
|
||||
|
||||
import (
|
||||
@@ -44,7 +41,7 @@ func NewHttpClient(ctx context.Context, baseUrl *url.URL, token string) *HttpCli
|
||||
}
|
||||
}
|
||||
|
||||
// getRequest returns a new request
|
||||
// getRequest returns a new request.
|
||||
func (c *HttpClient) getRequest() *resty.Request {
|
||||
return c.baseRequest.Clone(c.client.Context())
|
||||
}
|
||||
|
||||
@@ -21,8 +21,7 @@ var (
|
||||
id atomic.Int64 // default value is 0
|
||||
)
|
||||
|
||||
// NextId returns a unique integer (for the given process), often used for providing a uniquely identifiable
|
||||
// id for a request. This function is thread-safe.
|
||||
// NextId returns a unique integer (for the given process), often used for providing a uniquely identifiable ID for a request. This function is thread-safe.
|
||||
func NextId() int64 {
|
||||
return id.Add(1)
|
||||
}
|
||||
@@ -32,7 +31,7 @@ func GetFunctionName(i interface{}) string {
|
||||
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
|
||||
}
|
||||
|
||||
// GetEquivalentWebsocketScheme returns the equivalent websocket scheme for the given scheme.
|
||||
// GetEquivalentWebsocketScheme returns the equivalent WebSocket scheme for the given scheme.
|
||||
// If the scheme is http or https, it returns ws or wss respectively.
|
||||
// If the scheme is ws or wss, it returns the same scheme.
|
||||
// If the scheme is not any of the above, it returns an error.
|
||||
@@ -48,3 +47,7 @@ func GetEquivalentWebsocketScheme(scheme string) (string, error) {
|
||||
return "", fmt.Errorf("unexpected scheme: %s", scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
// Parses a HH:MM string.
|
||||
// ParseTime parses a HH:MM string.
|
||||
func ParseTime(s string) *carbon.Carbon {
|
||||
t, err := time.Parse("15:04", s)
|
||||
if err != nil {
|
||||
parsingErr := fmt.Errorf("failed to parse time string \"%s\"; format must be HH:MM.: %w", s, err)
|
||||
parsingErr := fmt.Errorf("failed to parse time string \"%s\"; format must be HH:MM: %w", s, err)
|
||||
slog.Error(parsingErr.Error())
|
||||
panic(parsingErr)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func ParseTime(s string) *carbon.Carbon {
|
||||
func ParseDuration(s string) time.Duration {
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
parsingErr := fmt.Errorf("couldn't parse string duration: \"%s\" see https://pkg.go.dev/time#ParseDuration for valid time units: %w", s, err)
|
||||
parsingErr := fmt.Errorf("couldn't parse string duration: \"%s\"; see https://pkg.go.dev/time#ParseDuration for valid time units: %w", s, err)
|
||||
slog.Error(parsingErr.Error())
|
||||
panic(parsingErr)
|
||||
}
|
||||
|
||||
114
internal/scheduling/builder.go
Normal file
114
internal/scheduling/builder.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/types"
|
||||
)
|
||||
|
||||
type DailyScheduleBuilder struct {
|
||||
errors []error
|
||||
hashes map[uint64]bool
|
||||
triggers []Trigger
|
||||
}
|
||||
|
||||
func NewSchedule() *DailyScheduleBuilder {
|
||||
return &DailyScheduleBuilder{
|
||||
hashes: make(map[uint64]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// tryAddTrigger adds a trigger to the builder if it is not already present.
|
||||
// If the trigger is already present, an error will be added to the builder's errors.
|
||||
// It will return the builder for chaining.
|
||||
func (b *DailyScheduleBuilder) tryAddTrigger(trigger Trigger) *DailyScheduleBuilder {
|
||||
hash := trigger.Hash()
|
||||
if _, ok := b.hashes[hash]; ok {
|
||||
b.errors = append(b.errors, fmt.Errorf("duplicate trigger: %v", trigger))
|
||||
return b
|
||||
}
|
||||
|
||||
b.triggers = append(b.triggers, trigger)
|
||||
b.hashes[hash] = true
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *DailyScheduleBuilder) onSun(sunset bool, offset ...types.DurationString) *DailyScheduleBuilder {
|
||||
if len(offset) == 0 {
|
||||
b.errors = append(b.errors, fmt.Errorf("no offset provided for sun"))
|
||||
return b
|
||||
}
|
||||
|
||||
offsetDuration, err := time.ParseDuration(string(offset[0]))
|
||||
if err != nil {
|
||||
b.errors = append(b.errors, err)
|
||||
return b
|
||||
}
|
||||
|
||||
return b.tryAddTrigger(&SunTrigger{
|
||||
sunset: sunset,
|
||||
offset: &offsetDuration,
|
||||
})
|
||||
}
|
||||
|
||||
// OnSunrise adds a trigger for sunrise with an optional offset.
|
||||
// Only the first offset is considered.
|
||||
// You can call this multiple times to add multiple triggers for sunrise with different offsets.
|
||||
func (b *DailyScheduleBuilder) OnSunrise(offset ...types.DurationString) *DailyScheduleBuilder {
|
||||
return b.onSun(false, offset...)
|
||||
}
|
||||
|
||||
// OnSunset adds a trigger for sunset with an optional offset.
|
||||
// Only the first offset is considered.
|
||||
func (b *DailyScheduleBuilder) OnSunset(offset ...types.DurationString) *DailyScheduleBuilder {
|
||||
return b.onSun(true, offset...)
|
||||
}
|
||||
|
||||
// OnFixedTime adds a trigger for a fixed time each day.
|
||||
// The time is in the local timezone.
|
||||
// This will error if the integer values are not in the range 0-23 for the hour and 0-59 for the minute.
|
||||
func (b *DailyScheduleBuilder) OnFixedTime(hour, minute int) *DailyScheduleBuilder {
|
||||
errored := false
|
||||
if hour < 0 || hour > 23 {
|
||||
b.errors = append(b.errors, fmt.Errorf("hour must be between 0 and 23"))
|
||||
errored = true
|
||||
}
|
||||
|
||||
if minute < 0 || minute > 59 {
|
||||
b.errors = append(b.errors, fmt.Errorf("minute must be between 0 and 59"))
|
||||
errored = true
|
||||
}
|
||||
|
||||
if errored {
|
||||
return b
|
||||
}
|
||||
|
||||
return b.tryAddTrigger(&FixedTimeTrigger{
|
||||
Hour: hour,
|
||||
Minute: minute,
|
||||
})
|
||||
}
|
||||
|
||||
// Build returns a Trigger that will trigger at the configured times.
|
||||
// It will return an error if any errors occurred during configuration.
|
||||
func (b *DailyScheduleBuilder) Build() (Trigger, error) {
|
||||
// If there are no triggers, add an error.
|
||||
if len(b.triggers) == 0 {
|
||||
b.errors = append(b.errors, fmt.Errorf("no triggers provided"))
|
||||
}
|
||||
|
||||
// If there are errors, return an error.
|
||||
if len(b.errors) > 0 {
|
||||
return nil, fmt.Errorf("errors occurred: %v", b.errors)
|
||||
}
|
||||
|
||||
// If there is only one trigger, return it.
|
||||
if len(b.triggers) == 1 {
|
||||
return b.triggers[0], nil
|
||||
}
|
||||
|
||||
// Otherwise, return a composite schedule that combines all the triggers.
|
||||
return &CompositeDailySchedule{triggers: b.triggers}, nil
|
||||
}
|
||||
353
internal/scheduling/builder_test.go
Normal file
353
internal/scheduling/builder_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewSchedule(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
assert.NotNil(t, builder)
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Empty(t, builder.triggers)
|
||||
assert.NotNil(t, builder.hashes)
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_OnFixedTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
minute int
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid time",
|
||||
hour: 12,
|
||||
minute: 30,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid hour negative",
|
||||
hour: -1,
|
||||
minute: 30,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hour too high",
|
||||
hour: 24,
|
||||
minute: 30,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid minute negative",
|
||||
hour: 12,
|
||||
minute: -1,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid minute too high",
|
||||
hour: 12,
|
||||
minute: 60,
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
result := builder.OnFixedTime(tt.hour, tt.minute)
|
||||
|
||||
assert.Equal(t, builder, result) // Should return self for chaining
|
||||
|
||||
if tt.expectError {
|
||||
assert.Len(t, builder.errors, 1)
|
||||
} else {
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Len(t, builder.triggers, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_OnSunrise(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset []types.DurationString
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "with offset",
|
||||
offset: []types.DurationString{"30m"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "with negative offset",
|
||||
offset: []types.DurationString{"-1h"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no offset",
|
||||
offset: []types.DurationString{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
offset: []types.DurationString{"invalid"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
result := builder.OnSunrise(tt.offset...)
|
||||
|
||||
assert.Equal(t, builder, result) // Should return self for chaining
|
||||
|
||||
if tt.expectError {
|
||||
assert.Len(t, builder.errors, 1)
|
||||
} else {
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Len(t, builder.triggers, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_OnSunset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
offset []types.DurationString
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "with offset",
|
||||
offset: []types.DurationString{"1h"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "with negative offset",
|
||||
offset: []types.DurationString{"-30m"},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "no offset",
|
||||
offset: []types.DurationString{},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
offset: []types.DurationString{"invalid"},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
result := builder.OnSunset(tt.offset...)
|
||||
|
||||
assert.Equal(t, builder, result) // Should return self for chaining
|
||||
|
||||
if tt.expectError {
|
||||
assert.Len(t, builder.errors, 1)
|
||||
} else {
|
||||
assert.Empty(t, builder.errors)
|
||||
assert.Len(t, builder.triggers, 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_DuplicateTriggers(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
|
||||
// Add the same fixed time trigger twice
|
||||
builder.OnFixedTime(12, 30)
|
||||
builder.OnFixedTime(12, 30)
|
||||
|
||||
assert.Len(t, builder.errors, 1)
|
||||
assert.Len(t, builder.triggers, 1) // Only one should be added
|
||||
assert.Contains(t, builder.errors[0].Error(), "duplicate trigger")
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_Build_Success(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupBuilder func(*DailyScheduleBuilder)
|
||||
expectedType string
|
||||
expectedCount int
|
||||
}{
|
||||
{
|
||||
name: "single fixed time trigger",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(12, 30)
|
||||
},
|
||||
expectedType: "*scheduling.FixedTimeTrigger",
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "single sunrise trigger",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnSunrise("30m")
|
||||
},
|
||||
expectedType: "*scheduling.SunTrigger",
|
||||
expectedCount: 1,
|
||||
},
|
||||
{
|
||||
name: "multiple triggers",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(8, 0)
|
||||
b.OnFixedTime(12, 0)
|
||||
b.OnSunrise("1h")
|
||||
},
|
||||
expectedType: "*scheduling.CompositeDailySchedule",
|
||||
expectedCount: 3,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
tt.setupBuilder(builder)
|
||||
|
||||
trigger, err := builder.Build()
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, trigger)
|
||||
assert.Equal(t, tt.expectedType, fmt.Sprintf("%T", trigger))
|
||||
|
||||
// Test that the trigger works
|
||||
now := time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local)
|
||||
result := trigger.NextTime(now)
|
||||
assert.NotNil(t, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_Build_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupBuilder func(*DailyScheduleBuilder)
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "no triggers",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
// Don't add any triggers
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid hour",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(25, 0) // Invalid hour
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid minute",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnFixedTime(12, 60) // Invalid minute
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "no offset for sun trigger",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnSunrise() // No offset
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid duration",
|
||||
setupBuilder: func(b *DailyScheduleBuilder) {
|
||||
b.OnSunset("invalid") // Invalid duration
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
tt.setupBuilder(builder)
|
||||
|
||||
trigger, err := builder.Build()
|
||||
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, trigger)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, trigger)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_Chaining(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
|
||||
// Test method chaining
|
||||
result := builder.
|
||||
OnFixedTime(8, 0).
|
||||
OnFixedTime(12, 0).
|
||||
OnSunrise("30m")
|
||||
|
||||
assert.Equal(t, builder, result)
|
||||
assert.Len(t, builder.triggers, 3)
|
||||
assert.Empty(t, builder.errors)
|
||||
}
|
||||
|
||||
func TestDailyScheduleBuilder_NextTime_Integration(t *testing.T) {
|
||||
builder := NewSchedule()
|
||||
builder.OnFixedTime(8, 0).
|
||||
OnFixedTime(12, 0).
|
||||
OnFixedTime(18, 0)
|
||||
|
||||
trigger, err := builder.Build()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test at different times
|
||||
tests := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "before all triggers",
|
||||
now: time.Date(2025, 8, 2, 6, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 2, 8, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "between triggers",
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 2, 12, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "after all triggers",
|
||||
now: time.Date(2025, 8, 2, 20, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 3, 8, 0, 0, 0, time.Local),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := trigger.NextTime(tt.now)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, tt.expected, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
43
internal/scheduling/cron.go
Normal file
43
internal/scheduling/cron.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
// CronTrigger represents a trigger based on a cron expression.
|
||||
type CronTrigger struct {
|
||||
expression string // required for hash
|
||||
schedule cron.Schedule
|
||||
}
|
||||
|
||||
// NewCronTrigger creates a new CronTrigger from a cron expression.
|
||||
func NewCronTrigger(expression string) (*CronTrigger, error) {
|
||||
// Use the standard cron parser
|
||||
parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
|
||||
schedule, err := parser.Parse(expression)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid cron expression: %w", err)
|
||||
}
|
||||
|
||||
return &CronTrigger{
|
||||
expression: expression,
|
||||
schedule: schedule,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NextTime calculates the next occurrence of this cron trigger after the given time.
|
||||
func (t *CronTrigger) NextTime(now time.Time) *time.Time {
|
||||
next := t.schedule.Next(now)
|
||||
return &next
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the CronTrigger.
|
||||
func (t *CronTrigger) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
fmt.Fprintf(h, "cron:%s", t.expression)
|
||||
return h.Sum64()
|
||||
}
|
||||
131
internal/scheduling/cron_test.go
Normal file
131
internal/scheduling/cron_test.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package scheduling_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/internal/scheduling"
|
||||
)
|
||||
|
||||
func TestCronTrigger(t *testing.T) {
|
||||
// Use a fixed time for consistent testing
|
||||
baseTime := time.Date(2025, 8, 2, 10, 30, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cron string
|
||||
now time.Time
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "daily at 9am",
|
||||
cron: "0 9 * * *",
|
||||
now: baseTime,
|
||||
expected: time.Date(2025, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "every 15 minutes",
|
||||
cron: "*/15 * * * *",
|
||||
now: baseTime,
|
||||
expected: time.Date(2025, 8, 2, 10, 45, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "weekdays at 8am (Saturday)",
|
||||
// Base time is a Saturday, so next run should be Monday
|
||||
cron: "0 8 * * 1-5",
|
||||
now: time.Date(2025, 8, 2, 10, 30, 0, 0, time.UTC),
|
||||
expected: time.Date(2025, 8, 4, 8, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "weekdays at 8am (Sunday)",
|
||||
// Base time is a Sunday, so next run should be Monday
|
||||
cron: "0 8 * * 1-5",
|
||||
now: time.Date(2025, 8, 3, 10, 30, 0, 0, time.UTC),
|
||||
expected: time.Date(2025, 8, 4, 8, 0, 0, 0, time.UTC),
|
||||
},
|
||||
|
||||
{
|
||||
name: "monthly on 1st",
|
||||
cron: "0 0 1 * *",
|
||||
now: baseTime,
|
||||
expected: time.Date(2025, 9, 1, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
{
|
||||
name: "specific time today",
|
||||
cron: "0 14 * * *",
|
||||
now: baseTime,
|
||||
expected: time.Date(2025, 8, 2, 14, 0, 0, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger, err := scheduling.NewCronTrigger(tt.cron)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cron trigger: %v", err)
|
||||
}
|
||||
|
||||
next := trigger.NextTime(tt.now)
|
||||
if next == nil {
|
||||
t.Fatal("Expected next time, got nil")
|
||||
}
|
||||
|
||||
if !next.Equal(tt.expected) {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, *next)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronTriggerInvalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
}{
|
||||
{
|
||||
name: "bad pattern",
|
||||
expression: "invalid",
|
||||
},
|
||||
{
|
||||
name: "requires 5 fields - too few",
|
||||
expression: "4",
|
||||
},
|
||||
{
|
||||
name: "requires 5 fields - missing field",
|
||||
expression: "0 9 * *",
|
||||
},
|
||||
{
|
||||
name: "too many fields",
|
||||
expression: "0 9 * * * *",
|
||||
},
|
||||
{
|
||||
name: "invalid minute",
|
||||
expression: "60 9 * * *",
|
||||
},
|
||||
{
|
||||
name: "invalid hour",
|
||||
expression: "0 25 * * *",
|
||||
},
|
||||
{
|
||||
name: "invalid day of month",
|
||||
expression: "0 9 32 * *",
|
||||
},
|
||||
{
|
||||
name: "invalid month",
|
||||
expression: "0 9 * 13 *",
|
||||
},
|
||||
{
|
||||
name: "invalid day of week",
|
||||
expression: "0 9 * * 7",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := scheduling.NewCronTrigger(tt.expression)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error for invalid expression %q", tt.expression)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
110
internal/scheduling/daily.go
Normal file
110
internal/scheduling/daily.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
|
||||
"github.com/Xevion/go-ha/internal"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/nathan-osman/go-sunrise"
|
||||
)
|
||||
|
||||
type Trigger interface {
|
||||
// NextTime calculates the next occurrence of this trigger after the given time
|
||||
NextTime(now time.Time) *time.Time
|
||||
Hash() uint64
|
||||
}
|
||||
|
||||
// FixedTimeTrigger represents a trigger at a specific hour and minute each day
|
||||
type FixedTimeTrigger struct {
|
||||
Hour int // 0-23
|
||||
Minute int // 0-59
|
||||
}
|
||||
|
||||
// SunTrigger represents a trigger based on sunrise or sunset with optional offset
|
||||
type SunTrigger struct {
|
||||
latitude float64 // latitude of the location
|
||||
longitude float64 // longitude of the location
|
||||
sunset bool // true for sunset, false for sunrise
|
||||
offset *time.Duration // offset from sun event (can be negative)
|
||||
}
|
||||
|
||||
func (t *FixedTimeTrigger) NextTime(now time.Time) *time.Time {
|
||||
next := carbon.NewCarbon(now).SetHour(t.Hour).SetMinute(t.Minute)
|
||||
|
||||
// If the calculated time is before or equal to now, advance to the next day
|
||||
if !next.StdTime().After(now) {
|
||||
next = next.AddDay()
|
||||
}
|
||||
|
||||
return internal.Ptr(next.StdTime().Local())
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the FixedTimeTrigger
|
||||
func (t *FixedTimeTrigger) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
fmt.Fprintf(h, "%d:%d", t.Hour, t.Minute)
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// NextTime returns the next time the sun will rise or set. If an offset is provided, it will be added to the calculated time.
|
||||
func (t *SunTrigger) NextTime(now time.Time) *time.Time {
|
||||
var sun time.Time
|
||||
|
||||
if t.sunset {
|
||||
_, sun = sunrise.SunriseSunset(t.latitude, t.longitude, now.Year(), now.Month(), now.Day())
|
||||
} else {
|
||||
sun, _ = sunrise.SunriseSunset(t.latitude, t.longitude, now.Year(), now.Month(), now.Day())
|
||||
}
|
||||
|
||||
// In the case that the sun does not rise or set on the given day, return nil
|
||||
if sun.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
sun = sun.Local() // Convert to local time
|
||||
if t.offset != nil && *t.offset != 0 {
|
||||
sun = sun.Add(*t.offset) // Add the offset if provided and not zero
|
||||
}
|
||||
|
||||
return &sun
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the SunTrigger
|
||||
func (t *SunTrigger) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
fmt.Fprintf(h, "%f:%f:%t", t.latitude, t.longitude, t.sunset)
|
||||
if t.offset != nil {
|
||||
fmt.Fprintf(h, ":%d", t.offset.Nanoseconds())
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
|
||||
// CompositeDailySchedule combines multiple triggers into a single daily schedule.
|
||||
type CompositeDailySchedule struct {
|
||||
triggers []Trigger
|
||||
}
|
||||
|
||||
// NextTime returns the next time the first viable trigger will run.
|
||||
func (c *CompositeDailySchedule) NextTime(now time.Time) *time.Time {
|
||||
best := c.triggers[0].NextTime(now)
|
||||
|
||||
for _, trigger := range c.triggers[1:] {
|
||||
potential := trigger.NextTime(now)
|
||||
if potential != nil && (best == nil || potential.Before(*best)) {
|
||||
best = potential
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the CompositeDailySchedule
|
||||
func (c *CompositeDailySchedule) Hash() uint64 {
|
||||
h := fnv.New64()
|
||||
for _, trigger := range c.triggers {
|
||||
fmt.Fprintf(h, "%d", trigger.Hash())
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
300
internal/scheduling/daily_test.go
Normal file
300
internal/scheduling/daily_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFixedTimeTrigger_NextTime(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
minute int
|
||||
now time.Time
|
||||
expected time.Time
|
||||
}{
|
||||
{
|
||||
name: "same day trigger",
|
||||
hour: 14,
|
||||
minute: 30,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 2, 14, 30, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "next day trigger",
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 3, 8, 0, 0, 0, time.Local),
|
||||
},
|
||||
{
|
||||
name: "exact time",
|
||||
hour: 10,
|
||||
minute: 0,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: time.Date(2025, 8, 3, 10, 0, 0, 0, time.Local),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &FixedTimeTrigger{
|
||||
Hour: tt.hour,
|
||||
Minute: tt.minute,
|
||||
}
|
||||
|
||||
result := trigger.NextTime(tt.now)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, tt.expected, *result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedTimeTrigger_Hash(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
hour int
|
||||
minute int
|
||||
expected uint64
|
||||
}{
|
||||
{
|
||||
name: "basic time",
|
||||
hour: 12,
|
||||
minute: 30,
|
||||
expected: 0, // We'll check it's not zero
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
expected: 0, // We'll check it's not zero
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &FixedTimeTrigger{
|
||||
Hour: tt.hour,
|
||||
Minute: tt.minute,
|
||||
}
|
||||
|
||||
hash := trigger.Hash()
|
||||
assert.NotZero(t, hash)
|
||||
assert.IsType(t, uint64(0), hash)
|
||||
})
|
||||
}
|
||||
|
||||
// Test that different times produce different hashes
|
||||
trigger1 := &FixedTimeTrigger{Hour: 12, Minute: 30}
|
||||
trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 31}
|
||||
trigger3 := &FixedTimeTrigger{Hour: 13, Minute: 30}
|
||||
|
||||
hash1 := trigger1.Hash()
|
||||
hash2 := trigger2.Hash()
|
||||
hash3 := trigger3.Hash()
|
||||
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
assert.NotEqual(t, hash2, hash3)
|
||||
|
||||
// Test that same times produce same hashes
|
||||
trigger4 := &FixedTimeTrigger{Hour: 12, Minute: 30}
|
||||
hash4 := trigger4.Hash()
|
||||
assert.Equal(t, hash1, hash4)
|
||||
}
|
||||
|
||||
func TestSunTrigger_NextTime(t *testing.T) {
|
||||
// Test with a known location (New York City)
|
||||
lat, lon := 40.7128, -74.0060
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sunset bool
|
||||
offset *time.Duration
|
||||
now time.Time
|
||||
expected bool // whether we expect a result
|
||||
}{
|
||||
{
|
||||
name: "sunrise without offset",
|
||||
sunset: false,
|
||||
offset: nil,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sunset without offset",
|
||||
sunset: true,
|
||||
offset: nil,
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sunrise with positive offset",
|
||||
sunset: false,
|
||||
offset: func() *time.Duration { d := 30 * time.Minute; return &d }(),
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "sunset with negative offset",
|
||||
sunset: true,
|
||||
offset: func() *time.Duration { d := -1 * time.Hour; return &d }(),
|
||||
now: time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &SunTrigger{
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
sunset: tt.sunset,
|
||||
offset: tt.offset,
|
||||
}
|
||||
|
||||
result := trigger.NextTime(tt.now)
|
||||
if tt.expected {
|
||||
require.NotNil(t, result)
|
||||
assert.False(t, result.IsZero())
|
||||
} else {
|
||||
// For polar regions or extreme dates, sun might not rise/set
|
||||
// This is acceptable behavior
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSunTrigger_Hash(t *testing.T) {
|
||||
lat1, lon1 := 40.7128, -74.0060
|
||||
lat2, lon2 := 51.5074, -0.1278
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lat float64
|
||||
lon float64
|
||||
sunset bool
|
||||
offset *time.Duration
|
||||
}{
|
||||
{
|
||||
name: "sunrise without offset",
|
||||
lat: lat1,
|
||||
lon: lon1,
|
||||
sunset: false,
|
||||
offset: nil,
|
||||
},
|
||||
{
|
||||
name: "sunset with offset",
|
||||
lat: lat1,
|
||||
lon: lon1,
|
||||
sunset: true,
|
||||
offset: func() *time.Duration { d := 30 * time.Minute; return &d }(),
|
||||
},
|
||||
{
|
||||
name: "different location",
|
||||
lat: lat2,
|
||||
lon: lon2,
|
||||
sunset: false,
|
||||
offset: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trigger := &SunTrigger{
|
||||
latitude: tt.lat,
|
||||
longitude: tt.lon,
|
||||
sunset: tt.sunset,
|
||||
offset: tt.offset,
|
||||
}
|
||||
|
||||
hash := trigger.Hash()
|
||||
assert.NotZero(t, hash)
|
||||
assert.IsType(t, uint64(0), hash)
|
||||
})
|
||||
}
|
||||
|
||||
// Test that different configurations produce different hashes
|
||||
trigger1 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: false, offset: nil}
|
||||
trigger2 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: true, offset: nil}
|
||||
trigger3 := &SunTrigger{latitude: lat2, longitude: lon2, sunset: false, offset: nil}
|
||||
|
||||
hash1 := trigger1.Hash()
|
||||
hash2 := trigger2.Hash()
|
||||
hash3 := trigger3.Hash()
|
||||
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
assert.NotEqual(t, hash2, hash3)
|
||||
|
||||
// Test that same configurations produce same hashes
|
||||
trigger4 := &SunTrigger{latitude: lat1, longitude: lon1, sunset: false, offset: nil}
|
||||
hash4 := trigger4.Hash()
|
||||
assert.Equal(t, hash1, hash4)
|
||||
}
|
||||
|
||||
func TestCompositeDailySchedule_NextTime(t *testing.T) {
|
||||
trigger1 := &FixedTimeTrigger{Hour: 8, Minute: 0}
|
||||
trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 0}
|
||||
trigger3 := &FixedTimeTrigger{Hour: 18, Minute: 0}
|
||||
|
||||
composite := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1, trigger2, trigger3},
|
||||
}
|
||||
|
||||
now := time.Date(2025, 8, 2, 10, 0, 0, 0, time.Local)
|
||||
result := composite.NextTime(now)
|
||||
|
||||
require.NotNil(t, result)
|
||||
// Should return the earliest trigger after now (12:00)
|
||||
expected := time.Date(2025, 8, 2, 12, 0, 0, 0, time.Local)
|
||||
assert.Equal(t, expected, *result)
|
||||
}
|
||||
|
||||
func TestCompositeDailySchedule_Hash(t *testing.T) {
|
||||
trigger1 := &FixedTimeTrigger{Hour: 8, Minute: 0}
|
||||
trigger2 := &FixedTimeTrigger{Hour: 12, Minute: 0}
|
||||
|
||||
composite1 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1, trigger2},
|
||||
}
|
||||
|
||||
composite2 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger2, trigger1}, // Different order
|
||||
}
|
||||
|
||||
composite3 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1}, // Different number of triggers
|
||||
}
|
||||
|
||||
hash1 := composite1.Hash()
|
||||
hash2 := composite2.Hash()
|
||||
hash3 := composite3.Hash()
|
||||
|
||||
assert.NotZero(t, hash1)
|
||||
assert.NotZero(t, hash2)
|
||||
assert.NotZero(t, hash3)
|
||||
assert.IsType(t, uint64(0), hash1)
|
||||
|
||||
// Different orders should produce different hashes
|
||||
assert.NotEqual(t, hash1, hash2)
|
||||
assert.NotEqual(t, hash1, hash3)
|
||||
assert.NotEqual(t, hash2, hash3)
|
||||
|
||||
// Same configuration should produce same hash
|
||||
composite4 := &CompositeDailySchedule{
|
||||
triggers: []Trigger{trigger1, trigger2},
|
||||
}
|
||||
hash4 := composite4.Hash()
|
||||
assert.Equal(t, hash1, hash4)
|
||||
}
|
||||
|
||||
func TestTriggerInterface(t *testing.T) {
|
||||
// Test that all trigger types implement the Trigger interface
|
||||
var _ Trigger = &FixedTimeTrigger{}
|
||||
var _ Trigger = &SunTrigger{}
|
||||
var _ Trigger = &CompositeDailySchedule{}
|
||||
}
|
||||
91
internal/scheduling/interval.go
Normal file
91
internal/scheduling/interval.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IntervalTrigger represents a trigger that fires at a sequence of intervals.
|
||||
type IntervalTrigger struct {
|
||||
intervals []time.Duration // required for hash
|
||||
epoch time.Time // required for hash
|
||||
totalDuration time.Duration
|
||||
}
|
||||
|
||||
// NewIntervalTrigger creates a new IntervalTrigger from one or more durations.
|
||||
// An error is returned if no intervals are provided or if any interval is not positive.
|
||||
// The epoch is the reference point for all interval calculations.
|
||||
// The duration between each time alternates between each interval (or, if there is only one interval, it is the interval).
|
||||
// For example, if the intervals are [1h, 2h, 3h], the first time will be at epoch + 1h, the second time will be at
|
||||
// epoch + 1h + 2h, the third time will be at epoch + 1h + 2h + 3h, and so on.
|
||||
func NewIntervalTrigger(interval time.Duration, additional ...time.Duration) (*IntervalTrigger, error) {
|
||||
if interval <= 0 {
|
||||
return nil, fmt.Errorf("intervals must be positive")
|
||||
}
|
||||
totalDuration := interval
|
||||
for _, d := range additional {
|
||||
if d <= 0 {
|
||||
return nil, fmt.Errorf("intervals must be positive")
|
||||
}
|
||||
totalDuration += d
|
||||
}
|
||||
|
||||
return &IntervalTrigger{
|
||||
intervals: append([]time.Duration{interval}, additional...),
|
||||
epoch: time.Time{}, // default epoch is zero time
|
||||
totalDuration: totalDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithEpoch sets the epoch time for the IntervalTrigger. The epoch is the reference point for all interval calculations.
|
||||
func (t *IntervalTrigger) WithEpoch(epoch time.Time) *IntervalTrigger {
|
||||
t.epoch = epoch
|
||||
return t
|
||||
}
|
||||
|
||||
// NextTime calculates the next occurrence of this interval trigger after the given time.
|
||||
func (t *IntervalTrigger) NextTime(now time.Time) *time.Time {
|
||||
if t.totalDuration == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
epoch := t.epoch
|
||||
if epoch.IsZero() {
|
||||
epoch = time.Unix(0, 0).UTC()
|
||||
}
|
||||
|
||||
// If the current time is before the epoch, the next time is the first one in the cycle.
|
||||
if now.Before(epoch) {
|
||||
next := epoch.Add(t.intervals[0])
|
||||
return &next
|
||||
}
|
||||
|
||||
cyclesSinceEpoch := now.Sub(epoch) / t.totalDuration
|
||||
currentCycleStart := epoch.Add(time.Duration(cyclesSinceEpoch) * t.totalDuration)
|
||||
|
||||
// Cycle through the offsets until the next time is found
|
||||
cycle := currentCycleStart
|
||||
for i := 0; i < len(t.intervals); i++ {
|
||||
cycle = cycle.Add(t.intervals[i])
|
||||
if cycle.After(now) {
|
||||
return &cycle
|
||||
}
|
||||
}
|
||||
|
||||
// If we've reached here, it means we're at the end of a cycle.
|
||||
// The next time will be the first interval of the next cycle.
|
||||
nextCycleStart := currentCycleStart.Add(t.totalDuration)
|
||||
next := nextCycleStart.Add(t.intervals[0])
|
||||
return &next
|
||||
}
|
||||
|
||||
// Hash returns a stable hash value for the IntervalTrigger.
|
||||
func (t *IntervalTrigger) Hash() uint64 {
|
||||
h := fnv.New64a()
|
||||
fmt.Fprintf(h, "interval:%d", t.epoch.UnixNano())
|
||||
for _, d := range t.intervals {
|
||||
fmt.Fprintf(h, ":%d", d)
|
||||
}
|
||||
return h.Sum64()
|
||||
}
|
||||
134
internal/scheduling/interval_test.go
Normal file
134
internal/scheduling/interval_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package scheduling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewIntervalTrigger(t *testing.T) {
|
||||
t.Run("valid single interval", func(t *testing.T) {
|
||||
trigger, err := NewIntervalTrigger(time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, trigger)
|
||||
assert.Equal(t, []time.Duration{time.Hour}, trigger.intervals)
|
||||
assert.Equal(t, time.Hour, trigger.totalDuration)
|
||||
assert.True(t, trigger.epoch.IsZero())
|
||||
})
|
||||
|
||||
t.Run("valid multiple intervals", func(t *testing.T) {
|
||||
trigger, err := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, trigger)
|
||||
assert.Equal(t, []time.Duration{time.Hour, 30 * time.Minute}, trigger.intervals)
|
||||
assert.Equal(t, time.Hour+30*time.Minute, trigger.totalDuration)
|
||||
assert.True(t, trigger.epoch.IsZero())
|
||||
})
|
||||
|
||||
t.Run("invalid zero interval", func(t *testing.T) {
|
||||
_, err := NewIntervalTrigger(time.Hour, 0)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid negative interval", func(t *testing.T) {
|
||||
_, err := NewIntervalTrigger(time.Hour, -time.Minute)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("first interval is invalid if zero", func(t *testing.T) {
|
||||
_, err := NewIntervalTrigger(0)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntervalTrigger_NextTime(t *testing.T) {
|
||||
// A known time for predictable tests
|
||||
now := time.Date(2024, 7, 25, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("single interval no epoch", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
// With a zero epoch, NextTime should calculate from the last hour boundary.
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
t.Run("single interval with aligned epoch", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
// Epoch is on an hour boundary relative to the Unix epoch, so it's not modified by WithEpoch.
|
||||
epoch := time.Date(2024, 7, 25, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
t.Run("multiple intervals", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour, 30*time.Minute) // total 1.5h
|
||||
epoch := time.Date(2024, 7, 25, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
// now = 12:00. epoch = 00:00. duration = 12h.
|
||||
// cycles = 12h / 1.5h = 8.
|
||||
// currentCycleStart = 00:00 + 8 * 1.5h = 12:00.
|
||||
// 1. 12:00 + 1h = 13:00. This is after now, so it's the next time.
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
|
||||
// Test the time after that
|
||||
now2 := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
// currentCycleStart is still 12:00.
|
||||
// 1. 12:00 + 1h = 13:00. Not after now2.
|
||||
// 2. 13:00 + 30m = 13:30. This is after now2.
|
||||
next2 := trigger.NextTime(now2)
|
||||
expected2 := time.Date(2024, 7, 25, 13, 30, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected2, *next2)
|
||||
})
|
||||
|
||||
t.Run("now before epoch", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
epoch := time.Date(2024, 7, 26, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
next := trigger.NextTime(now)
|
||||
expected := time.Date(2024, 7, 26, 1, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
t.Run("now is exactly on a trigger time", func(t *testing.T) {
|
||||
trigger, _ := NewIntervalTrigger(time.Hour)
|
||||
epoch := time.Date(2024, 7, 25, 0, 0, 0, 0, time.UTC)
|
||||
trigger.WithEpoch(epoch)
|
||||
nowOnTrigger := time.Date(2024, 7, 25, 12, 0, 0, 0, time.UTC)
|
||||
// The next trigger should be the following one.
|
||||
next := trigger.NextTime(nowOnTrigger)
|
||||
expected := time.Date(2024, 7, 25, 13, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, *next)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestIntervalTrigger_Hash(t *testing.T) {
|
||||
t.Run("stable hash for same configuration", func(t *testing.T) {
|
||||
trigger1, _ := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
trigger2, _ := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
assert.Equal(t, trigger1.Hash(), trigger2.Hash())
|
||||
})
|
||||
|
||||
t.Run("hash changes with interval", func(t *testing.T) {
|
||||
trigger1, _ := NewIntervalTrigger(time.Hour, 30*time.Minute)
|
||||
trigger2, _ := NewIntervalTrigger(time.Hour, 31*time.Minute)
|
||||
assert.NotEqual(t, trigger1.Hash(), trigger2.Hash())
|
||||
})
|
||||
|
||||
t.Run("hash changes with epoch", func(t *testing.T) {
|
||||
trigger1, _ := NewIntervalTrigger(time.Hour)
|
||||
trigger1.WithEpoch(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
trigger2, _ := NewIntervalTrigger(time.Hour)
|
||||
trigger2.WithEpoch(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
assert.NotEqual(t, trigger1.Hash(), trigger2.Hash())
|
||||
})
|
||||
}
|
||||
@@ -8,9 +8,7 @@ type AlarmControlPanel struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// Send the alarm the command for arm away.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for arm away. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
@@ -22,9 +20,7 @@ func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]
|
||||
return acp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the alarm the command for arm away.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for arm away. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
@@ -36,9 +32,7 @@ func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ..
|
||||
return acp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the alarm the command for arm home.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for arm home. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
@@ -50,9 +44,7 @@ func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]
|
||||
return acp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the alarm the command for arm night.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for arm night. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
@@ -64,9 +56,7 @@ func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string
|
||||
return acp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the alarm the command for arm vacation.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for arm vacation. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
@@ -78,9 +68,7 @@ func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[str
|
||||
return acp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the alarm the command for disarm.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for disarm. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
@@ -92,9 +80,7 @@ func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]a
|
||||
return acp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the alarm the command for trigger.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the alarm the command for trigger. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (acp AlarmControlPanel) Trigger(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "alarm_control_panel"
|
||||
|
||||
@@ -44,8 +44,7 @@ func (c Cover) OpenTilt(entityId string) error {
|
||||
return c.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Move to specific position all or specified cover. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Move to specific position all or specified cover. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "cover"
|
||||
@@ -57,8 +56,7 @@ func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error
|
||||
return c.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Move to specific position all or specified cover tilt. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Move to specific position all or specified cover tilt. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (c Cover) SetTiltPosition(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "cover"
|
||||
|
||||
@@ -17,8 +17,7 @@ type FireEventRequest struct {
|
||||
EventData map[string]any `json:"event_data,omitempty"`
|
||||
}
|
||||
|
||||
// Fire an event. Takes an event type and an optional map that is sent
|
||||
// as `event_data`.
|
||||
// Fire an event. Takes an event type and an optional map that is sent as `event_data`.
|
||||
func (e Event) Fire(eventType string, eventData ...map[string]any) error {
|
||||
req := FireEventRequest{
|
||||
Id: internal.NextId(),
|
||||
|
||||
@@ -8,8 +8,7 @@ type HomeAssistant struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// TurnOn a Home Assistant entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// TurnOn a Home Assistant entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "homeassistant"
|
||||
@@ -21,8 +20,7 @@ func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any)
|
||||
return ha.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Toggle a Home Assistant entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Toggle a Home Assistant entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "homeassistant"
|
||||
@@ -34,6 +32,7 @@ func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any)
|
||||
return ha.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// TurnOff turns off a Home Assistant entity.
|
||||
func (ha *HomeAssistant) TurnOff(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "homeassistant"
|
||||
|
||||
@@ -8,6 +8,7 @@ type InputBoolean struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// TurnOn turns on an input boolean entity.
|
||||
func (ib InputBoolean) TurnOn(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "input_boolean"
|
||||
@@ -16,6 +17,7 @@ func (ib InputBoolean) TurnOn(entityId string) error {
|
||||
return ib.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Toggle toggles an input boolean entity.
|
||||
func (ib InputBoolean) Toggle(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "input_boolean"
|
||||
@@ -24,6 +26,7 @@ func (ib InputBoolean) Toggle(entityId string) error {
|
||||
return ib.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// TurnOff turns off an input boolean entity.
|
||||
func (ib InputBoolean) TurnOff(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "input_boolean"
|
||||
|
||||
@@ -8,6 +8,7 @@ type InputButton struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// Press presses an input button entity.
|
||||
func (ib InputButton) Press(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "input_button"
|
||||
|
||||
@@ -8,6 +8,7 @@ type InputText struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// Set sets the value of an input text entity.
|
||||
func (ib InputText) Set(entityId string, value string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "input_text"
|
||||
|
||||
@@ -8,8 +8,7 @@ type Light struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// TurnOn a light entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// TurnOn a light entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "light"
|
||||
@@ -21,8 +20,7 @@ func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error {
|
||||
return l.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Toggle a light entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Toggle a light entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (l Light) Toggle(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "light"
|
||||
@@ -34,6 +32,7 @@ func (l Light) Toggle(entityId string, serviceData ...map[string]any) error {
|
||||
return l.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// TurnOff turns off a light entity.
|
||||
func (l Light) TurnOff(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "light"
|
||||
|
||||
@@ -8,8 +8,7 @@ type Lock struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// Lock a lock entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Lock a lock entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (l Lock) Lock(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "lock"
|
||||
@@ -21,8 +20,7 @@ func (l Lock) Lock(entityId string, serviceData ...map[string]any) error {
|
||||
return l.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Unlock a lock entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Unlock a lock entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (l Lock) Unlock(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "lock"
|
||||
|
||||
@@ -8,8 +8,7 @@ type MediaPlayer struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// Send the media player the command to clear players playlist.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command to clear players playlist. Takes an entityId.
|
||||
func (mp MediaPlayer) ClearPlaylist(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -18,9 +17,7 @@ func (mp MediaPlayer) ClearPlaylist(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Group players together. Only works on platforms with support for player groups.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Group players together. Only works on platforms with support for player groups. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -32,8 +29,7 @@ func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command for next track.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command for next track. Takes an entityId.
|
||||
func (mp MediaPlayer) Next(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -42,8 +38,7 @@ func (mp MediaPlayer) Next(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command for pause.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command for pause. Takes an entityId.
|
||||
func (mp MediaPlayer) Pause(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -52,8 +47,7 @@ func (mp MediaPlayer) Pause(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command for play.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command for play. Takes an entityId.
|
||||
func (mp MediaPlayer) Play(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -62,8 +56,7 @@ func (mp MediaPlayer) Play(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Toggle media player play/pause state.
|
||||
// Takes an entityId.
|
||||
// Toggle media player play/pause state. Takes an entityId.
|
||||
func (mp MediaPlayer) PlayPause(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -72,8 +65,7 @@ func (mp MediaPlayer) PlayPause(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command for previous track.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command for previous track. Takes an entityId.
|
||||
func (mp MediaPlayer) Previous(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -82,9 +74,7 @@ func (mp MediaPlayer) Previous(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command to seek in current playing media.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the media player the command to seek in current playing media. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -96,8 +86,7 @@ func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the stop command.
|
||||
// Takes an entityId.
|
||||
// Send the media player the stop command. Takes an entityId.
|
||||
func (mp MediaPlayer) Stop(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -106,9 +95,7 @@ func (mp MediaPlayer) Stop(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command for playing media.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send the media player the command to play a media. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -120,8 +107,7 @@ func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any)
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Set repeat mode. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Set repeat mode. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -133,9 +119,7 @@ func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any)
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command to change sound mode.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Select a sound mode. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -147,9 +131,7 @@ func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send the media player the command to change input source.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Select a source. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -161,9 +143,7 @@ func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]an
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Set shuffling state.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Toggle shuffle state. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -175,8 +155,7 @@ func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) er
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Toggles a media player power state.
|
||||
// Takes an entityId.
|
||||
// Toggle a media player on/off. Takes an entityId.
|
||||
func (mp MediaPlayer) Toggle(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -185,8 +164,7 @@ func (mp MediaPlayer) Toggle(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Turn a media player power off.
|
||||
// Takes an entityId.
|
||||
// Turn off a media player. Takes an entityId.
|
||||
func (mp MediaPlayer) TurnOff(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -195,8 +173,7 @@ func (mp MediaPlayer) TurnOff(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Turn a media player power on.
|
||||
// Takes an entityId.
|
||||
// Turn on a media player. Takes an entityId.
|
||||
func (mp MediaPlayer) TurnOn(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -205,9 +182,7 @@ func (mp MediaPlayer) TurnOn(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Unjoin the player from a group. Only works on
|
||||
// platforms with support for player groups.
|
||||
// Takes an entityId.
|
||||
// Separate a player from a group. Only works on platforms with support for player groups. Takes an entityId.
|
||||
func (mp MediaPlayer) Unjoin(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -216,8 +191,7 @@ func (mp MediaPlayer) Unjoin(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Turn a media player volume down.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command for volume down. Takes an entityId.
|
||||
func (mp MediaPlayer) VolumeDown(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -226,9 +200,7 @@ func (mp MediaPlayer) VolumeDown(entityId string) error {
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Mute a media player's volume.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Mute a media player. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -240,9 +212,7 @@ func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any)
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Set a media player's volume level.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Set volume level. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
@@ -254,8 +224,7 @@ func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any)
|
||||
return mp.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Turn a media player volume up.
|
||||
// Takes an entityId.
|
||||
// Send the media player the command for volume up. Takes an entityId.
|
||||
func (mp MediaPlayer) VolumeUp(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "media_player"
|
||||
|
||||
@@ -20,8 +20,7 @@ func (s Scene) Apply(serviceData ...map[string]any) error {
|
||||
return s.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Create a scene entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Create a scene entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (s Scene) Create(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "scene"
|
||||
@@ -42,8 +41,7 @@ func (s Scene) Reload() error {
|
||||
return s.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// TurnOn a scene entity. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// TurnOn a scene entity. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (s Scene) TurnOn(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "scene"
|
||||
|
||||
@@ -8,6 +8,7 @@ type Switch struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// TurnOn turns on a switch entity.
|
||||
func (s Switch) TurnOn(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "switch"
|
||||
@@ -16,6 +17,7 @@ func (s Switch) TurnOn(entityId string) error {
|
||||
return s.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Toggle toggles a switch entity.
|
||||
func (s Switch) Toggle(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "switch"
|
||||
@@ -24,6 +26,7 @@ func (s Switch) Toggle(entityId string) error {
|
||||
return s.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// TurnOff turns off a switch entity.
|
||||
func (s Switch) TurnOff(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "switch"
|
||||
|
||||
@@ -17,9 +17,7 @@ func (tts TTS) ClearCache() error {
|
||||
return tts.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Say something using text-to-speech on a media player with cloud.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Say something using text-to-speech on a media player with cloud. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "tts"
|
||||
@@ -31,9 +29,7 @@ func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error {
|
||||
return tts.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Say something using text-to-speech on a media player with google_translate.
|
||||
// Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Say something using text-to-speech on a media player with google_translate. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (tts TTS) GoogleTranslateSay(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "tts"
|
||||
|
||||
@@ -8,8 +8,7 @@ type Vacuum struct {
|
||||
conn *connect.HAConnection
|
||||
}
|
||||
|
||||
// Tell the vacuum cleaner to do a spot clean-up.
|
||||
// Takes an entityId.
|
||||
// Tell the vacuum cleaner to do a spot clean-up. Takes an entityId.
|
||||
func (v Vacuum) CleanSpot(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -18,8 +17,7 @@ func (v Vacuum) CleanSpot(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Locate the vacuum cleaner robot.
|
||||
// Takes an entityId.
|
||||
// Locate the vacuum cleaner robot. Takes an entityId.
|
||||
func (v Vacuum) Locate(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -28,8 +26,7 @@ func (v Vacuum) Locate(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Pause the cleaning task.
|
||||
// Takes an entityId.
|
||||
// Pause the cleaning task. Takes an entityId.
|
||||
func (v Vacuum) Pause(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -38,8 +35,7 @@ func (v Vacuum) Pause(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Tell the vacuum cleaner to return to its dock.
|
||||
// Takes an entityId.
|
||||
// Tell the vacuum cleaner to return to its dock. Takes an entityId.
|
||||
func (v Vacuum) ReturnToBase(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -48,8 +44,7 @@ func (v Vacuum) ReturnToBase(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Send a raw command to the vacuum cleaner. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Send a raw command to the vacuum cleaner. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -61,8 +56,7 @@ func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) erro
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Set the fan speed of the vacuum cleaner. Takes an entityId and an optional
|
||||
// map that is translated into service_data.
|
||||
// Set the fan speed of the vacuum cleaner. Takes an entityId and an optional map that is translated into service_data.
|
||||
func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -75,8 +69,7 @@ func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) erro
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Start or resume the cleaning task.
|
||||
// Takes an entityId.
|
||||
// Start or resume the cleaning task. Takes an entityId.
|
||||
func (v Vacuum) Start(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -85,8 +78,7 @@ func (v Vacuum) Start(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Start, pause, or resume the cleaning task.
|
||||
// Takes an entityId.
|
||||
// Start, pause, or resume the cleaning task. Takes an entityId.
|
||||
func (v Vacuum) StartPause(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -95,8 +87,7 @@ func (v Vacuum) StartPause(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Stop the current cleaning task.
|
||||
// Takes an entityId.
|
||||
// Stop the current cleaning task. Takes an entityId.
|
||||
func (v Vacuum) Stop(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -105,8 +96,7 @@ func (v Vacuum) Stop(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Stop the current cleaning task and return to home.
|
||||
// Takes an entityId.
|
||||
// Stop the current cleaning task and return to home. Takes an entityId.
|
||||
func (v Vacuum) TurnOff(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
@@ -115,8 +105,7 @@ func (v Vacuum) TurnOff(entityId string) error {
|
||||
return v.conn.WriteMessage(req)
|
||||
}
|
||||
|
||||
// Start a new cleaning task.
|
||||
// Takes an entityId.
|
||||
// Start a new cleaning task. Takes an entityId.
|
||||
func (v Vacuum) TurnOn(entityId string) error {
|
||||
req := NewBaseServiceRequest(entityId)
|
||||
req.Domain = "vacuum"
|
||||
|
||||
22
interval.go
22
interval.go
@@ -79,20 +79,20 @@ func (ib intervalBuilder) Call(callback IntervalCallback) intervalBuilderCall {
|
||||
return intervalBuilderCall(ib)
|
||||
}
|
||||
|
||||
// Takes a DurationString ("2h", "5m", etc) to set the frequency of the interval.
|
||||
// Every takes a DurationString ("2h", "5m", etc.) to set the frequency of the interval.
|
||||
func (ib intervalBuilderCall) Every(s types.DurationString) intervalBuilderEnd {
|
||||
d := internal.ParseDuration(string(s))
|
||||
ib.interval.frequency = d
|
||||
return intervalBuilderEnd(ib)
|
||||
}
|
||||
|
||||
// Takes a TimeString ("HH:MM") when this interval will start running for the day.
|
||||
// StartingAt takes a TimeString ("HH:MM") when this interval will start running for the day.
|
||||
func (ib intervalBuilderEnd) StartingAt(s types.TimeString) intervalBuilderEnd {
|
||||
ib.interval.startTime = s
|
||||
return ib
|
||||
}
|
||||
|
||||
// Takes a TimeString ("HH:MM") when this interval will stop running for the day.
|
||||
// EndingAt takes a TimeString ("HH:MM") when this interval will stop running for the day.
|
||||
func (ib intervalBuilderEnd) EndingAt(s types.TimeString) intervalBuilderEnd {
|
||||
ib.interval.endTime = s
|
||||
return ib
|
||||
@@ -111,10 +111,8 @@ func (ib intervalBuilderEnd) ExceptionRange(start, end time.Time) intervalBuilde
|
||||
return ib
|
||||
}
|
||||
|
||||
/*
|
||||
Enable this interval only when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// Enable this interval only when the current state of {entityId} matches {state}.
|
||||
// If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
|
||||
func (ib intervalBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) intervalBuilderEnd {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
|
||||
@@ -128,10 +126,8 @@ func (ib intervalBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkErr
|
||||
return ib
|
||||
}
|
||||
|
||||
/*
|
||||
Disable this interval when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// Disable this interval when the current state of {entityId} matches {state}.
|
||||
// If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
|
||||
func (ib intervalBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) intervalBuilderEnd {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
|
||||
@@ -188,10 +184,10 @@ func runIntervals(a *App) {
|
||||
}
|
||||
|
||||
func (i Interval) maybeRunCallback(a *App) {
|
||||
if c := CheckStartEndTime(i.startTime /* isStart = */, true); c.fail {
|
||||
if c := CheckStartEndTime(i.startTime, true); c.fail {
|
||||
return
|
||||
}
|
||||
if c := CheckStartEndTime(i.endTime /* isStart = */, false); c.fail {
|
||||
if c := CheckStartEndTime(i.endTime, false); c.fail {
|
||||
return
|
||||
}
|
||||
if c := CheckExceptionDates(i.exceptionDates); c.fail {
|
||||
|
||||
110
schedule.go
110
schedule.go
@@ -1,3 +1,6 @@
|
||||
// Package gomeassistant provides a Go library for creating Home Assistant automations
|
||||
// and schedules. This file contains the scheduling system that allows you to create
|
||||
// daily schedules with various conditions and callbacks.
|
||||
package gomeassistant
|
||||
|
||||
import (
|
||||
@@ -10,44 +13,67 @@ import (
|
||||
"github.com/dromara/carbon/v2"
|
||||
)
|
||||
|
||||
// ScheduleCallback is a function type that gets called when a schedule triggers.
|
||||
// It receives the service instance and current state as parameters.
|
||||
type ScheduleCallback func(*Service, State)
|
||||
|
||||
// DailySchedule represents a recurring daily schedule with various conditions.
|
||||
// It can be configured to run at specific times, sunrise/sunset, or based on
|
||||
// entity states and date restrictions.
|
||||
type DailySchedule struct {
|
||||
// 0-23
|
||||
// Hour of the day (0-23) when the schedule should run
|
||||
hour int
|
||||
// 0-59
|
||||
// Minute of the hour (0-59) when the schedule should run
|
||||
minute int
|
||||
|
||||
callback ScheduleCallback
|
||||
// Function to call when the schedule triggers
|
||||
callback ScheduleCallback
|
||||
// Next time this schedule should run
|
||||
nextRunTime time.Time
|
||||
|
||||
// If true, schedule runs at sunrise instead of fixed time
|
||||
isSunrise bool
|
||||
isSunset bool
|
||||
// If true, schedule runs at sunset instead of fixed time
|
||||
isSunset bool
|
||||
// Offset from sunrise/sunset (e.g., "-30m", "+1h")
|
||||
sunOffset types.DurationString
|
||||
|
||||
// Dates when this schedule should NOT run
|
||||
exceptionDates []time.Time
|
||||
// Dates when this schedule is ONLY allowed to run (if empty, runs on all dates)
|
||||
allowlistDates []time.Time
|
||||
|
||||
enabledEntities []internal.EnabledDisabledInfo
|
||||
// Entities that must be in specific states for this schedule to run
|
||||
enabledEntities []internal.EnabledDisabledInfo
|
||||
// Entities that must NOT be in specific states for this schedule to run
|
||||
disabledEntities []internal.EnabledDisabledInfo
|
||||
}
|
||||
|
||||
// Hash returns a unique string identifier for this schedule based on its
|
||||
// time and callback function.
|
||||
func (s DailySchedule) Hash() string {
|
||||
return fmt.Sprint(s.hour, s.minute, s.callback)
|
||||
}
|
||||
|
||||
// scheduleBuilder is used in the fluent API to build schedules step by step.
|
||||
type scheduleBuilder struct {
|
||||
schedule DailySchedule
|
||||
}
|
||||
|
||||
// scheduleBuilderCall represents the state after setting the callback function.
|
||||
type scheduleBuilderCall struct {
|
||||
schedule DailySchedule
|
||||
}
|
||||
|
||||
// scheduleBuilderEnd represents the final state where time and conditions are set.
|
||||
type scheduleBuilderEnd struct {
|
||||
schedule DailySchedule
|
||||
}
|
||||
|
||||
// NewDailySchedule creates a new schedule builder with default values.
|
||||
// Use the fluent API to configure the schedule:
|
||||
//
|
||||
// NewDailySchedule().Call(myFunction).At("15:30").Build()
|
||||
func NewDailySchedule() scheduleBuilder {
|
||||
return scheduleBuilder{
|
||||
DailySchedule{
|
||||
@@ -58,6 +84,7 @@ func NewDailySchedule() scheduleBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a human-readable representation of the schedule.
|
||||
func (s DailySchedule) String() string {
|
||||
return fmt.Sprintf("Schedule{ call %q daily at %s }",
|
||||
internal.GetFunctionName(s.callback),
|
||||
@@ -65,16 +92,20 @@ func (s DailySchedule) String() string {
|
||||
)
|
||||
}
|
||||
|
||||
// stringHourMinute formats hour and minute as "HH:MM".
|
||||
func stringHourMinute(hour, minute int) string {
|
||||
return fmt.Sprintf("%02d:%02d", hour, minute)
|
||||
}
|
||||
|
||||
// Call sets the callback function that will be executed when the schedule triggers.
|
||||
// This is the first step in the fluent API chain.
|
||||
func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall {
|
||||
sb.schedule.callback = callback
|
||||
return scheduleBuilderCall(sb)
|
||||
}
|
||||
|
||||
// At takes a string in 24hr format time like "15:30".
|
||||
// At sets the schedule to run at a specific time in 24-hour format.
|
||||
// Examples: "15:30", "09:00", "23:45"
|
||||
func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
|
||||
t := internal.ParseTime(s)
|
||||
sb.schedule.hour = t.Hour()
|
||||
@@ -82,9 +113,13 @@ func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
|
||||
return scheduleBuilderEnd(sb)
|
||||
}
|
||||
|
||||
// Sunrise takes an optional duration string that is passed to time.ParseDuration.
|
||||
// Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration
|
||||
// for full list.
|
||||
// Sunrise configures the schedule to run at sunrise with an optional offset.
|
||||
// The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
|
||||
// Only the first offset, if provided, is considered.
|
||||
// Examples:
|
||||
// - Sunrise() - runs at sunrise
|
||||
// - Sunrise("-30m") - runs 30 minutes before sunrise
|
||||
// - Sunrise("+1h") - runs 1 hour after sunrise
|
||||
func (sb scheduleBuilderCall) Sunrise(offset ...types.DurationString) scheduleBuilderEnd {
|
||||
sb.schedule.isSunrise = true
|
||||
if len(offset) > 0 {
|
||||
@@ -93,9 +128,13 @@ func (sb scheduleBuilderCall) Sunrise(offset ...types.DurationString) scheduleBu
|
||||
return scheduleBuilderEnd(sb)
|
||||
}
|
||||
|
||||
// Sunset takes an optional duration string that is passed to time.ParseDuration.
|
||||
// Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration
|
||||
// for full list.
|
||||
// Sunset configures the schedule to run at sunset with an optional offset.
|
||||
// The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
|
||||
// Only the first offset, if provided, is considered.
|
||||
// Examples:
|
||||
// - Sunset() - runs at sunset
|
||||
// - Sunset("-30m") - runs 30 minutes before sunset
|
||||
// - Sunset("+1h") - runs 1 hour after sunset
|
||||
func (sb scheduleBuilderCall) Sunset(offset ...types.DurationString) scheduleBuilderEnd {
|
||||
sb.schedule.isSunset = true
|
||||
if len(offset) > 0 {
|
||||
@@ -104,20 +143,27 @@ func (sb scheduleBuilderCall) Sunset(offset ...types.DurationString) scheduleBui
|
||||
return scheduleBuilderEnd(sb)
|
||||
}
|
||||
|
||||
// ExceptionDates adds dates when this schedule should NOT run.
|
||||
// You can pass multiple dates: ExceptionDates(date1, date2, date3)
|
||||
func (sb scheduleBuilderEnd) ExceptionDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
|
||||
sb.schedule.exceptionDates = append(tl, t)
|
||||
return sb
|
||||
}
|
||||
|
||||
// OnlyOnDates restricts the schedule to run ONLY on the specified dates.
|
||||
// If no dates are specified, the schedule runs on all dates.
|
||||
// You can pass multiple dates: OnlyOnDates(date1, date2, date3)
|
||||
func (sb scheduleBuilderEnd) OnlyOnDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
|
||||
sb.schedule.allowlistDates = append(tl, t)
|
||||
return sb
|
||||
}
|
||||
|
||||
/*
|
||||
Enable this schedule only when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the schedule runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// EnabledWhen makes this schedule only run when the specified entity is in the given state.
|
||||
// If there's a network error while checking the entity state, the schedule runs
|
||||
// only if runOnNetworkError is true.
|
||||
// Examples:
|
||||
// - EnabledWhen("light.living_room", "on", true) - only run when light is on
|
||||
// - EnabledWhen("sensor.motion", "detected", false) - only run when motion detected, fail on network error
|
||||
func (sb scheduleBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
|
||||
@@ -131,10 +177,11 @@ func (sb scheduleBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkErr
|
||||
return sb
|
||||
}
|
||||
|
||||
/*
|
||||
Disable this schedule when the current state of {entityId} matches {state}.
|
||||
If there is a network error while retrieving state, the schedule runs if {runOnNetworkError} is true.
|
||||
*/
|
||||
// DisabledWhen prevents this schedule from running when the specified entity is in the given state.
|
||||
// If there's a network error while checking the entity state, the schedule runs only if runOnNetworkError is true.
|
||||
// Examples:
|
||||
// - DisabledWhen("light.living_room", "off", true) - don't run when light is off
|
||||
// - DisabledWhen("sensor.motion", "detected", false) - don't run when motion detected, fail on network error
|
||||
func (sb scheduleBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
|
||||
if entityId == "" {
|
||||
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
|
||||
@@ -148,11 +195,15 @@ func (sb scheduleBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkEr
|
||||
return sb
|
||||
}
|
||||
|
||||
// Build finalizes the schedule configuration and returns the DailySchedule.
|
||||
// This is the final step in the fluent API chain.
|
||||
func (sb scheduleBuilderEnd) Build() DailySchedule {
|
||||
return sb.schedule
|
||||
}
|
||||
|
||||
// app.Start() functions
|
||||
// runSchedules is the main goroutine that manages all schedules.
|
||||
// It continuously processes schedules, running them when their time comes
|
||||
// and requeuing them for the next day.
|
||||
func runSchedules(a *App) {
|
||||
if a.schedules.Len() == 0 {
|
||||
return
|
||||
@@ -168,7 +219,7 @@ func runSchedules(a *App) {
|
||||
|
||||
sched := popSchedule(a)
|
||||
|
||||
// run callback for all schedules before now in case they overlap
|
||||
// Run callback for all schedules that are overdue in case they overlap
|
||||
for sched.nextRunTime.Before(time.Now()) {
|
||||
sched.maybeRunCallback(a)
|
||||
requeueSchedule(a, sched)
|
||||
@@ -178,7 +229,7 @@ func runSchedules(a *App) {
|
||||
|
||||
slog.Info("Next schedule", "start_time", sched.nextRunTime)
|
||||
|
||||
// Use context-aware sleep
|
||||
// Wait until the next schedule time or context cancellation
|
||||
select {
|
||||
case <-time.After(time.Until(sched.nextRunTime)):
|
||||
// Time elapsed, continue
|
||||
@@ -192,6 +243,13 @@ func runSchedules(a *App) {
|
||||
}
|
||||
}
|
||||
|
||||
// maybeRunCallback checks all conditions and runs the callback if they're all met.
|
||||
// Conditions checked:
|
||||
// 1. Exception dates (schedule should not run on these dates)
|
||||
// 2. Allowlist dates (schedule should only run on these dates)
|
||||
// 3. Enabled entities (required entity states)
|
||||
// 4. Disabled entities (forbidden entity states)
|
||||
// The callback runs in a goroutine to avoid blocking the scheduler.
|
||||
func (s DailySchedule) maybeRunCallback(a *App) {
|
||||
if c := CheckExceptionDates(s.exceptionDates); c.fail {
|
||||
return
|
||||
@@ -208,15 +266,19 @@ func (s DailySchedule) maybeRunCallback(a *App) {
|
||||
go s.callback(a.service, a.state)
|
||||
}
|
||||
|
||||
// popSchedule removes and returns the next schedule from the priority queue.
|
||||
func popSchedule(a *App) DailySchedule {
|
||||
_sched, _ := a.schedules.Get(1)
|
||||
return _sched[0].(Item).Value.(DailySchedule)
|
||||
}
|
||||
|
||||
// requeueSchedule calculates the next run time for a schedule and adds it back to the queue.
|
||||
// For sunrise/sunset schedules, it calculates the next sunrise/sunset time.
|
||||
// For fixed-time schedules, it adds one day to the current run time.
|
||||
func requeueSchedule(a *App, s DailySchedule) {
|
||||
if s.isSunrise || s.isSunset {
|
||||
var nextSunTime *carbon.Carbon
|
||||
// "0s" is default value
|
||||
// "0s" is the default value for no offset
|
||||
if s.sunOffset != "0s" {
|
||||
nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset)
|
||||
} else {
|
||||
|
||||
6
state.go
6
state.go
@@ -81,7 +81,7 @@ func (s *StateImpl) Get(entityId string) (EntityState, error) {
|
||||
}
|
||||
|
||||
// ListEntities returns a list of all entities in Home Assistant.
|
||||
// see rest documentation for more details: https://developers.home-assistant.io/docs/api/rest/#actions
|
||||
// See REST documentation for more details: https://developers.home-assistant.io/docs/api/rest/#actions
|
||||
func (s *StateImpl) ListEntities() ([]EntityState, error) {
|
||||
resp, err := s.httpClient.GetStates()
|
||||
if err != nil {
|
||||
@@ -101,7 +101,7 @@ func (s *StateImpl) Equals(entityId string, expectedState string) (bool, error)
|
||||
}
|
||||
|
||||
func (s *StateImpl) BeforeSunrise(offset ...types.DurationString) bool {
|
||||
sunrise := getSunriseSunset(s /* sunrise = */, true, carbon.Now(), offset...)
|
||||
sunrise := getSunriseSunset(s, true, carbon.Now(), offset...)
|
||||
return carbon.Now().Lt(sunrise)
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func (s *StateImpl) AfterSunrise(offset ...types.DurationString) bool {
|
||||
}
|
||||
|
||||
func (s *StateImpl) BeforeSunset(offset ...types.DurationString) bool {
|
||||
sunset := getSunriseSunset(s /* sunrise = */, false, carbon.Now(), offset...)
|
||||
sunset := getSunriseSunset(s, false, carbon.Now(), offset...)
|
||||
return carbon.Now().Lt(sunset)
|
||||
}
|
||||
|
||||
|
||||
10
types/app.go
10
types/app.go
@@ -7,7 +7,7 @@ type NewAppRequest struct {
|
||||
|
||||
// Optional
|
||||
// Deprecated: use URL instead
|
||||
// IpAddress of your Home Assistant instance i.e. "localhost"
|
||||
// IpAddress of your Home Assistant instance, e.g. "localhost"
|
||||
// or "192.168.86.59" etc.
|
||||
IpAddress string
|
||||
|
||||
@@ -18,18 +18,18 @@ type NewAppRequest struct {
|
||||
|
||||
// Required
|
||||
// Auth token generated in Home Assistant. Used
|
||||
// to connect to the Websocket API.
|
||||
// to connect to the WebSocket API.
|
||||
HAAuthToken string
|
||||
|
||||
// Required
|
||||
// EntityId of the zone representing your home e.g. "zone.home".
|
||||
// EntityId of the zone representing your home, e.g. "zone.home".
|
||||
// Used to pull latitude/longitude from Home Assistant
|
||||
// to calculate sunset/sunrise times.
|
||||
HomeZoneEntityId string
|
||||
|
||||
// Optional
|
||||
// Whether to use secure connections for http and websockets.
|
||||
// Setting this to `true` will use `https://` instead of `https://`
|
||||
// Whether to use secure connections for HTTP and WebSockets.
|
||||
// Setting this to `true` will use `https://` instead of `http://`
|
||||
// and `wss://` instead of `ws://`.
|
||||
Secure bool
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user