feat(climate): add SetTemperature method to ClimateService
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped

- Implemented SetTemperature in ClimateService for setting an absolute target temperature.
- Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature.
- Added corresponding handler and tests for SetTemperature in ClimateGRPC.
- Modified ClimateApp to handle SetTemperature requests without clamping.
- Updated climate.proto to define SetTemperatureRequest message.
- Adjusted Dockerfiles to include alexa-bridge dependencies.
This commit is contained in:
Nik Afiq 2026-07-25 12:08:24 +09:00
parent a7730772c2
commit 8f7024edfa
48 changed files with 4420 additions and 36 deletions

View File

@ -28,6 +28,7 @@ jobs:
ai-gateway: ${{ steps.filter.outputs.ai-gateway }} ai-gateway: ${{ steps.filter.outputs.ai-gateway }}
ha-gateway: ${{ steps.filter.outputs.ha-gateway }} ha-gateway: ${{ steps.filter.outputs.ha-gateway }}
discord-bot: ${{ steps.filter.outputs.discord-bot }} discord-bot: ${{ steps.filter.outputs.discord-bot }}
alexa-bridge: ${{ steps.filter.outputs.alexa-bridge }}
tts-gateway: ${{ steps.filter.outputs.tts-gateway }} tts-gateway: ${{ steps.filter.outputs.tts-gateway }}
tts-sidecar: ${{ steps.filter.outputs.tts-sidecar }} tts-sidecar: ${{ steps.filter.outputs.tts-sidecar }}
tts-model: ${{ steps.filter.outputs.tts-model }} tts-model: ${{ steps.filter.outputs.tts-model }}
@ -59,6 +60,11 @@ jobs:
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- 'discord-bot/**' - 'discord-bot/**'
alexa-bridge:
- 'gen/**'
- 'go.work'
- 'go.work.sum'
- 'alexa-bridge/**'
tts-gateway: tts-gateway:
- 'gen/**' - 'gen/**'
- 'go.work' - 'go.work'
@ -89,6 +95,7 @@ jobs:
ai-gateway/go.sum ai-gateway/go.sum
ha-gateway/go.sum ha-gateway/go.sum
discord-bot/go.sum discord-bot/go.sum
alexa-bridge/go.sum
tts-gateway/go.sum tts-gateway/go.sum
gen/go.sum gen/go.sum
@ -98,6 +105,7 @@ jobs:
cd ../ai-gateway && go vet ./... cd ../ai-gateway && go vet ./...
cd ../ha-gateway && go vet ./... cd ../ha-gateway && go vet ./...
cd ../discord-bot && go vet ./... cd ../discord-bot && go vet ./...
cd ../alexa-bridge && go vet ./...
cd ../tts-gateway && go vet ./... cd ../tts-gateway && go vet ./...
- name: go test - name: go test
@ -106,6 +114,7 @@ jobs:
cd ../ai-gateway && go test ./... cd ../ai-gateway && go test ./...
cd ../ha-gateway && go test ./... cd ../ha-gateway && go test ./...
cd ../discord-bot && go test ./... cd ../discord-bot && go test ./...
cd ../alexa-bridge && go test ./...
cd ../tts-gateway && go test ./... cd ../tts-gateway && go test ./...
build-ai-gateway: build-ai-gateway:
@ -189,6 +198,33 @@ jobs:
${{ env.IMAGE_PREFIX }}/discord-bot:${{ github.sha }} ${{ env.IMAGE_PREFIX }}/discord-bot:${{ github.sha }}
${{ env.IMAGE_PREFIX }}/discord-bot:latest ${{ env.IMAGE_PREFIX }}/discord-bot:latest
build-alexa-bridge:
needs: [test, changes]
if: github.ref == 'refs/heads/main' && needs.changes.outputs.alexa-bridge == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- uses: docker/build-push-action@v7
with:
context: .
file: alexa-bridge/Dockerfile
push: true
platforms: linux/amd64
cache-from: type=registry,ref=${{ env.IMAGE_PREFIX }}/alexa-bridge:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_PREFIX }}/alexa-bridge:buildcache,mode=max
tags: |
${{ env.IMAGE_PREFIX }}/alexa-bridge:${{ github.sha }}
${{ env.IMAGE_PREFIX }}/alexa-bridge:latest
build-tts-gateway: build-tts-gateway:
needs: [test, changes] needs: [test, changes]
if: github.ref == 'refs/heads/main' && needs.changes.outputs.tts-gateway == 'true' if: github.ref == 'refs/heads/main' && needs.changes.outputs.tts-gateway == 'true'

View File

@ -9,6 +9,7 @@ COPY gen/go.mod gen/go.sum ./gen/
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
WORKDIR /workspace/ai-gateway WORKDIR /workspace/ai-gateway
@ -19,6 +20,7 @@ COPY gen/ ./gen/
COPY ha-gateway/ ./ha-gateway/ COPY ha-gateway/ ./ha-gateway/
COPY discord-bot/ ./discord-bot/ COPY discord-bot/ ./discord-bot/
COPY tts-gateway/ ./tts-gateway/ COPY tts-gateway/ ./tts-gateway/
COPY alexa-bridge/ ./alexa-bridge/
COPY ai-gateway/ ./ai-gateway/ COPY ai-gateway/ ./ai-gateway/
WORKDIR /workspace/ai-gateway WORKDIR /workspace/ai-gateway

View File

@ -0,0 +1,9 @@
HTTP_PORT=8080
ALEXA_SKILL_ID=amzn1.ask.skill.your-skill-id-here
HA_GATEWAY_ADDR=localhost:50051
HA_GATEWAY_SERVER_NAME=ha-gateway.home-services.svc.cluster.local
TLS_DIR=
ENTITY_REFRESH_INTERVAL=5m
OTEL_ENDPOINT=
LOG_LEVEL=info
LOG_FORMAT=text

35
alexa-bridge/Dockerfile Normal file
View File

@ -0,0 +1,35 @@
FROM golang:1.26-alpine AS builder
WORKDIR /workspace
COPY go.work go.work.sum ./
# Manifests only, copied first, so `go mod download` gets its own cache layer
# invalidated only by dependency changes - not by every source edit below.
COPY gen/go.mod gen/go.sum ./gen/
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
WORKDIR /workspace/alexa-bridge
RUN go mod download
WORKDIR /workspace
COPY gen/ ./gen/
COPY ha-gateway/ ./ha-gateway/
COPY ai-gateway/ ./ai-gateway/
COPY discord-bot/ ./discord-bot/
COPY tts-gateway/ ./tts-gateway/
COPY alexa-bridge/ ./alexa-bridge/
WORKDIR /workspace/alexa-bridge
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /alexa-bridge ./cmd/bridge
FROM gcr.io/distroless/static:nonroot
COPY --from=builder /alexa-bridge /alexa-bridge
EXPOSE 8080
ENTRYPOINT ["/alexa-bridge"]

93
alexa-bridge/README.md Normal file
View File

@ -0,0 +1,93 @@
# alexa-bridge
`alexa-bridge` is a self-hosted Alexa Custom Skill backend. It verifies that
incoming HTTPS requests actually came from Alexa, resolves the spoken device
name against entities discovered from `ha-gateway`, and executes the
resulting action over gRPC. Unlike the other three services in this repo, it
is internet-facing — Alexa's servers call directly into it — so it is the
one service whose own inbound edge is untrusted by default and needs its own
request verification, on top of the mTLS it uses (like `ai-gateway` and
`discord-bot`) to call `ha-gateway`.
See [plan.md](plan.md) for the full design, the action-table mapping from
Alexa intents to `ha-gateway` RPCs, and the resolved "Decisions on open
questions" section documenting scope choices (single-setpoint-only climate
control, exact-match-only entity name resolution, no remote/SwitchBot
support, etc.).
## Runtime Flow
1. The service loads `.env`, configures logging and telemetry, blocking-fetches
the initial entity list from `ha-gateway`'s `EntityService` (failing
startup if that fails — nothing can resolve a device name without it),
then starts serving HTTP on `HTTP_PORT`.
2. A background goroutine refreshes the entity list every
`ENTITY_REFRESH_INTERVAL`; a failed periodic refresh only logs and keeps
serving the last-known-good list.
3. Each incoming Alexa request is verified: `SignatureCertChainUrl`/
`Signature` headers (cert chain fetched from Amazon's S3 host and cached,
RSA-SHA1 signature over the raw body), request timestamp (150s replay
tolerance), and the request envelope's application ID against
`ALEXA_SKILL_ID`.
4. Verified `IntentRequest`s are routed by intent name; the `Device` slot is
resolved (exact match, normalized) against the cached entity list, and the
resulting `(entity_id, action, params)` triple is executed via
`ha-gateway`'s `Light`/`Switch`/`Climate` services over mTLS.
5. Resolution and execution failures produce a spoken failure response
rather than an HTTP error — a Dispatch-layer error would surface as
Alexa's own generic failure speech instead of a message this skill
controls.
## Configuration
Environment variables:
| Variable | Default | Description |
| --- | --- | --- |
| `HTTP_PORT` | `8080` | HTTP listen port |
| `ALEXA_SKILL_ID` | — (required) | Verified against the request envelope's application ID |
| `HA_GATEWAY_ADDR` | `ha-gateway.home-services.svc.cluster.local:50051` | gRPC address for `ha-gateway` |
| `HA_GATEWAY_SERVER_NAME` | `ha-gateway.home-services.svc.cluster.local` | Expected server name for mTLS |
| `TLS_DIR` | `/tls` | mTLS client cert dir for the `ha-gateway` call. Unlike `ai-gateway`/`discord-bot`, this defaults **on** rather than empty — alexa-bridge is internet-facing, so mTLS to `ha-gateway` should be on by default rather than opt-in. Set empty to disable for local plaintext dev. |
| `ENTITY_REFRESH_INTERVAL` | `5m` | Periodic entity list refresh cadence |
| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
| `LOG_FORMAT` | `json` | `json` or `text` |
Example env file: [.env.example](.env.example)
When `TLS_DIR` is set, the directory must contain `tls.crt`, `tls.key`, and
`ca.crt`.
## Local Run
Start `ha-gateway` first, then run:
```bash
cd alexa-bridge
cp .env.example .env
# edit .env: set ALEXA_SKILL_ID, leave TLS_DIR empty for plaintext local dev
go run ./cmd/bridge
```
`alexa-bridge` doesn't register with Alexa on its own — during development,
point Alexa's request signature verification at a tunneled local endpoint
(e.g. `ngrok`) and set that HTTPS URL as the skill's endpoint in the Alexa
developer console. `/healthz` returns `200 OK` for liveness/readiness probes.
## HTTP API
There is no gRPC surface here (unlike the other three services) — this is a
plain HTTP server:
- `POST /`: the Alexa Custom Skill endpoint. Expects the standard Alexa
request envelope as JSON, with `SignatureCertChainUrl` and `Signature`
headers.
- `GET /healthz`: unauthenticated liveness/readiness check, always `200 OK`.
## Deployment
Kubernetes manifests (Deployment/Service, the `alexa-bridge-tls` mTLS client
cert, and the public HTTPS ingress) live in the separate `homelab` repo, not
here — see [plan.md](plan.md)'s mTLS section and "Decisions on open
questions" #2 for what's drafted there.

View File

@ -0,0 +1,118 @@
package main
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/joho/godotenv"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/config"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/directive"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/entities"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/haclient"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/logger"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/telemetry"
)
// entityDomains are the ha-gateway entity domains alexa-bridge resolves and
// controls. RemoteService/SwitchBot entities are deliberately excluded —
// see plan.md's "Decisions on open questions" #3.
var entityDomains = []string{"light", "switch", "climate"}
var version = "dev"
func main() {
_ = godotenv.Load()
cfg, err := config.Load()
if err != nil {
os.Stderr.WriteString("config error: " + err.Error() + "\n")
os.Exit(1)
}
log := logger.New(cfg.LogFormat, cfg.LogLevel)
slog.SetDefault(log)
log.Info("starting alexa-bridge",
"version", version,
"http_port", cfg.HTTPPort,
"ha_gateway_addr", cfg.HAGatewayAddr,
"tls_dir", cfg.TLSDir,
"entity_refresh_interval", cfg.EntityRefreshInterval.String(),
"otel_endpoint", cfg.OTELEndpoint,
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
ctx = logger.WithLogger(ctx, log)
shutdown, err := telemetry.Setup(ctx, "alexa-bridge", version, cfg)
if err != nil {
log.Error("telemetry setup failed", "err", err)
os.Exit(1)
}
haClient, err := haclient.New(ctx, cfg.HAGatewayAddr, cfg.TLSDir, cfg.HAGatewayServerName, log)
if err != nil {
log.Error("ha-gateway client setup failed", "err", err)
os.Exit(1)
}
defer func() {
if err := haClient.Close(); err != nil {
log.Error("ha-gateway client close failed", "err", err)
}
}()
entityClient := entities.NewClient(haClient.EntityServiceClient(), entityDomains)
resolver := entities.NewResolver()
refresher := entities.NewRefresher(entityClient, resolver, cfg.EntityRefreshInterval, log)
// Blocking: without an initial entity list nothing can resolve, so a
// failure here should fail startup rather than serve with an empty
// resolver. Periodic refreshes after this point only log on failure.
if err := refresher.Start(ctx); err != nil {
log.Error("initial entity fetch failed", "err", err)
os.Exit(1)
}
router := directive.NewRouter(resolver, haClient, log)
validator := alexa.NewSignatureValidator(nil)
alexaHandler := alexa.NewHandler(validator, cfg.AlexaSkillID, router)
mux := http.NewServeMux()
mux.Handle("/", alexaHandler)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
srv := &http.Server{
Addr: ":" + cfg.HTTPPort,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
log.Info("alexa-bridge listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("serve failed", "err", err)
}
}()
<-ctx.Done()
log.Info("shutdown signal received, draining")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Error("http server shutdown failed", "err", err)
}
log.Info("shutdown complete")
if err := shutdown(context.Background()); err != nil {
log.Error("telemetry shutdown error", "err", err)
}
}

37
alexa-bridge/go.mod Normal file
View File

@ -0,0 +1,37 @@
module gitea.nik4nao.com/nik/home-services/alexa-bridge
go 1.26
require (
gitea.nik4nao.com/nik/home-services/gen v0.0.0
github.com/joho/godotenv v1.5.1
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0
go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
google.golang.org/grpc v1.79.3
)
require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace gitea.nik4nao.com/nik/home-services/gen => ../gen

71
alexa-bridge/go.sum Normal file
View File

