- 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.
188 lines
5.6 KiB
Go
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
|
|
}
|