Nik Afiq b327150d45
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 39s
CI / build-ha-gateway (push) Successful in 39s
CI / build-discord-bot (push) Successful in 40s
feat: implement ClimateApp for managing HVAC entities
- Added ClimateApp to handle climate entity operations including turning on/off, adjusting temperature, and setting HVAC modes.
- Implemented caching mechanism for climate entities to optimize state retrieval.
- Created domain model for Climate with attributes such as current temperature, target temperature, and HVAC modes.
- Developed unit tests for ClimateApp to ensure functionality and correctness.

feat: add RemoteApp for SwitchBot commands

- Introduced RemoteApp to facilitate sending commands to SwitchBot devices.
- Implemented SendCommand method for executing device commands without state management.

chore: update configuration for SwitchBot integration

- Added SwitchBotToken and SwitchBotSecret to configuration for enabling SwitchBot Cloud commands.

feat: define gRPC services for Climate and Remote operations

- Created climate.proto and remote.proto files to define gRPC services for climate management and remote command execution.
- Implemented corresponding request and response message structures for gRPC interactions.
2026-07-23 13:49:54 +09:00

364 lines
13 KiB
Go

package gateway
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/logger"
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 app's HA driven port over gRPC.
type Client struct {
conn *grpc.ClientConn
lightClient hav1.LightServiceClient
switchClient hav1.SwitchServiceClient
climateClient hav1.ClimateServiceClient
log *slog.Logger
}
// New constructs a gRPC client for the internal ha-gateway service.
func New(ctx context.Context, addr, tlsDir string, log *slog.Logger) (*Client, error) {
transportCreds := insecure.NewCredentials()
if tlsDir != "" {
creds, err := loadTransportCredentials(tlsDir)
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),
switchClient: hav1.NewSwitchServiceClient(conn),
climateClient: hav1.NewClimateServiceClient(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
}
func loadTransportCredentials(tlsDir 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: "ha-gateway.home-services.svc.cluster.local",
MinVersion: tls.VersionTLS13,
}), nil
}
// ListLights calls ha-gateway discovery RPCs and maps protobuf messages into
// the driven port type expected by the app layer.
func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/ListLights")
resp, err := c.lightClient.ListLights(ctx, &hav1.ListLightsRequest{})
if err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return nil, fmt.Errorf("list lights: %w", err)
}
log.Debug("grpc call completed", "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(),
SupportedColorModes: append([]string(nil), light.GetSupportedColorModes()...),
MinColorTempKelvin: light.GetMinColorTempKelvin(),
MaxColorTempKelvin: light.GetMaxColorTempKelvin(),
IsHueGroup: light.GetIsHueGroup(),
EffectList: append([]string(nil), light.GetEffectList()...),
})
}
return lights, nil
}
// ListSwitches calls ha-gateway discovery RPCs and maps protobuf messages into
// the driven port type expected by the app layer.
func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "SwitchService/ListSwitches")
resp, err := c.switchClient.ListSwitches(ctx, &hav1.ListSwitchesRequest{})
if err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return nil, fmt.Errorf("list switches: %w", err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
switches := make([]driven.Switch, 0, len(resp.GetSwitches()))
for _, sw := range resp.GetSwitches() {
switches = append(switches, driven.Switch{
EntityID: sw.GetEntityId(),
FriendlyName: sw.GetFriendlyName(),
State: sw.GetState(),
DeviceClass: sw.GetDeviceClass(),
})
}
return switches, nil
}
// TurnOnLight forwards a light turn-on request over gRPC.
func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOn")
req := &hav1.TurnOnRequest{EntityId: entityID}
if brightnessPct != nil {
req.BrightnessPct = brightnessPct
}
if colorTempKelvin != nil {
req.ColorTempKelvin = colorTempKelvin
}
if _, err := c.lightClient.TurnOn(ctx, req); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn on light %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// TurnOffLight forwards a light turn-off request over gRPC.
func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOff")
req := &hav1.TurnOffRequest{EntityId: entityID}
if transition != nil {
req.Transition = transition
}
if _, err := c.lightClient.TurnOff(ctx, req); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn off light %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// ToggleLight forwards a light toggle request over gRPC.
func (c *Client) ToggleLight(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/Toggle")
if _, err := c.lightClient.Toggle(ctx, &hav1.ToggleRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("toggle light %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// TurnOnSwitch forwards a switch turn-on request over gRPC.
func (c *Client) TurnOnSwitch(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "SwitchService/TurnOn")
if _, err := c.switchClient.TurnOn(ctx, &hav1.SwitchRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn on switch %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// TurnOffSwitch forwards a switch turn-off request over gRPC.
func (c *Client) TurnOffSwitch(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "SwitchService/TurnOff")
if _, err := c.switchClient.TurnOff(ctx, &hav1.SwitchRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn off switch %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// ToggleSwitch forwards a switch toggle request over gRPC.
func (c *Client) ToggleSwitch(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "SwitchService/Toggle")
if _, err := c.switchClient.Toggle(ctx, &hav1.SwitchRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("toggle switch %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// ListClimates calls ha-gateway discovery RPCs and maps protobuf messages into
// the driven port type expected by the app layer.
func (c *Client) ListClimates(ctx context.Context) ([]driven.Climate, error) {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/ListClimates")
resp, err := c.climateClient.ListClimates(ctx, &hav1.ListClimatesRequest{})
if err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return nil, fmt.Errorf("list climates: %w", err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
climates := make([]driven.Climate, 0, len(resp.GetClimates()))
for _, c := range resp.GetClimates() {
climates = append(climates, driven.Climate{
EntityID: c.GetEntityId(),
FriendlyName: c.GetFriendlyName(),
State: c.GetState(),
HVACModes: append([]string(nil), c.GetHvacModes()...),
FanMode: c.GetFanMode(),
FanModes: append([]string(nil), c.GetFanModes()...),
CurrentTemperature: c.CurrentTemperature,
TargetTemperature: c.TargetTemperature,
TargetTempStep: c.GetTargetTempStep(),
MinTemp: c.GetMinTemp(),
MaxTemp: c.GetMaxTemp(),
})
}
return climates, nil
}
// TurnOnClimate forwards a climate turn-on request over gRPC.
func (c *Client) TurnOnClimate(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/TurnOn")
if _, err := c.climateClient.TurnOn(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn on climate %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// TurnOffClimate forwards a climate turn-off request over gRPC.
func (c *Client) TurnOffClimate(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/TurnOff")
if _, err := c.climateClient.TurnOff(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn off climate %s: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// SetClimateHVACMode forwards a climate HVAC mode change request over gRPC.
func (c *Client) SetClimateHVACMode(ctx context.Context, entityID, hvacMode string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/SetHVACMode")
if _, err := c.climateClient.SetHVACMode(ctx, &hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: hvacMode}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("set climate %s hvac mode: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// IncreaseClimateTemperature forwards a temperature step-up request over gRPC.
func (c *Client) IncreaseClimateTemperature(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/IncreaseTemperature")
if _, err := c.climateClient.IncreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("increase climate %s temperature: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}
// DecreaseClimateTemperature forwards a temperature step-down request over gRPC.
func (c *Client) DecreaseClimateTemperature(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/DecreaseTemperature")
if _, err := c.climateClient.DecreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("decrease climate %s temperature: %w", entityID, err)
}
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil
}