@ -0,0 +1,71 @@
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,80 @@
package alexa
import (
"context"
"encoding/json"
"io"
"net/http"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/logger"
)
// Dispatcher handles one already-validated Alexa request and produces a
// response. internal/directive.Router implements this.
type Dispatcher interface {
Dispatch(ctx context.Context, req Request) (Response, error)
}
// Handler is the HTTP entrypoint Alexa's servers call into: it verifies the
// request signature, timestamp, and skill ID (in that order — cheapest and
// most identity-establishing checks first), then hands the decoded envelope
// to a Dispatcher.
type Handler struct {
validator Validator
skillID string
dispatcher Dispatcher
}
// NewHandler constructs the HTTP handler for the Alexa endpoint.
func NewHandler(validator Validator, skillID string, dispatcher Dispatcher) *Handler {
return &Handler{validator: validator, skillID: skillID, dispatcher: dispatcher}
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := logger.FromContext(ctx)
body, err := io.ReadAll(r.Body)
if err != nil {
log.Warn("read request body failed", "err", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := h.validator.Validate(ctx, r, body); err != nil {
log.Warn("signature validation failed", "err", err)
http.Error(w, "signature verification failed", http.StatusUnauthorized)
return
}
var req Request
if err := json.Unmarshal(body, &req); err != nil {
log.Warn("decode request body failed", "err", err)
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := ValidateTimestamp(req.Request.Timestamp); err != nil {
log.Warn("timestamp validation failed", "err", err)
http.Error(w, "request timestamp outside tolerance", http.StatusUnauthorized)
return
}
if err := ValidateApplicationID(&req, h.skillID); err != nil {
log.Warn("application id validation failed", "err", err)
http.Error(w, "application id mismatch", http.StatusUnauthorized)
return
}
resp, err := h.dispatcher.Dispatch(ctx, req)
if err != nil {
log.Error("dispatch failed", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error("encode response failed", "err", err)
}
}

View File

@ -0,0 +1,177 @@
package alexa
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type fakeValidator struct {
err error
}
func (f *fakeValidator) Validate(ctx context.Context, r *http.Request, body []byte) error {
return f.err
}
type fakeDispatcher struct {
respFunc func(ctx context.Context, req Request) (Response, error)
}
func (f *fakeDispatcher) Dispatch(ctx context.Context, req Request) (Response, error) {
if f.respFunc == nil {
return NewTellResponse("ok"), nil
}
return f.respFunc(ctx, req)
}
const testSkillID = "amzn1.ask.skill.test"
func validRequestBody(t *testing.T, skillID, requestType string, ts time.Time) []byte {
t.Helper()
req := Request{
Version: "1.0",
Context: &Context{System: System{Application: Application{ApplicationID: skillID}}},
Request: RequestBody{
Type: requestType,
RequestID: "amzn1.echo-api.request.test",
Timestamp: ts.UTC().Format(time.RFC3339),
Locale: "en-US",
},
}
b, err := json.Marshal(req)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
return b
}
func TestHandlerServeHTTP(t *testing.T) {
t.Run("happy path returns the dispatcher's response", func(t *testing.T) {
body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now())
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{
respFunc: func(ctx context.Context, req Request) (Response, error) {
return NewTellResponse("hello"), nil
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
}
var got Response
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Response.OutputSpeech.Text != "hello" {
t.Fatalf("OutputSpeech.Text = %q, want %q", got.Response.OutputSpeech.Text, "hello")
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
})
t.Run("signature validation failure returns 401 without dispatching", func(t *testing.T) {
dispatched := false
body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now())
h := NewHandler(&fakeValidator{err: errors.New("bad signature")}, testSkillID, &fakeDispatcher{
respFunc: func(ctx context.Context, req Request) (Response, error) {
dispatched = true
return NewTellResponse("ok"), nil
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
if dispatched {
t.Fatal("dispatcher should not be called when signature validation fails")
}
})
t.Run("malformed json body returns 400", func(t *testing.T) {
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json"))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
})
t.Run("stale timestamp returns 401", func(t *testing.T) {
body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now().Add(-10*time.Minute))
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
})
t.Run("application id mismatch returns 401", func(t *testing.T) {
body := validRequestBody(t, "some-other-skill", "LaunchRequest", time.Now())
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
})
t.Run("dispatcher error returns 500", func(t *testing.T) {
body := validRequestBody(t, testSkillID, "IntentRequest", time.Now())
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{
respFunc: func(ctx context.Context, req Request) (Response, error) {
return Response{}, errors.New("boom")
},
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
}
})
t.Run("body read error returns 400", func(t *testing.T) {
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(&errReader{}))
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
})
}
type errReader struct{}
func (e *errReader) Read(p []byte) (int, error) {
return 0, errors.New("read failed")
}

View File

@ -0,0 +1,262 @@
package alexa
import (
"context"
"crypto"
"crypto/rsa"
"crypto/sha1" //nolint:gosec // Alexa's request signing scheme is fixed as SHA1withRSA; not our choice.
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
// certChainHost/certChainPathPrefix are Amazon's documented constraints
// on where a request's SignatureCertChainUrl header is allowed to point
// (https://developer.amazon.com/en-US/docs/alexa/custom-skills/host-a-custom-skill-as-a-web-service.html#check-the-signature-certificate-url).
// Accepting a URL to an attacker-controlled host would let anyone
// present their own signing certificate.
certChainHost = "s3.amazonaws.com"
certChainPathPrefix = "/echo.api/"
certSANRequired = "echo-api.amazon.com"
// timestampTolerance is Amazon's recommended replay-protection window.
timestampTolerance = 150 * time.Second
)
// Validator verifies an incoming HTTP request against its Signature/
// SignatureCertChainUrl headers. *SignatureValidator implements it; Handler
// depends on this interface (not the concrete type) so tests can inject a
// fake instead of exercising real crypto/network calls.
type Validator interface {
Validate(ctx context.Context, r *http.Request, body []byte) error
}
// SignatureValidator verifies an incoming HTTP request actually came from
// Alexa: the SignatureCertChainUrl points at Amazon's cert host, the
// referenced certificate chain is valid and carries the expected SAN, and
// the Signature header is a valid RSA-SHA1 signature over the raw request
// body made with that certificate's key. Amazon's certs are cached by URL
// until they expire, since the same handful of URLs are reused across many
// requests.
type SignatureValidator struct {
httpClient *http.Client
mu sync.RWMutex
cache map[string]*cachedChain
}
type cachedChain struct {
leaf *x509.Certificate
expiresAt time.Time
}
// NewSignatureValidator constructs a validator using the given HTTP client
// to fetch certificate chains (pass nil to use http.DefaultClient).
func NewSignatureValidator(httpClient *http.Client) *SignatureValidator {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &SignatureValidator{httpClient: httpClient, cache: make(map[string]*cachedChain)}
}
// Validate checks the SignatureCertChainUrl and Signature headers against
// the raw request body. Callers must pass the exact bytes that were signed —
// decoding then re-marshaling the JSON would produce different bytes and
// always fail verification.
func (v *SignatureValidator) Validate(ctx context.Context, r *http.Request, body []byte) error {
chainURL := r.Header.Get("SignatureCertChainUrl")
sigHeader := r.Header.Get("Signature")
if chainURL == "" || sigHeader == "" {
return errors.New("missing SignatureCertChainUrl or Signature header")
}
if err := validateCertChainURL(chainURL); err != nil {
return fmt.Errorf("invalid SignatureCertChainUrl: %w", err)
}
leaf, err := v.leafCertificate(ctx, chainURL)
if err != nil {
return fmt.Errorf("fetch/validate cert chain: %w", err)
}
sig, err := base64.StdEncoding.DecodeString(sigHeader)
if err != nil {
return fmt.Errorf("decode Signature header: %w", err)
}
if err := verifySignature(leaf, body, sig); err != nil {
return err
}
return nil
}
func verifySignature(leaf *x509.Certificate, body, sig []byte) error {
pubKey, ok := leaf.PublicKey.(*rsa.PublicKey)
if !ok {
return fmt.Errorf("leaf certificate public key is %T, want RSA", leaf.PublicKey)
}
hash := sha1.Sum(body) //nolint:gosec // required by Alexa's fixed signing scheme
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA1, hash[:], sig); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
func validateCertChainURL(raw string) error {
u, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse url: %w", err)
}
if !strings.EqualFold(u.Scheme, "https") {
return fmt.Errorf("scheme %q, want https", u.Scheme)
}
if !strings.EqualFold(u.Hostname(), certChainHost) {
return fmt.Errorf("host %q, want %q", u.Hostname(), certChainHost)
}
if port := u.Port(); port != "" && port != "443" {
return fmt.Errorf("port %q, want 443 or unset", port)
}
if !strings.HasPrefix(u.Path, certChainPathPrefix) {
return fmt.Errorf("path %q, want prefix %q", u.Path, certChainPathPrefix)
}
return nil
}
func (v *SignatureValidator) leafCertificate(ctx context.Context, chainURL string) (*x509.Certificate, error) {
v.mu.RLock()
cached, ok := v.cache[chainURL]
v.mu.RUnlock()
if ok && time.Now().Before(cached.expiresAt) {
return cached.leaf, nil
}
chain, err := fetchChain(ctx, v.httpClient, chainURL)
if err != nil {
return nil, err
}
leaf, err := verifyChain(chain)
if err != nil {
return nil, err
}
v.mu.Lock()
v.cache[chainURL] = &cachedChain{leaf: leaf, expiresAt: leaf.NotAfter}
v.mu.Unlock()
return leaf, nil
}
func fetchChain(ctx context.Context, httpClient *http.Client, chainURL string) ([]*x509.Certificate, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chainURL, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d fetching cert chain", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var chain []*x509.Certificate
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
break
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse certificate: %w", err)
}
chain = append(chain, cert)
}
if len(chain) == 0 {
return nil, errors.New("no certificates found in chain")
}
return chain, nil
}
// verifyChain validates the leaf certificate's expiry and required SAN, and
// that the chain cryptographically links leaf -> intermediates -> the
// chain's own terminal certificate (trusted here as the root: the chain was
// already fetched over a TLS connection to s3.amazonaws.com whose own
// certificate is checked against the system trust store, so this step is
// about proving the leaf/intermediate/root signatures are internally
// consistent and the leaf itself hasn't been tampered with or expired).
func verifyChain(chain []*x509.Certificate) (*x509.Certificate, error) {
leaf := chain[0]
now := time.Now()
if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
return nil, fmt.Errorf("leaf certificate not valid at %s (window %s to %s)", now, leaf.NotBefore, leaf.NotAfter)
}
found := false
for _, san := range leaf.DNSNames {
if strings.EqualFold(san, certSANRequired) {
found = true
break
}
}
if !found {
return nil, fmt.Errorf("leaf certificate SAN does not include %q", certSANRequired)
}
roots := x509.NewCertPool()
roots.AddCert(chain[len(chain)-1])
intermediates := x509.NewCertPool()
for _, c := range chain[1 : len(chain)-1] {
intermediates.AddCert(c)
}
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: now,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
return nil, fmt.Errorf("certificate chain verification failed: %w", err)
}
return leaf, nil
}
// ValidateTimestamp rejects requests outside Amazon's recommended
// replay-protection window.
func ValidateTimestamp(rfc3339 string) error {
ts, err := time.Parse(time.RFC3339, rfc3339)
if err != nil {
return fmt.Errorf("parse request timestamp %q: %w", rfc3339, err)
}
if d := time.Since(ts); d < -timestampTolerance || d > timestampTolerance {
return fmt.Errorf("request timestamp %s outside %s tolerance (age %s)", rfc3339, timestampTolerance, d)
}
return nil
}
// ValidateApplicationID checks the request envelope's application ID
// against the configured skill ID. See alexa-bridge/plan.md's "Decisions on
// open questions" #4.
func ValidateApplicationID(req *Request, expectedSkillID string) error {
got := req.ApplicationID()
if got == "" {
return errors.New("request has no application ID in context or session")
}
if got != expectedSkillID {
return errors.New("application id does not match configured skill id")
}
return nil
}

View File

