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

196 lines
6.4 KiB
Go

package main
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"github.com/joho/godotenv"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/health"
grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
grpcadapter "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/adapters/primary/grpc"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/adapters/secondary/ha"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/adapters/secondary/switchbot"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/app"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/logger"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/telemetry"
)
// MEMO: auth is not implemented.
// Add one of the following before exposing this service to any untrusted network:
// Option A — shared API key per client: unary + stream interceptors read
// "authorization" from gRPC metadata and compare to a secret
// from config. Good for small number of known clients.
// Option B — mTLS (recommended): tls.Config with ClientAuth: RequireAndVerifyClientCert,
// cert pool from the internal CA. Each client gets a cert from
// cert-manager. No runtime auth dependency, identity in the cert CN/SAN.
// version is set at build time via -ldflags "-X main.version=<tag>".
var version = "dev"
func main() {
_ = godotenv.Load()
// Config is loaded before logger setup so fatal startup errors still stop early.
cfg, err := config.Load()
if err != nil {
os.Stderr.WriteString("config error: " + err.Error() + "\n")
os.Exit(1)
}
log := logger.New(cfg.LogFormat, cfg.LogLevel)
log.Info("starting ha-gateway",
"version", version,
"grpc_port", cfg.GRPCPort,
"ha_base_url", cfg.HABaseURL,
"ha_token", redactToken(cfg.HAToken),
"tls_dir", cfg.TLSDir,
"otel_endpoint", cfg.OTELEndpoint,
"log_level", cfg.LogLevel,
"log_format", cfg.LogFormat,
"switchbot_configured", cfg.SwitchBotToken != "" && cfg.SwitchBotSecret != "",
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
ctx = logger.WithLogger(ctx, log)
// Telemetry is optional; an empty OTEL endpoint installs no-op providers.
shutdown, err := telemetry.Setup(ctx, "ha-gateway", version, cfg)
if err != nil {
log.Error("telemetry setup failed", "err", err)
os.Exit(1)
}
if cfg.OTELEndpoint != "" {
log.Info("telemetry enabled", "endpoint", cfg.OTELEndpoint)
} else {
log.Debug("telemetry disabled")
}
haClient := ha.NewClient(cfg, log)
switchBotClient := switchbot.NewClient(cfg, log)
// App services stay free of gRPC and HTTP details; adapters are wired here.
entityApp := app.NewEntityApp(haClient)
lightApp := app.NewLightApp(haClient)
switchApp := app.NewSwitchApp(haClient)
climateApp := app.NewClimateApp(haClient)
remoteApp := app.NewRemoteApp(switchBotClient)
if err := lightApp.Refresh(ctx); err != nil {
log.Warn("initial light discovery failed, will retry on first request", "err", err)
}
if err := switchApp.Refresh(ctx); err != nil {
log.Warn("initial switch discovery failed, will retry on first request", "err", err)
}
if err := climateApp.Refresh(ctx); err != nil {
log.Warn("initial climate discovery failed, will retry on first request", "err", err)
}
serverOpts := []grpc.ServerOption{
grpc.StatsHandler(otelgrpc.NewServerHandler()),
grpc.ChainUnaryInterceptor(grpcadapter.LoggingUnaryInterceptor(log)),
grpc.ChainStreamInterceptor(grpcadapter.LoggingStreamInterceptor(log)),
}
if cfg.TLSDir != "" {
creds, err := loadServerCredentials(cfg.TLSDir)
if err != nil {
log.Error("load mTLS credentials failed", "tls_dir", cfg.TLSDir, "err", err)
os.Exit(1)
}
serverOpts = append(serverOpts, grpc.Creds(creds))
log.Info("mTLS enabled", "tls_dir", cfg.TLSDir)
} else {
log.Info("mTLS disabled")
}
srv := grpc.NewServer(serverOpts...)
healthSrv := health.NewServer()
healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
hav1.RegisterEntityServiceServer(srv, grpcadapter.NewEntityGRPC(entityApp))
hav1.RegisterLightServiceServer(srv, grpcadapter.NewLightGRPC(lightApp))
hav1.RegisterSwitchServiceServer(srv, grpcadapter.NewSwitchGRPC(switchApp))
hav1.RegisterClimateServiceServer(srv, grpcadapter.NewClimateGRPC(climateApp))
hav1.RegisterRemoteServiceServer(srv, grpcadapter.NewRemoteGRPC(remoteApp))
hav1.RegisterEventServiceServer(srv, &grpcadapter.EventGRPC{})
grpc_health_v1.RegisterHealthServer(srv, healthSrv)
reflection.Register(srv)
lis, err := net.Listen("tcp", ":"+cfg.GRPCPort)
if err != nil {
log.Error("listen failed", "err", err)
os.Exit(1)
}
go func() {
log.Info("ha-gateway listening", "addr", lis.Addr().String())
if err := srv.Serve(lis); err != nil {
log.Error("serve failed", "err", err)
}
}()
<-ctx.Done()
log.Info("shutdown signal received, draining")
healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
srv.GracefulStop()
log.Info("shutdown complete")
if err := shutdown(context.Background()); err != nil {
log.Error("telemetry shutdown error", "err", err)
}
}
// redactToken logs only a short prefix so startup logs remain useful without
// leaking a full Home Assistant credential.
func redactToken(token string) string {
if token == "" {
return "[not set]"
}
if len(token) <= 8 {
return token + "..."
}
return token[:8] + "..."
}
func loadServerCredentials(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 server key pair: %w", err)
}
caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt"))
if err != nil {
return nil, fmt.Errorf("read client CA: %w", err)
}
clientCAs := x509.NewCertPool()
if !clientCAs.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("append client CA: invalid PEM")
}
return credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: clientCAs,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
}), nil
}