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

151 lines
5.5 KiB
Go

// 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.")
}