@ -0,0 +1,408 @@
package alexa
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha1" //nolint:gosec // matches Alexa's fixed signing scheme, see signature.go
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)
// generateTestChain builds a two-certificate PEM chain (leaf signed by a
// throwaway root) for exercising verifyChain/verifySignature without
// needing real Alexa-issued certificates.
func generateTestChain(t *testing.T, sans []string, notBefore, notAfter time.Time) ([]byte, *rsa.PrivateKey) {
t.Helper()
rootKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate root key: %v", err)
}
rootTemplate := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Test Root CA"},
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: true,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
}
rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
if err != nil {
t.Fatalf("create root cert: %v", err)
}
rootCert, err := x509.ParseCertificate(rootDER)
if err != nil {
t.Fatalf("parse root cert: %v", err)
}
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate leaf key: %v", err)
}
leafTemplate := &x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "echo-api.amazon.com"},
DNSNames: sans,
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKey.PublicKey, rootKey)
if err != nil {
t.Fatalf("create leaf cert: %v", err)
}
var buf bytes.Buffer
if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: leafDER}); err != nil {
t.Fatalf("encode leaf pem: %v", err)
}
if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: rootDER}); err != nil {
t.Fatalf("encode root pem: %v", err)
}
return buf.Bytes(), leafKey
}
func parseChain(t *testing.T, chainPEM []byte) []*x509.Certificate {
t.Helper()
var chain []*x509.Certificate
data := chainPEM
for {
var block *pem.Block
block, data = pem.Decode(data)
if block == nil {
break
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
chain = append(chain, cert)
}
return chain
}
func TestValidateCertChainURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
{name: "valid", url: "https://s3.amazonaws.com/echo.api/echo-api-cert.pem", wantErr: false},
{name: "valid with explicit 443 port", url: "https://s3.amazonaws.com:443/echo.api/echo-api-cert.pem", wantErr: false},
{name: "wrong scheme", url: "http://s3.amazonaws.com/echo.api/echo-api-cert.pem", wantErr: true},
{name: "wrong host", url: "https://evil.example.com/echo.api/echo-api-cert.pem", wantErr: true},
{name: "host looks similar but differs", url: "https://s3.amazonaws.com.evil.com/echo.api/echo-api-cert.pem", wantErr: true},
{name: "wrong port", url: "https://s3.amazonaws.com:8443/echo.api/echo-api-cert.pem", wantErr: true},
{name: "wrong path prefix", url: "https://s3.amazonaws.com/not-echo-api/echo-api-cert.pem", wantErr: true},
{name: "unparsable", url: "://bad", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateCertChainURL(tt.url)
if (err != nil) != tt.wantErr {
t.Fatalf("validateCertChainURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
}
})
}
}
func TestVerifyChain(t *testing.T) {
now := time.Now()
t.Run("valid chain with correct SAN", func(t *testing.T) {
chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
leaf, err := verifyChain(parseChain(t, chainPEM))
if err != nil {
t.Fatalf("verifyChain() error = %v", err)
}
if leaf == nil {
t.Fatal("verifyChain() leaf = nil")
}
})
t.Run("missing required SAN", func(t *testing.T) {
chainPEM, _ := generateTestChain(t, []string{"not-echo-api.amazon.com"}, now.Add(-time.Hour), now.Add(time.Hour))
if _, err := verifyChain(parseChain(t, chainPEM)); err == nil {
t.Fatal("verifyChain() error = nil, want error for missing SAN")
}
})
t.Run("expired certificate", func(t *testing.T) {
chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-2*time.Hour), now.Add(-time.Hour))
if _, err := verifyChain(parseChain(t, chainPEM)); err == nil {
t.Fatal("verifyChain() error = nil, want error for expired cert")
}
})
t.Run("not yet valid certificate", func(t *testing.T) {
chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(time.Hour), now.Add(2*time.Hour))
if _, err := verifyChain(parseChain(t, chainPEM)); err == nil {
t.Fatal("verifyChain() error = nil, want error for not-yet-valid cert")
}
})
t.Run("leaf not actually signed by the presented root", func(t *testing.T) {
chainA, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
chainB, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
mismatched := []*x509.Certificate{parseChain(t, chainA)[0], parseChain(t, chainB)[1]}
if _, err := verifyChain(mismatched); err == nil {
t.Fatal("verifyChain() error = nil, want error for mismatched leaf/root")
}
})
}
func TestVerifySignature(t *testing.T) {
now := time.Now()
chainPEM, leafKey := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
leaf := parseChain(t, chainPEM)[0]
body := []byte(`{"request":{"type":"LaunchRequest"}}`)
hash := sha1.Sum(body) //nolint:gosec
sig, err := rsa.SignPKCS1v15(rand.Reader, leafKey, crypto.SHA1, hash[:])
if err != nil {
t.Fatalf("sign body: %v", err)
}
t.Run("valid signature", func(t *testing.T) {
if err := verifySignature(leaf, body, sig); err != nil {
t.Fatalf("verifySignature() error = %v", err)
}
})
t.Run("tampered body fails", func(t *testing.T) {
if err := verifySignature(leaf, []byte(`{"request":{"type":"IntentRequest"}}`), sig); err == nil {
t.Fatal("verifySignature() error = nil, want error for tampered body")
}
})
t.Run("garbage signature fails", func(t *testing.T) {
if err := verifySignature(leaf, body, []byte("not a real signature")); err == nil {
t.Fatal("verifySignature() error = nil, want error for garbage signature")
}
})
}
// rewriteTransport redirects requests to a local httptest.Server while
// leaving the request's URL (as seen by application code, e.g.
// validateCertChainURL's host check) untouched.
type rewriteTransport struct {
target *url.URL
base http.RoundTripper
}
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.URL.Scheme = t.target.Scheme
req.URL.Host = t.target.Host
return t.base.RoundTrip(req)
}
func TestSignatureValidatorValidate(t *testing.T) {
now := time.Now()
chainPEM, leafKey := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
newTestValidator := func(t *testing.T, handler http.HandlerFunc) (*SignatureValidator, func()) {
t.Helper()
ts := httptest.NewServer(handler)
targetURL, err := url.Parse(ts.URL)
if err != nil {
t.Fatalf("parse test server url: %v", err)
}
httpClient := &http.Client{Transport: &rewriteTransport{target: targetURL, base: http.DefaultTransport}}
return NewSignatureValidator(httpClient), ts.Close
}
signBody := func(t *testing.T, key *rsa.PrivateKey, body []byte) string {
t.Helper()
hash := sha1.Sum(body) //nolint:gosec
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, hash[:])
if err != nil {
t.Fatalf("sign body: %v", err)
}
return base64.StdEncoding.EncodeToString(sig)
}
t.Run("valid request end to end", func(t *testing.T) {
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(chainPEM)
})
defer closeServer()
body := []byte(`{"request":{"type":"LaunchRequest"}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
req.Header.Set("Signature", signBody(t, leafKey, body))
if err := v.Validate(context.Background(), req, body); err != nil {
t.Fatalf("Validate() error = %v", err)
}
})
t.Run("caches the leaf certificate across calls", func(t *testing.T) {
fetches := 0
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
fetches++
_, _ = w.Write(chainPEM)
})
defer closeServer()
body := []byte(`{"request":{"type":"LaunchRequest"}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
req.Header.Set("Signature", signBody(t, leafKey, body))
if err := v.Validate(context.Background(), req, body); err != nil {
t.Fatalf("Validate() #1 error = %v", err)
}
if err := v.Validate(context.Background(), req, body); err != nil {
t.Fatalf("Validate() #2 error = %v", err)
}
if fetches != 1 {
t.Fatalf("fetches = %d, want 1 (second Validate should hit the cache)", fetches)
}
})
t.Run("missing headers", func(t *testing.T) {
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(chainPEM)
})
defer closeServer()
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
if err := v.Validate(context.Background(), req, body); err == nil {
t.Fatal("Validate() error = nil, want error for missing headers")
}
})
t.Run("cert chain url pointing at the wrong host is rejected before any fetch", func(t *testing.T) {
fetched := false
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
fetched = true
_, _ = w.Write(chainPEM)
})
defer closeServer()
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("SignatureCertChainUrl", "https://evil.example.com/echo.api/echo-api-cert.pem")
req.Header.Set("Signature", signBody(t, leafKey, body))
if err := v.Validate(context.Background(), req, body); err == nil {
t.Fatal("Validate() error = nil, want error for wrong cert chain host")
}
if fetched {
t.Fatal("cert chain should never be fetched when the URL itself is rejected")
}
})
t.Run("signature does not match body", func(t *testing.T) {
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(chainPEM)
})
defer closeServer()
signedBody := []byte(`{"request":{"type":"LaunchRequest"}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(signedBody))
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
req.Header.Set("Signature", signBody(t, leafKey, signedBody))
tamperedBody := []byte(`{"request":{"type":"IntentRequest"}}`)
if err := v.Validate(context.Background(), req, tamperedBody); err == nil {
t.Fatal("Validate() error = nil, want error for body/signature mismatch")
}
})
t.Run("fetch failure surfaces as an error", func(t *testing.T) {
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
defer closeServer()
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
req.Header.Set("Signature", signBody(t, leafKey, body))
if err := v.Validate(context.Background(), req, body); err == nil {
t.Fatal("Validate() error = nil, want error when cert fetch fails")
}
})
}
func TestValidateTimestamp(t *testing.T) {
tests := []struct {
name string
ts string
wantErr bool
}{
{name: "now", ts: time.Now().Format(time.RFC3339), wantErr: false},
{name: "within tolerance", ts: time.Now().Add(-100 * time.Second).Format(time.RFC3339), wantErr: false},
{name: "too old", ts: time.Now().Add(-200 * time.Second).Format(time.RFC3339), wantErr: true},
{name: "too far in the future", ts: time.Now().Add(200 * time.Second).Format(time.RFC3339), wantErr: true},
{name: "unparsable", ts: "not-a-timestamp", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateTimestamp(tt.ts)
if (err != nil) != tt.wantErr {
t.Fatalf("ValidateTimestamp(%q) error = %v, wantErr %v", tt.ts, err, tt.wantErr)
}
})
}
}
func TestValidateApplicationID(t *testing.T) {
const skillID = "amzn1.ask.skill.test"
t.Run("matches via context", func(t *testing.T) {
req := &Request{Context: &Context{System: System{Application: Application{ApplicationID: skillID}}}}
if err := ValidateApplicationID(req, skillID); err != nil {
t.Fatalf("ValidateApplicationID() error = %v", err)
}
})
t.Run("matches via session fallback when context absent", func(t *testing.T) {
req := &Request{Session: &Session{Application: Application{ApplicationID: skillID}}}
if err := ValidateApplicationID(req, skillID); err != nil {
t.Fatalf("ValidateApplicationID() error = %v", err)
}
})
t.Run("context takes precedence over session", func(t *testing.T) {
req := &Request{
Context: &Context{System: System{Application: Application{ApplicationID: skillID}}},
Session: &Session{Application: Application{ApplicationID: "some-other-skill"}},
}
if err := ValidateApplicationID(req, skillID); err != nil {
t.Fatalf("ValidateApplicationID() error = %v", err)
}
})
t.Run("mismatch errors", func(t *testing.T) {
req := &Request{Context: &Context{System: System{Application: Application{ApplicationID: "wrong-skill"}}}}
if err := ValidateApplicationID(req, skillID); err == nil {
t.Fatal("ValidateApplicationID() error = nil, want error")
}
})
t.Run("neither context nor session present errors", func(t *testing.T) {
req := &Request{}
if err := ValidateApplicationID(req, skillID); err == nil {
t.Fatal("ValidateApplicationID() error = nil, want error")
}
})
}

View File

@ -0,0 +1,126 @@
// Package alexa implements the Alexa Custom Skill request/response protocol
// edge: request/response envelope types, signature validation, and the HTTP
// handler Alexa's servers call into. It hands decoded, verified requests to
// a Dispatcher (internal/directive.Router) and returns whatever Response
// the dispatcher builds.
package alexa
// Request is the top-level envelope Alexa POSTs for every skill invocation.
type Request struct {
Version string `json:"version"`
Session *Session `json:"session,omitempty"`
Context *Context `json:"context,omitempty"`
Request RequestBody `json:"request"`
}
// ApplicationID returns the envelope's skill application ID, preferring
// context.System.application.applicationId (populated on essentially all
// real Custom Skill requests) and falling back to
// session.application.applicationId for the legacy-but-still-valid case of
// a request carrying a session without a top-level context. See
// alexa-bridge/plan.md's "Decisions on open questions" #4.
func (r *Request) ApplicationID() string {
if r.Context != nil && r.Context.System.Application.ApplicationID != "" {
return r.Context.System.Application.ApplicationID
}
if r.Session != nil {
return r.Session.Application.ApplicationID
}
return ""
}
type Session struct {
New bool `json:"new"`
SessionID string `json:"sessionId"`
Application Application `json:"application"`
User User `json:"user"`
}
type Context struct {
System System `json:"System"`
}
type System struct {
Application Application `json:"application"`
User User `json:"user"`
Device Device `json:"device"`
}
type Application struct {
ApplicationID string `json:"applicationId"`
}
type User struct {
UserID string `json:"userId"`
}
type Device struct {
DeviceID string `json:"deviceId"`
}
// RequestBody's Type distinguishes LaunchRequest, IntentRequest, and
// SessionEndedRequest — only IntentRequest populates Intent, only
// SessionEndedRequest populates Reason.
type RequestBody struct {
Type string `json:"type"`
RequestID string `json:"requestId"`
Timestamp string `json:"timestamp"` // RFC3339
Locale string `json:"locale"`
Intent *Intent `json:"intent,omitempty"`
Reason string `json:"reason,omitempty"`
}
type Intent struct {
Name string `json:"name"`
ConfirmationStatus string `json:"confirmationStatus"`
Slots map[string]Slot `json:"slots"`
}
type Slot struct {
Name string `json:"name"`
Value string `json:"value"`
ConfirmationStatus string `json:"confirmationStatus"`
}
// Response is the top-level envelope returned to Alexa.
type Response struct {
Version string `json:"version"`
SessionAttributes map[string]any `json:"sessionAttributes,omitempty"`
Response ResponseBody `json:"response"`
}
type ResponseBody struct {
OutputSpeech *OutputSpeech `json:"outputSpeech,omitempty"`
Card *Card `json:"card,omitempty"`
Reprompt *Reprompt `json:"reprompt,omitempty"`
ShouldEndSession bool `json:"shouldEndSession"`
}
type OutputSpeech struct {
Type string `json:"type"` // "PlainText" or "SSML"
Text string `json:"text,omitempty"`
SSML string `json:"ssml,omitempty"`
}
type Card struct {
Type string `json:"type"` // "Simple"
Title string `json:"title,omitempty"`
Content string `json:"content,omitempty"`
}
type Reprompt struct {
OutputSpeech OutputSpeech `json:"outputSpeech"`
}
// NewTellResponse builds a plain-text response that ends the session —
// directive handlers use this for both success confirmations and user-facing
// errors (e.g. "I couldn't find that device").
func NewTellResponse(text string) Response {
return Response{
Version: "1.0",
Response: ResponseBody{
OutputSpeech: &OutputSpeech{Type: "PlainText", Text: text},
ShouldEndSession: true,
},
}
}

View File

@ -0,0 +1,86 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Config holds runtime configuration for the alexa-bridge process.
type Config struct {
HTTPPort string // HTTP_PORT, default "8080"
AlexaSkillID string // ALEXA_SKILL_ID — verified against the request envelope's application ID (required)
HAGatewayAddr string // HA_GATEWAY_ADDR, default "ha-gateway.home-services.svc.cluster.local:50051"
HAGatewayServerName string // HA_GATEWAY_SERVER_NAME, default "ha-gateway.home-services.svc.cluster.local"
TLSDir string // TLS_DIR, default "/tls" — unlike ai-gateway/discord-bot, mTLS is on by default here since alexa-bridge is internet-facing; set empty to disable for local dev
EntityRefreshInterval time.Duration // ENTITY_REFRESH_INTERVAL, default 5m
OTELEndpoint string // OTEL_ENDPOINT, empty disables telemetry
LogLevel string // LOG_LEVEL, default "info"
LogFormat string // LOG_FORMAT, default "json"
}
// Load reads configuration from environment variables and applies defaults.
func Load() (*Config, error) {
skillID := os.Getenv("ALEXA_SKILL_ID")
if skillID == "" {
return nil, errors.New("ALEXA_SKILL_ID is required but not set")
}
refreshInterval, err := parseDurationEnv("ENTITY_REFRESH_INTERVAL", 5*time.Minute)
if err != nil {
return nil, err
}
cfg := &Config{
HTTPPort: getenvDefault("HTTP_PORT", "8080"),
AlexaSkillID: skillID,
HAGatewayAddr: getenvDefault("HA_GATEWAY_ADDR", "ha-gateway.home-services.svc.cluster.local:50051"),
HAGatewayServerName: getenvDefault("HA_GATEWAY_SERVER_NAME", "ha-gateway.home-services.svc.cluster.local"),
TLSDir: getenvDefault("TLS_DIR", "/tls"),
EntityRefreshInterval: refreshInterval,
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
LogLevel: getenvDefault("LOG_LEVEL", "info"),
LogFormat: getenvDefault("LOG_FORMAT", "json"),
}
if cfg.TLSDir != "" {
if err := validateTLSDir(cfg.TLSDir); err != nil {
return nil, err
}
}
return cfg, nil
}
func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func parseDurationEnv(key string, fallback time.Duration) (time.Duration, error) {
if v := os.Getenv(key); v != "" {
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("parse %s: %w", key, err)
}
return d, nil
}
return fallback, nil
}
func validateTLSDir(dir string) error {
required := []string{"tls.crt", "tls.key", "ca.crt"}
for _, name := range required {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("tls dir validation failed for %s: %w", path, err)
}
if info.IsDir() {
return fmt.Errorf("tls dir validation failed for %s: %w", path, errors.New("expected file"))
}
}
return nil
}

View File

@ -0,0 +1,12 @@
package domain
// Entity is a resolvable, controllable Home Assistant entity.
type Entity struct {
// EntityID is the Home Assistant entity identifier, e.g. "light.living_room".
EntityID string
// FriendlyName is the user-facing name used to resolve Alexa's Device slot.
FriendlyName string
// Domain is the entity_id prefix (light, switch, climate) and selects which
// ha-gateway RPC family Controller.ExecuteAction dispatches to.
Domain string
}

View File

@ -0,0 +1,12 @@
package driven
import "context"
// Controller executes a named action against a Home Assistant entity. It is
// generic across domains (light/switch/climate) rather than exposing typed
// per-domain methods — the concrete ha-gateway RPC and request type for a
// given (domain, action) pair are resolved inside the implementation
// (internal/haclient), not here. See alexa-bridge/plan.md's action table.
type Controller interface {
ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error
}

View File

@ -0,0 +1,11 @@
package driven
import "gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
// EntityResolver resolves the free-form Device slot value Alexa sends into a
// concrete Home Assistant entity, by normalized (lowercased, trimmed) friendly
// name. Matching is exact-match only for v1 — see alexa-bridge/plan.md's
// "Decisions on open questions" #5 for why fuzzy matching isn't implemented.
type EntityResolver interface {
Resolve(friendlyName string) (domain.Entity, bool)
}

View File

@ -0,0 +1,71 @@
package directive
import (
"fmt"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
)
func slotValue(slots map[string]alexa.Slot, name string) (string, error) {
s, ok := slots[name]
if !ok || s.Value == "" {
return "", fmt.Errorf("missing or empty slot %q", name)
}
return s.Value, nil
}
func brightnessParams(slots map[string]alexa.Slot) (map[string]any, error) {
v, err := slotValue(slots, "Brightness")
if err != nil {
return nil, err
}
return map[string]any{"brightness_pct": v}, nil
}
func colorTempParams(slots map[string]alexa.Slot) (map[string]any, error) {
v, err := slotValue(slots, "ColorTemp")
if err != nil {
return nil, err
}
return map[string]any{"color_temp_kelvin": v}, nil
}
// colorParams reads three separate numeric slots (Red/Green/Blue) rather
// than a single named-color slot. A nicer "set it to warm white" voice UX
// would need a custom slot type mapping color names to RGB triples — an
// interaction-model addition, not a backend gap — deliberately left for
// later rather than inventing a name/RGB table here.
func colorParams(slots map[string]alexa.Slot) (map[string]any, error) {
r, err := slotValue(slots, "Red")
if err != nil {
return nil, err
}
g, err := slotValue(slots, "Green")
if err != nil {
return nil, err
}
b, err := slotValue(slots, "Blue")
if err != nil {
return nil, err
}
return map[string]any{"rgb_color": map[string]any{"r": r, "g": g, "b": b}}, nil
}
func hvacModeParams(slots map[string]alexa.Slot) (map[string]any, error) {
v, err := slotValue(slots, "HVACMode")
if err != nil {
return nil, err
}
return map[string]any{"hvac_mode": v}, nil
}
// temperatureParams backs SetTemperatureIntent, added alongside
// ClimateService.SetTemperature — see plan.md's "Decisions on open
// questions" #1.
func temperatureParams(slots map[string]alexa.Slot) (map[string]any, error) {
v, err := slotValue(slots, "Temperature")
if err != nil {
return nil, err
}
return map[string]any{"target_temperature": v}, nil
}

View File

@ -0,0 +1,150 @@
// Package directive is the orchestration layer between the Alexa protocol
// edge (internal/alexa) and ha-gateway (internal/haclient): it routes a
// decoded IntentRequest to a handler, resolves the Device slot via
// driven.EntityResolver, and executes the resulting action via
// driven.Controller. See alexa-bridge/plan.md's action table for the full
// (intent, action) mapping this implements.
package directive
import (
"context"
"fmt"
"log/slog"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/ports/driven"
)
// handlerFunc handles one already-type-dispatched IntentRequest.
type handlerFunc func(ctx context.Context, r *Router, req alexa.Request) alexa.Response
// paramsFunc extracts a Controller.ExecuteAction params map from an
// intent's slots, or an error if a required slot is missing/empty.
type paramsFunc func(slots map[string]alexa.Slot) (map[string]any, error)
// Router implements alexa.Dispatcher.
type Router struct {
resolver driven.EntityResolver
controller driven.Controller
log *slog.Logger
handlers map[string]handlerFunc
}
// NewRouter constructs a Router with the full set of built-in and custom
// intent handlers registered.
func NewRouter(resolver driven.EntityResolver, controller driven.Controller, log *slog.Logger) *Router {
r := &Router{resolver: resolver, controller: controller, log: log}
r.handlers = map[string]handlerFunc{
"AMAZON.HelpIntent": handleHelp,
"AMAZON.StopIntent": handleStop,
"AMAZON.CancelIntent": handleStop,
"TurnOnIntent": handleAction("turn_on", nil),
"TurnOffIntent": handleAction("turn_off", nil),
"ToggleIntent": handleAction("toggle", nil),
"SetBrightnessIntent": handleAction("set_brightness", brightnessParams),
"SetColorTempIntent": handleAction("set_color_temp", colorTempParams),
"SetColorIntent": handleAction("set_color", colorParams),
"SetHVACModeIntent": handleAction("set_hvac_mode", hvacModeParams),
"IncreaseTemperatureIntent": handleAction("increase_temperature", nil),
"DecreaseTemperatureIntent": handleAction("decrease_temperature", nil),
"SetTemperatureIntent": handleAction("set_temperature", temperatureParams),
}
return r
}
// Dispatch implements alexa.Dispatcher.
func (r *Router) Dispatch(ctx context.Context, req alexa.Request) (alexa.Response, error) {
switch req.Request.Type {
case "LaunchRequest":
return handleLaunch(), nil
case "SessionEndedRequest":
return alexa.Response{Version: "1.0"}, nil
case "IntentRequest":
return r.dispatchIntent(ctx, req), nil
default:
r.log.Warn("unrecognized request type", "type", req.Request.Type)
return alexa.NewTellResponse("Sorry, I didn't understand that request."), nil
}
}
func (r *Router) dispatchIntent(ctx context.Context, req alexa.Request) alexa.Response {
if req.Request.Intent == nil {
return alexa.NewTellResponse("Sorry, I didn't understand that request.")
}
h, ok := r.handlers[req.Request.Intent.Name]
if !ok {
r.log.Warn("unrecognized intent", "intent", req.Request.Intent.Name)
return alexa.NewTellResponse("Sorry, I don't know how to do that yet.")
}
return h(ctx, r, req)
}
// handleAction builds a handlerFunc that resolves the Device slot,
// optionally extracts action params, and executes the action via
// driven.Controller. Controller/resolution failures are turned into a
// spoken failure response rather than propagated as a Dispatch error — a
// Dispatch error becomes an HTTP 500 and Alexa's own generic failure
// speech, which is a worse experience than the skill explaining what went
// wrong itself.
func handleAction(action string, paramsFn paramsFunc) handlerFunc {
return func(ctx context.Context, r *Router, req alexa.Request) alexa.Response {
slots := req.Request.Intent.Slots
deviceSlot, ok := slots["Device"]
if !ok || deviceSlot.Value == "" {
return alexa.NewTellResponse("Sorry, I didn't catch which device you meant.")
}
entity, ok := r.resolver.Resolve(deviceSlot.Value)
if !ok {
return alexa.NewTellResponse(fmt.Sprintf("Sorry, I couldn't find a device named %s.", deviceSlot.Value))
}
var params map[string]any
if paramsFn != nil {
p, err := paramsFn(slots)
if err != nil {
r.log.Warn("invalid intent slots", "action", action, "entity_id", entity.EntityID, "err", err)
return alexa.NewTellResponse("Sorry, I didn't understand that request.")
}
params = p
}
if err := r.controller.ExecuteAction(ctx, entity.EntityID, action, params); err != nil {
r.log.Error("execute action failed", "entity_id", entity.EntityID, "action", action, "err", err)
return alexa.NewTellResponse(fmt.Sprintf("Sorry, I couldn't do that to the %s.", entity.FriendlyName))
}
return alexa.NewTellResponse(fmt.Sprintf("OK, done with the %s.", entity.FriendlyName))
}
}
func handleLaunch() alexa.Response {
return alexa.Response{
Version: "1.0",
Response: alexa.ResponseBody{
OutputSpeech: &alexa.OutputSpeech{Type: "PlainText", Text: "Home control ready. What would you like to do?"},
ShouldEndSession: false,
},
}
}
func handleHelp(_ context.Context, _ *Router, _ alexa.Request) alexa.Response {
return alexa.Response{
Version: "1.0",
Response: alexa.ResponseBody{
OutputSpeech: &alexa.OutputSpeech{
Type: "PlainText",
Text: "You can ask me to turn on or off lights, switches, and climate devices, or set brightness and temperature.",
},
ShouldEndSession: false,
},
}
}
func handleStop(_ context.Context, _ *Router, _ alexa.Request) alexa.Response {
return alexa.NewTellResponse("Goodbye.")
}

View File

@ -0,0 +1,264 @@
package directive
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
type fakeResolver struct {
entities map[string]domain.Entity
}
func (f *fakeResolver) Resolve(friendlyName string) (domain.Entity, bool) {
e, ok := f.entities[friendlyName]
return e, ok
}
type fakeController struct {
executeFunc func(ctx context.Context, entityID, action string, params map[string]any) error
gotEntityID string
gotAction string
gotParams map[string]any
}
func (f *fakeController) ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error {
f.gotEntityID = entityID
f.gotAction = action
f.gotParams = params
if f.executeFunc == nil {
return nil
}
return f.executeFunc(ctx, entityID, action, params)
}
func intentRequest(intentName string, slots map[string]alexa.Slot) alexa.Request {
return alexa.Request{
Version: "1.0",
Request: alexa.RequestBody{
Type: "IntentRequest",
Intent: &alexa.Intent{
Name: intentName,
Slots: slots,
},
},
}
}
func TestRouterDispatchLaunchAndSessionEnded(t *testing.T) {
r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger())
t.Run("LaunchRequest keeps the session open", func(t *testing.T) {
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "LaunchRequest"}})
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if resp.Response.ShouldEndSession {
t.Fatal("LaunchRequest should not end the session")
}
})
t.Run("SessionEndedRequest returns an empty response", func(t *testing.T) {
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SessionEndedRequest"}})
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if resp.Version != "1.0" {
t.Fatalf("Version = %q, want %q", resp.Version, "1.0")
}
})
t.Run("unrecognized request type does not error", func(t *testing.T) {
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SomethingElse"}})
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if resp.Response.OutputSpeech == nil {
t.Fatal("expected a spoken fallback response")
}
})
}
func TestRouterDispatchBuiltinIntents(t *testing.T) {
r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger())
tests := []string{"AMAZON.HelpIntent", "AMAZON.StopIntent", "AMAZON.CancelIntent"}
for _, name := range tests {
t.Run(name, func(t *testing.T) {
resp, err := r.Dispatch(context.Background(), intentRequest(name, nil))
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if resp.Response.OutputSpeech == nil || resp.Response.OutputSpeech.Text == "" {
t.Fatal("expected spoken output")
}
})
}
t.Run("unrecognized intent", func(t *testing.T) {
resp, err := r.Dispatch(context.Background(), intentRequest("NoSuchIntent", nil))
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if resp.Response.OutputSpeech == nil {
t.Fatal("expected a spoken fallback response")
}
})
}
func TestRouterDispatchDeviceActions(t *testing.T) {
entity := domain.Entity{EntityID: "light.living_room", FriendlyName: "Living Room Lamp", Domain: "light"}
resolver := &fakeResolver{entities: map[string]domain.Entity{"living room lamp": entity}}
t.Run("TurnOnIntent resolves device and executes turn_on with no params", func(t *testing.T) {
ctrl := &fakeController{}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
resp, err := r.Dispatch(context.Background(), req)
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if ctrl.gotEntityID != "light.living_room" || ctrl.gotAction != "turn_on" {
t.Fatalf("ExecuteAction called with (%q, %q), want (light.living_room, turn_on)", ctrl.gotEntityID, ctrl.gotAction)
}
if ctrl.gotParams != nil {
t.Fatalf("params = %#v, want nil", ctrl.gotParams)
}
if !resp.Response.ShouldEndSession {
t.Fatal("a completed action should end the session")
}
})
t.Run("SetBrightnessIntent forwards the Brightness slot as brightness_pct", func(t *testing.T) {
ctrl := &fakeController{}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{
"Device": {Name: "Device", Value: "living room lamp"},
"Brightness": {Name: "Brightness", Value: "50"},
})
if _, err := r.Dispatch(context.Background(), req); err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if ctrl.gotAction != "set_brightness" || ctrl.gotParams["brightness_pct"] != "50" {
t.Fatalf("action/params = %q/%#v, want set_brightness/{brightness_pct:50}", ctrl.gotAction, ctrl.gotParams)
}
})
t.Run("SetTemperatureIntent forwards the Temperature slot as target_temperature", func(t *testing.T) {
climateEntity := domain.Entity{EntityID: "climate.air_conditioner", FriendlyName: "Air Conditioner", Domain: "climate"}
climateResolver := &fakeResolver{entities: map[string]domain.Entity{"air conditioner": climateEntity}}
ctrl := &fakeController{}
r := NewRouter(climateResolver, ctrl, discardLogger())
req := intentRequest("SetTemperatureIntent", map[string]alexa.Slot{
"Device": {Name: "Device", Value: "air conditioner"},
"Temperature": {Name: "Temperature", Value: "23.5"},
})
if _, err := r.Dispatch(context.Background(), req); err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if ctrl.gotEntityID != "climate.air_conditioner" || ctrl.gotAction != "set_temperature" {
t.Fatalf("entity/action = %q/%q, want climate.air_conditioner/set_temperature", ctrl.gotEntityID, ctrl.gotAction)
}
if ctrl.gotParams["target_temperature"] != "23.5" {
t.Fatalf("params = %#v, want target_temperature=23.5", ctrl.gotParams)
}
})
t.Run("SetColorIntent forwards Red/Green/Blue slots as an rgb_color map", func(t *testing.T) {
ctrl := &fakeController{}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("SetColorIntent", map[string]alexa.Slot{
"Device": {Name: "Device", Value: "living room lamp"},
"Red": {Name: "Red", Value: "255"},
"Green": {Name: "Green", Value: "0"},
"Blue": {Name: "Blue", Value: "0"},
})
if _, err := r.Dispatch(context.Background(), req); err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
rgb, ok := ctrl.gotParams["rgb_color"].(map[string]any)
if !ok || rgb["r"] != "255" {
t.Fatalf("rgb_color = %#v, want map with r=255", ctrl.gotParams["rgb_color"])
}
})
t.Run("missing Device slot does not call the controller", func(t *testing.T) {
ctrl := &fakeController{}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("TurnOnIntent", nil)
resp, err := r.Dispatch(context.Background(), req)
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if ctrl.gotAction != "" {
t.Fatal("controller should not be called without a Device slot")
}
if resp.Response.OutputSpeech == nil {
t.Fatal("expected a spoken response explaining the missing device")
}
})
t.Run("unresolvable device does not call the controller", func(t *testing.T) {
ctrl := &fakeController{}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "nonexistent"}})
resp, err := r.Dispatch(context.Background(), req)
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if ctrl.gotAction != "" {
t.Fatal("controller should not be called for an unresolvable device")
}
if resp.Response.OutputSpeech == nil {
t.Fatal("expected a spoken response explaining the device wasn't found")
}
})
t.Run("missing required action slot does not call the controller", func(t *testing.T) {
ctrl := &fakeController{}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
resp, err := r.Dispatch(context.Background(), req)
if err != nil {
t.Fatalf("Dispatch() error = %v", err)
}
if ctrl.gotAction != "" {
t.Fatal("controller should not be called when a required param slot is missing")
}
if resp.Response.OutputSpeech == nil {
t.Fatal("expected a spoken response explaining the request wasn't understood")
}
})
t.Run("controller error is turned into a spoken failure, not a Dispatch error", func(t *testing.T) {
ctrl := &fakeController{executeFunc: func(ctx context.Context, entityID, action string, params map[string]any) error {
return errors.New("ha-gateway unreachable")
}}
r := NewRouter(resolver, ctrl, discardLogger())
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
resp, err := r.Dispatch(context.Background(), req)
if err != nil {
t.Fatalf("Dispatch() error = %v, want nil (errors should be spoken, not propagated)", err)
}
if resp.Response.OutputSpeech == nil {
t.Fatal("expected a spoken failure response")
}
})
}

View File

@ -0,0 +1,84 @@
package entities
import (
"context"
"errors"
"fmt"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
)
// Client fetches resolvable entities from ha-gateway's EntityService. It
// calls ListStates once per configured domain (rather than one unfiltered
// call) since ListStatesRequest.domain is a single string, not repeated —
// see alexa-bridge/plan.md's Entity resolution section for why
// EntityService was chosen over the domain-specific ListLights/
// ListSwitches/ListClimates RPCs.
type Client struct {
entityClient hav1.EntityServiceClient
domains []string
}
// NewClient wraps an already-dialed EntityServiceClient — the connection
// itself is owned by internal/haclient.Client, shared across both packages
// rather than dialed twice. domains is the set of ha-gateway entity domains
// to resolve (e.g. "light", "switch", "climate"); RemoteService/SwitchBot
// entities are deliberately excluded (see plan.md Decisions #3).
func NewClient(entityClient hav1.EntityServiceClient, domains []string) *Client {
return &Client{entityClient: entityClient, domains: domains}
}
type fetchResult struct {
entities []domain.Entity
err error
}
// FetchAll fetches every entity across all configured domains, concurrently,
// and fails all-or-nothing: a partial entity list (e.g. missing every switch
// because just that one domain call failed) would silently break control
// for that domain without an obvious error, which is worse than a clean
// failure the caller can act on.
func (c *Client) FetchAll(ctx context.Context) ([]domain.Entity, error) {
results := make(chan fetchResult, len(c.domains))
for _, d := range c.domains {
go func(d string) {
resp, err := c.entityClient.ListStates(ctx, &hav1.ListStatesRequest{Domain: d})
if err != nil {
results <- fetchResult{err: fmt.Errorf("list states for domain %q: %w", d, err)}
return
}
out := make([]domain.Entity, 0, len(resp.GetStates()))
for _, s := range resp.GetStates() {
out = append(out, domain.Entity{
EntityID: s.GetEntityId(),
FriendlyName: firstNonEmpty(s.GetAttributes()["friendly_name"], s.GetEntityId()),
Domain: d,
})
}
results <- fetchResult{entities: out}
}(d)
}
var all []domain.Entity
var errs error
for range c.domains {
r := <-results
if r.err != nil {
errs = errors.Join(errs, r.err)
continue
}
all = append(all, r.entities...)
}
if errs != nil {
return nil, errs
}
return all, nil
}
func firstNonEmpty(a, b string) string {
if a != "" {
return a
}
return b
}

View File

@ -0,0 +1,109 @@
package entities
import (
"context"
"errors"
"sort"
"testing"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
"google.golang.org/grpc"
)
type fakeEntityServiceClient struct {
listStatesFunc func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error)
}
func (f *fakeEntityServiceClient) GetState(ctx context.Context, in *hav1.GetStateRequest, _ ...grpc.CallOption) (*hav1.GetStateResponse, error) {
return &hav1.GetStateResponse{}, nil
}
func (f *fakeEntityServiceClient) ListStates(ctx context.Context, in *hav1.ListStatesRequest, _ ...grpc.CallOption) (*hav1.ListStatesResponse, error) {
if f.listStatesFunc == nil {
return &hav1.ListStatesResponse{}, nil
}
return f.listStatesFunc(ctx, in)
}
func TestClientFetchAll(t *testing.T) {
t.Run("fetches per domain and extracts friendly_name", func(t *testing.T) {
fake := &fakeEntityServiceClient{
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
switch in.GetDomain() {
case "light":
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
}}, nil
case "switch":
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
{EntityId: "switch.fan", Attributes: map[string]string{"friendly_name": "Fan"}},
}}, nil
case "climate":
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
{EntityId: "climate.air_conditioner", Attributes: map[string]string{}},
}}, nil
}
t.Fatalf("unexpected domain %q", in.GetDomain())
return nil, nil
},
}
c := NewClient(fake, []string{"light", "switch", "climate"})
got, err := c.FetchAll(context.Background())
if err != nil {
t.Fatalf("FetchAll() error = %v", err)
}
sort.Slice(got, func(i, j int) bool { return got[i].EntityID < got[j].EntityID })
want := []struct {
entityID, friendlyName, domain string
}{
{"climate.air_conditioner", "climate.air_conditioner", "climate"}, // no friendly_name attr -> falls back to entity_id
{"light.living_room", "Living Room Lamp", "light"},
{"switch.fan", "Fan", "switch"},
}
if len(got) != len(want) {
t.Fatalf("len(got) = %d, want %d (got=%#v)", len(got), len(want), got)
}
for i, w := range want {
if got[i].EntityID != w.entityID || got[i].FriendlyName != w.friendlyName || got[i].Domain != w.domain {
t.Fatalf("got[%d] = %#v, want {%s %s %s}", i, got[i], w.entityID, w.friendlyName, w.domain)
}
}
})
t.Run("one domain failing fails the whole fetch", func(t *testing.T) {
wantErr := errors.New("boom")
fake := &fakeEntityServiceClient{
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
if in.GetDomain() == "switch" {
return nil, wantErr
}
return &hav1.ListStatesResponse{}, nil
},
}
c := NewClient(fake, []string{"light", "switch", "climate"})
got, err := c.FetchAll(context.Background())
if err == nil {
t.Fatal("FetchAll() error = nil, want error")
}
if !errors.Is(err, wantErr) {
t.Fatalf("FetchAll() error = %v, want it to wrap %v", err, wantErr)
}
if got != nil {
t.Fatalf("FetchAll() entities = %#v, want nil on partial failure", got)
}
})
t.Run("empty domain list returns empty result", func(t *testing.T) {
c := NewClient(&fakeEntityServiceClient{}, nil)
got, err := c.FetchAll(context.Background())
if err != nil {
t.Fatalf("FetchAll() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("FetchAll() = %#v, want empty", got)
}
})
}

View File

@ -0,0 +1,62 @@
package entities
import (
"context"
"log/slog"
"time"
)
// Refresher fetches entities from ha-gateway on startup (blocking, fail
// loud — without an initial entity list nothing can resolve) and then
// periodically in the background (non-blocking; a failed periodic refresh
// only logs and keeps serving the last-known-good snapshot, since going
// down over a transient ha-gateway hiccup would be worse than serving
// slightly stale entity names).
type Refresher struct {
client *Client
resolver *Resolver
interval time.Duration
log *slog.Logger
}
// NewRefresher constructs a Refresher. client fetches entities, resolver is
// the snapshot it populates, interval controls the periodic refresh cadence.
func NewRefresher(client *Client, resolver *Resolver, interval time.Duration, log *slog.Logger) *Refresher {
return &Refresher{client: client, resolver: resolver, interval: interval, log: log}
}
// Start performs the blocking initial fetch, then launches a background
// goroutine for periodic refreshes until ctx is cancelled. It returns an
// error only from the initial fetch — the caller should fail startup on it.
func (r *Refresher) Start(ctx context.Context) error {
if err := r.refreshOnce(ctx); err != nil {
return err
}
go r.loop(ctx)
return nil
}
func (r *Refresher) refreshOnce(ctx context.Context) error {
list, err := r.client.FetchAll(ctx)
if err != nil {
return err
}
r.resolver.Replace(list)
return nil
}
func (r *Refresher) loop(ctx context.Context) {
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.refreshOnce(ctx); err != nil {
r.log.Error("periodic entity refresh failed, keeping stale data", "err", err)
}
}
}
}

View File

@ -0,0 +1,154 @@
package entities
import (
"context"
"errors"
"io"
"log/slog"
"sync/atomic"
"testing"
"time"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
)
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestRefresherStart(t *testing.T) {
t.Run("happy path populates resolver before returning", func(t *testing.T) {
fake := &fakeEntityServiceClient{
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
}}, nil
},
}
client := NewClient(fake, []string{"light"})
resolver := NewResolver()
r := NewRefresher(client, resolver, time.Hour, discardLogger())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := r.Start(ctx); err != nil {
t.Fatalf("Start() error = %v", err)
}
if _, ok := resolver.Resolve("Living Room Lamp"); !ok {
t.Fatal("resolver not populated after Start() returned")
}
})
t.Run("initial fetch failure returns error and leaves resolver empty", func(t *testing.T) {
wantErr := errors.New("ha-gateway unreachable")
fake := &fakeEntityServiceClient{
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
return nil, wantErr
},
}
client := NewClient(fake, []string{"light"})
resolver := NewResolver()
r := NewRefresher(client, resolver, time.Hour, discardLogger())
err := r.Start(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Start() error = %v, want it to wrap %v", err, wantErr)
}
if _, ok := resolver.Resolve("anything"); ok {
t.Fatal("resolver should stay empty when the initial fetch fails")
}
})
}
func TestRefresherPeriodicRefresh(t *testing.T) {
var calls int32
called := make(chan struct{}, 16)
fake := &fakeEntityServiceClient{
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
atomic.AddInt32(&calls, 1)
select {
case called <- struct{}{}:
default:
}
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
}}, nil
},
}
client := NewClient(fake, []string{"light"})
resolver := NewResolver()
r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := r.Start(ctx); err != nil {
t.Fatalf("Start() error = %v", err)
}
// The initial blocking fetch inside Start already counts as one call;
// wait for at least two more from the background ticker loop.
timeout := time.After(2 * time.Second)
for i := 0; i < 2; i++ {
select {
case <-called:
case <-timeout:
t.Fatal("timed out waiting for periodic refresh")
}
}
if atomic.LoadInt32(&calls) < 2 {
t.Fatalf("calls = %d, want at least 2", calls)
}
}
func TestRefresherPeriodicRefreshFailureDoesNotStopLoop(t *testing.T) {
var calls int32
called := make(chan struct{}, 16)
fake := &fakeEntityServiceClient{
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
n := atomic.AddInt32(&calls, 1)
select {
case called <- struct{}{}:
default:
}
// Fail the initial fetch's implied call (n==1 is consumed by
// Start synchronously below, so only the periodic ones matter
// here); alternate failures thereafter to prove one bad
// refresh doesn't wedge the loop.
if n%2 == 0 {
return nil, errors.New("transient ha-gateway error")
}
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
}}, nil
},
}
client := NewClient(fake, []string{"light"})
resolver := NewResolver()
r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := r.Start(ctx); err != nil {
t.Fatalf("Start() error = %v", err)
}
timeout := time.After(2 * time.Second)
for i := 0; i < 3; i++ {
select {
case <-called:
case <-timeout:
t.Fatal("timed out waiting for periodic refresh")
}
}
if atomic.LoadInt32(&calls) < 3 {
t.Fatalf("calls = %d, want at least 3 (loop should keep running through failures)", calls)
}
// The resolver should still hold whatever the last successful refresh
// produced, even though some refreshes in between failed.
if _, ok := resolver.Resolve("Living Room Lamp"); !ok {
t.Fatal("resolver lost its last-known-good data after a failed periodic refresh")
}
}

View File

@ -0,0 +1,48 @@
package entities
import (
"strings"
"sync/atomic"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
)
// Resolver implements driven.EntityResolver via an atomically-swapped
// snapshot map, so an in-flight Alexa request never observes a half-built
// map while a refresh is in progress.
type Resolver struct {
snapshot atomic.Pointer[map[string]domain.Entity]
}
// NewResolver constructs a Resolver with an empty snapshot; call Replace
// (directly, or via Refresher.Start) to populate it before serving requests.
func NewResolver() *Resolver {
r := &Resolver{}
empty := map[string]domain.Entity{}
r.snapshot.Store(&empty)
return r
}
// Replace rebuilds the lookup map, keyed by normalized friendly name, and
// swaps it in atomically.
func (r *Resolver) Replace(list []domain.Entity) {
m := make(map[string]domain.Entity, len(list))
for _, e := range list {
m[normalize(e.FriendlyName)] = e
}
r.snapshot.Store(&m)
}
// Resolve implements driven.EntityResolver. Matching is exact-match only on
// the normalized (lowercased, trimmed) friendly name — see
// alexa-bridge/plan.md's Decisions on open questions #5 for why fuzzy
// matching isn't implemented here.
func (r *Resolver) Resolve(friendlyName string) (domain.Entity, bool) {
m := *r.snapshot.Load()
e, ok := m[normalize(friendlyName)]
return e, ok
}
func normalize(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}

View File

@ -0,0 +1,60 @@
package entities
import (
"testing"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
)
func TestResolverResolve(t *testing.T) {
r := NewResolver()
t.Run("empty resolver misses", func(t *testing.T) {
if _, ok := r.Resolve("Living Room Lamp"); ok {
t.Fatal("Resolve() ok = true on empty resolver, want false")
}
})
r.Replace([]domain.Entity{
{EntityID: "light.living_room", FriendlyName: "Living Room Lamp", Domain: "light"},
{EntityID: "switch.fan", FriendlyName: "Fan", Domain: "switch"},
})
t.Run("exact match after normalization", func(t *testing.T) {
e, ok := r.Resolve(" living room lamp ")
if !ok {
t.Fatal("Resolve() ok = false, want true")
}
if e.EntityID != "light.living_room" {
t.Fatalf("EntityID = %q, want %q", e.EntityID, "light.living_room")
}
})
t.Run("case-sensitivity does not matter", func(t *testing.T) {
if _, ok := r.Resolve("LIVING ROOM LAMP"); !ok {
t.Fatal("Resolve() ok = false, want true")
}
})
t.Run("no fuzzy match", func(t *testing.T) {
if _, ok := r.Resolve("living room light"); ok {
t.Fatal("Resolve() ok = true for a non-exact name, want false (exact-match only, see plan.md Decisions #5)")
}
})
t.Run("unknown name misses", func(t *testing.T) {
if _, ok := r.Resolve("kitchen"); ok {
t.Fatal("Resolve() ok = true, want false")
}
})
t.Run("Replace fully overwrites the previous snapshot", func(t *testing.T) {
r.Replace([]domain.Entity{{EntityID: "light.bedroom", FriendlyName: "Bedroom", Domain: "light"}})
if _, ok := r.Resolve("fan"); ok {
t.Fatal("Resolve() found stale entry after Replace, want it gone")
}
if _, ok := r.Resolve("bedroom"); !ok {
t.Fatal("Resolve() ok = false for newly replaced entry, want true")
}
})
}

View File

@ -0,0 +1,103 @@
package haclient
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"os"
"path/filepath"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
)
// Client is alexa-bridge's single mTLS connection to ha-gateway. It
// implements driven.Controller directly (see controller.go) and also
// exposes the raw EntityServiceClient for internal/entities to wrap, so the
// whole process shares one connection/handshake to ha-gateway rather than
// dialing it twice for two different RPC families.
type Client struct {
conn *grpc.ClientConn
lightClient hav1.LightServiceClient
switchClient hav1.SwitchServiceClient
climateClient hav1.ClimateServiceClient
entityClient hav1.EntityServiceClient
log *slog.Logger
}
// New constructs a gRPC client for ha-gateway with optional mTLS.
func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) {
transportCreds := insecure.NewCredentials()
if tlsDir != "" {
creds, err := loadTransportCredentials(tlsDir, serverName)
if err != nil {
return nil, fmt.Errorf("load mTLS credentials: %w", err)
}
transportCreds = creds
}
conn, err := grpc.NewClient(
addr,
grpc.WithTransportCredentials(transportCreds),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)
if err != nil {
return nil, fmt.Errorf("dial ha-gateway: %w", err)
}
return &Client{
conn: conn,
lightClient: hav1.NewLightServiceClient(conn),
switchClient: hav1.NewSwitchServiceClient(conn),
climateClient: hav1.NewClimateServiceClient(conn),
entityClient: hav1.NewEntityServiceClient(conn),
log: log,
}, nil
}
// EntityServiceClient exposes the shared connection's EntityService stub for
// internal/entities to wrap, instead of that package dialing its own
// second connection to ha-gateway.
func (c *Client) EntityServiceClient() hav1.EntityServiceClient {
return c.entityClient
}
// Close closes the underlying gRPC connection.
func (c *Client) Close() error {
if err := c.conn.Close(); err != nil {
return fmt.Errorf("close ha-gateway client: %w", err)
}
return nil
}
func loadTransportCredentials(tlsDir, serverName string) (credentials.TransportCredentials, error) {
cert, err := tls.LoadX509KeyPair(
filepath.Join(tlsDir, "tls.crt"),
filepath.Join(tlsDir, "tls.key"),
)
if err != nil {
return nil, fmt.Errorf("load client key pair: %w", err)
}
caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt"))
if err != nil {
return nil, fmt.Errorf("read server CA: %w", err)
}
rootCAs := x509.NewCertPool()
if !rootCAs.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("append server CA: invalid PEM")
}
return credentials.NewTLS(&tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: rootCAs,
ServerName: serverName,
MinVersion: tls.VersionTLS13,
}), nil
}

View File

@ -0,0 +1,127 @@
package haclient
import (
"context"
"fmt"
"strings"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
)
// ExecuteAction implements driven.Controller. It dispatches by entity domain
// (the entity_id prefix up to the first '.') to the matching ha-gateway
// service, then by action within that domain. See alexa-bridge/plan.md's
// action table for the full (domain, action) -> RPC mapping.
func (c *Client) ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error {
domain, _, ok := strings.Cut(entityID, ".")
if !ok {
return fmt.Errorf("entity id %q has no domain prefix", entityID)
}
switch domain {
case "light":
return c.executeLightAction(ctx, entityID, action, params)
case "switch":
return c.executeSwitchAction(ctx, entityID, action, params)
case "climate":
return c.executeClimateAction(ctx, entityID, action, params)
default:
return fmt.Errorf("unsupported entity domain %q for entity %q", domain, entityID)
}
}
func (c *Client) executeLightAction(ctx context.Context, entityID, action string, params map[string]any) error {
switch action {
case "turn_on":
_, err := c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{
EntityId: entityID,
BrightnessPct: optUint32(params, "brightness_pct"),
ColorTempKelvin: optUint32(params, "color_temp_kelvin"),
RgbColor: optRGB(params, "rgb_color"),
Transition: optUint32(params, "transition"),
})
return err
case "turn_off":
_, err := c.lightClient.TurnOff(ctx, &hav1.TurnOffRequest{
EntityId: entityID,
Transition: optUint32(params, "transition"),
})
return err
case "toggle":
_, err := c.lightClient.Toggle(ctx, &hav1.ToggleRequest{EntityId: entityID})
return err
case "set_brightness":
brightness, err := reqUint32(params, "brightness_pct")
if err != nil {
return err
}
_, err = c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: &brightness})
return err
case "set_color_temp":
colorTemp, err := reqUint32(params, "color_temp_kelvin")
if err != nil {
return err
}
_, err = c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{EntityId: entityID, ColorTempKelvin: &colorTemp})
return err
case "set_color":
rgb, err := reqRGB(params, "rgb_color")
if err != nil {
return err
}
_, err = c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{EntityId: entityID, RgbColor: rgb})
return err
default:
return fmt.Errorf("unsupported light action %q", action)
}
}
func (c *Client) executeSwitchAction(ctx context.Context, entityID, action string, _ map[string]any) error {
req := &hav1.SwitchRequest{EntityId: entityID}
switch action {
case "turn_on":
_, err := c.switchClient.TurnOn(ctx, req)
return err
case "turn_off":
_, err := c.switchClient.TurnOff(ctx, req)
return err
case "toggle":
_, err := c.switchClient.Toggle(ctx, req)
return err
default:
return fmt.Errorf("unsupported switch action %q", action)
}
}
func (c *Client) executeClimateAction(ctx context.Context, entityID, action string, params map[string]any) error {
switch action {
case "turn_on":
_, err := c.climateClient.TurnOn(ctx, &hav1.ClimateRequest{EntityId: entityID})
return err
case "turn_off":
_, err := c.climateClient.TurnOff(ctx, &hav1.ClimateRequest{EntityId: entityID})
return err
case "set_hvac_mode":
mode, err := reqString(params, "hvac_mode")
if err != nil {
return err
}
_, err = c.climateClient.SetHVACMode(ctx, &hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: mode})
return err
case "increase_temperature":
_, err := c.climateClient.IncreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID})
return err
case "decrease_temperature":
_, err := c.climateClient.DecreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID})
return err
case "set_temperature":
target, err := reqFloat64(params, "target_temperature")
if err != nil {
return err
}
_, err = c.climateClient.SetTemperature(ctx, &hav1.SetTemperatureRequest{EntityId: entityID, TargetTemperature: target})
return err
default:
return fmt.Errorf("unsupported climate action %q", action)
}
}

View File

@ -0,0 +1,461 @@
package haclient
import (
"context"
"errors"
"testing"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
"google.golang.org/grpc"
)
type fakeLightServiceClient struct {
turnOnFunc func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error)
turnOffFunc func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error)
toggleFunc func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error)
}
func (f *fakeLightServiceClient) TurnOn(ctx context.Context, in *hav1.TurnOnRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
if f.turnOnFunc == nil {
return &hav1.LightResponse{}, nil
}
return f.turnOnFunc(ctx, in)
}
func (f *fakeLightServiceClient) TurnOff(ctx context.Context, in *hav1.TurnOffRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
if f.turnOffFunc == nil {
return &hav1.LightResponse{}, nil
}
return f.turnOffFunc(ctx, in)
}
func (f *fakeLightServiceClient) Toggle(ctx context.Context, in *hav1.ToggleRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
if f.toggleFunc == nil {
return &hav1.LightResponse{}, nil
}
return f.toggleFunc(ctx, in)
}
func (f *fakeLightServiceClient) ListLights(ctx context.Context, in *hav1.ListLightsRequest, _ ...grpc.CallOption) (*hav1.ListLightsResponse, error) {
return &hav1.ListLightsResponse{}, nil
}
type fakeSwitchServiceClient struct {
turnOnFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
turnOffFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
toggleFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
}
func (f *fakeSwitchServiceClient) TurnOn(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
if f.turnOnFunc == nil {
return &hav1.SwitchResponse{}, nil
}
return f.turnOnFunc(ctx, in)
}
func (f *fakeSwitchServiceClient) TurnOff(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
if f.turnOffFunc == nil {
return &hav1.SwitchResponse{}, nil
}
return f.turnOffFunc(ctx, in)
}
func (f *fakeSwitchServiceClient) Toggle(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
if f.toggleFunc == nil {
return &hav1.SwitchResponse{}, nil
}
return f.toggleFunc(ctx, in)
}
func (f *fakeSwitchServiceClient) ListSwitches(ctx context.Context, in *hav1.ListSwitchesRequest, _ ...grpc.CallOption) (*hav1.ListSwitchesResponse, error) {
return &hav1.ListSwitchesResponse{}, nil
}
type fakeClimateServiceClient struct {
turnOnFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
turnOffFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
increaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
decreaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
setTemperatureFunc func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error)
setHVACModeFunc func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error)
}
func (f *fakeClimateServiceClient) TurnOn(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.turnOnFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.turnOnFunc(ctx, in)
}
func (f *fakeClimateServiceClient) TurnOff(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.turnOffFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.turnOffFunc(ctx, in)
}
func (f *fakeClimateServiceClient) IncreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.increaseTemperatureFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.increaseTemperatureFunc(ctx, in)
}
func (f *fakeClimateServiceClient) DecreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.decreaseTemperatureFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.decreaseTemperatureFunc(ctx, in)
}
func (f *fakeClimateServiceClient) SetTemperature(ctx context.Context, in *hav1.SetTemperatureRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.setTemperatureFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.setTemperatureFunc(ctx, in)
}
func (f *fakeClimateServiceClient) SetHVACMode(ctx context.Context, in *hav1.SetHVACModeRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
if f.setHVACModeFunc == nil {
return &hav1.ClimateResponse{}, nil
}
return f.setHVACModeFunc(ctx, in)
}
func (f *fakeClimateServiceClient) ListClimates(ctx context.Context, in *hav1.ListClimatesRequest, _ ...grpc.CallOption) (*hav1.ListClimatesResponse, error) {
return &hav1.ListClimatesResponse{}, nil
}
func TestExecuteActionLight(t *testing.T) {
t.Run("turn_on forwards optional params", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", map[string]any{
"brightness_pct": "80",
"color_temp_kelvin": "2700",
"rgb_color": map[string]any{"r": "255", "g": "0", "b": "0"},
})
if err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "light.living_room" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room")
}
if got.GetBrightnessPct() != 80 {
t.Fatalf("BrightnessPct = %v, want 80", got.GetBrightnessPct())
}
if got.GetColorTempKelvin() != 2700 {
t.Fatalf("ColorTempKelvin = %v, want 2700", got.GetColorTempKelvin())
}
if got.GetRgbColor().GetR() != 255 {
t.Fatalf("RgbColor.R = %v, want 255", got.GetRgbColor().GetR())
}
})
t.Run("turn_on with no params sets no optional fields", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.BrightnessPct != nil || got.ColorTempKelvin != nil || got.RgbColor != nil {
t.Fatalf("expected no optional fields set, got %#v", got)
}
})
t.Run("turn_off forwards transition", func(t *testing.T) {
var got *hav1.TurnOffRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOffFunc: func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_off", map[string]any{"transition": "3"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetTransition() != 3 {
t.Fatalf("Transition = %v, want 3", got.GetTransition())
}
})
t.Run("toggle", func(t *testing.T) {
var got *hav1.ToggleRequest
c := &Client{lightClient: &fakeLightServiceClient{
toggleFunc: func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "toggle", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "light.living_room" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room")
}
})
t.Run("set_brightness reuses TurnOn with only brightness set", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", map[string]any{"brightness_pct": "50"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetBrightnessPct() != 50 {
t.Fatalf("BrightnessPct = %v, want 50", got.GetBrightnessPct())
}
if got.ColorTempKelvin != nil || got.RgbColor != nil {
t.Fatalf("expected only brightness set, got %#v", got)
}
})
t.Run("set_brightness missing param errors without calling RPC", func(t *testing.T) {
called := false
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
called = true
return &hav1.LightResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", nil)
if err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
if called {
t.Fatal("TurnOn should not be called when the required param is missing")
}
})
t.Run("set_color_temp", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "set_color_temp", map[string]any{"color_temp_kelvin": "4000"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetColorTempKelvin() != 4000 {
t.Fatalf("ColorTempKelvin = %v, want 4000", got.GetColorTempKelvin())
}
})
t.Run("set_color", func(t *testing.T) {
var got *hav1.TurnOnRequest
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
got = in
return &hav1.LightResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "set_color", map[string]any{
"rgb_color": map[string]any{"r": "10", "g": "20", "b": "30"},
})
if err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetRgbColor().GetR() != 10 || got.GetRgbColor().GetG() != 20 || got.GetRgbColor().GetB() != 30 {
t.Fatalf("RgbColor = %#v, want (10,20,30)", got.GetRgbColor())
}
})
t.Run("unsupported action", func(t *testing.T) {
c := &Client{lightClient: &fakeLightServiceClient{}}
if err := c.ExecuteAction(context.Background(), "light.living_room", "explode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
t.Run("propagates RPC error", func(t *testing.T) {
wantErr := errors.New("boom")
c := &Client{lightClient: &fakeLightServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
return nil, wantErr
},
}}
err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil)
if !errors.Is(err, wantErr) {
t.Fatalf("ExecuteAction() error = %v, want %v", err, wantErr)
}
})
}
func TestExecuteActionSwitch(t *testing.T) {
tests := []struct {
action string
}{{"turn_on"}, {"turn_off"}, {"toggle"}}
for _, tt := range tests {
t.Run(tt.action, func(t *testing.T) {
var gotOn, gotOff, gotToggle *hav1.SwitchRequest
c := &Client{switchClient: &fakeSwitchServiceClient{
turnOnFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
gotOn = in
return &hav1.SwitchResponse{}, nil
},
turnOffFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
gotOff = in
return &hav1.SwitchResponse{}, nil
},
toggleFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
gotToggle = in
return &hav1.SwitchResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "switch.fan", tt.action, nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
for _, got := range []*hav1.SwitchRequest{gotOn, gotOff, gotToggle} {
if got != nil && got.GetEntityId() != "switch.fan" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "switch.fan")
}
}
})
}
t.Run("unsupported action", func(t *testing.T) {
c := &Client{switchClient: &fakeSwitchServiceClient{}}
if err := c.ExecuteAction(context.Background(), "switch.fan", "explode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
}
func TestExecuteActionClimate(t *testing.T) {
t.Run("set_hvac_mode", func(t *testing.T) {
var got *hav1.SetHVACModeRequest
c := &Client{climateClient: &fakeClimateServiceClient{
setHVACModeFunc: func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) {
got = in
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", map[string]any{"hvac_mode": "cool"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetHvacMode() != "cool" {
t.Fatalf("HvacMode = %q, want %q", got.GetHvacMode(), "cool")
}
})
t.Run("set_hvac_mode missing param errors", func(t *testing.T) {
c := &Client{climateClient: &fakeClimateServiceClient{}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
t.Run("increase_temperature", func(t *testing.T) {
var got *hav1.ClimateRequest
c := &Client{climateClient: &fakeClimateServiceClient{
increaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) {
got = in
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "increase_temperature", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "climate.air_conditioner" {
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "climate.air_conditioner")
}
})
t.Run("decrease_temperature", func(t *testing.T) {
called := false
c := &Client{climateClient: &fakeClimateServiceClient{
decreaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) {
called = true
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "decrease_temperature", nil); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if !called {
t.Fatal("DecreaseTemperature was not called")
}
})
t.Run("set_temperature forwards absolute target", func(t *testing.T) {
var got *hav1.SetTemperatureRequest
c := &Client{climateClient: &fakeClimateServiceClient{
setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
got = in
return &hav1.ClimateResponse{}, nil
},
}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", map[string]any{"target_temperature": "23.5"}); err != nil {
t.Fatalf("ExecuteAction() error = %v", err)
}
if got.GetEntityId() != "climate.air_conditioner" || got.GetTargetTemperature() != 23.5 {
t.Fatalf("SetTemperatureRequest = %#v, want entity=climate.air_conditioner target=23.5", got)
}
})
t.Run("set_temperature missing param errors without calling RPC", func(t *testing.T) {
called := false
c := &Client{climateClient: &fakeClimateServiceClient{
setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
called = true
return &hav1.ClimateResponse{}, nil
},
}}
err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", nil)
if err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
if called {
t.Fatal("SetTemperature should not be called when the required param is missing")
}
})
t.Run("unsupported action", func(t *testing.T) {
c := &Client{climateClient: &fakeClimateServiceClient{}}
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "explode", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
}
func TestExecuteActionDomainDispatch(t *testing.T) {
t.Run("unsupported domain", func(t *testing.T) {
c := &Client{}
if err := c.ExecuteAction(context.Background(), "sensor.temp", "turn_on", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
t.Run("entity id with no domain prefix", func(t *testing.T) {
c := &Client{}
if err := c.ExecuteAction(context.Background(), "no-domain-here", "turn_on", nil); err == nil {
t.Fatal("ExecuteAction() error = nil, want error")
}
})
}

View File

@ -0,0 +1,138 @@
package haclient
import (
"fmt"
"strconv"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
)
// optUint32 returns nil when key is absent. A present-but-unparsable value is
// treated as absent rather than failing the whole action — brightness/
// color-temp/transition are optional TurnOn/TurnOff fields, so a bad value
// there shouldn't block the rest of the request.
func optUint32(params map[string]any, key string) *uint32 {
v, ok := params[key]
if !ok {
return nil
}
n, err := toUint32(v)
if err != nil {
return nil
}
return &n
}
func reqUint32(params map[string]any, key string) (uint32, error) {
v, ok := params[key]
if !ok {
return 0, fmt.Errorf("missing required param %q", key)
}
return toUint32(v)
}
func reqFloat64(params map[string]any, key string) (float64, error) {
v, ok := params[key]
if !ok {
return 0, fmt.Errorf("missing required param %q", key)
}
return toFloat64(v)
}
func reqString(params map[string]any, key string) (string, error) {
v, ok := params[key]
if !ok {
return "", fmt.Errorf("missing required param %q", key)
}
s, ok := v.(string)
if !ok {
return "", fmt.Errorf("param %q = %v (%T), want string", key, v, v)
}
return s, nil
}
// optRGB mirrors optUint32's absent-or-unparsable-means-nil contract.
func optRGB(params map[string]any, key string) *hav1.RGBColor {
rgb, err := parseRGB(params, key)
if err != nil {
return nil
}
return rgb
}
func reqRGB(params map[string]any, key string) (*hav1.RGBColor, error) {
rgb, err := parseRGB(params, key)
if err != nil {
return nil, err
}
if rgb == nil {
return nil, fmt.Errorf("missing required param %q", key)
}
return rgb, nil
}
func parseRGB(params map[string]any, key string) (*hav1.RGBColor, error) {
v, ok := params[key]
if !ok {
return nil, nil
}
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("param %q = %v (%T), want map with r/g/b", key, v, v)
}
r, err := toUint32(m["r"])
if err != nil {
return nil, fmt.Errorf("param %q.r: %w", key, err)
}
g, err := toUint32(m["g"])
if err != nil {
return nil, fmt.Errorf("param %q.g: %w", key, err)
}
b, err := toUint32(m["b"])
if err != nil {
return nil, fmt.Errorf("param %q.b: %w", key, err)
}
return &hav1.RGBColor{R: r, G: g, B: b}, nil
}
// toUint32/toFloat64 primarily parse strings, since params values arriving
// from Alexa slots (via internal/directive) are always strings — even
// AMAZON.NUMBER slots decode to their literal spoken-number string, e.g.
// "80". The float64/int fallback exists only so tests (or a hypothetical
// future non-Alexa caller) can pass typed values directly; it isn't
// exercised on the real Alexa -> directive -> haclient path.
func toUint32(v any) (uint32, error) {
switch t := v.(type) {
case string:
n, err := strconv.ParseUint(t, 10, 32)
if err != nil {
return 0, fmt.Errorf("parse uint32 %q: %w", t, err)
}
return uint32(n), nil
case float64:
return uint32(t), nil
case int:
return uint32(t), nil
case uint32:
return t, nil
default:
return 0, fmt.Errorf("value %v (%T) is not a uint32-compatible type", v, v)
}
}
func toFloat64(v any) (float64, error) {
switch t := v.(type) {
case string:
n, err := strconv.ParseFloat(t, 64)
if err != nil {
return 0, fmt.Errorf("parse float64 %q: %w", t, err)
}
return n, nil
case float64:
return t, nil
case int:
return float64(t), nil
default:
return 0, fmt.Errorf("value %v (%T) is not a float64-compatible type", v, v)
}
}

View File

@ -0,0 +1,130 @@
package haclient
import (
"testing"
)
func TestOptUint32(t *testing.T) {
tests := []struct {
name string
params map[string]any
want *uint32
}{
{name: "absent key returns nil", params: map[string]any{}, want: nil},
{name: "valid string parses", params: map[string]any{"k": "80"}, want: ptrUint32(80)},
{name: "unparsable string returns nil, not error", params: map[string]any{"k": "not-a-number"}, want: nil},
{name: "float64 accepted", params: map[string]any{"k": float64(42)}, want: ptrUint32(42)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := optUint32(tt.params, "k")
if (got == nil) != (tt.want == nil) {
t.Fatalf("optUint32() = %v, want %v", got, tt.want)
}
if got != nil && *got != *tt.want {
t.Fatalf("optUint32() = %v, want %v", *got, *tt.want)
}
})
}
}
func TestReqUint32(t *testing.T) {
t.Run("missing key errors", func(t *testing.T) {
if _, err := reqUint32(map[string]any{}, "k"); err == nil {
t.Fatal("reqUint32() error = nil, want error")
}
})
t.Run("unparsable string errors", func(t *testing.T) {
if _, err := reqUint32(map[string]any{"k": "nope"}, "k"); err == nil {
t.Fatal("reqUint32() error = nil, want error")
}
})
t.Run("valid string parses", func(t *testing.T) {
got, err := reqUint32(map[string]any{"k": "255"}, "k")
if err != nil {
t.Fatalf("reqUint32() error = %v", err)
}
if got != 255 {
t.Fatalf("reqUint32() = %v, want 255", got)
}
})
}
func TestReqFloat64(t *testing.T) {
t.Run("missing key errors", func(t *testing.T) {
if _, err := reqFloat64(map[string]any{}, "k"); err == nil {
t.Fatal("reqFloat64() error = nil, want error")
}
})
t.Run("valid string parses", func(t *testing.T) {
got, err := reqFloat64(map[string]any{"k": "23.5"}, "k")
if err != nil {
t.Fatalf("reqFloat64() error = %v", err)
}
if got != 23.5 {
t.Fatalf("reqFloat64() = %v, want 23.5", got)
}
})
t.Run("negative and integral values parse", func(t *testing.T) {
got, err := reqFloat64(map[string]any{"k": "-5"}, "k")
if err != nil {
t.Fatalf("reqFloat64() error = %v", err)
}
if got != -5 {
t.Fatalf("reqFloat64() = %v, want -5", got)
}
})
}
func TestReqString(t *testing.T) {
t.Run("missing key errors", func(t *testing.T) {
if _, err := reqString(map[string]any{}, "k"); err == nil {
t.Fatal("reqString() error = nil, want error")
}
})
t.Run("non-string value errors", func(t *testing.T) {
if _, err := reqString(map[string]any{"k": 5}, "k"); err == nil {
t.Fatal("reqString() error = nil, want error")
}
})
t.Run("valid string", func(t *testing.T) {
got, err := reqString(map[string]any{"k": "cool"}, "k")
if err != nil {
t.Fatalf("reqString() error = %v", err)
}
if got != "cool" {
t.Fatalf("reqString() = %q, want %q", got, "cool")
}
})
}
func TestRGBHelpers(t *testing.T) {
t.Run("optRGB absent returns nil", func(t *testing.T) {
if got := optRGB(map[string]any{}, "k"); got != nil {
t.Fatalf("optRGB() = %v, want nil", got)
}
})
t.Run("optRGB malformed returns nil, not error", func(t *testing.T) {
if got := optRGB(map[string]any{"k": "not-a-map"}, "k"); got != nil {
t.Fatalf("optRGB() = %v, want nil", got)
}
})
t.Run("optRGB valid", func(t *testing.T) {
got := optRGB(map[string]any{"k": map[string]any{"r": "1", "g": "2", "b": "3"}}, "k")
if got == nil || got.R != 1 || got.G != 2 || got.B != 3 {
t.Fatalf("optRGB() = %v, want (1,2,3)", got)
}
})
t.Run("reqRGB missing key errors", func(t *testing.T) {
if _, err := reqRGB(map[string]any{}, "k"); err == nil {
t.Fatal("reqRGB() error = nil, want error")
}
})
t.Run("reqRGB missing component errors", func(t *testing.T) {
if _, err := reqRGB(map[string]any{"k": map[string]any{"r": "1", "g": "2"}}, "k"); err == nil {
t.Fatal("reqRGB() error = nil, want error")
}
})
}
func ptrUint32(v uint32) *uint32 { return &v }

View File

@ -0,0 +1,38 @@
package logger
import (
"context"
"fmt"
"log/slog"
"os"
)
type contextKey struct{}
// New constructs a root logger for the configured format and level.
func New(format, level string) *slog.Logger {
var parsed slog.Level
if err := parsed.UnmarshalText([]byte(level)); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "invalid log level %q, falling back to info\n", level)
parsed = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: parsed}
if format == "json" {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
// WithLogger attaches a logger to the provided context.
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, contextKey{}, l)
}
// FromContext retrieves a logger from context and falls back to slog.Default().
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil {
return l
}
return slog.Default()
}

View File

@ -0,0 +1,82 @@
package telemetry
import (
"context"
"errors"
"time"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/config"
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/logger"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
)
// Setup initialises OTel trace and metric providers for one service.
func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) {
if cfg.OTELEndpoint == "" {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
otel.SetMeterProvider(metricnoop.NewMeterProvider())
logger.FromContext(ctx).Debug("otel disabled — OTEL_ENDPOINT not set")
return func(context.Context) error { return nil }, nil
}
res := resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
semconv.ServiceVersionKey.String(version),
)
traceExp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(cfg.OTELEndpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.AlwaysSample()),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
metricExp, err := otlpmetricgrpc.New(ctx,
otlpmetricgrpc.WithEndpoint(cfg.OTELEndpoint),
otlpmetricgrpc.WithInsecure(),
)
if err != nil {
_ = tp.Shutdown(ctx)
return nil, err
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp,
sdkmetric.WithInterval(30*time.Second))),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)
return func(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var shutdownErr error
if err := tp.Shutdown(shutdownCtx); err != nil {
shutdownErr = errors.Join(shutdownErr, err)
}
if err := mp.Shutdown(shutdownCtx); err != nil {
shutdownErr = errors.Join(shutdownErr, err)
}
return shutdownErr
}, nil
}

423
alexa-bridge/plan.md Normal file
View File

@ -0,0 +1,423 @@
# alexa-bridge plan
## Correction on starting assumptions
`alexa-bridge/internal/alexa` and `alexa-bridge/internal/directive` do not exist anywhere in
this repo (checked working tree, `git log --all`, all branches, `go.work`). This plan therefore
covers the **whole service from scratch** — signature validation and request/response types,
directive routing, and the two pieces originally called out (entity resolution, generic
Controller) — but stays lightest on the parts that were assumed already scaffolded and goes
deep on entity resolution, the Controller/action mapping, and mTLS, per the original ask.
Everything below was grounded by reading `proto/ha/v1/*.proto`, `ha-gateway/go.mod`,
`ai-gateway/internal/adapters/secondary/hagateway/client.go`,
`discord-bot/internal/adapters/secondary/gateway/client.go`, `ai-gateway/internal/config/config.go`,
`ai-gateway/internal/core/domain/light_cache.go`, both services' `cmd/*/main.go`, and
`~/repo/homelab/manifests/home-services/{certs,ai-gateway,discord-bot}.yaml`.
## Package layout
Follows this repo's hexagonal convention (`core` has no adapter dependencies; ports are the
only boundary) while keeping the four package names already chosen (`alexa`, `directive`,
`entities`, `haclient`) rather than renaming them to `adapters/primary/...` etc. Mapping: `alexa`
and the HTTP entrypoint play the primary-adapter role, `haclient`/`entities` play the
secondary-adapter role, `directive` plays the app/orchestration role.
```text
alexa-bridge/
├── go.mod # module gitea.nik4nao.com/nik/home-services/alexa-bridge
├── .env.example
├── README.md
├── cmd/bridge/main.go # config, logger, telemetry, wiring, http.Server lifecycle
└── internal/
├── config/config.go # env loading (see mTLS section for TLS-specific fields)
├── logger/logger.go # mirror existing slog setup (New(format, level))
├── telemetry/telemetry.go # mirror existing OTEL setup (no-op when OTEL_ENDPOINT empty)
├── core/
│ ├── domain/
│ │ └── entity.go # Entity{EntityID, FriendlyName, Domain string}
│ └── ports/driven/
│ ├── controller.go # Controller interface (the one requested)
│ └── entities.go # EntityResolver interface
├── alexa/ # Alexa Custom Skill protocol edge
│ ├── types.go # Request/Response envelope, Session, Context, Intent, Slot
│ ├── signature.go # ValidateSignature: cert-chain fetch+cache, RSA-SHA1 verify, timestamp check
│ ├── handler.go # http.Handler: verify → decode → directive.Router.Dispatch → encode
│ └── *_test.go
├── directive/ # intent → action orchestration ("app" layer)
│ ├── router.go # Router{resolver driven.EntityResolver, controller driven.Controller}
│ ├── intents.go # per-intent handlers, slot → entityID/action/params
│ └── *_test.go
├── entities/ # EntityService client + cache + refresh (focus area 1)
│ ├── client.go # gRPC wrapper: FetchAll(ctx) ([]domain.Entity, error)
│ ├── resolver.go # in-memory Resolver implementing driven.EntityResolver
│ ├── refresher.go # startup fetch + ticker-based periodic refresh
│ └── *_test.go
└── haclient/ # ha-gateway RPC client, implements driven.Controller (focus area 2)
├── client.go # mTLS dial, holds Light/Switch/Climate service clients
├── controller.go # ExecuteAction: domain dispatch + action → RPC + param coercion
├── params.go # typed param extraction helpers
└── *_test.go
```
## 1. Entity resolution (`internal/entities`)
### Which RPC to use — a decision, not a given
The prompt assumed `EntityService` is the right client. Checked both options:
- `EntityService.ListStates` (generic, any domain) returns `EntityState{entity_id, state,
attributes map<string,string>, ...}` — **no dedicated `friendly_name` field**. Confirmed by
reading `ha-gateway/internal/app/{light,switch,climate}.go`: each extracts
`s.Attributes["friendly_name"].(string)` itself, i.e. `EntityService` passes through HA's raw
attributes map and friendly_name only exists as a string key inside it.
- `LightService.ListLights` / `SwitchService.ListSwitches` / `ClimateService.ListClimates` each
return a typed entity message with a dedicated `friendly_name` field, but that means three
client stubs instead of one, and only cover those three domains structurally.
**Recommendation: use `EntityService.ListStates`, called once per domain** (`domain: "light"`,
`"switch"`, `"climate"`), reading `attributes["friendly_name"]` with a fallback to the entity_id
suffix when absent/empty. This matches what was originally asked for (a single `EntityService`
client), and loses nothing functionally — resolution only needs entity_id + friendly_name + a
way to know which domain a name belongs to (see Controller section — domain dispatch there
reads it straight off the entity_id prefix, e.g. `light.living_room``light`, so no extra
typed fields like `supported_color_modes` are needed here). `ListStatesRequest.domain` is a
single string, not repeated, hence three calls (issued concurrently) rather than one unfiltered
call — this also avoids pulling every sensor/automation/etc. in the HA install into the lookup.
```go
// internal/entities/client.go
type Client struct {
entityClient hav1.EntityServiceClient
domains []string // {"light", "switch", "climate"} — extend if remote/other domains get in scope, see Open Questions
}
func (c *Client) FetchAll(ctx context.Context) ([]domain.Entity, error) {
// fan out one ListStates(domain: d) call per configured domain, concurrently;
// for each EntityState, entity := domain.Entity{
// EntityID: s.GetEntityId(),
// FriendlyName: firstNonEmpty(s.GetAttributes()["friendly_name"], s.GetEntityId()),
// Domain: d,
// }
}
```
### Resolver + refresh
- `Resolver` holds a `map[string]domain.Entity` keyed by normalized (lowercased, trimmed)
friendly name, swapped atomically on each refresh (`atomic.Pointer[map[string]domain.Entity]`
or `sync.RWMutex`) so an in-flight Alexa request never observes a half-built map.
- **Startup**: `FetchAll` is called once, synchronously, before the HTTP server starts accepting
traffic — fail loudly (`os.Exit(1)`) on error, mirroring how `haClient`/`aiClient` setup
failures are handled in `ai-gateway/cmd/gateway/main.go` and `discord-bot/cmd/bot/main.go`.
Without an initial entity list nothing can resolve, so there's no useful degraded mode.
- **Periodic refresh**: a background goroutine on a `time.Ticker` (`ENTITY_REFRESH_INTERVAL`,
suggest default `5m` — HA's entity/name set changes rarely) calls `FetchAll` again and
replaces the resolver's map. Unlike startup, a *periodic* refresh failure only logs and keeps
serving the last-known-good map — going down because HA was briefly unreachable would be worse
than serving slightly stale entity names.
- This is a new caching shape, not reused code: `ai-gateway`'s `LightCache`
(`ai-gateway/internal/core/domain/light_cache.go`) is lazy/pull-based (refreshes on `Get()` if
stale, no background goroutine) since ai-gateway can tolerate a slow first request. The
explicit "on startup plus periodic refresh" ask is closer to a push-based background loop, so
don't reuse `LightCache` as-is.
### Open question — name matching is exact-match only in this design
`Resolver.Resolve(friendlyName string)` does an exact match on the normalized string. Alexa's
ASR won't always produce a string that matches HA's `friendly_name` verbatim (e.g. user says
"living room light", HA has it named "Living Room Lamp"). Options, not resolved here:
1. **Ship exact-match for v1** (simplest, what this plan assumes) and accept some resolution
misses as a known limitation.
2. Add fuzzy matching (substring / Levenshtein) client-side in the resolver.
3. Use Alexa's own slot entity resolution — define the `Device` slot against a custom slot type
and keep it in sync via the `SetDynamicEntities` directive, so Alexa's own NLU does the fuzzy
matching against the same entity list before the request ever reaches alexa-bridge. Best UX,
most infra (interaction-model + skill-console work, not just backend code).
Recommend (1) now, flag (3) as the real long-term fix, and revisit once real usage shows how
often exact-match actually misses.
## 2. Controller (`internal/haclient`, `internal/core/ports/driven`)
```go
// internal/core/ports/driven/controller.go
package driven
type Controller interface {
ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error
}
```
`haclient.Client` implements it. Dispatch is two-level: entity domain (parsed off `entityID`'s
prefix up to the first `.`, e.g. `light.living_room``light` — no separate lookup needed, this
is exactly what HA/`ha-gateway` entity IDs already encode) selects which typed gRPC client to
use, then `action` selects the RPC within that domain.
### Action → RPC table (exact types, from `proto/ha/v1/*.proto`)
| Domain | action | RPC | Request construction |
|---|---|---|---|
| `light` | `turn_on` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: optUint32(params,"brightness_pct"), ColorTempKelvin: optUint32(params,"color_temp_kelvin"), RgbColor: optRGB(params,"rgb_color"), Transition: optUint32(params,"transition")}` |
| `light` | `turn_off` | `LightServiceClient.TurnOff` | `&hav1.TurnOffRequest{EntityId: entityID, Transition: optUint32(params,"transition")}` |
| `light` | `toggle` | `LightServiceClient.Toggle` | `&hav1.ToggleRequest{EntityId: entityID}` |
| `light` | `set_brightness` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: reqUint32(params,"brightness_pct")}` — reuses `TurnOn`; there's no separate brightness RPC, but `TurnOnRequest.brightness_pct` is `optional`, so a request with only that field set both sets brightness and turns the light on, matching HA's own `light.turn_on` semantics. (This resolves the exact gap the original prompt guessed might be missing — it isn't.) |
| `light` | `set_color_temp` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, ColorTempKelvin: reqUint32(params,"color_temp_kelvin")}` |
| `light` | `set_color` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, RgbColor: reqRGB(params,"rgb_color")}` |
| `switch` | `turn_on` | `SwitchServiceClient.TurnOn` | `&hav1.SwitchRequest{EntityId: entityID}` |
| `switch` | `turn_off` | `SwitchServiceClient.TurnOff` | `&hav1.SwitchRequest{EntityId: entityID}` |
| `switch` | `toggle` | `SwitchServiceClient.Toggle` | `&hav1.SwitchRequest{EntityId: entityID}` |
| `climate` | `turn_on` | `ClimateServiceClient.TurnOn` | `&hav1.ClimateRequest{EntityId: entityID}` |
| `climate` | `turn_off` | `ClimateServiceClient.TurnOff` | `&hav1.ClimateRequest{EntityId: entityID}` |
| `climate` | `set_hvac_mode` | `ClimateServiceClient.SetHVACMode` | `&hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: reqString(params,"hvac_mode")}` |
| `climate` | `increase_temperature` | `ClimateServiceClient.IncreaseTemperature` | `&hav1.ClimateRequest{EntityId: entityID}` |
| `climate` | `decrease_temperature` | `ClimateServiceClient.DecreaseTemperature` | `&hav1.ClimateRequest{EntityId: entityID}` |
| `climate` | `set_temperature` | `ClimateServiceClient.SetTemperature` | `&hav1.SetTemperatureRequest{EntityId: entityID, TargetTemperature: reqFloat64(params,"target_temperature")}`**implemented** (see "Decisions on open questions" #1); single-setpoint only, no `target_temp_high`/`target_temp_low` |
All response messages (`LightResponse`, `SwitchResponse`, `ClimateResponse`) just wrap
`EntityState`; `ExecuteAction`'s `error`-only signature means the returned state is simply
discarded (dropped, not needed by the interface as specified).
### Param coercion (`internal/haclient/params.go`)
`params map[string]any` values will, in practice, almost always be `string` — they come from
Alexa slot values (`req.Body.Intent.Slots["Brightness"].Value`), which are always strings even
for `AMAZON.NUMBER` slots (e.g. `"80"`). Coercion helpers should therefore parse from `string`
primarily, with a `float64`/`int` fallback only so unit tests (or a hypothetical future non-Alexa
caller) can pass typed values directly — don't over-build for JSON-number decoding that won't
actually occur on the real Alexa → directive → haclient path:
```go
func optUint32(params map[string]any, key string) *uint32 // nil if key absent; parse error → nil + logged, not fatal
func reqUint32(params map[string]any, key string) (uint32, error)
func reqFloat64(params map[string]any, key string) (float64, error) // backs set_temperature, see Decisions #1
func reqString(params map[string]any, key string) (string, error)
func reqRGB(params map[string]any, key string) (*hav1.RGBColor, error) // expects map[string]any{"r":..,"g":..,"b":..}
```
`params` is built in `internal/directive/intents.go`, one handler per Alexa intent, e.g.:
```go
func (r *Router) handleSetBrightness(ctx context.Context, req alexa.Request) (alexa.Response, error) {
entity, ok := r.resolver.Resolve(req.Body.Intent.Slots["Device"].Value)
if !ok {
return notFoundResponse(req.Body.Intent.Slots["Device"].Value), nil
}
err := r.controller.ExecuteAction(ctx, entity.EntityID, "set_brightness", map[string]any{
"brightness_pct": req.Body.Intent.Slots["Brightness"].Value,
})
...
}
```
## 3. mTLS (`internal/haclient/client.go`, `internal/config`)
Mirrors `ai-gateway/internal/adapters/secondary/hagateway/client.go`'s `New` +
`loadTransportCredentials` **verbatim** (client cert from `tls.crt`/`tls.key`, root CA from
`ca.crt`, `ServerName` set to ha-gateway's cert CN, `MinVersion: tls.VersionTLS13`, insecure
fallback via `credentials/insecure` when `tlsDir == ""`):
```go
func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) {
transportCreds := insecure.NewCredentials()
if tlsDir != "" {
creds, err := loadTransportCredentials(tlsDir, serverName) // identical to ai-gateway's version
...
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
...
return &Client{
conn: conn,
lightClient: hav1.NewLightServiceClient(conn),
switchClient: hav1.NewSwitchServiceClient(conn),
climateClient: hav1.NewClimateServiceClient(conn),
entityClient: hav1.NewEntityServiceClient(conn), // for internal/entities
}, nil
}
```
### Config — one deliberate deviation
```go
type Config struct {
...
HAGatewayAddr string // default "ha-gateway.home-services.svc.cluster.local:50051"
HAGatewayServerName string // default "ha-gateway.home-services.svc.cluster.local"
TLSDir string // default "/tls" — see note below
}
```
`ai-gateway` and `discord-bot` both default `TLS_DIR` to **empty** (mTLS opt-in, enabled only by
an explicit env var in the k8s manifest). Since `alexa-bridge` is this repo's one
internet-facing service, default `TLS_DIR` to `/tls` instead — mTLS to ha-gateway should be
on unless someone deliberately unsets it for local dev, not on only if someone remembers to set
it. Still fully overridable (empty string keeps the existing insecure-fallback behavior for
local plaintext dev, matching `TLSDir string // default ""` + `validateTLSDir` in the other two
services' `config.go`, copied as-is here).
### homelab-repo Certificate (separate repo, separate change — not made here)
Modeled on `discord-bot-tls` (`~/repo/homelab/manifests/home-services/certs.yaml`) since, like
discord-bot, alexa-bridge only *dials* ha-gateway over mTLS — it doesn't itself serve mTLS gRPC,
so no `server auth` usage or `dnsNames` needed:
```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: alexa-bridge-tls
namespace: home-services
spec:
secretName: alexa-bridge-tls
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
commonName: alexa-bridge
usages:
- client auth
- digital signature
- key encipherment
```
Note on naming: the prompt suggested `alexa-bridge-client-tls`; existing certs all follow
`<service>-tls` regardless of client/server/dual usage (`ha-gateway-tls` is server-only,
`discord-bot-tls` is client-only, `ai-gateway-tls` is both — none disambiguate in the name).
Defaulting to `alexa-bridge-tls` for consistency; flagging in case there was a specific reason
for wanting the longer name.
Mount: a `tls` volume from secret `alexa-bridge-tls` at `/tls`, `readOnly: true` — same shape as
`discord-bot.yaml`'s volume block.
## Open questions
**Status: all four resolved — see "Decisions on open questions" below, which is now the
source of truth for what actually got built.** Kept as originally written here for the
reasoning trail; where the two sections disagree, Decisions wins.
1. ~~**`climate.set_temperature` has no backing RPC.**~~ **Resolved — implemented, see
Decisions #1.** `ClimateService` only exposed
`IncreaseTemperature`/`DecreaseTemperature` (relative step) and `SetHVACMode` — there is no
"set target temperature to X°" RPC, even though that's a very natural Alexa ask ("set the
thermostat to 72"). This is a real proto/`ha-gateway` gap, not a client-side workaround
opportunity — the underlying `ha-gateway` `internal/app/climate.go` only computes `next :=
*TargetTemperature + direction*step`, it has no absolute-set path either. Options: (a) add a
`SetTemperature(entity_id, target)` RPC to `ClimateService` in `ha-gateway` first (out of
scope for alexa-bridge alone), (b) approximate it client-side in alexa-bridge by calling
`IncreaseTemperature`/`DecreaseTemperature` in a loop toward the target (fragile — depends on
knowing the current temperature and step size, race-prone), or (c) omit `set_temperature`
from alexa-bridge's v1 intent set and only support hvac-mode/step-based climate control.
Recommend (a), scoped as a small separate `ha-gateway` change, with (c) as the fallback if
that's not wanted right now.
2. **Resolved — drafted (not applied), see Decisions #2.** alexa-bridge's own public HTTPS
listener is a separate TLS concern from the mTLS covered above. Alexa's servers call *into*
alexa-bridge over the internet and require a publicly
trusted certificate — the `internal-ca-issuer` used for `alexa-bridge-tls` above won't satisfy
that (Alexa doesn't trust this homelab's internal CA). The homelab repo already has the
pattern for this: `manifests/gitea/gitea-public-ingress.yaml` pairs a `letsencrypt-prod`
`ClusterIssuer` `Certificate` with a Traefik `IngressRoute` for a public hostname
(`gitea.nik4nao.com`). alexa-bridge would need the equivalent — its own `Certificate` +
`IngressRoute` (e.g. `alexa-bridge.nik4nao.com`) — as a homelab-repo change. Out of scope for
this plan (which only covers the ha-gateway mTLS leg, as asked), but called out since
alexa-bridge can't function without it and it's easy to lose track of as a "separate repo"
task.
3. **Resolved — confirmed out of scope, see Decisions #3.** Remote/SwitchBot entities are
excluded from entity resolution and the Controller's domain table. `RemoteService.SendCommand`
is keyed by a SwitchBot `device_id`, not an HA
`entity_id`, and there's no evidence these show up in `EntityService.ListStates` the same way
light/switch/climate entities do (they're relayed from SwitchBot Cloud, not HA's own entity
registry). Treating IR/remote control as out of scope for v1 Alexa support; would need
separate design if wanted later.
4. **Resolved — implemented, see Decisions #4.** Skill ID verification: Alexa best practice
(and Amazon's certification checklist) also expects verifying
`request.context.System.application.applicationId` against a configured
skill ID, on top of signature validation, to reject requests replayed from a different skill
using the same endpoint. Not explicitly asked for, but cheap to add in `internal/alexa`
flagging so it's a conscious inclusion/exclusion rather than an oversight.
## Implementation steps
1. **Scaffold the module**: `alexa-bridge/go.mod` (module
`gitea.nik4nao.com/nik/home-services/alexa-bridge`, matching the other four's `go 1.26` +
`replace .../gen => ../gen`), add `./alexa-bridge` to root `go.work`. Add matching `COPY`
lines (manifest-only + full-source, per `CLAUDE.md`'s CI note) to the other four Dockerfiles,
and an `alexa-bridge` entry to `.gitea/workflows/ci.yaml`'s `changes` job, `test` job's
vet/test loop, and a new `build-alexa-bridge` job.
2. **`internal/core/domain` + `internal/core/ports/driven`**: `Entity`, `Controller`,
`EntityResolver` — no dependencies, straightforward.
3. **`internal/haclient`**: mTLS client + `Controller` implementation (section 2/3 above) — can
be built and unit-tested (hand-written fakes for the generated gRPC clients, per this repo's
no-testify convention) independently of the HTTP/Alexa side.
4. **`internal/entities`**: `EntityService` client + resolver + refresher (section 1 above),
same independence/testability as step 3.
5. **`internal/alexa`**: request/response types, signature validation (cert-chain fetch+cache,
RSA-SHA1 verify, timestamp tolerance ~150s, optionally skill-ID check per open question 4),
HTTP handler wiring the above together.
6. **`internal/directive`**: intent handlers per the action table in section 2, wired to
`driven.EntityResolver` and `driven.Controller`.
7. **`cmd/bridge/main.go` + `internal/config`**: env loading (including the `TLS_DIR` default
deviation from section 3), logger/telemetry setup mirroring the other services, startup
sequencing (blocking entity fetch before serving, per section 1), graceful shutdown.
8. **`Dockerfile` + `README.md`**: mirror `discord-bot`'s two-stage build shape (this one needs
no `ffmpeg`/GPU equivalent, so a plain `distroless`/`scratch` final stage should work, unlike
`discord-bot`/`tts-gateway`).
9. **homelab repo** (separate change, needs explicit confirmation before touching that repo):
`alexa-bridge-tls` `Certificate` (section 3), `alexa-bridge.yaml` Deployment/Service, and the
public ingress + `letsencrypt-prod` `Certificate` from open question 2.
## Decisions on open questions
Resolved before implementation started; this section is the source of truth for what actually
got built, superseding the "Open questions" section above where they conflict.
1. **`climate.set_temperature` → option (a), a real RPC.** Adding
`ClimateService.SetTemperature(SetTemperatureRequest) returns (ClimateResponse)` to
`proto/ha/v1/climate.proto`, reusing `ClimateResponse` like every other RPC on this service
rather than inventing a new response type. `ha-gateway/internal/app/climate.go` gets a
`SetTemperature` method that calls Home Assistant's `climate.set_temperature` service with the
caller-supplied absolute target directly — `IncreaseTemperature`/`DecreaseTemperature` keep
their existing read-current-state-then-step logic unchanged; they now sit alongside
`SetTemperature` rather than being replaced by it. Checked the live HA instance
(`mcp__home-assistant__GetLiveContext`): exactly one climate entity ("Air Conditioner"), with
a single `temperature` attribute and no `target_temp_high`/`target_temp_low` — range-mode
(`ClimateEntityFeature.TARGET_TEMPERATURE_RANGE`) is not in use anywhere in this HA instance,
and `ha-gateway`'s existing `haStateToClimate` mapping doesn't parse those attributes either.
**Scope: single-setpoint only. Range-mode climate entities are explicitly out of scope**
not half-built, not silently broken if one ever appears (it would just report no target
temperature, same as any other unmapped attribute today), but genuinely unhandled. Revisit if
a range-mode entity is ever added to this HA instance.
alexa-bridge's action table gains `climate`/`set_temperature`
`ClimateServiceClient.SetTemperature`, and `internal/haclient/params.go` gains a `reqFloat64`
helper alongside `reqUint32`/`reqString`/`reqRGB`. `internal/directive/intents.go` gains a
`SetTemperatureIntent` handler reading a `Temperature` (`AMAZON.NUMBER`) slot, same shape as
`SetBrightnessIntent`.
2. **Public HTTPS ingress for alexa-bridge — in scope.** Drafted (not applied) an `IngressRoute`
+ cert-manager `Certificate` for `alexa-bridge.nik4nao.com` against `letsencrypt-prod`,
mirroring `manifests/gitea/gitea-public-ingress.yaml`'s shape exactly, as a separate homelab
manifest file from `alexa-bridge-tls` (the `internal-ca-issuer` client cert for the mTLS leg to
ha-gateway) — different issuer, different trust domain, different purpose; not to be confused
or merged in naming.
3. **Remote/SwitchBot entities — out of scope for v1.** `RemoteService` is keyed by a SwitchBot
`device_id`, not an HA `entity_id`, and doesn't appear in `EntityService.ListStates`, so it
doesn't fit this service's entity_id-keyed `Controller`/`EntityResolver` design. Not
implemented; would need its own resolution + dispatch path if ever added.
4. **Skill ID verification — included.** `internal/alexa`'s request validation checks the
decoded envelope's application ID against a configured `ALEXA_SKILL_ID` env var, rejecting on
mismatch alongside the signature and timestamp checks. Alexa populates
`context.System.application.applicationId` on essentially all real requests (Custom Skill
requests always carry a `context` object); `session.application.applicationId` is checked too
as a fallback for the (now-legacy, but still-valid-per-spec) case of a request that carries a
`session` object without a top-level `context``internal/alexa/types.go` decodes both and
the check prefers `context` when present.
5. **Entity name matching — exact match only for v1** (already noted inline in the Entity
resolution section above; recorded here for completeness since it's a resolved question, not
a live one). Normalized as lowercased + trimmed, no fuzzy/substring matching. Alexa's
`SetDynamicEntities` directive (option 3 in that section) is **not** wired up — both left as
explicitly deferred future work rather than partially implemented.

View File

@ -9,6 +9,7 @@ COPY gen/go.mod gen/go.sum ./gen/
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
WORKDIR /workspace/discord-bot WORKDIR /workspace/discord-bot
@ -19,6 +20,7 @@ COPY gen/ ./gen/
COPY ai-gateway/ ./ai-gateway/ COPY ai-gateway/ ./ai-gateway/
COPY ha-gateway/ ./ha-gateway/ COPY ha-gateway/ ./ha-gateway/
COPY tts-gateway/ ./tts-gateway/ COPY tts-gateway/ ./tts-gateway/
COPY alexa-bridge/ ./alexa-bridge/
COPY discord-bot/ ./discord-bot/ COPY discord-bot/ ./discord-bot/
WORKDIR /workspace/discord-bot WORKDIR /workspace/discord-bot

View File

@ -70,6 +70,19 @@ AI-assisted commands.
AI queries are sent to `ai-gateway`. The active model is stored in process AI queries are sent to `ai-gateway`. The active model is stored in process
memory, so it resets when the bot restarts. memory, so it resets when the bot restarts.
### Speak
```text
/speak speaker:<name> text:<text>
```
Synthesizes `text` via `tts-gateway` (92-speaker autocomplete) and plays the
result in the invoking user's current voice channel. `tts-gateway` returns
AAC; `discord-bot` transcodes it to Opus locally (`ffmpeg` +
`github.com/jonas747/dca`) before streaming, since Discord's voice transport
requires Opus. **Currently non-functional in practice** - see "Limitations"
below; the synthesis half works, voice playback doesn't yet.
## Configuration ## Configuration
Environment variables: Environment variables:
@ -80,7 +93,8 @@ Environment variables:
| `GUILD_ID` | empty | Guild-scoped command registration target; empty registers global commands | | `GUILD_ID` | empty | Guild-scoped command registration target; empty registers global commands |
| `HA_GATEWAY_ADDR` | required | gRPC address for `ha-gateway` | | `HA_GATEWAY_ADDR` | required | gRPC address for `ha-gateway` |
| `AI_GATEWAY_ADDR` | `ai-gateway.home-services.svc.cluster.local:50052` | gRPC address for `ai-gateway` | | `AI_GATEWAY_ADDR` | `ai-gateway.home-services.svc.cluster.local:50052` | gRPC address for `ai-gateway` |
| `TLS_DIR` | empty | Enables mTLS for gateway clients when set | | `TTS_GATEWAY_ADDR` | `tts-gateway.home-services.svc.cluster.local:50053` | gRPC address for `tts-gateway` |
| `TLS_DIR` | empty | Enables mTLS for `ha-gateway`/`ai-gateway` clients when set - **not currently used for the `tts-gateway` client**, since `tts-gateway`'s own mTLS is still disabled (see its README); that's hardcoded to plaintext until both sides are ready together |
| `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` |
@ -129,9 +143,10 @@ docker build -f discord-bot/Dockerfile --build-arg VERSION=$(git rev-parse --sho
```text ```text
cmd/bot/ # process entrypoint and wiring cmd/bot/ # process entrypoint and wiring
internal/adapters/primary/discord/ # slash command registration and handlers internal/adapters/primary/discord/ # slash command registration, handlers, voice playback (voice.go)
internal/adapters/secondary/gateway/ # ha-gateway gRPC client internal/adapters/secondary/gateway/ # ha-gateway gRPC client
internal/adapters/secondary/aigateway/ # ai-gateway gRPC client internal/adapters/secondary/aigateway/ # ai-gateway gRPC client
internal/adapters/secondary/ttsgateway/ # tts-gateway gRPC client
internal/app/ # command orchestration and formatting internal/app/ # command orchestration and formatting
internal/config/ # environment loading internal/config/ # environment loading
internal/core/ports/driven/ # app-facing gateway interfaces internal/core/ports/driven/ # app-facing gateway interfaces
@ -147,3 +162,15 @@ internal/telemetry/ # OpenTelemetry setup
- The bot relies on Discord auth plus internal gateway/network controls; it - The bot relies on Discord auth plus internal gateway/network controls; it
does not implement per-user authorization. does not implement per-user authorization.
- List output is optimized for monospace Discord messages, not rich embeds. - List output is optimized for monospace Discord messages, not rich embeds.
- **`/speak` cannot currently join a voice channel at all.** Discord requires
its DAVE end-to-end-encryption protocol for every voice connection as of
March 1, 2026 (non-DAVE clients get disconnected with close code `4017`,
"E2EE/DAVE protocol required"). `github.com/bwmarrin/discordgo` v0.29.0 -
the latest tagged release, and what this module depends on - has no DAVE
support (confirmed by inspecting its source; an upstream PR adding it was
still open, unmerged, as of March 2026). This blocks any discordgo-based
voice bot right now, not just this one. The synthesis half of `/speak`
(calling `tts-gateway`, getting audio back) works correctly; only the
final "join the channel and stream" step fails. Revisit once discordgo
ships DAVE support - deliberately not worked around with an unofficial
fork (e.g. `cartridge-gg/discordgo`'s cgo/libdave binding) for now.

View File

@ -161,6 +161,61 @@ func (x *SetHVACModeRequest) GetHvacMode() string {
return "" return ""
} }
// SetTemperature sets an absolute target temperature. Single-setpoint only —
// there is no target_temp_high/target_temp_low pair here, since no climate
// entity in this deployment uses range mode (see alexa-bridge/plan.md).
type SetTemperatureRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"`
TargetTemperature float64 `protobuf:"fixed64,2,opt,name=target_temperature,json=targetTemperature,proto3" json:"target_temperature,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *SetTemperatureRequest) Reset() {
*x = SetTemperatureRequest{}
mi := &file_ha_v1_climate_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *SetTemperatureRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*SetTemperatureRequest) ProtoMessage() {}
func (x *SetTemperatureRequest) 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 SetTemperatureRequest.ProtoReflect.Descriptor instead.
func (*SetTemperatureRequest) Descriptor() ([]byte, []int) {
return file_ha_v1_climate_proto_rawDescGZIP(), []int{3}
}
func (x *SetTemperatureRequest) GetEntityId() string {
if x != nil {
return x.EntityId
}
return ""
}
func (x *SetTemperatureRequest) GetTargetTemperature() float64 {
if x != nil {
return x.TargetTemperature
}
return 0
}
type ClimateEntity struct { type ClimateEntity struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"` EntityId string `protobuf:"bytes,1,opt,name=entity_id,json=entityId,proto3" json:"entity_id,omitempty"`
@ -180,7 +235,7 @@ type ClimateEntity struct {
func (x *ClimateEntity) Reset() { func (x *ClimateEntity) Reset() {
*x = ClimateEntity{} *x = ClimateEntity{}
mi := &file_ha_v1_climate_proto_msgTypes[3] mi := &file_ha_v1_climate_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -192,7 +247,7 @@ func (x *ClimateEntity) String() string {
func (*ClimateEntity) ProtoMessage() {} func (*ClimateEntity) ProtoMessage() {}
func (x *ClimateEntity) ProtoReflect() protoreflect.Message { func (x *ClimateEntity) ProtoReflect() protoreflect.Message {
mi := &file_ha_v1_climate_proto_msgTypes[3] mi := &file_ha_v1_climate_proto_msgTypes[4]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -205,7 +260,7 @@ func (x *ClimateEntity) ProtoReflect() protoreflect.Message {
// Deprecated: Use ClimateEntity.ProtoReflect.Descriptor instead. // Deprecated: Use ClimateEntity.ProtoReflect.Descriptor instead.
func (*ClimateEntity) Descriptor() ([]byte, []int) { func (*ClimateEntity) Descriptor() ([]byte, []int) {
return file_ha_v1_climate_proto_rawDescGZIP(), []int{3} return file_ha_v1_climate_proto_rawDescGZIP(), []int{4}
} }
func (x *ClimateEntity) GetEntityId() string { func (x *ClimateEntity) GetEntityId() string {
@ -293,7 +348,7 @@ type ListClimatesRequest struct {
func (x *ListClimatesRequest) Reset() { func (x *ListClimatesRequest) Reset() {
*x = ListClimatesRequest{} *x = ListClimatesRequest{}
mi := &file_ha_v1_climate_proto_msgTypes[4] mi := &file_ha_v1_climate_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -305,7 +360,7 @@ func (x *ListClimatesRequest) String() string {
func (*ListClimatesRequest) ProtoMessage() {} func (*ListClimatesRequest) ProtoMessage() {}
func (x *ListClimatesRequest) ProtoReflect() protoreflect.Message { func (x *ListClimatesRequest) ProtoReflect() protoreflect.Message {
mi := &file_ha_v1_climate_proto_msgTypes[4] mi := &file_ha_v1_climate_proto_msgTypes[5]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -318,7 +373,7 @@ func (x *ListClimatesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListClimatesRequest.ProtoReflect.Descriptor instead. // Deprecated: Use ListClimatesRequest.ProtoReflect.Descriptor instead.
func (*ListClimatesRequest) Descriptor() ([]byte, []int) { func (*ListClimatesRequest) Descriptor() ([]byte, []int) {
return file_ha_v1_climate_proto_rawDescGZIP(), []int{4} return file_ha_v1_climate_proto_rawDescGZIP(), []int{5}
} }
type ListClimatesResponse struct { type ListClimatesResponse struct {
@ -330,7 +385,7 @@ type ListClimatesResponse struct {
func (x *ListClimatesResponse) Reset() { func (x *ListClimatesResponse) Reset() {
*x = ListClimatesResponse{} *x = ListClimatesResponse{}
mi := &file_ha_v1_climate_proto_msgTypes[5] mi := &file_ha_v1_climate_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -342,7 +397,7 @@ func (x *ListClimatesResponse) String() string {
func (*ListClimatesResponse) ProtoMessage() {} func (*ListClimatesResponse) ProtoMessage() {}
func (x *ListClimatesResponse) ProtoReflect() protoreflect.Message { func (x *ListClimatesResponse) ProtoReflect() protoreflect.Message {
mi := &file_ha_v1_climate_proto_msgTypes[5] mi := &file_ha_v1_climate_proto_msgTypes[6]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -355,7 +410,7 @@ func (x *ListClimatesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListClimatesResponse.ProtoReflect.Descriptor instead. // Deprecated: Use ListClimatesResponse.ProtoReflect.Descriptor instead.
func (*ListClimatesResponse) Descriptor() ([]byte, []int) { func (*ListClimatesResponse) Descriptor() ([]byte, []int) {
return file_ha_v1_climate_proto_rawDescGZIP(), []int{5} return file_ha_v1_climate_proto_rawDescGZIP(), []int{6}
} }
func (x *ListClimatesResponse) GetClimates() []*ClimateEntity { func (x *ListClimatesResponse) GetClimates() []*ClimateEntity {
@ -376,7 +431,10 @@ const file_ha_v1_climate_proto_rawDesc = "" +
"\x05state\x18\x01 \x01(\v2\x12.ha.v1.EntityStateR\x05state\"N\n" + "\x05state\x18\x01 \x01(\v2\x12.ha.v1.EntityStateR\x05state\"N\n" +
"\x12SetHVACModeRequest\x12\x1b\n" + "\x12SetHVACModeRequest\x12\x1b\n" +
"\tentity_id\x18\x01 \x01(\tR\bentityId\x12\x1b\n" + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12\x1b\n" +
"\thvac_mode\x18\x02 \x01(\tR\bhvacMode\"\xb7\x03\n" + "\thvac_mode\x18\x02 \x01(\tR\bhvacMode\"c\n" +
"\x15SetTemperatureRequest\x12\x1b\n" +
"\tentity_id\x18\x01 \x01(\tR\bentityId\x12-\n" +
"\x12target_temperature\x18\x02 \x01(\x01R\x11targetTemperature\"\xb7\x03\n" +
"\rClimateEntity\x12\x1b\n" + "\rClimateEntity\x12\x1b\n" +
"\tentity_id\x18\x01 \x01(\tR\bentityId\x12#\n" + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12#\n" +
"\rfriendly_name\x18\x02 \x01(\tR\ffriendlyName\x12\x14\n" + "\rfriendly_name\x18\x02 \x01(\tR\ffriendlyName\x12\x14\n" +
@ -395,12 +453,13 @@ const file_ha_v1_climate_proto_rawDesc = "" +
"\x13_target_temperature\"\x15\n" + "\x13_target_temperature\"\x15\n" +
"\x13ListClimatesRequest\"H\n" + "\x13ListClimatesRequest\"H\n" +
"\x14ListClimatesResponse\x120\n" + "\x14ListClimatesResponse\x120\n" +
"\bclimates\x18\x01 \x03(\v2\x14.ha.v1.ClimateEntityR\bclimates2\x9a\x03\n" + "\bclimates\x18\x01 \x03(\v2\x14.ha.v1.ClimateEntityR\bclimates2\xe2\x03\n" +
"\x0eClimateService\x127\n" + "\x0eClimateService\x127\n" +
"\x06TurnOn\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x128\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" + "\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" + "\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" + "\x13DecreaseTemperature\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12F\n" +
"\x0eSetTemperature\x12\x1c.ha.v1.SetTemperatureRequest\x1a\x16.ha.v1.ClimateResponse\x12@\n" +
"\vSetHVACMode\x12\x19.ha.v1.SetHVACModeRequest\x1a\x16.ha.v1.ClimateResponse\x12G\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" "\fListClimates\x12\x1a.ha.v1.ListClimatesRequest\x1a\x1b.ha.v1.ListClimatesResponseB4Z2gitea.nik4nao.com/nik/home-services/gen/ha/v1;hav1b\x06proto3"
@ -416,33 +475,36 @@ func file_ha_v1_climate_proto_rawDescGZIP() []byte {
return file_ha_v1_climate_proto_rawDescData return file_ha_v1_climate_proto_rawDescData
} }
var file_ha_v1_climate_proto_msgTypes = make([]protoimpl.MessageInfo, 6) var file_ha_v1_climate_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
var file_ha_v1_climate_proto_goTypes = []any{ var file_ha_v1_climate_proto_goTypes = []any{
(*ClimateRequest)(nil), // 0: ha.v1.ClimateRequest (*ClimateRequest)(nil), // 0: ha.v1.ClimateRequest
(*ClimateResponse)(nil), // 1: ha.v1.ClimateResponse (*ClimateResponse)(nil), // 1: ha.v1.ClimateResponse
(*SetHVACModeRequest)(nil), // 2: ha.v1.SetHVACModeRequest (*SetHVACModeRequest)(nil), // 2: ha.v1.SetHVACModeRequest
(*ClimateEntity)(nil), // 3: ha.v1.ClimateEntity (*SetTemperatureRequest)(nil), // 3: ha.v1.SetTemperatureRequest
(*ListClimatesRequest)(nil), // 4: ha.v1.ListClimatesRequest (*ClimateEntity)(nil), // 4: ha.v1.ClimateEntity
(*ListClimatesResponse)(nil), // 5: ha.v1.ListClimatesResponse (*ListClimatesRequest)(nil), // 5: ha.v1.ListClimatesRequest
(*EntityState)(nil), // 6: ha.v1.EntityState (*ListClimatesResponse)(nil), // 6: ha.v1.ListClimatesResponse
(*EntityState)(nil), // 7: ha.v1.EntityState
} }
var file_ha_v1_climate_proto_depIdxs = []int32{ var file_ha_v1_climate_proto_depIdxs = []int32{
6, // 0: ha.v1.ClimateResponse.state:type_name -> ha.v1.EntityState 7, // 0: ha.v1.ClimateResponse.state:type_name -> ha.v1.EntityState
3, // 1: ha.v1.ListClimatesResponse.climates:type_name -> ha.v1.ClimateEntity 4, // 1: ha.v1.ListClimatesResponse.climates:type_name -> ha.v1.ClimateEntity
0, // 2: ha.v1.ClimateService.TurnOn:input_type -> ha.v1.ClimateRequest 0, // 2: ha.v1.ClimateService.TurnOn:input_type -> ha.v1.ClimateRequest
0, // 3: ha.v1.ClimateService.TurnOff: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, // 4: ha.v1.ClimateService.IncreaseTemperature:input_type -> ha.v1.ClimateRequest
0, // 5: ha.v1.ClimateService.DecreaseTemperature: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 3, // 6: ha.v1.ClimateService.SetTemperature:input_type -> ha.v1.SetTemperatureRequest
4, // 7: ha.v1.ClimateService.ListClimates:input_type -> ha.v1.ListClimatesRequest 2, // 7: ha.v1.ClimateService.SetHVACMode:input_type -> ha.v1.SetHVACModeRequest
1, // 8: ha.v1.ClimateService.TurnOn:output_type -> ha.v1.ClimateResponse 5, // 8: ha.v1.ClimateService.ListClimates:input_type -> ha.v1.ListClimatesRequest
1, // 9: ha.v1.ClimateService.TurnOff:output_type -> ha.v1.ClimateResponse 1, // 9: ha.v1.ClimateService.TurnOn:output_type -> ha.v1.ClimateResponse
1, // 10: ha.v1.ClimateService.IncreaseTemperature:output_type -> ha.v1.ClimateResponse 1, // 10: ha.v1.ClimateService.TurnOff:output_type -> ha.v1.ClimateResponse
1, // 11: ha.v1.ClimateService.DecreaseTemperature:output_type -> ha.v1.ClimateResponse 1, // 11: ha.v1.ClimateService.IncreaseTemperature:output_type -> ha.v1.ClimateResponse
1, // 12: ha.v1.ClimateService.SetHVACMode:output_type -> ha.v1.ClimateResponse 1, // 12: ha.v1.ClimateService.DecreaseTemperature:output_type -> ha.v1.ClimateResponse
5, // 13: ha.v1.ClimateService.ListClimates:output_type -> ha.v1.ListClimatesResponse 1, // 13: ha.v1.ClimateService.SetTemperature:output_type -> ha.v1.ClimateResponse
8, // [8:14] is the sub-list for method output_type 1, // 14: ha.v1.ClimateService.SetHVACMode:output_type -> ha.v1.ClimateResponse
2, // [2:8] is the sub-list for method input_type 6, // 15: ha.v1.ClimateService.ListClimates:output_type -> ha.v1.ListClimatesResponse
9, // [9:16] is the sub-list for method output_type
2, // [2:9] 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 type_name
2, // [2:2] is the sub-list for extension extendee 2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name 0, // [0:2] is the sub-list for field type_name
@ -454,14 +516,14 @@ func file_ha_v1_climate_proto_init() {
return return
} }
file_ha_v1_common_proto_init() file_ha_v1_common_proto_init()
file_ha_v1_climate_proto_msgTypes[3].OneofWrappers = []any{} file_ha_v1_climate_proto_msgTypes[4].OneofWrappers = []any{}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_ha_v1_climate_proto_rawDesc), len(file_ha_v1_climate_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ha_v1_climate_proto_rawDesc), len(file_ha_v1_climate_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 6, NumMessages: 7,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@ -23,6 +23,7 @@ const (
ClimateService_TurnOff_FullMethodName = "/ha.v1.ClimateService/TurnOff" ClimateService_TurnOff_FullMethodName = "/ha.v1.ClimateService/TurnOff"
ClimateService_IncreaseTemperature_FullMethodName = "/ha.v1.ClimateService/IncreaseTemperature" ClimateService_IncreaseTemperature_FullMethodName = "/ha.v1.ClimateService/IncreaseTemperature"
ClimateService_DecreaseTemperature_FullMethodName = "/ha.v1.ClimateService/DecreaseTemperature" ClimateService_DecreaseTemperature_FullMethodName = "/ha.v1.ClimateService/DecreaseTemperature"
ClimateService_SetTemperature_FullMethodName = "/ha.v1.ClimateService/SetTemperature"
ClimateService_SetHVACMode_FullMethodName = "/ha.v1.ClimateService/SetHVACMode" ClimateService_SetHVACMode_FullMethodName = "/ha.v1.ClimateService/SetHVACMode"
ClimateService_ListClimates_FullMethodName = "/ha.v1.ClimateService/ListClimates" ClimateService_ListClimates_FullMethodName = "/ha.v1.ClimateService/ListClimates"
) )
@ -35,6 +36,7 @@ type ClimateServiceClient interface {
TurnOff(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) IncreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
DecreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error) DecreaseTemperature(ctx context.Context, in *ClimateRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
SetTemperature(ctx context.Context, in *SetTemperatureRequest, opts ...grpc.CallOption) (*ClimateResponse, error)
SetHVACMode(ctx context.Context, in *SetHVACModeRequest, 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) ListClimates(ctx context.Context, in *ListClimatesRequest, opts ...grpc.CallOption) (*ListClimatesResponse, error)
} }
@ -87,6 +89,16 @@ func (c *climateServiceClient) DecreaseTemperature(ctx context.Context, in *Clim
return out, nil return out, nil
} }
func (c *climateServiceClient) SetTemperature(ctx context.Context, in *SetTemperatureRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClimateResponse)
err := c.cc.Invoke(ctx, ClimateService_SetTemperature_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) { func (c *climateServiceClient) SetHVACMode(ctx context.Context, in *SetHVACModeRequest, opts ...grpc.CallOption) (*ClimateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ClimateResponse) out := new(ClimateResponse)
@ -115,6 +127,7 @@ type ClimateServiceServer interface {
TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error) TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error)
IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error)
DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error)
SetTemperature(context.Context, *SetTemperatureRequest) (*ClimateResponse, error)
SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error)
ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error) ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error)
mustEmbedUnimplementedClimateServiceServer() mustEmbedUnimplementedClimateServiceServer()
@ -139,6 +152,9 @@ func (UnimplementedClimateServiceServer) IncreaseTemperature(context.Context, *C
func (UnimplementedClimateServiceServer) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) { func (UnimplementedClimateServiceServer) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DecreaseTemperature not implemented") return nil, status.Error(codes.Unimplemented, "method DecreaseTemperature not implemented")
} }
func (UnimplementedClimateServiceServer) SetTemperature(context.Context, *SetTemperatureRequest) (*ClimateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetTemperature not implemented")
}
func (UnimplementedClimateServiceServer) SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) { func (UnimplementedClimateServiceServer) SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetHVACMode not implemented") return nil, status.Error(codes.Unimplemented, "method SetHVACMode not implemented")
} }
@ -238,6 +254,24 @@ func _ClimateService_DecreaseTemperature_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _ClimateService_SetTemperature_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetTemperatureRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(ClimateServiceServer).SetTemperature(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: ClimateService_SetTemperature_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(ClimateServiceServer).SetTemperature(ctx, req.(*SetTemperatureRequest))
}
return interceptor(ctx, in, info, handler)
}
func _ClimateService_SetHVACMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _ClimateService_SetHVACMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SetHVACModeRequest) in := new(SetHVACModeRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -297,6 +331,10 @@ var ClimateService_ServiceDesc = grpc.ServiceDesc{
MethodName: "DecreaseTemperature", MethodName: "DecreaseTemperature",
Handler: _ClimateService_DecreaseTemperature_Handler, Handler: _ClimateService_DecreaseTemperature_Handler,
}, },
{
MethodName: "SetTemperature",
Handler: _ClimateService_SetTemperature_Handler,
},
{ {
MethodName: "SetHVACMode", MethodName: "SetHVACMode",
Handler: _ClimateService_SetHVACMode_Handler, Handler: _ClimateService_SetHVACMode_Handler,

View File

@ -2,6 +2,7 @@ go 1.26
use ( use (
./ai-gateway ./ai-gateway
./alexa-bridge
./discord-bot ./discord-bot
./gen ./gen
./ha-gateway ./ha-gateway

View File

@ -7,11 +7,13 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0 h1:sBEjpZlNHzK1voKq9695PJSX2o5NEXl7/OL3coiIY0c=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.30.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA= github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
@ -20,13 +22,16 @@ github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw= github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU= github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
@ -34,15 +39,21 @@ github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
@ -53,43 +64,74 @@ go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a
go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo= go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE= go.opentelemetry.io/contrib/detectors/gcp v1.39.0 h1:kWRNZMsfBHZ+uHjiH4y7Etn2FK26LAGkNFw7RHv1DhE=
go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk=
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=

View File

@ -9,6 +9,7 @@ COPY gen/go.mod gen/go.sum ./gen/
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
WORKDIR /workspace/ha-gateway WORKDIR /workspace/ha-gateway
@ -19,6 +20,7 @@ COPY gen/ ./gen/
COPY ai-gateway/ ./ai-gateway/ COPY ai-gateway/ ./ai-gateway/
COPY discord-bot/ ./discord-bot/ COPY discord-bot/ ./discord-bot/
COPY tts-gateway/ ./tts-gateway/ COPY tts-gateway/ ./tts-gateway/
COPY alexa-bridge/ ./alexa-bridge/
COPY ha-gateway/ ./ha-gateway/ COPY ha-gateway/ ./ha-gateway/
WORKDIR /workspace/ha-gateway WORKDIR /workspace/ha-gateway

View File

@ -67,6 +67,15 @@ func (h *ClimateGRPC) DecreaseTemperature(ctx context.Context, req *hav1.Climate
return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil return &hav1.ClimateResponse{State: domainStateToProto(s)}, nil
} }
// SetTemperature forwards an absolute target-temperature request to the domain service.
func (h *ClimateGRPC) SetTemperature(ctx context.Context, req *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
s, err := h.svc.SetTemperature(ctx, domain.EntityID(req.EntityId), req.TargetTemperature)
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. // SetHVACMode forwards an HVAC mode change request to the domain service.
func (h *ClimateGRPC) SetHVACMode(ctx context.Context, req *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) { 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) s, err := h.svc.SetHVACMode(ctx, domain.EntityID(req.EntityId), req.HvacMode)

View File

@ -21,6 +21,7 @@ type mockClimateService struct {
turnOffFunc 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) increaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
setTemperatureFunc func(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error)
setHVACModeFunc func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) setHVACModeFunc func(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error)
listClimatesFunc func(ctx context.Context) ([]domain.Climate, error) listClimatesFunc func(ctx context.Context) ([]domain.Climate, error)
refreshFunc func(ctx context.Context) error refreshFunc func(ctx context.Context) error
@ -54,6 +55,13 @@ func (m *mockClimateService) DecreaseTemperature(ctx context.Context, id domain.
return m.decreaseTemperatureFunc(ctx, id) return m.decreaseTemperatureFunc(ctx, id)
} }
func (m *mockClimateService) SetTemperature(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error) {
if m.setTemperatureFunc == nil {
return nil, nil
}
return m.setTemperatureFunc(ctx, id, target)
}
func (m *mockClimateService) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) { func (m *mockClimateService) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) {
if m.setHVACModeFunc == nil { if m.setHVACModeFunc == nil {
return nil, nil return nil, nil
@ -282,6 +290,49 @@ func TestClimateGRPCSetHVACMode(t *testing.T) {
} }
} }
func TestClimateGRPCSetTemperature(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 gotTarget float64
conn := newClimateTestClientConn(t, &mockClimateService{
setTemperatureFunc: func(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error) {
gotID = id
gotTarget = target
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.SetTemperature(context.Background(), &hav1.SetTemperatureRequest{EntityId: "climate.air_conditioner", TargetTemperature: 23.5})
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" || gotTarget != 23.5 {
t.Fatalf("SetTemperature id/target = %q/%v, want %q/%v", gotID, gotTarget, "climate.air_conditioner", 23.5)
}
if resp.GetState().GetEntityId() != "climate.air_conditioner" {
t.Fatalf("response state = %#v", resp.GetState())
}
})
}
}
func TestClimateGRPCListClimates(t *testing.T) { func TestClimateGRPCListClimates(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@ -88,6 +88,16 @@ func (a *ClimateApp) DecreaseTemperature(ctx context.Context, id domain.EntityID
return a.stepTemperature(ctx, id, -1) return a.stepTemperature(ctx, id, -1)
} }
// SetTemperature sets an absolute target temperature via climate.set_temperature,
// unlike IncreaseTemperature/DecreaseTemperature which compute the next value
// from live state first. Home Assistant rejects out-of-range or otherwise
// invalid targets itself, so the caller's value is forwarded without local
// clamping.
func (a *ClimateApp) SetTemperature(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error) {
payload := map[string]any{"entity_id": string(id), "temperature": target}
return a.callService(ctx, "climate", "set_temperature", payload)
}
// stepTemperature reads live state rather than the discovery cache because the // stepTemperature reads live state rather than the discovery cache because the
// cache is never invalidated after a mutating call, which would let two // 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 // presses in a row silently double-step off a stale target temperature. Home

View File

@ -317,6 +317,54 @@ func TestClimateAppSetHVACMode(t *testing.T) {
}) })
} }
func TestClimateAppSetTemperature(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 sends absolute target with no clamping", 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_temperature" {
t.Fatalf("CallService() domain/service = %s/%s", svcDomain, service)
}
wantPayload := map[string]any{"entity_id": "climate.air_conditioner", "temperature": 23.5}
if !reflect.DeepEqual(payload, wantPayload) {
t.Fatalf("payload = %#v, want %#v", payload, wantPayload)
}
return []*driven.HAState{state}, nil
},
})
got, err := app.SetTemperature(context.Background(), "climate.air_conditioner", 23.5)
if err != nil {
t.Fatalf("SetTemperature() error = %v", err)
}
if !reflect.DeepEqual(got, haStateToDomain(state)) {
t.Fatalf("SetTemperature() = %#v, want %#v", got, haStateToDomain(state))
}
})
t.Run("error path", func(t *testing.T) {
wantErr := errors.New("set temperature failed")
app := NewClimateApp(&mockHAClient{
callServiceFunc: func(ctx context.Context, svcDomain, service string, payload map[string]any) ([]*driven.HAState, error) {
return nil, wantErr
},
})
_, err := app.SetTemperature(context.Background(), "climate.air_conditioner", 23.5)
if !errors.Is(err, wantErr) {
t.Fatalf("SetTemperature() error = %v, want %v", err, wantErr)
}
})
}
func TestClimateAppIncreaseTemperature(t *testing.T) { func TestClimateAppIncreaseTemperature(t *testing.T) {
now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC)
resultState := &driven.HAState{ resultState := &driven.HAState{

View File

@ -15,6 +15,8 @@ type ClimateService interface {
IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
// DecreaseTemperature steps the target temperature down by one increment. // DecreaseTemperature steps the target temperature down by one increment.
DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error)
// SetTemperature sets an absolute target temperature.
SetTemperature(ctx context.Context, id domain.EntityID, target float64) (*domain.EntityState, error)
// SetHVACMode sets the HVAC mode and returns the resulting entity state. // SetHVACMode sets the HVAC mode and returns the resulting entity state.
SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) SetHVACMode(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error)
// ListClimates returns cached climate metadata for discovery and UI use. // ListClimates returns cached climate metadata for discovery and UI use.

View File

@ -8,6 +8,7 @@ service ClimateService {
rpc TurnOff(ClimateRequest) returns (ClimateResponse); rpc TurnOff(ClimateRequest) returns (ClimateResponse);
rpc IncreaseTemperature(ClimateRequest) returns (ClimateResponse); rpc IncreaseTemperature(ClimateRequest) returns (ClimateResponse);
rpc DecreaseTemperature(ClimateRequest) returns (ClimateResponse); rpc DecreaseTemperature(ClimateRequest) returns (ClimateResponse);
rpc SetTemperature(SetTemperatureRequest) returns (ClimateResponse);
rpc SetHVACMode(SetHVACModeRequest) returns (ClimateResponse); rpc SetHVACMode(SetHVACModeRequest) returns (ClimateResponse);
rpc ListClimates(ListClimatesRequest) returns (ListClimatesResponse); rpc ListClimates(ListClimatesRequest) returns (ListClimatesResponse);
} }
@ -20,6 +21,14 @@ message SetHVACModeRequest {
string hvac_mode = 2; string hvac_mode = 2;
} }
// SetTemperature sets an absolute target temperature. Single-setpoint only
// there is no target_temp_high/target_temp_low pair here, since no climate
// entity in this deployment uses range mode (see alexa-bridge/plan.md).
message SetTemperatureRequest {
string entity_id = 1;
double target_temperature = 2;
}
message ClimateEntity { message ClimateEntity {
string entity_id = 1; string entity_id = 1;
string friendly_name = 2; string friendly_name = 2;

View File

@ -15,6 +15,7 @@ COPY gen/go.mod gen/go.sum ./gen/
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/ COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/ COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
WORKDIR /workspace/tts-gateway WORKDIR /workspace/tts-gateway
@ -25,6 +26,7 @@ COPY gen/ ./gen/
COPY ai-gateway/ ./ai-gateway/ COPY ai-gateway/ ./ai-gateway/
COPY ha-gateway/ ./ha-gateway/ COPY ha-gateway/ ./ha-gateway/
COPY discord-bot/ ./discord-bot/ COPY discord-bot/ ./discord-bot/
COPY alexa-bridge/ ./alexa-bridge/
COPY tts-gateway/ ./tts-gateway/ COPY tts-gateway/ ./tts-gateway/
WORKDIR /workspace/tts-gateway WORKDIR /workspace/tts-gateway