feat(climate): add SetTemperature method to ClimateService
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 24s
CI / build-ai-gateway (push) Successful in 1m6s
CI / build-ha-gateway (push) Successful in 1m3s
CI / build-discord-bot (push) Successful in 1m4s
CI / build-alexa-bridge (push) Successful in 1m15s
CI / build-tts-gateway (push) Successful in 1m5s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
- Implemented SetTemperature in ClimateService for setting an absolute target temperature. - Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature. - Added corresponding handler and tests for SetTemperature in ClimateGRPC. - Modified ClimateApp to handle SetTemperature requests without clamping. - Updated climate.proto to define SetTemperatureRequest message. - Adjusted Dockerfiles to include alexa-bridge dependencies.
This commit is contained in:
parent
a7730772c2
commit
8f7024edfa
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
9
alexa-bridge/.env.example
Normal file
9
alexa-bridge/.env.example
Normal file
@ -0,0 +1,9 @@
|
||||
HTTP_PORT=8080
|
||||
ALEXA_SKILL_ID=amzn1.ask.skill.your-skill-id-here
|
||||
HA_GATEWAY_ADDR=localhost:50051
|
||||
HA_GATEWAY_SERVER_NAME=ha-gateway.home-services.svc.cluster.local
|
||||
TLS_DIR=
|
||||
ENTITY_REFRESH_INTERVAL=5m
|
||||
OTEL_ENDPOINT=
|
||||
LOG_LEVEL=info
|
||||
LOG_FORMAT=text
|
||||
35
alexa-bridge/Dockerfile
Normal file
35
alexa-bridge/Dockerfile
Normal file
@ -0,0 +1,35 @@
|
||||
FROM golang:1.26-alpine AS builder
|
||||
WORKDIR /workspace
|
||||
|
||||
COPY go.work go.work.sum ./
|
||||
|
||||
# Manifests only, copied first, so `go mod download` gets its own cache layer
|
||||
# invalidated only by dependency changes - not by every source edit below.
|
||||
COPY gen/go.mod gen/go.sum ./gen/
|
||||
COPY ha-gateway/go.mod ha-gateway/go.sum ./ha-gateway/
|
||||
COPY ai-gateway/go.mod ai-gateway/go.sum ./ai-gateway/
|
||||
COPY discord-bot/go.mod discord-bot/go.sum ./discord-bot/
|
||||
COPY tts-gateway/go.mod tts-gateway/go.sum ./tts-gateway/
|
||||
COPY alexa-bridge/go.mod alexa-bridge/go.sum ./alexa-bridge/
|
||||
|
||||
WORKDIR /workspace/alexa-bridge
|
||||
RUN go mod download
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY gen/ ./gen/
|
||||
COPY ha-gateway/ ./ha-gateway/
|
||||
COPY ai-gateway/ ./ai-gateway/
|
||||
COPY discord-bot/ ./discord-bot/
|
||||
COPY tts-gateway/ ./tts-gateway/
|
||||
COPY alexa-bridge/ ./alexa-bridge/
|
||||
|
||||
WORKDIR /workspace/alexa-bridge
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-ldflags="-s -w -X main.version=${VERSION}" \
|
||||
-o /alexa-bridge ./cmd/bridge
|
||||
|
||||
FROM gcr.io/distroless/static:nonroot
|
||||
COPY --from=builder /alexa-bridge /alexa-bridge
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/alexa-bridge"]
|
||||
93
alexa-bridge/README.md
Normal file
93
alexa-bridge/README.md
Normal file
@ -0,0 +1,93 @@
|
||||
# alexa-bridge
|
||||
|
||||
`alexa-bridge` is a self-hosted Alexa Custom Skill backend. It verifies that
|
||||
incoming HTTPS requests actually came from Alexa, resolves the spoken device
|
||||
name against entities discovered from `ha-gateway`, and executes the
|
||||
resulting action over gRPC. Unlike the other three services in this repo, it
|
||||
is internet-facing — Alexa's servers call directly into it — so it is the
|
||||
one service whose own inbound edge is untrusted by default and needs its own
|
||||
request verification, on top of the mTLS it uses (like `ai-gateway` and
|
||||
`discord-bot`) to call `ha-gateway`.
|
||||
|
||||
See [plan.md](plan.md) for the full design, the action-table mapping from
|
||||
Alexa intents to `ha-gateway` RPCs, and the resolved "Decisions on open
|
||||
questions" section documenting scope choices (single-setpoint-only climate
|
||||
control, exact-match-only entity name resolution, no remote/SwitchBot
|
||||
support, etc.).
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
1. The service loads `.env`, configures logging and telemetry, blocking-fetches
|
||||
the initial entity list from `ha-gateway`'s `EntityService` (failing
|
||||
startup if that fails — nothing can resolve a device name without it),
|
||||
then starts serving HTTP on `HTTP_PORT`.
|
||||
2. A background goroutine refreshes the entity list every
|
||||
`ENTITY_REFRESH_INTERVAL`; a failed periodic refresh only logs and keeps
|
||||
serving the last-known-good list.
|
||||
3. Each incoming Alexa request is verified: `SignatureCertChainUrl`/
|
||||
`Signature` headers (cert chain fetched from Amazon's S3 host and cached,
|
||||
RSA-SHA1 signature over the raw body), request timestamp (150s replay
|
||||
tolerance), and the request envelope's application ID against
|
||||
`ALEXA_SKILL_ID`.
|
||||
4. Verified `IntentRequest`s are routed by intent name; the `Device` slot is
|
||||
resolved (exact match, normalized) against the cached entity list, and the
|
||||
resulting `(entity_id, action, params)` triple is executed via
|
||||
`ha-gateway`'s `Light`/`Switch`/`Climate` services over mTLS.
|
||||
5. Resolution and execution failures produce a spoken failure response
|
||||
rather than an HTTP error — a Dispatch-layer error would surface as
|
||||
Alexa's own generic failure speech instead of a message this skill
|
||||
controls.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `HTTP_PORT` | `8080` | HTTP listen port |
|
||||
| `ALEXA_SKILL_ID` | — (required) | Verified against the request envelope's application ID |
|
||||
| `HA_GATEWAY_ADDR` | `ha-gateway.home-services.svc.cluster.local:50051` | gRPC address for `ha-gateway` |
|
||||
| `HA_GATEWAY_SERVER_NAME` | `ha-gateway.home-services.svc.cluster.local` | Expected server name for mTLS |
|
||||
| `TLS_DIR` | `/tls` | mTLS client cert dir for the `ha-gateway` call. Unlike `ai-gateway`/`discord-bot`, this defaults **on** rather than empty — alexa-bridge is internet-facing, so mTLS to `ha-gateway` should be on by default rather than opt-in. Set empty to disable for local plaintext dev. |
|
||||
| `ENTITY_REFRESH_INTERVAL` | `5m` | Periodic entity list refresh cadence |
|
||||
| `OTEL_ENDPOINT` | empty | OTLP gRPC collector endpoint; empty disables telemetry |
|
||||
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|
||||
| `LOG_FORMAT` | `json` | `json` or `text` |
|
||||
|
||||
Example env file: [.env.example](.env.example)
|
||||
|
||||
When `TLS_DIR` is set, the directory must contain `tls.crt`, `tls.key`, and
|
||||
`ca.crt`.
|
||||
|
||||
## Local Run
|
||||
|
||||
Start `ha-gateway` first, then run:
|
||||
|
||||
```bash
|
||||
cd alexa-bridge
|
||||
cp .env.example .env
|
||||
# edit .env: set ALEXA_SKILL_ID, leave TLS_DIR empty for plaintext local dev
|
||||
go run ./cmd/bridge
|
||||
```
|
||||
|
||||
`alexa-bridge` doesn't register with Alexa on its own — during development,
|
||||
point Alexa's request signature verification at a tunneled local endpoint
|
||||
(e.g. `ngrok`) and set that HTTPS URL as the skill's endpoint in the Alexa
|
||||
developer console. `/healthz` returns `200 OK` for liveness/readiness probes.
|
||||
|
||||
## HTTP API
|
||||
|
||||
There is no gRPC surface here (unlike the other three services) — this is a
|
||||
plain HTTP server:
|
||||
|
||||
- `POST /`: the Alexa Custom Skill endpoint. Expects the standard Alexa
|
||||
request envelope as JSON, with `SignatureCertChainUrl` and `Signature`
|
||||
headers.
|
||||
- `GET /healthz`: unauthenticated liveness/readiness check, always `200 OK`.
|
||||
|
||||
## Deployment
|
||||
|
||||
Kubernetes manifests (Deployment/Service, the `alexa-bridge-tls` mTLS client
|
||||
cert, and the public HTTPS ingress) live in the separate `homelab` repo, not
|
||||
here — see [plan.md](plan.md)'s mTLS section and "Decisions on open
|
||||
questions" #2 for what's drafted there.
|
||||
118
alexa-bridge/cmd/bridge/main.go
Normal file
118
alexa-bridge/cmd/bridge/main.go
Normal file
@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/config"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/directive"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/entities"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/haclient"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/logger"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/telemetry"
|
||||
)
|
||||
|
||||
// entityDomains are the ha-gateway entity domains alexa-bridge resolves and
|
||||
// controls. RemoteService/SwitchBot entities are deliberately excluded —
|
||||
// see plan.md's "Decisions on open questions" #3.
|
||||
var entityDomains = []string{"light", "switch", "climate"}
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
os.Stderr.WriteString("config error: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log := logger.New(cfg.LogFormat, cfg.LogLevel)
|
||||
slog.SetDefault(log)
|
||||
log.Info("starting alexa-bridge",
|
||||
"version", version,
|
||||
"http_port", cfg.HTTPPort,
|
||||
"ha_gateway_addr", cfg.HAGatewayAddr,
|
||||
"tls_dir", cfg.TLSDir,
|
||||
"entity_refresh_interval", cfg.EntityRefreshInterval.String(),
|
||||
"otel_endpoint", cfg.OTELEndpoint,
|
||||
)
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
ctx = logger.WithLogger(ctx, log)
|
||||
|
||||
shutdown, err := telemetry.Setup(ctx, "alexa-bridge", version, cfg)
|
||||
if err != nil {
|
||||
log.Error("telemetry setup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
haClient, err := haclient.New(ctx, cfg.HAGatewayAddr, cfg.TLSDir, cfg.HAGatewayServerName, log)
|
||||
if err != nil {
|
||||
log.Error("ha-gateway client setup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
if err := haClient.Close(); err != nil {
|
||||
log.Error("ha-gateway client close failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
entityClient := entities.NewClient(haClient.EntityServiceClient(), entityDomains)
|
||||
resolver := entities.NewResolver()
|
||||
refresher := entities.NewRefresher(entityClient, resolver, cfg.EntityRefreshInterval, log)
|
||||
// Blocking: without an initial entity list nothing can resolve, so a
|
||||
// failure here should fail startup rather than serve with an empty
|
||||
// resolver. Periodic refreshes after this point only log on failure.
|
||||
if err := refresher.Start(ctx); err != nil {
|
||||
log.Error("initial entity fetch failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
router := directive.NewRouter(resolver, haClient, log)
|
||||
validator := alexa.NewSignatureValidator(nil)
|
||||
alexaHandler := alexa.NewHandler(validator, cfg.AlexaSkillID, router)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/", alexaHandler)
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.HTTPPort,
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Info("alexa-bridge listening", "addr", srv.Addr)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Error("serve failed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Info("shutdown signal received, draining")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Error("http server shutdown failed", "err", err)
|
||||
}
|
||||
log.Info("shutdown complete")
|
||||
|
||||
if err := shutdown(context.Background()); err != nil {
|
||||
log.Error("telemetry shutdown error", "err", err)
|
||||
}
|
||||
}
|
||||
37
alexa-bridge/go.mod
Normal file
37
alexa-bridge/go.mod
Normal file
@ -0,0 +1,37 @@
|
||||
module gitea.nik4nao.com/nik/home-services/alexa-bridge
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
gitea.nik4nao.com/nik/home-services/gen v0.0.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0
|
||||
go.opentelemetry.io/otel/metric v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
replace gitea.nik4nao.com/nik/home-services/gen => ../gen
|
||||
71
alexa-bridge/go.sum
Normal file
71
alexa-bridge/go.sum
Normal file
@ -0,0 +1,71 @@
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
80
alexa-bridge/internal/alexa/handler.go
Normal file
80
alexa-bridge/internal/alexa/handler.go
Normal file
@ -0,0 +1,80 @@
|
||||
package alexa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/logger"
|
||||
)
|
||||
|
||||
// Dispatcher handles one already-validated Alexa request and produces a
|
||||
// response. internal/directive.Router implements this.
|
||||
type Dispatcher interface {
|
||||
Dispatch(ctx context.Context, req Request) (Response, error)
|
||||
}
|
||||
|
||||
// Handler is the HTTP entrypoint Alexa's servers call into: it verifies the
|
||||
// request signature, timestamp, and skill ID (in that order — cheapest and
|
||||
// most identity-establishing checks first), then hands the decoded envelope
|
||||
// to a Dispatcher.
|
||||
type Handler struct {
|
||||
validator Validator
|
||||
skillID string
|
||||
dispatcher Dispatcher
|
||||
}
|
||||
|
||||
// NewHandler constructs the HTTP handler for the Alexa endpoint.
|
||||
func NewHandler(validator Validator, skillID string, dispatcher Dispatcher) *Handler {
|
||||
return &Handler{validator: validator, skillID: skillID, dispatcher: dispatcher}
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
log := logger.FromContext(ctx)
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Warn("read request body failed", "err", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validator.Validate(ctx, r, body); err != nil {
|
||||
log.Warn("signature validation failed", "err", err)
|
||||
http.Error(w, "signature verification failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req Request
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
log.Warn("decode request body failed", "err", err)
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateTimestamp(req.Request.Timestamp); err != nil {
|
||||
log.Warn("timestamp validation failed", "err", err)
|
||||
http.Error(w, "request timestamp outside tolerance", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ValidateApplicationID(&req, h.skillID); err != nil {
|
||||
log.Warn("application id validation failed", "err", err)
|
||||
http.Error(w, "application id mismatch", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.dispatcher.Dispatch(ctx, req)
|
||||
if err != nil {
|
||||
log.Error("dispatch failed", "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
log.Error("encode response failed", "err", err)
|
||||
}
|
||||
}
|
||||
177
alexa-bridge/internal/alexa/handler_test.go
Normal file
177
alexa-bridge/internal/alexa/handler_test.go
Normal file
@ -0,0 +1,177 @@
|
||||
package alexa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeValidator struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeValidator) Validate(ctx context.Context, r *http.Request, body []byte) error {
|
||||
return f.err
|
||||
}
|
||||
|
||||
type fakeDispatcher struct {
|
||||
respFunc func(ctx context.Context, req Request) (Response, error)
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) Dispatch(ctx context.Context, req Request) (Response, error) {
|
||||
if f.respFunc == nil {
|
||||
return NewTellResponse("ok"), nil
|
||||
}
|
||||
return f.respFunc(ctx, req)
|
||||
}
|
||||
|
||||
const testSkillID = "amzn1.ask.skill.test"
|
||||
|
||||
func validRequestBody(t *testing.T, skillID, requestType string, ts time.Time) []byte {
|
||||
t.Helper()
|
||||
req := Request{
|
||||
Version: "1.0",
|
||||
Context: &Context{System: System{Application: Application{ApplicationID: skillID}}},
|
||||
Request: RequestBody{
|
||||
Type: requestType,
|
||||
RequestID: "amzn1.echo-api.request.test",
|
||||
Timestamp: ts.UTC().Format(time.RFC3339),
|
||||
Locale: "en-US",
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestHandlerServeHTTP(t *testing.T) {
|
||||
t.Run("happy path returns the dispatcher's response", func(t *testing.T) {
|
||||
body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now())
|
||||
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{
|
||||
respFunc: func(ctx context.Context, req Request) (Response, error) {
|
||||
return NewTellResponse("hello"), nil
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
var got Response
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if got.Response.OutputSpeech.Text != "hello" {
|
||||
t.Fatalf("OutputSpeech.Text = %q, want %q", got.Response.OutputSpeech.Text, "hello")
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("signature validation failure returns 401 without dispatching", func(t *testing.T) {
|
||||
dispatched := false
|
||||
body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now())
|
||||
h := NewHandler(&fakeValidator{err: errors.New("bad signature")}, testSkillID, &fakeDispatcher{
|
||||
respFunc: func(ctx context.Context, req Request) (Response, error) {
|
||||
dispatched = true
|
||||
return NewTellResponse("ok"), nil
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
if dispatched {
|
||||
t.Fatal("dispatcher should not be called when signature validation fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed json body returns 400", func(t *testing.T) {
|
||||
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json"))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stale timestamp returns 401", func(t *testing.T) {
|
||||
body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now().Add(-10*time.Minute))
|
||||
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("application id mismatch returns 401", func(t *testing.T) {
|
||||
body := validRequestBody(t, "some-other-skill", "LaunchRequest", time.Now())
|
||||
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dispatcher error returns 500", func(t *testing.T) {
|
||||
body := validRequestBody(t, testSkillID, "IntentRequest", time.Now())
|
||||
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{
|
||||
respFunc: func(ctx context.Context, req Request) (Response, error) {
|
||||
return Response{}, errors.New("boom")
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body)))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("body read error returns 400", func(t *testing.T) {
|
||||
h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(&errReader{}))
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type errReader struct{}
|
||||
|
||||
func (e *errReader) Read(p []byte) (int, error) {
|
||||
return 0, errors.New("read failed")
|
||||
}
|
||||
262
alexa-bridge/internal/alexa/signature.go
Normal file
262
alexa-bridge/internal/alexa/signature.go
Normal file
@ -0,0 +1,262 @@
|
||||
package alexa
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1" //nolint:gosec // Alexa's request signing scheme is fixed as SHA1withRSA; not our choice.
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// certChainHost/certChainPathPrefix are Amazon's documented constraints
|
||||
// on where a request's SignatureCertChainUrl header is allowed to point
|
||||
// (https://developer.amazon.com/en-US/docs/alexa/custom-skills/host-a-custom-skill-as-a-web-service.html#check-the-signature-certificate-url).
|
||||
// Accepting a URL to an attacker-controlled host would let anyone
|
||||
// present their own signing certificate.
|
||||
certChainHost = "s3.amazonaws.com"
|
||||
certChainPathPrefix = "/echo.api/"
|
||||
certSANRequired = "echo-api.amazon.com"
|
||||
|
||||
// timestampTolerance is Amazon's recommended replay-protection window.
|
||||
timestampTolerance = 150 * time.Second
|
||||
)
|
||||
|
||||
// Validator verifies an incoming HTTP request against its Signature/
|
||||
// SignatureCertChainUrl headers. *SignatureValidator implements it; Handler
|
||||
// depends on this interface (not the concrete type) so tests can inject a
|
||||
// fake instead of exercising real crypto/network calls.
|
||||
type Validator interface {
|
||||
Validate(ctx context.Context, r *http.Request, body []byte) error
|
||||
}
|
||||
|
||||
// SignatureValidator verifies an incoming HTTP request actually came from
|
||||
// Alexa: the SignatureCertChainUrl points at Amazon's cert host, the
|
||||
// referenced certificate chain is valid and carries the expected SAN, and
|
||||
// the Signature header is a valid RSA-SHA1 signature over the raw request
|
||||
// body made with that certificate's key. Amazon's certs are cached by URL
|
||||
// until they expire, since the same handful of URLs are reused across many
|
||||
// requests.
|
||||
type SignatureValidator struct {
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]*cachedChain
|
||||
}
|
||||
|
||||
type cachedChain struct {
|
||||
leaf *x509.Certificate
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// NewSignatureValidator constructs a validator using the given HTTP client
|
||||
// to fetch certificate chains (pass nil to use http.DefaultClient).
|
||||
func NewSignatureValidator(httpClient *http.Client) *SignatureValidator {
|
||||
if httpClient == nil {
|
||||
httpClient = http.DefaultClient
|
||||
}
|
||||
return &SignatureValidator{httpClient: httpClient, cache: make(map[string]*cachedChain)}
|
||||
}
|
||||
|
||||
// Validate checks the SignatureCertChainUrl and Signature headers against
|
||||
// the raw request body. Callers must pass the exact bytes that were signed —
|
||||
// decoding then re-marshaling the JSON would produce different bytes and
|
||||
// always fail verification.
|
||||
func (v *SignatureValidator) Validate(ctx context.Context, r *http.Request, body []byte) error {
|
||||
chainURL := r.Header.Get("SignatureCertChainUrl")
|
||||
sigHeader := r.Header.Get("Signature")
|
||||
if chainURL == "" || sigHeader == "" {
|
||||
return errors.New("missing SignatureCertChainUrl or Signature header")
|
||||
}
|
||||
|
||||
if err := validateCertChainURL(chainURL); err != nil {
|
||||
return fmt.Errorf("invalid SignatureCertChainUrl: %w", err)
|
||||
}
|
||||
|
||||
leaf, err := v.leafCertificate(ctx, chainURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch/validate cert chain: %w", err)
|
||||
}
|
||||
|
||||
sig, err := base64.StdEncoding.DecodeString(sigHeader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode Signature header: %w", err)
|
||||
}
|
||||
|
||||
if err := verifySignature(leaf, body, sig); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifySignature(leaf *x509.Certificate, body, sig []byte) error {
|
||||
pubKey, ok := leaf.PublicKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("leaf certificate public key is %T, want RSA", leaf.PublicKey)
|
||||
}
|
||||
hash := sha1.Sum(body) //nolint:gosec // required by Alexa's fixed signing scheme
|
||||
if err := rsa.VerifyPKCS1v15(pubKey, crypto.SHA1, hash[:], sig); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCertChainURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse url: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(u.Scheme, "https") {
|
||||
return fmt.Errorf("scheme %q, want https", u.Scheme)
|
||||
}
|
||||
if !strings.EqualFold(u.Hostname(), certChainHost) {
|
||||
return fmt.Errorf("host %q, want %q", u.Hostname(), certChainHost)
|
||||
}
|
||||
if port := u.Port(); port != "" && port != "443" {
|
||||
return fmt.Errorf("port %q, want 443 or unset", port)
|
||||
}
|
||||
if !strings.HasPrefix(u.Path, certChainPathPrefix) {
|
||||
return fmt.Errorf("path %q, want prefix %q", u.Path, certChainPathPrefix)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *SignatureValidator) leafCertificate(ctx context.Context, chainURL string) (*x509.Certificate, error) {
|
||||
v.mu.RLock()
|
||||
cached, ok := v.cache[chainURL]
|
||||
v.mu.RUnlock()
|
||||
if ok && time.Now().Before(cached.expiresAt) {
|
||||
return cached.leaf, nil
|
||||
}
|
||||
|
||||
chain, err := fetchChain(ctx, v.httpClient, chainURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
leaf, err := verifyChain(chain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
v.cache[chainURL] = &cachedChain{leaf: leaf, expiresAt: leaf.NotAfter}
|
||||
v.mu.Unlock()
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
func fetchChain(ctx context.Context, httpClient *http.Client, chainURL string) ([]*x509.Certificate, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, chainURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status %d fetching cert chain", resp.StatusCode)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var chain []*x509.Certificate
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse certificate: %w", err)
|
||||
}
|
||||
chain = append(chain, cert)
|
||||
}
|
||||
if len(chain) == 0 {
|
||||
return nil, errors.New("no certificates found in chain")
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
// verifyChain validates the leaf certificate's expiry and required SAN, and
|
||||
// that the chain cryptographically links leaf -> intermediates -> the
|
||||
// chain's own terminal certificate (trusted here as the root: the chain was
|
||||
// already fetched over a TLS connection to s3.amazonaws.com whose own
|
||||
// certificate is checked against the system trust store, so this step is
|
||||
// about proving the leaf/intermediate/root signatures are internally
|
||||
// consistent and the leaf itself hasn't been tampered with or expired).
|
||||
func verifyChain(chain []*x509.Certificate) (*x509.Certificate, error) {
|
||||
leaf := chain[0]
|
||||
now := time.Now()
|
||||
if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) {
|
||||
return nil, fmt.Errorf("leaf certificate not valid at %s (window %s to %s)", now, leaf.NotBefore, leaf.NotAfter)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, san := range leaf.DNSNames {
|
||||
if strings.EqualFold(san, certSANRequired) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("leaf certificate SAN does not include %q", certSANRequired)
|
||||
}
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(chain[len(chain)-1])
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, c := range chain[1 : len(chain)-1] {
|
||||
intermediates.AddCert(c)
|
||||
}
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
CurrentTime: now,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("certificate chain verification failed: %w", err)
|
||||
}
|
||||
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
// ValidateTimestamp rejects requests outside Amazon's recommended
|
||||
// replay-protection window.
|
||||
func ValidateTimestamp(rfc3339 string) error {
|
||||
ts, err := time.Parse(time.RFC3339, rfc3339)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse request timestamp %q: %w", rfc3339, err)
|
||||
}
|
||||
if d := time.Since(ts); d < -timestampTolerance || d > timestampTolerance {
|
||||
return fmt.Errorf("request timestamp %s outside %s tolerance (age %s)", rfc3339, timestampTolerance, d)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateApplicationID checks the request envelope's application ID
|
||||
// against the configured skill ID. See alexa-bridge/plan.md's "Decisions on
|
||||
// open questions" #4.
|
||||
func ValidateApplicationID(req *Request, expectedSkillID string) error {
|
||||
got := req.ApplicationID()
|
||||
if got == "" {
|
||||
return errors.New("request has no application ID in context or session")
|
||||
}
|
||||
if got != expectedSkillID {
|
||||
return errors.New("application id does not match configured skill id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
408
alexa-bridge/internal/alexa/signature_test.go
Normal file
408
alexa-bridge/internal/alexa/signature_test.go
Normal file
@ -0,0 +1,408 @@
|
||||
package alexa
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha1" //nolint:gosec // matches Alexa's fixed signing scheme, see signature.go
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// generateTestChain builds a two-certificate PEM chain (leaf signed by a
|
||||
// throwaway root) for exercising verifyChain/verifySignature without
|
||||
// needing real Alexa-issued certificates.
|
||||
func generateTestChain(t *testing.T, sans []string, notBefore, notAfter time.Time) ([]byte, *rsa.PrivateKey) {
|
||||
t.Helper()
|
||||
|
||||
rootKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate root key: %v", err)
|
||||
}
|
||||
rootTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "Test Root CA"},
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create root cert: %v", err)
|
||||
}
|
||||
rootCert, err := x509.ParseCertificate(rootDER)
|
||||
if err != nil {
|
||||
t.Fatalf("parse root cert: %v", err)
|
||||
}
|
||||
|
||||
leafKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate leaf key: %v", err)
|
||||
}
|
||||
leafTemplate := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "echo-api.amazon.com"},
|
||||
DNSNames: sans,
|
||||
NotBefore: notBefore,
|
||||
NotAfter: notAfter,
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}
|
||||
leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, rootCert, &leafKey.PublicKey, rootKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create leaf cert: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: leafDER}); err != nil {
|
||||
t.Fatalf("encode leaf pem: %v", err)
|
||||
}
|
||||
if err := pem.Encode(&buf, &pem.Block{Type: "CERTIFICATE", Bytes: rootDER}); err != nil {
|
||||
t.Fatalf("encode root pem: %v", err)
|
||||
}
|
||||
return buf.Bytes(), leafKey
|
||||
}
|
||||
|
||||
func parseChain(t *testing.T, chainPEM []byte) []*x509.Certificate {
|
||||
t.Helper()
|
||||
var chain []*x509.Certificate
|
||||
data := chainPEM
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, data = pem.Decode(data)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
chain = append(chain, cert)
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
func TestValidateCertChainURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", url: "https://s3.amazonaws.com/echo.api/echo-api-cert.pem", wantErr: false},
|
||||
{name: "valid with explicit 443 port", url: "https://s3.amazonaws.com:443/echo.api/echo-api-cert.pem", wantErr: false},
|
||||
{name: "wrong scheme", url: "http://s3.amazonaws.com/echo.api/echo-api-cert.pem", wantErr: true},
|
||||
{name: "wrong host", url: "https://evil.example.com/echo.api/echo-api-cert.pem", wantErr: true},
|
||||
{name: "host looks similar but differs", url: "https://s3.amazonaws.com.evil.com/echo.api/echo-api-cert.pem", wantErr: true},
|
||||
{name: "wrong port", url: "https://s3.amazonaws.com:8443/echo.api/echo-api-cert.pem", wantErr: true},
|
||||
{name: "wrong path prefix", url: "https://s3.amazonaws.com/not-echo-api/echo-api-cert.pem", wantErr: true},
|
||||
{name: "unparsable", url: "://bad", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateCertChainURL(tt.url)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateCertChainURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyChain(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
t.Run("valid chain with correct SAN", func(t *testing.T) {
|
||||
chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
|
||||
leaf, err := verifyChain(parseChain(t, chainPEM))
|
||||
if err != nil {
|
||||
t.Fatalf("verifyChain() error = %v", err)
|
||||
}
|
||||
if leaf == nil {
|
||||
t.Fatal("verifyChain() leaf = nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing required SAN", func(t *testing.T) {
|
||||
chainPEM, _ := generateTestChain(t, []string{"not-echo-api.amazon.com"}, now.Add(-time.Hour), now.Add(time.Hour))
|
||||
if _, err := verifyChain(parseChain(t, chainPEM)); err == nil {
|
||||
t.Fatal("verifyChain() error = nil, want error for missing SAN")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired certificate", func(t *testing.T) {
|
||||
chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-2*time.Hour), now.Add(-time.Hour))
|
||||
if _, err := verifyChain(parseChain(t, chainPEM)); err == nil {
|
||||
t.Fatal("verifyChain() error = nil, want error for expired cert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("not yet valid certificate", func(t *testing.T) {
|
||||
chainPEM, _ := generateTestChain(t, []string{certSANRequired}, now.Add(time.Hour), now.Add(2*time.Hour))
|
||||
if _, err := verifyChain(parseChain(t, chainPEM)); err == nil {
|
||||
t.Fatal("verifyChain() error = nil, want error for not-yet-valid cert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaf not actually signed by the presented root", func(t *testing.T) {
|
||||
chainA, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
|
||||
chainB, _ := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
|
||||
mismatched := []*x509.Certificate{parseChain(t, chainA)[0], parseChain(t, chainB)[1]}
|
||||
if _, err := verifyChain(mismatched); err == nil {
|
||||
t.Fatal("verifyChain() error = nil, want error for mismatched leaf/root")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestVerifySignature(t *testing.T) {
|
||||
now := time.Now()
|
||||
chainPEM, leafKey := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
|
||||
leaf := parseChain(t, chainPEM)[0]
|
||||
|
||||
body := []byte(`{"request":{"type":"LaunchRequest"}}`)
|
||||
hash := sha1.Sum(body) //nolint:gosec
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, leafKey, crypto.SHA1, hash[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign body: %v", err)
|
||||
}
|
||||
|
||||
t.Run("valid signature", func(t *testing.T) {
|
||||
if err := verifySignature(leaf, body, sig); err != nil {
|
||||
t.Fatalf("verifySignature() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tampered body fails", func(t *testing.T) {
|
||||
if err := verifySignature(leaf, []byte(`{"request":{"type":"IntentRequest"}}`), sig); err == nil {
|
||||
t.Fatal("verifySignature() error = nil, want error for tampered body")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("garbage signature fails", func(t *testing.T) {
|
||||
if err := verifySignature(leaf, body, []byte("not a real signature")); err == nil {
|
||||
t.Fatal("verifySignature() error = nil, want error for garbage signature")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// rewriteTransport redirects requests to a local httptest.Server while
|
||||
// leaving the request's URL (as seen by application code, e.g.
|
||||
// validateCertChainURL's host check) untouched.
|
||||
type rewriteTransport struct {
|
||||
target *url.URL
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
req.URL.Scheme = t.target.Scheme
|
||||
req.URL.Host = t.target.Host
|
||||
return t.base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func TestSignatureValidatorValidate(t *testing.T) {
|
||||
now := time.Now()
|
||||
chainPEM, leafKey := generateTestChain(t, []string{certSANRequired}, now.Add(-time.Hour), now.Add(time.Hour))
|
||||
|
||||
newTestValidator := func(t *testing.T, handler http.HandlerFunc) (*SignatureValidator, func()) {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(handler)
|
||||
targetURL, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse test server url: %v", err)
|
||||
}
|
||||
httpClient := &http.Client{Transport: &rewriteTransport{target: targetURL, base: http.DefaultTransport}}
|
||||
return NewSignatureValidator(httpClient), ts.Close
|
||||
}
|
||||
|
||||
signBody := func(t *testing.T, key *rsa.PrivateKey, body []byte) string {
|
||||
t.Helper()
|
||||
hash := sha1.Sum(body) //nolint:gosec
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA1, hash[:])
|
||||
if err != nil {
|
||||
t.Fatalf("sign body: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(sig)
|
||||
}
|
||||
|
||||
t.Run("valid request end to end", func(t *testing.T) {
|
||||
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(chainPEM)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
body := []byte(`{"request":{"type":"LaunchRequest"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
|
||||
req.Header.Set("Signature", signBody(t, leafKey, body))
|
||||
|
||||
if err := v.Validate(context.Background(), req, body); err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("caches the leaf certificate across calls", func(t *testing.T) {
|
||||
fetches := 0
|
||||
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fetches++
|
||||
_, _ = w.Write(chainPEM)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
body := []byte(`{"request":{"type":"LaunchRequest"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
|
||||
req.Header.Set("Signature", signBody(t, leafKey, body))
|
||||
|
||||
if err := v.Validate(context.Background(), req, body); err != nil {
|
||||
t.Fatalf("Validate() #1 error = %v", err)
|
||||
}
|
||||
if err := v.Validate(context.Background(), req, body); err != nil {
|
||||
t.Fatalf("Validate() #2 error = %v", err)
|
||||
}
|
||||
if fetches != 1 {
|
||||
t.Fatalf("fetches = %d, want 1 (second Validate should hit the cache)", fetches)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing headers", func(t *testing.T) {
|
||||
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(chainPEM)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
body := []byte(`{}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
if err := v.Validate(context.Background(), req, body); err == nil {
|
||||
t.Fatal("Validate() error = nil, want error for missing headers")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cert chain url pointing at the wrong host is rejected before any fetch", func(t *testing.T) {
|
||||
fetched := false
|
||||
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
fetched = true
|
||||
_, _ = w.Write(chainPEM)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
body := []byte(`{}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("SignatureCertChainUrl", "https://evil.example.com/echo.api/echo-api-cert.pem")
|
||||
req.Header.Set("Signature", signBody(t, leafKey, body))
|
||||
|
||||
if err := v.Validate(context.Background(), req, body); err == nil {
|
||||
t.Fatal("Validate() error = nil, want error for wrong cert chain host")
|
||||
}
|
||||
if fetched {
|
||||
t.Fatal("cert chain should never be fetched when the URL itself is rejected")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("signature does not match body", func(t *testing.T) {
|
||||
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(chainPEM)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
signedBody := []byte(`{"request":{"type":"LaunchRequest"}}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(signedBody))
|
||||
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
|
||||
req.Header.Set("Signature", signBody(t, leafKey, signedBody))
|
||||
|
||||
tamperedBody := []byte(`{"request":{"type":"IntentRequest"}}`)
|
||||
if err := v.Validate(context.Background(), req, tamperedBody); err == nil {
|
||||
t.Fatal("Validate() error = nil, want error for body/signature mismatch")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetch failure surfaces as an error", func(t *testing.T) {
|
||||
v, closeServer := newTestValidator(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
defer closeServer()
|
||||
|
||||
body := []byte(`{}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("SignatureCertChainUrl", "https://s3.amazonaws.com/echo.api/echo-api-cert.pem")
|
||||
req.Header.Set("Signature", signBody(t, leafKey, body))
|
||||
|
||||
if err := v.Validate(context.Background(), req, body); err == nil {
|
||||
t.Fatal("Validate() error = nil, want error when cert fetch fails")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateTimestamp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ts string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "now", ts: time.Now().Format(time.RFC3339), wantErr: false},
|
||||
{name: "within tolerance", ts: time.Now().Add(-100 * time.Second).Format(time.RFC3339), wantErr: false},
|
||||
{name: "too old", ts: time.Now().Add(-200 * time.Second).Format(time.RFC3339), wantErr: true},
|
||||
{name: "too far in the future", ts: time.Now().Add(200 * time.Second).Format(time.RFC3339), wantErr: true},
|
||||
{name: "unparsable", ts: "not-a-timestamp", wantErr: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateTimestamp(tt.ts)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("ValidateTimestamp(%q) error = %v, wantErr %v", tt.ts, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateApplicationID(t *testing.T) {
|
||||
const skillID = "amzn1.ask.skill.test"
|
||||
|
||||
t.Run("matches via context", func(t *testing.T) {
|
||||
req := &Request{Context: &Context{System: System{Application: Application{ApplicationID: skillID}}}}
|
||||
if err := ValidateApplicationID(req, skillID); err != nil {
|
||||
t.Fatalf("ValidateApplicationID() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("matches via session fallback when context absent", func(t *testing.T) {
|
||||
req := &Request{Session: &Session{Application: Application{ApplicationID: skillID}}}
|
||||
if err := ValidateApplicationID(req, skillID); err != nil {
|
||||
t.Fatalf("ValidateApplicationID() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("context takes precedence over session", func(t *testing.T) {
|
||||
req := &Request{
|
||||
Context: &Context{System: System{Application: Application{ApplicationID: skillID}}},
|
||||
Session: &Session{Application: Application{ApplicationID: "some-other-skill"}},
|
||||
}
|
||||
if err := ValidateApplicationID(req, skillID); err != nil {
|
||||
t.Fatalf("ValidateApplicationID() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mismatch errors", func(t *testing.T) {
|
||||
req := &Request{Context: &Context{System: System{Application: Application{ApplicationID: "wrong-skill"}}}}
|
||||
if err := ValidateApplicationID(req, skillID); err == nil {
|
||||
t.Fatal("ValidateApplicationID() error = nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("neither context nor session present errors", func(t *testing.T) {
|
||||
req := &Request{}
|
||||
if err := ValidateApplicationID(req, skillID); err == nil {
|
||||
t.Fatal("ValidateApplicationID() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
126
alexa-bridge/internal/alexa/types.go
Normal file
126
alexa-bridge/internal/alexa/types.go
Normal file
@ -0,0 +1,126 @@
|
||||
// Package alexa implements the Alexa Custom Skill request/response protocol
|
||||
// edge: request/response envelope types, signature validation, and the HTTP
|
||||
// handler Alexa's servers call into. It hands decoded, verified requests to
|
||||
// a Dispatcher (internal/directive.Router) and returns whatever Response
|
||||
// the dispatcher builds.
|
||||
package alexa
|
||||
|
||||
// Request is the top-level envelope Alexa POSTs for every skill invocation.
|
||||
type Request struct {
|
||||
Version string `json:"version"`
|
||||
Session *Session `json:"session,omitempty"`
|
||||
Context *Context `json:"context,omitempty"`
|
||||
Request RequestBody `json:"request"`
|
||||
}
|
||||
|
||||
// ApplicationID returns the envelope's skill application ID, preferring
|
||||
// context.System.application.applicationId (populated on essentially all
|
||||
// real Custom Skill requests) and falling back to
|
||||
// session.application.applicationId for the legacy-but-still-valid case of
|
||||
// a request carrying a session without a top-level context. See
|
||||
// alexa-bridge/plan.md's "Decisions on open questions" #4.
|
||||
func (r *Request) ApplicationID() string {
|
||||
if r.Context != nil && r.Context.System.Application.ApplicationID != "" {
|
||||
return r.Context.System.Application.ApplicationID
|
||||
}
|
||||
if r.Session != nil {
|
||||
return r.Session.Application.ApplicationID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
New bool `json:"new"`
|
||||
SessionID string `json:"sessionId"`
|
||||
Application Application `json:"application"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
System System `json:"System"`
|
||||
}
|
||||
|
||||
type System struct {
|
||||
Application Application `json:"application"`
|
||||
User User `json:"user"`
|
||||
Device Device `json:"device"`
|
||||
}
|
||||
|
||||
type Application struct {
|
||||
ApplicationID string `json:"applicationId"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
type Device struct {
|
||||
DeviceID string `json:"deviceId"`
|
||||
}
|
||||
|
||||
// RequestBody's Type distinguishes LaunchRequest, IntentRequest, and
|
||||
// SessionEndedRequest — only IntentRequest populates Intent, only
|
||||
// SessionEndedRequest populates Reason.
|
||||
type RequestBody struct {
|
||||
Type string `json:"type"`
|
||||
RequestID string `json:"requestId"`
|
||||
Timestamp string `json:"timestamp"` // RFC3339
|
||||
Locale string `json:"locale"`
|
||||
Intent *Intent `json:"intent,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type Intent struct {
|
||||
Name string `json:"name"`
|
||||
ConfirmationStatus string `json:"confirmationStatus"`
|
||||
Slots map[string]Slot `json:"slots"`
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
ConfirmationStatus string `json:"confirmationStatus"`
|
||||
}
|
||||
|
||||
// Response is the top-level envelope returned to Alexa.
|
||||
type Response struct {
|
||||
Version string `json:"version"`
|
||||
SessionAttributes map[string]any `json:"sessionAttributes,omitempty"`
|
||||
Response ResponseBody `json:"response"`
|
||||
}
|
||||
|
||||
type ResponseBody struct {
|
||||
OutputSpeech *OutputSpeech `json:"outputSpeech,omitempty"`
|
||||
Card *Card `json:"card,omitempty"`
|
||||
Reprompt *Reprompt `json:"reprompt,omitempty"`
|
||||
ShouldEndSession bool `json:"shouldEndSession"`
|
||||
}
|
||||
|
||||
type OutputSpeech struct {
|
||||
Type string `json:"type"` // "PlainText" or "SSML"
|
||||
Text string `json:"text,omitempty"`
|
||||
SSML string `json:"ssml,omitempty"`
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
Type string `json:"type"` // "Simple"
|
||||
Title string `json:"title,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type Reprompt struct {
|
||||
OutputSpeech OutputSpeech `json:"outputSpeech"`
|
||||
}
|
||||
|
||||
// NewTellResponse builds a plain-text response that ends the session —
|
||||
// directive handlers use this for both success confirmations and user-facing
|
||||
// errors (e.g. "I couldn't find that device").
|
||||
func NewTellResponse(text string) Response {
|
||||
return Response{
|
||||
Version: "1.0",
|
||||
Response: ResponseBody{
|
||||
OutputSpeech: &OutputSpeech{Type: "PlainText", Text: text},
|
||||
ShouldEndSession: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
86
alexa-bridge/internal/config/config.go
Normal file
86
alexa-bridge/internal/config/config.go
Normal file
@ -0,0 +1,86 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config holds runtime configuration for the alexa-bridge process.
|
||||
type Config struct {
|
||||
HTTPPort string // HTTP_PORT, default "8080"
|
||||
AlexaSkillID string // ALEXA_SKILL_ID — verified against the request envelope's application ID (required)
|
||||
HAGatewayAddr string // HA_GATEWAY_ADDR, default "ha-gateway.home-services.svc.cluster.local:50051"
|
||||
HAGatewayServerName string // HA_GATEWAY_SERVER_NAME, default "ha-gateway.home-services.svc.cluster.local"
|
||||
TLSDir string // TLS_DIR, default "/tls" — unlike ai-gateway/discord-bot, mTLS is on by default here since alexa-bridge is internet-facing; set empty to disable for local dev
|
||||
EntityRefreshInterval time.Duration // ENTITY_REFRESH_INTERVAL, default 5m
|
||||
OTELEndpoint string // OTEL_ENDPOINT, empty disables telemetry
|
||||
LogLevel string // LOG_LEVEL, default "info"
|
||||
LogFormat string // LOG_FORMAT, default "json"
|
||||
}
|
||||
|
||||
// Load reads configuration from environment variables and applies defaults.
|
||||
func Load() (*Config, error) {
|
||||
skillID := os.Getenv("ALEXA_SKILL_ID")
|
||||
if skillID == "" {
|
||||
return nil, errors.New("ALEXA_SKILL_ID is required but not set")
|
||||
}
|
||||
|
||||
refreshInterval, err := parseDurationEnv("ENTITY_REFRESH_INTERVAL", 5*time.Minute)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
HTTPPort: getenvDefault("HTTP_PORT", "8080"),
|
||||
AlexaSkillID: skillID,
|
||||
HAGatewayAddr: getenvDefault("HA_GATEWAY_ADDR", "ha-gateway.home-services.svc.cluster.local:50051"),
|
||||
HAGatewayServerName: getenvDefault("HA_GATEWAY_SERVER_NAME", "ha-gateway.home-services.svc.cluster.local"),
|
||||
TLSDir: getenvDefault("TLS_DIR", "/tls"),
|
||||
EntityRefreshInterval: refreshInterval,
|
||||
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
|
||||
LogLevel: getenvDefault("LOG_LEVEL", "info"),
|
||||
LogFormat: getenvDefault("LOG_FORMAT", "json"),
|
||||
}
|
||||
if cfg.TLSDir != "" {
|
||||
if err := validateTLSDir(cfg.TLSDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getenvDefault(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseDurationEnv(key string, fallback time.Duration) (time.Duration, error) {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse %s: %w", key, err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func validateTLSDir(dir string) error {
|
||||
required := []string{"tls.crt", "tls.key", "ca.crt"}
|
||||
for _, name := range required {
|
||||
path := filepath.Join(dir, name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls dir validation failed for %s: %w", path, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return fmt.Errorf("tls dir validation failed for %s: %w", path, errors.New("expected file"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
12
alexa-bridge/internal/core/domain/entity.go
Normal file
12
alexa-bridge/internal/core/domain/entity.go
Normal file
@ -0,0 +1,12 @@
|
||||
package domain
|
||||
|
||||
// Entity is a resolvable, controllable Home Assistant entity.
|
||||
type Entity struct {
|
||||
// EntityID is the Home Assistant entity identifier, e.g. "light.living_room".
|
||||
EntityID string
|
||||
// FriendlyName is the user-facing name used to resolve Alexa's Device slot.
|
||||
FriendlyName string
|
||||
// Domain is the entity_id prefix (light, switch, climate) and selects which
|
||||
// ha-gateway RPC family Controller.ExecuteAction dispatches to.
|
||||
Domain string
|
||||
}
|
||||
12
alexa-bridge/internal/core/ports/driven/controller.go
Normal file
12
alexa-bridge/internal/core/ports/driven/controller.go
Normal file
@ -0,0 +1,12 @@
|
||||
package driven
|
||||
|
||||
import "context"
|
||||
|
||||
// Controller executes a named action against a Home Assistant entity. It is
|
||||
// generic across domains (light/switch/climate) rather than exposing typed
|
||||
// per-domain methods — the concrete ha-gateway RPC and request type for a
|
||||
// given (domain, action) pair are resolved inside the implementation
|
||||
// (internal/haclient), not here. See alexa-bridge/plan.md's action table.
|
||||
type Controller interface {
|
||||
ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error
|
||||
}
|
||||
11
alexa-bridge/internal/core/ports/driven/entities.go
Normal file
11
alexa-bridge/internal/core/ports/driven/entities.go
Normal file
@ -0,0 +1,11 @@
|
||||
package driven
|
||||
|
||||
import "gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
|
||||
|
||||
// EntityResolver resolves the free-form Device slot value Alexa sends into a
|
||||
// concrete Home Assistant entity, by normalized (lowercased, trimmed) friendly
|
||||
// name. Matching is exact-match only for v1 — see alexa-bridge/plan.md's
|
||||
// "Decisions on open questions" #5 for why fuzzy matching isn't implemented.
|
||||
type EntityResolver interface {
|
||||
Resolve(friendlyName string) (domain.Entity, bool)
|
||||
}
|
||||
71
alexa-bridge/internal/directive/intents.go
Normal file
71
alexa-bridge/internal/directive/intents.go
Normal file
@ -0,0 +1,71 @@
|
||||
package directive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
|
||||
)
|
||||
|
||||
func slotValue(slots map[string]alexa.Slot, name string) (string, error) {
|
||||
s, ok := slots[name]
|
||||
if !ok || s.Value == "" {
|
||||
return "", fmt.Errorf("missing or empty slot %q", name)
|
||||
}
|
||||
return s.Value, nil
|
||||
}
|
||||
|
||||
func brightnessParams(slots map[string]alexa.Slot) (map[string]any, error) {
|
||||
v, err := slotValue(slots, "Brightness")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"brightness_pct": v}, nil
|
||||
}
|
||||
|
||||
func colorTempParams(slots map[string]alexa.Slot) (map[string]any, error) {
|
||||
v, err := slotValue(slots, "ColorTemp")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"color_temp_kelvin": v}, nil
|
||||
}
|
||||
|
||||
// colorParams reads three separate numeric slots (Red/Green/Blue) rather
|
||||
// than a single named-color slot. A nicer "set it to warm white" voice UX
|
||||
// would need a custom slot type mapping color names to RGB triples — an
|
||||
// interaction-model addition, not a backend gap — deliberately left for
|
||||
// later rather than inventing a name/RGB table here.
|
||||
func colorParams(slots map[string]alexa.Slot) (map[string]any, error) {
|
||||
r, err := slotValue(slots, "Red")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g, err := slotValue(slots, "Green")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := slotValue(slots, "Blue")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"rgb_color": map[string]any{"r": r, "g": g, "b": b}}, nil
|
||||
}
|
||||
|
||||
func hvacModeParams(slots map[string]alexa.Slot) (map[string]any, error) {
|
||||
v, err := slotValue(slots, "HVACMode")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"hvac_mode": v}, nil
|
||||
}
|
||||
|
||||
// temperatureParams backs SetTemperatureIntent, added alongside
|
||||
// ClimateService.SetTemperature — see plan.md's "Decisions on open
|
||||
// questions" #1.
|
||||
func temperatureParams(slots map[string]alexa.Slot) (map[string]any, error) {
|
||||
v, err := slotValue(slots, "Temperature")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"target_temperature": v}, nil
|
||||
}
|
||||
150
alexa-bridge/internal/directive/router.go
Normal file
150
alexa-bridge/internal/directive/router.go
Normal file
@ -0,0 +1,150 @@
|
||||
// Package directive is the orchestration layer between the Alexa protocol
|
||||
// edge (internal/alexa) and ha-gateway (internal/haclient): it routes a
|
||||
// decoded IntentRequest to a handler, resolves the Device slot via
|
||||
// driven.EntityResolver, and executes the resulting action via
|
||||
// driven.Controller. See alexa-bridge/plan.md's action table for the full
|
||||
// (intent, action) mapping this implements.
|
||||
package directive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/ports/driven"
|
||||
)
|
||||
|
||||
// handlerFunc handles one already-type-dispatched IntentRequest.
|
||||
type handlerFunc func(ctx context.Context, r *Router, req alexa.Request) alexa.Response
|
||||
|
||||
// paramsFunc extracts a Controller.ExecuteAction params map from an
|
||||
// intent's slots, or an error if a required slot is missing/empty.
|
||||
type paramsFunc func(slots map[string]alexa.Slot) (map[string]any, error)
|
||||
|
||||
// Router implements alexa.Dispatcher.
|
||||
type Router struct {
|
||||
resolver driven.EntityResolver
|
||||
controller driven.Controller
|
||||
log *slog.Logger
|
||||
|
||||
handlers map[string]handlerFunc
|
||||
}
|
||||
|
||||
// NewRouter constructs a Router with the full set of built-in and custom
|
||||
// intent handlers registered.
|
||||
func NewRouter(resolver driven.EntityResolver, controller driven.Controller, log *slog.Logger) *Router {
|
||||
r := &Router{resolver: resolver, controller: controller, log: log}
|
||||
r.handlers = map[string]handlerFunc{
|
||||
"AMAZON.HelpIntent": handleHelp,
|
||||
"AMAZON.StopIntent": handleStop,
|
||||
"AMAZON.CancelIntent": handleStop,
|
||||
|
||||
"TurnOnIntent": handleAction("turn_on", nil),
|
||||
"TurnOffIntent": handleAction("turn_off", nil),
|
||||
"ToggleIntent": handleAction("toggle", nil),
|
||||
|
||||
"SetBrightnessIntent": handleAction("set_brightness", brightnessParams),
|
||||
"SetColorTempIntent": handleAction("set_color_temp", colorTempParams),
|
||||
"SetColorIntent": handleAction("set_color", colorParams),
|
||||
|
||||
"SetHVACModeIntent": handleAction("set_hvac_mode", hvacModeParams),
|
||||
"IncreaseTemperatureIntent": handleAction("increase_temperature", nil),
|
||||
"DecreaseTemperatureIntent": handleAction("decrease_temperature", nil),
|
||||
"SetTemperatureIntent": handleAction("set_temperature", temperatureParams),
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Dispatch implements alexa.Dispatcher.
|
||||
func (r *Router) Dispatch(ctx context.Context, req alexa.Request) (alexa.Response, error) {
|
||||
switch req.Request.Type {
|
||||
case "LaunchRequest":
|
||||
return handleLaunch(), nil
|
||||
case "SessionEndedRequest":
|
||||
return alexa.Response{Version: "1.0"}, nil
|
||||
case "IntentRequest":
|
||||
return r.dispatchIntent(ctx, req), nil
|
||||
default:
|
||||
r.log.Warn("unrecognized request type", "type", req.Request.Type)
|
||||
return alexa.NewTellResponse("Sorry, I didn't understand that request."), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) dispatchIntent(ctx context.Context, req alexa.Request) alexa.Response {
|
||||
if req.Request.Intent == nil {
|
||||
return alexa.NewTellResponse("Sorry, I didn't understand that request.")
|
||||
}
|
||||
h, ok := r.handlers[req.Request.Intent.Name]
|
||||
if !ok {
|
||||
r.log.Warn("unrecognized intent", "intent", req.Request.Intent.Name)
|
||||
return alexa.NewTellResponse("Sorry, I don't know how to do that yet.")
|
||||
}
|
||||
return h(ctx, r, req)
|
||||
}
|
||||
|
||||
// handleAction builds a handlerFunc that resolves the Device slot,
|
||||
// optionally extracts action params, and executes the action via
|
||||
// driven.Controller. Controller/resolution failures are turned into a
|
||||
// spoken failure response rather than propagated as a Dispatch error — a
|
||||
// Dispatch error becomes an HTTP 500 and Alexa's own generic failure
|
||||
// speech, which is a worse experience than the skill explaining what went
|
||||
// wrong itself.
|
||||
func handleAction(action string, paramsFn paramsFunc) handlerFunc {
|
||||
return func(ctx context.Context, r *Router, req alexa.Request) alexa.Response {
|
||||
slots := req.Request.Intent.Slots
|
||||
deviceSlot, ok := slots["Device"]
|
||||
if !ok || deviceSlot.Value == "" {
|
||||
return alexa.NewTellResponse("Sorry, I didn't catch which device you meant.")
|
||||
}
|
||||
|
||||
entity, ok := r.resolver.Resolve(deviceSlot.Value)
|
||||
if !ok {
|
||||
return alexa.NewTellResponse(fmt.Sprintf("Sorry, I couldn't find a device named %s.", deviceSlot.Value))
|
||||
}
|
||||
|
||||
var params map[string]any
|
||||
if paramsFn != nil {
|
||||
p, err := paramsFn(slots)
|
||||
if err != nil {
|
||||
r.log.Warn("invalid intent slots", "action", action, "entity_id", entity.EntityID, "err", err)
|
||||
return alexa.NewTellResponse("Sorry, I didn't understand that request.")
|
||||
}
|
||||
params = p
|
||||
}
|
||||
|
||||
if err := r.controller.ExecuteAction(ctx, entity.EntityID, action, params); err != nil {
|
||||
r.log.Error("execute action failed", "entity_id", entity.EntityID, "action", action, "err", err)
|
||||
return alexa.NewTellResponse(fmt.Sprintf("Sorry, I couldn't do that to the %s.", entity.FriendlyName))
|
||||
}
|
||||
|
||||
return alexa.NewTellResponse(fmt.Sprintf("OK, done with the %s.", entity.FriendlyName))
|
||||
}
|
||||
}
|
||||
|
||||
func handleLaunch() alexa.Response {
|
||||
return alexa.Response{
|
||||
Version: "1.0",
|
||||
Response: alexa.ResponseBody{
|
||||
OutputSpeech: &alexa.OutputSpeech{Type: "PlainText", Text: "Home control ready. What would you like to do?"},
|
||||
ShouldEndSession: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleHelp(_ context.Context, _ *Router, _ alexa.Request) alexa.Response {
|
||||
return alexa.Response{
|
||||
Version: "1.0",
|
||||
Response: alexa.ResponseBody{
|
||||
OutputSpeech: &alexa.OutputSpeech{
|
||||
Type: "PlainText",
|
||||
Text: "You can ask me to turn on or off lights, switches, and climate devices, or set brightness and temperature.",
|
||||
},
|
||||
ShouldEndSession: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func handleStop(_ context.Context, _ *Router, _ alexa.Request) alexa.Response {
|
||||
return alexa.NewTellResponse("Goodbye.")
|
||||
}
|
||||
264
alexa-bridge/internal/directive/router_test.go
Normal file
264
alexa-bridge/internal/directive/router_test.go
Normal file
@ -0,0 +1,264 @@
|
||||
package directive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/alexa"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
type fakeResolver struct {
|
||||
entities map[string]domain.Entity
|
||||
}
|
||||
|
||||
func (f *fakeResolver) Resolve(friendlyName string) (domain.Entity, bool) {
|
||||
e, ok := f.entities[friendlyName]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
type fakeController struct {
|
||||
executeFunc func(ctx context.Context, entityID, action string, params map[string]any) error
|
||||
gotEntityID string
|
||||
gotAction string
|
||||
gotParams map[string]any
|
||||
}
|
||||
|
||||
func (f *fakeController) ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error {
|
||||
f.gotEntityID = entityID
|
||||
f.gotAction = action
|
||||
f.gotParams = params
|
||||
if f.executeFunc == nil {
|
||||
return nil
|
||||
}
|
||||
return f.executeFunc(ctx, entityID, action, params)
|
||||
}
|
||||
|
||||
func intentRequest(intentName string, slots map[string]alexa.Slot) alexa.Request {
|
||||
return alexa.Request{
|
||||
Version: "1.0",
|
||||
Request: alexa.RequestBody{
|
||||
Type: "IntentRequest",
|
||||
Intent: &alexa.Intent{
|
||||
Name: intentName,
|
||||
Slots: slots,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouterDispatchLaunchAndSessionEnded(t *testing.T) {
|
||||
r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger())
|
||||
|
||||
t.Run("LaunchRequest keeps the session open", func(t *testing.T) {
|
||||
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "LaunchRequest"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if resp.Response.ShouldEndSession {
|
||||
t.Fatal("LaunchRequest should not end the session")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SessionEndedRequest returns an empty response", func(t *testing.T) {
|
||||
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SessionEndedRequest"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if resp.Version != "1.0" {
|
||||
t.Fatalf("Version = %q, want %q", resp.Version, "1.0")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unrecognized request type does not error", func(t *testing.T) {
|
||||
resp, err := r.Dispatch(context.Background(), alexa.Request{Request: alexa.RequestBody{Type: "SomethingElse"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil {
|
||||
t.Fatal("expected a spoken fallback response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouterDispatchBuiltinIntents(t *testing.T) {
|
||||
r := NewRouter(&fakeResolver{}, &fakeController{}, discardLogger())
|
||||
|
||||
tests := []string{"AMAZON.HelpIntent", "AMAZON.StopIntent", "AMAZON.CancelIntent"}
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resp, err := r.Dispatch(context.Background(), intentRequest(name, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil || resp.Response.OutputSpeech.Text == "" {
|
||||
t.Fatal("expected spoken output")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("unrecognized intent", func(t *testing.T) {
|
||||
resp, err := r.Dispatch(context.Background(), intentRequest("NoSuchIntent", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil {
|
||||
t.Fatal("expected a spoken fallback response")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRouterDispatchDeviceActions(t *testing.T) {
|
||||
entity := domain.Entity{EntityID: "light.living_room", FriendlyName: "Living Room Lamp", Domain: "light"}
|
||||
resolver := &fakeResolver{entities: map[string]domain.Entity{"living room lamp": entity}}
|
||||
|
||||
t.Run("TurnOnIntent resolves device and executes turn_on with no params", func(t *testing.T) {
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
|
||||
resp, err := r.Dispatch(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if ctrl.gotEntityID != "light.living_room" || ctrl.gotAction != "turn_on" {
|
||||
t.Fatalf("ExecuteAction called with (%q, %q), want (light.living_room, turn_on)", ctrl.gotEntityID, ctrl.gotAction)
|
||||
}
|
||||
if ctrl.gotParams != nil {
|
||||
t.Fatalf("params = %#v, want nil", ctrl.gotParams)
|
||||
}
|
||||
if !resp.Response.ShouldEndSession {
|
||||
t.Fatal("a completed action should end the session")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetBrightnessIntent forwards the Brightness slot as brightness_pct", func(t *testing.T) {
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{
|
||||
"Device": {Name: "Device", Value: "living room lamp"},
|
||||
"Brightness": {Name: "Brightness", Value: "50"},
|
||||
})
|
||||
if _, err := r.Dispatch(context.Background(), req); err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if ctrl.gotAction != "set_brightness" || ctrl.gotParams["brightness_pct"] != "50" {
|
||||
t.Fatalf("action/params = %q/%#v, want set_brightness/{brightness_pct:50}", ctrl.gotAction, ctrl.gotParams)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetTemperatureIntent forwards the Temperature slot as target_temperature", func(t *testing.T) {
|
||||
climateEntity := domain.Entity{EntityID: "climate.air_conditioner", FriendlyName: "Air Conditioner", Domain: "climate"}
|
||||
climateResolver := &fakeResolver{entities: map[string]domain.Entity{"air conditioner": climateEntity}}
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(climateResolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("SetTemperatureIntent", map[string]alexa.Slot{
|
||||
"Device": {Name: "Device", Value: "air conditioner"},
|
||||
"Temperature": {Name: "Temperature", Value: "23.5"},
|
||||
})
|
||||
if _, err := r.Dispatch(context.Background(), req); err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if ctrl.gotEntityID != "climate.air_conditioner" || ctrl.gotAction != "set_temperature" {
|
||||
t.Fatalf("entity/action = %q/%q, want climate.air_conditioner/set_temperature", ctrl.gotEntityID, ctrl.gotAction)
|
||||
}
|
||||
if ctrl.gotParams["target_temperature"] != "23.5" {
|
||||
t.Fatalf("params = %#v, want target_temperature=23.5", ctrl.gotParams)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SetColorIntent forwards Red/Green/Blue slots as an rgb_color map", func(t *testing.T) {
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("SetColorIntent", map[string]alexa.Slot{
|
||||
"Device": {Name: "Device", Value: "living room lamp"},
|
||||
"Red": {Name: "Red", Value: "255"},
|
||||
"Green": {Name: "Green", Value: "0"},
|
||||
"Blue": {Name: "Blue", Value: "0"},
|
||||
})
|
||||
if _, err := r.Dispatch(context.Background(), req); err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
rgb, ok := ctrl.gotParams["rgb_color"].(map[string]any)
|
||||
if !ok || rgb["r"] != "255" {
|
||||
t.Fatalf("rgb_color = %#v, want map with r=255", ctrl.gotParams["rgb_color"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing Device slot does not call the controller", func(t *testing.T) {
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("TurnOnIntent", nil)
|
||||
resp, err := r.Dispatch(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if ctrl.gotAction != "" {
|
||||
t.Fatal("controller should not be called without a Device slot")
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil {
|
||||
t.Fatal("expected a spoken response explaining the missing device")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unresolvable device does not call the controller", func(t *testing.T) {
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "nonexistent"}})
|
||||
resp, err := r.Dispatch(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if ctrl.gotAction != "" {
|
||||
t.Fatal("controller should not be called for an unresolvable device")
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil {
|
||||
t.Fatal("expected a spoken response explaining the device wasn't found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing required action slot does not call the controller", func(t *testing.T) {
|
||||
ctrl := &fakeController{}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("SetBrightnessIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
|
||||
resp, err := r.Dispatch(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if ctrl.gotAction != "" {
|
||||
t.Fatal("controller should not be called when a required param slot is missing")
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil {
|
||||
t.Fatal("expected a spoken response explaining the request wasn't understood")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("controller error is turned into a spoken failure, not a Dispatch error", func(t *testing.T) {
|
||||
ctrl := &fakeController{executeFunc: func(ctx context.Context, entityID, action string, params map[string]any) error {
|
||||
return errors.New("ha-gateway unreachable")
|
||||
}}
|
||||
r := NewRouter(resolver, ctrl, discardLogger())
|
||||
|
||||
req := intentRequest("TurnOnIntent", map[string]alexa.Slot{"Device": {Name: "Device", Value: "living room lamp"}})
|
||||
resp, err := r.Dispatch(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v, want nil (errors should be spoken, not propagated)", err)
|
||||
}
|
||||
if resp.Response.OutputSpeech == nil {
|
||||
t.Fatal("expected a spoken failure response")
|
||||
}
|
||||
})
|
||||
}
|
||||
84
alexa-bridge/internal/entities/client.go
Normal file
84
alexa-bridge/internal/entities/client.go
Normal file
@ -0,0 +1,84 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
)
|
||||
|
||||
// Client fetches resolvable entities from ha-gateway's EntityService. It
|
||||
// calls ListStates once per configured domain (rather than one unfiltered
|
||||
// call) since ListStatesRequest.domain is a single string, not repeated —
|
||||
// see alexa-bridge/plan.md's Entity resolution section for why
|
||||
// EntityService was chosen over the domain-specific ListLights/
|
||||
// ListSwitches/ListClimates RPCs.
|
||||
type Client struct {
|
||||
entityClient hav1.EntityServiceClient
|
||||
domains []string
|
||||
}
|
||||
|
||||
// NewClient wraps an already-dialed EntityServiceClient — the connection
|
||||
// itself is owned by internal/haclient.Client, shared across both packages
|
||||
// rather than dialed twice. domains is the set of ha-gateway entity domains
|
||||
// to resolve (e.g. "light", "switch", "climate"); RemoteService/SwitchBot
|
||||
// entities are deliberately excluded (see plan.md Decisions #3).
|
||||
func NewClient(entityClient hav1.EntityServiceClient, domains []string) *Client {
|
||||
return &Client{entityClient: entityClient, domains: domains}
|
||||
}
|
||||
|
||||
type fetchResult struct {
|
||||
entities []domain.Entity
|
||||
err error
|
||||
}
|
||||
|
||||
// FetchAll fetches every entity across all configured domains, concurrently,
|
||||
// and fails all-or-nothing: a partial entity list (e.g. missing every switch
|
||||
// because just that one domain call failed) would silently break control
|
||||
// for that domain without an obvious error, which is worse than a clean
|
||||
// failure the caller can act on.
|
||||
func (c *Client) FetchAll(ctx context.Context) ([]domain.Entity, error) {
|
||||
results := make(chan fetchResult, len(c.domains))
|
||||
for _, d := range c.domains {
|
||||
go func(d string) {
|
||||
resp, err := c.entityClient.ListStates(ctx, &hav1.ListStatesRequest{Domain: d})
|
||||
if err != nil {
|
||||
results <- fetchResult{err: fmt.Errorf("list states for domain %q: %w", d, err)}
|
||||
return
|
||||
}
|
||||
out := make([]domain.Entity, 0, len(resp.GetStates()))
|
||||
for _, s := range resp.GetStates() {
|
||||
out = append(out, domain.Entity{
|
||||
EntityID: s.GetEntityId(),
|
||||
FriendlyName: firstNonEmpty(s.GetAttributes()["friendly_name"], s.GetEntityId()),
|
||||
Domain: d,
|
||||
})
|
||||
}
|
||||
results <- fetchResult{entities: out}
|
||||
}(d)
|
||||
}
|
||||
|
||||
var all []domain.Entity
|
||||
var errs error
|
||||
for range c.domains {
|
||||
r := <-results
|
||||
if r.err != nil {
|
||||
errs = errors.Join(errs, r.err)
|
||||
continue
|
||||
}
|
||||
all = append(all, r.entities...)
|
||||
}
|
||||
if errs != nil {
|
||||
return nil, errs
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(a, b string) string {
|
||||
if a != "" {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
109
alexa-bridge/internal/entities/client_test.go
Normal file
109
alexa-bridge/internal/entities/client_test.go
Normal file
@ -0,0 +1,109 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type fakeEntityServiceClient struct {
|
||||
listStatesFunc func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error)
|
||||
}
|
||||
|
||||
func (f *fakeEntityServiceClient) GetState(ctx context.Context, in *hav1.GetStateRequest, _ ...grpc.CallOption) (*hav1.GetStateResponse, error) {
|
||||
return &hav1.GetStateResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeEntityServiceClient) ListStates(ctx context.Context, in *hav1.ListStatesRequest, _ ...grpc.CallOption) (*hav1.ListStatesResponse, error) {
|
||||
if f.listStatesFunc == nil {
|
||||
return &hav1.ListStatesResponse{}, nil
|
||||
}
|
||||
return f.listStatesFunc(ctx, in)
|
||||
}
|
||||
|
||||
func TestClientFetchAll(t *testing.T) {
|
||||
t.Run("fetches per domain and extracts friendly_name", func(t *testing.T) {
|
||||
fake := &fakeEntityServiceClient{
|
||||
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
||||
switch in.GetDomain() {
|
||||
case "light":
|
||||
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
||||
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
||||
}}, nil
|
||||
case "switch":
|
||||
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
||||
{EntityId: "switch.fan", Attributes: map[string]string{"friendly_name": "Fan"}},
|
||||
}}, nil
|
||||
case "climate":
|
||||
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
||||
{EntityId: "climate.air_conditioner", Attributes: map[string]string{}},
|
||||
}}, nil
|
||||
}
|
||||
t.Fatalf("unexpected domain %q", in.GetDomain())
|
||||
return nil, nil
|
||||
},
|
||||
}
|
||||
c := NewClient(fake, []string{"light", "switch", "climate"})
|
||||
|
||||
got, err := c.FetchAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAll() error = %v", err)
|
||||
}
|
||||
sort.Slice(got, func(i, j int) bool { return got[i].EntityID < got[j].EntityID })
|
||||
|
||||
want := []struct {
|
||||
entityID, friendlyName, domain string
|
||||
}{
|
||||
{"climate.air_conditioner", "climate.air_conditioner", "climate"}, // no friendly_name attr -> falls back to entity_id
|
||||
{"light.living_room", "Living Room Lamp", "light"},
|
||||
{"switch.fan", "Fan", "switch"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len(got) = %d, want %d (got=%#v)", len(got), len(want), got)
|
||||
}
|
||||
for i, w := range want {
|
||||
if got[i].EntityID != w.entityID || got[i].FriendlyName != w.friendlyName || got[i].Domain != w.domain {
|
||||
t.Fatalf("got[%d] = %#v, want {%s %s %s}", i, got[i], w.entityID, w.friendlyName, w.domain)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one domain failing fails the whole fetch", func(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
fake := &fakeEntityServiceClient{
|
||||
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
||||
if in.GetDomain() == "switch" {
|
||||
return nil, wantErr
|
||||
}
|
||||
return &hav1.ListStatesResponse{}, nil
|
||||
},
|
||||
}
|
||||
c := NewClient(fake, []string{"light", "switch", "climate"})
|
||||
|
||||
got, err := c.FetchAll(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("FetchAll() error = nil, want error")
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("FetchAll() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("FetchAll() entities = %#v, want nil on partial failure", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty domain list returns empty result", func(t *testing.T) {
|
||||
c := NewClient(&fakeEntityServiceClient{}, nil)
|
||||
got, err := c.FetchAll(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("FetchAll() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("FetchAll() = %#v, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
62
alexa-bridge/internal/entities/refresher.go
Normal file
62
alexa-bridge/internal/entities/refresher.go
Normal file
@ -0,0 +1,62 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Refresher fetches entities from ha-gateway on startup (blocking, fail
|
||||
// loud — without an initial entity list nothing can resolve) and then
|
||||
// periodically in the background (non-blocking; a failed periodic refresh
|
||||
// only logs and keeps serving the last-known-good snapshot, since going
|
||||
// down over a transient ha-gateway hiccup would be worse than serving
|
||||
// slightly stale entity names).
|
||||
type Refresher struct {
|
||||
client *Client
|
||||
resolver *Resolver
|
||||
interval time.Duration
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// NewRefresher constructs a Refresher. client fetches entities, resolver is
|
||||
// the snapshot it populates, interval controls the periodic refresh cadence.
|
||||
func NewRefresher(client *Client, resolver *Resolver, interval time.Duration, log *slog.Logger) *Refresher {
|
||||
return &Refresher{client: client, resolver: resolver, interval: interval, log: log}
|
||||
}
|
||||
|
||||
// Start performs the blocking initial fetch, then launches a background
|
||||
// goroutine for periodic refreshes until ctx is cancelled. It returns an
|
||||
// error only from the initial fetch — the caller should fail startup on it.
|
||||
func (r *Refresher) Start(ctx context.Context) error {
|
||||
if err := r.refreshOnce(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
go r.loop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Refresher) refreshOnce(ctx context.Context) error {
|
||||
list, err := r.client.FetchAll(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.resolver.Replace(list)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Refresher) loop(ctx context.Context) {
|
||||
ticker := time.NewTicker(r.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := r.refreshOnce(ctx); err != nil {
|
||||
r.log.Error("periodic entity refresh failed, keeping stale data", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
154
alexa-bridge/internal/entities/refresher_test.go
Normal file
154
alexa-bridge/internal/entities/refresher_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
)
|
||||
|
||||
func discardLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
func TestRefresherStart(t *testing.T) {
|
||||
t.Run("happy path populates resolver before returning", func(t *testing.T) {
|
||||
fake := &fakeEntityServiceClient{
|
||||
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
||||
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
||||
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
client := NewClient(fake, []string{"light"})
|
||||
resolver := NewResolver()
|
||||
r := NewRefresher(client, resolver, time.Hour, discardLogger())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := r.Start(ctx); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
if _, ok := resolver.Resolve("Living Room Lamp"); !ok {
|
||||
t.Fatal("resolver not populated after Start() returned")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("initial fetch failure returns error and leaves resolver empty", func(t *testing.T) {
|
||||
wantErr := errors.New("ha-gateway unreachable")
|
||||
fake := &fakeEntityServiceClient{
|
||||
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}
|
||||
client := NewClient(fake, []string{"light"})
|
||||
resolver := NewResolver()
|
||||
r := NewRefresher(client, resolver, time.Hour, discardLogger())
|
||||
|
||||
err := r.Start(context.Background())
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("Start() error = %v, want it to wrap %v", err, wantErr)
|
||||
}
|
||||
if _, ok := resolver.Resolve("anything"); ok {
|
||||
t.Fatal("resolver should stay empty when the initial fetch fails")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefresherPeriodicRefresh(t *testing.T) {
|
||||
var calls int32
|
||||
called := make(chan struct{}, 16)
|
||||
fake := &fakeEntityServiceClient{
|
||||
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
select {
|
||||
case called <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
||||
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
client := NewClient(fake, []string{"light"})
|
||||
resolver := NewResolver()
|
||||
r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := r.Start(ctx); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
// The initial blocking fetch inside Start already counts as one call;
|
||||
// wait for at least two more from the background ticker loop.
|
||||
timeout := time.After(2 * time.Second)
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case <-called:
|
||||
case <-timeout:
|
||||
t.Fatal("timed out waiting for periodic refresh")
|
||||
}
|
||||
}
|
||||
if atomic.LoadInt32(&calls) < 2 {
|
||||
t.Fatalf("calls = %d, want at least 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresherPeriodicRefreshFailureDoesNotStopLoop(t *testing.T) {
|
||||
var calls int32
|
||||
called := make(chan struct{}, 16)
|
||||
fake := &fakeEntityServiceClient{
|
||||
listStatesFunc: func(ctx context.Context, in *hav1.ListStatesRequest) (*hav1.ListStatesResponse, error) {
|
||||
n := atomic.AddInt32(&calls, 1)
|
||||
select {
|
||||
case called <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
// Fail the initial fetch's implied call (n==1 is consumed by
|
||||
// Start synchronously below, so only the periodic ones matter
|
||||
// here); alternate failures thereafter to prove one bad
|
||||
// refresh doesn't wedge the loop.
|
||||
if n%2 == 0 {
|
||||
return nil, errors.New("transient ha-gateway error")
|
||||
}
|
||||
return &hav1.ListStatesResponse{States: []*hav1.EntityState{
|
||||
{EntityId: "light.living_room", Attributes: map[string]string{"friendly_name": "Living Room Lamp"}},
|
||||
}}, nil
|
||||
},
|
||||
}
|
||||
client := NewClient(fake, []string{"light"})
|
||||
resolver := NewResolver()
|
||||
r := NewRefresher(client, resolver, 5*time.Millisecond, discardLogger())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := r.Start(ctx); err != nil {
|
||||
t.Fatalf("Start() error = %v", err)
|
||||
}
|
||||
|
||||
timeout := time.After(2 * time.Second)
|
||||
for i := 0; i < 3; i++ {
|
||||
select {
|
||||
case <-called:
|
||||
case <-timeout:
|
||||
t.Fatal("timed out waiting for periodic refresh")
|
||||
}
|
||||
}
|
||||
if atomic.LoadInt32(&calls) < 3 {
|
||||
t.Fatalf("calls = %d, want at least 3 (loop should keep running through failures)", calls)
|
||||
}
|
||||
// The resolver should still hold whatever the last successful refresh
|
||||
// produced, even though some refreshes in between failed.
|
||||
if _, ok := resolver.Resolve("Living Room Lamp"); !ok {
|
||||
t.Fatal("resolver lost its last-known-good data after a failed periodic refresh")
|
||||
}
|
||||
}
|
||||
48
alexa-bridge/internal/entities/resolver.go
Normal file
48
alexa-bridge/internal/entities/resolver.go
Normal file
@ -0,0 +1,48 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
// Resolver implements driven.EntityResolver via an atomically-swapped
|
||||
// snapshot map, so an in-flight Alexa request never observes a half-built
|
||||
// map while a refresh is in progress.
|
||||
type Resolver struct {
|
||||
snapshot atomic.Pointer[map[string]domain.Entity]
|
||||
}
|
||||
|
||||
// NewResolver constructs a Resolver with an empty snapshot; call Replace
|
||||
// (directly, or via Refresher.Start) to populate it before serving requests.
|
||||
func NewResolver() *Resolver {
|
||||
r := &Resolver{}
|
||||
empty := map[string]domain.Entity{}
|
||||
r.snapshot.Store(&empty)
|
||||
return r
|
||||
}
|
||||
|
||||
// Replace rebuilds the lookup map, keyed by normalized friendly name, and
|
||||
// swaps it in atomically.
|
||||
func (r *Resolver) Replace(list []domain.Entity) {
|
||||
m := make(map[string]domain.Entity, len(list))
|
||||
for _, e := range list {
|
||||
m[normalize(e.FriendlyName)] = e
|
||||
}
|
||||
r.snapshot.Store(&m)
|
||||
}
|
||||
|
||||
// Resolve implements driven.EntityResolver. Matching is exact-match only on
|
||||
// the normalized (lowercased, trimmed) friendly name — see
|
||||
// alexa-bridge/plan.md's Decisions on open questions #5 for why fuzzy
|
||||
// matching isn't implemented here.
|
||||
func (r *Resolver) Resolve(friendlyName string) (domain.Entity, bool) {
|
||||
m := *r.snapshot.Load()
|
||||
e, ok := m[normalize(friendlyName)]
|
||||
return e, ok
|
||||
}
|
||||
|
||||
func normalize(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
60
alexa-bridge/internal/entities/resolver_test.go
Normal file
60
alexa-bridge/internal/entities/resolver_test.go
Normal file
@ -0,0 +1,60 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/core/domain"
|
||||
)
|
||||
|
||||
func TestResolverResolve(t *testing.T) {
|
||||
r := NewResolver()
|
||||
|
||||
t.Run("empty resolver misses", func(t *testing.T) {
|
||||
if _, ok := r.Resolve("Living Room Lamp"); ok {
|
||||
t.Fatal("Resolve() ok = true on empty resolver, want false")
|
||||
}
|
||||
})
|
||||
|
||||
r.Replace([]domain.Entity{
|
||||
{EntityID: "light.living_room", FriendlyName: "Living Room Lamp", Domain: "light"},
|
||||
{EntityID: "switch.fan", FriendlyName: "Fan", Domain: "switch"},
|
||||
})
|
||||
|
||||
t.Run("exact match after normalization", func(t *testing.T) {
|
||||
e, ok := r.Resolve(" living room lamp ")
|
||||
if !ok {
|
||||
t.Fatal("Resolve() ok = false, want true")
|
||||
}
|
||||
if e.EntityID != "light.living_room" {
|
||||
t.Fatalf("EntityID = %q, want %q", e.EntityID, "light.living_room")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-sensitivity does not matter", func(t *testing.T) {
|
||||
if _, ok := r.Resolve("LIVING ROOM LAMP"); !ok {
|
||||
t.Fatal("Resolve() ok = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no fuzzy match", func(t *testing.T) {
|
||||
if _, ok := r.Resolve("living room light"); ok {
|
||||
t.Fatal("Resolve() ok = true for a non-exact name, want false (exact-match only, see plan.md Decisions #5)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown name misses", func(t *testing.T) {
|
||||
if _, ok := r.Resolve("kitchen"); ok {
|
||||
t.Fatal("Resolve() ok = true, want false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Replace fully overwrites the previous snapshot", func(t *testing.T) {
|
||||
r.Replace([]domain.Entity{{EntityID: "light.bedroom", FriendlyName: "Bedroom", Domain: "light"}})
|
||||
if _, ok := r.Resolve("fan"); ok {
|
||||
t.Fatal("Resolve() found stale entry after Replace, want it gone")
|
||||
}
|
||||
if _, ok := r.Resolve("bedroom"); !ok {
|
||||
t.Fatal("Resolve() ok = false for newly replaced entry, want true")
|
||||
}
|
||||
})
|
||||
}
|
||||
103
alexa-bridge/internal/haclient/client.go
Normal file
103
alexa-bridge/internal/haclient/client.go
Normal file
@ -0,0 +1,103 @@
|
||||
package haclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
// Client is alexa-bridge's single mTLS connection to ha-gateway. It
|
||||
// implements driven.Controller directly (see controller.go) and also
|
||||
// exposes the raw EntityServiceClient for internal/entities to wrap, so the
|
||||
// whole process shares one connection/handshake to ha-gateway rather than
|
||||
// dialing it twice for two different RPC families.
|
||||
type Client struct {
|
||||
conn *grpc.ClientConn
|
||||
lightClient hav1.LightServiceClient
|
||||
switchClient hav1.SwitchServiceClient
|
||||
climateClient hav1.ClimateServiceClient
|
||||
entityClient hav1.EntityServiceClient
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New constructs a gRPC client for ha-gateway with optional mTLS.
|
||||
func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) {
|
||||
transportCreds := insecure.NewCredentials()
|
||||
if tlsDir != "" {
|
||||
creds, err := loadTransportCredentials(tlsDir, serverName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load mTLS credentials: %w", err)
|
||||
}
|
||||
transportCreds = creds
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(
|
||||
addr,
|
||||
grpc.WithTransportCredentials(transportCreds),
|
||||
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial ha-gateway: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
conn: conn,
|
||||
lightClient: hav1.NewLightServiceClient(conn),
|
||||
switchClient: hav1.NewSwitchServiceClient(conn),
|
||||
climateClient: hav1.NewClimateServiceClient(conn),
|
||||
entityClient: hav1.NewEntityServiceClient(conn),
|
||||
log: log,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EntityServiceClient exposes the shared connection's EntityService stub for
|
||||
// internal/entities to wrap, instead of that package dialing its own
|
||||
// second connection to ha-gateway.
|
||||
func (c *Client) EntityServiceClient() hav1.EntityServiceClient {
|
||||
return c.entityClient
|
||||
}
|
||||
|
||||
// Close closes the underlying gRPC connection.
|
||||
func (c *Client) Close() error {
|
||||
if err := c.conn.Close(); err != nil {
|
||||
return fmt.Errorf("close ha-gateway client: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadTransportCredentials(tlsDir, serverName string) (credentials.TransportCredentials, error) {
|
||||
cert, err := tls.LoadX509KeyPair(
|
||||
filepath.Join(tlsDir, "tls.crt"),
|
||||
filepath.Join(tlsDir, "tls.key"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client key pair: %w", err)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(filepath.Join(tlsDir, "ca.crt"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read server CA: %w", err)
|
||||
}
|
||||
|
||||
rootCAs := x509.NewCertPool()
|
||||
if !rootCAs.AppendCertsFromPEM(caPEM) {
|
||||
return nil, fmt.Errorf("append server CA: invalid PEM")
|
||||
}
|
||||
|
||||
return credentials.NewTLS(&tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
RootCAs: rootCAs,
|
||||
ServerName: serverName,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}), nil
|
||||
}
|
||||
127
alexa-bridge/internal/haclient/controller.go
Normal file
127
alexa-bridge/internal/haclient/controller.go
Normal file
@ -0,0 +1,127 @@
|
||||
package haclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
)
|
||||
|
||||
// ExecuteAction implements driven.Controller. It dispatches by entity domain
|
||||
// (the entity_id prefix up to the first '.') to the matching ha-gateway
|
||||
// service, then by action within that domain. See alexa-bridge/plan.md's
|
||||
// action table for the full (domain, action) -> RPC mapping.
|
||||
func (c *Client) ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error {
|
||||
domain, _, ok := strings.Cut(entityID, ".")
|
||||
if !ok {
|
||||
return fmt.Errorf("entity id %q has no domain prefix", entityID)
|
||||
}
|
||||
|
||||
switch domain {
|
||||
case "light":
|
||||
return c.executeLightAction(ctx, entityID, action, params)
|
||||
case "switch":
|
||||
return c.executeSwitchAction(ctx, entityID, action, params)
|
||||
case "climate":
|
||||
return c.executeClimateAction(ctx, entityID, action, params)
|
||||
default:
|
||||
return fmt.Errorf("unsupported entity domain %q for entity %q", domain, entityID)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) executeLightAction(ctx context.Context, entityID, action string, params map[string]any) error {
|
||||
switch action {
|
||||
case "turn_on":
|
||||
_, err := c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{
|
||||
EntityId: entityID,
|
||||
BrightnessPct: optUint32(params, "brightness_pct"),
|
||||
ColorTempKelvin: optUint32(params, "color_temp_kelvin"),
|
||||
RgbColor: optRGB(params, "rgb_color"),
|
||||
Transition: optUint32(params, "transition"),
|
||||
})
|
||||
return err
|
||||
case "turn_off":
|
||||
_, err := c.lightClient.TurnOff(ctx, &hav1.TurnOffRequest{
|
||||
EntityId: entityID,
|
||||
Transition: optUint32(params, "transition"),
|
||||
})
|
||||
return err
|
||||
case "toggle":
|
||||
_, err := c.lightClient.Toggle(ctx, &hav1.ToggleRequest{EntityId: entityID})
|
||||
return err
|
||||
case "set_brightness":
|
||||
brightness, err := reqUint32(params, "brightness_pct")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: &brightness})
|
||||
return err
|
||||
case "set_color_temp":
|
||||
colorTemp, err := reqUint32(params, "color_temp_kelvin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{EntityId: entityID, ColorTempKelvin: &colorTemp})
|
||||
return err
|
||||
case "set_color":
|
||||
rgb, err := reqRGB(params, "rgb_color")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.lightClient.TurnOn(ctx, &hav1.TurnOnRequest{EntityId: entityID, RgbColor: rgb})
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported light action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) executeSwitchAction(ctx context.Context, entityID, action string, _ map[string]any) error {
|
||||
req := &hav1.SwitchRequest{EntityId: entityID}
|
||||
switch action {
|
||||
case "turn_on":
|
||||
_, err := c.switchClient.TurnOn(ctx, req)
|
||||
return err
|
||||
case "turn_off":
|
||||
_, err := c.switchClient.TurnOff(ctx, req)
|
||||
return err
|
||||
case "toggle":
|
||||
_, err := c.switchClient.Toggle(ctx, req)
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported switch action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) executeClimateAction(ctx context.Context, entityID, action string, params map[string]any) error {
|
||||
switch action {
|
||||
case "turn_on":
|
||||
_, err := c.climateClient.TurnOn(ctx, &hav1.ClimateRequest{EntityId: entityID})
|
||||
return err
|
||||
case "turn_off":
|
||||
_, err := c.climateClient.TurnOff(ctx, &hav1.ClimateRequest{EntityId: entityID})
|
||||
return err
|
||||
case "set_hvac_mode":
|
||||
mode, err := reqString(params, "hvac_mode")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.climateClient.SetHVACMode(ctx, &hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: mode})
|
||||
return err
|
||||
case "increase_temperature":
|
||||
_, err := c.climateClient.IncreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID})
|
||||
return err
|
||||
case "decrease_temperature":
|
||||
_, err := c.climateClient.DecreaseTemperature(ctx, &hav1.ClimateRequest{EntityId: entityID})
|
||||
return err
|
||||
case "set_temperature":
|
||||
target, err := reqFloat64(params, "target_temperature")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.climateClient.SetTemperature(ctx, &hav1.SetTemperatureRequest{EntityId: entityID, TargetTemperature: target})
|
||||
return err
|
||||
default:
|
||||
return fmt.Errorf("unsupported climate action %q", action)
|
||||
}
|
||||
}
|
||||
461
alexa-bridge/internal/haclient/controller_test.go
Normal file
461
alexa-bridge/internal/haclient/controller_test.go
Normal file
@ -0,0 +1,461 @@
|
||||
package haclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
"google.golang.org/grpc"
|
||||
)
|
||||
|
||||
type fakeLightServiceClient struct {
|
||||
turnOnFunc func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error)
|
||||
turnOffFunc func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error)
|
||||
toggleFunc func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error)
|
||||
}
|
||||
|
||||
func (f *fakeLightServiceClient) TurnOn(ctx context.Context, in *hav1.TurnOnRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
|
||||
if f.turnOnFunc == nil {
|
||||
return &hav1.LightResponse{}, nil
|
||||
}
|
||||
return f.turnOnFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeLightServiceClient) TurnOff(ctx context.Context, in *hav1.TurnOffRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
|
||||
if f.turnOffFunc == nil {
|
||||
return &hav1.LightResponse{}, nil
|
||||
}
|
||||
return f.turnOffFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeLightServiceClient) Toggle(ctx context.Context, in *hav1.ToggleRequest, _ ...grpc.CallOption) (*hav1.LightResponse, error) {
|
||||
if f.toggleFunc == nil {
|
||||
return &hav1.LightResponse{}, nil
|
||||
}
|
||||
return f.toggleFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeLightServiceClient) ListLights(ctx context.Context, in *hav1.ListLightsRequest, _ ...grpc.CallOption) (*hav1.ListLightsResponse, error) {
|
||||
return &hav1.ListLightsResponse{}, nil
|
||||
}
|
||||
|
||||
type fakeSwitchServiceClient struct {
|
||||
turnOnFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
|
||||
turnOffFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
|
||||
toggleFunc func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error)
|
||||
}
|
||||
|
||||
func (f *fakeSwitchServiceClient) TurnOn(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
|
||||
if f.turnOnFunc == nil {
|
||||
return &hav1.SwitchResponse{}, nil
|
||||
}
|
||||
return f.turnOnFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeSwitchServiceClient) TurnOff(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
|
||||
if f.turnOffFunc == nil {
|
||||
return &hav1.SwitchResponse{}, nil
|
||||
}
|
||||
return f.turnOffFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeSwitchServiceClient) Toggle(ctx context.Context, in *hav1.SwitchRequest, _ ...grpc.CallOption) (*hav1.SwitchResponse, error) {
|
||||
if f.toggleFunc == nil {
|
||||
return &hav1.SwitchResponse{}, nil
|
||||
}
|
||||
return f.toggleFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeSwitchServiceClient) ListSwitches(ctx context.Context, in *hav1.ListSwitchesRequest, _ ...grpc.CallOption) (*hav1.ListSwitchesResponse, error) {
|
||||
return &hav1.ListSwitchesResponse{}, nil
|
||||
}
|
||||
|
||||
type fakeClimateServiceClient struct {
|
||||
turnOnFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
|
||||
turnOffFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
|
||||
increaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
|
||||
decreaseTemperatureFunc func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error)
|
||||
setTemperatureFunc func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error)
|
||||
setHVACModeFunc func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) TurnOn(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
|
||||
if f.turnOnFunc == nil {
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
}
|
||||
return f.turnOnFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) TurnOff(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
|
||||
if f.turnOffFunc == nil {
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
}
|
||||
return f.turnOffFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) IncreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
|
||||
if f.increaseTemperatureFunc == nil {
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
}
|
||||
return f.increaseTemperatureFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) DecreaseTemperature(ctx context.Context, in *hav1.ClimateRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
|
||||
if f.decreaseTemperatureFunc == nil {
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
}
|
||||
return f.decreaseTemperatureFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) SetTemperature(ctx context.Context, in *hav1.SetTemperatureRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
|
||||
if f.setTemperatureFunc == nil {
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
}
|
||||
return f.setTemperatureFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) SetHVACMode(ctx context.Context, in *hav1.SetHVACModeRequest, _ ...grpc.CallOption) (*hav1.ClimateResponse, error) {
|
||||
if f.setHVACModeFunc == nil {
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
}
|
||||
return f.setHVACModeFunc(ctx, in)
|
||||
}
|
||||
|
||||
func (f *fakeClimateServiceClient) ListClimates(ctx context.Context, in *hav1.ListClimatesRequest, _ ...grpc.CallOption) (*hav1.ListClimatesResponse, error) {
|
||||
return &hav1.ListClimatesResponse{}, nil
|
||||
}
|
||||
|
||||
func TestExecuteActionLight(t *testing.T) {
|
||||
t.Run("turn_on forwards optional params", func(t *testing.T) {
|
||||
var got *hav1.TurnOnRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", map[string]any{
|
||||
"brightness_pct": "80",
|
||||
"color_temp_kelvin": "2700",
|
||||
"rgb_color": map[string]any{"r": "255", "g": "0", "b": "0"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetEntityId() != "light.living_room" {
|
||||
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room")
|
||||
}
|
||||
if got.GetBrightnessPct() != 80 {
|
||||
t.Fatalf("BrightnessPct = %v, want 80", got.GetBrightnessPct())
|
||||
}
|
||||
if got.GetColorTempKelvin() != 2700 {
|
||||
t.Fatalf("ColorTempKelvin = %v, want 2700", got.GetColorTempKelvin())
|
||||
}
|
||||
if got.GetRgbColor().GetR() != 255 {
|
||||
t.Fatalf("RgbColor.R = %v, want 255", got.GetRgbColor().GetR())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("turn_on with no params sets no optional fields", func(t *testing.T) {
|
||||
var got *hav1.TurnOnRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.BrightnessPct != nil || got.ColorTempKelvin != nil || got.RgbColor != nil {
|
||||
t.Fatalf("expected no optional fields set, got %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("turn_off forwards transition", func(t *testing.T) {
|
||||
var got *hav1.TurnOffRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOffFunc: func(ctx context.Context, in *hav1.TurnOffRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := c.ExecuteAction(context.Background(), "light.living_room", "turn_off", map[string]any{"transition": "3"}); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetTransition() != 3 {
|
||||
t.Fatalf("Transition = %v, want 3", got.GetTransition())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("toggle", func(t *testing.T) {
|
||||
var got *hav1.ToggleRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
toggleFunc: func(ctx context.Context, in *hav1.ToggleRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := c.ExecuteAction(context.Background(), "light.living_room", "toggle", nil); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetEntityId() != "light.living_room" {
|
||||
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "light.living_room")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_brightness reuses TurnOn with only brightness set", func(t *testing.T) {
|
||||
var got *hav1.TurnOnRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", map[string]any{"brightness_pct": "50"}); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetBrightnessPct() != 50 {
|
||||
t.Fatalf("BrightnessPct = %v, want 50", got.GetBrightnessPct())
|
||||
}
|
||||
if got.ColorTempKelvin != nil || got.RgbColor != nil {
|
||||
t.Fatalf("expected only brightness set, got %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_brightness missing param errors without calling RPC", func(t *testing.T) {
|
||||
called := false
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
called = true
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
err := c.ExecuteAction(context.Background(), "light.living_room", "set_brightness", nil)
|
||||
if err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("TurnOn should not be called when the required param is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_color_temp", func(t *testing.T) {
|
||||
var got *hav1.TurnOnRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := c.ExecuteAction(context.Background(), "light.living_room", "set_color_temp", map[string]any{"color_temp_kelvin": "4000"}); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetColorTempKelvin() != 4000 {
|
||||
t.Fatalf("ColorTempKelvin = %v, want 4000", got.GetColorTempKelvin())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_color", func(t *testing.T) {
|
||||
var got *hav1.TurnOnRequest
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
got = in
|
||||
return &hav1.LightResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
err := c.ExecuteAction(context.Background(), "light.living_room", "set_color", map[string]any{
|
||||
"rgb_color": map[string]any{"r": "10", "g": "20", "b": "30"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetRgbColor().GetR() != 10 || got.GetRgbColor().GetG() != 20 || got.GetRgbColor().GetB() != 30 {
|
||||
t.Fatalf("RgbColor = %#v, want (10,20,30)", got.GetRgbColor())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported action", func(t *testing.T) {
|
||||
c := &Client{lightClient: &fakeLightServiceClient{}}
|
||||
if err := c.ExecuteAction(context.Background(), "light.living_room", "explode", nil); err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("propagates RPC error", func(t *testing.T) {
|
||||
wantErr := errors.New("boom")
|
||||
c := &Client{lightClient: &fakeLightServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.TurnOnRequest) (*hav1.LightResponse, error) {
|
||||
return nil, wantErr
|
||||
},
|
||||
}}
|
||||
err := c.ExecuteAction(context.Background(), "light.living_room", "turn_on", nil)
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("ExecuteAction() error = %v, want %v", err, wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteActionSwitch(t *testing.T) {
|
||||
tests := []struct {
|
||||
action string
|
||||
}{{"turn_on"}, {"turn_off"}, {"toggle"}}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.action, func(t *testing.T) {
|
||||
var gotOn, gotOff, gotToggle *hav1.SwitchRequest
|
||||
c := &Client{switchClient: &fakeSwitchServiceClient{
|
||||
turnOnFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
|
||||
gotOn = in
|
||||
return &hav1.SwitchResponse{}, nil
|
||||
},
|
||||
turnOffFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
|
||||
gotOff = in
|
||||
return &hav1.SwitchResponse{}, nil
|
||||
},
|
||||
toggleFunc: func(ctx context.Context, in *hav1.SwitchRequest) (*hav1.SwitchResponse, error) {
|
||||
gotToggle = in
|
||||
return &hav1.SwitchResponse{}, nil
|
||||
},
|
||||
}}
|
||||
|
||||
if err := c.ExecuteAction(context.Background(), "switch.fan", tt.action, nil); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
for _, got := range []*hav1.SwitchRequest{gotOn, gotOff, gotToggle} {
|
||||
if got != nil && got.GetEntityId() != "switch.fan" {
|
||||
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "switch.fan")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("unsupported action", func(t *testing.T) {
|
||||
c := &Client{switchClient: &fakeSwitchServiceClient{}}
|
||||
if err := c.ExecuteAction(context.Background(), "switch.fan", "explode", nil); err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteActionClimate(t *testing.T) {
|
||||
t.Run("set_hvac_mode", func(t *testing.T) {
|
||||
var got *hav1.SetHVACModeRequest
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{
|
||||
setHVACModeFunc: func(ctx context.Context, in *hav1.SetHVACModeRequest) (*hav1.ClimateResponse, error) {
|
||||
got = in
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
},
|
||||
}}
|
||||
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", map[string]any{"hvac_mode": "cool"}); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetHvacMode() != "cool" {
|
||||
t.Fatalf("HvacMode = %q, want %q", got.GetHvacMode(), "cool")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_hvac_mode missing param errors", func(t *testing.T) {
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{}}
|
||||
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_hvac_mode", nil); err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("increase_temperature", func(t *testing.T) {
|
||||
var got *hav1.ClimateRequest
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{
|
||||
increaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) {
|
||||
got = in
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
},
|
||||
}}
|
||||
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "increase_temperature", nil); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetEntityId() != "climate.air_conditioner" {
|
||||
t.Fatalf("EntityId = %q, want %q", got.GetEntityId(), "climate.air_conditioner")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decrease_temperature", func(t *testing.T) {
|
||||
called := false
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{
|
||||
decreaseTemperatureFunc: func(ctx context.Context, in *hav1.ClimateRequest) (*hav1.ClimateResponse, error) {
|
||||
called = true
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
},
|
||||
}}
|
||||
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "decrease_temperature", nil); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("DecreaseTemperature was not called")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_temperature forwards absolute target", func(t *testing.T) {
|
||||
var got *hav1.SetTemperatureRequest
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{
|
||||
setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
|
||||
got = in
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
},
|
||||
}}
|
||||
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", map[string]any{"target_temperature": "23.5"}); err != nil {
|
||||
t.Fatalf("ExecuteAction() error = %v", err)
|
||||
}
|
||||
if got.GetEntityId() != "climate.air_conditioner" || got.GetTargetTemperature() != 23.5 {
|
||||
t.Fatalf("SetTemperatureRequest = %#v, want entity=climate.air_conditioner target=23.5", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("set_temperature missing param errors without calling RPC", func(t *testing.T) {
|
||||
called := false
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{
|
||||
setTemperatureFunc: func(ctx context.Context, in *hav1.SetTemperatureRequest) (*hav1.ClimateResponse, error) {
|
||||
called = true
|
||||
return &hav1.ClimateResponse{}, nil
|
||||
},
|
||||
}}
|
||||
err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "set_temperature", nil)
|
||||
if err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("SetTemperature should not be called when the required param is missing")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unsupported action", func(t *testing.T) {
|
||||
c := &Client{climateClient: &fakeClimateServiceClient{}}
|
||||
if err := c.ExecuteAction(context.Background(), "climate.air_conditioner", "explode", nil); err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteActionDomainDispatch(t *testing.T) {
|
||||
t.Run("unsupported domain", func(t *testing.T) {
|
||||
c := &Client{}
|
||||
if err := c.ExecuteAction(context.Background(), "sensor.temp", "turn_on", nil); err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entity id with no domain prefix", func(t *testing.T) {
|
||||
c := &Client{}
|
||||
if err := c.ExecuteAction(context.Background(), "no-domain-here", "turn_on", nil); err == nil {
|
||||
t.Fatal("ExecuteAction() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
138
alexa-bridge/internal/haclient/params.go
Normal file
138
alexa-bridge/internal/haclient/params.go
Normal file
@ -0,0 +1,138 @@
|
||||
package haclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
|
||||
)
|
||||
|
||||
// optUint32 returns nil when key is absent. A present-but-unparsable value is
|
||||
// treated as absent rather than failing the whole action — brightness/
|
||||
// color-temp/transition are optional TurnOn/TurnOff fields, so a bad value
|
||||
// there shouldn't block the rest of the request.
|
||||
func optUint32(params map[string]any, key string) *uint32 {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
n, err := toUint32(v)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &n
|
||||
}
|
||||
|
||||
func reqUint32(params map[string]any, key string) (uint32, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing required param %q", key)
|
||||
}
|
||||
return toUint32(v)
|
||||
}
|
||||
|
||||
func reqFloat64(params map[string]any, key string) (float64, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing required param %q", key)
|
||||
}
|
||||
return toFloat64(v)
|
||||
}
|
||||
|
||||
func reqString(params map[string]any, key string) (string, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("missing required param %q", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("param %q = %v (%T), want string", key, v, v)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optRGB mirrors optUint32's absent-or-unparsable-means-nil contract.
|
||||
func optRGB(params map[string]any, key string) *hav1.RGBColor {
|
||||
rgb, err := parseRGB(params, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return rgb
|
||||
}
|
||||
|
||||
func reqRGB(params map[string]any, key string) (*hav1.RGBColor, error) {
|
||||
rgb, err := parseRGB(params, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rgb == nil {
|
||||
return nil, fmt.Errorf("missing required param %q", key)
|
||||
}
|
||||
return rgb, nil
|
||||
}
|
||||
|
||||
func parseRGB(params map[string]any, key string) (*hav1.RGBColor, error) {
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("param %q = %v (%T), want map with r/g/b", key, v, v)
|
||||
}
|
||||
r, err := toUint32(m["r"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("param %q.r: %w", key, err)
|
||||
}
|
||||
g, err := toUint32(m["g"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("param %q.g: %w", key, err)
|
||||
}
|
||||
b, err := toUint32(m["b"])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("param %q.b: %w", key, err)
|
||||
}
|
||||
return &hav1.RGBColor{R: r, G: g, B: b}, nil
|
||||
}
|
||||
|
||||
// toUint32/toFloat64 primarily parse strings, since params values arriving
|
||||
// from Alexa slots (via internal/directive) are always strings — even
|
||||
// AMAZON.NUMBER slots decode to their literal spoken-number string, e.g.
|
||||
// "80". The float64/int fallback exists only so tests (or a hypothetical
|
||||
// future non-Alexa caller) can pass typed values directly; it isn't
|
||||
// exercised on the real Alexa -> directive -> haclient path.
|
||||
func toUint32(v any) (uint32, error) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
n, err := strconv.ParseUint(t, 10, 32)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse uint32 %q: %w", t, err)
|
||||
}
|
||||
return uint32(n), nil
|
||||
case float64:
|
||||
return uint32(t), nil
|
||||
case int:
|
||||
return uint32(t), nil
|
||||
case uint32:
|
||||
return t, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("value %v (%T) is not a uint32-compatible type", v, v)
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, error) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
n, err := strconv.ParseFloat(t, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse float64 %q: %w", t, err)
|
||||
}
|
||||
return n, nil
|
||||
case float64:
|
||||
return t, nil
|
||||
case int:
|
||||
return float64(t), nil
|
||||
default:
|
||||
return 0, fmt.Errorf("value %v (%T) is not a float64-compatible type", v, v)
|
||||
}
|
||||
}
|
||||
130
alexa-bridge/internal/haclient/params_test.go
Normal file
130
alexa-bridge/internal/haclient/params_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package haclient
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOptUint32(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params map[string]any
|
||||
want *uint32
|
||||
}{
|
||||
{name: "absent key returns nil", params: map[string]any{}, want: nil},
|
||||
{name: "valid string parses", params: map[string]any{"k": "80"}, want: ptrUint32(80)},
|
||||
{name: "unparsable string returns nil, not error", params: map[string]any{"k": "not-a-number"}, want: nil},
|
||||
{name: "float64 accepted", params: map[string]any{"k": float64(42)}, want: ptrUint32(42)},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := optUint32(tt.params, "k")
|
||||
if (got == nil) != (tt.want == nil) {
|
||||
t.Fatalf("optUint32() = %v, want %v", got, tt.want)
|
||||
}
|
||||
if got != nil && *got != *tt.want {
|
||||
t.Fatalf("optUint32() = %v, want %v", *got, *tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReqUint32(t *testing.T) {
|
||||
t.Run("missing key errors", func(t *testing.T) {
|
||||
if _, err := reqUint32(map[string]any{}, "k"); err == nil {
|
||||
t.Fatal("reqUint32() error = nil, want error")
|
||||
}
|
||||
})
|
||||
t.Run("unparsable string errors", func(t *testing.T) {
|
||||
if _, err := reqUint32(map[string]any{"k": "nope"}, "k"); err == nil {
|
||||
t.Fatal("reqUint32() error = nil, want error")
|
||||
}
|
||||
})
|
||||
t.Run("valid string parses", func(t *testing.T) {
|
||||
got, err := reqUint32(map[string]any{"k": "255"}, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("reqUint32() error = %v", err)
|
||||
}
|
||||
if got != 255 {
|
||||
t.Fatalf("reqUint32() = %v, want 255", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReqFloat64(t *testing.T) {
|
||||
t.Run("missing key errors", func(t *testing.T) {
|
||||
if _, err := reqFloat64(map[string]any{}, "k"); err == nil {
|
||||
t.Fatal("reqFloat64() error = nil, want error")
|
||||
}
|
||||
})
|
||||
t.Run("valid string parses", func(t *testing.T) {
|
||||
got, err := reqFloat64(map[string]any{"k": "23.5"}, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("reqFloat64() error = %v", err)
|
||||
}
|
||||
if got != 23.5 {
|
||||
t.Fatalf("reqFloat64() = %v, want 23.5", got)
|
||||
}
|
||||
})
|
||||
t.Run("negative and integral values parse", func(t *testing.T) {
|
||||
got, err := reqFloat64(map[string]any{"k": "-5"}, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("reqFloat64() error = %v", err)
|
||||
}
|
||||
if got != -5 {
|
||||
t.Fatalf("reqFloat64() = %v, want -5", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReqString(t *testing.T) {
|
||||
t.Run("missing key errors", func(t *testing.T) {
|
||||
if _, err := reqString(map[string]any{}, "k"); err == nil {
|
||||
t.Fatal("reqString() error = nil, want error")
|
||||
}
|
||||
})
|
||||
t.Run("non-string value errors", func(t *testing.T) {
|
||||
if _, err := reqString(map[string]any{"k": 5}, "k"); err == nil {
|
||||
t.Fatal("reqString() error = nil, want error")
|
||||
}
|
||||
})
|
||||
t.Run("valid string", func(t *testing.T) {
|
||||
got, err := reqString(map[string]any{"k": "cool"}, "k")
|
||||
if err != nil {
|
||||
t.Fatalf("reqString() error = %v", err)
|
||||
}
|
||||
if got != "cool" {
|
||||
t.Fatalf("reqString() = %q, want %q", got, "cool")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRGBHelpers(t *testing.T) {
|
||||
t.Run("optRGB absent returns nil", func(t *testing.T) {
|
||||
if got := optRGB(map[string]any{}, "k"); got != nil {
|
||||
t.Fatalf("optRGB() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
t.Run("optRGB malformed returns nil, not error", func(t *testing.T) {
|
||||
if got := optRGB(map[string]any{"k": "not-a-map"}, "k"); got != nil {
|
||||
t.Fatalf("optRGB() = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
t.Run("optRGB valid", func(t *testing.T) {
|
||||
got := optRGB(map[string]any{"k": map[string]any{"r": "1", "g": "2", "b": "3"}}, "k")
|
||||
if got == nil || got.R != 1 || got.G != 2 || got.B != 3 {
|
||||
t.Fatalf("optRGB() = %v, want (1,2,3)", got)
|
||||
}
|
||||
})
|
||||
t.Run("reqRGB missing key errors", func(t *testing.T) {
|
||||
if _, err := reqRGB(map[string]any{}, "k"); err == nil {
|
||||
t.Fatal("reqRGB() error = nil, want error")
|
||||
}
|
||||
})
|
||||
t.Run("reqRGB missing component errors", func(t *testing.T) {
|
||||
if _, err := reqRGB(map[string]any{"k": map[string]any{"r": "1", "g": "2"}}, "k"); err == nil {
|
||||
t.Fatal("reqRGB() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ptrUint32(v uint32) *uint32 { return &v }
|
||||
38
alexa-bridge/internal/logger/logger.go
Normal file
38
alexa-bridge/internal/logger/logger.go
Normal file
@ -0,0 +1,38 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type contextKey struct{}
|
||||
|
||||
// New constructs a root logger for the configured format and level.
|
||||
func New(format, level string) *slog.Logger {
|
||||
var parsed slog.Level
|
||||
if err := parsed.UnmarshalText([]byte(level)); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "invalid log level %q, falling back to info\n", level)
|
||||
parsed = slog.LevelInfo
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{Level: parsed}
|
||||
if format == "json" {
|
||||
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, opts))
|
||||
}
|
||||
|
||||
// WithLogger attaches a logger to the provided context.
|
||||
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
|
||||
return context.WithValue(ctx, contextKey{}, l)
|
||||
}
|
||||
|
||||
// FromContext retrieves a logger from context and falls back to slog.Default().
|
||||
func FromContext(ctx context.Context) *slog.Logger {
|
||||
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil {
|
||||
return l
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
82
alexa-bridge/internal/telemetry/telemetry.go
Normal file
82
alexa-bridge/internal/telemetry/telemetry.go
Normal file
@ -0,0 +1,82 @@
|
||||
package telemetry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/config"
|
||||
"gitea.nik4nao.com/nik/home-services/alexa-bridge/internal/logger"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// Setup initialises OTel trace and metric providers for one service.
|
||||
func Setup(ctx context.Context, serviceName, version string, cfg *config.Config) (shutdown func(context.Context) error, err error) {
|
||||
if cfg.OTELEndpoint == "" {
|
||||
otel.SetTracerProvider(tracenoop.NewTracerProvider())
|
||||
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||
logger.FromContext(ctx).Debug("otel disabled — OTEL_ENDPOINT not set")
|
||||
return func(context.Context) error { return nil }, nil
|
||||
}
|
||||
|
||||
res := resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceNameKey.String(serviceName),
|
||||
semconv.ServiceVersionKey.String(version),
|
||||
)
|
||||
|
||||
traceExp, err := otlptracegrpc.New(ctx,
|
||||
otlptracegrpc.WithEndpoint(cfg.OTELEndpoint),
|
||||
otlptracegrpc.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(traceExp),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
metricExp, err := otlpmetricgrpc.New(ctx,
|
||||
otlpmetricgrpc.WithEndpoint(cfg.OTELEndpoint),
|
||||
otlpmetricgrpc.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
_ = tp.Shutdown(ctx)
|
||||
return nil, err
|
||||
}
|
||||
mp := sdkmetric.NewMeterProvider(
|
||||
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp,
|
||||
sdkmetric.WithInterval(30*time.Second))),
|
||||
sdkmetric.WithResource(res),
|
||||
)
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
return func(ctx context.Context) error {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var shutdownErr error
|
||||
if err := tp.Shutdown(shutdownCtx); err != nil {
|
||||
shutdownErr = errors.Join(shutdownErr, err)
|
||||
}
|
||||
if err := mp.Shutdown(shutdownCtx); err != nil {
|
||||
shutdownErr = errors.Join(shutdownErr, err)
|
||||
}
|
||||
return shutdownErr
|
||||
}, nil
|
||||
}
|
||||
423
alexa-bridge/plan.md
Normal file
423
alexa-bridge/plan.md
Normal file
@ -0,0 +1,423 @@
|
||||
# alexa-bridge plan
|
||||
|
||||
## Correction on starting assumptions
|
||||
|
||||
`alexa-bridge/internal/alexa` and `alexa-bridge/internal/directive` do not exist anywhere in
|
||||
this repo (checked working tree, `git log --all`, all branches, `go.work`). This plan therefore
|
||||
covers the **whole service from scratch** — signature validation and request/response types,
|
||||
directive routing, and the two pieces originally called out (entity resolution, generic
|
||||
Controller) — but stays lightest on the parts that were assumed already scaffolded and goes
|
||||
deep on entity resolution, the Controller/action mapping, and mTLS, per the original ask.
|
||||
|
||||
Everything below was grounded by reading `proto/ha/v1/*.proto`, `ha-gateway/go.mod`,
|
||||
`ai-gateway/internal/adapters/secondary/hagateway/client.go`,
|
||||
`discord-bot/internal/adapters/secondary/gateway/client.go`, `ai-gateway/internal/config/config.go`,
|
||||
`ai-gateway/internal/core/domain/light_cache.go`, both services' `cmd/*/main.go`, and
|
||||
`~/repo/homelab/manifests/home-services/{certs,ai-gateway,discord-bot}.yaml`.
|
||||
|
||||
## Package layout
|
||||
|
||||
Follows this repo's hexagonal convention (`core` has no adapter dependencies; ports are the
|
||||
only boundary) while keeping the four package names already chosen (`alexa`, `directive`,
|
||||
`entities`, `haclient`) rather than renaming them to `adapters/primary/...` etc. Mapping: `alexa`
|
||||
and the HTTP entrypoint play the primary-adapter role, `haclient`/`entities` play the
|
||||
secondary-adapter role, `directive` plays the app/orchestration role.
|
||||
|
||||
```text
|
||||
alexa-bridge/
|
||||
├── go.mod # module gitea.nik4nao.com/nik/home-services/alexa-bridge
|
||||
├── .env.example
|
||||
├── README.md
|
||||
├── cmd/bridge/main.go # config, logger, telemetry, wiring, http.Server lifecycle
|
||||
└── internal/
|
||||
├── config/config.go # env loading (see mTLS section for TLS-specific fields)
|
||||
├── logger/logger.go # mirror existing slog setup (New(format, level))
|
||||
├── telemetry/telemetry.go # mirror existing OTEL setup (no-op when OTEL_ENDPOINT empty)
|
||||
├── core/
|
||||
│ ├── domain/
|
||||
│ │ └── entity.go # Entity{EntityID, FriendlyName, Domain string}
|
||||
│ └── ports/driven/
|
||||
│ ├── controller.go # Controller interface (the one requested)
|
||||
│ └── entities.go # EntityResolver interface
|
||||
├── alexa/ # Alexa Custom Skill protocol edge
|
||||
│ ├── types.go # Request/Response envelope, Session, Context, Intent, Slot
|
||||
│ ├── signature.go # ValidateSignature: cert-chain fetch+cache, RSA-SHA1 verify, timestamp check
|
||||
│ ├── handler.go # http.Handler: verify → decode → directive.Router.Dispatch → encode
|
||||
│ └── *_test.go
|
||||
├── directive/ # intent → action orchestration ("app" layer)
|
||||
│ ├── router.go # Router{resolver driven.EntityResolver, controller driven.Controller}
|
||||
│ ├── intents.go # per-intent handlers, slot → entityID/action/params
|
||||
│ └── *_test.go
|
||||
├── entities/ # EntityService client + cache + refresh (focus area 1)
|
||||
│ ├── client.go # gRPC wrapper: FetchAll(ctx) ([]domain.Entity, error)
|
||||
│ ├── resolver.go # in-memory Resolver implementing driven.EntityResolver
|
||||
│ ├── refresher.go # startup fetch + ticker-based periodic refresh
|
||||
│ └── *_test.go
|
||||
└── haclient/ # ha-gateway RPC client, implements driven.Controller (focus area 2)
|
||||
├── client.go # mTLS dial, holds Light/Switch/Climate service clients
|
||||
├── controller.go # ExecuteAction: domain dispatch + action → RPC + param coercion
|
||||
├── params.go # typed param extraction helpers
|
||||
└── *_test.go
|
||||
```
|
||||
|
||||
## 1. Entity resolution (`internal/entities`)
|
||||
|
||||
### Which RPC to use — a decision, not a given
|
||||
|
||||
The prompt assumed `EntityService` is the right client. Checked both options:
|
||||
|
||||
- `EntityService.ListStates` (generic, any domain) returns `EntityState{entity_id, state,
|
||||
attributes map<string,string>, ...}` — **no dedicated `friendly_name` field**. Confirmed by
|
||||
reading `ha-gateway/internal/app/{light,switch,climate}.go`: each extracts
|
||||
`s.Attributes["friendly_name"].(string)` itself, i.e. `EntityService` passes through HA's raw
|
||||
attributes map and friendly_name only exists as a string key inside it.
|
||||
- `LightService.ListLights` / `SwitchService.ListSwitches` / `ClimateService.ListClimates` each
|
||||
return a typed entity message with a dedicated `friendly_name` field, but that means three
|
||||
client stubs instead of one, and only cover those three domains structurally.
|
||||
|
||||
**Recommendation: use `EntityService.ListStates`, called once per domain** (`domain: "light"`,
|
||||
`"switch"`, `"climate"`), reading `attributes["friendly_name"]` with a fallback to the entity_id
|
||||
suffix when absent/empty. This matches what was originally asked for (a single `EntityService`
|
||||
client), and loses nothing functionally — resolution only needs entity_id + friendly_name + a
|
||||
way to know which domain a name belongs to (see Controller section — domain dispatch there
|
||||
reads it straight off the entity_id prefix, e.g. `light.living_room` → `light`, so no extra
|
||||
typed fields like `supported_color_modes` are needed here). `ListStatesRequest.domain` is a
|
||||
single string, not repeated, hence three calls (issued concurrently) rather than one unfiltered
|
||||
call — this also avoids pulling every sensor/automation/etc. in the HA install into the lookup.
|
||||
|
||||
```go
|
||||
// internal/entities/client.go
|
||||
type Client struct {
|
||||
entityClient hav1.EntityServiceClient
|
||||
domains []string // {"light", "switch", "climate"} — extend if remote/other domains get in scope, see Open Questions
|
||||
}
|
||||
|
||||
func (c *Client) FetchAll(ctx context.Context) ([]domain.Entity, error) {
|
||||
// fan out one ListStates(domain: d) call per configured domain, concurrently;
|
||||
// for each EntityState, entity := domain.Entity{
|
||||
// EntityID: s.GetEntityId(),
|
||||
// FriendlyName: firstNonEmpty(s.GetAttributes()["friendly_name"], s.GetEntityId()),
|
||||
// Domain: d,
|
||||
// }
|
||||
}
|
||||
```
|
||||
|
||||
### Resolver + refresh
|
||||
|
||||
- `Resolver` holds a `map[string]domain.Entity` keyed by normalized (lowercased, trimmed)
|
||||
friendly name, swapped atomically on each refresh (`atomic.Pointer[map[string]domain.Entity]`
|
||||
or `sync.RWMutex`) so an in-flight Alexa request never observes a half-built map.
|
||||
- **Startup**: `FetchAll` is called once, synchronously, before the HTTP server starts accepting
|
||||
traffic — fail loudly (`os.Exit(1)`) on error, mirroring how `haClient`/`aiClient` setup
|
||||
failures are handled in `ai-gateway/cmd/gateway/main.go` and `discord-bot/cmd/bot/main.go`.
|
||||
Without an initial entity list nothing can resolve, so there's no useful degraded mode.
|
||||
- **Periodic refresh**: a background goroutine on a `time.Ticker` (`ENTITY_REFRESH_INTERVAL`,
|
||||
suggest default `5m` — HA's entity/name set changes rarely) calls `FetchAll` again and
|
||||
replaces the resolver's map. Unlike startup, a *periodic* refresh failure only logs and keeps
|
||||
serving the last-known-good map — going down because HA was briefly unreachable would be worse
|
||||
than serving slightly stale entity names.
|
||||
- This is a new caching shape, not reused code: `ai-gateway`'s `LightCache`
|
||||
(`ai-gateway/internal/core/domain/light_cache.go`) is lazy/pull-based (refreshes on `Get()` if
|
||||
stale, no background goroutine) since ai-gateway can tolerate a slow first request. The
|
||||
explicit "on startup plus periodic refresh" ask is closer to a push-based background loop, so
|
||||
don't reuse `LightCache` as-is.
|
||||
|
||||
### Open question — name matching is exact-match only in this design
|
||||
|
||||
`Resolver.Resolve(friendlyName string)` does an exact match on the normalized string. Alexa's
|
||||
ASR won't always produce a string that matches HA's `friendly_name` verbatim (e.g. user says
|
||||
"living room light", HA has it named "Living Room Lamp"). Options, not resolved here:
|
||||
|
||||
1. **Ship exact-match for v1** (simplest, what this plan assumes) and accept some resolution
|
||||
misses as a known limitation.
|
||||
2. Add fuzzy matching (substring / Levenshtein) client-side in the resolver.
|
||||
3. Use Alexa's own slot entity resolution — define the `Device` slot against a custom slot type
|
||||
and keep it in sync via the `SetDynamicEntities` directive, so Alexa's own NLU does the fuzzy
|
||||
matching against the same entity list before the request ever reaches alexa-bridge. Best UX,
|
||||
most infra (interaction-model + skill-console work, not just backend code).
|
||||
|
||||
Recommend (1) now, flag (3) as the real long-term fix, and revisit once real usage shows how
|
||||
often exact-match actually misses.
|
||||
|
||||
## 2. Controller (`internal/haclient`, `internal/core/ports/driven`)
|
||||
|
||||
```go
|
||||
// internal/core/ports/driven/controller.go
|
||||
package driven
|
||||
|
||||
type Controller interface {
|
||||
ExecuteAction(ctx context.Context, entityID, action string, params map[string]any) error
|
||||
}
|
||||
```
|
||||
|
||||
`haclient.Client` implements it. Dispatch is two-level: entity domain (parsed off `entityID`'s
|
||||
prefix up to the first `.`, e.g. `light.living_room` → `light` — no separate lookup needed, this
|
||||
is exactly what HA/`ha-gateway` entity IDs already encode) selects which typed gRPC client to
|
||||
use, then `action` selects the RPC within that domain.
|
||||
|
||||
### Action → RPC table (exact types, from `proto/ha/v1/*.proto`)
|
||||
|
||||
| Domain | action | RPC | Request construction |
|
||||
|---|---|---|---|
|
||||
| `light` | `turn_on` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: optUint32(params,"brightness_pct"), ColorTempKelvin: optUint32(params,"color_temp_kelvin"), RgbColor: optRGB(params,"rgb_color"), Transition: optUint32(params,"transition")}` |
|
||||
| `light` | `turn_off` | `LightServiceClient.TurnOff` | `&hav1.TurnOffRequest{EntityId: entityID, Transition: optUint32(params,"transition")}` |
|
||||
| `light` | `toggle` | `LightServiceClient.Toggle` | `&hav1.ToggleRequest{EntityId: entityID}` |
|
||||
| `light` | `set_brightness` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, BrightnessPct: reqUint32(params,"brightness_pct")}` — reuses `TurnOn`; there's no separate brightness RPC, but `TurnOnRequest.brightness_pct` is `optional`, so a request with only that field set both sets brightness and turns the light on, matching HA's own `light.turn_on` semantics. (This resolves the exact gap the original prompt guessed might be missing — it isn't.) |
|
||||
| `light` | `set_color_temp` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, ColorTempKelvin: reqUint32(params,"color_temp_kelvin")}` |
|
||||
| `light` | `set_color` | `LightServiceClient.TurnOn` | `&hav1.TurnOnRequest{EntityId: entityID, RgbColor: reqRGB(params,"rgb_color")}` |
|
||||
| `switch` | `turn_on` | `SwitchServiceClient.TurnOn` | `&hav1.SwitchRequest{EntityId: entityID}` |
|
||||
| `switch` | `turn_off` | `SwitchServiceClient.TurnOff` | `&hav1.SwitchRequest{EntityId: entityID}` |
|
||||
| `switch` | `toggle` | `SwitchServiceClient.Toggle` | `&hav1.SwitchRequest{EntityId: entityID}` |
|
||||
| `climate` | `turn_on` | `ClimateServiceClient.TurnOn` | `&hav1.ClimateRequest{EntityId: entityID}` |
|
||||
| `climate` | `turn_off` | `ClimateServiceClient.TurnOff` | `&hav1.ClimateRequest{EntityId: entityID}` |
|
||||
| `climate` | `set_hvac_mode` | `ClimateServiceClient.SetHVACMode` | `&hav1.SetHVACModeRequest{EntityId: entityID, HvacMode: reqString(params,"hvac_mode")}` |
|
||||
| `climate` | `increase_temperature` | `ClimateServiceClient.IncreaseTemperature` | `&hav1.ClimateRequest{EntityId: entityID}` |
|
||||
| `climate` | `decrease_temperature` | `ClimateServiceClient.DecreaseTemperature` | `&hav1.ClimateRequest{EntityId: entityID}` |
|
||||
| `climate` | `set_temperature` | `ClimateServiceClient.SetTemperature` | `&hav1.SetTemperatureRequest{EntityId: entityID, TargetTemperature: reqFloat64(params,"target_temperature")}` — **implemented** (see "Decisions on open questions" #1); single-setpoint only, no `target_temp_high`/`target_temp_low` |
|
||||
|
||||
All response messages (`LightResponse`, `SwitchResponse`, `ClimateResponse`) just wrap
|
||||
`EntityState`; `ExecuteAction`'s `error`-only signature means the returned state is simply
|
||||
discarded (dropped, not needed by the interface as specified).
|
||||
|
||||
### Param coercion (`internal/haclient/params.go`)
|
||||
|
||||
`params map[string]any` values will, in practice, almost always be `string` — they come from
|
||||
Alexa slot values (`req.Body.Intent.Slots["Brightness"].Value`), which are always strings even
|
||||
for `AMAZON.NUMBER` slots (e.g. `"80"`). Coercion helpers should therefore parse from `string`
|
||||
primarily, with a `float64`/`int` fallback only so unit tests (or a hypothetical future non-Alexa
|
||||
caller) can pass typed values directly — don't over-build for JSON-number decoding that won't
|
||||
actually occur on the real Alexa → directive → haclient path:
|
||||
|
||||
```go
|
||||
func optUint32(params map[string]any, key string) *uint32 // nil if key absent; parse error → nil + logged, not fatal
|
||||
func reqUint32(params map[string]any, key string) (uint32, error)
|
||||
func reqFloat64(params map[string]any, key string) (float64, error) // backs set_temperature, see Decisions #1
|
||||
func reqString(params map[string]any, key string) (string, error)
|
||||
func reqRGB(params map[string]any, key string) (*hav1.RGBColor, error) // expects map[string]any{"r":..,"g":..,"b":..}
|
||||
```
|
||||
|
||||
`params` is built in `internal/directive/intents.go`, one handler per Alexa intent, e.g.:
|
||||
|
||||
```go
|
||||
func (r *Router) handleSetBrightness(ctx context.Context, req alexa.Request) (alexa.Response, error) {
|
||||
entity, ok := r.resolver.Resolve(req.Body.Intent.Slots["Device"].Value)
|
||||
if !ok {
|
||||
return notFoundResponse(req.Body.Intent.Slots["Device"].Value), nil
|
||||
}
|
||||
err := r.controller.ExecuteAction(ctx, entity.EntityID, "set_brightness", map[string]any{
|
||||
"brightness_pct": req.Body.Intent.Slots["Brightness"].Value,
|
||||
})
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## 3. mTLS (`internal/haclient/client.go`, `internal/config`)
|
||||
|
||||
Mirrors `ai-gateway/internal/adapters/secondary/hagateway/client.go`'s `New` +
|
||||
`loadTransportCredentials` **verbatim** (client cert from `tls.crt`/`tls.key`, root CA from
|
||||
`ca.crt`, `ServerName` set to ha-gateway's cert CN, `MinVersion: tls.VersionTLS13`, insecure
|
||||
fallback via `credentials/insecure` when `tlsDir == ""`):
|
||||
|
||||
```go
|
||||
func New(ctx context.Context, addr, tlsDir, serverName string, log *slog.Logger) (*Client, error) {
|
||||
transportCreds := insecure.NewCredentials()
|
||||
if tlsDir != "" {
|
||||
creds, err := loadTransportCredentials(tlsDir, serverName) // identical to ai-gateway's version
|
||||
...
|
||||
}
|
||||
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(transportCreds), grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
|
||||
...
|
||||
return &Client{
|
||||
conn: conn,
|
||||
lightClient: hav1.NewLightServiceClient(conn),
|
||||
switchClient: hav1.NewSwitchServiceClient(conn),
|
||||
climateClient: hav1.NewClimateServiceClient(conn),
|
||||
entityClient: hav1.NewEntityServiceClient(conn), // for internal/entities
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Config — one deliberate deviation
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
...
|
||||
HAGatewayAddr string // default "ha-gateway.home-services.svc.cluster.local:50051"
|
||||
HAGatewayServerName string // default "ha-gateway.home-services.svc.cluster.local"
|
||||
TLSDir string // default "/tls" — see note below
|
||||
}
|
||||
```
|
||||
|
||||
`ai-gateway` and `discord-bot` both default `TLS_DIR` to **empty** (mTLS opt-in, enabled only by
|
||||
an explicit env var in the k8s manifest). Since `alexa-bridge` is this repo's one
|
||||
internet-facing service, default `TLS_DIR` to `/tls` instead — mTLS to ha-gateway should be
|
||||
on unless someone deliberately unsets it for local dev, not on only if someone remembers to set
|
||||
it. Still fully overridable (empty string keeps the existing insecure-fallback behavior for
|
||||
local plaintext dev, matching `TLSDir string // default ""` + `validateTLSDir` in the other two
|
||||
services' `config.go`, copied as-is here).
|
||||
|
||||
### homelab-repo Certificate (separate repo, separate change — not made here)
|
||||
|
||||
Modeled on `discord-bot-tls` (`~/repo/homelab/manifests/home-services/certs.yaml`) since, like
|
||||
discord-bot, alexa-bridge only *dials* ha-gateway over mTLS — it doesn't itself serve mTLS gRPC,
|
||||
so no `server auth` usage or `dnsNames` needed:
|
||||
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: Certificate
|
||||
metadata:
|
||||
name: alexa-bridge-tls
|
||||
namespace: home-services
|
||||
spec:
|
||||
secretName: alexa-bridge-tls
|
||||
issuerRef:
|
||||
name: internal-ca-issuer
|
||||
kind: ClusterIssuer
|
||||
commonName: alexa-bridge
|
||||
usages:
|
||||
- client auth
|
||||
- digital signature
|
||||
- key encipherment
|
||||
```
|
||||
|
||||
Note on naming: the prompt suggested `alexa-bridge-client-tls`; existing certs all follow
|
||||
`<service>-tls` regardless of client/server/dual usage (`ha-gateway-tls` is server-only,
|
||||
`discord-bot-tls` is client-only, `ai-gateway-tls` is both — none disambiguate in the name).
|
||||
Defaulting to `alexa-bridge-tls` for consistency; flagging in case there was a specific reason
|
||||
for wanting the longer name.
|
||||
|
||||
Mount: a `tls` volume from secret `alexa-bridge-tls` at `/tls`, `readOnly: true` — same shape as
|
||||
`discord-bot.yaml`'s volume block.
|
||||
|
||||
## Open questions
|
||||
|
||||
**Status: all four resolved — see "Decisions on open questions" below, which is now the
|
||||
source of truth for what actually got built.** Kept as originally written here for the
|
||||
reasoning trail; where the two sections disagree, Decisions wins.
|
||||
|
||||
1. ~~**`climate.set_temperature` has no backing RPC.**~~ **Resolved — implemented, see
|
||||
Decisions #1.** `ClimateService` only exposed
|
||||
`IncreaseTemperature`/`DecreaseTemperature` (relative step) and `SetHVACMode` — there is no
|
||||
"set target temperature to X°" RPC, even though that's a very natural Alexa ask ("set the
|
||||
thermostat to 72"). This is a real proto/`ha-gateway` gap, not a client-side workaround
|
||||
opportunity — the underlying `ha-gateway` `internal/app/climate.go` only computes `next :=
|
||||
*TargetTemperature + direction*step`, it has no absolute-set path either. Options: (a) add a
|
||||
`SetTemperature(entity_id, target)` RPC to `ClimateService` in `ha-gateway` first (out of
|
||||
scope for alexa-bridge alone), (b) approximate it client-side in alexa-bridge by calling
|
||||
`IncreaseTemperature`/`DecreaseTemperature` in a loop toward the target (fragile — depends on
|
||||
knowing the current temperature and step size, race-prone), or (c) omit `set_temperature`
|
||||
from alexa-bridge's v1 intent set and only support hvac-mode/step-based climate control.
|
||||
Recommend (a), scoped as a small separate `ha-gateway` change, with (c) as the fallback if
|
||||
that's not wanted right now.
|
||||
|
||||
2. **Resolved — drafted (not applied), see Decisions #2.** alexa-bridge's own public HTTPS
|
||||
listener is a separate TLS concern from the mTLS covered above. Alexa's servers call *into*
|
||||
alexa-bridge over the internet and require a publicly
|
||||
trusted certificate — the `internal-ca-issuer` used for `alexa-bridge-tls` above won't satisfy
|
||||
that (Alexa doesn't trust this homelab's internal CA). The homelab repo already has the
|
||||
pattern for this: `manifests/gitea/gitea-public-ingress.yaml` pairs a `letsencrypt-prod`
|
||||
`ClusterIssuer` `Certificate` with a Traefik `IngressRoute` for a public hostname
|
||||
(`gitea.nik4nao.com`). alexa-bridge would need the equivalent — its own `Certificate` +
|
||||
`IngressRoute` (e.g. `alexa-bridge.nik4nao.com`) — as a homelab-repo change. Out of scope for
|
||||
this plan (which only covers the ha-gateway mTLS leg, as asked), but called out since
|
||||
alexa-bridge can't function without it and it's easy to lose track of as a "separate repo"
|
||||
task.
|
||||
|
||||
3. **Resolved — confirmed out of scope, see Decisions #3.** Remote/SwitchBot entities are
|
||||
excluded from entity resolution and the Controller's domain table. `RemoteService.SendCommand`
|
||||
is keyed by a SwitchBot `device_id`, not an HA
|
||||
`entity_id`, and there's no evidence these show up in `EntityService.ListStates` the same way
|
||||
light/switch/climate entities do (they're relayed from SwitchBot Cloud, not HA's own entity
|
||||
registry). Treating IR/remote control as out of scope for v1 Alexa support; would need
|
||||
separate design if wanted later.
|
||||
|
||||
4. **Resolved — implemented, see Decisions #4.** Skill ID verification: Alexa best practice
|
||||
(and Amazon's certification checklist) also expects verifying
|
||||
`request.context.System.application.applicationId` against a configured
|
||||
skill ID, on top of signature validation, to reject requests replayed from a different skill
|
||||
using the same endpoint. Not explicitly asked for, but cheap to add in `internal/alexa` —
|
||||
flagging so it's a conscious inclusion/exclusion rather than an oversight.
|
||||
|
||||
## Implementation steps
|
||||
|
||||
1. **Scaffold the module**: `alexa-bridge/go.mod` (module
|
||||
`gitea.nik4nao.com/nik/home-services/alexa-bridge`, matching the other four's `go 1.26` +
|
||||
`replace .../gen => ../gen`), add `./alexa-bridge` to root `go.work`. Add matching `COPY`
|
||||
lines (manifest-only + full-source, per `CLAUDE.md`'s CI note) to the other four Dockerfiles,
|
||||
and an `alexa-bridge` entry to `.gitea/workflows/ci.yaml`'s `changes` job, `test` job's
|
||||
vet/test loop, and a new `build-alexa-bridge` job.
|
||||
2. **`internal/core/domain` + `internal/core/ports/driven`**: `Entity`, `Controller`,
|
||||
`EntityResolver` — no dependencies, straightforward.
|
||||
3. **`internal/haclient`**: mTLS client + `Controller` implementation (section 2/3 above) — can
|
||||
be built and unit-tested (hand-written fakes for the generated gRPC clients, per this repo's
|
||||
no-testify convention) independently of the HTTP/Alexa side.
|
||||
4. **`internal/entities`**: `EntityService` client + resolver + refresher (section 1 above),
|
||||
same independence/testability as step 3.
|
||||
5. **`internal/alexa`**: request/response types, signature validation (cert-chain fetch+cache,
|
||||
RSA-SHA1 verify, timestamp tolerance ~150s, optionally skill-ID check per open question 4),
|
||||
HTTP handler wiring the above together.
|
||||
6. **`internal/directive`**: intent handlers per the action table in section 2, wired to
|
||||
`driven.EntityResolver` and `driven.Controller`.
|
||||
7. **`cmd/bridge/main.go` + `internal/config`**: env loading (including the `TLS_DIR` default
|
||||
deviation from section 3), logger/telemetry setup mirroring the other services, startup
|
||||
sequencing (blocking entity fetch before serving, per section 1), graceful shutdown.
|
||||
8. **`Dockerfile` + `README.md`**: mirror `discord-bot`'s two-stage build shape (this one needs
|
||||
no `ffmpeg`/GPU equivalent, so a plain `distroless`/`scratch` final stage should work, unlike
|
||||
`discord-bot`/`tts-gateway`).
|
||||
9. **homelab repo** (separate change, needs explicit confirmation before touching that repo):
|
||||
`alexa-bridge-tls` `Certificate` (section 3), `alexa-bridge.yaml` Deployment/Service, and the
|
||||
public ingress + `letsencrypt-prod` `Certificate` from open question 2.
|
||||
|
||||
## Decisions on open questions
|
||||
|
||||
Resolved before implementation started; this section is the source of truth for what actually
|
||||
got built, superseding the "Open questions" section above where they conflict.
|
||||
|
||||
1. **`climate.set_temperature` → option (a), a real RPC.** Adding
|
||||
`ClimateService.SetTemperature(SetTemperatureRequest) returns (ClimateResponse)` to
|
||||
`proto/ha/v1/climate.proto`, reusing `ClimateResponse` like every other RPC on this service
|
||||
rather than inventing a new response type. `ha-gateway/internal/app/climate.go` gets a
|
||||
`SetTemperature` method that calls Home Assistant's `climate.set_temperature` service with the
|
||||
caller-supplied absolute target directly — `IncreaseTemperature`/`DecreaseTemperature` keep
|
||||
their existing read-current-state-then-step logic unchanged; they now sit alongside
|
||||
`SetTemperature` rather than being replaced by it. Checked the live HA instance
|
||||
(`mcp__home-assistant__GetLiveContext`): exactly one climate entity ("Air Conditioner"), with
|
||||
a single `temperature` attribute and no `target_temp_high`/`target_temp_low` — range-mode
|
||||
(`ClimateEntityFeature.TARGET_TEMPERATURE_RANGE`) is not in use anywhere in this HA instance,
|
||||
and `ha-gateway`'s existing `haStateToClimate` mapping doesn't parse those attributes either.
|
||||
**Scope: single-setpoint only. Range-mode climate entities are explicitly out of scope** —
|
||||
not half-built, not silently broken if one ever appears (it would just report no target
|
||||
temperature, same as any other unmapped attribute today), but genuinely unhandled. Revisit if
|
||||
a range-mode entity is ever added to this HA instance.
|
||||
alexa-bridge's action table gains `climate`/`set_temperature` →
|
||||
`ClimateServiceClient.SetTemperature`, and `internal/haclient/params.go` gains a `reqFloat64`
|
||||
helper alongside `reqUint32`/`reqString`/`reqRGB`. `internal/directive/intents.go` gains a
|
||||
`SetTemperatureIntent` handler reading a `Temperature` (`AMAZON.NUMBER`) slot, same shape as
|
||||
`SetBrightnessIntent`.
|
||||
|
||||
2. **Public HTTPS ingress for alexa-bridge — in scope.** Drafted (not applied) an `IngressRoute`
|
||||
+ cert-manager `Certificate` for `alexa-bridge.nik4nao.com` against `letsencrypt-prod`,
|
||||
mirroring `manifests/gitea/gitea-public-ingress.yaml`'s shape exactly, as a separate homelab
|
||||
manifest file from `alexa-bridge-tls` (the `internal-ca-issuer` client cert for the mTLS leg to
|
||||
ha-gateway) — different issuer, different trust domain, different purpose; not to be confused
|
||||
or merged in naming.
|
||||
|
||||
3. **Remote/SwitchBot entities — out of scope for v1.** `RemoteService` is keyed by a SwitchBot
|
||||
`device_id`, not an HA `entity_id`, and doesn't appear in `EntityService.ListStates`, so it
|
||||
doesn't fit this service's entity_id-keyed `Controller`/`EntityResolver` design. Not
|
||||
implemented; would need its own resolution + dispatch path if ever added.
|
||||
|
||||
4. **Skill ID verification — included.** `internal/alexa`'s request validation checks the
|
||||
decoded envelope's application ID against a configured `ALEXA_SKILL_ID` env var, rejecting on
|
||||
mismatch alongside the signature and timestamp checks. Alexa populates
|
||||
`context.System.application.applicationId` on essentially all real requests (Custom Skill
|
||||
requests always carry a `context` object); `session.application.applicationId` is checked too
|
||||
as a fallback for the (now-legacy, but still-valid-per-spec) case of a request that carries a
|
||||
`session` object without a top-level `context` — `internal/alexa/types.go` decodes both and
|
||||
the check prefers `context` when present.
|
||||
|
||||
5. **Entity name matching — exact match only for v1** (already noted inline in the Entity
|
||||
resolution section above; recorded here for completeness since it's a resolved question, not
|
||||
a live one). Normalized as lowercased + trimmed, no fuzzy/substring matching. Alexa's
|
||||
`SetDynamicEntities` directive (option 3 in that section) is **not** wired up — both left as
|
||||
explicitly deferred future work rather than partially implemented.
|
||||
@ -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
|
||||
|
||||
@ -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:<name> text:<text>
|
||||
```
|
||||
|
||||
Synthesizes `text` via `tts-gateway` (92-speaker autocomplete) and plays the
|
||||
result in the invoking user's current voice channel. `tts-gateway` returns
|
||||
AAC; `discord-bot` transcodes it to Opus locally (`ffmpeg` +
|
||||
`github.com/jonas747/dca`) before streaming, since Discord's voice transport
|
||||
requires Opus. **Currently non-functional in practice** - see "Limitations"
|
||||
below; the synthesis half works, voice playback doesn't yet.
|
||||
|
||||
## Configuration
|
||||
|
||||
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.
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
1
go.work
1
go.work
@ -2,6 +2,7 @@ go 1.26
|
||||
|
||||
use (
|
||||
./ai-gateway
|
||||
./alexa-bridge
|
||||
./discord-bot
|
||||
./gen
|
||||
./ha-gateway
|
||||
|
||||
42
go.work.sum
42
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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user