1
0
Fork 0

Initial commit

main v0.1.0
Jordan Hotmann 2023-10-12 11:23:35 -06:00
commit 1d1c0a9e82
No known key found for this signature in database
GPG Key ID: 01B504170C2A2EA3
16 changed files with 992 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.envrc
temp/
.vscode/

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM golang:1 as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -o /hats
FROM builder as tester
RUN go test -v
FROM scratch
COPY --from=builder --chmod=755 /hats /hats
ENTRYPOINT [ "/hats" ]

3
README.md Normal file
View File

@ -0,0 +1,3 @@
# HATS
Push Home Assistant websocket events to a NATS message queue. Additionally acts as a caching proxy for Home Assistant API requests.

30
go.mod Normal file
View File

@ -0,0 +1,30 @@
module code.jhot.me/jhot/hats
go 1.21.1
require (
github.com/gorilla/websocket v1.5.0
github.com/nats-io/nats.go v1.30.2
)
require (
github.com/ajg/form v1.5.1 // indirect
golang.org/x/net v0.15.0 // indirect
golang.org/x/time v0.3.0 // indirect
)
require (
github.com/go-chi/chi v1.5.5
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/render v1.0.3
github.com/go-resty/resty/v2 v2.9.1
github.com/golang/protobuf v1.5.3 // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/nats-io/nats-server/v2 v2.10.2 // indirect
github.com/nats-io/nkeys v0.4.5 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.13.0 // indirect
golang.org/x/sys v0.12.0 // indirect
golang.org/x/text v0.13.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

81
go.sum Normal file
View File

@ -0,0 +1,81 @@
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4=
github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0=
github.com/go-resty/resty/v2 v2.9.1 h1:PIgGx4VrHvag0juCJ4dDv3MiFRlDmP0vicBucwf+gLM=
github.com/go-resty/resty/v2 v2.9.1/go.mod h1:4/GYJVjh9nhkhGR6AUNW3XhpDYNUr+Uvy9gV/VGZIy4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM=
github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
github.com/minio/highwayhash v1.0.2/go.mod h1:BQskDq+xkJ12lmlUUi7U0M5Swg3EWR+dLTk+kldvVxY=
github.com/nats-io/jwt/v2 v2.5.2 h1:DhGH+nKt+wIkDxM6qnVSKjokq5t59AZV5HRcFW0zJwU=
github.com/nats-io/jwt/v2 v2.5.2/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI=
github.com/nats-io/nats-server/v2 v2.10.2 h1:2o/OOyc/dxeMCQtrF1V/9er0SU0A3LKhDlv/+rqreBM=
github.com/nats-io/nats-server/v2 v2.10.2/go.mod h1:lzrskZ/4gyMAh+/66cCd+q74c6v7muBypzfWhP/MAaM=
github.com/nats-io/nats.go v1.30.2 h1:aloM0TGpPorZKQhbAkdCzYDj+ZmsJDyeo3Gkbr72NuY=
github.com/nats-io/nats.go v1.30.2/go.mod h1:dcfhUgmQNN4GJEfIb2f9R7Fow+gzBF4emzDHrVBd5qM=
github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk=
github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=

96
internal/api/api.go Normal file
View File

@ -0,0 +1,96 @@
package api
import (
"fmt"
"net/http"
"time"
"log/slog"
"code.jhot.me/jhot/hats/internal/nats"
"code.jhot.me/jhot/hats/pkg/config"
"code.jhot.me/jhot/hats/pkg/homeassistant"
"github.com/go-chi/chi/middleware"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
var (
cfg *config.HatsConfig
logger *slog.Logger
server http.Server
haClient *homeassistant.RestClient
)
const (
HA_STATE_PREFIX = "homeassistant.states"
)
func Listen(parentLogger *slog.Logger) {
logger = parentLogger
cfg = config.FromEnvironment()
haClient = homeassistant.NewRestClient(cfg.HomeAssistantBaseUrl, cfg.HomeAssistantToken)
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.RealIP)
router.Use(middleware.Recoverer)
router.Use(middleware.Timeout(60 * time.Second))
router.Get(`/api/state/{entityId}`, func(w http.ResponseWriter, r *http.Request) {
logger.Debug(fmt.Sprintf("%s %s", r.Method, r.URL.Path), "method", r.Method, "path", r.URL.Path, "address", r.RemoteAddr)
entityId := chi.URLParam(r, "entityId")
kvVal, err := nats.GetKeyValue(fmt.Sprintf("%s.%s", HA_STATE_PREFIX, entityId))
if err == nil && len(kvVal) > 0 {
w.Write(kvVal)
return
}
data, err := haClient.GetState(entityId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
nats.SetKeyValueString(fmt.Sprintf("%s.%s", HA_STATE_PREFIX, entityId), data.State)
render.PlainText(w, r, data.State)
})
router.Post("/api/state/{entityId}/{service}", func(w http.ResponseWriter, r *http.Request) {
logger.Debug(fmt.Sprintf("%s %s", r.Method, r.URL.Path), "method", r.Method, "path", r.URL.Path, "address", r.RemoteAddr)
entityId := chi.URLParam(r, "entityId")
service := chi.URLParam(r, "service")
var extras map[string]string
err := render.DecodeJSON(r.Body, &extras)
var haErr error
if err == nil && len(extras) > 0 {
haErr = haClient.CallService(entityId, service, extras)
} else {
haErr = haClient.CallService(entityId, service)
}
if haErr != nil {
logger.Error("Error setting state", "error", haErr)
http.Error(w, fmt.Sprintf("error proxying request: %s", haErr.Error()), http.StatusInternalServerError)
return
}
render.Status(r, http.StatusOK)
render.PlainText(w, r, "OK")
})
server = http.Server{
Addr: ":8888",
Handler: router,
}
go server.ListenAndServe()
}
func Close() {
if server.Addr != "" {
server.Close()
}
}

View File

@ -0,0 +1,144 @@
package homeassistant
import (
"encoding/json"
"errors"
"fmt"
"time"
"log/slog"
"code.jhot.me/jhot/hats/internal/nats"
"code.jhot.me/jhot/hats/pkg/config"
ha "code.jhot.me/jhot/hats/pkg/homeassistant"
"github.com/gorilla/websocket"
)
var (
cfg *config.HatsConfig
logger *slog.Logger
haWebsocketConn *websocket.Conn
done chan struct{}
)
const (
stateChangeEventId = 1001
zhaEventId = 1002
qrEventId = 1003
)
func CloseSubscription() error {
if haWebsocketConn != nil {
logger.Debug("Closing Home Assistant subscription")
haWebsocketConn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
close(done)
return haWebsocketConn.Close()
}
return nil
}
func Subscribe(parentLogger *slog.Logger) error {
logger = parentLogger
cfg = config.FromEnvironment()
var err error
url := fmt.Sprintf(cfg.HomeAssistantWebsocketUrl)
logger.Debug("Dialing Home Assistant websocket API", "url", url)
haWebsocketConn, _, err = websocket.DefaultDialer.Dial(url, nil)
if err != nil {
return fmt.Errorf("%w: error dialing Home Assistant websocket", err)
}
done = make(chan struct{})
handleMessages()
return nil
}
func reconnect() {
haWebsocketConn.Close()
attempts := 1
for {
if attempts > 10 {
panic(errors.New("unable to reconnect to Home Assistant"))
}
time.Sleep(time.Duration(attempts) * 5 * time.Second)
logger.Info("Trying to reconnect to Home Assistant", "attempt", attempts)
err := Subscribe(logger)
if err == nil {
break
}
attempts += 1
}
}
func handleMessages() {
go func() {
defer close(done)
for {
_, rawMessage, err := haWebsocketConn.ReadMessage()
if err != nil {
logger.Error("Error reading Home Assistant websocket message", "error", err)
reconnect()
break
}
if len(rawMessage) == 0 {
continue
}
var message ha.HassMessage
err = json.Unmarshal(rawMessage, &message)
if err != nil {
logger.Error("Error parsing HASS message", "message", string(rawMessage), "error", err)
continue
}
switch message.Type {
case ha.MessageType.AuthRequired:
logger.Debug("Logging in to HomeAssistant websocket API")
haWebsocketConn.WriteJSON(ha.AuthMessage{Type: "auth", AccessToken: cfg.HomeAssistantToken})
case ha.MessageType.AuthOk:
logger.Debug("Subscribing to Events")
haWebsocketConn.WriteJSON(ha.SubscribeEventsMessage{
Type: ha.MessageType.SubscribeEvents,
EventType: ha.MessageType.StateChanged,
Id: stateChangeEventId})
haWebsocketConn.WriteJSON(ha.SubscribeEventsMessage{
Type: ha.MessageType.SubscribeEvents,
EventType: ha.MessageType.ZhaEvent,
Id: zhaEventId})
haWebsocketConn.WriteJSON(ha.SubscribeEventsMessage{
Type: ha.MessageType.SubscribeEvents,
EventType: ha.MessageType.TagScanned,
Id: qrEventId})
case ha.MessageType.Result:
if !message.Success {
logger.Error("Non-Success Result:", "message", message)
reconnect()
return
}
case ha.MessageType.Event:
logger.Debug("Event received", "event", message.Event)
switch message.Id {
case stateChangeEventId:
data, marshallErr := json.Marshal(message.Event.Data)
if marshallErr != nil {
logger.Error("Error marshalling event data", "error", marshallErr)
}
nats.Publish(fmt.Sprintf("homeassistant.states.%s.%s", message.Event.Data.EntityId, message.Event.Data.NewState.State), data)
nats.SetKeyValueString(fmt.Sprintf("homeassistant.states.%s", message.Event.Data.EntityId), message.Event.Data.NewState.State)
case zhaEventId:
data, _ := json.Marshal(message.Event.Data)
nats.Publish(fmt.Sprintf("homeassistant.zha.%s", message.Event.Data.DeviceIeee), data)
case qrEventId:
data, _ := json.Marshal(message.Event.Data)
nats.Publish(fmt.Sprintf("homeassistant.qr.%s", message.Event.Data.TagId), data)
}
}
}
}()
}

102
internal/nats/client.go Normal file
View File

@ -0,0 +1,102 @@
package nats
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"code.jhot.me/jhot/hats/pkg/config"
n "code.jhot.me/jhot/hats/pkg/nats"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
var (
cfg *config.HatsConfig
client *n.NatsConnection
kv jetstream.KeyValue
ctx context.Context
logger *slog.Logger
)
func Close() {
client.Close()
}
func JetstreamConnect(parentContext context.Context, parentLogger *slog.Logger) error {
ctx = parentContext
logger = parentLogger
cfg = config.FromEnvironment()
var err error
client = n.DefaultNatsConnection().WithHostName(cfg.NatsHost).WithPort(cfg.NatsPort).WithConnectionOption(nats.Name(cfg.NatsClientName))
if cfg.NatsToken != "" {
client.WithConnectionOption(nats.Token(cfg.NatsToken))
}
logger.Debug("Connecting to nats")
err = client.Connect()
if err != nil {
return fmt.Errorf("%w: error connecting to nats server", err)
}
return nil
}
func KvConnect() error {
if client.JS == nil {
return errors.New("jetstream must be connected first")
}
logger.Debug("Looking for KV store")
listener := client.JS.KeyValueStoreNames(ctx)
found := false
for name := range listener.Name() {
if name == "KV_hats" {
found = true
}
}
var err error
if found {
logger.Debug("Connecting for KV store")
kv, err = client.JS.KeyValue(ctx, "hats")
} else {
logger.Debug("Creating for KV store")
kv, err = client.JS.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: "hats",
TTL: 2 * time.Hour,
})
}
return err
}
func Publish(subject string, message []byte) {
client.Publish(ctx, subject, message, jetstream.WithRetryAttempts(2), jetstream.WithRetryWait(500*time.Millisecond))
}
func PublishString(subject, message string) {
Publish(subject, []byte(message))
}
func GetKeyValue(key string) ([]byte, error) {
value, err := kv.Get(ctx, key)
if err != nil {
return []byte{}, err
}
return value.Value(), nil
}
func SetKeyValue(key string, value []byte) error {
_, err := kv.Put(ctx, key, value)
return err
}
func SetKeyValueString(key, value string) error {
_, err := kv.PutString(ctx, key, value)
return err
}

