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 }