Merge pull request #34 from saml-dev/gen

Generate a constants file for entities
This commit is contained in:
saml-dev
2025-06-02 20:04:21 -04:00
committed by GitHub
6 changed files with 400 additions and 33 deletions

View File

@@ -14,6 +14,56 @@ Gome-Assistant is a new library, and I'm opening it up early to get some user fe
go get saml.dev/gome-assistant go get saml.dev/gome-assistant
``` ```
### 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.
1. Create a `gen.yaml` file in your project root:
```yaml
url: "http://192.168.1.123:8123"
ha_auth_token: "your_auth_token" # Or set HA_AUTH_TOKEN env var
home_zone_entity_id: "zone.home" # Optional: defaults to zone.home
# Optional: List of domains to include when generating constants
# If provided, only these domains will be processed
include_domains: ["light", "switch", "climate"]
# Optional: List of domains to exclude when generating constants
# Only used if include_domains is empty
exclude_domains: ["device_tracker", "person"]
```
2. Add a `//go:generate` comment in your project:
```go
//go:generate go run saml.dev/gome-assistant/cmd/generate
```
Optionally use the `-config` flag to customize the file path of the config file.
3. Run the generator:
```
go generate
```
This will create an `entities` package with type-safe constants for all your Home Assistant entities, organized by domain. For example:
```go
import "your_project/entities"
// Instead of writing "light.living_room" as a string:
entities.Light.LivingRoom // Type-safe constant
// All your entities are organized by domain
entities.Switch.Kitchen
entities.Climate.Bedroom
entities.MediaPlayer.TVRoom
```
The constants are based on the entity ID itself, not the name of the entity in Home Assistant.
### Write your automations ### Write your automations
Check out [`example/example.go`](./example/example.go) for an example of the 3 types of automations — schedules, entity listeners, and event listeners. Check out [`example/example.go`](./example/example.go) for an example of the 3 types of automations — schedules, entity listeners, and event listeners.