11
internal/util/os.go Normal file
View File

@ -0,0 +1,11 @@
package util
import "os"
func GetEnvWithDefault(key, defaultValue string) string {
val := os.Getenv(key)
if val != "" {
return val
}
return defaultValue
}

53
main.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"code.jhot.me/jhot/hats/internal/api"
"code.jhot.me/jhot/hats/internal/homeassistant"
"code.jhot.me/jhot/hats/internal/nats"
)
var (
interrupt chan os.Signal
logger *slog.Logger
ctx context.Context
cancel context.CancelFunc
)
func main() {
ctx, cancel = context.WithCancel(context.Background())
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
interrupt = make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
err := nats.JetstreamConnect(ctx, logger)
if err != nil {
panic(err)
}
err = nats.KvConnect()
if err != nil {
panic(err)
}
defer nats.Close()
err = homeassistant.Subscribe(logger)
if err != nil {
panic(err)
}
defer homeassistant.CloseSubscription()
api.Listen(logger)
defer api.Close()
for sig := range interrupt {
logger.Debug("Interrupt:", "signal", sig.String())
cancel()
break
}
}

66
pkg/client/client.go Normal file
View File

@ -0,0 +1,66 @@
package client
import (
"fmt"
"code.jhot.me/jhot/hats/pkg/homeassistant"
"github.com/go-resty/resty/v2"
)
type HatsClient struct {
client *resty.Client
}
func NewHatsClient(baseUrl string) *HatsClient {
client := resty.New().SetBaseURL(baseUrl)
return &HatsClient{
client: client,
}
}
func (c *HatsClient) GetState(entityId string) (string, error) {
resp, err := c.client.R().Get(fmt.Sprintf("api/state/%s", entityId))
if err == nil && !resp.IsSuccess() {
err = fmt.Errorf("%d status code received: %s", resp.StatusCode(), resp.String())
}
if err != nil {
return "", err
}
return resp.String(), nil
}
func (c *HatsClient) GetStateBool(entityId string) (bool, error) {
stateString, err := c.GetState(entityId)
if err != nil {
return false, err
}
return homeassistant.StateToBool(stateString), nil
}
func (c *HatsClient) CallService(entityId string, service string, extras ...map[string]string) error {
req := c.client.R()
if len(extras) > 0 {
data := map[string]interface{}{}
for _, extra := range extras {
for k, v := range extra {
data[k] = v
}
}
req.SetBody(data)
}
resp, err := req.Post(fmt.Sprintf("api/state/%s/%s", entityId, service))
if err == nil && !resp.IsSuccess() {
err = fmt.Errorf("%d status code received: %s", resp.StatusCode(), resp.String())
}
if err != nil {
return err
}
return nil
}

