API token auth, readme updates
parent
6f1e9f4a75
commit
2076619576
91
README.md
91
README.md
|
@ -1,3 +1,92 @@
|
||||||
# HATS
|
# HATS
|
||||||
|
|
||||||
Push Home Assistant websocket events to a NATS message queue. Additionally acts as a caching proxy for Home Assistant API requests.
|
[Home Assistant](https://www.home-assistant.io/) + [NATS](https://nats.io/) = HATS
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Push Home Assistant websocket events to a NATS message queue
|
||||||
|
- Caching proxy for Home Assistant API
|
||||||
|
- Clients for some application APIs (limited functionality)
|
||||||
|
- [Gokapi](https://github.com/Forceu/Gokapi)
|
||||||
|
- [ntfy](https://github.com/binwiederhier/ntfy)
|
||||||
|
- [qBittorrent](https://github.com/qbittorrent/qBittorrent)
|
||||||
|
- [Syncthing](https://github.com/syncthing/syncthing)
|
||||||
|
- [National Weather Service](https://www.weather.gov/)
|
||||||
|
|
||||||
|
## Example Client
|
||||||
|
|
||||||
|
```golang
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"code.jhot.me/jhot/hats/pkg/client"
|
||||||
|
"code.jhot.me/jhot/hats/pkg/config"
|
||||||
|
ha "code.jhot.me/jhot/hats/pkg/homeassistant"
|
||||||
|
n "code.jhot.me/jhot/hats/pkg/nats"
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
logger *slog.Logger
|
||||||
|
hatsClient *client.HatsClient
|
||||||
|
natsClient *n.NatsConnection
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.FromEnvironment()
|
||||||
|
logger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: cfg.GetLogLevel(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
hatsClient = client.NewHatsClient(cfg.GetHatsBaseUrl(), cfg.HatsToken)
|
||||||
|
natsClient = n.DefaultNatsConnection().WithJetstream(false).WithHostName(cfg.NatsHost).WithPort(cfg.NatsPort).WithConnectionOption(nats.Name(cfg.NatsClientName))
|
||||||
|
defer natsClient.Close()
|
||||||
|
|
||||||
|
go GenericStateListener("sun.sun", SunHandler)
|
||||||
|
|
||||||
|
sigch := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigch, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
|
||||||
|
<-sigch
|
||||||
|
logger.Info("SIGTERM received")
|
||||||
|
}
|
||||||
|
|
||||||
|
func SunHandler(state ha.StateData) error {
|
||||||
|
return hatsClient.CallService("light.some_light", ha.Services.TurnOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenericStateListener(entityId string, handler func(ha.StateData) error) {
|
||||||
|
topic := fmt.Sprintf("homeassistant.states.%s.*", entityId)
|
||||||
|
l := logger.With("topic", topic, "entity_id", entityId)
|
||||||
|
l.Debug("Subscribing to topic")
|
||||||
|
sub, ch, err := natsClient.Subscribe(topic)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Error subscribing to topic", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer sub.Unsubscribe()
|
||||||
|
|
||||||
|
for msg := range ch {
|
||||||
|
msg.Ack()
|
||||||
|
var data ha.EventData
|
||||||
|
err = json.Unmarshal(msg.Data, &data)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Error parsing message", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l.Debug("Event state " + data.NewState.State)
|
||||||
|
err = handler(data.NewState)
|
||||||
|
if err != nil {
|
||||||
|
l.Error("Error handling state event", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
@ -37,8 +38,10 @@ func Listen(parentLogger *slog.Logger) {
|
||||||
|
|
||||||
router.Use(middleware.RequestID)
|
router.Use(middleware.RequestID)
|
||||||
router.Use(middleware.RealIP)
|
router.Use(middleware.RealIP)
|
||||||
|
router.Use(loggerMiddleware)
|
||||||
router.Use(middleware.Recoverer)
|
router.Use(middleware.Recoverer)
|
||||||
router.Use(middleware.Timeout(60 * time.Second))
|
router.Use(middleware.Timeout(60 * time.Second))
|
||||||
|
router.Use(tokenAuthMiddleware)
|
||||||
|
|
||||||
router.Get(`/api/state/{entityId}`, getEntityStateHandler)
|
router.Get(`/api/state/{entityId}`, getEntityStateHandler)
|
||||||
router.Post("/api/state/{entityId}/{service}", setEntityStateHandler)
|
router.Post("/api/state/{entityId}/{service}", setEntityStateHandler)
|
||||||
|
@ -69,14 +72,36 @@ func Close() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func logRequest(w http.ResponseWriter, r *http.Request) {
|
func tokenAuthMiddleware(next http.Handler) http.Handler {
|
||||||
logger.Debug(fmt.Sprintf("%s %s", r.Method, r.URL.Path), "method", r.Method, "path", r.URL.Path, "address", r.RemoteAddr)
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cfg.HatsToken == "" { // No token required
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
authHeaderParts := strings.Split(r.Header.Get("Authorization"), "")
|
||||||
|
switch {
|
||||||
|
case len(authHeaderParts) != 2:
|
||||||
|
case authHeaderParts[0] != "Bearer":
|
||||||
|
case authHeaderParts[1] != cfg.HatsToken:
|
||||||
|
http.Error(w, "Bearer authorization header doesn't match configured token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loggerMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(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)
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// HOME ASSISTANT ENTITIES
|
// HOME ASSISTANT ENTITIES
|
||||||
|
|
||||||
func getEntityStateHandler(w http.ResponseWriter, r *http.Request) {
|
func getEntityStateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
logRequest(w, r)
|
|
||||||
entityId := chi.URLParam(r, "entityId")
|
entityId := chi.URLParam(r, "entityId")
|
||||||
full := r.URL.Query().Get("full") == "true"
|
full := r.URL.Query().Get("full") == "true"
|
||||||
|
|
||||||
|
@ -103,7 +128,6 @@ func getEntityStateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setEntityStateHandler(w http.ResponseWriter, r *http.Request) {
|
func setEntityStateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
logRequest(w, r)
|
|
||||||
entityId := chi.URLParam(r, "entityId")
|
entityId := chi.URLParam(r, "entityId")
|
||||||
service := chi.URLParam(r, "service")
|
service := chi.URLParam(r, "service")
|
||||||
domain := r.URL.Query().Get("domain")
|
domain := r.URL.Query().Get("domain")
|
||||||
|
@ -139,7 +163,6 @@ func setEntityStateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func getTimerHandler(w http.ResponseWriter, r *http.Request) {
|
func getTimerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
timerName := chi.URLParam(r, "timerName")
|
timerName := chi.URLParam(r, "timerName")
|
||||||
logRequest(w, r)
|
|
||||||
|
|
||||||
timer, err := nats.GetTimer(timerName)
|
timer, err := nats.GetTimer(timerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -157,7 +180,6 @@ type StartTimerData struct {
|
||||||
|
|
||||||
func startTimerHandler(w http.ResponseWriter, r *http.Request) {
|
func startTimerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
timerName := chi.URLParam(r, "timerName")
|
timerName := chi.URLParam(r, "timerName")
|
||||||
logRequest(w, r)
|
|
||||||
|
|
||||||
data := &StartTimerData{}
|
data := &StartTimerData{}
|
||||||
if err := render.DecodeJSON(r.Body, data); err != nil {
|
if err := render.DecodeJSON(r.Body, data); err != nil {
|
||||||
|
@ -185,7 +207,6 @@ func startTimerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteTimerHandler(w http.ResponseWriter, r *http.Request) {
|
func deleteTimerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
logRequest(w, r)
|
|
||||||
timerName := chi.URLParam(r, "timerName")
|
timerName := chi.URLParam(r, "timerName")
|
||||||
|
|
||||||
timer, err := nats.GetTimer(timerName)
|
timer, err := nats.GetTimer(timerName)
|
||||||
|
@ -202,7 +223,6 @@ func deleteTimerHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func getScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
func getScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
scheduleName := chi.URLParam(r, "scheduleName")
|
scheduleName := chi.URLParam(r, "scheduleName")
|
||||||
logRequest(w, r)
|
|
||||||
|
|
||||||
schedule, err := nats.GetSchedule(scheduleName)
|
schedule, err := nats.GetSchedule(scheduleName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -219,7 +239,6 @@ type CreateScheduleData struct {
|
||||||
|
|
||||||
func createScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
func createScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
scheduleName := chi.URLParam(r, "scheduleName")
|
scheduleName := chi.URLParam(r, "scheduleName")
|
||||||
logRequest(w, r)
|
|
||||||
|
|
||||||
data := &CreateScheduleData{}
|
data := &CreateScheduleData{}
|
||||||
if err := render.DecodeJSON(r.Body, data); err != nil {
|
if err := render.DecodeJSON(r.Body, data); err != nil {
|
||||||
|
@ -239,7 +258,6 @@ func createScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
func deleteScheduleHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
logRequest(w, r)
|
|
||||||
scheduleName := chi.URLParam(r, "scheduleName")
|
scheduleName := chi.URLParam(r, "scheduleName")
|
||||||
|
|
||||||
schedule, err := nats.GetSchedule(scheduleName)
|
schedule, err := nats.GetSchedule(scheduleName)
|
||||||
|
@ -273,7 +291,6 @@ func postNtfyHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
func postCommandHandler(w http.ResponseWriter, r *http.Request) {
|
func postCommandHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
commandName := chi.URLParam(r, "commandName")
|
commandName := chi.URLParam(r, "commandName")
|
||||||
logRequest(w, r)
|
|
||||||
|
|
||||||
switch commandName {
|
switch commandName {
|
||||||
// Commands without payloads
|
// Commands without payloads
|
||||||
|
|
|
@ -13,8 +13,11 @@ type HatsClient struct {
|
||||||
client *resty.Client
|
client *resty.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHatsClient(baseUrl string) *HatsClient {
|
func NewHatsClient(baseUrl string, token string) *HatsClient {
|
||||||
client := resty.New().SetBaseURL(baseUrl)
|
client := resty.New().SetBaseURL(baseUrl)
|
||||||
|
if token != "" {
|
||||||
|
client.SetHeader("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||||
|
}
|
||||||
return &HatsClient{
|
return &HatsClient{
|
||||||
client: client,
|
client: client,
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ type HatsConfig struct {
|
||||||
|
|
||||||
HatsHost string
|
HatsHost string
|
||||||
HatsPort string
|
HatsPort string
|
||||||
|
HatsToken string
|
||||||
HatsSecure bool
|
HatsSecure bool
|
||||||
|
|
||||||
NtfyHost string
|
NtfyHost string
|
||||||
|
@ -32,18 +33,23 @@ type HatsConfig struct {
|
||||||
|
|
||||||
func FromEnvironment() *HatsConfig {
|
func FromEnvironment() *HatsConfig {
|
||||||
config := &HatsConfig{
|
config := &HatsConfig{
|
||||||
LogLevl: util.GetEnvWithDefault("LOG_LEVEL", "INFO"),
|
LogLevl: util.GetEnvWithDefault("LOG_LEVEL", "INFO"),
|
||||||
|
|
||||||
HomeAssistantHost: util.GetEnvWithDefault("HASS_HOST", "127.0.0.1"),
|
HomeAssistantHost: util.GetEnvWithDefault("HASS_HOST", "127.0.0.1"),
|
||||||
HomeAssistantPort: util.GetEnvWithDefault("HASS_PORT", "8123"),
|
HomeAssistantPort: util.GetEnvWithDefault("HASS_PORT", "8123"),
|
||||||
HomeAssistantToken: util.GetEnvWithDefault("HASS_TOKEN", ""),
|
HomeAssistantToken: util.GetEnvWithDefault("HASS_TOKEN", ""),
|
||||||
NatsHost: util.GetEnvWithDefault("NATS_HOST", "127.0.0.1"),
|
|
||||||
NatsPort: util.GetEnvWithDefault("NATS_PORT", "4222"),
|
NatsHost: util.GetEnvWithDefault("NATS_HOST", "127.0.0.1"),
|
||||||
NatsToken: util.GetEnvWithDefault("NATS_TOKEN", ""),
|
NatsPort: util.GetEnvWithDefault("NATS_PORT", "4222"),
|
||||||
NatsClientName: util.GetEnvWithDefault("NATS_CLIENT_NAME", "hats"),
|
NatsToken: util.GetEnvWithDefault("NATS_TOKEN", ""),
|
||||||
HatsHost: util.GetEnvWithDefault("HATS_HOST", "hats"),
|
NatsClientName: util.GetEnvWithDefault("NATS_CLIENT_NAME", "hats"),
|
||||||
HatsPort: util.GetEnvWithDefault("HATS_PORT", "8888"),
|
|
||||||
NtfyHost: util.GetEnvWithDefault("NTFY_HOST", "https://ntfy.sh"),
|
HatsHost: util.GetEnvWithDefault("HATS_HOST", "hats"),
|
||||||
NtfyToken: util.GetEnvWithDefault("NTFY_TOKEN", ""),
|
HatsPort: util.GetEnvWithDefault("HATS_PORT", "8888"),
|
||||||
|
HatsToken: util.GetEnvWithDefault("HATS_TOKEN", ""),
|
||||||
|
|
||||||
|
NtfyHost: util.GetEnvWithDefault("NTFY_HOST", "https://ntfy.sh"),
|
||||||
|
NtfyToken: util.GetEnvWithDefault("NTFY_TOKEN", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
config.HomeAssistantSecure, _ = strconv.ParseBool(util.GetEnvWithDefault("HASS_SECURE", "false"))
|
config.HomeAssistantSecure, _ = strconv.ParseBool(util.GetEnvWithDefault("HASS_SECURE", "false"))
|
||||||
|
|
|
@ -11,8 +11,10 @@ import (
|
||||||
//
|
//
|
||||||
// States that return true: "on", "home", "open", "playing", non-zero numbers, etc.
|
// States that return true: "on", "home", "open", "playing", non-zero numbers, etc.
|
||||||
// All others return false
|
// All others return false
|
||||||
|
//
|
||||||
|
// regex: ^(on|home|open(ing)?|unlocked|playing|active|good|walking|charging|alive|heat|cool|heat_cool|above_horizon|[1-9][\d\.]*|0\.0*[1-9]\d*)$
|
||||||
func StateToBool(state string) bool {
|
func StateToBool(state string) bool {
|
||||||
trueRegex := regexp.MustCompile(`^(on|home|open(ing)?|unlocked|playing|active|good|walking|charging|alive|heat|cool|heat_cool|[1-9][\d\.]*|0\.0*[1-9]\d*)$`)
|
trueRegex := regexp.MustCompile(`^(on|home|open(ing)?|unlocked|playing|active|good|walking|charging|alive|heat|cool|heat_cool|above_horizon|[1-9][\d\.]*|0\.0*[1-9]\d*)$`)
|
||||||
return trueRegex.MatchString(state)
|
return trueRegex.MatchString(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue