5 Commits
v0.7.1 ... main

38 changed files with 1495 additions and 241 deletions

27
app.go
View File

@@ -42,16 +42,16 @@ type App struct {
type Item types.Item type Item types.Item
func (mi Item) Compare(other queue.Item) int { func (i Item) Compare(other queue.Item) int {
if mi.Priority > other.(Item).Priority { if i.Priority > other.(Item).Priority {
return 1 return 1
} else if mi.Priority == other.(Item).Priority { } else if i.Priority == other.(Item).Priority {
return 0 return 0
} }
return -1 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 { func validateHomeZone(state State, entityID string) error {
entity, err := state.Get(entityID) entity, err := state.Get(entityID)
if err != nil { if err != nil {
@@ -75,10 +75,7 @@ func validateHomeZone(state State, entityID string) error {
return nil 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) { func NewApp(request types.NewAppRequest) (*App, error) {
if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" { if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" {
slog.Error("URL and HAAuthToken are required arguments in NewAppRequest") 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. // Close performs a clean shutdown of the application. It cancels the context, closes the WebSocket connection, and ensures all background processes are properly terminated.
// It cancels the context, closes the websocket connection,
// and ensures all background processes are properly terminated.
func (app *App) Close() error { func (app *App) Close() error {
// Close websocket connection if it exists // Close WebSocket connection if it exists
if app.conn != nil { if app.conn != nil {
deadline := time.Now().Add(10 * time.Second) deadline := time.Now().Add(10 * time.Second)
err := app.conn.Conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), deadline) err := app.conn.Conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), deadline)
@@ -151,15 +146,15 @@ func (app *App) Close() error {
return err return err
} }
// Close the websocket connection // Close the WebSocket connection
err = app.conn.Conn.Close() err = app.conn.Conn.Close()
if err != nil { if err != nil {
slog.Warn("Error closing websocket connection", "error", err) slog.Warn("Error closing WebSocket connection", "error", err)
return 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) time.Sleep(500 * time.Millisecond)
// Cancel context to signal all goroutines to stop // Cancel context to signal all goroutines to stop
@@ -338,7 +333,7 @@ func (app *App) Start() {
select { select {
case msg, ok := <-elChan: case msg, ok := <-elChan:
if !ok { if !ok {
slog.Info("Websocket channel closed, stopping main loop") slog.Info("WebSocket channel closed, stopping main loop")
return return
} }
if app.entityListenersId == msg.Id { if app.entityListenersId == msg.Id {

View File

@@ -133,11 +133,11 @@ func TestAppWithNilFields(t *testing.T) {
} }
func TestAppWithWebsocketConnection(t *testing.T) { func TestAppWithWebsocketConnection(t *testing.T) {
// Test app with websocket connection (mocked) // Test app with WebSocket connection (mocked)
app := &App{ app := &App{
ctx: context.Background(), ctx: context.Background(),
ctxCancel: func() {}, 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 // Test that Close() handles nil connection gracefully

View File

@@ -67,7 +67,7 @@ func toCamelCase(s string) string {
return result.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 { func validateHomeZone(state ha.State, entityID string) error {
entity, err := state.Get(entityID) entity, err := state.Get(entityID)
if err != nil { if err != nil {
@@ -93,7 +93,7 @@ func validateHomeZone(state ha.State, entityID string) error {
return nil 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 { func generate(config Config) error {
if config.HomeZoneEntityId == "" { if config.HomeZoneEntityId == "" {
config.HomeZoneEntityId = "zone.home" config.HomeZoneEntityId = "zone.home"

View File

@@ -67,8 +67,6 @@ type msgState struct {
Attributes map[string]any `json:"attributes"` Attributes map[string]any `json:"attributes"`
} }
/* Methods */
func NewEntityListener() elBuilder1 { func NewEntityListener() elBuilder1 {
return elBuilder1{EntityListener{ return elBuilder1{EntityListener{
lastRan: carbon.Now().StartOfCentury(), lastRan: carbon.Now().StartOfCentury(),
@@ -154,10 +152,8 @@ func (b elBuilder3) RunOnStartup() elBuilder3 {
return b return b
} }
/* // EnabledWhen enables this listener only when the current state of {entityId} matches {state}.
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.
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 { func (b elBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool) elBuilder3 {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state)) 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 return b
} }
/* // DisabledWhen disables this listener when the current state of {entityId} matches {state}.
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.
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 { func (b elBuilder3) DisabledWhen(entityId, state string, runOnNetworkError bool) elBuilder3 {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state)) panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))

View File

@@ -34,8 +34,6 @@ type EventData struct {
RawEventJSON []byte RawEventJSON []byte
} }
/* Methods */
func NewEventListener() eventListenerBuilder1 { func NewEventListener() eventListenerBuilder1 {
return eventListenerBuilder1{EventListener{ return eventListenerBuilder1{EventListener{
lastRan: carbon.Now().StartOfCentury(), lastRan: carbon.Now().StartOfCentury(),
@@ -96,10 +94,8 @@ func (b eventListenerBuilder3) ExceptionRange(start, end time.Time) eventListene
return b return b
} }
/* // EnabledWhen enables this listener only when the current state of {entityId} matches {state}.
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.
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 { func (b eventListenerBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool) eventListenerBuilder3 {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in eventListener EnabledWhen entityId='%s' state='%s' runOnNetworkError='%t'", entityId, state, runOnNetworkError)) 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 return b
} }
/* // DisabledWhen disables this listener when the current state of {entityId} matches {state}.
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.
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 { func (b eventListenerBuilder3) DisabledWhen(entityId, state string, runOnNetworkError bool) eventListenerBuilder3 {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in eventListener EnabledWhen entityId='%s' state='%s' runOnNetworkError='%t'", entityId, state, runOnNetworkError)) 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"` } `json:"event"`
} }
/* Functions */
func callEventListeners(app *App, msg connect.ChannelMessage) { func callEventListeners(app *App, msg connect.ChannelMessage) {
baseEventMsg := BaseEventMsg{} baseEventMsg := BaseEventMsg{}
_ = json.Unmarshal(msg.Raw, &baseEventMsg) _ = json.Unmarshal(msg.Raw, &baseEventMsg)

1
go.mod
View File

@@ -18,6 +18,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/pretty v0.1.0 // indirect github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.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 golang.org/x/net v0.42.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
) )

2
go.sum
View File

@@ -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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=

View File

@@ -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 package connect
import ( import (
@@ -20,13 +16,13 @@ import (
var ErrInvalidToken = errors.New("invalid authentication token") 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 { type HAConnection struct {
Conn *websocket.Conn // Note: this is not thread safe except for Close() and WriteControl() Conn *websocket.Conn // Note: this is not thread safe except for Close() and WriteControl()
mutex sync.Mutex 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 { func (w *HAConnection) WriteMessage(msg any) error {
w.mutex.Lock() w.mutex.Lock()
defer w.mutex.Unlock() defer w.mutex.Unlock()
@@ -34,7 +30,7 @@ func (w *HAConnection) WriteMessage(msg any) error {
return w.Conn.WriteJSON(msg) 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) { func ReadMessageRaw(conn *websocket.Conn) ([]byte, error) {
_, msg, err := conn.ReadMessage() _, msg, err := conn.ReadMessage()
if err != nil { if err != nil {
@@ -43,7 +39,7 @@ func ReadMessageRaw(conn *websocket.Conn) ([]byte, error) {
return msg, nil 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) { func ReadMessage[T any](conn *websocket.Conn) (T, error) {
var result T var result T
_, msg, err := conn.ReadMessage() _, msg, err := conn.ReadMessage()
@@ -59,14 +55,14 @@ func ReadMessage[T any](conn *websocket.Conn) (T, error) {
return result, nil 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) { func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.Context, context.CancelFunc, error) {
// Build the websocket URL // Build the WebSocket URL
urlWebsockets := *baseUrl urlWebsockets := *baseUrl
urlWebsockets.Path = "/api/websocket" urlWebsockets.Path = "/api/websocket"
scheme, err := internal.GetEquivalentWebsocketScheme(baseUrl.Scheme) scheme, err := internal.GetEquivalentWebsocketScheme(baseUrl.Scheme)
if err != nil { 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 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) connCtx, connCtxCancel := context.WithTimeout(context.Background(), time.Second*3)
defer connCtxCancel() // Always cancel the connection context when we're done defer connCtxCancel() // Always cancel the connection context when we're done
// Init websocket connection // Init WebSocket connection
dialer := websocket.DefaultDialer dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(connCtx, urlWebsockets.String(), nil) conn, _, err := dialer.DialContext(connCtx, urlWebsockets.String(), nil)
if err != 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 return nil, nil, nil, err
} }
@@ -87,7 +83,7 @@ func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.C
MsgType string `json:"type"` MsgType string `json:"type"`
}](conn) }](conn)
if err != nil { if err != nil {
slog.Error("Unknown error creating websocket client\n") slog.Error("Unknown error creating WebSocket client\n")
return nil, nil, nil, err return nil, nil, nil, err
} else if msg.MsgType != "auth_required" { } else if msg.MsgType != "auth_required" {
slog.Error("Expected auth_required message, got", "msgType", msg.MsgType) 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 // Send auth message
err = SendAuthMessage(conn, connCtx, token) err = SendAuthMessage(conn, connCtx, token)
if err != nil { if err != nil {
slog.Error("Unknown error creating websocket client\n") slog.Error("Unknown error creating WebSocket client\n")
return nil, nil, nil, err 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 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 { func SendAuthMessage(conn *websocket.Conn, ctx context.Context, token string) error {
type AuthMessage struct { type AuthMessage struct {
MsgType string `json:"type"` MsgType string `json:"type"`
@@ -174,7 +170,7 @@ func SubscribeToEventType(eventType string, conn *HAConnection, ctx context.Cont
err := conn.WriteMessage(e) err := conn.WriteMessage(e)
// TODO: Handle errors better // TODO: Handle errors better
if err != nil { 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()) slog.Error(wrappedErr.Error())
panic(wrappedErr) panic(wrappedErr)
} }

View File

@@ -7,7 +7,7 @@ import (
"github.com/gorilla/websocket" "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 BaseMessage struct {
Type string `json:"type"` Type string `json:"type"`
Id int64 `json:"id"` Id int64 `json:"id"`
@@ -21,14 +21,14 @@ type ChannelMessage struct {
Raw []byte 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 will close the channel if it encounters an error, or if the channel is full, and return.
// It ignores errors in deserialization. // It ignores errors in deserialization.
func ListenWebsocket(conn *websocket.Conn, c chan ChannelMessage) { func ListenWebsocket(conn *websocket.Conn, c chan ChannelMessage) {
for { for {
raw, err := ReadMessageRaw(conn) raw, err := ReadMessageRaw(conn)
if err != nil { if err != nil {
slog.Error("Error reading from websocket", "err", err) slog.Error("Error reading from WebSocket", "err", err)
close(c) close(c)
break break
} }
@@ -60,7 +60,7 @@ func ListenWebsocket(conn *websocket.Conn, c chan ChannelMessage) {
// Message sent successfully // Message sent successfully
default: default:
// Channel is full or closed, break out of loop // 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_capacity", cap(c),
"channel_length", len(c)) "channel_length", len(c))
close(c) close(c)

View File

@@ -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 package internal
import ( 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 { func (c *HttpClient) getRequest() *resty.Request {
return c.baseRequest.Clone(c.client.Context()) return c.baseRequest.Clone(c.client.Context())
} }

View File

@@ -21,8 +21,7 @@ var (
id atomic.Int64 // default value is 0 id atomic.Int64 // default value is 0
) )
// NextId returns a unique integer (for the given process), often used for providing a uniquely identifiable // 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.
// id for a request. This function is thread-safe.
func NextId() int64 { func NextId() int64 {
return id.Add(1) return id.Add(1)
} }
@@ -32,7 +31,7 @@ func GetFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() 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 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 ws or wss, it returns the same scheme.
// If the scheme is not any of the above, it returns an error. // 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) return "", fmt.Errorf("unexpected scheme: %s", scheme)
} }
} }
func Ptr[T any](v T) *T {
return &v
}

View File

@@ -8,11 +8,11 @@ import (
"github.com/dromara/carbon/v2" "github.com/dromara/carbon/v2"
) )
// Parses a HH:MM string. // ParseTime parses a HH:MM string.
func ParseTime(s string) *carbon.Carbon { func ParseTime(s string) *carbon.Carbon {
t, err := time.Parse("15:04", s) t, err := time.Parse("15:04", s)
if err != nil { 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()) slog.Error(parsingErr.Error())
panic(parsingErr) panic(parsingErr)
} }
@@ -22,7 +22,7 @@ func ParseTime(s string) *carbon.Carbon {
func ParseDuration(s string) time.Duration { func ParseDuration(s string) time.Duration {
d, err := time.ParseDuration(s) d, err := time.ParseDuration(s)
if err != nil { 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()) slog.Error(parsingErr.Error())
panic(parsingErr) panic(parsingErr)
} }

View 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
}

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

View 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()
}

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

View 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()
}

View 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{}
}

View 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()
}

View 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())
})
}

View File

@@ -8,9 +8,7 @@ type AlarmControlPanel struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// Send the alarm the command for arm away. // Send the alarm the command for arm away. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"
@@ -22,9 +20,7 @@ func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]
return acp.conn.WriteMessage(req) return acp.conn.WriteMessage(req)
} }
// Send the alarm the command for arm away. // Send the alarm the command for arm away. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"
@@ -36,9 +32,7 @@ func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ..
return acp.conn.WriteMessage(req) return acp.conn.WriteMessage(req)
} }
// Send the alarm the command for arm home. // Send the alarm the command for arm home. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"
@@ -50,9 +44,7 @@ func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]
return acp.conn.WriteMessage(req) return acp.conn.WriteMessage(req)
} }
// Send the alarm the command for arm night. // Send the alarm the command for arm night. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"
@@ -64,9 +56,7 @@ func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string
return acp.conn.WriteMessage(req) return acp.conn.WriteMessage(req)
} }
// Send the alarm the command for arm vacation. // Send the alarm the command for arm vacation. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"
@@ -78,9 +68,7 @@ func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[str
return acp.conn.WriteMessage(req) return acp.conn.WriteMessage(req)
} }
// Send the alarm the command for disarm. // Send the alarm the command for disarm. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"
@@ -92,9 +80,7 @@ func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]a
return acp.conn.WriteMessage(req) return acp.conn.WriteMessage(req)
} }
// Send the alarm the command for trigger. // Send the alarm the command for trigger. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (acp AlarmControlPanel) Trigger(entityId string, serviceData ...map[string]any) error { func (acp AlarmControlPanel) Trigger(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel" req.Domain = "alarm_control_panel"

View File

@@ -44,8 +44,7 @@ func (c Cover) OpenTilt(entityId string) error {
return c.conn.WriteMessage(req) return c.conn.WriteMessage(req)
} }
// Move to specific position all or specified cover. Takes an entityId and an optional // Move to specific position all or specified cover. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error { func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "cover" req.Domain = "cover"
@@ -57,8 +56,7 @@ func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error
return c.conn.WriteMessage(req) return c.conn.WriteMessage(req)
} }
// Move to specific position all or specified cover tilt. Takes an entityId and an optional // Move to specific position all or specified cover tilt. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (c Cover) SetTiltPosition(entityId string, serviceData ...map[string]any) error { func (c Cover) SetTiltPosition(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "cover" req.Domain = "cover"

View File

@@ -17,8 +17,7 @@ type FireEventRequest struct {
EventData map[string]any `json:"event_data,omitempty"` EventData map[string]any `json:"event_data,omitempty"`
} }
// Fire an event. Takes an event type and an optional map that is sent // Fire an event. Takes an event type and an optional map that is sent as `event_data`.
// as `event_data`.
func (e Event) Fire(eventType string, eventData ...map[string]any) error { func (e Event) Fire(eventType string, eventData ...map[string]any) error {
req := FireEventRequest{ req := FireEventRequest{
Id: internal.NextId(), Id: internal.NextId(),

View File

@@ -8,8 +8,7 @@ type HomeAssistant struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// TurnOn a Home Assistant entity. Takes an entityId and an optional // TurnOn a Home Assistant entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any) error { func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "homeassistant" req.Domain = "homeassistant"
@@ -21,8 +20,7 @@ func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any)
return ha.conn.WriteMessage(req) return ha.conn.WriteMessage(req)
} }
// Toggle a Home Assistant entity. Takes an entityId and an optional // Toggle a Home Assistant entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any) error { func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "homeassistant" req.Domain = "homeassistant"
@@ -34,6 +32,7 @@ func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any)
return ha.conn.WriteMessage(req) return ha.conn.WriteMessage(req)
} }
// TurnOff turns off a Home Assistant entity.
func (ha *HomeAssistant) TurnOff(entityId string) error { func (ha *HomeAssistant) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "homeassistant" req.Domain = "homeassistant"

View File

@@ -8,6 +8,7 @@ type InputBoolean struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// TurnOn turns on an input boolean entity.
func (ib InputBoolean) TurnOn(entityId string) error { func (ib InputBoolean) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "input_boolean" req.Domain = "input_boolean"
@@ -16,6 +17,7 @@ func (ib InputBoolean) TurnOn(entityId string) error {
return ib.conn.WriteMessage(req) return ib.conn.WriteMessage(req)
} }
// Toggle toggles an input boolean entity.
func (ib InputBoolean) Toggle(entityId string) error { func (ib InputBoolean) Toggle(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "input_boolean" req.Domain = "input_boolean"
@@ -24,6 +26,7 @@ func (ib InputBoolean) Toggle(entityId string) error {
return ib.conn.WriteMessage(req) return ib.conn.WriteMessage(req)
} }
// TurnOff turns off an input boolean entity.
func (ib InputBoolean) TurnOff(entityId string) error { func (ib InputBoolean) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "input_boolean" req.Domain = "input_boolean"

View File

@@ -8,6 +8,7 @@ type InputButton struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// Press presses an input button entity.
func (ib InputButton) Press(entityId string) error { func (ib InputButton) Press(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "input_button" req.Domain = "input_button"

View File

@@ -8,6 +8,7 @@ type InputText struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// Set sets the value of an input text entity.
func (ib InputText) Set(entityId string, value string) error { func (ib InputText) Set(entityId string, value string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "input_text" req.Domain = "input_text"

View File

@@ -8,8 +8,7 @@ type Light struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// TurnOn a light entity. Takes an entityId and an optional // TurnOn a light entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error { func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "light" req.Domain = "light"
@@ -21,8 +20,7 @@ func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error {
return l.conn.WriteMessage(req) return l.conn.WriteMessage(req)
} }
// Toggle a light entity. Takes an entityId and an optional // Toggle a light entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (l Light) Toggle(entityId string, serviceData ...map[string]any) error { func (l Light) Toggle(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "light" req.Domain = "light"
@@ -34,6 +32,7 @@ func (l Light) Toggle(entityId string, serviceData ...map[string]any) error {
return l.conn.WriteMessage(req) return l.conn.WriteMessage(req)
} }
// TurnOff turns off a light entity.
func (l Light) TurnOff(entityId string) error { func (l Light) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "light" req.Domain = "light"

View File

@@ -8,8 +8,7 @@ type Lock struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// Lock a lock entity. Takes an entityId and an optional // Lock a lock entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (l Lock) Lock(entityId string, serviceData ...map[string]any) error { func (l Lock) Lock(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "lock" req.Domain = "lock"
@@ -21,8 +20,7 @@ func (l Lock) Lock(entityId string, serviceData ...map[string]any) error {
return l.conn.WriteMessage(req) return l.conn.WriteMessage(req)
} }
// Unlock a lock entity. Takes an entityId and an optional // Unlock a lock entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (l Lock) Unlock(entityId string, serviceData ...map[string]any) error { func (l Lock) Unlock(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "lock" req.Domain = "lock"

View File

@@ -8,8 +8,7 @@ type MediaPlayer struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// Send the media player the command to clear players playlist. // Send the media player the command to clear players playlist. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) ClearPlaylist(entityId string) error { func (mp MediaPlayer) ClearPlaylist(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -18,9 +17,7 @@ func (mp MediaPlayer) ClearPlaylist(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Group players together. Only works on platforms with support for player groups. // 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.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -32,8 +29,7 @@ func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command for next track. // Send the media player the command for next track. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) Next(entityId string) error { func (mp MediaPlayer) Next(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -42,8 +38,7 @@ func (mp MediaPlayer) Next(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command for pause. // Send the media player the command for pause. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) Pause(entityId string) error { func (mp MediaPlayer) Pause(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -52,8 +47,7 @@ func (mp MediaPlayer) Pause(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command for play. // Send the media player the command for play. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) Play(entityId string) error { func (mp MediaPlayer) Play(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -62,8 +56,7 @@ func (mp MediaPlayer) Play(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Toggle media player play/pause state. // Toggle media player play/pause state. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) PlayPause(entityId string) error { func (mp MediaPlayer) PlayPause(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -72,8 +65,7 @@ func (mp MediaPlayer) PlayPause(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command for previous track. // Send the media player the command for previous track. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) Previous(entityId string) error { func (mp MediaPlayer) Previous(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -82,9 +74,7 @@ func (mp MediaPlayer) Previous(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command to seek in current playing media. // 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.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -96,8 +86,7 @@ func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the stop command. // Send the media player the stop command. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) Stop(entityId string) error { func (mp MediaPlayer) Stop(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -106,9 +95,7 @@ func (mp MediaPlayer) Stop(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command for playing media. // Send the media player the command to play a media. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -120,8 +107,7 @@ func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Set repeat mode. Takes an entityId and an optional // Set repeat mode. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -133,9 +119,7 @@ func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command to change sound mode. // Select a sound mode. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -147,9 +131,7 @@ func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Send the media player the command to change input source. // Select a source. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -161,9 +143,7 @@ func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]an
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Set shuffling state. // Toggle shuffle state. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -175,8 +155,7 @@ func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) er
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Toggles a media player power state. // Toggle a media player on/off. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) Toggle(entityId string) error { func (mp MediaPlayer) Toggle(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -185,8 +164,7 @@ func (mp MediaPlayer) Toggle(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Turn a media player power off. // Turn off a media player. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) TurnOff(entityId string) error { func (mp MediaPlayer) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -195,8 +173,7 @@ func (mp MediaPlayer) TurnOff(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Turn a media player power on. // Turn on a media player. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) TurnOn(entityId string) error { func (mp MediaPlayer) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -205,9 +182,7 @@ func (mp MediaPlayer) TurnOn(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Unjoin the player from a group. Only works on // Separate a player from a group. Only works on platforms with support for player groups. Takes an entityId.
// platforms with support for player groups.
// Takes an entityId.
func (mp MediaPlayer) Unjoin(entityId string) error { func (mp MediaPlayer) Unjoin(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -216,8 +191,7 @@ func (mp MediaPlayer) Unjoin(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Turn a media player volume down. // Send the media player the command for volume down. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) VolumeDown(entityId string) error { func (mp MediaPlayer) VolumeDown(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -226,9 +200,7 @@ func (mp MediaPlayer) VolumeDown(entityId string) error {
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Mute a media player's volume. // Mute a media player. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -240,9 +212,7 @@ func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Set a media player's volume level. // Set volume level. Takes an entityId and an optional map that is translated into service_data.
// Takes an entityId and an optional
// map that is translated into service_data.
func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any) error { func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"
@@ -254,8 +224,7 @@ func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req) return mp.conn.WriteMessage(req)
} }
// Turn a media player volume up. // Send the media player the command for volume up. Takes an entityId.
// Takes an entityId.
func (mp MediaPlayer) VolumeUp(entityId string) error { func (mp MediaPlayer) VolumeUp(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "media_player" req.Domain = "media_player"

View File

@@ -20,8 +20,7 @@ func (s Scene) Apply(serviceData ...map[string]any) error {
return s.conn.WriteMessage(req) return s.conn.WriteMessage(req)
} }
// Create a scene entity. Takes an entityId and an optional // Create a scene entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (s Scene) Create(entityId string, serviceData ...map[string]any) error { func (s Scene) Create(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "scene" req.Domain = "scene"
@@ -42,8 +41,7 @@ func (s Scene) Reload() error {
return s.conn.WriteMessage(req) return s.conn.WriteMessage(req)
} }
// TurnOn a scene entity. Takes an entityId and an optional // TurnOn a scene entity. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (s Scene) TurnOn(entityId string, serviceData ...map[string]any) error { func (s Scene) TurnOn(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "scene" req.Domain = "scene"

View File

@@ -8,6 +8,7 @@ type Switch struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// TurnOn turns on a switch entity.
func (s Switch) TurnOn(entityId string) error { func (s Switch) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "switch" req.Domain = "switch"
@@ -16,6 +17,7 @@ func (s Switch) TurnOn(entityId string) error {
return s.conn.WriteMessage(req) return s.conn.WriteMessage(req)
} }
// Toggle toggles a switch entity.
func (s Switch) Toggle(entityId string) error { func (s Switch) Toggle(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "switch" req.Domain = "switch"
@@ -24,6 +26,7 @@ func (s Switch) Toggle(entityId string) error {
return s.conn.WriteMessage(req) return s.conn.WriteMessage(req)
} }
// TurnOff turns off a switch entity.
func (s Switch) TurnOff(entityId string) error { func (s Switch) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "switch" req.Domain = "switch"

View File

@@ -17,9 +17,7 @@ func (tts TTS) ClearCache() error {
return tts.conn.WriteMessage(req) return tts.conn.WriteMessage(req)
} }
// Say something using text-to-speech on a media player with cloud. // 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.
// Takes an entityId and an optional
// map that is translated into service_data.
func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error { func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "tts" req.Domain = "tts"
@@ -31,9 +29,7 @@ func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error {
return tts.conn.WriteMessage(req) return tts.conn.WriteMessage(req)
} }
// Say something using text-to-speech on a media player with google_translate. // 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.
// Takes an entityId and an optional
// map that is translated into service_data.
func (tts TTS) GoogleTranslateSay(entityId string, serviceData ...map[string]any) error { func (tts TTS) GoogleTranslateSay(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "tts" req.Domain = "tts"

View File

@@ -8,8 +8,7 @@ type Vacuum struct {
conn *connect.HAConnection conn *connect.HAConnection
} }
// Tell the vacuum cleaner to do a spot clean-up. // Tell the vacuum cleaner to do a spot clean-up. Takes an entityId.
// Takes an entityId.
func (v Vacuum) CleanSpot(entityId string) error { func (v Vacuum) CleanSpot(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -18,8 +17,7 @@ func (v Vacuum) CleanSpot(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Locate the vacuum cleaner robot. // Locate the vacuum cleaner robot. Takes an entityId.
// Takes an entityId.
func (v Vacuum) Locate(entityId string) error { func (v Vacuum) Locate(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -28,8 +26,7 @@ func (v Vacuum) Locate(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Pause the cleaning task. // Pause the cleaning task. Takes an entityId.
// Takes an entityId.
func (v Vacuum) Pause(entityId string) error { func (v Vacuum) Pause(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -38,8 +35,7 @@ func (v Vacuum) Pause(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Tell the vacuum cleaner to return to its dock. // Tell the vacuum cleaner to return to its dock. Takes an entityId.
// Takes an entityId.
func (v Vacuum) ReturnToBase(entityId string) error { func (v Vacuum) ReturnToBase(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -48,8 +44,7 @@ func (v Vacuum) ReturnToBase(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Send a raw command to the vacuum cleaner. Takes an entityId and an optional // Send a raw command to the vacuum cleaner. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) error { func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -61,8 +56,7 @@ func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) erro
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Set the fan speed of the vacuum cleaner. Takes an entityId and an optional // Set the fan speed of the vacuum cleaner. Takes an entityId and an optional map that is translated into service_data.
// map that is translated into service_data.
func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) error { func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -75,8 +69,7 @@ func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) erro
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Start or resume the cleaning task. // Start or resume the cleaning task. Takes an entityId.
// Takes an entityId.
func (v Vacuum) Start(entityId string) error { func (v Vacuum) Start(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -85,8 +78,7 @@ func (v Vacuum) Start(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Start, pause, or resume the cleaning task. // Start, pause, or resume the cleaning task. Takes an entityId.
// Takes an entityId.
func (v Vacuum) StartPause(entityId string) error { func (v Vacuum) StartPause(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -95,8 +87,7 @@ func (v Vacuum) StartPause(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Stop the current cleaning task. // Stop the current cleaning task. Takes an entityId.
// Takes an entityId.
func (v Vacuum) Stop(entityId string) error { func (v Vacuum) Stop(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -105,8 +96,7 @@ func (v Vacuum) Stop(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Stop the current cleaning task and return to home. // Stop the current cleaning task and return to home. Takes an entityId.
// Takes an entityId.
func (v Vacuum) TurnOff(entityId string) error { func (v Vacuum) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"
@@ -115,8 +105,7 @@ func (v Vacuum) TurnOff(entityId string) error {
return v.conn.WriteMessage(req) return v.conn.WriteMessage(req)
} }
// Start a new cleaning task. // Start a new cleaning task. Takes an entityId.
// Takes an entityId.
func (v Vacuum) TurnOn(entityId string) error { func (v Vacuum) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId) req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum" req.Domain = "vacuum"

View File

@@ -79,20 +79,20 @@ func (ib intervalBuilder) Call(callback IntervalCallback) intervalBuilderCall {
return intervalBuilderCall(ib) 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 { func (ib intervalBuilderCall) Every(s types.DurationString) intervalBuilderEnd {
d := internal.ParseDuration(string(s)) d := internal.ParseDuration(string(s))
ib.interval.frequency = d ib.interval.frequency = d
return intervalBuilderEnd(ib) 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 { func (ib intervalBuilderEnd) StartingAt(s types.TimeString) intervalBuilderEnd {
ib.interval.startTime = s ib.interval.startTime = s
return ib 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 { func (ib intervalBuilderEnd) EndingAt(s types.TimeString) intervalBuilderEnd {
ib.interval.endTime = s ib.interval.endTime = s
return ib return ib
@@ -111,10 +111,8 @@ func (ib intervalBuilderEnd) ExceptionRange(start, end time.Time) intervalBuilde
return ib return ib
} }
/* // Enable this interval only when the current state of {entityId} matches {state}.
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.
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 { func (ib intervalBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) intervalBuilderEnd {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state)) 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 return ib
} }
/* // Disable this interval when the current state of {entityId} matches {state}.
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.
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 { func (ib intervalBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) intervalBuilderEnd {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state)) 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) { func (i Interval) maybeRunCallback(a *App) {
if c := CheckStartEndTime(i.startTime /* isStart = */, true); c.fail { if c := CheckStartEndTime(i.startTime, true); c.fail {
return return
} }
if c := CheckStartEndTime(i.endTime /* isStart = */, false); c.fail { if c := CheckStartEndTime(i.endTime, false); c.fail {
return return
} }
if c := CheckExceptionDates(i.exceptionDates); c.fail { if c := CheckExceptionDates(i.exceptionDates); c.fail {

View File

@@ -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 package gomeassistant
import ( import (
@@ -10,44 +13,67 @@ import (
"github.com/dromara/carbon/v2" "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) 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 { type DailySchedule struct {
// 0-23 // Hour of the day (0-23) when the schedule should run
hour int hour int
// 0-59 // Minute of the hour (0-59) when the schedule should run
minute int minute int
callback ScheduleCallback // Function to call when the schedule triggers
callback ScheduleCallback
// Next time this schedule should run
nextRunTime time.Time nextRunTime time.Time
// If true, schedule runs at sunrise instead of fixed time
isSunrise bool 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 sunOffset types.DurationString
// Dates when this schedule should NOT run
exceptionDates []time.Time exceptionDates []time.Time
// Dates when this schedule is ONLY allowed to run (if empty, runs on all dates)
allowlistDates []time.Time 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 disabledEntities []internal.EnabledDisabledInfo
} }
// Hash returns a unique string identifier for this schedule based on its
// time and callback function.
func (s DailySchedule) Hash() string { func (s DailySchedule) Hash() string {
return fmt.Sprint(s.hour, s.minute, s.callback) 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 { type scheduleBuilder struct {
schedule DailySchedule schedule DailySchedule
} }
// scheduleBuilderCall represents the state after setting the callback function.
type scheduleBuilderCall struct { type scheduleBuilderCall struct {
schedule DailySchedule schedule DailySchedule
} }
// scheduleBuilderEnd represents the final state where time and conditions are set.
type scheduleBuilderEnd struct { type scheduleBuilderEnd struct {
schedule DailySchedule 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 { func NewDailySchedule() scheduleBuilder {
return scheduleBuilder{ return scheduleBuilder{
DailySchedule{ DailySchedule{
@@ -58,6 +84,7 @@ func NewDailySchedule() scheduleBuilder {
} }
} }
// String returns a human-readable representation of the schedule.
func (s DailySchedule) String() string { func (s DailySchedule) String() string {
return fmt.Sprintf("Schedule{ call %q daily at %s }", return fmt.Sprintf("Schedule{ call %q daily at %s }",
internal.GetFunctionName(s.callback), 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 { func stringHourMinute(hour, minute int) string {
return fmt.Sprintf("%02d:%02d", hour, minute) 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 { func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall {
sb.schedule.callback = callback sb.schedule.callback = callback
return scheduleBuilderCall(sb) 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 { func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
t := internal.ParseTime(s) t := internal.ParseTime(s)
sb.schedule.hour = t.Hour() sb.schedule.hour = t.Hour()
@@ -82,9 +113,13 @@ func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
return scheduleBuilderEnd(sb) return scheduleBuilderEnd(sb)
} }
// Sunrise takes an optional duration string that is passed to time.ParseDuration. // Sunrise configures the schedule to run at sunrise with an optional offset.
// Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration // The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
// for full list. // 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 { func (sb scheduleBuilderCall) Sunrise(offset ...types.DurationString) scheduleBuilderEnd {
sb.schedule.isSunrise = true sb.schedule.isSunrise = true
if len(offset) > 0 { if len(offset) > 0 {
@@ -93,9 +128,13 @@ func (sb scheduleBuilderCall) Sunrise(offset ...types.DurationString) scheduleBu
return scheduleBuilderEnd(sb) return scheduleBuilderEnd(sb)
} }
// Sunset takes an optional duration string that is passed to time.ParseDuration. // Sunset configures the schedule to run at sunset with an optional offset.
// Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration // The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
// for full list. // 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 { func (sb scheduleBuilderCall) Sunset(offset ...types.DurationString) scheduleBuilderEnd {
sb.schedule.isSunset = true sb.schedule.isSunset = true
if len(offset) > 0 { if len(offset) > 0 {
@@ -104,20 +143,27 @@ func (sb scheduleBuilderCall) Sunset(offset ...types.DurationString) scheduleBui
return scheduleBuilderEnd(sb) 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 { func (sb scheduleBuilderEnd) ExceptionDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
sb.schedule.exceptionDates = append(tl, t) sb.schedule.exceptionDates = append(tl, t)
return sb 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 { func (sb scheduleBuilderEnd) OnlyOnDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
sb.schedule.allowlistDates = append(tl, t) sb.schedule.allowlistDates = append(tl, t)
return sb return sb
} }
/* // EnabledWhen makes this schedule only run when the specified entity is in the given state.
Enable this schedule only when the current state of {entityId} matches {state}. // If there's a network error while checking the entity state, the schedule runs
If there is a network error while retrieving state, the schedule runs if {runOnNetworkError} is true. // 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 { func (sb scheduleBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state)) 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 return sb
} }
/* // DisabledWhen prevents this schedule from running when the specified entity is in the given state.
Disable this schedule when the current state of {entityId} matches {state}. // If there's a network error while checking the entity state, the schedule runs only if runOnNetworkError is true.
If there is a network error while retrieving state, the schedule runs 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 { func (sb scheduleBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
if entityId == "" { if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state)) 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 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 { func (sb scheduleBuilderEnd) Build() DailySchedule {
return sb.schedule 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) { func runSchedules(a *App) {
if a.schedules.Len() == 0 { if a.schedules.Len() == 0 {
return return
@@ -168,7 +219,7 @@ func runSchedules(a *App) {
sched := popSchedule(a) 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()) { for sched.nextRunTime.Before(time.Now()) {
sched.maybeRunCallback(a) sched.maybeRunCallback(a)
requeueSchedule(a, sched) requeueSchedule(a, sched)
@@ -178,7 +229,7 @@ func runSchedules(a *App) {
slog.Info("Next schedule", "start_time", sched.nextRunTime) slog.Info("Next schedule", "start_time", sched.nextRunTime)
// Use context-aware sleep // Wait until the next schedule time or context cancellation
select { select {
case <-time.After(time.Until(sched.nextRunTime)): case <-time.After(time.Until(sched.nextRunTime)):
// Time elapsed, continue // 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) { func (s DailySchedule) maybeRunCallback(a *App) {
if c := CheckExceptionDates(s.exceptionDates); c.fail { if c := CheckExceptionDates(s.exceptionDates); c.fail {
return return
@@ -208,15 +266,19 @@ func (s DailySchedule) maybeRunCallback(a *App) {
go s.callback(a.service, a.state) go s.callback(a.service, a.state)
} }
// popSchedule removes and returns the next schedule from the priority queue.
func popSchedule(a *App) DailySchedule { func popSchedule(a *App) DailySchedule {
_sched, _ := a.schedules.Get(1) _sched, _ := a.schedules.Get(1)
return _sched[0].(Item).Value.(DailySchedule) 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) { func requeueSchedule(a *App, s DailySchedule) {
if s.isSunrise || s.isSunset { if s.isSunrise || s.isSunset {
var nextSunTime *carbon.Carbon var nextSunTime *carbon.Carbon
// "0s" is default value // "0s" is the default value for no offset
if s.sunOffset != "0s" { if s.sunOffset != "0s" {
nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset) nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset)
} else { } else {

View File

@@ -81,7 +81,7 @@ func (s *StateImpl) Get(entityId string) (EntityState, error) {
} }
// ListEntities returns a list of all entities in Home Assistant. // 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) { func (s *StateImpl) ListEntities() ([]EntityState, error) {
resp, err := s.httpClient.GetStates() resp, err := s.httpClient.GetStates()
if err != nil { 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 { 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) 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 { 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) return carbon.Now().Lt(sunset)
} }

View File

@@ -7,7 +7,7 @@ type NewAppRequest struct {
// Optional // Optional
// Deprecated: use URL instead // 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. // or "192.168.86.59" etc.
IpAddress string IpAddress string
@@ -18,18 +18,18 @@ type NewAppRequest struct {
// Required // Required
// Auth token generated in Home Assistant. Used // Auth token generated in Home Assistant. Used
// to connect to the Websocket API. // to connect to the WebSocket API.
HAAuthToken string HAAuthToken string
// Required // 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 // Used to pull latitude/longitude from Home Assistant
// to calculate sunset/sunrise times. // to calculate sunset/sunrise times.
HomeZoneEntityId string HomeZoneEntityId string
// Optional // Optional
// Whether to use secure connections for http and websockets. // Whether to use secure connections for HTTP and WebSockets.
// Setting this to `true` will use `https://` instead of `https://` // Setting this to `true` will use `https://` instead of `http://`
// and `wss://` instead of `ws://`. // and `wss://` instead of `ws://`.
Secure bool Secure bool
} }