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

424 lines
26 KiB
Markdown

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