package hagateway import ( "context" "crypto/tls" "crypto/x509" "fmt" "log/slog" "os" "path/filepath" "strconv" "time" "gitea.nik4nao.com/nik/home-services/ai-gateway/internal/core/ports/driven" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" ) // Client implements the HA driven port over gRPC. type Client struct { conn *grpc.ClientConn lightClient hav1.LightServiceClient log *slog.Logger } // New constructs a gRPC client for ha-gateway with optional mTLS. func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) { transportCreds := insecure.NewCredentials() if tlsDir != "" { creds, err := loadTransportCredentials(tlsDir, serverName) if err != nil { return nil, fmt.Errorf("load mTLS credentials: %w", err) } transportCreds = creds } conn, err := grpc.NewClient( addr, grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()), ) if err != nil { return nil, fmt.Errorf("dial ha-gateway: %w", err) } return &Client{ conn: conn, lightClient: hav1.NewLightServiceClient(conn), log: log, }, nil } // Close closes the underlying gRPC connection. func (c *Client) Close() error { if err := c.conn.Close(); err != nil { return fmt.Errorf("close ha-gateway client: %w", err) } return nil } // ListLights loads the discovery list used by prompt-building and list replies. func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) { start := time.Now() resp, err := c.lightClient.ListLights(ctx, &hav1.ListLightsRequest{}) if err != nil { return nil, fmt.Errorf("list lights: %w", err) } c.log.Debug("grpc call completed", "grpc.method", "LightService/ListLights", "duration_ms", time.Since(start).Milliseconds()) lights := make([]driven.Light, 0, len(resp.GetLights())) for _, light := range resp.GetLights() { lights = append(lights, driven.Light{ EntityID: light.GetEntityId(), FriendlyName: light.GetFriendlyName(), State: light.GetState(), }) } return lights, nil } // TurnOnLight forwards a turn-on request to ha-gateway. func (c *Client) TurnOnLight(ctx context.Context, entity string, params map[string]string) error { start := time.Now() req := &hav1.TurnOnRequest{EntityId: entity} if v, ok := firstParam(params, "brightness", "brightness_pct"); ok { brightness, err := parseUint32(v) if err != nil { return err } req.BrightnessPct = &brightness } if v, ok := firstParam(params, "color_temp", "color_temp_kelvin"); ok { colorTemp, err := parseUint32(v) if err != nil { return err } req.ColorTempKelvin = &colorTemp } if _, err := c.lightClient.TurnOn(ctx, req); err != nil { return fmt.Errorf("turn on light %s: %w", entity, err) } c.log.Debug("grpc call completed", "grpc.method", "LightService/TurnOn", "duration_ms", time.Since(start).Milliseconds()) return nil } // TurnOffLight forwards a turn-off request to ha-gateway. func (c *Client) TurnOffLight(ctx context.Context, entity string) error { start := time.Now() if _, err := c.lightClient.TurnOff(ctx, &hav1.TurnOffRequest{EntityId: entity}); err != nil { return fmt.Errorf("turn off light %s: %w", entity, err) } c.log.Debug("grpc call completed", "grpc.method", "LightService/TurnOff", "duration_ms", time.Since(start).Milliseconds()) return nil } func loadTransportCredentials(tlsDir, serverName string) (credentials.TransportCredentials, error) { cert, err := tls.LoadX509KeyPair( filepath.Join(tlsDir, "tls.crt"), filepath.Join(tlsDir, "tls.key"), ) if err != nil { return nil, fmt.Errorf("load client key pair: %w", err) } caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt")) if err != nil { return nil, fmt.Errorf("read server CA: %w", err) } rootCAs := x509.NewCertPool() if !rootCAs.AppendCertsFromPEM(caPEM) { return nil, fmt.Errorf("append server CA: invalid PEM") } return credentials.NewTLS(&tls.Config{ Certificates: []tls.Certificate{cert}, RootCAs: rootCAs, ServerName: serverName, MinVersion: tls.VersionTLS13, }), nil } func firstParam(params map[string]string, keys ...string) (string, bool) { for _, key := range keys { if v, ok := params[key]; ok { return v, true } } return "", false } func parseUint32(v string) (uint32, error) { n, err := strconv.ParseUint(v, 10, 32) if err != nil { return 0, fmt.Errorf("parse uint32 value %q: %w", v, err) } return uint32(n), nil }