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.
151 lines
5.5 KiB
Go
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.")
|
|
}
|