- Implemented new gRPC service `AIService` in `proto/ai/v1/ai.proto` for handling natural language queries. - Generated Go code for the gRPC service and messages in `gen/ai/v1/`. - Created `services/ai-gateway/` directory structure with necessary files for the service. - Added configuration loading and structured logging. - Implemented domain logic for intent parsing and interaction with Home Assistant. - Established outbound adapters for Ollama and Home Assistant with mTLS support. - Updated `go.work` to include the new service and maintain existing dependencies. - Modified `discord-bot` to use the new `ai-gateway` for AI interactions. - Added deployment manifest for Kubernetes and CI/CD configuration for building and deploying the service.
163 lines
4.6 KiB
Go
163 lines
4.6 KiB
Go
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
|
|
}
|