Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
dansimau committed Dec 4, 2024
0 parents commit 3ae2e71
Show file tree
Hide file tree
Showing 21 changed files with 1,272 additions and 0 deletions.
36 changes: 36 additions & 0 deletions .golangci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
linters:
enable-all: true
disable:
- bodyclose
- depguard
- exhaustruct
- exportloopref
- nonamedreturns
- wrapcheck
# Temporarily disabling while I figure out why severity settings are not working
- cyclop
- funlen
- gocognit
- godox

linters-settings:
exhaustive:
default-signifies-exhaustive: true
tagliatelle:
case:
rules:
json: snake

severity:
default-severity: info
rules:
- linters:
- funlen
- gocognit
- godox
- nestif
severity: info

issues:
exclude:
- "block should not end with a whitespace"
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.PHONY: lint
lint:
which golangci-lint || curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(shell go env GOPATH)/bin v1.62.2
golangci-lint run

.PHONY: test
test:
go test -v ./...
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Home Automation Logic (HAL) Framework

HAL is a framework for programming home automation logic in Golang using Home
Assistant.

The hypothesis is that programming (and debugging) automation logic is easier
to do in a programming language rather than YAML or tapping around in a UI.
7 changes: 7 additions & 0 deletions automations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hal

type Automation interface {
Name() string
Entities() Entities
Action()
}
111 changes: 111 additions & 0 deletions automations/sensor_lights.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package halautomations

import (
"log/slog"
"time"

"github.com/dansimau/hal"
)

// SensorsTriggerLights is an automation that combines one or more sensors (
// motion or presence sensors) and a set of lights. Lights are turned on when
// any of the sensors are triggered and turned off after a given duration.
type SensorsTriggerLights struct {
name string

sensors []hal.EntityLike
lights []*hal.Light
turnsOffAfter *time.Duration

turnOffTimer *time.Timer
}

func NewSensorsTriggersLights() *SensorsTriggerLights {
return &SensorsTriggerLights{}
}

func (a *SensorsTriggerLights) WithName(name string) *SensorsTriggerLights {
a.name = name

return a
}

func (a *SensorsTriggerLights) WithSensors(sensors ...hal.EntityLike) *SensorsTriggerLights {
a.sensors = sensors

return a
}

func (a *SensorsTriggerLights) WithLights(lights ...*hal.Light) *SensorsTriggerLights {
a.lights = lights

return a
}

func (a *SensorsTriggerLights) TurnsOffAfter(turnsOffAfter time.Duration) *SensorsTriggerLights {
a.turnsOffAfter = &turnsOffAfter

return a
}

// triggered returns true if any of the sensors have been triggered.
func (a *SensorsTriggerLights) triggered() bool {
for _, sensor := range a.sensors {
if sensor.GetState().State == "on" {
return true
}
}

return false
}

func (a *SensorsTriggerLights) startTurnOffTimer() {
if a.turnsOffAfter == nil {
return
}

if a.turnOffTimer == nil {
a.turnOffTimer = time.AfterFunc(*a.turnsOffAfter, a.turnOffLights)
} else {
a.turnOffTimer.Reset(*a.turnsOffAfter)
}
}

func (a *SensorsTriggerLights) stopTurnOffTimer() {
if a.turnOffTimer != nil {
a.turnOffTimer.Stop()
}
}

func (a *SensorsTriggerLights) turnOnLights() {
for _, light := range a.lights {
if err := light.TurnOn(); err != nil {
slog.Error("Error turning on light", "error", err)
}
}
}

func (a *SensorsTriggerLights) turnOffLights() {
for _, light := range a.lights {
if err := light.TurnOff(); err != nil {
slog.Error("Error turning off light", "error", err)
}
}
}

func (a *SensorsTriggerLights) Action() {
if a.triggered() {
a.stopTurnOffTimer()
a.turnOnLights()
} else {
a.startTurnOffTimer()
}
}

func (a *SensorsTriggerLights) Entities() hal.Entities {
return hal.Entities(a.sensors)
}

