From b327150d45b6a573fd690ac15f60f20491cae287 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Thu, 23 Jul 2026 13:49:54 +0900 Subject: [PATCH] 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. --- CLAUDE.md | 9 + discord-bot/README.md | 16 + .../adapters/primary/discord/handler.go | 58 ++ .../adapters/primary/discord/register.go | 69 +++ .../adapters/secondary/gateway/client.go | 127 ++++- discord-bot/internal/app/command.go | 97 ++++ discord-bot/internal/app/command_test.go | 363 +++++++++++- discord-bot/internal/core/ports/driven/ha.go | 38 ++ gen/ai/v1/ai_grpc.pb.go | 2 +- gen/ha/v1/climate.pb.go | 475 ++++++++++++++++ gen/ha/v1/climate_grpc.pb.go | 311 ++++++++++ gen/ha/v1/entity_grpc.pb.go | 2 +- gen/ha/v1/event_grpc.pb.go | 2 +- gen/ha/v1/light_grpc.pb.go | 2 +- gen/ha/v1/remote.pb.go | 182 ++++++ gen/ha/v1/remote_grpc.pb.go | 121 ++++ gen/ha/v1/switch_grpc.pb.go | 2 +- ha-gateway/.env.example | 2 + ha-gateway/README.md | 25 +- ha-gateway/cmd/gateway/main.go | 10 + .../internal/adapters/primary/grpc/climate.go | 77 +++ .../adapters/primary/grpc/climate_test.go | 379 ++++++++++++ .../internal/adapters/primary/grpc/mapping.go | 26 + .../internal/adapters/primary/grpc/remote.go | 26 + .../adapters/primary/grpc/remote_test.go | 100 ++++ .../adapters/secondary/switchbot/client.go | 193 +++++++ .../secondary/switchbot/client_test.go | 222 ++++++++ ha-gateway/internal/app/climate.go | 187 ++++++ ha-gateway/internal/app/climate_test.go | 538 ++++++++++++++++++ ha-gateway/internal/app/remote.go | 24 + ha-gateway/internal/config/config.go | 6 + ha-gateway/internal/core/domain/climate.go | 27 + .../internal/core/ports/driven/switchbot.go | 12 + .../internal/core/ports/driving/climate.go | 24 + .../internal/core/ports/driving/remote.go | 8 + proto/ha/v1/climate.proto | 41 ++ proto/ha/v1/remote.proto | 15 + 37 files changed, 3798 insertions(+), 20 deletions(-) create mode 100644 gen/ha/v1/climate.pb.go create mode 100644 gen/ha/v1/climate_grpc.pb.go create mode 100644 gen/ha/v1/remote.pb.go create mode 100644 gen/ha/v1/remote_grpc.pb.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/climate.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/climate_test.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/remote.go create mode 100644 ha-gateway/internal/adapters/primary/grpc/remote_test.go create mode 100644 ha-gateway/internal/adapters/secondary/switchbot/client.go create mode 100644 ha-gateway/internal/adapters/secondary/switchbot/client_test.go create mode 100644 ha-gateway/internal/app/climate.go create mode 100644 ha-gateway/internal/app/climate_test.go create mode 100644 ha-gateway/internal/app/remote.go create mode 100644 ha-gateway/internal/core/domain/climate.go create mode 100644 ha-gateway/internal/core/ports/driven/switchbot.go create mode 100644 ha-gateway/internal/core/ports/driving/climate.go create mode 100644 ha-gateway/internal/core/ports/driving/remote.go create mode 100644 proto/ha/v1/climate.proto create mode 100644 proto/ha/v1/remote.proto diff --git a/CLAUDE.md b/CLAUDE.md index ae1bf5c..592d932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,15 @@ internal/telemetry/ # OpenTelemetry setup Protobuf contracts live in `proto/` (buf module, `ai/v1` and `ha/v1` packages). Generated Go code is committed under `gen/` and consumed by all three services through the Go workspace — do not hand-edit files in `gen/`. +## `tmp/` is reference-only, never a dependency + +`tmp/` is gitignored — nothing under it is pushed to git, and it should be treated as temporary scratch space for reference material (e.g. `tmp/reference/switchbot-control-reference/`, a standalone CLI copied in for local discovery/testing against an external API). Rules: + +- It's fine to read code under `tmp/` for patterns, to run its scripts/tools locally (a discovery script, a CLI, etc.), or to use it as a manual testing aid. +- Never make any of the three services (`ha-gateway`, `ai-gateway`, `discord-bot`) import, `go.work use`, or otherwise depend on anything under `tmp/` at build or runtime. Since `tmp/` isn't committed, that dependency would silently break for every other clone of the repo (including CI). +- If a reference tool under `tmp/` lives in its own Go module nested inside this repo's `go.work` workspace, invoke it with `GOWORK=off` rather than adding it to the root `go.work` — e.g. `GOWORK=off ./scripts/some-tool ...` from that tool's own directory. +- If something under `tmp/` turns out to be genuinely needed at runtime, port the actual logic into the relevant service's `internal/` tree (following the hexagonal layout above) instead of reaching into `tmp/` from committed code. + ## Common Commands Regenerate protobuf code after changing anything under `proto/` (requires `buf`): diff --git a/discord-bot/README.md b/discord-bot/README.md index c118ea9..43ae1ca 100644 --- a/discord-bot/README.md +++ b/discord-bot/README.md @@ -42,6 +42,22 @@ AI-assisted commands. - `switch` is required for action commands and uses autocomplete. +### Air Conditioner + +```text +/ac on ac: +/ac off ac: +/ac mode ac: mode: +/ac temp up ac: +/ac temp down ac: +``` + +- `ac` is required for every subcommand and uses autocomplete. +- `mode` is a fixed choice list mapping to Home Assistant HVAC modes + (Cool→`cool`, Heat→`heat`, Dry→`dry`, Auto→`heat_cool`, Fan→`fan_only`). +- `temp up`/`temp down` step the target temperature by one + `target_temp_step` increment, clamped to the entity's `min_temp`/`max_temp`. + ### AI ```text diff --git a/discord-bot/internal/adapters/primary/discord/handler.go b/discord-bot/internal/adapters/primary/discord/handler.go index f257fa0..42e2451 100644 --- a/discord-bot/internal/adapters/primary/discord/handler.go +++ b/discord-bot/internal/adapters/primary/discord/handler.go @@ -29,12 +29,18 @@ type commandHandler interface { HandleSwitchOn(ctx context.Context, entityID string) (string, error) HandleSwitchOff(ctx context.Context, entityID string) (string, error) HandleSwitchToggle(ctx context.Context, entityID string) (string, error) + HandleACOn(ctx context.Context, entityID string) (string, error) + HandleACOff(ctx context.Context, entityID string) (string, error) + HandleACMode(ctx context.Context, entityID, hvacMode string) (string, error) + HandleACTempUp(ctx context.Context, entityID string) (string, error) + HandleACTempDown(ctx context.Context, entityID string) (string, error) HandleAIQuery(ctx context.Context, text string) (string, error) HandleAIModelSet(ctx context.Context, name string) (string, error) HandleAIModelGet(ctx context.Context) (string, error) HandleAIModelList(ctx context.Context) (string, error) AutocompleteLights(ctx context.Context) ([]apppkg.Choice, error) AutocompleteSwitches(ctx context.Context) ([]apppkg.Choice, error) + AutocompleteClimates(ctx context.Context) ([]apppkg.Choice, error) AutocompleteAIModels(ctx context.Context) ([]apppkg.Choice, error) } @@ -192,6 +198,56 @@ func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Ses } msg, err := h.app.HandleSwitchToggle(ctx, requiredStringOption(sub, "switch")) h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "ac.on": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleACOn(ctx, requiredStringOption(sub, "ac")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "ac.off": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleACOff(ctx, requiredStringOption(sub, "ac")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "ac.mode": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleACMode(ctx, requiredStringOption(sub, "ac"), requiredStringOption(sub, "mode")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "ac.temp.up": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleACTempUp(ctx, requiredStringOption(target, "ac")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) + case "ac.temp.down": + if err := h.deferResponse(s, i.Interaction, true); err != nil { + log.Error("discord response failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return + } + msg, err := h.app.HandleACTempDown(ctx, requiredStringOption(target, "ac")) + h.followup(ctx, s, i.Interaction, msg, true, start, err) case "ai.query": if err := h.deferResponse(s, i.Interaction, true); err != nil { log.Error("discord response failed", @@ -264,6 +320,8 @@ func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, choices, err = h.app.AutocompleteLights(ctx) case "switch": choices, err = h.app.AutocompleteSwitches(ctx) + case "ac": + choices, err = h.app.AutocompleteClimates(ctx) case "ai": if focusedOptionName(data) == "name" { choices, err = h.app.AutocompleteAIModels(ctx) diff --git a/discord-bot/internal/adapters/primary/discord/register.go b/discord-bot/internal/adapters/primary/discord/register.go index 4b57c1a..440fc00 100644 --- a/discord-bot/internal/adapters/primary/discord/register.go +++ b/discord-bot/internal/adapters/primary/discord/register.go @@ -99,6 +99,64 @@ func RegisterCommands(s *discordgo.Session, guildID string) error { }, }, }, + { + Name: "ac", + Description: "Control and inspect the air conditioner", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "on", + Description: "Turn on the AC", + Options: []*discordgo.ApplicationCommandOption{acOption()}, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "off", + Description: "Turn off the AC", + Options: []*discordgo.ApplicationCommandOption{acOption()}, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "mode", + Description: "Set the AC HVAC mode", + Options: []*discordgo.ApplicationCommandOption{ + acOption(), + { + Type: discordgo.ApplicationCommandOptionString, + Name: "mode", + Description: "HVAC mode", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + {Name: "Cool", Value: "cool"}, + {Name: "Heat", Value: "heat"}, + {Name: "Dry", Value: "dry"}, + {Name: "Auto", Value: "heat_cool"}, + {Name: "Fan", Value: "fan_only"}, + }, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommandGroup, + Name: "temp", + Description: "Adjust the AC target temperature", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "up", + Description: "Increase the AC target temperature", + Options: []*discordgo.ApplicationCommandOption{acOption()}, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "down", + Description: "Decrease the AC target temperature", + Options: []*discordgo.ApplicationCommandOption{acOption()}, + }, + }, + }, + }, + }, { Name: "ai", Description: "Query the AI home assistant", @@ -179,6 +237,17 @@ func switchOption() *discordgo.ApplicationCommandOption { } } +// acOption centralizes the shared AC entity selector used by subcommands. +func acOption() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionString, + Name: "ac", + Description: "AC entity", + Required: true, + Autocomplete: true, + } +} + // ptrFloat keeps command option min/max values readable in the command spec. func ptrFloat(v float64) *float64 { return &v diff --git a/discord-bot/internal/adapters/secondary/gateway/client.go b/discord-bot/internal/adapters/secondary/gateway/client.go index 7c0a7d7..8dcc4e1 100644 --- a/discord-bot/internal/adapters/secondary/gateway/client.go +++ b/discord-bot/internal/adapters/secondary/gateway/client.go @@ -21,10 +21,11 @@ import ( // Client implements the app's HA driven port over gRPC. type Client struct { - conn *grpc.ClientConn - lightClient hav1.LightServiceClient - switchClient hav1.SwitchServiceClient - log *slog.Logger + conn *grpc.ClientConn + lightClient hav1.LightServiceClient + switchClient hav1.SwitchServiceClient + climateClient hav1.ClimateServiceClient + log *slog.Logger } // New constructs a gRPC client for the internal ha-gateway service. @@ -48,10 +49,11 @@ func New(ctx context.Context, addr, tlsDir string, log *slog.Logger) (*Client, e } return &Client{ - conn: conn, - lightClient: hav1.NewLightServiceClient(conn), - switchClient: hav1.NewSwitchServiceClient(conn), - log: log, + conn: conn, + lightClient: hav1.NewLightServiceClient(conn), + switchClient: hav1.NewSwitchServiceClient(conn), + climateClient: hav1.NewClimateServiceClient(conn), + log: log, }, nil } @@ -250,3 +252,112 @@ func (c *Client) ToggleSwitch(ctx context.Context, entityID string) error { log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) return nil } + +// ListClimates calls ha-gateway discovery RPCs and maps protobuf messages into +// the driven port type expected by the app layer. +func (c *Client) ListClimates(ctx context.Context) ([]driven.Climate, error) { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "ClimateService/ListClimates") + resp, err := c.climateClient.ListClimates(ctx, &hav1.ListClimatesRequest{}) + if err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return nil, fmt.Errorf("list climates: %w", err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + + climates := make([]driven.Climate, 0, len(resp.GetClimates())) + for _, c := range resp.GetClimates() { + climates = append(climates, driven.Climate{ + EntityID: c.GetEntityId(), + FriendlyName: c.GetFriendlyName(), + State: c.GetState(), + HVACModes: append([]string(nil), c.GetHvacModes()...), + FanMode: c.GetFanMode(), + FanModes: append([]string(nil), c.GetFanModes()...), + CurrentTemperature: c.CurrentTemperature, + TargetTemperature: c.TargetTemperature, + TargetTempStep: c.GetTargetTempStep(), + MinTemp: c.GetMinTemp(), + MaxTemp: c.GetMaxTemp(), + }) + } + return climates, nil +} + +// TurnOnClimate forwards a climate turn-on request over gRPC. +func (c *Client) TurnOnClimate(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "ClimateService/TurnOn") + if _, err := c.climateClient.TurnOn(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("turn on climate %s: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// TurnOffClimate forwards a climate turn-off request over gRPC. +func (c *Client) TurnOffClimate(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "ClimateService/TurnOff") + if _, err := c.climateClient.TurnOff(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("turn off climate %s: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// SetClimateHVACMode forwards a climate HVAC mode change request over gRPC. +func (c *Client) SetClimateHVACMode(ctx context.Context, entityID, hvacMode string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "ClimateService/SetHVACMode") + if _, err := c.climateClient.SetHVACMode(ctx, &hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: hvacMode}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("set climate %s hvac mode: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// IncreaseClimateTemperature forwards a temperature step-up request over gRPC. +func (c *Client) IncreaseClimateTemperature(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "ClimateService/IncreaseTemperature") + if _, err := c.climateClient.IncreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("increase climate %s temperature: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} + +// DecreaseClimateTemperature forwards a temperature step-down request over gRPC. +func (c *Client) DecreaseClimateTemperature(ctx context.Context, entityID string) error { + start := time.Now() + log := logger.FromContext(ctx).With("grpc.method", "ClimateService/DecreaseTemperature") + if _, err := c.climateClient.DecreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil { + log.Error("grpc call failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("decrease climate %s temperature: %w", entityID, err) + } + log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds()) + return nil +} diff --git a/discord-bot/internal/app/command.go b/discord-bot/internal/app/command.go index 82094ed..b6ebb55 100644 --- a/discord-bot/internal/app/command.go +++ b/discord-bot/internal/app/command.go @@ -162,6 +162,66 @@ func (a *CommandApp) HandleSwitchToggle(ctx context.Context, entityID string) (s return fmt.Sprintf("Toggled `%s`.", name), nil } +// HandleACOn issues a turn-on request and returns a user-facing confirmation. +func (a *CommandApp) HandleACOn(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupClimateName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup climate name: %w", err) + } + if err := a.ha.TurnOnClimate(ctx, entityID); err != nil { + return "", fmt.Errorf("handle ac on: %w", err) + } + return fmt.Sprintf("Turned on `%s`.", name), nil +} + +// HandleACOff issues a turn-off request and returns a user-facing confirmation. +func (a *CommandApp) HandleACOff(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupClimateName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup climate name: %w", err) + } + if err := a.ha.TurnOffClimate(ctx, entityID); err != nil { + return "", fmt.Errorf("handle ac off: %w", err) + } + return fmt.Sprintf("Turned off `%s`.", name), nil +} + +// HandleACMode issues an HVAC mode change request and returns a user-facing confirmation. +func (a *CommandApp) HandleACMode(ctx context.Context, entityID, hvacMode string) (string, error) { + name, err := a.lookupClimateName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup climate name: %w", err) + } + if err := a.ha.SetClimateHVACMode(ctx, entityID, hvacMode); err != nil { + return "", fmt.Errorf("handle ac mode: %w", err) + } + return fmt.Sprintf("Set `%s` mode to `%s`.", name, hvacMode), nil +} + +// HandleACTempUp issues a temperature step-up request and returns a user-facing confirmation. +func (a *CommandApp) HandleACTempUp(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupClimateName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup climate name: %w", err) + } + if err := a.ha.IncreaseClimateTemperature(ctx, entityID); err != nil { + return "", fmt.Errorf("handle ac temp up: %w", err) + } + return fmt.Sprintf("Increased `%s` target temperature.", name), nil +} + +// HandleACTempDown issues a temperature step-down request and returns a user-facing confirmation. +func (a *CommandApp) HandleACTempDown(ctx context.Context, entityID string) (string, error) { + name, err := a.lookupClimateName(ctx, entityID) + if err != nil { + return "", fmt.Errorf("lookup climate name: %w", err) + } + if err := a.ha.DecreaseClimateTemperature(ctx, entityID); err != nil { + return "", fmt.Errorf("handle ac temp down: %w", err) + } + return fmt.Sprintf("Decreased `%s` target temperature.", name), nil +} + // HandleAIQuery forwards a free-form request to ai-gateway. func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) { reply, modelUsed, err := a.ai.Query(ctx, text, a.models.Get()) @@ -268,6 +328,24 @@ func (a *CommandApp) AutocompleteSwitches(ctx context.Context) ([]Choice, error) return choices, nil } +// AutocompleteClimates maps discovered climates into Discord autocomplete choices. +func (a *CommandApp) AutocompleteClimates(ctx context.Context) ([]Choice, error) { + climates, err := a.ha.ListClimates(ctx) + if err != nil { + return nil, fmt.Errorf("autocomplete climates: %w", err) + } + + choices := make([]Choice, 0, len(climates)) + for _, c := range climates { + label := c.FriendlyName + if label == "" { + label = c.EntityID + } + choices = append(choices, Choice{Label: label, Value: c.EntityID}) + } + return choices, nil +} + // lookupLightName falls back to the entity ID so confirmations remain useful // even when Home Assistant does not expose a friendly name. func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (string, error) { @@ -306,6 +384,25 @@ func (a *CommandApp) lookupSwitchName(ctx context.Context, entityID string) (str return switches[idx].FriendlyName, nil } +// lookupClimateName falls back to the entity ID so confirmations remain useful +// even when Home Assistant does not expose a friendly name. +func (a *CommandApp) lookupClimateName(ctx context.Context, entityID string) (string, error) { + climates, err := a.ha.ListClimates(ctx) + if err != nil { + return "", fmt.Errorf("list climates: %w", err) + } + idx := slices.IndexFunc(climates, func(c driven.Climate) bool { + return c.EntityID == entityID + }) + if idx == -1 { + return entityID, nil + } + if climates[idx].FriendlyName == "" { + return entityID, nil + } + return climates[idx].FriendlyName, nil +} + // formatLightLine keeps list output compact because Discord code blocks are // easier to scan than rich embeds for dense discovery data. func formatLightLine(light driven.Light) string { diff --git a/discord-bot/internal/app/command_test.go b/discord-bot/internal/app/command_test.go index 70524d0..6721884 100644 --- a/discord-bot/internal/app/command_test.go +++ b/discord-bot/internal/app/command_test.go @@ -13,14 +13,21 @@ import ( ) type mockHAGateway struct { - listLightsFunc func(ctx context.Context) ([]driven.Light, error) - listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error) + listLightsFunc func(ctx context.Context) ([]driven.Light, error) + listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error) turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error toggleLightFunc func(ctx context.Context, entityID string) error turnOnSwitchFunc func(ctx context.Context, entityID string) error turnOffSwitchFunc func(ctx context.Context, entityID string) error toggleSwitchFunc func(ctx context.Context, entityID string) error + + listClimatesFunc func(ctx context.Context) ([]driven.Climate, error) + turnOnClimateFunc func(ctx context.Context, entityID string) error + turnOffClimateFunc func(ctx context.Context, entityID string) error + setClimateHVACModeFunc func(ctx context.Context, entityID, hvacMode string) error + increaseClimateTemperatureFunc func(ctx context.Context, entityID string) error + decreaseClimateTemperatureFunc func(ctx context.Context, entityID string) error } type mockAIGateway struct { @@ -84,6 +91,48 @@ func (m *mockHAGateway) ToggleSwitch(ctx context.Context, entityID string) error return m.toggleSwitchFunc(ctx, entityID) } +func (m *mockHAGateway) ListClimates(ctx context.Context) ([]driven.Climate, error) { + if m.listClimatesFunc == nil { + return nil, nil + } + return m.listClimatesFunc(ctx) +} + +func (m *mockHAGateway) TurnOnClimate(ctx context.Context, entityID string) error { + if m.turnOnClimateFunc == nil { + return nil + } + return m.turnOnClimateFunc(ctx, entityID) +} + +func (m *mockHAGateway) TurnOffClimate(ctx context.Context, entityID string) error { + if m.turnOffClimateFunc == nil { + return nil + } + return m.turnOffClimateFunc(ctx, entityID) +} + +func (m *mockHAGateway) SetClimateHVACMode(ctx context.Context, entityID, hvacMode string) error { + if m.setClimateHVACModeFunc == nil { + return nil + } + return m.setClimateHVACModeFunc(ctx, entityID, hvacMode) +} + +func (m *mockHAGateway) IncreaseClimateTemperature(ctx context.Context, entityID string) error { + if m.increaseClimateTemperatureFunc == nil { + return nil + } + return m.increaseClimateTemperatureFunc(ctx, entityID) +} + +func (m *mockHAGateway) DecreaseClimateTemperature(ctx context.Context, entityID string) error { + if m.decreaseClimateTemperatureFunc == nil { + return nil + } + return m.decreaseClimateTemperatureFunc(ctx, entityID) +} + func (m *mockAIGateway) Query(ctx context.Context, text, model string) (string, string, error) { if m.queryFunc == nil { return "", "", nil @@ -601,6 +650,262 @@ func TestCommandAppHandleSwitchToggle(t *testing.T) { } } +func TestCommandAppHandleACOn(t *testing.T) { + tests := []struct { + name string + climates []driven.Climate + entityID string + turnOnErr error + want string + wantErr string + }{ + { + name: "happy path", + climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, + entityID: "climate.air_conditioner", + want: "Turned on `AC`.", + }, + { + name: "climate not found falls back to entity id", + climates: []driven.Climate{{EntityID: "climate.other", FriendlyName: "Other"}}, + entityID: "climate.air_conditioner", + want: "Turned on `climate.air_conditioner`.", + }, + { + name: "TurnOnClimate error", + climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, + entityID: "climate.air_conditioner", + turnOnErr: errors.New("boom"), + wantErr: "handle ac on: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotEntityID string + app := newTestCommandApp(&mockHAGateway{ + listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) { + return tt.climates, nil + }, + turnOnClimateFunc: func(ctx context.Context, entityID string) error { + gotEntityID = entityID + return tt.turnOnErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleACOn(context.Background(), tt.entityID) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleACOn() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleACOn() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleACOn() = %q, want %q", got, tt.want) + } + if gotEntityID != tt.entityID { + t.Fatalf("TurnOnClimate entityID = %q, want %q", gotEntityID, tt.entityID) + } + }) + } +} + +func TestCommandAppHandleACOff(t *testing.T) { + tests := []struct { + name string + climates []driven.Climate + turnOffErr error + want string + wantErr string + }{ + { + name: "happy path", + climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, + want: "Turned off `AC`.", + }, + { + name: "TurnOffClimate error", + climates: []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, + turnOffErr: errors.New("boom"), + wantErr: "handle ac off: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestCommandApp(&mockHAGateway{ + listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) { + return tt.climates, nil + }, + turnOffClimateFunc: func(ctx context.Context, entityID string) error { + return tt.turnOffErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleACOff(context.Background(), "climate.air_conditioner") + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleACOff() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleACOff() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleACOff() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommandAppHandleACMode(t *testing.T) { + tests := []struct { + name string + modeErr error + want string + wantErr string + }{ + { + name: "happy path passes raw mode through", + want: "Set `AC` mode to `cool`.", + }, + { + name: "SetClimateHVACMode error", + modeErr: errors.New("boom"), + wantErr: "handle ac mode: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotEntityID, gotMode string + app := newTestCommandApp(&mockHAGateway{ + listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) { + return []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, nil + }, + setClimateHVACModeFunc: func(ctx context.Context, entityID, hvacMode string) error { + gotEntityID = entityID + gotMode = hvacMode + return tt.modeErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleACMode(context.Background(), "climate.air_conditioner", "cool") + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleACMode() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleACMode() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleACMode() = %q, want %q", got, tt.want) + } + if gotEntityID != "climate.air_conditioner" || gotMode != "cool" { + t.Fatalf("SetClimateHVACMode entityID/mode = %q/%q, want %q/%q", gotEntityID, gotMode, "climate.air_conditioner", "cool") + } + }) + } +} + +func TestCommandAppHandleACTempUp(t *testing.T) { + tests := []struct { + name string + stepErr error + want string + wantErr string + }{ + { + name: "happy path", + want: "Increased `AC` target temperature.", + }, + { + name: "IncreaseClimateTemperature error", + stepErr: errors.New("boom"), + wantErr: "handle ac temp up: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestCommandApp(&mockHAGateway{ + listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) { + return []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, nil + }, + increaseClimateTemperatureFunc: func(ctx context.Context, entityID string) error { + return tt.stepErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleACTempUp(context.Background(), "climate.air_conditioner") + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleACTempUp() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleACTempUp() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleACTempUp() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommandAppHandleACTempDown(t *testing.T) { + tests := []struct { + name string + stepErr error + want string + wantErr string + }{ + { + name: "happy path", + want: "Decreased `AC` target temperature.", + }, + { + name: "DecreaseClimateTemperature error", + stepErr: errors.New("boom"), + wantErr: "handle ac temp down: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestCommandApp(&mockHAGateway{ + listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) { + return []driven.Climate{{EntityID: "climate.air_conditioner", FriendlyName: "AC"}}, nil + }, + decreaseClimateTemperatureFunc: func(ctx context.Context, entityID string) error { + return tt.stepErr + }, + }, &mockAIGateway{}) + + got, err := app.HandleACTempDown(context.Background(), "climate.air_conditioner") + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("HandleACTempDown() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("HandleACTempDown() error = %v", err) + } + if got != tt.want { + t.Fatalf("HandleACTempDown() = %q, want %q", got, tt.want) + } + }) + } +} + func TestCommandAppAutocompleteLights(t *testing.T) { tests := []struct { name string @@ -709,6 +1014,60 @@ func TestCommandAppAutocompleteSwitches(t *testing.T) { } } +func TestCommandAppAutocompleteClimates(t *testing.T) { + tests := []struct { + name string + climates []driven.Climate + listErr error + want []Choice + wantErr string + }{ + { + name: "friendly name and fallback", + climates: []driven.Climate{ + {EntityID: "climate.air_conditioner", FriendlyName: "AC"}, + {EntityID: "climate.bedroom"}, + }, + want: []Choice{ + {Label: "AC", Value: "climate.air_conditioner"}, + {Label: "climate.bedroom", Value: "climate.bedroom"}, + }, + }, + { + name: "ListClimates error", + listErr: errors.New("boom"), + wantErr: "autocomplete climates: boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := newTestCommandApp(&mockHAGateway{ + listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) { + if tt.listErr != nil { + return nil, tt.listErr + } + return tt.climates, nil + }, + }, &mockAIGateway{}) + + got, err := app.AutocompleteClimates(context.Background()) + if tt.wantErr != "" { + if err == nil || err.Error() != tt.wantErr { + t.Fatalf("AutocompleteClimates() error = %v, want %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("AutocompleteClimates() error = %v", err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Fatalf("AutocompleteClimates() = %#v, want %#v", got, tt.want) + } + }) + } +} + func TestCommandAppHandleAIQuery(t *testing.T) { store := modelstore.New() store.Set("llama3:latest") diff --git a/discord-bot/internal/core/ports/driven/ha.go b/discord-bot/internal/core/ports/driven/ha.go index 15b27e2..785b6ef 100644 --- a/discord-bot/internal/core/ports/driven/ha.go +++ b/discord-bot/internal/core/ports/driven/ha.go @@ -19,6 +19,18 @@ type HAGateway interface { TurnOffSwitch(ctx context.Context, entityID string) error // ToggleSwitch forwards a switch toggle request to ha-gateway. ToggleSwitch(ctx context.Context, entityID string) error + // ListClimates returns climate discovery data from ha-gateway. + ListClimates(ctx context.Context) ([]Climate, error) + // TurnOnClimate forwards a climate turn-on request to ha-gateway. + TurnOnClimate(ctx context.Context, entityID string) error + // TurnOffClimate forwards a climate turn-off request to ha-gateway. + TurnOffClimate(ctx context.Context, entityID string) error + // SetClimateHVACMode forwards a climate HVAC mode change request to ha-gateway. + SetClimateHVACMode(ctx context.Context, entityID, hvacMode string) error + // IncreaseClimateTemperature forwards a temperature step-up request to ha-gateway. + IncreaseClimateTemperature(ctx context.Context, entityID string) error + // DecreaseClimateTemperature forwards a temperature step-down request to ha-gateway. + DecreaseClimateTemperature(ctx context.Context, entityID string) error } // Light is the discovery-oriented light view exposed by ha-gateway. @@ -52,3 +64,29 @@ type Switch struct { // DeviceClass describes the semantic type when Home Assistant provides it. DeviceClass string } + +// Climate is the discovery-oriented climate view exposed by ha-gateway. +type Climate struct { + // EntityID is the Home Assistant entity identifier. + EntityID string + // FriendlyName is the user-facing name from Home Assistant. + FriendlyName string + // State is the current raw HVAC mode, e.g. "cool", "off", "heat_cool". + State string + // HVACModes lists the modes Home Assistant reports as supported. + HVACModes []string + // FanMode is the current fan mode, when set. + FanMode string + // FanModes lists the fan modes Home Assistant reports as supported. + FanModes []string + // CurrentTemperature is nil when no sensor reading is available. + CurrentTemperature *float32 + // TargetTemperature is nil when no target temperature is set. + TargetTemperature *float32 + // TargetTempStep is the increment Home Assistant expects for temperature changes. + TargetTempStep float32 + // MinTemp is the lower bound Home Assistant enforces for this entity. + MinTemp float32 + // MaxTemp is the upper bound Home Assistant enforces for this entity. + MaxTemp float32 +} diff --git a/gen/ai/v1/ai_grpc.pb.go b/gen/ai/v1/ai_grpc.pb.go index 4d25667..c94b782 100644 --- a/gen/ai/v1/ai_grpc.pb.go +++ b/gen/ai/v1/ai_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: ai/v1/ai.proto diff --git a/gen/ha/v1/climate.pb.go b/gen/ha/v1/climate.pb.go new file mode 100644 index 0000000..2248716 --- /dev/null +++ b/gen/ha/v1/climate.pb.go @@ -0,0 +1,475 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: ha/v1/climate.proto + +package hav1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ClimateRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClimateRequest) Reset() { + *x = ClimateRequest{} + mi := &file_ha_v1_climate_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClimateRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClimateRequest) ProtoMessage() {} + +func (x *ClimateRequest) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_climate_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClimateRequest.ProtoReflect.Descriptor instead. +func (*ClimateRequest) Descriptor() ([]byte, []int) { + return file_ha_v1_climate_proto_rawDescGZIP(), []int{0} +} + +func (x *ClimateRequest) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +type ClimateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + State *EntityState `protobuf:"bytes,1,opt,name=state,proto3" json:"state,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClimateResponse) Reset() { + *x = ClimateResponse{} + mi := &file_ha_v1_climate_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClimateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClimateResponse) ProtoMessage() {} + +func (x *ClimateResponse) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_climate_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClimateResponse.ProtoReflect.Descriptor instead. +func (*ClimateResponse) Descriptor() ([]byte, []int) { + return file_ha_v1_climate_proto_rawDescGZIP(), []int{1} +} + +func (x *ClimateResponse) GetState() *EntityState { + if x != nil { + return x.State + } + return nil +} + +type SetHVACModeRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + HvacMode string `protobuf:"bytes,2,opt,name=hvac_mode,json=hvacMode,proto3" json:"hvac_mode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetHVACModeRequest) Reset() { + *x = SetHVACModeRequest{} + mi := &file_ha_v1_climate_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetHVACModeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetHVACModeRequest) ProtoMessage() {} + +func (x *SetHVACModeRequest) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_climate_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetHVACModeRequest.ProtoReflect.Descriptor instead. +func (*SetHVACModeRequest) Descriptor() ([]byte, []int) { + return file_ha_v1_climate_proto_rawDescGZIP(), []int{2} +} + +func (x *SetHVACModeRequest) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +func (x *SetHVACModeRequest) GetHvacMode() string { + if x != nil { + return x.HvacMode + } + return "" +} + +type ClimateEntity struct { + state protoimpl.MessageState `protogen:"open.v1"` + EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` + FriendlyName string `protobuf:"bytes,2,opt,name=friendly_name,json=friendlyName,proto3" json:"friendly_name,omitempty"` + State string `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` + HvacModes []string `protobuf:"bytes,4,rep,name=hvac_modes,json=hvacModes,proto3" json:"hvac_modes,omitempty"` + FanMode string `protobuf:"bytes,5,opt,name=fan_mode,json=fanMode,proto3" json:"fan_mode,omitempty"` + FanModes []string `protobuf:"bytes,6,rep,name=fan_modes,json=fanModes,proto3" json:"fan_modes,omitempty"` + CurrentTemperature *float32 `protobuf:"fixed32,7,opt,name=current_temperature,json=currentTemperature,proto3,oneof" json:"current_temperature,omitempty"` + TargetTemperature *float32 `protobuf:"fixed32,8,opt,name=target_temperature,json=targetTemperature,proto3,oneof" json:"target_temperature,omitempty"` + TargetTempStep float32 `protobuf:"fixed32,9,opt,name=target_temp_step,json=targetTempStep,proto3" json:"target_temp_step,omitempty"` + MinTemp float32 `protobuf:"fixed32,10,opt,name=min_temp,json=minTemp,proto3" json:"min_temp,omitempty"` + MaxTemp float32 `protobuf:"fixed32,11,opt,name=max_temp,json=maxTemp,proto3" json:"max_temp,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ClimateEntity) Reset() { + *x = ClimateEntity{} + mi := &file_ha_v1_climate_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ClimateEntity) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClimateEntity) ProtoMessage() {} + +func (x *ClimateEntity) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_climate_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClimateEntity.ProtoReflect.Descriptor instead. +func (*ClimateEntity) Descriptor() ([]byte, []int) { + return file_ha_v1_climate_proto_rawDescGZIP(), []int{3} +} + +func (x *ClimateEntity) GetEntityId() string { + if x != nil { + return x.EntityId + } + return "" +} + +func (x *ClimateEntity) GetFriendlyName() string { + if x != nil { + return x.FriendlyName + } + return "" +} + +func (x *ClimateEntity) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *ClimateEntity) GetHvacModes() []string { + if x != nil { + return x.HvacModes + } + return nil +} + +func (x *ClimateEntity) GetFanMode() string { + if x != nil { + return x.FanMode + } + return "" +} + +func (x *ClimateEntity) GetFanModes() []string { + if x != nil { + return x.FanModes + } + return nil +} + +func (x *ClimateEntity) GetCurrentTemperature() float32 { + if x != nil && x.CurrentTemperature != nil { + return *x.CurrentTemperature + } + return 0 +} + +func (x *ClimateEntity) GetTargetTemperature() float32 { + if x != nil && x.TargetTemperature != nil { + return *x.TargetTemperature + } + return 0 +} + +func (x *ClimateEntity) GetTargetTempStep() float32 { + if x != nil { + return x.TargetTempStep + } + return 0 +} + +func (x *ClimateEntity) GetMinTemp() float32 { + if x != nil { + return x.MinTemp + } + return 0 +} + +func (x *ClimateEntity) GetMaxTemp() float32 { + if x != nil { + return x.MaxTemp + } + return 0 +} + +type ListClimatesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListClimatesRequest) Reset() { + *x = ListClimatesRequest{} + mi := &file_ha_v1_climate_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListClimatesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListClimatesRequest) ProtoMessage() {} + +func (x *ListClimatesRequest) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_climate_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListClimatesRequest.ProtoReflect.Descriptor instead. +func (*ListClimatesRequest) Descriptor() ([]byte, []int) { + return file_ha_v1_climate_proto_rawDescGZIP(), []int{4} +} + +type ListClimatesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Climates []*ClimateEntity `protobuf:"bytes,1,rep,name=climates,proto3" json:"climates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListClimatesResponse) Reset() { + *x = ListClimatesResponse{} + mi := &file_ha_v1_climate_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListClimatesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListClimatesResponse) ProtoMessage() {} + +func (x *ListClimatesResponse) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_climate_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListClimatesResponse.ProtoReflect.Descriptor instead. +func (*ListClimatesResponse) Descriptor() ([]byte, []int) { + return file_ha_v1_climate_proto_rawDescGZIP(), []int{5} +} + +func (x *ListClimatesResponse) GetClimates() []*ClimateEntity { + if x != nil { + return x.Climates + } + return nil +} + +var File_ha_v1_climate_proto protoreflect.FileDescriptor + +const file_ha_v1_climate_proto_rawDesc = "" + + "\n" + + "\x13ha/v1/climate.proto\x12\x05ha.v1\x1a\x12ha/v1/common.proto\"-\n" + + "\x0eClimateRequest\x12\x1b\n" + + "\tentity_id\x18\x01 \x01(\tR\bentityId\";\n" + + "\x0fClimateResponse\x12(\n" + + "\x05state\x18\x01 \x01(\v2\x12.ha.v1.EntityStateR\x05state\"N\n" + + "\x12SetHVACModeRequest\x12\x1b\n" + + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12\x1b\n" + + "\thvac_mode\x18\x02 \x01(\tR\bhvacMode\"\xb7\x03\n" + + "\rClimateEntity\x12\x1b\n" + + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12#\n" + + "\rfriendly_name\x18\x02 \x01(\tR\ffriendlyName\x12\x14\n" + + "\x05state\x18\x03 \x01(\tR\x05state\x12\x1d\n" + + "\n" + + "hvac_modes\x18\x04 \x03(\tR\thvacModes\x12\x19\n" + + "\bfan_mode\x18\x05 \x01(\tR\afanMode\x12\x1b\n" + + "\tfan_modes\x18\x06 \x03(\tR\bfanModes\x124\n" + + "\x13current_temperature\x18\a \x01(\x02H\x00R\x12currentTemperature\x88\x01\x01\x122\n" + + "\x12target_temperature\x18\b \x01(\x02H\x01R\x11targetTemperature\x88\x01\x01\x12(\n" + + "\x10target_temp_step\x18\t \x01(\x02R\x0etargetTempStep\x12\x19\n" + + "\bmin_temp\x18\n" + + " \x01(\x02R\aminTemp\x12\x19\n" + + "\bmax_temp\x18\v \x01(\x02R\amaxTempB\x16\n" + + "\x14_current_temperatureB\x15\n" + + "\x13_target_temperature\"\x15\n" + + "\x13ListClimatesRequest\"H\n" + + "\x14ListClimatesResponse\x120\n" + + "\bclimates\x18\x01 \x03(\v2\x14.ha.v1.ClimateEntityR\bclimates2\x9a\x03\n" + + "\x0eClimateService\x127\n" + + "\x06TurnOn\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x128\n" + + "\aTurnOff\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12D\n" + + "\x13IncreaseTemperature\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12D\n" + + "\x13DecreaseTemperature\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12@\n" + + "\vSetHVACMode\x12\x19.ha.v1.SetHVACModeRequest\x1a\x16.ha.v1.ClimateResponse\x12G\n" + + "\fListClimates\x12\x1a.ha.v1.ListClimatesRequest\x1a\x1b.ha.v1.ListClimatesResponseB4Z2gitea.nik4nao.com/nik/home-services/gen/ha/v1;hav1b\x06proto3" + +var ( + file_ha_v1_climate_proto_rawDescOnce sync.Once + file_ha_v1_climate_proto_rawDescData []byte +) + +func file_ha_v1_climate_proto_rawDescGZIP() []byte { + file_ha_v1_climate_proto_rawDescOnce.Do(func() { + file_ha_v1_climate_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ha_v1_climate_proto_rawDesc), len(file_ha_v1_climate_proto_rawDesc))) + }) + return file_ha_v1_climate_proto_rawDescData +} + +var file_ha_v1_climate_proto_msgTypes = make([]protoimpl.MessageInfo, 6) +var file_ha_v1_climate_proto_goTypes = []any{ + (*ClimateRequest)(nil), // 0: ha.v1.ClimateRequest + (*ClimateResponse)(nil), // 1: ha.v1.ClimateResponse + (*SetHVACModeRequest)(nil), // 2: ha.v1.SetHVACModeRequest + (*ClimateEntity)(nil), // 3: ha.v1.ClimateEntity + (*ListClimatesRequest)(nil), // 4: ha.v1.ListClimatesRequest + (*ListClimatesResponse)(nil), // 5: ha.v1.ListClimatesResponse + (*EntityState)(nil), // 6: ha.v1.EntityState +} +var file_ha_v1_climate_proto_depIdxs = []int32{ + 6, // 0: ha.v1.ClimateResponse.state:type_name -> ha.v1.EntityState + 3, // 1: ha.v1.ListClimatesResponse.climates:type_name -> ha.v1.ClimateEntity + 0, // 2: ha.v1.ClimateService.TurnOn:input_type -> ha.v1.ClimateRequest + 0, // 3: ha.v1.ClimateService.TurnOff:input_type -> ha.v1.ClimateRequest + 0, // 4: ha.v1.ClimateService.IncreaseTemperature:input_type -> ha.v1.ClimateRequest + 0, // 5: ha.v1.ClimateService.DecreaseTemperature:input_type -> ha.v1.ClimateRequest + 2, // 6: ha.v1.ClimateService.SetHVACMode:input_type -> ha.v1.SetHVACModeRequest + 4, // 7: ha.v1.ClimateService.ListClimates:input_type -> ha.v1.ListClimatesRequest + 1, // 8: ha.v1.ClimateService.TurnOn:output_type -> ha.v1.ClimateResponse + 1, // 9: ha.v1.ClimateService.TurnOff:output_type -> ha.v1.ClimateResponse + 1, // 10: ha.v1.ClimateService.IncreaseTemperature:output_type -> ha.v1.ClimateResponse + 1, // 11: ha.v1.ClimateService.DecreaseTemperature:output_type -> ha.v1.ClimateResponse + 1, // 12: ha.v1.ClimateService.SetHVACMode:output_type -> ha.v1.ClimateResponse + 5, // 13: ha.v1.ClimateService.ListClimates:output_type -> ha.v1.ListClimatesResponse + 8, // [8:14] is the sub-list for method output_type + 2, // [2:8] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_ha_v1_climate_proto_init() } +func file_ha_v1_climate_proto_init() { + if File_ha_v1_climate_proto != nil { + return + } + file_ha_v1_common_proto_init() + file_ha_v1_climate_proto_msgTypes[3].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ha_v1_climate_proto_rawDesc), len(file_ha_v1_climate_proto_rawDesc)), + NumEnums: 0, + NumMessages: 6, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ha_v1_climate_proto_goTypes, + DependencyIndexes: file_ha_v1_climate_proto_depIdxs, + MessageInfos: file_ha_v1_climate_proto_msgTypes, + }.Build() + File_ha_v1_climate_proto = out.File + file_ha_v1_climate_proto_goTypes = nil + file_ha_v1_climate_proto_depIdxs = nil +} diff --git a/gen/ha/v1/climate_grpc.pb.go b/gen/ha/v1/climate_grpc.pb.go new file mode 100644 index 0000000..315989b --- /dev/null +++ b/gen/ha/v1/climate_grpc.pb.go @@ -0,0 +1,311 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: ha/v1/climate.proto + +package hav1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + ClimateService_TurnOn_FullMethodName = "/ha.v1.ClimateService/TurnOn" + ClimateService_TurnOff_FullMethodName = "/ha.v1.ClimateService/TurnOff" + ClimateService_IncreaseTemperature_FullMethodName = "/ha.v1.ClimateService/IncreaseTemperature" + ClimateService_DecreaseTemperature_FullMethodName = "/ha.v1.ClimateService/DecreaseTemperature" + ClimateService_SetHVACMode_FullMethodName = "/ha.v1.ClimateService/SetHVACMode" + ClimateService_ListClimates_FullMethodName = "/ha.v1.ClimateService/ListClimates" +) + +// ClimateServiceClient is the client API for ClimateService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type ClimateServiceClient interface { + TurnOn(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) + TurnOff(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) + IncreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) + DecreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) + SetHVACMode(ctx context.Context, in *SetHVACModeRequest, opts ...grpc.CallOption) (*ClimateResponse, error) + ListClimates(ctx context.Context, in *ListClimatesRequest, opts ...grpc.CallOption) (*ListClimatesResponse, error) +} + +type climateServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewClimateServiceClient(cc grpc.ClientConnInterface) ClimateServiceClient { + return &climateServiceClient{cc} +} + +func (c *climateServiceClient) TurnOn(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClimateResponse) + err := c.cc.Invoke(ctx, ClimateService_TurnOn_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *climateServiceClient) TurnOff(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClimateResponse) + err := c.cc.Invoke(ctx, ClimateService_TurnOff_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *climateServiceClient) IncreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClimateResponse) + err := c.cc.Invoke(ctx, ClimateService_IncreaseTemperature_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *climateServiceClient) DecreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClimateResponse) + err := c.cc.Invoke(ctx, ClimateService_DecreaseTemperature_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *climateServiceClient) SetHVACMode(ctx context.Context, in *SetHVACModeRequest, opts ...grpc.CallOption) (*ClimateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ClimateResponse) + err := c.cc.Invoke(ctx, ClimateService_SetHVACMode_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *climateServiceClient) ListClimates(ctx context.Context, in *ListClimatesRequest, opts ...grpc.CallOption) (*ListClimatesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListClimatesResponse) + err := c.cc.Invoke(ctx, ClimateService_ListClimates_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// ClimateServiceServer is the server API for ClimateService service. +// All implementations must embed UnimplementedClimateServiceServer +// for forward compatibility. +type ClimateServiceServer interface { + TurnOn(context.Context, *ClimateRequest) (*ClimateResponse, error) + TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error) + IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) + DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) + SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) + ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error) + mustEmbedUnimplementedClimateServiceServer() +} + +// UnimplementedClimateServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedClimateServiceServer struct{} + +func (UnimplementedClimateServiceServer) TurnOn(context.Context, *ClimateRequest) (*ClimateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method TurnOn not implemented") +} +func (UnimplementedClimateServiceServer) TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method TurnOff not implemented") +} +func (UnimplementedClimateServiceServer) IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method IncreaseTemperature not implemented") +} +func (UnimplementedClimateServiceServer) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DecreaseTemperature not implemented") +} +func (UnimplementedClimateServiceServer) SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetHVACMode not implemented") +} +func (UnimplementedClimateServiceServer) ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListClimates not implemented") +} +func (UnimplementedClimateServiceServer) mustEmbedUnimplementedClimateServiceServer() {} +func (UnimplementedClimateServiceServer) testEmbeddedByValue() {} + +// UnsafeClimateServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to ClimateServiceServer will +// result in compilation errors. +type UnsafeClimateServiceServer interface { + mustEmbedUnimplementedClimateServiceServer() +} + +func RegisterClimateServiceServer(s grpc.ServiceRegistrar, srv ClimateServiceServer) { + // If the following call panics, it indicates UnimplementedClimateServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&ClimateService_ServiceDesc, srv) +} + +func _ClimateService_TurnOn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClimateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClimateServiceServer).TurnOn(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClimateService_TurnOn_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClimateServiceServer).TurnOn(ctx, req.(*ClimateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClimateService_TurnOff_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClimateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClimateServiceServer).TurnOff(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClimateService_TurnOff_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClimateServiceServer).TurnOff(ctx, req.(*ClimateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClimateService_IncreaseTemperature_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClimateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClimateServiceServer).IncreaseTemperature(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClimateService_IncreaseTemperature_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClimateServiceServer).IncreaseTemperature(ctx, req.(*ClimateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClimateService_DecreaseTemperature_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ClimateRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClimateServiceServer).DecreaseTemperature(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClimateService_DecreaseTemperature_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClimateServiceServer).DecreaseTemperature(ctx, req.(*ClimateRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClimateService_SetHVACMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetHVACModeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClimateServiceServer).SetHVACMode(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClimateService_SetHVACMode_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClimateServiceServer).SetHVACMode(ctx, req.(*SetHVACModeRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _ClimateService_ListClimates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListClimatesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ClimateServiceServer).ListClimates(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ClimateService_ListClimates_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ClimateServiceServer).ListClimates(ctx, req.(*ListClimatesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// ClimateService_ServiceDesc is the grpc.ServiceDesc for ClimateService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var ClimateService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ha.v1.ClimateService", + HandlerType: (*ClimateServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "TurnOn", + Handler: _ClimateService_TurnOn_Handler, + }, + { + MethodName: "TurnOff", + Handler: _ClimateService_TurnOff_Handler, + }, + { + MethodName: "IncreaseTemperature", + Handler: _ClimateService_IncreaseTemperature_Handler, + }, + { + MethodName: "DecreaseTemperature", + Handler: _ClimateService_DecreaseTemperature_Handler, + }, + { + MethodName: "SetHVACMode", + Handler: _ClimateService_SetHVACMode_Handler, + }, + { + MethodName: "ListClimates", + Handler: _ClimateService_ListClimates_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ha/v1/climate.proto", +} diff --git a/gen/ha/v1/entity_grpc.pb.go b/gen/ha/v1/entity_grpc.pb.go index 01fa1b1..9a73bea 100644 --- a/gen/ha/v1/entity_grpc.pb.go +++ b/gen/ha/v1/entity_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: ha/v1/entity.proto diff --git a/gen/ha/v1/event_grpc.pb.go b/gen/ha/v1/event_grpc.pb.go index 50fa593..7f3b089 100644 --- a/gen/ha/v1/event_grpc.pb.go +++ b/gen/ha/v1/event_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: ha/v1/event.proto diff --git a/gen/ha/v1/light_grpc.pb.go b/gen/ha/v1/light_grpc.pb.go index 01c5c4c..dcca360 100644 --- a/gen/ha/v1/light_grpc.pb.go +++ b/gen/ha/v1/light_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: ha/v1/light.proto diff --git a/gen/ha/v1/remote.pb.go b/gen/ha/v1/remote.pb.go new file mode 100644 index 0000000..d1a37b5 --- /dev/null +++ b/gen/ha/v1/remote.pb.go @@ -0,0 +1,182 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: ha/v1/remote.proto + +package hav1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type SendCommandRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + DeviceId string `protobuf:"bytes,1,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"` + Command string `protobuf:"bytes,2,opt,name=command,proto3" json:"command,omitempty"` + CommandType string `protobuf:"bytes,3,opt,name=command_type,json=commandType,proto3" json:"command_type,omitempty"` // "command" or "customize" + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendCommandRequest) Reset() { + *x = SendCommandRequest{} + mi := &file_ha_v1_remote_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendCommandRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendCommandRequest) ProtoMessage() {} + +func (x *SendCommandRequest) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_remote_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendCommandRequest.ProtoReflect.Descriptor instead. +func (*SendCommandRequest) Descriptor() ([]byte, []int) { + return file_ha_v1_remote_proto_rawDescGZIP(), []int{0} +} + +func (x *SendCommandRequest) GetDeviceId() string { + if x != nil { + return x.DeviceId + } + return "" +} + +func (x *SendCommandRequest) GetCommand() string { + if x != nil { + return x.Command + } + return "" +} + +func (x *SendCommandRequest) GetCommandType() string { + if x != nil { + return x.CommandType + } + return "" +} + +type SendCommandResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendCommandResponse) Reset() { + *x = SendCommandResponse{} + mi := &file_ha_v1_remote_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendCommandResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendCommandResponse) ProtoMessage() {} + +func (x *SendCommandResponse) ProtoReflect() protoreflect.Message { + mi := &file_ha_v1_remote_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendCommandResponse.ProtoReflect.Descriptor instead. +func (*SendCommandResponse) Descriptor() ([]byte, []int) { + return file_ha_v1_remote_proto_rawDescGZIP(), []int{1} +} + +var File_ha_v1_remote_proto protoreflect.FileDescriptor + +const file_ha_v1_remote_proto_rawDesc = "" + + "\n" + + "\x12ha/v1/remote.proto\x12\x05ha.v1\"n\n" + + "\x12SendCommandRequest\x12\x1b\n" + + "\tdevice_id\x18\x01 \x01(\tR\bdeviceId\x12\x18\n" + + "\acommand\x18\x02 \x01(\tR\acommand\x12!\n" + + "\fcommand_type\x18\x03 \x01(\tR\vcommandType\"\x15\n" + + "\x13SendCommandResponse2U\n" + + "\rRemoteService\x12D\n" + + "\vSendCommand\x12\x19.ha.v1.SendCommandRequest\x1a\x1a.ha.v1.SendCommandResponseB4Z2gitea.nik4nao.com/nik/home-services/gen/ha/v1;hav1b\x06proto3" + +var ( + file_ha_v1_remote_proto_rawDescOnce sync.Once + file_ha_v1_remote_proto_rawDescData []byte +) + +func file_ha_v1_remote_proto_rawDescGZIP() []byte { + file_ha_v1_remote_proto_rawDescOnce.Do(func() { + file_ha_v1_remote_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_ha_v1_remote_proto_rawDesc), len(file_ha_v1_remote_proto_rawDesc))) + }) + return file_ha_v1_remote_proto_rawDescData +} + +var file_ha_v1_remote_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_ha_v1_remote_proto_goTypes = []any{ + (*SendCommandRequest)(nil), // 0: ha.v1.SendCommandRequest + (*SendCommandResponse)(nil), // 1: ha.v1.SendCommandResponse +} +var file_ha_v1_remote_proto_depIdxs = []int32{ + 0, // 0: ha.v1.RemoteService.SendCommand:input_type -> ha.v1.SendCommandRequest + 1, // 1: ha.v1.RemoteService.SendCommand:output_type -> ha.v1.SendCommandResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_ha_v1_remote_proto_init() } +func file_ha_v1_remote_proto_init() { + if File_ha_v1_remote_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_ha_v1_remote_proto_rawDesc), len(file_ha_v1_remote_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_ha_v1_remote_proto_goTypes, + DependencyIndexes: file_ha_v1_remote_proto_depIdxs, + MessageInfos: file_ha_v1_remote_proto_msgTypes, + }.Build() + File_ha_v1_remote_proto = out.File + file_ha_v1_remote_proto_goTypes = nil + file_ha_v1_remote_proto_depIdxs = nil +} diff --git a/gen/ha/v1/remote_grpc.pb.go b/gen/ha/v1/remote_grpc.pb.go new file mode 100644 index 0000000..94c0601 --- /dev/null +++ b/gen/ha/v1/remote_grpc.pb.go @@ -0,0 +1,121 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: ha/v1/remote.proto + +package hav1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + RemoteService_SendCommand_FullMethodName = "/ha.v1.RemoteService/SendCommand" +) + +// RemoteServiceClient is the client API for RemoteService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type RemoteServiceClient interface { + SendCommand(ctx context.Context, in *SendCommandRequest, opts ...grpc.CallOption) (*SendCommandResponse, error) +} + +type remoteServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewRemoteServiceClient(cc grpc.ClientConnInterface) RemoteServiceClient { + return &remoteServiceClient{cc} +} + +func (c *remoteServiceClient) SendCommand(ctx context.Context, in *SendCommandRequest, opts ...grpc.CallOption) (*SendCommandResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendCommandResponse) + err := c.cc.Invoke(ctx, RemoteService_SendCommand_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// RemoteServiceServer is the server API for RemoteService service. +// All implementations must embed UnimplementedRemoteServiceServer +// for forward compatibility. +type RemoteServiceServer interface { + SendCommand(context.Context, *SendCommandRequest) (*SendCommandResponse, error) + mustEmbedUnimplementedRemoteServiceServer() +} + +// UnimplementedRemoteServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedRemoteServiceServer struct{} + +func (UnimplementedRemoteServiceServer) SendCommand(context.Context, *SendCommandRequest) (*SendCommandResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SendCommand not implemented") +} +func (UnimplementedRemoteServiceServer) mustEmbedUnimplementedRemoteServiceServer() {} +func (UnimplementedRemoteServiceServer) testEmbeddedByValue() {} + +// UnsafeRemoteServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to RemoteServiceServer will +// result in compilation errors. +type UnsafeRemoteServiceServer interface { + mustEmbedUnimplementedRemoteServiceServer() +} + +func RegisterRemoteServiceServer(s grpc.ServiceRegistrar, srv RemoteServiceServer) { + // If the following call panics, it indicates UnimplementedRemoteServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&RemoteService_ServiceDesc, srv) +} + +func _RemoteService_SendCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendCommandRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RemoteServiceServer).SendCommand(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RemoteService_SendCommand_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RemoteServiceServer).SendCommand(ctx, req.(*SendCommandRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// RemoteService_ServiceDesc is the grpc.ServiceDesc for RemoteService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var RemoteService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "ha.v1.RemoteService", + HandlerType: (*RemoteServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SendCommand", + Handler: _RemoteService_SendCommand_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "ha/v1/remote.proto", +} diff --git a/gen/ha/v1/switch_grpc.pb.go b/gen/ha/v1/switch_grpc.pb.go index a89c523..5391fe6 100644 --- a/gen/ha/v1/switch_grpc.pb.go +++ b/gen/ha/v1/switch_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: ha/v1/switch.proto diff --git a/ha-gateway/.env.example b/ha-gateway/.env.example index b22c6bd..c930e0a 100644 --- a/ha-gateway/.env.example +++ b/ha-gateway/.env.example @@ -4,3 +4,5 @@ HA_TOKEN=your-long-lived-token-here OTEL_ENDPOINT= LOG_LEVEL=info LOG_FORMAT=text +SWITCHBOT_TOKEN= +SWITCHBOT_SECRET= diff --git a/ha-gateway/README.md b/ha-gateway/README.md index 586ff4b..3ecf369 100644 --- a/ha-gateway/README.md +++ b/ha-gateway/README.md @@ -8,10 +8,13 @@ REST details out of clients. 1. The service loads `.env`, configures logging and telemetry, and starts gRPC on `GRPC_PORT`. -2. App services are wired to a Home Assistant REST adapter. -3. Light and switch discovery caches are refreshed during startup when possible. -4. gRPC clients call entity, light, switch, or event services. -5. The adapter maps requests to Home Assistant REST state and service calls. +2. App services are wired to a Home Assistant REST adapter and, when + `SWITCHBOT_TOKEN`/`SWITCHBOT_SECRET` are set, a SwitchBot Cloud adapter. +3. Light, switch, and climate discovery caches are refreshed during startup + when possible. +4. gRPC clients call entity, light, switch, climate, remote, or event services. +5. The adapter maps requests to Home Assistant REST state/service calls, or + for `RemoteService`, signed SwitchBot Cloud Open API requests. ## gRPC API @@ -29,6 +32,13 @@ Implemented: - `SwitchService.TurnOff` - `SwitchService.Toggle` - `SwitchService.ListSwitches` +- `ClimateService.TurnOn` +- `ClimateService.TurnOff` +- `ClimateService.IncreaseTemperature` +- `ClimateService.DecreaseTemperature` +- `ClimateService.SetHVACMode` +- `ClimateService.ListClimates` +- `RemoteService.SendCommand` Stubbed: @@ -49,6 +59,8 @@ Environment variables: | `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` | | `LOG_FORMAT` | `json` | `json` or `text` | +| `SWITCHBOT_TOKEN` | empty | optional; enables SwitchBot Cloud custom IR commands | +| `SWITCHBOT_SECRET` | empty | optional; enables SwitchBot Cloud custom IR commands | Example env file: [.env.example](https://gitea.nik4nao.com/nik/home-services/src/branch/main/ha-gateway/.env.example) @@ -84,6 +96,8 @@ grpcurl -plaintext -d '{}' localhost:50051 ha.v1.LightService/ListLights grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \ localhost:50051 ha.v1.LightService/TurnOn +grpcurl -plaintext -d '{}' localhost:50051 ha.v1.ClimateService/ListClimates + grpcurl -plaintext -d '{}' localhost:50051 grpc.health.v1.Health/Check ``` @@ -112,7 +126,8 @@ docker build -f ha-gateway/Dockerfile --build-arg VERSION=$(git rev-parse --shor cmd/gateway/ # process entrypoint and wiring internal/adapters/primary/grpc/ # gRPC service implementations internal/adapters/secondary/ha/ # Home Assistant REST adapter -internal/app/ # entity, light, and switch orchestration +internal/adapters/secondary/switchbot/ # SwitchBot Cloud Open API adapter +internal/app/ # entity, light, switch, climate, and remote orchestration internal/config/ # environment loading internal/core/domain/ # domain types internal/core/ports/ # driving and driven interfaces diff --git a/ha-gateway/cmd/gateway/main.go b/ha-gateway/cmd/gateway/main.go index e55b5bc..5135e03 100644 --- a/ha-gateway/cmd/gateway/main.go +++ b/ha-gateway/cmd/gateway/main.go @@ -22,6 +22,7 @@ import ( 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" @@ -60,6 +61,7 @@ func main() { "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) @@ -79,11 +81,14 @@ func main() { } 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) @@ -91,6 +96,9 @@ func main() { 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()), @@ -116,6 +124,8 @@ func main() { 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) diff --git a/ha-gateway/internal/adapters/primary/grpc/climate.go b/ha-gateway/internal/adapters/primary/grpc/climate.go new file mode 100644 index 0000000..c2d9755 --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/climate.go @@ -0,0 +1,77 @@ +package grpc + +import ( + "context" + + hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driving" +) + +type ClimateGRPC struct { + hav1.UnimplementedClimateServiceServer + svc driving.ClimateService +} + +// NewClimateGRPC constructs the gRPC adapter for ClimateService. +func NewClimateGRPC(svc driving.ClimateService) *ClimateGRPC { + return &ClimateGRPC{svc: svc} +} + +// ListClimates returns discovery-oriented climate metadata for clients. +func (h *ClimateGRPC) ListClimates(ctx context.Context, req *hav1.ListClimatesRequest) (*hav1.ListClimatesResponse, error) { + climates, err := h.svc.ListClimates(ctx) + if err != nil { + return nil, grpcError(err) + } + out := make([]*hav1.ClimateEntity, 0, len(climates)) + for _, c := range climates { + out = append(out, domainClimateToProto(c)) + } + return &hav1.ListClimatesResponse{Climates: out}, nil +} + +// TurnOn maps a protobuf turn-on request into a domain entity ID. +func (h *ClimateGRPC) TurnOn(ctx context.Context, req *hav1.ClimateRequest) (*hav1.ClimateResponse, error) { + s, err := h.svc.TurnOn(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil +} + +// TurnOff maps a protobuf turn-off request into a domain entity ID. +func (h *ClimateGRPC) TurnOff(ctx context.Context, req *hav1.ClimateRequest) (*hav1.ClimateResponse, error) { + s, err := h.svc.TurnOff(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil +} + +// IncreaseTemperature forwards a temperature step-up request to the domain service. +func (h *ClimateGRPC) IncreaseTemperature(ctx context.Context, req *hav1.ClimateRequest) (*hav1.ClimateResponse, error) { + s, err := h.svc.IncreaseTemperature(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil +} + +// DecreaseTemperature forwards a temperature step-down request to the domain service. +func (h *ClimateGRPC) DecreaseTemperature(ctx context.Context, req *hav1.ClimateRequest) (*hav1.ClimateResponse, error) { + s, err := h.svc.DecreaseTemperature(ctx, domain.EntityID(req.EntityId)) + if err != nil { + return nil, grpcError(err) + } + return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil +} + +// SetHVACMode forwards an HVAC mode change request to the domain service. +func (h *ClimateGRPC) SetHVACMode(ctx context.Context, req *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) { + s, err := h.svc.SetHVACMode(ctx, domain.EntityID(req.EntityId), req.HvacMode) + if err != nil { + return nil, grpcError(err) + } + return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil +} diff --git a/ha-gateway/internal/adapters/primary/grpc/climate_test.go b/ha-gateway/internal/adapters/primary/grpc/climate_test.go new file mode 100644 index 0000000..bb7c2b3 --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/climate_test.go @@ -0,0 +1,379 @@ +package grpc + +import ( + "context" + "errors" + "net" + "testing" + "time" + + hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type mockClimateService struct { + turnOnFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + increaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + setHVACModeFunc func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) + listClimatesFunc func(ctx context.Context) ([]domain.Climate, error) + refreshFunc func(ctx context.Context) error +} + +func (m *mockClimateService) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.turnOnFunc == nil { + return nil, nil + } + return m.turnOnFunc(ctx, id) +} + +func (m *mockClimateService) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.turnOffFunc == nil { + return nil, nil + } + return m.turnOffFunc(ctx, id) +} + +func (m *mockClimateService) IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.increaseTemperatureFunc == nil { + return nil, nil + } + return m.increaseTemperatureFunc(ctx, id) +} + +func (m *mockClimateService) DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if m.decreaseTemperatureFunc == nil { + return nil, nil + } + return m.decreaseTemperatureFunc(ctx, id) +} + +func (m *mockClimateService) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) { + if m.setHVACModeFunc == nil { + return nil, nil + } + return m.setHVACModeFunc(ctx, id, hvacMode) +} + +func (m *mockClimateService) ListClimates(ctx context.Context) ([]domain.Climate, error) { + if m.listClimatesFunc == nil { + return nil, nil + } + return m.listClimatesFunc(ctx) +} + +func (m *mockClimateService) Refresh(ctx context.Context) error { + if m.refreshFunc == nil { + return nil + } + return m.refreshFunc(ctx) +} + +func TestClimateGRPCTurnOn(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "not found maps to codes.NotFound", err: ErrNotFound, wantCode: codes.NotFound}, + {name: "generic error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID domain.EntityID + conn := newClimateTestClientConn(t, &mockClimateService{ + turnOnFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + gotID = id + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]string{"friendly_name": "Air Conditioner"}, + LastChanged: now, + LastUpdated: now, + }, nil + }, + }) + client := hav1.NewClimateServiceClient(conn) + + resp, err := client.TurnOn(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + if gotID != "climate.air_conditioner" { + t.Fatalf("TurnOn id = %q, want %q", gotID, "climate.air_conditioner") + } + if resp.GetState().GetEntityId() != "climate.air_conditioner" || resp.GetState().GetState() != "cool" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestClimateGRPCTurnOff(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := newClimateTestClientConn(t, &mockClimateService{ + turnOffFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{EntityID: "climate.air_conditioner", State: "off"}, nil + }, + }) + client := hav1.NewClimateServiceClient(conn) + + resp, err := client.TurnOff(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + if resp.GetState().GetState() != "off" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestClimateGRPCIncreaseTemperature(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID domain.EntityID + conn := newClimateTestClientConn(t, &mockClimateService{ + increaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + gotID = id + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil + }, + }) + client := hav1.NewClimateServiceClient(conn) + + resp, err := client.IncreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + if gotID != "climate.air_conditioner" { + t.Fatalf("IncreaseTemperature id = %q, want %q", gotID, "climate.air_conditioner") + } + if resp.GetState().GetEntityId() != "climate.air_conditioner" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestClimateGRPCDecreaseTemperature(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn := newClimateTestClientConn(t, &mockClimateService{ + decreaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) { + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil + }, + }) + client := hav1.NewClimateServiceClient(conn) + + resp, err := client.DecreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + if resp.GetState().GetEntityId() != "climate.air_conditioner" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestClimateGRPCSetHVACMode(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotID domain.EntityID + var gotMode string + conn := newClimateTestClientConn(t, &mockClimateService{ + setHVACModeFunc: func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) { + gotID = id + gotMode = hvacMode + if tt.err != nil { + return nil, tt.err + } + return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil + }, + }) + client := hav1.NewClimateServiceClient(conn) + + resp, err := client.SetHVACMode(context.Background(), &hav1.SetHVACModeRequest{EntityId: "climate.air_conditioner", HvacMode: "cool"}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + if gotID != "climate.air_conditioner" || gotMode != "cool" { + t.Fatalf("SetHVACMode id/mode = %q/%q, want %q/%q", gotID, gotMode, "climate.air_conditioner", "cool") + } + if resp.GetState().GetEntityId() != "climate.air_conditioner" { + t.Fatalf("response state = %#v", resp.GetState()) + } + }) + } +} + +func TestClimateGRPCListClimates(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path with multiple climates", wantCode: codes.OK}, + {name: "error path", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current := 21.5 + target := 22.0 + conn := newClimateTestClientConn(t, &mockClimateService{ + listClimatesFunc: func(ctx context.Context) ([]domain.Climate, error) { + if tt.err != nil { + return nil, tt.err + } + return []domain.Climate{ + { + EntityID: "climate.air_conditioner", + FriendlyName: "Air Conditioner", + State: "cool", + HVACModes: []string{"cool", "heat", "off"}, + FanMode: "auto", + FanModes: []string{"auto", "low"}, + CurrentTemperature: ¤t, + TargetTemperature: &target, + TargetTempStep: 1, + MinTemp: 7, + MaxTemp: 35, + }, + {EntityID: "climate.bedroom", State: "off"}, + }, nil + }, + }) + client := hav1.NewClimateServiceClient(conn) + + resp, err := client.ListClimates(context.Background(), &hav1.ListClimatesRequest{}) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + if len(resp.GetClimates()) != 2 { + t.Fatalf("len(climates) = %d, want 2", len(resp.GetClimates())) + } + first := resp.GetClimates()[0] + if first.GetEntityId() != "climate.air_conditioner" { + t.Fatalf("climates[0].EntityId = %q, want %q", first.GetEntityId(), "climate.air_conditioner") + } + if first.GetCurrentTemperature() != 21.5 || first.GetTargetTemperature() != 22.0 { + t.Fatalf("climates[0] temperatures = (%v, %v), want (21.5, 22.0)", first.GetCurrentTemperature(), first.GetTargetTemperature()) + } + second := resp.GetClimates()[1] + if second.CurrentTemperature != nil || second.TargetTemperature != nil { + t.Fatalf("climates[1] should have no temperature set, got current=%v target=%v", second.GetCurrentTemperature(), second.GetTargetTemperature()) + } + }) + } +} + +func newClimateTestClientConn(t *testing.T, svc *mockClimateService) *grpc.ClientConn { + t.Helper() + + lis := bufconn.Listen(testBufSize) + server := grpc.NewServer() + hav1.RegisterClimateServiceServer(server, NewClimateGRPC(svc)) + go func() { + _ = server.Serve(lis) + }() + t.Cleanup(func() { + server.Stop() + _ = lis.Close() + }) + + conn, err := grpc.DialContext( + context.Background(), + "bufnet", + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.DialContext() error = %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + return conn +} diff --git a/ha-gateway/internal/adapters/primary/grpc/mapping.go b/ha-gateway/internal/adapters/primary/grpc/mapping.go index 55af6a9..a96ca8c 100644 --- a/ha-gateway/internal/adapters/primary/grpc/mapping.go +++ b/ha-gateway/internal/adapters/primary/grpc/mapping.go @@ -83,3 +83,29 @@ func domainSwitchToProto(s domain.Switch) *hav1.SwitchEntity { DeviceClass: s.DeviceClass, } } + +// domainClimateToProto exposes climate discovery metadata over gRPC, keeping +// current/target temperature as optional so clients can distinguish "no +// sensor reading" from 0°. +func domainClimateToProto(c domain.Climate) *hav1.ClimateEntity { + e := &hav1.ClimateEntity{ + EntityId: string(c.EntityID), + FriendlyName: c.FriendlyName, + State: c.State, + HvacModes: c.HVACModes, + FanMode: c.FanMode, + FanModes: c.FanModes, + TargetTempStep: float32(c.TargetTempStep), + MinTemp: float32(c.MinTemp), + MaxTemp: float32(c.MaxTemp), + } + if c.CurrentTemperature != nil { + v := float32(*c.CurrentTemperature) + e.CurrentTemperature = &v + } + if c.TargetTemperature != nil { + v := float32(*c.TargetTemperature) + e.TargetTemperature = &v + } + return e +} diff --git a/ha-gateway/internal/adapters/primary/grpc/remote.go b/ha-gateway/internal/adapters/primary/grpc/remote.go new file mode 100644 index 0000000..d2c6247 --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/remote.go @@ -0,0 +1,26 @@ +package grpc + +import ( + "context" + + hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driving" +) + +type RemoteGRPC struct { + hav1.UnimplementedRemoteServiceServer + svc driving.RemoteService +} + +// NewRemoteGRPC constructs the gRPC adapter for RemoteService. +func NewRemoteGRPC(svc driving.RemoteService) *RemoteGRPC { + return &RemoteGRPC{svc: svc} +} + +// SendCommand forwards a SwitchBot Cloud device command to the domain service. +func (h *RemoteGRPC) SendCommand(ctx context.Context, req *hav1.SendCommandRequest) (*hav1.SendCommandResponse, error) { + if err := h.svc.SendCommand(ctx, req.DeviceId, req.Command, req.CommandType); err != nil { + return nil, grpcError(err) + } + return &hav1.SendCommandResponse{}, nil +} diff --git a/ha-gateway/internal/adapters/primary/grpc/remote_test.go b/ha-gateway/internal/adapters/primary/grpc/remote_test.go new file mode 100644 index 0000000..2137f3b --- /dev/null +++ b/ha-gateway/internal/adapters/primary/grpc/remote_test.go @@ -0,0 +1,100 @@ +package grpc + +import ( + "context" + "errors" + "net" + "testing" + + hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" +) + +type mockRemoteService struct { + sendCommandFunc func(ctx context.Context, deviceID, command, commandType string) error +} + +func (m *mockRemoteService) SendCommand(ctx context.Context, deviceID, command, commandType string) error { + if m.sendCommandFunc == nil { + return nil + } + return m.sendCommandFunc(ctx, deviceID, command, commandType) +} + +func TestRemoteGRPCSendCommand(t *testing.T) { + tests := []struct { + name string + err error + wantCode codes.Code + }{ + {name: "happy path", wantCode: codes.OK}, + {name: "generic error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotDeviceID, gotCommand, gotCommandType string + conn := newRemoteTestClientConn(t, &mockRemoteService{ + sendCommandFunc: func(ctx context.Context, deviceID, command, commandType string) error { + gotDeviceID = deviceID + gotCommand = command + gotCommandType = commandType + return tt.err + }, + }) + client := hav1.NewRemoteServiceClient(conn) + + _, err := client.SendCommand(context.Background(), &hav1.SendCommandRequest{ + DeviceId: "dyson-id", + Command: "turnOn", + CommandType: "command", + }) + if status.Code(err) != tt.wantCode { + t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode) + } + if tt.wantCode != codes.OK { + return + } + + if gotDeviceID != "dyson-id" || gotCommand != "turnOn" || gotCommandType != "command" { + t.Fatalf("SendCommand args = (%q, %q, %q), want (%q, %q, %q)", + gotDeviceID, gotCommand, gotCommandType, "dyson-id", "turnOn", "command") + } + }) + } +} + +func newRemoteTestClientConn(t *testing.T, svc *mockRemoteService) *grpc.ClientConn { + t.Helper() + + lis := bufconn.Listen(testBufSize) + server := grpc.NewServer() + hav1.RegisterRemoteServiceServer(server, NewRemoteGRPC(svc)) + go func() { + _ = server.Serve(lis) + }() + t.Cleanup(func() { + server.Stop() + _ = lis.Close() + }) + + conn, err := grpc.DialContext( + context.Background(), + "bufnet", + grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.DialContext() error = %v", err) + } + t.Cleanup(func() { + _ = conn.Close() + }) + return conn +} diff --git a/ha-gateway/internal/adapters/secondary/switchbot/client.go b/ha-gateway/internal/adapters/secondary/switchbot/client.go new file mode 100644 index 0000000..faa230b --- /dev/null +++ b/ha-gateway/internal/adapters/secondary/switchbot/client.go @@ -0,0 +1,193 @@ +package switchbot + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + "time" + + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" +) + +const defaultBaseURL = "https://api.switch-bot.com" + +// Client implements the SwitchBot driven port over SwitchBot Cloud's Open API. +type Client struct { + token string + secret string + baseURL string + httpClient *http.Client + log *slog.Logger +} + +// NewClient constructs a SwitchBot Cloud REST client. Token/secret are +// optional at this layer — SendCommand short-circuits with a clear error when +// either is unset rather than failing gateway startup. +func NewClient(cfg *config.Config, log *slog.Logger) *Client { + return &Client{ + token: cfg.SwitchBotToken, + secret: cfg.SwitchBotSecret, + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: 20 * time.Second}, + log: log, + } +} + +type commandRequest struct { + Command string `json:"command"` + Parameter string `json:"parameter"` + CommandType string `json:"commandType"` +} + +// apiEnvelope is the outer response shape SwitchBot Cloud wraps every reply in. +type apiEnvelope struct { + StatusCode int `json:"statusCode"` + Message string `json:"message"` + Body json.RawMessage `json:"body"` +} + +// apiError maps SwitchBot's documented statusCode values to clearer messages +// than a raw passthrough of their (often terse) message field. +type apiError struct { + StatusCode int + Message string +} + +func (e *apiError) Error() string { + switch e.StatusCode { + case 151: + return "SwitchBot device type error (151)" + case 152: + return "SwitchBot device not found (152)" + case 160: + return "SwitchBot command is not supported by this device (160)" + case 161: + return "SwitchBot device is offline (161)" + case 171: + return "SwitchBot hub is offline (171)" + case 190: + return "SwitchBot internal error or invalid command format (190)" + default: + return fmt.Sprintf("SwitchBot API error %d: %s", e.StatusCode, e.Message) + } +} + +// SendCommand builds a signed POST and sends one device command. parameter is +// hardcoded to "default" because every command this gateway currently sends +// uses it; only commandType varies per call ("command" for SwitchBot's +// documented per-device commands, "customize" for a user-configured IR +// button label). +func (c *Client) SendCommand(ctx context.Context, deviceID, command, commandType string) error { + if c.token == "" || c.secret == "" { + return errors.New("switchbot: token/secret not configured") + } + + body, err := json.Marshal(commandRequest{ + Command: command, + Parameter: "default", + CommandType: commandType, + }) + if err != nil { + return fmt.Errorf("encode request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.baseURL+"/v1.1/devices/"+deviceID+"/commands", + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + nonce, err := newNonce() + if err != nil { + return err + } + + mac := hmac.New(sha256.New, []byte(c.secret)) + _, _ = mac.Write([]byte(c.token + timestamp + nonce)) + // SwitchBot's docs prose says to upper-case this signature, but the + // reference CLI's tested implementation does not, and a working + // implementation beats a doc paraphrase. + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + req.Header.Set("Authorization", c.token) + req.Header.Set("sign", signature) + req.Header.Set("nonce", nonce) + req.Header.Set("t", timestamp) + req.Header.Set("Content-Type", "application/json; charset=utf8") + + start := time.Now() + resp, err := c.httpClient.Do(req) + if err != nil { + c.log.Error("switchbot request failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", err.Error(), + ) + return fmt.Errorf("send command: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + preview := string(data) + if len(preview) > 200 { + preview = preview[:200] + } + c.log.Error("switchbot request failed", + "http.status", resp.StatusCode, + "duration_ms", time.Since(start).Milliseconds(), + "error", preview, + ) + return fmt.Errorf("switchbot HTTP %d: %s", resp.StatusCode, preview) + } + + var envelope apiEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + return fmt.Errorf("decode response: %w", err) + } + if envelope.StatusCode != 100 { + apiErr := &apiError{StatusCode: envelope.StatusCode, Message: envelope.Message} + c.log.Error("switchbot request failed", + "duration_ms", time.Since(start).Milliseconds(), + "error", apiErr.Error(), + ) + return apiErr + } + + c.log.Debug("switchbot request completed", + "http.status", resp.StatusCode, + "duration_ms", time.Since(start).Milliseconds(), + ) + return nil +} + +// newNonce generates a dependency-free RFC 4122 v4 UUID, mirroring the +// reference CLI so this implementation stays proven against the real API +// instead of diverging with a new dependency. +func newNonce() (string, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + encoded := hex.EncodeToString(value[:]) + return encoded[0:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:32], nil +} diff --git a/ha-gateway/internal/adapters/secondary/switchbot/client_test.go b/ha-gateway/internal/adapters/secondary/switchbot/client_test.go new file mode 100644 index 0000000..dce894e --- /dev/null +++ b/ha-gateway/internal/adapters/secondary/switchbot/client_test.go @@ -0,0 +1,222 @@ +package switchbot + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config" +) + +func testLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestClientSendCommandSignsRequestAndSucceeds(t *testing.T) { + const token = "test-token" + const secret = "test-secret" + + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/v1.1/devices/dyson-id/commands" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != token { + t.Fatalf("Authorization header = %q, want %q", got, token) + } + if got := r.Header.Get("Content-Type"); got != "application/json; charset=utf8" { + t.Fatalf("Content-Type header = %q", got) + } + + timestamp := r.Header.Get("t") + nonce := r.Header.Get("nonce") + if timestamp == "" || nonce == "" { + t.Fatalf("missing t/nonce headers: t=%q nonce=%q", timestamp, nonce) + } + + mac := hmac.New(sha256.New, []byte(secret)) + _, _ = mac.Write([]byte(token + timestamp + nonce)) + wantSignature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + if got := r.Header.Get("sign"); got != wantSignature { + t.Fatalf("sign header = %q, want %q", got, wantSignature) + } + + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request body: %v", err) + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "statusCode": 100, + "message": "success", + "body": map[string]any{}, + }) + })) + defer server.Close() + + c := &Client{ + token: token, + secret: secret, + baseURL: server.URL, + httpClient: server.Client(), + log: testLogger(), + } + + if err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command"); err != nil { + t.Fatalf("SendCommand() error = %v", err) + } + + wantBody := map[string]any{"command": "turnOn", "parameter": "default", "commandType": "command"} + if len(gotBody) != len(wantBody) { + t.Fatalf("body = %#v, want %#v", gotBody, wantBody) + } + for k, v := range wantBody { + if gotBody[k] != v { + t.Fatalf("body[%q] = %#v, want %#v", k, gotBody[k], v) + } + } +} + +func TestClientSendCommandCustomizeType(t *testing.T) { + var gotCommandType string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotCommandType, _ = body["commandType"].(string) + _ = json.NewEncoder(w).Encode(map[string]any{"statusCode": 100, "message": "success", "body": map[string]any{}}) + })) + defer server.Close() + + c := &Client{ + token: "token", + secret: "secret", + baseURL: server.URL, + httpClient: server.Client(), + log: testLogger(), + } + + if err := c.SendCommand(context.Background(), "dyson-id", "Cool", "customize"); err != nil { + t.Fatalf("SendCommand() error = %v", err) + } + if gotCommandType != "customize" { + t.Fatalf("commandType = %q, want %q", gotCommandType, "customize") + } +} + +func TestClientSendCommandAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "statusCode": 190, + "message": "invalid format", + "body": map[string]any{}, + }) + })) + defer server.Close() + + c := &Client{ + token: "token", + secret: "secret", + baseURL: server.URL, + httpClient: server.Client(), + log: testLogger(), + } + + err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command") + wantErr := "SwitchBot internal error or invalid command format (190)" + if err == nil || err.Error() != wantErr { + t.Fatalf("SendCommand() error = %v, want %q", err, wantErr) + } +} + +func TestClientSendCommandNon2xxHTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("unauthorized")) + })) + defer server.Close() + + c := &Client{ + token: "token", + secret: "secret", + baseURL: server.URL, + httpClient: server.Client(), + log: testLogger(), + } + + err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command") + if err == nil { + t.Fatal("SendCommand() error = nil, want error") + } +} + +func TestClientSendCommandNotConfigured(t *testing.T) { + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + })) + defer server.Close() + + tests := []struct { + name string + token string + secret string + }{ + {name: "both empty"}, + {name: "token only", token: "token"}, + {name: "secret only", secret: "secret"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &Client{ + token: tt.token, + secret: tt.secret, + baseURL: server.URL, + httpClient: server.Client(), + log: testLogger(), + } + + err := c.SendCommand(context.Background(), "dyson-id", "turnOn", "command") + if err == nil || err.Error() != "switchbot: token/secret not configured" { + t.Fatalf("SendCommand() error = %v, want not-configured error", err) + } + }) + } + if calls != 0 { + t.Fatalf("requests reached test server = %d, want 0", calls) + } +} + +func TestNewClient(t *testing.T) { + cfg := &config.Config{SwitchBotToken: "tok", SwitchBotSecret: "sec"} + c := NewClient(cfg, testLogger()) + if c.token != "tok" || c.secret != "sec" { + t.Fatalf("NewClient() token/secret = %q/%q, want %q/%q", c.token, c.secret, "tok", "sec") + } + if c.baseURL != defaultBaseURL { + t.Fatalf("NewClient() baseURL = %q, want %q", c.baseURL, defaultBaseURL) + } +} + +func TestNewNonceIsUniqueAndFormatted(t *testing.T) { + a, err := newNonce() + if err != nil { + t.Fatalf("newNonce() error = %v", err) + } + b, err := newNonce() + if err != nil { + t.Fatalf("newNonce() error = %v", err) + } + if a == b { + t.Fatalf("newNonce() returned duplicate values: %q", a) + } + if len(a) != len("00000000-0000-0000-0000-000000000000") { + t.Fatalf("newNonce() length = %d, want 36", len(a)) + } +} diff --git a/ha-gateway/internal/app/climate.go b/ha-gateway/internal/app/climate.go new file mode 100644 index 0000000..3c9ae2d --- /dev/null +++ b/ha-gateway/internal/app/climate.go @@ -0,0 +1,187 @@ +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 +} diff --git a/ha-gateway/internal/app/climate_test.go b/ha-gateway/internal/app/climate_test.go new file mode 100644 index 0000000..a4c244e --- /dev/null +++ b/ha-gateway/internal/app/climate_test.go @@ -0,0 +1,538 @@ +package app + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven" +) + +func TestClimateAppRefresh(t *testing.T) { + t.Run("filters non-climate entities and populates cache", func(t *testing.T) { + current := 21.5 + target := 22.0 + ha := &mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return []*driven.HAState{ + { + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{ + "friendly_name": "Air Conditioner", + "hvac_modes": []any{"heat_cool", "cool", "dry", "fan_only", "heat", "off"}, + "fan_mode": "auto", + "fan_modes": []any{"auto", "low", "high"}, + "current_temperature": current, + "temperature": target, + "target_temp_step": float64(1), + "min_temp": float64(7), + "max_temp": float64(35), + }, + }, + { + EntityID: "switch.fan", + State: "off", + }, + }, nil + }, + } + + app := NewClimateApp(ha) + if err := app.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + got := app.cache + want := []domain.Climate{ + { + EntityID: "climate.air_conditioner", + FriendlyName: "Air Conditioner", + State: "cool", + HVACModes: []string{"heat_cool", "cool", "dry", "fan_only", "heat", "off"}, + FanMode: "auto", + FanModes: []string{"auto", "low", "high"}, + CurrentTemperature: ¤t, + TargetTemperature: &target, + TargetTempStep: 1, + MinTemp: 7, + MaxTemp: 35, + }, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("cache = %#v, want %#v", got, want) + } + }) + + t.Run("nil current_temperature means no sensor, not zero", func(t *testing.T) { + ha := &mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return []*driven.HAState{ + { + EntityID: "climate.air_conditioner", + State: "off", + Attributes: map[string]any{"current_temperature": nil}, + }, + }, nil + }, + } + + app := NewClimateApp(ha) + if err := app.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + if app.cache[0].CurrentTemperature != nil { + t.Fatalf("CurrentTemperature = %v, want nil", *app.cache[0].CurrentTemperature) + } + }) +} + +func TestClimateAppListClimates(t *testing.T) { + t.Run("uses cache when populated", func(t *testing.T) { + calls := 0 + app := NewClimateApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + calls++ + return nil, nil + }, + }) + app.cache = []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}} + + got, err := app.ListClimates(context.Background()) + if err != nil { + t.Fatalf("ListClimates() error = %v", err) + } + if calls != 0 { + t.Fatalf("ListStates() calls = %d, want 0", calls) + } + if !reflect.DeepEqual(got, app.cache) { + t.Fatalf("ListClimates() = %#v, want %#v", got, app.cache) + } + }) + + t.Run("calls ListStates when cache is nil", func(t *testing.T) { + calls := 0 + app := NewClimateApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + calls++ + return []*driven.HAState{ + {EntityID: "climate.air_conditioner", State: "cool"}, + {EntityID: "sensor.temp", State: "21"}, + }, nil + }, + }) + + got, err := app.ListClimates(context.Background()) + if err != nil { + t.Fatalf("ListClimates() error = %v", err) + } + if calls != 1 { + t.Fatalf("ListStates() calls = %d, want 1", calls) + } + want := []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListClimates() = %#v, want %#v", got, want) + } + }) + + t.Run("propagates refresh error", func(t *testing.T) { + wantErr := errors.New("list failed") + app := NewClimateApp(&mockHAClient{ + listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.ListClimates(context.Background()) + if !errors.Is(err, wantErr) { + t.Fatalf("ListClimates() error = %v, want %v", err, wantErr) + } + }) +} + +func TestClimateAppTurnOn(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + state := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{"friendly_name": "Air Conditioner"}, + LastChanged: now, + LastUpdated: now, + } + + t.Run("happy path", func(t *testing.T) { + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "climate" || service != "turn_on" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "climate.air_conditioner"} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{state}, nil + }, + }) + + got, err := app.TurnOn(context.Background(), "climate.air_conditioner") + if err != nil { + t.Fatalf("TurnOn() error = %v", err) + } + if !reflect.DeepEqual(got, haStateToDomain(state)) { + t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state)) + } + }) + + t.Run("falls back to GetState when service returns empty list", func(t *testing.T) { + getStateCalls := 0 + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return []*driven.HAState{}, nil + }, + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + getStateCalls++ + return state, nil + }, + }) + + got, err := app.TurnOn(context.Background(), "climate.air_conditioner") + if err != nil { + t.Fatalf("TurnOn() error = %v", err) + } + if getStateCalls != 1 { + t.Fatalf("GetState() calls = %d, want 1", getStateCalls) + } + if !reflect.DeepEqual(got, haStateToDomain(state)) { + t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state)) + } + }) + + t.Run("returns CallService error", func(t *testing.T) { + wantErr := errors.New("call failed") + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.TurnOn(context.Background(), "climate.air_conditioner") + if !errors.Is(err, wantErr) { + t.Fatalf("TurnOn() error = %v, want %v", err, wantErr) + } + }) +} + +func TestClimateAppTurnOff(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + state := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "off", + Attributes: map[string]any{"friendly_name": "Air Conditioner"}, + LastChanged: now, + LastUpdated: now, + } + + t.Run("happy path", func(t *testing.T) { + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "climate" || service != "turn_off" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + return []*driven.HAState{state}, nil + }, + }) + + got, err := app.TurnOff(context.Background(), "climate.air_conditioner") + if err != nil { + t.Fatalf("TurnOff() error = %v", err) + } + if !reflect.DeepEqual(got, haStateToDomain(state)) { + t.Fatalf("TurnOff() = %#v, want %#v", got, haStateToDomain(state)) + } + }) + + t.Run("error path", func(t *testing.T) { + wantErr := errors.New("turn off failed") + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.TurnOff(context.Background(), "climate.air_conditioner") + if !errors.Is(err, wantErr) { + t.Fatalf("TurnOff() error = %v, want %v", err, wantErr) + } + }) +} + +func TestClimateAppSetHVACMode(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + state := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{"friendly_name": "Air Conditioner"}, + LastChanged: now, + LastUpdated: now, + } + + t.Run("happy path passes raw mode through with no validation", func(t *testing.T) { + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "climate" || service != "set_hvac_mode" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "hvac_mode": "cool"} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{state}, nil + }, + }) + + got, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool") + if err != nil { + t.Fatalf("SetHVACMode() error = %v", err) + } + if !reflect.DeepEqual(got, haStateToDomain(state)) { + t.Fatalf("SetHVACMode() = %#v, want %#v", got, haStateToDomain(state)) + } + }) + + t.Run("error path", func(t *testing.T) { + wantErr := errors.New("set hvac mode failed") + app := NewClimateApp(&mockHAClient{ + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool") + if !errors.Is(err, wantErr) { + t.Fatalf("SetHVACMode() error = %v, want %v", err, wantErr) + } + }) +} + +func TestClimateAppIncreaseTemperature(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + resultState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + LastChanged: now, + LastUpdated: now, + } + + t.Run("steps up by target_temp_step", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{ + "temperature": float64(21), + "target_temp_step": float64(1), + "min_temp": float64(7), + "max_temp": float64(35), + }, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "climate" || service != "set_temperature" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{resultState}, nil + }, + }) + + if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { + t.Fatalf("IncreaseTemperature() error = %v", err) + } + }) + + t.Run("step defaults to 1 when target_temp_step is 0", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{ + "temperature": float64(21), + "min_temp": float64(7), + "max_temp": float64(35), + }, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{resultState}, nil + }, + }) + + if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { + t.Fatalf("IncreaseTemperature() error = %v", err) + } + }) + + t.Run("clamps at max_temp", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{ + "temperature": float64(35), + "target_temp_step": float64(1), + "min_temp": float64(7), + "max_temp": float64(35), + }, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(35)} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{resultState}, nil + }, + }) + + if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { + t.Fatalf("IncreaseTemperature() error = %v", err) + } + }) + + t.Run("error when no target temperature is set", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "off", + Attributes: map[string]any{}, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + }) + + _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner") + if err == nil { + t.Fatal("IncreaseTemperature() error = nil, want error") + } + }) + + t.Run("propagates GetState error", func(t *testing.T) { + wantErr := errors.New("get state failed") + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return nil, wantErr + }, + }) + + _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner") + if !errors.Is(err, wantErr) { + t.Fatalf("IncreaseTemperature() error = %v, want %v", err, wantErr) + } + }) +} + +func TestClimateAppDecreaseTemperature(t *testing.T) { + now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) + resultState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + LastChanged: now, + LastUpdated: now, + } + + t.Run("steps down by target_temp_step", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{ + "temperature": float64(21), + "target_temp_step": float64(1), + "min_temp": float64(7), + "max_temp": float64(35), + }, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + if svcDomain != "climate" || service != "set_temperature" { + t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service) + } + wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(20)} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{resultState}, nil + }, + }) + + if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { + t.Fatalf("DecreaseTemperature() error = %v", err) + } + }) + + t.Run("clamps at min_temp", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "cool", + Attributes: map[string]any{ + "temperature": float64(7), + "target_temp_step": float64(1), + "min_temp": float64(7), + "max_temp": float64(35), + }, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) { + wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(7)} + if !reflect.DeepEqual(payload, wantPayload) { + t.Fatalf("payload = %#v, want %#v", payload, wantPayload) + } + return []*driven.HAState{resultState}, nil + }, + }) + + if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil { + t.Fatalf("DecreaseTemperature() error = %v", err) + } + }) + + t.Run("error when no target temperature is set", func(t *testing.T) { + liveState := &driven.HAState{ + EntityID: "climate.air_conditioner", + State: "off", + Attributes: map[string]any{}, + } + app := NewClimateApp(&mockHAClient{ + getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) { + return liveState, nil + }, + }) + + _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner") + if err == nil { + t.Fatal("DecreaseTemperature() error = nil, want error") + } + }) +} diff --git a/ha-gateway/internal/app/remote.go b/ha-gateway/internal/app/remote.go new file mode 100644 index 0000000..34f5ee5 --- /dev/null +++ b/ha-gateway/internal/app/remote.go @@ -0,0 +1,24 @@ +package app + +import ( + "context" + + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven" +) + +// RemoteApp is a thin passthrough to the SwitchBot driven port: SwitchBot +// custom IR buttons have no state to read back, so there is no discovery +// cache to maintain here, unlike LightApp/SwitchApp/ClimateApp. +type RemoteApp struct { + switchbot driven.SwitchBotClient +} + +// NewRemoteApp constructs the remote application service. +func NewRemoteApp(switchbot driven.SwitchBotClient) *RemoteApp { + return &RemoteApp{switchbot: switchbot} +} + +// SendCommand forwards one SwitchBot Cloud device command. +func (a *RemoteApp) SendCommand(ctx context.Context, deviceID, command, commandType string) error { + return a.switchbot.SendCommand(ctx, deviceID, command, commandType) +} diff --git a/ha-gateway/internal/config/config.go b/ha-gateway/internal/config/config.go index cd51838..a0dc041 100644 --- a/ha-gateway/internal/config/config.go +++ b/ha-gateway/internal/config/config.go @@ -15,6 +15,9 @@ type Config struct { LogLevel string // LOG_LEVEL, default "info" LogFormat string // LOG_FORMAT, default "json" // empty = telemetry disabled (local dev default) + + SwitchBotToken string // SWITCHBOT_TOKEN, optional; enables SwitchBot Cloud commands + SwitchBotSecret string // SWITCHBOT_SECRET, optional; enables SwitchBot Cloud commands } // Load reads configuration from environment variables and applies defaults. @@ -37,6 +40,9 @@ func Load() (*Config, error) { OTELEndpoint: os.Getenv("OTEL_ENDPOINT"), LogLevel: getenvDefault("LOG_LEVEL", "info"), LogFormat: getenvDefault("LOG_FORMAT", "json"), + + SwitchBotToken: os.Getenv("SWITCHBOT_TOKEN"), + SwitchBotSecret: os.Getenv("SWITCHBOT_SECRET"), }, nil } diff --git a/ha-gateway/internal/core/domain/climate.go b/ha-gateway/internal/core/domain/climate.go new file mode 100644 index 0000000..a09e91e --- /dev/null +++ b/ha-gateway/internal/core/domain/climate.go @@ -0,0 +1,27 @@ +package domain + +// Climate represents a discovered climate (HVAC) entity. +type Climate struct { + // EntityID is the Home Assistant entity identifier. + EntityID EntityID + // FriendlyName is the user-facing name from Home Assistant attributes. + FriendlyName string + // State is the current raw HVAC mode, e.g. "cool", "off", "heat_cool". + State string + // HVACModes lists the modes Home Assistant reports as supported. + HVACModes []string + // FanMode is the current fan mode, when set. + FanMode string + // FanModes lists the fan modes Home Assistant reports as supported. + FanModes []string + // CurrentTemperature is nil when no sensor reading is available. + CurrentTemperature *float64 + // TargetTemperature is nil when no target temperature is set. + TargetTemperature *float64 + // TargetTempStep is the increment Home Assistant expects for set_temperature calls. + TargetTempStep float64 + // MinTemp is the lower bound Home Assistant enforces for this entity. + MinTemp float64 + // MaxTemp is the upper bound Home Assistant enforces for this entity. + MaxTemp float64 +} diff --git a/ha-gateway/internal/core/ports/driven/switchbot.go b/ha-gateway/internal/core/ports/driven/switchbot.go new file mode 100644 index 0000000..061eb43 --- /dev/null +++ b/ha-gateway/internal/core/ports/driven/switchbot.go @@ -0,0 +1,12 @@ +package driven + +import "context" + +// SwitchBotClient sends commands to SwitchBot Cloud's Open API for devices +// (typically infrared remotes) that have no Home Assistant entity. +type SwitchBotClient interface { + // SendCommand issues one device command. commandType is "command" for + // SwitchBot's documented per-device command set, or "customize" for a + // user-configured custom IR button label. + SendCommand(ctx context.Context, deviceID, command, commandType string) error +} diff --git a/ha-gateway/internal/core/ports/driving/climate.go b/ha-gateway/internal/core/ports/driving/climate.go new file mode 100644 index 0000000..ee327b2 --- /dev/null +++ b/ha-gateway/internal/core/ports/driving/climate.go @@ -0,0 +1,24 @@ +package driving + +import ( + "context" + + "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain" +) + +type ClimateService interface { + // TurnOn turns on a climate entity and returns the resulting entity state. + TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // TurnOff turns off a climate entity and returns the resulting entity state. + TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // IncreaseTemperature steps the target temperature up by one increment. + IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // DecreaseTemperature steps the target temperature down by one increment. + DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // SetHVACMode sets the HVAC mode and returns the resulting entity state. + SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) + // ListClimates returns cached climate metadata for discovery and UI use. + ListClimates(ctx context.Context) ([]domain.Climate, error) + // Refresh repopulates the climate discovery cache from Home Assistant states. + Refresh(ctx context.Context) error +} diff --git a/ha-gateway/internal/core/ports/driving/remote.go b/ha-gateway/internal/core/ports/driving/remote.go new file mode 100644 index 0000000..7de3613 --- /dev/null +++ b/ha-gateway/internal/core/ports/driving/remote.go @@ -0,0 +1,8 @@ +package driving + +import "context" + +type RemoteService interface { + // SendCommand issues one SwitchBot Cloud device command. + SendCommand(ctx context.Context, deviceID, command, commandType string) error +} diff --git a/proto/ha/v1/climate.proto b/proto/ha/v1/climate.proto new file mode 100644 index 0000000..2b4b6e4 --- /dev/null +++ b/proto/ha/v1/climate.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; +package ha.v1; +option go_package = "gitea.nik4nao.com/nik/home-services/gen/ha/v1;hav1"; +import "ha/v1/common.proto"; + +service ClimateService { + rpc TurnOn(ClimateRequest) returns (ClimateResponse); + rpc TurnOff(ClimateRequest) returns (ClimateResponse); + rpc IncreaseTemperature(ClimateRequest) returns (ClimateResponse); + rpc DecreaseTemperature(ClimateRequest) returns (ClimateResponse); + rpc SetHVACMode(SetHVACModeRequest) returns (ClimateResponse); + rpc ListClimates(ListClimatesRequest) returns (ListClimatesResponse); +} + +message ClimateRequest { string entity_id = 1; } +message ClimateResponse { EntityState state = 1; } + +message SetHVACModeRequest { + string entity_id = 1; + string hvac_mode = 2; +} + +message ClimateEntity { + string entity_id = 1; + string friendly_name = 2; + string state = 3; + repeated string hvac_modes = 4; + string fan_mode = 5; + repeated string fan_modes = 6; + optional float current_temperature = 7; + optional float target_temperature = 8; + float target_temp_step = 9; + float min_temp = 10; + float max_temp = 11; +} + +message ListClimatesRequest {} + +message ListClimatesResponse { + repeated ClimateEntity climates = 1; +} diff --git a/proto/ha/v1/remote.proto b/proto/ha/v1/remote.proto new file mode 100644 index 0000000..be078bc --- /dev/null +++ b/proto/ha/v1/remote.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; +package ha.v1; +option go_package = "gitea.nik4nao.com/nik/home-services/gen/ha/v1;hav1"; + +service RemoteService { + rpc SendCommand(SendCommandRequest) returns (SendCommandResponse); +} + +message SendCommandRequest { + string device_id = 1; + string command = 2; + string command_type = 3; // "command" or "customize" +} + +message SendCommandResponse {}