41
app.go
View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/url" "net/url"
"strings"
"time" "time"
"github.com/golang-module/carbon" "github.com/golang-module/carbon"
@@ -85,16 +86,47 @@ type NewAppRequest struct {
Secure bool Secure bool
} }
// 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 {
return fmt.Errorf("home zone entity '%s' not found: %w", entityID, err)
}
// Ensure it's a zone entity
if !strings.HasPrefix(entityID, "zone.") {
return fmt.Errorf("entity '%s' is not a zone entity (must start with zone.)", entityID)
}
// 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 {
return fmt.Errorf("home zone entity '%s' missing latitude attribute", entityID)
}
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 NewApp establishes the websocket connection and returns an object
you can use to register schedules and listeners. you can use to register schedules and listeners.
*/ */
func NewApp(request NewAppRequest) (*App, error) { func NewApp(request NewAppRequest) (*App, error) {
if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" || request.HomeZoneEntityId == "" { if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" {
slog.Error("URL, HAAuthToken, and HomeZoneEntityId are all required arguments in NewAppRequest") slog.Error("URL and HAAuthToken are required arguments in NewAppRequest")
return nil, ErrInvalidArgs return nil, ErrInvalidArgs
} }
// Set default home zone if not provided
if request.HomeZoneEntityId == "" {
request.HomeZoneEntityId = "zone.home"
}
baseURL := &url.URL{} baseURL := &url.URL{}
if request.URL != "" { if request.URL != "" {
@@ -133,6 +165,11 @@ func NewApp(request NewAppRequest) (*App, error) {
return nil, err return nil, err
} }
// Validate home zone
if err := validateHomeZone(state, request.HomeZoneEntityId); err != nil {
return nil, err
}
return &App{ return &App{
conn: conn, conn: conn,
wsWriter: wsWriter, wsWriter: wsWriter,

258
cmd/generate/main.go Normal file
View File

@@ -0,0 +1,258 @@
// Package main provides the generate command for generating Home Assistant entity constants
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"gopkg.in/yaml.v3"
ga "saml.dev/gome-assistant"
)
type Config struct {
URL string `yaml:"url"`
HAAuthToken string `yaml:"ha_auth_token"`
HomeZoneEntityId string `yaml:"home_zone_entity_id,omitempty"` // Now optional
IncludeDomains []string `yaml:"include_domains,omitempty"` // Optional list of domains to include
ExcludeDomains []string `yaml:"exclude_domains,omitempty"` // Optional list of domains to exclude
}
type Domain struct {
Name string
Entities []Entity
}
type Entity struct {
FieldName string
EntityID string
}
func toFieldName(entityID string) string {
parts := strings.Split(entityID, ".")
if len(parts) != 2 {
return ""
}
return toCamelCase(parts[1])
}
func toCamelCase(s string) string {
if s == "" {
return ""
}
parts := strings.Split(s, "_")
var result strings.Builder
// If first character is numeric
firstChar := parts[0][0]
if firstChar >= '0' && firstChar <= '9' {
result.WriteString("_")
}
for _, part := range parts {
if part == "" {
continue
}
result.WriteString(strings.ToUpper(string(part[0])))
if len(part) > 1 {
result.WriteString(part[1:])
}
}
return result.String()
}
// validateHomeZone verifies that the home zone entity exists and is valid
func validateHomeZone(state ga.State, entityID string) error {
entity, err := state.Get(entityID)
if err != nil {
return fmt.Errorf("home zone entity '%s' not found: %w", entityID, err)
}
// Ensure it's a zone entity
if !strings.HasPrefix(entityID, "zone.") {
return fmt.Errorf("entity '%s' is not a zone entity (must start with zone.)", entityID)
}
// 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 {
return fmt.Errorf("home zone entity '%s' missing latitude attribute", entityID)
}
if entity.Attributes["longitude"] == nil {
return fmt.Errorf("home zone entity '%s' missing longitude attribute", entityID)
}
return nil
}
// 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{
URL: config.URL,
HAAuthToken: config.HAAuthToken,
HomeZoneEntityId: config.HomeZoneEntityId,
})
if err != nil {
return fmt.Errorf("failed to create app: %w", err)
}
defer app.Cleanup()
// Validate that the home zone exists before proceeding
if err := validateHomeZone(app.GetState(), config.HomeZoneEntityId); err != nil {
return fmt.Errorf("invalid home zone: %w", err)
}
entities, err := app.GetState().ListEntities()
if err != nil {
return fmt.Errorf("failed to list entities: %w", err)
}
// Group entities by domain
domainMap := make(map[string]*Domain)
for _, entity := range entities {
if entity.State == "unavailable" {
continue
}
parts := strings.Split(entity.EntityID, ".")
if len(parts) != 2 {
continue
}
domain := parts[0]
// Filter domains based on include/exclude lists
if len(config.IncludeDomains) > 0 {
// If include list is specified, only process listed domains
found := false
for _, includeDomain := range config.IncludeDomains {
if domain == includeDomain {
found = true
break
}
}
if !found {
continue
}
} else {
// If only exclude list is specified, skip excluded domains
excluded := false
for _, excludeDomain := range config.ExcludeDomains {
if domain == excludeDomain {
println("skipping excluded domain:", domain)
excluded = true
break
}
}
if excluded {
continue
}
}
if _, exists := domainMap[domain]; !exists {
domainMap[domain] = &Domain{
Name: toCamelCase(domain),
}
}
domainMap[domain].Entities = append(domainMap[domain].Entities, Entity{
FieldName: toFieldName(entity.EntityID),
EntityID: entity.EntityID,
})
}
// Map to slice for template
domains := make([]Domain, 0)
for _, domain := range domainMap {
domains = append(domains, *domain)
}
// Create entities directory if it doesn't exist
err = os.MkdirAll("entities", 0755)
if err != nil {
return fmt.Errorf("failed to create entities directory: %w", err)
}
// Create the file
tmpl := template.Must(template.New("entities").Parse(`// Code generated by go generate; DO NOT EDIT.
package entities
{{ range .Domains }}
type {{ .Name }}Domain struct {
{{- range .Entities }}
{{ .FieldName }} string
{{- end }}
}
var {{ .Name }} = {{ .Name }}Domain{
{{- range .Entities }}
{{ .FieldName }}: "{{ .EntityID }}",
{{- end }}
}
{{ end }}
`))
f, err := os.Create(filepath.Join("entities", "entities.go"))
if err != nil {
return fmt.Errorf("failed to create entities.go: %w", err)
}
defer f.Close()
err = tmpl.Execute(f, struct{ Domains []Domain }{domains})
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
return nil
}
func main() {
println("Generating entities.go...")
configFile := flag.String("config", "gen.yaml", "Path to config file")
flag.Parse()
absConfigPath, err := filepath.Abs(*configFile)
if err != nil {
fmt.Printf("Error resolving config path: %v\n", err)
os.Exit(1)
}
configBytes, err := os.ReadFile(absConfigPath)
if err != nil {
fmt.Printf("Error reading config file: %v\n", err)
os.Exit(1)
}
var config Config
if err := yaml.Unmarshal(configBytes, &config); err != nil {
fmt.Printf("Error parsing config file: %v\n", err)
os.Exit(1)
}
if config.HAAuthToken == "" {
config.HAAuthToken = os.Getenv("HA_AUTH_TOKEN")
}
if config.URL == "" || config.HAAuthToken == "" {
fmt.Println("Error: url and ha_auth_token are required in config")
os.Exit(1)
}
if err := generate(config); err != nil {
fmt.Printf("Error generating entities: %v\n", err)
os.Exit(1)
}
fmt.Println("Generated entities/entities.go")
}