func (a *SensorsTriggerLights) Name() string {
return a.name
}
108 changes: 108 additions & 0 deletions connection.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package hal

import (
"log/slog"
"sync"
"time"

"github.com/dansimau/hal/hassws"
"github.com/dansimau/hal/perf"
"github.com/davecgh/go-spew/spew"
)

// Connection is a new instance of the HAL framework. It connects to Home Assistant,
// listens for state updates and invokes automations when state changes are detected.
// TODO: Rename "Connection" to something more descriptive.
type Connection struct {
homeAssistant *hassws.Client

automations map[string][]Automation
entities map[string]EntityLike

// Lock to serialize state updates and ensure automations fire in order.
mutex sync.RWMutex
}

// ConnectionBinder is an interface that can be implemented by entities to bind
// them to a connection.
type ConnectionBinder interface {
BindConnection(connection *Connection)
}

func NewConnection(api *hassws.Client) *Connection {
return &Connection{
homeAssistant: api,

automations: make(map[string][]Automation),
entities: make(map[string]EntityLike),
}
}

// HomeAssistant returns the underlying Home Assistant websocket client.
func (h *Connection) HomeAssistant() *hassws.Client {
return h.homeAssistant
}

// FindEntities recursively finds and registers all entities in a struct, map, or slice.
func (h *Connection) FindEntities(v any) {
h.RegisterEntities(findEntities(v)...)
}

// RegisterAutomations registers automations and binds them to the relevant entities.
func (h *Connection) RegisterAutomations(automations ...Automation) {
for _, automation := range automations {
for _, entity := range automation.Entities() {
h.automations[entity.GetID()] = append(h.automations[entity.GetID()], automation)
}
}
}

// RegisterEntities registers entities and binds them to the connection.
func (h *Connection) RegisterEntities(entities ...EntityLike) {
for _, entity := range entities {
slog.Info("Registering entity", "EntityID", entity.GetID())
entity.BindConnection(h)
h.entities[entity.GetID()] = entity
}
}

// Start connects to the Home Assistant websocket and starts listening for events.
func (h *Connection) Start() error {
if err := h.HomeAssistant().Connect(); err != nil {
return err
}

return h.HomeAssistant().SubscribeEvents(string(hassws.MessageTypeStateChanged), h.StateChangeEvent)
}

// Process incoming state change events. Dispatch state change to the relevant
// entity and fire any automations listening for state changes to this entity.
func (h *Connection) StateChangeEvent(event hassws.EventMessage) {
defer perf.Timer(func(timeTaken time.Duration) {
slog.Debug("Tick processing time", "duration", timeTaken)
})()

h.mutex.Lock()
defer h.mutex.Unlock()

entity, ok := h.entities[event.Event.EventData.EntityID]
if !ok {
slog.Debug("Entity not registered", "EntityID", event.Event.EventData.EntityID)

return
}

slog.Debug("State changed for",
"EntityID", event.Event.EventData.EntityID,
"NewState", spew.Sdump(event.Event.EventData.NewState),
)

if event.Event.EventData.NewState != nil {
entity.SetState(*event.Event.EventData.NewState)
}

// Dispatch automations
for _, automation := range h.automations[event.Event.EventData.EntityID] {
automation.Action()
}
}
33 changes: 33 additions & 0 deletions connection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package hal_test

import (
"testing"

"github.com/dansimau/hal"
"github.com/dansimau/hal/homeassistant"
"github.com/dansimau/hal/testutil"
)

func TestConnection(t *testing.T) {
t.Parallel()

conn, server, cleanup := testutil.NewClientServer(t)
defer cleanup()

// Create test entity and register it
entity := hal.NewEntity("test.entity")
conn.RegisterEntities(entity)

// Send state change event
server.SendStateChangeEvent(homeassistant.Event{
EventData: homeassistant.EventData{
EntityID: "test.entity",
NewState: &homeassistant.State{State: "on"},
},
})

// Verify entity state was updated
testutil.WaitFor(t, func() bool {
return entity.State.State == "on"
})
}
Loading

0 comments on commit 3ae2e71

Please sign in to comment.