- 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.
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) returnsEntityState{entity_id, state, attributes map<string,string>, ...}— no dedicatedfriendly_namefield. Confirmed by readingha-gateway/internal/app/{light,switch,climate}.go: each extractss.Attributes["friendly_name"].(string)itself, i.e.EntityServicepasses through HA's raw attributes map and friendly_name only exists as a string key inside it.LightService.ListLights/SwitchService.ListSwitches/ClimateService.ListClimateseach return a typed entity message with a dedicatedfriendly_namefield, 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.
// 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
Resolverholds amap[string]domain.Entitykeyed by normalized (lowercased, trimmed) friendly name, swapped atomically on each refresh (atomic.Pointer[map[string]domain.Entity]orsync.RWMutex) so an in-flight Alexa request never observes a half-built map.- Startup:
FetchAllis called once, synchronously, before the HTTP server starts accepting traffic — fail loudly (os.Exit(1)) on error, mirroring howhaClient/aiClientsetup failures are handled inai-gateway/cmd/gateway/main.goanddiscord-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 default5m— HA's entity/name set changes rarely) callsFetchAllagain 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'sLightCache(ai-gateway/internal/core/domain/light_cache.go) is lazy/pull-based (refreshes onGet()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 reuseLightCacheas-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:
- Ship exact-match for v1 (simplest, what this plan assumes) and accept some resolution misses as a known limitation.
- Add fuzzy matching (substring / Levenshtein) client-side in the resolver.
- Use Alexa's own slot entity resolution — define the
Deviceslot against a custom slot type and keep it in sync via theSetDynamicEntitiesdirective, 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_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:
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.
-
Resolved — implemented, see Decisions #1.climate.set_temperaturehas no backing RPC.ClimateServiceonly exposedIncreaseTemperature/DecreaseTemperature(relative step) andSetHVACMode— 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-gatewaygap, not a client-side workaround opportunity — the underlyingha-gatewayinternal/app/climate.goonly computesnext := *TargetTemperature + direction*step, it has no absolute-set path either. Options: (a) add aSetTemperature(entity_id, target)RPC toClimateServiceinha-gatewayfirst (out of scope for alexa-bridge alone), (b) approximate it client-side in alexa-bridge by callingIncreaseTemperature/DecreaseTemperaturein a loop toward the target (fragile — depends on knowing the current temperature and step size, race-prone), or (c) omitset_temperaturefrom alexa-bridge's v1 intent set and only support hvac-mode/step-based climate control. Recommend (a), scoped as a small separateha-gatewaychange, with (c) as the fallback if that's not wanted right now. -
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-issuerused foralexa-bridge-tlsabove 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.yamlpairs aletsencrypt-prodClusterIssuerCertificatewith a TraefikIngressRoutefor a public hostname (gitea.nik4nao.com). alexa-bridge would need the equivalent — its ownCertificate+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. -
Resolved — confirmed out of scope, see Decisions #3. Remote/SwitchBot entities are excluded from entity resolution and the Controller's domain table.
RemoteService.SendCommandis keyed by a SwitchBotdevice_id, not an HAentity_id, and there's no evidence these show up inEntityService.ListStatesthe 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. -
Resolved — implemented, see Decisions #4. Skill ID verification: Alexa best practice (and Amazon's certification checklist) also expects verifying
request.context.System.application.applicationIdagainst 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 ininternal/alexa— flagging so it's a conscious inclusion/exclusion rather than an oversight.
Implementation steps
- Scaffold the module:
alexa-bridge/go.mod(modulegitea.nik4nao.com/nik/home-services/alexa-bridge, matching the other four'sgo 1.26+replace .../gen => ../gen), add./alexa-bridgeto rootgo.work. Add matchingCOPYlines (manifest-only + full-source, perCLAUDE.md's CI note) to the other four Dockerfiles, and analexa-bridgeentry to.gitea/workflows/ci.yaml'schangesjob,testjob's vet/test loop, and a newbuild-alexa-bridgejob. internal/core/domain+internal/core/ports/driven:Entity,Controller,EntityResolver— no dependencies, straightforward.internal/haclient: mTLS client +Controllerimplementation (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.internal/entities:EntityServiceclient + resolver + refresher (section 1 above), same independence/testability as step 3.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.internal/directive: intent handlers per the action table in section 2, wired todriven.EntityResolveranddriven.Controller.cmd/bridge/main.go+internal/config: env loading (including theTLS_DIRdefault deviation from section 3), logger/telemetry setup mirroring the other services, startup sequencing (blocking entity fetch before serving, per section 1), graceful shutdown.Dockerfile+README.md: mirrordiscord-bot's two-stage build shape (this one needs noffmpeg/GPU equivalent, so a plaindistroless/scratchfinal stage should work, unlikediscord-bot/tts-gateway).- homelab repo (separate change, needs explicit confirmation before touching that repo):
alexa-bridge-tlsCertificate(section 3),alexa-bridge.yamlDeployment/Service, and the public ingress +letsencrypt-prodCertificatefrom 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.
-
climate.set_temperature→ option (a), a real RPC. AddingClimateService.SetTemperature(SetTemperatureRequest) returns (ClimateResponse)toproto/ha/v1/climate.proto, reusingClimateResponselike every other RPC on this service rather than inventing a new response type.ha-gateway/internal/app/climate.gogets aSetTemperaturemethod that calls Home Assistant'sclimate.set_temperatureservice with the caller-supplied absolute target directly —IncreaseTemperature/DecreaseTemperaturekeep their existing read-current-state-then-step logic unchanged; they now sit alongsideSetTemperaturerather than being replaced by it. Checked the live HA instance (mcp__home-assistant__GetLiveContext): exactly one climate entity ("Air Conditioner"), with a singletemperatureattribute and notarget_temp_high/target_temp_low— range-mode (ClimateEntityFeature.TARGET_TEMPERATURE_RANGE) is not in use anywhere in this HA instance, andha-gateway's existinghaStateToClimatemapping 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 gainsclimate/set_temperature→ClimateServiceClient.SetTemperature, andinternal/haclient/params.gogains areqFloat64helper alongsidereqUint32/reqString/reqRGB.internal/directive/intents.gogains aSetTemperatureIntenthandler reading aTemperature(AMAZON.NUMBER) slot, same shape asSetBrightnessIntent. -
Public HTTPS ingress for alexa-bridge — in scope. Drafted (not applied) an
IngressRoute- cert-manager
Certificateforalexa-bridge.nik4nao.comagainstletsencrypt-prod, mirroringmanifests/gitea/gitea-public-ingress.yaml's shape exactly, as a separate homelab manifest file fromalexa-bridge-tls(theinternal-ca-issuerclient cert for the mTLS leg to ha-gateway) — different issuer, different trust domain, different purpose; not to be confused or merged in naming.
- cert-manager
-
Remote/SwitchBot entities — out of scope for v1.
RemoteServiceis keyed by a SwitchBotdevice_id, not an HAentity_id, and doesn't appear inEntityService.ListStates, so it doesn't fit this service's entity_id-keyedController/EntityResolverdesign. Not implemented; would need its own resolution + dispatch path if ever added. -
Skill ID verification — included.
internal/alexa's request validation checks the decoded envelope's application ID against a configuredALEXA_SKILL_IDenv var, rejecting on mismatch alongside the signature and timestamp checks. Alexa populatescontext.System.application.applicationIdon essentially all real requests (Custom Skill requests always carry acontextobject);session.application.applicationIdis checked too as a fallback for the (now-legacy, but still-valid-per-spec) case of a request that carries asessionobject without a top-levelcontext—internal/alexa/types.godecodes both and the check preferscontextwhen present. -
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
SetDynamicEntitiesdirective (option 3 in that section) is not wired up — both left as explicitly deferred future work rather than partially implemented.