Skip to content

Commit b72b494

Browse files
authored
Merge pull request #34 from saml-dev/gen
Generate a constants file for entities
2 parents 1700dbd + 7cf7817 commit b72b494

File tree

6 files changed

+392
-25
lines changed

6 files changed

+392
-25
lines changed

README.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,56 @@ Gome-Assistant is a new library, and I'm opening it up early to get some user fe
1414
go get saml.dev/gome-assistant
1515
```
1616

17+
### Generate Entity Constants
18+
19+
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.
20+
21+
1. Create a `gen.yaml` file in your project root:
22+
23+
```yaml
24+
url: "http://192.168.1.123:8123"
25+
ha_auth_token: "your_auth_token" # Or set HA_AUTH_TOKEN env var
26+
home_zone_entity_id: "zone.home" # Optional: defaults to zone.home
27+
28+
# Optional: List of domains to include when generating constants
29+
# If provided, only these domains will be processed
30+
include_domains: ["light", "switch", "climate"]
31+
32+
# Optional: List of domains to exclude when generating constants
33+
# Only used if include_domains is empty
34+
exclude_domains: ["device_tracker", "person"]
35+
```
36+
37+
2. Add a `//go:generate` comment in your project:
38+
39+
```go
40+
//go:generate go run saml.dev/gome-assistant/cmd/generate
41+
```
42+
43+
Optionally use the `-config` flag to customize the file path of the config file.
44+
45+
3. Run the generator:
46+
47+
```
48+
go generate
49+
```
50+
51+
This will create an `entities` package with type-safe constants for all your Home Assistant entities, organized by domain. For example:
52+
53+
```go
54+
import "your_project/entities"
55+
56+
// Instead of writing "light.living_room" as a string:
57+
entities.Light.LivingRoom // Type-safe constant
58+
59+
// All your entities are organized by domain
60+
entities.Switch.Kitchen
61+
entities.Climate.Bedroom
62+
entities.MediaPlayer.TVRoom
63+
```
64+
65+
The constants are based on the entity ID itself, not the name of the entity in Home Assistant.
66+
1767
### Write your automations
1868

1969
Check out [`example/example.go`](./example/example.go) for an example of the 3 types of automations — schedules, entity listeners, and event listeners.

app.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"log/slog"
88
"net/url"
9+
"strings"
910
"time"
1011

1112
"github.com/golang-module/carbon"
@@ -85,16 +86,47 @@ type NewAppRequest struct {
8586
Secure bool
8687
}
8788

