Nik Afiq 6ea4e84949
All checks were successful
CI / test (push) Successful in 4s
CI / build-ha-gateway (push) Successful in 1m7s
CI / build-discord-bot (push) Successful in 51s
Enhance Discord bot and HA gateway with improved structure and documentation
- 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.
2026-04-09 06:00:59 +09:00

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
}