Compare commits

..

2 Commits

Author SHA1 Message Date
b327150d45 feat: implement ClimateApp for managing HVAC entities
All checks were successful
CI / test (push) Successful in 5s
CI / build-ai-gateway (push) Successful in 39s
CI / build-ha-gateway (push) Successful in 39s
CI / build-discord-bot (push) Successful in 40s
- 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.
2026-07-23 13:49:54 +09:00
81c756555d feat: enhance security hooks for .env file access and add switchbot control and read skills 2026-07-23 13:27:41 +09:00
41 changed files with 3968 additions and 23 deletions

View File

@ -1,18 +1,32 @@
{ {
"hooks": { "hooks": {
"PreToolUse": [ "PreToolUse": [
{
"matcher": "Edit|Write|Read",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == *.env && \"$f\" != *.env.example ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Refusing to read/edit a real .env file - it holds live secrets (HA_TOKEN, DISCORD_TOKEN, SwitchBot token/secret, etc). Use the corresponding .env.example instead.\"}}'; fi; }"
}
]
},
{ {
"matcher": "Edit|Write", "matcher": "Edit|Write",
"hooks": [ "hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == *.env && \"$f\" != *.env.example ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Refusing to edit a real .env file - it holds live secrets (HA_TOKEN, DISCORD_TOKEN). Edit the corresponding .env.example instead.\"}}'; fi; }"
},
{ {
"type": "command", "type": "command",
"command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == */gen/* ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"gen/ is committed buf-generated protobuf/gRPC code. Edit the source .proto file under proto/ and run buf generate instead.\"}}'; fi; }" "command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == */gen/* ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"gen/ is committed buf-generated protobuf/gRPC code. Edit the source .proto file under proto/ and run buf generate instead.\"}}'; fi; }"
} }
] ]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.command // empty' | { cmd=$(cat); reduced=$(echo \"$cmd\" | sed 's/\\.env\\.example//g'); if echo \"$reduced\" | grep -qE '\\.env'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Refusing to run a shell command that references a real .env file - these hold live secrets and must never be read, printed, sourced, or exposed. Use .env.example, or ask the user to check the value directly.\"}}'; fi; }"
}
]
} }
] ]
} }

View File

@ -0,0 +1,87 @@
---
name: switchbot-control
description: Gated SwitchBot Cloud device control via the local reference CLI (tmp/reference/switchbot-control-reference) — turn-on/off, fan/AC/lock commands, and custom IR buttons. Always resolves and inspects the target first, always dry-runs before a real send, always requires explicit user approval before executing.
---
# SwitchBot Control (gated)
This wraps `tmp/reference/switchbot-control-reference/scripts/switchbotctl`,
the full control CLI. Every real control call fires a real signal at real
hardware (an actual IR blast, an actual lock toggle) — treat every non-dry-run
invocation as something you are asking permission to do, not something you
just do.
## Required sequence, every time
1. **Resolve the device.** Use the `switchbot-read` skill (`list all` /
`list remotes` / `list switches` / `list locks`) to find the exact
`deviceId` for the device the user named. Never guess an ID or reuse one
from memory without re-confirming it still matches — if more than one
device could match the name, ask which one; never pick one silently.
2. **Inspect the interface.** Run `switchbot-read interface DEVICE_ID` and
confirm the command you're about to send is actually in its documented
`commands` list (or, for a custom button, that the device is an infrared
remote where `customize`-type commands apply).
3. **Dry-run first.** Always construct and check the exact call with
`--dry-run` before ever running it for real:
```bash
cd tmp/reference/switchbot-control-reference
GOWORK=off ./scripts/switchbotctl ac DEVICE_ID 24 cool medium on --dry-run
```
Confirm the echoed `command`/`parameter`/`commandType` in the dry-run
output is exactly what you intend.
4. **Ask for explicit approval** before running the same command without
`--dry-run`. State the exact device (name + ID) and the exact action in
plain language. Do not treat a general "go ahead with the plan" from
earlier in the conversation as approval for a specific real control call —
ask again, per command, right before running it.
5. **Execute once.** Run the approved command exactly once. Do not retry
automatically on failure or on an ambiguous result — a repeated lock,
toggle, Bot press, or custom IR command can produce a real second action
(e.g. re-toggling something back off). If it needs retrying, ask first.
6. **Treat success as API acceptance, not physical confirmation.** SwitchBot
Cloud's infrared control is fire-and-forget — a `200`/`statusCode: 100`
response means the cloud accepted the command, not that the appliance
actually received or acted on the IR signal.
## Custom IR buttons (`button`) need the user, not a guess
```bash
GOWORK=off ./scripts/switchbotctl button DEVICE_ID "Exact Button Name" --dry-run
```
SwitchBot's API cannot enumerate a device's learned custom button names —
they only exist as case-sensitive labels in the SwitchBot app. Never
approximate, guess, or case-fold a button name. If you don't have the exact
label from the user, stop and ask for it rather than trying a plausible
guess.
## Command reference (see `tmp/reference/switchbot-control-reference/README.md` for the full list)
```bash
switchbotctl turn-on DEVICE_ID [--dry-run]
switchbotctl turn-off DEVICE_ID [--dry-run]
switchbotctl press DEVICE_ID [--dry-run]
switchbotctl lock DEVICE_ID [--dry-run]
switchbotctl unlock DEVICE_ID [--dry-run]
switchbotctl fan DEVICE_ID swing|timer|lowSpeed|middleSpeed|highSpeed [--dry-run]
switchbotctl ac DEVICE_ID TEMPERATURE MODE FAN_SPEED POWER [--dry-run]
switchbotctl button DEVICE_ID "Exact Button Name" [--dry-run]
switchbotctl send DEVICE_ID COMMAND PARAMETER COMMAND_TYPE [--dry-run] # generic escape hatch
```
Always run from `tmp/reference/switchbot-control-reference/` with
`GOWORK=off` prefixed (this repo's root `go.work` doesn't include this nested
module — see the `switchbot-read` skill for why not to fix that by editing
`go.work`).
## Hard rules
- Never read, print, edit, source, or expose `.env` in this directory.
- Never add an automatic hook, cron, or script that calls `switchbotctl`
without a human in the loop for that specific invocation.
- This CLI is a **development/testing aid only** — it is not a dependency of
`ha-gateway`. The actual `RemoteService` implementation in `ha-gateway` has
its own hand-rolled SwitchBot HTTP client (see `plan.md`); use this CLI to
discover device IDs and verify command behavior during development, not as
something the running services call at runtime.

