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

188 lines
5.6 KiB
Go

package app
import (
"context"
"fmt"
"strings"
"sync"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain"
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven"
)
type ClimateApp struct {
ha driven.HAClient
mu sync.RWMutex
cache []domain.Climate
}
// NewClimateApp constructs the climate application service.
func NewClimateApp(ha driven.HAClient) *ClimateApp {
return &ClimateApp{ha: ha}
}
// Refresh repopulates the climate cache from the full Home Assistant state list.
func (a *ClimateApp) Refresh(ctx context.Context) error {
all, err := a.ha.ListStates(ctx)
if err != nil {
return err
}
var climates []domain.Climate
for _, s := range all {
if !strings.HasPrefix(s.EntityID, "climate.") {
continue
}
climates = append(climates, haStateToClimate(s))
}
a.mu.Lock()
a.cache = climates
a.mu.Unlock()
return nil
}
// ListClimates returns cached climate discovery data, refreshing lazily on first use.
func (a *ClimateApp) ListClimates(ctx context.Context) ([]domain.Climate, error) {
a.mu.RLock()
c := a.cache
a.mu.RUnlock()
if c == nil {
if err := a.Refresh(ctx); err != nil {
return nil, err
}
a.mu.RLock()
c = a.cache
a.mu.RUnlock()
}
return c, nil
}
// TurnOn maps application parameters into a Home Assistant climate.turn_on call.
func (a *ClimateApp) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(id)}
return a.callService(ctx, "climate", "turn_on", payload)
}
// TurnOff maps application parameters into a Home Assistant climate.turn_off call.
func (a *ClimateApp) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(id)}
return a.callService(ctx, "climate", "turn_off", payload)
}
// SetHVACMode is a direct passthrough to climate.set_hvac_mode; Home Assistant
// rejects unsupported mode values itself, so the caller's raw mode string is
// forwarded without local validation.
func (a *ClimateApp) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(id), "hvac_mode": hvacMode}
return a.callService(ctx, "climate", "set_hvac_mode", payload)
}
// IncreaseTemperature steps the target temperature up by one target_temp_step increment.
func (a *ClimateApp) IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
return a.stepTemperature(ctx, id, 1)
}
// DecreaseTemperature steps the target temperature down by one target_temp_step increment.
func (a *ClimateApp) DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
return a.stepTemperature(ctx, id, -1)
}
// stepTemperature reads live state rather than the discovery cache because the
// cache is never invalidated after a mutating call, which would let two
// presses in a row silently double-step off a stale target temperature. Home
// Assistant only exposes an absolute climate.set_temperature service, so the
// next value is computed here and sent as an absolute target.
func (a *ClimateApp) stepTemperature(ctx context.Context, id domain.EntityID, direction float64) (*domain.EntityState, error) {
s, err := a.ha.GetState(ctx, string(id))
if err != nil {
return nil, err
}
c := haStateToClimate(s)
if c.TargetTemperature == nil {
return nil, fmt.Errorf("climate %s has no target temperature to step from", id)
}
step := c.TargetTempStep
if step == 0 {
step = 1
}
next := *c.TargetTemperature + direction*step
if next < c.MinTemp {
next = c.MinTemp
}
if next > c.MaxTemp {
next = c.MaxTemp
}
payload := map[string]any{"entity_id": string(id), "temperature": next}
return a.callService(ctx, "climate", "set_temperature", payload)
}
// callService falls back to GetState because Home Assistant may succeed without
// returning a full entity state list for the service call response.
func (a *ClimateApp) callService(ctx context.Context, svcDomain, service string, payload map[string]any) (*domain.EntityState, error) {
states, err := a.ha.CallService(ctx, svcDomain, service, payload)
if err != nil {
return nil, err
}
entityID, _ := payload["entity_id"].(string)
for _, s := range states {
if s.EntityID == entityID {
return haStateToDomain(s), nil
}
}
// HA may return an empty list on success; fall back to GetState.
s, err := a.ha.GetState(ctx, entityID)
if err != nil {
return nil, err
}
return haStateToDomain(s), nil
}
// haStateToClimate extracts the subset of attributes needed for climate
// discovery and temperature stepping.
func haStateToClimate(s *driven.HAState) domain.Climate {
c := domain.Climate{
EntityID: domain.EntityID(s.EntityID),
State: s.State,
}
if v, ok := s.Attributes["friendly_name"].(string); ok {
c.FriendlyName = v
}
if modes, ok := s.Attributes["hvac_modes"].([]any); ok {
for _, m := range modes {
if ms, ok := m.(string); ok {
c.HVACModes = append(c.HVACModes, ms)
}
}
}
if v, ok := s.Attributes["fan_mode"].(string); ok {
c.FanMode = v
}
if modes, ok := s.Attributes["fan_modes"].([]any); ok {
for _, m := range modes {
if ms, ok := m.(string); ok {
c.FanModes = append(c.FanModes, ms)
}
}
}
if v, ok := s.Attributes["current_temperature"].(float64); ok {
c.CurrentTemperature = &v
}
if v, ok := s.Attributes["temperature"].(float64); ok {
c.TargetTemperature = &v
}
if v, ok := s.Attributes["target_temp_step"].(float64); ok {
c.TargetTempStep = v
}
if v, ok := s.Attributes["min_temp"].(float64); ok {
c.MinTemp = v
}
if v, ok := s.Attributes["max_temp"].(float64); ok {
c.MaxTemp = v
}
return c
}