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 }