View File

@ -0,0 +1,65 @@
---
name: switchbot-read
description: Read-only SwitchBot Cloud discovery via the local reference CLI (tmp/reference/switchbot-control-reference) — list devices/infrared remotes and inspect a device's documented command interface. Never sends control commands and never touches .env.
---
# SwitchBot Read-Only Discovery
Use this whenever you need to find a SwitchBot device ID, confirm a device's
type, or see which commands are documented for it — e.g. resolving "the
Dyson" or "the AC" to an exact `deviceId` before building or running any
control call in `ha-gateway`'s `RemoteService`.
This wraps `tmp/reference/switchbot-control-reference/scripts/switchbot-read`,
a wrapper that only accepts `list` and `interface` — it has no code path that
can send a device command, so it's safe to run freely.
## Why `GOWORK=off` is required
This reference tool is nested inside the `home-service` repo but is its own
Go module, not a member of the root `go.work`. Running it from inside the
workspace fails with "directory prefix . does not contain modules listed in
go.work" unless you disable workspace mode for that one subprocess:
```bash
cd tmp/reference/switchbot-control-reference
GOWORK=off ./scripts/switchbot-read list all
```
Do not add this tool to the root `go.work` — that file is committed, `tmp/`
is gitignored, and pointing a committed file at a gitignored path would break
every other clone of this repo.
## Commands
```bash
GOWORK=off ./scripts/switchbot-read list all
GOWORK=off ./scripts/switchbot-read list physical
GOWORK=off ./scripts/switchbot-read list remotes # infrared/virtual remotes — Dyson, AC live here
GOWORK=off ./scripts/switchbot-read list switches
GOWORK=off ./scripts/switchbot-read list locks
GOWORK=off ./scripts/switchbot-read interface DEVICE_ID
```
Output is JSON: `{filter, count, devices: [...]}` for `list`, or a single
`deviceView` object for `interface`. Each device includes a `commands` array
of documented `{name, parameter, commandType, description}` entries derived
from the device's type — this is a **static catalog**, not something
dynamically read from the appliance, and it does not include learned custom
IR button names (SwitchBot's API has no endpoint for that — see
`CustomButtonNamesDiscoverable: false` on infrared entries).
## Rules
1. Never read, print, or expose `tmp/reference/switchbot-control-reference/.env`
yourself — the CLI reads it internally via `os.Getenv`, you never need to.
(A project hook already blocks `Read`/`Edit`/`Write` on real `.env` files
and Bash commands referencing them, but don't rely on the hook — just
don't try.)
2. This skill is discovery-only. If you need to send a command, use the
`switchbot-control` skill instead — never shell out to `switchbotctl`
(the full CLI) from here.
3. Treat `commands` in the output as "what SwitchBot's documented API
supports for this device type," not confirmation the physical appliance
will respond correctly to every listed command — some DIY/customized
remotes reject a documented standard command with error 160.

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
# Environment files — never commit real tokens # Environment files — never commit real tokens
.env .env
*.env *.env
tmp/
!.env.example !.env.example
# Go build output # Go build output

View File

@ -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`):

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -24,6 +24,7 @@ type Client struct {
conn *grpc.ClientConn conn *grpc.ClientConn
lightClient hav1.LightServiceClient lightClient hav1.LightServiceClient
switchClient hav1.SwitchServiceClient switchClient hav1.SwitchServiceClient
climateClient hav1.ClimateServiceClient
log *slog.Logger log *slog.Logger
} }
@ -51,6 +52,7 @@ func New(ctx context.Context, addr, tlsDir string, log *slog.Logger) (*Client, e
conn: conn, conn: conn,
lightClient: hav1.NewLightServiceClient(conn), lightClient: hav1.NewLightServiceClient(conn),
switchClient: hav1.NewSwitchServiceClient(conn), switchClient: hav1.NewSwitchServiceClient(conn),
climateClient: hav1.NewClimateServiceClient(conn),
log: log, 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
}

View File

@ -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 {

View File

@ -21,6 +21,13 @@ type mockHAGateway struct {
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")

View File

@ -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
}

View File

@ -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
View 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
}

View 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",
}

View File

@ -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

View File

@ -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

View File

@ -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
View 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
View 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",
}

View File

@ -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

View File

@ -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=

View File

@ -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

View File

@ -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)

View 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
}

View 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: &current,
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
}

View File

@ -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
}

View 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
}

View 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
}

View 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
}

View 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))
}
}

View 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
}

View 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: &current,
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")
}
})
}

View 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)
}

View File

@ -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
} }

View 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
}

View 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
}

View 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
}

View 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
View 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
View 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 {}