48
pkg/config/config.go Normal file
View File

@ -0,0 +1,48 @@
package config
import (
"fmt"
"strconv"
"code.jhot.me/jhot/hats/internal/util"
)
type HatsConfig struct {
HomeAssistantHost string
HomeAssistantPort string
HomeAssistantSecure bool
HomeAssistantBaseUrl string
HomeAssistantWebsocketUrl string
HomeAssistantToken string
NatsHost string
NatsPort string
NatsBaseUrl string
NatsToken string
NatsClientName string
}
func FromEnvironment() *HatsConfig {
config := &HatsConfig{
HomeAssistantHost: util.GetEnvWithDefault("HASS_HOST", "127.0.0.1"),
HomeAssistantPort: util.GetEnvWithDefault("HASS_PORT", "8123"),
HomeAssistantToken: util.GetEnvWithDefault("HASS_TOKEN", ""),
NatsHost: util.GetEnvWithDefault("NATS_HOST", "127.0.0.1"),
NatsPort: util.GetEnvWithDefault("NATS_PORT", "4222"),
NatsToken: util.GetEnvWithDefault("NATS_TOKEN", ""),
NatsClientName: util.GetEnvWithDefault("NATS_CLIENT_NAME", "hats"),
}
config.HomeAssistantSecure, _ = strconv.ParseBool(util.GetEnvWithDefault("HASS_SECURE", "false"))
hassProtocol := "http"
HassWsProtocol := "ws"
if config.HomeAssistantSecure {
hassProtocol += "s"
HassWsProtocol += "s"
}
config.HomeAssistantBaseUrl = fmt.Sprintf("%s://%s:%s", hassProtocol, config.HomeAssistantHost, config.HomeAssistantPort)
config.HomeAssistantWebsocketUrl = fmt.Sprintf("%s://%s:%s/api/websocket", HassWsProtocol, config.HomeAssistantHost, config.HomeAssistantPort)
config.NatsBaseUrl = fmt.Sprintf("nats://%s:%s", config.NatsHost, config.NatsPort)
return config
}