89+
// validateHomeZone verifies that the home zone entity exists and has latitude/longitude
90+
func validateHomeZone(state State, entityID string) error {
91+
entity, err := state.Get(entityID)
92+
if err != nil {
93+
return fmt.Errorf("home zone entity '%s' not found: %w", entityID, err)
94+
}
95+
96+
// Ensure it's a zone entity
97+
if !strings.HasPrefix(entityID, "zone.") {
98+
return fmt.Errorf("entity '%s' is not a zone entity (must start with zone.)", entityID)
99+
}
100+
101+
// Verify it has latitude and longitude
102+
if entity.Attributes == nil {
103+
return fmt.Errorf("home zone entity '%s' has no attributes", entityID)
104+
}
105+
if entity.Attributes["latitude"] == nil {
106+
return fmt.Errorf("home zone entity '%s' missing latitude attribute", entityID)
107+
}
108+
if entity.Attributes["longitude"] == nil {
109+
return fmt.Errorf("home zone entity '%s' missing longitude attribute", entityID)
110+
}
111+
112+
return nil
113+
}
114+
88115
/*
89116
NewApp establishes the websocket connection and returns an object
90117
you can use to register schedules and listeners.
91118
*/
92119
func NewApp(request NewAppRequest) (*App, error) {
93-
if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" || request.HomeZoneEntityId == "" {
94-
slog.Error("URL, HAAuthToken, and HomeZoneEntityId are all required arguments in NewAppRequest")
120+
if (request.URL == "" && request.IpAddress == "") || request.HAAuthToken == "" {
121+
slog.Error("URL and HAAuthToken are required arguments in NewAppRequest")
95122
return nil, ErrInvalidArgs
96123
}
97124

125+
// Set default home zone if not provided
126+
if request.HomeZoneEntityId == "" {
127+
request.HomeZoneEntityId = "zone.home"
128+
}
129+
98130
baseURL := &url.URL{}
99131

100132
if request.URL != "" {
@@ -133,6 +165,11 @@ func NewApp(request NewAppRequest) (*App, error) {
133165
return nil, err
134166
}
135167

168+
// Validate home zone
169+
if err := validateHomeZone(state, request.HomeZoneEntityId); err != nil {
170+
return nil, err
171+
}
172+
136173
return &App{
137174
conn: conn,
138175
wsWriter: wsWriter,

cmd/generate/main.go

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
// Package main provides the generate command for generating Home Assistant entity constants
2+
package main
3+
4+
import (
5+
"flag"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"text/template"
11+
12+
"gopkg.in/yaml.v3"
13+
ga "saml.dev/gome-assistant"
14+
)
15+
16+
type Config struct {
17+
URL string `yaml:"url"`
18+
HAAuthToken string `yaml:"ha_auth_token"`
19+
HomeZoneEntityId string `yaml:"home_zone_entity_id,omitempty"` // Now optional
20+
IncludeDomains []string `yaml:"include_domains,omitempty"` // Optional list of domains to include
21+
ExcludeDomains []string `yaml:"exclude_domains,omitempty"` // Optional list of domains to exclude
22+
}
23+
24+
type Domain struct {
25+
Name string
26+
Entities []Entity
27+
}
28+
29+
type Entity struct {
30+
FieldName string
31+
EntityID string
32+
}
33+
34+
func toFieldName(entityID string) string {
35+
parts := strings.Split(entityID, ".")
36+
if len(parts) != 2 {
37+
return ""
38+
}
39+
return toCamelCase(parts[1])
40+
}
41+
42+
func toCamelCase(s string) string {
43+
if s == "" {
44+
return ""
45+
}
46+
47+
parts := strings.Split(s, "_")
48+
var result strings.Builder
49+
50+
// If first character is numeric
51+
firstChar := parts[0][0]
52+
if firstChar >= '0' && firstChar <= '9' {
53+
result.WriteString("_")
54+
}
55+
56+
for _, part := range parts {
57+
if part == "" {
58+
continue
59+
}
60+
result.WriteString(strings.ToUpper(string(part[0])))
61+
if len(part) > 1 {
62+
result.WriteString(part[1:])
63+
}
64+
}
65+
66+
return result.String()
67+
}
68+
69+
// validateHomeZone verifies that the home zone entity exists and is valid
70+
func validateHomeZone(state ga.State, entityID string) error {
71+
entity, err := state.Get(entityID)
72+
if err != nil {
73+
return fmt.Errorf("home zone entity '%s' not found: %w", entityID, err)
74+
}
75+
76+
// Ensure it's a zone entity
77+
if !strings.HasPrefix(entityID, "zone.") {
78+
return fmt.Errorf("entity '%s' is not a zone entity (must start with zone.)", entityID)
79+
}
80+
81+
// Verify it has latitude and longitude
82+
if entity.Attributes == nil {
83+
return fmt.Errorf("home zone entity '%s' has no attributes", entityID)
84+
}
85+
if entity.Attributes["latitude"] == nil {
86+
return fmt.Errorf("home zone entity '%s' missing latitude attribute", entityID)
87+
}
88+
if entity.Attributes["longitude"] == nil {
89+
return fmt.Errorf("home zone entity '%s' missing longitude attribute", entityID)
90+
}
91+
92+
return nil
93+
}
94+
95+
// generate creates the entities.go file with constants for all Home Assistant entities
96+
func generate(config Config) error {
97+
if config.HomeZoneEntityId == "" {
98+
config.HomeZoneEntityId = "zone.home"
99+
}
100+
101+
app, err := ga.NewApp(ga.NewAppRequest{
102+
URL: config.URL,
103+
HAAuthToken: config.HAAuthToken,
104+
HomeZoneEntityId: config.HomeZoneEntityId,
105+
})
106+
if err != nil {
107+
return fmt.Errorf("failed to create app: %w", err)
108+
}
109+
defer app.Cleanup()
110+
111+
// Validate that the home zone exists before proceeding
112+
if err := validateHomeZone(app.GetState(), config.HomeZoneEntityId); err != nil {
113+
return fmt.Errorf("invalid home zone: %w", err)
114+
}
115+
116+
entities, err := app.GetState().ListEntities()
117+
if err != nil {
118+
return fmt.Errorf("failed to list entities: %w", err)
119+
}
120+
121+
// Group entities by domain
122+
domainMap := make(map[string]*Domain)
123+
for _, entity := range entities {
124+
if entity.State == "unavailable" {
125+
continue
126+
}
127+
128+
parts := strings.Split(entity.EntityID, ".")
129+
if len(parts) != 2 {
130+
continue
131+
}
132+
133+
domain := parts[0]
134+
135+
// Filter domains based on include/exclude lists
136+
if len(config.IncludeDomains) > 0 {
137+
// If include list is specified, only process listed domains
138+
found := false
139+
for _, includeDomain := range config.IncludeDomains {
140+
if domain == includeDomain {
141+
found = true
142+
break
143+
}
144+
}
145+
if !found {
146+
continue
147+
}
148+
} else {
149+
// If only exclude list is specified, skip excluded domains
150+
excluded := false
151+
for _, excludeDomain := range config.ExcludeDomains {
152+
if domain == excludeDomain {
153+
println("skipping excluded domain:", domain)
154+
excluded = true
155+
break
156+
}
157+
}
158+
if excluded {
159+
continue
160+
}
161+
}
162+
163+
if _, exists := domainMap[domain]; !exists {
164+
domainMap[domain] = &Domain{
165+
Name: toCamelCase(domain),
166+
}
167+
}
168+
169+
domainMap[domain].Entities = append(domainMap[domain].Entities, Entity{
170+
FieldName: toFieldName(entity.EntityID),
171+
EntityID: entity.EntityID,
172+
})
173+
}
174+
175+
// Map to slice for template
176+
domains := make([]Domain, 0)
177+
for _, domain := range domainMap {
178+
domains = append(domains, *domain)
179+
}
180+
181+
// Create entities directory if it doesn't exist
182+
err = os.MkdirAll("entities", 0755)
183+
if err != nil {
184+
return fmt.Errorf("failed to create entities directory: %w", err)
185+
}
186+
187+
// Create the file
188+
tmpl := template.Must(template.New("entities").Parse(`// Code generated by go generate; DO NOT EDIT.
189+
package entities
190+
191+
{{ range .Domains }}
192+
type {{ .Name }}Domain struct {
193+
{{- range .Entities }}
194+
{{ .FieldName }} string
195+
{{- end }}
196+
}
197+
198+
var {{ .Name }} = {{ .Name }}Domain{
199+
{{- range .Entities }}
200+
{{ .FieldName }}: "{{ .EntityID }}",
201+
{{- end }}
202+
}
203+
{{ end }}
204+
`))
205+
206+
f, err := os.Create(filepath.Join("entities", "entities.go"))
207+
if err != nil {
208+
return fmt.Errorf("failed to create entities.go: %w", err)
209+
}
210+
defer f.Close()
211+
212+
err = tmpl.Execute(f, struct{ Domains []Domain }{domains})
213+
if err != nil {
214+
return fmt.Errorf("failed to execute template: %w", err)
215+
}
216+
217+
return nil
218+
}
219+
220+
func main() {
221+
println("Generating entities.go...")
222+
configFile := flag.String("config", "gen.yaml", "Path to config file")
223+
flag.Parse()
224+
225+
absConfigPath, err := filepath.Abs(*configFile)
226+
if err != nil {
227+
fmt.Printf("Error resolving config path: %v\n", err)
228+
os.Exit(1)
229+
}
230+
231+
configBytes, err := os.ReadFile(absConfigPath)
232+
if err != nil {
233+
fmt.Printf("Error reading config file: %v\n", err)
234+
os.Exit(1)
235+
}
236+
237+
var config Config
238+
if err := yaml.Unmarshal(configBytes, &config); err != nil {
239+
fmt.Printf("Error parsing config file: %v\n", err)
240+
os.Exit(1)
241+
}
242+
243+
if config.HAAuthToken == "" {
244+
config.HAAuthToken = os.Getenv("HA_AUTH_TOKEN")
245+
}
246+
247+
if config.URL == "" || config.HAAuthToken == "" {
248+
fmt.Println("Error: url and ha_auth_token are required in config")
249+
os.Exit(1)
250+
}
251+
252+
if err := generate(config); err != nil {
253+
fmt.Printf("Error generating entities: %v\n", err)
254+
os.Exit(1)
255+
}
256+
257+
fmt.Println("Generated entities/entities.go")
258+
}

0 commit comments

Comments
 (0)