Compare commits
No commits in common. "b327150d45b6a573fd690ac15f60f20491cae287" and "b54a7474674c0e51488d177081489d38ad4092a9" have entirely different histories.
b327150d45
...
b54a747467
@ -1,32 +1,18 @@
|
|||||||
{
|
{
|
||||||
"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; }"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
---
|
|
||||||
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
1
.gitignore
vendored
@ -1,7 +1,6 @@
|
|||||||
# 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
|
||||||
|
|||||||
@ -45,15 +45,6 @@ internal/telemetry/ # OpenTelemetry setup
|
|||||||
|
|
||||||
Protobuf contracts live in `proto/` (buf module, `ai/v1` and `ha/v1` packages). Generated Go code is committed under `gen/` and consumed by all three services through the Go workspace — do not hand-edit files in `gen/`.
|
Protobuf contracts live in `proto/` (buf module, `ai/v1` and `ha/v1` packages). Generated Go code is committed under `gen/` and consumed by all three services through the Go workspace — do not hand-edit files in `gen/`.
|
||||||
|
|
||||||
## `tmp/` is reference-only, never a dependency
|
|
||||||
|
|
||||||
`tmp/` is gitignored — nothing under it is pushed to git, and it should be treated as temporary scratch space for reference material (e.g. `tmp/reference/switchbot-control-reference/`, a standalone CLI copied in for local discovery/testing against an external API). Rules:
|
|
||||||
|
|
||||||
- It's fine to read code under `tmp/` for patterns, to run its scripts/tools locally (a discovery script, a CLI, etc.), or to use it as a manual testing aid.
|
|
||||||
- Never make any of the three services (`ha-gateway`, `ai-gateway`, `discord-bot`) import, `go.work use`, or otherwise depend on anything under `tmp/` at build or runtime. Since `tmp/` isn't committed, that dependency would silently break for every other clone of the repo (including CI).
|
|
||||||
- If a reference tool under `tmp/` lives in its own Go module nested inside this repo's `go.work` workspace, invoke it with `GOWORK=off` rather than adding it to the root `go.work` — e.g. `GOWORK=off ./scripts/some-tool ...` from that tool's own directory.
|
|
||||||
- If something under `tmp/` turns out to be genuinely needed at runtime, port the actual logic into the relevant service's `internal/` tree (following the hexagonal layout above) instead of reaching into `tmp/` from committed code.
|
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
|
|
||||||
Regenerate protobuf code after changing anything under `proto/` (requires `buf`):
|
Regenerate protobuf code after changing anything under `proto/` (requires `buf`):
|
||||||
|
|||||||
@ -42,22 +42,6 @@ AI-assisted commands.
|
|||||||
|
|
||||||
- `switch` is required for action commands and uses autocomplete.
|
- `switch` is required for action commands and uses autocomplete.
|
||||||
|
|
||||||
### Air Conditioner
|
|
||||||
|
|
||||||
```text
|
|
||||||
/ac on ac:<entity>
|
|
||||||
/ac off ac:<entity>
|
|
||||||
/ac mode ac:<entity> mode:<Cool|Heat|Dry|Auto|Fan>
|
|
||||||
/ac temp up ac:<entity>
|
|
||||||
/ac temp down ac:<entity>
|
|
||||||
```
|
|
||||||
|
|
||||||
- `ac` is required for every subcommand and uses autocomplete.
|
|
||||||
- `mode` is a fixed choice list mapping to Home Assistant HVAC modes
|
|
||||||
(Cool→`cool`, Heat→`heat`, Dry→`dry`, Auto→`heat_cool`, Fan→`fan_only`).
|
|
||||||
- `temp up`/`temp down` step the target temperature by one
|
|
||||||
`target_temp_step` increment, clamped to the entity's `min_temp`/`max_temp`.
|
|
||||||
|
|
||||||
### AI
|
### AI
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@ -29,18 +29,12 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,56 +192,6 @@ 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",
|
||||||
@ -320,8 +264,6 @@ func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session,
|
|||||||
choices, err = h.app.AutocompleteLights(ctx)
|
choices, err = h.app.AutocompleteLights(ctx)
|
||||||
case "switch":
|
case "switch":
|
||||||
choices, err = h.app.AutocompleteSwitches(ctx)
|
choices, err = h.app.AutocompleteSwitches(ctx)
|
||||||
case "ac":
|
|
||||||
choices, err = h.app.AutocompleteClimates(ctx)
|
|
||||||
case "ai":
|
case "ai":
|
||||||
if focusedOptionName(data) == "name" {
|
if focusedOptionName(data) == "name" {
|
||||||
choices, err = h.app.AutocompleteAIModels(ctx)
|
choices, err = h.app.AutocompleteAIModels(ctx)
|
||||||
|
|||||||
@ -99,64 +99,6 @@ 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",
|
||||||
@ -237,17 +179,6 @@ 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
|
||||||
|
|||||||
@ -24,7 +24,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +51,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -252,112 +250,3 @@ func (c *Client) ToggleSwitch(ctx context.Context, entityID string) error {
|
|||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListClimates calls ha-gateway discovery RPCs and maps protobuf messages into
|
|
||||||
// the driven port type expected by the app layer.
|
|
||||||
func (c *Client) ListClimates(ctx context.Context) ([]driven.Climate, error) {
|
|
||||||
start := time.Now()
|
|
||||||
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/ListClimates")
|
|
||||||
resp, err := c.climateClient.ListClimates(ctx, &hav1.ListClimatesRequest{})
|
|
||||||
if err != nil {
|
|
||||||
log.Error("grpc call failed",
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
return nil, fmt.Errorf("list climates: %w", err)
|
|
||||||
}
|
|
||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
|
||||||
|
|
||||||
climates := make([]driven.Climate, 0, len(resp.GetClimates()))
|
|
||||||
for _, c := range resp.GetClimates() {
|
|
||||||
climates = append(climates, driven.Climate{
|
|
||||||
EntityID: c.GetEntityId(),
|
|
||||||
FriendlyName: c.GetFriendlyName(),
|
|
||||||
State: c.GetState(),
|
|
||||||
HVACModes: append([]string(nil), c.GetHvacModes()...),
|
|
||||||
FanMode: c.GetFanMode(),
|
|
||||||
FanModes: append([]string(nil), c.GetFanModes()...),
|
|
||||||
CurrentTemperature: c.CurrentTemperature,
|
|
||||||
TargetTemperature: c.TargetTemperature,
|
|
||||||
TargetTempStep: c.GetTargetTempStep(),
|
|
||||||
MinTemp: c.GetMinTemp(),
|
|
||||||
MaxTemp: c.GetMaxTemp(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return climates, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TurnOnClimate forwards a climate turn-on request over gRPC.
|
|
||||||
func (c *Client) TurnOnClimate(ctx context.Context, entityID string) error {
|
|
||||||
start := time.Now()
|
|
||||||
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/TurnOn")
|
|
||||||
if _, err := c.climateClient.TurnOn(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
|
|
||||||
log.Error("grpc call failed",
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
return fmt.Errorf("turn on climate %s: %w", entityID, err)
|
|
||||||
}
|
|
||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TurnOffClimate forwards a climate turn-off request over gRPC.
|
|
||||||
func (c *Client) TurnOffClimate(ctx context.Context, entityID string) error {
|
|
||||||
start := time.Now()
|
|
||||||
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/TurnOff")
|
|
||||||
if _, err := c.climateClient.TurnOff(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
|
|
||||||
log.Error("grpc call failed",
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
return fmt.Errorf("turn off climate %s: %w", entityID, err)
|
|
||||||
}
|
|
||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetClimateHVACMode forwards a climate HVAC mode change request over gRPC.
|
|
||||||
func (c *Client) SetClimateHVACMode(ctx context.Context, entityID, hvacMode string) error {
|
|
||||||
start := time.Now()
|
|
||||||
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/SetHVACMode")
|
|
||||||
if _, err := c.climateClient.SetHVACMode(ctx, &hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: hvacMode}); err != nil {
|
|
||||||
log.Error("grpc call failed",
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
return fmt.Errorf("set climate %s hvac mode: %w", entityID, err)
|
|
||||||
}
|
|
||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncreaseClimateTemperature forwards a temperature step-up request over gRPC.
|
|
||||||
func (c *Client) IncreaseClimateTemperature(ctx context.Context, entityID string) error {
|
|
||||||
start := time.Now()
|
|
||||||
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/IncreaseTemperature")
|
|
||||||
if _, err := c.climateClient.IncreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
|
|
||||||
log.Error("grpc call failed",
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
return fmt.Errorf("increase climate %s temperature: %w", entityID, err)
|
|
||||||
}
|
|
||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecreaseClimateTemperature forwards a temperature step-down request over gRPC.
|
|
||||||
func (c *Client) DecreaseClimateTemperature(ctx context.Context, entityID string) error {
|
|
||||||
start := time.Now()
|
|
||||||
log := logger.FromContext(ctx).With("grpc.method", "ClimateService/DecreaseTemperature")
|
|
||||||
if _, err := c.climateClient.DecreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID}); err != nil {
|
|
||||||
log.Error("grpc call failed",
|
|
||||||
"duration_ms", time.Since(start).Milliseconds(),
|
|
||||||
"error", err.Error(),
|
|
||||||
)
|
|
||||||
return fmt.Errorf("decrease climate %s temperature: %w", entityID, err)
|
|
||||||
}
|
|
||||||
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -162,66 +162,6 @@ 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())
|
||||||
@ -328,24 +268,6 @@ 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) {
|
||||||
@ -384,25 +306,6 @@ 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 {
|
||||||
|
|||||||
@ -21,13 +21,6 @@ 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 {
|
||||||
@ -91,48 +84,6 @@ 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
|
||||||
@ -650,262 +601,6 @@ 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
|
||||||
@ -1014,60 +709,6 @@ func TestCommandAppAutocompleteSwitches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommandAppAutocompleteClimates(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
climates []driven.Climate
|
|
||||||
listErr error
|
|
||||||
want []Choice
|
|
||||||
wantErr string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "friendly name and fallback",
|
|
||||||
climates: []driven.Climate{
|
|
||||||
{EntityID: "climate.air_conditioner", FriendlyName: "AC"},
|
|
||||||
{EntityID: "climate.bedroom"},
|
|
||||||
},
|
|
||||||
want: []Choice{
|
|
||||||
{Label: "AC", Value: "climate.air_conditioner"},
|
|
||||||
{Label: "climate.bedroom", Value: "climate.bedroom"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "ListClimates error",
|
|
||||||
listErr: errors.New("boom"),
|
|
||||||
wantErr: "autocomplete climates: boom",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
app := newTestCommandApp(&mockHAGateway{
|
|
||||||
listClimatesFunc: func(ctx context.Context) ([]driven.Climate, error) {
|
|
||||||
if tt.listErr != nil {
|
|
||||||
return nil, tt.listErr
|
|
||||||
}
|
|
||||||
return tt.climates, nil
|
|
||||||
},
|
|
||||||
}, &mockAIGateway{})
|
|
||||||
|
|
||||||
got, err := app.AutocompleteClimates(context.Background())
|
|
||||||
if tt.wantErr != "" {
|
|
||||||
if err == nil || err.Error() != tt.wantErr {
|
|
||||||
t.Fatalf("AutocompleteClimates() error = %v, want %q", err, tt.wantErr)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AutocompleteClimates() error = %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, tt.want) {
|
|
||||||
t.Fatalf("AutocompleteClimates() = %#v, want %#v", got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandAppHandleAIQuery(t *testing.T) {
|
func TestCommandAppHandleAIQuery(t *testing.T) {
|
||||||
store := modelstore.New()
|
store := modelstore.New()
|
||||||
store.Set("llama3:latest")
|
store.Set("llama3:latest")
|
||||||
|
|||||||
@ -19,18 +19,6 @@ 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.
|
||||||
@ -64,29 +52,3 @@ type Switch struct {
|
|||||||
// DeviceClass describes the semantic type when Home Assistant provides it.
|
// DeviceClass describes the semantic type when Home Assistant provides it.
|
||||||
DeviceClass string
|
DeviceClass string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Climate is the discovery-oriented climate view exposed by ha-gateway.
|
|
||||||
type Climate struct {
|
|
||||||
// EntityID is the Home Assistant entity identifier.
|
|
||||||
EntityID string
|
|
||||||
// FriendlyName is the user-facing name from Home Assistant.
|
|
||||||
FriendlyName string
|
|
||||||
// State is the current raw HVAC mode, e.g. "cool", "off", "heat_cool".
|
|
||||||
State string
|
|
||||||
// HVACModes lists the modes Home Assistant reports as supported.
|
|
||||||
HVACModes []string
|
|
||||||
// FanMode is the current fan mode, when set.
|
|
||||||
FanMode string
|
|
||||||
// FanModes lists the fan modes Home Assistant reports as supported.
|
|
||||||
FanModes []string
|
|
||||||
// CurrentTemperature is nil when no sensor reading is available.
|
|
||||||
CurrentTemperature *float32
|
|
||||||
// TargetTemperature is nil when no target temperature is set.
|
|
||||||
TargetTemperature *float32
|
|
||||||
// TargetTempStep is the increment Home Assistant expects for temperature changes.
|
|
||||||
TargetTempStep float32
|
|
||||||
// MinTemp is the lower bound Home Assistant enforces for this entity.
|
|
||||||
MinTemp float32
|
|
||||||
// MaxTemp is the upper bound Home Assistant enforces for this entity.
|
|
||||||
MaxTemp float32
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ai/v1/ai.proto
|
// source: ai/v1/ai.proto
|
||||||
|
|
||||||
|
|||||||
@ -1,475 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,311 +0,0 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
|
||||||
// - protoc (unknown)
|
|
||||||
// source: ha/v1/climate.proto
|
|
||||||
|
|
||||||
package hav1
|
|
||||||
|
|
||||||
import (
|
|
||||||
context "context"
|
|
||||||
grpc "google.golang.org/grpc"
|
|
||||||
codes "google.golang.org/grpc/codes"
|
|
||||||
status "google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This is a compile-time assertion to ensure that this generated file
|
|
||||||
// is compatible with the grpc package it is being compiled against.
|
|
||||||
// Requires gRPC-Go v1.64.0 or later.
|
|
||||||
const _ = grpc.SupportPackageIsVersion9
|
|
||||||
|
|
||||||
const (
|
|
||||||
ClimateService_TurnOn_FullMethodName = "/ha.v1.ClimateService/TurnOn"
|
|
||||||
ClimateService_TurnOff_FullMethodName = "/ha.v1.ClimateService/TurnOff"
|
|
||||||
ClimateService_IncreaseTemperature_FullMethodName = "/ha.v1.ClimateService/IncreaseTemperature"
|
|
||||||
ClimateService_DecreaseTemperature_FullMethodName = "/ha.v1.ClimateService/DecreaseTemperature"
|
|
||||||
ClimateService_SetHVACMode_FullMethodName = "/ha.v1.ClimateService/SetHVACMode"
|
|
||||||
ClimateService_ListClimates_FullMethodName = "/ha.v1.ClimateService/ListClimates"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ClimateServiceClient is the client API for ClimateService service.
|
|
||||||
//
|
|
||||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
|
||||||
type ClimateServiceClient interface {
|
|
||||||
TurnOn(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
|
|
||||||
TurnOff(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
|
|
||||||
IncreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
|
|
||||||
DecreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
|
|
||||||
SetHVACMode(ctx context.Context, in *SetHVACModeRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
|
|
||||||
ListClimates(ctx context.Context, in *ListClimatesRequest, opts ...grpc.CallOption) (*ListClimatesResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type climateServiceClient struct {
|
|
||||||
cc grpc.ClientConnInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewClimateServiceClient(cc grpc.ClientConnInterface) ClimateServiceClient {
|
|
||||||
return &climateServiceClient{cc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *climateServiceClient) TurnOn(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(ClimateResponse)
|
|
||||||
err := c.cc.Invoke(ctx, ClimateService_TurnOn_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *climateServiceClient) TurnOff(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(ClimateResponse)
|
|
||||||
err := c.cc.Invoke(ctx, ClimateService_TurnOff_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *climateServiceClient) IncreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(ClimateResponse)
|
|
||||||
err := c.cc.Invoke(ctx, ClimateService_IncreaseTemperature_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *climateServiceClient) DecreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(ClimateResponse)
|
|
||||||
err := c.cc.Invoke(ctx, ClimateService_DecreaseTemperature_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *climateServiceClient) SetHVACMode(ctx context.Context, in *SetHVACModeRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(ClimateResponse)
|
|
||||||
err := c.cc.Invoke(ctx, ClimateService_SetHVACMode_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *climateServiceClient) ListClimates(ctx context.Context, in *ListClimatesRequest, opts ...grpc.CallOption) (*ListClimatesResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(ListClimatesResponse)
|
|
||||||
err := c.cc.Invoke(ctx, ClimateService_ListClimates_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClimateServiceServer is the server API for ClimateService service.
|
|
||||||
// All implementations must embed UnimplementedClimateServiceServer
|
|
||||||
// for forward compatibility.
|
|
||||||
type ClimateServiceServer interface {
|
|
||||||
TurnOn(context.Context, *ClimateRequest) (*ClimateResponse, error)
|
|
||||||
TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error)
|
|
||||||
IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error)
|
|
||||||
DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error)
|
|
||||||
SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error)
|
|
||||||
ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error)
|
|
||||||
mustEmbedUnimplementedClimateServiceServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnimplementedClimateServiceServer must be embedded to have
|
|
||||||
// forward compatible implementations.
|
|
||||||
//
|
|
||||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
|
||||||
// pointer dereference when methods are called.
|
|
||||||
type UnimplementedClimateServiceServer struct{}
|
|
||||||
|
|
||||||
func (UnimplementedClimateServiceServer) TurnOn(context.Context, *ClimateRequest) (*ClimateResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method TurnOn not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedClimateServiceServer) TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method TurnOff not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedClimateServiceServer) IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method IncreaseTemperature not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedClimateServiceServer) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method DecreaseTemperature not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedClimateServiceServer) SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method SetHVACMode not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedClimateServiceServer) ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method ListClimates not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedClimateServiceServer) mustEmbedUnimplementedClimateServiceServer() {}
|
|
||||||
func (UnimplementedClimateServiceServer) testEmbeddedByValue() {}
|
|
||||||
|
|
||||||
// UnsafeClimateServiceServer may be embedded to opt out of forward compatibility for this service.
|
|
||||||
// Use of this interface is not recommended, as added methods to ClimateServiceServer will
|
|
||||||
// result in compilation errors.
|
|
||||||
type UnsafeClimateServiceServer interface {
|
|
||||||
mustEmbedUnimplementedClimateServiceServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegisterClimateServiceServer(s grpc.ServiceRegistrar, srv ClimateServiceServer) {
|
|
||||||
// If the following call panics, it indicates UnimplementedClimateServiceServer was
|
|
||||||
// embedded by pointer and is nil. This will cause panics if an
|
|
||||||
// unimplemented method is ever invoked, so we test this at initialization
|
|
||||||
// time to prevent it from happening at runtime later due to I/O.
|
|
||||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
|
||||||
t.testEmbeddedByValue()
|
|
||||||
}
|
|
||||||
s.RegisterService(&ClimateService_ServiceDesc, srv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _ClimateService_TurnOn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(ClimateRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(ClimateServiceServer).TurnOn(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: ClimateService_TurnOn_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(ClimateServiceServer).TurnOn(ctx, req.(*ClimateRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _ClimateService_TurnOff_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(ClimateRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(ClimateServiceServer).TurnOff(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: ClimateService_TurnOff_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(ClimateServiceServer).TurnOff(ctx, req.(*ClimateRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _ClimateService_IncreaseTemperature_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(ClimateRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(ClimateServiceServer).IncreaseTemperature(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: ClimateService_IncreaseTemperature_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(ClimateServiceServer).IncreaseTemperature(ctx, req.(*ClimateRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _ClimateService_DecreaseTemperature_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(ClimateRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(ClimateServiceServer).DecreaseTemperature(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: ClimateService_DecreaseTemperature_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(ClimateServiceServer).DecreaseTemperature(ctx, req.(*ClimateRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _ClimateService_SetHVACMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(SetHVACModeRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(ClimateServiceServer).SetHVACMode(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: ClimateService_SetHVACMode_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(ClimateServiceServer).SetHVACMode(ctx, req.(*SetHVACModeRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _ClimateService_ListClimates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(ListClimatesRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(ClimateServiceServer).ListClimates(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: ClimateService_ListClimates_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(ClimateServiceServer).ListClimates(ctx, req.(*ListClimatesRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClimateService_ServiceDesc is the grpc.ServiceDesc for ClimateService service.
|
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
|
||||||
// and not to be introspected or modified (even as a copy)
|
|
||||||
var ClimateService_ServiceDesc = grpc.ServiceDesc{
|
|
||||||
ServiceName: "ha.v1.ClimateService",
|
|
||||||
HandlerType: (*ClimateServiceServer)(nil),
|
|
||||||
Methods: []grpc.MethodDesc{
|
|
||||||
{
|
|
||||||
MethodName: "TurnOn",
|
|
||||||
Handler: _ClimateService_TurnOn_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "TurnOff",
|
|
||||||
Handler: _ClimateService_TurnOff_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "IncreaseTemperature",
|
|
||||||
Handler: _ClimateService_IncreaseTemperature_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "DecreaseTemperature",
|
|
||||||
Handler: _ClimateService_DecreaseTemperature_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "SetHVACMode",
|
|
||||||
Handler: _ClimateService_SetHVACMode_Handler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MethodName: "ListClimates",
|
|
||||||
Handler: _ClimateService_ListClimates_Handler,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Streams: []grpc.StreamDesc{},
|
|
||||||
Metadata: "ha/v1/climate.proto",
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/entity.proto
|
// source: ha/v1/entity.proto
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/event.proto
|
// source: ha/v1/event.proto
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/light.proto
|
// source: ha/v1/light.proto
|
||||||
|
|
||||||
|
|||||||
@ -1,182 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
|
||||||
// versions:
|
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
|
||||||
// - protoc (unknown)
|
|
||||||
// source: ha/v1/remote.proto
|
|
||||||
|
|
||||||
package hav1
|
|
||||||
|
|
||||||
import (
|
|
||||||
context "context"
|
|
||||||
grpc "google.golang.org/grpc"
|
|
||||||
codes "google.golang.org/grpc/codes"
|
|
||||||
status "google.golang.org/grpc/status"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This is a compile-time assertion to ensure that this generated file
|
|
||||||
// is compatible with the grpc package it is being compiled against.
|
|
||||||
// Requires gRPC-Go v1.64.0 or later.
|
|
||||||
const _ = grpc.SupportPackageIsVersion9
|
|
||||||
|
|
||||||
const (
|
|
||||||
RemoteService_SendCommand_FullMethodName = "/ha.v1.RemoteService/SendCommand"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RemoteServiceClient is the client API for RemoteService service.
|
|
||||||
//
|
|
||||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
|
||||||
type RemoteServiceClient interface {
|
|
||||||
SendCommand(ctx context.Context, in *SendCommandRequest, opts ...grpc.CallOption) (*SendCommandResponse, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type remoteServiceClient struct {
|
|
||||||
cc grpc.ClientConnInterface
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRemoteServiceClient(cc grpc.ClientConnInterface) RemoteServiceClient {
|
|
||||||
return &remoteServiceClient{cc}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *remoteServiceClient) SendCommand(ctx context.Context, in *SendCommandRequest, opts ...grpc.CallOption) (*SendCommandResponse, error) {
|
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
|
||||||
out := new(SendCommandResponse)
|
|
||||||
err := c.cc.Invoke(ctx, RemoteService_SendCommand_FullMethodName, in, out, cOpts...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteServiceServer is the server API for RemoteService service.
|
|
||||||
// All implementations must embed UnimplementedRemoteServiceServer
|
|
||||||
// for forward compatibility.
|
|
||||||
type RemoteServiceServer interface {
|
|
||||||
SendCommand(context.Context, *SendCommandRequest) (*SendCommandResponse, error)
|
|
||||||
mustEmbedUnimplementedRemoteServiceServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnimplementedRemoteServiceServer must be embedded to have
|
|
||||||
// forward compatible implementations.
|
|
||||||
//
|
|
||||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
|
||||||
// pointer dereference when methods are called.
|
|
||||||
type UnimplementedRemoteServiceServer struct{}
|
|
||||||
|
|
||||||
func (UnimplementedRemoteServiceServer) SendCommand(context.Context, *SendCommandRequest) (*SendCommandResponse, error) {
|
|
||||||
return nil, status.Error(codes.Unimplemented, "method SendCommand not implemented")
|
|
||||||
}
|
|
||||||
func (UnimplementedRemoteServiceServer) mustEmbedUnimplementedRemoteServiceServer() {}
|
|
||||||
func (UnimplementedRemoteServiceServer) testEmbeddedByValue() {}
|
|
||||||
|
|
||||||
// UnsafeRemoteServiceServer may be embedded to opt out of forward compatibility for this service.
|
|
||||||
// Use of this interface is not recommended, as added methods to RemoteServiceServer will
|
|
||||||
// result in compilation errors.
|
|
||||||
type UnsafeRemoteServiceServer interface {
|
|
||||||
mustEmbedUnimplementedRemoteServiceServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegisterRemoteServiceServer(s grpc.ServiceRegistrar, srv RemoteServiceServer) {
|
|
||||||
// If the following call panics, it indicates UnimplementedRemoteServiceServer was
|
|
||||||
// embedded by pointer and is nil. This will cause panics if an
|
|
||||||
// unimplemented method is ever invoked, so we test this at initialization
|
|
||||||
// time to prevent it from happening at runtime later due to I/O.
|
|
||||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
|
||||||
t.testEmbeddedByValue()
|
|
||||||
}
|
|
||||||
s.RegisterService(&RemoteService_ServiceDesc, srv)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _RemoteService_SendCommand_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
|
||||||
in := new(SendCommandRequest)
|
|
||||||
if err := dec(in); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if interceptor == nil {
|
|
||||||
return srv.(RemoteServiceServer).SendCommand(ctx, in)
|
|
||||||
}
|
|
||||||
info := &grpc.UnaryServerInfo{
|
|
||||||
Server: srv,
|
|
||||||
FullMethod: RemoteService_SendCommand_FullMethodName,
|
|
||||||
}
|
|
||||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
|
||||||
return srv.(RemoteServiceServer).SendCommand(ctx, req.(*SendCommandRequest))
|
|
||||||
}
|
|
||||||
return interceptor(ctx, in, info, handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteService_ServiceDesc is the grpc.ServiceDesc for RemoteService service.
|
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
|
||||||
// and not to be introspected or modified (even as a copy)
|
|
||||||
var RemoteService_ServiceDesc = grpc.ServiceDesc{
|
|
||||||
ServiceName: "ha.v1.RemoteService",
|
|
||||||
HandlerType: (*RemoteServiceServer)(nil),
|
|
||||||
Methods: []grpc.MethodDesc{
|
|
||||||
{
|
|
||||||
MethodName: "SendCommand",
|
|
||||||
Handler: _RemoteService_SendCommand_Handler,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Streams: []grpc.StreamDesc{},
|
|
||||||
Metadata: "ha/v1/remote.proto",
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.6.2
|
// - protoc-gen-go-grpc v1.6.1
|
||||||
// - protoc (unknown)
|
// - protoc (unknown)
|
||||||
// source: ha/v1/switch.proto
|
// source: ha/v1/switch.proto
|
||||||
|
|
||||||
|
|||||||
@ -4,5 +4,3 @@ HA_TOKEN=your-long-lived-token-here
|
|||||||
OTEL_ENDPOINT=
|
OTEL_ENDPOINT=
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
LOG_FORMAT=text
|
LOG_FORMAT=text
|
||||||
SWITCHBOT_TOKEN=
|
|
||||||
SWITCHBOT_SECRET=
|
|
||||||
|
|||||||
@ -8,13 +8,10 @@ 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 and, when
|
2. App services are wired to a Home Assistant REST adapter.
|
||||||
`SWITCHBOT_TOKEN`/`SWITCHBOT_SECRET` are set, a SwitchBot Cloud adapter.
|
3. Light and switch discovery caches are refreshed during startup when possible.
|
||||||
3. Light, switch, and climate discovery caches are refreshed during startup
|
4. gRPC clients call entity, light, switch, or event services.
|
||||||
when possible.
|
5. The adapter maps requests to Home Assistant REST state and service calls.
|
||||||
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
|
||||||
|
|
||||||
@ -32,13 +29,6 @@ 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:
|
||||||
|
|
||||||
@ -59,8 +49,6 @@ 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)
|
||||||
|
|
||||||
@ -96,8 +84,6 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -126,8 +112,7 @@ 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/adapters/secondary/switchbot/ # SwitchBot Cloud Open API adapter
|
internal/app/ # entity, light, and switch orchestration
|
||||||
internal/app/ # entity, light, switch, climate, and remote orchestration
|
|
||||||
internal/config/ # environment loading
|
internal/config/ # environment loading
|
||||||
internal/core/domain/ # domain types
|
internal/core/domain/ # domain types
|
||||||
internal/core/ports/ # driving and driven interfaces
|
internal/core/ports/ # driving and driven interfaces
|
||||||
|
|||||||
@ -22,7 +22,6 @@ 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"
|
||||||
@ -61,7 +60,6 @@ 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)
|
||||||
@ -81,14 +79,11 @@ 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)
|
||||||
@ -96,9 +91,6 @@ 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()),
|
||||||
@ -124,8 +116,6 @@ 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)
|
||||||
|
|||||||
@ -1,77 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,379 +0,0 @@
|
|||||||
package grpc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
|
||||||
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
|
||||||
"google.golang.org/grpc/status"
|
|
||||||
"google.golang.org/grpc/test/bufconn"
|
|
||||||
)
|
|
||||||
|
|
||||||
type mockClimateService struct {
|
|
||||||
turnOnFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
||||||
turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
||||||
increaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
||||||
decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
|
|
||||||
setHVACModeFunc func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error)
|
|
||||||
listClimatesFunc func(ctx context.Context) ([]domain.Climate, error)
|
|
||||||
refreshFunc func(ctx context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) TurnOn(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
if m.turnOnFunc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return m.turnOnFunc(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) TurnOff(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
if m.turnOffFunc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return m.turnOffFunc(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
if m.increaseTemperatureFunc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return m.increaseTemperatureFunc(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
if m.decreaseTemperatureFunc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return m.decreaseTemperatureFunc(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
|
|
||||||
if m.setHVACModeFunc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return m.setHVACModeFunc(ctx, id, hvacMode)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) ListClimates(ctx context.Context) ([]domain.Climate, error) {
|
|
||||||
if m.listClimatesFunc == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return m.listClimatesFunc(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockClimateService) Refresh(ctx context.Context) error {
|
|
||||||
if m.refreshFunc == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m.refreshFunc(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateGRPCTurnOn(t *testing.T) {
|
|
||||||
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
wantCode codes.Code
|
|
||||||
}{
|
|
||||||
{name: "happy path", wantCode: codes.OK},
|
|
||||||
{name: "not found maps to codes.NotFound", err: ErrNotFound, wantCode: codes.NotFound},
|
|
||||||
{name: "generic error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
var gotID domain.EntityID
|
|
||||||
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
||||||
turnOnFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
gotID = id
|
|
||||||
if tt.err != nil {
|
|
||||||
return nil, tt.err
|
|
||||||
}
|
|
||||||
return &domain.EntityState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]string{"friendly_name": "Air Conditioner"},
|
|
||||||
LastChanged: now,
|
|
||||||
LastUpdated: now,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
client := hav1.NewClimateServiceClient(conn)
|
|
||||||
|
|
||||||
resp, err := client.TurnOn(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
||||||
if status.Code(err) != tt.wantCode {
|
|
||||||
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
||||||
}
|
|
||||||
if tt.wantCode != codes.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if gotID != "climate.air_conditioner" {
|
|
||||||
t.Fatalf("TurnOn id = %q, want %q", gotID, "climate.air_conditioner")
|
|
||||||
}
|
|
||||||
if resp.GetState().GetEntityId() != "climate.air_conditioner" || resp.GetState().GetState() != "cool" {
|
|
||||||
t.Fatalf("response state = %#v", resp.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateGRPCTurnOff(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
wantCode codes.Code
|
|
||||||
}{
|
|
||||||
{name: "happy path", wantCode: codes.OK},
|
|
||||||
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
||||||
turnOffFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
if tt.err != nil {
|
|
||||||
return nil, tt.err
|
|
||||||
}
|
|
||||||
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "off"}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
client := hav1.NewClimateServiceClient(conn)
|
|
||||||
|
|
||||||
resp, err := client.TurnOff(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
||||||
if status.Code(err) != tt.wantCode {
|
|
||||||
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
||||||
}
|
|
||||||
if tt.wantCode != codes.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.GetState().GetState() != "off" {
|
|
||||||
t.Fatalf("response state = %#v", resp.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateGRPCIncreaseTemperature(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
wantCode codes.Code
|
|
||||||
}{
|
|
||||||
{name: "happy path", wantCode: codes.OK},
|
|
||||||
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
var gotID domain.EntityID
|
|
||||||
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
||||||
increaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
gotID = id
|
|
||||||
if tt.err != nil {
|
|
||||||
return nil, tt.err
|
|
||||||
}
|
|
||||||
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
client := hav1.NewClimateServiceClient(conn)
|
|
||||||
|
|
||||||
resp, err := client.IncreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
||||||
if status.Code(err) != tt.wantCode {
|
|
||||||
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
||||||
}
|
|
||||||
if tt.wantCode != codes.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if gotID != "climate.air_conditioner" {
|
|
||||||
t.Fatalf("IncreaseTemperature id = %q, want %q", gotID, "climate.air_conditioner")
|
|
||||||
}
|
|
||||||
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
|
|
||||||
t.Fatalf("response state = %#v", resp.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateGRPCDecreaseTemperature(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
wantCode codes.Code
|
|
||||||
}{
|
|
||||||
{name: "happy path", wantCode: codes.OK},
|
|
||||||
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
||||||
decreaseTemperatureFunc: func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) {
|
|
||||||
if tt.err != nil {
|
|
||||||
return nil, tt.err
|
|
||||||
}
|
|
||||||
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
client := hav1.NewClimateServiceClient(conn)
|
|
||||||
|
|
||||||
resp, err := client.DecreaseTemperature(context.Background(), &hav1.ClimateRequest{EntityId: "climate.air_conditioner"})
|
|
||||||
if status.Code(err) != tt.wantCode {
|
|
||||||
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
||||||
}
|
|
||||||
if tt.wantCode != codes.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
|
|
||||||
t.Fatalf("response state = %#v", resp.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateGRPCSetHVACMode(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
wantCode codes.Code
|
|
||||||
}{
|
|
||||||
{name: "happy path", wantCode: codes.OK},
|
|
||||||
{name: "error maps to codes.Internal", err: errors.New("boom"), wantCode: codes.Internal},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
var gotID domain.EntityID
|
|
||||||
var gotMode string
|
|
||||||
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
||||||
setHVACModeFunc: func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
|
|
||||||
gotID = id
|
|
||||||
gotMode = hvacMode
|
|
||||||
if tt.err != nil {
|
|
||||||
return nil, tt.err
|
|
||||||
}
|
|
||||||
return &domain.EntityState{EntityID: "climate.air_conditioner", State: "cool"}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
client := hav1.NewClimateServiceClient(conn)
|
|
||||||
|
|
||||||
resp, err := client.SetHVACMode(context.Background(), &hav1.SetHVACModeRequest{EntityId: "climate.air_conditioner", HvacMode: "cool"})
|
|
||||||
if status.Code(err) != tt.wantCode {
|
|
||||||
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
||||||
}
|
|
||||||
if tt.wantCode != codes.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if gotID != "climate.air_conditioner" || gotMode != "cool" {
|
|
||||||
t.Fatalf("SetHVACMode id/mode = %q/%q, want %q/%q", gotID, gotMode, "climate.air_conditioner", "cool")
|
|
||||||
}
|
|
||||||
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
|
|
||||||
t.Fatalf("response state = %#v", resp.GetState())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateGRPCListClimates(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
wantCode codes.Code
|
|
||||||
}{
|
|
||||||
{name: "happy path with multiple climates", wantCode: codes.OK},
|
|
||||||
{name: "error path", err: errors.New("boom"), wantCode: codes.Internal},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
current := 21.5
|
|
||||||
target := 22.0
|
|
||||||
conn := newClimateTestClientConn(t, &mockClimateService{
|
|
||||||
listClimatesFunc: func(ctx context.Context) ([]domain.Climate, error) {
|
|
||||||
if tt.err != nil {
|
|
||||||
return nil, tt.err
|
|
||||||
}
|
|
||||||
return []domain.Climate{
|
|
||||||
{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
FriendlyName: "Air Conditioner",
|
|
||||||
State: "cool",
|
|
||||||
HVACModes: []string{"cool", "heat", "off"},
|
|
||||||
FanMode: "auto",
|
|
||||||
FanModes: []string{"auto", "low"},
|
|
||||||
CurrentTemperature: ¤t,
|
|
||||||
TargetTemperature: &target,
|
|
||||||
TargetTempStep: 1,
|
|
||||||
MinTemp: 7,
|
|
||||||
MaxTemp: 35,
|
|
||||||
},
|
|
||||||
{EntityID: "climate.bedroom", State: "off"},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
client := hav1.NewClimateServiceClient(conn)
|
|
||||||
|
|
||||||
resp, err := client.ListClimates(context.Background(), &hav1.ListClimatesRequest{})
|
|
||||||
if status.Code(err) != tt.wantCode {
|
|
||||||
t.Fatalf("status code = %v, want %v", status.Code(err), tt.wantCode)
|
|
||||||
}
|
|
||||||
if tt.wantCode != codes.OK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp.GetClimates()) != 2 {
|
|
||||||
t.Fatalf("len(climates) = %d, want 2", len(resp.GetClimates()))
|
|
||||||
}
|
|
||||||
first := resp.GetClimates()[0]
|
|
||||||
if first.GetEntityId() != "climate.air_conditioner" {
|
|
||||||
t.Fatalf("climates[0].EntityId = %q, want %q", first.GetEntityId(), "climate.air_conditioner")
|
|
||||||
}
|
|
||||||
if first.GetCurrentTemperature() != 21.5 || first.GetTargetTemperature() != 22.0 {
|
|
||||||
t.Fatalf("climates[0] temperatures = (%v, %v), want (21.5, 22.0)", first.GetCurrentTemperature(), first.GetTargetTemperature())
|
|
||||||
}
|
|
||||||
second := resp.GetClimates()[1]
|
|
||||||
if second.CurrentTemperature != nil || second.TargetTemperature != nil {
|
|
||||||
t.Fatalf("climates[1] should have no temperature set, got current=%v target=%v", second.GetCurrentTemperature(), second.GetTargetTemperature())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClimateTestClientConn(t *testing.T, svc *mockClimateService) *grpc.ClientConn {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lis := bufconn.Listen(testBufSize)
|
|
||||||
server := grpc.NewServer()
|
|
||||||
hav1.RegisterClimateServiceServer(server, NewClimateGRPC(svc))
|
|
||||||
go func() {
|
|
||||||
_ = server.Serve(lis)
|
|
||||||
}()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
server.Stop()
|
|
||||||
_ = lis.Close()
|
|
||||||
})
|
|
||||||
|
|
||||||
conn, err := grpc.DialContext(
|
|
||||||
context.Background(),
|
|
||||||
"bufnet",
|
|
||||||
grpc.WithContextDialer(func(ctx context.Context, s string) (net.Conn, error) {
|
|
||||||
return lis.Dial()
|
|
||||||
}),
|
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("grpc.DialContext() error = %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() {
|
|
||||||
_ = conn.Close()
|
|
||||||
})
|
|
||||||
return conn
|
|
||||||
}
|
|
||||||
@ -83,29 +83,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,222 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,187 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,538 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/domain"
|
|
||||||
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClimateAppRefresh(t *testing.T) {
|
|
||||||
t.Run("filters non-climate entities and populates cache", func(t *testing.T) {
|
|
||||||
current := 21.5
|
|
||||||
target := 22.0
|
|
||||||
ha := &mockHAClient{
|
|
||||||
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
||||||
return []*driven.HAState{
|
|
||||||
{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{
|
|
||||||
"friendly_name": "Air Conditioner",
|
|
||||||
"hvac_modes": []any{"heat_cool", "cool", "dry", "fan_only", "heat", "off"},
|
|
||||||
"fan_mode": "auto",
|
|
||||||
"fan_modes": []any{"auto", "low", "high"},
|
|
||||||
"current_temperature": current,
|
|
||||||
"temperature": target,
|
|
||||||
"target_temp_step": float64(1),
|
|
||||||
"min_temp": float64(7),
|
|
||||||
"max_temp": float64(35),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
EntityID: "switch.fan",
|
|
||||||
State: "off",
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
app := NewClimateApp(ha)
|
|
||||||
if err := app.Refresh(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Refresh() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got := app.cache
|
|
||||||
want := []domain.Climate{
|
|
||||||
{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
FriendlyName: "Air Conditioner",
|
|
||||||
State: "cool",
|
|
||||||
HVACModes: []string{"heat_cool", "cool", "dry", "fan_only", "heat", "off"},
|
|
||||||
FanMode: "auto",
|
|
||||||
FanModes: []string{"auto", "low", "high"},
|
|
||||||
CurrentTemperature: ¤t,
|
|
||||||
TargetTemperature: &target,
|
|
||||||
TargetTempStep: 1,
|
|
||||||
MinTemp: 7,
|
|
||||||
MaxTemp: 35,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("cache = %#v, want %#v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nil current_temperature means no sensor, not zero", func(t *testing.T) {
|
|
||||||
ha := &mockHAClient{
|
|
||||||
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
||||||
return []*driven.HAState{
|
|
||||||
{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "off",
|
|
||||||
Attributes: map[string]any{"current_temperature": nil},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
app := NewClimateApp(ha)
|
|
||||||
if err := app.Refresh(context.Background()); err != nil {
|
|
||||||
t.Fatalf("Refresh() error = %v", err)
|
|
||||||
}
|
|
||||||
if app.cache[0].CurrentTemperature != nil {
|
|
||||||
t.Fatalf("CurrentTemperature = %v, want nil", *app.cache[0].CurrentTemperature)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateAppListClimates(t *testing.T) {
|
|
||||||
t.Run("uses cache when populated", func(t *testing.T) {
|
|
||||||
calls := 0
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
||||||
calls++
|
|
||||||
return nil, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
app.cache = []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}}
|
|
||||||
|
|
||||||
got, err := app.ListClimates(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListClimates() error = %v", err)
|
|
||||||
}
|
|
||||||
if calls != 0 {
|
|
||||||
t.Fatalf("ListStates() calls = %d, want 0", calls)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, app.cache) {
|
|
||||||
t.Fatalf("ListClimates() = %#v, want %#v", got, app.cache)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("calls ListStates when cache is nil", func(t *testing.T) {
|
|
||||||
calls := 0
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
||||||
calls++
|
|
||||||
return []*driven.HAState{
|
|
||||||
{EntityID: "climate.air_conditioner", State: "cool"},
|
|
||||||
{EntityID: "sensor.temp", State: "21"},
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := app.ListClimates(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListClimates() error = %v", err)
|
|
||||||
}
|
|
||||||
if calls != 1 {
|
|
||||||
t.Fatalf("ListStates() calls = %d, want 1", calls)
|
|
||||||
}
|
|
||||||
want := []domain.Climate{{EntityID: "climate.air_conditioner", State: "cool"}}
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Fatalf("ListClimates() = %#v, want %#v", got, want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("propagates refresh error", func(t *testing.T) {
|
|
||||||
wantErr := errors.New("list failed")
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
listStatesFunc: func(ctx context.Context) ([]*driven.HAState, error) {
|
|
||||||
return nil, wantErr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.ListClimates(context.Background())
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("ListClimates() error = %v, want %v", err, wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateAppTurnOn(t *testing.T) {
|
|
||||||
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
||||||
state := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{"friendly_name": "Air Conditioner"},
|
|
||||||
LastChanged: now,
|
|
||||||
LastUpdated: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("happy path", func(t *testing.T) {
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
if svcDomain != "climate" || service != "turn_on" {
|
|
||||||
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
||||||
}
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner"}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{state}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := app.TurnOn(context.Background(), "climate.air_conditioner")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TurnOn() error = %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
||||||
t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("falls back to GetState when service returns empty list", func(t *testing.T) {
|
|
||||||
getStateCalls := 0
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
return []*driven.HAState{}, nil
|
|
||||||
},
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
getStateCalls++
|
|
||||||
return state, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := app.TurnOn(context.Background(), "climate.air_conditioner")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TurnOn() error = %v", err)
|
|
||||||
}
|
|
||||||
if getStateCalls != 1 {
|
|
||||||
t.Fatalf("GetState() calls = %d, want 1", getStateCalls)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
||||||
t.Fatalf("TurnOn() = %#v, want %#v", got, haStateToDomain(state))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("returns CallService error", func(t *testing.T) {
|
|
||||||
wantErr := errors.New("call failed")
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
return nil, wantErr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.TurnOn(context.Background(), "climate.air_conditioner")
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("TurnOn() error = %v, want %v", err, wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateAppTurnOff(t *testing.T) {
|
|
||||||
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
||||||
state := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "off",
|
|
||||||
Attributes: map[string]any{"friendly_name": "Air Conditioner"},
|
|
||||||
LastChanged: now,
|
|
||||||
LastUpdated: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("happy path", func(t *testing.T) {
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
if svcDomain != "climate" || service != "turn_off" {
|
|
||||||
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{state}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := app.TurnOff(context.Background(), "climate.air_conditioner")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("TurnOff() error = %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
||||||
t.Fatalf("TurnOff() = %#v, want %#v", got, haStateToDomain(state))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("error path", func(t *testing.T) {
|
|
||||||
wantErr := errors.New("turn off failed")
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
return nil, wantErr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.TurnOff(context.Background(), "climate.air_conditioner")
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("TurnOff() error = %v, want %v", err, wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateAppSetHVACMode(t *testing.T) {
|
|
||||||
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
||||||
state := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{"friendly_name": "Air Conditioner"},
|
|
||||||
LastChanged: now,
|
|
||||||
LastUpdated: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("happy path passes raw mode through with no validation", func(t *testing.T) {
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
if svcDomain != "climate" || service != "set_hvac_mode" {
|
|
||||||
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
||||||
}
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "hvac_mode": "cool"}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{state}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("SetHVACMode() error = %v", err)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(got, haStateToDomain(state)) {
|
|
||||||
t.Fatalf("SetHVACMode() = %#v, want %#v", got, haStateToDomain(state))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("error path", func(t *testing.T) {
|
|
||||||
wantErr := errors.New("set hvac mode failed")
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
return nil, wantErr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.SetHVACMode(context.Background(), "climate.air_conditioner", "cool")
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("SetHVACMode() error = %v, want %v", err, wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateAppIncreaseTemperature(t *testing.T) {
|
|
||||||
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
||||||
resultState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
LastChanged: now,
|
|
||||||
LastUpdated: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("steps up by target_temp_step", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{
|
|
||||||
"temperature": float64(21),
|
|
||||||
"target_temp_step": float64(1),
|
|
||||||
"min_temp": float64(7),
|
|
||||||
"max_temp": float64(35),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
if svcDomain != "climate" || service != "set_temperature" {
|
|
||||||
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
||||||
}
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{resultState}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
||||||
t.Fatalf("IncreaseTemperature() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("step defaults to 1 when target_temp_step is 0", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{
|
|
||||||
"temperature": float64(21),
|
|
||||||
"min_temp": float64(7),
|
|
||||||
"max_temp": float64(35),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(22)}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{resultState}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
||||||
t.Fatalf("IncreaseTemperature() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("clamps at max_temp", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{
|
|
||||||
"temperature": float64(35),
|
|
||||||
"target_temp_step": float64(1),
|
|
||||||
"min_temp": float64(7),
|
|
||||||
"max_temp": float64(35),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(35)}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{resultState}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
||||||
t.Fatalf("IncreaseTemperature() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("error when no target temperature is set", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "off",
|
|
||||||
Attributes: map[string]any{},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("IncreaseTemperature() error = nil, want error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("propagates GetState error", func(t *testing.T) {
|
|
||||||
wantErr := errors.New("get state failed")
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return nil, wantErr
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.IncreaseTemperature(context.Background(), "climate.air_conditioner")
|
|
||||||
if !errors.Is(err, wantErr) {
|
|
||||||
t.Fatalf("IncreaseTemperature() error = %v, want %v", err, wantErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClimateAppDecreaseTemperature(t *testing.T) {
|
|
||||||
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
|
|
||||||
resultState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
LastChanged: now,
|
|
||||||
LastUpdated: now,
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("steps down by target_temp_step", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{
|
|
||||||
"temperature": float64(21),
|
|
||||||
"target_temp_step": float64(1),
|
|
||||||
"min_temp": float64(7),
|
|
||||||
"max_temp": float64(35),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
if svcDomain != "climate" || service != "set_temperature" {
|
|
||||||
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
|
|
||||||
}
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(20)}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{resultState}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
||||||
t.Fatalf("DecreaseTemperature() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("clamps at min_temp", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "cool",
|
|
||||||
Attributes: map[string]any{
|
|
||||||
"temperature": float64(7),
|
|
||||||
"target_temp_step": float64(1),
|
|
||||||
"min_temp": float64(7),
|
|
||||||
"max_temp": float64(35),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
|
|
||||||
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": float64(7)}
|
|
||||||
if !reflect.DeepEqual(payload, wantPayload) {
|
|
||||||
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
|
|
||||||
}
|
|
||||||
return []*driven.HAState{resultState}, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if _, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner"); err != nil {
|
|
||||||
t.Fatalf("DecreaseTemperature() error = %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("error when no target temperature is set", func(t *testing.T) {
|
|
||||||
liveState := &driven.HAState{
|
|
||||||
EntityID: "climate.air_conditioner",
|
|
||||||
State: "off",
|
|
||||||
Attributes: map[string]any{},
|
|
||||||
}
|
|
||||||
app := NewClimateApp(&mockHAClient{
|
|
||||||
getStateFunc: func(ctx context.Context, entityID string) (*driven.HAState, error) {
|
|
||||||
return liveState, nil
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := app.DecreaseTemperature(context.Background(), "climate.air_conditioner")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("DecreaseTemperature() error = nil, want error")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"gitea.nik4nao.com/nik/home-services/ha-gateway/internal/core/ports/driven"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RemoteApp is a thin passthrough to the SwitchBot driven port: SwitchBot
|
|
||||||
// custom IR buttons have no state to read back, so there is no discovery
|
|
||||||
// cache to maintain here, unlike LightApp/SwitchApp/ClimateApp.
|
|
||||||
type RemoteApp struct {
|
|
||||||
switchbot driven.SwitchBotClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewRemoteApp constructs the remote application service.
|
|
||||||
func NewRemoteApp(switchbot driven.SwitchBotClient) *RemoteApp {
|
|
||||||
return &RemoteApp{switchbot: switchbot}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendCommand forwards one SwitchBot Cloud device command.
|
|
||||||
func (a *RemoteApp) SendCommand(ctx context.Context, deviceID, command, commandType string) error {
|
|
||||||
return a.switchbot.SendCommand(ctx, deviceID, command, commandType)
|
|
||||||
}
|
|
||||||
@ -15,9 +15,6 @@ 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.
|
||||||
@ -40,9 +37,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
package driving
|
|
||||||
|
|
||||||
import "context"
|
|
||||||
|
|
||||||
type RemoteService interface {
|
|
||||||
// SendCommand issues one SwitchBot Cloud device command.
|
|
||||||
SendCommand(ctx context.Context, deviceID, command, commandType string) error
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
syntax = "proto3";
|
|
||||||
package ha.v1;
|
|
||||||
option go_package = "gitea.nik4nao.com/nik/home-services/gen/ha/v1;hav1";
|
|
||||||
|
|
||||||
service RemoteService {
|
|
||||||
rpc SendCommand(SendCommandRequest) returns (SendCommandResponse);
|
|
||||||
}
|
|
||||||
|
|
||||||
message SendCommandRequest {
|
|
||||||
string device_id = 1;
|
|
||||||
string command = 2;
|
|
||||||
string command_type = 3; // "command" or "customize"
|
|
||||||
}
|
|
||||||
|
|
||||||
message SendCommandResponse {}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user