joshuar-go-hass-agent/internal/hass/registration.go

149 lines
4.3 KiB
Go

// Copyright 2024 Joshua Rich <joshua.rich@gmail.com>.
// SPDX-License-Identifier: MIT
package hass
import (
"context"
"errors"
"fmt"
"net/url"
"github.com/joshuar/go-hass-agent/internal/components/preferences"
"github.com/joshuar/go-hass-agent/internal/hass/api"
)
const (
RegistrationPath = "/api/mobile_app/registrations"
WebsocketPath = "/api/websocket"
WebHookPath = "/api/webhook/"
)
var (
ErrInternalValidationFailed = errors.New("internal validation error")
ErrDeviceRegistrationFailed = errors.New("device registration failed")
)
type deviceRegistrationRequest struct {
*preferences.Device
Token string `json:"-"`
}
func (r *deviceRegistrationRequest) Auth() string {
return r.Token
}
func (r *deviceRegistrationRequest) RequestBody() any {
return r.Device
}
// revive:disable:unused-receiver
func (r *deviceRegistrationRequest) Retry() bool {
return true
}
type deviceRegistrationResponse struct {
CloudhookURL string `json:"cloudhook_url"`
RemoteUIURL string `json:"remote_ui_url"`
Secret string `json:"secret"`
WebhookID string `json:"webhook_id"`
}
func newRegistrationRequest(thisDevice *preferences.Device, token string) *deviceRegistrationRequest {
return &deviceRegistrationRequest{
Device: thisDevice,
Token: token,
}
}
func RegisterDevice(ctx context.Context, registration *preferences.Registration) error {
// Validate provided registration details.
if err := registration.Validate(); err != nil {
return errors.Join(ErrDeviceRegistrationFailed, err)
}
registrationURL := registration.Server + RegistrationPath
dev := preferences.NewDevice()
// Register the device against the registration endpoint.
response, err := api.Send[deviceRegistrationResponse](ctx, registrationURL, newRegistrationRequest(dev, registration.Token))
if err != nil {
return errors.Join(ErrDeviceRegistrationFailed, err)
}
// Generate a rest API URL.
restAPIURL, err := generateAPIURL(&response, registration)
if err != nil {
return errors.Join(ErrDeviceRegistrationFailed, err)
}
// Generate a websocket API URL.
websocketAPIURL, err := generateWebsocketURL(registration.Server)
if err != nil {
return errors.Join(ErrDeviceRegistrationFailed, err)
}
// Set registration status in preferences.
err = preferences.Set(
preferences.SetHassSecret(response.Secret),
preferences.SetRestAPIURL(restAPIURL),
preferences.SetWebsocketURL(websocketAPIURL),
preferences.SetWebhookID(response.WebhookID),
preferences.SetServer(registration.Server),
preferences.SetToken(registration.Token),
preferences.SetRegistered(true),
)
// Save preferences to disk.
if err != nil {
return errors.Join(ErrDeviceRegistrationFailed, err)
}
return nil
}
// generateAPIURL creates a URL to use for sending data back to Home
// Assistant from the registration information returned by Home Assistant. It
// follows the rules mentioned in the developer docs to generate the URL:
//
// https://developers.home-assistant.io/docs/api/native-app-integration/sending-data#sending-webhook-data-via-rest-api
func generateAPIURL(response *deviceRegistrationResponse, request *preferences.Registration) (string, error) {
switch {
case response.CloudhookURL != "" && !request.IgnoreHassURLs:
return response.CloudhookURL, nil
case response.RemoteUIURL != "" && response.WebhookID != "" && !request.IgnoreHassURLs:
return response.RemoteUIURL + WebHookPath + response.WebhookID, nil
default:
apiURL, err := url.Parse(request.Server)
if err != nil {
return "", fmt.Errorf("could not parse registration server: %w", err)
}
return apiURL.JoinPath(WebHookPath, response.WebhookID).String(), nil
}
}
// generateWebsocketURL creates a URL for the websocket connection. There is a
// specific format and scheme:
//
// https://developers.home-assistant.io/docs/api/websocket
func generateWebsocketURL(server string) (string, error) {
websocketURL, err := url.Parse(server)
if err != nil {
return "", fmt.Errorf("could not parse registration server: %w", err)
}
switch websocketURL.Scheme {
case "https":
websocketURL.Scheme = "wss"
case "http":
websocketURL.Scheme = "ws"
case "wss":
default:
websocketURL.Scheme = "ws"
}
// Strip any port from host.
websocketURL.Host = websocketURL.Hostname()
return websocketURL.JoinPath(WebsocketPath).String(), nil
}