diff --git a/app.go b/app.go index 0af9be9..a2c1e53 100644 --- a/app.go +++ b/app.go @@ -2,42 +2,51 @@ package gomeassistant import ( "context" + "log" + "os" "time" + "github.com/saml-dev/gome-assistant/internal/http" "github.com/saml-dev/gome-assistant/internal/setup" "nhooyr.io/websocket" ) type app struct { - ctx context.Context - ctxCancel context.CancelFunc - conn *websocket.Conn + ctx context.Context + ctxCancel context.CancelFunc + conn *websocket.Conn + httpClient *http.HttpClient + + service *Service + state *State + schedules []schedule entityListeners []entityListener } -var ( - Sunrise hourMinute = hourMinute{1000, 0} - Sunset hourMinute = hourMinute{1001, 0} -) - /* App establishes the websocket connection and returns an object you can use to register schedules and listeners. */ -func App(connString string) (app, error) { - conn, ctx, ctxCancel, err := setup.SetupConnection(connString) - if err != nil { - return app{}, err - } +func App(connString string) app { + token := os.Getenv("AUTH_TOKEN") + conn, ctx, ctxCancel := setup.SetupConnection(connString) + + httpClient := http.NewHttpClient(connString, token) + + service := NewService(conn, ctx, httpClient) + state := NewState(httpClient) return app{ conn: conn, ctx: ctx, ctxCancel: ctxCancel, + httpClient: httpClient, + service: service, + state: state, schedules: []schedule{}, entityListeners: []entityListener{}, - }, nil + } } func (a *app) Cleanup() { @@ -48,27 +57,20 @@ func (a *app) Cleanup() { func (a *app) RegisterSchedule(s schedule) { if s.err != nil { - panic(s.err) // something wasn't configured properly when the schedule was built + log.Fatalln(s.err) // something wasn't configured properly when the schedule was built } if s.frequency == 0 { - panic("A schedule must call either Daily() or Every() when built.") + log.Fatalln("A schedule must call either Daily() or Every() when built.") } now := time.Now() startTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // start at midnight today // apply offset if set - if s.offset.int() != 0 { - if s.offset.int() == Sunrise.int() { - // TODO: same as sunset w/ sunrise - } else if s.offset.int() == Sunset.int() { - // TODO: add an http client (w/ token) to *app, use it to get state of sun.sun - // to get next sunset time - } else { - startTime.Add(time.Hour * time.Duration(s.offset.Hour)) - startTime.Add(time.Minute * time.Duration(s.offset.Minute)) - } + if s.offset.minutes() != 0 { + startTime.Add(time.Hour * time.Duration(s.offset.hour)) + startTime.Add(time.Minute * time.Duration(s.offset.minute)) } // advance first scheduled time by frequency until it is in the future @@ -81,6 +83,11 @@ func (a *app) RegisterSchedule(s schedule) { } func (a *app) Start() { + // NOTE:should the prio queue and websocket listener both write to a channel or something? + // then select from that and spawn new goroutine to call callback? + + // TODO: loop through schedules and create heap priority queue + // TODO: figure out looping listening to messages for // listeners } diff --git a/cmd/main/testing.go b/cmd/main/testing.go index 3377c1e..b2c7783 100644 --- a/cmd/main/testing.go +++ b/cmd/main/testing.go @@ -8,28 +8,24 @@ import ( ) func main() { - app, err := ga.App("192.168.86.67:8123") + app := ga.App("192.168.86.67:8123") defer app.Cleanup() - if err != nil { - fmt.Println(err) - return - } - s := ga.ScheduleBuilder().Call(lightsOut).Daily().At(ga.HourMinute(22, 00)).Build() - s2 := ga.ScheduleBuilder().Call(lightsOut).Every(time.Hour*4 + time.Minute*30).Offset(ga.HourMinute(1, 0)).Build() + s := ga.ScheduleBuilder().Call(lightsOut).Daily().At(ga.Sunset.Subtract(ga.TimeOfDay(0, 30))).Build() + s2 := ga.ScheduleBuilder().Call(lightsOut).Every(time.Hour*4 + time.Minute*30).Offset(ga.TimeOfDay(1, 0)).Build() app.RegisterSchedule(s2) // err = app.Start() simpleListener := ga.EntityListenerBuilder(). EntityId("light.lights"). Call(cool). - OnlyBetween(ga.HourMinute(22, 00), ga.HourMinute(07, 00)) + OnlyBetween(ga.TimeOfDay(22, 00), ga.TimeOfDay(07, 00)) fmt.Println(simpleListener) fmt.Println(s, "\n", s2) } -func lightsOut(service ga.Service) { +func lightsOut(service ga.Service, state ga.State) { // ga.TurnOff("light.all_lights") } diff --git a/entitylistener.go b/entitylistener.go index 4659537..88c26e5 100644 --- a/entitylistener.go +++ b/entitylistener.go @@ -5,15 +5,15 @@ type entityListener struct { callback entityListenerCallback fromState string toState string - betweenStart hourMinute - betweenEnd hourMinute + betweenStart timeOfDay + betweenEnd timeOfDay } type entityListenerCallback func(Service, Data) type Data struct{} -func (b elBuilder3) OnlyBetween(start hourMinute, end hourMinute) elBuilder3 { +func (b elBuilder3) OnlyBetween(start timeOfDay, end timeOfDay) elBuilder3 { b.entityListener.betweenStart = start b.entityListener.betweenEnd = end return b diff --git a/go.mod b/go.mod index 4d18dba..587e0e6 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.19 require nhooyr.io/websocket v1.8.7 require ( + github.com/golang-module/carbon/v2 v2.1.9 // indirect github.com/klauspost/compress v1.10.3 // indirect ) diff --git a/go.sum b/go.sum index d069a2d..b3517c5 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/golang-module/carbon/v2 v2.1.9 h1:OWkhYzTTPe+jPOUEL2JkvGwf6bKNQJoh4LVT1LUay80= +github.com/golang-module/carbon/v2 v2.1.9/go.mod h1:NF5unWf838+pyRY0o+qZdIwBMkFf7w0hmLIguLiEpzU= github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= @@ -47,6 +49,7 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= @@ -68,5 +71,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g= nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0= diff --git a/internal/http/http.go b/internal/http/http.go index cf41f61..f46097a 100644 --- a/internal/http/http.go +++ b/internal/http/http.go @@ -1,6 +1,83 @@ // http is used to interact with the home assistant -// REST API, currently only for retrieving state for +// REST API. Currently only used to retrieve state for // a single entity_id package http -// TODO: impl http struct, should be initialized as part of App() +import ( + "errors" + "fmt" + "io" + "net/http" +) + +type HttpClient struct { + url string + token string +} + +func NewHttpClient(url, token string) *HttpClient { + url = fmt.Sprintf("http://%s/api", url) + return &HttpClient{url, 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 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 +} + +// func post(url string, token string, data any) ([]byte, error) { +// postBody, err := json.Marshal(data) +// if err != nil { +// return nil, err +// } +// req, err := http.NewRequest("GET", url, bytes.NewBuffer(postBody)) +// if err != nil { +// return nil, errors.New("Error building post 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 in post response: " + err.Error()) +// } +// defer resp.Body.Close() + +// if resp.StatusCode == 401 { +// log.Fatalln("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile") +// } + +// body, err := io.ReadAll(resp.Body) +// if err != nil { +// log.Fatalln(err) +// } + +// return body, nil +// } diff --git a/internal/services/builder.go b/internal/services/builder.go new file mode 100644 index 0000000..ee30859 --- /dev/null +++ b/internal/services/builder.go @@ -0,0 +1,11 @@ +package services + +import ( + "context" + + "nhooyr.io/websocket" +) + +func BuildService[T Light | HomeAssistant](conn *websocket.Conn, ctx context.Context) *T { + return &T{conn: conn, ctx: ctx} +} diff --git a/internal/services/homeassistant.go b/internal/services/homeassistant.go new file mode 100644 index 0000000..a9cd7b9 --- /dev/null +++ b/internal/services/homeassistant.go @@ -0,0 +1,18 @@ +package services + +import ( + "context" + + "github.com/saml-dev/gome-assistant/internal/http" + "nhooyr.io/websocket" +) + +type HomeAssistant struct { + conn *websocket.Conn + ctx context.Context + httpClient *http.HttpClient +} + +// TODO: design how much reuse I can get between request types. E.g. +// only difference between light.turnon and homeassistant.turnon is +// domain and extra data diff --git a/internal/services/light.go b/internal/services/light.go index e3b2d22..0bac36a 100644 --- a/internal/services/light.go +++ b/internal/services/light.go @@ -3,13 +3,15 @@ package services import ( "context" + "github.com/saml-dev/gome-assistant/internal/http" "github.com/saml-dev/gome-assistant/internal/setup" "nhooyr.io/websocket" ) type Light struct { - conn *websocket.Conn - ctx context.Context + conn *websocket.Conn + ctx context.Context + httpClient *http.HttpClient } type LightRequest struct { @@ -22,7 +24,21 @@ type LightRequest struct { } `json:"target"` } -func LightOnRequest(entityId string) LightRequest { +/* Public API */ + +func (l Light) TurnOn(entityId string) { + req := newLightOnRequest(entityId) + setup.WriteMessage(req, l.conn, l.ctx) +} + +func (l Light) TurnOff(entityId string) { + req := newLightOffRequest(entityId) + setup.WriteMessage(req, l.conn, l.ctx) +} + +/* Internal */ + +func newLightOnRequest(entityId string) LightRequest { req := LightRequest{ Id: 5, Type: "call_service", @@ -33,18 +49,8 @@ func LightOnRequest(entityId string) LightRequest { return req } -func LightOffRequest(entityId string) LightRequest { - req := LightOnRequest(entityId) +func newLightOffRequest(entityId string) LightRequest { + req := newLightOnRequest(entityId) req.Service = "turn_off" return req } - -func (l Light) TurnOn(entityId string) { - req := LightOnRequest(entityId) - setup.WriteMessage(req, l.conn, l.ctx) -} - -func (l Light) TurnOff(entityId string) { - req := LightOffRequest(entityId) - setup.WriteMessage(req, l.conn, l.ctx) -} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index b68befe..a9d0297 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "os" "time" @@ -39,40 +40,38 @@ func ReadMessage(conn *websocket.Conn, ctx context.Context) (string, error) { return string(msg), nil } -func SetupConnection(connString string) (*websocket.Conn, context.Context, context.CancelFunc, error) { - ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*5) +func SetupConnection(connString string) (*websocket.Conn, context.Context, context.CancelFunc) { + ctx, ctxCancel := context.WithTimeout(context.Background(), time.Second*3) // Init websocket connection conn, _, err := websocket.Dial(ctx, fmt.Sprintf("ws://%s/api/websocket", connString), nil) if err != nil { - fmt.Printf("ERROR: Failed to connect to websocket at ws://%s/api/websocket. Check IP address and port\n", connString) ctxCancel() - return nil, nil, nil, err + log.Fatalf("ERROR: Failed to connect to websocket at ws://%s/api/websocket. Check IP address and port\n", connString) } // Read auth_required message _, err = ReadMessage(conn, ctx) if err != nil { ctxCancel() - return nil, nil, nil, err + log.Fatalln("Unknown error creating websocket client") } // Send auth message err = SendAuthMessage(conn, ctx) if err != nil { ctxCancel() - return nil, nil, nil, err + log.Fatalln("Unknown error creating websocket client") } // Verify auth message err = VerifyAuthResponse(conn, ctx) if err != nil { - fmt.Println("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile") ctxCancel() - return nil, nil, nil, err + log.Fatalln("ERROR: Auth token is invalid. Please double check it or create a new token in your Home Assistant profile") } - return conn, ctx, ctxCancel, err + return conn, ctx, ctxCancel } func SendAuthMessage(conn *websocket.Conn, ctx context.Context) error { diff --git a/schedule.go b/schedule.go index 25dfc23..788d764 100644 --- a/schedule.go +++ b/schedule.go @@ -2,29 +2,76 @@ package gomeassistant import ( "fmt" + "log" "reflect" "runtime" "time" ) -type hourMinute struct { - Hour int - Minute int +type sunriseSunset struct { + base timeOfDay + addition timeOfDay + subtraction timeOfDay } -func (hm hourMinute) int() int { - return hm.Hour + hm.Minute +var Sunrise *sunriseSunset = &sunriseSunset{ + base: TimeOfDay(0, 10000), + addition: TimeOfDay(0, 0), + subtraction: TimeOfDay(0, 0), } -func HourMinute(Hour, Minute int) hourMinute { - return hourMinute{Hour, Minute} +var Sunset *sunriseSunset = &sunriseSunset{ + base: TimeOfDay(0, 20000), + addition: TimeOfDay(0, 0), + subtraction: TimeOfDay(0, 0), } -func (hm hourMinute) String() string { - return fmt.Sprintf("%02d:%02d", hm.Hour, hm.Minute) +func (ss *sunriseSunset) Add(hm timeOfDay) *sunriseSunset { + ss.addition = hm + return ss } -type scheduleCallback func(Service) +func (ss *sunriseSunset) Subtract(hm timeOfDay) *sunriseSunset { + ss.subtraction = hm + return ss +} + +func (ss *sunriseSunset) minutes() int { + return ss.base.minute + + (ss.addition.hour*60 + ss.addition.minute) - + (ss.subtraction.hour*60 + ss.subtraction.minute) +} + +// HourMinute is used to express a time of day +// but it shouldn't be used directly. Use +// HourMinute(), Sunset(), or Sunrise() to +// create one. Add() and Subtract() can be +// called on Sunset and Sunrise to offset +// from that time. +type timeOfDay struct { + hour int + minute int +} + +type timeOfDayInterface interface { + // Time represented as number of minutes + // after midnight. E.g. 02:00 would be 120. + minutes() int +} + +func (hm timeOfDay) minutes() int { + return hm.hour*60 + hm.minute +} + +func TimeOfDay(Hour, Minute int) timeOfDay { + return timeOfDay{Hour, Minute} +} + +func (hm timeOfDay) String() string { + return fmt.Sprintf("%02d:%02d", hm.hour, hm.minute) +} + +type scheduleCallback func(Service, State) type schedule struct { /* @@ -50,10 +97,10 @@ type schedule struct { offset: "0003" } */ - offset hourMinute + offset timeOfDay /* This will be set rather than returning an error to avoid checking err for nil on every schedule :) - RegisterSchedule will panic if the error is set. + RegisterSchedule will exit if the error is set. */ err error realStartTime time.Time @@ -83,7 +130,7 @@ func ScheduleBuilder() scheduleBuilder { return scheduleBuilder{ schedule{ frequency: 0, - offset: hourMinute{0, 0}, + offset: timeOfDay{0, 0}, }, } } @@ -113,18 +160,18 @@ func (sb scheduleBuilderCall) Daily() scheduleBuilderDaily { return scheduleBuilderDaily(sb) } -func (sb scheduleBuilderDaily) At(t hourMinute) scheduleBuilderEnd { - sb.schedule.offset = t +func (sb scheduleBuilderDaily) At(t timeOfDayInterface) scheduleBuilderEnd { + sb.schedule.offset = convertTimeOfDayToActualOffset(t) return scheduleBuilderEnd(sb) } -func (sb scheduleBuilderCall) Every(d time.Duration) scheduleBuilderCustom { - sb.schedule.frequency = d +func (sb scheduleBuilderCall) Every(duration time.Duration) scheduleBuilderCustom { + sb.schedule.frequency = duration return scheduleBuilderCustom(sb) } -func (sb scheduleBuilderCustom) Offset(o hourMinute) scheduleBuilderEnd { - sb.schedule.offset = o +func (sb scheduleBuilderCustom) Offset(t timeOfDay) scheduleBuilderEnd { + sb.schedule.offset = t return scheduleBuilderEnd(sb) } @@ -139,3 +186,22 @@ func (sb scheduleBuilderEnd) Build() schedule { func getFunctionName(i interface{}) string { return runtime.FuncForPC(reflect.ValueOf(i).Pointer()).Name() } + +func convertTimeOfDayToActualOffset(t timeOfDayInterface) timeOfDay { + if t.minutes() > 15000 { + // TODO: same as below but w/ sunset + return TimeOfDay(0, 0) + } else if t.minutes() > 5000 { + // TODO: use httpClient to get state of sun.sun + // to get next sunrise time + + // retrieve next sunrise time + + // use carbon.Parse() to create time.Time of that time + + // return Time() of that many hours and minutes to set offset from midnight + } else if t.minutes() >= 1440 { + log.Fatalln("Offset (set via At() or Offset()) cannot be more than 1 day (23h59m)") + } + return TimeOfDay(0, t.minutes()) +} diff --git a/service.go b/service.go index ee5a72d..6717823 100644 --- a/service.go +++ b/service.go @@ -3,21 +3,19 @@ package gomeassistant import ( "context" + "github.com/saml-dev/gome-assistant/internal/http" "github.com/saml-dev/gome-assistant/internal/services" "nhooyr.io/websocket" ) type Service struct { - HomeAssistant homeAssistant - Light services.Light + HomeAssistant *services.HomeAssistant + Light *services.Light } -type homeAssistant struct { - conn websocket.Conn - ctx context.Context +func NewService(conn *websocket.Conn, ctx context.Context, httpClient *http.HttpClient) *Service { + return &Service{ + Light: services.BuildService[services.Light](conn, ctx), + HomeAssistant: services.BuildService[services.HomeAssistant](conn, ctx), + } } - -// type light struct { -// conn websocket.Conn -// ctx context.Context -// } diff --git a/state.go b/state.go new file mode 100644 index 0000000..f901be6 --- /dev/null +++ b/state.go @@ -0,0 +1,20 @@ +package gomeassistant + +import "github.com/saml-dev/gome-assistant/internal/http" + +// State is used to retrieve state from Home Assistant. +type State struct { + httpClient *http.HttpClient +} + +func NewState(c *http.HttpClient) *State { + return &State{httpClient: c} +} + +func (s *State) Get(entityId string) (string, error) { + resp, err := s.httpClient.GetState(entityId) + if err != nil { + return "", err + } + return string(resp), nil +}