Nik Afiq 8f7024edfa
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
feat(climate): add SetTemperature method to ClimateService
- Implemented SetTemperature in ClimateService for setting an absolute target temperature.
- Updated ClimateServiceClient and ClimateServiceServer interfaces to include SetTemperature.
- Added corresponding handler and tests for SetTemperature in ClimateGRPC.
- Modified ClimateApp to handle SetTemperature requests without clamping.
- Updated climate.proto to define SetTemperatureRequest message.
- Adjusted Dockerfiles to include alexa-bridge dependencies.
2026-07-25 12:08:24 +09:00

26 KiB

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.

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_roomlight, 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.

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

// 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_roomlight — 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:

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.:

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 == ""):

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

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:

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_temperatureClimateServiceClient.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 contextinternal/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.