- Added detailed comments to clarify the purpose of various functions and types in the Discord bot and HA gateway. - Introduced new methods in the CommandApp for handling light and switch operations, including HandleLightOn, HandleLightOff, HandleLightToggle, and their respective autocomplete functions. - Updated the HAClient interface to include methods for fetching states and calling services, enhancing the interaction with Home Assistant. - Improved the structure of entity and light domain models to include additional attributes and clearer documentation. - Implemented logging enhancements in both the Discord bot and HA gateway to ensure better traceability and context in logs. - Refactored the configuration loading process to streamline environment variable handling and defaults. - Stubbed out switch control methods in the gRPC adapter, indicating future implementation plans. - Enhanced telemetry setup to ensure proper initialization and shutdown procedures for observability.
230 lines
6.5 KiB
Go
230 lines
6.5 KiB
Go
package ha
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config"
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven"
|
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/logger"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/attribute"
|
|
"go.opentelemetry.io/otel/codes"
|
|
"go.opentelemetry.io/otel/trace"
|
|
)
|
|
|
|
var tracer = otel.Tracer("ha-gateway/ha-client")
|
|
|
|
// Client implements the HA driven port over Home Assistant's REST API.
|
|
type Client struct {
|
|
baseURL string
|
|
token string
|
|
httpClient *http.Client
|
|
log *slog.Logger
|
|
}
|
|
|
|
// NewClient constructs a REST client configured for one Home Assistant instance.
|
|
func NewClient(cfg *config.Config, log *slog.Logger) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(cfg.HABaseURL, "/"),
|
|
token: cfg.HAToken,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// GetState fetches one entity state from the Home Assistant states endpoint.
|
|
func (c *Client) GetState(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
ctx, span := tracer.Start(ctx, "ha.GetState",
|
|
trace.WithAttributes(attribute.String("ha.entity_id", entityID)),
|
|
)
|
|
defer span.End()
|
|
|
|
var raw haStateRaw
|
|
if err := c.get(ctx, "/api/states/"+entityID, &raw); err != nil {
|
|
span.RecordError(err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
return raw.toDriven()
|
|
}
|
|
|
|
// ListStates fetches the full Home Assistant state list for discovery use cases.
|
|
func (c *Client) ListStates(ctx context.Context) ([]*driven.HAState, error) {
|
|
ctx, span := tracer.Start(ctx, "ha.ListStates")
|
|
defer span.End()
|
|
|
|
var raw []haStateRaw
|
|
if err := c.get(ctx, "/api/states", &raw); err != nil {
|
|
span.RecordError(err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]*driven.HAState, 0, len(raw))
|
|
for i := range raw {
|
|
s, err := raw[i].toDriven()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// CallService invokes one Home Assistant domain/service pair and maps the
|
|
// optional returned entity list back into the driven port shape.
|
|
func (c *Client) CallService(ctx context.Context, domain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
ctx, span := tracer.Start(ctx, "ha.CallService",
|
|
trace.WithAttributes(
|
|
attribute.String("ha.domain", domain),
|
|
attribute.String("ha.service", service),
|
|
),
|
|
)
|
|
defer span.End()
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
|
c.baseURL+"/api/services/"+domain+"/"+service,
|
|
strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
log := logger.FromContext(ctx).With("ha.method", req.Method, "ha.path", req.URL.Path)
|
|
start := time.Now()
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
log.Error("ha request failed",
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"error", err.Error(),
|
|
)
|
|
span.RecordError(err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, fmt.Errorf("call service %s/%s: %w", domain, service, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
preview := string(respBody)
|
|
if len(preview) > 200 {
|
|
preview = preview[:200]
|
|
}
|
|
log.Error("ha request failed",
|
|
"http.status", resp.StatusCode,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"error", preview,
|
|
)
|
|
err := fmt.Errorf("HA returned %d: %s", resp.StatusCode, preview)
|
|
span.RecordError(err)
|
|
span.SetStatus(codes.Error, err.Error())
|
|
return nil, err
|
|
}
|
|
log.Debug("ha request completed",
|
|
"http.status", resp.StatusCode,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
|
|
var raw []haStateRaw
|
|
if err := json.Unmarshal(respBody, &raw); err != nil {
|
|
// HA may return an empty body or non-array on some calls; treat as empty.
|
|
return nil, nil
|
|
}
|
|
|
|
out := make([]*driven.HAState, 0, len(raw))
|
|
for i := range raw {
|
|
s, err := raw[i].toDriven()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, s)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// get centralizes GET request construction and consistent non-2xx handling.
|
|
func (c *Client) get(ctx context.Context, path string, dst any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
|
|
log := logger.FromContext(ctx).With("ha.method", req.Method, "ha.path", req.URL.Path)
|
|
start := time.Now()
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
log.Error("ha request failed",
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"error", err.Error(),
|
|
)
|
|
return fmt.Errorf("GET %s: %w", path, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
preview := string(body)
|
|
if len(preview) > 200 {
|
|
preview = preview[:200]
|
|
}
|
|
log.Error("ha request failed",
|
|
"http.status", resp.StatusCode,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"error", preview,
|
|
)
|
|
return fmt.Errorf("HA returned %d for GET %s: %s", resp.StatusCode, path, preview)
|
|
}
|
|
log.Debug("ha request completed",
|
|
"http.status", resp.StatusCode,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
)
|
|
|
|
if err := json.Unmarshal(body, dst); err != nil {
|
|
return fmt.Errorf("decode response for GET %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// haStateRaw is the raw JSON shape returned by the HA REST API.
|
|
type haStateRaw struct {
|
|
EntityID string `json:"entity_id"`
|
|
State string `json:"state"`
|
|
Attributes map[string]any `json:"attributes"`
|
|
LastChanged string `json:"last_changed"`
|
|
LastUpdated string `json:"last_updated"`
|
|
}
|
|
|
|
// toDriven tolerates missing/invalid timestamps because Home Assistant payloads
|
|
// are more useful to callers than failing the whole request on parse issues.
|
|
func (r *haStateRaw) toDriven() (*driven.HAState, error) {
|
|
lc, err := time.Parse(time.RFC3339, r.LastChanged)
|
|
if err != nil {
|
|
lc = time.Time{}
|
|
}
|
|
lu, err := time.Parse(time.RFC3339, r.LastUpdated)
|
|
if err != nil {
|
|
lu = time.Time{}
|
|
}
|
|
return &driven.HAState{
|
|
EntityID: r.EntityID,
|
|
State: r.State,
|
|
Attributes: r.Attributes,
|
|
LastChanged: lc,
|
|
LastUpdated: lu,
|
|
}, nil
|
|
}
|