49
pkg/homeassistant/rest.go Normal file
View File

@ -0,0 +1,49 @@
package homeassistant
import (
"fmt"
"strings"
"github.com/go-resty/resty/v2"
)
type RestClient struct {
client *resty.Client
}
func NewRestClient(baseUrl, token string) *RestClient {
client := resty.New().SetBaseURL(baseUrl)
client.SetHeaders(map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", token),
"Accept": "application/json",
})
return &RestClient{
client: client,
}
}
func (c *RestClient) GetState(entityId string) (StateData, error) {
var data StateData
resp, err := c.client.R().SetResult(&data).Get(fmt.Sprintf("api/states/%s", entityId))
if err == nil && !resp.IsSuccess() {
err = fmt.Errorf("%d status code received: %s", resp.StatusCode(), resp.String())
}
return data, err
}
func (c *RestClient) CallService(entityId string, service string, extras ...map[string]string) error {
domain := strings.Split(entityId, ".")[0]
data := map[string]interface{}{
"entity_id": entityId,
}
for _, extra := range extras {
for k, v := range extra {
data[k] = v
}
}
resp, err := c.client.R().SetBody(data).Post(fmt.Sprintf("api/services/%s/%s", domain, service))
if err == nil && !resp.IsSuccess() {
err = fmt.Errorf("%d status code received: %s", resp.StatusCode(), resp.String())
}
return err
}

