From 79811571caa1bdc6a54ffbbd26a1c701ed9afb6f Mon Sep 17 00:00:00 2001 From: Sam Lewis Date: Sun, 1 Jun 2025 17:27:36 -0400 Subject: [PATCH 1/4] codegen works --- README.md | 42 ++++++++++ cmd/generate/main.go | 192 +++++++++++++++++++++++++++++++++++++++++++ example/example.go | 19 +++-- example/gen.yaml | 3 + 4 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 cmd/generate/main.go create mode 100644 example/gen.yaml diff --git a/README.md b/README.md index 2cea3e3..6265a3b 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,48 @@ Gome-Assistant is a new library, and I'm opening it up early to get some user fe 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" +``` + +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 Check out [`example/example.go`](./example/example.go) for an example of the 3 types of automations — schedules, entity listeners, and event listeners. diff --git a/cmd/generate/main.go b/cmd/generate/main.go new file mode 100644 index 0000000..6573adc --- /dev/null +++ b/cmd/generate/main.go @@ -0,0 +1,192 @@ +// 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"` +} + +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() +} + +// generate creates the entities.go file with constants for all Home Assistant entities +func generate(config Config) error { + 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() + + 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] + 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 == "" || config.HomeZoneEntityId == "" { + fmt.Println("Error: url, ha_auth_token and home_zone_entity_id 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") +} diff --git a/example/example.go b/example/example.go index f4e2f76..7d68e18 100644 --- a/example/example.go +++ b/example/example.go @@ -6,12 +6,16 @@ import ( "os" "time" + "example/entities" // Import generated entities + ga "saml.dev/gome-assistant" ) +//go:generate go run saml.dev/gome-assistant/cmd/generate + func main() { 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"), HomeZoneEntityId: "zone.home", }) @@ -24,7 +28,7 @@ func main() { pantryDoor := ga. NewEntityListener(). - EntityIds("binary_sensor.pantry_door"). + EntityIds(entities.BinarySensor.PantryDoor). // Use generated entity constant Call(pantryLights). Build() @@ -55,6 +59,7 @@ func main() { func pantryLights(service *ga.Service, state ga.State, sensor ga.EntityData) { l := "light.pantry" + // l := entities.Light.Pantry // Or use generated entity constant if sensor.ToState == "on" { service.HomeAssistant.TurnOn(l) } else { @@ -75,8 +80,8 @@ func onEvent(service *ga.Service, state ga.State, data ga.EventData) { func lightsOut(service *ga.Service, state ga.State) { // always turn off outside lights - service.Light.TurnOff("light.outside_lights") - s, err := state.Get("binary_sensor.living_room_motion") + service.Light.TurnOff(entities.Light.OutsideLights) + s, err := state.Get(entities.BinarySensor.LivingRoomMotion) if err != nil { slog.Warn("couldnt get living room motion state, doing nothing") return @@ -84,11 +89,11 @@ func lightsOut(service *ga.Service, state ga.State) { // if no motion detected in living room for 30mins 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) { - service.Light.TurnOn("light.living_room_lamps") - service.Light.TurnOff("light.christmas_lights") + service.Light.TurnOn(entities.Light.LivingRoomLamps) + service.Light.TurnOff(entities.Light.ChristmasLights) } diff --git a/example/gen.yaml b/example/gen.yaml new file mode 100644 index 0000000..729c0e9 --- /dev/null +++ b/example/gen.yaml @@ -0,0 +1,3 @@ +url: "http://192.168.4.67:8123" # Replace with your Home Assistant URL +ha_auth_token: "" # Your auth token +home_zone_entity_id: "zone.home" From 25076130d801ed43210fdf2f0706623fb1c8e46b Mon Sep 17 00:00:00 2001 From: Sam Lewis Date: Sun, 1 Jun 2025 17:50:03 -0400 Subject: [PATCH 2/4] home zone changed to optional --- app.go | 41 ++++++++++++++++++++++++++++++++-- cmd/generate/main.go | 41 +++++++++++++++++++++++++++++++--- example/example.go | 2 +- example/gen.yaml | 4 ++-- state.go | 52 ++++++++++++++++++++++++-------------------- 5 files changed, 108 insertions(+), 32 deletions(-) diff --git a/app.go b/app.go index bd20a4f..7ac2132 100644 --- a/app.go +++ b/app.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "net/url" + "strings" "time" "github.com/golang-module/carbon" @@ -85,16 +86,47 @@ type NewAppRequest struct { 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 you can use to register schedules and listeners. */ func NewApp(request NewAppRequest) (*App, error) { - if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" || request.HomeZoneEntityId == "" { - slog.Error("URL, HAAuthToken, and HomeZoneEntityId are all required arguments in NewAppRequest") + if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" { + slog.Error("URL and HAAuthToken are required arguments in NewAppRequest") return nil, ErrInvalidArgs } + // Set default home zone if not provided + if request.HomeZoneEntityId == "" { + request.HomeZoneEntityId = "zone.home" + } + baseURL := &url.URL{} if request.URL != "" { @@ -133,6 +165,11 @@ func NewApp(request NewAppRequest) (*App, error) { return nil, err } + // Validate home zone + if err := validateHomeZone(state, request.HomeZoneEntityId); err != nil { + return nil, err + } + return &App{ conn: conn, wsWriter: wsWriter, diff --git a/cmd/generate/main.go b/cmd/generate/main.go index 6573adc..1a75723 100644 --- a/cmd/generate/main.go +++ b/cmd/generate/main.go @@ -16,7 +16,7 @@ import ( type Config struct { URL string `yaml:"url"` HAAuthToken string `yaml:"ha_auth_token"` - HomeZoneEntityId string `yaml:"home_zone_entity_id"` + HomeZoneEntityId string `yaml:"home_zone_entity_id,omitempty"` // Now optional } type Domain struct { @@ -64,8 +64,38 @@ func toCamelCase(s string) string { 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, @@ -76,6 +106,11 @@ func generate(config Config) error { } 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) @@ -178,8 +213,8 @@ func main() { config.HAAuthToken = os.Getenv("HA_AUTH_TOKEN") } - if config.URL == "" || config.HAAuthToken == "" || config.HomeZoneEntityId == "" { - fmt.Println("Error: url, ha_auth_token and home_zone_entity_id are required in config") + if config.URL == "" || config.HAAuthToken == "" { + fmt.Println("Error: url and ha_auth_token are required in config") os.Exit(1) } diff --git a/example/example.go b/example/example.go index 7d68e18..f05b79d 100644 --- a/example/example.go +++ b/example/example.go @@ -6,7 +6,7 @@ import ( "os" "time" - "example/entities" // Import generated entities + // "example/entities" // Optional import generated entities ga "saml.dev/gome-assistant" ) diff --git a/example/gen.yaml b/example/gen.yaml index 729c0e9..a15dad2 100644 --- a/example/gen.yaml +++ b/example/gen.yaml @@ -1,3 +1,3 @@ url: "http://192.168.4.67:8123" # Replace with your Home Assistant URL -ha_auth_token: "" # Your auth token -home_zone_entity_id: "zone.home" +ha_auth_token: "" # Your auth token or set HA_AUTH_TOKEN env var +home_zone_entity_id: "zone.home" # Optional: defaults to zone.home diff --git a/state.go b/state.go index 3d7430c..5280607 100644 --- a/state.go +++ b/state.go @@ -2,8 +2,8 @@ package gomeassistant import ( "encoding/json" - "errors" "fmt" + "strings" "time" "github.com/golang-module/carbon" @@ -37,34 +37,38 @@ type EntityState struct { func newState(c *http.HttpClient, homeZoneEntityId string) (*StateImpl, error) { 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 { - 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 } -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) { resp, err := s.httpClient.GetState(entityId) if err != nil { From 75f0b6b848de1d7d41ea643a62b143f9fab5bbcb Mon Sep 17 00:00:00 2001 From: Sam Lewis Date: Sun, 1 Jun 2025 20:21:06 -0400 Subject: [PATCH 3/4] add include and exclude options for generation --- cmd/generate/main.go | 37 ++++++++++++++++++++++++++++++++++--- example/gen.yaml | 10 ++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/cmd/generate/main.go b/cmd/generate/main.go index 1a75723..3cb27cb 100644 --- a/cmd/generate/main.go +++ b/cmd/generate/main.go @@ -14,9 +14,11 @@ import ( ) type Config struct { - URL string `yaml:"url"` - HAAuthToken string `yaml:"ha_auth_token"` - HomeZoneEntityId string `yaml:"home_zone_entity_id,omitempty"` // Now optional + 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 { @@ -129,6 +131,35 @@ func generate(config Config) error { } 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), diff --git a/example/gen.yaml b/example/gen.yaml index a15dad2..8c02d9a 100644 --- a/example/gen.yaml +++ b/example/gen.yaml @@ -1,3 +1,13 @@ url: "http://192.168.4.67:8123" # Replace with your Home Assistant URL 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 +# 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: [] From 7cf78177f3309f7286bc5c11de06e0076be0d652 Mon Sep 17 00:00:00 2001 From: Sam Lewis Date: Mon, 2 Jun 2025 20:02:27 -0400 Subject: [PATCH 4/4] update README --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6265a3b..ab67e02 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,15 @@ You can generate type-safe constants for all your Home Assistant entities using ```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" +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: