From 8f7024edfab938c80ed93af2da03e85344359467 Mon Sep 17 00:00:00 2001 From: Nik Afiq Date: Sat, 25 Jul 2026 12:08:24 +0900 Subject: [PATCH] feat(climate): add SetTemperature method to ClimateService - 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. --- .gitea/workflows/ci.yaml | 36 ++ ai-gateway/Dockerfile | 2 + alexa-bridge/.env.example | 9 + alexa-bridge/Dockerfile | 35 ++ alexa-bridge/README.md | 93 ++++ alexa-bridge/cmd/bridge/main.go | 118 +++++ alexa-bridge/go.mod | 37 ++ alexa-bridge/go.sum | 71 +++ alexa-bridge/internal/alexa/handler.go | 80 +++ alexa-bridge/internal/alexa/handler_test.go | 177 +++++++ alexa-bridge/internal/alexa/signature.go | 262 ++++++++++ alexa-bridge/internal/alexa/signature_test.go | 408 ++++++++++++++++ alexa-bridge/internal/alexa/types.go | 126 +++++ alexa-bridge/internal/config/config.go | 86 ++++ alexa-bridge/internal/core/domain/entity.go | 12 + .../internal/core/ports/driven/controller.go | 12 + .../internal/core/ports/driven/entities.go | 11 + alexa-bridge/internal/directive/intents.go | 71 +++ alexa-bridge/internal/directive/router.go | 150 ++++++ .../internal/directive/router_test.go | 264 ++++++++++ alexa-bridge/internal/entities/client.go | 84 ++++ alexa-bridge/internal/entities/client_test.go | 109 +++++ alexa-bridge/internal/entities/refresher.go | 62 +++ .../internal/entities/refresher_test.go | 154 ++++++ alexa-bridge/internal/entities/resolver.go | 48 ++ .../internal/entities/resolver_test.go | 60 +++ alexa-bridge/internal/haclient/client.go | 103 ++++ alexa-bridge/internal/haclient/controller.go | 127 +++++ .../internal/haclient/controller_test.go | 461 ++++++++++++++++++ alexa-bridge/internal/haclient/params.go | 138 ++++++ alexa-bridge/internal/haclient/params_test.go | 130 +++++ alexa-bridge/internal/logger/logger.go | 38 ++ alexa-bridge/internal/telemetry/telemetry.go | 82 ++++ alexa-bridge/plan.md | 423 ++++++++++++++++ discord-bot/Dockerfile | 2 + discord-bot/README.md | 31 +- gen/ha/v1/climate.pb.go | 130 +++-- gen/ha/v1/climate_grpc.pb.go | 38 ++ go.work | 1 + go.work.sum | 42 ++ ha-gateway/Dockerfile | 2 + .../internal/adapters/primary/grpc/climate.go | 9 + .../adapters/primary/grpc/climate_test.go | 51 ++ ha-gateway/internal/app/climate.go | 10 + ha-gateway/internal/app/climate_test.go | 48 ++ .../internal/core/ports/driving/climate.go | 2 + proto/ha/v1/climate.proto | 9 + tts-gateway/Dockerfile | 2 + 48 files changed, 4420 insertions(+), 36 deletions(-) create mode 100644 alexa-bridge/.env.example create mode 100644 alexa-bridge/Dockerfile create mode 100644 alexa-bridge/README.md create mode 100644 alexa-bridge/cmd/bridge/main.go create mode 100644 alexa-bridge/go.mod create mode 100644 alexa-bridge/go.sum create mode 100644 alexa-bridge/internal/alexa/handler.go create mode 100644 alexa-bridge/internal/alexa/handler_test.go create mode 100644 alexa-bridge/internal/alexa/signature.go create mode 100644 alexa-bridge/internal/alexa/signature_test.go create mode 100644 alexa-bridge/internal/alexa/types.go create mode 100644 alexa-bridge/internal/config/config.go create mode 100644 alexa-bridge/internal/core/domain/entity.go create mode 100644 alexa-bridge/internal/core/ports/driven/controller.go create mode 100644 alexa-bridge/internal/core/ports/driven/entities.go create mode 100644 alexa-bridge/internal/directive/intents.go create mode 100644 alexa-bridge/internal/directive/router.go create mode 100644 alexa-bridge/internal/directive/router_test.go create mode 100644 alexa-bridge/internal/entities/client.go create mode 100644 alexa-bridge/internal/entities/client_test.go create mode 100644 alexa-bridge/internal/entities/refresher.go create mode 100644 alexa-bridge/internal/entities/refresher_test.go create mode 100644 alexa-bridge/internal/entities/resolver.go create mode 100644 alexa-bridge/internal/entities/resolver_test.go create mode 100644 alexa-bridge/internal/haclient/client.go create mode 100644 alexa-bridge/internal/haclient/controller.go create mode 100644 alexa-bridge/internal/haclient/controller_test.go create mode 100644 alexa-bridge/internal/haclient/params.go create mode 100644 alexa-bridge/internal/haclient/params_test.go create mode 100644 alexa-bridge/internal/logger/logger.go create mode 100644 alexa-bridge/internal/telemetry/telemetry.go create mode 100644 alexa-bridge/plan.md diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index d099402..94ac0ec 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -28,6 +28,7 @@ jobs: ai-gateway: ${{ steps.filter.outputs.ai-gateway }} ha-gateway: ${{ steps.filter.outputs.ha-gateway }} discord-bot: ${{ steps.filter.outputs.discord-bot }} + alexa-bridge: ${{ steps.filter.outputs.alexa-bridge }} tts-gateway: ${{ steps.filter.outputs.tts-gateway }} tts-sidecar: ${{ steps.filter.outputs.tts-sidecar }} tts-model: ${{ steps.filter.outputs.tts-model }} @@ -59,6 +60,11 @@ jobs: - 'go.work' - 'go.work.sum' - 'discord-bot/**' + alexa-bridge: + - 'gen/**' + - 'go.work' + - 'go.work.sum' + - 'alexa-bridge/**' tts-gateway: - 'gen/**' - 'go.work' @@ -89,6 +95,7 @@ jobs: ai-gateway/go.sum ha-gateway/go.sum discord-bot/go.sum + alexa-bridge/go.sum tts-gateway/go.sum gen/go.sum @@ -98,6 +105,7 @@ jobs: cd ../ai-gateway && go vet ./... cd ../ha-gateway && go vet ./... cd ../discord-bot && go vet ./... + cd ../alexa-bridge && go vet ./... cd ../tts-gateway && go vet ./... - name: go test @@ -106,6 +114,7 @@ jobs: cd ../ai-gateway && go test ./... cd ../ha-gateway && go test ./... cd ../discord-bot && go test ./... + cd ../alexa-bridge && go test ./... cd ../tts-gateway && go test ./... build-ai-gateway: @@ -189,6 +198,33 @@ jobs: ${{ env.IMAGE_PREFIX }}/discord-bot:${{ github.sha }} ${{ 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: needs: [test, changes] if: github.ref == 'refs/heads/main' && needs.changes.outputs.tts-gateway == 'true' diff --git a/ai-gateway/Dockerfile b/ai-gateway/Dockerfile index 5b857c3..a5f90bf 100644 --- a/ai-gateway/Dockerfile +++ b/ai-gateway/Dockerfile @@ -9,6 +9,7 @@ COPY gen/go.mod gen/go.sum ./gen/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-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/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ WORKDIR /workspace/ai-gateway @@ -19,6 +20,7 @@ COPY gen/ ./gen/ COPY ha-gateway/ ./ha-gateway/ COPY discord-bot/ ./discord-bot/ COPY tts-gateway/ ./tts-gateway/ +COPY alexa-bridge/ ./alexa-bridge/ COPY ai-gateway/ ./ai-gateway/ WORKDIR /workspace/ai-gateway diff --git a/alexa-bridge/.env.example b/alexa-bridge/.env.example new file mode 100644 index 0000000..b015f49 --- /dev/null +++ b/alexa-bridge/.env.example @@ -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 diff --git a/alexa-bridge/Dockerfile b/alexa-bridge/Dockerfile new file mode 100644 index 0000000..0376fe2 --- /dev/null +++ b/alexa-bridge/Dockerfile @@ -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"] diff --git a/alexa-bridge/README.md b/alexa-bridge/README.md new file mode 100644 index 0000000..4d92553 --- /dev/null +++ b/alexa-bridge/README.md @@ -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. diff --git a/alexa-bridge/cmd/bridge/main.go b/alexa-bridge/cmd/bridge/main.go new file mode 100644 index 0000000..2984f3d --- /dev/null +++ b/alexa-bridge/cmd/bridge/main.go @@ -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) + } +} diff --git a/alexa-bridge/go.mod b/alexa-bridge/go.mod new file mode 100644 index 0000000..8bb984d --- /dev/null +++ b/alexa-bridge/go.mod @@ -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 diff --git a/alexa-bridge/go.sum b/alexa-bridge/go.sum new file mode 100644 index 0000000..568f716 --- /dev/null +++ b/alexa-bridge/go.sum @@ -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= diff --git a/alexa-bridge/internal/alexa/handler.go b/alexa-bridge/internal/alexa/handler.go new file mode 100644 index 0000000..ddd0cc4 --- /dev/null +++ b/alexa-bridge/internal/alexa/handler.go @@ -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) + } +} diff --git a/alexa-bridge/internal/alexa/handler_test.go b/alexa-bridge/internal/alexa/handler_test.go new file mode 100644 index 0000000..9674a3d --- /dev/null +++ b/alexa-bridge/internal/alexa/handler_test.go @@ -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") +} diff --git a/alexa-bridge/internal/alexa/signature.go b/alexa-bridge/internal/alexa/signature.go new file mode 100644 index 0000000..14f3f5b --- /dev/null +++ b/alexa-bridge/internal/alexa/signature.go @@ -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 +} diff --git a/alexa-bridge/internal/alexa/signature_test.go b/alexa-bridge/internal/alexa/signature_test.go new file mode 100644 index 0000000..25512db --- /dev/null +++ b/alexa-bridge/internal/alexa/signature_test.go @@ -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") + } + }) +} diff --git a/alexa-bridge/internal/alexa/types.go b/alexa-bridge/internal/alexa/types.go new file mode 100644 index 0000000..f85aa67 --- /dev/null +++ b/alexa-bridge/internal/alexa/types.go @@ -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, + }, + } +} diff --git a/alexa-bridge/internal/config/config.go b/alexa-bridge/internal/config/config.go new file mode 100644 index 0000000..66eb91f --- /dev/null +++ b/alexa-bridge/internal/config/config.go @@ -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 +} diff --git a/alexa-bridge/internal/core/domain/entity.go b/alexa-bridge/internal/core/domain/entity.go new file mode 100644 index 0000000..37a724e --- /dev/null +++ b/alexa-bridge/internal/core/domain/entity.go @@ -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 +} diff --git a/alexa-bridge/internal/core/ports/driven/controller.go b/alexa-bridge/internal/core/ports/driven/controller.go new file mode 100644 index 0000000..1970575 --- /dev/null +++ b/alexa-bridge/internal/core/ports/driven/controller.go @@ -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 +} diff --git a/alexa-bridge/internal/core/ports/driven/entities.go b/alexa-bridge/internal/core/ports/driven/entities.go new file mode 100644 index 0000000..afcb7cf --- /dev/null +++ b/alexa-bridge/internal/core/ports/driven/entities.go @@ -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) +} diff --git a/alexa-bridge/internal/directive/intents.go b/alexa-bridge/internal/directive/intents.go new file mode 100644 index 0000000..28ef986 --- /dev/null +++ b/alexa-bridge/internal/directive/intents.go @@ -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 +} diff --git a/alexa-bridge/internal/directive/router.go b/alexa-bridge/internal/directive/router.go new file mode 100644 index 0000000..181daa6 --- /dev/null +++ b/alexa-bridge/internal/directive/router.go @@ -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.") +} diff --git a/alexa-bridge/internal/directive/router_test.go b/alexa-bridge/internal/directive/router_test.go new file mode 100644 index 0000000..3833603 --- /dev/null +++ b/alexa-bridge/internal/directive/router_test.go @@ -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") + } + }) +} diff --git a/alexa-bridge/internal/entities/client.go b/alexa-bridge/internal/entities/client.go new file mode 100644 index 0000000..9557329 --- /dev/null +++ b/alexa-bridge/internal/entities/client.go @@ -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 +} diff --git a/alexa-bridge/internal/entities/client_test.go b/alexa-bridge/internal/entities/client_test.go new file mode 100644 index 0000000..5a86fd4 --- /dev/null +++ b/alexa-bridge/internal/entities/client_test.go @@ -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) + } + }) +} diff --git a/alexa-bridge/internal/entities/refresher.go b/alexa-bridge/internal/entities/refresher.go new file mode 100644 index 0000000..0b6bfb7 --- /dev/null +++ b/alexa-bridge/internal/entities/refresher.go @@ -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) + } + } + } +} diff --git a/alexa-bridge/internal/entities/refresher_test.go b/alexa-bridge/internal/entities/refresher_test.go new file mode 100644 index 0000000..1f14c97 --- /dev/null +++ b/alexa-bridge/internal/entities/refresher_test.go @@ -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") + } +} diff --git a/alexa-bridge/internal/entities/resolver.go b/alexa-bridge/internal/entities/resolver.go new file mode 100644 index 0000000..7a7f9b0 --- /dev/null +++ b/alexa-bridge/internal/entities/resolver.go @@ -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)) +} diff --git a/alexa-bridge/internal/entities/resolver_test.go b/alexa-bridge/internal/entities/resolver_test.go new file mode 100644 index 0000000..0c5a526 --- /dev/null +++ b/alexa-bridge/internal/entities/resolver_test.go @@ -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") + } + }) +} diff --git a/alexa-bridge/internal/haclient/client.go b/alexa-bridge/internal/haclient/client.go new file mode 100644 index 0000000..9a82591 --- /dev/null +++ b/alexa-bridge/internal/haclient/client.go @@ -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 +} diff --git a/alexa-bridge/internal/haclient/controller.go b/alexa-bridge/internal/haclient/controller.go new file mode 100644 index 0000000..7ada7ba --- /dev/null +++ b/alexa-bridge/internal/haclient/controller.go @@ -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) + } +} diff --git a/alexa-bridge/internal/haclient/controller_test.go b/alexa-bridge/internal/haclient/controller_test.go new file mode 100644 index 0000000..f2eab4d --- /dev/null +++ b/alexa-bridge/internal/haclient/controller_test.go @@ -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") + } + }) +} diff --git a/alexa-bridge/internal/haclient/params.go b/alexa-bridge/internal/haclient/params.go new file mode 100644 index 0000000..840e31e --- /dev/null +++ b/alexa-bridge/internal/haclient/params.go @@ -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) + } +} diff --git a/alexa-bridge/internal/haclient/params_test.go b/alexa-bridge/internal/haclient/params_test.go new file mode 100644 index 0000000..d7f438a --- /dev/null +++ b/alexa-bridge/internal/haclient/params_test.go @@ -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 } diff --git a/alexa-bridge/internal/logger/logger.go b/alexa-bridge/internal/logger/logger.go new file mode 100644 index 0000000..498bf86 --- /dev/null +++ b/alexa-bridge/internal/logger/logger.go @@ -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() +} diff --git a/alexa-bridge/internal/telemetry/telemetry.go b/alexa-bridge/internal/telemetry/telemetry.go new file mode 100644 index 0000000..d1b93f5 --- /dev/null +++ b/alexa-bridge/internal/telemetry/telemetry.go @@ -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 +} diff --git a/alexa-bridge/plan.md b/alexa-bridge/plan.md new file mode 100644 index 0000000..c1f04e3 --- /dev/null +++ b/alexa-bridge/plan.md @@ -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, ...}` — **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 +`-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. diff --git a/discord-bot/Dockerfile b/discord-bot/Dockerfile index ae623b2..8727c8b 100644 --- a/discord-bot/Dockerfile +++ b/discord-bot/Dockerfile @@ -9,6 +9,7 @@ COPY gen/go.mod gen/go.sum ./gen/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-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/ WORKDIR /workspace/discord-bot @@ -19,6 +20,7 @@ COPY gen/ ./gen/ COPY ai-gateway/ ./ai-gateway/ COPY ha-gateway/ ./ha-gateway/ COPY tts-gateway/ ./tts-gateway/ +COPY alexa-bridge/ ./alexa-bridge/ COPY discord-bot/ ./discord-bot/ WORKDIR /workspace/discord-bot diff --git a/discord-bot/README.md b/discord-bot/README.md index 43ae1ca..1d35872 100644 --- a/discord-bot/README.md +++ b/discord-bot/README.md @@ -70,6 +70,19 @@ AI-assisted commands. AI queries are sent to `ai-gateway`. The active model is stored in process memory, so it resets when the bot restarts. +### Speak + +```text +/speak speaker: 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 Environment variables: @@ -80,7 +93,8 @@ Environment variables: | `GUILD_ID` | empty | Guild-scoped command registration target; empty registers global commands | | `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` | -| `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 | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` | | `LOG_FORMAT` | `json` | `json` or `text` | @@ -129,9 +143,10 @@ docker build -f discord-bot/Dockerfile --build-arg VERSION=$(git rev-parse --sho ```text 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/aigateway/ # ai-gateway gRPC client +internal/adapters/secondary/ttsgateway/ # tts-gateway gRPC client internal/app/ # command orchestration and formatting internal/config/ # environment loading 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 does not implement per-user authorization. - 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. diff --git a/gen/ha/v1/climate.pb.go b/gen/ha/v1/climate.pb.go index 2248716..2b428c9 100644 --- a/gen/ha/v1/climate.pb.go +++ b/gen/ha/v1/climate.pb.go @@ -161,6 +161,61 @@ func (x *SetHVACModeRequest) GetHvacMode() string { 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 { state protoimpl.MessageState `protogen:"open.v1"` 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() { *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.StoreMessageInfo(mi) } @@ -192,7 +247,7 @@ func (x *ClimateEntity) String() string { func (*ClimateEntity) ProtoMessage() {} 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 { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -205,7 +260,7 @@ func (x *ClimateEntity) ProtoReflect() protoreflect.Message { // Deprecated: Use ClimateEntity.ProtoReflect.Descriptor instead. 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 { @@ -293,7 +348,7 @@ type ListClimatesRequest struct { func (x *ListClimatesRequest) Reset() { *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.StoreMessageInfo(mi) } @@ -305,7 +360,7 @@ func (x *ListClimatesRequest) String() string { func (*ListClimatesRequest) ProtoMessage() {} 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 { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -318,7 +373,7 @@ func (x *ListClimatesRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListClimatesRequest.ProtoReflect.Descriptor instead. 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 { @@ -330,7 +385,7 @@ type ListClimatesResponse struct { func (x *ListClimatesResponse) Reset() { *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.StoreMessageInfo(mi) } @@ -342,7 +397,7 @@ func (x *ListClimatesResponse) String() string { func (*ListClimatesResponse) ProtoMessage() {} 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 { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -355,7 +410,7 @@ func (x *ListClimatesResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListClimatesResponse.ProtoReflect.Descriptor instead. 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 { @@ -376,7 +431,10 @@ const file_ha_v1_climate_proto_rawDesc = "" + "\x05state\x18\x01 \x01(\v2\x12.ha.v1.EntityStateR\x05state\"N\n" + "\x12SetHVACModeRequest\x12\x1b\n" + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12\x1b\n" + - "\thvac_mode\x18\x02 \x01(\tR\bhvacMode\"\xb7\x03\n" + + "\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" + "\tentity_id\x18\x01 \x01(\tR\bentityId\x12#\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" + "\x13ListClimatesRequest\"H\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" + "\x06TurnOn\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x128\n" + "\aTurnOff\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12D\n" + "\x13IncreaseTemperature\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12D\n" + - "\x13DecreaseTemperature\x12\x15.ha.v1.ClimateRequest\x1a\x16.ha.v1.ClimateResponse\x12@\n" + + "\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" + "\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 } -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{ - (*ClimateRequest)(nil), // 0: ha.v1.ClimateRequest - (*ClimateResponse)(nil), // 1: ha.v1.ClimateResponse - (*SetHVACModeRequest)(nil), // 2: ha.v1.SetHVACModeRequest - (*ClimateEntity)(nil), // 3: ha.v1.ClimateEntity - (*ListClimatesRequest)(nil), // 4: ha.v1.ListClimatesRequest - (*ListClimatesResponse)(nil), // 5: ha.v1.ListClimatesResponse - (*EntityState)(nil), // 6: ha.v1.EntityState + (*ClimateRequest)(nil), // 0: ha.v1.ClimateRequest + (*ClimateResponse)(nil), // 1: ha.v1.ClimateResponse + (*SetHVACModeRequest)(nil), // 2: ha.v1.SetHVACModeRequest + (*SetTemperatureRequest)(nil), // 3: ha.v1.SetTemperatureRequest + (*ClimateEntity)(nil), // 4: ha.v1.ClimateEntity + (*ListClimatesRequest)(nil), // 5: ha.v1.ListClimatesRequest + (*ListClimatesResponse)(nil), // 6: ha.v1.ListClimatesResponse + (*EntityState)(nil), // 7: ha.v1.EntityState } var file_ha_v1_climate_proto_depIdxs = []int32{ - 6, // 0: ha.v1.ClimateResponse.state:type_name -> ha.v1.EntityState - 3, // 1: ha.v1.ListClimatesResponse.climates:type_name -> ha.v1.ClimateEntity + 7, // 0: ha.v1.ClimateResponse.state:type_name -> ha.v1.EntityState + 4, // 1: ha.v1.ListClimatesResponse.climates:type_name -> ha.v1.ClimateEntity 0, // 2: ha.v1.ClimateService.TurnOn:input_type -> ha.v1.ClimateRequest 0, // 3: ha.v1.ClimateService.TurnOff:input_type -> ha.v1.ClimateRequest 0, // 4: ha.v1.ClimateService.IncreaseTemperature:input_type -> ha.v1.ClimateRequest 0, // 5: ha.v1.ClimateService.DecreaseTemperature:input_type -> ha.v1.ClimateRequest - 2, // 6: ha.v1.ClimateService.SetHVACMode:input_type -> ha.v1.SetHVACModeRequest - 4, // 7: ha.v1.ClimateService.ListClimates:input_type -> ha.v1.ListClimatesRequest - 1, // 8: ha.v1.ClimateService.TurnOn:output_type -> ha.v1.ClimateResponse - 1, // 9: ha.v1.ClimateService.TurnOff:output_type -> ha.v1.ClimateResponse - 1, // 10: ha.v1.ClimateService.IncreaseTemperature:output_type -> ha.v1.ClimateResponse - 1, // 11: ha.v1.ClimateService.DecreaseTemperature:output_type -> ha.v1.ClimateResponse - 1, // 12: ha.v1.ClimateService.SetHVACMode:output_type -> ha.v1.ClimateResponse - 5, // 13: ha.v1.ClimateService.ListClimates:output_type -> ha.v1.ListClimatesResponse - 8, // [8:14] is the sub-list for method output_type - 2, // [2:8] is the sub-list for method input_type + 3, // 6: ha.v1.ClimateService.SetTemperature:input_type -> ha.v1.SetTemperatureRequest + 2, // 7: ha.v1.ClimateService.SetHVACMode:input_type -> ha.v1.SetHVACModeRequest + 5, // 8: ha.v1.ClimateService.ListClimates:input_type -> ha.v1.ListClimatesRequest + 1, // 9: ha.v1.ClimateService.TurnOn:output_type -> ha.v1.ClimateResponse + 1, // 10: ha.v1.ClimateService.TurnOff:output_type -> ha.v1.ClimateResponse + 1, // 11: ha.v1.ClimateService.IncreaseTemperature:output_type -> ha.v1.ClimateResponse + 1, // 12: ha.v1.ClimateService.DecreaseTemperature:output_type -> ha.v1.ClimateResponse + 1, // 13: ha.v1.ClimateService.SetTemperature:output_type -> ha.v1.ClimateResponse + 1, // 14: ha.v1.ClimateService.SetHVACMode:output_type -> ha.v1.ClimateResponse + 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 extendee 0, // [0:2] is the sub-list for field type_name @@ -454,14 +516,14 @@ func file_ha_v1_climate_proto_init() { return } 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{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_ha_v1_climate_proto_rawDesc), len(file_ha_v1_climate_proto_rawDesc)), NumEnums: 0, - NumMessages: 6, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, diff --git a/gen/ha/v1/climate_grpc.pb.go b/gen/ha/v1/climate_grpc.pb.go index 315989b..22bd061 100644 --- a/gen/ha/v1/climate_grpc.pb.go +++ b/gen/ha/v1/climate_grpc.pb.go @@ -23,6 +23,7 @@ const ( ClimateService_TurnOff_FullMethodName = "/ha.v1.ClimateService/TurnOff" ClimateService_IncreaseTemperature_FullMethodName = "/ha.v1.ClimateService/IncreaseTemperature" ClimateService_DecreaseTemperature_FullMethodName = "/ha.v1.ClimateService/DecreaseTemperature" + ClimateService_SetTemperature_FullMethodName = "/ha.v1.ClimateService/SetTemperature" ClimateService_SetHVACMode_FullMethodName = "/ha.v1.ClimateService/SetHVACMode" 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) IncreaseTemperature(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) 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 } +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) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ClimateResponse) @@ -115,6 +127,7 @@ type ClimateServiceServer interface { TurnOff(context.Context, *ClimateRequest) (*ClimateResponse, error) IncreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) + SetTemperature(context.Context, *SetTemperatureRequest) (*ClimateResponse, error) SetHVACMode(context.Context, *SetHVACModeRequest) (*ClimateResponse, error) ListClimates(context.Context, *ListClimatesRequest) (*ListClimatesResponse, error) mustEmbedUnimplementedClimateServiceServer() @@ -139,6 +152,9 @@ func (UnimplementedClimateServiceServer) IncreaseTemperature(context.Context, *C func (UnimplementedClimateServiceServer) DecreaseTemperature(context.Context, *ClimateRequest) (*ClimateResponse, error) { 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) { 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) } +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) { in := new(SetHVACModeRequest) if err := dec(in); err != nil { @@ -297,6 +331,10 @@ var ClimateService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DecreaseTemperature", Handler: _ClimateService_DecreaseTemperature_Handler, }, + { + MethodName: "SetTemperature", + Handler: _ClimateService_SetTemperature_Handler, + }, { MethodName: "SetHVACMode", Handler: _ClimateService_SetHVACMode_Handler, diff --git a/go.work b/go.work index 0331b9a..70cc901 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,7 @@ go 1.26 use ( ./ai-gateway + ./alexa-bridge ./discord-bot ./gen ./ha-gateway diff --git a/go.work.sum b/go.work.sum index c8ee978..d2347cc 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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.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.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4= 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/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/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/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= @@ -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.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.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/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.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.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/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/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 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/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/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/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/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/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= 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/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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.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.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs= 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.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/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/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/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/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/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/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/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.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= 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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 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/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/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/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/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= 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-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/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/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.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= diff --git a/ha-gateway/Dockerfile b/ha-gateway/Dockerfile index 5fe9a50..63884e2 100644 --- a/ha-gateway/Dockerfile +++ b/ha-gateway/Dockerfile @@ -9,6 +9,7 @@ COPY gen/go.mod gen/go.sum ./gen/ 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/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ WORKDIR /workspace/ha-gateway @@ -19,6 +20,7 @@ COPY gen/ ./gen/ COPY ai-gateway/ ./ai-gateway/ COPY discord-bot/ ./discord-bot/ COPY tts-gateway/ ./tts-gateway/ +COPY alexa-bridge/ ./alexa-bridge/ COPY ha-gateway/ ./ha-gateway/ WORKDIR /workspace/ha-gateway diff --git a/ha-gateway/internal/adapters/primary/grpc/climate.go b/ha-gateway/internal/adapters/primary/grpc/climate.go index c2d9755..df04660 100644 --- a/ha-gateway/internal/adapters/primary/grpc/climate.go +++ b/ha-gateway/internal/adapters/primary/grpc/climate.go @@ -67,6 +67,15 @@ func (h *ClimateGRPC) DecreaseTemperature(ctx context.Context, req *hav1.Climate 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. 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) diff --git a/ha-gateway/internal/adapters/primary/grpc/climate_test.go b/ha-gateway/internal/adapters/primary/grpc/climate_test.go index bb7c2b3..ff116fa 100644 --- a/ha-gateway/internal/adapters/primary/grpc/climate_test.go +++ b/ha-gateway/internal/adapters/primary/grpc/climate_test.go @@ -21,6 +21,7 @@ type mockClimateService struct { turnOffFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) increaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) decreaseTemperatureFunc func(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + 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) listClimatesFunc func(ctx context.Context) ([]domain.Climate, 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) } +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) { if m.setHVACModeFunc == 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) { tests := []struct { name string diff --git a/ha-gateway/internal/app/climate.go b/ha-gateway/internal/app/climate.go index 3c9ae2d..b9da069 100644 --- a/ha-gateway/internal/app/climate.go +++ b/ha-gateway/internal/app/climate.go @@ -88,6 +88,16 @@ func (a *ClimateApp) DecreaseTemperature(ctx context.Context, id domain.EntityID 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 // 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 diff --git a/ha-gateway/internal/app/climate_test.go b/ha-gateway/internal/app/climate_test.go index a4c244e..dc9d084 100644 --- a/ha-gateway/internal/app/climate_test.go +++ b/ha-gateway/internal/app/climate_test.go @@ -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) { now := time.Date(2026, 4, 9, 10, 0, 0, 0, time.UTC) resultState := &driven.HAState{ diff --git a/ha-gateway/internal/core/ports/driving/climate.go b/ha-gateway/internal/core/ports/driving/climate.go index ee327b2..9f79d09 100644 --- a/ha-gateway/internal/core/ports/driving/climate.go +++ b/ha-gateway/internal/core/ports/driving/climate.go @@ -15,6 +15,8 @@ type ClimateService interface { IncreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) // DecreaseTemperature steps the target temperature down by one increment. DecreaseTemperature(ctx context.Context, id domain.EntityID) (*domain.EntityState, error) + // 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(ctx context.Context, id domain.EntityID, hvacMode string) (*domain.EntityState, error) // ListClimates returns cached climate metadata for discovery and UI use. diff --git a/proto/ha/v1/climate.proto b/proto/ha/v1/climate.proto index 2b4b6e4..1abf8f9 100644 --- a/proto/ha/v1/climate.proto +++ b/proto/ha/v1/climate.proto @@ -8,6 +8,7 @@ service ClimateService { rpc TurnOff(ClimateRequest) returns (ClimateResponse); rpc IncreaseTemperature(ClimateRequest) returns (ClimateResponse); rpc DecreaseTemperature(ClimateRequest) returns (ClimateResponse); + rpc SetTemperature(SetTemperatureRequest) returns (ClimateResponse); rpc SetHVACMode(SetHVACModeRequest) returns (ClimateResponse); rpc ListClimates(ListClimatesRequest) returns (ListClimatesResponse); } @@ -20,6 +21,14 @@ message SetHVACModeRequest { 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 { string entity_id = 1; string friendly_name = 2; diff --git a/tts-gateway/Dockerfile b/tts-gateway/Dockerfile index 4c9d5e1..bea9db3 100644 --- a/tts-gateway/Dockerfile +++ b/tts-gateway/Dockerfile @@ -15,6 +15,7 @@ COPY gen/go.mod gen/go.sum ./gen/ COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/ COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/ 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/ WORKDIR /workspace/tts-gateway @@ -25,6 +26,7 @@ COPY gen/ ./gen/ COPY ai-gateway/ ./ai-gateway/ COPY ha-gateway/ ./ha-gateway/ COPY discord-bot/ ./discord-bot/ +COPY alexa-bridge/ ./alexa-bridge/ COPY tts-gateway/ ./tts-gateway/ WORKDIR /workspace/tts-gateway