View File

@ -0,0 +1,127 @@
package homeassistant
// Message types that can be returned from Home Assistants websocket API
var MessageType = struct {
AuthRequired string
AuthOk string
AuthInvalid string
Result string
Event string
ZhaEvent string
SubscribeEvents string
StateChanged string
TagScanned string
}{
AuthRequired: "auth_required",
AuthOk: "auth_ok",
AuthInvalid: "auth_invalid",
Result: "result",
Event: "event",
ZhaEvent: "zha_event",
SubscribeEvents: "subscribe_events",
StateChanged: "state_changed",
TagScanned: "tag_scanned",
}
// Home Assistant device domains
var Domains = struct {
Light string
Switch string
Lock string
Cover string
}{
Light: "light",
Switch: "switch",
Lock: "lock",
Cover: "cover",
}
// Home Assistant services
var Services = struct {
TurnOn string
TurnOff string
Toggle string
Reload string
Lock string
Unlock string
OpenCover string
CloseCover string
}{
TurnOn: "turn_on",
TurnOff: "turn_off",
Toggle: "toggle",
Reload: "reload",
Lock: "lock",
Unlock: "unlock",
OpenCover: "open_cover",
CloseCover: "close_cover",
}
// Extra props that can be sent when calling a Home Assistant service
var ExtraProps = struct {
Transition string
Brightness string
BrightnessPercent string
}{
Transition: "transition",
Brightness: "brightness",
BrightnessPercent: "brightness_pct",
}
type ResultContext struct {
Id string `json:"id,omitempty"`
}
type Result struct {
Context ResultContext `json:"context,omitempty"`
}
type StateData struct {
LastChanged string `json:"last_changed,omitempty"`
LastUpdated string `json:"last_updated,omitempty"`
State string `json:"state,omitempty"`
Attributes map[string]interface{} `json:"attributes,omitempty"`
Context interface{} `json:"context,omitempty"`
}
type EventData struct {
EntityId string `json:"entity_id,omitempty"`
NewState StateData `json:"new_state,omitempty"`
OldState StateData `json:"old_state,omitempty"`
DeviceIeee string `json:"device_ieee,omitempty"`
DeviceId string `json:"device_id,omitempty"`
Command string `json:"command,omitempty"`
Args interface{} `json:"args,omitempty"`
Params interface{} `json:"params,omitempty"`
TagId string `json:"tag_id,omitempty"`
}
type Event struct {
Data EventData `json:"data,omitempty"`
EventType string `json:"event_type,omitempty"`
TimeFired string `json:"time_fired,omitempty"`
Origin string `json:"origin,omitempty"`
}
type HassMessage struct {
Type string `json:"type"`
Version string `json:"ha_version,omitempty"`
AccessToken string `json:"access_token,omitempty"`
Message string `json:"message,omitempty"`
Success bool `json:"success,omitempty"`
Result Result `json:"result,omitempty"`
EventType string `json:"event_type,omitempty"`
Event Event `json:"event,omitempty"`
Id int `json:"id,omitempty"`
}
type AuthMessage struct {
Type string `json:"type"`
AccessToken string `json:"access_token,omitempty"`
}
type SubscribeEventsMessage struct {
Type string `json:"type"`
EventType string `json:"event_type"`
Id int `json:"id"`
}