View File

@@ -6,12 +6,16 @@ import (
"os" "os"
"time" "time"
// "example/entities" // Optional import generated entities
ga "saml.dev/gome-assistant" ga "saml.dev/gome-assistant"
) )
//go:generate go run saml.dev/gome-assistant/cmd/generate
func main() { func main() {
app, err := ga.NewApp(ga.NewAppRequest{ app, err := ga.NewApp(ga.NewAppRequest{
URL: "http://192.168.86.67:8123", // Replace with your Home Assistant IP Address URL: "http://192.168.86.67:8123", // Replace with your Home Assistant URL
HAAuthToken: os.Getenv("HA_AUTH_TOKEN"), HAAuthToken: os.Getenv("HA_AUTH_TOKEN"),
HomeZoneEntityId: "zone.home", HomeZoneEntityId: "zone.home",
}) })
@@ -24,7 +28,7 @@ func main() {
pantryDoor := ga. pantryDoor := ga.
NewEntityListener(). NewEntityListener().
EntityIds("binary_sensor.pantry_door"). EntityIds(entities.BinarySensor.PantryDoor). // Use generated entity constant
Call(pantryLights). Call(pantryLights).
Build() Build()
@@ -55,6 +59,7 @@ func main() {
func pantryLights(service *ga.Service, state ga.State, sensor ga.EntityData) { func pantryLights(service *ga.Service, state ga.State, sensor ga.EntityData) {
l := "light.pantry" l := "light.pantry"
// l := entities.Light.Pantry // Or use generated entity constant
if sensor.ToState == "on" { if sensor.ToState == "on" {
service.HomeAssistant.TurnOn(l) service.HomeAssistant.TurnOn(l)
} else { } else {
@@ -75,8 +80,8 @@ func onEvent(service *ga.Service, state ga.State, data ga.EventData) {
func lightsOut(service *ga.Service, state ga.State) { func lightsOut(service *ga.Service, state ga.State) {
// always turn off outside lights // always turn off outside lights
service.Light.TurnOff("light.outside_lights") service.Light.TurnOff(entities.Light.OutsideLights)
s, err := state.Get("binary_sensor.living_room_motion") s, err := state.Get(entities.BinarySensor.LivingRoomMotion)
if err != nil { if err != nil {
slog.Warn("couldnt get living room motion state, doing nothing") slog.Warn("couldnt get living room motion state, doing nothing")
return return
@@ -84,11 +89,11 @@ func lightsOut(service *ga.Service, state ga.State) {
// if no motion detected in living room for 30mins // if no motion detected in living room for 30mins
if s.State == "off" && time.Since(s.LastChanged).Minutes() > 30 { if s.State == "off" && time.Since(s.LastChanged).Minutes() > 30 {
service.Light.TurnOff("light.main_lights") service.Light.TurnOff(entities.Light.MainLights)
} }
} }
func sunriseSched(service *ga.Service, state ga.State) { func sunriseSched(service *ga.Service, state ga.State) {
service.Light.TurnOn("light.living_room_lamps") service.Light.TurnOn(entities.Light.LivingRoomLamps)
service.Light.TurnOff("light.christmas_lights") service.Light.TurnOff(entities.Light.ChristmasLights)
} }

13
example/gen.yaml Normal file
View File

@@ -0,0 +1,13 @@
url: "http://192.168.4.67:8123" # Replace with your Home Assistant URL
ha_auth_token: "<token>" # Your auth token or set HA_AUTH_TOKEN env var
home_zone_entity_id: "zone.home" # Optional: defaults to zone.home
# Optional: List of domains to include when generating constants
# If provided, only these domains will be processed
# Example: ["light", "switch", "climate"]
include_domains: []
# Optional: List of domains to exclude when generating constants
# Only used if include_domains is empty
# Example: ["device_tracker", "person"]
exclude_domains: []

View File

@@ -2,8 +2,8 @@ package gomeassistant
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings"
"time" "time"
"github.com/golang-module/carbon" "github.com/golang-module/carbon"
@@ -37,34 +37,38 @@ type EntityState struct {
func newState(c *http.HttpClient, homeZoneEntityId string) (*StateImpl, error) { func newState(c *http.HttpClient, homeZoneEntityId string) (*StateImpl, error) {
state := &StateImpl{httpClient: c} state := &StateImpl{httpClient: c}
err := state.getLatLong(c, homeZoneEntityId)
// Ensure the zone exists and has required attributes
entity, err := state.Get(homeZoneEntityId)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("home zone entity '%s' not found: %w", homeZoneEntityId, err)
} }
// Ensure it's a zone entity
if !strings.HasPrefix(homeZoneEntityId, "zone.") {
return nil, fmt.Errorf("entity '%s' is not a zone entity (must start with zone.)", homeZoneEntityId)
}
// Verify and extract latitude and longitude
if entity.Attributes == nil {
return nil, fmt.Errorf("home zone entity '%s' has no attributes", homeZoneEntityId)
}
if lat, ok := entity.Attributes["latitude"].(float64); ok {
state.latitude = lat
} else {
return nil, fmt.Errorf("home zone entity '%s' missing valid latitude attribute", homeZoneEntityId)
}
if long, ok := entity.Attributes["longitude"].(float64); ok {
state.longitude = long
} else {
return nil, fmt.Errorf("home zone entity '%s' missing valid longitude attribute", homeZoneEntityId)
}
return state, nil return state, nil
} }
func (s *StateImpl) getLatLong(c *http.HttpClient, homeZoneEntityId string) error {
resp, err := s.Get(homeZoneEntityId)
if err != nil {
return fmt.Errorf("couldn't get latitude/longitude from home assistant entity '%s'. Did you type it correctly? It should be a zone like 'zone.home'", homeZoneEntityId)
}
if resp.Attributes["latitude"] != nil {
s.latitude = resp.Attributes["latitude"].(float64)
} else {
return errors.New("server returned nil latitude")
}
if resp.Attributes["longitude"] != nil {
s.longitude = resp.Attributes["longitude"].(float64)
} else {
return errors.New("server returned nil longitude")
}
return nil
}
func (s *StateImpl) Get(entityId string) (EntityState, error) { func (s *StateImpl) Get(entityId string) (EntityState, error) {
resp, err := s.httpClient.GetState(entityId) resp, err := s.httpClient.GetState(entityId)
if err != nil { if err != nil {