Nik Afiq 8f7024edfa
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
feat(climate): add SetTemperature method to ClimateService
- Implemented SetTemperature in ClimateService for setting an absolute target temperature.
- Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature.
- Added corresponding handler and tests for SetTemperature in ClimateGRPC.
- Modified ClimateApp to handle SetTemperature requests without clamping.
- Updated climate.proto to define SetTemperatureRequest message.
- Adjusted Dockerfiles to include alexa-bridge dependencies.
2026-07-25 12:08:24 +09:00

198 lines
6.2 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)
}
// SetTemperature sets an absolute target temperature via climate.set_temperature,
// unlike IncreaseTemperature/DecreaseTemperature which compute the next value
// from live state first. Home Assistant rejects out-of-range or otherwise
// invalid targets itself, so the caller's value is forwarded without local
// clamping.
func (a *ClimateApp) SetTemperature(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(id), "temperature": target}
return a.callService(ctx, "climate", "set_temperature", payload)
}
// 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
}