45
pkg/homeassistant/util.go Normal file
View File

@ -0,0 +1,45 @@
package homeassistant
import (
"regexp"
"strings"
)
// StateToBool converts a state string into a boolean
//
// States that return true: "on", "home", "open", "playing", non-zero numbers, etc.
// All others return false
func StateToBool(state string) bool {
trueRegex := regexp.MustCompile(`^(on|home|open(ing)?|unlocked|playing|good|walking|charging|alive|heat|cool|heat_cool|[1-9][\d\.]*|0\.0*[1-9]\d*)$`)
return trueRegex.MatchString(state)
}
// BoolToService converts a boolean into the appropriate service string
//
// For locks: true becomes "unlock" and false becomes "lock"
// For covers: true becomes "open_cover" and false becomes "close_cover"
// For all others: true becomes "turn_on" and false becomes "turn_off"
func BoolToService(entityId string, desiredState bool) string {
domain := strings.Split(entityId, ".")[0]
switch domain {
case Domains.Lock:
if desiredState {
return Services.Unlock
} else {
return Services.Lock
}
case Domains.Cover:
if desiredState {
return Services.OpenCover
} else {
return Services.CloseCover
}
default:
if desiredState {
return Services.TurnOn
} else {
return Services.TurnOff
}
}
}

118
pkg/nats/client.go Normal file
View File

@ -0,0 +1,118 @@
package nats
import (
"context"
"errors"
"fmt"
"strings"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
type NatsConnection struct {
HostName string
Port string
UseJetstream bool
connOpts []nats.Option
jetOpts []jetstream.JetStreamOpt
Conn *nats.Conn
JS jetstream.JetStream
}
func DefaultNatsConnection() *NatsConnection {
return &NatsConnection{
HostName: "127.0.0.1",
Port: "4222",
UseJetstream: true,
connOpts: []nats.Option{},
jetOpts: []jetstream.JetStreamOpt{},
}
}
func (n *NatsConnection) WithHostName(hostname string) *NatsConnection {
n.HostName = hostname
return n
}
func (n *NatsConnection) WithPort(port string) *NatsConnection {
n.Port = port
return n
}
func (n *NatsConnection) WithJetstream(jetstream bool) *NatsConnection {
n.UseJetstream = jetstream
return n
}
func (n *NatsConnection) WithConnectionOption(opt nats.Option) *NatsConnection {
n.connOpts = append(n.connOpts, opt)
return n
}
func (n *NatsConnection) WithJetstreamOption(opt jetstream.JetStreamOpt) *NatsConnection {
n.jetOpts = append(n.jetOpts, opt)
return n
}
func (n *NatsConnection) Connect() error {
var err error
n.Conn, err = nats.Connect(fmt.Sprintf("nats://%s:%s", n.HostName, n.Port), n.connOpts...)
if err != nil {
return err
}
if n.UseJetstream {
n.JS, err = jetstream.New(n.Conn, n.jetOpts...)
if err != nil {
return err
}
}
return nil
}
func (n *NatsConnection) Close() {
if n.Conn != nil {
n.Conn.Close()
}
}
func (n *NatsConnection) Publish(ctx context.Context, subject string, payload []byte, opts ...jetstream.PublishOpt) {
if n.UseJetstream {
n.JS.PublishAsync(subject, payload, opts...)
// n.JS.Publish(ctx, subject, payload, opts...)
} else {
n.Conn.Publish(subject, payload)
}
}
func (n *NatsConnection) Subscribe(subject string) (sub *nats.Subscription, ch chan *nats.Msg, err error) {
if !n.UseJetstream {
ch = make(chan *nats.Msg, 64)
sub, err = n.Conn.ChanSubscribe(subject, ch)
return sub, ch, err
}
return nil, nil, errors.New("jetstream in use, you should use Stream instead")
}
func (n *NatsConnection) Stream(ctx context.Context, subject string) (stream jetstream.Stream, consumer jetstream.Consumer, err error) {
if n.UseJetstream {
stream, err = n.JS.CreateStream(ctx, jetstream.StreamConfig{
Name: strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(subject, ".", "_"), "*", "any"), ">", "arrow"),
Subjects: []string{subject},
})
if err != nil {
return nil, nil, err
}
consumer, err = stream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{})
if err != nil {
return nil, nil, err
}
return stream, consumer, nil
}
return nil, nil, errors.New("jetstream not in use, you should use Subscribe instead")
}