16 Commits
v0.6.5 ... main

Author SHA1 Message Date
440c5afac2 feat: new interval trigger 2025-08-02 03:02:51 -05:00
258bea962a feat: new cron trigger 2025-08-02 03:02:51 -05:00
bbba55574f feat: new schedule triggering, new builder pattern in internal 2025-08-02 03:02:51 -05:00
79f810b1f8 docs: improve schedule.go 2025-08-01 23:29:48 -05:00
292879a8a9 docs: normalize & make basic corrections to documentation 2025-08-01 21:27:21 -05:00
e13fd6ab5b chore: update carbon pkg to v2 latest, bump version 2025-08-01 21:02:21 -05:00
d281d70d8c chore: update dependencies 2025-08-01 20:57:25 -05:00
8fe6bc0cff chore: reformat, normalize imports 2025-08-01 20:44:38 -05:00
9b8ef545a6 refactor: websockets into 'connect' module, rename & adjust generally 2025-08-01 20:34:45 -05:00
102a4e7438 refactor: minor changes 2025-08-01 18:25:39 -05:00
d51f6d5946 refactor: move 'parse' back to internal root 2025-08-01 18:18:16 -05:00
26b8892ff6 refactor: move event_types into types/ pkg, make methods public, small fixes 2025-08-01 18:16:25 -05:00
21358b73e1 refactor: move types out of app.go into types/, renamed module files 2025-08-01 18:08:01 -05:00
3d178ad05e refactor: rename request_types.go 2025-08-01 17:10:03 -05:00
c91c4f85c3 refactor: rename module to github.com/Xevion/go-ha 2025-08-01 17:04:11 -05:00
5698a30b37 refactor: move http module into internal root, remove deprecated handling 2025-08-01 17:03:28 -05:00
62 changed files with 2223 additions and 1024 deletions

View File

