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.
This commit is contained in:
parent
81c756555d
commit
b327150d45
@ -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/`.
|
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
|
## Common Commands
|
||||||
|
|
||||||
Regenerate protobuf code after changing anything under `proto/` (requires `buf`):
|
Regenerate protobuf code after changing anything under `proto/` (requires `buf`):
|
||||||
|
|||||||
@ -42,6 +42,22 @@ AI-assisted commands.
|
|||||||
|
|
||||||
- `switch` is required for action commands and uses autocomplete.
|
- `switch` is required for action commands and uses autocomplete.
|
||||||
|
|
||||||
|
### Air Conditioner
|
||||||
|
|
||||||
|
```text
|
||||||
|
/ac on ac:<entity>
|
||||||
|
/ac off ac:<entity>
|
||||||
|
/ac mode ac:<entity> mode:<Cool|Heat|Dry|Auto|Fan>
|
||||||
|
/ac temp up ac:<entity>
|
||||||
|
/ac temp down ac:<entity>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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
|
### AI
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@ -29,12 +29,18 @@ type commandHandler interface {
|
|||||||
HandleSwitchOn(ctx context.Context, entityID string) (string, error)
|
HandleSwitchOn(ctx context.Context, entityID string) (string, error)
|
||||||
HandleSwitchOff(ctx context.Context, entityID string) (string, error)
|
HandleSwitchOff(ctx context.Context, entityID string) (string, error)
|
||||||
HandleSwitchToggle(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)
|
HandleAIQuery(ctx context.Context, text string) (string, error)
|
||||||
HandleAIModelSet(ctx context.Context, name string) (string, error)
|
HandleAIModelSet(ctx context.Context, name string) (string, error)
|
||||||
HandleAIModelGet(ctx context.Context) (string, error)
|
HandleAIModelGet(ctx context.Context) (string, error)
|
||||||
HandleAIModelList(ctx context.Context) (string, error)
|
HandleAIModelList(ctx context.Context) (string, error)
|
||||||
AutocompleteLights(ctx context.Context) ([]apppkg.Choice, error)
|
AutocompleteLights(ctx context.Context) ([]apppkg.Choice, error)
|
||||||
AutocompleteSwitches(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)
|
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"))
|
msg, err := h.app.HandleSwitchToggle(ctx, requiredStringOption(sub, "switch"))
|
||||||
h.followup(ctx, s, i.Interaction, msg, true, start, err)
|
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":
|
case "ai.query":
|
||||||
if err := h.deferResponse(s, i.Interaction, true); err != nil {
|
if err := h.deferResponse(s, i.Interaction, true); err != nil {
|
||||||
log.Error("discord response failed",
|
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)
|
choices, err = h.app.AutocompleteLights(ctx)
|
||||||
case "switch":
|
case "switch":
|
||||||
choices, err = h.app.AutocompleteSwitches(ctx)
|
choices, err = h.app.AutocompleteSwitches(ctx)
|
||||||
|
case "ac":
|
||||||
|
choices, err = h.app.AutocompleteClimates(ctx)
|
||||||
case "ai":
|
case "ai":
|
||||||
if focusedOptionName(data) == "name" {
|
if focusedOptionName(data) == "name" {
|
||||||
choices, err = h.app.AutocompleteAIModels(ctx)
|
choices, err = h.app.AutocompleteAIModels(ctx)
|
||||||
|
|||||||
@ -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",
|
Name: "ai",
|
||||||
Description: "Query the AI home assistant",
|
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.
|
// ptrFloat keeps command option min/max values readable in the command spec.
|
||||||
func ptrFloat(v float64) *float64 {
|
func ptrFloat(v float64) *float64 {
|
||||||
return &v
|
return &v
|
||||||
|
|||||||
@ -21,10 +21,11 @@ import (
|
|||||||
|
|
||||||
// Client implements the app's HA driven port over gRPC.
|
// Client implements the app's HA driven port over gRPC.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
lightClient hav1.LightServiceClient
|
lightClient hav1.LightServiceClient
|
||||||
switchClient hav1.SwitchServiceClient
|
switchClient hav1.SwitchServiceClient
|
||||||
log *slog.Logger
|
climateClient hav1.ClimateServiceClient
|
||||||
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a gRPC client for the internal ha-gateway service.
|
// 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{
|
return &Client{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
lightClient: hav1.NewLightServiceClient(conn),
|
lightClient: hav1.NewLightServiceClient(conn),
|
||||||
switchClient: hav1.NewSwitchServiceClient(conn),
|
switchClient: hav1.NewSwitchServiceClient(conn),
|
||||||
log: log,
|
climateClient: hav1.NewClimateServiceClient(conn),
|
||||||
|
log: log,
|
||||||
}, nil
|
}, 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())
|
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -162,6 +162,66 @@ func (a *CommandApp) HandleSwitchToggle(ctx context.Context, entityID string) (s
|
|||||||
return fmt.Sprintf("Toggled `%s`.", name), nil
|
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.
|
// HandleAIQuery forwards a free-form request to ai-gateway.
|
||||||
func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) {
|
func (a *CommandApp) HandleAIQuery(ctx context.Context, text string) (string, error) {
|
||||||
reply, modelUsed, err := a.ai.Query(ctx, text, a.models.Get())
|
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
|
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
|
// lookupLightName falls back to the entity ID so confirmations remain useful
|
||||||
// even when Home Assistant does not expose a friendly name.
|
// even when Home Assistant does not expose a friendly name.
|
||||||
func (a *CommandApp) lookupLightName(ctx context.Context, entityID string) (string, error) {
|
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
|
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
|
// formatLightLine keeps list output compact because Discord code blocks are
|
||||||
// easier to scan than rich embeds for dense discovery data.
|
// easier to scan than rich embeds for dense discovery data.
|
||||||
func formatLightLine(light driven.Light) string {
|
func formatLightLine(light driven.Light) string {
|
||||||
|
|||||||
@ -13,14 +13,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type mockHAGateway struct {
|
type mockHAGateway struct {
|
||||||
listLightsFunc func(ctx context.Context) ([]driven.Light, error)
|
listLightsFunc func(ctx context.Context) ([]driven.Light, error)
|
||||||
listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error)
|
listSwitchesFunc func(ctx context.Context) ([]driven.Switch, error)
|
||||||
turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error
|
turnOnLightFunc func(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error
|
||||||
turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error
|
turnOffLightFunc func(ctx context.Context, entityID string, transition *uint32) error
|
||||||
toggleLightFunc func(ctx context.Context, entityID string) error
|
toggleLightFunc func(ctx context.Context, entityID string) error
|
||||||
turnOnSwitchFunc func(ctx context.Context, entityID string) error
|
turnOnSwitchFunc func(ctx context.Context, entityID string) error
|
||||||
turnOffSwitchFunc func(ctx context.Context, entityID string) error
|
turnOffSwitchFunc func(ctx context.Context, entityID string) error
|
||||||
toggleSwitchFunc 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 {
|
type mockAIGateway struct {
|
||||||
@ -84,6 +91,48 @@ func (m *mockHAGateway) ToggleSwitch(ctx context.Context, entityID string) error
|
|||||||
return m.toggleSwitchFunc(ctx, entityID)
|
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) {
|
func (m *mockAIGateway) Query(ctx context.Context, text, model string) (string, string, error) {
|
||||||
if m.queryFunc == nil {
|
if m.queryFunc == nil {
|
||||||
return "", "", 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) {
|
func TestCommandAppAutocompleteLights(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
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) {
|
func TestCommandAppHandleAIQuery(t *testing.T) {
|
||||||
store := modelstore.New()
|
store := modelstore.New()
|
||||||
store.Set("llama3:latest")
|
store.Set("llama3:latest")
|
||||||
|
|||||||
@ -19,6 +19,18 @@ type HAGateway interface {
|
|||||||
TurnOffSwitch(ctx context.Context, entityID string) error
|
TurnOffSwitch(ctx context.Context, entityID string) error
|
||||||
// ToggleSwitch forwards a switch toggle request to ha-gateway.
|
// ToggleSwitch forwards a switch toggle request to ha-gateway.
|
||||||
ToggleSwitch(ctx context.Context, entityID string) error
|
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.
|
// 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 describes the semantic type when Home Assistant provides it.
|
||||||
DeviceClass string
|
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
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ai/v1/ai.proto
|
// source: ai/v1/ai.proto
|
||||||
|
|
||||||
|
|||||||
475
gen/ha/v1/climate.pb.go
Normal file
475
gen/ha/v1/climate.pb.go
Normal file
@ -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
|
||||||
|
}
|
||||||
311
gen/ha/v1/climate_grpc.pb.go
Normal file
311
gen/ha/v1/climate_grpc.pb.go
Normal file
@ -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",
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/entity.proto
|
// source: ha/v1/entity.proto
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/event.proto
|
// source: ha/v1/event.proto
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/light.proto
|
// source: ha/v1/light.proto
|
||||||
|
|
||||||
|
|||||||
182
gen/ha/v1/remote.pb.go
Normal file
182
gen/ha/v1/remote.pb.go
Normal file
@ -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
|
||||||
|
}
|
||||||
121
gen/ha/v1/remote_grpc.pb.go
Normal file
121
gen/ha/v1/remote_grpc.pb.go
Normal file
@ -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",
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.1
|
// - protoc-gen-go-grpc v1.6.2
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/switch.proto
|
// source: ha/v1/switch.proto
|
||||||
|
|
||||||
|
|||||||
@ -4,3 +4,5 @@ HA_TOKEN=your-long-lived-token-here
|
|||||||
OTEL_ENDPOINT=
|
OTEL_ENDPOINT=
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=text
|
LOG_FORMAT=text
|
||||||
|
SWITCHBOT_TOKEN=
|
||||||
|
SWITCHBOT_SECRET=
|
||||||
|
|||||||
@ -8,10 +8,13 @@ REST details out of clients.
|
|||||||
|
|
||||||
1. The service loads `.env`, configures logging and telemetry, and starts gRPC
|
1. The service loads `.env`, configures logging and telemetry, and starts gRPC
|
||||||
on `GRPC_PORT`.
|
on `GRPC_PORT`.
|
||||||
2. App services are wired to a Home Assistant REST adapter.
|
2. App services are wired to a Home Assistant REST adapter and, when
|
||||||
3. Light and switch discovery caches are refreshed during startup when possible.
|
`SWITCHBOT_TOKEN`/`SWITCHBOT_SECRET` are set, a SwitchBot Cloud adapter.
|
||||||
4. gRPC clients call entity, light, switch, or event services.
|
3. Light, switch, and climate discovery caches are refreshed during startup
|
||||||
5. The adapter maps requests to Home Assistant REST state and service calls.
|
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
|
## gRPC API
|
||||||
|
|
||||||
@ -29,6 +32,13 @@ Implemented:
|
|||||||
- `SwitchService.TurnOff`
|
- `SwitchService.TurnOff`
|
||||||
- `SwitchService.Toggle`
|
- `SwitchService.Toggle`
|
||||||
- `SwitchService.ListSwitches`
|
- `SwitchService.ListSwitches`
|
||||||
|
- `ClimateService.TurnOn`
|
||||||
|
- `ClimateService.TurnOff`
|
||||||
|
- `ClimateService.IncreaseTemperature`
|
||||||
|
- `ClimateService.DecreaseTemperature`
|
||||||
|
- `ClimateService.SetHVACMode`
|
||||||
|
- `ClimateService.ListClimates`
|
||||||
|
- `RemoteService.SendCommand`
|
||||||
|
|
||||||
Stubbed:
|
Stubbed:
|
||||||
|
|
||||||
@ -49,6 +59,8 @@ Environment variables:
|
|||||||
| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry |
|
| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry |
|
||||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|
||||||
| `LOG_FORMAT` | `json` | `json` or `text` |
|
| `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)
|
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}' \
|
grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' \
|
||||||
localhost:50051 ha.v1.LightService/TurnOn
|
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
|
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
|
cmd/gateway/ # process entrypoint and wiring
|
||||||
internal/adapters/primary/grpc/ # gRPC service implementations
|
internal/adapters/primary/grpc/ # gRPC service implementations
|
||||||
internal/adapters/secondary/ha/ # Home Assistant REST adapter
|
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/config/ # environment loading
|
||||||
internal/core/domain/ # domain types
|
internal/core/domain/ # domain types
|
||||||
internal/core/ports/ # driving and driven interfaces
|
internal/core/ports/ # driving and driven interfaces
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import (
|
|||||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||||
grpcadapter "gitea.nik4nao.com/nik/home-services/ha-gateway/internal/adapters/primary/grpc"
|
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/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/app"
|
||||||
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config"
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/config"
|
||||||
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/logger"
|
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/logger"
|
||||||
@ -60,6 +61,7 @@ func main() {
|
|||||||
"otel_endpoint", cfg.OTELEndpoint,
|
"otel_endpoint", cfg.OTELEndpoint,
|
||||||
"log_level", cfg.LogLevel,
|
"log_level", cfg.LogLevel,
|
||||||
"log_format", cfg.LogFormat,
|
"log_format", cfg.LogFormat,
|
||||||
|
"switchbot_configured", cfg.SwitchBotToken != "" && cfg.SwitchBotSecret != "",
|
||||||
)
|
)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
@ -79,11 +81,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
haClient := ha.NewClient(cfg, log)
|
haClient := ha.NewClient(cfg, log)
|
||||||
|
switchBotClient := switchbot.NewClient(cfg, log)
|
||||||
|
|
||||||
// App services stay free of gRPC and HTTP details; adapters are wired here.
|
// App services stay free of gRPC and HTTP details; adapters are wired here.
|
||||||
entityApp := app.NewEntityApp(haClient)
|
entityApp := app.NewEntityApp(haClient)
|
||||||
lightApp := app.NewLightApp(haClient)
|
lightApp := app.NewLightApp(haClient)
|
||||||
switchApp := app.NewSwitchApp(haClient)
|
switchApp := app.NewSwitchApp(haClient)
|
||||||
|
climateApp := app.NewClimateApp(haClient)
|
||||||
|
remoteApp := app.NewRemoteApp(switchBotClient)
|
||||||
|
|
||||||
if err := lightApp.Refresh(ctx); err != nil {
|
if err := lightApp.Refresh(ctx); err != nil {
|
||||||
log.Warn("initial light discovery failed, will retry on first request", "err", err)
|
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 {
|
if err := switchApp.Refresh(ctx); err != nil {
|
||||||
log.Warn("initial switch discovery failed, will retry on first request", "err", err)
|
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{
|
serverOpts := []grpc.ServerOption{
|
||||||
grpc.StatsHandler(otelgrpc.NewServerHandler()),
|
grpc.StatsHandler(otelgrpc.NewServerHandler()),
|
||||||
@ -116,6 +124,8 @@ func main() {
|
|||||||
hav1.RegisterEntityServiceServer(srv, grpcadapter.NewEntityGRPC(entityApp))
|
hav1.RegisterEntityServiceServer(srv, grpcadapter.NewEntityGRPC(entityApp))
|
||||||
hav1.RegisterLightServiceServer(srv, grpcadapter.NewLightGRPC(lightApp))
|
hav1.RegisterLightServiceServer(srv, grpcadapter.NewLightGRPC(lightApp))
|
||||||
hav1.RegisterSwitchServiceServer(srv, grpcadapter.NewSwitchGRPC(switchApp))
|
hav1.RegisterSwitchServiceServer(srv, grpcadapter.NewSwitchGRPC(switchApp))
|
||||||
|
hav1.RegisterClimateServiceServer(srv, grpcadapter.NewClimateGRPC(climateApp))
|
||||||
|
hav1.RegisterRemoteServiceServer(srv, grpcadapter.NewRemoteGRPC(remoteApp))
|
||||||
hav1.RegisterEventServiceServer(srv, &grpcadapter.EventGRPC{})
|
hav1.RegisterEventServiceServer(srv, &grpcadapter.EventGRPC{})
|
||||||
grpc_health_v1.RegisterHealthServer(srv, healthSrv)
|
grpc_health_v1.RegisterHealthServer(srv, healthSrv)
|
||||||
reflection.Register(srv)
|
reflection.Register(srv)
|
||||||
|
|||||||
77
ha-gateway/internal/adapters/primary/grpc/climate.go
Normal file
77
ha-gateway/internal/adapters/primary/grpc/climate.go
Normal file
@ -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
|
||||||
|
}
|
||||||
379
ha-gateway/internal/adapters/primary/grpc/climate_test.go
Normal file
379
ha-gateway/internal/adapters/primary/grpc/climate_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
@ -83,3 +83,29 @@ func domainSwitchToProto(s domain.Switch) *hav1.SwitchEntity {
|
|||||||
DeviceClass: s.DeviceClass,
|
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
|
||||||
|
}
|
||||||
|
|||||||
26
ha-gateway/internal/adapters/primary/grpc/remote.go
Normal file
26
ha-gateway/internal/adapters/primary/grpc/remote.go
Normal file
@ -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
|
||||||
|
}
|
||||||
100
ha-gateway/internal/adapters/primary/grpc/remote_test.go
Normal file
100
ha-gateway/internal/adapters/primary/grpc/remote_test.go
Normal file
@ -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
|
||||||
|
}
|
||||||
193
ha-gateway/internal/adapters/secondary/switchbot/client.go
Normal file
193
ha-gateway/internal/adapters/secondary/switchbot/client.go
Normal file
@ -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
|
||||||
|
}
|
||||||
222
ha-gateway/internal/adapters/secondary/switchbot/client_test.go
Normal file
222
ha-gateway/internal/adapters/secondary/switchbot/client_test.go
Normal file
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
187
ha-gateway/internal/app/climate.go
Normal file
187
ha-gateway/internal/app/climate.go
Normal file
@ -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
|
||||||
|
}
|
||||||
538
ha-gateway/internal/app/climate_test.go
Normal file
538
ha-gateway/internal/app/climate_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
24
ha-gateway/internal/app/remote.go
Normal file
24
ha-gateway/internal/app/remote.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
@ -15,6 +15,9 @@ type Config struct {
|
|||||||
LogLevel string // LOG_LEVEL, default "info"
|
LogLevel string // LOG_LEVEL, default "info"
|
||||||
LogFormat string // LOG_FORMAT, default "json"
|
LogFormat string // LOG_FORMAT, default "json"
|
||||||
// empty = telemetry disabled (local dev default)
|
// 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.
|
// Load reads configuration from environment variables and applies defaults.
|
||||||
@ -37,6 +40,9 @@ func Load() (*Config, error) {
|
|||||||
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
|
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
|
||||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||||
LogFormat: getenvDefault("LOG_FORMAT", "json"),
|
LogFormat: getenvDefault("LOG_FORMAT", "json"),
|
||||||
|
|
||||||
|
SwitchBotToken: os.Getenv("SWITCHBOT_TOKEN"),
|
||||||
|
SwitchBotSecret: os.Getenv("SWITCHBOT_SECRET"),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
27
ha-gateway/internal/core/domain/climate.go
Normal file
27
ha-gateway/internal/core/domain/climate.go
Normal file
@ -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
|
||||||
|
}
|
||||||
12
ha-gateway/internal/core/ports/driven/switchbot.go
Normal file
12
ha-gateway/internal/core/ports/driven/switchbot.go
Normal file
@ -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
|
||||||
|
}
|
||||||
24
ha-gateway/internal/core/ports/driving/climate.go
Normal file
24
ha-gateway/internal/core/ports/driving/climate.go
Normal file
@ -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
|
||||||
|
}
|
||||||
8
ha-gateway/internal/core/ports/driving/remote.go
Normal file
8
ha-gateway/internal/core/ports/driving/remote.go
Normal file
@ -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
|
||||||
|
}
|
||||||
41
proto/ha/v1/climate.proto
Normal file
41
proto/ha/v1/climate.proto
Normal file
@ -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;
|
||||||
|
}
|
||||||
15
proto/ha/v1/remote.proto
Normal file
15
proto/ha/v1/remote.proto
Normal file
@ -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 {}
|
||||||
Loading…
x
Reference in New Issue
Block a user