@@ -1,20 +1,18 @@
# Gome-Assistant
# go-ha
Write strongly typed [Home Assistant](https://www.home-assistant.io/) automations in Go!
## Disclaimer
Gome-Assistant is a new library, and I'm opening it up early to get some user feedback on the API and help shape the direction. I plan for it to grow to cover all Home Assistant use cases, services, and event types. So it's possible that breaking changes will happen before v1.0.0!
## Quick Start
### Installation
```
go get github.com/Xevion/gome-assistant
```bash
go get github.com/Xevion/go-ha
```
### Generate Entity Constants
or in `go.mod`:
```go
require github.com/Xevion/go-ha
```
## Generate Entity Constants
You can generate type-safe constants for all your Home Assistant entities using `go generate`. This makes it easier to reference entities in your code.
@@ -37,7 +35,7 @@ exclude_domains: ["device_tracker", "person"]
2. Add a `//go:generate` comment in your project:
```go
//go:generate go run github.com/Xevion/gome-assistant/cmd/generate
//go:generate go run github.com/Xevion/go-ha/cmd/generate
```
Optionally use the `-config` flag to customize the file path of the config file.
@@ -72,11 +70,11 @@ Check out [`example/example.go`](./example/example.go) for an example of the 3 t
### Run your code
Keeping with the simplicity that Go is famous for, you don't need a specific environment or docker container to run Gome-Assistant. You just write and run your code like any other Go binary. So once you build your code, you can run it however you like — using `screen` or `tmux`, a cron job, a linux service, or wrap it up in a docker container if you like!
Keeping with the simplicity that Go is famous for, you don't need a specific environment or docker container to run go-ha. You just write and run your code like any other Go binary. So once you build your code, you can run it however you like — using `screen` or `tmux`, a cron job, a linux service, or wrap it up in a docker container if you like!
> _❗ No promises, but I may provide a Docker image with file watching to automatically restart gome-assistant, to make it easier to use gome-assistant on a fully managed Home Assistant installation._
> _❗ No promises, but I may provide a Docker image with file watching to automatically restart go-ha, to make it easier to use go-ha on a fully managed Home Assistant installation._
## gome-assistant Concepts
## go-ha Concepts
### Overview
@@ -87,13 +85,13 @@ The general flow is
3. Start app
```go
import ga "github.com/Xevion/gome-assistant"
import ha "github.com/Xevion/go-ha"
// replace with IP and port of your Home Assistant installation
app, err := ga.NewApp(ga.NewAppRequest{
URL: "http://192.168.1.123:8123",
HAAuthToken: os.Getenv("HA_AUTH_TOKEN"),
HomeZoneEntityId: "zone.home",
URL: "http://192.168.1.123:8123",
HAAuthToken: os.Getenv("HA_AUTH_TOKEN"),
HomeZoneEntityId: "zone.home",
})
// create automations here (see next sections)
@@ -107,7 +105,7 @@ app.RegisterIntervals(...)
app.Start()
```
A full reference is available on [pkg.go.dev](https://pkg.go.dev/github.com/Xevion/gome-assistant), but all you need to know to get started are the four types of automations in gome-assistant.
A full reference is available on [pkg.go.dev](https://pkg.go.dev/github.com/Xevion/go-ha), but all you need to know to get started are the four types of automations in go-ha.
- [Daily Schedules](#daily-schedule)
- [Entity Listeners](#entity-listener)
@@ -226,7 +224,7 @@ func myCallback(service *ga.Service, state ga.State, data ga.EventData) {
}
```
> 💡 Check `eventTypes.go` for pre-defined event types, or create your own struct type for custom events and contribute them back to gome-assistant with a PR.
> 💡 Check `eventTypes.go` for pre-defined event types, or create your own struct type for custom events and contribute them back to go-ha with a PR.
### Interval

227
app.go
View File

@@ -9,15 +9,14 @@ import (
"strings"
"time"
"github.com/golang-module/carbon"
"github.com/dromara/carbon/v2"
"github.com/gorilla/websocket"
sunriseLib "github.com/nathan-osman/go-sunrise"
"github.com/Workiva/go-datastructures/queue"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/gome-assistant/internal/http"
"github.com/Xevion/gome-assistant/internal/parse"
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/internal/connect"
"github.com/Xevion/go-ha/types"
)
var ErrInvalidArgs = errors.New("invalid arguments provided")
@@ -25,12 +24,11 @@ var ErrInvalidArgs = errors.New("invalid arguments provided")
type App struct {
ctx context.Context
ctxCancel context.CancelFunc
conn *websocket.Conn
// Wraps the ws connection with added mutex locking
wsWriter *ws.WebsocketWriter
conn *connect.HAConnection
httpClient *http.HttpClient
httpClient *internal.HttpClient
service *Service
state *StateImpl
@@ -42,66 +40,18 @@ type App struct {
eventListeners map[string][]*EventListener
}
type Item struct {
Value interface{}
Priority float64
}
type Item types.Item
func (mi Item) Compare(other queue.Item) int {
if mi.Priority > other.(Item).Priority {
func (i Item) Compare(other queue.Item) int {
if i.Priority > other.(Item).Priority {
return 1
} else if mi.Priority == other.(Item).Priority {
} else if i.Priority == other.(Item).Priority {
return 0
}
return -1
}
// DurationString represents a duration, such as "2s" or "24h".
// See https://pkg.go.dev/time#ParseDuration for all valid time units.
type DurationString string
// TimeString is a 24-hr format time "HH:MM" such as "07:30".
type TimeString string
type timeRange struct {
start time.Time
end time.Time
}
type NewAppRequest struct {
// Required
URL string
// Optional
// Deprecated: use URL instead
// IpAddress of your Home Assistant instance i.e. "localhost"
// or "192.168.86.59" etc.
IpAddress string
// Optional
// Deprecated: use URL instead
// Port number Home Assistant is running on. Defaults to 8123.
Port string
// Required
// Auth token generated in Home Assistant. Used
// to connect to the Websocket API.
HAAuthToken string
// Required
// EntityId of the zone representing your home e.g. "zone.home".
// Used to pull latitude/longitude from Home Assistant
// to calculate sunset/sunrise times.
HomeZoneEntityId string
// Optional
// Whether to use secure connections for http and websockets.
// Setting this to `true` will use `https://` instead of `https://`
// and `wss://` instead of `ws://`.
Secure bool
}
// validateHomeZone verifies that the home zone entity exists and has latitude/longitude
// validateHomeZone verifies that the home zone entity exists and has latitude/longitude.
func validateHomeZone(state State, entityID string) error {
entity, err := state.Get(entityID)
if err != nil {
@@ -116,22 +66,17 @@ func validateHomeZone(state State, entityID string) error {
// Verify it has latitude and longitude
if entity.Attributes == nil {
return fmt.Errorf("home zone entity '%s' has no attributes", entityID)
}
if entity.Attributes["latitude"] == nil {
} else if entity.Attributes["latitude"] == nil {
return fmt.Errorf("home zone entity '%s' missing latitude attribute", entityID)
}
if entity.Attributes["longitude"] == nil {
} else if entity.Attributes["longitude"] == nil {
return fmt.Errorf("home zone entity '%s' missing longitude attribute", entityID)
}
return nil
}
/*
NewApp establishes the websocket connection and returns an object
you can use to register schedules and listeners.
*/
func NewApp(request NewAppRequest) (*App, error) {
// NewApp establishes the WebSocket connection and returns an object you can use to register schedules and listeners.
func NewApp(request types.NewAppRequest) (*App, error) {
if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" {
slog.Error("URL and HAAuthToken are required arguments in NewAppRequest")
return nil, ErrInvalidArgs
@@ -148,33 +93,18 @@ func NewApp(request NewAppRequest) (*App, error) {
var err error
baseURL, err = url.Parse(request.URL)
if err != nil {
return nil, ErrInvalidArgs
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
} else {
// This is deprecated and will be removed in a future release
port := request.Port
if port == "" {
port = "8123"
}
baseURL.Scheme = "http"
if request.Secure {
baseURL.Scheme = "https"
}
baseURL.Host = request.IpAddress + ":" + port
}
conn, ctx, ctxCancel, err := ws.ConnectionFromUri(baseURL, request.HAAuthToken)
conn, ctx, ctxCancel, err := connect.ConnectionFromUri(baseURL, request.HAAuthToken)
if err != nil {
return nil, err
}
if conn == nil {
return nil, err
}
httpClient := http.NewHttpClient(baseURL, request.HAAuthToken)
httpClient := internal.NewHttpClient(ctx, baseURL, request.HAAuthToken)
wsWriter := &ws.WebsocketWriter{Conn: conn}
service := newService(wsWriter)
service := newService(conn)
state, err := newState(httpClient, request.HomeZoneEntityId)
if err != nil {
return nil, err
@@ -187,7 +117,6 @@ func NewApp(request NewAppRequest) (*App, error) {
return &App{
conn: conn,
wsWriter: wsWriter,
ctx: ctx,
ctxCancel: ctxCancel,
httpClient: httpClient,
@@ -200,39 +129,37 @@ func NewApp(request NewAppRequest) (*App, error) {
}, nil
}
func (a *App) Cleanup() {
if a.ctxCancel != nil {
a.ctxCancel()
func (app *App) Cleanup() {
if app.ctxCancel != nil {
app.ctxCancel()
}
}
// Close performs a clean shutdown of the application.
// It cancels the context, closes the websocket connection,
// and ensures all background processes are properly terminated.
func (a *App) Close() error {
// Close websocket connection if it exists
if a.conn != nil {
// Close performs a clean shutdown of the application. It cancels the context, closes the WebSocket connection, and ensures all background processes are properly terminated.
func (app *App) Close() error {
// Close WebSocket connection if it exists
if app.conn != nil {
deadline := time.Now().Add(10 * time.Second)
err := a.conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), deadline)
err := app.conn.Conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), deadline)
if err != nil {
slog.Warn("Error writing close message", "error", err)
return err
}
// Close the websocket connection
err = a.conn.Close()
// Close the WebSocket connection
err = app.conn.Conn.Close()
if err != nil {
slog.Warn("Error closing websocket connection", "error", err)
slog.Warn("Error closing WebSocket connection", "error", err)
return err
}
}
// Wait a short time for the websocket connection to close
// Wait a short time for the WebSocket connection to close
time.Sleep(500 * time.Millisecond)
// Cancel context to signal all goroutines to stop
if a.ctxCancel != nil {
a.ctxCancel()
if app.ctxCancel != nil {
app.ctxCancel()
}
// Wait a short time for goroutines to finish
@@ -242,12 +169,12 @@ func (a *App) Close() error {
return nil
}
func (a *App) RegisterSchedules(schedules ...DailySchedule) {
func (app *App) RegisterSchedules(schedules ...DailySchedule) {
for _, s := range schedules {
// realStartTime already set for sunset/sunrise
if s.isSunrise || s.isSunset {
s.nextRunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset).Carbon2Time()
a.schedules.Put()
s.nextRunTime = getNextSunRiseOrSet(app, s.isSunrise, s.sunOffset).StdTime()
app.schedules.Put()
continue
}
@@ -259,34 +186,34 @@ func (a *App) RegisterSchedules(schedules ...DailySchedule) {
startTime = startTime.AddDay()
}
s.nextRunTime = startTime.Carbon2Time()
a.schedules.Put(Item{
s.nextRunTime = startTime.StdTime()
app.schedules.Put(Item{
Value: s,
Priority: float64(startTime.Carbon2Time().Unix()),
Priority: float64(startTime.StdTime().Unix()),
})
}
}
func (a *App) RegisterIntervals(intervals ...Interval) {
func (app *App) RegisterIntervals(intervals ...Interval) {
for _, i := range intervals {
if i.frequency == 0 {
slog.Error("A schedule must use either set frequency via Every()")
panic(ErrInvalidArgs)
}
i.nextRunTime = parse.ParseTime(string(i.startTime)).Carbon2Time()
i.nextRunTime = internal.ParseTime(string(i.startTime)).StdTime()
now := time.Now()
for i.nextRunTime.Before(now) {
i.nextRunTime = i.nextRunTime.Add(i.frequency)
}
a.intervals.Put(Item{
app.intervals.Put(Item{
Value: i,
Priority: float64(i.nextRunTime.Unix()),
})
}
}
func (a *App) RegisterEntityListeners(etls ...EntityListener) {
func (app *App) RegisterEntityListeners(etls ...EntityListener) {
for _, etl := range etls {
etl := etl
if etl.delay != 0 && etl.toState == "" {
@@ -295,31 +222,31 @@ func (a *App) RegisterEntityListeners(etls ...EntityListener) {
}
for _, entity := range etl.entityIds {
if elList, ok := a.entityListeners[entity]; ok {
a.entityListeners[entity] = append(elList, &etl)
if elList, ok := app.entityListeners[entity]; ok {
app.entityListeners[entity] = append(elList, &etl)
} else {
a.entityListeners[entity] = []*EntityListener{&etl}
app.entityListeners[entity] = []*EntityListener{&etl}
}
}
}
}
func (a *App) RegisterEventListeners(evls ...EventListener) {
func (app *App) RegisterEventListeners(evls ...EventListener) {
for _, evl := range evls {
evl := evl
for _, eventType := range evl.eventTypes {
if elList, ok := a.eventListeners[eventType]; ok {
a.eventListeners[eventType] = append(elList, &evl)
if elList, ok := app.eventListeners[eventType]; ok {
app.eventListeners[eventType] = append(elList, &evl)
} else {
ws.SubscribeToEventType(eventType, a.wsWriter, a.ctx)
a.eventListeners[eventType] = []*EventListener{&evl}
connect.SubscribeToEventType(eventType, app.conn, app.ctx)
app.eventListeners[eventType] = []*EventListener{&evl}
}
}
}
}
func getSunriseSunset(s *StateImpl, sunrise bool, dateToUse carbon.Carbon, offset ...DurationString) carbon.Carbon {
date := dateToUse.Carbon2Time()
func getSunriseSunset(s *StateImpl, sunrise bool, dateToUse *carbon.Carbon, offset ...types.DurationString) *carbon.Carbon {
date := dateToUse.StdTime()
rise, set := sunriseLib.SunriseSunset(s.latitude, s.longitude, date.Year(), date.Month(), date.Day())
rise, set = rise.Local(), set.Local()
@@ -351,7 +278,7 @@ func getSunriseSunset(s *StateImpl, sunrise bool, dateToUse carbon.Carbon, offse
return setOrRiseToday
}
func getNextSunRiseOrSet(a *App, sunrise bool, offset ...DurationString) carbon.Carbon {
func getNextSunRiseOrSet(a *App, sunrise bool, offset ...types.DurationString) *carbon.Carbon {
sunriseOrSunset := getSunriseSunset(a.state, sunrise, carbon.Now(), offset...)
if sunriseOrSunset.Lt(carbon.Now()) {
// if we're past today's sunset or sunrise (accounting for offset) then get tomorrows
@@ -361,32 +288,32 @@ func getNextSunRiseOrSet(a *App, sunrise bool, offset ...DurationString) carbon.
return sunriseOrSunset
}
func (a *App) Start() {
slog.Info("Starting", "schedules", a.schedules.Len())
slog.Info("Starting", "entity listeners", len(a.entityListeners))
slog.Info("Starting", "event listeners", len(a.eventListeners))
func (app *App) Start() {
slog.Info("Starting", "schedules", app.schedules.Len())
slog.Info("Starting", "entity listeners", len(app.entityListeners))
slog.Info("Starting", "event listeners", len(app.eventListeners))
go runSchedules(a)
go runIntervals(a)
go runSchedules(app)
go runIntervals(app)
// subscribe to state_changed events
id := internal.NextId()
ws.SubscribeToStateChangedEvents(id, a.wsWriter, a.ctx)
a.entityListenersId = id
connect.SubscribeToStateChangedEvents(id, app.conn, app.ctx)
app.entityListenersId = id
// entity listeners runOnStartup
for eid, etls := range a.entityListeners {
// Run entity listeners startup
for eid, etls := range app.entityListeners {
for _, etl := range etls {
// ensure each ETL only runs once, even if
// it listens to multiple entities
if etl.runOnStartup && !etl.runOnStartupCompleted {
entityState, err := a.state.Get(eid)
entityState, err := app.state.Get(eid)
if err != nil {
slog.Warn("Failed to get entity state \"", eid, "\" during startup, skipping RunOnStartup")
}
etl.runOnStartupCompleted = true
go etl.callback(a.service, a.state, EntityData{
go etl.callback(app.service, app.state, EntityData{
TriggerEntityId: eid,
FromState: entityState.State,
FromAttributes: entityState.Attributes,
@@ -399,32 +326,32 @@ func (a *App) Start() {
}
// entity listeners and event listeners
elChan := make(chan ws.ChanMsg, 100) // Add buffer to prevent channel overflow
go ws.ListenWebsocket(a.conn, elChan)
elChan := make(chan connect.ChannelMessage, 100) // Add buffer to prevent channel overflow
go connect.ListenWebsocket(app.conn.Conn, elChan)
for {
select {
case msg, ok := <-elChan:
if !ok {
slog.Info("Websocket channel closed, stopping main loop")
slog.Info("WebSocket channel closed, stopping main loop")
return
}
if a.entityListenersId == msg.Id {
go callEntityListeners(a, msg.Raw)
if app.entityListenersId == msg.Id {
go callEntityListeners(app, msg.Raw)
} else {
go callEventListeners(a, msg)
go callEventListeners(app, msg)
}
case <-a.ctx.Done():
case <-app.ctx.Done():
slog.Info("Context cancelled, stopping main loop")
return
}
}
}
func (a *App) GetService() *Service {
return a.service
func (app *App) GetService() *Service {
return app.service
}
func (a *App) GetState() State {
return a.state
func (app *App) GetState() State {
return app.state
}

View File

@@ -133,11 +133,11 @@ func TestAppWithNilFields(t *testing.T) {
}
func TestAppWithWebsocketConnection(t *testing.T) {
// Test app with websocket connection (mocked)
// Test app with WebSocket connection (mocked)
app := &App{
ctx: context.Background(),
ctxCancel: func() {},
conn: nil, // In real test, this would be a mock websocket
conn: nil, // In real test, this would be a mock WebSocket
}
// Test that Close() handles nil connection gracefully

View File

@@ -3,22 +3,22 @@ package gomeassistant
import (
"time"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/gome-assistant/internal/parse"
"github.com/golang-module/carbon"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
"github.com/dromara/carbon/v2"
)
type conditionCheck struct {
type ConditionCheck struct {
fail bool
}
func checkWithinTimeRange(startTime, endTime string) conditionCheck {
cc := conditionCheck{fail: false}
func CheckWithinTimeRange(startTime, endTime string) ConditionCheck {
cc := ConditionCheck{fail: false}
// if betweenStart and betweenEnd both set, first account for midnight
// overlap, then check if between those times.
if startTime != "" && endTime != "" {
parsedStart := parse.ParseTime(startTime)
parsedEnd := parse.ParseTime(endTime)
parsedStart := internal.ParseTime(startTime)
parsedEnd := internal.ParseTime(endTime)
// check for midnight overlap
if parsedEnd.Lt(parsedStart) { // example turn on night lights when motion from 23:00 to 07:00
@@ -35,16 +35,16 @@ func checkWithinTimeRange(startTime, endTime string) conditionCheck {
}
// otherwise just check individual before/after
} else if startTime != "" && parse.ParseTime(startTime).IsFuture() {
} else if startTime != "" && internal.ParseTime(startTime).IsFuture() {
cc.fail = true
} else if endTime != "" && parse.ParseTime(endTime).IsPast() {
} else if endTime != "" && internal.ParseTime(endTime).IsPast() {
cc.fail = true
}
return cc
}
func checkStatesMatch(listenerState, s string) conditionCheck {
cc := conditionCheck{fail: false}
func CheckStatesMatch(listenerState, s string) ConditionCheck {
cc := ConditionCheck{fail: false}
// check if fromState or toState are set and don't match
if listenerState != "" && listenerState != s {
cc.fail = true
@@ -52,8 +52,8 @@ func checkStatesMatch(listenerState, s string) conditionCheck {
return cc
}
func checkThrottle(throttle time.Duration, lastRan carbon.Carbon) conditionCheck {
cc := conditionCheck{fail: false}
func CheckThrottle(throttle time.Duration, lastRan *carbon.Carbon) ConditionCheck {
cc := ConditionCheck{fail: false}
// check if Throttle is set and that duration hasn't passed since lastRan
if throttle.Seconds() > 0 &&
lastRan.DiffAbsInSeconds(carbon.Now()) < int64(throttle.Seconds()) {
@@ -62,8 +62,8 @@ func checkThrottle(throttle time.Duration, lastRan carbon.Carbon) conditionCheck
return cc
}
func checkExceptionDates(eList []time.Time) conditionCheck {
cc := conditionCheck{fail: false}
func CheckExceptionDates(eList []time.Time) ConditionCheck {
cc := ConditionCheck{fail: false}
for _, e := range eList {
y1, m1, d1 := e.Date()
y2, m2, d2 := time.Now().Date()
@@ -75,11 +75,11 @@ func checkExceptionDates(eList []time.Time) conditionCheck {
return cc
}
func checkExceptionRanges(eList []timeRange) conditionCheck {
cc := conditionCheck{fail: false}
func CheckExceptionRanges(eList []types.TimeRange) ConditionCheck {
cc := ConditionCheck{fail: false}
now := time.Now()
for _, eRange := range eList {
if now.After(eRange.start) && now.Before(eRange.end) {
if now.After(eRange.Start) && now.Before(eRange.End) {
cc.fail = true
break
}
@@ -87,8 +87,8 @@ func checkExceptionRanges(eList []timeRange) conditionCheck {
return cc
}
func checkEnabledEntity(s State, infos []internal.EnabledDisabledInfo) conditionCheck {
cc := conditionCheck{fail: false}
func CheckEnabledEntity(s State, infos []internal.EnabledDisabledInfo) ConditionCheck {
cc := ConditionCheck{fail: false}
if len(infos) == 0 {
return cc
}
@@ -115,8 +115,8 @@ func checkEnabledEntity(s State, infos []internal.EnabledDisabledInfo) condition
return cc
}
func checkDisabledEntity(s State, infos []internal.EnabledDisabledInfo) conditionCheck {
cc := conditionCheck{fail: false}
func CheckDisabledEntity(s State, infos []internal.EnabledDisabledInfo) ConditionCheck {
cc := ConditionCheck{fail: false}
if len(infos) == 0 {
return cc
}
@@ -144,12 +144,12 @@ func checkDisabledEntity(s State, infos []internal.EnabledDisabledInfo) conditio
return cc
}
func checkAllowlistDates(eList []time.Time) conditionCheck {
func CheckAllowlistDates(eList []time.Time) ConditionCheck {
if len(eList) == 0 {
return conditionCheck{fail: false}
return ConditionCheck{fail: false}
}
cc := conditionCheck{fail: true}
cc := ConditionCheck{fail: true}
for _, e := range eList {
y1, m1, d1 := e.Date()
y2, m2, d2 := time.Now().Date()
@@ -161,15 +161,15 @@ func checkAllowlistDates(eList []time.Time) conditionCheck {
return cc
}
func checkStartEndTime(s TimeString, isStart bool) conditionCheck {
cc := conditionCheck{fail: false}
func CheckStartEndTime(s types.TimeString, isStart bool) ConditionCheck {
cc := ConditionCheck{fail: false}
// pass immediately if default
if s == "00:00" {
return cc
}
now := time.Now()
parsedTime := parse.ParseTime(string(s)).Carbon2Time()
parsedTime := internal.ParseTime(string(s)).StdTime()
if isStart {
if parsedTime.After(now) {
cc.fail = true

View File

@@ -4,7 +4,8 @@ import (
"errors"
"testing"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
"github.com/stretchr/testify/assert"
)
@@ -15,16 +16,16 @@ type MockState struct {
GetError bool
}
func (s MockState) AfterSunrise(_ ...DurationString) bool {
func (s MockState) AfterSunrise(_ ...types.DurationString) bool {
return true
}
func (s MockState) BeforeSunrise(_ ...DurationString) bool {
func (s MockState) BeforeSunrise(_ ...types.DurationString) bool {
return true
}
func (s MockState) AfterSunset(_ ...DurationString) bool {
func (s MockState) AfterSunset(_ ...types.DurationString) bool {
return true
}
func (s MockState) BeforeSunset(_ ...DurationString) bool {
func (s MockState) BeforeSunset(_ ...types.DurationString) bool {
return true
}
func (s MockState) Get(eid string) (EntityState, error) {
@@ -65,7 +66,7 @@ func TestEnabledEntity_StateEqual_Passes(t *testing.T) {
state := MockState{
EqualsReturn: true,
}
c := checkEnabledEntity(state, list(runOnError))
c := CheckEnabledEntity(state, list(runOnError))
assert.False(t, c.fail, "should pass")
}
@@ -73,7 +74,7 @@ func TestEnabledEntity_StateNotEqual_Fails(t *testing.T) {
state := MockState{
EqualsReturn: false,
}
c := checkEnabledEntity(state, list(runOnError))
c := CheckEnabledEntity(state, list(runOnError))
assert.True(t, c.fail, "should fail")
}
@@ -81,7 +82,7 @@ func TestEnabledEntity_NetworkError_DontRun_Fails(t *testing.T) {
state := MockState{
EqualsError: true,
}
c := checkEnabledEntity(state, list(dontRunOnError))
c := CheckEnabledEntity(state, list(dontRunOnError))
assert.True(t, c.fail, "should fail")
}
@@ -89,7 +90,7 @@ func TestEnabledEntity_NetworkError_StillRun_Passes(t *testing.T) {
state := MockState{
EqualsError: true,
}
c := checkEnabledEntity(state, list(runOnError))
c := CheckEnabledEntity(state, list(runOnError))
assert.False(t, c.fail, "should fail")
}
@@ -97,7 +98,7 @@ func TestDisabledEntity_StateEqual_Fails(t *testing.T) {
state := MockState{
EqualsReturn: true,
}
c := checkDisabledEntity(state, list(runOnError))
c := CheckDisabledEntity(state, list(runOnError))
assert.True(t, c.fail, "should pass")
}
@@ -105,7 +106,7 @@ func TestDisabledEntity_StateNotEqual_Passes(t *testing.T) {
state := MockState{
EqualsReturn: false,
}
c := checkDisabledEntity(state, list(runOnError))
c := CheckDisabledEntity(state, list(runOnError))
assert.False(t, c.fail, "should fail")
}
@@ -113,7 +114,7 @@ func TestDisabledEntity_NetworkError_DontRun_Fails(t *testing.T) {
state := MockState{
EqualsError: true,
}
c := checkDisabledEntity(state, list(dontRunOnError))
c := CheckDisabledEntity(state, list(dontRunOnError))
assert.True(t, c.fail, "should fail")
}
@@ -121,16 +122,16 @@ func TestDisabledEntity_NetworkError_StillRun_Passes(t *testing.T) {
state := MockState{
EqualsError: true,
}
c := checkDisabledEntity(state, list(runOnError))
c := CheckDisabledEntity(state, list(runOnError))
assert.False(t, c.fail, "should fail")
}
func TestStatesMatch(t *testing.T) {
c := checkStatesMatch("hey", "hey")
c := CheckStatesMatch("hey", "hey")
assert.False(t, c.fail, "should pass")
}
func TestStatesDontMatch(t *testing.T) {
c := checkStatesMatch("hey", "bye")
c := CheckStatesMatch("hey", "bye")
assert.True(t, c.fail, "should fail")
}

View File

@@ -9,7 +9,8 @@ import (
"strings"
"text/template"
ga "github.com/Xevion/gome-assistant"
ha "github.com/Xevion/go-ha"
"github.com/Xevion/go-ha/types"
"gopkg.in/yaml.v3"
)
@@ -66,8 +67,8 @@ func toCamelCase(s string) string {
return result.String()
}
// validateHomeZone verifies that the home zone entity exists and is valid
func validateHomeZone(state ga.State, entityID string) error {
// validateHomeZone verifies that the home zone entity exists and is valid.
func validateHomeZone(state ha.State, entityID string) error {
entity, err := state.Get(entityID)
if err != nil {
return fmt.Errorf("home zone entity '%s' not found: %w", entityID, err)
@@ -92,13 +93,13 @@ func validateHomeZone(state ga.State, entityID string) error {
return nil
}
// generate creates the entities.go file with constants for all Home Assistant entities
// generate creates the entities.go file with constants for all Home Assistant entities.
func generate(config Config) error {
if config.HomeZoneEntityId == "" {
config.HomeZoneEntityId = "zone.home"
}
app, err := ga.NewApp(ga.NewAppRequest{
app, err := ha.NewApp(types.NewAppRequest{
URL: config.URL,
HAAuthToken: config.HAAuthToken,
HomeZoneEntityId: config.HomeZoneEntityId,

View File

@@ -5,10 +5,10 @@ import (
"fmt"
"time"
"github.com/golang-module/carbon"
"github.com/dromara/carbon/v2"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/gome-assistant/internal/parse"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
)
type EntityListener struct {
@@ -17,7 +17,7 @@ type EntityListener struct {
fromState string
toState string
throttle time.Duration
lastRan carbon.Carbon
lastRan *carbon.Carbon
betweenStart string
betweenEnd string
@@ -26,7 +26,7 @@ type EntityListener struct {
delayTimer *time.Timer
exceptionDates []time.Time
exceptionRanges []timeRange
exceptionRanges []types.TimeRange
runOnStartup bool
runOnStartupCompleted bool
@@ -67,8 +67,6 @@ type msgState struct {
Attributes map[string]any `json:"attributes"`
}
/* Methods */
func NewEntityListener() elBuilder1 {
return elBuilder1{EntityListener{
lastRan: carbon.Now().StartOfCentury(),
@@ -127,14 +125,14 @@ func (b elBuilder3) ToState(s string) elBuilder3 {
return b
}
func (b elBuilder3) Duration(s DurationString) elBuilder3 {
d := parse.ParseDuration(string(s))
func (b elBuilder3) Duration(s types.DurationString) elBuilder3 {
d := internal.ParseDuration(string(s))
b.entityListener.delay = d
return b
}
func (b elBuilder3) Throttle(s DurationString) elBuilder3 {
d := parse.ParseDuration(string(s))
func (b elBuilder3) Throttle(s types.DurationString) elBuilder3 {
d := internal.ParseDuration(string(s))
b.entityListener.throttle = d
return b
}
@@ -145,7 +143,7 @@ func (b elBuilder3) ExceptionDates(t time.Time, tl ...time.Time) elBuilder3 {
}
func (b elBuilder3) ExceptionRange(start, end time.Time) elBuilder3 {
b.entityListener.exceptionRanges = append(b.entityListener.exceptionRanges, timeRange{start, end})
b.entityListener.exceptionRanges = append(b.entityListener.exceptionRanges, types.TimeRange{Start: start, End: end})
return b
}
@@ -154,10 +152,8 @@ func (b elBuilder3) RunOnStartup() elBuilder3 {
return b
}
/*
Enable this listener only when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
*/
// EnabledWhen enables this listener only when the current state of {entityId} matches {state}.
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
func (b elBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool) elBuilder3 {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
@@ -171,10 +167,8 @@ func (b elBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool)
return b
}
/*
Disable this listener when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
*/
// DisabledWhen disables this listener when the current state of {entityId} matches {state}.
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
func (b elBuilder3) DisabledWhen(entityId, state string, runOnNetworkError bool) elBuilder3 {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
@@ -192,7 +186,6 @@ func (b elBuilder3) Build() EntityListener {
return b.entityListener
}
/* Functions */
func callEntityListeners(app *App, msgBytes []byte) {
msg := stateChangedMsg{}
_ = json.Unmarshal(msgBytes, &msg)
@@ -214,31 +207,31 @@ func callEntityListeners(app *App, msgBytes []byte) {
for _, l := range listeners {
// Check conditions
if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail {
if c := CheckWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail {
continue
}
if c := checkStatesMatch(l.fromState, data.OldState.State); c.fail {
if c := CheckStatesMatch(l.fromState, data.OldState.State); c.fail {
continue
}
if c := checkStatesMatch(l.toState, data.NewState.State); c.fail {
if c := CheckStatesMatch(l.toState, data.NewState.State); c.fail {
if l.delayTimer != nil {
l.delayTimer.Stop()
}
continue
}
if c := checkThrottle(l.throttle, l.lastRan); c.fail {
if c := CheckThrottle(l.throttle, l.lastRan); c.fail {
continue
}
if c := checkExceptionDates(l.exceptionDates); c.fail {
if c := CheckExceptionDates(l.exceptionDates); c.fail {
continue
}
if c := checkExceptionRanges(l.exceptionRanges); c.fail {
if c := CheckExceptionRanges(l.exceptionRanges); c.fail {
continue
}
if c := checkEnabledEntity(app.state, l.enabledEntities); c.fail {
if c := CheckEnabledEntity(app.state, l.enabledEntities); c.fail {
continue
}
if c := checkDisabledEntity(app.state, l.disabledEntities); c.fail {
if c := CheckDisabledEntity(app.state, l.disabledEntities); c.fail {
continue
}

View File

@@ -5,11 +5,11 @@ import (
"fmt"
"time"
"github.com/golang-module/carbon"
"github.com/dromara/carbon/v2"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/gome-assistant/internal/parse"
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/internal/connect"
"github.com/Xevion/go-ha/types"
)
type EventListener struct {
@@ -18,10 +18,10 @@ type EventListener struct {
betweenStart string
betweenEnd string
throttle time.Duration
lastRan carbon.Carbon
lastRan *carbon.Carbon
exceptionDates []time.Time
exceptionRanges []timeRange
exceptionRanges []types.TimeRange
enabledEntities []internal.EnabledDisabledInfo
disabledEntities []internal.EnabledDisabledInfo
@@ -34,8 +34,6 @@ type EventData struct {
RawEventJSON []byte
}
/* Methods */
func NewEventListener() eventListenerBuilder1 {
return eventListenerBuilder1{EventListener{
lastRan: carbon.Now().StartOfCentury(),
@@ -80,8 +78,8 @@ func (b eventListenerBuilder3) OnlyBefore(end string) eventListenerBuilder3 {
return b
}
func (b eventListenerBuilder3) Throttle(s DurationString) eventListenerBuilder3 {
d := parse.ParseDuration(string(s))
func (b eventListenerBuilder3) Throttle(s types.DurationString) eventListenerBuilder3 {
d := internal.ParseDuration(string(s))
b.eventListener.throttle = d
return b
}
@@ -92,14 +90,12 @@ func (b eventListenerBuilder3) ExceptionDates(t time.Time, tl ...time.Time) even
}
func (b eventListenerBuilder3) ExceptionRange(start, end time.Time) eventListenerBuilder3 {
b.eventListener.exceptionRanges = append(b.eventListener.exceptionRanges, timeRange{start, end})
b.eventListener.exceptionRanges = append(b.eventListener.exceptionRanges, types.TimeRange{Start: start, End: end})
return b
}
/*
Enable this listener only when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
*/
// EnabledWhen enables this listener only when the current state of {entityId} matches {state}.
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
func (b eventListenerBuilder3) EnabledWhen(entityId, state string, runOnNetworkError bool) eventListenerBuilder3 {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in eventListener EnabledWhen entityId='%s' state='%s' runOnNetworkError='%t'", entityId, state, runOnNetworkError))
@@ -113,10 +109,8 @@ func (b eventListenerBuilder3) EnabledWhen(entityId, state string, runOnNetworkE
return b
}
/*
Disable this listener when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
*/
// DisabledWhen disables this listener when the current state of {entityId} matches {state}.
// If there is a network error while retrieving state, the listener runs if {runOnNetworkError} is true.
func (b eventListenerBuilder3) DisabledWhen(entityId, state string, runOnNetworkError bool) eventListenerBuilder3 {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in eventListener EnabledWhen entityId='%s' state='%s' runOnNetworkError='%t'", entityId, state, runOnNetworkError))
@@ -140,8 +134,7 @@ type BaseEventMsg struct {
} `json:"event"`
}
/* Functions */
func callEventListeners(app *App, msg ws.ChanMsg) {
func callEventListeners(app *App, msg connect.ChannelMessage) {
baseEventMsg := BaseEventMsg{}
_ = json.Unmarshal(msg.Raw, &baseEventMsg)
listeners, ok := app.eventListeners[baseEventMsg.Event.EventType]
@@ -152,22 +145,22 @@ func callEventListeners(app *App, msg ws.ChanMsg) {
for _, l := range listeners {
// Check conditions
if c := checkWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail {
if c := CheckWithinTimeRange(l.betweenStart, l.betweenEnd); c.fail {
continue
}
if c := checkThrottle(l.throttle, l.lastRan); c.fail {
if c := CheckThrottle(l.throttle, l.lastRan); c.fail {
continue
}
if c := checkExceptionDates(l.exceptionDates); c.fail {
if c := CheckExceptionDates(l.exceptionDates); c.fail {
continue
}
if c := checkExceptionRanges(l.exceptionRanges); c.fail {
if c := CheckExceptionRanges(l.exceptionRanges); c.fail {
continue
}
if c := checkEnabledEntity(app.state, l.enabledEntities); c.fail {
if c := CheckEnabledEntity(app.state, l.enabledEntities); c.fail {
continue
}
if c := checkDisabledEntity(app.state, l.disabledEntities); c.fail {
if c := CheckDisabledEntity(app.state, l.disabledEntities); c.fail {
continue
}

View File

@@ -7,14 +7,13 @@ import (
"time"
// "example/entities" // Optional import generated entities
ga "github.com/Xevion/gome-assistant"
ha "github.com/Xevion/go-ha"
)
//go:generate go run github.com/Xevion/gome-assistant/cmd/generate
//go:generate go run github.com/Xevion/go-ha/cmd/generate
func main() {
app, err := ga.NewApp(ga.NewAppRequest{
app, err := ha.NewApp(ha.NewAppRequest{
URL: "http://192.168.86.67:8123", // Replace with your Home Assistant URL
HAAuthToken: os.Getenv("HA_AUTH_TOKEN"),
HomeZoneEntityId: "zone.home",
@@ -32,25 +31,25 @@ func main() {
slog.Info("Application shutdown complete")
}()
pantryDoor := ga.
pantryDoor := ha.
NewEntityListener().
EntityIds(entities.BinarySensor.PantryDoor). // Use generated entity constant
Call(pantryLights).
Build()
_11pmSched := ga.
_11pmSched := ha.
NewDailySchedule().
Call(lightsOut).
At("23:00").
Build()
_30minsBeforeSunrise := ga.
_30minsBeforeSunrise := ha.
NewDailySchedule().
Call(sunriseSched).
Sunrise("-30m").
Build()
zwaveEventListener := ga.
zwaveEventListener := ha.
NewEventListener().
EventTypes("zwave_js_value_notification").
Call(onEvent).
@@ -63,7 +62,7 @@ func main() {
app.Start()
}
func pantryLights(service *ga.Service, state ga.State, sensor ga.EntityData) {
func pantryLights(service *ha.Service, state ha.State, sensor ha.EntityData) {
l := "light.pantry"
// l := entities.Light.Pantry // Or use generated entity constant
if sensor.ToState == "on" {
@@ -73,18 +72,18 @@ func pantryLights(service *ga.Service, state ga.State, sensor ga.EntityData) {
}
}
func onEvent(service *ga.Service, state ga.State, data ga.EventData) {
func onEvent(service *ha.Service, state ha.State, data ha.EventData) {
// Since the structure of the event changes depending
// on the event type, you can Unmarshal the raw json
// into a Go type. If a type for your event doesn't
// exist, you can write it yourself! PR's welcome to
// the eventTypes.go file :)
ev := ga.EventZWaveJSValueNotification{}
ev := ha.EventZWaveJSValueNotification{}
json.Unmarshal(data.RawEventJSON, &ev)
slog.Info("On event invoked", "event", ev)
}
func lightsOut(service *ga.Service, state ga.State) {
func lightsOut(service *ha.Service, state ha.State) {
// always turn off outside lights
service.Light.TurnOff(entities.Light.OutsideLights)
s, err := state.Get(entities.BinarySensor.LivingRoomMotion)
@@ -99,7 +98,7 @@ func lightsOut(service *ga.Service, state ga.State) {
}
}
func sunriseSched(service *ga.Service, state ga.State) {
func sunriseSched(service *ha.Service, state ha.State) {
service.Light.TurnOn(entities.Light.LivingRoomLamps)
service.Light.TurnOff(entities.Light.ChristmasLights)
}

View File

@@ -11,13 +11,13 @@ import (
"github.com/stretchr/testify/suite"
"gopkg.in/yaml.v3"
ga "github.com/Xevion/gome-assistant"
ha "github.com/Xevion/go-ha"
)
type (
MySuite struct {
suite.Suite
app *ga.App
app *ha.App
config *Config
suiteCtx map[string]any
}
@@ -62,7 +62,7 @@ func (s *MySuite) SetupSuite() {
slog.Error("Error unmarshalling config file", "error", err)
}
s.app, err = ga.NewApp(ga.NewAppRequest{
s.app, err = ha.NewApp(ha.NewAppRequest{
HAAuthToken: s.config.Hass.HAAuthToken,
IpAddress: s.config.Hass.IpAddress,
HomeZoneEntityId: s.config.Hass.HomeZoneEntityId,
@@ -76,13 +76,13 @@ func (s *MySuite) SetupSuite() {
entityId := s.config.Entities.LightEntityId
if entityId != "" {
s.suiteCtx["entityCallbackInvoked"] = false
etl := ga.NewEntityListener().EntityIds(entityId).Call(s.entityCallback).Build()
etl := ha.NewEntityListener().EntityIds(entityId).Call(s.entityCallback).Build()
s.app.RegisterEntityListeners(etl)
}
s.suiteCtx["dailyScheduleCallbackInvoked"] = false
runTime := time.Now().Add(1 * time.Minute).Format("15:04")
dailySchedule := ga.NewDailySchedule().Call(s.dailyScheduleCallback).At(runTime).Build()
dailySchedule := ha.NewDailySchedule().Call(s.dailyScheduleCallback).At(runTime).Build()
s.app.RegisterSchedules(dailySchedule)
// start GA app
@@ -122,13 +122,13 @@ func (s *MySuite) TestSchedule() {
}
// Capture event after light entity state has changed
func (s *MySuite) entityCallback(se *ga.Service, st ga.State, e ga.EntityData) {
func (s *MySuite) entityCallback(se *ha.Service, st ha.State, e ha.EntityData) {
slog.Info("Entity callback called.", "entity id", e.TriggerEntityId, "from state", e.FromState, "to state", e.ToState)
s.suiteCtx["entityCallbackInvoked"] = true
}
// Capture planned daily schedule
func (s *MySuite) dailyScheduleCallback(se *ga.Service, st ga.State) {
func (s *MySuite) dailyScheduleCallback(se *ha.Service, st ha.State) {
slog.Info("Daily schedule callback called.")
s.suiteCtx["dailyScheduleCallbackInvoked"] = true
}

View File

@@ -3,22 +3,25 @@ module example
go 1.23
require (
github.com/Xevion/go-ha v0.7.0
github.com/golang-cz/devslog v0.0.8
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v3 v3.0.1
github.com/Xevion/gome-assistant v0.2.0
)
require (
github.com/Workiva/go-datastructures v1.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gobuffalo/envy v1.10.2 // indirect
github.com/gobuffalo/packd v1.0.2 // indirect
github.com/gobuffalo/packr v1.30.1 // indirect
github.com/golang-module/carbon v1.7.3 // indirect
github.com/golang-module/carbon v1.7.1 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/nathan-osman/go-sunrise v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
golang.org/x/mod v0.9.0 // indirect
golang.org/x/net v0.33.0 // indirect
resty.dev/v3 v3.0.0-beta.3 // indirect
)

View File

@@ -1,4 +1,8 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Workiva/go-datastructures v1.1.5 h1:5YfhQ4ry7bZc2Mc7R0YZyYwpf5c6t1cEFvdAhd6Mkf4=
github.com/Workiva/go-datastructures v1.1.5/go.mod h1:1yZL+zfsztete+ePzZz/Zb1/t5BnDuE2Ya2MMGhzP6A=
github.com/Xevion/go-ha v0.7.0 h1:jf+ZVSDaw0xjY0TcCA/TodWmAehtm47hDQI5z8XJMQE=
github.com/Xevion/go-ha v0.7.0/go.mod h1:TN+40o0znxEdvR7GQgm5YWMiCEJvsoFbnro2oW38RVU=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
@@ -20,8 +24,8 @@ github.com/gobuffalo/packr v1.30.1/go.mod h1:ljMyFO2EcrnzsHsN99cvbq055Y9OhRrIavi
github.com/gobuffalo/packr/v2 v2.5.1/go.mod h1:8f9c96ITobJlPzI44jj+4tHnEKNt0xXWSVlXRN9X1Iw=
github.com/golang-cz/devslog v0.0.8 h1:53ipA2rC5JzWBWr9qB8EfenvXppenNiF/8DwgtNT5Q4=
github.com/golang-cz/devslog v0.0.8/go.mod h1:bSe5bm0A7Nyfqtijf1OMNgVJHlWEuVSXnkuASiE1vV8=
github.com/golang-module/carbon v1.7.3 h1:p5mUZj7Tg62MblrkF7XEoxVPvhVs20N/kimqsZOQ+/U=
github.com/golang-module/carbon v1.7.3/go.mod h1:nUMnXq90Rv8a7h2+YOo2BGKS77Y0w/hMPm4/a8h19N8=
github.com/golang-module/carbon v1.7.1 h1:EDPV0YjxeS2kE2cRedfGgDikU6l5D79HB/teHuZDLu8=
github.com/golang-module/carbon v1.7.1/go.mod h1:M/TDTYPp3qWtW68u49dLDJOyGmls6L6BXdo/pyvkMaU=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
@@ -44,6 +48,7 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/nathan-osman/go-sunrise v1.1.0 h1:ZqZmtmtzs8Os/DGQYi0YMHpuUqR/iRoJK+wDO0wTCw8=
github.com/nathan-osman/go-sunrise v1.1.0/go.mod h1:RcWqhT+5ShCZDev79GuWLayetpJp78RSjSWxiDowmlM=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -63,31 +68,49 @@ github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DM
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg=
github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190624180213-70d37148ca0c/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -96,5 +119,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
github.com/Xevion/gome-assistant v0.2.0 h1:Clo5DrziTdsYydVUTQfroeBVmToMnNHoObr+k6HhbMY=
github.com/Xevion/gome-assistant v0.2.0/go.mod h1:jsZUtnxANCP0zB2B7iyy4j7sZohMGop8g+5EB2MER3o=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=

20
go.mod
View File

@@ -1,22 +1,24 @@
module github.com/Xevion/gome-assistant
module github.com/Xevion/go-ha
go 1.21
go 1.23.0
toolchain go1.24.2
require (
github.com/Workiva/go-datastructures v1.1.5
github.com/golang-module/carbon v1.7.1
github.com/gorilla/websocket v1.5.0
github.com/dromara/carbon/v2 v2.6.11
github.com/gorilla/websocket v1.5.3
github.com/nathan-osman/go-sunrise v1.1.0
github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v3 v3.0.1
resty.dev/v3 v3.0.0-beta.3
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gobuffalo/envy v1.10.2 // indirect
github.com/gobuffalo/packd v1.0.2 // indirect
github.com/gobuffalo/packr v1.30.1 // indirect
github.com/joho/godotenv v1.4.0 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/robfig/cron/v3 v3.0.0 // indirect
golang.org/x/net v0.42.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
)

73
go.sum
View File

@@ -1,101 +1,48 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/Workiva/go-datastructures v1.1.5 h1:5YfhQ4ry7bZc2Mc7R0YZyYwpf5c6t1cEFvdAhd6Mkf4=
github.com/Workiva/go-datastructures v1.1.5/go.mod h1:1yZL+zfsztete+ePzZz/Zb1/t5BnDuE2Ya2MMGhzP6A=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gobuffalo/envy v1.7.0/go.mod h1:n7DRkBerg/aorDM8kbduw5dN3oXGswK5liaSCx4T5NI=
github.com/gobuffalo/envy v1.10.2 h1:EIi03p9c3yeuRCFPOKcSfajzkLb3hrRjEpHGI8I2Wo4=
github.com/gobuffalo/envy v1.10.2/go.mod h1:qGAGwdvDsaEtPhfBzb3o0SfDea8ByGn9j8bKmVft9z8=
github.com/gobuffalo/logger v1.0.0/go.mod h1:2zbswyIUa45I+c+FLXuWl9zSWEiVuthsk8ze5s8JvPs=
github.com/gobuffalo/packd v0.3.0/go.mod h1:zC7QkmNkYVGKPw4tHpBQ+ml7W/3tIebgeo1b36chA3Q=
github.com/gobuffalo/packd v1.0.2 h1:Yg523YqnOxGIWCp69W12yYBKsoChwI7mtu6ceM9Bwfw=
github.com/gobuffalo/packd v1.0.2/go.mod h1:sUc61tDqGMXON80zpKGp92lDb86Km28jfvX7IAyxFT8=
github.com/gobuffalo/packr v1.30.1 h1:hu1fuVR3fXEZR7rXNW3h8rqSML8EVAf6KNm0NKO/wKg=
github.com/gobuffalo/packr v1.30.1/go.mod h1:ljMyFO2EcrnzsHsN99cvbq055Y9OhRrIaviy289eRuk=
github.com/gobuffalo/packr/v2 v2.5.1/go.mod h1:8f9c96ITobJlPzI44jj+4tHnEKNt0xXWSVlXRN9X1Iw=
github.com/golang-module/carbon v1.7.1 h1:EDPV0YjxeS2kE2cRedfGgDikU6l5D79HB/teHuZDLu8=
github.com/golang-module/carbon v1.7.1/go.mod h1:M/TDTYPp3qWtW68u49dLDJOyGmls6L6BXdo/pyvkMaU=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/karrick/godirwalk v1.10.12/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/dromara/carbon/v2 v2.6.11 h1:wnAWZ+sbza1uXw3r05hExNSCaBPFaarWfUvYAX86png=
github.com/dromara/carbon/v2 v2.6.11/go.mod h1:7GXqCUplwN1s1b4whGk2zX4+g4CMCoDIZzmjlyt0vLY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/nathan-osman/go-sunrise v1.1.0 h1:ZqZmtmtzs8Os/DGQYi0YMHpuUqR/iRoJK+wDO0wTCw8=
github.com/nathan-osman/go-sunrise v1.1.0/go.mod h1:RcWqhT+5ShCZDev79GuWLayetpJp78RSjSWxiDowmlM=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
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.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.1.5/go.mod h1:eQsjooMTnV42mHu917E26IogZ2930nFyBQdofk10Udg=
github.com/ttacon/chalk v0.0.0-20160626202418-22c06c80ed31/go.mod h1:onvgF043R+lC5RZ8IT9rBXDaEDnpnw/Cl+HFiw+v/7Q=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190621222207-cc06ce4a13d4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190515120540-06a5c4944438/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190624180213-70d37148ca0c/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -104,8 +51,8 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
resty.dev/v3 v3.0.0-beta.3 h1:3kEwzEgCnnS6Ob4Emlk94t+I/gClyoah7SnNi67lt+E=
resty.dev/v3 v3.0.0-beta.3/go.mod h1:OgkqiPvTDtOuV4MGZuUDhwOpkY8enjOsjjMzeOHefy4=

View File

@@ -0,0 +1,177 @@
package connect
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"sync"
"time"
"github.com/Xevion/go-ha/internal"
"github.com/gorilla/websocket"
)
var ErrInvalidToken = errors.New("invalid authentication token")
// HAConnection is a wrapper around a WebSocket connection that provides a mutex for thread safety.
type HAConnection struct {
Conn *websocket.Conn // Note: this is not thread safe except for Close() and WriteControl()
mutex sync.Mutex
}
// WriteMessage writes a message to the WebSocket connection.
func (w *HAConnection) WriteMessage(msg any) error {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.Conn.WriteJSON(msg)
}
// ReadMessageRaw reads a raw message from the WebSocket connection.
func ReadMessageRaw(conn *websocket.Conn) ([]byte, error) {
_, msg, err := conn.ReadMessage()
if err != nil {
return nil, err
}
return msg, nil
}
// ReadMessage reads a message from the WebSocket connection and unmarshals it into the given type.
func ReadMessage[T any](conn *websocket.Conn) (T, error) {
var result T
_, msg, err := conn.ReadMessage()
if err != nil {
return result, err
}
err = json.Unmarshal(msg, &result)
if err != nil {
return result, err
}
return result, nil
}
// ConnectionFromUri creates a new WebSocket connection from the given base URL and authentication token.
func ConnectionFromUri(baseUrl *url.URL, token string) (*HAConnection, context.Context, context.CancelFunc, error) {
// Build the WebSocket URL
urlWebsockets := *baseUrl
urlWebsockets.Path = "/api/websocket"
scheme, err := internal.GetEquivalentWebsocketScheme(baseUrl.Scheme)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to build WebSocket URL: %w", err)
}
urlWebsockets.Scheme = scheme
// Create a short timeout context for the connection only
connCtx, connCtxCancel := context.WithTimeout(context.Background(), time.Second*3)
defer connCtxCancel() // Always cancel the connection context when we're done
// Init WebSocket connection
dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(connCtx, urlWebsockets.String(), nil)
if err != nil {
slog.Error("Failed to connect to WebSocket. Check URI\n", "url", urlWebsockets)
return nil, nil, nil, err
}
// Read auth_required message
msg, err := ReadMessage[struct {
MsgType string `json:"type"`
}](conn)
if err != nil {
slog.Error("Unknown error creating WebSocket client\n")
return nil, nil, nil, err
} else if msg.MsgType != "auth_required" {
slog.Error("Expected auth_required message, got", "msgType", msg.MsgType)
return nil, nil, nil, fmt.Errorf("expected auth_required message, got %s", msg.MsgType)
}
// Send auth message
err = SendAuthMessage(conn, connCtx, token)
if err != nil {
slog.Error("Unknown error creating WebSocket client\n")
return nil, nil, nil, err
}
// Verify auth message was successful
err = VerifyAuthResponse(conn, connCtx)
if err != nil {
slog.Error("Auth token is invalid. Please double check it or create a new token in your Home Assistant profile\n")
return nil, nil, nil, err
}
// Create a new background context for the application lifecycle (no timeout)
appCtx, appCtxCancel := context.WithCancel(context.Background())
return &HAConnection{Conn: conn}, appCtx, appCtxCancel, nil
}
// SendAuthMessage sends an auth message to the WebSocket connection.
func SendAuthMessage(conn *websocket.Conn, ctx context.Context, token string) error {
type AuthMessage struct {
MsgType string `json:"type"`
AccessToken string `json:"access_token"`
}
err := conn.WriteJSON(AuthMessage{MsgType: "auth", AccessToken: token})
if err != nil {
return err
}
return nil
}
// VerifyAuthResponse verifies that the auth response is valid.
func VerifyAuthResponse(conn *websocket.Conn, ctx context.Context) error {
msg, err := ReadMessage[struct {
MsgType string `json:"type"`
Message string `json:"message"`
}](conn)
if err != nil {
return err
}
if msg.MsgType != "auth_ok" {
return ErrInvalidToken
}
return nil
}
func SubscribeToStateChangedEvents(id int64, conn *HAConnection, ctx context.Context) {
SubscribeToEventType("state_changed", conn, ctx, id)
}
// TODO: Instead of using variadic arguments, just use a nillable pointer for the id
func SubscribeToEventType(eventType string, conn *HAConnection, ctx context.Context, id ...int64) {
type SubEvent struct {
Id int64 `json:"id"`
Type string `json:"type"`
EventType string `json:"event_type"`
}
// If no id is provided, generate a new one
var finalId int64
if len(id) == 0 {
finalId = internal.NextId()
} else {
finalId = id[0]
}
e := SubEvent{
Id: finalId,
Type: "subscribe_events",
EventType: eventType,
}
err := conn.WriteMessage(e)
// TODO: Handle errors better
if err != nil {
wrappedErr := fmt.Errorf("error writing to WebSocket: %w", err)
slog.Error(wrappedErr.Error())
panic(wrappedErr)
}
}

View File

@@ -0,0 +1,70 @@
package connect
import (
"encoding/json"
"log/slog"
"github.com/gorilla/websocket"
)
// BaseMessage is the base message type for all messages sent by the WebSocket server.
type BaseMessage struct {
Type string `json:"type"`
Id int64 `json:"id"`
Success bool `json:"success"` // not present in all messages
}
type ChannelMessage struct {
Id int64
Type string
Success bool
Raw []byte
}
// ListenWebsocket reads messages from the WebSocket connection and sends them to the channel.
// It will close the channel if it encounters an error, or if the channel is full, and return.
// It ignores errors in deserialization.
func ListenWebsocket(conn *websocket.Conn, c chan ChannelMessage) {
for {
raw, err := ReadMessageRaw(conn)
if err != nil {
slog.Error("Error reading from WebSocket", "err", err)
close(c)
break
}
base := BaseMessage{
// default to true for messages that don't include "success" at all
Success: true,
}
err = json.Unmarshal(raw, &base)
if err != nil {
slog.Error("Error unmarshalling message", "err", err, "message", string(raw))
continue
}
if !base.Success {
slog.Warn("Received unsuccessful response", "response", string(raw))
}
// Create a channel message from the raw message
channelMessage := ChannelMessage{
Type: base.Type,
Id: base.Id,
Success: base.Success,
Raw: raw,
}
// Use non-blocking send to avoid hanging on closed channel
select {
case c <- channelMessage:
// Message sent successfully
default:
// Channel is full or closed, break out of loop
slog.Warn("WebSocket message channel is full or closed, stopping listener",
"channel_capacity", cap(c),
"channel_length", len(c))
close(c)
return
}
}
}

76
internal/http.go Normal file
View File

@@ -0,0 +1,76 @@
package internal
import (
"context"
"errors"
"net/url"
"time"
"resty.dev/v3"
)
type HttpClient struct {
client *resty.Client
baseRequest *resty.Request
}
func NewHttpClient(ctx context.Context, baseUrl *url.URL, token string) *HttpClient {
// Shallow copy the URL to avoid modifying the original
u := *baseUrl
u.Path = "/api"
// Create resty client with configuration
client := resty.New().
SetBaseURL(u.String()).
SetTimeout(30*time.Second).
SetRetryCount(3).
SetRetryWaitTime(1*time.Second).
SetRetryMaxWaitTime(5*time.Second).
AddRetryConditions(func(r *resty.Response, err error) bool {
return err != nil || (r.StatusCode() >= 500 && r.StatusCode() != 403)
}).
SetHeader("User-Agent", "go-ha/"+currentVersion).
SetContext(ctx)
return &HttpClient{
client: client,
baseRequest: client.R().
SetContentType("application/json").
SetHeader("Accept", "application/json").
SetAuthToken(token),
}
}
// getRequest returns a new request.
func (c *HttpClient) getRequest() *resty.Request {
return c.baseRequest.Clone(c.client.Context())
}
func (c *HttpClient) GetState(entityId string) ([]byte, error) {
resp, err := c.getRequest().Get("/states/" + entityId)
if err != nil {
return nil, errors.New("Error making HTTP request: " + err.Error())
}
if resp.StatusCode() >= 400 {
return nil, errors.New("HTTP error: " + resp.Status() + " - " + string(resp.Bytes()))
}
return resp.Bytes(), nil
}
// GetStates returns the states of all entities.
func (c *HttpClient) GetStates() ([]byte, error) {
resp, err := c.getRequest().Get("/states")
if err != nil {
return nil, errors.New("Error making HTTP request: " + err.Error())
}
if resp.StatusCode() >= 400 {
return nil, errors.New("HTTP error: " + resp.Status() + " - " + string(resp.Bytes()))
}
return resp.Bytes(), nil
}

View File

@@ -1,72 +0,0 @@
// http is used to interact with the home assistant
// REST API. Currently only used to retrieve state for
// a single entity_id
package http
import (
"errors"
"io"
"net/http"
"net/url"
)
type HttpClient struct {
url string
token string
}
func NewHttpClient(url *url.URL, token string) *HttpClient {
// Shallow copy the URL to avoid modifying the original
u := *url
u.Path = "/api"
if u.Scheme == "ws" {
u.Scheme = "http"
}
if u.Scheme == "wss" {
u.Scheme = "https"
}
return &HttpClient{
url: u.String(),
token: token,
}
}
func (c *HttpClient) GetState(entityId string) ([]byte, error) {
resp, err := get(c.url+"/states/"+entityId, c.token)
if err != nil {
return nil, err
}
return resp, nil
}
func (c *HttpClient) States() ([]byte, error) {
resp, err := get(c.url+"/states", c.token)
if err != nil {
return nil, err
}
return resp, nil
}
func get(url, token string) ([]byte, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, errors.New("Error creating HTTP request: " + err.Error())
}
req.Header.Add("Authorization", "Bearer "+token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, errors.New("Error on response.\n[ERROR] -" + err.Error())
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.New("Error while reading the response bytes:" + err.Error())
}
return body, nil
}

View File

@@ -1,6 +1,7 @@
package internal
import (
"fmt"
"reflect"
"runtime"
"sync/atomic"
@@ -12,12 +13,15 @@ type EnabledDisabledInfo struct {
RunOnError bool
}
var (
currentVersion = "0.7.1"
)
var (
id atomic.Int64 // default value is 0
)
// NextId returns a unique integer (for the given process), often used for providing a uniquely identifiable
// id for a request. This function is thread-safe.
// NextId returns a unique integer (for the given process), often used for providing a uniquely identifiable ID for a request. This function is thread-safe.
func NextId() int64 {
return id.Add(1)
}
@@ -26,3 +30,24 @@ func NextId() int64 {
func GetFunctionName(i interface{}) string {
return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name()
}
// GetEquivalentWebsocketScheme returns the equivalent WebSocket scheme for the given scheme.
// If the scheme is http or https, it returns ws or wss respectively.
// If the scheme is ws or wss, it returns the same scheme.
// If the scheme is not any of the above, it returns an error.
func GetEquivalentWebsocketScheme(scheme string) (string, error) {
switch scheme {
case "http":
return "ws", nil
case "https":
return "wss", nil
case "ws", "wss":
return scheme, nil
default:
return "", fmt.Errorf("unexpected scheme: %s", scheme)
}
}
func Ptr[T any](v T) *T {
return &v
}

View File

@@ -1,18 +1,18 @@
package parse
package internal
import (
"fmt"
"log/slog"
"time"
"github.com/golang-module/carbon"
"github.com/dromara/carbon/v2"
)
// Parses a HH:MM string.
func ParseTime(s string) carbon.Carbon {
// ParseTime parses a HH:MM string.
func ParseTime(s string) *carbon.Carbon {
t, err := time.Parse("15:04", s)
if err != nil {
parsingErr := fmt.Errorf("failed to parse time string \"%s\"; format must be HH:MM.: %w", s, err)
parsingErr := fmt.Errorf("failed to parse time string \"%s\"; format must be HH:MM: %w", s, err)
slog.Error(parsingErr.Error())
panic(parsingErr)
}
@@ -22,7 +22,7 @@ func ParseTime(s string) carbon.Carbon {
func ParseDuration(s string) time.Duration {
d, err := time.ParseDuration(s)
if err != nil {
parsingErr := fmt.Errorf("couldn't parse string duration: \"%s\" see https://pkg.go.dev/time#ParseDuration for valid time units: %w", s, err)
parsingErr := fmt.Errorf("couldn't parse string duration: \"%s\"; see https://pkg.go.dev/time#ParseDuration for valid time units: %w", s, err)
slog.Error(parsingErr.Error())
panic(parsingErr)
}

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

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type AdaptiveLighting struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Set manual control for an adaptive lighting entity.
func (al AdaptiveLighting) SetManualControl(entityId string, enabled bool) error {
req := NewBaseServiceRequest("")

View File

@@ -1,20 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type AlarmControlPanel struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Send the alarm the command for arm away.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for arm away. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"
@@ -26,9 +20,7 @@ func (acp AlarmControlPanel) ArmAway(entityId string, serviceData ...map[string]
return acp.conn.WriteMessage(req)
}
// Send the alarm the command for arm away.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for arm away. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"
@@ -40,9 +32,7 @@ func (acp AlarmControlPanel) ArmWithCustomBypass(entityId string, serviceData ..
return acp.conn.WriteMessage(req)
}
// Send the alarm the command for arm home.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for arm home. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"
@@ -54,9 +44,7 @@ func (acp AlarmControlPanel) ArmHome(entityId string, serviceData ...map[string]
return acp.conn.WriteMessage(req)
}
// Send the alarm the command for arm night.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for arm night. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"
@@ -68,9 +56,7 @@ func (acp AlarmControlPanel) ArmNight(entityId string, serviceData ...map[string
return acp.conn.WriteMessage(req)
}
// Send the alarm the command for arm vacation.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for arm vacation. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"
@@ -82,9 +68,7 @@ func (acp AlarmControlPanel) ArmVacation(entityId string, serviceData ...map[str
return acp.conn.WriteMessage(req)
}
// Send the alarm the command for disarm.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for disarm. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"
@@ -96,9 +80,7 @@ func (acp AlarmControlPanel) Disarm(entityId string, serviceData ...map[string]a
return acp.conn.WriteMessage(req)
}
// Send the alarm the command for trigger.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the alarm the command for trigger. Takes an entityId and an optional map that is translated into service_data.
func (acp AlarmControlPanel) Trigger(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "alarm_control_panel"

View File

@@ -1,18 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/gome-assistant/types"
"github.com/Xevion/go-ha/internal/connect"
"github.com/Xevion/go-ha/types"
)
/* Structs */
type Climate struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
func (c Climate) SetFanMode(entityId string, fanMode string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "climate"

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Cover struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Close all or specified cover. Takes an entityId.
func (c Cover) Close(entityId string) error {
req := NewBaseServiceRequest(entityId)
@@ -48,8 +44,7 @@ func (c Cover) OpenTilt(entityId string) error {
return c.conn.WriteMessage(req)
}
// Move to specific position all or specified cover. Takes an entityId and an optional
// map that is translated into service_data.
// Move to specific position all or specified cover. Takes an entityId and an optional map that is translated into service_data.
func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "cover"
@@ -61,8 +56,7 @@ func (c Cover) SetPosition(entityId string, serviceData ...map[string]any) error
return c.conn.WriteMessage(req)
}
// Move to specific position all or specified cover tilt. Takes an entityId and an optional
// map that is translated into service_data.
// Move to specific position all or specified cover tilt. Takes an entityId and an optional map that is translated into service_data.
func (c Cover) SetTiltPosition(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "cover"

View File

@@ -1,12 +1,12 @@
package services
import (
"github.com/Xevion/gome-assistant/internal"
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/internal/connect"
)
type Event struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
// Fire an event
@@ -17,10 +17,7 @@ type FireEventRequest struct {
EventData map[string]any `json:"event_data,omitempty"`
}
/* Public API */
// Fire an event. Takes an event type and an optional map that is sent
// as `event_data`.
// Fire an event. Takes an event type and an optional map that is sent as `event_data`.
func (e Event) Fire(eventType string, eventData ...map[string]any) error {
req := FireEventRequest{
Id: internal.NextId(),

View File

@@ -1,15 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
type HomeAssistant struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
// TurnOn a Home Assistant entity. Takes an entityId and an optional
// map that is translated into service_data.
// TurnOn a Home Assistant entity. Takes an entityId and an optional map that is translated into service_data.
func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "homeassistant"
@@ -21,8 +20,7 @@ func (ha *HomeAssistant) TurnOn(entityId string, serviceData ...map[string]any)
return ha.conn.WriteMessage(req)
}
// Toggle a Home Assistant entity. Takes an entityId and an optional
// map that is translated into service_data.
// Toggle a Home Assistant entity. Takes an entityId and an optional map that is translated into service_data.
func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "homeassistant"
@@ -34,6 +32,7 @@ func (ha *HomeAssistant) Toggle(entityId string, serviceData ...map[string]any)
return ha.conn.WriteMessage(req)
}
// TurnOff turns off a Home Assistant entity.
func (ha *HomeAssistant) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "homeassistant"

View File

@@ -1,17 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type InputBoolean struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// TurnOn turns on an input boolean entity.
func (ib InputBoolean) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_boolean"
@@ -20,6 +17,7 @@ func (ib InputBoolean) TurnOn(entityId string) error {
return ib.conn.WriteMessage(req)
}
// Toggle toggles an input boolean entity.
func (ib InputBoolean) Toggle(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_boolean"
@@ -28,6 +26,7 @@ func (ib InputBoolean) Toggle(entityId string) error {
return ib.conn.WriteMessage(req)
}
// TurnOff turns off an input boolean entity.
func (ib InputBoolean) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_boolean"

View File

@@ -1,17 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type InputButton struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Press presses an input button entity.
func (ib InputButton) Press(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_button"

View File

@@ -4,17 +4,13 @@ import (
"fmt"
"time"
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type InputDatetime struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
func (ib InputDatetime) Set(entityId string, value time.Time) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_datetime"

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type InputNumber struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
func (ib InputNumber) Set(entityId string, value float32) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_number"

View File

@@ -1,17 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type InputText struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Set sets the value of an input text entity.
func (ib InputText) Set(entityId string, value string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "input_text"

View File

@@ -1,19 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Light struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// TurnOn a light entity. Takes an entityId and an optional
// map that is translated into service_data.
// TurnOn a light entity. Takes an entityId and an optional map that is translated into service_data.
func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "light"
@@ -25,8 +20,7 @@ func (l Light) TurnOn(entityId string, serviceData ...map[string]any) error {
return l.conn.WriteMessage(req)
}
// Toggle a light entity. Takes an entityId and an optional
// map that is translated into service_data.
// Toggle a light entity. Takes an entityId and an optional map that is translated into service_data.
func (l Light) Toggle(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "light"
@@ -38,6 +32,7 @@ func (l Light) Toggle(entityId string, serviceData ...map[string]any) error {
return l.conn.WriteMessage(req)
}
// TurnOff turns off a light entity.
func (l Light) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "light"

View File

@@ -1,19 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Lock struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Lock a lock entity. Takes an entityId and an optional
// map that is translated into service_data.
// Lock a lock entity. Takes an entityId and an optional map that is translated into service_data.
func (l Lock) Lock(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "lock"
@@ -25,8 +20,7 @@ func (l Lock) Lock(entityId string, serviceData ...map[string]any) error {
return l.conn.WriteMessage(req)
}
// Unlock a lock entity. Takes an entityId and an optional
// map that is translated into service_data.
// Unlock a lock entity. Takes an entityId and an optional map that is translated into service_data.
func (l Lock) Unlock(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "lock"

View File

@@ -1,19 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type MediaPlayer struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Send the media player the command to clear players playlist.
// Takes an entityId.
// Send the media player the command to clear players playlist. Takes an entityId.
func (mp MediaPlayer) ClearPlaylist(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -22,9 +17,7 @@ func (mp MediaPlayer) ClearPlaylist(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Group players together. Only works on platforms with support for player groups.
// Takes an entityId and an optional
// map that is translated into service_data.
// Group players together. Only works on platforms with support for player groups. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -36,8 +29,7 @@ func (mp MediaPlayer) Join(entityId string, serviceData ...map[string]any) error
return mp.conn.WriteMessage(req)
}
// Send the media player the command for next track.
// Takes an entityId.
// Send the media player the command for next track. Takes an entityId.
func (mp MediaPlayer) Next(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -46,8 +38,7 @@ func (mp MediaPlayer) Next(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Send the media player the command for pause.
// Takes an entityId.
// Send the media player the command for pause. Takes an entityId.
func (mp MediaPlayer) Pause(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -56,8 +47,7 @@ func (mp MediaPlayer) Pause(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Send the media player the command for play.
// Takes an entityId.
// Send the media player the command for play. Takes an entityId.
func (mp MediaPlayer) Play(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -66,8 +56,7 @@ func (mp MediaPlayer) Play(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Toggle media player play/pause state.
// Takes an entityId.
// Toggle media player play/pause state. Takes an entityId.
func (mp MediaPlayer) PlayPause(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -76,8 +65,7 @@ func (mp MediaPlayer) PlayPause(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Send the media player the command for previous track.
// Takes an entityId.
// Send the media player the command for previous track. Takes an entityId.
func (mp MediaPlayer) Previous(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -86,9 +74,7 @@ func (mp MediaPlayer) Previous(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Send the media player the command to seek in current playing media.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the media player the command to seek in current playing media. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -100,8 +86,7 @@ func (mp MediaPlayer) Seek(entityId string, serviceData ...map[string]any) error
return mp.conn.WriteMessage(req)
}
// Send the media player the stop command.
// Takes an entityId.
// Send the media player the stop command. Takes an entityId.
func (mp MediaPlayer) Stop(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -110,9 +95,7 @@ func (mp MediaPlayer) Stop(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Send the media player the command for playing media.
// Takes an entityId and an optional
// map that is translated into service_data.
// Send the media player the command to play a media. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -124,8 +107,7 @@ func (mp MediaPlayer) PlayMedia(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req)
}
// Set repeat mode. Takes an entityId and an optional
// map that is translated into service_data.
// Set repeat mode. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -137,9 +119,7 @@ func (mp MediaPlayer) RepeatSet(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req)
}
// Send the media player the command to change sound mode.
// Takes an entityId and an optional
// map that is translated into service_data.
// Select a sound mode. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -151,9 +131,7 @@ func (mp MediaPlayer) SelectSoundMode(entityId string, serviceData ...map[string
return mp.conn.WriteMessage(req)
}
// Send the media player the command to change input source.
// Takes an entityId and an optional
// map that is translated into service_data.
// Select a source. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -165,9 +143,7 @@ func (mp MediaPlayer) SelectSource(entityId string, serviceData ...map[string]an
return mp.conn.WriteMessage(req)
}
// Set shuffling state.
// Takes an entityId and an optional
// map that is translated into service_data.
// Toggle shuffle state. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -179,8 +155,7 @@ func (mp MediaPlayer) Shuffle(entityId string, serviceData ...map[string]any) er
return mp.conn.WriteMessage(req)
}
// Toggles a media player power state.
// Takes an entityId.
// Toggle a media player on/off. Takes an entityId.
func (mp MediaPlayer) Toggle(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -189,8 +164,7 @@ func (mp MediaPlayer) Toggle(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Turn a media player power off.
// Takes an entityId.
// Turn off a media player. Takes an entityId.
func (mp MediaPlayer) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -199,8 +173,7 @@ func (mp MediaPlayer) TurnOff(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Turn a media player power on.
// Takes an entityId.
// Turn on a media player. Takes an entityId.
func (mp MediaPlayer) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -209,9 +182,7 @@ func (mp MediaPlayer) TurnOn(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Unjoin the player from a group. Only works on
// platforms with support for player groups.
// Takes an entityId.
// Separate a player from a group. Only works on platforms with support for player groups. Takes an entityId.
func (mp MediaPlayer) Unjoin(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -220,8 +191,7 @@ func (mp MediaPlayer) Unjoin(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Turn a media player volume down.
// Takes an entityId.
// Send the media player the command for volume down. Takes an entityId.
func (mp MediaPlayer) VolumeDown(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -230,9 +200,7 @@ func (mp MediaPlayer) VolumeDown(entityId string) error {
return mp.conn.WriteMessage(req)
}
// Mute a media player's volume.
// Takes an entityId and an optional
// map that is translated into service_data.
// Mute a media player. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -244,9 +212,7 @@ func (mp MediaPlayer) VolumeMute(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req)
}
// Set a media player's volume level.
// Takes an entityId and an optional
// map that is translated into service_data.
// Set volume level. Takes an entityId and an optional map that is translated into service_data.
func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"
@@ -258,8 +224,7 @@ func (mp MediaPlayer) VolumeSet(entityId string, serviceData ...map[string]any)
return mp.conn.WriteMessage(req)
}
// Turn a media player volume up.
// Takes an entityId.
// Send the media player the command for volume up. Takes an entityId.
func (mp MediaPlayer) VolumeUp(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "media_player"

View File

@@ -1,12 +1,12 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/gome-assistant/types"
"github.com/Xevion/go-ha/internal/connect"
"github.com/Xevion/go-ha/types"
)
type Notify struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
// Notify sends a notification. Takes a types.NotifyRequest.

View File

@@ -1,11 +1,11 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
type Number struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
func (ib Number) SetValue(entityId string, value float32) error {

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Scene struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Apply a scene. Takes map that is translated into service_data.
func (s Scene) Apply(serviceData ...map[string]any) error {
req := NewBaseServiceRequest("")
@@ -24,8 +20,7 @@ func (s Scene) Apply(serviceData ...map[string]any) error {
return s.conn.WriteMessage(req)
}
// Create a scene entity. Takes an entityId and an optional
// map that is translated into service_data.
// Create a scene entity. Takes an entityId and an optional map that is translated into service_data.
func (s Scene) Create(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "scene"
@@ -46,8 +41,7 @@ func (s Scene) Reload() error {
return s.conn.WriteMessage(req)
}
// TurnOn a scene entity. Takes an entityId and an optional
// map that is translated into service_data.
// TurnOn a scene entity. Takes an entityId and an optional map that is translated into service_data.
func (s Scene) TurnOn(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "scene"

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Script struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Reload a script that was created in the HA UI.
func (s Script) Reload(entityId string) error {
req := NewBaseServiceRequest(entityId)

View File

@@ -1,8 +1,8 @@
package services
import (
"github.com/Xevion/gome-assistant/internal"
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/internal/connect"
)
func BuildService[
@@ -29,7 +29,7 @@ func BuildService[
Timer |
Vacuum |
ZWaveJS,
](conn *ws.WebsocketWriter) *T {
](conn *connect.HAConnection) *T {
return &T{conn: conn}
}

View File

@@ -1,17 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Switch struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// TurnOn turns on a switch entity.
func (s Switch) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "switch"
@@ -20,6 +17,7 @@ func (s Switch) TurnOn(entityId string) error {
return s.conn.WriteMessage(req)
}
// Toggle toggles a switch entity.
func (s Switch) Toggle(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "switch"
@@ -28,6 +26,7 @@ func (s Switch) Toggle(entityId string) error {
return s.conn.WriteMessage(req)
}
// TurnOff turns off a switch entity.
func (s Switch) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "switch"

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Timer struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// See https://www.home-assistant.io/integrations/timer/#action-timerstart
func (t Timer) Start(entityId string, duration string) error {
req := NewBaseServiceRequest(entityId)

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type TTS struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Remove all text-to-speech cache files and RAM cache.
func (tts TTS) ClearCache() error {
req := NewBaseServiceRequest("")
@@ -21,9 +17,7 @@ func (tts TTS) ClearCache() error {
return tts.conn.WriteMessage(req)
}
// Say something using text-to-speech on a media player with cloud.
// Takes an entityId and an optional
// map that is translated into service_data.
// Say something using text-to-speech on a media player with cloud. Takes an entityId and an optional map that is translated into service_data.
func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "tts"
@@ -35,9 +29,7 @@ func (tts TTS) CloudSay(entityId string, serviceData ...map[string]any) error {
return tts.conn.WriteMessage(req)
}
// Say something using text-to-speech on a media player with google_translate.
// Takes an entityId and an optional
// map that is translated into service_data.
// Say something using text-to-speech on a media player with google_translate. Takes an entityId and an optional map that is translated into service_data.
func (tts TTS) GoogleTranslateSay(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "tts"

View File

@@ -1,19 +1,14 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type Vacuum struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// Tell the vacuum cleaner to do a spot clean-up.
// Takes an entityId.
// Tell the vacuum cleaner to do a spot clean-up. Takes an entityId.
func (v Vacuum) CleanSpot(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -22,8 +17,7 @@ func (v Vacuum) CleanSpot(entityId string) error {
return v.conn.WriteMessage(req)
}
// Locate the vacuum cleaner robot.
// Takes an entityId.
// Locate the vacuum cleaner robot. Takes an entityId.
func (v Vacuum) Locate(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -32,8 +26,7 @@ func (v Vacuum) Locate(entityId string) error {
return v.conn.WriteMessage(req)
}
// Pause the cleaning task.
// Takes an entityId.
// Pause the cleaning task. Takes an entityId.
func (v Vacuum) Pause(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -42,8 +35,7 @@ func (v Vacuum) Pause(entityId string) error {
return v.conn.WriteMessage(req)
}
// Tell the vacuum cleaner to return to its dock.
// Takes an entityId.
// Tell the vacuum cleaner to return to its dock. Takes an entityId.
func (v Vacuum) ReturnToBase(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -52,8 +44,7 @@ func (v Vacuum) ReturnToBase(entityId string) error {
return v.conn.WriteMessage(req)
}
// Send a raw command to the vacuum cleaner. Takes an entityId and an optional
// map that is translated into service_data.
// Send a raw command to the vacuum cleaner. Takes an entityId and an optional map that is translated into service_data.
func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -65,8 +56,7 @@ func (v Vacuum) SendCommand(entityId string, serviceData ...map[string]any) erro
return v.conn.WriteMessage(req)
}
// Set the fan speed of the vacuum cleaner. Takes an entityId and an optional
// map that is translated into service_data.
// Set the fan speed of the vacuum cleaner. Takes an entityId and an optional map that is translated into service_data.
func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -79,8 +69,7 @@ func (v Vacuum) SetFanSpeed(entityId string, serviceData ...map[string]any) erro
return v.conn.WriteMessage(req)
}
// Start or resume the cleaning task.
// Takes an entityId.
// Start or resume the cleaning task. Takes an entityId.
func (v Vacuum) Start(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -89,8 +78,7 @@ func (v Vacuum) Start(entityId string) error {
return v.conn.WriteMessage(req)
}
// Start, pause, or resume the cleaning task.
// Takes an entityId.
// Start, pause, or resume the cleaning task. Takes an entityId.
func (v Vacuum) StartPause(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -99,8 +87,7 @@ func (v Vacuum) StartPause(entityId string) error {
return v.conn.WriteMessage(req)
}
// Stop the current cleaning task.
// Takes an entityId.
// Stop the current cleaning task. Takes an entityId.
func (v Vacuum) Stop(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -109,8 +96,7 @@ func (v Vacuum) Stop(entityId string) error {
return v.conn.WriteMessage(req)
}
// Stop the current cleaning task and return to home.
// Takes an entityId.
// Stop the current cleaning task and return to home. Takes an entityId.
func (v Vacuum) TurnOff(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"
@@ -119,8 +105,7 @@ func (v Vacuum) TurnOff(entityId string) error {
return v.conn.WriteMessage(req)
}
// Start a new cleaning task.
// Takes an entityId.
// Start a new cleaning task. Takes an entityId.
func (v Vacuum) TurnOn(entityId string) error {
req := NewBaseServiceRequest(entityId)
req.Domain = "vacuum"

View File

@@ -1,17 +1,13 @@
package services
import (
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
)
/* Structs */
type ZWaveJS struct {
conn *ws.WebsocketWriter
conn *connect.HAConnection
}
/* Public API */
// ZWaveJS bulk_set_partial_config_parameters service.
func (zw ZWaveJS) BulkSetPartialConfigParam(entityId string, parameter int, value any) error {
req := NewBaseServiceRequest(entityId)

View File

@@ -1,60 +0,0 @@
package websocket
import (
"encoding/json"
"log/slog"
"github.com/gorilla/websocket"
)
type BaseMessage struct {
Type string `json:"type"`
Id int64 `json:"id"`
Success bool `json:"success"`
}
type ChanMsg struct {
Id int64
Type string
Success bool
Raw []byte
}
func ListenWebsocket(conn *websocket.Conn, c chan ChanMsg) {
for {
bytes, err := ReadMessage(conn)
if err != nil {
slog.Error("Error reading from websocket", "err", err)
close(c)
break
}
base := BaseMessage{
// default to true for messages that don't include "success" at all
Success: true,
}
_ = json.Unmarshal(bytes, &base)
if !base.Success {
slog.Warn("Received unsuccessful response", "response", string(bytes))
}
chanMsg := ChanMsg{
Type: base.Type,
Id: base.Id,
Success: base.Success,
Raw: bytes,
}
// Use non-blocking send to avoid hanging on closed channel
select {
case c <- chanMsg:
// Message sent successfully
default:
// Channel is full or closed, break out of loop
slog.Warn("Websocket message channel is full or closed, stopping listener",
"channel_capacity", cap(c),
"channel_length", len(c))
close(c)
return
}
}
}

View File

@@ -1,160 +0,0 @@
// 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 websocket
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/Xevion/gome-assistant/internal"
)
var ErrInvalidToken = errors.New("invalid authentication token")
type AuthMessage struct {
MsgType string `json:"type"`
AccessToken string `json:"access_token"`
}
type WebsocketWriter struct {
Conn *websocket.Conn
mutex sync.Mutex
}
func (w *WebsocketWriter) WriteMessage(msg any) error {
w.mutex.Lock()
defer w.mutex.Unlock()
return w.Conn.WriteJSON(msg)
}
func ReadMessage(conn *websocket.Conn) ([]byte, error) {
_, msg, err := conn.ReadMessage()
if err != nil {
return []byte{}, err
}
return msg, nil
}
func ConnectionFromUri(baseURL *url.URL, authToken string) (*websocket.Conn, context.Context, context.CancelFunc, error) {
// Create a short timeout context for the connection only
connCtx, connCtxCancel := context.WithTimeout(context.Background(), time.Second*3)
defer connCtxCancel() // Always cancel the connection context when we're done
// Shallow copy the URL to avoid modifying the original
urlWebsockets := *baseURL
urlWebsockets.Path = "/api/websocket"
if baseURL.Scheme == "http" {
urlWebsockets.Scheme = "ws"
}
if baseURL.Scheme == "https" {
urlWebsockets.Scheme = "wss"
}
// Init websocket connection
dialer := websocket.DefaultDialer
conn, _, err := dialer.DialContext(connCtx, urlWebsockets.String(), nil)
if err != nil {
slog.Error("Failed to connect to websocket. Check URI\n", "url", urlWebsockets)
return nil, nil, nil, err
}
// Read auth_required message
_, err = ReadMessage(conn)
if err != nil {
slog.Error("Unknown error creating websocket client\n")
return nil, nil, nil, err
}
// Send auth message
err = SendAuthMessage(conn, connCtx, authToken)
if err != nil {
slog.Error("Unknown error creating websocket client\n")
return nil, nil, nil, err
}
// Verify auth message was successful
err = VerifyAuthResponse(conn, connCtx)
if err != nil {
slog.Error("Auth token is invalid. Please double check it or create a new token in your Home Assistant profile\n")
return nil, nil, nil, err
}
// Create a new background context for the application lifecycle (no timeout)
appCtx, appCtxCancel := context.WithCancel(context.Background())
return conn, appCtx, appCtxCancel, nil
}
func SendAuthMessage(conn *websocket.Conn, ctx context.Context, token string) error {
err := conn.WriteJSON(AuthMessage{MsgType: "auth", AccessToken: token})
if err != nil {
return err
}
return nil
}
type authResponse struct {
MsgType string `json:"type"`
Message string `json:"message"`
}
func VerifyAuthResponse(conn *websocket.Conn, ctx context.Context) error {
msg, err := ReadMessage(conn)
if err != nil {
return err
}
var authResp authResponse
err = json.Unmarshal(msg, &authResp)
if err != nil {
return err
}
if authResp.MsgType != "auth_ok" {
return ErrInvalidToken
}
return nil
}
type SubEvent struct {
Id int64 `json:"id"`
Type string `json:"type"`
EventType string `json:"event_type"`
}
func SubscribeToStateChangedEvents(id int64, conn *WebsocketWriter, ctx context.Context) {
SubscribeToEventType("state_changed", conn, ctx, id)
}
func SubscribeToEventType(eventType string, conn *WebsocketWriter, ctx context.Context, id ...int64) {
var finalId int64
if len(id) == 0 {
finalId = internal.NextId()
} else {
finalId = id[0]
}
e := SubEvent{
Id: finalId,
Type: "subscribe_events",
EventType: eventType,
}
err := conn.WriteMessage(e)
if err != nil {
wrappedErr := fmt.Errorf("error writing to websocket: %w", err)
slog.Error(wrappedErr.Error())
panic(wrappedErr)
}
// m, _ := ReadMessage(conn, ctx)
// log.Default().Println(string(m))
}

View File

@@ -5,8 +5,8 @@ import (
"log/slog"
"time"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/gome-assistant/internal/parse"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
)
type IntervalCallback func(*Service, State)
@@ -14,12 +14,12 @@ type IntervalCallback func(*Service, State)
type Interval struct {
frequency time.Duration
callback IntervalCallback
startTime TimeString
endTime TimeString
startTime types.TimeString
endTime types.TimeString
nextRunTime time.Time
exceptionDates []time.Time
exceptionRanges []timeRange
exceptionRanges []types.TimeRange
enabledEntities []internal.EnabledDisabledInfo
disabledEntities []internal.EnabledDisabledInfo
@@ -58,12 +58,12 @@ func (i Interval) String() string {
return fmt.Sprintf("Interval{ call %q every %s%s%s }",
internal.GetFunctionName(i.callback),
i.frequency,
formatStartOrEndString(i.startTime /* isStart = */, true),
formatStartOrEndString(i.endTime /* isStart = */, false),
formatStartOrEndString(i.startTime, true),
formatStartOrEndString(i.endTime, false),
)
}
func formatStartOrEndString(s TimeString, isStart bool) string {
func formatStartOrEndString(s types.TimeString, isStart bool) string {
if s == "00:00" {
return ""
}
@@ -79,21 +79,21 @@ func (ib intervalBuilder) Call(callback IntervalCallback) intervalBuilderCall {
return intervalBuilderCall(ib)
}
// Takes a DurationString ("2h", "5m", etc) to set the frequency of the interval.
func (ib intervalBuilderCall) Every(s DurationString) intervalBuilderEnd {
d := parse.ParseDuration(string(s))
// Every takes a DurationString ("2h", "5m", etc.) to set the frequency of the interval.
func (ib intervalBuilderCall) Every(s types.DurationString) intervalBuilderEnd {
d := internal.ParseDuration(string(s))
ib.interval.frequency = d
return intervalBuilderEnd(ib)
}
// Takes a TimeString ("HH:MM") when this interval will start running for the day.
func (ib intervalBuilderEnd) StartingAt(s TimeString) intervalBuilderEnd {
// StartingAt takes a TimeString ("HH:MM") when this interval will start running for the day.
func (ib intervalBuilderEnd) StartingAt(s types.TimeString) intervalBuilderEnd {
ib.interval.startTime = s
return ib
}
// Takes a TimeString ("HH:MM") when this interval will stop running for the day.
func (ib intervalBuilderEnd) EndingAt(s TimeString) intervalBuilderEnd {
// EndingAt takes a TimeString ("HH:MM") when this interval will stop running for the day.
func (ib intervalBuilderEnd) EndingAt(s types.TimeString) intervalBuilderEnd {
ib.interval.endTime = s
return ib
}
@@ -104,14 +104,15 @@ func (ib intervalBuilderEnd) ExceptionDates(t time.Time, tl ...time.Time) interv
}
func (ib intervalBuilderEnd) ExceptionRange(start, end time.Time) intervalBuilderEnd {
ib.interval.exceptionRanges = append(ib.interval.exceptionRanges, timeRange{start, end})
ib.interval.exceptionRanges = append(
ib.interval.exceptionRanges,
types.TimeRange{Start: start, End: end},
)
return ib
}
/*
Enable this interval only when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
*/
// Enable this interval only when the current state of {entityId} matches {state}.
// If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
func (ib intervalBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) intervalBuilderEnd {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
@@ -125,10 +126,8 @@ func (ib intervalBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkErr
return ib
}
/*
Disable this interval when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
*/
// Disable this interval when the current state of {entityId} matches {state}.
// If there is a network error while retrieving state, the interval runs if {runOnNetworkError} is true.
func (ib intervalBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) intervalBuilderEnd {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
@@ -185,22 +184,22 @@ func runIntervals(a *App) {
}
func (i Interval) maybeRunCallback(a *App) {
if c := checkStartEndTime(i.startTime /* isStart = */, true); c.fail {
if c := CheckStartEndTime(i.startTime, true); c.fail {
return
}
if c := checkStartEndTime(i.endTime /* isStart = */, false); c.fail {
if c := CheckStartEndTime(i.endTime, false); c.fail {
return
}
if c := checkExceptionDates(i.exceptionDates); c.fail {
if c := CheckExceptionDates(i.exceptionDates); c.fail {
return
}
if c := checkExceptionRanges(i.exceptionRanges); c.fail {
if c := CheckExceptionRanges(i.exceptionRanges); c.fail {
return
}
if c := checkEnabledEntity(a.state, i.enabledEntities); c.fail {
if c := CheckEnabledEntity(a.state, i.enabledEntities); c.fail {
return
}
if c := checkDisabledEntity(a.state, i.disabledEntities); c.fail {
if c := CheckDisabledEntity(a.state, i.disabledEntities); c.fail {
return
}
go i.callback(a.service, a.state)

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
import (
@@ -5,49 +8,72 @@ import (
"log/slog"
"time"
"github.com/Xevion/gome-assistant/internal"
"github.com/Xevion/gome-assistant/internal/parse"
"github.com/golang-module/carbon"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
"github.com/dromara/carbon/v2"
)
// ScheduleCallback is a function type that gets called when a schedule triggers.
// It receives the service instance and current state as parameters.
type ScheduleCallback func(*Service, State)
// DailySchedule represents a recurring daily schedule with various conditions.
// It can be configured to run at specific times, sunrise/sunset, or based on
// entity states and date restrictions.
type DailySchedule struct {
// 0-23
// Hour of the day (0-23) when the schedule should run
hour int
// 0-59
// Minute of the hour (0-59) when the schedule should run
minute int
callback ScheduleCallback
// Function to call when the schedule triggers
callback ScheduleCallback
// Next time this schedule should run
nextRunTime time.Time
// If true, schedule runs at sunrise instead of fixed time
isSunrise bool
isSunset bool
sunOffset DurationString
// If true, schedule runs at sunset instead of fixed time
isSunset bool
// Offset from sunrise/sunset (e.g., "-30m", "+1h")
sunOffset types.DurationString
// Dates when this schedule should NOT run
exceptionDates []time.Time
// Dates when this schedule is ONLY allowed to run (if empty, runs on all dates)
allowlistDates []time.Time
enabledEntities []internal.EnabledDisabledInfo
// Entities that must be in specific states for this schedule to run
enabledEntities []internal.EnabledDisabledInfo
// Entities that must NOT be in specific states for this schedule to run
disabledEntities []internal.EnabledDisabledInfo
}
// Hash returns a unique string identifier for this schedule based on its
// time and callback function.
func (s DailySchedule) Hash() string {
return fmt.Sprint(s.hour, s.minute, s.callback)
}
// scheduleBuilder is used in the fluent API to build schedules step by step.
type scheduleBuilder struct {
schedule DailySchedule
}
// scheduleBuilderCall represents the state after setting the callback function.
type scheduleBuilderCall struct {
schedule DailySchedule
}
// scheduleBuilderEnd represents the final state where time and conditions are set.
type scheduleBuilderEnd struct {
schedule DailySchedule
}
// NewDailySchedule creates a new schedule builder with default values.
// Use the fluent API to configure the schedule:
//
// NewDailySchedule().Call(myFunction).At("15:30").Build()
func NewDailySchedule() scheduleBuilder {
return scheduleBuilder{
DailySchedule{
@@ -58,6 +84,7 @@ func NewDailySchedule() scheduleBuilder {
}
}
// String returns a human-readable representation of the schedule.
func (s DailySchedule) String() string {
return fmt.Sprintf("Schedule{ call %q daily at %s }",
internal.GetFunctionName(s.callback),
@@ -65,27 +92,35 @@ func (s DailySchedule) String() string {
)
}
// stringHourMinute formats hour and minute as "HH:MM".
func stringHourMinute(hour, minute int) string {
return fmt.Sprintf("%02d:%02d", hour, minute)
}
// Call sets the callback function that will be executed when the schedule triggers.
// This is the first step in the fluent API chain.
func (sb scheduleBuilder) Call(callback ScheduleCallback) scheduleBuilderCall {
sb.schedule.callback = callback
return scheduleBuilderCall(sb)
}
// At takes a string in 24hr format time like "15:30".
// At sets the schedule to run at a specific time in 24-hour format.
// Examples: "15:30", "09:00", "23:45"
func (sb scheduleBuilderCall) At(s string) scheduleBuilderEnd {
t := parse.ParseTime(s)
t := internal.ParseTime(s)
sb.schedule.hour = t.Hour()
sb.schedule.minute = t.Minute()
return scheduleBuilderEnd(sb)
}
// Sunrise takes an optional duration string that is passed to time.ParseDuration.
// Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration
// for full list.
func (sb scheduleBuilderCall) Sunrise(offset ...DurationString) scheduleBuilderEnd {
// Sunrise configures the schedule to run at sunrise with an optional offset.
// The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
// Only the first offset, if provided, is considered.
// Examples:
// - Sunrise() - runs at sunrise
// - Sunrise("-30m") - runs 30 minutes before sunrise
// - Sunrise("+1h") - runs 1 hour after sunrise
func (sb scheduleBuilderCall) Sunrise(offset ...types.DurationString) scheduleBuilderEnd {
sb.schedule.isSunrise = true
if len(offset) > 0 {
sb.schedule.sunOffset = offset[0]
@@ -93,10 +128,14 @@ func (sb scheduleBuilderCall) Sunrise(offset ...DurationString) scheduleBuilderE
return scheduleBuilderEnd(sb)
}
// Sunset takes an optional duration string that is passed to time.ParseDuration.
// Examples include "-1.5h", "30m", etc. See https://pkg.go.dev/time#ParseDuration
// for full list.
func (sb scheduleBuilderCall) Sunset(offset ...DurationString) scheduleBuilderEnd {
// Sunset configures the schedule to run at sunset with an optional offset.
// The offset parameter is a duration string (e.g., "-30m", "+1h", "-1.5h").
// Only the first offset, if provided, is considered.
// Examples:
// - Sunset() - runs at sunset
// - Sunset("-30m") - runs 30 minutes before sunset
// - Sunset("+1h") - runs 1 hour after sunset
func (sb scheduleBuilderCall) Sunset(offset ...types.DurationString) scheduleBuilderEnd {
sb.schedule.isSunset = true
if len(offset) > 0 {
sb.schedule.sunOffset = offset[0]
@@ -104,20 +143,27 @@ func (sb scheduleBuilderCall) Sunset(offset ...DurationString) scheduleBuilderEn
return scheduleBuilderEnd(sb)
}
// ExceptionDates adds dates when this schedule should NOT run.
// You can pass multiple dates: ExceptionDates(date1, date2, date3)
func (sb scheduleBuilderEnd) ExceptionDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
sb.schedule.exceptionDates = append(tl, t)
return sb
}
// OnlyOnDates restricts the schedule to run ONLY on the specified dates.
// If no dates are specified, the schedule runs on all dates.
// You can pass multiple dates: OnlyOnDates(date1, date2, date3)
func (sb scheduleBuilderEnd) OnlyOnDates(t time.Time, tl ...time.Time) scheduleBuilderEnd {
sb.schedule.allowlistDates = append(tl, t)
return sb
}
/*
Enable this schedule only when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the schedule runs if {runOnNetworkError} is true.
*/
// EnabledWhen makes this schedule only run when the specified entity is in the given state.
// If there's a network error while checking the entity state, the schedule runs
// only if runOnNetworkError is true.
// Examples:
// - EnabledWhen("light.living_room", "on", true) - only run when light is on
// - EnabledWhen("sensor.motion", "detected", false) - only run when motion detected, fail on network error
func (sb scheduleBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
@@ -131,10 +177,11 @@ func (sb scheduleBuilderEnd) EnabledWhen(entityId, state string, runOnNetworkErr
return sb
}
/*
Disable this schedule when the current state of {entityId} matches {state}.
If there is a network error while retrieving state, the schedule runs if {runOnNetworkError} is true.
*/
// DisabledWhen prevents this schedule from running when the specified entity is in the given state.
// If there's a network error while checking the entity state, the schedule runs only if runOnNetworkError is true.
// Examples:
// - DisabledWhen("light.living_room", "off", true) - don't run when light is off
// - DisabledWhen("sensor.motion", "detected", false) - don't run when motion detected, fail on network error
func (sb scheduleBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkError bool) scheduleBuilderEnd {
if entityId == "" {
panic(fmt.Sprintf("entityId is empty in EnabledWhen entityId='%s' state='%s'", entityId, state))
@@ -148,11 +195,15 @@ func (sb scheduleBuilderEnd) DisabledWhen(entityId, state string, runOnNetworkEr
return sb
}
// Build finalizes the schedule configuration and returns the DailySchedule.
// This is the final step in the fluent API chain.
func (sb scheduleBuilderEnd) Build() DailySchedule {
return sb.schedule
}
// app.Start() functions
// runSchedules is the main goroutine that manages all schedules.
// It continuously processes schedules, running them when their time comes
// and requeuing them for the next day.
func runSchedules(a *App) {
if a.schedules.Len() == 0 {
return
@@ -168,7 +219,7 @@ func runSchedules(a *App) {
sched := popSchedule(a)
// run callback for all schedules before now in case they overlap
// Run callback for all schedules that are overdue in case they overlap
for sched.nextRunTime.Before(time.Now()) {
sched.maybeRunCallback(a)
requeueSchedule(a, sched)
@@ -178,7 +229,7 @@ func runSchedules(a *App) {
slog.Info("Next schedule", "start_time", sched.nextRunTime)
// Use context-aware sleep
// Wait until the next schedule time or context cancellation
select {
case <-time.After(time.Until(sched.nextRunTime)):
// Time elapsed, continue
@@ -192,40 +243,51 @@ func runSchedules(a *App) {
}
}
// maybeRunCallback checks all conditions and runs the callback if they're all met.
// Conditions checked:
// 1. Exception dates (schedule should not run on these dates)
// 2. Allowlist dates (schedule should only run on these dates)
// 3. Enabled entities (required entity states)
// 4. Disabled entities (forbidden entity states)
// The callback runs in a goroutine to avoid blocking the scheduler.
func (s DailySchedule) maybeRunCallback(a *App) {
if c := checkExceptionDates(s.exceptionDates); c.fail {
if c := CheckExceptionDates(s.exceptionDates); c.fail {
return
}
if c := checkAllowlistDates(s.allowlistDates); c.fail {
if c := CheckAllowlistDates(s.allowlistDates); c.fail {
return
}
if c := checkEnabledEntity(a.state, s.enabledEntities); c.fail {
if c := CheckEnabledEntity(a.state, s.enabledEntities); c.fail {
return
}
if c := checkDisabledEntity(a.state, s.disabledEntities); c.fail {
if c := CheckDisabledEntity(a.state, s.disabledEntities); c.fail {
return
}
go s.callback(a.service, a.state)
}
// popSchedule removes and returns the next schedule from the priority queue.
func popSchedule(a *App) DailySchedule {
_sched, _ := a.schedules.Get(1)
return _sched[0].(Item).Value.(DailySchedule)
}
// requeueSchedule calculates the next run time for a schedule and adds it back to the queue.
// For sunrise/sunset schedules, it calculates the next sunrise/sunset time.
// For fixed-time schedules, it adds one day to the current run time.
func requeueSchedule(a *App, s DailySchedule) {
if s.isSunrise || s.isSunset {
var nextSunTime carbon.Carbon
// "0s" is default value
var nextSunTime *carbon.Carbon
// "0s" is the default value for no offset
if s.sunOffset != "0s" {
nextSunTime = getNextSunRiseOrSet(a, s.isSunrise, s.sunOffset)
} else {
nextSunTime = getNextSunRiseOrSet(a, s.isSunrise)
}
s.nextRunTime = nextSunTime.Carbon2Time()
s.nextRunTime = nextSunTime.StdTime()
} else {
s.nextRunTime = carbon.Time2Carbon(s.nextRunTime).AddDay().Carbon2Time()
s.nextRunTime = carbon.CreateFromStdTime(s.nextRunTime).AddDay().StdTime()
}
a.schedules.Put(Item{

View File

@@ -1,8 +1,8 @@
package gomeassistant
import (
"github.com/Xevion/gome-assistant/internal/services"
ws "github.com/Xevion/gome-assistant/internal/websocket"
"github.com/Xevion/go-ha/internal/connect"
"github.com/Xevion/go-ha/internal/services"
)
type Service struct {
@@ -31,7 +31,7 @@ type Service struct {
ZWaveJS *services.ZWaveJS
}
func newService(conn *ws.WebsocketWriter) *Service {
func newService(conn *connect.HAConnection) *Service {
return &Service{
AdaptiveLighting: services.BuildService[services.AdaptiveLighting](conn),
AlarmControlPanel: services.BuildService[services.AlarmControlPanel](conn),

View File

@@ -6,16 +6,17 @@ import (
"strings"
"time"
"github.com/golang-module/carbon"
"github.com/dromara/carbon/v2"
"github.com/Xevion/gome-assistant/internal/http"
"github.com/Xevion/go-ha/internal"
"github.com/Xevion/go-ha/types"
)
type State interface {
AfterSunrise(...DurationString) bool
BeforeSunrise(...DurationString) bool
AfterSunset(...DurationString) bool
BeforeSunset(...DurationString) bool
AfterSunrise(...types.DurationString) bool
BeforeSunrise(...types.DurationString) bool
AfterSunset(...types.DurationString) bool
BeforeSunset(...types.DurationString) bool
ListEntities() ([]EntityState, error)
Get(entityId string) (EntityState, error)
Equals(entityId, state string) (bool, error)
@@ -23,7 +24,7 @@ type State interface {
// State is used to retrieve state from Home Assistant.
type StateImpl struct {
httpClient *http.HttpClient
httpClient *internal.HttpClient
latitude float64
longitude float64
}
@@ -35,7 +36,7 @@ type EntityState struct {
LastChanged time.Time `json:"last_changed"`
}
func newState(c *http.HttpClient, homeZoneEntityId string) (*StateImpl, error) {
func newState(c *internal.HttpClient, homeZoneEntityId string) (*StateImpl, error) {
state := &StateImpl{httpClient: c}
// Ensure the zone exists and has required attributes
@@ -80,9 +81,9 @@ func (s *StateImpl) Get(entityId string) (EntityState, error) {
}
// ListEntities returns a list of all entities in Home Assistant.
// see rest documentation for more details: https://developers.home-assistant.io/docs/api/rest/#actions
// See REST documentation for more details: https://developers.home-assistant.io/docs/api/rest/#actions
func (s *StateImpl) ListEntities() ([]EntityState, error) {
resp, err := s.httpClient.States()
resp, err := s.httpClient.GetStates()
if err != nil {
return nil, err
}
@@ -99,20 +100,20 @@ func (s *StateImpl) Equals(entityId string, expectedState string) (bool, error)
return currentState.State == expectedState, nil
}
func (s *StateImpl) BeforeSunrise(offset ...DurationString) bool {
sunrise := getSunriseSunset(s /* sunrise = */, true, carbon.Now(), offset...)
func (s *StateImpl) BeforeSunrise(offset ...types.DurationString) bool {
sunrise := getSunriseSunset(s, true, carbon.Now(), offset...)
return carbon.Now().Lt(sunrise)
}
func (s *StateImpl) AfterSunrise(offset ...DurationString) bool {
func (s *StateImpl) AfterSunrise(offset ...types.DurationString) bool {
return !s.BeforeSunrise(offset...)
}
func (s *StateImpl) BeforeSunset(offset ...DurationString) bool {
sunset := getSunriseSunset(s /* sunrise = */, false, carbon.Now(), offset...)
func (s *StateImpl) BeforeSunset(offset ...types.DurationString) bool {
sunset := getSunriseSunset(s, false, carbon.Now(), offset...)
return carbon.Now().Lt(sunset)
}
func (s *StateImpl) AfterSunset(offset ...DurationString) bool {
func (s *StateImpl) AfterSunset(offset ...types.DurationString) bool {
return !s.BeforeSunset(offset...)
}

35
types/app.go Normal file
View File

@@ -0,0 +1,35 @@
package types
// NewAppRequest contains the configuration for creating a new App instance.
type NewAppRequest struct {
// Required
URL string
// Optional
// Deprecated: use URL instead
// IpAddress of your Home Assistant instance, e.g. "localhost"
// or "192.168.86.59" etc.
IpAddress string
// Optional
// Deprecated: use URL instead
// Port number Home Assistant is running on. Defaults to 8123.
Port string
// Required
// Auth token generated in Home Assistant. Used
// to connect to the WebSocket API.
HAAuthToken string
// Required
// EntityId of the zone representing your home, e.g. "zone.home".
// Used to pull latitude/longitude from Home Assistant
// to calculate sunset/sunrise times.
HomeZoneEntityId string
// Optional
// Whether to use secure connections for HTTP and WebSockets.
// Setting this to `true` will use `https://` instead of `http://`
// and `wss://` instead of `ws://`.
Secure bool
}

22
types/common.go Normal file
View File

@@ -0,0 +1,22 @@
package types
import "time"
// DurationString represents a duration, such as "2s" or "24h".
// See https://pkg.go.dev/time#ParseDuration for all valid time units.
type DurationString string
// TimeString is a 24-hr format time "HH:MM" such as "07:30".
type TimeString string
// TimeRange represents a time range with start and end times.
type TimeRange struct {
Start time.Time
End time.Time
}
// Item represents a priority queue item with a value and priority.
type Item struct {
Value interface{}
Priority float64
}

View File

@@ -1,4 +1,4 @@
package gomeassistant
package types
import "time"

View File